From a9cd6d10c76cce44c164775b622eb564f765a85b Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 03:28:40 +0800 Subject: [PATCH] =?UTF-8?q?M6(2/n):=20=E6=B2=BB=E7=90=86=E5=8F=B0=E4=B9=9D?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20+=20SSE=20=E8=AE=A2=E9=98=85=E5=B1=82=20+?= =?UTF-8?q?=20=E8=AF=BB/=E6=B2=BB=E5=88=86=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 治理台按 §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) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../contracts/src/api/governance.d.ts | 987 +++++++++++++++ apps/api/src/app/broadcast.ts | 44 + apps/api/src/app/container.ts | 5 + apps/api/src/app/routes/governance-ops.ts | 438 +++++++ apps/api/src/app/routes/stream.ts | 52 + apps/api/src/app/server.ts | 4 + apps/api/src/app/workers/dispatcher.ts | 6 + .../memory/__tests__/task-memory.test.ts | 17 + .../memory/internal/domain/task-log.ts | 2 + .../memory/internal/domain/task-memory.ts | 4 + .../task/internal/usecase/task-service.ts | 6 +- apps/api/src/modules/task/port.ts | 3 +- apps/web/package.json | 1 + apps/web/src/app/gov-shell.tsx | 250 ++++ apps/web/src/app/main.tsx | 25 +- apps/web/src/app/subscribe.ts | 48 + apps/web/src/features/gov-activity/index.ts | 5 + .../features/gov-activity/internal/panel.tsx | 115 ++ apps/web/src/features/gov-data/index.ts | 5 + .../src/features/gov-data/internal/panel.tsx | 121 ++ apps/web/src/features/gov-memory/index.ts | 5 + .../features/gov-memory/internal/panel.tsx | 115 ++ apps/web/src/features/gov-process/index.ts | 5 + .../features/gov-process/internal/panel.tsx | 169 +++ apps/web/src/features/gov-staff/index.ts | 5 + .../src/features/gov-staff/internal/panel.tsx | 155 +++ apps/web/src/features/gov-system/index.ts | 5 + .../features/gov-system/internal/panel.tsx | 152 +++ apps/web/src/features/gov-task/index.ts | 5 + .../src/features/gov-task/internal/panel.tsx | 506 ++++++++ apps/web/src/features/gov-tool/index.ts | 5 + .../src/features/gov-tool/internal/panel.tsx | 109 ++ apps/web/src/features/gov-trigger/index.ts | 5 + .../features/gov-trigger/internal/panel.tsx | 138 +++ apps/web/src/shared/gov/flow/FlowDiagram.tsx | 343 ++++++ apps/web/src/shared/gov/flow/FlowFigure.tsx | 36 + apps/web/src/shared/gov/flow/flow-canvas.css | 597 +++++++++ apps/web/src/shared/gov/flow/flow-layout.ts | 1076 +++++++++++++++++ .../src/shared/gov/flow/flow-node-paint.ts | 204 ++++ apps/web/src/shared/gov/flow/pkg-to-graph.ts | 111 ++ apps/web/src/shared/gov/flow/support.tsx | 50 + apps/web/src/shared/gov/gov-api.ts | 118 ++ apps/web/src/shared/gov/gov-route.ts | 164 +++ apps/web/src/shared/gov/primitives.tsx | 614 ++++++++++ apps/web/src/shared/gov/tone.ts | 51 + packages/contracts/src/api/governance.ts | 304 ++++- pnpm-lock.yaml | 98 ++ 47 files changed, 7272 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/app/broadcast.ts create mode 100644 apps/api/src/app/routes/governance-ops.ts create mode 100644 apps/api/src/app/routes/stream.ts create mode 100644 apps/web/src/app/gov-shell.tsx create mode 100644 apps/web/src/app/subscribe.ts create mode 100644 apps/web/src/features/gov-activity/index.ts create mode 100644 apps/web/src/features/gov-activity/internal/panel.tsx create mode 100644 apps/web/src/features/gov-data/index.ts create mode 100644 apps/web/src/features/gov-data/internal/panel.tsx create mode 100644 apps/web/src/features/gov-memory/index.ts create mode 100644 apps/web/src/features/gov-memory/internal/panel.tsx create mode 100644 apps/web/src/features/gov-process/index.ts create mode 100644 apps/web/src/features/gov-process/internal/panel.tsx create mode 100644 apps/web/src/features/gov-staff/index.ts create mode 100644 apps/web/src/features/gov-staff/internal/panel.tsx create mode 100644 apps/web/src/features/gov-system/index.ts create mode 100644 apps/web/src/features/gov-system/internal/panel.tsx create mode 100644 apps/web/src/features/gov-task/index.ts create mode 100644 apps/web/src/features/gov-task/internal/panel.tsx create mode 100644 apps/web/src/features/gov-tool/index.ts create mode 100644 apps/web/src/features/gov-tool/internal/panel.tsx create mode 100644 apps/web/src/features/gov-trigger/index.ts create mode 100644 apps/web/src/features/gov-trigger/internal/panel.tsx create mode 100644 apps/web/src/shared/gov/flow/FlowDiagram.tsx create mode 100644 apps/web/src/shared/gov/flow/FlowFigure.tsx create mode 100644 apps/web/src/shared/gov/flow/flow-canvas.css create mode 100644 apps/web/src/shared/gov/flow/flow-layout.ts create mode 100644 apps/web/src/shared/gov/flow/flow-node-paint.ts create mode 100644 apps/web/src/shared/gov/flow/pkg-to-graph.ts create mode 100644 apps/web/src/shared/gov/flow/support.tsx create mode 100644 apps/web/src/shared/gov/gov-api.ts create mode 100644 apps/web/src/shared/gov/gov-route.ts create mode 100644 apps/web/src/shared/gov/primitives.tsx create mode 100644 apps/web/src/shared/gov/tone.ts diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 4fe112c..653c9bd 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -306,3 +306,990 @@ export declare const probeLlmInstanceRoute: { error: z.ZodOptional; }, z.core.$strip>; }; +export declare const govTaskRowSchema: z.ZodObject<{ + id: z.core.$ZodBranded; + title: z.ZodString; + activityId: z.ZodString; + paradigm: z.ZodEnum<{ + loop: "loop"; + flow: "flow"; + }>; + ownerName: z.ZodString; + runtime: z.ZodEnum<{ + running: "running"; + "awaiting-human": "awaiting-human"; + "awaiting-event": "awaiting-event"; + suspended: "suspended"; + "landing-blocked": "landing-blocked"; + terminal: "terminal"; + }>; + terminal: z.ZodOptional>; + workflowId: z.ZodOptional; + usedSteps: z.ZodNumber; + capSteps: z.ZodNumber; + startedAt: z.ZodString; +}, z.core.$strip>; +export type GovTaskRow = z.infer; +export declare const listGovTasksRoute: { + readonly method: "GET"; + readonly path: "/api/gov/tasks"; + readonly output: z.ZodObject<{ + tasks: z.ZodArray; + title: z.ZodString; + activityId: z.ZodString; + paradigm: z.ZodEnum<{ + loop: "loop"; + flow: "flow"; + }>; + ownerName: z.ZodString; + runtime: z.ZodEnum<{ + running: "running"; + "awaiting-human": "awaiting-human"; + "awaiting-event": "awaiting-event"; + suspended: "suspended"; + "landing-blocked": "landing-blocked"; + terminal: "terminal"; + }>; + terminal: z.ZodOptional>; + workflowId: z.ZodOptional; + usedSteps: z.ZodNumber; + capSteps: z.ZodNumber; + startedAt: z.ZodString; + }, z.core.$strip>>; + workflows: z.ZodArray; + packageId: z.ZodString; + startedAt: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 控制三语义:暂停 / 叫停 / 终止 —— 治理台的干预面(OS 06) */ +export declare const controlTaskRoute: { + readonly method: "POST"; + readonly path: "/api/gov/tasks/:taskId/control"; + readonly params: z.ZodObject<{ + taskId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + semantic: z.ZodEnum<{ + pause: "pause"; + halt: "halt"; + terminate: "terminate"; + }>; + reason: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + resumable: z.ZodBoolean; + }, z.core.$strip>; +}; +export declare const resumeTaskRoute: { + readonly method: "POST"; + readonly path: "/api/gov/tasks/:taskId/resume"; + readonly params: z.ZodObject<{ + taskId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + runtime: z.ZodEnum<{ + running: "running"; + "awaiting-human": "awaiting-human"; + "awaiting-event": "awaiting-event"; + suspended: "suspended"; + "landing-blocked": "landing-blocked"; + terminal: "terminal"; + }>; + }, z.core.$strip>; +}; +/** 改派 —— 在途任务的负责人变更,留痕并通知双方 */ +export declare const reassignTaskRoute: { + readonly method: "POST"; + readonly path: "/api/gov/tasks/:taskId/reassign"; + readonly params: z.ZodObject<{ + taskId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + to: z.core.$ZodBranded; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + ownerId: z.core.$ZodBranded; + }, z.core.$strip>; +}; +/** 任务日志 —— 治理台可查看任意任务的任务日志(OS 02) */ +export declare const govTaskLogRoute: { + readonly method: "GET"; + readonly path: "/api/gov/tasks/:taskId/log"; + readonly params: z.ZodObject<{ + taskId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + log: z.ZodArray>; + }, z.core.$strip>; +}; +export declare const listProcessPackagesRoute: { + readonly method: "GET"; + readonly path: "/api/gov/process"; + readonly output: z.ZodObject<{ + packages: z.ZodArray; + name: z.ZodString; + version: z.ZodNumber; + state: z.ZodEnum<{ + draft: "draft"; + published: "published"; + retired: "retired"; + }>; + callInterface: z.ZodObject<{ + input: z.ZodRecord; + output: z.ZodRecord; + }, z.core.$strip>; + nodes: z.ZodArray; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + activityVersion: z.ZodOptional; + owner: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + failure: z.ZodDefault; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"reroute">; + edgeId: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"escalate">; + approvalNodeId: z.core.$ZodBranded; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"tolerate">; + }, z.core.$strip>], "kind">>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"approval">; + approver: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"reopen">; + nodeId: z.core.$ZodBranded; + mapping: z.ZodDefault; + port: z.ZodString; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"workflow-var">; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"initiation">; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"constant">; + value: z.ZodUnknown; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"expression">; + sfeel: z.ZodString; + }, z.core.$strip>], "from">; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"terminate">; + }, z.core.$strip>], "kind">; + nudge: z.ZodOptional; + }, z.core.$strip>>>; + }, z.core.$strip>>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"gateway">; + gateway: z.ZodEnum<{ + parallel: "parallel"; + exclusive: "exclusive"; + }>; + mode: z.ZodEnum<{ + join: "join"; + fork: "fork"; + }>; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>], "kind">>; + edges: z.ZodArray; + to: z.core.$ZodBranded; + condition: z.ZodOptional; + mapping: z.ZodDefault; + port: z.ZodString; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"workflow-var">; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"initiation">; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"constant">; + value: z.ZodUnknown; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"expression">; + sfeel: z.ZodString; + }, z.core.$strip>], "from">; + }, z.core.$strip>>>; + label: z.ZodOptional; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 画图即校验 —— 与前端画布同源的闭合校验 */ +export declare const validateProcessRoute: { + readonly method: "POST"; + readonly path: "/api/gov/process/validate"; + readonly body: z.ZodObject<{ + pkg: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + version: z.ZodNumber; + state: z.ZodEnum<{ + draft: "draft"; + published: "published"; + retired: "retired"; + }>; + callInterface: z.ZodObject<{ + input: z.ZodRecord; + output: z.ZodRecord; + }, z.core.$strip>; + nodes: z.ZodArray; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + activityVersion: z.ZodOptional; + owner: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + failure: z.ZodDefault; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"reroute">; + edgeId: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"escalate">; + approvalNodeId: z.core.$ZodBranded; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"tolerate">; + }, z.core.$strip>], "kind">>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"approval">; + approver: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"reopen">; + nodeId: z.core.$ZodBranded; + mapping: z.ZodDefault; + port: z.ZodString; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"workflow-var">; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"initiation">; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"constant">; + value: z.ZodUnknown; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"expression">; + sfeel: z.ZodString; + }, z.core.$strip>], "from">; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"terminate">; + }, z.core.$strip>], "kind">; + nudge: z.ZodOptional; + }, z.core.$strip>>>; + }, z.core.$strip>>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"gateway">; + gateway: z.ZodEnum<{ + parallel: "parallel"; + exclusive: "exclusive"; + }>; + mode: z.ZodEnum<{ + join: "join"; + fork: "fork"; + }>; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>], "kind">>; + edges: z.ZodArray; + to: z.core.$ZodBranded; + condition: z.ZodOptional; + mapping: z.ZodDefault; + port: z.ZodString; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"workflow-var">; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"initiation">; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"constant">; + value: z.ZodUnknown; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"expression">; + sfeel: z.ZodString; + }, z.core.$strip>], "from">; + }, z.core.$strip>>>; + label: z.ZodOptional; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + publishable: z.ZodBoolean; + violations: z.ZodArray; + nodeId: z.ZodOptional>; + edgeId: z.ZodOptional; + field: z.ZodOptional; + message: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const publishProcessRoute: { + readonly method: "POST"; + readonly path: "/api/gov/process/:id/:version/publish"; + readonly params: z.ZodObject<{ + id: z.ZodString; + version: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + publishable: z.ZodBoolean; + violations: z.ZodArray; + nodeId: z.ZodOptional>; + edgeId: z.ZodOptional; + field: z.ZodOptional; + message: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const listTriggersRoute: { + readonly method: "GET"; + readonly path: "/api/gov/triggers"; + readonly output: z.ZodObject<{ + triggers: z.ZodArray; + name: z.ZodString; + enabled: z.ZodDefault; + processPackageId: z.core.$ZodBranded; + mapping: z.ZodDefault; + port: z.ZodString; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"workflow-var">; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"initiation">; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"constant">; + value: z.ZodUnknown; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"expression">; + sfeel: z.ZodString; + }, z.core.$strip>], "from">; + }, z.core.$strip>>>; + spec: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"periodic-calendar">; + calendar: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"periodic-data">; + domain: z.ZodString; + object: z.ZodString; + dateField: z.ZodString; + offset: z.ZodString; + filter: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"event">; + rule: z.ZodString; + eventType: z.ZodDefault>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"registration">; + formHint: z.ZodOptional; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const toggleTriggerRoute: { + readonly method: "POST"; + readonly path: "/api/gov/triggers/:id/toggle"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + enabled: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; +export declare const listActivitiesRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities"; + readonly output: z.ZodObject<{ + activities: z.ZodArray; + enabled: z.ZodBoolean; + packageVersions: z.ZodArray>; + assetVersions: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const listToolsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/tools"; + readonly output: z.ZodObject<{ + tools: z.ZodArray; + pure: z.ZodBoolean; + connectionKeys: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const listLedgerObjectsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/data/objects"; + readonly output: z.ZodObject<{ + objects: z.ZodArray>; + }, z.core.$strip>; +}; +export declare const browseLedgerRoute: { + readonly method: "GET"; + readonly path: "/api/gov/data/:domain/:object"; + readonly params: z.ZodObject<{ + domain: z.ZodString; + object: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + columns: z.ZodArray>; + primaryKey: z.ZodArray; + rows: z.ZodArray>; + total: z.ZodNumber; + }, z.core.$strip>; +}; +export declare const listActivityMemoryRoute: { + readonly method: "GET"; + readonly path: "/api/gov/memory/:activityId"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + entries: z.ZodArray; + createdAt: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 通道配置里本就没有凭证字段(凭证只在治理台的连接配置中,ADR-009);此处整体可回 */ +export declare const listChannelsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/channels"; + readonly output: z.ZodObject<{ + channels: z.ZodArray; + address: z.ZodString; + enabled: z.ZodBoolean; + extractCount: z.ZodNumber; + mappingCount: z.ZodNumber; + fallbackPosition: z.ZodOptional; + addressWindowMs: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 审批单不是任务:没有预算、没有任务日志,只有批不批(卡在审批人工位的验收工单) */ +export declare const listApprovalsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/approvals"; + readonly output: z.ZodObject<{ + approvals: z.ZodArray; + issuedAt: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const govWorkflowRoute: { + readonly method: "GET"; + readonly path: "/api/gov/workflows/:id"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + workflow: z.ZodObject<{ + id: z.ZodString; + subject: z.ZodString; + state: z.ZodEnum<{ + terminated: "terminated"; + running: "running"; + waiting: "waiting"; + completed: "completed"; + abnormal: "abnormal"; + }>; + packageId: z.ZodString; + packageVersion: z.ZodNumber; + source: z.ZodObject<{ + kind: z.ZodString; + ref: z.ZodString; + by: z.ZodOptional; + }, z.core.$strip>; + startedAt: z.ZodString; + finishedAt: z.ZodOptional; + }, z.core.$strip>; + pkg: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + version: z.ZodNumber; + state: z.ZodEnum<{ + draft: "draft"; + published: "published"; + retired: "retired"; + }>; + callInterface: z.ZodObject<{ + input: z.ZodRecord; + output: z.ZodRecord; + }, z.core.$strip>; + nodes: z.ZodArray; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + activityVersion: z.ZodOptional; + owner: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + failure: z.ZodDefault; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"reroute">; + edgeId: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"escalate">; + approvalNodeId: z.core.$ZodBranded; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"tolerate">; + }, z.core.$strip>], "kind">>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"approval">; + approver: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"reopen">; + nodeId: z.core.$ZodBranded; + mapping: z.ZodDefault; + port: z.ZodString; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"workflow-var">; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"initiation">; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"constant">; + value: z.ZodUnknown; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"expression">; + sfeel: z.ZodString; + }, z.core.$strip>], "from">; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"terminate">; + }, z.core.$strip>], "kind">; + nudge: z.ZodOptional; + }, z.core.$strip>>>; + }, z.core.$strip>>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"gateway">; + gateway: z.ZodEnum<{ + parallel: "parallel"; + exclusive: "exclusive"; + }>; + mode: z.ZodEnum<{ + join: "join"; + fork: "fork"; + }>; + id: z.core.$ZodBranded; + label: z.ZodString; + layout: z.ZodOptional>; + }, z.core.$strip>], "kind">>; + edges: z.ZodArray; + to: z.core.$ZodBranded; + condition: z.ZodOptional; + mapping: z.ZodDefault; + port: z.ZodString; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"workflow-var">; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"initiation">; + path: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"constant">; + value: z.ZodUnknown; + }, z.core.$strip>, z.ZodObject<{ + from: z.ZodLiteral<"expression">; + sfeel: z.ZodString; + }, z.core.$strip>], "from">; + }, z.core.$strip>>>; + label: z.ZodOptional; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + }, z.core.$strip>; + instances: z.ZodArray>; + }, z.core.$strip>>; + tasks: z.ZodArray; + nodeId: z.ZodString; + title: z.ZodString; + ownerName: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 自主率与纠正率**成对读**,复合为有效自主率 —— 单看自主率会奖励「猛冲后返工」 */ +export declare const govMetricsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/metrics"; + readonly output: z.ZodObject<{ + autonomy: z.ZodNumber; + correction: z.ZodNumber; + effectiveAutonomy: z.ZodNumber; + interruption: z.ZodNumber; + dmnCoverage: z.ZodNumber; + taskCount: z.ZodNumber; + }, z.core.$strip>; +}; +export type GovTasksOutput = z.infer; +export type GovApprovalsOutput = z.infer; +export type GovStaffOutput = z.infer; +export type GovGrantsOutput = z.infer; +export type GovPackagesOutput = z.infer; +export type GovTriggersOutput = z.infer; +export type GovActivitiesOutput = z.infer; +export type GovToolsOutput = z.infer; +export type GovObjectsOutput = z.infer; +export type GovLedgerPageOutput = z.infer; +export type GovMemoryOutput = z.infer; +export type GovMetricsOutput = z.infer; +export type GovChannelsOutput = z.infer; +export type GovLlmOutput = z.infer; +export type GovWorkflowOutput = z.infer; +export type GovTaskLogOutput = z.infer; diff --git a/apps/api/src/app/broadcast.ts b/apps/api/src/app/broadcast.ts new file mode 100644 index 0000000..56ed850 --- /dev/null +++ b/apps/api/src/app/broadcast.ts @@ -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; + }, + }; +} diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index f679987..c5dc1fb 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -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 { return { config, + broadcaster: createBroadcaster(), pool, db, org, diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts new file mode 100644 index 0000000..6d32cb4 --- /dev/null +++ b/apps/api/src/app/routes/governance-ops.ts @@ -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, 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 => { + 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, + })), + }); + }); +} diff --git a/apps/api/src/app/routes/stream.ts b/apps/api/src/app/routes/stream.ts new file mode 100644 index 0000000..77b92ff --- /dev/null +++ b/apps/api/src/app/routes/stream.ts @@ -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, 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((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(); + } + }), + ); +} diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index 0e795a1..bf234bf 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -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 { registerWorkbenchRoutes(app, container); registerGovernanceRoutes(app, container); registerDeskRoutes(app, container); + registerGovernanceOpsRoutes(app, container); registerIngestRoutes(app, container); + registerStreamRoute(app, container.broadcaster); return app; } diff --git a/apps/api/src/app/workers/dispatcher.ts b/apps/api/src/app/workers/dispatcher.ts index 33cab69..e904af2 100644 --- a/apps/api/src/app/workers/dispatcher.ts +++ b/apps/api/src/app/workers/dispatcher.ts @@ -56,6 +56,12 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise { 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)); diff --git a/apps/api/src/modules/memory/__tests__/task-memory.test.ts b/apps/api/src/modules/memory/__tests__/task-memory.test.ts index fc83189..3e4b34f 100644 --- a/apps/api/src/modules/memory/__tests__/task-memory.test.ts +++ b/apps/api/src/modules/memory/__tests__/task-memory.test.ts @@ -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(['人裁决', '机器产出']); + }); +}); diff --git a/apps/api/src/modules/memory/internal/domain/task-log.ts b/apps/api/src/modules/memory/internal/domain/task-log.ts index 45f1f97..02929b3 100644 --- a/apps/api/src/modules/memory/internal/domain/task-log.ts +++ b/apps/api/src/modules/memory/internal/domain/task-log.ts @@ -33,6 +33,7 @@ const SKELETON: ReadonlySet = new Set([ 'release-verdict', 'terminal', 'human-input', + 'control', ]); const TEMPLATE: Readonly string>> = { @@ -49,6 +50,7 @@ const TEMPLATE: Readonly string>> = { 'release-verdict': (e) => `放行裁决:${e.title}`, terminal: (e) => `终态:${e.title}`, 'human-input': (e) => `留言:${e.title}`, + control: (e) => `治理:${e.title}`, }; /** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */ diff --git a/apps/api/src/modules/memory/internal/domain/task-memory.ts b/apps/api/src/modules/memory/internal/domain/task-memory.ts index ffbb267..c7f1f5a 100644 --- a/apps/api/src/modules/memory/internal/domain/task-memory.ts +++ b/apps/api/src/modules/memory/internal/domain/task-memory.ts @@ -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'; diff --git a/apps/api/src/modules/task/internal/usecase/task-service.ts b/apps/api/src/modules/task/internal/usecase/task-service.ts index e1366c6..ecf961e 100644 --- a/apps/api/src/modules/task/internal/usecase/task-service.ts +++ b/apps/api/src/modules/task/internal/usecase/task-service.ts @@ -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 }, }); diff --git a/apps/api/src/modules/task/port.ts b/apps/api/src/modules/task/port.ts index c086f6a..a4f8798 100644 --- a/apps/api/src/modules/task/port.ts +++ b/apps/api/src/modules/task/port.ts @@ -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; }): Promise; diff --git a/apps/web/package.json b/apps/web/package.json index 35a3b07..7aaf2c2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" }, diff --git a/apps/web/src/app/gov-shell.tsx b/apps/web/src/app/gov-shell.tsx new file mode 100644 index 0000000..62307b1 --- /dev/null +++ b/apps/web/src/app/gov-shell.tsx @@ -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//[/:…]` ──────────────────────── */ + +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(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 ( +
+ {/* ===== 深色左轨 ===== */} + + +
+ {/* ===== 深色顶栏 ===== */} +
+ + JCAI 操作系统单组织 · ≤50 人 · 生产环境 + + +
+ + + + 工作台 + +
+
+ + {/* ===== 工作面 ===== */} +
+
+ {stack.length > 0 && ( + + )} + {crumbs.map((c, i) => ( + + {i > 0 && ›} + {c.go ? : {c.label}} + + ))} +
+ + {top === undefined ? ( + <> +
+ + + +
+

{meta.title}

+

{meta.desc}

+
+
+
+ {TABS[zone].map((t) => ( + + ))} +
+ + + ) : ( +
+ +
+ )} +
+
+
+ ); +} + +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

