From ac85ecf2ea878a82e500502ada0313d5d8e827ea Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 20:43:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E7=BA=BF:=E9=89=B4=E6=9D=83=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D+=E5=B7=A5=E4=BD=9C=E7=BB=84+=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E4=B8=89=E5=8C=BA=E4=BF=AE=E5=A4=8D+=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E5=B7=A5=E4=BD=9C=E5=8C=BA+=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .data/attachments/inbound/sim-001@x.com.eml | 12 + .data/attachments/inbound/sim-002@x.com.eml | 12 + activities/quote-intake/src/manifest.ts | 4 +- .../api/src/modules/human-tool/contract.d.ts | 2 + .../api/src/modules/interaction/port.d.ts | 6 + .../apps/api/src/modules/org/contract.d.ts | 28 +- .../apps/api/src/modules/org/index.d.ts | 9 +- api-report/apps/api/src/modules/org/port.d.ts | 33 +- .../apps/api/src/modules/process/port.d.ts | 9 + .../contracts/src/api/governance.d.ts | 485 +++ .../packages/contracts/src/api/http.d.ts | 2 +- .../packages/contracts/src/api/workbench.d.ts | 325 +- api-report/packages/contracts/src/card.d.ts | 86 +- api-report/packages/contracts/src/org.d.ts | 17 + .../contracts/src/process-package.d.ts | 45 + apps/api/package.json | 4 +- apps/api/src/app/config.ts | 8 + apps/api/src/app/container.ts | 13 + apps/api/src/app/http-env.ts | 9 +- apps/api/src/app/reset-runs.ts | 68 + apps/api/src/app/routes/desk.ts | 150 +- apps/api/src/app/routes/governance-ops.ts | 28 + apps/api/src/app/routes/registration.ts | 62 + apps/api/src/app/routes/workbench.ts | 42 +- apps/api/src/app/seed-quote.ts | 196 +- apps/api/src/app/server.ts | 38 +- apps/api/src/app/session-cookie.ts | 39 + apps/api/src/app/set-password.ts | 31 + apps/api/src/app/wiring/engines.ts | 13 +- .../internal/usecase/channel-service.ts | 11 +- .../modules/event/__tests__/delivery.test.ts | 27 +- .../modules/event/internal/domain/delivery.ts | 12 +- .../event/internal/usecase/event-service.ts | 7 +- apps/api/src/modules/human-tool/CONTRACT.md | 1 + apps/api/src/modules/human-tool/contract.ts | 2 + .../internal/usecase/human-tool-service.ts | 4 + apps/api/src/modules/interaction/CONTRACT.md | 5 +- .../__tests__/chat-projection.test.ts | 62 + .../internal/domain/chat-projection.ts | 71 + .../internal/usecase/interaction-service.ts | 11 +- apps/api/src/modules/interaction/port.ts | 6 + apps/api/src/modules/org/CONTRACT.md | 36 +- .../modules/org/__tests__/password.test.ts | 56 + apps/api/src/modules/org/contract.ts | 36 +- apps/api/src/modules/org/index.ts | 11 +- .../org/internal/adapter/org-repository.ts | 70 +- .../org/internal/adapter/org-schema.ts | 38 + .../modules/org/internal/adapter/password.ts | 61 + .../modules/org/internal/domain/seed-data.ts | 30 + .../org/internal/usecase/org-service.ts | 117 +- apps/api/src/modules/org/port.ts | 30 +- apps/api/src/modules/process/CONTRACT.md | 1 + .../process/__tests__/start-guard.test.ts | 2 +- .../internal/adapter/process-schema.ts | 2 + .../process/internal/adapter/process-store.ts | 2 + .../internal/domain/owner-resolution.ts | 29 +- .../internal/usecase/process-service.ts | 32 +- apps/api/src/modules/process/port.ts | 9 + .../trigger/__tests__/input-assembly.test.ts | 68 + .../trigger/internal/domain/matching.ts | 10 +- .../internal/usecase/trigger-service.ts | 8 +- apps/api/tsup.config.ts | 10 +- apps/web/index.html | 2 +- apps/web/src/app/app-shell.tsx | 57 +- apps/web/src/app/gov-shell.tsx | 18 + apps/web/src/app/main.tsx | 97 +- apps/web/src/app/styles/app.css | 163 +- apps/web/src/app/styles/login.css | 151 + apps/web/src/app/styles/theme.css | 15 - apps/web/src/app/tab-identity.ts | 47 + apps/web/src/features/auth/index.ts | 2 + .../src/features/auth/internal/LoginPage.tsx | 85 + .../desk-approval/internal/ApprovalDesk.tsx | 23 +- .../desk-message/internal/MessageDesk.tsx | 19 +- .../desk-minute/internal/MinuteDesk.tsx | 28 +- .../desk-report/internal/ReportDesk.tsx | 23 +- apps/web/src/features/desk-task/index.ts | 11 +- .../desk-task/internal/FileViewer.tsx | 106 + .../features/desk-task/internal/TaskChat.tsx | 186 +- .../features/desk-task/internal/TaskList.tsx | 21 +- .../desk-task/internal/ViewRegion.tsx | 556 ++- .../desk-task/internal/chat-bubbles.tsx | 643 +++ .../desk-task/internal/work-order-report.tsx | 449 +++ .../features/gov-process/internal/panel.tsx | 995 ++++- apps/web/src/shared/ChatAvatar.tsx | 46 + apps/web/src/shared/Ico.tsx | 117 +- apps/web/src/shared/desk/module-kit.tsx | 63 +- apps/web/src/shared/gov/flow/FlowDiagram.tsx | 4 +- apps/web/src/shared/gov/flow/FlowEditor.tsx | 465 +++ apps/web/src/shared/gov/flow/FlowFigure.tsx | 115 +- .../src/shared/gov/flow/flow-editor-core.ts | 909 +++++ apps/web/src/shared/gov/flow/flow-layout.ts | 18 +- apps/web/src/shared/gov/flow/graph-to-pkg.ts | 136 + apps/web/src/shared/gov/flow/manual-flow.ts | 266 ++ apps/web/src/shared/gov/flow/pkg-to-graph.ts | 75 +- apps/web/src/shared/gov/gov-route.ts | 6 +- apps/web/src/shared/gov/primitives.tsx | 2 +- apps/web/src/shared/useChatWidth.ts | 12 +- db/migrations/0010_clammy_fabian_cortez.sql | 12 + db/migrations/0011_little_pet_avengers.sql | 11 + db/migrations/meta/0010_snapshot.json | 3364 ++++++++++++++++ db/migrations/meta/0011_snapshot.json | 3446 +++++++++++++++++ db/migrations/meta/_journal.json | 14 + docs/MVP示例工作流.html | 402 +- docs/adr/ADR-010-auth-session.md | 80 + docs/adr/ADR-011-work-group.md | 66 + docs/debt.md | 38 + .../plans/2026-07-27-three-surfaces.md | 819 ++++ docs/产品设计.md | 187 + packages/contracts/src/api/governance.ts | 13 + packages/contracts/src/api/http.ts | 2 +- packages/contracts/src/api/workbench.ts | 101 +- packages/contracts/src/card.ts | 27 +- packages/contracts/src/org.ts | 22 + packages/contracts/src/process-package.ts | 33 +- packages/ui-kit/src/Ico.tsx | 117 + packages/ui-kit/src/ViewBlocks.tsx | 496 ++- packages/ui-kit/src/ViewContainer.tsx | 45 +- packages/ui-kit/src/index.ts | 1 + 119 files changed, 16634 insertions(+), 1246 deletions(-) create mode 100644 .data/attachments/inbound/sim-001@x.com.eml create mode 100644 .data/attachments/inbound/sim-002@x.com.eml create mode 100644 apps/api/src/app/reset-runs.ts create mode 100644 apps/api/src/app/routes/registration.ts create mode 100644 apps/api/src/app/session-cookie.ts create mode 100644 apps/api/src/app/set-password.ts create mode 100644 apps/api/src/modules/interaction/__tests__/chat-projection.test.ts create mode 100644 apps/api/src/modules/interaction/internal/domain/chat-projection.ts create mode 100644 apps/api/src/modules/org/__tests__/password.test.ts create mode 100644 apps/api/src/modules/org/internal/adapter/password.ts create mode 100644 apps/api/src/modules/trigger/__tests__/input-assembly.test.ts create mode 100644 apps/web/src/app/styles/login.css create mode 100644 apps/web/src/app/tab-identity.ts create mode 100644 apps/web/src/features/auth/index.ts create mode 100644 apps/web/src/features/auth/internal/LoginPage.tsx create mode 100644 apps/web/src/features/desk-task/internal/FileViewer.tsx create mode 100644 apps/web/src/features/desk-task/internal/chat-bubbles.tsx create mode 100644 apps/web/src/features/desk-task/internal/work-order-report.tsx create mode 100644 apps/web/src/shared/ChatAvatar.tsx create mode 100644 apps/web/src/shared/gov/flow/FlowEditor.tsx create mode 100644 apps/web/src/shared/gov/flow/flow-editor-core.ts create mode 100644 apps/web/src/shared/gov/flow/graph-to-pkg.ts create mode 100644 apps/web/src/shared/gov/flow/manual-flow.ts create mode 100644 db/migrations/0010_clammy_fabian_cortez.sql create mode 100644 db/migrations/0011_little_pet_avengers.sql create mode 100644 db/migrations/meta/0010_snapshot.json create mode 100644 db/migrations/meta/0011_snapshot.json create mode 100644 docs/adr/ADR-010-auth-session.md create mode 100644 docs/adr/ADR-011-work-group.md create mode 100644 docs/superpowers/plans/2026-07-27-three-surfaces.md create mode 100644 docs/产品设计.md create mode 100644 packages/ui-kit/src/Ico.tsx diff --git a/.data/attachments/inbound/sim-001@x.com.eml b/.data/attachments/inbound/sim-001@x.com.eml new file mode 100644 index 0000000..ab89fa5 --- /dev/null +++ b/.data/attachments/inbound/sim-001@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "sim-001@x.com", + "from": "buyer@acme.com", + "to": [ + "rfq-review@we.com" + ], + "cc": [], + "subject": "【询价】客户 C910 RFQ-20260728S1 求报价", + "body": "客户编码 C910,询价单号 RFQ-20260728S1。\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周", + "receivedAt": "2026-07-28T19:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/sim-002@x.com.eml b/.data/attachments/inbound/sim-002@x.com.eml new file mode 100644 index 0000000..512418a --- /dev/null +++ b/.data/attachments/inbound/sim-002@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "sim-002@x.com", + "from": "buyer@acme.com", + "to": [ + "rfq-review@we.com" + ], + "cc": [], + "subject": "【询价】客户 C911 RFQ-20260728S2 求报价", + "body": "客户编码 C911,询价单号 RFQ-20260728S2。\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周", + "receivedAt": "2026-07-28T20:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/activities/quote-intake/src/manifest.ts b/activities/quote-intake/src/manifest.ts index 7801e8b..5e81cbc 100644 --- a/activities/quote-intake/src/manifest.ts +++ b/activities/quote-intake/src/manifest.ts @@ -1,5 +1,5 @@ /** - * 活动包清单 —— 统一报价 A1 · RFQ 需求分析(受理归一)。 + * 活动包清单 —— 统一报价 · 客户RFQ受理(受理归一)。 * * 业务素材出处:docs/MVP示例工作流.html §1.1;按 README《归一化对照表》映射为: * SOP 节点 → **标准活动(flow)**;作业指导书 S1…S6 → **步序声明**(四种步型); @@ -41,7 +41,7 @@ export const P03_DMN: DmnModel = { export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, - name: 'A1 · RFQ 需求分析', + name: '客户RFQ受理', packageVersion: '0.1.8', category: '统一报价', paradigm: 'flow', diff --git a/api-report/apps/api/src/modules/human-tool/contract.d.ts b/api-report/apps/api/src/modules/human-tool/contract.d.ts index a81f258..8b2b693 100644 --- a/api-report/apps/api/src/modules/human-tool/contract.d.ts +++ b/api-report/apps/api/src/modules/human-tool/contract.d.ts @@ -82,6 +82,8 @@ export type HumanToolService = { /** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */ rejectAndReask(cardId: string, reason: string): Promise; openWorkOrders(taskId: TaskId): Promise; + /** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */ + allWorkOrders(taskId: TaskId): Promise; chatCards(taskId: TaskId): Promise; /** org 停用前置检查要问的「待答卡」 */ openCardsOf(staffId: StaffId): Promise; }): Promise; + /** + * 叙事条目 —— 对话流与工作日志**同源不同筛选**: + * AI 发言与系统事件不另设写入口,一律从任务记忆投影而来(见 `chat-projection`)。 + */ + history(taskId: TaskId): Promise; }; /** 收件人解析 —— app 注入 `org`(L1):SSE 只推给有权看的人 */ export type Audience = { diff --git a/api-report/apps/api/src/modules/org/contract.d.ts b/api-report/apps/api/src/modules/org/contract.d.ts index abda25a..a63caa7 100644 --- a/api-report/apps/api/src/modules/org/contract.d.ts +++ b/api-report/apps/api/src/modules/org/contract.d.ts @@ -1,8 +1,8 @@ /** * module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。 */ -import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId } from '@contracts'; -export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff }; +import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts'; +export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding }; /** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ export type StaffWithPosition = Staff & { readonly position: Position; @@ -29,6 +29,11 @@ export declare class DisableBlockedError extends Error { readonly check: DisableCheck; constructor(check: DisableCheck); } +/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */ +export type IssuedSession = { + readonly token: string; + readonly expiresAt: string; +}; export type OrgService = { listStaff(): Promise<{ staff: StaffWithPosition[]; @@ -46,6 +51,25 @@ export type OrgService = { /** 岗位权限校验 —— app 层鉴权中间件读它 */ authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise; loadSession(account: string): Promise; + /** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */ + login(account: string, password: string): Promise; + /** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */ + logout(token: string): Promise; + /** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */ + resolveSession(token: string): Promise; + /** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */ + setPassword(staffId: StaffId, password: string): Promise; + /** 建组 / 改组;id 留空即新建 */ + saveWorkGroup(input: { + id?: string; + name: string; + ownerId: StaffId; + members: Readonly>; + }): Promise; + /** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */ + workGroupOf(staffId: StaffId): Promise; + /** 装进工作流的快照:改组不改已起的单 */ + bindingOf(staffId: StaffId): Promise; /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */ staffOfPosition(positionCode: string): Promise; ensureSeeded(): Promise; diff --git a/api-report/apps/api/src/modules/org/index.d.ts b/api-report/apps/api/src/modules/org/index.d.ts index 6e6d6b2..aaa64e7 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -1,12 +1,13 @@ /** * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ -export type { CreateStaffInput, DisableCheck, Grant, GrantAction, GrantObject, OrgService, Position, Session, Staff, StaffWithPosition, } from './contract.ts'; +export type { CreateStaffInput, DisableCheck, Grant, GrantAction, GrantObject, IssuedSession, OrgService, Position, Session, Staff, StaffWithPosition, WorkGroup, WorkGroupBinding, } from './contract.ts'; export { AccountTakenError, DisableBlockedError, PositionNotFoundError, StaffNotFoundError, } from './contract.ts'; -export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PositionRepository, StaffRepository, } from './port.ts'; +export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PasswordHasher, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository, } from './port.ts'; export type { OrgServiceDeps } from './internal/usecase/org-service.ts'; export { createOrgService } from './internal/usecase/org-service.ts'; export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts'; export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts'; -export { createGrantRepository, createPositionRepository, createStaffRepository, objectKeyOf, } from './internal/adapter/org-repository.ts'; -export { grantTable, positionTable, staffTable } from './internal/adapter/org-schema.ts'; +export { createGrantRepository, createPositionRepository, createSessionRepository, createStaffRepository, createWorkGroupRepository, objectKeyOf, } from './internal/adapter/org-repository.ts'; +export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts'; +export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; diff --git a/api-report/apps/api/src/modules/org/port.d.ts b/api-report/apps/api/src/modules/org/port.d.ts index 2089afa..791f337 100644 --- a/api-report/apps/api/src/modules/org/port.d.ts +++ b/api-report/apps/api/src/modules/org/port.d.ts @@ -2,7 +2,7 @@ * module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 * adapter 实现之,usecase 依赖之,app 装配之。 */ -import type { Grant, Position, PositionId, Staff, StaffId } from '@contracts'; +import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts'; export type Clock = { now(): Date; }; @@ -21,6 +21,32 @@ export type StaffRepository = { listByPosition(positionId: PositionId): Promise; insert(staff: Staff): Promise; setState(id: StaffId, state: Staff['state']): Promise; + /** 口令散列的读写 —— 明文不经此端口(ADR-010) */ + passwordHashOf(account: string): Promise; + setPasswordHash(id: StaffId, hash: string): Promise; +}; +/** + * 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。 + * token 即主键,不透明随机串,不编码任何身份信息。 + */ +export type SessionRepository = { + issue(input: { + token: string; + staffId: StaffId; + expiresAt: Date; + }): Promise; + /** 未过期才返回;命中同时滑动续期 */ + touch(token: string, now: Date, expiresAt: Date): Promise<{ + staffId: StaffId; + } | undefined>; + revoke(token: string): Promise; + revokeAllOf(staffId: StaffId): Promise; +}; +/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */ +export type PasswordHasher = { + hash(password: string): Promise; + verify(password: string, stored: string | undefined): Promise; + newToken(): string; }; export type GrantRepository = { listByPosition(positionId: PositionId): Promise; @@ -44,6 +70,11 @@ export type InFlightWorkProbe = { title: string; }>>; }; +/** 工作组仓储(ADR-011) */ +export type WorkGroupRepository = { + listByOwner(ownerId: StaffId): Promise; + upsert(group: WorkGroup): Promise; +}; /** ID 生成 —— domain 零随机(§5.3) */ export type IdGenerator = { next(): string; diff --git a/api-report/apps/api/src/modules/process/port.d.ts b/api-report/apps/api/src/modules/process/port.d.ts index 78ed55c..1e62ab4 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -24,6 +24,11 @@ export type WorkflowRecord = { by?: string; }; readonly initiation: Record; + /** + * 工作组绑定的**快照**(ADR-011):起单时按来源人装载,之后改组不影响这条单。 + * 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。 + */ + readonly workGroup?: Record; /** 各节点输出口载荷 —— 边映射装配的来源 */ readonly ports: Record>; readonly vars: Record; @@ -65,6 +70,10 @@ export type OrgDirectory = { name: string; openTasks: number; }>>; + /** 某人的工作组绑定(ADR-011)—— 起单时按来源人取,取不到即不装载 */ + workGroupBinding(staffId: string): Promise> | undefined>; + /** 在岗与否 —— 绑定的人已离岗就不硬用,退回四策略 */ + isActive(staffId: string): Promise; }; /** 起单与终态 —— app 注入 `task`(L6) */ export type TaskGateway = { diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 358653d..23ace64 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -474,22 +474,30 @@ export declare const listProcessPackagesRoute: { kind: z.ZodLiteral<"start">; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"end">; output: z.ZodOptional>; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"activity">; activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; activityVersion: z.ZodOptional; owner: z.ZodObject<{ position: z.ZodString; @@ -515,9 +523,12 @@ export declare const listProcessPackagesRoute: { message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; @@ -570,9 +581,12 @@ export declare const listProcessPackagesRoute: { message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"gateway">; @@ -584,11 +598,15 @@ export declare const listProcessPackagesRoute: { join: "join"; fork: "fork"; }>; + basis: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray], "from">; }, z.core.$strip>>>; label: z.ZodOptional; + tone: z.ZodOptional>; reopen: z.ZodDefault; }, z.core.$strip>>; variables: z.ZodDefault; }, z.core.$strip>>>; description: z.ZodOptional; + diagram: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>; }; /** 画图即校验 —— 与前端画布同源的闭合校验 */ +/** + * 保存流程包(编排工作区的落笔)。 + * **保存 ≠ 发布**:落的是这一版的定义(草稿可反复存);已发布版本不可回改 —— + * 后端按 (id, version, state) 判:published 版拒存,提示另起新版本。 + * 返回连同闭合报告:画图即校验,每次落笔都知道离「可发布」还差几条。 + */ +export declare const saveProcessRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/process"; + 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; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; + 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; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, 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; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"gateway">; + gateway: z.ZodEnum<{ + parallel: "parallel"; + exclusive: "exclusive"; + }>; + mode: z.ZodEnum<{ + join: "join"; + fork: "fork"; + }>; + basis: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, 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; + tone: z.ZodOptional>; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + diagram: z.ZodOptional>; + }, z.core.$strip>; + }, z.core.$strip>; + readonly output: 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; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; + 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; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, 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; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"gateway">; + gateway: z.ZodEnum<{ + parallel: "parallel"; + exclusive: "exclusive"; + }>; + mode: z.ZodEnum<{ + join: "join"; + fork: "fork"; + }>; + basis: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, 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; + tone: z.ZodOptional>; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + diagram: z.ZodOptional>; + }, z.core.$strip>; + report: 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>; + }, z.core.$strip>; +}; export declare const validateProcessRoute: { readonly method: "POST"; readonly path: "/api/gov/process/validate"; @@ -649,22 +1088,30 @@ export declare const validateProcessRoute: { kind: z.ZodLiteral<"start">; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"end">; output: z.ZodOptional>; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"activity">; activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; activityVersion: z.ZodOptional; owner: z.ZodObject<{ position: z.ZodString; @@ -690,9 +1137,12 @@ export declare const validateProcessRoute: { message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; @@ -745,9 +1195,12 @@ export declare const validateProcessRoute: { message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"gateway">; @@ -759,11 +1212,15 @@ export declare const validateProcessRoute: { join: "join"; fork: "fork"; }>; + basis: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray], "from">; }, z.core.$strip>>>; label: z.ZodOptional; + tone: z.ZodOptional>; reopen: z.ZodDefault; }, z.core.$strip>>; variables: z.ZodDefault; }, z.core.$strip>>>; description: z.ZodOptional; + diagram: z.ZodOptional>; }, z.core.$strip>; }, z.core.$strip>; readonly output: z.ZodObject<{ @@ -1091,22 +1553,30 @@ export declare const govWorkflowRoute: { kind: z.ZodLiteral<"start">; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"end">; output: z.ZodOptional>; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"activity">; activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; activityVersion: z.ZodOptional; owner: z.ZodObject<{ position: z.ZodString; @@ -1132,9 +1602,12 @@ export declare const govWorkflowRoute: { message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; @@ -1187,9 +1660,12 @@ export declare const govWorkflowRoute: { message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"gateway">; @@ -1201,11 +1677,15 @@ export declare const govWorkflowRoute: { join: "join"; fork: "fork"; }>; + basis: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray], "from">; }, z.core.$strip>>>; label: z.ZodOptional; + tone: z.ZodOptional>; reopen: z.ZodDefault; }, z.core.$strip>>; variables: z.ZodDefault; }, z.core.$strip>>>; description: z.ZodOptional; + diagram: z.ZodOptional>; }, z.core.$strip>; instances: z.ZodArray>; }, z.core.$strip>; }; +/** + * 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。 + * 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。 + */ +export declare const loginRoute: { + readonly method: "POST"; + readonly path: "/api/login"; + readonly body: z.ZodObject<{ + account: z.ZodString; + password: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + staff: z.ZodObject<{ + id: z.core.$ZodBranded; + account: z.ZodString; + name: z.ZodString; + positionId: z.core.$ZodBranded; + state: z.ZodDefault>; + }, z.core.$strip>; + position: z.ZodObject<{ + id: z.core.$ZodBranded; + code: z.ZodString; + name: z.ZodString; + note: z.ZodOptional; + }, z.core.$strip>; + grants: z.ZodArray; + object: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"data">; + domain: z.ZodString; + object: z.ZodString; + field: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"governance">; + function: z.ZodEnum<{ + data: "data"; + task: "task"; + trigger: "trigger"; + system: "system"; + tool: "tool"; + activity: "activity"; + staff: "staff"; + process: "process"; + memory: "memory"; + }>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"task">; + activityId: z.ZodOptional; + }, z.core.$strip>], "kind">; + action: z.ZodEnum<{ + read: "read"; + write: "write"; + govern: "govern"; + }>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */ +export declare const logoutRoute: { + readonly method: "POST"; + readonly path: "/api/logout"; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; export declare const healthRoute: { readonly method: "GET"; readonly path: "/api/health"; @@ -137,6 +206,26 @@ export declare const listTasksRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** + * 流程链上的一环 —— 「一条 = 一个活动的一次运行」之外,人还要看见自己在整条流程的哪儿。 + * 别人负责的节点也在链上(只读),但不进任务清单。 + */ +export declare const chainNodeSchema: z.ZodObject<{ + nodeId: z.ZodString; + label: z.ZodString; + kind: z.ZodEnum<{ + activity: "activity"; + approval: "approval"; + }>; + ownerName: z.ZodString; + ownerRole: z.ZodString; + state: z.ZodEnum<{ + done: "done"; + current: "current"; + todo: "todo"; + }>; +}, z.core.$strip>; +export type ChainNode = z.infer; /** 任务详情 —— 工作区与对话区的服务端投影(前端零过程状态,刷新即重建) */ export declare const taskDetailSchema: z.ZodObject<{ card: z.ZodObject<{ @@ -171,6 +260,25 @@ export declare const taskDetailSchema: z.ZodObject<{ startedAt: z.ZodString; }, z.core.$strip>; goal: z.ZodString; + owner: z.ZodObject<{ + name: z.ZodString; + role: z.ZodString; + }, z.core.$strip>; + chain: z.ZodArray; + ownerName: z.ZodString; + ownerRole: z.ZodString; + state: z.ZodEnum<{ + done: "done"; + current: "current"; + todo: "todo"; + }>; + }, z.core.$strip>>; budget: z.ZodObject<{ steps: z.ZodString; tokens: z.ZodString; @@ -290,6 +398,10 @@ export declare const taskDetailSchema: z.ZodObject<{ name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -359,6 +471,10 @@ export declare const taskDetailSchema: z.ZodObject<{ label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -396,6 +512,43 @@ export declare const taskDetailSchema: z.ZodObject<{ }>; at: z.ZodOptional; }, z.core.$strip>>; + results: z.ZodArray; + file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; workOrders: z.ZodArray; preset: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -576,6 +733,10 @@ export declare const taskDetailSchema: z.ZodObject<{ label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -644,7 +805,12 @@ export declare const taskDetailSchema: z.ZodObject<{ done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ @@ -708,7 +874,12 @@ export declare const taskDetailSchema: z.ZodObject<{ done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>], "mode">>; @@ -902,6 +1073,10 @@ export declare const taskDetailSchema: z.ZodObject<{ name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -971,6 +1146,10 @@ export declare const taskDetailSchema: z.ZodObject<{ label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1055,6 +1234,25 @@ export declare const taskDetailRoute: { startedAt: z.ZodString; }, z.core.$strip>; goal: z.ZodString; + owner: z.ZodObject<{ + name: z.ZodString; + role: z.ZodString; + }, z.core.$strip>; + chain: z.ZodArray; + ownerName: z.ZodString; + ownerRole: z.ZodString; + state: z.ZodEnum<{ + done: "done"; + current: "current"; + todo: "todo"; + }>; + }, z.core.$strip>>; budget: z.ZodObject<{ steps: z.ZodString; tokens: z.ZodString; @@ -1174,6 +1372,10 @@ export declare const taskDetailRoute: { name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1243,6 +1445,10 @@ export declare const taskDetailRoute: { label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1280,6 +1486,43 @@ export declare const taskDetailRoute: { }>; at: z.ZodOptional; }, z.core.$strip>>; + results: z.ZodArray; + file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; workOrders: z.ZodArray; preset: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1460,6 +1707,10 @@ export declare const taskDetailRoute: { label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1528,7 +1779,12 @@ export declare const taskDetailRoute: { done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ @@ -1592,7 +1848,12 @@ export declare const taskDetailRoute: { done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>], "mode">>; @@ -1786,6 +2047,10 @@ export declare const taskDetailRoute: { name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1855,6 +2120,10 @@ export declare const taskDetailRoute: { label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1912,6 +2181,20 @@ export declare const replyWorkOrderRoute: { producesCompletion: z.ZodBoolean; }, z.core.$strip>; }; +/** + * 对话卡回传(决策 / 补全 / 评价)—— 与后端回传校验**同 schema**。 + * 对话流是一切回环输入的唯一入口(OS 4.1):卡就长在对话流上,答也就地答。 + */ +export declare const replyChatCardRoute: { + readonly method: "POST"; + readonly path: "/api/desk/chat-cards/reply"; + readonly body: z.ZodObject<{ + reply: z.ZodRecord; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; /** 负责人的自由输入 —— 人事件的第二形态 */ export declare const sayRoute: { readonly method: "POST"; @@ -2582,3 +2865,37 @@ export declare const decideApprovalRoute: { }, z.core.$strip>; }; export type ListApprovalsForMeOutput = z.infer; +/** + * 「登记触发 —— 人工起单的统一形态:登记人选择流程包、按调用接口 schema 填写输入 + * (表单由 schema 自动渲染)、提交即起单。」**登记人即来源**。 + */ +export declare const listRegistrationTriggersRoute: { + readonly method: "GET"; + readonly path: "/api/triggers/registration"; + readonly output: z.ZodObject<{ + triggers: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const fireRegistrationRoute: { + readonly method: "POST"; + readonly path: "/api/triggers/:triggerId/register"; + readonly params: z.ZodObject<{ + triggerId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + data: z.ZodRecord; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + workflowId: z.ZodOptional; + skipped: z.ZodOptional; + missing: z.ZodOptional>; + }, z.core.$strip>; +}; +export type ListRegistrationTriggersOutput = z.infer; diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index 9f19848..431c0f0 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -76,6 +76,10 @@ export declare const formFieldSchema: z.ZodObject<{ }, z.core.$strip>; export type FormField = z.infer; declare const fileDocSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; +}, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -207,6 +211,10 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -276,6 +284,10 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -434,6 +446,10 @@ export declare const deliverableSchema: z.ZodObject<{ name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -503,6 +519,10 @@ export declare const deliverableSchema: z.ZodObject<{ label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -548,11 +568,31 @@ export declare const workOrderModeSchema: z.ZodEnum<{ }>; export type WorkOrderMode = z.infer; export declare const WORK_ORDER_MODE_LABEL: Readonly>; +/** + * 办结口径:执行件只有「已提交」,验收件是放行三选一 —— 与回传协议同一套标识符。 + * 界面文案由字典给出(与原型逐字一致),不在组件里散落中文。 + */ +export declare const WORK_ORDER_RESULTS: readonly ["submitted", "pass", "redo", "revise"]; +export declare const workOrderResultSchema: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; +}>; +export type WorkOrderResult = z.infer; +export declare const WORK_ORDER_RESULT_LABEL: Readonly>; +/** 驳回两式 —— 退回即重来,判定处只此一处 */ +export declare function isRejection(result: WorkOrderResult): boolean; /** 完成回传:谁、何时、答了什么(回传即留痕) */ export declare const workDoneSchema: z.ZodObject<{ at: z.ZodISODateTime; by: z.core.$ZodBranded; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>; export type WorkDone = z.infer; @@ -668,6 +708,10 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -737,6 +781,10 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -805,7 +853,12 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>; @@ -895,7 +948,12 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{ done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>; @@ -1011,6 +1069,10 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ name: z.ZodString; meta: z.ZodOptional; file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1080,6 +1142,10 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ label: z.ZodString; desc: z.ZodString; file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; @@ -1148,7 +1214,12 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ @@ -1212,7 +1283,12 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ done: z.ZodOptional; - result: z.ZodString; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; values: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>], "mode">; diff --git a/api-report/packages/contracts/src/org.d.ts b/api-report/packages/contracts/src/org.d.ts index af77e1a..609f25f 100644 --- a/api-report/packages/contracts/src/org.d.ts +++ b/api-report/packages/contracts/src/org.d.ts @@ -174,3 +174,20 @@ export declare const sessionSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>; export type Session = z.infer; +/** + * 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。 + * + * **不是第五种负责人策略**(OS 03「策略封闭四选」不动): + * 四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,策略于是无从发问。 + * 键是**岗位 code**(BD / PM / FAE …),与流程包 `owner.position` 同一口径。 + */ +export declare const workGroupSchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + ownerId: z.core.$ZodBranded; + members: z.ZodRecord>; +}, z.core.$strip>; +export type WorkGroup = z.infer; +/** 装进工作流的**快照**:改组不改已起的单(留痕不被改写) */ +export declare const workGroupBindingSchema: z.ZodRecord>; +export type WorkGroupBinding = z.infer; diff --git a/api-report/packages/contracts/src/process-package.d.ts b/api-report/packages/contracts/src/process-package.d.ts index 26f1fa1..f12b28c 100644 --- a/api-report/packages/contracts/src/process-package.d.ts +++ b/api-report/packages/contracts/src/process-package.d.ts @@ -97,6 +97,10 @@ export declare const edgeSchema: z.ZodObject<{ }, z.core.$strip>], "from">; }, z.core.$strip>>>; label: z.ZodOptional; + tone: z.ZodOptional>; reopen: z.ZodDefault; }, z.core.$strip>; export type Edge = z.infer; @@ -135,22 +139,30 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"start">; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"end">; output: z.ZodOptional>; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"activity">; activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; activityVersion: z.ZodOptional; owner: z.ZodObject<{ position: z.ZodString; @@ -176,9 +188,12 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; @@ -231,9 +246,12 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"gateway">; @@ -245,11 +263,15 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ join: "join"; fork: "fork"; }>; + basis: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>], "kind">; export type ProcessNode = z.infer; @@ -282,22 +304,30 @@ export declare const processPackageSchema: z.ZodObject<{ kind: z.ZodLiteral<"start">; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"end">; output: z.ZodOptional>; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"activity">; activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; activityVersion: z.ZodOptional; owner: z.ZodObject<{ position: z.ZodString; @@ -323,9 +353,12 @@ export declare const processPackageSchema: z.ZodObject<{ message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; @@ -378,9 +411,12 @@ export declare const processPackageSchema: z.ZodObject<{ message: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"gateway">; @@ -392,11 +428,15 @@ export declare const processPackageSchema: z.ZodObject<{ join: "join"; fork: "fork"; }>; + basis: z.ZodOptional; id: z.core.$ZodBranded; label: z.ZodString; + sub: z.ZodOptional; layout: z.ZodOptional; + y: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray], "from">; }, z.core.$strip>>>; label: z.ZodOptional; + tone: z.ZodOptional>; reopen: z.ZodDefault; }, z.core.$strip>>; variables: z.ZodDefault; }, z.core.$strip>>>; description: z.ZodOptional; + diagram: z.ZodOptional>; }, z.core.$strip>; export type ProcessPackage = z.infer; export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "gateway-unbalanced", "join-ambiguous-source", "tolerate-required-dependency", "unknown-activity", "missing-owner-position"]; diff --git a/apps/api/package.json b/apps/api/package.json index eee4c8d..010d3ef 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,9 @@ "build": "tsup", "start": "node dist/main.js", "seed": "node dist/seed.js", - "migrate": "node dist/migrate.js" + "migrate": "node dist/migrate.js", + "set-password": "node dist/set-password.js", + "reset-runs": "node dist/reset-runs.js" }, "dependencies": { "@ai-sdk/openai-compatible": "^3.0.14", diff --git a/apps/api/src/app/config.ts b/apps/api/src/app/config.ts index 21044e1..1e36853 100644 --- a/apps/api/src/app/config.ts +++ b/apps/api/src/app/config.ts @@ -11,9 +11,16 @@ export type AppConfig = { readonly attachmentRoot: string; /** 派发器轮询间隔(毫秒) */ readonly dispatchIntervalMs: number; + /** + * 播种口令(ADR-010)—— 新建员工的初始口令。 + * 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。 + */ + readonly seedPassword: string; }; const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai'; +/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */ +const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026'; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const nodeEnv = env['NODE_ENV']; @@ -23,5 +30,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'), dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000), + seedPassword: env['JCAI_SEED_PASSWORD'] ?? DEFAULT_SEED_PASSWORD, }; } diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index b250d9d..4d9037a 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -19,7 +19,12 @@ import { createGrantRepository, createOrgService, createPositionRepository, + createSessionRepository, createStaffRepository, + createWorkGroupRepository, + hashPassword, + newSessionToken, + verifyPassword, } from '@modules/org'; import type { LlmService, ModelGateway } from '@modules/llm'; import { @@ -172,7 +177,13 @@ export async function createContainer(config: AppConfig): Promise { positions: createPositionRepository(db), staff: createStaffRepository(db), grants: createGrantRepository(db), + sessions: createSessionRepository(db), + workGroups: createWorkGroupRepository(db), + // 散列算法在 adapter,usecase 只认这三个动作(ADR-010) + passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken }, inFlight: inFlightProbe, + clock: systemClock, + seedPassword: config.seedPassword, ids, }); @@ -308,6 +319,8 @@ export async function createContainer(config: AppConfig): Promise { ...input, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, })), + // 对话流的 AI 发言与系统事件由此投影而来 —— 与工作日志同源 + history: (taskId) => memory.history(taskId), }, audience: { canSee: async () => true }, clock: systemClock, diff --git a/apps/api/src/app/http-env.ts b/apps/api/src/app/http-env.ts index a671be6..0da4db4 100644 --- a/apps/api/src/app/http-env.ts +++ b/apps/api/src/app/http-env.ts @@ -1,4 +1,11 @@ /** * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 */ -export type AppEnv = { Variables: { account: string } }; +import type { Session } from '@contracts'; + +/** + * 请求上下文(ADR-010)。 + * `session` 由鉴权中间件从**服务端会话表**解出 —— 不再有「cookie 里写谁就是谁」的明文身份; + * `account` 保留为便捷读法,值恒取自已核验的会话,免鉴权路由上为空串。 + */ +export type AppEnv = { Variables: { account: string; session: Session | undefined } }; diff --git a/apps/api/src/app/reset-runs.ts b/apps/api/src/app/reset-runs.ts new file mode 100644 index 0000000..9fe7908 --- /dev/null +++ b/apps/api/src/app/reset-runs.ts @@ -0,0 +1,68 @@ +/** + * 清运行数据 —— 开发期反复用:把跑过的单清空,配置原样留着。 + * + * pnpm --filter @app/api reset-runs + * + * **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆… + * **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、 + * 触发器、通道、模型实例、纪要。 + * + * 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 —— + * 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。 + */ +import { Pool } from 'pg'; +import { loadConfig } from './config.ts'; + +/** 顺序无所谓:一条 TRUNCATE 里的表会一起截断,外键互指也不冲突 */ +const RUN_TABLES = [ + 'sys.task', + 'sys.task_memory', + 'sys.task_memory_seal', + 'sys.task_view', + 'sys.work_order', + 'sys.chat_card', + 'sys.chat_message', + 'sys.system_message', + 'sys.node_instance', + 'sys.workflow', + 'sys.flow_state', + 'sys.loop_state', + 'sys.listener', + 'sys.action_intent', + 'sys.event', + 'sys.event_delivery', + 'sys.event_consumption', + 'sys.outbound_mail', + 'sys.outbound_ref', + 'sys.trigger_claim', + 'sys.report', + 'sys.replay_model', + 'sys.replay_tool', + /* 活动记忆是跑出来的蒸馏物,跟着运行数据一起清 */ + 'sys.activity_memory', + 'sys.asset_increment', + /* 纪要是人记的,但开发期同属「跑出来的东西」—— 清库要清得干净,不留半场旧会 */ + 'sys.minute', + 'sys.minute_resolution', +]; + +export async function resetRuns(): Promise { + const pool = new Pool({ connectionString: loadConfig().databaseUrl, max: 2 }); + try { + // 台账的业务表是动态建的,名字问库不写死 + const ledger = await pool.query<{ name: string }>( + `select quote_ident(schemaname) || '.' || quote_ident(tablename) as name + from pg_tables where schemaname = 'ledger'`, + ); + const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)]; + await pool.query(`truncate table ${all.join(', ')} restart identity cascade`); + process.stdout.write(`已清运行数据:${all.length} 张表(配置与组织数据原样保留)\n`); + } finally { + await pool.end(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await resetRuns(); + process.exit(0); +} diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index caad80c..fc36dcd 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -4,16 +4,19 @@ * 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。 */ import type { Hono } from 'hono'; -import type { ApiError, ChatMessage, TaskCard, ViewBlock } from '@contracts'; +import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, ViewBlock } from '@contracts'; import { ATTRIBUTION_SIDE_LABEL, answerMessageRoute, + chatCardReplySchema, listMessagesRoute, markMessageReadRoute, listTasksRoute, + replyChatCardRoute, replyWorkOrderRoute, sayRoute, taskDetailRoute, + staffIdSchema, taskIdSchema, workOrderReplySchema, } from '@contracts'; @@ -62,6 +65,79 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }; } + /** + * 流程链 —— 人要看见自己在整条流程的哪一环,以及前后是谁。 + * 别人负责的节点在链上只读呈现,不进任务清单(清单只装我负责的)。 + */ + async function toChain(task: Awaited>): Promise { + if (task.workflowId === undefined) return []; + const workflow = await container.process.getWorkflow(task.workflowId).catch(() => undefined); + if (workflow === undefined) return []; + const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); + + /* + * 链的呈现顺序:**按流程列位排,同列按节点编号排** —— 不吃 `nodes` 数组的书写顺序。 + * + * 并行分支(如 G2 分出的 A2 / A3)在图上本无先后,链却必须挑一个顺序。 + * 挑「作者数组里怎么写」是最不稳的:换个人写包,链的读法就变了。 + * 列位 `layout.col` 是流程的推进次序(拓扑的可视化),同列的并行支再按编号定 —— + * 于是链永远读作 A1 → A2 → A3 → A4,与人对活动编号的预期一致。 + * `layout` 声明为「不影响语义」,这里也只拿它排**呈现**,不参与任何流转判断。 + */ + const declared = (pkg?.nodes ?? []).filter((n) => n.kind === 'activity' || n.kind === 'approval'); + const nodes = declared + .map((n, index) => ({ n, index })) + .sort( + (a, b) => + (a.n.layout?.col ?? a.index) - (b.n.layout?.col ?? b.index) || + (a.n.id < b.n.id ? -1 : a.n.id > b.n.id ? 1 : 0), + ) + .map((x) => x.n); + + /* + * 状态取**真实的节点实例**,不按下标推。 + * 按下标推(「排在当前之前的就算做完」)在并行支上会说谎:人在 A3 时 A2 可能还在跑, + * 却被标成已完成。宁可把在跑的并行支报作待办(少报),也不能把没做完的说成做完了。 + */ + const instances = await container.process.instancesOf(workflow.id).catch(() => []); + const finished = new Set(instances.filter((x) => x.terminal !== undefined).map((x) => x.nodeId)); + + return await Promise.all( + nodes.map(async (n) => { + const spec = n.kind === 'approval' ? n.approver : n.owner; + + /* + * 报谁,要和**真正派单时的口径一致**(ADR-011:绑定先于策略)。 + * 只按岗位报人的话,装了工作组的单会当面说谎:整条流实际都归起单人, + * 链上却写着各岗位的常驻人。所以这里也先看工作组绑定,再退回岗位解析。 + */ + const bound = workflow.workGroup?.[spec.position]; + const boundStaff = + bound === undefined + ? undefined + : await container.org.getStaff(staffIdSchema.parse(bound)).catch(() => undefined); + + // 岗位唯一解析(sole)即报人名;否则只报岗位 —— 不编造具体承接人 + const holders = + boundStaff !== undefined ? [] : await container.org.staffOfPosition(spec.position).catch(() => []); + const who = boundStaff ?? (holders.length === 1 ? holders[0] : undefined); + return { + nodeId: n.id, + label: n.label, + kind: n.kind === 'approval' ? ('approval' as const) : ('activity' as const), + ownerName: who?.name ?? spec.position, + ownerRole: who?.position.name ?? spec.position, + state: + n.id === task.nodeId + ? ('current' as const) + : finished.has(n.id) + ? ('done' as const) + : ('todo' as const), + }; + }), + ); + } + app.get(listTasksRoute.path, async (c) => { const staff = await me(c.get('account')); if (staff === undefined) { @@ -83,12 +159,49 @@ export function registerDeskRoutes(app: Hono, container: Container): voi const task = await container.task.get(taskId); const [workOrders, chat, lines, pushed] = await Promise.all([ - container.humanTool.openWorkOrders(taskId), + // 全部工单:待处理要翻页办,已办结要回看定稿报告 —— 两摞同源 + container.humanTool.allWorkOrders(taskId), container.interaction.timeline(taskId), container.memory.taskLog(taskId), container.interaction.viewOf(taskId), ]); const cards = await container.humanTool.chatCards(taskId); + const chain = await toChain(task); + + /* + * 工作成果 = 一组**文件**,大部分是 HTML 汇报报告。 + * 两处来源: + * ① 活动推的那一屏 —— 它就是这单当下的汇报报告(AM 07); + * ② 过程成果里声明的 `files` 块 —— 活动按需生成的目标输出(报价单 / 台账 / 档案)。 + * 活动尚未产出文件时给空列表,界面落空态,不编造条目。 + */ + /* + * 过程成果尚无来源:活动还不发布 deliverable(一个个活动开发时逐步补上)。 + * 先声明出来并接进成果文件的装配 —— 活动一开始产出,文件列表就自动有了。 + */ + const deliverables: Deliverable[] = []; + + const results: Array<{ label: string; desc: string; at?: string; file: FileDoc }> = []; + if (pushed?.html !== undefined) { + results.push({ + label: `${task.title} · 汇报报告`, + desc: 'HTML · 活动按当下局面组织的一屏', + file: { kind: 'html', name: `${task.activityId}-report.html`, html: pushed.html }, + }); + } + for (const deliverable of deliverables) { + for (const block of deliverable.blocks) { + if (block.t !== 'files') continue; + for (const item of block.items) { + results.push({ + label: item.label, + desc: item.desc, + ...(deliverable.at === undefined ? {} : { at: deliverable.at }), + file: item.file, + }); + } + } + } // 对话流 = 时间线 + 对话卡族,按时间全序 —— 与任务记忆同源、不同筛选 const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); @@ -111,6 +224,8 @@ export function registerDeskRoutes(app: Hono, container: Container): voi return c.json({ card: await toCard(task), goal: task.title, + owner: { name: staff.name, role: staff.position.name }, + chain, budget: { steps: `${task.used.steps}/${task.caps.steps}`, tokens: `${task.used.tokens}/${task.caps.tokens}`, @@ -118,7 +233,8 @@ export function registerDeskRoutes(app: Hono, container: Container): voi humanCalls: `${task.used.humanCalls}/${task.caps.humanCalls}`, }, stages: [], - deliverables: [], + deliverables, + results, workOrders, chat: merged, view: pushed?.blocks !== undefined ? [...pushed.blocks] : view, @@ -159,6 +275,34 @@ export function registerDeskRoutes(app: Hono, container: Container): voi } }); + /** + * 对话卡回传(决策 / 补全 / 评价)。 + * 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。 + */ + app.post(replyChatCardRoute.path, async (c) => { + const staff = await me(c.get('account')); + if (staff === undefined) { + const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; + return c.json(body, 401); + } + const parsed = chatCardReplySchema.safeParse((await c.req.json())['reply']); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues }; + return c.json(body, 400); + } + try { + await container.humanTool.replyChatCard(parsed.data, staff.id); + return c.json({ ok: true }); + } catch (error) { + if (error instanceof ReplyRejectedError) { + // 补全两条路必走其一 / 已答不得再答 —— 三规矩的回传面 + const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.violations }; + return c.json(body, 400); + } + throw error; + } + }); + /** 自由输入 —— 人事件的第二形态:写入任务记忆,loop 作为观察;不驱动 flow 步 */ app.post(sayRoute.path, async (c) => { const staff = await me(c.get('account')); diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 6d32cb4..7f5b51c 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -23,6 +23,7 @@ import { listToolsRoute, listTriggersRoute, publishProcessRoute, + saveProcessRoute, reassignTaskRoute, resumeTaskRoute, staffIdSchema, @@ -255,6 +256,33 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai return c.json({ packages: await container.process.listPackages() }); }); + /** + * 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。 + * 想改已发布的,另起新版本(恒新增、版本并存,在途按启动时版本跑完)。 + */ + app.put(saveProcessRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = saveProcessRoute.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); + } + const incoming = parsed.data.pkg; + const existing = await container.process.getPackage(incoming.id, incoming.version); + if (existing !== undefined && existing.state === 'published') { + const body: ApiError = { + code: 'precondition-failed', + message: `v${incoming.version} 已发布,不可回改 —— 另存为 v${incoming.version + 1} 再编排`, + }; + return c.json(body, 412); + } + // 保存的永远是草稿:发布是另一道门(闭合校验),不在保存里顺带 + const pkg = await container.process.savePackage({ ...incoming, state: 'draft' }); + const report = await container.process.validate(pkg); + return c.json({ pkg, report }); + }); + /** 画图即校验 —— 与前端画布同源 */ app.post(validateProcessRoute.path, async (c) => { const denied = await governProcess(c.get('account')); diff --git a/apps/api/src/app/routes/registration.ts b/apps/api/src/app/routes/registration.ts new file mode 100644 index 0000000..a3e71ee --- /dev/null +++ b/apps/api/src/app/routes/registration.ts @@ -0,0 +1,62 @@ +/** + * 登记触发的路由(编码规范 §3;语义出处 OS 3.1)。 + * + * 「登记触发 = 人工起单的统一形态:登记人选择流程包、按调用接口 schema 填写输入、 + * 提交即起单。」**凡单必有来源** —— 登记起单的来源就是登记人,记在工作流上。 + * + * 与事件触发同走一条 `trigger.fire` 通路:差别只在**时机由谁给定**,不在起单本身。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; + +export function registerRegistrationRoutes(app: Hono, container: Container): void { + const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' }; + + /** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */ + app.get(listRegistrationTriggersRoute.path, async (c) => { + const me = await container.org.findByAccount(c.get('account')); + if (me === undefined) return c.json(unauthenticated, 401); + + const triggers = await container.trigger.list(); + const registration = triggers.filter((t) => triggerKindOf(t.spec) === 'registration'); + const rows = await Promise.all( + registration.map(async (t) => { + const pkg = await container.process.getPackage(t.processPackageId); + return { + id: t.id, + name: t.name, + processPackageId: t.processPackageId, + enabled: t.enabled, + inputSchema: (pkg?.callInterface.input ?? {}) as Record, + }; + }), + ); + return c.json({ triggers: rows }); + }); + + app.post(fireRegistrationRoute.path, async (c) => { + const me = await container.org.findByAccount(c.get('account')); + if (me === undefined) return c.json(unauthenticated, 401); + const parsed = fireRegistrationRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '登记入参不合法' }; + return c.json(body, 400); + } + + // 登记人即来源 —— 由会话给定,不由前端传 + const outcome = await container.trigger.fireByRegistration( + c.req.param('triggerId'), + me.id, + parsed.data.data, + ); + return c.json({ + ok: outcome.workflowId !== undefined, + ...(outcome.workflowId === undefined ? {} : { workflowId: outcome.workflowId }), + ...(outcome.skipped === undefined ? {} : { skipped: outcome.skipped }), + ...(outcome.missing === undefined ? {} : { missing: [...outcome.missing] }), + }); + }); +} diff --git a/apps/api/src/app/routes/workbench.ts b/apps/api/src/app/routes/workbench.ts index 33e0b8d..66e9b0e 100644 --- a/apps/api/src/app/routes/workbench.ts +++ b/apps/api/src/app/routes/workbench.ts @@ -1,10 +1,12 @@ /** * 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。 + * 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。 */ import type { Hono } from 'hono'; import type { ApiError } from '@contracts'; -import { CONTRACTS_VERSION, healthRoute, sessionRoute } from '@contracts'; +import { CONTRACTS_VERSION, healthRoute, loginRoute, logoutRoute, sessionRoute } from '@contracts'; import type { Container } from '../container.ts'; +import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.ts'; import type { AppEnv } from '../http-env.ts'; export function registerWorkbenchRoutes(app: Hono, container: Container): void { @@ -19,12 +21,44 @@ export function registerWorkbenchRoutes(app: Hono, container: Container) return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db }); }); - app.get(sessionRoute.path, async (c) => { - const session = await container.org.loadSession(c.get('account')); + /** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */ + app.get(sessionRoute.path, (c) => { + const session = c.get('session'); if (session === undefined) { - const body: ApiError = { code: 'unauthenticated', message: '会话无效或员工已停用' }; + const body: ApiError = { code: 'unauthenticated', message: '尚未登录' }; return c.json(body, 401); } return c.json(session); }); + + app.post(loginRoute.path, async (c) => { + const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' }; + return c.json(body, 400); + } + + const issued = await container.org.login(parsed.data.account, parsed.data.password); + // 失败只有一句话:不区分账号不存在 / 已停用 / 口令错,不外泄账号是否存在 + if (issued === undefined) { + const body: ApiError = { code: 'unauthenticated', message: '账号或口令不对' }; + return c.json(body, 401); + } + + const session = await container.org.resolveSession(issued.token); + if (session === undefined) { + const body: ApiError = { code: 'internal', message: '会话签发后立即失效' }; + return c.json(body, 500); + } + setSessionCookie(c, issued.token, new Date(issued.expiresAt)); + return c.json(session); + }); + + app.post(logoutRoute.path, async (c) => { + const token = c.req.header('cookie')?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`))?.[1]; + // 服务端表先作废,再清 cookie —— 顺序反了会留下「cookie 没了但会话还活着」的窗口 + if (token !== undefined) await container.org.logout(token); + clearSessionCookie(c); + return c.json({ ok: true }); + }); } diff --git a/apps/api/src/app/seed-quote.ts b/apps/api/src/app/seed-quote.ts index 03ae393..eaf9c6a 100644 --- a/apps/api/src/app/seed-quote.ts +++ b/apps/api/src/app/seed-quote.ts @@ -211,7 +211,7 @@ export async function seedQuote(container: Container): Promise { const pkg = processPackageSchema.parse({ id: 'unified-quote', name: '统一报价', - version: 1, + version: 2, state: 'draft', callInterface: { input: { @@ -221,44 +221,15 @@ export async function seedQuote(container: Container): Promise { }, output: { type: 'object', properties: { landingRate: { type: 'number' } } }, }, + /* + * **当前只展开第一个活动**(v2)。 + * 后续活动一个一个加回来:每加一个,补它的节点、边映射与出口口载荷, + * 版本随之上抬 —— 在途的旧单仍跑各自起单时那一版,不受影响。 + */ nodes: [ { id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0 } }, - activityNode('A1', 'A1 RFQ需求分析', 'quote-intake', 'BD', 'terminate', 1), - // 已裁定 ②:G2 料号分流 = **并行网关**,三道并行、每边载荷按 Type 过滤 - { id: 'G2', kind: 'gateway', label: 'G2 料号分流', gateway: 'parallel', mode: 'fork', layout: { row: 0, col: 2 } }, - // 某类条目为空的分支按「容忍」处置 —— A4 汇合处的必填映射不得依赖可空分支 - activityNode('A3', 'A3 渠道寻源', 'quote-sourcing', 'PRM', 'tolerate', 3, -1), - activityNode('A2', 'A2 替代选型', 'quote-alternative', 'FAE', 'tolerate', 3, 1), - { id: 'G2J', kind: 'gateway', label: 'G2 汇合', gateway: 'parallel', mode: 'join', layout: { row: 0, col: 4 } }, - activityNode('A4', 'A4 成本定价', 'quote-pricing', 'BD', 'terminate', 5), - // 已裁定 ①:A5 定价审批 = **审批点**;G3 审批结果并入驳回走向(驳回 → 回 A4 重开新实例) - { - id: 'AP', - kind: 'approval', - label: 'AP 定价审批', - approver: { position: 'PM', strategy: 'sole' }, - // 驳回走向的载荷映射不能空:重开的是**新的 A4 实例**,它照样要过 A4 的入参契约。 - // 空映射 = 重开必失败,等于把「驳回回 A4」写成了一句空话。 - reject: { - kind: 'reopen', - nodeId: 'A4', - mapping: [ - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - { target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } }, - { target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }, - { target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } }, - { target: 'customerGrade', source: { from: 'constant', value: '普通客户' } }, - { target: 'volumeTier', source: { from: 'constant', value: '大批量' } }, - ], - }, - nudge: { deadline: 'PT30M', steps: [{ after: 'PT20M', action: 'nudge' }] }, - layout: { row: 0, col: 6 }, - }, - activityNode('A6', 'A6 报价议价', 'quote-negotiate', 'BD', 'terminate', 7), - // G4 议价结果 = 异或网关:成交 / 有异议(回边 A4)/ 拒绝超时 - { id: 'G4', kind: 'gateway', label: 'G4 议价结果', gateway: 'exclusive', mode: 'fork', layout: { row: 0, col: 8 } }, - activityNode('A7', 'A7 情报归档', 'quote-archive', 'BD', 'terminate', 9), - { id: 'end', kind: 'end', label: '归档闭环', layout: { row: 0, col: 10 } }, + activityNode('A1', '客户RFQ受理', 'quote-intake', 'BD', 'terminate', 1), + { id: 'end', kind: 'end', label: '受理闭环', layout: { row: 0, col: 2 } }, ], edges: [ { @@ -274,107 +245,7 @@ export async function seedQuote(container: Container): Promise { ], reopen: false, }, - { id: 'e-a1-g2', from: 'A1', to: 'G2', mapping: [], label: '统一受理记录', reopen: false }, - - // 三道并行:TYPE-A 直通定价 / TYPE-B 寻源 / TYPE-C 选型 - // TYPE-A 直通:不经活动,直接进汇合 —— A4 因此只有一条并发入边,来源无歧义 - { id: 'e-g2-j', from: 'G2', to: 'G2J', label: 'TYPE-A 直通定价', mapping: [], reopen: false }, - { - id: 'e-g2-a3', - from: 'G2', - to: 'A3', - label: 'TYPE-B 转寻源', - mapping: [ - { target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }, - { target: 'scenario', source: { from: 'constant', value: 'BOM后期' } }, - ], - reopen: false, - }, - { - id: 'e-g2-a2', - from: 'G2', - to: 'A2', - label: 'TYPE-C 转选型', - mapping: [{ target: 'originalPn', source: { from: 'output-port', port: 'intake', path: 'pn' } }], - reopen: false, - }, - - // 汇合:容忍分支的输出**不进 A4 的必填映射**(静态校验会拦) - { id: 'e-a3-j', from: 'A3', to: 'G2J', mapping: [], reopen: false }, - { id: 'e-a2-j', from: 'A2', to: 'G2J', mapping: [], reopen: false }, - { - id: 'e-j-a4', - from: 'G2J', - to: 'A4', - mapping: [ - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - // records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行 - { target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } }, - // 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条 - { target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }, - // P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值; - // 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档, - // P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。 - { target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } }, - { target: 'customerGrade', source: { from: 'constant', value: '普通客户' } }, - { target: 'volumeTier', source: { from: 'constant', value: '大批量' } }, - ], - reopen: false, - }, - - { id: 'e-a4-ap', from: 'A4', to: 'AP', mapping: [], label: '报价草案提审', reopen: false }, - { - id: 'e-ap-a6', - from: 'AP', - to: 'A6', - label: '通过', - mapping: [ - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - { target: 'items', source: { from: 'output-port', port: 'draft', path: 'items' } }, - ], - reopen: false, - }, - - { id: 'e-a6-g4', from: 'A6', to: 'G4', mapping: [], reopen: false }, - { id: 'e-g4-end', from: 'G4', to: 'end', condition: 'outcome = "won"', label: '成交 → 转订单履约', reopen: false }, - // 已裁定 ④:议价回环 = **流程层回边**,人裁决后重开新任务实例 - { - id: 'e-g4-a4', - from: 'G4', - to: 'A4', - condition: 'outcome = "revise"', - label: '有异议 → 返回调整', - mapping: [ - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - // records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行 - { target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } }, - // 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条 - { target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }, - // P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值; - // 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档, - // P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。 - { target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } }, - { target: 'customerGrade', source: { from: 'constant', value: '普通客户' } }, - { target: 'volumeTier', source: { from: 'constant', value: '大批量' } }, - ], - reopen: true, - }, - { - id: 'e-g4-a7', - from: 'G4', - to: 'A7', - condition: 'outcome = "lost"', - label: '拒绝/超时 → 情报归档', - mapping: [ - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - { target: 'quoteNo', source: { from: 'initiation', path: 'rfqRef' } }, - // 归档写的是 (pn, quoted_at) 这一行 —— 两个主键都得传到,它自己造不出来 - { target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }, - { target: 'quotedAt', source: { from: 'initiation', path: 'quotedAt' } }, - ], - reopen: false, - }, - { id: 'e-a7-end', from: 'A7', to: 'end', mapping: [], reopen: false }, + { id: 'e-a1-end', from: 'A1', to: 'end', mapping: [], label: '统一受理记录', reopen: false }, ], variables: [], }); @@ -385,7 +256,7 @@ export async function seedQuote(container: Container): Promise { log(' 闭合校验未过,不得发布:' + report.violations.map((v) => v.message).join('; ')); } else { await container.process.publish(pkg.id, pkg.version); - log('④ 流程包「统一报价」v1 已发布 —— 7 节点 + 3 网关 + 1 审批点,边映射闭合校验通过'); + log(`④ 流程包「统一报价」v${pkg.version} 已发布 —— ${pkg.nodes.length} 节点,边映射闭合校验通过`); } /* 事件触发器「客户RFQ询价」 */ @@ -409,7 +280,13 @@ export async function seedQuote(container: Container): Promise { */ { target: 'quotedAt', source: { from: 'expression', sfeel: 'occurredAt' } }, ], - spec: { kind: 'event', rule: 'contains(subject, "询价") or contains(subject, "RFQ")', eventType: 'data' }, + // 按**通道**收口:只有投进「公司询价邮箱」的才自动起单; + // 同一封信投到「询价待确认邮箱」不命中,落人工确认那条路 + spec: { + kind: 'event', + rule: 'channelId = "ch-rfq-mail" and (contains(subject, "询价") or contains(subject, "RFQ"))', + eventType: 'data', + }, }); await container.trigger.upsert(trigger); log(' 事件触发器「客户RFQ询价」已配置'); @@ -433,4 +310,43 @@ export async function seedQuote(container: Container): Promise { addressWindowMs: 7 * 86_400_000, }); log(' 邮件通道实例「公司询价邮箱」已配置(默认认领岗位:OP)'); + + /* + * ⑥ 人工确认通道 + 登记触发器 —— 三类触发里的**登记触发**(OS 3.1)。 + * + * 同一封 RFQ,两条起单路径: + * · 走「公司询价邮箱」→ 事件触发器命中 → **自动起单**; + * · 走「询价待确认邮箱」→ 无触发器命中 → 按「宁交人,不错投」落**消息兜底**到 + * 默认认领岗位(PM)→ 由人确认后**登记起单**,「登记人即来源」。 + * 后者是给需要人过一道目的场景用的:同样的信,起不起单由人定。 + */ + const pm = positions.find((p) => p.code === 'PM'); + await container.channel.upsert({ + id: 'ch-rfq-review', + name: '询价待确认邮箱', + kind: 'mail', + address: 'rfq-review@we.com', + enabled: true, + // 归属键规则与正式通道一致:识别照做,只是不自动起单 + extract: [ + { key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' }, + { key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' }, + ], + mapping: [], + ...(pm === undefined ? {} : { fallbackPosition: pm.id }), + addressWindowMs: 7 * 86_400_000, + }); + + await container.trigger.upsert( + triggerConfigSchema.parse({ + id: 'trg-rfq-manual', + name: '人工登记起单(统一报价)', + enabled: true, + processPackageId: 'unified-quote', + // 登记触发以**登记表单**为输入,不做字段映射:表单由调用接口 schema 自动渲染 + mapping: [], + spec: { kind: 'registration' }, + }), + ); + log(' 人工确认通道「询价待确认邮箱」+ 登记触发器已配置(默认认领岗位:PM)'); } diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index a3289ab..b81f74b 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -4,12 +4,14 @@ */ import { Hono } from 'hono'; import type { ApiError } from '@contracts'; -import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org'; +import { healthRoute, ingestMailRoute, loginRoute } from '@contracts'; import type { Container } from './container.ts'; import type { AppEnv } from './http-env.ts'; +import { readSessionToken } from './session-cookie.ts'; import { registerDeskRoutes } from './routes/desk.ts'; import { registerDeskElementRoutes } from './routes/desk-elements.ts'; import { registerDeskApprovalRoutes } from './routes/desk-approval.ts'; +import { registerRegistrationRoutes } from './routes/registration.ts'; import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts'; import { registerGovernanceRoutes } from './routes/governance.ts'; import { registerIngestRoutes } from './routes/ingest.ts'; @@ -18,15 +20,40 @@ import { registerWorkbenchRoutes } from './routes/workbench.ts'; export type { AppEnv }; +/** + * 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。 + * 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。 + */ +const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':')); + +function isPublic(method: string, path: string): boolean { + if (method === 'GET' && path === healthRoute.path) return true; + if (method === 'POST' && path === loginRoute.path) return true; + if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true; + return false; +} + export function createServer(container: Container): Hono { const app = new Hono(); - // 单组织 cookie session(编码规范 §4 鉴权定调)—— MVP 单登录者,cookie 缺省即种子登录者 + /** + * 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-010)。 + * + * 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来, + * 冒充不了任何人 —— 这正是明文 `jcai_account` cookie 被废止的理由。 + */ app.use('*', async (c, next) => { - const cookie = c.req.header('cookie') ?? ''; - const match = /(?:^|;\s*)jcai_account=([^;]+)/.exec(cookie); - c.set('account', match?.[1] ?? DEFAULT_LOGIN_ACCOUNT); + const token = readSessionToken(c.req.header('cookie')); + const session = token === undefined ? undefined : await container.org.resolveSession(token); + c.set('session', session); + c.set('account', session?.staff.account ?? ''); + + if (session === undefined && !isPublic(c.req.method, c.req.path)) { + const body: ApiError = { code: 'unauthenticated', message: '尚未登录' }; + return c.json(body, 401); + } await next(); + return undefined; }); app.onError((error, c) => { @@ -48,6 +75,7 @@ export function createServer(container: Container): Hono { registerDeskRoutes(app, container); registerDeskElementRoutes(app, container); registerDeskApprovalRoutes(app, container); + registerRegistrationRoutes(app, container); registerGovernanceOpsRoutes(app, container); registerIngestRoutes(app, container); registerStreamRoute(app, container.broadcaster); diff --git a/apps/api/src/app/session-cookie.ts b/apps/api/src/app/session-cookie.ts new file mode 100644 index 0000000..2bb5d27 --- /dev/null +++ b/apps/api/src/app/session-cookie.ts @@ -0,0 +1,39 @@ +/** + * 会话 cookie 的读写(ADR-010)—— 只此一处拼 `Set-Cookie`,属性不许各写各的。 + * + * `HttpOnly`:脚本读不到 token,XSS 拿不走会话。 + * `SameSite=Lax`:跨站表单 POST 不带 cookie,挡掉 CSRF 的常见形态; + * 同站导航仍带,登录后跳转不受影响。 + * 开发期走 http://localhost,故 `Secure` 随部署环境开关(生产必开)。 + */ +import type { Context } from 'hono'; +import type { AppEnv } from './http-env.ts'; + +export const SESSION_COOKIE = 'jcai_session'; + +/** 生产环境必带 Secure;开发期 localhost 是 http,带了浏览器就不存 */ +function secureAttr(): string { + return process.env['NODE_ENV'] === 'production' ? '; Secure' : ''; +} + +export function readSessionToken(cookieHeader: string | undefined): string | undefined { + if (cookieHeader === undefined) return undefined; + const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookieHeader); + return match?.[1]; +} + +export function setSessionCookie(c: Context, token: string, expiresAt: Date): void { + c.header( + 'Set-Cookie', + `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Expires=${expiresAt.toUTCString()}`, + { append: true }, + ); +} + +export function clearSessionCookie(c: Context): void { + c.header( + 'Set-Cookie', + `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`, + { append: true }, + ); +} diff --git a/apps/api/src/app/set-password.ts b/apps/api/src/app/set-password.ts new file mode 100644 index 0000000..e321231 --- /dev/null +++ b/apps/api/src/app/set-password.ts @@ -0,0 +1,31 @@ +/** + * 改密 CLI(ADR-010)—— 治理台的改密界面还没做,先给运维一条路。 + * + * pnpm --filter @app/api exec node --experimental-strip-types src/app/set-password.ts <账号> <新口令> + * + * 口令从**命令行参数**取,不写进任何文件;落库前即散列,明文只在进程内存里停留一瞬。 + */ +import { loadConfig } from './config.ts'; +import { createContainer } from './container.ts'; + +export async function setPassword(account: string, password: string): Promise { + const container = await createContainer(loadConfig()); + try { + const staff = await container.org.findByAccount(account); + if (staff === undefined) throw new Error(`账号不存在:${account}`); + await container.org.setPassword(staff.id, password); + process.stdout.write(`已改密:${staff.name}(${account})\n`); + } finally { + await container.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [account, password] = process.argv.slice(2); + if (account === undefined || password === undefined) { + process.stderr.write('用法:set-password.ts <账号> <新口令>\n'); + process.exit(1); + } + await setPassword(account, password); + process.exit(0); +} diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index e798e89..3082fb6 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -31,6 +31,7 @@ import type { TriggerService } from '@modules/trigger'; import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel'; import type { StaffId, TaskId } from '@contracts'; +import { staffIdSchema } from '@contracts'; import { createAgentRunner } from '../agent-runner.ts'; import type { Clock, IdGenerator } from '../runtime.ts'; @@ -656,6 +657,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { ); return withLoad; }, + // 起单时按来源人取其工作组(ADR-011);没建组即 undefined,全走四策略 + workGroupBinding: async (staffId) => deps.org.bindingOf(staffIdSchema.parse(staffId)), + isActive: async (staffId) => { + const staff = await deps.org.getStaff(staffIdSchema.parse(staffId)).catch(() => undefined); + return staff?.state === 'active'; + }, }, tasks: { start: async (input) => @@ -732,7 +739,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { process: { start: async (input) => process.start(input), requiredInputs: async (packageId) => { - const pkg = await process.getPackage(packageId); + // 与起单取版同口径:读**最新已发布**的调用接口 —— 草稿的 schema 不该影响装配判定 + const all = await process.listPackages(); + const pkg = all + .filter((x) => x.id === packageId && x.state === 'published') + .sort((a, b) => b.version - a.version)[0]; const input = pkg?.callInterface.input as { required?: string[] } | undefined; return input?.required ?? []; }, diff --git a/apps/api/src/modules/channel/internal/usecase/channel-service.ts b/apps/api/src/modules/channel/internal/usecase/channel-service.ts index 3cd44cc..f49b053 100644 --- a/apps/api/src/modules/channel/internal/usecase/channel-service.ts +++ b/apps/api/src/modules/channel/internal/usecase/channel-service.ts @@ -81,7 +81,16 @@ export function createChannelService(deps: ChannelServiceDeps): ChannelService { * 载荷带**原文**:信封只带原件指针够不够,取决于下游要不要读内容 —— * 归一步要拆解的正是这段文本,只给一个 .eml 指针它无从下手。 */ - payload: { data: { ...extracted, subject: mail.subject, body: mail.body, from: mail.from }, originRef }, + /* + * 载荷带**原文**与**通道标识**: + * 原文是归一步要拆解的对象;通道标识让触发器能按通道收口 —— + * 不带它,规则就只能按主题匹配,一封信投到哪个信箱都照样自动起单, + * 「换个信箱走人工确认」这种配置根本无从表达。 + */ + payload: { + data: { ...extracted, subject: mail.subject, body: mail.body, from: mail.from, channelId: config.id }, + originRef, + }, occurredAt: receivedAt.toISOString(), source: { kind: 'channel', detail: config.name }, }, diff --git a/apps/api/src/modules/event/__tests__/delivery.test.ts b/apps/api/src/modules/event/__tests__/delivery.test.ts index 605a4a9..9184a1b 100644 --- a/apps/api/src/modules/event/__tests__/delivery.test.ts +++ b/apps/api/src/modules/event/__tests__/delivery.test.ts @@ -27,6 +27,7 @@ describe('planConsumption —— 三级消费', () => { listenerHits: [{ taskId: 't1' }], triggerHits: ['tr1'], ownedTaskId: 't1', + hasOwner: true, }); expect(plan.taskTargets).toEqual(['t1']); expect(plan.triggerTargets).toEqual(['tr1']); @@ -34,24 +35,42 @@ describe('planConsumption —— 三级消费', () => { }); it('皆未命中且无主 → 消息兜底', () => { - const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined }); + const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false }); expect(plan.messageFallback).toBe(true); }); it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => { - const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7' }); + const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true }); expect(plan.messageFallback).toBe(false); expect(plan.recordOnlyTask).toBe('t7'); expect(plan.taskTargets).toEqual([]); }); + it('主不是任务也算有主 —— 引擎发的工作流事件不得被当成无主件塞进收件箱', () => { + // 起单 / 解析负责人 / 起任务这些事件的 target 是 workflow,不是 task。 + // 只认 ownedTaskId 的话,每起一单都会给人推一条「无主入站待认领」。 + const plan = planConsumption({ + listenerHits: [], + triggerHits: [], + ownedTaskId: undefined, + hasOwner: true, + }); + expect(plan.messageFallback).toBe(false); + expect(plan.recordOnlyTask).toBeUndefined(); + }); + it('有主且监听命中 → 不是仅记录', () => { - const plan = planConsumption({ listenerHits: [{ taskId: 't7' }], triggerHits: [], ownedTaskId: 't7' }); + const plan = planConsumption({ + listenerHits: [{ taskId: 't7' }], + triggerHits: [], + ownedTaskId: 't7', + hasOwner: true, + }); expect(plan.recordOnlyTask).toBeUndefined(); }); it('无主但触发命中 → 不兜底', () => { - const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined }); + const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined, hasOwner: false }); expect(plan.messageFallback).toBe(false); }); }); diff --git a/apps/api/src/modules/event/internal/domain/delivery.ts b/apps/api/src/modules/event/internal/domain/delivery.ts index 6ced4f4..70f41be 100644 --- a/apps/api/src/modules/event/internal/domain/delivery.ts +++ b/apps/api/src/modules/event/internal/domain/delivery.ts @@ -58,6 +58,14 @@ export function planConsumption(input: { readonly listenerHits: ReadonlyArray<{ taskId: string }>; readonly triggerHits: readonly string[]; readonly ownedTaskId: string | undefined; + /** + * 信封是否**有主** —— 关联里指了目标就算有主,不限于任务。 + * + * 不能拿「有没有 ownedTaskId」当有主:引擎自己发的工作流事件 + * (`target: { kind: 'workflow' }`)照样是有主的,只是主不是任务。 + * 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。 + */ + readonly hasOwner: boolean; }): ConsumptionPlan { const taskTargets = fanOutTargets(input.listenerHits); const triggerTargets = [...new Set(input.triggerHits)]; @@ -65,8 +73,8 @@ export function planConsumption(input: { return { taskTargets, triggerTargets, - // 有主但零监听命中 → 仅记录,不兜底 - messageFallback: nothingHit && input.ownedTaskId === undefined, + // 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3) + messageFallback: nothingHit && !input.hasOwner, recordOnlyTask: input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId) ? input.ownedTaskId diff --git a/apps/api/src/modules/event/internal/usecase/event-service.ts b/apps/api/src/modules/event/internal/usecase/event-service.ts index 428905a..d0bc75f 100644 --- a/apps/api/src/modules/event/internal/usecase/event-service.ts +++ b/apps/api/src/modules/event/internal/usecase/event-service.ts @@ -46,7 +46,12 @@ export function createEventService(deps: EventServiceDeps): EventService { const ownedTaskId = envelope.correlation.target?.kind === 'task' ? String(envelope.correlation.target.id) : undefined; - const plan = planConsumption({ listenerHits: hits, triggerHits, ownedTaskId }); + const plan = planConsumption({ + listenerHits: hits, + triggerHits, + ownedTaskId, + hasOwner: envelope.correlation.target !== undefined, + }); // 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标 const deliveries: Delivery[] = [ diff --git a/apps/api/src/modules/human-tool/CONTRACT.md b/apps/api/src/modules/human-tool/CONTRACT.md index f3b8b1d..c66a277 100644 --- a/apps/api/src/modules/human-tool/CONTRACT.md +++ b/apps/api/src/modules/human-tool/CONTRACT.md @@ -11,6 +11,7 @@ - 错误:`BareCallError` `ReplyRejectedError` `HumanCallBudgetExceededError` - 端口类型:`CardStore` `MessageOutbox` `MemoryRecorder` `HumanEventPublisher` `Clock` `IdGenerator` - 用例工厂:`createHumanToolService(deps): HumanToolService` + (读侧两支:`openWorkOrders` 只给在途,`allWorkOrders` 含已办结 —— 工作成果要回看定稿报告) - 纯函数:`checkWorkOrder` `checkChatCard` `checkWorkOrderReply` `checkChatCardReply` `arrivalOf` `producesCompletion` `allCriteriaGreen` - adapter:`createCardStore`;Drizzle 表:`workOrderTable` `chatCardTable` diff --git a/apps/api/src/modules/human-tool/contract.ts b/apps/api/src/modules/human-tool/contract.ts index 4694424..40c9642 100644 --- a/apps/api/src/modules/human-tool/contract.ts +++ b/apps/api/src/modules/human-tool/contract.ts @@ -115,6 +115,8 @@ export type HumanToolService = { rejectAndReask(cardId: string, reason: string): Promise; openWorkOrders(taskId: TaskId): Promise; + /** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */ + allWorkOrders(taskId: TaskId): Promise; chatCards(taskId: TaskId): Promise; /** org 停用前置检查要问的「待答卡」 */ openCardsOf(staffId: StaffId): Promise>; diff --git a/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts index ee017d1..e1fab5d 100644 --- a/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts +++ b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts @@ -243,6 +243,10 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer return deps.cards.listWorkOrders(taskId, true); }, + async allWorkOrders(taskId) { + return deps.cards.listWorkOrders(taskId, false); + }, + async chatCards(taskId) { return deps.cards.listChatCards(taskId); }, diff --git a/apps/api/src/modules/interaction/CONTRACT.md b/apps/api/src/modules/interaction/CONTRACT.md index eb44d14..cbee73e 100644 --- a/apps/api/src/modules/interaction/CONTRACT.md +++ b/apps/api/src/modules/interaction/CONTRACT.md @@ -10,6 +10,7 @@ - 端口类型:`InteractionStore` `HumanEventPublisher` `MemoryRecorder` `Audience` `Clock` `IdGenerator` - 用例工厂:`createInteractionService(deps): InteractionService` - 纯函数:`outputControl` `inputControl` `handoverHappens` `checkTemplate` `templateIsSafe` + `projectChat`(任务记忆 → 对话消息的筛选投影) - adapter:`createInteractionStore`;Drizzle 表:`chatMessageTable` `taskViewTable` 层级: L3(编码规范 §4) @@ -24,4 +25,6 @@ 转交随引擎事件自动发生,活动不感知交接 - **视图区无脚本执行权**:含 ` + \ No newline at end of file diff --git a/docs/adr/ADR-010-auth-session.md b/docs/adr/ADR-010-auth-session.md new file mode 100644 index 0000000..f2480d8 --- /dev/null +++ b/docs/adr/ADR-010-auth-session.md @@ -0,0 +1,80 @@ +# ADR-010 鉴权会话方案 + +状态:已接受(2026-07-27) +背景出处:编码规范 §4「鉴权定调:单组织 cookie session —— 登录、会话与岗位权限校验中间件在 +apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制」; +§12 留白清单「鉴权会话与凭证加密方案 —— 实现期再定,各建 ADR 后方可动工」。 + +## 问题 + +在此之前系统**没有登录、也没有登出**。中间件只做一件事: + +```ts +const match = /(?:^|;\s*)jcai_account=([^;]+)/.exec(cookie); +c.set('account', match?.[1] ?? DEFAULT_LOGIN_ACCOUNT); +``` + +三个后果,前两个是缺功能,第三个是安全缺陷: + +1. 没有登录口 —— 谁打开页面都是种子登录者冯兵,无法验多人协作 + (A1 冯兵 → A3 林佩萱 → AP 丁子乔 的流转根本走不通) +2. 没有登出口 —— cookie 从未被写入,也就无从清除 +3. **身份可任意伪造** —— cookie 是明文账号,浏览器里改一个字符即可冒充任何人, + 包括审批人。「驳回必附意见」「完成只出自放行通过」这类规矩全部形同虚设: + 规矩管的是**这个人**做了什么,而这个人是谁可以随手改写 + +## 决策 + +### 一、凭据:口令 + scrypt + +`staff` 增列 `password_hash`。散列用 Node 内置 `crypto.scrypt`(N=16384, r=8, p=1), +格式 `scrypt$N$r$p$$`,比对走 `timingSafeEqual`。 + +**不引第三方散列库**:scrypt 是 Node 标准库自带的内存硬散列,参数与格式自描述, +零依赖即可满足 ≤50 人单组织的口令存储要求。 + +种子口令取自环境变量 `JCAI_SEED_PASSWORD`,缺省 `jcai-dev-2026`,播种时在 stdout 明示。 +**源码里不写任何真实口令**。 + +### 二、会话:服务端会话表 + 不透明 token + +新表 `sys.session`:`id`(即 token,32 字节随机 base64url)、`staff_id`、 +`issued_at`、`expires_at`、`last_seen_at`。cookie `jcai_session`, +`HttpOnly; SameSite=Lax; Path=/`,有效期 12 小时,每次命中滑动续期。 + +**为什么不用签名 cookie(HMAC 自包含)**:登出必须**立刻**失效。签名 cookie 在到期前 +无法作废,除非另建吊销表 —— 那还不如直接用会话表。会话表还顺带解决另一件事: +`org` 停用员工时可以一并踢掉他的在途会话,否则被停用的人凭旧 cookie 能继续办事。 + +### 三、归属:会话归 `org`,中间件归 app + +会话是**身份**的一部分,而 `org` 已经拥有 staff / position / grant 与 `loadSession`。 +所以 `login` / `logout` / `resolveSession` / `setPassword` 落 `org` 出口; +app 层只放中间件与两条路由 —— 与 §4「中间件在 app 层实现,权限判定读 `org` 出口」一致。 + +### 四、免鉴权白名单 + +只三条,其余一律 401: + +| 路由 | 理由 | +|---|---| +| `GET /api/health` | 存活探针 | +| `POST /api/login` | 登录本身 | +| `POST /api/ingest/mail/:channelId` | **外界边界**:代替真实 IMAP 拉件,不是人的请求 | + +`/api/stream`(SSE)此前完全无鉴权,现纳入鉴权 —— 它推的是「哪个目标动了」, +虽不含业务载荷,但目标 ID 本身即信息。 + +### 五、明文 `jcai_account` cookie 立即废止,不走三步弃用 + +三步弃用是给**契约变更**留的缓冲期。这一条是安全缺陷修复:保留兼容期 +等于保留一条「改 cookie 即可冒充审批人」的旁路。同理,`DEFAULT_LOGIN_ACCOUNT` +不再作为请求身份的兜底,只保留其种子语义(播种时谁是第一个员工)。 + +## 后果 + +- 前端必须有登录页:`/api/session` 返回 401 即落登录页,登录成功后进原台面 +- 开发期用 curl 打接口需要先 `POST /api/login` 拿 cookie,不能再靠缺省身份 +- 口令重置目前只在种子期给出;治理台的「员工」面后续可加重置动作(未做,见 debt) +- 未做:失败次数限制 / 锁定、口令强度校验、首登强制改密。单组织 ≤50 人、 + 部署在内网的前提下先不做,列入 `docs/debt.md` diff --git a/docs/adr/ADR-011-work-group.md b/docs/adr/ADR-011-work-group.md new file mode 100644 index 0000000..55183eb --- /dev/null +++ b/docs/adr/ADR-011-work-group.md @@ -0,0 +1,66 @@ +# ADR-011 工作组:起单时的岗位绑定 + +状态:已接受(2026-07-27) +背景出处:OS 03「编排声明岗位,**起单时确定性解析**。策略封闭四选; +默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,结果与依据留痕」。 + +## 问题 + +流程包声明的是**岗位**(`owner.position`),谁来做由四策略在节点起单时算。 +这对生产是对的,对两类场景却不够: + +1. **一单一队**:同一条工作流的各节点,业务上常常要固定给同一拨人 + (客户 A 的报价,前后都归这个商务、这个采购)。`follow-source` 只能固定 + *同一岗位*的人,跨岗位的搭档关系表达不了。 +2. **测试与演练**:要让一条流的所有节点都落到同一个人身上,好一个人从头走到尾。 + 现状做不到 —— 每个岗位都会解析到该岗位上的人。 + +## 决策 + +引入**工作组**:一组「岗位 → 员工」的绑定,起单时装载,随这条工作流走。 + +### 一、绑定先于策略,但不是第五种策略 + +OS 03 说「策略封闭四选」,这条不动。工作组不是策略,是**岗位的人选已经定了** —— +四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,于是策略无从发问。 +解析顺序因此是: + +``` +工作组给了这个岗位的人 且 此人在岗 → 用他(by: 'work-group') +否则 → 四策略照常(by: 四选之一) +``` + +工作组给的人**已离岗**时不硬用,退回四策略 —— 绑定不该把单派给一个不在岗的人。 + +### 二、快照装载,不引用 + +起单时把成员表**拷进工作流记录**,之后改组不影响已起的单。 +理由与版本信封一致(AM 9.1):留痕要能解释「当时为什么派给他」, +而不是被今天的组织调整改写。 + +### 3、按来源人装载 + +「凡单必有来源」(OS 3.1)。起单时取 `source.by`(登记人 / 触发来源人)的工作组: + +| 来源 | 装载 | +|---|---| +| 登记触发(人工起单) | 登记人的工作组 | +| 事件 / 周期触发,带来源人 | 该人的工作组 | +| 无来源人 | **不装载** —— 全走四策略 | + +没有工作组的员工起单也不装载。**向后兼容**:此前起的单、以及所有没建组的人, +行为与从前完全一致。 + +### 四、归属 `org` + +工作组是「人和岗位」的事,`org`(L1)已经拥有 staff / position / grant。 +`process` 经既有的 `OrgGateway` 端口多问一句 `workGroupOf(staffId)`,不新增依赖方向。 + +## 后果 + +- `WorkflowRecord` 增 `workGroup?: Record<岗位 code, 员工 id>`(快照,可空) +- `Resolution.by` 从 `OwnerStrategy` 放宽到 `OwnerStrategy | 'work-group'` —— + 留痕里能一眼看出这单是绑定派的还是策略派的 +- 一人一组即可造出「整条流都归我」的测试组:所有岗位都指向同一人 +- 未做:工作组的治理台管理界面(建组 / 改组 / 指派默认组)。 + 目前只能经播种或直接调用例创建,列入 `docs/debt.md` diff --git a/docs/debt.md b/docs/debt.md index 88e1974..a063f8e 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -15,3 +15,41 @@ | M5-2 | loop 的 Agent 回路直接经 `ModelGateway`,未用 Mastra `Agent` | 编码规范 §6.1 规定用 Mastra Agent;`AgentPort` 端口不变,换实现时 usecase 不动;当前实现仍过两缝(录制回放可见) | M6 前 | 进行中 | | M5-1 | `process` 未用 Mastra workflow 编译器,流转由自持确定性状态机 + `sys.node_instance` 承担 | 编码规范 §6.1 规定流程包编译为 Mastra 图;二者语义等价(挂起/恢复由节点实例表承担),先落可测的自持实现,Mastra 编译器作为可替换 adapter 后补 —— usecase 不动 | M5 收尾 | 进行中 | | M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 | + +## 鉴权(ADR-010 落地后的已知欠账) + +- **登录失败无次数限制**:可无限次试口令。单组织 ≤50 人、内网部署下暂缓; + 接入外网前必须补(按账号 + 来源 IP 双维度退避)。 +- **无口令强度校验、无首登强制改密**:全员初始口令相同(`JCAI_SEED_PASSWORD`), + 谁都没改过就等于没有口令。治理台「员工」面需补:改密 / 重置 / 强制改密标记。 +- **`setPassword` 已在 org 出口就位但无路由**:改密只能经播种或直接调用例,界面上没有入口。 +- **会话无「登出全部设备」**:`revokeAllOf` 已实现(停用员工时调),但本人无从触发。 + +## 工作组(ADR-011 落地后的已知欠账) + +- **没有治理台管理界面**:建组 / 改组 / 指派默认组只能经播种或直接调用例。 + `saveWorkGroup` 已在 org 出口就位,缺的是「员工」面下的一个子页。 +- **一人一组**:`workGroupOf` 取该人名下稳定的第一条;起单时选哪个组这一步没做。 +- **组成员离岗无提醒**:绑定的人离岗时静默退回四策略,不告诉任何人。 + +## 工作成果 · 文件 + +- **Markdown 只以原文纸面呈现**:富文本渲染(GFM 表格 / 内联 SVG)要 `react-markdown` + + `remark-gfm` + `rehype-raw` 三个依赖,当前成果文件绝大多数是 HTML,先不引。 + 引入前不假装渲染过 —— FileViewer 里以 `
` 出原文。
+- **过程成果(deliverable)尚无来源**:活动还不发布 deliverable,所以成果文件目前
+  只有活动推的那一屏。装配已接好(`desk.ts` 的 `results`),活动一产出就自动进列表。
+
+## 沟通记录(占位)
+
+工作区第二个页签「沟通记录」目前是空占位。它要汇集这一单**与外界的往来**:
+邮件线程、通道消息、原件存档 —— 数据在 `channel` 与 `event` 里已有,尚未做投影。
+界面上明说「还没接」,不伪装成「暂无数据」。
+
+## 工作区页签条装不下五个页签
+
+五个四字页签 380px + 翻页囊 112px + 间距 30px = 522px,工作区仅 429px,差 94px。
+当前先做成**整条可横滚**(不裁、不溢出卡片),最终形态待定,备选:
+- 缩短页签文字(沟通 / 成果 / 详情 / 日志)—— 省 ~100px,但文案变短
+- 翻页囊只留箭头、页码收进 title —— 省 ~50px,但看不见「第几件/共几件」
+- 页签条换行两排 —— 全都放得下,但工作区正文少一行高
diff --git a/docs/superpowers/plans/2026-07-27-three-surfaces.md b/docs/superpowers/plans/2026-07-27-three-surfaces.md
new file mode 100644
index 0000000..7b7c857
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-27-three-surfaces.md
@@ -0,0 +1,819 @@
+# 三面一体(设计中心 / 运营中心 / 工作台)实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 按 `docs/产品设计.md` 把治理中心拆成设计中心(`/design`)与运营中心(`/ops`)两个台面,实现到**编排层**为止 —— 壳、两张首页、触发器归位、台账动态位、工作组管理;活动内部(活动开发、契约深化)不在本计划内。
+
+**Architecture:** 纯前端信息架构重排 + 三处小后端增量(触发器 upsert 路由、度量路由补齐八指标、工作组读写路由 —— 全部是既有模块用例的 HTTP 面,零模块语义改动)。壳层由一份 surface 配置驱动:`surfaces.ts` 定静态区表与权限亮灯,`gov-shell` 参数化成两壳,台账导航项按已发布数据域运行时生成。
+
+**Tech Stack:** React 19 + Vite + TanStack Query(前端零过程状态,服务端投影);Hono + zod 契约(`packages/contracts/src/api/governance.ts`);vitest(纯逻辑单测);皮肤复用 `console.css` 的 `g-*` 类,**不新造类名**。
+
+## Global Constraints
+
+- 五门必须全绿才算一个任务完成:`pnpm typecheck && pnpm lint && pnpm depcruise && pnpm api:check && pnpm test`(api:check 报契约变更时:同步 CONTRACT.md → `pnpm api:check --update` → `git add api-report`)。
+- 编码规范 §12.5 禁用:`any`、`as unknown as`、`@ts-ignore`、domain 层 IO、全局可变状态、删测试、改门禁配置。
+- `exactOptionalPropertyTypes: true`:显式赋 `undefined` 的可选属性要写成 `field?: T | undefined`。
+- ESM 相对导入必须带扩展名(`./x.ts` / `./x.tsx`);web 别名:`@contracts` `@web-shared` `@features` `@ui`。
+- 不新增任何 npm 依赖。
+- 治理台界面只用 `console.css` 已有的 `g-*` 类(自造类名 = 没样式);共享件从 `@web-shared/gov/primitives.tsx` 取(Chip/St/Note/Sec/Kpi/Card/Rows/NList/NRow/Entry/PageHead/Todo/Clear/QueryGate/Prop/Field/Seg/ListEdit/Acts)。
+- 空态文案用**用户视角**(「还没有…,会有什么」),不写「还没接」。
+- 授权模型不动:九治理功能 × 读/写/治;台面入口按「持有该面任一功能的任意授权」亮灯(动作蕴含 govern ⊃ write ⊃ read,有任意 grant 即至少可读)。
+- 提交信息:中文一句话,风格同 `git log`(如「三面拆分:…」),结尾加
+  `Co-Authored-By: Claude Fable 5 ` 与 `Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m`。
+- 服务地址:API `:8787`(`pnpm --filter @app/api dev`),Web `:5273`(`cd apps/web && npx vite --port 5273 --strictPort`;5173 被别的项目占用)。测试账号 `Chaihao` / `ch19900614`。
+- 浏览器验证一律先 `POST /api/login` 拿 cookie(HttpOnly 会话;curl 用 `-c/-b /tmp/ch.cookie`)。
+
+---
+
+### Task 1: 基线入库
+
+工作区里有约 90 个未提交文件(鉴权、工作组、工作台修复、编排工作区、产品设计文档等)。**先整体入库一次**,否则后续每个任务的提交都会混入无关变更,评审无从做起。
+
+**Files:**
+- Modify: 无(纯 git 操作)
+
+**Interfaces:**
+- Produces: 干净的工作区 —— 后续任务的 `git add` 只含本任务文件。
+
+- [ ] **Step 1: 确认五门全绿(基线必须是好的)**
+
+Run: `pnpm typecheck && pnpm lint && pnpm depcruise && pnpm test`
+Expected: 全部通过(api:check 已在上一轮 `--update` 并 `git add`,若再报差异则先 `pnpm api:check --update && git add api-report`)。
+
+- [ ] **Step 2: 整体提交**
+
+```bash
+git add -A
+git commit -m "基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
+
+Co-Authored-By: Claude Fable 5 
+Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m"
+```
+
+- [ ] **Step 3: 确认干净**
+
+Run: `git status --porcelain | wc -l`
+Expected: `0`
+
+---
+
+### Task 2: surface 配置与纯逻辑(surfaces.ts)
+
+三面骨架的唯一事实源:区归属、权限亮灯、旧链接改写 —— 全部纯函数,先测后写。
+
+**Files:**
+- Create: `apps/web/src/shared/gov/surfaces.ts`
+- Create: `apps/web/src/shared/gov/__tests__/surfaces.test.ts`
+- Modify: `apps/web/src/shared/gov/gov-route.ts`(GovZone 增减 + TABS/ZM 相应条目)
+
+**Interfaces:**
+- Consumes: `gov-route.ts` 的 `GovZone`、`ZONES`、`TABS`、`ZM`;`@contracts` 的 `Session`、`GovernanceFunction`。
+- Produces(后续任务按名引用):
+  ```ts
+  export type Surface = 'design' | 'ops';
+  export const SURFACE_META: Readonly>;
+  export const SURFACE_ZONES: Readonly>;
+  export const SURFACE_FNS: Readonly>;
+  export function canSeeSurface(session: Session | undefined, surface: Surface): boolean;
+  export function surfaceOfZone(zone: GovZone): Surface;
+  /** 旧 #/gov//… 深链 → 新台面完整 URL(pathname+hash);非 gov 哈希返回 undefined */
+  export function legacyGovTarget(hash: string): string | undefined;
+  ```
+
+- [ ] **Step 1: 改 `gov-route.ts` 的区表(surfaces 的类型依赖它,先动它)**
+
+在 `gov-route.ts`:
+1. `GOV_ZONES` 改为:
+```ts
+export const GOV_ZONES = [
+  'blueprint', 'workflows', 'library', 'tools', 'data',        // 设计中心
+  'cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system', // 运营中心
+] as const;
+```
+(删 `'triggers'` 与 `'memory'` —— 触发器定义并入 workflows,处置并入 tasks;度量升驾驶舱,活动记忆归知识库。)
+2. `ZONES` 数组同步(label/icon):`blueprint 蓝图/route`、`cockpit 驾驶舱/gauge`、`ledgers 台账/database`、`knowledge 知识库/layers`;其余沿用;删 triggers/memory 两行。
+3. `ZM` 增四条、删两条(desc 从 `docs/产品设计.md` 二、三节的定位文字取;`ledgers` 的 desc:「行数据 · 版本 · 引用 —— 域的 schema 在设计中心改,这里做运营动作」)。
+4. `TABS` 增删:
+```ts
+blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
+cockpit: [{ key: 'dash', label: '驾驶舱', ico: 'gauge' }],
+knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }],
+ledgers: [],   // 动态:页签即数据域,由壳层运行时生成
+workflows: [ …原三条…, { key: 'rules', label: '调起规则', ico: 'zap' } ],
+tasks: [ …原四条…, { key: 'trg', label: '起单开关', ico: 'zap' } ],
+```
+(`TABS` 类型放宽为 `ReadonlyArray<…>` 可空数组即可,`readPlace` 对空 tabs 的兜底在 Task 4 处理。)
+
+- [ ] **Step 2: 写失败测试**
+
+`apps/web/src/shared/gov/__tests__/surfaces.test.ts`:
+```ts
+/**
+ * 三面骨架的唯一事实源 —— 区归属、亮灯、旧链接改写。
+ * 这里守的是「配置自洽」:每个区恰属一面;亮灯只看该面功能;旧深链一条不死。
+ */
+import { describe, expect, it } from 'vitest';
+import type { Session } from '@contracts';
+import { sessionSchema } from '@contracts';
+import { GOV_ZONES } from '../gov-route.ts';
+import { SURFACE_ZONES, canSeeSurface, legacyGovTarget } from '../surfaces.ts';
+
+/** 经 schema 构造合法会话 —— §12.5 禁 `as unknown as`,测试同样遵守 */
+const sessionWith = (fns: string[]): Session =>
+  sessionSchema.parse({
+    staff: { id: 's-1', account: 'a', name: 'n', positionId: 'p-1', state: 'active' },
+    position: { id: 'p-1', code: 'PM', name: '项目经理' },
+    grants: fns.map((fn) => ({
+      positionId: 'p-1',
+      object: { kind: 'governance', function: fn },
+      action: 'read',
+    })),
+  });
+
+describe('SURFACE_ZONES', () => {
+  it('每个区恰属一面,不重不漏', () => {
+    const all = [...SURFACE_ZONES.design, ...SURFACE_ZONES.ops];
+    expect([...all].sort()).toEqual([...GOV_ZONES].sort());
+    expect(new Set(all).size).toBe(all.length);
+  });
+  it('设计面没有任何运行态区', () => {
+    for (const z of ['tasks', 'cockpit', 'ledgers'] as const) {
+      expect(SURFACE_ZONES.design).not.toContain(z);
+    }
+  });
+});
+
+describe('canSeeSurface —— 入口按读权亮灯', () => {
+  it('持有该面任一功能的授权即亮', () => {
+    expect(canSeeSurface(sessionWith(['process']), 'design')).toBe(true);
+    expect(canSeeSurface(sessionWith(['task']), 'ops')).toBe(true);
+  });
+  it('只有另一面的授权不亮', () => {
+    expect(canSeeSurface(sessionWith(['task']), 'design')).toBe(false);
+  });
+  it('trigger 与 data 两面都算(定义归设计、处置与行数据归运营)', () => {
+    expect(canSeeSurface(sessionWith(['trigger']), 'design')).toBe(true);
+    expect(canSeeSurface(sessionWith(['trigger']), 'ops')).toBe(true);
+    expect(canSeeSurface(sessionWith(['data']), 'ops')).toBe(true);
+  });
+  it('无会话不亮', () => {
+    expect(canSeeSurface(undefined, 'design')).toBe(false);
+  });
+});
+
+describe('legacyGovTarget —— 旧 #/gov 深链一条不死', () => {
+  it('设计态区改写到 /design', () => {
+    expect(legacyGovTarget('#/gov/workflows/pkgs/pkg:unified-quote%404')).toBe(
+      '/design#/design/workflows/pkgs/pkg:unified-quote%404',
+    );
+  });
+  it('运行态区改写到 /ops', () => {
+    expect(legacyGovTarget('#/gov/tasks/board')).toBe('/ops#/ops/tasks/board');
+  });
+  it('已删的区落到对应新家:triggers→设计·workflows/rules,memory→运营·cockpit', () => {
+    expect(legacyGovTarget('#/gov/triggers/rules')).toBe('/design#/design/workflows/rules');
+    expect(legacyGovTarget('#/gov/memory/metrics')).toBe('/ops#/ops/cockpit/dash');
+    expect(legacyGovTarget('#/gov/memory/layers')).toBe('/ops#/ops/knowledge/layers');
+  });
+  it('裸 #/gov 落运营首页;非 gov 哈希不管', () => {
+    expect(legacyGovTarget('#/gov')).toBe('/ops#/ops/cockpit/dash');
+    expect(legacyGovTarget('#/whatever')).toBeUndefined();
+  });
+});
+```
+- [ ] **Step 3: 跑测试确认失败**
+
+Run: `pnpm exec vitest run apps/web/src/shared/gov 2>&1 | tail -5`
+Expected: FAIL(`surfaces.ts` 不存在 / gov-route 缺新区)。
+
+- [ ] **Step 4: 写实现 `surfaces.ts`**
+
+```ts
+/**
+ * 三面骨架的唯一事实源(docs/产品设计.md §1/§6)。
+ * 区归属、权限亮灯、旧链接改写都从这里出 —— 壳层只消费,不各写各的。
+ */
+import type { Session } from '@contracts';
+import type { GovZone } from './gov-route.ts';
+
+export type Surface = 'design' | 'ops';
+
+export const SURFACE_META: Readonly> = {
+  design: { path: '/design', brand: '设计中心', searchHint: '检索定义:流程包 / 活动 / 工具 / 数据域…' },
+  ops: { path: '/ops', brand: '运营中心', searchHint: '检索运行:任务 / 工作流 / 员工 / 台账…' },
+};
+
+export const SURFACE_ZONES: Readonly> = {
+  design: ['blueprint', 'workflows', 'library', 'tools', 'data'],
+  ops: ['cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system'],
+};
+
+/**
+ * 亮灯功能集:trigger 与 data 两面都算 ——
+ * 触发器「定义归设计、处置归运营」;数据「schema 归设计、行数据归运营」。
+ */
+export const SURFACE_FNS: Readonly> = {
+  design: ['process', 'activity', 'tool', 'data', 'trigger'],
+  ops: ['task', 'staff', 'memory', 'system', 'trigger', 'data'],
+};
+
+export function canSeeSurface(session: Session | undefined, surface: Surface): boolean {
+  if (session === undefined) return false;
+  // 动作蕴含 govern ⊃ write ⊃ read:持有任意动作的 grant 即至少可读,入口即亮
+  return session.grants.some(
+    (g) => g.object.kind === 'governance' && (SURFACE_FNS[surface] as readonly string[]).includes(g.object.function),
+  );
+}
+
+export function surfaceOfZone(zone: GovZone): Surface {
+  return (SURFACE_ZONES.design as readonly string[]).includes(zone) ? 'design' : 'ops';
+}
+
+/** 已删区的搬迁表:旧深链按内容归新家 */
+const LEGACY_ZONE: Readonly> = {
+  triggers: { surface: 'design', zone: 'workflows', tab: 'rules' },
+  memory: { surface: 'ops', zone: 'cockpit', tab: 'dash' },
+};
+
+export function legacyGovTarget(hash: string): string | undefined {
+  const parts = hash.replace(/^#\/?/, '').split('/').filter((s) => s !== '');
+  if (parts[0] !== 'gov') return undefined;
+  const zone = parts[1];
+  if (zone === undefined) return '/ops#/ops/cockpit/dash';
+
+  const moved = LEGACY_ZONE[zone];
+  if (moved !== undefined) {
+    // memory 的两个旧页签各归其位
+    const tab = zone === 'memory' && parts[2] === 'layers' ? undefined : moved.tab;
+    const target = zone === 'memory' && parts[2] === 'layers' ? { surface: 'ops' as const, zone: 'knowledge', tab: 'layers' } : { ...moved, tab };
+    return `${SURFACE_META[target.surface].path}#/${target.surface}/${target.zone}/${target.tab ?? ''}`.replace(/\/$/, '');
+  }
+
+  const surface = surfaceOfZone(zone as GovZone);
+  const rest = parts.slice(1).join('/');
+  return `${SURFACE_META[surface].path}#/${surface}/${rest}`;
+}
+```
+(实现时按测试逐条对齐 —— 特别是 `#/gov/memory/layers` 与 `#/gov/triggers/rules` 两条的期望输出。)
+
+- [ ] **Step 5: 跑测试确认通过**
+
+Run: `pnpm exec vitest run apps/web/src/shared/gov 2>&1 | tail -5`
+Expected: PASS(全部用例)。
+随后 `pnpm typecheck` —— gov-route 改动会把还引用 `triggers`/`memory` 区的旧代码打红(gov-shell / PkgPage 的 `nav.jump('triggers', …)` 等)。**本任务只修类型层面**:PkgPage 里 `nav.jump('triggers', 'rules', …)` 改为 `nav.jump('workflows', 'rules', …)`;gov-shell 的 TriggersHome/MemoryHome 引用暂时改挂到新区键(`knowledge`→MemoryHome、`cockpit`→MemoryHome,Task 5/8 再真正拆)。tsc 必须归零。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add apps/web/src/shared/gov/surfaces.ts apps/web/src/shared/gov/__tests__/surfaces.test.ts \
+        apps/web/src/shared/gov/gov-route.ts apps/web/src/features/gov-process/internal/panel.tsx \
+        apps/web/src/app/gov-shell.tsx
+git commit -m "三面拆分①:surface 配置与区表 —— 归属/亮灯/旧链改写皆纯函数先测后写"
+```
+(此后每个提交都带 Global Constraints 里的两行 footer,下略。)
+
+---
+
+### Task 3: 网页标签三章 + 入口路由(/design、/ops、/gov 跳转)
+
+**Files:**
+- Modify: `apps/web/src/app/tab-identity.ts`
+- Modify: `apps/web/src/app/main.tsx`
+
+**Interfaces:**
+- Consumes: `legacyGovTarget`、`SURFACE_META`(Task 2)。
+- Produces: `applyTabIdentity(surface: 'desk' | Surface): void`;`main.tsx` 导出的常量 `surface: 'desk' | Surface`(Gate 组件与 Task 4 的壳选择用)。
+
+- [ ] **Step 1: `tab-identity.ts` 扩为三章**
+
+签名 `applyTabIdentity(surface: 'desk' | 'design' | 'ops')`。新增两枚 SVG(与现有 `DESK_ICON`/`GOV_ICON` 同构;`GOV_ICON` 弃用删除):
+```ts
+/** 设计中心:深靛底 + 路网(定义企业的流转) */
+const DESIGN_ICON = `
+  
+  
+    
+    
+  
+`;
+/** 运营中心:深色底 + 仪表(看企业今天跑得怎样) */
+const OPS_ICON = `
+  
+  
+    
+    
+  
+`;
+```
+标题:`JCAI 工作台` / `JCAI 设计中心` / `JCAI 运营中心`。
+
+- [ ] **Step 2: `main.tsx` 入口判台**
+
+替换现有 `isGovernance` 段:
+```ts
+import { legacyGovTarget, SURFACE_META } from '@web-shared/gov/surfaces.ts';
+import type { Surface } from '@web-shared/gov/surfaces.ts';
+
+/** 旧 /gov 链接一条不死:带着深链改写到新台面(replace,不留历史) */
+const legacy = window.location.pathname.startsWith('/gov')
+  ? (legacyGovTarget(window.location.hash) ?? '/ops#/ops/cockpit/dash')
+  : undefined;
+if (legacy !== undefined) window.location.replace(legacy);
+
+const surface: 'desk' | Surface = window.location.pathname.startsWith(SURFACE_META.design.path)
+  ? 'design'
+  : window.location.pathname.startsWith(SURFACE_META.ops.path)
+    ? 'ops'
+    : 'desk';
+applyTabIdentity(surface);
+```
+样式装载:`surface === 'desk'` 装 `proto.vendor.css + app.css`,否则装 `theme.css + console.css`(两台治理面同一套皮肤)。`Gate` 里 `isGovernance` 相应改为 `surface !== 'desk'`,`GovShell` 暂以 `` 原样渲染(Task 4 传 surface)。
+
+- [ ] **Step 3: 验证**
+
+```bash
+lsof -ti:8787 >/dev/null || (nohup pnpm --filter @app/api dev >/tmp/api.log 2>&1 &)
+lsof -ti:5273 >/dev/null || (cd apps/web && nohup npx vite --port 5273 --strictPort >/tmp/web.log 2>&1 &)
+sleep 8
+curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5273/design   # 200
+curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5273/ops      # 200
+```
+浏览器(agent-browser)验证:开 `/design` 标签题为「JCAI 设计中心」、`/ops` 为「JCAI 运营中心」;开 `http://localhost:5273/gov#/gov/workflows/pkgs` 应落到 `/design#/design/workflows/pkgs`。
+Run: `pnpm typecheck && pnpm lint`,Expected: 通过。
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add apps/web/src/app/tab-identity.ts apps/web/src/app/main.tsx
+git commit -m "三面拆分②:三章标签与入口路由,/gov 深链整链改写"
+```
+
+---
+
+### Task 4: 壳参数化 —— 两套左轨、动态台账项、顶栏互跳亮灯
+
+**Files:**
+- Modify: `apps/web/src/app/gov-shell.tsx`
+- Modify: `apps/web/src/app/main.tsx`(把 `surface` 传给壳)
+- Modify: `apps/web/src/app/app-shell.tsx`(工作台轨底加两面入口,按亮灯)
+
+**Interfaces:**
+- Consumes: `SURFACE_META / SURFACE_ZONES / canSeeSurface`(Task 2);`useGovObjects`(域清单);`sessionRoute` + `queryKeys.session`(亮灯与轨底身份)。
+- Produces: `GovShell({ surface }: { surface: Surface })`;hash 格式 `#////:…`;`Place.tab` 对 `ledgers` 区 = 域名。
+
+- [ ] **Step 1: `readPlace`/`writePlace` 带 surface**
+
+```ts
+function readPlace(surface: Surface): Place {
+  const raw = window.location.hash.replace(/^#\/?/, '');
+  const parts = raw.split('/').filter((s) => s !== '');
+  // parts[0] 是台面名;区限定在本台面的区表内,越界落回本台首区
+  const zones = SURFACE_ZONES[surface];
+  const zone = (zones.includes(parts[1] as GovZone) ? parts[1] : zones[0]) as GovZone;
+  const tabs = TABS[zone];
+  // ledgers 的页签是数据域(动态):不校验,原样收;静态区仍校验并兜底到首签
+  const tab = zone === 'ledgers' ? (parts[2] ?? '') : (tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]?.key ?? '');
+  …stack 解析不变…
+}
+const writePlace = (surface: Surface, p: Place): string =>
+  ['#', surface, p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/');
+```
+
+- [ ] **Step 2: 壳按 surface 渲染**
+
+`GovShell({ surface })`:
+- 左轨:`SURFACE_ZONES[surface]` 过滤 `ZONES`;品牌字 `SURFACE_META[surface].brand`;轨章图标 design=`route`、ops=`gauge`。
+- **ledgers 动态项**(仅 ops):`useGovObjects()` 取 `objects`,`domains = [...new Set(objects.map(o => o.domain))]`;左轨在 `ledgers` 位置渲染一枚分组小标题「台账」+ 每域一个按钮(`goZone` 改为 `go({zone:'ledgers', tab: domain, stack: []})`,高亮条件 `zone==='ledgers' && tab===domain`)。域为空时渲染一条灰字「随数据域发布自动生成」。
+- 顶栏:`工作台` 链接保留;新增另一面按钮(design 壳给「运营中心」→ `/ops`,ops 壳给「设计中心」→ `/design`),以 `canSeeSurface(session, …)` 决定渲染;`告警` 按钮仅 ops 壳渲染。session 取法:`useQuery({ queryKey: queryKeys.session, queryFn: () => call(sessionRoute) })`。
+- 轨底身份:删除 `account === 'fengbing'` 硬编码(现存 bug),改用上面的 session(`session.data?.staff.name`)。
+- 面包屑首项:`治理台` 改为 `SURFACE_META[surface].brand`。
+- `ZoneHome` 映射:`blueprint: BlueprintHome`(Task 6 前先渲染 `企业蓝图建设中` 的内联占位组件)、`cockpit/knowledge: MemoryHome`(Task 5 拆)、`ledgers: LedgersHome`(Task 10 前内联占位)、其余不变;`triggers` 分支删除。
+- `Detail`:不动(所有 `Route.k` 仍有效;`trigger` 详情页仍可从两面进)。
+
+- [ ] **Step 3: 工作台入口亮灯**
+
+`app-shell.tsx` 轨底(`jc-rail-foot` 内、身份钮上方)加:
+```tsx
+{canSeeSurface(session.data, 'design') && (
+  设设计
+)}
+{canSeeSurface(session.data, 'ops') && (
+  运运营
+)}
+```
+(复用 `jc-nav-user` 皮肤,不造新类。)
+
+- [ ] **Step 4: 验证**
+
+浏览器:
+1. `/ops` 登录柴浩(TEST 岗九功能全 govern)→ 左轨 = 驾驶舱/任务/员工/台账(报价域、共享域)/知识库/系统;顶栏见「设计中心」「工作台」。
+2. `/design` → 左轨 = 蓝图/流程包/活动库/工具箱/数据域;无告警钮。
+3. 点台账「报价域」→ hash 变 `#/ops/ledgers/quote_intake…`(域名),再点「共享域」切换。
+4. 工作台 `/` 轨底出现 设计/运营 两枚入口。
+Run: `pnpm typecheck && pnpm lint && pnpm exec vitest run apps/web`,Expected: 通过。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add apps/web/src/app/gov-shell.tsx apps/web/src/app/main.tsx apps/web/src/app/app-shell.tsx
+git commit -m "三面拆分③:壳参数化两台,台账随域动态生成,顶栏互跳按权亮灯"
+```
+
+---
+
+### Task 5: 驾驶舱(运营首页)+ 度量路由补齐八指标
+
+**Files:**
+- Modify: `packages/contracts/src/api/governance.ts`(`govMetricsRoute` 输出补三字段)
+- Modify: `apps/api/src/app/routes/governance-ops.ts`(metrics handler 补映射)
+- Modify: `apps/web/src/shared/gov/gov-api.ts`(`usePkgReports` 自 gov-process 面板上移并更名 `useGovPkgReports` —— 驾驶舱的未闭合草稿数用它;实现体见 Task 6 Step 1 的代码块,**在本任务完成上移**,Task 6 只消费)
+- Modify: `apps/web/src/features/gov-process/internal/panel.tsx`(改引上移后的 hook,删本地副本)
+- Modify: `apps/web/src/features/gov-memory/internal/panel.tsx`(拆出 CockpitHome / KnowledgeHome)
+- Modify: `apps/web/src/features/gov-memory/index.ts`(出口)
+- Modify: `apps/web/src/app/gov-shell.tsx`(cockpit/knowledge 挂新组件)
+- Modify: `api-report/`(api:check --update)
+
+**Interfaces:**
+- Consumes: `container.memory.metrics`(已计算八指标,现路由只暴露六)、`useGovTasks`(异常工作流数)、`useGovPackages` + `validateProcessRoute`(未闭合草稿数)。
+- Produces: `govMetricsRoute.output` 增 `breakpoint: z.number()`, `unitCost: z.object({ tokens: z.number(), minutes: z.number(), humanCalls: z.number() })`, `terminalMix: z.record(z.string(), z.number())`;组件 `CockpitHome({ tab, nav }: HomeProps)`、`KnowledgeHome({ tab, nav }: HomeProps)`。
+
+- [ ] **Step 1: 契约与 handler**
+
+`govMetricsRoute.output` 补三字段(全部 memory 模块已算,handler 里从 `metrics` 结果透传;对照 `packages/contracts/src/api/workbench.ts` 的 `reportMetricsSchema` 字段名保持一致)。跑 `pnpm api:check` 确认只报此处差异 → 同步 `apps/api/src/modules/memory/CONTRACT.md` 影响半径一句 → `pnpm api:check --update && git add api-report`。
+
+- [ ] **Step 2: 拆 gov-memory 面板**
+
+`CockpitHome` = 原 metrics 页签内容 + 顶部加一排 KPI:
+```tsx
+
+ + + +
+``` +`abnormal` 自 `useGovTasks().data.workflows.filter(w=>w.state==='abnormal').length`;`draftBad` 自 `useGovPackages` + `useGovPkgReports`(本任务上移到 gov-api.ts);`running` 自 tasks。八指标卡沿用原 metrics 渲染,补 `断点率/单位成本/终态分布` 三卡(数据来自 Step 1 扩的路由)。点「异常工作流」卡 → `nav.jump('tasks','todo')`。 +`KnowledgeHome` = 原 layers 页签内容原样搬入。原 `MemoryHome` 删除。 +**死信卡不做**(无查询数据源),在 Step 4 记入 debt。 + +- [ ] **Step 3: 验证** + +curl:`curl -s -b /tmp/ch.cookie http://localhost:8787/api/gov/metrics | python3 -m json.tool` → 九个键齐(六旧 + 三新)。 +浏览器:`/ops` 开屏即驾驶舱(KPI 排 + 八指标);知识库页签只剩活动记忆。 +Run: 五门,Expected: 全绿。 + +- [ ] **Step 4: 记欠账 + 提交** + +`docs/debt.md` 追加:「驾驶舱·死信卡:死信目前只送治理者消息要素,无计数查询面 —— 驾驶舱首版不含死信卡,补上需给 system_message 一个按 kind 计数的治理路由」。 +```bash +git add packages/contracts apps/api/src/app/routes/governance-ops.ts apps/api/src/modules/memory/CONTRACT.md \ + api-report apps/web/src/features/gov-memory apps/web/src/app/gov-shell.tsx docs/debt.md +git commit -m "三面拆分④:驾驶舱首页 —— 度量路由补齐八指标,异常/草稿/在途三卡" +``` + +--- + +### Task 6: 企业蓝图(设计首页) + +**Files:** +- Create: `apps/web/src/features/gov-blueprint/index.ts` +- Create: `apps/web/src/features/gov-blueprint/internal/panel.tsx` +- Modify: `apps/web/src/app/gov-shell.tsx`(blueprint 挂 BlueprintHome) + +**Interfaces:** +- Consumes: `useGovPackages / useGovActivities / useGovTools / useGovObjects / useGovTriggers` + `useGovPkgReports(packages)`。 +- Produces: `useGovPkgReports(packages: readonly ProcessPackage[]): UseQueryResult>`;`BlueprintHome({ tab, nav }: HomeProps)`。 + +- [ ] **Step 1: 确认 hook 已上移(Task 5 完成);若按乱序执行先补上移** + +`gov-api.ts` 的 `useGovPkgReports`(实现体即原 gov-process 面板里的 `usePkgReports`,原地删除并改引用): +```ts +export const useGovPkgReports = (packages: readonly ProcessPackage[]): UseQueryResult> => + useQuery({ + queryKey: ['gov', 'process-reports', packages.map((p) => `${p.id}@${p.version}`).join(',')], + enabled: packages.length > 0, + queryFn: async () => + new Map( + await Promise.all( + packages.map( + async (p) => [`${p.id}@${p.version}`, await call(validateProcessRoute, { body: { pkg: p } })] as const, + ), + ), + ), + }); +``` + +- [ ] **Step 2: BlueprintHome** + +因果链一屏(全部用既有 `g-*` 件): +```tsx +export function BlueprintHome({ nav }: HomeProps) { + const pkgs = useGovPackages(); const acts = useGovActivities(); + const tools = useGovTools(); const objs = useGovObjects(); const trgs = useGovTriggers(); + const reports = useGovPkgReports(pkgs.data?.packages ?? []); + const drafts = (pkgs.data?.packages ?? []).filter((p) => p.state !== 'published'); + const draftBad = drafts.filter((p) => reports.data?.get(`${p.id}@${p.version}`)?.publishable === false); + const violations = [...(reports.data?.values() ?? [])].flatMap((r) => r.violations); + const domains = [...new Set((objs.data?.objects ?? []).map((o) => o.domain))]; + return ( + <> + 战略 → 方法 → 能力 → 对象:流程包决定企业以哪些流转运转,活动库决定每件事怎么干,工具箱决定系统能对世界做什么,数据域决定企业运营什么。设计中心没有任何运行态数字。 +
+ + + + +
+ + {violations.length === 0 ? ( + 无待补 —— 全部流程包闭合校验通过。 + ) : ( +
+ {draftBad.map((p) => ( + v.message).join(';')} + acts={[{ label: '去编排', ico: 'route', onClick: () => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` }) }]} /> + ))} +
+ )} +
+ +
+ nav.jump('workflows', 'rules')} /> +
+
+ + ); +} +``` +(工具/活动的 output 字段名以 `GovToolsOutput`/`GovActivitiesOutput` 实际为准 —— 写前 `grep listToolsRoute -A10` 校对。) + +- [ ] **Step 3: 验证 + 提交** + +浏览器:`/design` 开屏即蓝图(四卡 + 待补清单;v5 草稿若仍未发布应出现在待补或草稿计数里)。五门全绿。 +```bash +git add apps/web/src/shared/gov/gov-api.ts apps/web/src/features/gov-blueprint apps/web/src/features/gov-process/internal/panel.tsx apps/web/src/app/gov-shell.tsx +git commit -m "三面拆分⑤:企业蓝图首页 —— 四库因果链与待补清单" +``` + +--- + +### Task 7: 触发器 upsert 路由(后端) + +**Files:** +- Modify: `packages/contracts/src/api/governance.ts` +- Modify: `apps/api/src/app/routes/governance-ops.ts` +- Modify: `api-report/` + +**Interfaces:** +- Consumes: `container.trigger.upsert(config: TriggerConfig)`(模块出口已有,播种在用)。 +- Produces: + ```ts + export const upsertTriggerRoute = { + method: 'PUT', + path: '/api/gov/triggers', + body: z.object({ trigger: triggerConfigSchema }), + output: z.object({ trigger: triggerConfigSchema }), + } as const satisfies RouteContract; + ``` + +- [ ] **Step 1: 契约 + handler** + +governance-ops.ts(紧挨 `toggleTriggerRoute`,复用 `governTrigger` 守卫): +```ts +/** 调起规则的定义面(设计中心):新建 / 改规则 / 改装配 —— 启停仍走 toggle */ +app.put(upsertTriggerRoute.path, async (c) => { + const denied = await governTrigger(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = upsertTriggerRoute.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({ trigger: await container.trigger.upsert(parsed.data.trigger) }); +}); +``` + +- [ ] **Step 2: curl 验证** + +```bash +curl -s -b /tmp/ch.cookie -X PUT http://localhost:8787/api/gov/triggers \ + -H 'content-type: application/json' \ + -d '{"trigger":{"id":"trg-rfq-manual","name":"人工登记起单(统一报价)","enabled":true,"processPackageId":"unified-quote","mapping":[],"spec":{"kind":"registration"}}}' +``` +Expected: 200 + trigger 回显;错 schema 给 400;无权给 403。 + +- [ ] **Step 3: api:check + 提交** + +CONTRACT.md:trigger 模块面未变(路由在 app 层),无需动;`pnpm api:check --update && git add api-report`。五门全绿。 +```bash +git add packages/contracts apps/api/src/app/routes/governance-ops.ts api-report +git commit -m "三面拆分⑥:触发器 upsert 路由 —— 定义面的 HTTP 口" +``` + +--- + +### Task 8: 调起规则(设计侧)—— workflows/rules 页签 + 包详情可编辑 + +**Files:** +- Modify: `apps/web/src/features/gov-trigger/internal/panel.tsx`(TriggersHome 改造为 `TriggerRulesHome` 定义面:列表 + 编辑表单;`TriggerPage` 保留) +- Modify: `apps/web/src/features/gov-trigger/index.ts` +- Modify: `apps/web/src/features/gov-process/internal/panel.tsx`(WorkflowsHome 增 `tab === 'rules'` 分支渲染 `TriggerRulesHome`;PkgPage「调起它的触发器」段加「编辑」进入行内表单) +- Modify: `apps/web/src/app/gov-shell.tsx`(TriggersHome 引用清理) + +**Interfaces:** +- Consumes: `upsertTriggerRoute`(Task 7)、`useGovTriggers`、`toggleTriggerRoute`(启停不在此面,只读展示状态)。 +- Produces: `TriggerRulesHome({ nav }: { nav: Nav })` —— 供 workflows 区 rules 页签与蓝图入口复用。 + +- [ ] **Step 1: TriggerRulesHome(定义面)** + +列表(NList/NRow):名称 / 三类章 / 规则原文 / 目标包;行点开 `nav.push({k:'trigger', id})`。行尾「编辑」展开行内表单(Prop/Field 版式): +- `name` 文本;`processPackageId` 下拉(useGovPackages 的 id 去重); +- `spec` 按 kind 三分支:registration 无参;event 给 `eventType`(data/time/human/control 下拉)+ `rule` textarea;periodic-calendar 给 `calendar` 文本;periodic-data 给 domain/object/dateField/offset/filter 五格(domain/object 下拉自 useGovObjects); +- `mapping` 用 `ListEdit` 编辑 `target=source.path` 形式的行,提交时解析为 `{target, source:{from:'initiation', path}}`(定义面先支持起单载荷映射这一种 —— 其余 source 类型显示只读,注明「在流程包边映射里维护」); +- 保存:`call(upsertTriggerRoute, { body: { trigger } })` → 失效 `['gov','triggers']`;`AskBand` 确认带(影响:此后命中规则即按新定义起单;可再改;即时生效)。 +**启停开关不出现在此面**(那是运营的事,Task 9)。 + +- [ ] **Step 2: 接线** + +WorkflowsHome:`if (tab === 'rules') return ;`。 +PkgPage「调起它的触发器」段:每行加「编辑规则」按钮 → `nav.jump('workflows', 'rules')`(编辑集中在 rules 页签,包详情不重复摆表单);无绑定时的 Note 文案追加「在调起规则页签新建」。 + +- [ ] **Step 3: 验证 + 提交** + +浏览器:`/design` → 流程包 → 调起规则:改 `trg-rfq-manual` 的 name 存盘,刷新仍在;`curl …/api/gov/triggers` 回显新名。工作台起单验证不受影响(`POST /api/triggers/trg-rfq-manual/register` 仍 200)。五门全绿。 +```bash +git add apps/web/src/features/gov-trigger apps/web/src/features/gov-process/internal/panel.tsx apps/web/src/app/gov-shell.tsx +git commit -m "三面拆分⑦:调起规则并入流程包区 —— 触发器定义可编辑" +``` + +--- + +### Task 9: 起单开关(运营侧)—— tasks/trg 页签 + +**Files:** +- Modify: `apps/web/src/features/gov-trigger/internal/panel.tsx`(新增 `TriggerOpsTab`:启停 + 状态) +- Modify: `apps/web/src/features/gov-trigger/index.ts` +- Modify: `apps/web/src/features/gov-task/internal/panel.tsx`(TasksHome 增 `tab === 'trg'` 分支) + +**Interfaces:** +- Consumes: `useGovTriggers`、`toggleTriggerRoute`(既有:`POST /api/gov/triggers/:id/toggle`,body `{enabled}` —— 写前 grep 确认参数形状)。 +- Produces: `TriggerOpsTab({ nav }: { nav: Nav })`。 + +- [ ] **Step 1: TriggerOpsTab** + +NList 每触发器一行:三类章 / 规则原文 / 目标包 / `St` 状态章;行尾「启用/停用」按钮走 `AskBand` 确认(影响文案照原型:「触发器不入流程包结构…此后不再起新单;已起的照常跑完」),`useMutation` → toggle → 失效 `['gov','triggers']`。行点开仍 `nav.push({k:'trigger', id})`。「编辑规则」入口按 `canSeeSurface(session,'design')` 亮灯,点了整页跳 `/design#/design/workflows/rules`。 + +- [ ] **Step 2: 验证 + 提交** + +浏览器:`/ops` → 任务 → 起单开关:停用 `trg-rfq-manual` → 工作台登记起单应被拒(`fireByRegistration` 回 `skipped:'disabled'`);再启用恢复。五门全绿。 +```bash +git add apps/web/src/features/gov-trigger apps/web/src/features/gov-task/internal/panel.tsx +git commit -m "三面拆分⑧:起单开关进运营·任务区 —— 定义与处置各归其位" +``` + +--- + +### Task 10: 台账动态位 + 数据域定义面瘦身 + +**Files:** +- Modify: `apps/web/src/features/gov-data/internal/panel.tsx`(拆三件:`DataHome` 定义面、`LedgersHome` 运营面、`ObjPage` 按来源分呈现) +- Modify: `apps/web/src/features/gov-data/index.ts` +- Modify: `apps/web/src/app/gov-shell.tsx`(ledgers 挂 `LedgersHome`,替换 Task 4 的占位) + +**Interfaces:** +- Consumes: `useGovObjects`(域/对象清单)、`useGovLedger(domain, object)`(列 / 主键 / 行 / 总数)。 +- Produces: `LedgersHome({ domain, nav }: { domain: string; nav: Nav })` —— 壳层以 `tab` 为域名传入;`DataHome` 保持 `HomeProps` 签名。 + +- [ ] **Step 1: LedgersHome(运营面)** + +按传入 `domain` 过滤对象,每对象一张 `NRow`(名称 / 行数徽 —— 行数取 `useGovLedger` 的 `total`,懒加载:仅当前域的对象查询);点行 `nav.push({k:'obj', id: `${domain}.${object}`})` 进 `ObjPage` 看行数据(现 ObjPage 已是行浏览,复用)。空域文案:「这个域还没有对象 —— 对象随活动安装物化」。 + +- [ ] **Step 2: DataHome 瘦身(定义面)** + +设计侧只留定义信息:域分组 → 对象卡(列结构 `columns`、主键、被哪些活动声明 —— 声明反查暂无查询面,此卡先列 `columns/primaryKey`,行数与行浏览入口**移除**);卡底加一行灰字「行数据在运营中心 · 台账」。`ObjPage` 从设计侧不再可达(设计侧对象卡不下钻行数据)。 + +- [ ] **Step 3: 验证 + 提交** + +浏览器:`/ops` 台账·报价域 → 对象行数与行浏览正常;`/design` 数据域 → 只见结构,无行数据入口。五门全绿。 +```bash +git add apps/web/src/features/gov-data apps/web/src/app/gov-shell.tsx +git commit -m "三面拆分⑨:台账动态位入运营,数据域瘦身为纯定义面" +``` + +--- + +### Task 11: 工作组管理(员工区新页签 + 读写路由) + +**Files:** +- Modify: `packages/contracts/src/api/governance.ts`(两条路由) +- Modify: `apps/api/src/app/routes/governance-ops.ts`(handler,app 层组合 org 出口) +- Modify: `apps/web/src/features/gov-staff/internal/panel.tsx`(PeopleHome 增 `tab === 'groups'`) +- Modify: `apps/web/src/shared/gov/gov-route.ts`(people TABS 增 `{ key: 'groups', label: '工作组', ico: 'users' }`) +- Modify: `api-report/` + +**Interfaces:** +- Consumes: `container.org.listStaff() / workGroupOf(staffId) / saveWorkGroup({id?, name, ownerId, members})`(均在 org 出口);`workGroupSchema`(@contracts)。 +- Produces: + ```ts + export const listWorkGroupsRoute = { + method: 'GET', + path: '/api/gov/work-groups', + output: z.object({ groups: z.array(workGroupSchema.extend({ ownerName: z.string() })) }), + } as const satisfies RouteContract; + export const saveWorkGroupRoute = { + method: 'PUT', + path: '/api/gov/work-groups', + body: z.object({ ownerId: staffIdSchema, name: z.string().min(1), members: z.record(z.string(), staffIdSchema) }), + output: z.object({ group: workGroupSchema }), + } as const satisfies RouteContract; + ``` + +- [ ] **Step 1: handler(守卫 `guard('staff', 'read'/'govern')`)** + +```ts +app.get(listWorkGroupsRoute.path, async (c) => { + const denied = await readStaff(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const { staff } = await container.org.listStaff(); + const groups = []; + for (const s of staff) { + const g = await container.org.workGroupOf(s.id); + if (g !== undefined) groups.push({ ...g, ownerName: s.name }); + } + return c.json({ groups }); +}); + +app.put(saveWorkGroupRoute.path, async (c) => { + const denied = await governStaff(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = saveWorkGroupRoute.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); + } + // 一人一组:已有组即更新(id 复用),没有即新建 —— 与 org.workGroupOf 的取组口径一致 + const existing = await container.org.workGroupOf(parsed.data.ownerId); + const group = await container.org.saveWorkGroup({ + ...(existing === undefined ? {} : { id: existing.id }), + name: parsed.data.name, + ownerId: parsed.data.ownerId, + members: parsed.data.members, + }); + return c.json({ group }); +}); +``` +(`readStaff`/`governStaff` 守卫若不存在,按文件内既有 `guard('staff', …)` 工厂补两行。) + +- [ ] **Step 2: 界面(people/groups 页签)** + +列表:每组一张 `Card`(组名 · 组长 ownerName · 成员表 `Rows`:岗位 code → 员工名,员工名经 `useGovStaff` 的 id→name 映射)。编辑:选组长(下拉全员)→ 逐岗位下拉选员工(岗位集 = `useGovStaff().positions` 的 code;可留空 = 该岗位不绑定)→ `AskBand` 确认(影响:「仅新起的工作流;在途不受影响 —— 绑定是起单时的快照」)→ PUT → 失效 `['gov','work-groups']` 键(新增 hook `useGovWorkGroups` 于 gov-api.ts)。空态:「还没有工作组 —— 建一个,起单时整条流可以按组绑定到人」。 + +- [ ] **Step 3: 验证 + 提交** + +curl:GET 应见播种的 `wg-solo-ai`/`wg-solo-chaihao`;PUT 改柴浩组名后 GET 回显。浏览器:`/ops` 员工 → 工作组页签可看可改;改完用柴浩起一单,`sys.workflow.work_group` 快照应含新成员表。`pnpm api:check --update && git add api-report`(org 模块 CONTRACT.md 影响半径补一句:app 层新增工作组读写路由)。五门全绿。 +```bash +git add packages/contracts apps/api/src/app/routes/governance-ops.ts apps/api/src/modules/org/CONTRACT.md \ + api-report apps/web/src/features/gov-staff apps/web/src/shared/gov/gov-route.ts apps/web/src/shared/gov/gov-api.ts +git commit -m "三面拆分⑩:工作组管理落员工区 —— ADR-011 欠账清账" +``` + +--- + +### Task 12: 收口 —— 走查、门禁、文档对账 + +**Files:** +- Modify: `docs/debt.md`(核销已清项:工作组界面;新增项见各任务) +- Modify: `docs/产品设计.md`(如实现与设计有出入,改设计或记偏差 —— 文档与实现不许各说各话) + +**Interfaces:** 无新接口。 + +- [ ] **Step 1: 全链走查(浏览器,柴浩账号)** + +1. `/` 工作台:轨底两枚入口 → 进 `/ops`; +2. `/ops` 驾驶舱开屏 → 任务/员工/台账×2/知识库/系统逐区开一遍,无空白页、无 `(无)`; +3. 起单开关停/启验证一轮; +4. `/design` 蓝图开屏 → 流程包(编排工作区仍可用:v5 草稿点开、属性卡、编辑芯片)→ 调起规则改名 → 活动库/工具箱/数据域逐区开; +5. 旧链接 `/gov#/gov/workflows/pkgs`、`/gov#/gov/memory/layers` 各自落对; +6. 工作台起一单 → `/ops` 驾驶舱在途 +1。 + +- [ ] **Step 2: 五门 + 文档** + +Run: `pnpm gen:tiers && pnpm typecheck && pnpm lint && pnpm depcruise && pnpm api:check && pnpm test` +Expected: 全绿(api:check 无未入库差异)。 +`docs/debt.md`:划掉「工作组无治理台界面」;确认「驾驶舱死信卡」「声明反查无查询面」两条在列。 + +- [ ] **Step 3: 提交** + +```bash +git add docs +git commit -m "三面拆分⑪:收口 —— 走查通过,债务台账对齐" +``` + +--- + +## 自审记录(写完计划后跑过) + +- **规格覆盖**:产品设计.md §5 前端改动清单五项 → Task 3/4(壳与标签)、5/6(两首页)、7/8/9(触发器归位)、10(台账+数据域)、11(工作组);§6 权限亮灯 → Task 2/4;§1 `/gov` 跳转 → Task 3。驾驶舱「死信卡」为已声明偏差(Task 5 Step 4 记 debt)。 +- **占位扫描**:无 TBD;两处「写前 grep 校对」是对既有代码形状的核对动作,不是留白。 +- **类型一致性**:`canSeeSurface(session, surface)` 三处消费(Task 4 壳、Task 4 工作台、Task 9 编辑入口)同签名;`useGovPkgReports` 的**上移动作归 Task 5**,Task 6 只消费 —— 前向依赖已消除。 diff --git a/docs/产品设计.md b/docs/产品设计.md new file mode 100644 index 0000000..f491e04 --- /dev/null +++ b/docs/产品设计.md @@ -0,0 +1,187 @@ +# 产品设计 · 三面一体 + +> 2026-07-27 定稿。本文档回答一个问题:**这个产品由哪几张脸组成,每张脸对谁、说什么。** +> 背景:治理中心此前是平铺的九个区,设计态与运行态混在一排 —— 定义企业的人和看企业跑的人 +> 挤在同一套导航里,谁都找不到自己的第一屏。本次拆分把它按心智一分为二,与工作台合成三面。 + +--- + +## 一、概念模型:三面一体 + +产品按一条因果链排成三面: + +``` +设计中心 ──定义──▶ 运营中心 ──派发──▶ 工作台 +(企业怎么运转) (今天跑得怎样) (在企业里干活) + 类 · 规则 实例 · 监控 人 · 劳动 +``` + +- **设计产生运营的形状**:设计侧每发布一个数据域,运营侧自动长出一个台账位; + 每发布一个流程包,运营侧的驾驶舱就多一类可监控的流。 +- **运营产生工作台的内容**:流程跑起来,任务、审批、消息才落到人的工位上。 +- **工作台产生驾驶舱的数字**:人把活干完,八指标才有读数 —— 圈闭合。 + +| 面 | 地址 | 网页标签 | 一句话 | 谁用 | +|---|---|---|---|---| +| 设计中心 | `/design` | 深靛底 + 路网章 | 定义企业:战略 → 方法 → 能力 → 对象 | 业务架构师 / 管理者 | +| 运营中心 | `/ops` | 深色底 + 仪表章 | 看企业跑:驾驶舱 + 处置 + 沉淀 | PM / 当班主管 | +| 工作台 | `/` | 蓝渐变 + 机器人章 | 干活:任务五要素 | 全员 | + +- `/gov` 保留为跳转(302 → `/ops`),旧链接不断。 +- 三面共用一套登录会话(ADR-010);顶栏互跳,**入口按权限亮灯**(见第六节)。 +- 网页标签三章并立:多标签并开时,靠图标即可分辨"我在定义、在监控、还是在干活"。 + +## 二、设计中心 `/design` + +**左轨 = 企业蓝图(首页)+ 四库。** + +### 企业蓝图(首页) + +一屏把四库串成因果链,回答"这家企业被定义成什么样、哪里还没定义完": + +``` +战略 ────── 方法 ────── 能力 ────── 对象 +流程包 → 活动库 → 工具箱 → 数据域 + ↓健康度 ↓健康度 ↓健康度 ↓健康度 +未闭合草稿 未安装引用 未授权工具 未发布域 +``` + +- 每库一张卡:总数、健康度、最近变更;点卡即入库。 +- 下方是**待补清单**:闭合校验未过、契约缺口、名称未匹配的活动引用 —— 全部聚在这里, + 点进即改。设计者的"需处置"不散落在四库里。 + +### 四库 + +| 库 | 定位 | 内容 | +|---|---|---| +| **流程包** | 战略 —— 企业以哪些流转运转 | 现工作流区整体迁入:编排工作区(画布 + 属性卡)、边映射校验、版本谱系、导出依赖文件。**触发器的定义并入本库**:包详情"调起它的触发器"从只读变可编辑(规则 / 输入装配 / 目标版本),另设「调起规则」页签横览全部触发器。触发器不再是独立一区 | +| **活动库** | 方法 —— 每件事怎么干 | 现活动库区平移:货架、契约面、范式、资产版本谱系、安装两审 | +| **工具箱** | 能力 —— 系统能对世界做什么 | 现工具区平移:读/写目录、白名单授权、幂等口径、引用反查 | +| **数据域** | 对象 —— 企业运营什么 | 现数据区的**定义面**:域与对象 schema、字段、放行规则、被谁引用。**行数据不在这里** —— 那是运营的事 | + +### 一条纪律 + +设计中心里**没有任何运行态数字**(在跑几单、30 天起单数、死信数都不出现)。 +设计者看的是定义完不完整,不是今天忙不忙 —— 运行态数字一旦混进来, +设计页面就会退化成第二个监控页。 + +## 三、运营中心 `/ops` + +**左轨 = 驾驶舱(首页)+ 固定位 + 动态位 + 固定位。** + +``` +◉ 驾驶舱(首页) +── 固定 ── +任务 +员工 +── 台账(动态,随数据域长) ── +报价域 +共享域 +… +── 固定 ── +知识库 +系统 +``` + +### 驾驶舱(首页) + +经营第一屏,从现"记忆 · 度量仪表"升格而来: + +- **度量八指标**(OS 8.3,零埋点):自主率 / 纠正率 / 有效自主率 / 打扰率 / 断点率 / + DMN 覆盖率 / 单位成本 / 终态分布; +- **异常面**:异常工作流、死信、需处置件数 —— 点数字直达处置页。 + +### 固定位 + +| 位 | 内容 | +|---|---| +| **任务** | 现任务管理区平移:运行看板 / 任务 / 审批单 / 需处置。**触发器的处置并入**:启停开关、近 30 天起单、死信重投 —— 定义在设计中心,开关在这里 | +| **员工** | 现员工区平移(员工 / 岗位授权),**补上工作组管理**(ADR-011 欠账:建组 / 改组 —— 起单时的岗位绑定) | +| **知识库** | 活动记忆四层 + 技能资产 + 候选增量的回放与放行。"记忆的终点是成为模型"这条进化管线的**人工放行口**在这里 | +| **系统** | LLM 实例 / 通道实例,现系统区平移 | + +### 动态位:台账 ×N + +按**数据域**动态生成导航项 —— 设计侧一发布新域,运营侧自动长出一位。 +点进去是该域全部对象的行数据、版本历史、被哪些任务引用。 +域的 schema 不在这里改(那是设计的事);这里做的是运营动作:查行、看版本、追引用。 + +> 这是"设计产生运营的形状"最直观的一处:导航本身就是被设计出来的。 +> 数据来源:`ledger.listObjects`(已有),无需新后端能力。 + +## 四、与工作台的联动 + +- **三面互跳**:每面顶栏(或轨底)有另两面的入口,按权限亮灯。跳转带上下文: + 运营中心看某任务 → "去工作台处理"直达该单;工作台任务详情 → 有运营权者见"去运营中心看这条流"。 +- **同一语言**:工作台里人看到的任务、审批、台账字段,与运营中心管理的是**同一份投影、同一套词**。 + 运营中心是工作台的管理者视角,不是另一套系统 —— 字段名、状态名、终态五名逐字一致。 +- **闭环叙事**:设计中心发布流程包 → 运营中心看它跑、放行知识沉淀 → + 工作台的人把活干完 → 数字回到驾驶舱。产品讲的就是这一个圈。 + +## 五、现状迁移映射 + +**后端模块、授权模型、数据一概不动** —— 这是一次纯前端的信息架构重排: + +| 现九区 | 去向 | +|---|---| +| 工作流 | 设计 · 流程包 | +| 活动库 | 设计 · 活动库 | +| 工具 | 设计 · 工具箱 | +| 数据(schema 面) | 设计 · 数据域 | +| 数据(行数据面) | 运营 · 台账 ×N | +| 触发器(规则 / 装配定义) | 设计 · 流程包 - 调起规则 | +| 触发器(启停 / 死信处置) | 运营 · 任务 - 需处置 | +| 任务 | 运营 · 任务 | +| 员工 | 运营 · 员工(+ 工作组管理,新) | +| 记忆(度量仪表) | 运营 · 驾驶舱(首页) | +| 记忆(活动记忆) | 运营 · 知识库 | +| 系统 | 运营 · 系统 | + +前端改动清单: + +1. 壳层:`gov-shell` 一分为二(`/design` 与 `/ops` 各一套左轨与路由),`/gov` 302 → `/ops`; +2. 网页标签:`tab-identity` 从两章扩为三章; +3. 新页两张:企业蓝图(设计首页)、驾驶舱(运营首页)—— 数据全部来自既有接口的再聚合; +4. 新界面一处:工作组管理(员工位内;`org.saveWorkGroup` 已在出口,缺的只是界面); +5. 触发器编辑面:从独立区改为流程包详情内的可编辑段(启停路由已有;规则与装配的 + 编辑需补一条 upsert 路由 —— `trigger.upsert` 用例已在模块出口,只缺 HTTP 面)。 + +## 六、权限 + +**授权模型不动**:仍是九治理功能 × 读/写/治三动作(客体三类中的 governance 类)。 +变化只在**归属与亮灯**: + +- 九功能按上表归属两面; +- 台面入口按"是否持有该面任一功能的读权"亮灯 —— 无权者看不见入口,有读权者进去只读, + 动作仍逐功能校验(读是 read,动才是 govern); +- 不新增"设计权 / 运营权"这类粗粒度开关,不做两层叠加 —— 单组织 ≤50 人用不上, + 且现有 grants 数据零迁移。 + +## 七、边界(本设计不做的) + +- **不动后端**:模块、契约、授权、数据库都保持原样; +- **不做对象级动态导航**:台账动态位止于"域"粒度,对象在域内切换; +- **不做设计中心的运行态回显**:哪怕"看起来有用"也不加 —— 纪律见第二节; +- **不做工作台的管理者混入**:运营能力永远住运营中心,不往工作台塞管理页签。 + +## 八、落地顺序建议 + +1. **壳先行**:两套左轨 + 三章标签 + `/gov` 跳转 + 权限亮灯(纯搬迁,一次可验); +2. **两张首页**:驾驶舱(度量升格)→ 企业蓝图(四库健康度); +3. **归位改造**:触发器定义并入流程包、台账动态位、数据域定义面瘦身; +4. **补欠账**:工作组管理界面。 + +每步落完都可独立验收;顺序可按当期在做的活动开发穿插。 + +--- + +## 附 · 决策记录 + +| 决策 | 选择 | 理由 | +|---|---|---| +| 拆分形态 | 两个独立台面(+工作台成三面) | 产品叙事最干净:定义 / 监控 / 干活是三种心智;权限天然分界 | +| 触发器归属 | 定义归设计(流程包内),处置归运营 | 同一对象两种动作各归其位;避免两边各放一份 | +| 度量归属 | 升为运营首页(驾驶舱) | "看企业今天跑得怎样"需要开屏答案;与活动记忆分开是两种心智 | +| 动态位形态 | 域 = 导航项,随发布长 | "设计产生运营的形状"要在导航上直接可见;对象级太碎 | +| 权限粒度 | 保留九功能粒度,入口按读权亮灯 | grants 零迁移,细粒度保住;≤50 人不需要两层模型 | +| 设计中心 IA | 蓝图首页 + 四库平铺 | 因果链要有一屏能讲;四库直达的稳定导航同时保住 | diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 1512e49..e818778 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -192,6 +192,19 @@ export const listProcessPackagesRoute = { } as const satisfies RouteContract; /** 画图即校验 —— 与前端画布同源的闭合校验 */ +/** + * 保存流程包(编排工作区的落笔)。 + * **保存 ≠ 发布**:落的是这一版的定义(草稿可反复存);已发布版本不可回改 —— + * 后端按 (id, version, state) 判:published 版拒存,提示另起新版本。 + * 返回连同闭合报告:画图即校验,每次落笔都知道离「可发布」还差几条。 + */ +export const saveProcessRoute = { + method: 'PUT', + path: '/api/gov/process', + body: z.object({ pkg: processPackageSchema }), + output: z.object({ pkg: processPackageSchema, report: closureReportSchema }), +} as const satisfies RouteContract; + export const validateProcessRoute = { method: 'POST', path: '/api/gov/process/validate', diff --git a/packages/contracts/src/api/http.ts b/packages/contracts/src/api/http.ts index 5ac5ce3..2f9528c 100644 --- a/packages/contracts/src/api/http.ts +++ b/packages/contracts/src/api/http.ts @@ -6,7 +6,7 @@ */ import type { z } from 'zod'; -export const HTTP_METHODS = ['GET', 'POST', 'PATCH', 'DELETE'] as const; +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; export type HttpMethod = (typeof HTTP_METHODS)[number]; /** diff --git a/packages/contracts/src/api/workbench.ts b/packages/contracts/src/api/workbench.ts index b8411ac..d3b6d89 100644 --- a/packages/contracts/src/api/workbench.ts +++ b/packages/contracts/src/api/workbench.ts @@ -5,16 +5,35 @@ import { z } from 'zod'; import type { RouteContract } from './http.ts'; import { sessionSchema } from '../org.ts'; -import { chatMessageSchema, deliverableSchema, formFieldSchema, stageSchema, viewBlockSchema, workOrderSchema } from '../card.ts'; +import { chatMessageSchema, deliverableSchema, fileDocSchema, formFieldSchema, stageSchema, viewBlockSchema, workOrderSchema } from '../card.ts'; import { attributionSideSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts'; import { staffIdSchema, taskIdSchema } from '../id.ts'; +/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */ export const sessionRoute = { method: 'GET', path: '/api/session', output: sessionSchema, } as const satisfies RouteContract; +/** + * 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。 + * 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。 + */ +export const loginRoute = { + method: 'POST', + path: '/api/login', + body: z.object({ account: z.string().min(1), password: z.string().min(1) }), + output: sessionSchema, +} as const satisfies RouteContract; + +/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */ +export const logoutRoute = { + method: 'POST', + path: '/api/logout', + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; + export const healthRoute = { method: 'GET', path: '/api/health', @@ -49,10 +68,29 @@ export const listTasksRoute = { output: z.object({ tasks: z.array(taskCardSchema) }), } as const satisfies RouteContract; +/** + * 流程链上的一环 —— 「一条 = 一个活动的一次运行」之外,人还要看见自己在整条流程的哪儿。 + * 别人负责的节点也在链上(只读),但不进任务清单。 + */ +export const chainNodeSchema = z.object({ + nodeId: z.string(), + label: z.string(), + kind: z.enum(['activity', 'approval']), + /** 谁做:姓名 · 岗位;岗位未落到具体人时只给岗位名 */ + ownerName: z.string(), + ownerRole: z.string(), + state: z.enum(['done', 'current', 'todo']), +}); +export type ChainNode = z.infer; + /** 任务详情 —— 工作区与对话区的服务端投影(前端零过程状态,刷新即重建) */ export const taskDetailSchema = z.object({ card: taskCardSchema, goal: z.string(), + /** 本单负责人 —— 标题区落款与报告署名 */ + owner: z.object({ name: z.string(), role: z.string() }), + /** 整条流程的节点链(含他人节点,只读) */ + chain: z.array(chainNodeSchema), budget: z.object({ steps: z.string(), tokens: z.string(), @@ -61,6 +99,18 @@ export const taskDetailSchema = z.object({ }), stages: z.array(stageSchema), deliverables: z.array(deliverableSchema), + /** + * 工作成果 = 一组**文件**,大部分是 HTML 汇报报告。 + * 一行一份、点进去看;文件本体随投影一并下发,前端不另取。 + */ + results: z.array( + z.object({ + label: z.string(), + desc: z.string(), + at: z.string().optional(), + file: fileDocSchema, + }), + ), workOrders: z.array(workOrderSchema), chat: z.array(chatMessageSchema), view: z.array(viewBlockSchema), @@ -94,6 +144,17 @@ export const replyWorkOrderRoute = { output: z.object({ ok: z.boolean(), producesCompletion: z.boolean() }), } as const satisfies RouteContract; +/** + * 对话卡回传(决策 / 补全 / 评价)—— 与后端回传校验**同 schema**。 + * 对话流是一切回环输入的唯一入口(OS 4.1):卡就长在对话流上,答也就地答。 + */ +export const replyChatCardRoute = { + method: 'POST', + path: '/api/desk/chat-cards/reply', + body: z.object({ reply: z.record(z.string(), z.unknown()) }), + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; + /** 负责人的自由输入 —— 人事件的第二形态 */ export const sayRoute = { method: 'POST', @@ -319,3 +380,41 @@ export const decideApprovalRoute = { } as const satisfies RouteContract; export type ListApprovalsForMeOutput = z.infer; + +/* ── 登记触发:人工起单的统一形态(OS 3.1) ─────────────────────────── */ + +/** + * 「登记触发 —— 人工起单的统一形态:登记人选择流程包、按调用接口 schema 填写输入 + * (表单由 schema 自动渲染)、提交即起单。」**登记人即来源**。 + */ +export const listRegistrationTriggersRoute = { + method: 'GET', + path: '/api/triggers/registration', + output: z.object({ + triggers: z.array( + z.object({ + id: z.string(), + name: z.string(), + processPackageId: z.string(), + enabled: z.boolean(), + /** 调用接口的入参 schema —— 登记表单据此渲染 */ + inputSchema: z.record(z.string(), z.unknown()), + }), + ), + }), +} as const satisfies RouteContract; + +export const fireRegistrationRoute = { + method: 'POST', + path: '/api/triggers/:triggerId/register', + params: z.object({ triggerId: z.string() }), + body: z.object({ data: z.record(z.string(), z.unknown()) }), + output: z.object({ + ok: z.boolean(), + workflowId: z.string().optional(), + skipped: z.string().optional(), + missing: z.array(z.string()).optional(), + }), +} as const satisfies RouteContract; + +export type ListRegistrationTriggersOutput = z.infer; diff --git a/packages/contracts/src/card.ts b/packages/contracts/src/card.ts index 83c8eb5..579e233 100644 --- a/packages/contracts/src/card.ts +++ b/packages/contracts/src/card.ts @@ -73,6 +73,11 @@ export type FormField = z.infer; /* ── 视图块:视图区的结构化呈现(活动推送,只读) ────────────────────── */ const fileDocSchema = z.discriminatedUnion('kind', [ + /** + * HTML 汇报报告 —— 成果文件的**主要形态**:活动按当下局面组织的一屏。 + * 渲染一律经 `@ui` 的 `ViewContainer` 沙箱,无脚本执行权(AM 07)。 + */ + z.object({ kind: z.literal('html'), name: z.string(), html: z.string() }), z.object({ kind: z.literal('markdown'), name: z.string(), md: z.string() }), z.object({ kind: z.literal('excel'), @@ -243,11 +248,31 @@ export const WORK_ORDER_MODE_LABEL: Readonly> = { acceptance: '验收', }; +/** + * 办结口径:执行件只有「已提交」,验收件是放行三选一 —— 与回传协议同一套标识符。 + * 界面文案由字典给出(与原型逐字一致),不在组件里散落中文。 + */ +export const WORK_ORDER_RESULTS = ['submitted', 'pass', 'redo', 'revise'] as const; +export const workOrderResultSchema = z.enum(WORK_ORDER_RESULTS); +export type WorkOrderResult = z.infer; + +export const WORK_ORDER_RESULT_LABEL: Readonly> = { + submitted: '已提交', + pass: '验收通过', + redo: '驳回重做', + revise: '驳回迭代', +}; + +/** 驳回两式 —— 退回即重来,判定处只此一处 */ +export function isRejection(result: WorkOrderResult): boolean { + return result === 'redo' || result === 'revise'; +} + /** 完成回传:谁、何时、答了什么(回传即留痕) */ export const workDoneSchema = z.object({ at: z.iso.datetime(), by: staffIdSchema, - result: z.string(), + result: workOrderResultSchema, values: z.record(z.string(), z.string()).optional(), }); export type WorkDone = z.infer; diff --git a/packages/contracts/src/org.ts b/packages/contracts/src/org.ts index 8391fa0..0dbfa35 100644 --- a/packages/contracts/src/org.ts +++ b/packages/contracts/src/org.ts @@ -107,3 +107,25 @@ export const sessionSchema = z.object({ grants: z.array(grantSchema), }); export type Session = z.infer; + +/* ── 工作组(ADR-011) ────────────────────────────────────────────────── */ + +/** + * 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。 + * + * **不是第五种负责人策略**(OS 03「策略封闭四选」不动): + * 四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,策略于是无从发问。 + * 键是**岗位 code**(BD / PM / FAE …),与流程包 `owner.position` 同一口径。 + */ +export const workGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + /** 组归谁 —— 起单时按来源人取其组 */ + ownerId: staffIdSchema, + members: z.record(z.string().min(1), staffIdSchema), +}); +export type WorkGroup = z.infer; + +/** 装进工作流的**快照**:改组不改已起的单(留痕不被改写) */ +export const workGroupBindingSchema = z.record(z.string().min(1), staffIdSchema); +export type WorkGroupBinding = z.infer; diff --git a/packages/contracts/src/process-package.ts b/packages/contracts/src/process-package.ts index b57b825..fe9a53b 100644 --- a/packages/contracts/src/process-package.ts +++ b/packages/contracts/src/process-package.ts @@ -71,6 +71,8 @@ export const edgeSchema = z.object({ mapping: z.array(fieldMappingSchema).default([]), /** 边标签:画布上的出口名(如「TYPE-A」「通过」) */ label: z.string().optional(), + /** 边标签色调(呈现):ok = 绿(通过类) / bad = 红(驳回类) */ + tone: z.enum(['ok', 'bad']).optional(), /** 回边标记:人裁决后重开新任务实例(同节点、新任务 ID、留痕关联) */ reopen: z.boolean().default(false), }); @@ -117,8 +119,20 @@ export const GATEWAY_KIND_LABEL: Readonly> = { const nodeBase = { id: nodeIdSchema, label: z.string().min(1), - /** 画布坐标 —— 布局引擎的输入;不影响语义 */ - layout: z.object({ row: z.number().int(), col: z.number().int() }).optional(), + /** 画布上的第二行小字(如「照谱执行 / 临场演绎」)—— 呈现注记,不影响语义 */ + sub: z.string().optional(), + /** + * 画布坐标 —— 布局引擎的输入;不影响语义。 + * row/col = 编译布局的行列;x/y = **手绘几何**(编辑器保存后,手绘成为呈现真相)。 + */ + layout: z + .object({ + row: z.number().int(), + col: z.number().int(), + x: z.number().optional(), + y: z.number().optional(), + }) + .optional(), }; export const processNodeSchema = z.discriminatedUnion('kind', [ @@ -134,6 +148,13 @@ export const processNodeSchema = z.discriminatedUnion('kind', [ kind: z.literal('activity'), /** 以声明入流:引用活动库中已安装的活动 */ activityId: activityIdSchema, + /** + * 契约注记(编排定契约,AM 03):这个节点**声明**吃什么、吐什么。 + * 是「导出依赖文件」的骨架 —— 开发者对着声明在系统外开发活动; + * 闭合校验的机检仍以边映射与活动契约面为准,注记是给人读的那一层。 + */ + in: z.array(z.string()).optional(), + out: z.array(z.string()).optional(), /** 版本约束:留空 = 装配时取最新生效版 */ activityVersion: z.string().optional(), owner: ownerSpecSchema, @@ -160,6 +181,8 @@ export const processNodeSchema = z.discriminatedUnion('kind', [ gateway: gatewayKindSchema, /** 分叉 / 汇合 */ mode: z.enum(['fork', 'join']), + /** 判据说明(人读):条件本体仍在出边的 S-FEEL 上,这里是「为什么这么分」 */ + basis: z.string().optional(), }), ]); export type ProcessNode = z.infer; @@ -190,6 +213,12 @@ export const processPackageSchema = z.object({ /** 流程变量声明:边映射的 workflow-var 来源 */ variables: z.array(z.object({ name: z.string().min(1), schema: jsonSchemaSchema })).default([]), description: z.string().optional(), + /** + * 手绘图存证(可空):编辑器保存的 FlowGraph 原样落此 —— **手绘几何即呈现真相**。 + * 语义(节点/边/映射)永远以上面的 nodes/edges 为准,流转判断不读它; + * 它只承载语义装不下的画布事实:卡点、行分组、锚定、折线点。 + */ + diagram: z.record(z.string(), z.unknown()).optional(), }); export type ProcessPackage = z.infer; diff --git a/packages/ui-kit/src/Ico.tsx b/packages/ui-kit/src/Ico.tsx new file mode 100644 index 0000000..7173e05 --- /dev/null +++ b/packages/ui-kit/src/Ico.tsx @@ -0,0 +1,117 @@ +// 共享内联 lucide 图标(会话面板 / 工作区驾驶舱 / 设置等共用)。 +export function Ico({ name, s = 18 }: { name: string; s?: number }) { + const p: Record = { + "chevron-right": (), + "chevron-down": (), + check: (), + "circle-check": (<>), + star: (<>), + sparkles: (<>), + terminal: (<>), + clock: (<>), + info: (<>), + "clipboard-check": (<>), + target: (<>), + "list-checks": (<>), + plus: (<>), + "panel-left-close": (<>), + "sliders-horizontal": (<>), + filter: (), + "arrow-left": (<>), + "file-spreadsheet": (<>), + "file-type": (<>), + wrench: (), + "git-branch": (<>), + search: (<>), + bell: (<>), + settings: (<>), + user: (<>), + "user-round": (<>), + bot: (<>), + dashboard: (<>), + mail: (<>), + "mail-open": (<>), + inbox: (<>), + "shield-alert": (<>), + send: (<>), + archive: (<>), + "file-text": (<>), + "pen-line": (<>), + "messages-square": (<>), + "notebook-pen": (<>), + "calendar-days": (<>), + hourglass: (<>), + play: (), + paperclip: (), + "building-2": (<>), + "panel-left-open": (<>), + "trash-2": (<>), + calculator: (<>), + /* —— 治理中心扩充图标 —— */ + gauge: (<>), + database: (<>), + layers: (<>), + users: (<>), + "share-2": (<>), + cpu: (<>), + zap: (), + "git-merge": (<>), + "refresh-cw": (<>), + "rotate-ccw": (<>), + "alert-triangle": (<>), + "shield-check": (<>), + scan: (<>), + "file-check": (<>), + "trending-up": (<>), + ban: (<>), + key: (<>), + workflow: (<>), + "arrow-right": (<>), + "circle-dot": (<>), + /* —— 治理中心补充图标(运行 / 连接 / 岗位 / 事件 / 度量)—— */ + activity: (), + route: (<>), + plug: (<>), + radio: (<>), + "git-commit": (<>), + history: (<>), + "user-check": (<>), + "user-plus": (<>), + power: (<>), + "alert-circle": (<>), + "bar-chart": (<>), + shuffle: (<>), + pin: (<>), + timer: (<>), + repeat: (<>), + briefcase: (<>), + fingerprint: (<>), + server: (<>), + network: (<>), + eye: (<>), + flag: (<>), + lock: (<>), + percent: (<>), + hash: (<>), + "external-link": (<>), + upload: (<>), + puzzle: (), + "circle-x": (<>), + "chevron-left": (), + "calendar-clock": (<>), + "file-search": (<>), + boxes: (<>), + "square-pen": (<>), + /* —— 手绘编辑器工具条 —— */ + "undo-2": (<>), + "redo-2": (<>), + "maximize-2": (<>), + x: (<>), + "log-out": (<>), + }; + return ( + + {p[name]} + + ); +} diff --git a/packages/ui-kit/src/ViewBlocks.tsx b/packages/ui-kit/src/ViewBlocks.tsx index d5ce884..46cfcbf 100644 --- a/packages/ui-kit/src/ViewBlocks.tsx +++ b/packages/ui-kit/src/ViewBlocks.tsx @@ -1,161 +1,425 @@ /** * 视图块渲染 —— 无业务语义的展示原语(编码规范 §2 packages/ui-kit)。 - * DOM 与类名自原型平移(`jc-*`),渲染输出保持 1:1。 + * + * DOM 与类名逐字取自原型 `_components/ViewRegion.tsx` 的 `Block`(2026-07-26 定稿): + * 皮肤(`proto.vendor.css` / `app.css`)是原型的原样,类名对不上就等于没样式 —— + * 所以这里**不新造类名**,块的骨架、层级、元素种类一律照搬。 */ -import type { ViewBlock } from '@contracts'; +import { useState } from 'react'; +import type { FileDoc, Tone, ViewBlock } from '@contracts'; +import { Ico } from './Ico.tsx'; -export function ViewBlocks({ blocks }: { readonly blocks: readonly ViewBlock[] }) { +/** 语义色 → 皮肤色位(原型口径:danger 走 bad,muted 走 mute) */ +const TONE_CLASS: Readonly> = { + ok: 'ok', + warn: 'warn', + danger: 'bad', + brand: 'brand', + muted: 'mute', +}; + +export type ViewBlocksProps = { + readonly blocks: readonly ViewBlock[]; + /** 报告级「修改」态:表 / 邮件就地进入编辑(存与弃由外层报告统一管) */ + readonly editing?: boolean | undefined; + readonly onOpenFile?: ((file: FileDoc) => void) | undefined; +}; + +export function ViewBlocks({ blocks, editing = false, onOpenFile }: ViewBlocksProps) { return ( <> {blocks.map((block, i) => ( - + ))} ); } -function Block({ block }: { block: ViewBlock }) { - switch (block.t) { +function Block({ + b, + editing, + onOpenFile, +}: { + b: ViewBlock; + editing: boolean; + onOpenFile?: ((file: FileDoc) => void) | undefined; +}) { + switch (b.t) { case 'callout': - return

{block.text}

; + return ( +
+ +

{b.text}

+
+ ); case 'kv': return ( -
- {block.title !== undefined &&

{block.title}

} -
- {block.items.map((item, i) => ( -
-
{item.k}
-
{item.v}
+
+
+ {b.items.map((it, i) => ( +
+ {it.k} + {it.v}
))} -
-
+ + ); case 'badges': return ( -
- {block.title !== undefined &&

{block.title}

} - {block.items.map((item, i) => ( - - {item.label} - - ))} -
+
+
+ {b.items.map((it, i) => ( + + + {it.label} + + ))} +
+
); case 'table': - return ( -
- {block.title !== undefined &&

{block.title}

} - - - - {block.cols.map((c, i) => ( - - ))} - - - - {block.rows.map((row, i) => ( - - {row.map((cell, j) => ( - - ))} - - ))} - -
{c}
{cell}
-
- ); + return ; case 'bars': return ( -
- {block.title !== undefined &&

{block.title}

} - {block.items.map((item, i) => ( -
- {item.label} - - - - {item.text ?? item.value} -
- ))} -
+
+
+ {b.items.map((it, i) => { + const max = b.max ?? Math.max(...b.items.map((x) => x.value)); + return ( +
+ {it.label} +
+ + {b.redline !== undefined && ( + + )} +
+ {it.text ?? it.value} +
+ ); + })} +
+
); case 'donut': - return ( -
- {block.title !== undefined &&

{block.title}

} -
    - {block.items.map((item, i) => ( -
  • - {item.value} - {item.label} -
  • - ))} -
-
- ); + return ; + + case 'mail': + return ; case 'cards': return ( -
- {block.title !== undefined &&

{block.title}

} - {block.items.map((item, i) => ( -
- {item.tag} -
{item.title}
- {item.swap !== undefined && ( -

- {item.swap.from} → {item.swap.to} -

- )} - {item.big} - {item.bigLabel !== undefined && {item.bigLabel}} - {item.note !== undefined &&

{item.note}

} -
- ))} -
+
+
+ {b.items.map((it, i) => ( +
+
+ + + {it.tag} + + {it.code !== undefined && {it.code}} +
+ {it.swap === undefined ? ( + {it.title} + ) : ( +
+ {it.swap.from} + + {it.swap.to} + {it.swap.hint !== undefined && {it.swap.hint}} +
+ )} +
+ {it.big} + {it.bigLabel !== undefined && {it.bigLabel}} +
+ {it.meta !== undefined && it.meta.length > 0 && ( +
+ {it.meta.map((m, k) => ( + + {m.k} + {m.v} + + ))} +
+ )} + {it.note !== undefined &&

{it.note}

} + {(it.from !== undefined || it.time !== undefined) && ( +
+ {it.from} + {it.time !== undefined && {it.time}} +
+ )} +
+ ))} +
+
); case 'waiting': return ( -

- {block.label} - {block.hint !== undefined && {block.hint}} -

- ); - - case 'mail': - return ( -
-
- {block.subject} - - {block.from} → {block.to} - - -
-
{block.body}
-
+
+ +
+ {b.label} + {b.hint !== undefined &&

{b.hint}

} +
+
+ + +
+
); case 'files': return ( -
- {block.title !== undefined &&

{block.title}

} - {block.items.map((item, i) => ( -
- {item.label} - {item.desc} -
- ))} -
+
+
+ {b.items.map((it, i) => ( + + ))} +
+
); } } + +function Section({ title, children }: { title?: string | undefined; children: React.ReactNode }) { + return ( +
+ {title !== undefined &&

{title}

} + {children} +
+ ); +} + +/** 可编辑表:报告「修改」态下就地编辑;数据源一换即复位(渲染期调整 state,不用 effect) */ +function TableBlock({ b, editing }: { b: Extract; editing: boolean }) { + const [rows, setRows] = useState(b.rows); + const [srcRows, setSrcRows] = useState(b.rows); + if (srcRows !== b.rows) { + setSrcRows(b.rows); + setRows(b.rows); + } + + const live = editing && b.editable === true; + const canEditCol = (c: number) => b.editableCols === undefined || b.editableCols.includes(c); + const donut = b.linkDonut; + + return ( +
+ {donut !== undefined && ( + ({ + label: m.label, + tone: m.tone, + value: rows.filter((r) => (r[donut.col] ?? '').includes(m.match)).length, + }))} + /> + )} +
+ + + + {b.cols.map((c, i) => ( + + ))} + + + + {rows.map((r, ri) => ( + + {r.map((cell, ci) => ( + + ))} + + ))} + +
{c}
+ {live && canEditCol(ci) ? ( + + setRows(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x))) + } + /> + ) : ( + cell + )} +
+
+
+ ); +} + +/** 环图:圆心 = 合计,图例带数值;`live` 标注随编辑联动 */ +function Donut({ + title, + unit, + items, + live = false, +}: { + title?: string | undefined; + unit?: string | undefined; + items: ReadonlyArray<{ label: string; value: number; tone: Tone }>; + live?: boolean | undefined; +}) { + const total = items.reduce((s, x) => s + x.value, 0) || 1; + const R = 52; + const C = 2 * Math.PI * R; + // 各段占比与累计偏移一次算完(纯函数式,不在渲染中改闭包变量) + const segs = items.reduce>((acc, x) => { + const prev = acc[acc.length - 1]; + return [...acc, { tone: x.tone, fr: x.value / total, off: prev === undefined ? 0 : prev.off + prev.fr }]; + }, []); + return ( +
+
+ + + {segs.map((sg, i) => ( + + ))} + + {total} + + + {unit ?? ''} + + +
    + {items.map((it, i) => ( +
  • + + {it.label} + {it.value} +
  • + ))} + {live && ( +
  • + + 随编辑实时联动 +
  • + )} +
+
+
+ ); +} + +function MailBlock({ + b, + editing, + onOpenFile, +}: { + b: Extract; + editing: boolean; + onOpenFile?: ((file: FileDoc) => void) | undefined; +}) { + const [d, setD] = useState({ subject: b.subject, to: b.to, cc: b.cc ?? '', body: b.body }); + const [src, setSrc] = useState(b); + if (src !== b) { + setSrc(b); + setD({ subject: b.subject, to: b.to, cc: b.cc ?? '', body: b.body }); + } + const edit = editing && b.editable === true; + + return ( +
+
+ + + {b.dir === 'in' ? '收件' : '发件'} + + {b.statusLabel !== undefined && {b.statusLabel}} + {b.time} +
+
+

+ 发件人 + {b.from} + {b.fromAddr !== undefined && {b.fromAddr}} +

+

+ 收件人 + {edit ? setD({ ...d, to: e.target.value })} /> : d.to} +

+ {(d.cc !== '' || edit) && ( +

+ 抄送 + {edit ? setD({ ...d, cc: e.target.value })} /> : d.cc} +

+ )} +
+ {edit ? ( + setD({ ...d, subject: e.target.value })} /> + ) : ( +

{d.subject}

+ )} + {edit ? ( +