Archived
1176 lines
51 KiB
TypeScript
1176 lines
51 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, 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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 员工 / 岗位 ---------------- */
|
||
|