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:
parent
29df92ee17
commit
32ad7d327f
|
|
@ -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 ? '本地演示数据' : '后端已连接'}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="token-panel">
|
||||
<label>
|
||||
本地联调令牌
|
||||
<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="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}`
|
||||
: '先从左侧选择一条单据。'}
|
||||
</div>
|
||||
{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">
|
||||
<div className="strong">{businessOverview.primaryTask.title}</div>
|
||||
<div className="muted">
|
||||
{businessOverview.primaryTask.nodeText} · {businessOverview.primaryTask.roleText} · {businessOverview.primaryTask.statusText}
|
||||
<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>
|
||||
<div className="muted">{businessOverview.primaryTask.reasonText}</div>
|
||||
</div>
|
||||
<span className="primary-task-action">去处理</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="empty">当前没有未完成待办。</div>
|
||||
<>
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}`}>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
{current ? <div className="timeline-node-flag">当前</div> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,26 +22,31 @@ export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr
|
||||
key={task.task_id}
|
||||
className={task.task_id === selectedTaskId ? 'is-selected' : ''}
|
||||
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>
|
||||
</td>
|
||||
<td>{nodeLabel(meta, task.node_code)}</td>
|
||||
<td>{roleLabel(task.role_code)}</td>
|
||||
<td>
|
||||
<span className="status-tag status-tag-waiting">
|
||||
{task.submitted_action ? actionLabel(task.submitted_action) : taskStatusLabel(task.status)}
|
||||
</span>
|
||||
<div className="cell-sub">{formatDateTime(task.created_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tasks.map((task) => {
|
||||
const selected = task.task_id === selectedTaskId;
|
||||
const currentJob = Boolean(selectedJobId && task.job_id === selectedJobId);
|
||||
return (
|
||||
<tr
|
||||
key={task.task_id}
|
||||
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>
|
||||
<td>
|
||||
<span className="status-tag status-tag-waiting">
|
||||
{task.submitted_action ? actionLabel(task.submitted_action) : taskStatusLabel(task.status)}
|
||||
</span>
|
||||
<div className="cell-sub">{formatDateTime(task.created_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{tasks.length === 0 ? <div className="empty">当前没有待办。</div> : null}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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: '采购',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue