/**
* 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, NList, NRow, Note, PageHead, Sec } from '@web-shared/gov/primitives.tsx';
import type { ActSpec } from '@web-shared/gov/primitives.tsx';
import { 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 { Btn } from '@web-shared/Btn.tsx';
import { 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 ;
}
/**
* 在途工作流看板 —— **工作流是流程包的运行时**,一级看板归「运营中心 · 工作流」区;
* 任务区(活动的运行时)不再摆它。
*/
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 (
{lanes.map((ln) => {
const items = workflows.filter((w) => w.state === ln.k);
return (
{WORKFLOW_STATE_LABEL[ln.k]}
{items.length}
{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 (
);
})}
{items.length === 0 &&
—
}
);
})}
);
}
/**
* 运营中心 · 活动区一级页(L1)—— 2026-08-25 重构模版(静态数据占位,接口未接):
* 页签二分「已发布 / 审核中」(与「创建过审核才展示」的规则对应);
* 活动以卡片呈现 —— 头 = 名称 + 标识 + 状态丸,契约带 = 输入 → 输出,底板 = 实例/负责人/时间;
* 尾部幽灵卡 = 创建活动入口(创建功能本期不做,纯占位)。
* L2(活动详情 + 实例清单)、L3(实例详情)随后接。
*/
type DemoActivity = {
id: string;
name: string;
biz: string;
instances: number;
owner: string;
createdAt: string;
/** 运营态:激活中 / 已禁用(2026-08-25 用户定)—— L2 里可改;发布/审核是设计部分,暂缓 */
status: 'active' | 'disabled';
/** 当前版本号 —— 活动无编辑/删除,演进只能靠发新版本(2026-08-25 用户定) */
version?: string;
/** 语义图标与业务色相 —— 色即业务,一卡一色(2026-08-25 视觉增强) */
ico: string;
hue: 'blue' | 'teal' | 'amber' | 'violet';
/** 这个活动绑定的 LLM —— 每个活动一个专属 agent,负责把流程跑通(2026-08-25 用户定:L2 要展示) */
llm: string;
};
/** 占位静态数据 —— 接口设计定稿后整体换掉(2026-08-25 用户定:先出页面模版看效果) */
const DEMO_ACTIVITIES: readonly DemoActivity[] = [
{ id: 'quote-intake', name: '询价接收与结构化', biz: '报价业务', instances: 12, owner: '柴浩', createdAt: '08-12', status: 'active', version: '1.3.0', ico: 'inbox', hue: 'blue', llm: 'K3 · quote-intake' },
{ id: 'quote-pricing', name: '报价定价测算', biz: '报价业务', instances: 8, owner: '丁子乔', createdAt: '08-12', status: 'active', version: '1.2.0', ico: 'calculator', hue: 'blue', llm: 'K3 · quote-pricing' },
{ id: 'crm-coverage', name: '关键人触达计划', biz: 'CRM 业务', instances: 5, owner: '冯兵', createdAt: '08-15', status: 'active', version: '1.0.0', ico: 'target', hue: 'teal', llm: 'K3 · crm-coverage' },
{ id: 'competitor-watch', name: '竞品价格监测', biz: '报价业务', instances: 2, owner: '柴浩', createdAt: '08-24', status: 'active', version: '1.1.0', ico: 'eye', hue: 'blue', llm: 'K3 · competitor-watch' },
{ id: 'market-weekly', name: '市场信号周报生成', biz: '市场业务', instances: 3, owner: '林佩萱', createdAt: '08-18', status: 'disabled', version: '2.1.0', ico: 'trending-up', hue: 'amber', llm: 'K3 · market-weekly' },
{ id: 'supplier-verify', name: '供应商资质核验', biz: '采购业务', instances: 1, owner: '丁子乔', createdAt: '08-25', status: 'disabled', version: '1.0.0', ico: 'shield-check', hue: 'violet', llm: 'K3 · supplier-verify' },
];
function RuntimeHome({ tab, nav }: HomeProps) {
const items = DEMO_ACTIVITIES.filter((a) => a.status === tab);
return (
{items.map((a) => (
{/* 整卡热区 → L2 活动详情(2026-08-25 接) */}
))}
{/*
幽灵卡 = 创建入口 —— 2026-08-25 用户定:设计功能暂缓,先做运营部分,
创建活动的幽灵卡先注释掉;恢复时把这段 JSX 解开即可(样式 .g-fcard.ghost 保留着)
创建活动
*/}
);
}
/* ==================== 活动详情(L2,2026-08-25 重构模版) ==================== */
/** 实例状态五态(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰 */
type InstanceState = '进行中' | '待执行' | '已完成' | '驳回' | '失败';
const INSTANCE_ST: Readonly> = {
进行中: 'brand',
待执行: 'warn',
已完成: 'ok',
驳回: 'danger',
失败: 'mute',
};
type DemoInstance = {
id: string;
input: string;
state: InstanceState;
owner: string;
startedAt: string;
output: string;
};
/** 占位实例清单 —— 每个活动先共用同一份样子,接口定稿后按活动取(2026-08-25 模版) */
const demoInstances = (owner: string): readonly DemoInstance[] => [
{ id: 'INS-2417', input: 'Itelma 车身控制器询价 · 3 项物料', state: '进行中', owner, startedAt: '08-25 10:12', output: '—' },
{ id: 'INS-2416', input: '伏尔加重卡灯组询价 · 12 项物料', state: '待执行', owner, startedAt: '08-25 09:40', output: '—' },
{ id: 'INS-2415', input: 'GAZ 底盘线束询价 · 7 项物料', state: '已完成', owner, startedAt: '08-24 17:03', output: 'quote-20260824-07.json' },
{ id: 'INS-2414', input: 'KAMAZ 传感器询价 · 5 项物料', state: '已完成', owner, startedAt: '08-24 14:26', output: 'quote-20260824-03.json' },
{ id: 'INS-2413', input: 'UAZ 控制模块询价 · 4 项物料', state: '驳回', owner, startedAt: '08-23 16:51', output: '—' },
{ id: 'INS-2412', input: 'Lada 车机询价 · 9 项物料', state: '失败', owner, startedAt: '08-23 11:05', output: '—' },
{ id: 'INS-2411', input: 'Itelma 座椅电机询价 · 6 项物料', state: '已完成', owner, startedAt: '08-22 18:22', output: 'quote-20260822-11.json' },
{ id: 'INS-2410', input: 'GAZ 门锁执行器询价 · 2 项物料', state: '已完成', owner, startedAt: '08-22 15:47', output: 'quote-20260822-06.json' },
{ id: 'INS-2409', input: 'KAMAZ 变速箱询价 · 8 项物料', state: '驳回', owner, startedAt: '08-22 10:19', output: '—' },
{ id: 'INS-2408', input: '伏尔加仪表集群询价 · 5 项物料', state: '已完成', owner, startedAt: '08-21 16:33', output: 'quote-20260821-09.json' },
{ id: 'INS-2407', input: 'UAZ 分动箱询价 · 3 项物料', state: '失败', owner, startedAt: '08-21 13:58', output: '—' },
{ id: 'INS-2406', input: 'Lada 悬挂件询价 · 11 项物料', state: '已完成', owner, startedAt: '08-21 09:24', output: 'quote-20260821-02.json' },
{ id: 'INS-2405', input: 'Itelma 雨刮电机询价 · 1 项物料', state: '已完成', owner, startedAt: '08-20 17:41', output: 'quote-20260820-08.json' },
];
/** 实例清单单页上限(2026-08-25 用户定:6 → 8,6 张时页面与卡内留白太多) */
const INSTANCE_PAGE_SIZE = 8;
/* ── 历史活动检索(2026-08-26 用户定:列表就要能搜)──────────────────────
条件下拉四选:实例(号,模糊)/ 负责人(人员下拉)/ 状态(五态)/ 启动时间(日期段);
条件一换,右边的控件跟着换 —— 一个条件一个趁手的组件 */
type InstanceFilterKey = 'id' | 'owner' | 'state' | 'time';
const INSTANCE_FILTER_LABEL: Readonly> = {
id: '实例',
owner: '负责人',
state: '状态',
time: '启动时间',
};
/** 占位数据的启动时间是 `MM-DD HH:mm`(2026 年),转成可比较的 Date;接口定稿后随真数据格式一起换 */
function instanceTime(at: string): number {
const m = /^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(at);
if (m === null) return 0;
return new Date(2026, Number(m[1]) - 1, Number(m[2]), Number(m[3]), Number(m[4])).getTime();
}
/** 日期段 [from, to](`YYYY-MM-DD`,含两端整日)—— 空端不卡那一边 */
function inTimeRange(at: string, from: string, to: string): boolean {
const t = instanceTime(at);
if (from !== '' && t < new Date(`${from}T00:00:00`).getTime()) return false;
if (to !== '' && t > new Date(`${to}T23:59:59`).getTime()) return false;
return true;
}
/**
* L2 · 活动详情 —— 两个问题一屏答:「这个活动是什么」(定义,只读)
* 与「它跑得怎么样」(小统计 + 实例清单,点行进 L3)。
*
* 头部动作三个(2026-08-25 用户定):
* · 激活/禁用 —— 本期做交互,二次确认;**有实例时无法禁用**(规则来自接口,本期前端占位演示);
* · 启动活动(即创建实例)/ 版本迭代 —— 只留入口,功能后做(disabled)。
*
* 沙箱区:加载服务器返回的活动视图 HTML —— iframe + sandbox 隔离;
* v1 静态占位页,src 与内容高度通讯(postMessage)待接口一起定。
*/
export function ActvPage({ id }: { id: string }) {
const a = DEMO_ACTIVITIES.find((x) => x.id === id);
const [status, setStatus] = useState<'active' | 'disabled' | undefined>(undefined);
const [blocked, setBlocked] = useState(false);
const [frameLoaded, setFrameLoaded] = useState(false);
/* 实例清单入组件状态:操作列的「终止活动」要就地改行(本期前端演示,接口对接后走 mutation) */
const [rows, setRows] = useState(undefined);
const [page, setPage] = useState(0);
/* 检索条件(2026-08-26 用户定):条件下拉 + 随条件换控件;值一变回第一页 */
const [fKey, setFKey] = useState('id');
const [fText, setFText] = useState('');
const [fOwner, setFOwner] = useState('');
const [fState, setFState] = useState('');
const [fFrom, setFFrom] = useState('');
const [fTo, setFTo] = useState('');
if (a === undefined) return 活动不存在 —— 它可能已被调整,返回活动清单看看。;
const st = status ?? a.status;
const instances = rows ?? demoInstances(a.owner);
const countOf = (s: InstanceState): number => instances.filter((i) => i.state === s).length;
/** 终止实例(2026-08-25 用户定:进行中的实例要有「终止活动」钮)—— 终止后归到哪个状态接口语义未定,前端先落「失败」占位 */
const stopInstance = (rid: string): void =>
setRows((cur) => (cur ?? demoInstances(a.owner)).map((r) => (r.id === rid ? { ...r, state: '失败' as InstanceState } : r)));
/* 检索:负责人下拉的候选从现有实例里收(真接口落地后换人员接口) */
const owners = [...new Set(instances.map((i) => i.owner))];
/** 当前条件是否真的带了值 —— 没带值不过滤,等于没搜 */
const filtering =
(fKey === 'id' && fText.trim() !== '') ||
(fKey === 'owner' && fOwner !== '') ||
(fKey === 'state' && fState !== '') ||
(fKey === 'time' && (fFrom !== '' || fTo !== ''));
const filtered = !filtering
? instances
: instances.filter((r) => {
switch (fKey) {
case 'id':
return r.id.toLowerCase().includes(fText.trim().toLowerCase());
case 'owner':
return r.owner === fOwner;
case 'state':
return r.state === fState;
case 'time':
return inTimeRange(r.startedAt, fFrom, fTo);
}
});
const clearFilter = () => {
setFText('');
setFOwner('');
setFState('');
setFFrom('');
setFTo('');
setPage(0);
};
/* 分页(2026-08-25 用户定:单页最多 8 条)—— 检索后按筛出的集合翻页 */
const pageCount = Math.max(1, Math.ceil(filtered.length / INSTANCE_PAGE_SIZE));
const cur = Math.min(page, pageCount - 1);
const pageRows = filtered.slice(cur * INSTANCE_PAGE_SIZE, (cur + 1) * INSTANCE_PAGE_SIZE);
return (
{st === 'active' ? '激活中' : '已禁用'}
{a.version !== undefined && v{a.version}}
{/* 绑定的 LLM 上头版(2026-08-25 用户定):每个活动一个专属 agent */}
{a.llm}
{a.owner}
创建于 {a.createdAt}
>
}
desc="活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。"
acts={[
st === 'active'
? {
label: '禁用活动',
ico: 'power',
danger: true,
ask: {
title: '确认禁用这个活动?',
note: '禁用后不能再为它创建实例;已在跑的实例不受影响。',
impact: { who: '该活动及其后续实例创建', undo: '可恢复 —— 随时重新激活', when: '确认后立即生效(本期前端演示,接口对接后落库)' },
okLabel: '确认禁用',
onOk: () => {
/* 规则(2026-08-25 用户定):有实例时接口会提示无法禁用 —— 本期前端先演出这个拦截 */
if (a.instances > 0) {
setBlocked(true);
} else {
setStatus('disabled');
setBlocked(false);
}
},
},
}
: {
label: '激活活动',
ico: 'zap',
primary: true,
ask: {
title: '确认激活这个活动?',
note: '激活后可以为它创建实例。',
impact: { who: '该活动及其后续实例创建', undo: '可逆 —— 随时可以再次禁用', when: '确认后立即生效(本期前端演示,接口对接后落库)' },
okLabel: '确认激活',
onOk: () => {
setStatus('active');
setBlocked(false);
},
},
},
{ label: '启动活动', ico: 'plus', disabled: true },
/* 版本迭代入口先隐藏(2026-08-25 用户定):功能设计回头讨论,要做时把这行加回 acts
{ label: '版本迭代', ico: 'git-branch', disabled: true },
*/
]}
/>
{blocked && (
无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。
)}
{/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)——
左:总数 + 五态灰字合计(状态色只进 6px 小圆点);右:条件下拉 + 随条件换控件 */}
{/* 列表式清单(2026-08-26 用户定:卡片撤了改列表)——
行 = 一次实例,列齐好扫;状态色只进 6px 圆点(色盲可读:状态词在点旁边);
行内操作常驻(查看详情是主入口,不藏) */}
{/* 列序(2026-08-26 用户定):实例 / 负责人 / 输入 / 输出 / 启动时间 / 状态 / 操作(操作栏也有标题) */}
实例
负责人
输入
输出
启动时间
状态
操作
{pageRows.map((r) => (
{r.id}
{r.owner}
{r.input}
{r.output}
{r.startedAt}
{/* key 带状态:状态翻转(终止)时弹出一次高亮 —— 状态变化要看得见 */}
{r.state}
{r.state === '进行中' && (
)}
))}
{/* 检索无结果:如实摆一句话,不留一张只剩表头的空表 */}
{pageRows.length === 0 && (
没有匹配的实例 —— 换个条件,或清空重看全部。
)}
{/* 分页(2026-08-25 用户定:单页最多 8 条)—— 页少不上页码器,翻页两枚就够;计数跟着筛出的集合走 */}
第 {cur + 1} / {pageCount} 页 · {filtering ? `筛出 ${filtered.length} / 共 ${instances.length} 个` : `共 ${filtered.length} 个`}
{/*
* 沙箱(2026-08-25 用户定):加载服务器返回的活动视图 HTML。
* 隔离:iframe + sandbox="allow-scripts" —— 不透明源,禁同源/表单/弹窗,视图只能渲染与跑自己的脚本;
* v1 静态占位页(/sandbox-demo/activity-view.html),src 以后来自接口(活动定义里的视图地址);
* 待议:内容高度 postMessage 自适应(现在固定高 480)、加载失败态(接口 404/超时)。
*/}
{/* 仿浏览器 chrome 条:让人一眼认出「这是嵌进来的外部视图」—— 全页唯一一处隐喻,不多用 */}
{a.id} · 活动视图(沙箱隔离)
{!frameLoaded && (
视图载入中…
)}
);
}
/* ---------------- ② 员工管理 ---------------- */
/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */
function useGovAction(mutate: (input: TInput) => Promise, 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 载入中…;
const { workflow, pkg, instances, tasks } = q.data;
const st = WF_ST[workflow.state];
return (
<>
{WORKFLOW_STATE_LABEL[workflow.state]}
{workflow.packageId} v{workflow.packageVersion}
来源 · {workflow.source.kind} · {workflow.source.ref}
>
}
desc="工作流状态由它上面的节点(任务与审批单)聚合推导,工作流层不另设状态机;重开产生新的一条工作流,旧的已终、不再入算。"
/>
{tasks.map((t) => (
nav.push({ k: 'task', id: t.id })}
/>
))}
{tasks.length === 0 && 暂无任务。}
{instances.map((i) => (
{pkg.nodes.find((n) => n.id === i.nodeId)?.label ?? i.nodeId}
{i.instance > 1 ? ` #${i.instance}` : ''}
{i.terminal !== undefined
? TERMINAL_STATE_LABEL[i.terminal]
: i.running
? '运行中'
: i.waiting
? '等待中'
: i.activated
? '已激活'
: '未激活'}
))}
>
);
}
/* ---------------- 任务:在途监控 · 控制 · 改派 ---------------- */
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('');
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 任务不存在或已不在近期窗口内。;
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,
busy: control.isPending,
ask: {
title: `控制事件 · ${CONTROL_SEMANTIC_LABEL[semantic]}:${task.title}`,
note: 控制事件走统一事件信封(四类事件之控制事件),先持久后投递。,
impact: {
who:
semantic === 'terminate'
? '在途任务终止、待答卡与征答消息作废、审批点撤卡(级联)'
: semantic === 'halt'
? `负责人 ${task.ownerName} —— 挂起并记最重档纠正信号`
: '任务挂起,监听与预算冻结',
undo: semantic === 'terminate' ? '不可恢复' : '可恢复',
when: '确认后即时',
},
okLabel: `确认${CONTROL_SEMANTIC_LABEL[semantic]}`,
onOk: () => control.mutate(semantic),
},
});
return (
<>
{label}
{task.ownerName}
>
}
desc="活动的一次运行 —— 治理不改定义:控制只作用于这一次运行。"
acts={[
controlAct('pause', false),
controlAct('halt', true),
controlAct('terminate', true),
{
label: '恢复',
ico: 'play',
primary: true,
disabled: !suspended,
busy: resume.isPending,
ask: {
title: `恢复任务:${task.title}`,
note: 恢复 = 从挂起点继续;预算与监听解冻,任务记忆只追加、不回滚。,
impact: { who: `负责人 ${task.ownerName}`, undo: '可再次控制', when: '确认后即时' },
okLabel: '确认恢复',
onOk: () => resume.mutate(undefined),
},
},
]}
/>
{`${task.usedSteps}/${task.capSteps} 步`}}>
新负责人
{(staff.data?.staff ?? [])
.filter((s) => s.state === 'active' && s.name !== task.ownerName)
.map((s) => (
))}
reassign.mutate(assignee)}
>
确认改派
nav.push({ k: 'tasklog', id: task.id })}
/>
>
);
}
export function TaskLogPage({ id }: { id: string }) {
const q = useGovTaskLog(id);
const lines = q.data?.log ?? [];
return (
<>
{lines.map((l) => (
{l.title}
{l.at.slice(0, 19).replace('T', ' ')}
{/* 与工作台同口径:章是四字动作;归侧另有度量面在看 */}
{l.action}
))}
{lines.length === 0 &&
暂无留痕。}
>
);
}
/* ---------------- 员工 / 岗位 ---------------- */