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.
This commit is contained in:
Yuuuu 2026-09-11 22:03:59 +08:00
parent 29df92ee17
commit 32ad7d327f
9 changed files with 1119 additions and 181 deletions

View File

@ -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<JobStatusBucket>('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<JobStatusBucket, number>;
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 (
<div className="app-shell">
<header className="topbar">
<div>
<div className="eyebrow">采购主链自动化一期</div>
<div className="topbar-brand">
<h1>状态中心</h1>
<div className={`mode-badge ${demoMode ? 'mode-demo' : 'mode-live'}`}>
<span className="eyebrow">采购主链自动化一期</span>
<span className={`mode-badge ${demoMode ? 'mode-demo' : 'mode-live'}`}>
{demoMode ? '本地演示数据' : '后端已连接'}
</span>
</div>
<div className="toolbar">
<div className="toolbar-field toolbar-search">
<span>查询</span>
<div className="toolbar-search-box">
<span className="toolbar-search-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="4.25" stroke="currentColor" strokeWidth="1.4" />
<path d="M9.2 9.2L12 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</span>
<input
id="job-search"
type="search"
value={jobQuery}
onChange={(event) => setJobQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') setJobQuery('');
}}
placeholder="供应商、单号、来源、节点…"
aria-label="查询单据"
/>
{jobQuery ? (
<button
type="button"
className="toolbar-search-clear"
onClick={() => setJobQuery('')}
aria-label="清除查询"
>
×
</button>
) : null}
</div>
</div>
<div className="token-panel">
<label>
本地联调令牌
<div className="toolbar-field">
<span>待办角色</span>
<ToolbarSelect
ariaLabel="待办角色"
value={roleCode}
options={roleOptions}
onChange={setRoleCode}
/>
</div>
<div className="toolbar-field">
<span>待办状态</span>
<div className="toolbar-pills" role="group" aria-label="待办状态">
{taskStatusOptions.map((status) => (
<button
key={status}
type="button"
className={taskStatus === status ? 'toolbar-pill is-active' : 'toolbar-pill'}
aria-pressed={taskStatus === status}
onClick={() => setTaskStatus(status)}
>
{taskStatusLabel(status)}
</button>
))}
</div>
</div>
<div className="toolbar-field">
<span>单据</span>
<div className="toolbar-pills" role="group" aria-label="单据筛选">
{jobBucketOptions.map((bucket) => (
<button
key={bucket}
type="button"
className={jobBucket === bucket ? 'toolbar-pill is-active' : 'toolbar-pill'}
aria-pressed={jobBucket === bucket}
onClick={() => setJobBucket(bucket)}
>
{jobStatusBucketLabel(bucket)}
<span className="toolbar-pill-count">{jobBucketCounts[bucket]}</span>
</button>
))}
</div>
</div>
<label className="toolbar-field toolbar-token">
<span>联调令牌</span>
<input
value={token}
onChange={(event) => void saveToken(event.target.value)}
placeholder="仅本地联调时填写"
placeholder="本地联调"
type="password"
autoComplete="off"
/>
</label>
<label>
待办角色
<select value={roleCode} onChange={(event) => setRoleCode(event.target.value)}>
<option value="PURCHASER">采购</option>
<option value="PLANNER">计划员</option>
<option value="QC">质管</option>
<option value="OPS">运维</option>
</select>
</label>
<label>
待办状态
<select value={taskStatus} onChange={(event) => setTaskStatus(event.target.value)}>
<option value="OPEN">待处理</option>
<option value="SUBMITTED">已提交</option>
</select>
</label>
<label>
单据筛选
<select value={jobBucket} onChange={(event) => setJobBucket(event.target.value as JobStatusBucket)}>
<option value="ALL">{jobStatusBucketLabel('ALL')}</option>
<option value="ACTIVE">{jobStatusBucketLabel('ACTIVE')}</option>
<option value="WAITING">{jobStatusBucketLabel('WAITING')}</option>
<option value="FAILED">{jobStatusBucketLabel('FAILED')}</option>
<option value="SUCCESS">{jobStatusBucketLabel('SUCCESS')}</option>
</select>
</label>
</div>
</header>
{notice ? <div className="notice">{notice}</div> : null}
{notice ? (
<div className="notice">
<span>{notice}</span>
<button type="button" className="notice-dismiss" onClick={() => setNotice('')} aria-label="关闭提示">
×
</button>
</div>
) : null}
<main className="dashboard-layout">
<aside className="dashboard-sidebar">
<JobList meta={meta} jobs={visibleJobs} selectedJobId={selectedJobId} onSelect={setSelectedJobId} />
<JobList
meta={meta}
jobs={visibleJobs}
selectedJobId={selectedJobId}
query={jobQuery}
taskJobIds={taskJobIds}
onSelect={selectJob}
/>
</aside>
<div className="dashboard-main">
@ -643,69 +798,131 @@ export default function App() {
<div>
<div className="eyebrow">单据概览</div>
<h2>{businessOverview?.header.supplierName || '请选择一条单据'}</h2>
<div className="muted">
{businessOverview
? `${businessOverview.header.jobId} · ${businessOverview.header.orderDateText} · ${businessOverview.header.sourceTypeText}`
: '先从左侧选择一条单据。'}
{businessOverview ? (
<div className="hero-id-row">
<button
type="button"
className="text-button"
title="点击复制单号"
onClick={() => void copyValue('单号', businessOverview.header.jobId)}
>
{businessOverview.header.jobId}
</button>
<span className="muted">
{businessOverview.header.orderDateText} · {businessOverview.header.sourceTypeText}
</span>
</div>
) : (
<div className="muted">先从左侧选择一条单据。</div>
)}
</div>
<div className="hero-status">
<div className="hero-status-text">{businessOverview?.header.currentStatusText || '-'}</div>
{jobDetail ? (
<button
type="button"
className="hero-status-text text-button"
title="按此状态筛选单据"
onClick={() => setJobBucket(bucketForStatus(jobDetail.job.status))}
>
{businessOverview?.header.currentStatusText || '-'}
</button>
) : (
<div className="hero-status-text">-</div>
)}
<div className="muted">当前节点:{businessOverview?.header.currentNodeText || '-'}</div>
<div className="muted">责任角色:{businessOverview?.header.currentRoleText || '-'}</div>
</div>
</div>
<div className="hero-metrics">
<div className="metric-card">
<button
type="button"
className="metric-card"
onClick={() => {
if (businessOverview?.primaryTask) activateTask(businessOverview.primaryTask.task_id);
else document.getElementById('task-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
>
<div className="detail-label">开放待办</div>
<div className="detail-value">{businessOverview?.summary.openTaskCount ?? 0}</div>
</div>
<div className="metric-card">
</button>
<button type="button" className="metric-card" onClick={() => openPanel('evidence-panel')}>
<div className="detail-label">证据</div>
<div className="detail-value">{businessOverview?.summary.evidenceCount ?? 0}</div>
</div>
<div className="metric-card">
</button>
<button type="button" className="metric-card" onClick={() => openPanel('node-details-panel')}>
<div className="detail-label">节点</div>
<div className="detail-value">{businessOverview?.summary.nodeDetailCount ?? 0}</div>
</div>
<div className="metric-card">
</button>
<button
type="button"
className="metric-card"
onClick={() => document.getElementById('reference-strip')?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
>
<div className="detail-label">关键编号</div>
<div className="detail-value">{businessOverview?.summary.referenceDoneCount ?? 0}/5</div>
</div>
</button>
</div>
<div className="reference-strip">
<div className="reference-strip" id="reference-strip">
{businessOverview?.referenceChain.map((item) => (
<div key={item.key} className={`reference-pill reference-${item.status.toLowerCase()}`}>
<div className="detail-label">{item.label}</div>
<div className="detail-value">{item.value}</div>
<div className="muted">{item.status === 'DONE' ? '已回填' : '待回填'}</div>
</div>
<button
key={item.key}
type="button"
className={`reference-pill reference-${item.status.toLowerCase()}`}
title={item.status === 'DONE' ? `点击复制${item.label}` : `${item.label}尚未回填`}
onClick={() => void copyValue(item.label, item.value)}
>
<span className="detail-label">{item.label}</span>
<span className="detail-value">{item.value || '-'}</span>
<span className="muted">{item.status === 'DONE' ? '已回填' : '待回填'}</span>
</button>
)) || <div className="empty">请选择一条单据。</div>}
</div>
<div className="primary-task-strip">
<div className="detail-label">当前待办</div>
{businessOverview?.primaryTask ? (
<div className="primary-task-body">
<button
type="button"
className="primary-task-cta"
onClick={() => activateTask(businessOverview.primaryTask!.task_id)}
>
<div>
<div className="detail-label">当前待办</div>
<div className="strong">{businessOverview.primaryTask.title}</div>
<div className="muted">
{businessOverview.primaryTask.nodeText} · {businessOverview.primaryTask.roleText} · {businessOverview.primaryTask.statusText}
</div>
<div className="muted">{businessOverview.primaryTask.reasonText}</div>
</div>
<span className="primary-task-action">去处理</span>
</button>
) : (
<>
<div className="detail-label">当前待办</div>
<div className="empty">当前没有未完成待办。</div>
</>
)}
</div>
</section>
<JobTimeline meta={meta} timeline={timeline} />
<JobTimeline
meta={meta}
timeline={timeline}
currentNodeCode={currentNodeCode}
focusedNodeCode={focusedNodeCode}
onSelect={focusNode}
/>
<section className="workflow-grid" id="task-section">
<div className="workflow-tasks">
<TaskList meta={meta} tasks={tasks} selectedTaskId={selectedTaskId} onSelect={setSelectedTaskId} />
<TaskList
meta={meta}
tasks={tasks}
selectedTaskId={selectedTaskId}
selectedJobId={selectedJobId}
onSelect={activateTask}
/>
</div>
<div className="workflow-form">
@ -722,6 +939,17 @@ export default function App() {
<div className="detail-value">{selectedTask.reason_user_friendly || reasonLabel(selectedTask.reason_type)}</div>
</div>
</div>
) : businessOverview?.primaryTask ? (
<div className="empty">
当前单据待办属于{businessOverview.primaryTask.roleText}。
<button
type="button"
className="text-button"
onClick={() => activateTask(businessOverview.primaryTask!.task_id)}
>
切换角色并处理
</button>
</div>
) : (
<div className="empty">请先选择一条待办。</div>
)}
@ -811,15 +1039,15 @@ export default function App() {
</section>
<section className="collapse-stack">
<details className="collapse-panel">
<details className="collapse-panel" id="evidence-panel">
<summary>
<span>证据包</span>
<span className="collapse-count">{businessOverview?.summary.evidenceCount ?? 0}</span>
</summary>
<JobEvidenceList meta={meta} artifacts={jobArtifacts} onArtifactClick={(path) => void handleArtifactClick(path)} />
<JobEvidenceList meta={meta} artifacts={jobArtifacts} onArtifactClick={(path) => void copyValue('证据路径', path)} />
</details>
<details className="collapse-panel">
<details className="collapse-panel" id="node-details-panel">
<summary>
<span>节点详情</span>
<span className="collapse-count">{businessOverview?.summary.nodeDetailCount ?? 0}</span>
@ -827,8 +1055,9 @@ export default function App() {
<JobNodeDetailList
meta={meta}
nodes={jobNodeDetails}
onTaskSelect={selectTaskFromNode}
onArtifactClick={(path) => void handleArtifactClick(path)}
focusedNodeCode={focusedNodeCode}
onTaskSelect={activateTask}
onArtifactClick={(path) => void copyValue('证据路径', path)}
/>
</details>

View File

@ -4,6 +4,8 @@ type Props = {
meta: StateMachineMeta | null;
jobs: JobRow[];
selectedJobId: string;
query?: string;
taskJobIds?: Set<string>;
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<string | { mark: string }> = [];
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 (
<div className="panel">
<div className="panel-title">单据列表 · {jobs.length}</div>
<table className="data-table">
<thead>
<tr>
<th>供应商</th>
<th>日期</th>
<th>来源</th>
<th>当前节点</th>
<th>角色</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr
<>
{parts.map((part, i) =>
typeof part === 'string' ? <span key={i}>{part}</span> : <mark key={i}>{part.mark}</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 (
<div className="panel job-list-panel">
<div className="panel-title">单据 · {jobs.length}</div>
<div className="job-list" role="listbox" aria-label="单据列表">
{jobs.map((job) => {
const selected = job.job_id === selectedJobId;
const hasTask = taskJobIds?.has(job.job_id);
return (
<button
key={job.job_id}
className={job.job_id === selectedJobId ? 'is-selected' : ''}
id={`job-item-${job.job_id}`}
type="button"
role="option"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`job-list-item${selected ? ' is-selected' : ''}${hasTask ? ' has-task' : ''}`}
onClick={() => onSelect(job.job_id)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveSelection(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
moveSelection(-1);
}
}}
>
<td>
<div className="cell-title">{job.supplier_name}</div>
<div className="cell-sub">{job.job_id}</div>
</td>
<td>{job.order_date}</td>
<td>{sourceTypeLabel(job.source_type)}</td>
<td>{nodeLabel(meta, job.current_node_code)}</td>
<td>{roleLabel(job.current_role)}</td>
<td>
<div className="job-list-top">
<span className="cell-title">
<Highlight text={job.supplier_name} query={query} />
</span>
<span className={`status-tag ${statusClass(job.status)}`}>
{jobStatusLabel(job.status)}
</span>
</td>
</tr>
))}
</tbody>
</table>
{jobs.length === 0 ? <div className="empty">没有可展示的单据。</div> : null}
</div>
<div className="cell-sub">
<Highlight text={job.job_id} query={query} />
</div>
<div className="job-list-meta">
<span>{job.order_date}</span>
<span>{sourceTypeLabel(job.source_type)}</span>
<span>{nodeLabel(meta, job.current_node_code)}</span>
<span>{roleLabel(job.current_role)}</span>
{hasTask ? <span className="job-task-flag">待办</span> : null}
</div>
</button>
);
})}
</div>
{jobs.length === 0 ? (
<div className="empty">{query?.trim() ? '没有匹配的单据。' : '没有可展示的单据。'}</div>
) : null}
</div>
);
}

View File

@ -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 (
<div className="panel">
<div className="panel-title">节点详情</div>
<div className="node-detail-list">
{nodes.map((node) => (
<div key={node.node_code} className={`node-detail-card status-${node.display_status.toLowerCase()}`}>
<div
key={node.node_code}
id={`node-detail-${node.node_code}`}
className={`node-detail-card status-${node.display_status.toLowerCase()}${
focusedNodeCode === node.node_code ? ' is-focused' : ''
}`}
>
<div className="node-detail-head">
<div>
<div className="strong">{nodeLabel(meta, node.node_code)}</div>

View File

@ -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 (
<div className="panel">
<div className="panel-title">流程状态图</div>
<div className="timeline">
{timeline.map((node) => (
<div key={node.node_code} className={`timeline-node status-${node.display_status.toLowerCase()}`}>
{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 (
<button
key={node.node_code}
type="button"
className={className}
title={onSelect ? '查看节点详情' : undefined}
onClick={() => onSelect?.(node.node_code)}
>
<div className="timeline-node-name">{nodeLabel(meta, node.node_code)}</div>
<div className="timeline-node-status">{statusLabel(meta, node.display_status)}</div>
<div className="timeline-node-executor">{executorLabel(meta, node.executor_type || meta?.node_executor[node.node_code])}</div>
<div className="timeline-node-executor">
{executorLabel(meta, node.executor_type || meta?.node_executor[node.node_code])}
</div>
))}
{current ? <div className="timeline-node-flag">当前</div> : null}
</button>
);
})}
</div>
</div>
);

View File

@ -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 (
<div className="panel">
<div className="panel-title">待办列表 · {tasks.length}</div>
@ -21,15 +22,19 @@ export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) {
</tr>
</thead>
<tbody>
{tasks.map((task) => (
{tasks.map((task) => {
const selected = task.task_id === selectedTaskId;
const currentJob = Boolean(selectedJobId && task.job_id === selectedJobId);
return (
<tr
key={task.task_id}
className={task.task_id === selectedTaskId ? 'is-selected' : ''}
className={`${selected ? 'is-selected' : ''}${currentJob && !selected ? ' is-current-job' : ''}`}
onClick={() => onSelect(task.task_id)}
>
<td>
<div className="cell-title">{task.title}</div>
<div className="cell-sub">{task.reason_user_friendly || reasonLabel(task.reason_type)}</div>
<div className="cell-sub">{task.job_id}</div>
</td>
<td>{nodeLabel(meta, task.node_code)}</td>
<td>{roleLabel(task.role_code)}</td>
@ -40,7 +45,8 @@ export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) {
<div className="cell-sub">{formatDateTime(task.created_at)}</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
{tasks.length === 0 ? <div className="empty">当前没有待办。</div> : null}

View File

@ -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<HTMLDivElement>(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 (
<div className={`toolbar-select${open ? ' is-open' : ''}`} ref={rootRef}>
<button
type="button"
className="toolbar-select-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-label={ariaLabel}
onClick={() => setOpen((current) => !current)}
>
<span>{selected?.label}</span>
<svg className="toolbar-select-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4.5L6 7.5L9 4.5"
/>
</svg>
</button>
{open ? (
<ul className="toolbar-select-menu" id={listId} role="listbox" aria-label={ariaLabel}>
{options.map((option) => {
const active = option.value === value;
return (
<li key={option.value} role="presentation">
<button
type="button"
role="option"
aria-selected={active}
className={active ? 'toolbar-select-option is-selected' : 'toolbar-select-option'}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
{option.label}
</button>
</li>
);
})}
</ul>
) : null}
</div>
);
}

View File

@ -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(
[

View File

@ -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: '采购',

View File

@ -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 {