; +} + +function Detail({ route, nav }: { route: Route; nav: Nav }) { + switch (route.k) { + case 'wf': + case 'apr': + return ; + case 'task': + return ; + case 'tasklog': + 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 ; + case 'chan': + return ; + } +} diff --git a/apps/web/src/app/main.tsx b/apps/web/src/app/main.tsx index d139488..d151051 100644 --- a/apps/web/src/app/main.tsx +++ b/apps/web/src/app/main.tsx @@ -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( - +/** + * 两台面共一个 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 ( {/* .proto-root 是设计令牌的作用域(原型 app.css 的 `.proto-root .app-shell`) */} -

- -
+
{governance ? : }
+ ); +} + +createRoot(container).render( + + , ); diff --git a/apps/web/src/app/subscribe.ts b/apps/web/src/app/subscribe.ts new file mode 100644 index 0000000..d037440 --- /dev/null +++ b/apps/web/src/app/subscribe.ts @@ -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).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(); +} diff --git a/apps/web/src/features/gov-activity/index.ts b/apps/web/src/features/gov-activity/index.ts new file mode 100644 index 0000000..acf8974 --- /dev/null +++ b/apps/web/src/features/gov-activity/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-activity` 唯一出口(编码规范 §9.1)。 + * 活动库:两维分类 × 包版本 × 资产版本 + */ +export { LibraryHome, ActPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-activity/internal/panel.tsx b/apps/web/src/features/gov-activity/internal/panel.tsx new file mode 100644 index 0000000..58ccb6c --- /dev/null +++ b/apps/web/src/features/gov-activity/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 ( + + <> + + 活动是任务的定义:编排引用一个活动,运行时就长出一个任务。业务分类 × 活动类型两维平级; + 每活动两条版本序列——包版本走安装两审,资产版本走进化管线。 + + + {activities.map((a) => { + const effective = a.assetVersions.filter((v) => v.effective); + return ( + {a.packageVersions.map((p) => p.version).join(', ')} }, + { k: '资产生效版', v: {effective.length} }, + ]} + right={ + + {a.enabled ? '已启用' : '已停用'} + + } + onClick={() => nav.push({ k: 'act', id: a.activityId })} + /> + ); + })} + + + + ); +} + +/* ---------------- ⑥ 工具管理 ---------------- */ + +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 活动不存在。; + return ( + <> + + {PARA[a.paradigm].label} + {a.category} + {a.enabled ? '已启用' : '已停用'} + + } + desc={PARA[a.paradigm].full + ' —— 活动是系统外开发、经此安装的外装件。'} + /> + +
+ {a.packageVersions.map((p) => ( +
+ v{p.version} + + {p.state} · {p.installedAt.slice(0, 16).replace('T', ' ')} + +
+ ))} +
+
+ +
+ {a.assetVersions.map((v, i) => ( +
+ + {v.kind} v{v.version} + + {v.effective ? '生效中' : '历史'} +
+ ))} + {a.assetVersions.length === 0 && 尚无资产版本。} +
+
+ nav.jump('memory', 'layers')} + /> + + ); +} + diff --git a/apps/web/src/features/gov-data/index.ts b/apps/web/src/features/gov-data/index.ts new file mode 100644 index 0000000..70f026b --- /dev/null +++ b/apps/web/src/features/gov-data/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-data` 唯一出口(编码规范 §9.1)。 + * 数据管理:域 / 对象目录、schema 视图、记录浏览 + */ +export { DataHome, ObjPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-data/internal/panel.tsx b/apps/web/src/features/gov-data/internal/panel.tsx new file mode 100644 index 0000000..736dbfc --- /dev/null +++ b/apps/web/src/features/gov-data/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 ( + + <> + + 数据不设箱、靠域:域是承载的划分,对象由活动声明物化——最大化合并、只增不破坏。 + 数据可以自生长,权力不可以自生长。 + + {domains.map((d) => ( +
+
+ + {d} + {objects.filter((o) => o.domain === d).length} 个对象 +
+ + {objects + .filter((o) => o.domain === d) + .map((o) => ( + nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })} + /> + ))} + +
+ ))} + {objects.length === 0 && 尚无物化对象——活动首次写入时按声明物化。} + +
+ ); +} + +/* ---------------- ⑧ 记忆管理 ---------------- */ + +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 对象未物化。; + const columns = page.data?.columns ?? []; + return ( + <> + + {columns.length} 列 + {page.data?.total ?? 0} 行 + 主键 {(page.data?.primaryKey ?? []).join(', ') || '—'} + + } + desc="声明物化:最大化合并、只增不破坏——不删列、不改窄、不换主键。此处只读正式版,跨任务只见过门的事实。" + /> + +
+ {columns.map((c) => ( +
+ {c.name} + {c.type} +
+ ))} +
+
+ +
+ + + + {columns.map((c) => ( + + ))} + + + + {(page.data?.rows ?? []).map((row, i) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
{c.name}
+ {String(row[c.name] ?? '')} +
+
+
+ + ); +} + +/* ---------------- 记忆条目 ---------------- */ + diff --git a/apps/web/src/features/gov-memory/index.ts b/apps/web/src/features/gov-memory/index.ts new file mode 100644 index 0000000..645bf50 --- /dev/null +++ b/apps/web/src/features/gov-memory/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-memory` 唯一出口(编码规范 §9.1)。 + * 记忆管理:度量仪表、活动记忆四层 + */ +export { MemoryHome, MemPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-memory/internal/panel.tsx b/apps/web/src/features/gov-memory/internal/panel.tsx new file mode 100644 index 0000000..c110315 --- /dev/null +++ b/apps/web/src/features/gov-memory/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(undefined); + const memory = useGovMemory(picked); + + if (tab === 'layers') { + return ( + <> + + 活动记忆四层:事实层 / 模式层 / 裁决层 / 例外层。写入唯蒸馏,条目必挂证据链; + 记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。 + +
+ {(activities.data?.activities ?? []).map((a) => ( + + ))} +
+ + {(memory.data?.entries ?? []).map((e) => ( + 候选 · {e.incrementState} : 已成条目} + onClick={() => nav.push({ k: 'mem', id: e.id })} + /> + ))} + {picked !== undefined && (memory.data?.entries ?? []).length === 0 && ( + 该活动尚无记忆条目——终态封卷触发蒸馏钩子后产出。 + )} + + + ); + } + + const m = metrics.data; + const pct = (v: number | undefined) => Math.round((v ?? 0) * 100); + return ( + + <> +
+ + + + + +
+ + 自主率与纠正率成对读,复合为有效自主率——单看自主率会奖励「猛冲后返工」。度量只是仪表,不载权力。 + + +
+ ); +} + +/* ---------------- ⑨ 系统管理 ---------------- */ + +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 记忆条目需从对应活动进入。; + return ( + <> + + {e.evidenceCount} 条证据 + {e.incrementState !== undefined && 候选增量 · {e.incrementState}} + + } + desc="写入唯蒸馏,条目必挂证据链;记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。" + /> + + ); +} + +/* ---------------- 系统 ---------------- */ + diff --git a/apps/web/src/features/gov-process/index.ts b/apps/web/src/features/gov-process/index.ts new file mode 100644 index 0000000..06cb39e --- /dev/null +++ b/apps/web/src/features/gov-process/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-process` 唯一出口(编码规范 §9.1)。 + * 流程包编排画布(diagram-js 画布件平移):连线即映射、画图即校验、不闭合不得发布 + */ +export { WorkflowsHome, PkgPage, EdgesPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx new file mode 100644 index 0000000..3f84a2d --- /dev/null +++ b/apps/web/src/features/gov-process/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 ( + + <> + + 编排即定契约——连线即映射、画图即校验,边不闭合不得发布。流程包是设计态的定义,工作流是它的运行时实例。 + + + {packages.map((p) => { + const published = p.state === 'published'; + return ( + n.kind === 'activity').length }, + ]} + right={ + + {published ? '已发布' : '草稿'} + + } + onClick={() => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` })} + /> + ); + })} + + + + ); +} + +/* ---------------- ④ 触发器配置 ---------------- */ + +/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */ +function useGovAction(mutate: (input: TInput) => Promise, keys: readonly string[]) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: mutate, + onSuccess: () => { + for (const key of keys) void queryClient.invalidateQueries({ queryKey: ['gov', key] }); + }, + }); +} + +/* ---------------- 工作流 ---------------- */ + +export function PkgPage({ id, nav }: { id: string; nav: Nav }) { + const [pid, versionText] = id.split('@'); + const q = useGovPackages(); + const [report, setReport] = useState(undefined); + const pkg = (q.data?.packages ?? []).find((p) => p.id === pid && String(p.version) === versionText); + + const publish = useGovAction( + () => call(publishProcessRoute, { params: { id: pid!, version: versionText! } }).then((r) => setReport(r)), + ['process'], + ); + + if (pkg === undefined) return 流程包不存在。; + const published = pkg.state === 'published'; + + return ( + <> + + {published ? '已发布' : '草稿'} + {pkg.nodes.length} 节点 + {pkg.edges.length} 边 + + } + desc="编排即定契约——连线即映射、画图即校验,边不闭合不得发布。" + acts={[ + { + label: '发布', + ico: 'upload', + primary: true, + disabled: published, + ask: { + title: `发布流程包:${pkg.name} v${pkg.version}`, + note: 发布前跑闭合校验:任一违规即拒绝,不存在「带病发布」。, + impact: { who: '此后新起单按此版本执行;在途工作流仍按其起单时的版本', undo: '可发新版本覆盖,不可回改本版', when: '校验通过后即时' }, + okLabel: '确认发布', + onOk: () => publish.mutate(undefined), + }, + }, + ]} + /> + + {report !== undefined && ( + + {report.publishable ? '闭合校验通过 —— 已发布。' : `闭合校验未过:${report.violations.map((v) => v.message).join(';')}`} + + )} + + + + + + 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 流程包不存在。; + const labelOf = (nodeId: string) => pkg.nodes.find((n) => n.id === nodeId)?.label ?? nodeId; + return ( + <> + + + {pkg.edges.map((e, i) => ( + {e.mapping.length} }, + ]} + onClick={() => undefined} + /> + ))} + + + ); +} + +/* ---------------- 触发器 ---------------- */ + diff --git a/apps/web/src/features/gov-staff/index.ts b/apps/web/src/features/gov-staff/index.ts new file mode 100644 index 0000000..4abfb93 --- /dev/null +++ b/apps/web/src/features/gov-staff/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-staff` 唯一出口(编码规范 §9.1)。 + * 员工管理:账号、岗位授权、停用前置检查 + */ +export { PeopleHome, EmpPage, RolePage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-staff/internal/panel.tsx b/apps/web/src/features/gov-staff/internal/panel.tsx new file mode 100644 index 0000000..7110e6c --- /dev/null +++ b/apps/web/src/features/gov-staff/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 ( + <> + + 权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。 + 人换岗即换权,权限随岗位走。 + + + {positions.map((p) => ( + s.position.id === p.id).length }]} + onClick={() => nav.push({ k: 'role', id: p.id })} + /> + ))} + + + ); + } + + return ( + + <> + + 停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。 + + + {staff.map((s) => { + const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined); + return ( + + {s.state === 'active' ? '在职' : '已停用'} + + } + onClick={() => nav.push({ k: 'emp', id: s.id })} + /> + ); + })} + + + + ); +} + +/* ---------------- ③ 工作流管理 ---------------- */ + +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 员工不存在。; + const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name); + const live = mine.filter((t) => t.terminal === undefined); + return ( + <> + + + {s.position.name} · {s.position.code} + + {s.state === 'active' ? '在职' : '已停用'} + + } + desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。" + /> + {live.length}}> + + {live.map((t) => ( + nav.push({ k: 'task', id: t.id })} /> + ))} + {live.length === 0 && 无在途任务——可安全停用。} + + + 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 ( + <> + {position?.code ?? '—'}} + desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。" + /> +
+ {(grants.data?.grants ?? []).map((g, i) => ( +
+ + {g.object.kind === 'data' + ? `数据 · ${g.object.domain}.${g.object.object}` + : g.object.kind === 'governance' + ? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}` + : `任务 · ${g.object.activityId ?? '全部活动'}`} + + {GRANT_ACTION_LABEL[g.action]} +
+ ))} + {(grants.data?.grants ?? []).length === 0 && 未授予任何权限。} +
+ + ); +} + +/* ---------------- 流程包 ---------------- */ + diff --git a/apps/web/src/features/gov-system/index.ts b/apps/web/src/features/gov-system/index.ts new file mode 100644 index 0000000..572e610 --- /dev/null +++ b/apps/web/src/features/gov-system/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-system` 唯一出口(编码规范 §9.1)。 + * 系统管理:LLM 实例四元绑定、通道实例 + */ +export { SystemHome, LlmPage, ChanPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-system/internal/panel.tsx b/apps/web/src/features/gov-system/internal/panel.tsx new file mode 100644 index 0000000..597ec72 --- /dev/null +++ b/apps/web/src/features/gov-system/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 ( + <> + + 通道实例声明归属键规则与默认认领岗位。归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口; + 歧义或失败一律判无主——宁交人,不错投。 + + + {(channels.data?.channels ?? []).map((ch) => ( + {Math.round(ch.addressWindowMs / 3600_000)}h }, + { k: '默认认领岗位', v: ch.fallbackPosition ?? '—' }, + ]} + right={ + + {ch.enabled ? '已启用' : '已停用'} + + } + onClick={() => nav.push({ k: 'chan', id: ch.id })} + /> + ))} + + + ); + } + + return ( + + <> + + LLM 实例是名称间接层:活动只见自定义模型名,Provider / URL / API-Key / Model 四元在系统侧绑定。 + 密钥与 endpoint 值只存治理台——不入包、不入活动、不入留痕明文。 + + + {(llm.data?.instances ?? []).map((i) => ( + {i.baseUrl} }, + { k: '系统用途', v: i.systemUse ? '是' : '—' }, + ]} + right={ + + {i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'} + + } + onClick={() => nav.push({ k: 'llm', id: i.id })} + /> + ))} + + + + ); +} + +export function LlmPage({ id }: { id: string }) { + const q = useGovLlm(); + const i = (q.data?.instances ?? []).find((x) => x.id === id); + if (i === undefined) return 实例不存在。; + return ( + <> + + {i.model} + {i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'} + {i.systemUse && 系统用途} + + } + desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。" + /> + + + + + ); +} + +export function ChanPage({ id }: { id: string }) { + const q = useGovChannels(); + const ch = (q.data?.channels ?? []).find((x) => x.id === id); + if (ch === undefined) return 通道不存在。; + return ( + <> + + {ch.address} + {ch.enabled ? '已启用' : '已停用'} + + } + desc="归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口;歧义或失败一律判无主——宁交人,不错投。" + /> + + + + + ); +} + diff --git a/apps/web/src/features/gov-task/index.ts b/apps/web/src/features/gov-task/index.ts new file mode 100644 index 0000000..beb7ea2 --- /dev/null +++ b/apps/web/src/features/gov-task/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-task` 唯一出口(编码规范 §9.1)。 + * 任务管理:在途监控、控制事件(暂停/叫停/终止)、恢复、改派、任务日志查看 + */ +export { TasksHome, WfPage, TaskPage, TaskLogPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx new file mode 100644 index 0000000..1e59ed1 --- /dev/null +++ b/apps/web/src/features/gov-task/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 ( +
+ {lanes.map((ln) => { + const items = workflows.filter((w) => w.state === ln.k); + return ( +
+
+ + {WORKFLOW_STATE_LABEL[ln.k]} + {items.length} +
+ {items.map((w) => { + const mine = tasks.filter((t) => t.workflowId === w.id); + const done = mine.filter((t) => t.terminal === 'released').length; + const total = Math.max(mine.length, 1); + const holder = holderOf(w.id); + const cur = mine.find((t) => t.terminal === undefined)?.title ?? '—'; + return ( + + ); + })} + {items.length === 0 &&
—
} +
+ ); + })} +
+ ); + } + + if (tab === 'apr') { + const rows = approvals.data?.approvals ?? []; + return ( + <> + + 审批点是流程层的通用人裁节点,与活动、网关、触发器、流程包并列为编排层五要素——它是 + 系统内置要素,声明即用、零开发、不进活动库。它卡到审批人工位产出审批单:通过 / + 驳回,附依据;没有预算、没有 Harness、没有任务日志,所以不是任务。 + + + {rows.map((a) => ( + + {APR_ST[a.state].label} + + } + onClick={() => nav.push({ k: 'wf', id: a.workflowId })} + /> + ))} + {rows.length === 0 && 暂无审批单——流上还没有走到审批点。} + + + ); + } + + if (tab === 'list') { + return ( + <> + + 任务 = 工作流上的节点,一个活动 = 一个任务;它派进员工工作台的任务清单,负责人由节点声明的岗位解析而来。 + + + {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 ( + + {t.usedSteps}/{t.capSteps} + + ), + }, + { k: '起始', v: {t.startedAt.slice(5, 16).replace('T', ' ')} }, + ]} + right={ + + {label} + + } + onClick={() => nav.push({ k: 'task', id: t.id })} + /> + ); + })} + {tasks.length === 0 && 暂无任务——起单后这里会出现。} + + + ); + } + + const abnormal = workflows.filter((w) => w.state === 'abnormal'); + const stuck = tasks.filter((t) => t.terminal === 'blocked' || t.terminal === 'exhausted' || t.terminal === 'stalled'); + return ( + +
+
+ w.state === 'running' || w.state === 'waiting').length} + sub="运行中 + 等待中" + /> + + + t.terminal === 'released').length} + sub="唯一成功终态" + /> +
+ {abnormal.map((w) => ( + nav.push({ k: 'wf', id: w.id })} + /> + ))} + {stuck.map((t) => ( + nav.push({ k: 'task', id: t.id })} + /> + ))} + {abnormal.length + stuck.length === 0 && 无待处置——在途实例全部正常推进。} +
+
+ ); +} + +/* ---------------- ② 员工管理 ---------------- */ + +/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */ +function useGovAction(mutate: (input: TInput) => Promise, keys: readonly string[]) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: mutate, + onSuccess: () => { + for (const key of keys) void queryClient.invalidateQueries({ queryKey: ['gov', key] }); + }, + }); +} + +/* ---------------- 工作流 ---------------- */ + +export function WfPage({ id, nav }: { id: string; nav: Nav }) { + const workflowId = id.includes(':') ? id.split(':')[0]! : id; + const q = useGovWorkflow(workflowId); + if (q.data === undefined) return 载入中…; + const { workflow, pkg, instances, tasks } = q.data; + const st = WF_ST[workflow.state]; + + return ( + <> + + + {WORKFLOW_STATE_LABEL[workflow.state]} + + + {workflow.packageId} v{workflow.packageVersion} + + + 来源 · {workflow.source.kind} · {workflow.source.ref} + + + } + desc="工作流状态由它上面的节点(任务与审批单)聚合推导,工作流层不另设状态机;重开产生新的一条工作流,旧的已终、不再入算。" + /> + + + + + + + + {tasks.map((t) => ( + nav.push({ k: 'task', id: t.id })} + /> + ))} + {tasks.length === 0 && 暂无任务。} + + + + +
+ {instances.map((i) => ( +
+ + {pkg.nodes.find((n) => n.id === i.nodeId)?.label ?? i.nodeId} + {i.instance > 1 ? ` #${i.instance}` : ''} + + + {i.terminal !== undefined + ? TERMINAL_STATE_LABEL[i.terminal] + : i.running + ? '运行中' + : i.waiting + ? '等待中' + : i.activated + ? '已激活' + : '未激活'} + +
+ ))} +
+
+ + ); +} + +/* ---------------- 任务:在途监控 · 控制 · 改派 ---------------- */ + +export function TaskPage({ id, nav }: { id: string; nav: Nav }) { + const q = useGovTasks(); + const staff = useGovStaff(); + const task = (q.data?.tasks ?? []).find((t) => t.id === id); + const [assignee, setAssignee] = useState(''); + + const control = useGovAction( + (semantic: ControlSemantic) => + call(controlTaskRoute, { params: { taskId: id }, body: { semantic, reason: '治理台干预' } }), + ['tasks', 'workflow', 'tasklog'], + ); + const resume = useGovAction(() => call(resumeTaskRoute, { params: { taskId: id } }), ['tasks', 'workflow', 'tasklog']); + const reassign = useGovAction( + (to: string) => call(reassignTaskRoute, { params: { taskId: id }, body: { to } }), + ['tasks', 'staff'], + ); + + if (task === undefined) return 任务不存在或已不在近期窗口内。; + const st = task.terminal !== undefined ? TERMINAL_ST[task.terminal] : RUNTIME_ST[task.runtime]; + const label = task.terminal !== undefined ? TERMINAL_STATE_LABEL[task.terminal] : TASK_RUNTIME_STATE_LABEL[task.runtime]; + const live = task.terminal === undefined; + const suspended = task.runtime === 'suspended'; + + /** 控制三语义:暂停可恢复、叫停归负责人、终止级联作废 */ + const controlAct = (semantic: ControlSemantic, danger: boolean): ActSpec => ({ + label: CONTROL_SEMANTIC_LABEL[semantic], + ico: semantic === 'terminate' ? 'power' : semantic === 'halt' ? 'hand' : 'pause', + danger, + disabled: !live, + ask: { + title: `控制事件 · ${CONTROL_SEMANTIC_LABEL[semantic]}:${task.title}`, + note: 控制事件走统一事件信封(四类事件之控制事件),先持久后投递。, + impact: { + who: + semantic === 'terminate' + ? '在途任务终止、待答卡与征答消息作废、审批点撤卡(级联)' + : semantic === 'halt' + ? `负责人 ${task.ownerName} —— 挂起并记最重档纠正信号` + : '任务挂起,监听与预算冻结', + undo: semantic === 'terminate' ? '不可恢复' : '可恢复', + when: '确认后即时', + }, + okLabel: `确认${CONTROL_SEMANTIC_LABEL[semantic]}`, + onOk: () => control.mutate(semantic), + }, + }); + + return ( + <> + + + {label} + + + {PARA[task.paradigm].label} + + {task.ownerName} + + } + 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: 恢复 = 从挂起点继续;预算与监听解冻,任务记忆只追加、不回滚。, + impact: { who: `负责人 ${task.ownerName}`, undo: '可再次控制', when: '确认后即时' }, + okLabel: '确认恢复', + onOk: () => resume.mutate(undefined), + }, + }, + ]} + /> + + {`${task.usedSteps}/${task.capSteps} 步`}}> + + + + + 改派 = 负责人变更;改派留痕并通知双方,任务记忆与草稿层不动。 +
+ 新负责人 +
+ {(staff.data?.staff ?? []) + .filter((s) => s.state === 'active' && s.name !== task.ownerName) + .map((s) => ( + + ))} +
+
+
+ +
+
+ + nav.push({ k: 'tasklog', id: task.id })} + /> + + ); +} + +export function TaskLogPage({ id }: { id: string }) { + const q = useGovTaskLog(id); + const lines = q.data?.log ?? []; + return ( + <> + +
+ {lines.map((l) => ( +
+ + + + + {l.title} + {l.at.slice(0, 19).replace('T', ' ')} + + + {l.sideLabel} + +
+ ))} + {lines.length === 0 && 暂无留痕。} +
+ + ); +} + +/* ---------------- 员工 / 岗位 ---------------- */ + diff --git a/apps/web/src/features/gov-tool/index.ts b/apps/web/src/features/gov-tool/index.ts new file mode 100644 index 0000000..5d3e83e --- /dev/null +++ b/apps/web/src/features/gov-tool/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-tool` 唯一出口(编码规范 §9.1)。 + * 工具管理:注册、白名单、凭证绑定(只回键,永不回值) + */ +export { ToolsHome, ToolPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-tool/internal/panel.tsx b/apps/web/src/features/gov-tool/internal/panel.tsx new file mode 100644 index 0000000..217bf84 --- /dev/null +++ b/apps/web/src/features/gov-tool/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 ( + + <> + + 按效应等级分列:读类过程中自由调用;写类只登记动作意向,放行后统一落地。密钥只存治理台,不入包、不入活动。 + + {[ + { label: '读类 · 过程中自由调用', items: reads, tone: 'brand', ico: 'search' }, + { label: '写类 · 登记意向,放行后落地', items: writes, tone: 'danger', ico: 'pen-line' }, + ].map((group) => ( +
+
+ + {group.label} + {group.items.length} +
+ + {group.items.map((t) => ( + {t.version} }, + { k: '连接配置键', v: {t.connectionKeys.join(', ') || '—'} }, + ]} + right={t.pure ? 纯工具 : {t.effect === 'read' ? '读' : '写'}} + onClick={() => nav.push({ k: 'tool', id: t.name })} + /> + ))} + +
+ ))} + +
+ ); +} + +/* ---------------- ⑦ 数据管理 ---------------- */ + +export function ToolPage({ id }: { id: string }) { + const q = useGovTools(); + const t = (q.data?.tools ?? []).find((x) => x.name === id); + if (t === undefined) return 工具不存在。; + const write = t.effect === 'write'; + return ( + <> + + {write ? '写类' : '读类'} + {t.pure && 纯工具} + v{t.version} + + } + desc={ + write + ? '写类工具在过程中只登记动作意向,不改世界;放行通过后由落地收敛统一执行。' + : '读类工具过程中自由调用 —— 读不改世界,故不需放行。' + } + /> + +

{t.description}

+
+ + 凭证隔离:治理台只回配置键,永不回值;密钥不入包、不入活动、不入留痕明文。 +
+ {t.connectionKeys.map((k) => ( +
+ {k} + 已在治理台绑定 +
+ ))} + {t.connectionKeys.length === 0 && 此工具无需外部连接。} +
+
+ + ); +} + +/* ---------------- 数据对象 ---------------- */ + diff --git a/apps/web/src/features/gov-trigger/index.ts b/apps/web/src/features/gov-trigger/index.ts new file mode 100644 index 0000000..6dde48c --- /dev/null +++ b/apps/web/src/features/gov-trigger/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-trigger` 唯一出口(编码规范 §9.1)。 + * 触发器配置(周期 / 事件 / 登记三类):与流程包平级,启停不改任何版本 + */ +export { TriggersHome, TriggerPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-trigger/internal/panel.tsx b/apps/web/src/features/gov-trigger/internal/panel.tsx new file mode 100644 index 0000000..415449a --- /dev/null +++ b/apps/web/src/features/gov-trigger/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 ( + + <> + + 触发器与流程包平级——不入包结构、不随包版本,增删启停不改任何版本。周期 / 事件 / 登记三类。 + + + {triggers.map((t) => { + const kind = triggerKindOf(t.spec); + return ( + {TRIGGER_KIND_LABEL[kind]} }]} + right={ + + {t.enabled ? '已启用' : '已停用'} + + } + onClick={() => nav.push({ k: 'trigger', id: t.id })} + /> + ); + })} + + + + ); +} + +/* ---------------- ⑤ 活动库管理 ---------------- */ + +/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */ +function useGovAction(mutate: (input: TInput) => Promise, keys: readonly string[]) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: mutate, + onSuccess: () => { + for (const key of keys) void queryClient.invalidateQueries({ queryKey: ['gov', key] }); + }, + }); +} + +/* ---------------- 工作流 ---------------- */ + +export function 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 触发器不存在。; + return ( + <> + + {TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]} + 调起 {t.processPackageId} + + } + desc="触发器与流程包平级——不入包结构、不随包版本,增删启停不改任何版本。" + acts={[ + { + label: t.enabled ? '停用' : '启用', + ico: 'power', + danger: t.enabled, + ask: { + title: `${t.enabled ? '停用' : '启用'}触发器:${t.name}`, + note: 启停不改任何版本;已起的单不受影响。, + impact: { + who: t.enabled ? '此后不再自动起单,已在途工作流不受影响' : '此后按规则自动起单', + undo: '可随时反向切换', + when: '确认后即时', + }, + okLabel: t.enabled ? '确认停用' : '确认启用', + onOk: () => toggle.mutate(!t.enabled), + }, + }, + ]} + /> + + + + {t.mapping.length}}> +
+ {t.mapping.map((m, i) => ( +
+ {m.target} + {JSON.stringify(m.source)} +
+ ))} +
+
+ + ); +} + +/* ---------------- 活动 / 工具 ---------------- */ + diff --git a/apps/web/src/shared/gov/flow/FlowDiagram.tsx b/apps/web/src/shared/gov/flow/FlowDiagram.tsx new file mode 100644 index 0000000..8e16d58 --- /dev/null +++ b/apps/web/src/shared/gov/flow/FlowDiagram.tsx @@ -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; + /** 执行人候选(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(null); + const editRef = useRef(null); + const clickRef = useRef(onNodeClick); + clickRef.current = onNodeClick; + const editNodeRef = useRef(onEditNode); + editNodeRef.current = onEditNode; + // 单击下钻延时:等一拍看是否双击(双击=改文字,取消下钻)。 + const drillTimer = useRef(null); + // 活动文字就地编辑浮层(双击活动)。 + const [nodeEdit, setNodeEdit] = useState(null); + const [editDraft, setEditDraft] = useState({ label: "", owner: "" }); + const [editBusy, setEditBusy] = useState(false); + const [editErr, setEditErr] = useState(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"); + const factory = diagram.get("elementFactory"); + const eventBus = diagram.get("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 ( +
+
+ {nodeEdit && ( +
+
+ ▭ + {nodeEdit.node.id} · 改文字 +
+
+ 标题 + setEditDraft((d) => ({ ...d, label: e.target.value }))} /> +
+
+ 执行 + {ownerOptions ? ( + setEditDraft((d) => ({ ...d, owner: v }))} /> + ) : ( + setEditDraft((d) => ({ ...d, owner: e.target.value }))} /> + )} +
+ {editErr &&

{editErr}

} +
+ + +
+
+ )} +
+ ); +} + +/** 供节点导航等场景推导活动节点清单(与渲染同一数据源)。 */ +export function activityNodes(graph: FlowGraph | null | undefined): FlowNode[] { + return (graph?.nodes ?? []).filter((node) => node.kind === "activity" && !node.ghost); +} + +export type { ShapeLayout }; diff --git a/apps/web/src/shared/gov/flow/FlowFigure.tsx b/apps/web/src/shared/gov/flow/FlowFigure.tsx new file mode 100644 index 0000000..80d1f4a --- /dev/null +++ b/apps/web/src/shared/gov/flow/FlowFigure.tsx @@ -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 ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/shared/gov/flow/flow-canvas.css b/apps/web/src/shared/gov/flow/flow-canvas.css new file mode 100644 index 0000000..a0274c8 --- /dev/null +++ b/apps/web/src/shared/gov/flow/flow-canvas.css @@ -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,'); + background-size: 22px auto; +} +.fe-pi-case { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 22px auto; +} + +.fe-pi-approval { + background-image: url('data:image/svg+xml;utf8,'); + 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,'); + background-size: 22px auto; +} +.fe-pi-gwx { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 18px 18px; +} +.fe-pi-gwand { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 18px 18px; +} +.fe-pi-start { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 17px 17px; +} +.fe-pi-end { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 17px 17px; +} +.fe-pi-callout { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 22px auto; +} +.fe-ci-connect { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 15px 15px; +} +.fe-ci-del { + background-image: url('data:image/svg+xml;utf8,'); + background-size: 15px 15px; +} +.fe-ci-edit { + background-image: url('data:image/svg+xml;utf8,'); + 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,'); + 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; +} diff --git a/apps/web/src/shared/gov/flow/flow-layout.ts b/apps/web/src/shared/gov/flow/flow-layout.ts new file mode 100644 index 0000000..bbf2ba6 --- /dev/null +++ b/apps/web/src/shared/gov/flow/flow-layout.ts @@ -0,0 +1,1076 @@ +/** + * 流程图确定性布局引擎:把服务器规范化后的结构化图数据(节点/行/挂靠/连线) + * 算成坐标与折线路径,喂给 diagram-js 绘制。SVG 几何不经模型之手。 + * + * 两组度量预设对应两份参考文档: + * business = docs/业务流示例.html FIG 2.1(L2 操作流程图,viewBox 1000 宽) + * steps = docs/操作流示例.html 步骤流程(L3,单行为主) + * 常量取自参考 SVG 的实测几何。 + * + * 折行采用**蛇形(boustrophedon)**:偶数行 L→R、奇数行 R→L,行与行由一小段竖落相接。 + * (参考图原本用右侧折返车道 x=966 绕回行首——那个大回环已弃用,`lane` 仅作跨多行的兜底; + * 故此处已刻意偏离参考图的折返画法,改为蛇形。) + */ + +export type FlowNodeKind = "start" | "end" | "end-bad" | "activity" | "gateway" | "callout"; +export type FlowTone = "main" | "ok" | "bad"; + +export interface FlowNode { + id: string; + kind: FlowNodeKind; + /** 仅活动:case=Case 节点(绿,一事一议,下钻 CMMN 信封);缺省=SOP 节点(蓝)。 */ + /** + * 节点变体:case = 案例活动(绿) / approval = 审批点(琥珀);缺省 = 标准活动(蓝)。 + * ⚠️ approval **不是一种活动**——按规范,审批点是流程层的系统内置人裁节点,与活动并列为 + * 编排层五要素之一,零开发、不进活动库。它挂在 kind:"activity" 下**仅因共用同一套方框几何** + * (蛇形折行与尺寸计算),不表示它在模型上属于活动。 + */ + flavor?: "case" | "approval"; + /** Case 节点引用的库案例 id(cases[].id):L2 只是引用,下钻编辑共享库案例。 */ + caseRef?: string; + label: string; + sub?: string; + code?: string; + ghost?: boolean; + note?: string; + /** (遗留)仅卡点:旧编译器按方向着色的产物——卡点现统一红,渲染忽略。 */ + tone?: "ok" | "bad"; + /** 仅卡点:去向节点 id(回跳返工/前跳直达)——渲染时框内标 →id。 */ + to?: string; + /** 责任下沉:活动=主办(A);网关=判定人。活动框内角标,网关随判据进浮层。 */ + owner?: string; + /** 判据下沉:仅网关——判定标准(节点数据;画布不再弹窗,导出派生表可见)。 */ + basis?: string; + /** 仅网关:x=判断(×,默认)/ and=并行(+,所有出口同时走)。 */ + mode?: "x" | "and"; + /** 数据线下沉(仅活动):进/出=流转,查=底座——L2 图后「数据 · 台账与加工」段由此派生。 */ + in?: string[]; + out?: string[]; + use?: string[]; + /** 执行基数(exec-spec):item=逐条办;缺省=跟单办。 */ + per?: "item"; + /** 人机分工:auto 自动 / assist 帮我做 / human 我来做(缺省)。 */ + assign?: "auto" | "assist" | "human"; + /** Wait/等待(BPMN 调用活动):被调流程规范名(软引用);画双边框+等待角词,点击跳被调流程。 */ + wait?: string; + /** 手绘图(graph.manual)专用:节点左上角坐标(px,MANUAL_GRID 的倍数)。 */ + x?: number; + y?: number; + /** 手绘图专用:旁注文字(起止/网关名)中心点——注记自由摆放,不吸附网格。 */ + lx?: number; + ly?: number; +} + +export interface FlowAnchor { + node: string; + at: "above" | "below" | "right"; + of: string; +} + +export interface FlowEdge { + from: string; + to: string; + label?: string; + tone?: FlowTone; + /** 手绘图专用:整条折线路径(px,含两端;每段水平或垂直)。 */ + points?: Array<{ x: number; y: number }>; + /** 手绘图专用:线标签文字中心点(注记,自由摆放)。 */ + lx?: number; + ly?: number; +} + +export interface FlowGraph { + nodes: FlowNode[]; + rows: string[][]; + anchors: FlowAnchor[]; + edges: FlowEdge[]; + /** true=手绘图:几何即真相(节点 x/y + 连线 points),不走自动布局。 */ + manual?: boolean; +} + +export type FlowVariant = "business" | "steps"; + +export interface ShapeLayout { + node: FlowNode; + x: number; + y: number; + w: number; + h: number; +} + +export interface CaptionLayout { + text: string; + x: number; + y: number; + anchor: "start" | "middle" | "end"; + role: "gateway" | "start" | "end" | "end-bad" | "note"; +} + +export interface ConnectionLayout { + id: string; + edge: FlowEdge; + tone: FlowTone; + waypoints: Array<{ x: number; y: number }>; + label?: { text: string; x: number; y: number; anchor: "start" | "middle" | "end" }; +} + +export interface FlowLayoutResult { + shapes: ShapeLayout[]; + captions: CaptionLayout[]; + connections: ConnectionLayout[]; + width: number; + height: number; + /** 空图:渲染为虚线 ghost 骨架。 */ + ghost: boolean; +} + +/* ---- SVG 文字估宽与适配(与服务器画布渲染同一套估算) ---- */ + +/** 估算文本宽度:CJK ≈ fontSize,其余 ≈ 0.58×。 */ +export function estTextWidth(text: string, fontSize: number): number { + let width = 0; + for (const ch of text) { + width += /[⺀-鿿豈-﫿＀-￯]/.test(ch) ? fontSize : fontSize * 0.58; + } + return width; +} + +/** 先缩字号(至 minSize)再截断加省略号,保证一行放得下。 */ +export function fitLine( + text: string, + maxWidth: number, + fontSize: number, + minSize: number, +): { text: string; size: number } { + let size = fontSize; + while (size > minSize && estTextWidth(text, size) > maxWidth) size -= 0.5; + if (estTextWidth(text, size) <= maxWidth) return { text, size }; + let out = text; + while (out.length > 1 && estTextWidth(`${out}…`, size) > maxWidth) out = out.slice(0, -1); + return { text: `${out}…`, size }; +} + +interface Metrics { + actW: number; + actWide: number; + actH: number; + gwHd: number; + r: number; + calloutW: number; + calloutH: number; + xLeft: number; + xRight: number; + lane: number; + rowGap: number; + midBase: number; + midWithAbove: number; + aboveCalloutTop: number; + aboveActivityTop: number; + belowActivityTop: number; + belowCalloutTop: number; + belowEndBadMid: number; + rightGap: number; + corridorPlain: number; + corridorWithAbove: number; +} + +const METRICS: Record = { + business: { + actW: 100, + actWide: 130, + actH: 38, + gwHd: 18, + r: 11, + calloutW: 104, + calloutH: 26, + xLeft: 35, + xRight: 930, + lane: 966, + rowGap: 198, + midBase: 66, + midWithAbove: 100, + aboveCalloutTop: -69, + aboveActivityTop: -94, + belowActivityTop: 61, + belowCalloutTop: 51, + belowEndBadMid: 64, + rightGap: 70, + corridorPlain: 58, + corridorWithAbove: 88, + }, + steps: { + actW: 150, + actWide: 150, + actH: 40, + gwHd: 20, + r: 10, + calloutW: 120, + calloutH: 34, + xLeft: 35, + xRight: 930, + lane: 966, + rowGap: 198, + midBase: 66, + midWithAbove: 100, + aboveCalloutTop: -69, + aboveActivityTop: -94, + belowActivityTop: 61, + belowCalloutTop: 42, + belowEndBadMid: 59, + rightGap: 70, + corridorPlain: 58, + corridorWithAbove: 88, + }, +}; + +/** 行首为左置标注网关时的内容缩进(参考图 G3:左顶点 112 = 35+77)。 */ +const GW_INDENT = 77; +const GAP_MIN = 40; +/** 网关出线离开顶点后先沿顶点法向延伸一小段再拐弯(不贴着顶点横拉)。 */ +const GW_STUB = 20; + +const GHOST_GRAPH: FlowGraph = { + nodes: [ + { id: "S", kind: "start", label: "起点" }, + { id: "A", kind: "activity", label: "活动 A", ghost: true }, + { id: "G", kind: "gateway", label: "岔口" }, + { id: "B", kind: "activity", label: "活动 B", ghost: true }, + { id: "E", kind: "end", label: "终点" }, + ], + rows: [["S", "A", "G", "B", "E"]], + anchors: [], + edges: [ + { from: "S", to: "A" }, + { from: "A", to: "G" }, + { from: "G", to: "B" }, + { from: "B", to: "E" }, + ], +}; + +function nodeWidth(node: FlowNode, m: Metrics, shrink: number): number { + switch (node.kind) { + case "start": + case "end": + case "end-bad": + return m.r * 2; + case "gateway": + return m.gwHd * 2; + case "callout": + return m.calloutW; + case "activity": + return Math.max(80, (node.sub || node.ghost ? m.actWide : m.actW) - shrink); + } +} + +function nodeHeight(node: FlowNode, m: Metrics): number { + switch (node.kind) { + case "start": + case "end": + case "end-bad": + return m.r * 2; + case "gateway": + return m.gwHd * 2; + case "callout": + return m.calloutH; + case "activity": + return m.actH; + } +} + +type CaptionSide = "above" | "below" | "left"; + +/* ---- 手绘图(manual):几何即真相,布局只做直排 ---- */ + +/** 手绘珊格(px):节点坐标、连线拐点都吸附到它的倍数。 */ +export const MANUAL_GRID = 10; + +/** 手绘节点尺寸:取网格友好值(边中点/顶点都落在珊格上),与自动布局的参考几何相近。 */ +export const MANUAL_SIZES: Record = { + activity: { w: 100, h: 40 }, + gateway: { w: 40, h: 40 }, + start: { w: 20, h: 20 }, + end: { w: 20, h: 20 }, + "end-bad": { w: 20, h: 20 }, + callout: { w: 100, h: 30 }, +}; + +export function manualNodeSize(node: FlowNode): { w: number; h: number } { + return MANUAL_SIZES[node.kind]; +} + +/** 手绘连线的标签位:最长一段的中点(横段标在线上方居中,竖段标在线右侧)。 */ +export function manualEdgeLabel( + edge: FlowEdge, + points: Array<{ x: number; y: number }>, +): ConnectionLayout["label"] { + if (!edge.label) return undefined; + // 手工摆放过的标签位(中心点)优先。 + if (edge.lx !== undefined && edge.ly !== undefined) { + return { text: edge.label, x: edge.lx, y: edge.ly + 3.5, anchor: "middle" }; + } + let best = 0; + let bestLen = -1; + for (let i = 1; i < points.length; i++) { + const len = Math.abs(points[i]!.x - points[i - 1]!.x) + Math.abs(points[i]!.y - points[i - 1]!.y); + if (len > bestLen) { + bestLen = len; + best = i; + } + } + const a = points[best - 1]; + const b = points[best]; + if (a === undefined || b === undefined) return undefined; + if (a.y === b.y) { + return { text: edge.label, x: (a.x + b.x) / 2, y: a.y - 5, anchor: "middle" }; + } + return { text: edge.label, x: a.x + 6, y: (a.y + b.y) / 2 + 3, anchor: "start" }; +} + +/** 手绘图直排:节点按存储坐标放置,连线按存储折线画,起止/网关的名字作旁注。 */ +function layoutManualFlow(graph: FlowGraph): FlowLayoutResult { + const shapes: ShapeLayout[] = graph.nodes.map((node) => { + const size = manualNodeSize(node); + return { node, x: node.x ?? 0, y: node.y ?? 0, w: size.w, h: size.h }; + }); + + const connections: ConnectionLayout[] = []; + graph.edges.forEach((edge, i) => { + const points = edge.points; + if (!points || points.length < 2) return; + const label = manualEdgeLabel(edge, points); + connections.push({ + id: `manual-${i}`, + edge, + tone: edge.tone ?? "main", + waypoints: points, + ...(label === undefined ? {} : { label }), + }); + }); + + const captions: CaptionLayout[] = []; + for (const shape of shapes) { + const { node } = shape; + if (node.kind === "start" || node.kind === "end" || node.kind === "end-bad" || node.kind === "gateway") { + // 手工摆放过的旁注位(中心点)优先;缺省在节点正下方。 + const placed = node.lx !== undefined && node.ly !== undefined; + captions.push({ + text: node.label, + x: placed ? node.lx! : shape.x + shape.w / 2, + y: placed ? node.ly! + 3.5 : shape.y + shape.h + 13, + anchor: "middle", + role: node.kind === "gateway" ? "gateway" : node.kind, + }); + } + } + + // 内容整体收顶/收左:手绘坐标可能从很深处开始,把最上/最左留白压到 PAD, + // 否则展示态(viewBox 从 0 起)顶着一大块空白。只向原点收,不外推。 + const PAD = 20; + let minX = Infinity; + let minY = Infinity; + for (const shape of shapes) { + minX = Math.min(minX, shape.x); + minY = Math.min(minY, shape.y); + } + for (const connection of connections) { + for (const p of connection.waypoints) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + } + } + for (const caption of captions) { + minX = Math.min(minX, caption.x - estTextWidth(caption.text, 10) / 2); + minY = Math.min(minY, caption.y - 10); + } + const dx = Number.isFinite(minX) && minX > PAD ? minX - PAD : 0; + const dy = Number.isFinite(minY) && minY > PAD ? minY - PAD : 0; + if (dx || dy) { + for (const shape of shapes) { + shape.x -= dx; + shape.y -= dy; + } + for (const caption of captions) { + caption.x -= dx; + caption.y -= dy; + } + for (const connection of connections) { + connection.waypoints = connection.waypoints.map((p) => ({ x: p.x - dx, y: p.y - dy })); + if (connection.label) { + connection.label = { ...connection.label, x: connection.label.x - dx, y: connection.label.y - dy }; + } + } + } + + let maxX = 0; + let maxY = 0; + for (const shape of shapes) { + maxX = Math.max(maxX, shape.x + shape.w); + maxY = Math.max(maxY, shape.y + shape.h); + } + for (const connection of connections) { + for (const p of connection.waypoints) { + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + } + for (const caption of captions) { + maxX = Math.max(maxX, caption.x + estTextWidth(caption.text, 10) / 2); + maxY = Math.max(maxY, caption.y + 4); + } + + return { + shapes, + captions, + connections, + width: Math.max(1000, maxX + 40), + height: maxY + 44, + ghost: false, + }; +} + +export function layoutFlow(input: FlowGraph, variant: FlowVariant): FlowLayoutResult { + if (input.manual && input.nodes.length > 0) return layoutManualFlow(input); + const m = METRICS[variant]; + const ghost = input.nodes.length === 0; + const graph = ghost ? GHOST_GRAPH : input; + + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const rows = graph.rows.filter((row) => row.length > 0); + const rowIndex = new Map(); + rows.forEach((row, r) => row.forEach((id, c) => rowIndex.set(id, { row: r, col: c }))); + const anchorByNode = new Map(graph.anchors.map((anchor) => [anchor.node, anchor])); + + /** + * 网关的**前跳/汇回**边:从网关(非行尾)直连到更后一行的某步(mergeTo 分支,如「A类」直接汇到 A4)。 + * 这类边要从网关**下端顶点**发出(不再挤进右侧折返车道),故认定它占用了网关的下端。 + */ + const gatewaySkipsDown = (id: string): boolean => { + const node = byId.get(id); + const pos = rowIndex.get(id); + if (node?.kind !== "gateway" || pos === undefined) return false; + if (pos.col >= (rows[pos.row]?.length ?? 0) - 1) return false; // 行尾网关→那是主折返,不算前跳 + return graph.edges.some((e) => { + if (e.from !== id) return false; + const to = rowIndex.get(e.to); + return to !== undefined && to.row > pos.row; + }); + }; + + /* ---- 蛇形折行占用的中心口:行尾底部中心(出)与次行首顶部中心(入)。 + * 支线汇入这些口时让位到靠源侧的三分位竖轨——否则汇入线与折行线对向共轨, + * 视觉上"进线出线叠成一条"(2026-07 A3→A5 vs A5→A6 实例)。 ---- */ + const foldBottomIds = new Set(); + const foldTopIds = new Set(); + for (const e of graph.edges) { + const ap = rowIndex.get(e.from); + const bp = rowIndex.get(e.to); + if (ap !== undefined && bp !== undefined && bp.row === ap.row + 1 && !anchorByNode.has(e.from) && !anchorByNode.has(e.to)) { + foldBottomIds.add(e.from); + foldTopIds.add(e.to); + } + } + /** 支线汇入目标口的竖轨 x:中心口被折行占用时让位到靠源侧三分位。 */ + const mergeInX = (targetId: string, b: { x: number; w: number }, bCenterX: number, side: "top" | "bottom", srcX: number): number => { + const contested = side === "bottom" ? foldBottomIds.has(targetId) : foldTopIds.has(targetId); + if (!contested) return bCenterX; + return srcX <= bCenterX ? b.x + b.w * 0.3 : b.x + b.w * 0.7; + }; + + /* ---- 网关标注方位:下方空→下;上方空→上;都占→左置 ---- */ + const captionSides = new Map(); + for (const [id, node] of byId) { + if (node.kind !== "gateway") continue; + const pos = rowIndex.get(id); + const bottomTaken = + graph.anchors.some((a) => a.of === id && a.at === "below") || gatewaySkipsDown(id); + const topTaken = + graph.anchors.some((a) => a.of === id && a.at === "above") || + // 跨行折返进它的顶点(它是次行行首且有来自上一行行尾的边) + (pos !== undefined && + pos.row > 0 && + pos.col === 0 && + graph.edges.some((e) => { + const from = rowIndex.get(e.from); + return e.to === id && from !== undefined && from.row < pos.row; + })); + captionSides.set(id, !bottomTaken ? "below" : !topTaken ? "above" : "left"); + } + + /* ---- 行内水平排布 ---- */ + const rowHasAbove = rows.map((row) => + row.some((id) => graph.anchors.some((a) => a.of === id && a.at === "above")), + ); + // 挂靠占用层数:锚点顺次外挂(下挂探向下一行、上挂探出画布顶)。**同宿主同侧的多个挂靠 + // 竖直阶梯堆叠**(每个兄弟连同其链占一段),所以层数 = Σ(1 + 各兄弟子树层数)——单链与旧 + // chainDepth 等价;多分支时把兄弟也计入,行距/画布高才腾得开。 + const stackLevels = (rootId: string, at: "above" | "below"): number => { + const kids = graph.anchors.filter((an) => an.at === at && an.of === rootId); + return kids.reduce((sum, k) => sum + 1 + stackLevels(k.node, at), 0); + }; + const rowBelowDepth = rows.map((row) => Math.max(0, ...row.map((id) => stackLevels(id, "below")))); + const row0AboveDepth = rows.length ? Math.max(0, ...(rows[0] ?? []).map((id) => stackLevels(id, "above"))) : 0; + const belowStep = m.belowActivityTop + m.actH / 2; // 每多一层下挂,链底再下移这么多 + const aboveStep = -m.aboveActivityTop - m.actH / 2; // 每多一层上挂,链顶再上移这么多 + const rowMids: number[] = []; + for (let r = 0; r < rows.length; r++) { + if (r === 0) { + rowMids[r] = + row0AboveDepth >= 1 ? m.midWithAbove + (row0AboveDepth - 1) * aboveStep : m.midBase; + } else { + const d = rowBelowDepth[r - 1] ?? 0; + // depth-d 下挂链底距行中线 = belowActivityTop+actH +(d-1)·belowStep;再留 GAP_MIN + 下一行半高。 + const need = + d >= 1 ? m.belowActivityTop + m.actH + GAP_MIN + m.actH / 2 + (d - 1) * belowStep : m.rowGap; + rowMids[r] = rowMids[r - 1]! + Math.max(m.rowGap, need); + } + } + + const shapes = new Map(); + + rows.forEach((row, r) => { + const mid = rowMids[r] ?? 0; + const first = row[0] === undefined ? undefined : byId.get(row[0]); + const indent = first?.kind === "gateway" && captionSides.get(row[0] ?? "") === "left" ? GW_INDENT : 0; + const xl = m.xLeft + indent; + + // 求间距 U:Σw + Σ(weight·U) = xRight − xl;圆后的间距权重 0.6。 + let shrink = 0; + let widths: number[] = []; + let unit = 0; + let weights: number[] = []; + for (;;) { + widths = row.map((id) => nodeWidth(byId.get(id)!, m, shrink)); + weights = row.slice(1).map((_, i) => { + const prev = byId.get(row[i] ?? "")!; + return prev.kind === "start" || prev.kind === "end" || prev.kind === "end-bad" ? 0.6 : 1; + }); + const totalW = widths.reduce((sum, w) => sum + w, 0); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + unit = totalWeight > 0 ? (m.xRight - xl - totalW) / totalWeight : 0; + if (unit >= GAP_MIN || shrink >= 20 || row.length === 1) break; + shrink += 10; + } + // 横向铺满:节点始终从 xLeft 排到 xRight(unit 即填满整行的间距),不做稀疏居中留白。 + let x = xl; + + row.forEach((id, i) => { + const node = byId.get(id)!; + const w = widths[i]!; + const h = nodeHeight(node, m); + if (i > 0) x += weights[i - 1]! * unit; + shapes.set(id, { node, x, y: mid - h / 2, w, h }); + x += w; + }); + + // 蛇形折行:奇数行沿行带中线镜像 → 该行第一个节点(接上一行末尾)落到**右侧正下方**, + // 整行右→左读,行间连接由此变成一小段竖落(而非绕到最右折返车道)。 + if (r % 2 === 1) { + for (const id of row) { + const s = shapes.get(id)!; + s.x = m.xLeft + m.xRight - (s.x + s.w); + } + } + }); + + /* ---- 挂靠节点(相对宿主;挂靠链按依赖顺序解) ---- */ + // 同侧多个挂靠:第一个居中于宿主(参考几何不变);第 2+ 个**竖直阶梯堆叠**——每个兄弟 + // 沿 belowStep/aboveStep 网格再下(上)一层(前面兄弟的链也占层),横向朝行流向的**反侧** + // 错开 STAIR_X(网关→深层挂靠的竖落线要能避开浅层框;流向侧留给汇回线)。 + // 曾经横向右堆:宿主靠右时直接堆出画布,且汇回线沿框中线横穿同排兄弟框——已废弃。 + const STAIR_X = Math.max(m.actW, m.actWide, m.calloutW) / 2 + 12; + const anchorLevel = new Map(); // 节点 → 阶梯层号(0 = 槽基线) + const slotState = new Map(); // `${of}:${at}` + // 出口列(指导书尾网关多出口,2026-07 用户规则:出口并成一列贴右缘): + // 仅当宿主行**最后一个主带节点是结束圆**(尾网关形)时生效,返回该圆的中线 x; + // 其他形状(折行网关的 stop 分支等)保持原挂靠几何。绿 end 全变体;红 end-bad 仅步骤图。 + const exitColumnX = (hostId: string, targetKind: FlowNodeKind): number | undefined => { + if (!(targetKind === "end" || (targetKind === "end-bad" && variant === "steps"))) return undefined; + const hostRow = rowIndex.get(hostId)?.row; + const rowIds = hostRow !== undefined ? rows[hostRow] : undefined; + if (!rowIds || rowIds.length < 2) return undefined; + // 尾网关形 = 宿主网关**紧邻**行尾的主结束圆(中间隔着主线节点的普通网关不进列)。 + const lastId = rowIds[rowIds.length - 1]; + if (lastId === undefined || byId.get(lastId)?.kind !== "end" || rowIds[rowIds.length - 2] !== hostId) return undefined; + const lastMain = shapes.get(lastId); + return lastMain ? lastMain.x + lastMain.w / 2 : undefined; + }; + let pass = 0; + let remaining = [...graph.anchors]; + while (remaining.length > 0 && pass < 4) { + const next: FlowAnchor[] = []; + for (const anchor of remaining) { + const host = shapes.get(anchor.of); + const node = byId.get(anchor.node); + if (!node) continue; + if (!host) { + next.push(anchor); + continue; + } + const hostMid = host.y + host.h / 2; + const hostCx = host.x + host.w / 2; + const w = nodeWidth(node, m, 0); + const h = nodeHeight(node, m); + const slotKey = `${anchor.of}:${anchor.at}`; + const centered = anchor.at === "right" ? host.x + host.w + m.rightGap - w / 2 : hostCx - w / 2; + let level = 0; + let x = centered; + if (anchor.at !== "right") { + let slot = slotState.get(slotKey); + if (!slot) { + // 错开方向 = 行流向的反侧(偶数行 L→R 流,汇回线去右边 → 错去左;镜像行反之); + // 反侧越出左缘则回落右侧(右侧画布可增宽,左侧是硬边)。 + const hostRow = rowIndex.get(anchor.of)?.row ?? 0; + let dir: 1 | -1 = hostRow % 2 === 1 ? 1 : -1; + const maxDrift = STAIR_X * Math.max(0, stackLevels(anchor.of, anchor.at) - 1); + if (dir === -1 && hostCx - maxDrift - Math.max(m.actW, m.calloutW) / 2 < m.xLeft - 20) dir = 1; + slot = { dir, nextLevel: 0 }; + slotState.set(slotKey, slot); + } + level = slot.nextLevel; + // 本兄弟连同其名下挂靠链占的层数,下一个兄弟从其后接着排。 + slot.nextLevel += 1 + stackLevels(anchor.node, anchor.at); + x = centered + slot.dir * STAIR_X * level; + } + // 出口圆(指导书尾网关多出口):并成一列**贴右缘对齐**(判定见 exitColumnX)。 + if (anchor.at !== "right") { + const colX = exitColumnX(anchor.of, node.kind); + if (colX !== undefined) x = colX - w / 2; + } + anchorLevel.set(anchor.node, level); + let y: number; + if (anchor.at === "right") { + y = hostMid - h / 2; + } else if (anchor.at === "above") { + // 活动比小圆注高,要抬得更高才不贴着网关(否则上探连线只有十几像素)。 + y = hostMid + (node.kind === "activity" ? m.aboveActivityTop : m.aboveCalloutTop) - level * aboveStep; + } else { + y = + hostMid + + // end(绿色出口圆,指导书尾网关多出口)与 end-bad 同为 20×20 圆,共用居中式偏移。 + (node.kind === "end-bad" || node.kind === "end" + ? m.belowEndBadMid - h / 2 + : node.kind === "activity" + ? m.belowActivityTop + : m.belowCalloutTop) + + level * belowStep; + } + shapes.set(anchor.node, { node, x, y, w, h }); + } + remaining = next; + pass++; + } + + /* ---- 标注(网关 G# · 名 / 圆旁小注 / 网关交叉引用) ---- */ + const captions: CaptionLayout[] = []; + for (const { node, x, y, w, h } of shapes.values()) { + const cx = x + w / 2; + const cy = y + h / 2; + if (node.kind === "gateway") { + const side = captionSides.get(node.id) ?? "below"; + const text = `${node.id} · ${node.label}`; + if (side === "below") captions.push({ text, x: cx, y: cy + 40, anchor: "middle", role: "gateway" }); + else if (side === "above") captions.push({ text, x: cx, y: cy - 30, anchor: "middle", role: "gateway" }); + // 四顶点都有线时(上下都挂分支):标注放到网关**左上方**——避开正中的进/出主线(在 cy 高度) + // 与上/下探连线(在 cx 竖列),落在左上空档里。 + else captions.push({ text, x: x - 6, y: cy - m.gwHd - 8, anchor: "end", role: "gateway" }); + if (node.note) { + captions.push({ + text: node.note, + x: cx + m.gwHd * 2, + y: cy + m.gwHd + 20, + anchor: "start", + role: "note", + }); + } + } else if (node.kind === "start") { + captions.push({ text: node.label, x: cx, y: cy - m.r - 15, anchor: "middle", role: "start" }); + } else if (node.kind === "end" || node.kind === "end-bad") { + captions.push({ text: node.label, x: cx, y: cy + m.r + 19, anchor: "middle", role: node.kind }); + } + } + + /* ---- 网关四顶点端口分配 ---- */ + // 多条下挂分支挤在网关**底顶点**时(如 G3 的「无·免费 / 无·收费」),把明显偏侧、且该侧顶点空闲 + //(无同行边占用)的分支挪到左/右顶点,让进/出四条线尽量各占一顶点。仅 ≥2 条下挂分支才触发,单分支流程不变。 + const gwPortOverride = new Map(); // edge index → 改从该侧顶点出 + { + const sideTaken = new Map(); + // 网关同行的**出边与入边**都占用左右顶点。入边曾被漏记——蛇形镜像行主线从右顶点进, + // 右顶点被误判空闲,分流边的横段就沿行中线穿过网关的前驱活动;两侧都被占时 + // 正确行为是回落到底顶点 stub 路由(那条走廊在行带下方,天然避框)。 + const markSide = (gwId: string, otherId: string) => { + const gw = byId.get(gwId); + const ap = rowIndex.get(gwId); + const bp = rowIndex.get(otherId); + if (gw?.kind !== "gateway" || !ap || !bp || ap.row !== bp.row) return; + const g = shapes.get(gwId); + const t = shapes.get(otherId); + if (!g || !t) return; + const s = sideTaken.get(gwId) ?? { left: false, right: false }; + if (t.x + t.w / 2 >= g.x + g.w / 2) s.right = true; + else s.left = true; + sideTaken.set(gwId, s); + }; + graph.edges.forEach((e) => { + markSide(e.from, e.to); + markSide(e.to, e.from); + }); + const belowByGw = new Map(); + graph.edges.forEach((e, i) => { + const an = anchorByNode.get(e.to); + if (an?.of === e.from && an.at === "below") { + const l = belowByGw.get(e.from) ?? []; + l.push(i); + belowByGw.set(e.from, l); + } + }); + for (const [gw, idxs] of belowByGw) { + if (idxs.length < 2) continue; // 只有多条下挂挤在底顶点才分流 + const g = shapes.get(gw); + if (!g) continue; + const gcx = g.x + g.w / 2; + const taken = sideTaken.get(gw) ?? { left: false, right: false }; + // 偏得最远的优先占侧顶点(离网关越远,越该走侧顶点绕开) + const sorted = [...idxs].sort((i, j) => { + const ti = shapes.get(graph.edges[i]!.to)!; + const tj = shapes.get(graph.edges[j]!.to)!; + return Math.abs(tj.x + tj.w / 2 - gcx) - Math.abs(ti.x + ti.w / 2 - gcx); + }); + for (const i of sorted) { + // 阶梯层(≥1)的挂靠一律走底顶点 stub 路由:左右顶点的横段沿行中线走, + // 阶梯框横向错位后该横段可能扫过同行主带框。 + if ((anchorLevel.get(graph.edges[i]!.to) ?? 0) > 0) continue; + const t = shapes.get(graph.edges[i]!.to)!; + const tcx = t.x + t.w / 2; + if (tcx > gcx + m.gwHd && !taken.right) { + gwPortOverride.set(i, "right"); + taken.right = true; + } else if (tcx < gcx - m.gwHd && !taken.left) { + gwPortOverride.set(i, "left"); + taken.left = true; + } + } + } + } + + /* ---- 连线路由 R1–R7 ---- */ + const connections: ConnectionLayout[] = []; + graph.edges.forEach((edge, index) => { + const a = shapes.get(edge.from); + const b = shapes.get(edge.to); + if (!a || !b) return; + const tone: FlowTone = edge.tone ?? "main"; + const aMid = { x: a.x + a.w / 2, y: a.y + a.h / 2 }; + const bMid = { x: b.x + b.w / 2, y: b.y + b.h / 2 }; + const aPos = rowIndex.get(edge.from); + const bPos = rowIndex.get(edge.to); + const aAnchor = anchorByNode.get(edge.from); + const bAnchor = anchorByNode.get(edge.to); + + let waypoints: Array<{ x: number; y: number }>; + let label: ConnectionLayout["label"]; + + if ( + bAnchor?.of === edge.from && + bAnchor.at !== "right" && + exitColumnX(edge.from, b.node.kind) !== undefined + ) { + // R3c 出口圆(右缘一列):网关底/顶顶点竖出到圆心高度,横到右缘入圆**左口**—— + // 多条出口共用顶点竖干(同向共轨=合流干线),在各自高度分层向右。判定见 exitColumnX。 + const down = bAnchor.at === "below"; + waypoints = [ + { x: aMid.x, y: down ? a.y + a.h : a.y }, + { x: aMid.x, y: bMid.y }, + { x: b.x, y: bMid.y }, + ]; + if (edge.label) + label = { text: edge.label, x: aMid.x + 10, y: bMid.y - 7, anchor: "start" }; + } else if (bAnchor?.of === edge.from && bAnchor.at === "above") { + // R2 上探:正上方直上;被错开(同侧第 2 个挂靠)则先横到目标 x 再上,箭头准落到目标。 + waypoints = + Math.abs(bMid.x - aMid.x) < 1 + ? [ + { x: aMid.x, y: a.y }, + { x: aMid.x, y: b.y + b.h }, + ] + : [ + { x: aMid.x, y: a.y }, + { x: bMid.x, y: a.y }, + { x: bMid.x, y: b.y + b.h }, + ]; + if (edge.label) label = { text: edge.label, x: bMid.x + 10, y: (a.y + b.y + b.h) / 2 + 4, anchor: "start" }; + } else if (bAnchor?.of === edge.from && bAnchor.at === "below") { + // R3 下探:网关只从上/下/左/右四顶点进出(不从菱形斜边出)。默认走**底顶点**——正下方直下,错开的 + // 先向下延伸一段 stub 再横到目标列(不贴着顶点横拉)。被端口分配挪走的分支改从左/右顶点出: + // 先沿该向延伸到目标列,再竖入目标顶,让进/出四条线各占一个顶点(如 G3 的无·免费=底、无·收费=右)。 + const port = gwPortOverride.get(index); + if (port === "right" || port === "left") { + const exitX = port === "right" ? a.x + a.w : a.x; + waypoints = [ + { x: exitX, y: aMid.y }, + { x: bMid.x, y: aMid.y }, + { x: bMid.x, y: b.y }, + ]; + if (edge.label) + label = { text: edge.label, x: bMid.x + 10, y: (aMid.y + b.y) / 2, anchor: "start" }; + } else { + const stubY = a.y + a.h + GW_STUB; + waypoints = + Math.abs(bMid.x - aMid.x) < 1 + ? [ + { x: aMid.x, y: a.y + a.h }, + { x: aMid.x, y: b.y }, + ] + : [ + { x: aMid.x, y: a.y + a.h }, + { x: aMid.x, y: stubY }, + { x: bMid.x, y: stubY }, + { x: bMid.x, y: b.y }, + ]; + if (edge.label) + label = { text: edge.label, x: bMid.x + 10, y: (a.y + a.h + b.y) / 2 + 4, anchor: "start" }; + } + } else if (bAnchor?.of === edge.from && bAnchor.at === "right") { + // R6 挂靠横连 + waypoints = [ + { x: a.x + a.w, y: aMid.y }, + { x: b.x, y: aMid.y }, + ]; + if (edge.label) label = { text: edge.label, x: (a.x + a.w + b.x) / 2, y: aMid.y - 7, anchor: "middle" }; + } else if (aAnchor && bPos !== undefined) { + // R4 支线汇回主带。按**挂靠边**分三种走法,核心是不穿过任何活动/网关。 + if (aAnchor.at === "above") { + // ① 上挂分支:正下方就是它挂靠的网关——**绝不能向下出线**。从朝目标的一侧**水平**出 + // (仍在网关上方那条高度),横到目标列,再竖入目标(目标更低入其顶、更高入其底)。 + const exitX = bMid.x >= aMid.x ? a.x + a.w : a.x; + const intoTop = bMid.y >= aMid.y; + const inX = mergeInX(edge.to, b, bMid.x, intoTop ? "top" : "bottom", aMid.x); + waypoints = [ + { x: exitX, y: aMid.y }, + { x: inX, y: aMid.y }, + { x: inX, y: intoTop ? b.y : b.y + b.h }, + ]; + if (edge.label) label = { text: edge.label, x: (exitX + inX) / 2, y: aMid.y - 7, anchor: "middle" }; + } else if (bMid.y > aMid.y) { + // ② 下挂分支 → 更低的目标:走分支行**下方**的走廊(不横穿同排分支盒),横到目标列 + // 再竖入其顶。多条支线共用走廊、竖段重叠没关系(合流本就该汇到一处)。 + const corridorY = a.y + a.h + 14; + const inX = mergeInX(edge.to, b, bMid.x, "top", aMid.x); + waypoints = [ + { x: aMid.x, y: a.y + a.h }, + { x: aMid.x, y: corridorY }, + { x: inX, y: corridorY }, + { x: inX, y: b.y }, + ]; + if (edge.label) + label = { text: edge.label, x: aMid.x + 8, y: (a.y + a.h + corridorY) / 2, anchor: "start" }; + } else { + // ③ 下挂分支 → 上方/同行的目标:横到目标列,竖入其底。 + const exitX = bMid.x >= aMid.x ? a.x + a.w : a.x; + const inX = mergeInX(edge.to, b, bMid.x, "bottom", aMid.x); + waypoints = [ + { x: exitX, y: aMid.y }, + { x: inX, y: aMid.y }, + { x: inX, y: b.y + b.h }, + ]; + if (edge.label) label = { text: edge.label, x: (exitX + inX) / 2, y: aMid.y - 7, anchor: "middle" }; + } + } else if (aPos !== undefined && bPos !== undefined && aPos.row === bPos.row && Math.abs(bPos.col - aPos.col) > 1) { + // R1b 同行前跳(网关分支的无链 mergeTo):直连会横穿中间主线框——改走行上走廊: + // 网关**顶端顶点**上到走廊(行上标注深度之上再抬 12),横到目标列,竖入目标顶。 + // (跨行的网关前跳走 R5b 支线走廊;这里补同行缺口——指导书推导的判断分支首次触达。) + // 行 0 的中线−标注深度会算到画布外(该公式此前只服务下方行),夹到 ≥6 保持在框顶与画布顶之间。 + const corridor = Math.max( + 6, + rowMids[aPos.row]! - (rowHasAbove[aPos.row] ? m.corridorWithAbove : m.corridorPlain) - 12, + ); + const goRight = bMid.x >= aMid.x; + waypoints = [ + { x: aMid.x, y: a.y }, + { x: aMid.x, y: corridor }, + { x: bMid.x, y: corridor }, + { x: bMid.x, y: b.y }, + ]; + if (edge.label) + label = { + text: edge.label, + x: aMid.x + (goRight ? 10 : -10), + y: (a.y + corridor) / 2 + 3, + anchor: goRight ? "start" : "end", + }; + } else if (aPos !== undefined && bPos !== undefined && aPos.row === bPos.row) { + // R1 同行横连(方向自适应:偶数行 L→R 从右侧出,奇数行 R→L 从左侧出,箭头随之反向)。 + const aRightOfB = aMid.x > bMid.x; + const fromX = aRightOfB ? a.x : a.x + a.w; + const toX = aRightOfB ? b.x + b.w : b.x; + waypoints = [ + { x: fromX, y: aMid.y }, + { x: toX, y: bMid.y }, + ]; + if (edge.label) label = { text: edge.label, x: (fromX + toX) / 2, y: aMid.y - 7, anchor: "middle" }; + } else if (aPos !== undefined && bPos !== undefined && bPos.row > aPos.row && gatewaySkipsDown(edge.from)) { + // R5b 网关前跳/汇回:从网关**下端顶点**落到一条略高于主折返走廊的支线走廊,横到目标列, + // 再竖入目标顶。让「分流」这类分支边真正从网关底部发出,不再挤进右侧折返车道盖住主折返边。 + const mainCorridor = rowMids[bPos.row]! - (rowHasAbove[bPos.row] ? m.corridorWithAbove : m.corridorPlain); + const skipCorridor = mainCorridor - 12; + const goRight = bMid.x >= aMid.x; + waypoints = [ + { x: aMid.x, y: a.y + a.h }, + { x: aMid.x, y: skipCorridor }, + { x: bMid.x, y: skipCorridor }, + { x: bMid.x, y: b.y }, + ]; + if (edge.label) + label = { + text: edge.label, + x: aMid.x + (goRight ? 10 : -10), + y: (a.y + a.h + skipCorridor) / 2 + 4, + anchor: goRight ? "start" : "end", + }; + } else if (aPos !== undefined && bPos !== undefined && bPos.row === aPos.row + 1) { + // R5 蛇形行间:相邻行由蛇形折行首尾同侧对齐——源底部竖落到行间走廊,横一小段(通常很短), + // 再竖入目标顶。取代原先绕到最右折返车道(x=966)的大回环。 + const corridor = rowMids[bPos.row]! - (rowHasAbove[bPos.row] ? m.corridorWithAbove : m.corridorPlain); + const goRight = bMid.x >= aMid.x; + waypoints = [ + { x: aMid.x, y: a.y + a.h }, + { x: aMid.x, y: corridor }, + { x: bMid.x, y: corridor }, + { x: bMid.x, y: b.y }, + ]; + if (edge.label) + label = { + text: edge.label, + x: aMid.x + (goRight ? 10 : -10), + y: (a.y + a.h + corridor) / 2, + anchor: goRight ? "start" : "end", + }; + } else if (aPos !== undefined && bPos !== undefined && bPos.row > aPos.row) { + // R5c 跨多行兜底:仍走右侧车道折返(避免竖穿中间行)。蛇形下相邻行已由 R5 处理,此处极少命中。 + const corridor = rowMids[bPos.row]! - (rowHasAbove[bPos.row] ? m.corridorWithAbove : m.corridorPlain); + waypoints = [ + { x: a.x + a.w, y: aMid.y }, + { x: m.lane, y: aMid.y }, + { x: m.lane, y: corridor }, + { x: bMid.x, y: corridor }, + { x: bMid.x, y: b.y }, + ]; + if (edge.label) label = { text: edge.label, x: (a.x + a.w + m.lane) / 2, y: aMid.y - 7, anchor: "middle" }; + } else { + // R7 兜底:横到目标中线再竖入(方向自适应:目标在左则从左侧出,箭头反向)。 + const intoTop = bMid.y > aMid.y; + const exitRight = bMid.x >= aMid.x; + const exitX = exitRight ? a.x + a.w : a.x; + waypoints = [ + { x: exitX, y: aMid.y }, + { x: bMid.x, y: aMid.y }, + { x: bMid.x, y: intoTop ? b.y : b.y + b.h }, + ]; + if (waypoints[1]!.x === waypoints[0]!.x) waypoints.splice(1, 1); + if (edge.label) + label = { + text: edge.label, + x: exitX + (exitRight ? 12 : -12), + y: aMid.y - 7, + anchor: exitRight ? "start" : "end", + }; + } + + // 去掉相邻重复点(蛇形竖落里源与目标同列时会产生零长段),保持折线干净。 + waypoints = waypoints.filter( + (p, i) => i === 0 || p.x !== waypoints[i - 1]!.x || p.y !== waypoints[i - 1]!.y, + ); + + connections.push({ id: `e${index}`, edge, tone, waypoints, ...(label ? { label } : {}) }); + }); + + /* ---- 端口分配:同一活动的多条线不要挤在一个点上 ---- */ + // 活动是矩形:每条线落在某条边上。同一条边落了多条线时,沿边把落点铺开(边中点起铺), + // 让四条线尽量各占一条边的中点;直线主干(如 A4→A5 折返)保持不动,让带拐弯的支线/合流线让位。 + type Port = { conn: ConnectionLayout; ptIdx: number; adjIdx: number; far: number; movable: boolean }; + const portsByNode = new Map>(); + for (const conn of connections) { + for (const end of ["from", "to"] as const) { + const list = portsByNode.get(conn.edge[end]) ?? []; + list.push({ conn, end }); + portsByNode.set(conn.edge[end], list); + } + } + for (const [id, ports] of portsByNode) { + const shape = shapes.get(id); + if (!shape || shape.node.kind !== "activity") continue; + const { x, y, w, h } = shape; + const sideOf = (p: { x: number; y: number }): "top" | "bottom" | "left" | "right" | null => { + if (Math.abs(p.y - y) < 2) return "top"; + if (Math.abs(p.y - (y + h)) < 2) return "bottom"; + if (Math.abs(p.x - x) < 2) return "left"; + if (Math.abs(p.x - (x + w)) < 2) return "right"; + return null; + }; + const bySide = new Map(); + for (const { conn, end } of ports) { + const wp = conn.waypoints; + const ptIdx = end === "from" ? 0 : wp.length - 1; + const adjIdx = end === "from" ? 1 : wp.length - 2; + if (adjIdx < 0 || adjIdx >= wp.length) continue; + const side = sideOf(wp[ptIdx]!); + if (!side) continue; + const horiz = side === "top" || side === "bottom"; + const along = (p: { x: number; y: number }) => (horiz ? p.x : p.y); + // 可移动 = 相邻段垂直入边(与落点同坐标),且整条线**不是笔直**——有拐弯能吸收平移。 + // 笔直主干(如 A4→A5 折返,全程同一 x)一律不动,免得被拉出斜段。 + const movable = + Math.abs(along(wp[adjIdx]!) - along(wp[ptIdx]!)) < 0.5 && + wp.some((p) => Math.abs(along(p) - along(wp[ptIdx]!)) > 0.5); + const other = wp[end === "from" ? wp.length - 1 : 0]; + // 挂靠分支的**汇入端**不参与铺开:多条支线合流本该共用一根竖干(T 形汇入是允许的 + // 设计语言)。铺开会把合流拆成并排的竖升,深层分支的竖升就会十字穿过浅层分支的横走 + //(阶梯堆叠后各分支横走高度不同)。 + const mergeIn = end === "to" && anchorByNode.has(conn.edge.from); + const list = bySide.get(side) ?? []; + list.push({ conn, ptIdx, adjIdx, far: along(other!), movable: movable && !mergeIn }); + bySide.set(side, list); + } + for (const [side, list] of bySide) { + if (list.length < 2) continue; + const horiz = side === "top" || side === "bottom"; + list.sort((a, b) => a.far - b.far); + const n = list.length; + list.forEach((port, i) => { + if (!port.movable) return; + const slot = horiz ? x + (w * (i + 1)) / (n + 1) : y + (h * (i + 1)) / (n + 1); + const wp = port.conn.waypoints; + if (horiz) { + wp[port.ptIdx]!.x = slot; + wp[port.adjIdx]!.x = slot; + } else { + wp[port.ptIdx]!.y = slot; + wp[port.adjIdx]!.y = slot; + } + }); + } + } + + /* ---- 画布尺寸 ---- */ + let maxBottom = 0; + let maxRight = 0; + for (const { node, x, y, w, h } of shapes.values()) { + const bottom = + node.kind === "end" || node.kind === "end-bad" || node.kind === "start" + ? y + h + 23 // 圆下标注基线 cy+r+19,再留 4 + : y + h; + maxBottom = Math.max(maxBottom, bottom); + maxRight = Math.max(maxRight, x + w); + } + for (const caption of captions) maxBottom = Math.max(maxBottom, caption.y + 4); + + return { + shapes: [...shapes.values()], + captions, + connections, + // 宽度基准 1000(参考几何);阶梯挂靠向右溢出时增宽,绝不让内容画出画布。 + width: Math.max(1000, Math.ceil(maxRight + 20)), + height: Math.ceil(maxBottom + 18), + ghost, + }; +} diff --git a/apps/web/src/shared/gov/flow/flow-node-paint.ts b/apps/web/src/shared/gov/flow/flow-node-paint.ts new file mode 100644 index 0000000..67684e7 --- /dev/null +++ b/apps/web/src/shared/gov/flow/flow-node-paint.ts @@ -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( + tag: K, + attrs: Record, + 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 = { 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 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 }, +}; diff --git a/apps/web/src/shared/gov/flow/pkg-to-graph.ts b/apps/web/src/shared/gov/flow/pkg-to-graph.ts new file mode 100644 index 0000000..5a75e90 --- /dev/null +++ b/apps/web/src/shared/gov/flow/pkg-to-graph.ts @@ -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(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(); + + 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; +} diff --git a/apps/web/src/shared/gov/flow/support.tsx b/apps/web/src/shared/gov/flow/support.tsx new file mode 100644 index 0000000..873c045 --- /dev/null +++ b/apps/web/src/shared/gov/flow/support.tsx @@ -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(); + 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 ( + + ); +} diff --git a/apps/web/src/shared/gov/gov-api.ts b/apps/web/src/shared/gov/gov-api.ts new file mode 100644 index 0000000..ad9f7a5 --- /dev/null +++ b/apps/web/src/shared/gov/gov-api.ts @@ -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 => + useQuery({ queryKey: ['gov', 'tasks'], queryFn: () => call(listGovTasksRoute) }); + +export const useGovApprovals = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'approvals'], queryFn: () => call(listApprovalsRoute) }); + +export const useGovStaff = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'staff'], queryFn: () => call(listStaffRoute) }); + +export const useGovGrants = (positionId: string | undefined): UseQueryResult => + useQuery({ + queryKey: ['gov', 'grants', positionId], + queryFn: () => call(listGrantsRoute, { params: { positionId: positionId! } }), + enabled: positionId !== undefined, + }); + +export const useGovPackages = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'process'], queryFn: () => call(listProcessPackagesRoute) }); + +export const useGovTriggers = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'triggers'], queryFn: () => call(listTriggersRoute) }); + +export const useGovActivities = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'activities'], queryFn: () => call(listActivitiesRoute) }); + +export const useGovTools = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'tools'], queryFn: () => call(listToolsRoute) }); + +export const useGovObjects = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'objects'], queryFn: () => call(listLedgerObjectsRoute) }); + +export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult => + useQuery({ + queryKey: ['gov', 'ledger', domain, object], + queryFn: () => call(browseLedgerRoute, { params: { domain: domain!, object: object! } }), + enabled: domain !== undefined && object !== undefined, + }); + +export const useGovMemory = (activityId: string | undefined): UseQueryResult => + useQuery({ + queryKey: ['gov', 'memory', activityId], + queryFn: () => call(listActivityMemoryRoute, { params: { activityId: activityId! } }), + enabled: activityId !== undefined, + }); + +export const useGovMetrics = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'metrics'], queryFn: () => call(govMetricsRoute) }); + +export const useGovChannels = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'channels'], queryFn: () => call(listChannelsRoute) }); + +export const useGovLlm = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'llm'], queryFn: () => call(listLlmInstancesRoute) }); + +export const useGovWorkflow = (id: string): UseQueryResult => + useQuery({ queryKey: ['gov', 'workflow', id], queryFn: () => call(govWorkflowRoute, { params: { id } }) }); + +export const useGovTaskLog = (taskId: string): UseQueryResult => + useQuery({ queryKey: ['gov', 'tasklog', taskId], queryFn: () => call(govTaskLogRoute, { params: { taskId } }) }); diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts new file mode 100644 index 0000000..5122194 --- /dev/null +++ b/apps/web/src/shared/gov/gov-route.ts @@ -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> = { + 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>> = { + 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; +}; diff --git a/apps/web/src/shared/gov/primitives.tsx b/apps/web/src/shared/gov/primitives.tsx new file mode 100644 index 0000000..873e395 --- /dev/null +++ b/apps/web/src/shared/gov/primitives.tsx @@ -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 ( + + {ico && } + {children} + + ); +} + +export function St({ tone, ico, children }: { tone: string; ico: string; children: ReactNode }) { + return ( + + + {children} + + ); +} + +export function Ava({ name }: { name: string }) { + return {name.slice(0, 1)}; +} + +export function Note({ tone, children }: { tone?: 'warn' | 'ok'; children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +export function Band({ ico, children, extra }: { ico: string; children: ReactNode; extra?: string }) { + return ( +
+ + {children} + {extra && {extra}} +
+ ); +} + +export function Sec({ ico, title, extra, children }: { ico: string; title: string; extra?: string; children: ReactNode }) { + return ( +
+ + {title} + + {children} +
+ ); +} + +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 ( +
+ + + {label} + + {dial !== undefined ? ( +
+ + + {value} + {unit && {unit}} + +
+ ) : ( + + {value} + {unit && {unit}} + + )} + {sub && {sub}} +
+ ); +} + +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 ( + + + + + + + ); +} + +export function Card({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) { + return ( +
+
+ + {title} + {extra && {extra}} +
+ {children} +
+ ); +} + +export function Rows({ items }: { items: ReadonlyArray<{ k: string; v: ReactNode; mono?: boolean }> }) { + return ( +
+ {items.map((r, i) => ( +
+ {r.k} + {r.v} +
+ ))} +
+ ); +} + +export function Dist({ rows }: { rows: ReadonlyArray<{ k: string; v: number; tone: string; label?: string }> }) { + return ( +
+ {rows.map((r) => ( +
+ {r.k} + + + + {r.label ?? `${r.v}%`} +
+ ))} +
+ ); +} + +export function Bars({ data, tone = 'brand' }: { data: readonly number[]; tone?: string }) { + const max = Math.max(1, ...data); + return ( + + {data.map((d, i) => ( + + ))} + + ); +} + +export function Load({ n, max, tone = 'brand' }: { n: number; max: number; tone?: string }) { + return ( + + + + + {n} + + ); +} + +/** 小事实块(预算 / 要素统计等定量并列项) */ +export function Facts({ items }: { items: ReadonlyArray<{ k: string; v: string; tone?: string; ratio?: number }> }) { + return ( +
+ {items.map((f, i) => ( +
+ {f.k} + {f.v} + {f.ratio !== undefined && ( + + + + )} +
+ ))} +
+ ); +} + +/* ---- 可下钻的清单行:清单只负责导航,动作留给详情页 ---- */ + +export function NList({ children }: { children: ReactNode }) { + return
{children}
; +} + +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 ( + + ); +} + +/** 下一级入口(详情页里指向子维度页) */ +export function Entry({ + ico, + tone = 'brand', + title, + desc, + meta, + onClick, +}: { + ico: string; + tone?: string; + title: string; + desc: string; + meta?: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +/* ---- 属性卡:就地填写,不下钻 ---- */ + +export function Prop({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) { + return ( +
+
+ + {title} + {extra && {extra}} +
+
{children}
+
+ ); +} + +export function Field({ k, hint, children }: { k: string; hint?: string; children: ReactNode }) { + return ( +
+ + {k} + {hint && {hint}} + + {children} +
+ ); +} + +export function Seg({ + value, + options, + onPick, +}: { + value: string; + options: ReadonlyArray<{ k: string; label: string }>; + onPick: (k: string) => void; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +export function ListEdit({ + items, + placeholder, + onChange, +}: { + items: readonly string[]; + placeholder: string; + onChange: (v: string[]) => void; +}) { + return ( +
+ {items.map((it, i) => ( +
+ onChange(items.map((x, j) => (j === i ? e.target.value : x)))} + /> + +
+ ))} + {items.length === 0 && 未声明} + +
+ ); +} + +/* ---- 就地确认带:影响预览三必答,不弹窗、不遮挡 ---- */ + +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 ( +
+
+ + {spec.title} +
+ {spec.note} +
+
+ 影响谁 + {spec.impact.who} +
+
+ 可否撤销 + {spec.impact.undo} +
+
+ 何时生效 + {spec.impact.when} +
+
+
+ + +
+
+ ); +} + +export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; align?: 'start' | 'end' }) { + const [armed, setArmed] = useState(null); + const a = armed !== null ? items[armed] : null; + return ( +
+
+ {items.map((it, i) => ( + + ))} +
+ {a?.ask && setArmed(null)} />} +
+ ); +} + +/* ---- 详情页头 ---- */ + +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(null); + const a = acts && armed !== null ? acts[armed] : null; + return ( +
+
+ + + +
+ {kicker} +

{title}

+ {chips &&
{chips}
} + {desc &&

{desc}

} +
+ {acts && ( +
+ {acts.map((it, i) => ( + + ))} +
+ )} +
+ {a?.ask && setArmed(null)} />} +
+ ); +} + +/** 需处置卡:一条偏差 + 就地处置 */ +export function Todo({ + tone = 'danger', + ico, + title, + desc, + acts, +}: { + tone?: string; + ico: string; + title: string; + desc: string; + acts: readonly ActSpec[]; +}) { + return ( +
+
+ + + + + {title} + {desc} + +
+ +
+ ); +} + +export function Clear({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +/** 区首页/详情页共用的板栅 */ +export function Board({ children, style }: { children: ReactNode; style?: CSSProperties }) { + return ( +
+ {children} +
+ ); +} + +export function Kpis({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function Todos({ children }: { children: ReactNode }) { + return
{children}
; +} + +export type Armed = [number | null, Dispatch>]; + +/** + * 取数态:载入 / 无权 / 出错 —— 三种都得**说出来**。 + * + * 治理台尤其不能把「无权查看」画成空清单:那会让人误以为系统里什么都没有。 + * 权限不足按岗位说明缺哪一项,而不是笼统「失败」。 + */ +export function QueryGate({ + query, + children, +}: { + query: { isPending: boolean; isError: boolean; error: unknown }; + children: ReactNode; +}) { + if (query.isPending) { + return ( +
+ + 载入中… +
+ ); + } + if (query.isError) { + const error = query.error as { status?: number; message?: string } | undefined; + const forbidden = error?.status === 403; + return ( + + {forbidden ? ( + <> + 无权查看 —— {error?.message ?? ''}。权限按岗位授予:客体三类 × 动作三种; + 换岗即换权,需要请找有该治理功能授权的岗位开通。 + + ) : ( + <> + 取数失败 —— {error?.message ?? '未知错误'} + + )} + + ); + } + return <>{children}; +} diff --git a/apps/web/src/shared/gov/tone.ts b/apps/web/src/shared/gov/tone.ts new file mode 100644 index 0000000..83ae8b1 --- /dev/null +++ b/apps/web/src/shared/gov/tone.ts @@ -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> = { + 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> = { + 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> = { + 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> = { + loop: { tone: 'collab', ico: 'repeat', label: '案例 · loop', full: '临场演绎,调度权归 AI' }, + flow: { tone: 'auto', ico: 'git-commit', label: '标准 · flow', full: '照谱执行,调度权归系统' }, +}; + +export const APR_ST: Readonly> = { + 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> = { + 机器产出: { tone: 'auto', ico: 'bot' }, + 人产出: { tone: 'brand', ico: 'user-round' }, + 人裁决: { tone: 'decision', ico: 'shield-check' }, + 世界等待: { tone: 'wait', ico: 'hourglass' }, +}; diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 56fa8ff..850815d 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -5,7 +5,10 @@ import { z } from 'zod'; import type { RouteContract } from './http.ts'; import { disableCheckSchema, grantSchema, positionSchema, staffSchema } from '../org.ts'; -import { positionIdSchema, staffIdSchema } from '../id.ts'; +import { positionIdSchema, staffIdSchema, taskIdSchema } from '../id.ts'; +import { closureReportSchema, processPackageSchema, triggerConfigSchema } from '../process-package.ts'; +import { controlSemanticSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts'; +import { paradigmSchema } from '../manifest.ts'; /* ── 员工管理 ─────────────────────────────────────────────────────────── */ @@ -109,3 +112,302 @@ export const probeLlmInstanceRoute = { params: z.object({ id: z.string() }), output: z.object({ ok: z.boolean(), latencyMs: z.number().int().optional(), error: z.string().optional() }), } as const satisfies RouteContract; + +/* ── 任务管理:在途监控 · 控制事件 · 改派 ─────────────────────────────── */ + +export const govTaskRowSchema = z.object({ + id: taskIdSchema, + title: z.string(), + activityId: z.string(), + paradigm: paradigmSchema, + ownerName: z.string(), + runtime: taskRuntimeStateSchema, + terminal: terminalStateSchema.optional(), + workflowId: z.string().optional(), + usedSteps: z.number().int(), + capSteps: z.number().int(), + startedAt: z.string(), +}); +export type GovTaskRow = z.infer; + +export const listGovTasksRoute = { + method: 'GET', + path: '/api/gov/tasks', + output: z.object({ + tasks: z.array(govTaskRowSchema), + workflows: z.array( + z.object({ + id: z.string(), + subject: z.string(), + state: workflowStateSchema, + packageId: z.string(), + startedAt: z.string(), + }), + ), + }), +} as const satisfies RouteContract; + +/** 控制三语义:暂停 / 叫停 / 终止 —— 治理台的干预面(OS 06) */ +export const controlTaskRoute = { + method: 'POST', + path: '/api/gov/tasks/:taskId/control', + params: z.object({ taskId: taskIdSchema }), + body: z.object({ semantic: controlSemanticSchema, reason: z.string().optional() }), + output: z.object({ ok: z.boolean(), resumable: z.boolean() }), +} as const satisfies RouteContract; + +export const resumeTaskRoute = { + method: 'POST', + path: '/api/gov/tasks/:taskId/resume', + params: z.object({ taskId: taskIdSchema }), + output: z.object({ ok: z.boolean(), runtime: taskRuntimeStateSchema }), +} as const satisfies RouteContract; + +/** 改派 —— 在途任务的负责人变更,留痕并通知双方 */ +export const reassignTaskRoute = { + method: 'POST', + path: '/api/gov/tasks/:taskId/reassign', + params: z.object({ taskId: taskIdSchema }), + body: z.object({ to: staffIdSchema }), + output: z.object({ ok: z.boolean(), ownerId: staffIdSchema }), +} as const satisfies RouteContract; + +/** 任务日志 —— 治理台可查看任意任务的任务日志(OS 02) */ +export const govTaskLogRoute = { + method: 'GET', + path: '/api/gov/tasks/:taskId/log', + params: z.object({ taskId: taskIdSchema }), + output: z.object({ + log: z.array(z.object({ id: z.string(), at: z.string(), sideLabel: z.string(), title: z.string() })), + }), +} as const satisfies RouteContract; + +/* ── 工作流管理:流程包编排与版本 ─────────────────────────────────────── */ + +export const listProcessPackagesRoute = { + method: 'GET', + path: '/api/gov/process', + output: z.object({ packages: z.array(processPackageSchema) }), +} as const satisfies RouteContract; + +/** 画图即校验 —— 与前端画布同源的闭合校验 */ +export const validateProcessRoute = { + method: 'POST', + path: '/api/gov/process/validate', + body: z.object({ pkg: processPackageSchema }), + output: closureReportSchema, +} as const satisfies RouteContract; + +export const publishProcessRoute = { + method: 'POST', + path: '/api/gov/process/:id/:version/publish', + params: z.object({ id: z.string(), version: z.string() }), + output: closureReportSchema, +} as const satisfies RouteContract; + +/* ── 触发器配置 ──────────────────────────────────────────────────────── */ + +export const listTriggersRoute = { + method: 'GET', + path: '/api/gov/triggers', + output: z.object({ triggers: z.array(triggerConfigSchema) }), +} as const satisfies RouteContract; + +export const toggleTriggerRoute = { + method: 'POST', + path: '/api/gov/triggers/:id/toggle', + params: z.object({ id: z.string() }), + body: z.object({ enabled: z.boolean() }), + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; + +/* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */ + +export const listActivitiesRoute = { + method: 'GET', + path: '/api/gov/activities', + output: z.object({ + activities: z.array( + z.object({ + activityId: z.string(), + name: z.string(), + category: z.string(), + paradigm: paradigmSchema, + enabled: z.boolean(), + packageVersions: z.array(z.object({ version: z.string(), state: z.string(), installedAt: z.string() })), + assetVersions: z.array(z.object({ kind: z.string(), version: z.number().int(), effective: z.boolean() })), + }), + ), + }), +} as const satisfies RouteContract; + +/* ── 工具管理 ────────────────────────────────────────────────────────── */ + +export const listToolsRoute = { + method: 'GET', + path: '/api/gov/tools', + output: z.object({ + tools: z.array( + z.object({ + name: z.string(), + version: z.string(), + description: z.string(), + effect: z.enum(['read', 'write']), + pure: z.boolean(), + connectionKeys: z.array(z.string()), + }), + ), + }), +} as const satisfies RouteContract; + +/* ── 数据管理:域 / 对象目录 + 记录浏览 ───────────────────────────────── */ + +export const listLedgerObjectsRoute = { + method: 'GET', + path: '/api/gov/data/objects', + output: z.object({ objects: z.array(z.object({ domain: z.string(), object: z.string() })) }), +} as const satisfies RouteContract; + +export const browseLedgerRoute = { + method: 'GET', + path: '/api/gov/data/:domain/:object', + params: z.object({ domain: z.string(), object: z.string() }), + output: z.object({ + columns: z.array(z.object({ name: z.string(), type: z.string() })), + primaryKey: z.array(z.string()), + rows: z.array(z.record(z.string(), z.unknown())), + total: z.number().int(), + }), +} as const satisfies RouteContract; + +/* ── 记忆管理:活动记忆四层 ──────────────────────────────────────────── */ + +export const listActivityMemoryRoute = { + method: 'GET', + path: '/api/gov/memory/:activityId', + params: z.object({ activityId: z.string() }), + output: z.object({ + entries: z.array( + z.object({ + id: z.string(), + layer: z.string(), + statement: z.string(), + evidenceCount: z.number().int(), + incrementState: z.string().optional(), + createdAt: z.string(), + }), + ), + }), +} as const satisfies RouteContract; + +/* ── 系统管理 · 通道实例 ──────────────────────────────────────────────── */ + +/** 通道配置里本就没有凭证字段(凭证只在治理台的连接配置中,ADR-009);此处整体可回 */ +export const listChannelsRoute = { + method: 'GET', + path: '/api/gov/channels', + output: z.object({ + channels: z.array( + z.object({ + id: z.string(), + name: z.string(), + kind: z.enum(['mail', 'im']), + address: z.string(), + enabled: z.boolean(), + extractCount: z.number().int(), + mappingCount: z.number().int(), + fallbackPosition: z.string().optional(), + addressWindowMs: z.number().int(), + }), + ), + }), +} as const satisfies RouteContract; + +/* ── 任务管理 · 审批单 ────────────────────────────────────────────────── */ + +/** 审批单不是任务:没有预算、没有任务日志,只有批不批(卡在审批人工位的验收工单) */ +export const listApprovalsRoute = { + method: 'GET', + path: '/api/gov/approvals', + output: z.object({ + approvals: z.array( + z.object({ + id: z.string(), + workflowId: z.string(), + workflowSubject: z.string(), + title: z.string(), + approverName: z.string(), + approverPosition: z.string(), + state: z.enum(['pending', 'passed', 'rejected']), + issuedAt: z.string(), + }), + ), + }), +} as const satisfies RouteContract; + +/* ── 工作流下钻:实例 × 节点 ──────────────────────────────────────────── */ + +export const govWorkflowRoute = { + method: 'GET', + path: '/api/gov/workflows/:id', + params: z.object({ id: z.string() }), + output: z.object({ + workflow: z.object({ + id: z.string(), + subject: z.string(), + state: workflowStateSchema, + packageId: z.string(), + packageVersion: z.number().int(), + source: z.object({ kind: z.string(), ref: z.string(), by: z.string().optional() }), + startedAt: z.string(), + finishedAt: z.string().optional(), + }), + pkg: processPackageSchema, + instances: z.array( + z.object({ + nodeId: z.string(), + instance: z.number().int(), + running: z.boolean(), + waiting: z.boolean(), + activated: z.boolean(), + terminal: terminalStateSchema.optional(), + }), + ), + tasks: z.array(z.object({ id: taskIdSchema, nodeId: z.string(), title: z.string(), ownerName: z.string() })), + }), +} as const satisfies RouteContract; + +/* ── 记忆管理 · 度量仪表 ──────────────────────────────────────────────── */ + +/** 自主率与纠正率**成对读**,复合为有效自主率 —— 单看自主率会奖励「猛冲后返工」 */ +export const govMetricsRoute = { + method: 'GET', + path: '/api/gov/metrics', + output: z.object({ + autonomy: z.number(), + correction: z.number(), + effectiveAutonomy: z.number(), + interruption: z.number(), + dmnCoverage: z.number(), + taskCount: z.number().int(), + }), +} as const satisfies RouteContract; + +/* ── 出参类型:契约自己命名,消费方不再 `z.infer` ──────────────────────── */ + +export type GovTasksOutput = z.infer; +export type GovApprovalsOutput = z.infer; +export type GovStaffOutput = z.infer; +export type GovGrantsOutput = z.infer; +export type GovPackagesOutput = z.infer; +export type GovTriggersOutput = z.infer; +export type GovActivitiesOutput = z.infer; +export type GovToolsOutput = z.infer; +export type GovObjectsOutput = z.infer; +export type GovLedgerPageOutput = z.infer; +export type GovMemoryOutput = z.infer; +export type GovMetricsOutput = z.infer; +export type GovChannelsOutput = z.infer; +export type GovLlmOutput = z.infer; +export type GovWorkflowOutput = z.infer; +export type GovTaskLogOutput = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0258599..8afd3ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@tanstack/react-router': specifier: ^1.170.18 version: 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + diagram-js: + specifier: ^15.23.0 + version: 15.23.0 react: specifier: ^19.2.0 version: 19.2.8 @@ -292,6 +295,9 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bpmn-io/diagram-js-ui@0.2.4': + resolution: {integrity: sha512-I678ZGtoH7z7FgzFhzzppM9ThsJ3Lh83kW3GjhjG3xyYciIPYHMaHK1MABP8F69H9pFZpViM6t4qUo8dwrVz0w==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1604,6 +1610,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1687,6 +1697,13 @@ packages: engines: {node: ^18.17||>=20} hasBin: true + diagram-js@15.23.0: + resolution: {integrity: sha512-cCgL+4Ep0xrLe6TtdvRVgo6bWYjUbrQg+ES/9rpltqmTD2YaPoC+NYcRsKYw/fDO+1RN0q16NzErP1svVZhNQg==} + + didi@11.0.0: + resolution: {integrity: sha512-PzCfRzQttvFpVcYMbSF7h8EsWjeJpVjWH4qDhB5LkMi1ILvHq4Ob0vhM2wLFziPkbUBi+PAo7ODbe2sacR7nJQ==} + engines: {node: '>= 20.12'} + docker-compose@1.4.2: resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} engines: {node: '>= 6.0.0'} @@ -1699,6 +1716,10 @@ packages: resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} engines: {node: '>= 14.17'} + domify@3.0.0: + resolution: {integrity: sha512-bs2yO68JDFOm6rKv8f0EnrM2cENduhRkpqOtt/s5l5JBA/eqGBZCzLPmdYoHtJ6utgLGgcBajFsEQbl12pT0lQ==} + engines: {node: '>=20'} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -2025,6 +2046,9 @@ packages: resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} engines: {node: '>=16.9.0'} + htm@3.1.1: + resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2044,6 +2068,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits-browser@0.1.0: + resolution: {integrity: sha512-CJHHvW3jQ6q7lzsXPpapLdMx5hDpSF3FSh45pwsj6bKxJJ8Nl8v43i5yXnr3BdfOimGHKyniewQtnAIp3vyJJw==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2201,6 +2228,12 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-dash@5.1.0: + resolution: {integrity: sha512-HAvN6XzmCQj+js43G9sJRRw8kj/7DvoxN7qhoIN9Xo5ZN1NMljtpyKs/NunXmw0QSWwlgZzNv6tLeEa4xaZ0tg==} + + min-dom@5.3.0: + resolution: {integrity: sha512-0w5FEBgPAyHhmFojW3zxd7we3D+m5XYS3E/06OyvxmbHJoiQVa4Nagj6RWvoAKYRw5xth6cP5TMePc5cR1M9hA==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2267,6 +2300,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-refs@0.4.0: + resolution: {integrity: sha512-6kJqKWryKZmtte6QYvouas0/EIJKPI1/MMIuRsiBlNuhIMfqYTggzX2F1AJ2+cDs288xyi9GL7FyasHINR98BQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2293,6 +2329,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-intersection@4.1.0: + resolution: {integrity: sha512-urUP6WvhnxbHPdHYl6L7Yrc6+1ny6uOFKPCzPxTSUSYGHG0o94RmI7SvMMaScNAM5RtTf08bg4skc6/kjfne3A==} + engines: {node: '>= 14.20'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2402,6 +2442,14 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2690,6 +2738,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-svg@4.1.4: + resolution: {integrity: sha512-cBaEACCbouYrQc9RG+eTXnPYosX1Ijqty/I6DdXovwDd89Pwu4jcmpOR7BuFEF9YCcd7/AWwasE0207WMK7hdw==} + engines: {node: '>= 20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3093,6 +3145,13 @@ snapshots: '@balena/dockerignore@1.0.2': {} + '@bpmn-io/diagram-js-ui@0.2.4': + dependencies: + htm: 3.1.1 + preact: 10.29.7 + transitivePeerDependencies: + - preact-render-to-string + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -4118,6 +4177,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4206,6 +4267,22 @@ snapshots: tsconfig-paths-webpack-plugin: 4.2.0 watskeburt: 4.2.3 + diagram-js@15.23.0: + dependencies: + '@bpmn-io/diagram-js-ui': 0.2.4 + clsx: 2.1.1 + didi: 11.0.0 + inherits-browser: 0.1.0 + min-dash: 5.1.0 + min-dom: 5.3.0 + object-refs: 0.4.0 + path-intersection: 4.1.0 + tiny-svg: 4.1.4 + transitivePeerDependencies: + - preact-render-to-string + + didi@11.0.0: {} + docker-compose@1.4.2: dependencies: yaml: 2.9.0 @@ -4230,6 +4307,8 @@ snapshots: transitivePeerDependencies: - supports-color + domify@3.0.0: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -4554,6 +4633,8 @@ snapshots: hono@4.12.32: {} + htm@3.1.1: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -4567,6 +4648,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits-browser@0.1.0: {} + inherits@2.0.4: {} ini@4.1.1: {} @@ -4683,6 +4766,13 @@ snapshots: mimic-function@5.0.1: {} + min-dash@5.1.0: {} + + min-dom@5.3.0: + dependencies: + domify: 3.0.0 + min-dash: 5.1.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.8 @@ -4737,6 +4827,8 @@ snapshots: object-assign@4.1.1: {} + object-refs@0.4.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -4766,6 +4858,8 @@ snapshots: path-exists@4.0.0: {} + path-intersection@4.1.0: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -4858,6 +4952,8 @@ snapshots: dependencies: xtend: 4.0.2 + preact@10.29.7: {} + prelude-ls@1.2.1: {} process-nextick-args@2.0.1: {} @@ -5220,6 +5316,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-svg@4.1.4: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {}