From 32ad7d327ffc25b6de7a2e6a8b5b4a10e06a289a Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Fri, 11 Sep 2026 22:03:59 +0800 Subject: [PATCH] feat: add job search, bucket counts, and operator console polish State center web can filter jobs by id/supplier/source/node, keep task selection tied to the current job, and jump from timeline to node detail. --- state_center_web/src/App.tsx | 395 ++++++++++--- state_center_web/src/components/JobList.tsx | 120 ++-- .../src/components/JobNodeDetailList.tsx | 11 +- .../src/components/JobTimeline.tsx | 38 +- state_center_web/src/components/TaskList.tsx | 48 +- .../src/components/ToolbarSelect.tsx | 97 ++++ state_center_web/src/stateMachine.test.mjs | 21 + state_center_web/src/stateMachine.ts | 26 + state_center_web/src/styles.css | 544 ++++++++++++++++-- 9 files changed, 1119 insertions(+), 181 deletions(-) create mode 100644 state_center_web/src/components/ToolbarSelect.tsx diff --git a/state_center_web/src/App.tsx b/state_center_web/src/App.tsx index 27fcd11..6aae693 100644 --- a/state_center_web/src/App.tsx +++ b/state_center_web/src/App.tsx @@ -6,6 +6,7 @@ import { JobEvidenceList } from './components/JobEvidenceList'; import { JobNodeDetailList } from './components/JobNodeDetailList'; import { JobTimeline } from './components/JobTimeline'; import { TaskList } from './components/TaskList'; +import { ToolbarSelect } from './components/ToolbarSelect'; import { type JobArtifactRow, type JobNodeRow, @@ -21,6 +22,7 @@ import { roleLabel, jobStatusBucketLabel, jobStatusBucketMatches, + jobMatchesQuery, type JobStatusBucket, type NodeCallbackKind, taskCandidates, @@ -46,6 +48,14 @@ type TaskSubmitResponse = { }; const devActionsEnabled = import.meta.env.DEV; +const jobBucketOptions: JobStatusBucket[] = ['ALL', 'ACTIVE', 'WAITING', 'FAILED', 'SUCCESS']; +const taskStatusOptions = ['OPEN', 'SUBMITTED'] as const; +const roleOptions = [ + { value: 'PURCHASER', label: '采购' }, + { value: 'PLANNER', label: '计划员' }, + { value: 'QC', label: '质管' }, + { value: 'OPS', label: '运维' }, +]; const demoJobId = 'YYC-重庆亿昊药业-20260804-demo'; const demoMeta: StateMachineMeta = { node_order: [ @@ -265,6 +275,7 @@ export default function App() { const [roleCode, setRoleCode] = useState('PURCHASER'); const [taskStatus, setTaskStatus] = useState('OPEN'); const [jobBucket, setJobBucket] = useState('ALL'); + const [jobQuery, setJobQuery] = useState(''); const [token, setToken] = useState(sessionTokenStore.get()); const [taskAction, setTaskAction] = useState('CONFIRM_AND_CONTINUE'); const [selectedCandidateId, setSelectedCandidateId] = useState(''); @@ -276,6 +287,7 @@ export default function App() { const [notice, setNotice] = useState(''); const [busy, setBusy] = useState(false); const [demoMode, setDemoMode] = useState(false); + const [focusedNodeCode, setFocusedNodeCode] = useState(''); async function loadJobs() { if (demoMode) { @@ -303,7 +315,7 @@ export default function App() { const filtered = demoTasks.filter((task) => task.role_code === roleCode && task.status === taskStatus); setTasks(filtered); setSelectedTaskId((current) => - filtered.some((task) => task.task_id === current) ? current : (filtered[0]?.task_id || ''), + filtered.some((task) => task.task_id === current) ? current : '', ); return; } @@ -312,7 +324,7 @@ export default function App() { ); setTasks(data.items); setSelectedTaskId((current) => - data.items.some((task) => task.task_id === current) ? current : (data.items[0]?.task_id || ''), + data.items.some((task) => task.task_id === current) ? current : '', ); } @@ -388,9 +400,23 @@ export default function App() { const selectedTask = useMemo(() => tasks.find((task) => task.task_id === selectedTaskId) || null, [tasks, selectedTaskId]); const visibleJobs = useMemo( - () => jobs.filter((job) => jobStatusBucketMatches(jobBucket, job.status)), - [jobs, jobBucket], + () => + jobs.filter( + (job) => jobStatusBucketMatches(jobBucket, job.status) && jobMatchesQuery(job, jobQuery, meta), + ), + [jobs, jobBucket, jobQuery, meta], ); + const jobBucketCounts = useMemo(() => { + const matched = jobs.filter((job) => jobMatchesQuery(job, jobQuery, meta)); + const counts = { ALL: matched.length } as Record; + for (const bucket of jobBucketOptions) { + counts[bucket] = bucket === 'ALL' + ? matched.length + : matched.filter((job) => jobStatusBucketMatches(bucket, job.status)).length; + } + return counts; + }, [jobs, jobQuery, meta]); + const taskJobIds = useMemo(() => new Set(tasks.map((task) => task.job_id)), [tasks]); const selectedTaskActions = useMemo( () => (selectedTask ? allowedTaskActions(selectedTask) : []), [selectedTask], @@ -431,6 +457,37 @@ export default function App() { } }, [visibleJobs, selectedJobId]); + useEffect(() => { + const related = tasks.filter((task) => task.job_id === selectedJobId); + setSelectedTaskId((current) => { + if (related.some((task) => task.task_id === current)) return current; + return related[0]?.task_id || ''; + }); + }, [selectedJobId, tasks]); + + useEffect(() => { + setFocusedNodeCode(jobDetail?.job.current_node_code || ''); + }, [jobDetail?.job.job_id, jobDetail?.job.current_node_code]); + + useEffect(() => { + function onKey(event: KeyboardEvent) { + const target = event.target as HTMLElement | null; + const inField = Boolean( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable), + ); + if (event.key === '/' && !inField) { + event.preventDefault(); + document.getElementById('job-search')?.focus(); + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + useEffect(() => { setTaskAction((current) => selectedTaskActions.includes(current) ? current : (selectedTaskActions[0] || ''), @@ -564,77 +621,175 @@ export default function App() { } } - function selectTaskFromNode(taskId: string) { - setTaskStatus('OPEN'); + function selectJob(jobId: string) { + setSelectedJobId(jobId); + } + + function activateTask(taskId: string) { + const fromQueue = tasks.find((task) => task.task_id === taskId); + const fromJob = jobDetail?.tasks.find((task) => task.task_id === taskId); + const task = fromQueue || fromJob; + if (task?.role_code && task.role_code !== roleCode) setRoleCode(task.role_code); + if (task?.status && task.status !== taskStatus) setTaskStatus(task.status); + if (task?.job_id) setSelectedJobId(task.job_id); setSelectedTaskId(taskId); window.setTimeout(() => { document.getElementById('task-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 0); } - async function handleArtifactClick(storagePath: string) { - try { - await navigator.clipboard.writeText(storagePath); - setNotice(`证据路径已复制:${storagePath}`); - } catch { - setNotice(`证据路径:${storagePath}`); + function openPanel(id: string) { + const el = document.getElementById(id); + if (el instanceof HTMLDetailsElement) el.open = true; + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function focusNode(nodeCode: string) { + setFocusedNodeCode(nodeCode); + openPanel('node-details-panel'); + window.setTimeout(() => { + document.getElementById(`node-detail-${nodeCode}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 50); + } + + async function copyValue(label: string, value: string) { + if (!value || value === '-') { + setNotice(`${label}尚未回填`); + return; } + try { + await navigator.clipboard.writeText(value); + setNotice(`已复制${label}:${value}`); + } catch { + setNotice(`${label}:${value}`); + } + } + + function bucketForStatus(status: string): JobStatusBucket { + if (jobStatusBucketMatches('WAITING', status)) return 'WAITING'; + if (jobStatusBucketMatches('FAILED', status)) return 'FAILED'; + if (jobStatusBucketMatches('SUCCESS', status)) return 'SUCCESS'; + if (jobStatusBucketMatches('ACTIVE', status)) return 'ACTIVE'; + return 'ALL'; } return (
-
-
采购主链自动化一期
+

状态中心

-
+ 采购主链自动化一期 + {demoMode ? '本地演示数据' : '后端已连接'} -
+
-
-
- {notice ?
{notice}
: null} + {notice ? ( +
+ {notice} + +
+ ) : null}
@@ -643,69 +798,131 @@ export default function App() {
单据概览

{businessOverview?.header.supplierName || '请选择一条单据'}

-
- {businessOverview - ? `${businessOverview.header.jobId} · ${businessOverview.header.orderDateText} · ${businessOverview.header.sourceTypeText}` - : '先从左侧选择一条单据。'} -
+ {businessOverview ? ( +
+ + + {businessOverview.header.orderDateText} · {businessOverview.header.sourceTypeText} + +
+ ) : ( +
先从左侧选择一条单据。
+ )}
-
{businessOverview?.header.currentStatusText || '-'}
+ {jobDetail ? ( + + ) : ( +
-
+ )}
当前节点:{businessOverview?.header.currentNodeText || '-'}
责任角色:{businessOverview?.header.currentRoleText || '-'}
-
+
-
+ +
-
+ +
-
+ +
+
-
+
{businessOverview?.referenceChain.map((item) => ( -
-
{item.label}
-
{item.value}
-
{item.status === 'DONE' ? '已回填' : '待回填'}
-
+ )) ||
请选择一条单据。
}
-
当前待办
{businessOverview?.primaryTask ? ( -
-
{businessOverview.primaryTask.title}
-
- {businessOverview.primaryTask.nodeText} · {businessOverview.primaryTask.roleText} · {businessOverview.primaryTask.statusText} +
+ 去处理 + ) : ( -
当前没有未完成待办。
+ <> +
当前待办
+
当前没有未完成待办。
+ )}
- +
- +
@@ -722,6 +939,17 @@ export default function App() {
{selectedTask.reason_user_friendly || reasonLabel(selectedTask.reason_type)}
+ ) : businessOverview?.primaryTask ? ( +
+ 当前单据待办属于{businessOverview.primaryTask.roleText}。 + +
) : (
请先选择一条待办。
)} @@ -811,15 +1039,15 @@ export default function App() {
-
+
证据包 {businessOverview?.summary.evidenceCount ?? 0} - void handleArtifactClick(path)} /> + void copyValue('证据路径', path)} />
-
+
节点详情 {businessOverview?.summary.nodeDetailCount ?? 0} @@ -827,8 +1055,9 @@ export default function App() { void handleArtifactClick(path)} + focusedNodeCode={focusedNodeCode} + onTaskSelect={activateTask} + onArtifactClick={(path) => void copyValue('证据路径', path)} />
diff --git a/state_center_web/src/components/JobList.tsx b/state_center_web/src/components/JobList.tsx index 8955aef..78919f0 100644 --- a/state_center_web/src/components/JobList.tsx +++ b/state_center_web/src/components/JobList.tsx @@ -4,6 +4,8 @@ type Props = { meta: StateMachineMeta | null; jobs: JobRow[]; selectedJobId: string; + query?: string; + taskJobIds?: Set; onSelect(jobId: string): void; }; @@ -15,46 +17,98 @@ function statusClass(status: string): string { return 'status-tag-running'; } -export function JobList({ meta, jobs, selectedJobId, onSelect }: Props) { +function Highlight({ text, query }: { text: string; query?: string }) { + const q = query?.trim(); + if (!q) return <>{text}; + const lower = text.toLowerCase(); + const needle = q.toLowerCase(); + const parts: Array = []; + let start = 0; + let index = lower.indexOf(needle); + if (index < 0) return <>{text}; + while (index >= 0) { + if (index > start) parts.push(text.slice(start, index)); + parts.push({ mark: text.slice(index, index + q.length) }); + start = index + q.length; + index = lower.indexOf(needle, start); + } + if (start < text.length) parts.push(text.slice(start)); return ( -
-
单据列表 · {jobs.length}
- - - - - - - - - - - - - {jobs.map((job) => ( - + {parts.map((part, i) => + typeof part === 'string' ? {part} : {part.mark}, + )} + + ); +} + +export function JobList({ meta, jobs, selectedJobId, query, taskJobIds, onSelect }: Props) { + function moveSelection(direction: 1 | -1) { + if (!jobs.length) return; + const current = jobs.findIndex((job) => job.job_id === selectedJobId); + const nextIndex = current < 0 + ? 0 + : Math.min(jobs.length - 1, Math.max(0, current + direction)); + const next = jobs[nextIndex]; + if (!next) return; + onSelect(next.job_id); + window.requestAnimationFrame(() => { + document.getElementById(`job-item-${next.job_id}`)?.focus(); + }); + } + + return ( +
+
单据 · {jobs.length}
+
+ {jobs.map((job) => { + const selected = job.job_id === selectedJobId; + const hasTask = taskJobIds?.has(job.job_id); + return ( +
- - - - - - - ))} - -
供应商日期来源当前节点角色状态
-
{job.supplier_name}
-
{job.job_id}
-
{job.order_date}{sourceTypeLabel(job.source_type)}{nodeLabel(meta, job.current_node_code)}{roleLabel(job.current_role)} +
+ + + {jobStatusLabel(job.status)} -
- {jobs.length === 0 ?
没有可展示的单据。
: null} +
+
+ +
+
+ {job.order_date} + {sourceTypeLabel(job.source_type)} + {nodeLabel(meta, job.current_node_code)} + {roleLabel(job.current_role)} + {hasTask ? 待办 : null} +
+ + ); + })} +
+ {jobs.length === 0 ? ( +
{query?.trim() ? '没有匹配的单据。' : '没有可展示的单据。'}
+ ) : null}
); } diff --git a/state_center_web/src/components/JobNodeDetailList.tsx b/state_center_web/src/components/JobNodeDetailList.tsx index bb61cf4..08ac52b 100644 --- a/state_center_web/src/components/JobNodeDetailList.tsx +++ b/state_center_web/src/components/JobNodeDetailList.tsx @@ -11,17 +11,24 @@ import { type Props = { meta: StateMachineMeta | null; nodes: NodeDetail[]; + focusedNodeCode?: string; onTaskSelect(taskId: string): void; onArtifactClick(storagePath: string): void; }; -export function JobNodeDetailList({ meta, nodes, onTaskSelect, onArtifactClick }: Props) { +export function JobNodeDetailList({ meta, nodes, focusedNodeCode, onTaskSelect, onArtifactClick }: Props) { return (
节点详情
{nodes.map((node) => ( -
+
{nodeLabel(meta, node.node_code)}
diff --git a/state_center_web/src/components/JobTimeline.tsx b/state_center_web/src/components/JobTimeline.tsx index 236c1af..3ae8144 100644 --- a/state_center_web/src/components/JobTimeline.tsx +++ b/state_center_web/src/components/JobTimeline.tsx @@ -3,20 +3,42 @@ import { executorLabel, nodeLabel, statusLabel, type StateMachineMeta, type Time type Props = { meta: StateMachineMeta | null; timeline: TimelineNode[]; + currentNodeCode?: string; + focusedNodeCode?: string; + onSelect?(nodeCode: string): void; }; -export function JobTimeline({ meta, timeline }: Props) { +export function JobTimeline({ meta, timeline, currentNodeCode, focusedNodeCode, onSelect }: Props) { return (
流程状态图
- {timeline.map((node) => ( -
-
{nodeLabel(meta, node.node_code)}
-
{statusLabel(meta, node.display_status)}
-
{executorLabel(meta, node.executor_type || meta?.node_executor[node.node_code])}
-
- ))} + {timeline.map((node) => { + const current = node.node_code === currentNodeCode; + const focused = node.node_code === focusedNodeCode; + const className = [ + 'timeline-node', + `status-${node.display_status.toLowerCase()}`, + current ? 'is-current' : '', + focused ? 'is-focused' : '', + ].filter(Boolean).join(' '); + return ( + + ); + })}
); diff --git a/state_center_web/src/components/TaskList.tsx b/state_center_web/src/components/TaskList.tsx index a4def12..bd8115e 100644 --- a/state_center_web/src/components/TaskList.tsx +++ b/state_center_web/src/components/TaskList.tsx @@ -4,10 +4,11 @@ type Props = { meta: StateMachineMeta | null; tasks: TaskRow[]; selectedTaskId: string; + selectedJobId?: string; onSelect(taskId: string): void; }; -export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) { +export function TaskList({ meta, tasks, selectedTaskId, selectedJobId, onSelect }: Props) { return (
待办列表 · {tasks.length}
@@ -21,26 +22,31 @@ export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) { - {tasks.map((task) => ( - onSelect(task.task_id)} - > - -
{task.title}
-
{task.reason_user_friendly || reasonLabel(task.reason_type)}
- - {nodeLabel(meta, task.node_code)} - {roleLabel(task.role_code)} - - - {task.submitted_action ? actionLabel(task.submitted_action) : taskStatusLabel(task.status)} - -
{formatDateTime(task.created_at)}
- - - ))} + {tasks.map((task) => { + const selected = task.task_id === selectedTaskId; + const currentJob = Boolean(selectedJobId && task.job_id === selectedJobId); + return ( + onSelect(task.task_id)} + > + +
{task.title}
+
{task.reason_user_friendly || reasonLabel(task.reason_type)}
+
{task.job_id}
+ + {nodeLabel(meta, task.node_code)} + {roleLabel(task.role_code)} + + + {task.submitted_action ? actionLabel(task.submitted_action) : taskStatusLabel(task.status)} + +
{formatDateTime(task.created_at)}
+ + + ); + })} {tasks.length === 0 ?
当前没有待办。
: null} diff --git a/state_center_web/src/components/ToolbarSelect.tsx b/state_center_web/src/components/ToolbarSelect.tsx new file mode 100644 index 0000000..d1cb5db --- /dev/null +++ b/state_center_web/src/components/ToolbarSelect.tsx @@ -0,0 +1,97 @@ +import { useEffect, useId, useRef, useState } from 'react'; + +export type ToolbarSelectOption = { + value: string; + label: string; +}; + +type Props = { + value: string; + options: ToolbarSelectOption[]; + ariaLabel: string; + onChange(value: string): void; +}; + +export function ToolbarSelect({ value, options, ariaLabel, onChange }: Props) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const listId = useId(); + const selected = options.find((option) => option.value === value) || options[0]; + + useEffect(() => { + if (!open) return; + function onPointer(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + } + function onKey(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + setOpen(false); + return; + } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + const idx = options.findIndex((option) => option.value === value); + const nextIndex = event.key === 'ArrowDown' + ? Math.min(options.length - 1, Math.max(0, idx + 1)) + : Math.max(0, idx < 0 ? 0 : idx - 1); + const next = options[nextIndex]; + if (next) onChange(next.value); + } + document.addEventListener('mousedown', onPointer); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onPointer); + document.removeEventListener('keydown', onKey); + }; + }, [open, options, value, onChange]); + + return ( +
+ + {open ? ( +
    + {options.map((option) => { + const active = option.value === value; + return ( +
  • + +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/state_center_web/src/stateMachine.test.mjs b/state_center_web/src/stateMachine.test.mjs index 6352470..3ff1592 100644 --- a/state_center_web/src/stateMachine.test.mjs +++ b/state_center_web/src/stateMachine.test.mjs @@ -12,6 +12,7 @@ import { buildNodeCallbackTemplate, jobStatusBucketLabel, jobStatusBucketMatches, + jobMatchesQuery, nodeCallbackActionLabel, reasonLabel, roleLabel, @@ -99,6 +100,26 @@ test('单据状态筛选按验收口径归类', () => { assert.equal(jobStatusBucketMatches('SUCCESS', 'SUCCESS'), true); }); +test('单据查询匹配单号、供应商、来源和节点中文', () => { + const job = { + job_id: 'YYC-重庆亿昊药业-20260804-demo', + supplier_name: '重庆亿昊药业', + order_date: '2026-08-04', + source_type: 'YYC', + status: 'WAITING_USER', + current_node_code: 'SOURCE_ADD', + current_role: 'PURCHASER', + }; + const meta = { node_labels: { SOURCE_ADD: '新增货源' } }; + assert.equal(jobMatchesQuery(job, ''), true); + assert.equal(jobMatchesQuery(job, ' 亿昊 '), true); + assert.equal(jobMatchesQuery(job, 'YYC-重庆'), true); + assert.equal(jobMatchesQuery(job, '医药城'), true); + assert.equal(jobMatchesQuery(job, '等待人工'), true); + assert.equal(jobMatchesQuery(job, '新增货源', meta), true); + assert.equal(jobMatchesQuery(job, '华润'), false); +}); + test('节点详情按节点关联执行记录、证据和待办', () => { const details = buildNodeDetails( [ diff --git a/state_center_web/src/stateMachine.ts b/state_center_web/src/stateMachine.ts index 2afa9e7..73e82da 100644 --- a/state_center_web/src/stateMachine.ts +++ b/state_center_web/src/stateMachine.ts @@ -230,6 +230,32 @@ export function jobStatusBucketMatches(bucket: JobStatusBucket, status: string) return true; } +export function jobMatchesQuery(job: JobRow, query: string, meta: StateMachineMeta | null = null): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + const haystack = [ + job.job_id, + job.supplier_name, + job.order_date, + job.source_type, + sourceTypeLabel(job.source_type), + job.status, + jobStatusLabel(job.status), + job.current_node_code, + nodeLabel(meta, job.current_node_code), + job.current_role || '', + roleLabel(job.current_role), + job.order_no_list_json || '', + job.contract_system_no || '', + job.rebate_order_no || '', + job.m5_order_no || '', + job.receipt_notice_no || '', + ] + .join('\n') + .toLowerCase(); + return haystack.includes(q); +} + export function roleLabel(roleCode?: string | null) { return { PURCHASER: '采购', diff --git a/state_center_web/src/styles.css b/state_center_web/src/styles.css index 5154f94..c1ff984 100644 --- a/state_center_web/src/styles.css +++ b/state_center_web/src/styles.css @@ -91,24 +91,47 @@ textarea { margin: 0 auto; } -/* Topbar - 极致紧凑 */ +/* Topbar */ .topbar { display: flex; - justify-content: space-between; - gap: var(--space-lg); - align-items: center; + flex-direction: column; + align-items: stretch; + gap: var(--space-md); margin-bottom: var(--space-md); background: var(--surface); - padding: var(--space-md); - border-radius: var(--radius-sm); + padding: 10px 12px; + border-radius: var(--radius-md); border: 1px solid var(--gray-200); + position: relative; + z-index: 20; +} + +.topbar-brand { + display: flex; + align-items: center; + gap: var(--space-md); + flex-shrink: 0; + min-width: 0; +} + +.topbar-brand .eyebrow { + margin: 0; + text-transform: none; + letter-spacing: 0; + font-size: 11px; + font-weight: 500; +} + +.topbar-brand .mode-badge { + margin-left: 0; } h1 { margin: 0; - font-size: 18px; + font-size: 16px; font-weight: 700; color: var(--text-primary); + white-space: nowrap; } h2 { @@ -149,43 +172,278 @@ h2 { margin-bottom: var(--space-xs); } -/* Token Panel - 内联紧凑 */ -.token-panel { +/* Toolbar */ +.toolbar { display: flex; + align-items: flex-end; + justify-content: flex-start; gap: var(--space-md); - align-items: center; + min-width: 0; flex-wrap: wrap; } -.token-panel label { +.toolbar-field { display: flex; - gap: var(--space-sm); - align-items: center; - font-size: 12px; - font-weight: 500; - color: var(--text-secondary); + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.toolbar-field > span { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + line-height: 1; white-space: nowrap; } -.token-panel input, -.token-panel select { - border: 1px solid var(--gray-300); - border-radius: var(--radius-sm); - background: var(--surface); - padding: 4px 8px; +.toolbar-field input, +.toolbar-field select { + height: 32px; + border: 1px solid var(--gray-200); + border-radius: var(--radius-md); + background-color: var(--gray-50); + padding: 0 8px; font-size: 12px; - min-width: 120px; + color: var(--text-primary); } -.token-panel input:focus, -.token-panel select:focus { +.toolbar-field select { + appearance: none; + -webkit-appearance: none; + min-width: 88px; + padding-right: 24px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%2364748b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 12px; +} + +.toolbar-field input:focus, +.toolbar-field select:focus, +.toolbar-search input:focus { outline: none; border-color: var(--brand-primary); + background-color: var(--surface); box-shadow: 0 0 0 2px var(--brand-primary-light); } +.toolbar-search { + flex: 1 1 240px; + max-width: 420px; + min-width: 200px; +} + +.toolbar-search-box { + position: relative; +} + +.toolbar-search input { + width: 100%; + height: 32px; + border: 1px solid var(--gray-200); + border-radius: var(--radius-md); + background-color: var(--gray-50); + padding: 0 28px 0 30px; + font-size: 12px; + color: var(--text-primary); + -webkit-appearance: none; + appearance: none; +} + +.toolbar-search input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} + +.toolbar-search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + display: flex; + color: var(--text-muted); + pointer-events: none; +} + +button.toolbar-search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border: 0; + border-radius: var(--radius-full); + background: transparent; + color: var(--text-muted); + font-size: 16px; + line-height: 1; + padding: 0; + font-weight: 500; +} + +button.toolbar-search-clear:hover:not(:disabled) { + background: var(--gray-200); + color: var(--text-primary); +} + +.toolbar-pills { + display: inline-flex; + align-items: center; + gap: 2px; + height: 32px; + padding: 2px; + background: var(--gray-100); + border-radius: var(--radius-md); +} + +button.toolbar-pill { + border: 0; + background: transparent; + color: var(--text-secondary); + height: 28px; + padding: 0 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + box-shadow: none; +} + +button.toolbar-pill:hover:not(:disabled) { + background: transparent; + color: var(--text-primary); +} + +button.toolbar-pill.is-active, +button.toolbar-pill.is-active:hover:not(:disabled) { + background: var(--surface); + color: var(--text-primary); + font-weight: 600; + box-shadow: var(--shadow-sm); +} + +.toolbar-pill-count { + margin-left: 4px; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +button.toolbar-pill.is-active .toolbar-pill-count { + color: var(--brand-primary); +} + +.toolbar-select { + position: relative; +} + +button.toolbar-select-trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + height: 32px; + min-width: 96px; + padding: 0 8px 0 10px; + border: 1px solid var(--gray-200); + border-radius: var(--radius-md); + background: var(--gray-50); + color: var(--text-primary); + font-weight: 500; + box-shadow: none; +} + +button.toolbar-select-trigger:hover:not(:disabled) { + background: var(--surface); + color: var(--text-primary); + border-color: var(--gray-300); +} + +.toolbar-select.is-open .toolbar-select-trigger, +.toolbar-select.is-open .toolbar-select-trigger:hover:not(:disabled) { + border-color: var(--brand-primary); + background: var(--surface); + color: var(--text-primary); + box-shadow: 0 0 0 2px var(--brand-primary-light); +} + +.toolbar-select-chevron { + color: var(--text-muted); + transition: transform 0.16s ease; +} + +.toolbar-select.is-open .toolbar-select-chevron { + transform: rotate(180deg); +} + +.toolbar-select-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 30; + min-width: max(100%, 112px); + margin: 0; + padding: 4px; + list-style: none; + background: var(--surface); + border: 1px solid var(--gray-200); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + animation: toolbar-select-in 0.14s ease; +} + +@keyframes toolbar-select-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +button.toolbar-select-option { + display: flex; + width: 100%; + align-items: center; + height: 28px; + padding: 0 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + font-weight: 500; + text-align: left; + box-shadow: none; + white-space: nowrap; +} + +button.toolbar-select-option:hover:not(:disabled) { + background: var(--gray-100); + color: var(--text-primary); +} + +button.toolbar-select-option.is-selected, +button.toolbar-select-option.is-selected:hover:not(:disabled) { + background: var(--brand-primary-light); + color: var(--brand-primary); + font-weight: 600; +} + +.toolbar-token input { + width: 128px; +} + /* Notice */ .notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); border: 1px solid var(--info-border); background: var(--info-bg); padding: var(--space-sm) var(--space-md); @@ -196,6 +454,30 @@ h2 { font-weight: 500; } +button.notice-dismiss { + border: 0; + background: transparent; + color: var(--info); + width: 22px; + height: 22px; + padding: 0; + border-radius: var(--radius-full); + font-size: 16px; + font-weight: 500; +} + +button.notice-dismiss:hover:not(:disabled) { + background: rgb(8 145 178 / 0.12); + color: var(--info); +} + +mark { + background: #fde68a; + color: inherit; + padding: 0 1px; + border-radius: 2px; +} + /* Layout */ .layout { display: grid; @@ -248,6 +530,158 @@ h2 { min-width: 0; } +@media (min-width: 1100px) { + .dashboard-layout { + grid-template-columns: 340px minmax(0, 1fr); + align-items: start; + } + + .dashboard-sidebar { + position: sticky; + top: 8px; + max-height: calc(100vh - 16px); + overflow: auto; + } +} + +.job-list { + display: grid; +} + +button.job-list-item { + display: grid; + gap: 2px; + width: 100%; + text-align: left; + border: 0; + border-bottom: 1px solid var(--gray-100); + border-radius: 0; + background: transparent; + color: inherit; + padding: 8px 10px; + font-weight: 400; + box-shadow: none; +} + +button.job-list-item:hover:not(:disabled) { + background: var(--surface-hover); + color: inherit; +} + +button.job-list-item.is-selected, +button.job-list-item.is-selected:hover:not(:disabled) { + background: var(--brand-primary-light); + color: inherit; + box-shadow: inset 3px 0 0 var(--brand-primary); +} + +.job-list-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.job-list-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; + font-size: 10px; + color: var(--text-muted); +} + +.job-task-flag { + color: var(--warning); + font-weight: 700; +} + +.hero-id-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); +} + +button.text-button { + border: 0; + background: transparent; + color: var(--text-muted); + padding: 0; + font-weight: 500; + font-size: 12px; + box-shadow: none; +} + +button.text-button:hover:not(:disabled) { + background: transparent; + color: var(--brand-primary); +} + +button.hero-status-text { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +button.metric-card { + border: 0; + background: transparent; + padding: 4px 8px; + border-radius: var(--radius-md); + color: inherit; + box-shadow: none; +} + +button.metric-card:hover:not(:disabled) { + background: var(--gray-100); + color: inherit; +} + +button.reference-pill { + font: inherit; + cursor: pointer; + text-align: left; +} + +button.reference-pill:hover:not(:disabled) { + color: inherit; + box-shadow: var(--shadow-sm); + filter: brightness(0.98); +} + +button.primary-task-cta { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + border: 1px solid var(--warning-border); + background: var(--warning-bg); + color: inherit; + text-align: left; + padding: 8px 10px; + border-radius: var(--radius-md); + font-weight: 400; + box-shadow: none; +} + +button.primary-task-cta:hover:not(:disabled) { + background: var(--warning-bg); + color: inherit; + border-color: var(--warning); +} + +.primary-task-action { + flex-shrink: 0; + border-radius: var(--radius-sm); + background: var(--brand-primary); + color: var(--surface); + padding: 6px 10px; + font-size: 12px; + font-weight: 600; +} + /* Hero Panel - 改为紧凑单行 */ .hero-panel { display: grid; @@ -360,8 +794,7 @@ h2 { /* Timeline - 极致紧凑 */ .timeline { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + display: flex; gap: var(--space-sm); overflow-x: auto; padding-bottom: var(--space-xs); @@ -373,6 +806,8 @@ h2 { background: var(--surface); padding: var(--space-sm); min-height: 60px; + min-width: 88px; + flex: 1 0 88px; display: grid; gap: 1px; transition: all 0.1s; @@ -383,6 +818,35 @@ h2 { box-shadow: var(--shadow-sm); } +button.timeline-node { + color: inherit; + text-align: left; + font-weight: 400; + box-shadow: none; +} + +button.timeline-node:hover:not(:disabled) { + color: inherit; + background: inherit; + box-shadow: var(--shadow-sm); +} + +button.timeline-node.is-current { + outline: 2px solid var(--brand-primary); + outline-offset: 1px; +} + +button.timeline-node.is-focused:not(.is-current) { + box-shadow: 0 0 0 2px var(--brand-primary-light); +} + +.timeline-node-flag { + margin-top: 2px; + font-size: 10px; + font-weight: 700; + color: var(--brand-primary); +} + .timeline-node-name { font-weight: 700; font-size: 11px; @@ -520,6 +984,10 @@ h2 { box-shadow: inset 3px 0 0 var(--brand-primary); } +.data-table tbody tr.is-current-job:not(.is-selected) { + box-shadow: inset 3px 0 0 var(--warning); +} + .cell-title { font-weight: 600; color: var(--text-primary); @@ -580,6 +1048,7 @@ h2 { background: var(--gray-50); overflow: hidden; transition: all 0.2s; + scroll-margin-top: 8px; } .collapse-panel[open] { @@ -759,6 +1228,12 @@ button:disabled { font-style: italic; } +.empty .text-button { + margin-left: 6px; + color: var(--brand-primary); + font-style: normal; +} + .filter-summary { margin-top: var(--space-xs); font-size: 11px; @@ -785,6 +1260,11 @@ button:disabled { gap: var(--space-md); background: var(--surface); transition: all 0.2s; + scroll-margin-top: 12px; +} + +.node-detail-card.is-focused { + box-shadow: 0 0 0 2px var(--brand-primary); } .node-detail-card:hover { @@ -1092,13 +1572,9 @@ button:disabled { } @media (max-width: 767px) { - .topbar { - flex-direction: column; - align-items: flex-start; - } - - .token-panel { - width: 100%; + .toolbar-search { + flex: 1 1 100%; + max-width: none; } .hero-head {