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 { JobNodeDetailList } from './components/JobNodeDetailList';
|
||||||
import { JobTimeline } from './components/JobTimeline';
|
import { JobTimeline } from './components/JobTimeline';
|
||||||
import { TaskList } from './components/TaskList';
|
import { TaskList } from './components/TaskList';
|
||||||
|
import { ToolbarSelect } from './components/ToolbarSelect';
|
||||||
import {
|
import {
|
||||||
type JobArtifactRow,
|
type JobArtifactRow,
|
||||||
type JobNodeRow,
|
type JobNodeRow,
|
||||||
|
|
@ -21,6 +22,7 @@ import {
|
||||||
roleLabel,
|
roleLabel,
|
||||||
jobStatusBucketLabel,
|
jobStatusBucketLabel,
|
||||||
jobStatusBucketMatches,
|
jobStatusBucketMatches,
|
||||||
|
jobMatchesQuery,
|
||||||
type JobStatusBucket,
|
type JobStatusBucket,
|
||||||
type NodeCallbackKind,
|
type NodeCallbackKind,
|
||||||
taskCandidates,
|
taskCandidates,
|
||||||
|
|
@ -46,6 +48,14 @@ type TaskSubmitResponse = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const devActionsEnabled = import.meta.env.DEV;
|
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 demoJobId = 'YYC-重庆亿昊药业-20260804-demo';
|
||||||
const demoMeta: StateMachineMeta = {
|
const demoMeta: StateMachineMeta = {
|
||||||
node_order: [
|
node_order: [
|
||||||
|
|
@ -265,6 +275,7 @@ export default function App() {
|
||||||
const [roleCode, setRoleCode] = useState('PURCHASER');
|
const [roleCode, setRoleCode] = useState('PURCHASER');
|
||||||
const [taskStatus, setTaskStatus] = useState('OPEN');
|
const [taskStatus, setTaskStatus] = useState('OPEN');
|
||||||
const [jobBucket, setJobBucket] = useState<JobStatusBucket>('ALL');
|
const [jobBucket, setJobBucket] = useState<JobStatusBucket>('ALL');
|
||||||
|
const [jobQuery, setJobQuery] = useState('');
|
||||||
const [token, setToken] = useState(sessionTokenStore.get());
|
const [token, setToken] = useState(sessionTokenStore.get());
|
||||||
const [taskAction, setTaskAction] = useState('CONFIRM_AND_CONTINUE');
|
const [taskAction, setTaskAction] = useState('CONFIRM_AND_CONTINUE');
|
||||||
const [selectedCandidateId, setSelectedCandidateId] = useState('');
|
const [selectedCandidateId, setSelectedCandidateId] = useState('');
|
||||||
|
|
@ -276,6 +287,7 @@ export default function App() {
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [demoMode, setDemoMode] = useState(false);
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
|
const [focusedNodeCode, setFocusedNodeCode] = useState('');
|
||||||
|
|
||||||
async function loadJobs() {
|
async function loadJobs() {
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
|
|
@ -303,7 +315,7 @@ export default function App() {
|
||||||
const filtered = demoTasks.filter((task) => task.role_code === roleCode && task.status === taskStatus);
|
const filtered = demoTasks.filter((task) => task.role_code === roleCode && task.status === taskStatus);
|
||||||
setTasks(filtered);
|
setTasks(filtered);
|
||||||
setSelectedTaskId((current) =>
|
setSelectedTaskId((current) =>
|
||||||
filtered.some((task) => task.task_id === current) ? current : (filtered[0]?.task_id || ''),
|
filtered.some((task) => task.task_id === current) ? current : '',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -312,7 +324,7 @@ export default function App() {
|
||||||
);
|
);
|
||||||
setTasks(data.items);
|
setTasks(data.items);
|
||||||
setSelectedTaskId((current) =>
|
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 selectedTask = useMemo(() => tasks.find((task) => task.task_id === selectedTaskId) || null, [tasks, selectedTaskId]);
|
||||||
const visibleJobs = useMemo(
|
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(
|
const selectedTaskActions = useMemo(
|
||||||
() => (selectedTask ? allowedTaskActions(selectedTask) : []),
|
() => (selectedTask ? allowedTaskActions(selectedTask) : []),
|
||||||
[selectedTask],
|
[selectedTask],
|
||||||
|
|
@ -431,6 +457,37 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [visibleJobs, selectedJobId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setTaskAction((current) =>
|
setTaskAction((current) =>
|
||||||
selectedTaskActions.includes(current) ? current : (selectedTaskActions[0] || ''),
|
selectedTaskActions.includes(current) ? current : (selectedTaskActions[0] || ''),
|
||||||
|
|
@ -564,77 +621,175 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectTaskFromNode(taskId: string) {
|
function selectJob(jobId: string) {
|
||||||
setTaskStatus('OPEN');
|
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);
|
setSelectedTaskId(taskId);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
document.getElementById('task-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById('task-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleArtifactClick(storagePath: string) {
|
function openPanel(id: string) {
|
||||||
try {
|
const el = document.getElementById(id);
|
||||||
await navigator.clipboard.writeText(storagePath);
|
if (el instanceof HTMLDetailsElement) el.open = true;
|
||||||
setNotice(`证据路径已复制:${storagePath}`);
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
} catch {
|
}
|
||||||
setNotice(`证据路径:${storagePath}`);
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div className="topbar-brand">
|
||||||
<div className="eyebrow">采购主链自动化一期</div>
|
|
||||||
<h1>状态中心</h1>
|
<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 ? '本地演示数据' : '后端已连接'}
|
{demoMode ? '本地演示数据' : '后端已连接'}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="token-panel">
|
<div className="toolbar">
|
||||||
<label>
|
<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
|
<input
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(event) => void saveToken(event.target.value)}
|
onChange={(event) => void saveToken(event.target.value)}
|
||||||
placeholder="仅本地联调时填写"
|
placeholder="本地联调"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</header>
|
</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">
|
<main className="dashboard-layout">
|
||||||
<aside className="dashboard-sidebar">
|
<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>
|
</aside>
|
||||||
|
|
||||||
<div className="dashboard-main">
|
<div className="dashboard-main">
|
||||||
|
|
@ -643,69 +798,131 @@ export default function App() {
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow">单据概览</div>
|
<div className="eyebrow">单据概览</div>
|
||||||
<h2>{businessOverview?.header.supplierName || '请选择一条单据'}</h2>
|
<h2>{businessOverview?.header.supplierName || '请选择一条单据'}</h2>
|
||||||
<div className="muted">
|
{businessOverview ? (
|
||||||
{businessOverview
|
<div className="hero-id-row">
|
||||||
? `${businessOverview.header.jobId} · ${businessOverview.header.orderDateText} · ${businessOverview.header.sourceTypeText}`
|
<button
|
||||||
: '先从左侧选择一条单据。'}
|
type="button"
|
||||||
</div>
|
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>
|
||||||
<div className="hero-status">
|
<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.currentNodeText || '-'}</div>
|
||||||
<div className="muted">责任角色:{businessOverview?.header.currentRoleText || '-'}</div>
|
<div className="muted">责任角色:{businessOverview?.header.currentRoleText || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hero-metrics">
|
<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-label">开放待办</div>
|
||||||
<div className="detail-value">{businessOverview?.summary.openTaskCount ?? 0}</div>
|
<div className="detail-value">{businessOverview?.summary.openTaskCount ?? 0}</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="metric-card">
|
<button type="button" className="metric-card" onClick={() => openPanel('evidence-panel')}>
|
||||||
<div className="detail-label">证据</div>
|
<div className="detail-label">证据</div>
|
||||||
<div className="detail-value">{businessOverview?.summary.evidenceCount ?? 0}</div>
|
<div className="detail-value">{businessOverview?.summary.evidenceCount ?? 0}</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="metric-card">
|
<button type="button" className="metric-card" onClick={() => openPanel('node-details-panel')}>
|
||||||
<div className="detail-label">节点</div>
|
<div className="detail-label">节点</div>
|
||||||
<div className="detail-value">{businessOverview?.summary.nodeDetailCount ?? 0}</div>
|
<div className="detail-value">{businessOverview?.summary.nodeDetailCount ?? 0}</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="metric-card">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="metric-card"
|
||||||
|
onClick={() => document.getElementById('reference-strip')?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
|
||||||
|
>
|
||||||
<div className="detail-label">关键编号</div>
|
<div className="detail-label">关键编号</div>
|
||||||
<div className="detail-value">{businessOverview?.summary.referenceDoneCount ?? 0}/5</div>
|
<div className="detail-value">{businessOverview?.summary.referenceDoneCount ?? 0}/5</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="reference-strip">
|
<div className="reference-strip" id="reference-strip">
|
||||||
{businessOverview?.referenceChain.map((item) => (
|
{businessOverview?.referenceChain.map((item) => (
|
||||||
<div key={item.key} className={`reference-pill reference-${item.status.toLowerCase()}`}>
|
<button
|
||||||
<div className="detail-label">{item.label}</div>
|
key={item.key}
|
||||||
<div className="detail-value">{item.value}</div>
|
type="button"
|
||||||
<div className="muted">{item.status === 'DONE' ? '已回填' : '待回填'}</div>
|
className={`reference-pill reference-${item.status.toLowerCase()}`}
|
||||||
</div>
|
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 className="empty">请选择一条单据。</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="primary-task-strip">
|
<div className="primary-task-strip">
|
||||||
<div className="detail-label">当前待办</div>
|
|
||||||
{businessOverview?.primaryTask ? (
|
{businessOverview?.primaryTask ? (
|
||||||
<div className="primary-task-body">
|
<button
|
||||||
<div className="strong">{businessOverview.primaryTask.title}</div>
|
type="button"
|
||||||
<div className="muted">
|
className="primary-task-cta"
|
||||||
{businessOverview.primaryTask.nodeText} · {businessOverview.primaryTask.roleText} · {businessOverview.primaryTask.statusText}
|
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>
|
||||||
<div className="muted">{businessOverview.primaryTask.reasonText}</div>
|
<span className="primary-task-action">去处理</span>
|
||||||
</div>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty">当前没有未完成待办。</div>
|
<>
|
||||||
|
<div className="detail-label">当前待办</div>
|
||||||
|
<div className="empty">当前没有未完成待办。</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<JobTimeline meta={meta} timeline={timeline} />
|
<JobTimeline
|
||||||
|
meta={meta}
|
||||||
|
timeline={timeline}
|
||||||
|
currentNodeCode={currentNodeCode}
|
||||||
|
focusedNodeCode={focusedNodeCode}
|
||||||
|
onSelect={focusNode}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="workflow-grid" id="task-section">
|
<section className="workflow-grid" id="task-section">
|
||||||
<div className="workflow-tasks">
|
<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>
|
||||||
|
|
||||||
<div className="workflow-form">
|
<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 className="detail-value">{selectedTask.reason_user_friendly || reasonLabel(selectedTask.reason_type)}</div>
|
||||||
</div>
|
</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>
|
<div className="empty">请先选择一条待办。</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -811,15 +1039,15 @@ export default function App() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="collapse-stack">
|
<section className="collapse-stack">
|
||||||
<details className="collapse-panel">
|
<details className="collapse-panel" id="evidence-panel">
|
||||||
<summary>
|
<summary>
|
||||||
<span>证据包</span>
|
<span>证据包</span>
|
||||||
<span className="collapse-count">{businessOverview?.summary.evidenceCount ?? 0}</span>
|
<span className="collapse-count">{businessOverview?.summary.evidenceCount ?? 0}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<JobEvidenceList meta={meta} artifacts={jobArtifacts} onArtifactClick={(path) => void handleArtifactClick(path)} />
|
<JobEvidenceList meta={meta} artifacts={jobArtifacts} onArtifactClick={(path) => void copyValue('证据路径', path)} />
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details className="collapse-panel">
|
<details className="collapse-panel" id="node-details-panel">
|
||||||
<summary>
|
<summary>
|
||||||
<span>节点详情</span>
|
<span>节点详情</span>
|
||||||
<span className="collapse-count">{businessOverview?.summary.nodeDetailCount ?? 0}</span>
|
<span className="collapse-count">{businessOverview?.summary.nodeDetailCount ?? 0}</span>
|
||||||
|
|
@ -827,8 +1055,9 @@ export default function App() {
|
||||||
<JobNodeDetailList
|
<JobNodeDetailList
|
||||||
meta={meta}
|
meta={meta}
|
||||||
nodes={jobNodeDetails}
|
nodes={jobNodeDetails}
|
||||||
onTaskSelect={selectTaskFromNode}
|
focusedNodeCode={focusedNodeCode}
|
||||||
onArtifactClick={(path) => void handleArtifactClick(path)}
|
onTaskSelect={activateTask}
|
||||||
|
onArtifactClick={(path) => void copyValue('证据路径', path)}
|
||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ type Props = {
|
||||||
meta: StateMachineMeta | null;
|
meta: StateMachineMeta | null;
|
||||||
jobs: JobRow[];
|
jobs: JobRow[];
|
||||||
selectedJobId: string;
|
selectedJobId: string;
|
||||||
|
query?: string;
|
||||||
|
taskJobIds?: Set<string>;
|
||||||
onSelect(jobId: string): void;
|
onSelect(jobId: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -15,46 +17,98 @@ function statusClass(status: string): string {
|
||||||
return 'status-tag-running';
|
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 (
|
return (
|
||||||
<div className="panel">
|
<>
|
||||||
<div className="panel-title">单据列表 · {jobs.length}</div>
|
{parts.map((part, i) =>
|
||||||
<table className="data-table">
|
typeof part === 'string' ? <span key={i}>{part}</span> : <mark key={i}>{part.mark}</mark>,
|
||||||
<thead>
|
)}
|
||||||
<tr>
|
</>
|
||||||
<th>供应商</th>
|
);
|
||||||
<th>日期</th>
|
}
|
||||||
<th>来源</th>
|
|
||||||
<th>当前节点</th>
|
export function JobList({ meta, jobs, selectedJobId, query, taskJobIds, onSelect }: Props) {
|
||||||
<th>角色</th>
|
function moveSelection(direction: 1 | -1) {
|
||||||
<th>状态</th>
|
if (!jobs.length) return;
|
||||||
</tr>
|
const current = jobs.findIndex((job) => job.job_id === selectedJobId);
|
||||||
</thead>
|
const nextIndex = current < 0
|
||||||
<tbody>
|
? 0
|
||||||
{jobs.map((job) => (
|
: Math.min(jobs.length - 1, Math.max(0, current + direction));
|
||||||
<tr
|
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}
|
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)}
|
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="job-list-top">
|
||||||
<div className="cell-title">{job.supplier_name}</div>
|
<span className="cell-title">
|
||||||
<div className="cell-sub">{job.job_id}</div>
|
<Highlight text={job.supplier_name} query={query} />
|
||||||
</td>
|
</span>
|
||||||
<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>
|
|
||||||
<span className={`status-tag ${statusClass(job.status)}`}>
|
<span className={`status-tag ${statusClass(job.status)}`}>
|
||||||
{jobStatusLabel(job.status)}
|
{jobStatusLabel(job.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<div className="cell-sub">
|
||||||
))}
|
<Highlight text={job.job_id} query={query} />
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
<div className="job-list-meta">
|
||||||
{jobs.length === 0 ? <div className="empty">没有可展示的单据。</div> : null}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,24 @@ import {
|
||||||
type Props = {
|
type Props = {
|
||||||
meta: StateMachineMeta | null;
|
meta: StateMachineMeta | null;
|
||||||
nodes: NodeDetail[];
|
nodes: NodeDetail[];
|
||||||
|
focusedNodeCode?: string;
|
||||||
onTaskSelect(taskId: string): void;
|
onTaskSelect(taskId: string): void;
|
||||||
onArtifactClick(storagePath: string): void;
|
onArtifactClick(storagePath: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function JobNodeDetailList({ meta, nodes, onTaskSelect, onArtifactClick }: Props) {
|
export function JobNodeDetailList({ meta, nodes, focusedNodeCode, onTaskSelect, onArtifactClick }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-title">节点详情</div>
|
<div className="panel-title">节点详情</div>
|
||||||
<div className="node-detail-list">
|
<div className="node-detail-list">
|
||||||
{nodes.map((node) => (
|
{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 className="node-detail-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="strong">{nodeLabel(meta, node.node_code)}</div>
|
<div className="strong">{nodeLabel(meta, node.node_code)}</div>
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,42 @@ import { executorLabel, nodeLabel, statusLabel, type StateMachineMeta, type Time
|
||||||
type Props = {
|
type Props = {
|
||||||
meta: StateMachineMeta | null;
|
meta: StateMachineMeta | null;
|
||||||
timeline: TimelineNode[];
|
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 (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-title">流程状态图</div>
|
<div className="panel-title">流程状态图</div>
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{timeline.map((node) => (
|
{timeline.map((node) => {
|
||||||
<div key={node.node_code} className={`timeline-node status-${node.display_status.toLowerCase()}`}>
|
const current = node.node_code === currentNodeCode;
|
||||||
<div className="timeline-node-name">{nodeLabel(meta, node.node_code)}</div>
|
const focused = node.node_code === focusedNodeCode;
|
||||||
<div className="timeline-node-status">{statusLabel(meta, node.display_status)}</div>
|
const className = [
|
||||||
<div className="timeline-node-executor">{executorLabel(meta, node.executor_type || meta?.node_executor[node.node_code])}</div>
|
'timeline-node',
|
||||||
</div>
|
`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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ type Props = {
|
||||||
meta: StateMachineMeta | null;
|
meta: StateMachineMeta | null;
|
||||||
tasks: TaskRow[];
|
tasks: TaskRow[];
|
||||||
selectedTaskId: string;
|
selectedTaskId: string;
|
||||||
|
selectedJobId?: string;
|
||||||
onSelect(taskId: string): void;
|
onSelect(taskId: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) {
|
export function TaskList({ meta, tasks, selectedTaskId, selectedJobId, onSelect }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-title">待办列表 · {tasks.length}</div>
|
<div className="panel-title">待办列表 · {tasks.length}</div>
|
||||||
|
|
@ -21,26 +22,31 @@ export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => {
|
||||||
<tr
|
const selected = task.task_id === selectedTaskId;
|
||||||
key={task.task_id}
|
const currentJob = Boolean(selectedJobId && task.job_id === selectedJobId);
|
||||||
className={task.task_id === selectedTaskId ? 'is-selected' : ''}
|
return (
|
||||||
onClick={() => onSelect(task.task_id)}
|
<tr
|
||||||
>
|
key={task.task_id}
|
||||||
<td>
|
className={`${selected ? 'is-selected' : ''}${currentJob && !selected ? ' is-current-job' : ''}`}
|
||||||
<div className="cell-title">{task.title}</div>
|
onClick={() => onSelect(task.task_id)}
|
||||||
<div className="cell-sub">{task.reason_user_friendly || reasonLabel(task.reason_type)}</div>
|
>
|
||||||
</td>
|
<td>
|
||||||
<td>{nodeLabel(meta, task.node_code)}</td>
|
<div className="cell-title">{task.title}</div>
|
||||||
<td>{roleLabel(task.role_code)}</td>
|
<div className="cell-sub">{task.reason_user_friendly || reasonLabel(task.reason_type)}</div>
|
||||||
<td>
|
<div className="cell-sub">{task.job_id}</div>
|
||||||
<span className="status-tag status-tag-waiting">
|
</td>
|
||||||
{task.submitted_action ? actionLabel(task.submitted_action) : taskStatusLabel(task.status)}
|
<td>{nodeLabel(meta, task.node_code)}</td>
|
||||||
</span>
|
<td>{roleLabel(task.role_code)}</td>
|
||||||
<div className="cell-sub">{formatDateTime(task.created_at)}</div>
|
<td>
|
||||||
</td>
|
<span className="status-tag status-tag-waiting">
|
||||||
</tr>
|
{task.submitted_action ? actionLabel(task.submitted_action) : taskStatusLabel(task.status)}
|
||||||
))}
|
</span>
|
||||||
|
<div className="cell-sub">{formatDateTime(task.created_at)}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{tasks.length === 0 ? <div className="empty">当前没有待办。</div> : null}
|
{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,
|
buildNodeCallbackTemplate,
|
||||||
jobStatusBucketLabel,
|
jobStatusBucketLabel,
|
||||||
jobStatusBucketMatches,
|
jobStatusBucketMatches,
|
||||||
|
jobMatchesQuery,
|
||||||
nodeCallbackActionLabel,
|
nodeCallbackActionLabel,
|
||||||
reasonLabel,
|
reasonLabel,
|
||||||
roleLabel,
|
roleLabel,
|
||||||
|
|
@ -99,6 +100,26 @@ test('单据状态筛选按验收口径归类', () => {
|
||||||
assert.equal(jobStatusBucketMatches('SUCCESS', 'SUCCESS'), true);
|
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('节点详情按节点关联执行记录、证据和待办', () => {
|
test('节点详情按节点关联执行记录、证据和待办', () => {
|
||||||
const details = buildNodeDetails(
|
const details = buildNodeDetails(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,32 @@ export function jobStatusBucketMatches(bucket: JobStatusBucket, status: string)
|
||||||
return true;
|
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) {
|
export function roleLabel(roleCode?: string | null) {
|
||||||
return {
|
return {
|
||||||
PURCHASER: '采购',
|
PURCHASER: '采购',
|
||||||
|
|
|
||||||
|
|
@ -91,24 +91,47 @@ textarea {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Topbar - 极致紧凑 */
|
/* Topbar */
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
gap: var(--space-lg);
|
align-items: stretch;
|
||||||
align-items: center;
|
gap: var(--space-md);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: var(--space-md);
|
padding: 10px 12px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid var(--gray-200);
|
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 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
|
@ -149,43 +172,278 @@ h2 {
|
||||||
margin-bottom: var(--space-xs);
|
margin-bottom: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Token Panel - 内联紧凑 */
|
/* Toolbar */
|
||||||
.token-panel {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-panel label {
|
.toolbar-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-sm);
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 3px;
|
||||||
font-size: 12px;
|
min-width: 0;
|
||||||
font-weight: 500;
|
}
|
||||||
color: var(--text-secondary);
|
|
||||||
|
.toolbar-field > span {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-panel input,
|
.toolbar-field input,
|
||||||
.token-panel select {
|
.toolbar-field select {
|
||||||
border: 1px solid var(--gray-300);
|
height: 32px;
|
||||||
border-radius: var(--radius-sm);
|
border: 1px solid var(--gray-200);
|
||||||
background: var(--surface);
|
border-radius: var(--radius-md);
|
||||||
padding: 4px 8px;
|
background-color: var(--gray-50);
|
||||||
|
padding: 0 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
min-width: 120px;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-panel input:focus,
|
.toolbar-field select {
|
||||||
.token-panel select:focus {
|
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;
|
outline: none;
|
||||||
border-color: var(--brand-primary);
|
border-color: var(--brand-primary);
|
||||||
|
background-color: var(--surface);
|
||||||
box-shadow: 0 0 0 2px var(--brand-primary-light);
|
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 */
|
||||||
.notice {
|
.notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
border: 1px solid var(--info-border);
|
border: 1px solid var(--info-border);
|
||||||
background: var(--info-bg);
|
background: var(--info-bg);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
|
@ -196,6 +454,30 @@ h2 {
|
||||||
font-weight: 500;
|
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 */
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -248,6 +530,158 @@ h2 {
|
||||||
min-width: 0;
|
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 - 改为紧凑单行 */
|
||||||
.hero-panel {
|
.hero-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -360,8 +794,7 @@ h2 {
|
||||||
|
|
||||||
/* Timeline - 极致紧凑 */
|
/* Timeline - 极致紧凑 */
|
||||||
.timeline {
|
.timeline {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: var(--space-xs);
|
padding-bottom: var(--space-xs);
|
||||||
|
|
@ -373,6 +806,8 @@ h2 {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
|
min-width: 88px;
|
||||||
|
flex: 1 0 88px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
transition: all 0.1s;
|
transition: all 0.1s;
|
||||||
|
|
@ -383,6 +818,35 @@ h2 {
|
||||||
box-shadow: var(--shadow-sm);
|
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 {
|
.timeline-node-name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -520,6 +984,10 @@ h2 {
|
||||||
box-shadow: inset 3px 0 0 var(--brand-primary);
|
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 {
|
.cell-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
@ -580,6 +1048,7 @@ h2 {
|
||||||
background: var(--gray-50);
|
background: var(--gray-50);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
scroll-margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-panel[open] {
|
.collapse-panel[open] {
|
||||||
|
|
@ -759,6 +1228,12 @@ button:disabled {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty .text-button {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--brand-primary);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-summary {
|
.filter-summary {
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -785,6 +1260,11 @@ button:disabled {
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
transition: all 0.2s;
|
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 {
|
.node-detail-card:hover {
|
||||||
|
|
@ -1092,13 +1572,9 @@ button:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.topbar {
|
.toolbar-search {
|
||||||
flex-direction: column;
|
flex: 1 1 100%;
|
||||||
align-items: flex-start;
|
max-width: none;
|
||||||
}
|
|
||||||
|
|
||||||
.token-panel {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-head {
|
.hero-head {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue