forked from ChaiHao/BPOS-MVP
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
/**
|
|
* feature `gov-task` —— 任务管理:在途监控、控制事件(暂停/叫停/终止)、恢复、改派、任务日志查看
|
|
*
|
|
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
|
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
|
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
|
*/
|
|
import { useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
CONTROL_SEMANTIC_LABEL,
|
|
TASK_RUNTIME_STATE_LABEL,
|
|
TERMINAL_STATE_LABEL,
|
|
WORKFLOW_STATE_LABEL,
|
|
controlTaskRoute,
|
|
reassignTaskRoute,
|
|
resumeTaskRoute,
|
|
} from '@contracts';
|
|
import type { ControlSemantic } from '@contracts';
|
|
import { Ico } from '@web-shared/Ico.tsx';
|
|
import { call } from '@web-shared/typed-fetch.ts';
|
|
import { Ava, Card, Chip, Clear, Entry, Facts, Kpi, NList, NRow, Note, PageHead, QueryGate, Sec, St } from '@web-shared/gov/primitives.tsx';
|
|
import type { ActSpec } from '@web-shared/gov/primitives.tsx';
|
|
import { APR_ST, RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts';
|
|
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
|
import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx';
|
|
import { atFull, atMinute } from '@web-shared/at.ts';
|
|
import {
|
|
useGovApprovals,
|
|
useGovStaff,
|
|
useGovTaskLog,
|
|
useGovTasks,
|
|
useGovWorkflow,
|
|
} from '@web-shared/gov/gov-api.ts';
|
|
|
|
export type HomeProps = { tab: string; nav: Nav };
|
|
|
|
export function TasksHome({ tab, nav }: HomeProps) {
|
|
return <RuntimeHome tab={tab} nav={nav} />;
|
|
}
|
|
|
|
/**
|
|
* 在途工作流看板 —— **工作流是流程包的运行时**,一级看板归「运营中心 · 工作流」区;
|
|
* 任务区(活动的运行时)不再摆它。
|
|
*/
|
|
export function WorkflowBoard({ nav }: { nav: Nav }) {
|
|
const q = useGovTasks();
|
|
const tasks = q.data?.tasks ?? [];
|
|
const workflows = q.data?.workflows ?? [];
|
|
|
|
/** 工作流不设负责人:要看「这条流现在落在谁身上」,读它当前节点的任务负责人 */
|
|
const holderOf = (workflowId: string) => {
|
|
const live = tasks.find((t) => t.workflowId === workflowId && t.terminal === undefined);
|
|
if (live) return { name: live.ownerName, label: '当前任务负责人' };
|
|
const stuck = tasks.find((t) => t.workflowId === workflowId && t.terminal !== undefined && t.terminal !== 'released');
|
|
return stuck ? { name: stuck.ownerName, label: '最后经手 · 待处置' } : undefined;
|
|
};
|
|
|
|
const lanes = [
|
|
{ k: 'running', tone: 'brand' },
|
|
{ k: 'waiting', tone: 'warn' },
|
|
{ k: 'abnormal', tone: 'danger' },
|
|
{ k: 'completed', tone: 'ok' },
|
|
] as const;
|
|
return (
|
|
<div className="g-board">
|
|
{lanes.map((ln) => {
|
|
const items = workflows.filter((w) => w.state === ln.k);
|
|
return (
|
|
<div key={ln.k} className={`g-lane t-${ln.tone}`}>
|
|
<div className="g-lane-h">
|
|
<i />
|
|
{WORKFLOW_STATE_LABEL[ln.k]}
|
|
<b>{items.length}</b>
|
|
</div>
|
|
{items.map((w) => {
|
|
const mine = tasks.filter((t) => t.workflowId === w.id);
|
|
const done = mine.filter((t) => t.terminal === 'released').length;
|
|
const total = Math.max(mine.length, 1);
|
|
const holder = holderOf(w.id);
|
|
const cur = mine.find((t) => t.terminal === undefined)?.title ?? '—';
|
|
return (
|
|
<button key={w.id} className={`g-wf t-${ln.tone}`} onClick={() => nav.push({ k: 'wf', id: w.id })}>
|
|
<span className="g-wf-top">
|
|
{w.id.slice(0, 8)}
|
|
<span className="r">{atMinute(w.startedAt)}</span>
|
|
</span>
|
|
<span className="g-wf-t">{w.subject}</span>
|
|
<span className="g-wf-m">
|
|
<Ico name="boxes" s={12} />
|
|
{w.packageId}
|
|
</span>
|
|
<span className="g-wf-track">
|
|
<i style={{ width: `${(done / total) * 100}%` }} />
|
|
</span>
|
|
<span className="g-wf-m">
|
|
<Ico name="circle-dot" s={12} />
|
|
{cur}
|
|
<span style={{ marginLeft: 'auto' }} className="g-mono">
|
|
{done}/{total}
|
|
</span>
|
|
</span>
|
|
<span className="g-wf-m g-wf-hold">
|
|
{holder ? (
|
|
<>
|
|
<Ava name={holder.name} />
|
|
{holder.name}
|
|
<em>{holder.label}</em>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Ico name="users" s={12} />
|
|
无在途任务
|
|
</>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{items.length === 0 && <div className="g-lane-e">—</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RuntimeHome({ tab, nav }: HomeProps) {
|
|
const q = useGovTasks();
|
|
const approvals = useGovApprovals();
|
|
const tasks = q.data?.tasks ?? [];
|
|
const workflows = q.data?.workflows ?? [];
|
|
|
|
if (tab === 'apr') {
|
|
const rows = approvals.data?.approvals ?? [];
|
|
return (
|
|
<>
|
|
<Note>
|
|
<b>审批点</b>是流程层的通用人裁节点,与活动、网关、触发器、流程包并列为编排层五要素——它是
|
|
<b>系统内置要素</b>,声明即用、零开发、不进活动库。它卡到审批人工位产出<b>审批单</b>:通过 /
|
|
驳回,附依据;没有预算、没有 Harness、没有任务日志,所以不是任务。
|
|
</Note>
|
|
<NList>
|
|
{rows.map((a) => (
|
|
<NRow
|
|
key={a.id}
|
|
tone={APR_ST[a.state].tone}
|
|
ico="shield-check"
|
|
title={a.title}
|
|
sub={`${a.workflowSubject} · ${a.id}`}
|
|
cols={[
|
|
{ k: '审批人', v: a.approverName },
|
|
]}
|
|
right={
|
|
<St tone={APR_ST[a.state].tone} ico={APR_ST[a.state].ico}>
|
|
{APR_ST[a.state].label}
|
|
</St>
|
|
}
|
|
onClick={() => nav.push({ k: 'wf', id: a.workflowId })}
|
|
/>
|
|
))}
|
|
{rows.length === 0 && <Clear>暂无审批单——流上还没有走到审批点。</Clear>}
|
|
</NList>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (tab === 'list') {
|
|
return (
|
|
<>
|
|
<NList>
|
|
{tasks.map((t) => {
|
|
const st = t.terminal !== undefined ? TERMINAL_ST[t.terminal] : RUNTIME_ST[t.runtime];
|
|
const label = t.terminal !== undefined ? TERMINAL_STATE_LABEL[t.terminal] : TASK_RUNTIME_STATE_LABEL[t.runtime];
|
|
return (
|
|
<NRow
|
|
key={t.id}
|
|
tone="brand"
|
|
ico="repeat"
|
|
title={t.title}
|
|
sub={`${t.id.slice(0, 8)} · 活动 ${t.activityId}`}
|
|
cols={[
|
|
{ k: '负责人', v: t.ownerName },
|
|
{
|
|
k: '步数占用',
|
|
v: (
|
|
<span className="g-mono">
|
|
{t.usedSteps}/{t.capSteps}
|
|
</span>
|
|
),
|
|
},
|
|
{ k: '起始', v: <span className="g-mono">{atMinute(t.startedAt)}</span> },
|
|
]}
|
|
right={
|
|
<St tone={st.tone} ico={st.ico}>
|
|
{label}
|
|
</St>
|
|
}
|
|
onClick={() => nav.push({ k: 'task', id: t.id })}
|
|
/>
|
|
);
|
|
})}
|
|
{tasks.length === 0 && <Clear>暂无任务——起单后这里会出现。</Clear>}
|
|
</NList>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const abnormal = workflows.filter((w) => w.state === 'abnormal');
|
|
const stuck = tasks.filter((t) => t.terminal === 'blocked' || t.terminal === 'exhausted' || t.terminal === 'stalled');
|
|
return (
|
|
<QueryGate query={q}>
|
|
<div className="g-todo">
|
|
<div className="g-kpis">
|
|
<Kpi
|
|
ico="activity"
|
|
tone="brand"
|
|
label="在途工作流"
|
|
value={workflows.filter((w) => w.state === 'running' || w.state === 'waiting').length}
|
|
sub="运行中 + 等待中"
|
|
/>
|
|
<Kpi ico="alert-triangle" tone="danger" label="异常工作流" value={abnormal.length} sub="失败走向未消化,待人工处置" />
|
|
<Kpi ico="alert-circle" tone="warn" label="任务需处置" value={stuck.length} sub="受阻待人 + 预算耗尽 + 停滞熔断" />
|
|
<Kpi
|
|
ico="circle-check"
|
|
tone="ok"
|
|
label="放行完成"
|
|
value={tasks.filter((t) => t.terminal === 'released').length}
|
|
sub="唯一成功终态"
|
|
/>
|
|
</div>
|
|
{abnormal.map((w) => (
|
|
<NRow
|
|
key={w.id}
|
|
tone="danger"
|
|
ico="alert-triangle"
|
|
title={`${w.subject} · 异常`}
|
|
sub="节点以非完成终态结束且失败走向未消化,按声明为「终止流」,需人工处置。"
|
|
onClick={() => nav.push({ k: 'wf', id: w.id })}
|
|
/>
|
|
))}
|
|
{stuck.map((t) => (
|
|
<NRow
|
|
key={t.id}
|
|
tone={t.terminal === 'exhausted' ? 'warn' : 'danger'}
|
|
ico={TERMINAL_ST[t.terminal!].ico}
|
|
title={`${t.title} · ${TERMINAL_STATE_LABEL[t.terminal!]}`}
|
|
sub={`${t.id.slice(0, 8)} · 处置 = 改派 / 重开新实例 / 终止`}
|
|
onClick={() => nav.push({ k: 'task', id: t.id })}
|
|
/>
|
|
))}
|
|
{abnormal.length + stuck.length === 0 && <Clear>无待处置——在途实例全部正常推进。</Clear>}
|
|
</div>
|
|
</QueryGate>
|
|
);
|
|
}
|
|
|
|
/* ---------------- ② 员工管理 ---------------- */
|
|
|
|
/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */
|
|
function useGovAction<TInput>(mutate: (input: TInput) => Promise<unknown>, keys: readonly string[]) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: mutate,
|
|
onSuccess: () => {
|
|
for (const key of keys) void queryClient.invalidateQueries({ queryKey: ['gov', key] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/* ---------------- 工作流 ---------------- */
|
|
|
|
export function WfPage({ id, nav }: { id: string; nav: Nav }) {
|
|
const workflowId = id.includes(':') ? id.split(':')[0]! : id;
|
|
const q = useGovWorkflow(workflowId);
|
|
if (q.data === undefined) return <Note>载入中…</Note>;
|
|
const { workflow, pkg, instances, tasks } = q.data;
|
|
const st = WF_ST[workflow.state];
|
|
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="workflow"
|
|
tone={st.tone}
|
|
kicker={`工作流 · ${workflow.id.slice(0, 8)}`}
|
|
title={workflow.subject}
|
|
chips={
|
|
<>
|
|
<Chip tone={st.tone} ico={st.ico}>
|
|
{WORKFLOW_STATE_LABEL[workflow.state]}
|
|
</Chip>
|
|
<Chip ico="boxes">
|
|
{workflow.packageId} v{workflow.packageVersion}
|
|
</Chip>
|
|
<Chip ico="zap">
|
|
来源 · {workflow.source.kind} · {workflow.source.ref}
|
|
</Chip>
|
|
</>
|
|
}
|
|
desc="工作流状态由它上面的节点(任务与审批单)聚合推导,工作流层不另设状态机;重开产生新的一条工作流,旧的已终、不再入算。"
|
|
/>
|
|
|
|
<Sec ico="git-branch" title="流程图 · 节点实例落位">
|
|
<FlowFigure pkg={pkg} instances={instances} />
|
|
</Sec>
|
|
|
|
<Sec ico="list-checks" title="流内任务" extra={`${tasks.length} 个`}>
|
|
<NList>
|
|
{tasks.map((t) => (
|
|
<NRow
|
|
key={t.id}
|
|
tone="brand"
|
|
ico="list-checks"
|
|
title={t.title}
|
|
sub={`节点 ${t.nodeId} · ${t.id.slice(0, 8)}`}
|
|
cols={[{ k: '负责人', v: t.ownerName }]}
|
|
onClick={() => nav.push({ k: 'task', id: t.id })}
|
|
/>
|
|
))}
|
|
{tasks.length === 0 && <Clear>暂无任务。</Clear>}
|
|
</NList>
|
|
</Sec>
|
|
|
|
<Sec ico="circle-dot" title="节点实例" extra={`${instances.length} 条`}>
|
|
<div className="g-rows">
|
|
{instances.map((i) => (
|
|
<div key={`${i.nodeId}#${i.instance}`}>
|
|
<em>
|
|
{pkg.nodes.find((n) => n.id === i.nodeId)?.label ?? i.nodeId}
|
|
{i.instance > 1 ? ` #${i.instance}` : ''}
|
|
</em>
|
|
<b className="mono">
|
|
{i.terminal !== undefined
|
|
? TERMINAL_STATE_LABEL[i.terminal]
|
|
: i.running
|
|
? '运行中'
|
|
: i.waiting
|
|
? '等待中'
|
|
: i.activated
|
|
? '已激活'
|
|
: '未激活'}
|
|
</b>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Sec>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---------------- 任务:在途监控 · 控制 · 改派 ---------------- */
|
|
|
|
export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
|
|
const q = useGovTasks();
|
|
const staff = useGovStaff();
|
|
const task = (q.data?.tasks ?? []).find((t) => t.id === id);
|
|
const [assignee, setAssignee] = useState<string>('');
|
|
|
|
const control = useGovAction(
|
|
(semantic: ControlSemantic) =>
|
|
call(controlTaskRoute, { params: { taskId: id }, body: { semantic, reason: '治理台干预' } }),
|
|
['tasks', 'workflow', 'tasklog'],
|
|
);
|
|
const resume = useGovAction(() => call(resumeTaskRoute, { params: { taskId: id } }), ['tasks', 'workflow', 'tasklog']);
|
|
const reassign = useGovAction(
|
|
(to: string) => call(reassignTaskRoute, { params: { taskId: id }, body: { to } }),
|
|
['tasks', 'staff'],
|
|
);
|
|
|
|
if (task === undefined) return <Note>任务不存在或已不在近期窗口内。</Note>;
|
|
const st = task.terminal !== undefined ? TERMINAL_ST[task.terminal] : RUNTIME_ST[task.runtime];
|
|
const label = task.terminal !== undefined ? TERMINAL_STATE_LABEL[task.terminal] : TASK_RUNTIME_STATE_LABEL[task.runtime];
|
|
const live = task.terminal === undefined;
|
|
const suspended = task.runtime === 'suspended';
|
|
|
|
/** 控制三语义:暂停可恢复、叫停归负责人、终止级联作废 */
|
|
const controlAct = (semantic: ControlSemantic, danger: boolean): ActSpec => ({
|
|
label: CONTROL_SEMANTIC_LABEL[semantic],
|
|
ico: semantic === 'terminate' ? 'power' : semantic === 'halt' ? 'hand' : 'pause',
|
|
danger,
|
|
disabled: !live,
|
|
ask: {
|
|
title: `控制事件 · ${CONTROL_SEMANTIC_LABEL[semantic]}:${task.title}`,
|
|
note: <Note>控制事件走统一事件信封(四类事件之控制事件),先持久后投递。</Note>,
|
|
impact: {
|
|
who:
|
|
semantic === 'terminate'
|
|
? '在途任务终止、待答卡与征答消息作废、审批点撤卡(级联)'
|
|
: semantic === 'halt'
|
|
? `负责人 ${task.ownerName} —— 挂起并记最重档纠正信号`
|
|
: '任务挂起,监听与预算冻结',
|
|
undo: semantic === 'terminate' ? '不可恢复' : '可恢复',
|
|
when: '确认后即时',
|
|
},
|
|
okLabel: `确认${CONTROL_SEMANTIC_LABEL[semantic]}`,
|
|
onOk: () => control.mutate(semantic),
|
|
},
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="repeat"
|
|
tone={st.tone}
|
|
kicker={`任务 · ${task.id.slice(0, 8)}`}
|
|
title={task.title}
|
|
chips={
|
|
<>
|
|
<Chip tone={st.tone} ico={st.ico}>
|
|
{label}
|
|
</Chip>
|
|
<Chip ico="user-round">{task.ownerName}</Chip>
|
|
</>
|
|
}
|
|
desc="活动的一次运行 —— 治理不改定义:控制只作用于这一次运行。"
|
|
acts={[
|
|
controlAct('pause', false),
|
|
controlAct('halt', true),
|
|
controlAct('terminate', true),
|
|
{
|
|
label: '恢复',
|
|
ico: 'play',
|
|
primary: true,
|
|
disabled: !suspended,
|
|
ask: {
|
|
title: `恢复任务:${task.title}`,
|
|
note: <Note tone="ok">恢复 = 从挂起点继续;预算与监听解冻,任务记忆只追加、不回滚。</Note>,
|
|
impact: { who: `负责人 ${task.ownerName}`, undo: '可再次控制', when: '确认后即时' },
|
|
okLabel: '确认恢复',
|
|
onOk: () => resume.mutate(undefined),
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Card ico="gauge" title="预算占用" extra={<span className="g-mono">{`${task.usedSteps}/${task.capSteps} 步`}</span>}>
|
|
<Facts
|
|
items={[
|
|
{ k: '步数', v: `${task.usedSteps}/${task.capSteps}`, ratio: task.usedSteps / Math.max(task.capSteps, 1) },
|
|
{ k: '活动', v: task.activityId },
|
|
{ k: '起始', v: atFull(task.startedAt) },
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
<Sec ico="user-plus" title="改派">
|
|
<div className="g-field">
|
|
<em>新负责人</em>
|
|
<div className="g-seg">
|
|
{(staff.data?.staff ?? [])
|
|
.filter((s) => s.state === 'active' && s.name !== task.ownerName)
|
|
.map((s) => (
|
|
<button key={s.id} className={'g-seg-b' + (assignee === s.id ? ' on' : '')} onClick={() => setAssignee(s.id)}>
|
|
{s.name} · {s.position.code}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="g-acts-r">
|
|
<button className="g-btn primary" disabled={assignee === '' || !live} onClick={() => reassign.mutate(assignee)}>
|
|
<Ico name="user-plus" s={13} />
|
|
确认改派
|
|
</button>
|
|
</div>
|
|
</Sec>
|
|
|
|
<Entry
|
|
ico="scroll-text"
|
|
title="任务日志"
|
|
desc="只追加、信封盖戳、归侧标注 —— 过程即证据"
|
|
onClick={() => nav.push({ k: 'tasklog', id: task.id })}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function TaskLogPage({ id }: { id: string }) {
|
|
const q = useGovTaskLog(id);
|
|
const lines = q.data?.log ?? [];
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="scroll-text"
|
|
kicker={`任务日志 · ${id.slice(0, 8)}`}
|
|
title="过程留痕"
|
|
desc="任务记忆只追加、不修改:每条都带信封戳与归侧标注(机器产出 / 员工动作 / 人裁决 / 系统等待)。"
|
|
/>
|
|
<div className="g-entries">
|
|
{lines.map((l) => (
|
|
<div key={l.id} className="g-entry">
|
|
<span className="g-entry-i">
|
|
<Ico name="circle-dot" s={14} />
|
|
</span>
|
|
<span className="g-entry-t">
|
|
<b>{l.title}</b>
|
|
<em>{l.at.slice(0, 19).replace('T', ' ')}</em>
|
|
</span>
|
|
<span className="g-entry-m">
|
|
{/* 与工作台同口径:章是四字动作;归侧另有度量面在看 */}
|
|
<Chip>{l.action}</Chip>
|
|
</span>
|
|
</div>
|
|
))}
|
|
{lines.length === 0 && <Clear>暂无留痕。</Clear>}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---------------- 员工 / 岗位 ---------------- */
|
|
|