This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/features/gov-task/internal/panel.tsx
T

1176 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <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>
);
}
/**
* 运营中心 · 活动区一级页(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 (
<div className="g-actv">
<div className="g-shelf">
{items.map((a) => (
<div key={a.id} className={'g-fcard' + (a.hue === 'blue' ? '' : ` h-${a.hue}`) + (a.status === 'disabled' ? ' off' : '')}>
{/* 整卡热区 → L2 活动详情(2026-08-25 接) */}
<button className="hit" aria-label={`打开 ${a.name}`} onClick={() => nav.push({ k: 'actd', id: a.id })} />
<div className="hd">
<span className="ico">
<Ico name={a.ico} s={17} />
</span>
<div className="ttl">
{/* 活动名 = 卡上最重要的数据,全部显示不截断(2026-08-25 用户定) */}
<b>{a.name}</b>
<em>
{a.id} · {a.biz}
</em>
</div>
<span className={'pill' + (a.status === 'active' ? ' on' : '')}>
{a.status === 'active' ? '激活中' : '已禁用'}
</span>
</div>
{/* 输入/输出契约不上卡(2026-08-25 用户定):它是 L2 详情的内容,卡上只留识别信息 */}
<div className="plate">
<span>
<b>{a.instances}</b>
<i>实例</i>
</span>
<span>
<b>{a.owner}</b>
<i>负责人</i>
</span>
<span>
<b>{a.createdAt}</b>
<i>创建于</i>
</span>
{/* 版本进底板第四格(2026-08-25 用户定):与实例/负责人/创建并列 */}
<span>
<b>{a.version === undefined ? '—' : `v${a.version}`}</b>
<i>版本</i>
</span>
</div>
</div>
))}
{/*
幽灵卡 = 创建入口 —— 2026-08-25 用户定:设计功能暂缓,先做运营部分,
创建活动的幽灵卡先注释掉;恢复时把这段 JSX 解开即可(样式 .g-fcard.ghost 保留着)
<div className="g-fcard ghost" role="button" aria-disabled="true" title="创建活动 —— 功能即将开放">
<Ico name="plus" s={18} />
创建活动
</div>
*/}
</div>
</div>
);
}
/* ==================== 活动详情(L2,2026-08-25 重构模版) ==================== */
/** 实例状态五态(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰 */
type InstanceState = '进行中' | '待执行' | '已完成' | '驳回' | '失败';
const INSTANCE_ST: Readonly<Record<InstanceState, string>> = {
进行中: '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<Record<InstanceFilterKey, string>> = {
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;
}
/** 轻量二次确认的规格(2026-08-26 用户定:只问一句 + 确认/取消 —— 影响三必答(AskBand)是治理台重度动作的规格,禁用活动/终止实例是轻动作,不摆) */
type PopAskSpec = { title: string; okLabel: string; danger?: boolean; onOk: () => void };
/** 气泡式二次确认 —— 锚在页头右下的小泡泡:一句话问清,确认/取消两钮;点气泡外的透明罩即取消 */
function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) {
return (
<>
<div className="g-popask-veil" onClick={onCancel} />
<div className={'g-popask' + (spec.danger === true ? ' t-danger' : '')} role="alertdialog" aria-label={spec.title}>
<p>{spec.title}</p>
<div className="g-popask-a">
<button type="button" className="g-rowbtn" onClick={onCancel}>
取消
</button>
<button
type="button"
className={'g-rowbtn' + (spec.danger === true ? ' danger' : '')}
onClick={() => {
spec.onOk();
onCancel();
}}
>
{spec.okLabel}
</button>
</div>
</div>
</>
);
}
/**
* L2 · 活动详情 —— 两个问题一屏答:「这个活动是什么」(定义,只读)
* 与「它跑得怎么样」(小统计 + 实例清单,点行进 L3)。
*
* 头部动作三个(2026-08-25 用户定):
* · 激活/禁用 —— 本期做交互,二次确认;**有实例时无法禁用**(规则来自接口,本期前端占位演示);
* · 启动活动(即创建实例)/ 版本迭代 —— 只留入口,功能后做(disabled)。
*
* 沙箱区:加载服务器返回的活动视图 HTML —— iframe + sandbox 隔离;
* v1 静态占位页,src 与内容高度通讯(postMessage)待接口一起定。
*/
export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
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<readonly DemoInstance[] | undefined>(undefined);
const [page, setPage] = useState(0);
/* 检索条件(2026-08-26 用户定):条件下拉 + 随条件换控件;值一变回第一页 */
const [fKey, setFKey] = useState<InstanceFilterKey>('id');
const [fText, setFText] = useState('');
const [fOwner, setFOwner] = useState('');
const [fState, setFState] = useState<InstanceState | ''>('');
const [fFrom, setFFrom] = useState('');
const [fTo, setFTo] = useState('');
/* 轻量二次确认(2026-08-26 用户定):一句话 + 确认/取消的气泡,不走 AskBand 三必答 */
const [ask, setAsk] = useState<PopAskSpec | null>(null);
if (a === undefined) return <Note tone="warn">活动不存在 —— 它可能已被调整,返回活动清单看看。</Note>;
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 (
<div className={'g-actvd' + (a.hue === 'blue' ? '' : ` h-${a.hue}`)}>
<div className="g-pahead">
<PageHead
ico={a.ico}
kicker={`${a.biz} · ${a.id}`}
title={a.name}
chips={
<>
<Chip tone={st === 'active' ? 'ok' : 'mute'}>{st === 'active' ? '激活中' : '已禁用'}</Chip>
{a.version !== undefined && <Chip ico="tag">v{a.version}</Chip>}
{/* 绑定的 LLM 上头版(2026-08-25 用户定):每个活动一个专属 agent */}
<Chip ico="cpu">{a.llm}</Chip>
<Chip ico="user">{a.owner}</Chip>
<Chip ico="calendar-days">创建于 {a.createdAt}</Chip>
</>
}
desc="活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。"
acts={[
st === 'active'
? {
label: '禁用活动',
ico: 'power',
danger: true,
onClick: () =>
setAsk({
title: '确认禁用这个活动?',
okLabel: '确认禁用',
danger: true,
onOk: () => {
/* 规则(2026-08-25 用户定):有实例时接口会提示无法禁用 —— 本期前端先演出这个拦截 */
if (a.instances > 0) {
setBlocked(true);
} else {
setStatus('disabled');
setBlocked(false);
}
},
}),
}
: {
label: '激活活动',
ico: 'zap',
primary: true,
onClick: () =>
setAsk({
title: '确认激活这个活动?',
okLabel: '确认激活',
onOk: () => {
setStatus('active');
setBlocked(false);
},
}),
},
{ label: '启动活动', ico: 'plus', disabled: true },
/* 版本迭代入口先隐藏(2026-08-25 用户定):功能设计回头讨论,要做时把这行加回 acts
{ label: '版本迭代', ico: 'git-branch', disabled: true },
*/
]}
/>
{ask !== null && <PopAsk spec={ask} onCancel={() => setAsk(null)} />}
</div>
{blocked && (
<Note tone="warn">
无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。
</Note>
)}
<Sec ico="repeat" title="历史活动" extra={`共 ${instances.length} 个 · 查看详情进 L3`}>
{/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)——
左:总数 + 五态灰字合计(状态色只进 6px 小圆点);右:条件下拉 + 随条件换控件 */}
<div className="g-ihead">
<div className="g-statline">
<span>
总数 <b>{instances.length}</b>
</span>
{(Object.keys(INSTANCE_ST) as InstanceState[]).map((s) => (
<span key={s}>
<i className={`dot t-${INSTANCE_ST[s]}`} />
{s} <b>{countOf(s)}</b>
</span>
))}
</div>
<div className="g-ifilter">
<Ico name="search" s={13} />
<select
className="g-isel"
value={fKey}
aria-label="检索条件"
onChange={(e) => {
setFKey(e.target.value as InstanceFilterKey);
setPage(0);
}}
>
{(Object.keys(INSTANCE_FILTER_LABEL) as InstanceFilterKey[]).map((k) => (
<option key={k} value={k}>
{INSTANCE_FILTER_LABEL[k]}
</option>
))}
</select>
{fKey === 'id' && (
<input
className="g-iselect-input"
value={fText}
placeholder="按实例号模糊搜,如 INS-2417"
onChange={(e) => {
setFText(e.target.value);
setPage(0);
}}
/>
)}
{fKey === 'owner' && (
<select
className="g-isel"
value={fOwner}
aria-label="选择负责人"
onChange={(e) => {
setFOwner(e.target.value);
setPage(0);
}}
>
<option value="">全部负责人</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)}
{fKey === 'state' && (
<select
className="g-isel"
value={fState}
aria-label="选择状态"
onChange={(e) => {
setFState(e.target.value as InstanceState | '');
setPage(0);
}}
>
<option value="">全部状态</option>
{(Object.keys(INSTANCE_ST) as InstanceState[]).map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
)}
{fKey === 'time' && (
<span className="g-ifilter-range">
<input
type="date"
value={fFrom}
aria-label="起始日期"
onChange={(e) => {
setFFrom(e.target.value);
setPage(0);
}}
/>
<i>–</i>
<input
type="date"
value={fTo}
aria-label="截止日期"
onChange={(e) => {
setFTo(e.target.value);
setPage(0);
}}
/>
</span>
)}
{filtering && (
<>
<span className="g-ifilter-n">
筛出 <b>{filtered.length}</b> / 共 {instances.length} 个
</span>
<button type="button" className="g-rowbtn" onClick={clearFilter}>
清空
</button>
</>
)}
</div>
</div>
{/* 列表式清单(2026-08-26 用户定:卡片撤了改列表)——
行 = 一次实例,列齐好扫;状态色只进 6px 圆点(色盲可读:状态词在点旁边);
行内操作常驻(查看详情是主入口,不藏) */}
<div className="g-itab">
{/* 列序(2026-08-26 用户定):实例 / 负责人 / 输入 / 输出 / 启动时间 / 状态 / 操作(操作栏也有标题) */}
<div className="g-itab-h">
<span>实例</span>
<span>负责人</span>
<span>输入</span>
<span>输出</span>
<span>启动时间</span>
<span>状态</span>
<span>操作</span>
</div>
{pageRows.map((r) => (
<div key={r.id} className={`g-irow t-${INSTANCE_ST[r.state]}`}>
<span className="id g-mono">{r.id}</span>
<span className="ow">{r.owner}</span>
<span className="in" title={r.input}>
{r.input}
</span>
<span className="out g-mono" title={r.output}>
{r.output}
</span>
<span className="at g-mono">{r.startedAt}</span>
{/* key 带状态:状态翻转(终止)时弹出一次高亮 —— 状态变化要看得见 */}
<span key={r.state} className="st">
<i className="dot" />
{r.state}
</span>
<span className="a">
{r.state === '进行中' && (
<button type="button" className="g-rowbtn danger" onClick={() => stopInstance(r.id)}>
终止活动
</button>
)}
{/* L3 实例详情(2026-08-26 接):路由 id 带活动与实例两段,详情页按活动找回上下文 */}
<button type="button" className="g-rowbtn" onClick={() => nav.push({ k: 'acti', id: `${a.id}:${r.id}` })}>
查看详情
</button>
</span>
</div>
))}
{/* 检索无结果:如实摆一句话,不留一张只剩表头的空表 */}
{pageRows.length === 0 && (
<div className="g-irow-empty">
<Ico name="search" s={14} />
没有匹配的实例 —— 换个条件,或清空重看全部。
</div>
)}
</div>
{/* 分页(2026-08-25 用户定:单页最多 8 条)—— 页少不上页码器,翻页两枚就够;计数跟着筛出的集合走 */}
<div className="g-pager">
<span className="g-pager-n">
第 {cur + 1} / {pageCount} 页 · {filtering ? `筛出 ${filtered.length} / 共 ${instances.length} 个` : `共 ${filtered.length} 个`}
</span>
<button type="button" className="g-rowbtn" disabled={cur === 0} onClick={() => setPage(cur - 1)}>
上一页
</button>
<button type="button" className="g-rowbtn" disabled={cur >= pageCount - 1} onClick={() => setPage(cur + 1)}>
下一页
</button>
</div>
</Sec>
{/*
* 沙箱(2026-08-25 用户定):加载服务器返回的活动视图 HTML。
* 隔离:iframe + sandbox="allow-scripts" —— 不透明源,禁同源/表单/弹窗,视图只能渲染与跑自己的脚本;
* v1 静态占位页(/sandbox-demo/activity-view.html),src 以后来自接口(活动定义里的视图地址);
* 待议:内容高度 postMessage 自适应(现在固定高 480)、加载失败态(接口 404/超时)。
*/}
<Sec ico="dashboard" title="活动视图 · 沙箱" extra="服务器返回的 HTML · 隔离渲染">
<div className="g-sandbox">
{/* 仿浏览器 chrome 条:让人一眼认出「这是嵌进来的外部视图」—— 全页唯一一处隐喻,不多用 */}
<div className="g-sandbox-bar">
<span className="dots">
<i />
<i />
<i />
</span>
<span className="addr">
<Ico name="lock" s={10} />
{a.id} · 活动视图(沙箱隔离)
</span>
</div>
{!frameLoaded && (
<div className="g-sandbox-load">
<Ico name="clock" s={14} />
视图载入中…
</div>
)}
<iframe
title={`${a.name} · 活动视图`}
className="g-sandbox-frame"
src="/sandbox-demo/activity-view.html"
sandbox="allow-scripts"
onLoad={() => setFrameLoaded(true)}
/>
</div>
</Sec>
</div>
);
}
/* ==================== 实例详情(L3,2026-08-26) ==================== */
/** 动作过程的一步(占位形状,按将来接口铺):步骤 / 执行方 / 时间 / 用时 / 结果 / 一句说明 */
type DemoStep = {
name: string;
by: 'agent' | 'human';
/** `MM-DD HH:mm`,未到达的步骤为空串 */
at: string;
/** 完成步的用时,如 `6m`;未完成为空串 */
dur: string;
note: string;
res: 'done' | 'fail' | 'current' | 'pending';
};
/**
* 活动一次运行的节点流(占位)—— agent 跑规划与执行,验收这一步永远留给人
* (2026-08-25 用户定的人机分工:AI 不能承担责任,负责人验收规划与统计产出)。
*/
const STEP_FLOW: ReadonlyArray<{ name: string; by: 'agent' | 'human'; dur: string; note: string }> = [
{ name: '输入接收', by: 'agent', dur: '1m', note: '询价单已进入,要素识别完成,实例正式起步。' },
{ name: '要素结构化', by: 'agent', dur: '6m', note: '抽取客户 / 物料 / 交期等字段,落入结构化模型。' },
{ name: '定价测算', by: 'agent', dur: '12m', note: '按成本模型逐项给出建议价与毛利测算。' },
{ name: '负责人验收', by: 'human', dur: '5m', note: '确认测算结果 —— 规划类产出必须由人验收担责。' },
{ name: '输出归档', by: 'agent', dur: '1m', note: '生成报价文件并归档,产出回写台账。' },
];
/** 各步相对启动时间的偏移(分钟,占位)—— 真数据随接口时间戳一起换 */
const STEP_OFFSET = [0, 3, 9, 21, 26] as const;
/** 占位时间 `MM-DD HH:mm`(2026 年)的加分钟格式化 —— 与 instanceTime 配套 */
function fmtMinute(t: number): string {
const d = new Date(t);
const p = (n: number): string => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 步工厂:以启动时间为零点铺某一步(占位)—— note 可覆盖默认说明(如「被手动终止」) */
function mkStep(t0: number, i: number, res: DemoStep['res'], note?: string): DemoStep {
return {
...STEP_FLOW[i]!,
...(note === undefined ? {} : { note }),
res,
at: res === 'pending' ? '' : fmtMinute(t0 + STEP_OFFSET[i]! * 60000),
dur: res === 'done' ? STEP_FLOW[i]!.dur : '',
};
}
/** 按实例状态铺五步的结果(占位演示):进行中走到测算,驳回倒在验收,失败倒在结构化,待执行一步未走 */
function demoSteps(state: InstanceState, startedAt: string): readonly DemoStep[] {
const t0 = instanceTime(startedAt);
const mk = (i: number, res: DemoStep['res']): DemoStep => mkStep(t0, i, res);
switch (state) {
case '已完成':
return [mk(0, 'done'), mk(1, 'done'), mk(2, 'done'), mk(3, 'done'), mk(4, 'done')];
case '进行中':
return [mk(0, 'done'), mk(1, 'done'), mk(2, 'current'), mk(3, 'pending'), mk(4, 'pending')];
case '待执行':
return [mk(0, 'pending'), mk(1, 'pending'), mk(2, 'pending'), mk(3, 'pending'), mk(4, 'pending')];
case '驳回':
return [mk(0, 'done'), mk(1, 'done'), mk(2, 'done'), mk(3, 'fail'), mk(4, 'pending')];
case '失败':
return [mk(0, 'done'), mk(1, 'fail'), mk(2, 'pending'), mk(3, 'pending'), mk(4, 'pending')];
}
}
/** 手动终止的过程面(占位):终止发生在测算步 —— 走完的留痕,当前步记「已终止」,后面不再走 */
function terminatedSteps(startedAt: string): readonly DemoStep[] {
const t0 = instanceTime(startedAt);
return [
mkStep(t0, 0, 'done'),
mkStep(t0, 1, 'done'),
mkStep(t0, 2, 'fail', '定价测算进行中,被负责人手动终止 —— 已算出的中间结果留痕保留。'),
mkStep(t0, 3, 'pending'),
mkStep(t0, 4, 'pending'),
];
}
/** 步结果的色调类 —— 复用全局 t-*(色即状态,色盲可读:结果词在点旁边) */
const STEP_RES_TONE: Readonly<Record<DemoStep['res'], string>> = {
done: 't-ok',
fail: 't-danger',
current: 't-brand',
pending: 't-mute',
};
/**
* L3 · 实例详情 —— 一次运行的完整视图(2026-08-26 规划,用户确认后落地):
* 主从 2:1(DESIGN-SPEC §9)—— 左「动作过程记录」是主角(追溯与验收的落点),
* 右栏输入/输出是辅助。实例不可删除、不可编辑(2026-08-26 用户定)——
* 编辑/删除入口撤掉,唯一的动作是进行中的「终止活动」。
* 数据为静态占位,结构按将来接口的形状铺。
*/
export function ActiPage({ id }: { id: string }) {
const [aid, iid] = id.split(':');
const a = DEMO_ACTIVITIES.find((x) => x.id === aid);
const inst = a !== undefined ? demoInstances(a.owner).find((x) => x.id === iid) : undefined;
/* 状态就地翻转(本期前端演示):终止暂落「失败」(归态接口语义未定),对接后走 mutation 由服务端重投影;
term 记下终止时刻 —— 终止的过程面与「自己跑挂」不同(当前步记「已终止」,不是「附件缺失」那种失败原因) */
const [term, setTerm] = useState<string | undefined>(undefined);
/* 轻量二次确认(2026-08-26 用户定):一句话 + 确认/取消的气泡 */
const [ask, setAsk] = useState<PopAskSpec | null>(null);
if (a === undefined || inst === undefined) {
return <Note tone="warn">实例不存在 —— 它不在该活动的清单里,从面包屑返回活动详情看看。</Note>;
}
const state: InstanceState = term !== undefined ? '失败' : inst.state;
const steps = term !== undefined ? terminatedSteps(inst.startedAt) : demoSteps(state, inst.startedAt);
const doneN = steps.filter((s) => s.res === 'done').length;
/* 结束时间/耗时:占位按「第五步走完」推(27m),真数据随接口一起换 */
const endedAt = fmtMinute(instanceTime(inst.startedAt) + 27 * 60000);
const reason =
term !== undefined
? `已于 ${term} 由 ${inst.owner} 手动终止 —— 终止后归入哪个状态接口语义未定,本期前端落「失败」占位。`
: state === '驳回'
? `验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 ${inst.owner}。`
: state === '失败'
? '要素结构化失败:询价单附件缺失,已通知发起人补齐后重试。'
: undefined;
return (
<div className={'g-actid' + (a.hue === 'blue' ? '' : ` h-${a.hue}`)}>
<div className="g-pahead">
<PageHead
ico={a.ico}
tone={INSTANCE_ST[state]}
kicker={`${a.name} · 一次运行`}
title={inst.id}
chips={
<>
{/* key 带状态:终止后状态丸翻转时弹出一次高亮 —— 变化要看得见 */}
<Chip key={state} tone={INSTANCE_ST[state]} ico={state === '进行中' ? 'play' : state === '已完成' ? 'circle-check' : state === '待执行' ? 'hourglass' : state === '驳回' ? 'circle-x' : 'power'}>
{state}
</Chip>
<Chip ico="user">{inst.owner}</Chip>
<Chip ico="calendar-days">启动 {inst.startedAt}</Chip>
{state === '已完成' && <Chip ico="flag">结束 {endedAt} · 耗时 27m</Chip>}
{state === '进行中' && <Chip ico="timer">已跑 12m</Chip>}
<Chip ico="cpu">{a.llm}</Chip>
</>
}
desc="一次运行的完整视图:输入 / 过程 / 输出 / 状态 —— 实例不可编辑、不可删除,进行中的运行可终止。"
acts={[
/* 唯一动作(2026-08-26 用户定:实例不许删、不许编):进行中可终止,轻气泡二次确认 */
...(state === '进行中'
? [
{
label: '终止活动',
ico: 'power',
danger: true,
onClick: () =>
setAsk({
title: `确认终止实例 ${inst.id}?`,
okLabel: '确认终止',
danger: true,
onOk: () => setTerm(fmtMinute(Date.now())),
}),
} satisfies ActSpec,
]
: []),
]}
/>
{ask !== null && <PopAsk spec={ask} onCancel={() => setAsk(null)} />}
</div>
{reason !== undefined && <Note tone="warn">{reason}</Note>}
{state === '待执行' && <Note>待执行:已排入队列,等待调度 —— 到点或由负责人手动起步。</Note>}
<div className="g-actid-grid">
{/* 主角:动作过程记录 —— 时间线竖排,当前步呼吸高亮,未到达灰显(2026-08-26 用户定:过程是实例页的重点) */}
<Sec ico="list-checks" title="动作过程记录" extra={`${doneN} / ${steps.length} 步`}>
<ol className="g-tl">
{steps.map((s, i) => (
<li key={s.name} className={`g-tl-row ${s.res} ${STEP_RES_TONE[s.res]}`}>
<span className="g-tl-rail" aria-hidden>
<i className="g-tl-dot" />
{i < steps.length - 1 && <i className="g-tl-line" />}
</span>
<div className="g-tl-c">
<div className="g-tl-h">
<b>
{i + 1} · {s.name}
</b>
<span className="g-tl-by">
<Ico name={s.by === 'agent' ? 'cpu' : 'user'} s={11} />
{s.by === 'agent' ? a.llm : `负责人 · ${inst.owner}`}
</span>
{/* 时间 meta 只摆事实(什么时候 / 用了多久),结果交给右侧结果词 —— 不重复说两遍 */}
<span className="g-tl-meta">
{s.res === 'pending' ? '' : s.res === 'current' ? `${s.at} 起` : `${s.at} · 用时 ${s.dur}`}
</span>
<span className="g-tl-res">
{s.res === 'done' ? '完成' : s.res === 'fail' ? (term !== undefined ? '已终止' : state === '驳回' ? '驳回' : '失败') : s.res === 'current' ? '进行中' : '未到达'}
</span>
</div>
<p className="g-tl-note">{s.note}</p>
</div>
</li>
))}
</ol>
</Sec>
{/* 辅栏:输入 / 输出 —— 完整输入与最终产出(2026-08-26 规划);文件下载随接口开放(票据机制已备) */}
<div className="g-actid-rail">
<Sec ico="inbox" title="输入">
<div className="g-kv">
<div className="g-kv-row">
<em>内容</em>
<b>{inst.input}</b>
</div>
<div className="g-kv-row">
<em>来源</em>
<b>邮件 · quotes@bpos.demo</b>
</div>
<div className="g-kv-row">
<em>提交</em>
<b className="g-mono">{inst.startedAt}</b>
</div>
<div className="g-kv-note">文件类输入的原文下载随接口开放(本期占位)。</div>
</div>
</Sec>
<Sec ico="file-text" title="输出">
{inst.output === '—' ? (
<div className="g-file-empty">暂无产出 —— 走到「输出归档」后在这里出现。</div>
) : (
<div className="g-file">
<span className="g-file-i">
<Ico name="file-text" s={15} />
</span>
<span className="g-file-n">
<b>{inst.output}</b>
<em>报价文件 · 输出归档步生成</em>
</span>
<button type="button" className="g-rowbtn" disabled title="随接口开放下载(票据机制已备)">
<Ico name="download" s={12} /> 下载
</button>
</div>
)}
</Sec>
</div>
</div>
</div>
);
}
/* ---------------- ② 员工管理 ---------------- */
/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */
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,
busy: control.isPending,
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,
busy: resume.isPending,
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">
<Btn
className="g-btn primary"
disabled={assignee === '' || !live}
busy={reassign.isPending}
onClick={() => reassign.mutate(assignee)}
>
<Ico name="user-plus" s={13} />
确认改派
</Btn>
</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>
</>
);
}
/* ---------------- 员工 / 岗位 ---------------- */