forked from ChaiHao/BPOS-MVP
M6(2/n): 治理台九功能 + SSE 订阅层 + 读/治分权
治理台按 §9.1 拆成九个 gov-* feature,共用件落 shared/gov:
· 外壳(左轨/顶栏/面包屑/页签/整页下钻)自原型 Governance.tsx 平移,
DOM 与 g-* 类名原样;路由状态即应用状态,区/页签/下钻栈全编码进 hash 可深链
· 编排画布 diagram-js 画布件 1:1 平移(flow-layout/flow-node-paint/FlowDiagram),
新增 pkg-to-graph 把 ProcessPackage 转成画布图模型;统一报价 v1 全图 12 节点 15 边渲染通过
· 后端补四路由:审批单 / 工作流下钻 / 通道实例 / 度量仪表
SSE 订阅层(§9.2):app 层单一连接,按信封目标失效缓存;
组件不再自行轮询(去掉 refetchInterval)。广播只说「哪个目标动了」,不带业务载荷,
且在投递已落库已送达之后才发 —— 不是第二条投递链路。
两处实质修正:
· 治理路由一律要 govern → 读路由改要 read。客体三类 × 动作三种的分法就是为此:
BD 有 task:read 能看在途、动不了控制事件;一律 govern 等于把三种动作压成一种
· 控制事件/恢复/改派原记为 engine-transition(归机器产出)→ 新增 control 条目种类,
归人裁决侧。归侧是按动作类型的纯推导,所以「谁干的」必须体现在 kind 上;
否则人的叫停会被算进自主率的机器分子
判据 ⑩ 治理台一半跑通:在途监控 / 叫停 / 恢复,治理动作留痕可查。
无权查看不再画成空清单,按岗位说明缺哪一项。
gates: tsc / eslint / depcruise(286 模块)/ api-report / 360 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 投递回执广播 —— 前端单一 SSE 连接的服务端一侧(编码规范 §9.2)。
|
||||
*
|
||||
* 它**不是第二条投递链路**:投递五诺仍由 `sys.event_delivery` 承担。
|
||||
* 这里只在投递已落库、已送达**之后**,把「哪个目标动了」这一事实告诉在线的浏览器,
|
||||
* 让前端失效对应缓存、回服务端重取投影 —— 前端零过程状态,推送里不带业务载荷。
|
||||
*/
|
||||
|
||||
/** 动了的目标:与信封 `correlation.target` 同构;`kind: 'system'` 表示治理面的全局变更 */
|
||||
export type Touch = {
|
||||
readonly kind: 'task' | 'workflow' | 'staff' | 'system';
|
||||
readonly id: string;
|
||||
/** 变更缘由,仅用于前端选择失效哪一组键 */
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
export type Broadcaster = {
|
||||
publish(touch: Touch): void;
|
||||
subscribe(listener: (touch: Touch) => void): () => void;
|
||||
readonly size: number;
|
||||
};
|
||||
|
||||
export function createBroadcaster(): Broadcaster {
|
||||
const listeners = new Set<(touch: Touch) => void>();
|
||||
return {
|
||||
publish(touch) {
|
||||
// 广播失败不得反噬业务:任何一个订阅者抛错都只影响它自己
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(touch);
|
||||
} catch {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -66,9 +66,13 @@ import {
|
||||
import type { AppConfig } from './config.ts';
|
||||
import { createIdGenerator, systemClock } from './runtime.ts';
|
||||
import { createSecretBox, loadSecretKey } from './secret-box.ts';
|
||||
import { createBroadcaster } from './broadcast.ts';
|
||||
import type { Broadcaster } from './broadcast.ts';
|
||||
|
||||
export type Container = {
|
||||
readonly config: AppConfig;
|
||||
/** 投递回执广播 —— 前端 SSE 的服务端一侧;不承担投递语义 */
|
||||
readonly broadcaster: Broadcaster;
|
||||
readonly pool: Pool;
|
||||
readonly db: Db;
|
||||
readonly org: OrgService;
|
||||
@@ -496,6 +500,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
|
||||
return {
|
||||
config,
|
||||
broadcaster: createBroadcaster(),
|
||||
pool,
|
||||
db,
|
||||
org,
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 治理台九功能的运行面路由(编码规范 §3)。
|
||||
*
|
||||
* 「治理台九功能**不设**后端 governance 模块:每个功能是对应模块的管理用例 + 路由组装。」
|
||||
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
browseLedgerRoute,
|
||||
controlTaskRoute,
|
||||
govMetricsRoute,
|
||||
govTaskLogRoute,
|
||||
govWorkflowRoute,
|
||||
listActivitiesRoute,
|
||||
listApprovalsRoute,
|
||||
listChannelsRoute,
|
||||
listActivityMemoryRoute,
|
||||
listGovTasksRoute,
|
||||
listLedgerObjectsRoute,
|
||||
listProcessPackagesRoute,
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
publishProcessRoute,
|
||||
reassignTaskRoute,
|
||||
resumeTaskRoute,
|
||||
staffIdSchema,
|
||||
taskIdSchema,
|
||||
toggleTriggerRoute,
|
||||
validateProcessRoute,
|
||||
} from '@contracts';
|
||||
import { NotPublishableError } from '@modules/process';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/**
|
||||
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
||||
*
|
||||
* 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件;
|
||||
* PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。
|
||||
* 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。
|
||||
*/
|
||||
function guard(fn: GovernanceFunction, action: GrantAction) {
|
||||
return async (account: string): Promise<ApiError | undefined> => {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
|
||||
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action);
|
||||
if (ok) return undefined;
|
||||
const need = action === 'govern' ? '治理权' : '查看权';
|
||||
return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` };
|
||||
};
|
||||
}
|
||||
|
||||
const readTask = guard('task', 'read');
|
||||
const governTask = guard('task', 'govern');
|
||||
const readProcess = guard('process', 'read');
|
||||
const governProcess = guard('process', 'govern');
|
||||
const readTrigger = guard('trigger', 'read');
|
||||
const governTrigger = guard('trigger', 'govern');
|
||||
const readActivity = guard('activity', 'read');
|
||||
const readTool = guard('tool', 'read');
|
||||
const readData = guard('data', 'read');
|
||||
const readMemory = guard('memory', 'read');
|
||||
|
||||
/** 拒绝的 HTTP 语义:无权 403,无会话 401 */
|
||||
const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401);
|
||||
|
||||
/* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */
|
||||
|
||||
app.get(listGovTasksRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
||||
const rows = await Promise.all(
|
||||
staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, name: s.name }))),
|
||||
);
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
|
||||
return c.json({
|
||||
tasks: rows.flat().map(({ t, name }) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
activityId: t.activityId,
|
||||
paradigm: t.paradigm,
|
||||
ownerName: nameOf.get(t.ownerId) ?? name,
|
||||
runtime: t.runtime,
|
||||
...(t.terminal === undefined ? {} : { terminal: t.terminal }),
|
||||
...(t.workflowId === undefined ? {} : { workflowId: t.workflowId }),
|
||||
usedSteps: t.used.steps,
|
||||
capSteps: t.caps.steps,
|
||||
startedAt: t.startedAt,
|
||||
})),
|
||||
workflows: workflows.map((w) => ({
|
||||
id: w.id,
|
||||
subject: w.subject,
|
||||
state: w.state,
|
||||
packageId: w.packageId,
|
||||
startedAt: w.startedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
||||
app.post(controlTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success || staff === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const outcome = await container.task.control(
|
||||
taskIdSchema.parse(c.req.param('taskId')),
|
||||
parsed.data.semantic,
|
||||
staff.id,
|
||||
parsed.data.reason,
|
||||
);
|
||||
return c.json({ ok: true, resumable: outcome.resumable });
|
||||
});
|
||||
|
||||
app.post(resumeTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401);
|
||||
const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id);
|
||||
return c.json({ ok: true, runtime: task.runtime });
|
||||
});
|
||||
|
||||
app.post(reassignTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success || staff === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const task = await container.task.reassign(
|
||||
taskIdSchema.parse(c.req.param('taskId')),
|
||||
staffIdSchema.parse(parsed.data.to),
|
||||
staff.id,
|
||||
);
|
||||
return c.json({ ok: true, ownerId: task.ownerId });
|
||||
});
|
||||
|
||||
app.get(govTaskLogRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
||||
return c.json({
|
||||
log: lines.map((l) => ({ id: l.id, at: l.at, sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], title: l.title })),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 审批单 —— 审批点的产物,**不是任务**:没有预算、没有任务日志,只有批不批。
|
||||
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
||||
*/
|
||||
app.get(listApprovalsRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
const perWorkflow = await Promise.all(
|
||||
workflows.map(async (w) => {
|
||||
const pkg = await container.process.getPackage(w.packageId, w.packageVersion);
|
||||
if (pkg === undefined) return [];
|
||||
const instances = await container.process.instancesOf(w.id);
|
||||
const rows = await Promise.all(
|
||||
instances
|
||||
.filter((i) => i.activated && pkg.nodes.find((n) => n.id === i.nodeId)?.kind === 'approval')
|
||||
.map(async (i) => {
|
||||
const node = pkg.nodes.find((n) => n.id === i.nodeId);
|
||||
const position = node?.kind === 'approval' ? node.approver.position : '';
|
||||
const holders = position === '' ? [] : await container.org.staffOfPosition(position);
|
||||
return {
|
||||
id: `${w.id}:${i.nodeId}#${i.instance}`,
|
||||
workflowId: w.id,
|
||||
workflowSubject: w.subject,
|
||||
title: `审批:${node?.label ?? i.nodeId}`,
|
||||
approverName: holders[0]?.name ?? '—',
|
||||
approverPosition: position,
|
||||
// 终局唯放行:released = 通过;其余终态 = 退回;未终态 = 待审批
|
||||
state:
|
||||
i.terminal === undefined
|
||||
? ('pending' as const)
|
||||
: i.terminal === 'released'
|
||||
? ('passed' as const)
|
||||
: ('rejected' as const),
|
||||
issuedAt: w.startedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return rows;
|
||||
}),
|
||||
);
|
||||
return c.json({ approvals: perWorkflow.flat() });
|
||||
});
|
||||
|
||||
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
||||
app.get(govWorkflowRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const workflow = await container.process.getWorkflow(c.req.param('id'));
|
||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||
if (pkg === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: `流程包缺失:${workflow.packageId}` };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
const instances = await container.process.instancesOf(workflow.id);
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nested = await Promise.all(
|
||||
staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, owner: s.name }))),
|
||||
);
|
||||
|
||||
return c.json({
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
subject: workflow.subject,
|
||||
state: workflow.state,
|
||||
packageId: workflow.packageId,
|
||||
packageVersion: workflow.packageVersion,
|
||||
source: workflow.source,
|
||||
startedAt: workflow.startedAt,
|
||||
...(workflow.finishedAt === undefined ? {} : { finishedAt: workflow.finishedAt }),
|
||||
},
|
||||
pkg,
|
||||
instances: instances.map((i) => ({
|
||||
nodeId: i.nodeId,
|
||||
instance: i.instance,
|
||||
running: i.running,
|
||||
waiting: i.waiting,
|
||||
activated: i.activated,
|
||||
...(i.terminal === undefined ? {} : { terminal: i.terminal }),
|
||||
})),
|
||||
tasks: nested
|
||||
.flat()
|
||||
.filter(({ t }) => t.workflowId === workflow.id)
|
||||
.map(({ t, owner }) => ({ id: t.id, nodeId: t.nodeId ?? '', title: t.title, ownerName: owner })),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listProcessPackagesRoute.path, async (c) => {
|
||||
const denied = await readProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ packages: await container.process.listPackages() });
|
||||
});
|
||||
|
||||
/** 画图即校验 —— 与前端画布同源 */
|
||||
app.post(validateProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = validateProcessRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json(await container.process.validate(parsed.data.pkg));
|
||||
});
|
||||
|
||||
/** 不闭合不得发布 */
|
||||
app.post(publishProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
try {
|
||||
return c.json(await container.process.publish(c.req.param('id'), Number(c.req.param('version'))));
|
||||
} catch (error) {
|
||||
if (error instanceof NotPublishableError) {
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 触发器配置 ──────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listTriggersRoute.path, async (c) => {
|
||||
const denied = await readTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ triggers: await container.trigger.list() });
|
||||
});
|
||||
|
||||
app.post(toggleTriggerRoute.path, async (c) => {
|
||||
const denied = await governTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = toggleTriggerRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
// 触发器的增删启停不改流程包的任何版本
|
||||
await container.trigger.setEnabled(c.req.param('id'), parsed.data.enabled);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ── 活动库管理:两维分类 × 两条版本序列 ─────────────────────────── */
|
||||
|
||||
app.get(listActivitiesRoute.path, async (c) => {
|
||||
const denied = await readActivity(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const cards = await container.registry.list();
|
||||
return c.json({
|
||||
activities: cards.map((card) => ({
|
||||
activityId: card.activityId,
|
||||
name: card.name,
|
||||
category: card.category,
|
||||
paradigm: card.paradigm,
|
||||
enabled: card.enabled,
|
||||
packageVersions: card.packageVersions.map((p) => ({
|
||||
version: p.version,
|
||||
state: p.state,
|
||||
installedAt: p.installedAt,
|
||||
})),
|
||||
assetVersions: card.assetVersions.map((a) => ({
|
||||
kind: a.kind,
|
||||
version: a.version,
|
||||
effective: a.effective,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 工具管理 ────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listToolsRoute.path, async (c) => {
|
||||
const denied = await readTool(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const tools = await container.toolbox.list();
|
||||
return c.json({
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
version: t.version,
|
||||
description: t.description,
|
||||
effect: t.effect,
|
||||
pure: t.pure === true,
|
||||
// 凭证隔离:只回键,永不回值(ADR-009)
|
||||
connectionKeys: [...t.connectionKeys],
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
||||
|
||||
app.get(listLedgerObjectsRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ objects: await container.ledger.listObjects() });
|
||||
});
|
||||
|
||||
app.get(browseLedgerRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const address = { domain: c.req.param('domain'), object: c.req.param('object') };
|
||||
const described = await container.ledger.describe(address);
|
||||
if (described === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: `台账对象未物化:${address.domain}.${address.object}` };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
// 记录浏览只读正式版 —— 跨任务只见过门的事实
|
||||
const page = await container.ledger.query({ address, where: {}, orderBy: [], limit: 50, offset: 0 });
|
||||
return c.json({
|
||||
columns: described.columns.map((col) => ({ name: col.name, type: col.type })),
|
||||
primaryKey: [...described.primaryKey],
|
||||
rows: page.rows,
|
||||
total: page.total,
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 记忆管理:活动记忆四层 ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 度量仪表 —— 自主率与纠正率**成对读**,复合为有效自主率。
|
||||
* 只读近期任务的任务记忆聚合;度量本身不载权力。
|
||||
*/
|
||||
app.get(govMetricsRoute.path, async (c) => {
|
||||
const denied = await readMemory(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nested = await Promise.all(staff.map((s) => container.task.listByOwner(s.id)));
|
||||
const taskIds = nested.flat().map((t) => t.id);
|
||||
const metrics = await container.memory.metrics({
|
||||
taskIds,
|
||||
submissions: 0,
|
||||
corrections: 0,
|
||||
taskCount: taskIds.length,
|
||||
deterministicJudgements: 0,
|
||||
totalJudgements: 0,
|
||||
});
|
||||
return c.json({ ...metrics, taskCount: taskIds.length });
|
||||
});
|
||||
|
||||
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
|
||||
|
||||
app.get(listChannelsRoute.path, async (c) => {
|
||||
const denied = await readTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const channels = await container.channel.list();
|
||||
return c.json({
|
||||
channels: channels.map((ch) => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
kind: ch.kind,
|
||||
address: ch.address,
|
||||
enabled: ch.enabled,
|
||||
extractCount: ch.extract.length,
|
||||
mappingCount: ch.mapping.length,
|
||||
...(ch.fallbackPosition === undefined ? {} : { fallbackPosition: ch.fallbackPosition }),
|
||||
addressWindowMs: ch.addressWindowMs,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.get(listActivityMemoryRoute.path, async (c) => {
|
||||
const denied = await readMemory(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 });
|
||||
return c.json({
|
||||
entries: entries.map((e) => ({
|
||||
id: e.id,
|
||||
layer: e.layer,
|
||||
statement: e.statement,
|
||||
evidenceCount: e.evidence.length,
|
||||
...(e.increment === undefined ? {} : { incrementState: e.increment.state }),
|
||||
createdAt: e.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* SSE:前端唯一一条订阅连接(编码规范 §9.2)。
|
||||
*
|
||||
* 「SSE 事件到达 → 按信封目标失效对应 TanStack Query 缓存,由订阅层统一做;
|
||||
* 组件禁止自行轮询或手写缓存。」
|
||||
* 推送里**不带业务载荷**——只说「哪个目标动了」,画面仍由服务端投影重建。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import type { Broadcaster } from '../broadcast.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerStreamRoute(app: Hono<AppEnv>, broadcaster: Broadcaster): void {
|
||||
app.get('/api/stream', (c) =>
|
||||
streamSSE(c, async (stream) => {
|
||||
const queue: string[] = [];
|
||||
let wake: (() => void) | undefined;
|
||||
|
||||
const unsubscribe = broadcaster.subscribe((touch) => {
|
||||
queue.push(JSON.stringify(touch));
|
||||
wake?.();
|
||||
});
|
||||
stream.onAbort(unsubscribe);
|
||||
|
||||
await stream.writeSSE({ event: 'ready', data: '1' });
|
||||
|
||||
// 心跳兼作断线探测:客户端 EventSource 自带重连,重连后前端整体失效一次
|
||||
const heartbeat = setInterval(() => {
|
||||
queue.push('');
|
||||
wake?.();
|
||||
}, 20_000);
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (queue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = undefined;
|
||||
continue;
|
||||
}
|
||||
const next = queue.shift()!;
|
||||
if (next === '') await stream.writeSSE({ event: 'ping', data: '1' });
|
||||
else await stream.writeSSE({ event: 'touch', data: next });
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
|
||||
import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { registerDeskRoutes } from './routes/desk.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
import { registerStreamRoute } from './routes/stream.ts';
|
||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
|
||||
export type { AppEnv };
|
||||
@@ -42,7 +44,9 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerDeskRoutes(app, container);
|
||||
registerGovernanceOpsRoutes(app, container);
|
||||
registerIngestRoutes(app, container);
|
||||
registerStreamRoute(app, container.broadcaster);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
||||
await container.db.execute(
|
||||
sql`update sys.event_delivery set state = 'delivered' where id = ${delivery.id}`,
|
||||
);
|
||||
// 已落库、已送达之后才广播 —— 广播不是投递,只是告诉在线浏览器「这个目标动了」
|
||||
container.broadcaster.publish({
|
||||
kind: delivery.target.kind === 'message-position' ? 'staff' : 'task',
|
||||
id: String(delivery.target.id),
|
||||
reason: delivery.target.kind,
|
||||
});
|
||||
handled += 1;
|
||||
} catch (error) {
|
||||
await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -187,3 +187,20 @@ describe('活动记忆 —— 三件套与不载权力', () => {
|
||||
expect(selectForLoading(rows, { limit: 10 })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('归侧:治理干预', () => {
|
||||
it('控制事件归人裁决侧 —— 人的叫停不得记成机器产出', () => {
|
||||
// 归侧是纯推导(按动作类型),所以「谁干的」必须体现在 kind 上,而不是靠调用方传侧。
|
||||
expect(deriveSide('control')).toBe('human-verdict');
|
||||
expect(deriveSide('engine-transition')).toBe('machine');
|
||||
});
|
||||
|
||||
it('治理干预进结构化日志骨架,模板与引擎自转可分辨', () => {
|
||||
const lines = structuredLog([
|
||||
entry('control', { seq: 1, title: '控制事件:halt' }),
|
||||
entry('engine-transition', { seq: 2, title: '步 1 完成' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['治理:控制事件:halt', '迁移:步 1 完成']);
|
||||
expect(lines.map((l) => l.sideLabel)).toEqual(['人裁决', '机器产出']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
|
||||
'release-verdict',
|
||||
'terminal',
|
||||
'human-input',
|
||||
'control',
|
||||
]);
|
||||
|
||||
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
||||
@@ -49,6 +50,7 @@ const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
||||
'release-verdict': (e) => `放行裁决:${e.title}`,
|
||||
terminal: (e) => `终态:${e.title}`,
|
||||
'human-input': (e) => `留言:${e.title}`,
|
||||
control: (e) => `治理:${e.title}`,
|
||||
};
|
||||
|
||||
/** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */
|
||||
|
||||
@@ -21,6 +21,8 @@ export const ENTRY_KINDS = [
|
||||
'release-verdict',
|
||||
'terminal',
|
||||
'human-input',
|
||||
/** 治理干预:控制事件 / 恢复 / 改派 —— 人对过程的裁断,不是引擎自转 */
|
||||
'control',
|
||||
] as const;
|
||||
export type EntryKind = (typeof ENTRY_KINDS)[number];
|
||||
|
||||
@@ -76,6 +78,8 @@ export function deriveSide(kind: EntryKind): AttributionSide {
|
||||
case 'card-reply':
|
||||
case 'human-input':
|
||||
return 'human';
|
||||
// 治理干预由人发起:归人裁决侧,否则自主率会把人的叫停算成机器产出
|
||||
case 'control':
|
||||
case 'release-verdict':
|
||||
case 'terminal':
|
||||
return 'human-verdict';
|
||||
|
||||
@@ -172,7 +172,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
await deps.events.publishControl(taskId, semantic, by, reason);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
kind: 'control',
|
||||
title: `控制事件:${semantic}`,
|
||||
detail: { by, reason, correctionSignal: outcome.kind === 'halted' ? 'heaviest' : undefined },
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
async resume(taskId, by) {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
await deps.memory.append({ taskId, kind: 'engine-transition', title: '恢复', detail: { by } });
|
||||
await deps.memory.append({ taskId, kind: 'control', title: '恢复', detail: { by } });
|
||||
const updated = await deps.store.update(taskId, { runtime: 'running' });
|
||||
await deps.interaction.setPhase(taskId, 'active');
|
||||
return updated;
|
||||
@@ -202,7 +202,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
const updated = await deps.store.update(taskId, { ownerId: to });
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
kind: 'control',
|
||||
title: `改派:${task.ownerId} → ${to}`,
|
||||
detail: { by },
|
||||
});
|
||||
|
||||
@@ -48,7 +48,8 @@ export type ParadigmEngine = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'terminal' | 'observation';
|
||||
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"diagram-js": "^15.23.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 治理台外壳 —— 自原型 `Governance.tsx` 第 389–514 行平移。
|
||||
*
|
||||
* 左轨(九功能)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。
|
||||
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
|
||||
*
|
||||
* §9.2「路由状态即应用状态」:当前区 / 页签 / 下钻栈全部编码进 URL 的 hash,
|
||||
* 刷新与深链都能完整还原;前端零过程状态。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { TABS, ZM, ZONES, routeLabel } from '@web-shared/gov/gov-route.ts';
|
||||
import type { GovZone, Nav, Route } from '@web-shared/gov/gov-route.ts';
|
||||
import { useGovStaff, useGovTasks } from '@web-shared/gov/gov-api.ts';
|
||||
import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task';
|
||||
import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff';
|
||||
import { EdgesPage, PkgPage, WorkflowsHome } from '@features/gov-process';
|
||||
import { TriggerPage, TriggersHome } from '@features/gov-trigger';
|
||||
import { ActPage, LibraryHome } from '@features/gov-activity';
|
||||
import { ToolPage, ToolsHome } from '@features/gov-tool';
|
||||
import { DataHome, ObjPage } from '@features/gov-data';
|
||||
import { MemPage, MemoryHome } from '@features/gov-memory';
|
||||
import { ChanPage, LlmPage, SystemHome } from '@features/gov-system';
|
||||
|
||||
/* ── URL 编解码:`#/gov/<zone>/<tab>[/<k>:<id>…]` ──────────────────────── */
|
||||
|
||||
type Place = { zone: GovZone; tab: string; stack: Route[] };
|
||||
|
||||
function readPlace(): Place {
|
||||
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||
const parts = raw.split('/').filter((s) => s !== '');
|
||||
const zone = (ZONES.find((z) => z.key === parts[1])?.key ?? 'tasks') as GovZone;
|
||||
const tabs = TABS[zone];
|
||||
const tab = tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]!.key;
|
||||
const stack: Route[] = [];
|
||||
for (const seg of parts.slice(3)) {
|
||||
const at = seg.indexOf(':');
|
||||
if (at <= 0) continue;
|
||||
stack.push({ k: seg.slice(0, at), id: decodeURIComponent(seg.slice(at + 1)) } as Route);
|
||||
}
|
||||
return { zone, tab, stack };
|
||||
}
|
||||
|
||||
const writePlace = (p: Place): string =>
|
||||
['#', 'gov', p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/');
|
||||
|
||||
export function GovShell() {
|
||||
const [place, setPlace] = useState<Place>(readPlace);
|
||||
const staff = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
|
||||
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
|
||||
useEffect(() => {
|
||||
const onHash = () => setPlace(readPlace());
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
|
||||
const go = useCallback((next: Place) => {
|
||||
window.location.hash = writePlace(next);
|
||||
setPlace(next);
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(r: Route) => go({ ...readPlace(), stack: [...readPlace().stack, r] }),
|
||||
[go],
|
||||
);
|
||||
const back = useCallback(() => {
|
||||
const p = readPlace();
|
||||
go({ ...p, stack: p.stack.slice(0, -1) });
|
||||
}, [go]);
|
||||
const goZone = useCallback((z: GovZone) => go({ zone: z, tab: TABS[z][0]!.key, stack: [] }), [go]);
|
||||
const jump = useCallback((z: GovZone, tab: string, r?: Route) => go({ zone: z, tab, stack: r ? [r] : [] }), [go]);
|
||||
const nav: Nav = { push, jump };
|
||||
|
||||
// Esc 退一级 —— 与面包屑同义
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') back();
|
||||
};
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [back]);
|
||||
|
||||
const { zone, tab, stack } = place;
|
||||
const meta = ZM[zone];
|
||||
const top = stack[stack.length - 1];
|
||||
const me = staff.data?.staff.find((s) => s.account === 'fengbing');
|
||||
|
||||
/** 告警计数:异常工作流 + 非放行终态的任务 */
|
||||
const alerts =
|
||||
(tasks.data?.workflows ?? []).filter((w) => w.state === 'abnormal').length +
|
||||
(tasks.data?.tasks ?? []).filter((t) => t.terminal !== undefined && t.terminal !== 'released').length;
|
||||
|
||||
/* 面包屑:治理台 › 区 › 页签 › 下钻各级 */
|
||||
const crumbs: Array<{ label: string; go?: () => void }> = [{ label: '治理台', go: () => goZone(zone) }];
|
||||
crumbs.push({ label: meta.title, go: () => go({ zone, tab, stack: [] }) });
|
||||
crumbs.push({ label: TABS[zone].find((t) => t.key === tab)?.label ?? '', go: () => go({ zone, tab, stack: [] }) });
|
||||
stack.forEach((r, i) =>
|
||||
crumbs.push({
|
||||
label: routeLabel(r),
|
||||
...(i === stack.length - 1 ? {} : { go: () => go({ zone, tab, stack: stack.slice(0, i + 1) }) }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="g-shell gov-root">
|
||||
{/* ===== 深色左轨 ===== */}
|
||||
<nav className="g-nav">
|
||||
<div className="g-nav-mark">
|
||||
<Ico name="shield-alert" s={20} />
|
||||
</div>
|
||||
<div className="g-nav-brand">治理台</div>
|
||||
{ZONES.map((z) => (
|
||||
<button key={z.key} className={'g-nav-b' + (zone === z.key ? ' on' : '')} onClick={() => goZone(z.key)}>
|
||||
<span className="g-nav-ico">
|
||||
<Ico name={z.icon} s={18} />
|
||||
</span>
|
||||
<span>{z.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="g-nav-foot">
|
||||
<span className="g-nav-ava">{(me?.name ?? '—').slice(0, 1)}</span>
|
||||
<span>{me?.name ?? '未登录'}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="g-body">
|
||||
{/* ===== 深色顶栏 ===== */}
|
||||
<header className="g-top">
|
||||
<span className="g-top-brand">
|
||||
JCAI 操作系统<em>单组织 · ≤50 人 · 生产环境</em>
|
||||
</span>
|
||||
<label className="g-search">
|
||||
<Ico name="search" s={14} />
|
||||
<input placeholder="跨对象检索:工作流 / 任务 / 活动 / 工具 / 触发器 / 对象…" />
|
||||
<kbd>⌘K</kbd>
|
||||
</label>
|
||||
<div className="g-top-r">
|
||||
<button className={'g-tbtn' + (alerts ? ' alert' : '')} onClick={() => jump('tasks', 'todo')}>
|
||||
<Ico name="alert-triangle" s={13} />
|
||||
告警<b>{alerts}</b>
|
||||
</button>
|
||||
<a className="g-tbtn" href="/">
|
||||
<Ico name="list-checks" s={13} />
|
||||
工作台
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ===== 工作面 ===== */}
|
||||
<div className="g-page">
|
||||
<div className="g-crumb">
|
||||
{stack.length > 0 && (
|
||||
<button className="g-back" onClick={back} title="返回上一级(Esc)">
|
||||
<Ico name="arrow-left" s={15} />
|
||||
</button>
|
||||
)}
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} className="g-crumb-i">
|
||||
{i > 0 && <i className="sep">›</i>}
|
||||
{c.go ? <button onClick={c.go}>{c.label}</button> : <b>{c.label}</b>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{top === undefined ? (
|
||||
<>
|
||||
<div className="g-head">
|
||||
<span className="g-head-i" style={{ background: meta.accent }}>
|
||||
<Ico name={meta.icon} s={19} />
|
||||
</span>
|
||||
<div className="g-head-t">
|
||||
<h1>{meta.title}</h1>
|
||||
<p>{meta.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="g-tabs">
|
||||
{TABS[zone].map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={'g-tab' + (tab === t.key ? ' on' : '')}
|
||||
onClick={() => go({ zone, tab: t.key, stack: [] })}
|
||||
>
|
||||
<Ico name={t.ico} s={13} />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ZoneHome zone={zone} tab={tab} nav={nav} />
|
||||
</>
|
||||
) : (
|
||||
<div className="g-detail" key={`${stack.length}:${top.k}:${top.id}`}>
|
||||
<Detail route={top} nav={nav} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) {
|
||||
const P = {
|
||||
tasks: TasksHome,
|
||||
people: PeopleHome,
|
||||
workflows: WorkflowsHome,
|
||||
triggers: TriggersHome,
|
||||
library: LibraryHome,
|
||||
tools: ToolsHome,
|
||||
data: DataHome,
|
||||
memory: MemoryHome,
|
||||
system: SystemHome,
|
||||
}[zone];
|
||||
return <P tab={tab} nav={nav} />;
|
||||
}
|
||||
|
||||
function Detail({ route, nav }: { route: Route; nav: Nav }) {
|
||||
switch (route.k) {
|
||||
case 'wf':
|
||||
case 'apr':
|
||||
return <WfPage id={route.id} nav={nav} />;
|
||||
case 'task':
|
||||
return <TaskPage id={route.id} nav={nav} />;
|
||||
case 'tasklog':
|
||||
return <TaskLogPage id={route.id} />;
|
||||
case 'emp':
|
||||
return <EmpPage id={route.id} nav={nav} />;
|
||||
case 'role':
|
||||
return <RolePage id={route.id} />;
|
||||
case 'pkg':
|
||||
return <PkgPage id={route.id} nav={nav} />;
|
||||
case 'edges':
|
||||
return <EdgesPage id={route.id} />;
|
||||
case 'trigger':
|
||||
return <TriggerPage id={route.id} />;
|
||||
case 'act':
|
||||
return <ActPage id={route.id} nav={nav} />;
|
||||
case 'tool':
|
||||
return <ToolPage id={route.id} />;
|
||||
case 'obj':
|
||||
return <ObjPage id={route.id} />;
|
||||
case 'mem':
|
||||
return <MemPage id={route.id} />;
|
||||
case 'llm':
|
||||
return <LlmPage id={route.id} />;
|
||||
case 'chan':
|
||||
return <ChanPage id={route.id} />;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* apps/web 唯一组装层(编码规范 §9):Provider、鉴权壳、SSE 连接。
|
||||
* 样式整文件搬自两台原型:设计令牌 + 全部 `jc-*` 组件样式 + vendor 皮肤。
|
||||
*/
|
||||
import { StrictMode } from 'react';
|
||||
import { StrictMode, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import './styles/proto.vendor.css';
|
||||
@@ -10,18 +10,31 @@ import './styles/app.css';
|
||||
import './styles/theme.css';
|
||||
import './styles/console.css';
|
||||
import { AppShell } from './app-shell.tsx';
|
||||
import { connectStream } from './subscribe.ts';
|
||||
import { GovShell } from './gov-shell.tsx';
|
||||
import { queryClient } from './query.ts';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('#root 缺失');
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
/**
|
||||
* 两台面共一个 SPA:路径决定进哪台。
|
||||
* 路由状态即应用状态 —— 可深链(§9.2);TanStack Router 的类型化 search params 随后续接入。
|
||||
*/
|
||||
function Root() {
|
||||
// 单一 SSE 连接在组装层建立;feature 只管订阅缓存键,不自建连接、不轮询
|
||||
useEffect(() => connectStream(queryClient), []);
|
||||
const governance = window.location.pathname.startsWith('/gov') || window.location.hash.startsWith('#/gov');
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* .proto-root 是设计令牌的作用域(原型 app.css 的 `.proto-root .app-shell`) */}
|
||||
<div className="proto-root">
|
||||
<AppShell />
|
||||
</div>
|
||||
<div className="proto-root">{governance ? <GovShell /> : <AppShell />}</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<Root />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* SSE 订阅层 —— apps/web 唯一一条服务端推送连接(编码规范 §9.2)。
|
||||
*
|
||||
* 「SSE 事件到达 → **按信封目标**失效对应 TanStack Query 缓存,由订阅层统一做;
|
||||
* 组件禁止自行轮询或手写缓存。」
|
||||
*
|
||||
* 推送不带业务载荷:它只说「哪个目标动了」,画面照旧由服务端投影重建 —— 前端零过程状态。
|
||||
* 断线由 `EventSource` 自行重连;重连后整体失效一次,补上断线期间错过的变更。
|
||||
*/
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Touch = { kind: 'task' | 'workflow' | 'staff' | 'system'; id: string; reason: string };
|
||||
|
||||
export function connectStream(queryClient: QueryClient): () => void {
|
||||
const source = new EventSource('/api/stream');
|
||||
|
||||
source.addEventListener('touch', (event) => {
|
||||
const touch = JSON.parse((event as MessageEvent<string>).data) as Touch;
|
||||
switch (touch.kind) {
|
||||
case 'task':
|
||||
// 工作台三处 + 治理台任务面都以任务为轴,一起失效
|
||||
void queryClient.invalidateQueries({ queryKey: ['task', touch.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'tasks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'tasklog', touch.id] });
|
||||
break;
|
||||
case 'workflow':
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'workflow', touch.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'tasks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'approvals'] });
|
||||
break;
|
||||
case 'staff':
|
||||
void queryClient.invalidateQueries({ queryKey: ['messages'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'staff'] });
|
||||
break;
|
||||
case 'system':
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov'] });
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 重连即补课:断线期间的推送收不到,回来先整体失效一次
|
||||
source.addEventListener('open', () => {
|
||||
void queryClient.invalidateQueries();
|
||||
});
|
||||
|
||||
return () => source.close();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-activity` 唯一出口(编码规范 §9.1)。
|
||||
* 活动库:两维分类 × 包版本 × 资产版本
|
||||
*/
|
||||
export { LibraryHome, ActPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* feature `gov-activity` —— 活动库:两维分类 × 包版本 × 资产版本
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { PARA } from '@web-shared/gov/tone.ts';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovActivities,
|
||||
useGovMemory,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function LibraryHome({ nav }: HomeProps) {
|
||||
const q = useGovActivities();
|
||||
const activities = q.data?.activities ?? [];
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
活动是任务的<b>定义</b>:编排引用一个活动,运行时就长出一个任务。业务分类 × 活动类型两维平级;
|
||||
每活动两条版本序列——包版本走安装两审,资产版本走进化管线。
|
||||
</Note>
|
||||
<NList>
|
||||
{activities.map((a) => {
|
||||
const effective = a.assetVersions.filter((v) => v.effective);
|
||||
return (
|
||||
<NRow
|
||||
key={a.activityId}
|
||||
tone={PARA[a.paradigm].tone}
|
||||
ico={PARA[a.paradigm].ico}
|
||||
title={a.name}
|
||||
sub={`${a.activityId} · ${a.category}`}
|
||||
cols={[
|
||||
{ k: '类型', v: PARA[a.paradigm].label },
|
||||
{ k: '包版本', v: <span className="g-mono">{a.packageVersions.map((p) => p.version).join(', ')}</span> },
|
||||
{ k: '资产生效版', v: <span className="g-mono">{effective.length}</span> },
|
||||
]}
|
||||
right={
|
||||
<St tone={a.enabled ? 'ok' : 'mute'} ico={a.enabled ? 'circle-check' : 'power'}>
|
||||
{a.enabled ? '已启用' : '已停用'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'act', id: a.activityId })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NList>
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- ⑥ 工具管理 ---------------- */
|
||||
|
||||
export function ActPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
const q = useGovActivities();
|
||||
const memory = useGovMemory(id);
|
||||
const a = (q.data?.activities ?? []).find((x) => x.activityId === id);
|
||||
if (a === undefined) return <Note>活动不存在。</Note>;
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico={PARA[a.paradigm].ico}
|
||||
tone={PARA[a.paradigm].tone}
|
||||
kicker={`活动 · ${a.activityId}`}
|
||||
title={a.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip tone={PARA[a.paradigm].tone}>{PARA[a.paradigm].label}</Chip>
|
||||
<Chip ico="tag">{a.category}</Chip>
|
||||
<Chip tone={a.enabled ? 'ok' : 'mute'}>{a.enabled ? '已启用' : '已停用'}</Chip>
|
||||
</>
|
||||
}
|
||||
desc={PARA[a.paradigm].full + ' —— 活动是系统外开发、经此安装的外装件。'}
|
||||
/>
|
||||
<Card ico="package" title="包版本 · 安装两审">
|
||||
<div className="g-rows">
|
||||
{a.packageVersions.map((p) => (
|
||||
<div key={p.version}>
|
||||
<em>v{p.version}</em>
|
||||
<b className="mono">
|
||||
{p.state} · {p.installedAt.slice(0, 16).replace('T', ' ')}
|
||||
</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card ico="trending-up" title="资产版本 · 进化管线">
|
||||
<div className="g-rows">
|
||||
{a.assetVersions.map((v, i) => (
|
||||
<div key={i}>
|
||||
<em>
|
||||
{v.kind} v{v.version}
|
||||
</em>
|
||||
<b className="mono">{v.effective ? '生效中' : '历史'}</b>
|
||||
</div>
|
||||
))}
|
||||
{a.assetVersions.length === 0 && <Clear>尚无资产版本。</Clear>}
|
||||
</div>
|
||||
</Card>
|
||||
<Entry
|
||||
ico="layers"
|
||||
title="活动记忆"
|
||||
desc={`四层条目 · 当前 ${(memory.data?.entries ?? []).length} 条`}
|
||||
onClick={() => nav.jump('memory', 'layers')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-data` 唯一出口(编码规范 §9.1)。
|
||||
* 数据管理:域 / 对象目录、schema 视图、记录浏览
|
||||
*/
|
||||
export { DataHome, ObjPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* feature `gov-data` —— 数据管理:域 / 对象目录、schema 视图、记录浏览
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovLedger,
|
||||
useGovObjects,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function DataHome({ nav }: HomeProps) {
|
||||
const q = useGovObjects();
|
||||
const objects = q.data?.objects ?? [];
|
||||
const domains = [...new Set(objects.map((o) => o.domain))];
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
数据<b>不设箱、靠域</b>:域是承载的划分,对象由活动声明物化——最大化合并、只增不破坏。
|
||||
数据可以自生长,权力不可以自生长。
|
||||
</Note>
|
||||
{domains.map((d) => (
|
||||
<div key={d} className="g-sec">
|
||||
<div className="g-band">
|
||||
<Ico name="database" s={13} />
|
||||
{d}
|
||||
<span className="x">{objects.filter((o) => o.domain === d).length} 个对象</span>
|
||||
</div>
|
||||
<NList>
|
||||
{objects
|
||||
.filter((o) => o.domain === d)
|
||||
.map((o) => (
|
||||
<NRow
|
||||
key={`${o.domain}.${o.object}`}
|
||||
tone="brand"
|
||||
ico="table"
|
||||
title={o.object}
|
||||
sub={`${o.domain}.${o.object}`}
|
||||
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
</div>
|
||||
))}
|
||||
{objects.length === 0 && <Clear>尚无物化对象——活动首次写入时按声明物化。</Clear>}
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- ⑧ 记忆管理 ---------------- */
|
||||
|
||||
export function ObjPage({ id }: { id: string }) {
|
||||
const [domain, object] = id.split('.');
|
||||
const objects = useGovObjects();
|
||||
const page = useGovLedger(domain, object);
|
||||
const known = (objects.data?.objects ?? []).some((o) => o.domain === domain && o.object === object);
|
||||
if (!known) return <Note>对象未物化。</Note>;
|
||||
const columns = page.data?.columns ?? [];
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="table"
|
||||
kicker={`数据对象 · ${domain}`}
|
||||
title={object ?? ''}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="columns">{columns.length} 列</Chip>
|
||||
<Chip ico="hash">{page.data?.total ?? 0} 行</Chip>
|
||||
<Chip ico="key">主键 {(page.data?.primaryKey ?? []).join(', ') || '—'}</Chip>
|
||||
</>
|
||||
}
|
||||
desc="声明物化:最大化合并、只增不破坏——不删列、不改窄、不换主键。此处只读正式版,跨任务只见过门的事实。"
|
||||
/>
|
||||
<Card ico="columns" title="模式">
|
||||
<div className="g-rows">
|
||||
{columns.map((c) => (
|
||||
<div key={c.name}>
|
||||
<em>{c.name}</em>
|
||||
<b className="mono">{c.type}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Sec ico="table" title="记录浏览" extra={`前 ${(page.data?.rows ?? []).length} 行`}>
|
||||
<div className="g-dbwrap">
|
||||
<table className="g-dbtable">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.name}>{c.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(page.data?.rows ?? []).map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((c) => (
|
||||
<td key={c.name} className="g-mono">
|
||||
{String(row[c.name] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Sec>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 记忆条目 ---------------- */
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-memory` 唯一出口(编码规范 §9.1)。
|
||||
* 记忆管理:度量仪表、活动记忆四层
|
||||
*/
|
||||
export { MemoryHome, MemPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* feature `gov-memory` —— 记忆管理:度量仪表、活动记忆四层
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Chip, Clear, Kpi, NList, NRow, Note, PageHead, QueryGate } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovActivities,
|
||||
useGovMemory,
|
||||
useGovMetrics,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function MemoryHome({ tab, nav }: HomeProps) {
|
||||
const metrics = useGovMetrics();
|
||||
const activities = useGovActivities();
|
||||
const [picked, setPicked] = useState<string | undefined>(undefined);
|
||||
const memory = useGovMemory(picked);
|
||||
|
||||
if (tab === 'layers') {
|
||||
return (
|
||||
<>
|
||||
<Note>
|
||||
活动记忆四层:<b>事实层 / 模式层 / 裁决层 / 例外层</b>。写入唯蒸馏,条目必挂证据链;
|
||||
记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。
|
||||
</Note>
|
||||
<div className="g-picker">
|
||||
{(activities.data?.activities ?? []).map((a) => (
|
||||
<button key={a.activityId} className={'g-pick' + (picked === a.activityId ? ' on' : '')} onClick={() => setPicked(a.activityId)}>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<NList>
|
||||
{(memory.data?.entries ?? []).map((e) => (
|
||||
<NRow
|
||||
key={e.id}
|
||||
tone={e.incrementState === undefined ? 'brand' : 'warn'}
|
||||
ico="layers"
|
||||
title={e.statement}
|
||||
sub={`${e.layer} · ${e.createdAt.slice(0, 16).replace('T', ' ')}`}
|
||||
cols={[{ k: '证据', v: e.evidenceCount }]}
|
||||
right={e.incrementState !== undefined ? <Chip tone="warn">候选 · {e.incrementState}</Chip> : <Chip tone="ok">已成条目</Chip>}
|
||||
onClick={() => nav.push({ k: 'mem', id: e.id })}
|
||||
/>
|
||||
))}
|
||||
{picked !== undefined && (memory.data?.entries ?? []).length === 0 && (
|
||||
<Clear>该活动尚无记忆条目——终态封卷触发蒸馏钩子后产出。</Clear>
|
||||
)}
|
||||
</NList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const m = metrics.data;
|
||||
const pct = (v: number | undefined) => Math.round((v ?? 0) * 100);
|
||||
return (
|
||||
<QueryGate query={metrics}>
|
||||
<>
|
||||
<div className="g-kpis">
|
||||
<Kpi ico="bot" tone="auto" label="自主率" value={pct(m?.autonomy)} unit="%" dial={pct(m?.autonomy)} sub="机器产出 ÷(机器 + 人)" />
|
||||
<Kpi ico="rotate-ccw" tone="warn" label="纠正率" value={pct(m?.correction)} unit="%" dial={pct(m?.correction)} sub="被纠正批次 ÷ 提交批次" />
|
||||
<Kpi
|
||||
ico="gauge"
|
||||
tone="brand"
|
||||
label="有效自主率"
|
||||
value={pct(m?.effectiveAutonomy)}
|
||||
unit="%"
|
||||
dial={pct(m?.effectiveAutonomy)}
|
||||
sub="自主率 ×(1 − 纠正率)"
|
||||
/>
|
||||
<Kpi ico="hand" label="打扰率" value={(m?.interruption ?? 0).toFixed(1)} sub="每单任务调用人类工具次数" />
|
||||
<Kpi ico="git-commit" tone="ok" label="DMN 覆盖率" value={pct(m?.dmnCoverage)} unit="%" sub="确定性求值占判断次数" />
|
||||
</div>
|
||||
<Note>
|
||||
自主率与纠正率<b>成对读</b>,复合为有效自主率——单看自主率会奖励「猛冲后返工」。度量只是仪表,不载权力。
|
||||
</Note>
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- ⑨ 系统管理 ---------------- */
|
||||
|
||||
export function MemPage({ id }: { id: string }) {
|
||||
const activities = useGovActivities();
|
||||
const first = (activities.data?.activities ?? [])[0]?.activityId;
|
||||
const memory = useGovMemory(first);
|
||||
const e = (memory.data?.entries ?? []).find((x) => x.id === id);
|
||||
if (e === undefined) return <Note>记忆条目需从对应活动进入。</Note>;
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="layers"
|
||||
kicker={`活动记忆 · ${e.layer}`}
|
||||
title={e.statement}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="link">{e.evidenceCount} 条证据</Chip>
|
||||
{e.incrementState !== undefined && <Chip tone="warn">候选增量 · {e.incrementState}</Chip>}
|
||||
</>
|
||||
}
|
||||
desc="写入唯蒸馏,条目必挂证据链;记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 系统 ---------------- */
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-process` 唯一出口(编码规范 §9.1)。
|
||||
* 流程包编排画布(diagram-js 画布件平移):连线即映射、画图即校验、不闭合不得发布
|
||||
*/
|
||||
export { WorkflowsHome, PkgPage, EdgesPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* feature `gov-process` —— 流程包编排画布(diagram-js 画布件平移):连线即映射、画图即校验、不闭合不得发布
|
||||
*
|
||||
* 自 `~/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 {
|
||||
publishProcessRoute,
|
||||
} from '@contracts';
|
||||
import type { ClosureReport } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Chip, Entry, NList, NRow, Note, PageHead, QueryGate, Sec, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovPackages,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function WorkflowsHome({ nav }: HomeProps) {
|
||||
const q = useGovPackages();
|
||||
const packages = q.data?.packages ?? [];
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
编排即定契约——<b>连线即映射、画图即校验</b>,边不闭合不得发布。流程包是设计态的定义,工作流是它的运行时实例。
|
||||
</Note>
|
||||
<NList>
|
||||
{packages.map((p) => {
|
||||
const published = p.state === 'published';
|
||||
return (
|
||||
<NRow
|
||||
key={`${p.id}@${p.version}`}
|
||||
tone={published ? 'ok' : 'warn'}
|
||||
ico="workflow"
|
||||
title={p.name}
|
||||
sub={`${p.id} · v${p.version}`}
|
||||
cols={[
|
||||
{ k: '节点', v: p.nodes.length },
|
||||
{ k: '边', v: p.edges.length },
|
||||
{ k: '活动', v: p.nodes.filter((n) => n.kind === 'activity').length },
|
||||
]}
|
||||
right={
|
||||
<St tone={published ? 'ok' : 'warn'} ico={published ? 'circle-check' : 'file-pen'}>
|
||||
{published ? '已发布' : '草稿'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NList>
|
||||
</>
|
||||
</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 PkgPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
const [pid, versionText] = id.split('@');
|
||||
const q = useGovPackages();
|
||||
const [report, setReport] = useState<ClosureReport | undefined>(undefined);
|
||||
const pkg = (q.data?.packages ?? []).find((p) => p.id === pid && String(p.version) === versionText);
|
||||
|
||||
const publish = useGovAction(
|
||||
() => call<ClosureReport>(publishProcessRoute, { params: { id: pid!, version: versionText! } }).then((r) => setReport(r)),
|
||||
['process'],
|
||||
);
|
||||
|
||||
if (pkg === undefined) return <Note>流程包不存在。</Note>;
|
||||
const published = pkg.state === 'published';
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="workflow"
|
||||
tone={published ? 'ok' : 'warn'}
|
||||
kicker={`流程包 · ${pkg.id} v${pkg.version}`}
|
||||
title={pkg.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip tone={published ? 'ok' : 'warn'}>{published ? '已发布' : '草稿'}</Chip>
|
||||
<Chip ico="circle-dot">{pkg.nodes.length} 节点</Chip>
|
||||
<Chip ico="git-branch">{pkg.edges.length} 边</Chip>
|
||||
</>
|
||||
}
|
||||
desc="编排即定契约——连线即映射、画图即校验,边不闭合不得发布。"
|
||||
acts={[
|
||||
{
|
||||
label: '发布',
|
||||
ico: 'upload',
|
||||
primary: true,
|
||||
disabled: published,
|
||||
ask: {
|
||||
title: `发布流程包:${pkg.name} v${pkg.version}`,
|
||||
note: <Note tone="warn">发布前跑闭合校验:任一违规即拒绝,不存在「带病发布」。</Note>,
|
||||
impact: { who: '此后新起单按此版本执行;在途工作流仍按其起单时的版本', undo: '可发新版本覆盖,不可回改本版', when: '校验通过后即时' },
|
||||
okLabel: '确认发布',
|
||||
onOk: () => publish.mutate(undefined),
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{report !== undefined && (
|
||||
<Note tone={report.publishable ? 'ok' : 'warn'}>
|
||||
{report.publishable ? '闭合校验通过 —— 已发布。' : `闭合校验未过:${report.violations.map((v) => v.message).join(';')}`}
|
||||
</Note>
|
||||
)}
|
||||
|
||||
<Sec ico="git-branch" title="编排画布">
|
||||
<FlowFigure pkg={pkg} instances={[]} />
|
||||
</Sec>
|
||||
|
||||
<Entry ico="arrow-left-right" title="边映射" desc="连线即映射:每条边把上游输出口装配成下游输入" onClick={() => nav.push({ k: 'edges', id })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EdgesPage({ id }: { id: string }) {
|
||||
const [pid, versionText] = id.split('@');
|
||||
const q = useGovPackages();
|
||||
const pkg = (q.data?.packages ?? []).find((p) => p.id === pid && String(p.version) === versionText);
|
||||
if (pkg === undefined) return <Note>流程包不存在。</Note>;
|
||||
const labelOf = (nodeId: string) => pkg.nodes.find((n) => n.id === nodeId)?.label ?? nodeId;
|
||||
return (
|
||||
<>
|
||||
<PageHead ico="arrow-left-right" kicker={`边映射 · ${pkg.id} v${pkg.version}`} title={pkg.name} desc="边上的字段装配就是下游活动的输入契约:装配不上,校验期就拦下。" />
|
||||
<NList>
|
||||
{pkg.edges.map((e, i) => (
|
||||
<NRow
|
||||
key={i}
|
||||
tone={e.reopen ? 'warn' : 'brand'}
|
||||
ico={e.reopen ? 'rotate-ccw' : 'arrow-right'}
|
||||
title={`${labelOf(e.from)} → ${labelOf(e.to)}`}
|
||||
sub={e.condition === undefined ? undefined : `条件 · ${e.condition}`}
|
||||
cols={[
|
||||
{ k: '类别', v: e.reopen ? '回边 · 重开新实例' : '正向' },
|
||||
{ k: '字段装配', v: <span className="g-mono">{e.mapping.length}</span> },
|
||||
]}
|
||||
onClick={() => undefined}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 触发器 ---------------- */
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-staff` 唯一出口(编码规范 §9.1)。
|
||||
* 员工管理:账号、岗位授权、停用前置检查
|
||||
*/
|
||||
export { PeopleHome, EmpPage, RolePage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* feature `gov-staff` —— 员工管理:账号、岗位授权、停用前置检查
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import {
|
||||
GOVERNANCE_FUNCTION_LABEL,
|
||||
GRANT_ACTION_LABEL,
|
||||
} from '@contracts';
|
||||
import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovGrants,
|
||||
useGovStaff,
|
||||
useGovTasks,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function PeopleHome({ tab, nav }: HomeProps) {
|
||||
const q = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
const staff = q.data?.staff ?? [];
|
||||
const positions = q.data?.positions ?? [];
|
||||
|
||||
if (tab === 'roles') {
|
||||
return (
|
||||
<>
|
||||
<Note>
|
||||
权限按<b>岗位</b>授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。
|
||||
人换岗即换权,权限随岗位走。
|
||||
</Note>
|
||||
<NList>
|
||||
{positions.map((p) => (
|
||||
<NRow
|
||||
key={p.id}
|
||||
tone="brand"
|
||||
ico="key"
|
||||
title={p.name}
|
||||
sub={`岗位编码 ${p.code}`}
|
||||
cols={[{ k: '在岗', v: staff.filter((s) => s.position.id === p.id).length }]}
|
||||
onClick={() => nav.push({ k: 'role', id: p.id })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
停用前置检查:在途任务与待答卡须先<b>改派完毕</b>——停用不得让在途工作无人认领。
|
||||
</Note>
|
||||
<NList>
|
||||
{staff.map((s) => {
|
||||
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined);
|
||||
return (
|
||||
<NRow
|
||||
key={s.id}
|
||||
tone={s.state === 'active' ? 'brand' : 'mute'}
|
||||
ico="user-round"
|
||||
title={s.name}
|
||||
sub={`${s.account} · ${s.position.name}(${s.position.code})`}
|
||||
cols={[{ k: '在途任务', v: mine.length }]}
|
||||
right={
|
||||
<St tone={s.state === 'active' ? 'ok' : 'mute'} ico={s.state === 'active' ? 'circle-check' : 'power'}>
|
||||
{s.state === 'active' ? '在职' : '已停用'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'emp', id: s.id })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NList>
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- ③ 工作流管理 ---------------- */
|
||||
|
||||
export function EmpPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
const q = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
const s = (q.data?.staff ?? []).find((x) => x.id === id);
|
||||
if (s === undefined) return <Note>员工不存在。</Note>;
|
||||
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name);
|
||||
const live = mine.filter((t) => t.terminal === undefined);
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="user-round"
|
||||
kicker={`员工 · ${s.account}`}
|
||||
title={s.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="key">
|
||||
{s.position.name} · {s.position.code}
|
||||
</Chip>
|
||||
<Chip tone={s.state === 'active' ? 'ok' : 'mute'}>{s.state === 'active' ? '在职' : '已停用'}</Chip>
|
||||
</>
|
||||
}
|
||||
desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。"
|
||||
/>
|
||||
<Card ico="list-checks" title="在途任务" extra={<span className="g-mono">{live.length}</span>}>
|
||||
<NList>
|
||||
{live.map((t) => (
|
||||
<NRow key={t.id} tone="brand" ico="list-checks" title={t.title} onClick={() => nav.push({ k: 'task', id: t.id })} />
|
||||
))}
|
||||
{live.length === 0 && <Clear>无在途任务——可安全停用。</Clear>}
|
||||
</NList>
|
||||
</Card>
|
||||
<Entry ico="key" title="岗位授权" desc={`查看 ${s.position.name} 的授权清单`} onClick={() => nav.push({ k: 'role', id: s.position.id })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RolePage({ id }: { id: string }) {
|
||||
const staff = useGovStaff();
|
||||
const grants = useGovGrants(id);
|
||||
const position = (staff.data?.positions ?? []).find((p) => p.id === id);
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="key"
|
||||
kicker="岗位授权"
|
||||
title={position?.name ?? id}
|
||||
chips={<Chip ico="hash">{position?.code ?? '—'}</Chip>}
|
||||
desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。"
|
||||
/>
|
||||
<div className="g-rows">
|
||||
{(grants.data?.grants ?? []).map((g, i) => (
|
||||
<div key={i}>
|
||||
<em>
|
||||
{g.object.kind === 'data'
|
||||
? `数据 · ${g.object.domain}.${g.object.object}`
|
||||
: g.object.kind === 'governance'
|
||||
? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}`
|
||||
: `任务 · ${g.object.activityId ?? '全部活动'}`}
|
||||
</em>
|
||||
<b className="mono">{GRANT_ACTION_LABEL[g.action]}</b>
|
||||
</div>
|
||||
))}
|
||||
{(grants.data?.grants ?? []).length === 0 && <Clear>未授予任何权限。</Clear>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 流程包 ---------------- */
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-system` 唯一出口(编码规范 §9.1)。
|
||||
* 系统管理:LLM 实例四元绑定、通道实例
|
||||
*/
|
||||
export { SystemHome, LlmPage, ChanPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* feature `gov-system` —— 系统管理:LLM 实例四元绑定、通道实例
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovChannels,
|
||||
useGovLlm,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function SystemHome({ tab, nav }: HomeProps) {
|
||||
const llm = useGovLlm();
|
||||
const channels = useGovChannels();
|
||||
|
||||
if (tab === 'chan') {
|
||||
return (
|
||||
<>
|
||||
<Note>
|
||||
通道实例声明<b>归属键规则</b>与默认认领岗位。归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口;
|
||||
歧义或失败一律判无主——<b>宁交人,不错投</b>。
|
||||
</Note>
|
||||
<NList>
|
||||
{(channels.data?.channels ?? []).map((ch) => (
|
||||
<NRow
|
||||
key={ch.id}
|
||||
tone={ch.enabled ? 'brand' : 'mute'}
|
||||
ico="mail"
|
||||
title={ch.name}
|
||||
sub={ch.address}
|
||||
cols={[
|
||||
{ k: '归属键规则', v: ch.extractCount },
|
||||
{ k: '载荷映射', v: ch.mappingCount },
|
||||
{ k: '地址窗口', v: <span className="g-mono">{Math.round(ch.addressWindowMs / 3600_000)}h</span> },
|
||||
{ k: '默认认领岗位', v: ch.fallbackPosition ?? '—' },
|
||||
]}
|
||||
right={
|
||||
<St tone={ch.enabled ? 'ok' : 'mute'} ico={ch.enabled ? 'circle-check' : 'power'}>
|
||||
{ch.enabled ? '已启用' : '已停用'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'chan', id: ch.id })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryGate query={llm}>
|
||||
<>
|
||||
<Note>
|
||||
LLM 实例是<b>名称间接层</b>:活动只见自定义模型名,Provider / URL / API-Key / Model 四元在系统侧绑定。
|
||||
密钥与 endpoint 值只存治理台——不入包、不入活动、不入留痕明文。
|
||||
</Note>
|
||||
<NList>
|
||||
{(llm.data?.instances ?? []).map((i) => (
|
||||
<NRow
|
||||
key={i.id}
|
||||
tone={i.enabled ? 'brand' : 'mute'}
|
||||
ico="cpu"
|
||||
title={i.name}
|
||||
sub={`${i.provider} · ${i.model}`}
|
||||
cols={[
|
||||
{ k: 'Base URL', v: <span className="g-mono">{i.baseUrl}</span> },
|
||||
{ k: '系统用途', v: i.systemUse ? '是' : '—' },
|
||||
]}
|
||||
right={
|
||||
<St tone={i.apiKeyBound ? 'ok' : 'warn'} ico={i.apiKeyBound ? 'shield-check' : 'alert-triangle'}>
|
||||
{i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'llm', id: i.id })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
export function LlmPage({ id }: { id: string }) {
|
||||
const q = useGovLlm();
|
||||
const i = (q.data?.instances ?? []).find((x) => x.id === id);
|
||||
if (i === undefined) return <Note>实例不存在。</Note>;
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="cpu"
|
||||
kicker={`LLM 实例 · ${i.provider}`}
|
||||
title={i.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="hash">{i.model}</Chip>
|
||||
<Chip tone={i.apiKeyBound ? 'ok' : 'warn'}>{i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'}</Chip>
|
||||
{i.systemUse && <Chip tone="brand">系统用途</Chip>}
|
||||
</>
|
||||
}
|
||||
desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。"
|
||||
/>
|
||||
<Card ico="settings" title="四元绑定">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: 'Provider', v: i.provider },
|
||||
{ k: 'Base URL', v: i.baseUrl, mono: true },
|
||||
{ k: 'Model', v: i.model, mono: true },
|
||||
{ k: 'API-Key', v: i.apiKeyBound ? '已绑定(密文存储,永不回显)' : '未绑定' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChanPage({ id }: { id: string }) {
|
||||
const q = useGovChannels();
|
||||
const ch = (q.data?.channels ?? []).find((x) => x.id === id);
|
||||
if (ch === undefined) return <Note>通道不存在。</Note>;
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="mail"
|
||||
kicker={`通道实例 · ${ch.kind}`}
|
||||
title={ch.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="at-sign">{ch.address}</Chip>
|
||||
<Chip tone={ch.enabled ? 'ok' : 'mute'}>{ch.enabled ? '已启用' : '已停用'}</Chip>
|
||||
</>
|
||||
}
|
||||
desc="归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口;歧义或失败一律判无主——宁交人,不错投。"
|
||||
/>
|
||||
<Card ico="settings" title="归属与映射">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '归属键规则', v: `${ch.extractCount} 条` },
|
||||
{ k: '载荷映射', v: `${ch.mappingCount} 条` },
|
||||
{ k: '地址窗口', v: `${Math.round(ch.addressWindowMs / 3600_000)} 小时`, mono: true },
|
||||
{ k: '默认认领岗位', v: ch.fallbackPosition ?? '—' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-task` 唯一出口(编码规范 §9.1)。
|
||||
* 任务管理:在途监控、控制事件(暂停/叫停/终止)、恢复、改派、任务日志查看
|
||||
*/
|
||||
export { TasksHome, WfPage, TaskPage, TaskLogPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* 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, PARA, 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 {
|
||||
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) {
|
||||
const q = useGovTasks();
|
||||
const approvals = useGovApprovals();
|
||||
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;
|
||||
};
|
||||
|
||||
if (tab === 'board') {
|
||||
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">{w.startedAt.slice(5, 16).replace('T', ' ')}</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>
|
||||
);
|
||||
}
|
||||
|
||||
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.approverPosition },
|
||||
{ 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 (
|
||||
<>
|
||||
<Note>
|
||||
任务 = 工作流上的节点,一个活动 = 一个任务;它派进员工工作台的<b>任务清单</b>,负责人由节点声明的岗位解析而来。
|
||||
</Note>
|
||||
<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={PARA[t.paradigm].tone}
|
||||
ico={PARA[t.paradigm].ico}
|
||||
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">{t.startedAt.slice(5, 16).replace('T', ' ')}</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={PARA[task.paradigm].ico}
|
||||
tone={st.tone}
|
||||
kicker={`任务 · ${task.id.slice(0, 8)}`}
|
||||
title={task.title}
|
||||
chips={
|
||||
<>
|
||||
<Chip tone={st.tone} ico={st.ico}>
|
||||
{label}
|
||||
</Chip>
|
||||
<Chip tone={PARA[task.paradigm].tone} ico={PARA[task.paradigm].ico}>
|
||||
{PARA[task.paradigm].label}
|
||||
</Chip>
|
||||
<Chip ico="user-round">{task.ownerName}</Chip>
|
||||
</>
|
||||
}
|
||||
desc={PARA[task.paradigm].full + ' —— 治理不改定义:控制只作用于这一次运行。'}
|
||||
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: PARA[task.paradigm].label },
|
||||
{ k: '起始', v: task.startedAt.slice(0, 16).replace('T', ' ') },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Sec ico="user-plus" title="改派">
|
||||
<Note>改派 = 负责人变更;改派留痕并通知双方,任务记忆与草稿层不动。</Note>
|
||||
<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.sideLabel}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{lines.length === 0 && <Clear>暂无留痕。</Clear>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 员工 / 岗位 ---------------- */
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-tool` 唯一出口(编码规范 §9.1)。
|
||||
* 工具管理:注册、白名单、凭证绑定(只回键,永不回值)
|
||||
*/
|
||||
export { ToolsHome, ToolPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* feature `gov-tool` —— 工具管理:注册、白名单、凭证绑定(只回键,永不回值)
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovTools,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function ToolsHome({ nav }: HomeProps) {
|
||||
const q = useGovTools();
|
||||
const tools = q.data?.tools ?? [];
|
||||
const reads = tools.filter((t) => t.effect === 'read');
|
||||
const writes = tools.filter((t) => t.effect === 'write');
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
按<b>效应等级</b>分列:读类过程中自由调用;写类只登记<b>动作意向</b>,放行后统一落地。密钥只存治理台,不入包、不入活动。
|
||||
</Note>
|
||||
{[
|
||||
{ label: '读类 · 过程中自由调用', items: reads, tone: 'brand', ico: 'search' },
|
||||
{ label: '写类 · 登记意向,放行后落地', items: writes, tone: 'danger', ico: 'pen-line' },
|
||||
].map((group) => (
|
||||
<div key={group.label} className="g-sec">
|
||||
<div className="g-band">
|
||||
<Ico name={group.ico} s={13} />
|
||||
{group.label}
|
||||
<span className="x">{group.items.length}</span>
|
||||
</div>
|
||||
<NList>
|
||||
{group.items.map((t) => (
|
||||
<NRow
|
||||
key={t.name}
|
||||
tone={group.tone}
|
||||
ico="wrench"
|
||||
title={t.name}
|
||||
sub={t.description}
|
||||
cols={[
|
||||
{ k: '版本', v: <span className="g-mono">{t.version}</span> },
|
||||
{ k: '连接配置键', v: <span className="g-mono">{t.connectionKeys.join(', ') || '—'}</span> },
|
||||
]}
|
||||
right={t.pure ? <Chip tone="ok">纯工具</Chip> : <Chip tone={group.tone}>{t.effect === 'read' ? '读' : '写'}</Chip>}
|
||||
onClick={() => nav.push({ k: 'tool', id: t.name })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- ⑦ 数据管理 ---------------- */
|
||||
|
||||
export function ToolPage({ id }: { id: string }) {
|
||||
const q = useGovTools();
|
||||
const t = (q.data?.tools ?? []).find((x) => x.name === id);
|
||||
if (t === undefined) return <Note>工具不存在。</Note>;
|
||||
const write = t.effect === 'write';
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="wrench"
|
||||
tone={write ? 'danger' : 'brand'}
|
||||
kicker={`工具 · ${t.name}`}
|
||||
title={t.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip tone={write ? 'danger' : 'brand'}>{write ? '写类' : '读类'}</Chip>
|
||||
{t.pure && <Chip tone="ok">纯工具</Chip>}
|
||||
<Chip ico="hash">v{t.version}</Chip>
|
||||
</>
|
||||
}
|
||||
desc={
|
||||
write
|
||||
? '写类工具在过程中只登记动作意向,不改世界;放行通过后由落地收敛统一执行。'
|
||||
: '读类工具过程中自由调用 —— 读不改世界,故不需放行。'
|
||||
}
|
||||
/>
|
||||
<Card ico="info" title="说明">
|
||||
<p className="g-desc">{t.description}</p>
|
||||
</Card>
|
||||
<Card ico="shield-check" title="连接配置">
|
||||
<Note>凭证隔离:治理台只回<b>配置键</b>,永不回值;密钥不入包、不入活动、不入留痕明文。</Note>
|
||||
<div className="g-rows">
|
||||
{t.connectionKeys.map((k) => (
|
||||
<div key={k}>
|
||||
<em>{k}</em>
|
||||
<b className="mono">已在治理台绑定</b>
|
||||
</div>
|
||||
))}
|
||||
{t.connectionKeys.length === 0 && <Clear>此工具无需外部连接。</Clear>}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 数据对象 ---------------- */
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-trigger` 唯一出口(编码规范 §9.1)。
|
||||
* 触发器配置(周期 / 事件 / 登记三类):与流程包平级,启停不改任何版本
|
||||
*/
|
||||
export { TriggersHome, TriggerPage } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* feature `gov-trigger` —— 触发器配置(周期 / 事件 / 登记三类):与流程包平级,启停不改任何版本
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
TRIGGER_KIND_LABEL,
|
||||
toggleTriggerRoute,
|
||||
triggerKindOf,
|
||||
} from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovTriggers,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function TriggersHome({ nav }: HomeProps) {
|
||||
const q = useGovTriggers();
|
||||
const triggers = q.data?.triggers ?? [];
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
触发器与流程包<b>平级</b>——不入包结构、不随包版本,增删启停不改任何版本。周期 / 事件 / 登记三类。
|
||||
</Note>
|
||||
<NList>
|
||||
{triggers.map((t) => {
|
||||
const kind = triggerKindOf(t.spec);
|
||||
return (
|
||||
<NRow
|
||||
key={t.id}
|
||||
tone={t.enabled ? 'brand' : 'mute'}
|
||||
ico="zap"
|
||||
title={t.name}
|
||||
sub={`调起 ${t.processPackageId} · ${t.mapping.length} 条起单映射`}
|
||||
cols={[{ k: '类别', v: <Chip tone="brand">{TRIGGER_KIND_LABEL[kind]}</Chip> }]}
|
||||
right={
|
||||
<St tone={t.enabled ? 'ok' : 'mute'} ico={t.enabled ? 'circle-check' : 'power'}>
|
||||
{t.enabled ? '已启用' : '已停用'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'trigger', id: t.id })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NList>
|
||||
</>
|
||||
</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 TriggerPage({ id }: { id: string }) {
|
||||
const q = useGovTriggers();
|
||||
const t = (q.data?.triggers ?? []).find((x) => x.id === id);
|
||||
const toggle = useGovAction(
|
||||
(enabled: boolean) => call(toggleTriggerRoute, { params: { id }, body: { enabled } }),
|
||||
['triggers'],
|
||||
);
|
||||
if (t === undefined) return <Note>触发器不存在。</Note>;
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="zap"
|
||||
tone={t.enabled ? 'brand' : 'mute'}
|
||||
kicker={`触发器 · ${t.id}`}
|
||||
title={t.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip>
|
||||
<Chip ico="boxes">调起 {t.processPackageId}</Chip>
|
||||
</>
|
||||
}
|
||||
desc="触发器与流程包平级——不入包结构、不随包版本,增删启停不改任何版本。"
|
||||
acts={[
|
||||
{
|
||||
label: t.enabled ? '停用' : '启用',
|
||||
ico: 'power',
|
||||
danger: t.enabled,
|
||||
ask: {
|
||||
title: `${t.enabled ? '停用' : '启用'}触发器:${t.name}`,
|
||||
note: <Note>启停不改任何版本;已起的单不受影响。</Note>,
|
||||
impact: {
|
||||
who: t.enabled ? '此后不再自动起单,已在途工作流不受影响' : '此后按规则自动起单',
|
||||
undo: '可随时反向切换',
|
||||
when: '确认后即时',
|
||||
},
|
||||
okLabel: t.enabled ? '确认停用' : '确认启用',
|
||||
onOk: () => toggle.mutate(!t.enabled),
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card ico="settings" title="起单规则">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '类别', v: TRIGGER_KIND_LABEL[triggerKindOf(t.spec)] },
|
||||
{ k: '规格', v: JSON.stringify(t.spec), mono: true },
|
||||
{ k: '流程包', v: t.processPackageId, mono: true },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card ico="arrow-left-right" title="起单映射" extra={<span className="g-mono">{t.mapping.length}</span>}>
|
||||
<div className="g-rows">
|
||||
{t.mapping.map((m, i) => (
|
||||
<div key={i}>
|
||||
<em>{m.target}</em>
|
||||
<b className="mono">{JSON.stringify(m.source)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 活动 / 工具 ---------------- */
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import Diagram from "diagram-js";
|
||||
import BaseRenderer from "diagram-js/lib/draw/BaseRenderer";
|
||||
import InteractionEventsModule from "diagram-js/lib/features/interaction-events";
|
||||
import type Canvas from "diagram-js/lib/core/Canvas";
|
||||
import type ElementFactory from "diagram-js/lib/core/ElementFactory";
|
||||
import type EventBus from "diagram-js/lib/core/EventBus";
|
||||
import type { ShapeLike } from "diagram-js/lib/core/Types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
layoutFlow,
|
||||
type ConnectionLayout,
|
||||
type FlowGraph,
|
||||
type FlowLayoutResult,
|
||||
type FlowNode,
|
||||
type FlowVariant,
|
||||
type ShapeLayout,
|
||||
} from "./flow-layout";
|
||||
import { arrowHeadPoints, CAPTION_STYLE, FLOW_MONO, paintNode, svgEl as svg, TONE_COLOR } from "./flow-node-paint";
|
||||
import { OwnerSelect } from "./support";
|
||||
import type { SpPosition } from "./support";
|
||||
import "diagram-js/assets/diagram-js.css";
|
||||
|
||||
/**
|
||||
* 流程图绘制:diagram-js 承载节点(shape + 点击交互),
|
||||
* 连线/箭头/标注按布局引擎产出的几何画进独立图层。
|
||||
* 节点/连线画笔在 flow-node-paint.ts,与 FlowEditor(手绘)共用一套视觉。
|
||||
* 模型只产结构化数据——坐标来自 flow-layout,绘制在这里,SVG 不经模型之手。
|
||||
*/
|
||||
|
||||
/** 画布上方预留的留白(布局坐标单位;避免顶部标注贴边)。 */
|
||||
const DIAGRAM_TOP_PAD = 26;
|
||||
|
||||
interface FlowBO {
|
||||
node: FlowNode;
|
||||
variant: FlowVariant;
|
||||
clickable: boolean;
|
||||
}
|
||||
|
||||
/** 节点绘制(共用画笔 + diagram-js 命中层标记)。 */
|
||||
class FlowShapeRenderer extends BaseRenderer {
|
||||
static $inject = ["eventBus"];
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
super(eventBus, 2000);
|
||||
}
|
||||
|
||||
override canRender(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override drawShape(parent: SVGElement, element: ShapeLike): SVGElement {
|
||||
const { node, variant, clickable } = (element as { businessObject: FlowBO }).businessObject;
|
||||
const group = svg("g", {});
|
||||
if (clickable) {
|
||||
// 光标由 diagram-js 盖在最上层的 .djs-hit 命中层决定(默认 move 十字)——
|
||||
// 在元素组上打标记类,由 process-doc.css 把该层光标改成 pointer。
|
||||
(parent.parentElement as SVGElement | null)?.classList.add("flow-node-clickable");
|
||||
}
|
||||
paintNode(group, node, element.width, element.height, variant);
|
||||
parent.appendChild(group);
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
const rendererModule = {
|
||||
__init__: ["flowShapeRenderer"],
|
||||
flowShapeRenderer: ["type", FlowShapeRenderer],
|
||||
};
|
||||
|
||||
/** 连线 + 箭头 + 线上标签,画进注解图层(几何全部来自布局引擎)。 */
|
||||
function drawConnection(layer: SVGElement, connection: ConnectionLayout, variant: FlowVariant): void {
|
||||
const color = TONE_COLOR[connection.tone];
|
||||
const [first, ...rest] = connection.waypoints;
|
||||
if (first === undefined) return;
|
||||
const d = `M${first.x},${first.y} ${rest.map((p) => `L${p.x},${p.y}`).join(" ")}`;
|
||||
layer.append(svg("path", { d, fill: "none", stroke: color, "stroke-width": 1.4 }));
|
||||
layer.append(svg("polygon", { points: arrowHeadPoints(connection.waypoints), fill: color }));
|
||||
|
||||
if (connection.label) {
|
||||
layer.append(
|
||||
svg(
|
||||
"text",
|
||||
{
|
||||
x: connection.label.x,
|
||||
y: connection.label.y,
|
||||
"text-anchor": connection.label.anchor,
|
||||
"font-family": FLOW_MONO,
|
||||
"font-size": variant === "business" ? 9.8 : 8.6,
|
||||
fill: color,
|
||||
},
|
||||
connection.label.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawAnnotations(layer: SVGElement, layout: FlowLayoutResult, variant: FlowVariant): void {
|
||||
for (const connection of layout.connections) drawConnection(layer, connection, variant);
|
||||
for (const caption of layout.captions) {
|
||||
const style = CAPTION_STYLE[caption.role];
|
||||
// 严格索引:未知 role 不画注记,而不是画一个半成品
|
||||
if (style === undefined) continue;
|
||||
layer.append(
|
||||
svg(
|
||||
"text",
|
||||
{
|
||||
x: caption.x,
|
||||
y: caption.y,
|
||||
"text-anchor": caption.anchor,
|
||||
"font-family": style.font,
|
||||
"font-size": style.size(variant),
|
||||
...(style.weight ? { "font-weight": style.weight } : {}),
|
||||
fill: style.fill,
|
||||
...(layout.ghost ? { opacity: 0.55 } : {}),
|
||||
},
|
||||
caption.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlowDiagramProps {
|
||||
graph: FlowGraph | null | undefined;
|
||||
variant: FlowVariant;
|
||||
/** 点击活动节点(下钻);不传则节点不可点。 */
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
/** 双击活动节点就地改文字(标题/小字/主办);不传则不可改。 */
|
||||
onEditNode?: (nodeId: string, patch: { label: string; owner?: string }) => Promise<void>;
|
||||
/** 执行人候选(L1 业务能力)——传了则执行字段只能下拉选缩写,不开自由填。 */
|
||||
ownerOptions?: SpPosition[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
interface NodeEdit {
|
||||
node: FlowNode;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function FlowDiagram({ graph, variant, onNodeClick, onEditNode, ownerOptions, ariaLabel }: FlowDiagramProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editRef = useRef<HTMLDivElement>(null);
|
||||
const clickRef = useRef(onNodeClick);
|
||||
clickRef.current = onNodeClick;
|
||||
const editNodeRef = useRef(onEditNode);
|
||||
editNodeRef.current = onEditNode;
|
||||
// 单击下钻延时:等一拍看是否双击(双击=改文字,取消下钻)。
|
||||
const drillTimer = useRef<number | null>(null);
|
||||
// 活动文字就地编辑浮层(双击活动)。
|
||||
const [nodeEdit, setNodeEdit] = useState<NodeEdit | null>(null);
|
||||
const [editDraft, setEditDraft] = useState({ label: "", owner: "" });
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
const [editErr, setEditErr] = useState<string | null>(null);
|
||||
|
||||
const layout = useMemo(
|
||||
() => layoutFlow(graph ?? { nodes: [], rows: [], anchors: [], edges: [] }, variant),
|
||||
[graph, variant],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const diagram = new Diagram({
|
||||
canvas: { container },
|
||||
modules: [rendererModule, InteractionEventsModule],
|
||||
});
|
||||
const canvas = diagram.get<Canvas>("canvas");
|
||||
const factory = diagram.get<ElementFactory>("elementFactory");
|
||||
const eventBus = diagram.get<EventBus>("eventBus");
|
||||
|
||||
const root = factory.createRoot({ id: "flow-root" });
|
||||
canvas.setRootElement(root);
|
||||
|
||||
for (const shape of layout.shapes) {
|
||||
const businessObject: FlowBO = {
|
||||
node: shape.node,
|
||||
variant,
|
||||
clickable: Boolean(clickRef.current) && shape.node.kind === "activity" && !shape.node.ghost && !layout.ghost,
|
||||
};
|
||||
canvas.addShape(
|
||||
factory.createShape({
|
||||
id: shape.node.id,
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
width: shape.w,
|
||||
height: shape.h,
|
||||
businessObject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 连线与标注画在形状之下的图层(层序号小于默认元素层)。
|
||||
const annotationLayer = canvas.getLayer("flow-annotations", -1);
|
||||
drawAnnotations(annotationLayer as SVGElement, layout, variant);
|
||||
if (layout.ghost) {
|
||||
(canvas.getContainer().querySelector(".djs-container svg, svg") as SVGElement | null)?.setAttribute(
|
||||
"opacity",
|
||||
"0.75",
|
||||
);
|
||||
}
|
||||
|
||||
const overlayPos = (oe: MouseEvent | undefined, boxW: number, boxH: number) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const rawLeft = (oe?.clientX ?? rect.left + rect.width / 2) - rect.left;
|
||||
const rawTop = (oe?.clientY ?? rect.top + rect.height / 2) - rect.top;
|
||||
return {
|
||||
left: Math.max(8, Math.min(rawLeft, rect.width - boxW)),
|
||||
top: Math.max(8, Math.min(rawTop + 10, rect.height - boxH)),
|
||||
};
|
||||
};
|
||||
const onClick = (event: { element?: { businessObject?: FlowBO }; originalEvent?: MouseEvent }) => {
|
||||
const bo = event.element?.businessObject;
|
||||
if (!bo?.clickable) return;
|
||||
if (!clickRef.current) return;
|
||||
const id = bo.node.id;
|
||||
if (editNodeRef.current) {
|
||||
// 可改文字时:单击延一拍下钻,给双击留窗口。
|
||||
if (drillTimer.current) window.clearTimeout(drillTimer.current);
|
||||
drillTimer.current = window.setTimeout(() => {
|
||||
drillTimer.current = null;
|
||||
clickRef.current?.(id);
|
||||
}, 260);
|
||||
} else {
|
||||
clickRef.current(id);
|
||||
}
|
||||
};
|
||||
const onDblClick = (event: { element?: { businessObject?: FlowBO }; originalEvent?: MouseEvent }) => {
|
||||
const bo = event.element?.businessObject;
|
||||
if (!editNodeRef.current || bo?.node.kind !== "activity" || bo.node.ghost) return;
|
||||
if (drillTimer.current) {
|
||||
window.clearTimeout(drillTimer.current);
|
||||
drillTimer.current = null;
|
||||
}
|
||||
setEditDraft({ label: bo.node.label, owner: bo.node.owner ?? "" });
|
||||
setEditErr(null);
|
||||
setEditBusy(false);
|
||||
setNodeEdit({ node: bo.node, ...overlayPos(event.originalEvent, 300, 190) });
|
||||
};
|
||||
eventBus.on("element.click", onClick);
|
||||
eventBus.on("element.dblclick", onDblClick);
|
||||
setNodeEdit(null);
|
||||
|
||||
// 自动布局的网关/起点标注画在节点上方(负 y),需要顶部留白;
|
||||
// 手绘图旁注都在节点下方且布局已收顶,大留白反而碍眼。
|
||||
const topPad = graph?.manual ? 6 : DIAGRAM_TOP_PAD;
|
||||
const fit = () => {
|
||||
canvas.viewbox({
|
||||
x: 0,
|
||||
y: -topPad,
|
||||
width: layout.width,
|
||||
height: layout.height + topPad,
|
||||
});
|
||||
};
|
||||
fit();
|
||||
const observer = new ResizeObserver(() => {
|
||||
canvas.resized();
|
||||
fit();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
diagram.destroy();
|
||||
};
|
||||
}, [layout, variant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeEdit) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
if (editRef.current?.contains(event.target as Node)) return;
|
||||
setNodeEdit(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", close, true);
|
||||
return () => document.removeEventListener("pointerdown", close, true);
|
||||
}, [nodeEdit]);
|
||||
|
||||
return (
|
||||
<div className="flow-diagram-wrap" style={{ position: "relative" }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className="flow-diagram"
|
||||
data-ghost={layout.ghost ? "true" : undefined}
|
||||
style={{ width: "100%", aspectRatio: `${layout.width} / ${layout.height + (graph?.manual ? 6 : DIAGRAM_TOP_PAD)}` }}
|
||||
/>
|
||||
{nodeEdit && (
|
||||
<div ref={editRef} className="gw-pop" style={{ left: nodeEdit.left, top: nodeEdit.top }} role="dialog" aria-label={`编辑活动 · ${nodeEdit.node.id}`}>
|
||||
<div className="gp-hd">
|
||||
<span className="gp-dia" aria-hidden>▭</span>
|
||||
<span className="gp-nm">{nodeEdit.node.id} · 改文字</span>
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">标题</span>
|
||||
<input value={editDraft.label} maxLength={16} onChange={(e) => setEditDraft((d) => ({ ...d, label: e.target.value }))} />
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">执行</span>
|
||||
{ownerOptions ? (
|
||||
<OwnerSelect value={editDraft.owner} positions={ownerOptions} onChange={(v) => setEditDraft((d) => ({ ...d, owner: v }))} />
|
||||
) : (
|
||||
<input value={editDraft.owner} maxLength={16} placeholder="执行人岗位缩写(可空)" onChange={(e) => setEditDraft((d) => ({ ...d, owner: e.target.value }))} />
|
||||
)}
|
||||
</div>
|
||||
{editErr && <p className="gp-basis muted" style={{ color: "#8f3330" }}>{editErr}</p>}
|
||||
<div className="gp-btns">
|
||||
<button type="button" className="be-btn ghost" disabled={editBusy} onClick={() => setNodeEdit(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="be-btn solid"
|
||||
disabled={editBusy || editDraft.label.trim().length === 0}
|
||||
onClick={() => {
|
||||
setEditBusy(true);
|
||||
setEditErr(null);
|
||||
editNodeRef
|
||||
.current?.(nodeEdit.node.id, {
|
||||
label: editDraft.label.trim(),
|
||||
...(editDraft.owner.trim() === "" ? {} : { owner: editDraft.owner.trim() }),
|
||||
})
|
||||
.then(() => setNodeEdit(null))
|
||||
.catch((err) => {
|
||||
setEditErr(err instanceof Error ? err.message : "保存失败");
|
||||
setEditBusy(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{editBusy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 供节点导航等场景推导活动节点清单(与渲染同一数据源)。 */
|
||||
export function activityNodes(graph: FlowGraph | null | undefined): FlowNode[] {
|
||||
return (graph?.nodes ?? []).filter((node) => node.kind === "activity" && !node.ghost);
|
||||
}
|
||||
|
||||
export type { ShapeLayout };
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 编排画布 —— diagram-js 画布件的宿主(编码规范 §6.1「PRD 画布件 1:1 平移」)。
|
||||
*
|
||||
* 原型的 `HostExample.FlowFigureBlock` 同时带手绘编辑器;此处先接**只读态**:
|
||||
* 流程包 → `FlowGraph`(`pkg-to-graph`)→ 布局引擎算几何 → `FlowDiagram` 绘制。
|
||||
* 节点实例落位以角注呈现,几何不因运行态而变——**图是定义,标注是运行**。
|
||||
*/
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { FlowDiagram } from './FlowDiagram.tsx';
|
||||
import { packageToGraph } from './pkg-to-graph.ts';
|
||||
import type { InstanceMark } from './pkg-to-graph.ts';
|
||||
import './flow-canvas.css';
|
||||
|
||||
export function FlowFigure({
|
||||
pkg,
|
||||
instances,
|
||||
onNodeClick,
|
||||
}: {
|
||||
pkg: ProcessPackage;
|
||||
instances: readonly InstanceMark[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}) {
|
||||
const graph = packageToGraph(pkg, instances);
|
||||
return (
|
||||
<div className="process-desk">
|
||||
<div className="process-doc">
|
||||
<FlowDiagram
|
||||
graph={graph}
|
||||
variant="business"
|
||||
ariaLabel={`流程图 · ${pkg.name}`}
|
||||
{...(onNodeClick === undefined ? {} : { onNodeClick })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
/* ============================================================
|
||||
L2 流程图画布 + 手工绘图编辑器 —— 抽离自 Mind-Tinker
|
||||
apps/web/src/components/workspace/process/process-doc.css
|
||||
(仅保留流程图相关规则,选择器与原文件逐字一致)。
|
||||
画布是嵌在暗色工作区里的一张"白纸":组件必须放在
|
||||
.process-desk > .process-doc 容器内,这些 scoped 规则才生效。
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 纸面容器与设计令牌 ---------- */
|
||||
.process-desk {
|
||||
background: #e7eaef;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.process-doc {
|
||||
--ink: #161b22;
|
||||
--ink-soft: #3a4452;
|
||||
--muted: #6a7686;
|
||||
--steel: #46586a;
|
||||
--brass: #b07d2b;
|
||||
--brass-deep: #8a5f1c;
|
||||
--brass-wash: #f7f0e0;
|
||||
--line: #d8dee6;
|
||||
--grid: #eceff3;
|
||||
--alert: #b0413e;
|
||||
--mono: "SF Mono", "JetBrains Mono", "Menlo", "Roboto Mono", ui-monospace, monospace;
|
||||
--sans: "PingFang SC", "Noto Sans SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
|
||||
width: 100%;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
padding: 40px 44px 52px;
|
||||
box-shadow: 0 12px 40px -18px rgba(20, 27, 34, 0.3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- 区块小标题(view-lbl,编辑芯片右对齐) ---------- */
|
||||
.process-doc .view-lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--steel);
|
||||
margin: 16px 0 2px;
|
||||
/* 编辑铅笔统一右对齐(2026-07 user rule)。 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.process-doc .view-lbl .be-pencil {
|
||||
margin-left: auto;
|
||||
}
|
||||
.process-doc .view-lbl.b {
|
||||
color: var(--brass-deep);
|
||||
}
|
||||
|
||||
/* ---------- 记号图例 ---------- */
|
||||
.process-doc .legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
color: var(--ink-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--steel);
|
||||
background: #f7f9fb;
|
||||
padding: 9px 14px;
|
||||
margin: 12px 0 2px;
|
||||
}
|
||||
.process-doc .legend b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.process-doc .legend .sep {
|
||||
color: var(--line);
|
||||
}
|
||||
|
||||
/* ---------- 框图容器(hero/cap)+ 只读画布(flow-diagram) ---------- */
|
||||
.process-doc .hero {
|
||||
margin: 16px 0 4px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
.process-doc .hero .cap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 4px 10px;
|
||||
}
|
||||
.process-doc .flow-diagram {
|
||||
display: block;
|
||||
}
|
||||
.process-doc .flow-diagram .djs-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
/* diagram-js 的 svg 没有 viewBox,靠 height=100% 属性撑满容器——
|
||||
CSS 优先级高于表现属性,别让下面静态 SVG 的 height:auto 把它压回 150px。 */
|
||||
.process-doc .flow-diagram svg {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
/* diagram-js 把 svg 设为可聚焦(tabindex),点击节点时它吃焦点会触发全局
|
||||
:focus-visible 蓝框——这张只读图的键盘焦点无导航意义(节点间无法 tab),
|
||||
去掉它的焦点轮廓;下钻的点击态由节点自身的 hover/pointer 表达。 */
|
||||
.process-doc .flow-diagram svg:focus,
|
||||
.process-doc .flow-diagram svg:focus-visible,
|
||||
.process-doc .flow-diagram .djs-container:focus,
|
||||
.process-doc .flow-diagram [tabindex]:focus,
|
||||
.process-doc .flow-diagram [tabindex]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
/* 只读画布:命中层默认不给 move 十字;可下钻的活动节点给手指。 */
|
||||
.process-doc .flow-diagram .djs-hit {
|
||||
cursor: default;
|
||||
}
|
||||
.process-doc .flow-diagram .flow-node-clickable .djs-hit {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- 待确认红框(草稿预览) ---------- */
|
||||
.process-doc .changed {
|
||||
position: relative;
|
||||
outline: 2px solid var(--alert);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.process-doc .changed::after {
|
||||
content: "待确认";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 6px;
|
||||
background: var(--alert);
|
||||
color: #fff;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 1.5px 7px;
|
||||
border-radius: 2px;
|
||||
line-height: 1.5;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ---------- 浮层纸卡(gw-pop:双击活动改文字 + 编辑器属性卡共用) ---------- */
|
||||
.gw-pop {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
width: 288px;
|
||||
background: #fffdf9;
|
||||
border: 1px solid #c9c2b6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(22, 27, 34, 0.22);
|
||||
padding: 9px 11px 10px;
|
||||
font-family: "PingFang SC", "Noto Sans SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.gw-pop .gp-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.gw-pop .gp-dia {
|
||||
color: #46586a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.gw-pop .gp-nm {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #161b22;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.gw-pop .gp-owner {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #8a5f1c;
|
||||
border: 1px solid #d8c9a8;
|
||||
background: #f6efe0;
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gw-pop .gp-basis {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #3a4452;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.gw-pop .gp-basis.muted {
|
||||
color: #8a93a0;
|
||||
}
|
||||
|
||||
/* ---------- 浮层按钮(be-btn) ---------- */
|
||||
.process-doc .be-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.process-doc .be-btn.ghost {
|
||||
border-color: #cfd8e2;
|
||||
background: #fff;
|
||||
color: #5a6b7e;
|
||||
}
|
||||
.process-doc .be-btn.ghost:hover {
|
||||
border-color: #9fb0c0;
|
||||
}
|
||||
.process-doc .be-btn.solid {
|
||||
background: #46586a;
|
||||
color: #fff;
|
||||
}
|
||||
.process-doc .be-btn.solid:hover {
|
||||
background: #3a4a5a;
|
||||
}
|
||||
.process-doc .be-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- 浮层编辑行(gp-edit-row) ---------- */
|
||||
/* 流程图活动文字就地编辑浮层(gw-pop 同款纸卡) */
|
||||
.gw-pop .gp-edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.gw-pop .gp-edit-row .lb {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
color: #5a6b7e;
|
||||
}
|
||||
.gw-pop .gp-edit-row input {
|
||||
flex: 1;
|
||||
border: 1px solid #cfd8e2;
|
||||
border-radius: 5px;
|
||||
padding: 3px 7px;
|
||||
font-size: 12px;
|
||||
color: #161b22;
|
||||
outline: none;
|
||||
}
|
||||
.gw-pop .gp-edit-row input:focus {
|
||||
border-color: #5a718c;
|
||||
}
|
||||
.gw-pop .gp-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
/* ---------- 手工绘图编辑器(FlowEditor):珊格画布 + 工具条 + 属性卡 ---------- */
|
||||
.process-doc .fig-body {
|
||||
position: relative;
|
||||
}
|
||||
.gw-pop .be-btn.danger {
|
||||
color: #8f3330;
|
||||
border-color: #d8a5a3;
|
||||
}
|
||||
.gw-pop .gp-edit-row select,
|
||||
.gw-pop .gp-edit-row textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #cfd8e2;
|
||||
border-radius: 5px;
|
||||
padding: 3px 6px;
|
||||
font-size: 11.5px;
|
||||
color: #161b22;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
.gw-pop .gp-edit-row textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.flow-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.flow-editor .fe-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e7ed;
|
||||
border-radius: 7px;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
.flow-editor .fe-bar .be-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flow-editor .fe-bar .be-btn.danger {
|
||||
color: #8f3330;
|
||||
border-color: #d8a5a3;
|
||||
}
|
||||
.flow-editor .fe-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #d8dfe7;
|
||||
margin: 0 2px;
|
||||
}
|
||||
.flow-editor .fe-el .fe-chip {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transform: translateY(-1px);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.flow-editor .fe-chip.sop {
|
||||
color: #46586a;
|
||||
}
|
||||
.flow-editor .fe-chip.case {
|
||||
color: #3f7a5c;
|
||||
}
|
||||
.flow-editor .fe-chip.gw {
|
||||
color: #46586a;
|
||||
}
|
||||
.flow-editor .fe-chip.start {
|
||||
color: #3f7a5c;
|
||||
}
|
||||
.flow-editor .fe-chip.end {
|
||||
color: #2f5c45;
|
||||
}
|
||||
.flow-editor .fe-chip.bad {
|
||||
color: #b0413e;
|
||||
}
|
||||
.flow-editor .fe-hint {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
font-size: 10.5px;
|
||||
color: #8a93a0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.flow-editor .fe-error {
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d8a5a3;
|
||||
border-radius: 6px;
|
||||
background: #fdf3f2;
|
||||
color: #8f3330;
|
||||
font-size: 11.5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.flow-editor .fe-canvas {
|
||||
position: relative;
|
||||
border: 1px solid #e2e7ed;
|
||||
border-radius: 7px;
|
||||
background: #fdfefe;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
height: 480px;
|
||||
}
|
||||
.flow-editor .fe-djs {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.flow-editor .fe-foot {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #9aa6b4;
|
||||
}
|
||||
.gw-pop.fe-prop {
|
||||
width: 290px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* ---------- diagram-js 元素盘/上下文小盘:浅色纸面主题 ---------- */
|
||||
/* 元素盘:画布左上角横向铺开(2026-07 user rule)。 */
|
||||
.flow-editor .djs-palette {
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe5ec;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px rgba(22, 27, 34, 0.08);
|
||||
width: auto;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.flow-editor .djs-palette .djs-palette-entries,
|
||||
.flow-editor .djs-palette .group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.flow-editor .djs-palette .entry {
|
||||
width: 38px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
font-size: 15px;
|
||||
color: #46586a;
|
||||
cursor: grab;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.flow-editor .djs-palette .entry:hover {
|
||||
background: #eef3f8;
|
||||
color: #1e3a5f;
|
||||
}
|
||||
.flow-editor .djs-context-pad {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe5ec;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 14px rgba(22, 27, 34, 0.1);
|
||||
padding: 3px;
|
||||
width: auto;
|
||||
}
|
||||
.flow-editor .djs-context-pad .entry {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
color: #46586a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flow-editor .djs-context-pad .entry:hover {
|
||||
background: #eef3f8;
|
||||
}
|
||||
|
||||
/* SVG 图标:与画布元素同形同色(蓝/绿活动框、×/+ 菱形、粗细圆环、红卡点)。 */
|
||||
.fe-pi,
|
||||
.fe-ci {
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.fe-pi-sop {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23E8F1FB" stroke="%234E7DAF" stroke-width="1.5"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
.fe-pi-case {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23E6F1EA" stroke="%233F7A5C" stroke-width="1.5"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
|
||||
.fe-pi-approval {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23FBF0DC" stroke="%23B98432" stroke-width="1.5"/><path d="M7.5 9.2l2.6 2.6 5-5" fill="none" stroke="%23B98432" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
|
||||
/* ---------- 编辑芯片(view-lbl 里的 dc2-addbtn) ---------- */
|
||||
.process-doc .view-lbl .dc2-addbtn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid #b9cfe8;
|
||||
border-radius: 6px;
|
||||
background: #edf4fc;
|
||||
color: #23507e;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn:hover {
|
||||
border-color: #4e7daf;
|
||||
box-shadow: 0 0 0 2px rgba(78, 125, 175, 0.14);
|
||||
}
|
||||
/* 编辑态原位切换:取消/保存 双芯片占据「编辑」的位置(2026-07 user rule)。 */
|
||||
.process-doc .view-lbl .lbl-btns {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.process-doc .view-lbl .lbl-btns .dc2-addbtn {
|
||||
margin-left: 0;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn.primary {
|
||||
background: #4e7daf;
|
||||
border-color: #4e7daf;
|
||||
color: #ffffff;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn.primary:hover {
|
||||
background: #3f6a99;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- 元素盘/小盘 SVG 图标 + 图例小图标 + 选中描边 ---------- */
|
||||
.fe-pi-call {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23EEF1F5" stroke="%235C6B80" stroke-width="1.5"/><rect x="3.5" y="5" width="17" height="8" rx="1.6" fill="none" stroke="%235C6B80" stroke-width="1.1"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
.fe-pi-gwx {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><polygon points="10,1 19,10 10,19 1,10" fill="%23EEF1F5" stroke="%2346586A" stroke-width="1.4"/><path d="M7.2 7.2l5.6 5.6M12.8 7.2l-5.6 5.6" stroke="%2346586A" stroke-width="1.6"/></svg>');
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
.fe-pi-gwand {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><polygon points="10,1 19,10 10,19 1,10" fill="%23EEF1F5" stroke="%2346586A" stroke-width="1.4"/><path d="M10 5.5v9M5.5 10h9" stroke="%2346586A" stroke-width="1.6"/></svg>');
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
.fe-pi-start {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="%23E6F1EA" stroke="%233F7A5C" stroke-width="1.8"/></svg>');
|
||||
background-size: 17px 17px;
|
||||
}
|
||||
.fe-pi-end {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="6.5" fill="%23E6F1EA" stroke="%232F5C45" stroke-width="3"/></svg>');
|
||||
background-size: 17px 17px;
|
||||
}
|
||||
.fe-pi-callout {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16"><rect x="1" y="2" width="22" height="12" rx="3" fill="%23F6E7E6" stroke="%23B0413E" stroke-width="1.5"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
.fe-ci-connect {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M3.5 16.5L13 7" fill="none" stroke="%231E3A5F" stroke-width="1.7"/><polygon points="16.5,3.5 17.2,9 11.4,8.2" fill="%231E3A5F"/></svg>');
|
||||
background-size: 15px 15px;
|
||||
}
|
||||
.fe-ci-del {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4 6h12M8.2 6V4h3.6v2M6 6l1 10.5h6L14 6M8.6 9v4.5M11.4 9v4.5" fill="none" stroke="%238F3330" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"/></svg>');
|
||||
background-size: 15px 15px;
|
||||
}
|
||||
.fe-ci-edit {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.5 15.5l1-3.8 8-8 2.8 2.8-8 8-3.8 1z" fill="%23fff" stroke="%235A6B7E" stroke-width="1.4" stroke-linejoin="round"/><path d="M12 5.2l2.8 2.8" stroke="%235A6B7E" stroke-width="1.2"/></svg>');
|
||||
background-size: 15px 15px;
|
||||
}
|
||||
/* 小盘里的元素图标略缩一号(24px 格)。 */
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-sop,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-case,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-approval,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-callout {
|
||||
background-size: 17px auto;
|
||||
}
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-gwx,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-gwand,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-start,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-end {
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
/* 图例小图标:与元素盘同一套 SVG,缩排进文字行(全局垂直居中契约)。 */
|
||||
.process-doc .legend .lg-i {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 14px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: auto 12px;
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.process-doc .legend .lg-i.fe-pi-sop,
|
||||
.process-doc .legend .lg-i.fe-pi-case,
|
||||
.process-doc .legend .lg-i.fe-pi-approval,
|
||||
.process-doc .legend .lg-i.fe-pi-callout {
|
||||
background-size: 18px auto;
|
||||
}
|
||||
.process-doc .legend .lg-i.lg-flow {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 12"><path d="M2 6h16" fill="none" stroke="%2346586A" stroke-width="1.6"/><polygon points="22,6 16,3 16,9" fill="%2346586A"/></svg>');
|
||||
background-size: 18px auto;
|
||||
}
|
||||
|
||||
/* 选中描边/悬停反馈(diagram-js outline)贴黄铜色。 */
|
||||
.flow-editor .djs-outline {
|
||||
stroke: #b58a2e;
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-width: 1.2px;
|
||||
}
|
||||
.flow-editor .djs-element.hover .djs-outline {
|
||||
stroke: #b58a2e;
|
||||
}
|
||||
.flow-editor .djs-bendpoint .floating {
|
||||
fill: #b58a2e;
|
||||
}
|
||||
.flow-editor .djs-segment-dragger .djs-visual,
|
||||
.flow-editor .djs-bendpoint .djs-visual {
|
||||
fill: #b58a2e;
|
||||
stroke: #8a6a20;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
import { fitLine, type FlowNode, type FlowTone, type FlowVariant } from "./flow-layout";
|
||||
|
||||
/**
|
||||
* 流程图节点/连线的共用画笔:FlowDiagram(diagram-js 渲染)与 FlowEditor(手绘编辑)
|
||||
* 都从这里取形——同一套配色/字级/几何,编辑态与展示态不许有视觉漂移。
|
||||
*/
|
||||
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
|
||||
export function svgEl<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number>,
|
||||
text?: string,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const el = document.createElementNS(svgNS, tag);
|
||||
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value));
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
export const FLOW_SANS = '"PingFang SC","Noto Sans SC","Hiragino Sans GB","Microsoft YaHei",sans-serif';
|
||||
export const FLOW_MONO = '"SF Mono","JetBrains Mono",Menlo,ui-monospace,monospace';
|
||||
|
||||
export const TONE_COLOR: Record<FlowTone, string> = { main: "#46586A", ok: "#3F7A5C", bad: "#B0413E" };
|
||||
|
||||
/** 节点画形(往 group 里追加图元;调用方自己定位 group)。 */
|
||||
export function paintNode(group: SVGElement, node: FlowNode, w: number, h: number, variant: FlowVariant): void {
|
||||
if (node.kind === "start" || node.kind === "end" || node.kind === "end-bad") {
|
||||
const bad = node.kind === "end-bad";
|
||||
group.append(
|
||||
svgEl("circle", {
|
||||
cx: w / 2,
|
||||
cy: h / 2,
|
||||
r: w / 2,
|
||||
fill: bad ? "#F6E7E6" : "#E6F1EA",
|
||||
stroke: bad ? "#B0413E" : "#3F7A5C",
|
||||
"stroke-width": node.kind === "start" ? 1.8 : bad ? 2.4 : 2.6,
|
||||
}),
|
||||
);
|
||||
} else if (node.kind === "gateway") {
|
||||
const hd = w / 2;
|
||||
// 2026-07 精简:网关无弹窗、无铜点——判据存于节点数据,导出派生表与 L3 upstreamGates 承接。
|
||||
group.append(
|
||||
svgEl("polygon", {
|
||||
points: `${hd},0 ${w},${hd} ${hd},${h} 0,${hd}`,
|
||||
fill: "#EEF1F5",
|
||||
stroke: "#46586A",
|
||||
"stroke-width": 1.3,
|
||||
}),
|
||||
);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: hd,
|
||||
y: hd + 4,
|
||||
"text-anchor": "middle",
|
||||
"font-family": FLOW_MONO,
|
||||
"font-size": variant === "business" ? 13 : 12,
|
||||
"font-weight": 700,
|
||||
fill: "#46586A",
|
||||
},
|
||||
node.mode === "and" ? "+" : "×",
|
||||
),
|
||||
);
|
||||
} else if (node.kind === "callout") {
|
||||
// 卡点统一红(绿色让位给 Case 活动节点);去向以框内 →id 表达。
|
||||
group.append(
|
||||
svgEl("rect", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
rx: variant === "business" ? 5 : 6,
|
||||
fill: "#F6E7E6",
|
||||
stroke: "#B0413E",
|
||||
"stroke-width": variant === "business" ? 1.2 : 1.3,
|
||||
}),
|
||||
);
|
||||
const fitted = fitLine(
|
||||
node.to ? `${node.label}→${node.to}` : node.label,
|
||||
w - 12,
|
||||
variant === "business" ? 10 : 11.5,
|
||||
9,
|
||||
);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: w / 2,
|
||||
y: h / 2 + 3.5,
|
||||
"text-anchor": "middle",
|
||||
"font-family": FLOW_SANS,
|
||||
"font-size": fitted.size,
|
||||
"font-weight": 600,
|
||||
fill: "#8F3330",
|
||||
},
|
||||
fitted.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 方框三色:标准活动 浅蓝(缺省)/ 案例活动 绿(一事一议)/ 审批点 琥珀(人裁节点)。
|
||||
// 审批点非活动,只是共用方框几何——模型上它是系统内置的编排要素(见 flow-layout 的 flavor 注释)。
|
||||
// 活动调用已整体退役(2026-07 终拍:所有节点都是内置节点;schema 容忍遗留 wait 但不再有专属画法)。
|
||||
const ghost = node.ghost === true;
|
||||
const isCase = node.flavor === "case";
|
||||
const isApproval = node.flavor === "approval";
|
||||
const skin = isApproval
|
||||
? { fill: "#FBF0DC", stroke: "#B98432", ink: "#6E4B12" }
|
||||
: isCase
|
||||
? { fill: "#E6F1EA", stroke: "#3F7A5C", ink: "#2F5C45" }
|
||||
: { fill: "#E8F1FB", stroke: "#4E7DAF", ink: "#161B22" };
|
||||
group.append(
|
||||
svgEl("rect", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
rx: 6,
|
||||
fill: ghost ? "#F4F6F9" : skin.fill,
|
||||
stroke: ghost ? "#9AA6B4" : skin.stroke,
|
||||
"stroke-width": ghost ? 1.2 : 1.3,
|
||||
...(ghost ? { "stroke-dasharray": "4 3" } : {}),
|
||||
}),
|
||||
);
|
||||
const titleSize = variant === "business" ? 12 : 11.5;
|
||||
const inkColor = ghost ? "#6A7686" : skin.ink;
|
||||
const title = fitLine(node.label, w - 12, titleSize, 10);
|
||||
// L2 主图只留 标号/活动名/责任角标(2026-07 精简)——sub 小字仅步骤图(steps)渲染。
|
||||
if (node.sub && variant !== "business") {
|
||||
const sub = fitLine(node.sub, w - 12, 8.6, 8);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: w / 2, y: 18, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor },
|
||||
title.text,
|
||||
),
|
||||
);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: w / 2, y: 32, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": sub.size, fill: ghost ? "#9AA6B4" : "#6A7686" },
|
||||
sub.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: w / 2, y: h / 2 + 4, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor },
|
||||
title.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
// 标号角标:business 一贯有;步骤图(steps)的真实节点也要 S#/B# 编号与指导书条目对齐
|
||||
// (2026-07 用户纠正)——ghost 骨架节点不标。
|
||||
if (variant === "business" || !ghost) {
|
||||
group.append(
|
||||
svgEl("text", { x: 6, y: 11, "font-family": FLOW_MONO, "font-size": 8.5, fill: "#8A93A0" }, node.code ?? node.id),
|
||||
);
|
||||
}
|
||||
// 责任下沉:执行人角标在右上——图即责任表。
|
||||
if (node.owner && !ghost) {
|
||||
const ownerFit = fitLine(node.owner, w * 0.55, 8.5, 7);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: w - 6,
|
||||
y: 11,
|
||||
"text-anchor": "end",
|
||||
"font-family": FLOW_SANS,
|
||||
"font-size": ownerFit.size,
|
||||
"font-weight": 600,
|
||||
fill: skin.stroke,
|
||||
},
|
||||
ownerFit.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
// 分工角标已随词签退役(2026-07 user「删除 AI/系统/人 选项配置」):步骤图不再画 assign 角标。
|
||||
}
|
||||
}
|
||||
|
||||
/** 箭头三角(按折线最后一段方向)。 */
|
||||
export function arrowHeadPoints(waypoints: Array<{ x: number; y: number }>): string {
|
||||
const tip = waypoints[waypoints.length - 1];
|
||||
const prev = waypoints[waypoints.length - 2];
|
||||
// 严格索引:两点以下画不出箭头,直接退空(布局引擎不会产出这种连线,此处只为类型闭合)
|
||||
if (tip === undefined || prev === undefined) return "";
|
||||
const dx = Math.sign(tip.x - prev.x);
|
||||
const dy = Math.sign(tip.y - prev.y);
|
||||
return dx !== 0
|
||||
? `${tip.x},${tip.y} ${tip.x - dx * 9},${tip.y - 4.5} ${tip.x - dx * 9},${tip.y + 4.5}`
|
||||
: `${tip.x},${tip.y} ${tip.x - 4.5},${tip.y - dy * 9} ${tip.x + 4.5},${tip.y - dy * 9}`;
|
||||
}
|
||||
|
||||
export const CAPTION_STYLE: Record<string, { fill: string; size: (v: FlowVariant) => number; font: string; weight?: number }> = {
|
||||
gateway: { fill: "#3A4452", size: (v) => (v === "business" ? 10 : 9), font: FLOW_SANS, weight: 600 },
|
||||
start: { fill: "#3F7A5C", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||||
end: { fill: "#2F5C45", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||||
"end-bad": { fill: "#8F3330", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||||
note: { fill: "#8A5F1C", size: (v) => (v === "business" ? 9.8 : 8.6), font: FLOW_MONO },
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 流程包 → 画布图模型:**连线即映射**的可视一侧。
|
||||
*
|
||||
* 画布件(`flow-layout` / `flow-node-paint` / `FlowDiagram`)是框架无关的纯几何与画笔,
|
||||
* 它认的是 `FlowGraph`;我们的权威模型是 `ProcessPackage`。这里做且只做一次形状转换,
|
||||
* **不产生任何新语义** —— 节点仍是编排层五要素,审批点仍是系统内置人裁节点(共用方框几何而已)。
|
||||
*/
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import type { FlowEdge, FlowGraph, FlowNode } from './flow-layout.ts';
|
||||
|
||||
/** 节点实例落位:running / waiting / terminal 决定描边高亮,不改几何 */
|
||||
export type InstanceMark = {
|
||||
readonly nodeId: string;
|
||||
readonly running: boolean;
|
||||
readonly waiting: boolean;
|
||||
readonly activated: boolean;
|
||||
readonly terminal?: string | undefined;
|
||||
};
|
||||
|
||||
/** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */
|
||||
const ROW_WIDTH = 4;
|
||||
|
||||
export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark[] = []): FlowGraph {
|
||||
const markOf = new Map(marks.map((m) => [m.nodeId, m]));
|
||||
|
||||
const nodes: FlowNode[] = pkg.nodes.map((n) => {
|
||||
const mark = markOf.get(n.id);
|
||||
const state =
|
||||
mark === undefined || !mark.activated
|
||||
? undefined
|
||||
: mark.terminal !== undefined
|
||||
? mark.terminal === 'released'
|
||||
? '已放行'
|
||||
: '已终止'
|
||||
: mark.running
|
||||
? '运行中'
|
||||
: mark.waiting
|
||||
? '等待中'
|
||||
: '已激活';
|
||||
|
||||
if (n.kind === 'gateway') {
|
||||
return {
|
||||
id: n.id,
|
||||
kind: 'gateway',
|
||||
label: n.label,
|
||||
// 并行网关画 +,异或网关画 ×(判断)
|
||||
mode: n.gateway === 'parallel' ? 'and' : 'x',
|
||||
...(n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }),
|
||||
...(state === undefined ? {} : { note: state }),
|
||||
};
|
||||
}
|
||||
|
||||
if (n.kind === 'start') return { id: n.id, kind: 'start', label: n.label };
|
||||
if (n.kind === 'end') return { id: n.id, kind: 'end', label: n.label };
|
||||
|
||||
const base: FlowNode = {
|
||||
id: n.id,
|
||||
kind: 'activity',
|
||||
label: n.label,
|
||||
code: n.id,
|
||||
...(state === undefined ? {} : { note: state }),
|
||||
};
|
||||
if (n.kind === 'approval') {
|
||||
// approval **不是一种活动**:它挂在 kind:"activity" 下仅因共用同一套方框几何
|
||||
return { ...base, flavor: 'approval', owner: n.approver.position };
|
||||
}
|
||||
return { ...base, owner: n.owner.position };
|
||||
});
|
||||
|
||||
/** 蛇形折行:偶数行 L→R、奇数行 R→L —— 行的划分交给拓扑序 */
|
||||
const order = topologicalOrder(pkg);
|
||||
const rows: string[][] = [];
|
||||
for (let i = 0; i < order.length; i += ROW_WIDTH) rows.push(order.slice(i, i + ROW_WIDTH));
|
||||
|
||||
const edges: FlowEdge[] = pkg.edges.map((e) => ({
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
// 回边按声明走「回 A4 重开新实例」,视觉上标红以区分正向流转
|
||||
...(e.reopen ? { tone: 'bad' as const } : {}),
|
||||
...(e.label === undefined ? (e.condition === undefined ? {} : { label: e.condition }) : { label: e.label }),
|
||||
}));
|
||||
|
||||
return { nodes, rows, anchors: [], edges };
|
||||
}
|
||||
|
||||
/** 拓扑序:回边不参与定序(否则成环),同层按声明顺序稳定排列 */
|
||||
function topologicalOrder(pkg: ProcessPackage): string[] {
|
||||
const ids = pkg.nodes.map((n) => n.id as string);
|
||||
const forward = pkg.edges.filter((e) => !e.reopen);
|
||||
const indegree = new Map<string, number>(ids.map((id) => [id, 0]));
|
||||
for (const e of forward) indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1);
|
||||
|
||||
const ready = ids.filter((id) => indegree.get(id) === 0);
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (ready.length > 0) {
|
||||
const id = ready.shift()!;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
for (const e of forward.filter((x) => x.from === id)) {
|
||||
const left = (indegree.get(e.to) ?? 0) - 1;
|
||||
indegree.set(e.to, left);
|
||||
if (left === 0) ready.push(e.to);
|
||||
}
|
||||
}
|
||||
// 环(理论上闭合校验已挡住)或孤点:兜底补齐,画布不因数据异常而空白
|
||||
for (const id of ids) if (!seen.has(id)) out.push(id);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 抽离配套件:画布组件对宿主应用仅有的两个依赖,原样搬来使之自足。
|
||||
* - SpPosition 来自 apps/web/src/lib/process-types.ts(业务能力/岗位台账行)
|
||||
* - OwnerSelect 来自 apps/web/src/components/workspace/process/BlockEdit.tsx
|
||||
* 执行人(owner)是按"能力缩写"的软引用:活动框右上角标、属性卡的执行下拉都用它。
|
||||
*/
|
||||
|
||||
/** 能力岗位:工作总览里的角色台账。 */
|
||||
export interface SpPosition {
|
||||
id: string;
|
||||
name: string;
|
||||
abbr: string;
|
||||
icon?: string;
|
||||
/** 绑定的负责人账号 userId(1:1)。 */
|
||||
account?: string;
|
||||
}
|
||||
|
||||
/** 执行人下拉:只能选能力缩写,空=不标;遗留值保留为一项防静默改值。 */
|
||||
export function OwnerSelect({
|
||||
value,
|
||||
onChange,
|
||||
positions,
|
||||
emptyLabel = "(不标执行人)",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
positions: SpPosition[] | undefined;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
// owner 是按缩写的软引用——同缩写只保留一条(否则重复能力会在下拉里重影)。
|
||||
const seenAbbr = new Set<string>();
|
||||
const options = (positions ?? []).filter((p) => {
|
||||
const abbr = p.abbr.trim();
|
||||
if (!abbr || seenAbbr.has(abbr)) return false;
|
||||
seenAbbr.add(abbr);
|
||||
return true;
|
||||
});
|
||||
const legacy = value && !options.some((p) => p.abbr === value) ? value : null;
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{emptyLabel}</option>
|
||||
{legacy && <option value={legacy}>{legacy}(遗留)</option>}
|
||||
{options.map((p) => (
|
||||
<option key={p.id} value={p.abbr}>
|
||||
{p.abbr} · {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 治理台取数 —— 每个查询一把键,写动作后按键失效。
|
||||
* 前端零过程状态:所有画面都是服务端投影的函数,刷新即重建(§9.2)。
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import type {
|
||||
GovActivitiesOutput,
|
||||
GovApprovalsOutput,
|
||||
GovChannelsOutput,
|
||||
GovGrantsOutput,
|
||||
GovLedgerPageOutput,
|
||||
GovLlmOutput,
|
||||
GovMemoryOutput,
|
||||
GovMetricsOutput,
|
||||
GovObjectsOutput,
|
||||
GovPackagesOutput,
|
||||
GovStaffOutput,
|
||||
GovTaskLogOutput,
|
||||
GovTasksOutput,
|
||||
GovToolsOutput,
|
||||
GovTriggersOutput,
|
||||
GovWorkflowOutput,
|
||||
} from '@contracts';
|
||||
import {
|
||||
browseLedgerRoute,
|
||||
govMetricsRoute,
|
||||
govTaskLogRoute,
|
||||
govWorkflowRoute,
|
||||
listActivitiesRoute,
|
||||
listActivityMemoryRoute,
|
||||
listApprovalsRoute,
|
||||
listChannelsRoute,
|
||||
listGovTasksRoute,
|
||||
listGrantsRoute,
|
||||
listLedgerObjectsRoute,
|
||||
listLlmInstancesRoute,
|
||||
listProcessPackagesRoute,
|
||||
listStaffRoute,
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
} from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
|
||||
export type GovTasks = GovTasksOutput;
|
||||
export type GovApprovals = GovApprovalsOutput;
|
||||
export type GovStaff = GovStaffOutput;
|
||||
export type GovActivities = GovActivitiesOutput;
|
||||
export type GovTools = GovToolsOutput;
|
||||
export type GovObjects = GovObjectsOutput;
|
||||
export type GovLedgerPage = GovLedgerPageOutput;
|
||||
export type GovMemory = GovMemoryOutput;
|
||||
export type GovMetrics = GovMetricsOutput;
|
||||
export type GovChannels = GovChannelsOutput;
|
||||
export type GovLlm = GovLlmOutput;
|
||||
export type GovWorkflow = GovWorkflowOutput;
|
||||
export type GovTaskLog = GovTaskLogOutput;
|
||||
export type GovGrants = GovGrantsOutput;
|
||||
|
||||
export const useGovTasks = (): UseQueryResult<GovTasks> =>
|
||||
useQuery({ queryKey: ['gov', 'tasks'], queryFn: () => call<GovTasks>(listGovTasksRoute) });
|
||||
|
||||
export const useGovApprovals = (): UseQueryResult<GovApprovals> =>
|
||||
useQuery({ queryKey: ['gov', 'approvals'], queryFn: () => call<GovApprovals>(listApprovalsRoute) });
|
||||
|
||||
export const useGovStaff = (): UseQueryResult<GovStaff> =>
|
||||
useQuery({ queryKey: ['gov', 'staff'], queryFn: () => call<GovStaff>(listStaffRoute) });
|
||||
|
||||
export const useGovGrants = (positionId: string | undefined): UseQueryResult<GovGrants> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'grants', positionId],
|
||||
queryFn: () => call<GovGrants>(listGrantsRoute, { params: { positionId: positionId! } }),
|
||||
enabled: positionId !== undefined,
|
||||
});
|
||||
|
||||
export const useGovPackages = (): UseQueryResult<GovPackagesOutput> =>
|
||||
useQuery({ queryKey: ['gov', 'process'], queryFn: () => call<GovPackagesOutput>(listProcessPackagesRoute) });
|
||||
|
||||
export const useGovTriggers = (): UseQueryResult<GovTriggersOutput> =>
|
||||
useQuery({ queryKey: ['gov', 'triggers'], queryFn: () => call<GovTriggersOutput>(listTriggersRoute) });
|
||||
|
||||
export const useGovActivities = (): UseQueryResult<GovActivities> =>
|
||||
useQuery({ queryKey: ['gov', 'activities'], queryFn: () => call<GovActivities>(listActivitiesRoute) });
|
||||
|
||||
export const useGovTools = (): UseQueryResult<GovTools> =>
|
||||
useQuery({ queryKey: ['gov', 'tools'], queryFn: () => call<GovTools>(listToolsRoute) });
|
||||
|
||||
export const useGovObjects = (): UseQueryResult<GovObjects> =>
|
||||
useQuery({ queryKey: ['gov', 'objects'], queryFn: () => call<GovObjects>(listLedgerObjectsRoute) });
|
||||
|
||||
export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'ledger', domain, object],
|
||||
queryFn: () => call<GovLedgerPage>(browseLedgerRoute, { params: { domain: domain!, object: object! } }),
|
||||
enabled: domain !== undefined && object !== undefined,
|
||||
});
|
||||
|
||||
export const useGovMemory = (activityId: string | undefined): UseQueryResult<GovMemory> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'memory', activityId],
|
||||
queryFn: () => call<GovMemory>(listActivityMemoryRoute, { params: { activityId: activityId! } }),
|
||||
enabled: activityId !== undefined,
|
||||
});
|
||||
|
||||
export const useGovMetrics = (): UseQueryResult<GovMetrics> =>
|
||||
useQuery({ queryKey: ['gov', 'metrics'], queryFn: () => call<GovMetrics>(govMetricsRoute) });
|
||||
|
||||
export const useGovChannels = (): UseQueryResult<GovChannels> =>
|
||||
useQuery({ queryKey: ['gov', 'channels'], queryFn: () => call<GovChannels>(listChannelsRoute) });
|
||||
|
||||
export const useGovLlm = (): UseQueryResult<GovLlm> =>
|
||||
useQuery({ queryKey: ['gov', 'llm'], queryFn: () => call<GovLlm>(listLlmInstancesRoute) });
|
||||
|
||||
export const useGovWorkflow = (id: string): UseQueryResult<GovWorkflow> =>
|
||||
useQuery({ queryKey: ['gov', 'workflow', id], queryFn: () => call<GovWorkflow>(govWorkflowRoute, { params: { id } }) });
|
||||
|
||||
export const useGovTaskLog = (taskId: string): UseQueryResult<GovTaskLog> =>
|
||||
useQuery({ queryKey: ['gov', 'tasklog', taskId], queryFn: () => call<GovTaskLog>(govTaskLogRoute, { params: { taskId } }) });
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 治理台的分级页面路由 —— 自原型 `Governance.tsx` 第 25–90 行平移。
|
||||
*
|
||||
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
|
||||
* 区 → 对象 → 子维度 → 元素,顶部面包屑可回跳任意一级,Esc / 返回退一级。
|
||||
*/
|
||||
|
||||
export type Route =
|
||||
| { k: 'wf'; id: string }
|
||||
| { k: 'task'; id: string }
|
||||
| { k: 'tasklog'; id: string }
|
||||
| { k: 'apr'; id: string }
|
||||
| { k: 'emp'; id: string }
|
||||
| { k: 'role'; id: string }
|
||||
| { k: 'pkg'; id: string }
|
||||
| { k: 'edges'; id: string }
|
||||
| { k: 'trigger'; id: string }
|
||||
| { k: 'act'; id: string }
|
||||
| { k: 'tool'; id: string }
|
||||
| { k: 'obj'; id: string }
|
||||
| { k: 'mem'; id: string }
|
||||
| { k: 'llm'; id: string }
|
||||
| { k: 'chan'; id: string };
|
||||
|
||||
/** 九功能 —— 每一项对应架构中的一件,治理台由此覆盖全部五层 */
|
||||
export const GOV_ZONES = ['tasks', 'people', 'workflows', 'triggers', 'library', 'tools', 'data', 'memory', 'system'] as const;
|
||||
export type GovZone = (typeof GOV_ZONES)[number];
|
||||
|
||||
export const ZONES: ReadonlyArray<{ key: GovZone; label: string; icon: string }> = [
|
||||
{ key: 'tasks', label: '任务', icon: 'activity' },
|
||||
{ key: 'people', label: '员工', icon: 'users' },
|
||||
{ key: 'workflows', label: '工作流', icon: 'workflow' },
|
||||
{ key: 'triggers', label: '触发器', icon: 'zap' },
|
||||
{ key: 'library', label: '活动库', icon: 'boxes' },
|
||||
{ key: 'tools', label: '工具', icon: 'wrench' },
|
||||
{ key: 'data', label: '数据', icon: 'database' },
|
||||
{ key: 'memory', label: '记忆', icon: 'layers' },
|
||||
{ key: 'system', label: '系统', icon: 'settings' },
|
||||
];
|
||||
|
||||
/** 区元信息:标题 / 定位说明 / 主色 —— 说明文取自两卷规范,不是装饰 */
|
||||
export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent: string; icon: string }>> = {
|
||||
tasks: {
|
||||
title: '任务管理',
|
||||
icon: 'activity',
|
||||
accent: 'linear-gradient(140deg,#3370ff,#245bdb)',
|
||||
desc: '运行态:任务与工作流两级运行时实例的在途监控、干预与控制——任务 = 活动被调起的一次运行,恰有一名负责人;工作流 = 流程包的一次运行,状态由流内各任务聚合投影而来。审批点卡到审批人工位,是流程层的人裁节点、不是任务。控制事件三种语义:暂停可恢复、叫停归负责人、终止级联作废。治理不改定义。',
|
||||
},
|
||||
people: {
|
||||
title: '员工管理',
|
||||
icon: 'users',
|
||||
accent: 'linear-gradient(140deg,#2bc7d4,#0e9fd8)',
|
||||
desc: '员工负载、岗位与授权。权限按岗位授予:客体三类 × 动作三种;停用前置检查——在途任务与待答卡须先改派完毕。',
|
||||
},
|
||||
workflows: {
|
||||
title: '工作流管理',
|
||||
icon: 'workflow',
|
||||
accent: 'linear-gradient(140deg,#4954e6,#3a44c9)',
|
||||
desc: '设计态:流程包的五要素编排(活动 · 审批点 · 网关 · 触发器 · 流程包)与版本。编排即定契约——连线即映射、画图即校验,边不闭合不得发布。工作流 = 流程包的运行时实例。',
|
||||
},
|
||||
triggers: {
|
||||
title: '触发器配置',
|
||||
icon: 'zap',
|
||||
accent: 'linear-gradient(140deg,#ff8800,#de7802)',
|
||||
desc: '起单时机的规则清单:周期 / 事件 / 登记三类。触发器与流程包平级——不入包结构、不随包版本,增删启停不改任何版本。',
|
||||
},
|
||||
library: {
|
||||
title: '活动库管理',
|
||||
icon: 'boxes',
|
||||
accent: 'linear-gradient(140deg,#0aa294,#0a6a64)',
|
||||
desc: '已安装活动的资产货架:活动是任务的定义——编排引用一个活动,运行时就长出一个任务。审批点与网关是系统内置要素,零开发、不入库。业务分类 × 活动类型两维,每活动两条版本序列。',
|
||||
},
|
||||
tools: {
|
||||
title: '工具管理',
|
||||
icon: 'wrench',
|
||||
accent: 'linear-gradient(140deg,#7f3bf5,#5b21cf)',
|
||||
desc: '能力目录:功能工具按效应等级分列——读类过程中自由调用,写类只登记动作意向、放行后落地。密钥只存治理台。',
|
||||
},
|
||||
data: {
|
||||
title: '数据管理',
|
||||
icon: 'database',
|
||||
accent: 'linear-gradient(140deg,#0e9fd8,#245bdb)',
|
||||
desc: '域 × 对象的承载全景:声明物化(最大化合并、只增不破坏)与调用接入(事实源在外)。数据可以自生长,权力不可以自生长。',
|
||||
},
|
||||
memory: {
|
||||
title: '记忆管理',
|
||||
icon: 'layers',
|
||||
accent: 'linear-gradient(140deg,#f14ba9,#c92d86)',
|
||||
desc: '度量仪表 + 进化队列:自主率与纠正率成对读,复合为有效自主率。候选增量必经回放与人放行才成为模型。',
|
||||
},
|
||||
system: {
|
||||
title: '系统管理',
|
||||
icon: 'settings',
|
||||
accent: 'linear-gradient(140deg,#46586a,#2b3644)',
|
||||
desc: '系统边界的配置面:LLM 实例(名称间接层)、通道实例(归属键规则与默认认领岗位)、系统用途模型绑定。',
|
||||
},
|
||||
};
|
||||
|
||||
/** 区内页签:一页一个维度 —— 不把四种维度摞在同一屏 */
|
||||
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
|
||||
tasks: [
|
||||
{ key: 'board', label: '运行看板', ico: 'activity' },
|
||||
{ key: 'list', label: '任务', ico: 'list-checks' },
|
||||
{ key: 'apr', label: '审批单', ico: 'shield-check' },
|
||||
{ key: 'todo', label: '需处置', ico: 'flag' },
|
||||
],
|
||||
people: [
|
||||
{ key: 'emp', label: '员工', ico: 'users' },
|
||||
{ key: 'roles', label: '岗位授权', ico: 'key' },
|
||||
],
|
||||
workflows: [{ key: 'pkgs', label: '流程包', ico: 'boxes' }],
|
||||
triggers: [{ key: 'rules', label: '起单规则', ico: 'zap' }],
|
||||
library: [{ key: 'shelf', label: '活动货架', ico: 'boxes' }],
|
||||
tools: [{ key: 'catalog', label: '能力目录', ico: 'wrench' }],
|
||||
data: [{ key: 'domains', label: '数据对象', ico: 'database' }],
|
||||
memory: [
|
||||
{ key: 'metrics', label: '度量仪表', ico: 'gauge' },
|
||||
{ key: 'layers', label: '活动记忆', ico: 'layers' },
|
||||
],
|
||||
system: [
|
||||
{ key: 'llm', label: 'LLM 实例', ico: 'cpu' },
|
||||
{ key: 'chan', label: '通道实例', ico: 'mail' },
|
||||
],
|
||||
};
|
||||
|
||||
export function routeLabel(r: Route): string {
|
||||
switch (r.k) {
|
||||
case 'wf':
|
||||
return '工作流';
|
||||
case 'task':
|
||||
return '任务';
|
||||
case 'tasklog':
|
||||
return '任务日志';
|
||||
case 'apr':
|
||||
return '审批单';
|
||||
case 'emp':
|
||||
return '员工';
|
||||
case 'role':
|
||||
return '岗位授权';
|
||||
case 'pkg':
|
||||
return '流程包';
|
||||
case 'edges':
|
||||
return '边映射';
|
||||
case 'trigger':
|
||||
return '触发器';
|
||||
case 'act':
|
||||
return '活动';
|
||||
case 'tool':
|
||||
return '工具';
|
||||
case 'obj':
|
||||
return '数据对象';
|
||||
case 'mem':
|
||||
return '活动记忆';
|
||||
case 'llm':
|
||||
return 'LLM 实例';
|
||||
case 'chan':
|
||||
return '通道实例';
|
||||
}
|
||||
}
|
||||
|
||||
export type Nav = {
|
||||
push: (r: Route) => void;
|
||||
jump: (z: GovZone, tab: string, r?: Route) => void;
|
||||
};
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* 治理台小件 —— 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 第 138–387 行平移。
|
||||
*
|
||||
* **DOM 结构与类名原样保留**(`g-*` + 色调字典 `t-*`),只做三处必要改写(README 平移规则 1/2/4):
|
||||
* · 去掉 `"use client"`(我们不是 Next);
|
||||
* · 显式导出(原型是同文件私有函数);
|
||||
* · 显式返回类型(编码规范 §11.3 `explicit-module-boundary-types` 不覆盖 apps/web,但同风格保留)。
|
||||
*
|
||||
* 这些件是**纯呈现**:不取数、不调 API,数据一律由调用方传入。
|
||||
*/
|
||||
import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
|
||||
export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`g-chip t-${tone}`}>
|
||||
{ico && <Ico name={ico} s={10} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function St({ tone, ico, children }: { tone: string; ico: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`g-st t-${tone}`}>
|
||||
<Ico name={ico} s={13} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Ava({ name }: { name: string }) {
|
||||
return <span className="g-avatar">{name.slice(0, 1)}</span>;
|
||||
}
|
||||
|
||||
export function Note({ tone, children }: { tone?: 'warn' | 'ok'; children: ReactNode }) {
|
||||
return (
|
||||
<div className={'g-note' + (tone ? ' ' + tone : '')}>
|
||||
<Ico name={tone === 'warn' ? 'alert-triangle' : tone === 'ok' ? 'circle-check' : 'info'} s={14} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Band({ ico, children, extra }: { ico: string; children: ReactNode; extra?: string }) {
|
||||
return (
|
||||
<div className="g-band">
|
||||
<Ico name={ico} s={13} />
|
||||
{children}
|
||||
{extra && <span className="x">{extra}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sec({ ico, title, extra, children }: { ico: string; title: string; extra?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="g-sec">
|
||||
<Band ico={ico} {...(extra === undefined ? {} : { extra })}>
|
||||
{title}
|
||||
</Band>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Kpi({
|
||||
tone,
|
||||
ico,
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
sub,
|
||||
dial,
|
||||
}: {
|
||||
tone?: string;
|
||||
ico: string;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
unit?: string;
|
||||
sub?: string;
|
||||
dial?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={'g-kpi' + (tone ? ' t-' + tone : '')}>
|
||||
<span className="g-kpi-l">
|
||||
<Ico name={ico} s={12} />
|
||||
{label}
|
||||
</span>
|
||||
{dial !== undefined ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Dial v={dial} tone={tone ?? 'brand'} />
|
||||
<span className="g-kpi-v">
|
||||
{value}
|
||||
{unit && <small>{unit}</small>}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="g-kpi-v">
|
||||
{value}
|
||||
{unit && <small>{unit}</small>}
|
||||
</span>
|
||||
)}
|
||||
{sub && <span className="g-kpi-s">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dial({ v, tone = 'brand', size = 42 }: { v: number; tone?: string; size?: number }) {
|
||||
const r = (size - 6) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
return (
|
||||
<span className={`g-dial t-${tone}`}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle className="trk" cx={size / 2} cy={size / 2} r={r} strokeWidth={5} />
|
||||
<circle
|
||||
className="val"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
strokeWidth={5}
|
||||
stroke="var(--tone)"
|
||||
strokeDasharray={`${(c * v) / 100} ${c}`}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="g-card">
|
||||
<div className="g-card-h">
|
||||
<Ico name={ico} s={14} />
|
||||
{title}
|
||||
{extra && <span className="x">{extra}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Rows({ items }: { items: ReadonlyArray<{ k: string; v: ReactNode; mono?: boolean }> }) {
|
||||
return (
|
||||
<div className="g-rows">
|
||||
{items.map((r, i) => (
|
||||
<div key={i}>
|
||||
<em>{r.k}</em>
|
||||
<b className={r.mono ? 'mono' : ''}>{r.v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dist({ rows }: { rows: ReadonlyArray<{ k: string; v: number; tone: string; label?: string }> }) {
|
||||
return (
|
||||
<div className="g-dist">
|
||||
{rows.map((r) => (
|
||||
<div key={r.k} className={`g-dist-row t-${r.tone}`}>
|
||||
<span className="g-dist-l">{r.k}</span>
|
||||
<span className="g-dist-t">
|
||||
<i style={{ width: `${r.v}%` }} />
|
||||
</span>
|
||||
<b className="g-dist-v">{r.label ?? `${r.v}%`}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bars({ data, tone = 'brand' }: { data: readonly number[]; tone?: string }) {
|
||||
const max = Math.max(1, ...data);
|
||||
return (
|
||||
<span className={`g-bars t-${tone}`}>
|
||||
{data.map((d, i) => (
|
||||
<i key={i} className={i === data.length - 1 ? 'hot' : ''} style={{ height: `${Math.max(8, (d / max) * 100)}%` }} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Load({ n, max, tone = 'brand' }: { n: number; max: number; tone?: string }) {
|
||||
return (
|
||||
<span className={`g-load t-${tone}`}>
|
||||
<span className="g-load-t">
|
||||
<i style={{ width: `${Math.min(100, (n / max) * 100)}%` }} />
|
||||
</span>
|
||||
<b>{n}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 小事实块(预算 / 要素统计等定量并列项) */
|
||||
export function Facts({ items }: { items: ReadonlyArray<{ k: string; v: string; tone?: string; ratio?: number }> }) {
|
||||
return (
|
||||
<div className="g-facts">
|
||||
{items.map((f, i) => (
|
||||
<div key={i} className={'g-fact' + (f.tone ? ' t-' + f.tone : '')}>
|
||||
<em>{f.k}</em>
|
||||
<b>{f.v}</b>
|
||||
{f.ratio !== undefined && (
|
||||
<span className="t">
|
||||
<i style={{ width: `${Math.min(100, f.ratio * 100)}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 可下钻的清单行:清单只负责导航,动作留给详情页 ---- */
|
||||
|
||||
export function NList({ children }: { children: ReactNode }) {
|
||||
return <div className="g-nlist">{children}</div>;
|
||||
}
|
||||
|
||||
export function NRow({
|
||||
tone = 'mute',
|
||||
ico,
|
||||
title,
|
||||
sub,
|
||||
cols,
|
||||
right,
|
||||
onClick,
|
||||
}: {
|
||||
tone?: string;
|
||||
ico: string;
|
||||
title: ReactNode;
|
||||
sub?: ReactNode;
|
||||
cols?: ReadonlyArray<{ k: string; v: ReactNode }>;
|
||||
right?: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className={`g-nrow t-${tone}`} onClick={onClick}>
|
||||
<span className="g-nrow-i">
|
||||
<Ico name={ico} s={15} />
|
||||
</span>
|
||||
<span className="g-nrow-t">
|
||||
<b>{title}</b>
|
||||
{sub && <span>{sub}</span>}
|
||||
</span>
|
||||
{cols && (
|
||||
<span className="g-nrow-m">
|
||||
{cols.map((c, i) => (
|
||||
<span key={i} className="g-nrow-c">
|
||||
<em>{c.k}</em>
|
||||
<b>{c.v}</b>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{right && <span className="g-nrow-r">{right}</span>}
|
||||
<span className="g-nrow-x">
|
||||
<Ico name="chevron-right" s={16} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 下一级入口(详情页里指向子维度页) */
|
||||
export function Entry({
|
||||
ico,
|
||||
tone = 'brand',
|
||||
title,
|
||||
desc,
|
||||
meta,
|
||||
onClick,
|
||||
}: {
|
||||
ico: string;
|
||||
tone?: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
meta?: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className={`g-entry t-${tone}`} onClick={onClick}>
|
||||
<span className="g-entry-i">
|
||||
<Ico name={ico} s={17} />
|
||||
</span>
|
||||
<span className="g-entry-t">
|
||||
<b>{title}</b>
|
||||
<em>{desc}</em>
|
||||
</span>
|
||||
{meta && <span className="g-entry-m">{meta}</span>}
|
||||
<span className="g-entry-x">
|
||||
<Ico name="chevron-right" s={16} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 属性卡:就地填写,不下钻 ---- */
|
||||
|
||||
export function Prop({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="g-prop">
|
||||
<div className="g-prop-h">
|
||||
<Ico name={ico} s={13} />
|
||||
{title}
|
||||
{extra && <span className="x">{extra}</span>}
|
||||
</div>
|
||||
<div className="g-prop-b">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ k, hint, children }: { k: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="g-field">
|
||||
<em>
|
||||
{k}
|
||||
{hint && <i>{hint}</i>}
|
||||
</em>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Seg({
|
||||
value,
|
||||
options,
|
||||
onPick,
|
||||
}: {
|
||||
value: string;
|
||||
options: ReadonlyArray<{ k: string; label: string }>;
|
||||
onPick: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="g-seg">
|
||||
{options.map((o) => (
|
||||
<button key={o.k} className={'g-seg-b' + (value === o.k ? ' on' : '')} onClick={() => onPick(o.k)}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListEdit({
|
||||
items,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
items: readonly string[];
|
||||
placeholder: string;
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="g-listedit">
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className="g-le-row">
|
||||
<input
|
||||
className="g-input"
|
||||
value={it}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(items.map((x, j) => (j === i ? e.target.value : x)))}
|
||||
/>
|
||||
<button className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||
<Ico name="trash-2" s={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <span className="g-le-e">未声明</span>}
|
||||
<button className="g-le-add" onClick={() => onChange([...items, ''])}>
|
||||
<Ico name="plus" s={12} />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 就地确认带:影响预览三必答,不弹窗、不遮挡 ---- */
|
||||
|
||||
export type AskSpec = {
|
||||
title: string;
|
||||
note?: ReactNode;
|
||||
/** 三必答 —— 治理动作在按下之前就把后果讲清楚 */
|
||||
impact: { who: string; undo: string; when: string };
|
||||
okLabel: string;
|
||||
onOk: () => void;
|
||||
};
|
||||
|
||||
export type ActSpec = {
|
||||
label: string;
|
||||
ico?: string;
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
ask?: AskSpec;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: boolean; onCancel: () => void }) {
|
||||
return (
|
||||
<div className={'g-ask t-' + (danger ? 'danger' : 'warn')}>
|
||||
<div className="g-ask-h">
|
||||
<Ico name={danger ? 'alert-triangle' : 'shield-check'} s={15} />
|
||||
{spec.title}
|
||||
</div>
|
||||
{spec.note}
|
||||
<div className="g-impact">
|
||||
<div>
|
||||
<em>影响谁</em>
|
||||
<b>{spec.impact.who}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>可否撤销</em>
|
||||
<b>{spec.impact.undo}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>何时生效</em>
|
||||
<b>{spec.impact.when}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="g-ask-a">
|
||||
<button className="g-btn" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={'g-btn ' + (danger ? 'danger' : 'primary')}
|
||||
onClick={() => {
|
||||
spec.onOk();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{spec.okLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; align?: 'start' | 'end' }) {
|
||||
const [armed, setArmed] = useState<number | null>(null);
|
||||
const a = armed !== null ? items[armed] : null;
|
||||
return (
|
||||
<div className="g-acts">
|
||||
<div className="g-acts-r" style={{ justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }}>
|
||||
{items.map((it, i) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={it.disabled === true}
|
||||
className={
|
||||
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
|
||||
}
|
||||
onClick={() => (it.ask ? setArmed(armed === i ? null : i) : it.onClick?.())}
|
||||
>
|
||||
{it.ico && <Ico name={it.ico} s={13} />}
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 详情页头 ---- */
|
||||
|
||||
export function PageHead({
|
||||
ico,
|
||||
tone = 'brand',
|
||||
kicker,
|
||||
title,
|
||||
chips,
|
||||
desc,
|
||||
acts,
|
||||
}: {
|
||||
ico: string;
|
||||
tone?: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
chips?: ReactNode;
|
||||
desc?: ReactNode;
|
||||
acts?: readonly ActSpec[];
|
||||
}) {
|
||||
const [armed, setArmed] = useState<number | null>(null);
|
||||
const a = acts && armed !== null ? acts[armed] : null;
|
||||
return (
|
||||
<div className="g-dhwrap">
|
||||
<div className={`g-dh t-${tone}`}>
|
||||
<span className="g-dh-i">
|
||||
<Ico name={ico} s={20} />
|
||||
</span>
|
||||
<div className="g-dh-t">
|
||||
<em>{kicker}</em>
|
||||
<h2>{title}</h2>
|
||||
{chips && <div className="chips">{chips}</div>}
|
||||
{desc && <p>{desc}</p>}
|
||||
</div>
|
||||
{acts && (
|
||||
<div className="g-dh-a">
|
||||
{acts.map((it, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={
|
||||
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
|
||||
}
|
||||
onClick={() => (it.ask ? setArmed(armed === i ? null : i) : it.onClick?.())}
|
||||
>
|
||||
{it.ico && <Ico name={it.ico} s={13} />}
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 需处置卡:一条偏差 + 就地处置 */
|
||||
export function Todo({
|
||||
tone = 'danger',
|
||||
ico,
|
||||
title,
|
||||
desc,
|
||||
acts,
|
||||
}: {
|
||||
tone?: string;
|
||||
ico: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
acts: readonly ActSpec[];
|
||||
}) {
|
||||
return (
|
||||
<div className={`g-todo-card t-${tone}`}>
|
||||
<div className="g-todo-row">
|
||||
<span className="g-todo-i">
|
||||
<Ico name={ico} s={15} />
|
||||
</span>
|
||||
<span className="g-todo-t">
|
||||
<b>{title}</b>
|
||||
<span>{desc}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Acts items={acts} align="end" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Clear({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="g-clear">
|
||||
<Ico name="circle-check" s={16} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 区首页/详情页共用的板栅 */
|
||||
export function Board({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
||||
return (
|
||||
<div className="g-board" {...(style === undefined ? {} : { style })}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Kpis({ children }: { children: ReactNode }) {
|
||||
return <div className="g-kpis">{children}</div>;
|
||||
}
|
||||
|
||||
export function Todos({ children }: { children: ReactNode }) {
|
||||
return <div className="g-todo">{children}</div>;
|
||||
}
|
||||
|
||||
export type Armed = [number | null, Dispatch<SetStateAction<number | null>>];
|
||||
|
||||
/**
|
||||
* 取数态:载入 / 无权 / 出错 —— 三种都得**说出来**。
|
||||
*
|
||||
* 治理台尤其不能把「无权查看」画成空清单:那会让人误以为系统里什么都没有。
|
||||
* 权限不足按岗位说明缺哪一项,而不是笼统「失败」。
|
||||
*/
|
||||
export function QueryGate({
|
||||
query,
|
||||
children,
|
||||
}: {
|
||||
query: { isPending: boolean; isError: boolean; error: unknown };
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="g-clear">
|
||||
<Ico name="clock" s={16} />
|
||||
载入中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query.isError) {
|
||||
const error = query.error as { status?: number; message?: string } | undefined;
|
||||
const forbidden = error?.status === 403;
|
||||
return (
|
||||
<Note tone="warn">
|
||||
{forbidden ? (
|
||||
<>
|
||||
<b>无权查看</b> —— {error?.message ?? ''}。权限按岗位授予:客体三类 × 动作三种;
|
||||
换岗即换权,需要请找有该治理功能授权的岗位开通。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>取数失败</b> —— {error?.message ?? '未知错误'}
|
||||
</>
|
||||
)}
|
||||
</Note>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 状态 → 色调 / 图标字典 —— 自原型 `Governance.tsx` 第 92–116 行平移。
|
||||
* 原型用中文状态名做键;我们的状态名是契约里的枚举,故键换成枚举、标签仍取契约的 `*_LABEL`。
|
||||
*/
|
||||
import type { Paradigm, TaskRuntimeState, TerminalState, WorkflowState } from '@contracts';
|
||||
|
||||
export type ToneIco = { tone: string; ico: string };
|
||||
|
||||
export const WF_ST: Readonly<Record<WorkflowState, ToneIco>> = {
|
||||
running: { tone: 'brand', ico: 'activity' },
|
||||
waiting: { tone: 'warn', ico: 'hourglass' },
|
||||
completed: { tone: 'ok', ico: 'circle-check' },
|
||||
terminated: { tone: 'mute', ico: 'power' },
|
||||
abnormal: { tone: 'danger', ico: 'alert-triangle' },
|
||||
};
|
||||
|
||||
export const RUNTIME_ST: Readonly<Record<TaskRuntimeState, ToneIco>> = {
|
||||
running: { tone: 'brand', ico: 'activity' },
|
||||
'awaiting-human': { tone: 'warn', ico: 'hourglass' },
|
||||
'awaiting-event': { tone: 'warn', ico: 'hourglass' },
|
||||
suspended: { tone: 'mute', ico: 'pause' },
|
||||
'landing-blocked': { tone: 'danger', ico: 'alert-circle' },
|
||||
terminal: { tone: 'ok', ico: 'circle-check' },
|
||||
};
|
||||
|
||||
/** 终态五名 —— 放行完成是唯一成功终态 */
|
||||
export const TERMINAL_ST: Readonly<Record<TerminalState, ToneIco>> = {
|
||||
released: { tone: 'ok', ico: 'circle-check' },
|
||||
blocked: { tone: 'danger', ico: 'alert-circle' },
|
||||
stalled: { tone: 'danger', ico: 'ban' },
|
||||
exhausted: { tone: 'warn', ico: 'gauge' },
|
||||
terminated: { tone: 'mute', ico: 'power' },
|
||||
};
|
||||
|
||||
export const PARA: Readonly<Record<Paradigm, ToneIco & { label: string; full: string }>> = {
|
||||
loop: { tone: 'collab', ico: 'repeat', label: '案例 · loop', full: '临场演绎,调度权归 AI' },
|
||||
flow: { tone: 'auto', ico: 'git-commit', label: '标准 · flow', full: '照谱执行,调度权归系统' },
|
||||
};
|
||||
|
||||
export const APR_ST: Readonly<Record<'pending' | 'passed' | 'rejected', ToneIco & { label: string }>> = {
|
||||
pending: { tone: 'warn', ico: 'hourglass', label: '待审批' },
|
||||
passed: { tone: 'ok', ico: 'circle-check', label: '已通过' },
|
||||
rejected: { tone: 'danger', ico: 'rotate-ccw', label: '已退回' },
|
||||
};
|
||||
|
||||
export const SIDE_ST: Readonly<Record<string, ToneIco>> = {
|
||||
机器产出: { tone: 'auto', ico: 'bot' },
|
||||
人产出: { tone: 'brand', ico: 'user-round' },
|
||||
人裁决: { tone: 'decision', ico: 'shield-check' },
|
||||
世界等待: { tone: 'wait', ico: 'hourglass' },
|
||||
};
|
||||
Reference in New Issue
Block a user