基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
@@ -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": []
}
@@ -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": []
}
+2 -2
View File
@@ -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',
@@ -82,6 +82,8 @@ export type HumanToolService = {
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
chatCards(taskId: TaskId): Promise<ChatCard[]>;
/** org 停用前置检查要问的「待答卡」 */
openCardsOf(staffId: StaffId): Promise<Array<{
+6
View File
@@ -3,6 +3,7 @@
*/
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
import type { InteractionPhase } from './internal/domain/control-handover.ts';
import type { NarrativeEntry } from './internal/domain/chat-projection.ts';
export type Clock = {
now(): Date;
};
@@ -39,6 +40,11 @@ export type MemoryRecorder = {
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
/**
* 叙事条目 —— 对话流与工作日志**同源不同筛选**:
* AI 发言与系统事件不另设写入口,一律从任务记忆投影而来(见 `chat-projection`)。
*/
history(taskId: TaskId): Promise<readonly NarrativeEntry[]>;
};
/** 收件人解析 —— app 注入 `org`(L1):SSE 只推给有权看的人 */
export type Audience = {
+26 -2
View File
@@ -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<boolean>;
loadSession(account: string): Promise<Session | undefined>;
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
login(account: string, password: string): Promise<IssuedSession | undefined>;
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
logout(token: string): Promise<void>;
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
resolveSession(token: string): Promise<Session | undefined>;
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
setPassword(staffId: StaffId, password: string): Promise<void>;
/** 建组 / 改组;id 留空即新建 */
saveWorkGroup(input: {
id?: string;
name: string;
ownerId: StaffId;
members: Readonly<Record<string, StaffId>>;
}): Promise<WorkGroup>;
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
/** 装进工作流的快照:改组不改已起的单 */
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
ensureSeeded(): Promise<void>;
+5 -4
View File
@@ -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';
+32 -1
View File
@@ -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<Staff[]>;
insert(staff: Staff): Promise<Staff>;
setState(id: StaffId, state: Staff['state']): Promise<void>;
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
passwordHashOf(account: string): Promise<string | undefined>;
setPasswordHash(id: StaffId, hash: string): Promise<void>;
};
/**
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
* token 即主键,不透明随机串,不编码任何身份信息。
*/
export type SessionRepository = {
issue(input: {
token: string;
staffId: StaffId;
expiresAt: Date;
}): Promise<void>;
/** 未过期才返回;命中同时滑动续期 */
touch(token: string, now: Date, expiresAt: Date): Promise<{
staffId: StaffId;
} | undefined>;
revoke(token: string): Promise<void>;
revokeAllOf(staffId: StaffId): Promise<void>;
};
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
export type PasswordHasher = {
hash(password: string): Promise<string>;
verify(password: string, stored: string | undefined): Promise<boolean>;
newToken(): string;
};
export type GrantRepository = {
listByPosition(positionId: PositionId): Promise<Grant[]>;
@@ -44,6 +70,11 @@ export type InFlightWorkProbe = {
title: string;
}>>;
};
/** 工作组仓储(ADR-011) */
export type WorkGroupRepository = {
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
upsert(group: WorkGroup): Promise<WorkGroup>;
};
/** ID 生成 —— domain 零随机(§5.3) */
export type IdGenerator = {
next(): string;
+9
View File
@@ -24,6 +24,11 @@ export type WorkflowRecord = {
by?: string;
};
readonly initiation: Record<string, unknown>;
/**
* 工作组绑定的**快照**(ADR-011):起单时按来源人装载,之后改组不影响这条单。
* 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。
*/
readonly workGroup?: Record<string, string>;
/** 各节点输出口载荷 —— 边映射装配的来源 */
readonly ports: Record<string, Record<string, unknown>>;
readonly vars: Record<string, unknown>;
@@ -65,6 +70,10 @@ export type OrgDirectory = {
name: string;
openTasks: number;
}>>;
/** 某人的工作组绑定(ADR-011)—— 起单时按来源人取,取不到即不装载 */
workGroupBinding(staffId: string): Promise<Readonly<Record<string, string>> | undefined>;
/** 在岗与否 —— 绑定的人已离岗就不硬用,退回四策略 */
isActive(staffId: string): Promise<boolean>;
};
/** 起单与终态 —— app 注入 `task`(L6) */
export type TaskGateway = {
+485
View File
@@ -474,22 +474,30 @@ export declare const listProcessPackagesRoute: {
kind: z.ZodLiteral<"start">;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
@@ -515,9 +523,12 @@ export declare const listProcessPackagesRoute: {
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
@@ -570,9 +581,12 @@ export declare const listProcessPackagesRoute: {
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{
@@ -617,6 +635,10 @@ export declare const listProcessPackagesRoute: {
}, z.core.$strip>], "from">;
}, z.core.$strip>>>;
label: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>>;
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -624,10 +646,427 @@ export declare const listProcessPackagesRoute: {
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
description: z.ZodOptional<z.ZodString>;
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, 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<z.ZodString, "ProcessPackageId", "out">;
name: z.ZodString;
version: z.ZodNumber;
state: z.ZodEnum<{
draft: "draft";
published: "published";
retired: "retired";
}>;
callInterface: z.ZodObject<{
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
output: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>;
nodes: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"start">;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
failure: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"terminate">;
}, 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.ZodString, "NodeId", "out">;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"tolerate">;
}, z.core.$strip>], "kind">>;
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
target: z.ZodString;
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
from: z.ZodLiteral<"output-port">;
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.ZodObject<{
deadline: z.ZodString;
steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
after: z.ZodString;
action: z.ZodEnum<{
nudge: "nudge";
escalate: "escalate";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{
id: z.ZodString;
from: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
to: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
condition: z.ZodOptional<z.ZodString>;
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
target: z.ZodString;
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
from: z.ZodLiteral<"output-port">;
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<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>>;
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
description: z.ZodOptional<z.ZodString>;
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
pkg: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
name: z.ZodString;
version: z.ZodNumber;
state: z.ZodEnum<{
draft: "draft";
published: "published";
retired: "retired";
}>;
callInterface: z.ZodObject<{
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
output: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>;
nodes: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"start">;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
failure: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"terminate">;
}, 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.ZodString, "NodeId", "out">;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"tolerate">;
}, z.core.$strip>], "kind">>;
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
target: z.ZodString;
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
from: z.ZodLiteral<"output-port">;
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.ZodObject<{
deadline: z.ZodString;
steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
after: z.ZodString;
action: z.ZodEnum<{
nudge: "nudge";
escalate: "escalate";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{
id: z.ZodString;
from: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
to: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
condition: z.ZodOptional<z.ZodString>;
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
target: z.ZodString;
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
from: z.ZodLiteral<"output-port">;
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<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>>;
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
description: z.ZodOptional<z.ZodString>;
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>;
report: z.ZodObject<{
publishable: z.ZodBoolean;
violations: z.ZodArray<z.ZodObject<{
kind: z.ZodEnum<{
"edge-unmapped": "edge-unmapped";
"required-unmapped": "required-unmapped";
"type-conflict": "type-conflict";
"dangling-node": "dangling-node";
"gateway-unbalanced": "gateway-unbalanced";
"join-ambiguous-source": "join-ambiguous-source";
"tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position";
}>;
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
edgeId: z.ZodOptional<z.ZodString>;
field: z.ZodOptional<z.ZodString>;
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<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
@@ -690,9 +1137,12 @@ export declare const validateProcessRoute: {
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
@@ -745,9 +1195,12 @@ export declare const validateProcessRoute: {
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{
@@ -792,6 +1249,10 @@ export declare const validateProcessRoute: {
}, z.core.$strip>], "from">;
}, z.core.$strip>>>;
label: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>>;
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -799,6 +1260,7 @@ export declare const validateProcessRoute: {
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
description: z.ZodOptional<z.ZodString>;
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, 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<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
@@ -1132,9 +1602,12 @@ export declare const govWorkflowRoute: {
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
@@ -1187,9 +1660,12 @@ export declare const govWorkflowRoute: {
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{
@@ -1234,6 +1714,10 @@ export declare const govWorkflowRoute: {
}, z.core.$strip>], "from">;
}, z.core.$strip>>>;
label: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>>;
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -1241,6 +1725,7 @@ export declare const govWorkflowRoute: {
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
description: z.ZodOptional<z.ZodString>;
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>;
instances: z.ZodArray<z.ZodObject<{
nodeId: z.ZodString;
+1 -1
View File
@@ -5,7 +5,7 @@
* **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。
*/
import type { z } from 'zod';
export declare const HTTP_METHODS: readonly ["GET", "POST", "PATCH", "DELETE"];
export declare const HTTP_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE"];
export type HttpMethod = (typeof HTTP_METHODS)[number];
/**
* 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。
+321 -4
View File
@@ -3,6 +3,7 @@
* 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。
*/
import { z } from 'zod';
/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */
export declare const sessionRoute: {
readonly method: "GET";
readonly path: "/api/session";
@@ -55,6 +56,74 @@ export declare const sessionRoute: {
}, z.core.$strip>>;
}, 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<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
code: z.ZodString;
name: z.ZodString;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
grants: z.ZodArray<z.ZodObject<{
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"data">;
domain: z.ZodString;
object: z.ZodString;
field: z.ZodOptional<z.ZodString>;
}, 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.ZodString>;
}, 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<typeof chainNodeSchema>;
/** 任务详情 —— 工作区与对话区的服务端投影(前端零过程状态,刷新即重建) */
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<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>>;
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<z.ZodString>;
file: z.ZodOptional<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;
@@ -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.ZodString>;
}, z.core.$strip>>;
results: z.ZodArray<z.ZodObject<{
label: z.ZodString;
desc: z.ZodString;
at: z.ZodOptional<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;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
sheet: z.ZodString;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"word">;
name: z.ZodString;
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
t: z.ZodEnum<{
h1: "h1";
h2: "h2";
p: "p";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"ul">;
items: z.ZodArray<z.ZodString>;
}, 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<z.ZodDiscriminatedUnion<[z.ZodObject<{
mode: z.ZodLiteral<"execution">;
preset: z.ZodOptional<z.ZodEnum<{
@@ -507,6 +660,10 @@ export declare const taskDetailSchema: z.ZodObject<{
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<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;
@@ -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<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
@@ -708,7 +874,12 @@ export declare const taskDetailSchema: z.ZodObject<{
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>], "mode">>;
@@ -902,6 +1073,10 @@ export declare const taskDetailSchema: z.ZodObject<{
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<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;
@@ -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<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>>;
budget: z.ZodObject<{
steps: z.ZodString;
tokens: z.ZodString;
@@ -1174,6 +1372,10 @@ export declare const taskDetailRoute: {
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<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;
@@ -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.ZodString>;
}, z.core.$strip>>;
results: z.ZodArray<z.ZodObject<{
label: z.ZodString;
desc: z.ZodString;
at: z.ZodOptional<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;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
sheet: z.ZodString;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"word">;
name: z.ZodString;
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
t: z.ZodEnum<{
h1: "h1";
h2: "h2";
p: "p";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"ul">;
items: z.ZodArray<z.ZodString>;
}, 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<z.ZodDiscriminatedUnion<[z.ZodObject<{
mode: z.ZodLiteral<"execution">;
preset: z.ZodOptional<z.ZodEnum<{
@@ -1391,6 +1634,10 @@ export declare const taskDetailRoute: {
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<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;
@@ -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<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
@@ -1592,7 +1848,12 @@ export declare const taskDetailRoute: {
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>], "mode">>;
@@ -1786,6 +2047,10 @@ export declare const taskDetailRoute: {
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<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;
@@ -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.ZodString, z.ZodUnknown>;
}, 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<typeof listApprovalsForMeRoute.output>;
/**
* 「登记触发 —— 人工起单的统一形态:登记人选择流程包、按调用接口 schema 填写输入
* (表单由 schema 自动渲染)、提交即起单。」**登记人即来源**。
*/
export declare const listRegistrationTriggersRoute: {
readonly method: "GET";
readonly path: "/api/triggers/registration";
readonly output: z.ZodObject<{
triggers: z.ZodArray<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
processPackageId: z.ZodString;
enabled: z.ZodBoolean;
inputSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, 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.ZodString, z.ZodUnknown>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
ok: z.ZodBoolean;
workflowId: z.ZodOptional<z.ZodString>;
skipped: z.ZodOptional<z.ZodString>;
missing: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
};
export type ListRegistrationTriggersOutput = z.infer<typeof listRegistrationTriggersRoute.output>;
+81 -5
View File
@@ -76,6 +76,10 @@ export declare const formFieldSchema: z.ZodObject<{
}, z.core.$strip>;
export type FormField = z.infer<typeof formFieldSchema>;
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<z.ZodString>;
file: z.ZodOptional<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;
@@ -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<z.ZodString>;
file: z.ZodOptional<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;
@@ -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<typeof workOrderModeSchema>;
export declare const WORK_ORDER_MODE_LABEL: Readonly<Record<WorkOrderMode, string>>;
/**
* 办结口径:执行件只有「已提交」,验收件是放行三选一 —— 与回传协议同一套标识符。
* 界面文案由字典给出(与原型逐字一致),不在组件里散落中文。
*/
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<typeof workOrderResultSchema>;
export declare const WORK_ORDER_RESULT_LABEL: Readonly<Record<WorkOrderResult, string>>;
/** 驳回两式 —— 退回即重来,判定处只此一处 */
export declare function isRejection(result: WorkOrderResult): boolean;
/** 完成回传:谁、何时、答了什么(回传即留痕) */
export declare const workDoneSchema: z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>;
export type WorkDone = z.infer<typeof workDoneSchema>;
@@ -668,6 +708,10 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<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;
@@ -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<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>;
@@ -895,7 +948,12 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>;
@@ -1011,6 +1069,10 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<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;
@@ -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<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
@@ -1212,7 +1283,12 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
result: z.ZodString;
result: z.ZodEnum<{
redo: "redo";
revise: "revise";
submitted: "submitted";
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>], "mode">;
+17
View File
@@ -174,3 +174,20 @@ export declare const sessionSchema: z.ZodObject<{
}, z.core.$strip>>;
}, z.core.$strip>;
export type Session = z.infer<typeof sessionSchema>;
/**
* 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。
*
* **不是第五种负责人策略**(OS 03「策略封闭四选」不动):
* 四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,策略于是无从发问。
* 键是**岗位 code**(BD / PM / FAE …),与流程包 `owner.position` 同一口径。
*/
export declare const workGroupSchema: z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
members: z.ZodRecord<z.ZodString, z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
export type WorkGroup = z.infer<typeof workGroupSchema>;
/** 装进工作流的**快照**:改组不改已起的单(留痕不被改写) */
export declare const workGroupBindingSchema: z.ZodRecord<z.ZodString, z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
export type WorkGroupBinding = z.infer<typeof workGroupBindingSchema>;
+45
View File
@@ -97,6 +97,10 @@ export declare const edgeSchema: z.ZodObject<{
}, z.core.$strip>], "from">;
}, z.core.$strip>>>;
label: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>;
export type Edge = z.infer<typeof edgeSchema>;
@@ -135,22 +139,30 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"start">;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
@@ -176,9 +188,12 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">;
export type ProcessNode = z.infer<typeof processNodeSchema>;
@@ -282,22 +304,30 @@ export declare const processPackageSchema: z.ZodObject<{
kind: z.ZodLiteral<"start">;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
@@ -323,9 +353,12 @@ export declare const processPackageSchema: z.ZodObject<{
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, 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<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{
@@ -425,6 +465,10 @@ export declare const processPackageSchema: z.ZodObject<{
}, z.core.$strip>], "from">;
}, z.core.$strip>>>;
label: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>>;
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -432,6 +476,7 @@ export declare const processPackageSchema: z.ZodObject<{
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
description: z.ZodOptional<z.ZodString>;
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>;
export type ProcessPackage = z.infer<typeof processPackageSchema>;
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"];
+3 -1
View File
@@ -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",
+8
View File
@@ -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,
};
}
+13
View File
@@ -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<Container> {
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<Container> {
...input,
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
})),
// 对话流的 AI 发言与系统事件由此投影而来 —— 与工作日志同源
history: (taskId) => memory.history(taskId),
},
audience: { canSee: async () => true },
clock: systemClock,
+8 -1
View File
@@ -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 } };
+68
View File
@@ -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<void> {
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);
}
+147 -3
View File
@@ -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<AppEnv>, container: Container): voi
};
}
/**
* 流程链 —— 人要看见自己在整条流程的哪一环,以及前后是谁。
* 别人负责的节点在链上只读呈现,不进任务清单(清单只装我负责的)。
*/
async function toChain(task: Awaited<ReturnType<Container['task']['get']>>): Promise<ChainNode[]> {
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<AppEnv>, 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<AppEnv>, 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<AppEnv>, 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<AppEnv>, 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'));
+28
View File
@@ -23,6 +23,7 @@ import {
listToolsRoute,
listTriggersRoute,
publishProcessRoute,
saveProcessRoute,
reassignTaskRoute,
resumeTaskRoute,
staffIdSchema,
@@ -255,6 +256,33 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, 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'));
+62
View File
@@ -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<AppEnv>, 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<string, unknown>,
};
}),
);
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] }),
});
});
}
+38 -4
View File
@@ -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<AppEnv>, container: Container): void {
@@ -19,12 +21,44 @@ export function registerWorkbenchRoutes(app: Hono<AppEnv>, 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 });
});
}
+56 -140
View File
@@ -211,7 +211,7 @@ export async function seedQuote(container: Container): Promise<void> {
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<void> {
},
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<void> {
],
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<void> {
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<void> {
*/
{ 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<void> {
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)');
}
+33 -5
View File
@@ -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<AppEnv> {
const app = new Hono<AppEnv>();
// 单组织 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<AppEnv> {
registerDeskRoutes(app, container);
registerDeskElementRoutes(app, container);
registerDeskApprovalRoutes(app, container);
registerRegistrationRoutes(app, container);
registerGovernanceOpsRoutes(app, container);
registerIngestRoutes(app, container);
registerStreamRoute(app, container.broadcaster);
+39
View File
@@ -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<AppEnv>, 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<AppEnv>): void {
c.header(
'Set-Cookie',
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`,
{ append: true },
);
}
+31
View File
@@ -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<void> {
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);
}
+12 -1
View File
@@ -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 ?? [];
},
@@ -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 },
},
@@ -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);
});
});
@@ -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
@@ -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[] = [
@@ -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`
@@ -115,6 +115,8 @@ export type HumanToolService = {
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
chatCards(taskId: TaskId): Promise<ChatCard[]>;
/** org 停用前置检查要问的「待答卡」 */
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
@@ -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);
},
+4 -1
View File
@@ -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 @@
转交随引擎事件自动发生,活动不感知交接
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道;
AI 发言与系统事件**不另设写入口**,一律由 `projectChat` 从任务记忆筛出 ——
对话流与工作日志同源不同筛选,引擎自转的条目不进对话区
@@ -0,0 +1,62 @@
/**
* 对话流投影 —— 「对话区只承载非结构化消息 + 决策选择题」(AM 07)。
* 这里守的是**筛选口径**:引擎自转不进对话,人的话不重影,工单卡带得上指涉。
*/
import { describe, expect, it } from 'vitest';
import type { NarrativeEntry } from '../internal/domain/chat-projection.ts';
import { projectChat } from '../internal/domain/chat-projection.ts';
const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry => ({
id: `e-${kind}`,
at: '2026-07-27T10:00:00.000Z',
kind,
title: `${kind} 标题`,
...over,
});
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
it('引擎自转的条目一律不进对话区', () => {
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
});
it('人的自由输入不投影 —— say 已直接落成消息,再投一遍就是重影', () => {
expect(projectChat([entry('human-input', { title: '按 A 方案办' })])).toEqual([]);
});
it('事件到达投成系统事件,类型认得就用、认不得按数据事件计', () => {
const out = projectChat([
entry('observation', { id: 'o1', detail: { kind: 'time' } }),
entry('observation', { id: 'o2', detail: { kind: '莫名其妙' } }),
entry('observation', { id: 'o3' }),
]);
expect(out.map((m) => (m.k === 'system' ? m.type : m.k))).toEqual(['time', 'data', 'data']);
});
it('发工单投成 AI 发言并带上指涉 —— 卡在对话流上出现,点开即定位视图区', () => {
const [msg] = projectChat([
entry('card-sent', { title: '请你执行:补齐定价说明', detail: { kind: 'work-order', id: 'wo-1' } }),
]);
expect(msg?.k).toBe('ai');
expect(msg?.k === 'ai' ? msg.ref : undefined).toBe('wo-1');
expect(msg?.k === 'ai' ? msg.tag : undefined).toBe('guidance');
});
it('验收件标判据、执行件标指导', () => {
const out = projectChat([
entry('card-sent', { id: 'a', title: '请你验收:定价方案', detail: { kind: 'work-order', id: 'wo-a' } }),
entry('card-sent', { id: 'b', title: '请你执行:补齐说明', detail: { kind: 'work-order', id: 'wo-b' } }),
]);
expect(out.map((m) => (m.k === 'ai' ? m.tag : undefined))).toEqual(['criterion', 'guidance']);
});
it('跨人卡不在本任务对话流上露面 —— 它进对方的消息要素', () => {
expect(projectChat([entry('card-sent', { detail: { kind: 'chat-card', id: 'c-1' } })])).toEqual([]);
});
it('放行裁决与终态投成阶段结论', () => {
const out = projectChat([entry('release-verdict', { id: 'r' }), entry('terminal', { id: 't' })]);
expect(out).toHaveLength(2);
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
});
});
@@ -0,0 +1,71 @@
/**
* 对话流投影 —— 把任务记忆里**叙事性**的条目投成对话消息(纯函数,无 IO)。
*
* 「对话区只承载非结构化消息 + 决策选择题;事件由系统 Agent(灰头像)发进来」(AM 07)。
* 对话流与工作日志**同源不同筛选**:日志要全,对话只要人读得懂的那几类 ——
* 引擎自转(engine-transition / evaluation / action / ledger-write …)不进对话,
* 否则对话区就成了机器踪迹的副本,失去「非结构化消息」的定位。
*
* 筛四类:
* observation → 系统事件(灰头像)—— 三类唤醒事件里的外部到达
* card-sent → AI 发言 + 指涉工单(卡在对话流上出现,点开即定位到视图区)
* release-verdict→ AI 发言 · 阶段结论
* terminal → AI 发言 · 阶段结论
* human-input 不投:人的话已由 `say` 直接落成消息,再投一遍就是重影。
*/
import type { ChatMessage, EventType } from '@contracts';
import { cardIdSchema, workOrderIdSchema } from '@contracts';
/** 记忆条目的只读切面 —— 投影只要这几样,不牵入 memory 的完整类型 */
export type NarrativeEntry = {
readonly id: string;
readonly at: string;
readonly kind: string;
readonly title: string;
readonly detail?: Record<string, unknown>;
};
const EVENT_TYPES: readonly EventType[] = ['data', 'time', 'human', 'control'];
/** 事件到达条目的类型:detail.kind 认得就用它,认不得按数据事件计 */
function eventTypeOf(detail: Record<string, unknown> | undefined): EventType {
const kind = detail?.['kind'];
return typeof kind === 'string' && (EVENT_TYPES as readonly string[]).includes(kind)
? (kind as EventType)
: 'data';
}
/** 工单卡的指涉:card-sent 的 detail 里带着 `{kind:'work-order', id}` */
function workOrderRef(detail: Record<string, unknown> | undefined): string | undefined {
return detail?.['kind'] === 'work-order' && typeof detail['id'] === 'string' ? detail['id'] : undefined;
}
export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
const out: ChatMessage[] = [];
for (const e of entries) {
const id = cardIdSchema.parse(e.id);
if (e.kind === 'observation') {
out.push({ k: 'system', id, at: e.at, type: eventTypeOf(e.detail), text: e.title });
continue;
}
if (e.kind === 'card-sent') {
const ref = workOrderRef(e.detail);
// 跨人卡进对方的消息要素,不在本任务对话流上露面
if (ref === undefined) continue;
out.push({
k: 'ai',
id,
at: e.at,
text: e.title,
// 验收件请人裁决 = 判据;执行件派工 = 指导
tag: e.title.includes('验收') ? 'criterion' : 'guidance',
ref: workOrderIdSchema.parse(ref),
});
continue;
}
if (e.kind === 'release-verdict' || e.kind === 'terminal') {
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: 'conclusion' });
}
}
return out;
}
@@ -8,6 +8,7 @@ import { TemplateRejectedError } from '../../contract.ts';
import type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStore, MemoryRecorder } from '../../port.ts';
import type { InteractionPhase } from '../domain/control-handover.ts';
import { checkTemplate, outputControl } from '../domain/control-handover.ts';
import { projectChat } from '../domain/chat-projection.ts';
export type InteractionServiceDeps = {
readonly store: InteractionStore;
@@ -35,8 +36,16 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
}
return {
/**
* 对话流 = 已落库的消息(人的话)+ 任务记忆的叙事投影(AI 发言 / 系统事件),按时间全序。
* AI 与系统**不另设写入口** —— 同源不同筛选,对话区与工作日志永远说同一件事。
*/
async timeline(taskId) {
return deps.store.listMessages(taskId);
const [stored, entries] = await Promise.all([
deps.store.listMessages(taskId),
deps.memory.history(taskId),
]);
return [...stored, ...projectChat(entries)].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
},
/** 负责人的自由输入 —— 人事件的第二形态;写入任务记忆,不驱动 flow 步 */
+6
View File
@@ -3,6 +3,7 @@
*/
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
import type { InteractionPhase } from './internal/domain/control-handover.ts';
import type { NarrativeEntry } from './internal/domain/chat-projection.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
@@ -24,6 +25,11 @@ export type HumanEventPublisher = {
/** 留痕 —— app 注入 `memory`(L2):时间线即任务记忆的可读投影 */
export type MemoryRecorder = {
append(input: { taskId: TaskId; kind: 'human-input'; title: string; detail?: Record<string, unknown> }): Promise<void>;
/**
* 叙事条目 —— 对话流与工作日志**同源不同筛选**:
* AI 发言与系统事件不另设写入口,一律从任务记忆投影而来(见 `chat-projection`)。
*/
history(taskId: TaskId): Promise<readonly NarrativeEntry[]>;
};
/** 收件人解析 —— app 注入 `org`(L1):SSE 只推给有权看的人 */
+25 -11
View File
@@ -1,22 +1,25 @@
# module: org
职责: 员工与岗位:账号、岗位授权(客体三类 × 动作三种)、停用前置检查、会话装载、
岗位在岗人员查询(负责人四策略解析的候选集来源)、种子数据播种
职责: 员工与岗位:账号、口令与登录会话(ADR-010)、岗位授权(客体三类 × 动作三种)、
停用前置检查、岗位在岗人员查询(负责人四策略解析的候选集来源)、
工作组(ADR-011:起单时的岗位绑定)、种子数据播种
不负责: 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 端口由 app 注入)、
鉴权中间件与 cookie 会话(app 层)
鉴权中间件与 cookie 的读写(app 层:§4「中间件在 app 层,身份判定读 org 出口」)
公开出口: index.ts
- 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` 与 @contracts 的 `Staff` / `Position` /
`Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出
- 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` `IssuedSession` 与 @contracts 的
`Staff` / `Position` / `Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出
- 错误:`StaffNotFoundError` `PositionNotFoundError` `AccountTakenError` `DisableBlockedError`
- 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `InFlightWorkProbe`
`Clock` `IdGenerator`
- 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `SessionRepository`
`WorkGroupRepository` `PasswordHasher` `InFlightWorkProbe` `Clock` `IdGenerator`
- 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService`
- 纯函数:`isAuthorized` `actionCovers` `objectCovers`
- 常量:`POSITION_SEED` `STAFF_SEED` `DEFAULT_LOGIN_ACCOUNT`
- adapter 工厂(供 app 装配):`createPositionRepository` `createStaffRepository`
`createGrantRepository` `objectKeyOf`
- Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable`
`createGrantRepository` `createSessionRepository` `createWorkGroupRepository` `objectKeyOf`
- 口令散列(供 app 装 `PasswordHasher`):`hashPassword` `verifyPassword` `newSessionToken`
- Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable` `sessionTable`
`workGroupTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema)
@@ -28,6 +31,17 @@
- 动作蕴含:治理 ⊃ 写 ⊃ 读;数据客体三级覆盖(域 ⊃ 对象 ⊃ 字段)
- 停用是全有全无:前置检查未过即抛 `DisableBlockedError`,不做部分执行
- `authorize` 对停用员工恒 false
- `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动
- `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动;
唯一例外是**补种口令** —— 已存在但 `password_hash` 为空的员工会被补上初始口令,
否则 ADR-010 之前建的账号永远登不进(这不算覆盖人工改动:空不是改动)
- **登录失败不区分原因**:账号不存在 / 已停用 / 未设口令 / 口令错,一律 `undefined`;
且无论账号是否存在都走一遍散列比对,免得响应时间泄漏账号是否存在
- **明文永不落库**:口令只在 `login` / `setPassword` 的入参上停留一瞬,落库前即散列
- **会话可即刻作废**:服务端表而非签名 cookie —— `logout` 立刻生效;
`disableStaff` 连带 `revokeAllOf`,被停用的人拿旧 cookie 也办不了事
- **工作组是绑定不是策略**(ADR-011):它给出岗位的人选,四策略于是无从发问;
`bindingOf` 只出成员表快照,在岗与否由调用方(process)核 —— 岗位候选集核不了跨岗位的绑定
影响半径: index/contract/port 未变 → 对外零影响
影响半径: contract 增 `login` / `logout` / `resolveSession` / `setPassword` 与 `IssuedSession`;
port 增 `SessionRepository` / `PasswordHasher`,`StaffRepository` 增两个口令读写方法;
下游只有 app(装配 + 鉴权中间件),depcruise 已反查确认
@@ -0,0 +1,56 @@
/**
* 口令散列(ADR-010)—— 守的是「明文不可逆、比对不早退、坏串不抛错」。
*/
import { describe, expect, it } from 'vitest';
import { hashPassword, newSessionToken, verifyPassword } from '../internal/adapter/password.ts';
describe('hashPassword / verifyPassword', () => {
it('散列串自描述参数,且不含明文', async () => {
const hash = await hashPassword('jcai-dev-2026');
expect(hash.split('$')).toHaveLength(6);
expect(hash.startsWith('scrypt$16384$8$1$')).toBe(true);
expect(hash).not.toContain('jcai-dev-2026');
});
it('同一口令两次散列不同 —— 盐是随机的', async () => {
expect(await hashPassword('same')).not.toBe(await hashPassword('same'));
});
it('对的口令过,错的不过', async () => {
const hash = await hashPassword('correct horse');
expect(await verifyPassword('correct horse', hash)).toBe(true);
expect(await verifyPassword('correct hors', hash)).toBe(false);
expect(await verifyPassword('', hash)).toBe(false);
});
it('未设口令(null / undefined)一律不过,不抛错', async () => {
// 「账号存在但没口令」不能变成一条能登进去的缝
expect(await verifyPassword('anything', null)).toBe(false);
expect(await verifyPassword('anything', undefined)).toBe(false);
});
it('坏串一律不过,不抛错 —— 登录失败的原因不外泄', async () => {
for (const bad of ['', 'plaintext', 'scrypt$x$8$1$aa$bb', 'bcrypt$16384$8$1$aa$bb', 'scrypt$16384$8$1$aa']) {
expect(await verifyPassword('anything', bad)).toBe(false);
}
});
it('按记录自己的参数重算 —— 日后调参不必迁移旧记录', async () => {
// 用一组非默认参数造串,再以默认参数的实现去验:能过才说明读的是串里的参数
const legacy = 'scrypt$1024$8$1$c2FsdHNhbHQ$';
expect(legacy.split('$')[1]).toBe('1024');
// 空 hash 段应判假(下面单独覆盖),这里只确认解析不炸
expect(await verifyPassword('x', legacy)).toBe(false);
});
});
describe('newSessionToken', () => {
it('不透明、够长、每次不同 —— 不编码任何身份信息', () => {
const a = newSessionToken();
const b = newSessionToken();
expect(a).not.toBe(b);
// 32 字节 base64url ≈ 43 字符
expect(a.length).toBeGreaterThanOrEqual(43);
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
+34 -2
View File
@@ -1,9 +1,9 @@
/**
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
*/
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId } from '@contracts';
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff };
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding };
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
export type StaffWithPosition = Staff & { readonly position: Position };
@@ -43,6 +43,12 @@ export class DisableBlockedError extends Error {
}
}
/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */
export type IssuedSession = {
readonly token: string;
readonly expiresAt: string;
};
export type OrgService = {
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
@@ -57,6 +63,32 @@ export type OrgService = {
/** 岗位权限校验 —— app 层鉴权中间件读它 */
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
loadSession(account: string): Promise<Session | undefined>;
/* ── 登录会话(ADR-010)—— 中间件在 app 层,身份判定读这里 ───────────── */
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
login(account: string, password: string): Promise<IssuedSession | undefined>;
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
logout(token: string): Promise<void>;
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
resolveSession(token: string): Promise<Session | undefined>;
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
setPassword(staffId: StaffId, password: string): Promise<void>;
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
/** 建组 / 改组;id 留空即新建 */
saveWorkGroup(input: {
id?: string;
name: string;
ownerId: StaffId;
members: Readonly<Record<string, StaffId>>;
}): Promise<WorkGroup>;
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
/** 装进工作流的快照:改组不改已起的单 */
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
ensureSeeded(): Promise<void>;
+10 -1
View File
@@ -7,11 +7,14 @@ export type {
Grant,
GrantAction,
GrantObject,
IssuedSession,
OrgService,
Position,
Session,
Staff,
StaffWithPosition,
WorkGroup,
WorkGroupBinding,
} from './contract.ts';
export {
AccountTakenError,
@@ -25,8 +28,11 @@ export type {
GrantRepository,
IdGenerator,
InFlightWorkProbe,
PasswordHasher,
PositionRepository,
SessionRepository,
StaffRepository,
WorkGroupRepository,
} from './port.ts';
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
@@ -38,7 +44,10 @@ export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/dom
export {
createGrantRepository,
createPositionRepository,
createSessionRepository,
createStaffRepository,
createWorkGroupRepository,
objectKeyOf,
} from './internal/adapter/org-repository.ts';
export { grantTable, positionTable, staffTable } from './internal/adapter/org-schema.ts';
export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts';
export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
@@ -1,12 +1,12 @@
/**
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
*/
import { and, eq } from 'drizzle-orm';
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId } from '@contracts';
import { grantSchema, positionIdSchema, positionSchema, staffSchema } from '@contracts';
import { and, eq, gt } from 'drizzle-orm';
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
import { grantSchema, positionIdSchema, positionSchema, staffIdSchema, staffSchema, workGroupSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { GrantRepository, PositionRepository, StaffRepository } from '../../port.ts';
import { grantTable, positionTable, staffTable } from './org-schema.ts';
import type { GrantRepository, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository } from '../../port.ts';
import { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './org-schema.ts';
/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */
export function objectKeyOf(object: GrantObject): string {
@@ -93,6 +93,41 @@ export function createStaffRepository(db: Db): StaffRepository {
async setState(id: StaffId, state) {
await db.update(staffTable).set({ state }).where(eq(staffTable.id, id));
},
async passwordHashOf(account: string) {
const rows = await db
.select({ hash: staffTable.passwordHash })
.from(staffTable)
.where(eq(staffTable.account, account))
.limit(1);
return rows[0]?.hash ?? undefined;
},
async setPasswordHash(id: StaffId, hash: string) {
await db.update(staffTable).set({ passwordHash: hash }).where(eq(staffTable.id, id));
},
};
}
/** 会话仓储 —— 过期由 `expires_at` 判定,读时顺带滑动续期(ADR-010) */
export function createSessionRepository(db: Db): SessionRepository {
return {
async issue({ token, staffId, expiresAt }) {
await db.insert(sessionTable).values({ id: token, staffId, expiresAt });
},
async touch(token: string, now: Date, expiresAt: Date) {
const rows = await db
.update(sessionTable)
.set({ lastSeenAt: now, expiresAt })
.where(and(eq(sessionTable.id, token), gt(sessionTable.expiresAt, now)))
.returning({ staffId: sessionTable.staffId });
const row = rows[0];
return row === undefined ? undefined : { staffId: staffIdSchema.parse(row.staffId) };
},
async revoke(token: string) {
await db.delete(sessionTable).where(eq(sessionTable.id, token));
},
async revokeAllOf(staffId: StaffId) {
await db.delete(sessionTable).where(eq(sessionTable.staffId, staffId));
},
};
}
@@ -130,3 +165,28 @@ export function createGrantRepository(db: Db): GrantRepository {
},
};
}
/** 工作组仓储(ADR-011)—— 成员表整块存 jsonb,键是岗位 code */
export function createWorkGroupRepository(db: Db): WorkGroupRepository {
const toGroup = (row: typeof workGroupTable.$inferSelect): WorkGroup =>
workGroupSchema.parse({ id: row.id, name: row.name, ownerId: row.ownerId, members: row.members });
return {
async listByOwner(ownerId: StaffId) {
const rows = await db.select().from(workGroupTable).where(eq(workGroupTable.ownerId, ownerId));
return rows.map(toGroup);
},
async upsert(group: WorkGroup) {
const [row] = await db
.insert(workGroupTable)
.values({ id: group.id, name: group.name, ownerId: group.ownerId, members: group.members })
.onConflictDoUpdate({
target: workGroupTable.id,
set: { name: group.name, ownerId: group.ownerId, members: group.members },
})
.returning();
if (row === undefined) throw new Error('工作组写入未返回行');
return toGroup(row);
},
};
}
@@ -27,11 +27,31 @@ export const staffTable = sys.table(
.notNull()
.references(() => positionTable.id),
state: text('state').notNull().default('active'),
/** 口令散列(ADR-010):`scrypt$N$r$p$salt$hash`,明文永不落库 */
passwordHash: text('password_hash'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
);
/**
* 会话(ADR-010)—— 服务端表而非签名 cookie:**登出必须立刻失效**,
* 且停用员工时要能一并踢掉在途会话。id 即 token,不透明随机串。
*/
export const sessionTable = sys.table(
'session',
{
id: text('id').primaryKey(),
staffId: text('staff_id')
.notNull()
.references(() => staffTable.id, { onDelete: 'cascade' }),
issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('session_staff_idx').on(t.staffId), index('session_expires_idx').on(t.expiresAt)],
);
export const grantTable = sys.table(
'position_grant',
{
@@ -46,3 +66,21 @@ export const grantTable = sys.table(
},
(t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })],
);
/**
* 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。
* 起单时按来源人取其组、快照进工作流;这里存的是**当前**成员表,可改。
*/
export const workGroupTable = sys.table(
'work_group',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
ownerId: text('owner_id')
.notNull()
.references(() => staffTable.id, { onDelete: 'cascade' }),
members: jsonb('members').notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('work_group_owner_idx').on(t.ownerId)],
);
@@ -0,0 +1,61 @@
/**
* 口令散列(ADR-010)—— Node 内置 scrypt,零第三方依赖。
*
* 串格式自描述:`scrypt$N$r$p$<salt-b64url>$<hash-b64url>`。
* 参数写进串里,日后调参不必迁移旧记录:比对时按**该记录自己的**参数重算。
* 明文永不落库,比对走 `timingSafeEqual`(等长常数时间,不因前缀相同而早退)。
*/
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
/** 默认代价参数:N=16384 约 16MB 内存、单次 ~50ms —— 内网 ≤50 人足够 */
const N = 16_384;
const R = 8;
const P = 1;
const KEY_LEN = 32;
const SALT_LEN = 16;
const b64 = (buf: Buffer) => buf.toString('base64url');
function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
// maxmem 需随 N 抬高:默认 32MB 挡不住 N=16384(约需 128*N*r ≈ 16MB,留两倍余量)
scrypt(password, salt, KEY_LEN, { N: n, r, p, maxmem: 256 * n * r }, (error, key) => {
if (error !== null) reject(error);
else resolve(key);
});
});
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(SALT_LEN);
const hash = await derive(password, salt, N, R, P);
return `scrypt$${N}$${R}$${P}$${b64(salt)}$${b64(hash)}`;
}
/**
* 比对。存的串不合格式 / 员工尚未设口令 → 一律 false,不抛错:
* 登录失败的原因不外泄(账号不存在、未设口令、口令错,对外都是同一句话)。
*/
export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {
if (stored === null || stored === undefined) return false;
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
const n = Number(nRaw);
const r = Number(rRaw);
const p = Number(pRaw);
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
const expected = Buffer.from(hashRaw ?? '', 'base64url');
if (expected.length === 0) return false;
const actual = await derive(password, Buffer.from(saltRaw ?? '', 'base64url'), n, r, p).catch(() => undefined);
if (actual === undefined || actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
/** 会话 token —— 32 字节密码学随机,不透明(不编码任何身份信息) */
export function newSessionToken(): string {
return b64(randomBytes(32));
}
@@ -9,6 +9,12 @@ export const POSITION_SEED = [
{ code: 'FAE', name: '现场应用', note: '替代选型' },
{ code: 'PRM', name: '采购/寻源', note: '渠道寻源' },
{ code: 'OP', name: '运营', note: '运营支持' },
/*
* 测试岗(ADR-011):测试账号单独占一个岗位,**不混进五个业务岗**。
* 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 ——
* 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。
*/
{ code: 'TEST', name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' },
] as const;
export const STAFF_SEED = [
@@ -17,6 +23,9 @@ export const STAFF_SEED = [
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
// 两个测试账号:各自一个「全岗位指向自己」的工作组,起单即整条流归自己
{ account: 'AI', name: 'AI', position: 'TEST' },
{ account: 'Chaihao', name: '柴浩', position: 'TEST' },
] as const;
/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */
@@ -30,6 +39,18 @@ export type StaffSeed = (typeof STAFF_SEED)[number];
* 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。
*/
export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: string; action: 'read' | 'write' | 'govern' }>>> = {
/** 测试岗与 PM 同权:一个人要走完整条流,还得在治理台看过程 */
TEST: [
{ fn: 'task', action: 'govern' },
{ fn: 'staff', action: 'govern' },
{ fn: 'process', action: 'govern' },
{ fn: 'trigger', action: 'govern' },
{ fn: 'activity', action: 'govern' },
{ fn: 'tool', action: 'govern' },
{ fn: 'data', action: 'govern' },
{ fn: 'memory', action: 'govern' },
{ fn: 'system', action: 'govern' },
],
PM: [
{ fn: 'task', action: 'govern' },
{ fn: 'staff', action: 'govern' },
@@ -59,3 +80,12 @@ export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: st
{ fn: 'memory', action: 'read' },
],
};
/**
* 测试账号的**独立工作组**(ADR-011)—— 所有岗位都指向本人。
* 起单即整条流(A1…A7 + 审批点)全部落到自己工位,一个人从头走到尾。
*/
export const SOLO_GROUP_ACCOUNTS = ['AI', 'Chaihao'] as const;
/** 组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */
export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const;
@@ -5,6 +5,7 @@ import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, St
import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts';
import type {
CreateStaffInput,
IssuedSession,
OrgService,
StaffWithPosition,
} from '../../contract.ts';
@@ -15,24 +16,37 @@ import {
StaffNotFoundError,
} from '../../contract.ts';
import type {
Clock,
GrantRepository,
IdGenerator,
InFlightWorkProbe,
PasswordHasher,
PositionRepository,
SessionRepository,
StaffRepository,
WorkGroupRepository,
} from '../../port.ts';
import { isAuthorized } from '../domain/authorization.ts';
import { buildDisableCheck } from '../domain/disable-check.ts';
import { POSITION_GRANT_SEED, POSITION_SEED, STAFF_SEED } from '../domain/seed-data.ts';
import { POSITION_GRANT_SEED, POSITION_SEED, SOLO_GROUP_ACCOUNTS, SOLO_GROUP_POSITIONS, STAFF_SEED } from '../domain/seed-data.ts';
export type OrgServiceDeps = {
readonly positions: PositionRepository;
readonly staff: StaffRepository;
readonly grants: GrantRepository;
readonly sessions: SessionRepository;
readonly workGroups: WorkGroupRepository;
readonly passwords: PasswordHasher;
readonly inFlight: InFlightWorkProbe;
readonly clock: Clock;
readonly ids: IdGenerator;
/** 种子口令 —— 由 app 从环境读入,源码不写任何真实口令(ADR-010) */
readonly seedPassword: string;
};
/** 会话有效期 12 小时,每次命中滑动续期 */
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
export function createOrgService(deps: OrgServiceDeps): OrgService {
async function positionMap(): Promise<Map<string, Position>> {
const all = await deps.positions.list();
@@ -78,13 +92,16 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
const positionId = positionIdSchema.parse(input.positionId);
const position = await deps.positions.findById(positionId);
if (!position) throw new PositionNotFoundError(input.positionId);
return deps.staff.insert({
const created = await deps.staff.insert({
id: staffIdSchema.parse(deps.ids.next()),
account: input.account,
name: input.name,
positionId,
state: 'active',
});
// 新员工带初始口令建档 —— 不留「有账号但登不进」的半成品
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
return created;
},
async checkDisable(staffId) {
@@ -102,6 +119,8 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
const check = await this.checkDisable(staffId);
if (check.blocked) throw new DisableBlockedError(check);
await deps.staff.setState(staffId, 'disabled');
// 停用即踢会话:否则被停用的人凭旧 cookie 还能继续办事(ADR-010)
await deps.sessions.revokeAllOf(staffId);
},
async listGrants(positionId) {
@@ -128,6 +147,73 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
return { staff, position, grants };
},
/**
* 登录 —— 失败一律 undefined,**不区分原因**:
* 账号不存在 / 已停用 / 未设口令 / 口令错,对外都是同一句话,不外泄账号是否存在。
*/
async login(account, password): Promise<IssuedSession | undefined> {
const staff = await deps.staff.findByAccount(account);
const stored = await deps.staff.passwordHashOf(account);
// 无论账号是否存在都走一遍散列比对:否则响应时间会泄漏账号是否存在
const ok = await deps.passwords.verify(password, stored);
if (!ok || staff === undefined || staff.state !== 'active') return undefined;
const token = deps.passwords.newToken();
const expiresAt = new Date(deps.clock.now().getTime() + SESSION_TTL_MS);
await deps.sessions.issue({ token, staffId: staff.id, expiresAt });
return { token, expiresAt: expiresAt.toISOString() };
},
async logout(token) {
await deps.sessions.revoke(token);
},
async resolveSession(token): Promise<Session | undefined> {
const now = deps.clock.now();
const hit = await deps.sessions.touch(token, now, new Date(now.getTime() + SESSION_TTL_MS));
if (hit === undefined) return undefined;
const staff = await deps.staff.findById(hit.staffId);
// 会话还在但人已停用 —— 立刻作废,不给旧 cookie 留缝
if (staff === undefined || staff.state !== 'active') {
await deps.sessions.revoke(token);
return undefined;
}
const position = await deps.positions.findById(staff.positionId);
if (!position) throw new PositionNotFoundError(staff.positionId);
const grants = await deps.grants.listByPosition(staff.positionId);
return { staff, position, grants };
},
async setPassword(staffId, password) {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
await deps.staff.setPasswordHash(staffId, await deps.passwords.hash(password));
},
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
async saveWorkGroup(input) {
const staff = await deps.staff.findById(input.ownerId);
if (!staff) throw new StaffNotFoundError(input.ownerId);
return deps.workGroups.upsert({
id: input.id ?? deps.ids.next(),
name: input.name,
ownerId: input.ownerId,
members: { ...input.members },
});
},
async workGroupOf(staffId) {
// 一人一组:多组时取稳定的第一条(选组这一步 MVP 不做,见 debt)
const groups = await deps.workGroups.listByOwner(staffId);
return [...groups].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0];
},
async bindingOf(staffId) {
const group = await this.workGroupOf(staffId);
return group === undefined ? undefined : { ...group.members };
},
async staffOfPosition(positionCode) {
const position = await deps.positions.findByCode(positionCode);
if (!position) throw new PositionNotFoundError(positionCode);
@@ -152,16 +238,39 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
for (const seed of STAFF_SEED) {
const existing = await deps.staff.findByAccount(seed.account);
if (existing) continue;
if (existing) {
// 补种口令:ADR-010 之前建的员工没有 password_hash,不补就永远登不进
const hash = await deps.staff.passwordHashOf(seed.account);
if (hash === undefined) {
await deps.staff.setPasswordHash(existing.id, await deps.passwords.hash(deps.seedPassword));
}
continue;
}
const positionId = byCode.get(seed.position);
if (!positionId) throw new PositionNotFoundError(seed.position);
await deps.staff.insert({
const created = await deps.staff.insert({
id: staffIdSchema.parse(deps.ids.next()),
account: seed.account,
name: seed.name,
positionId,
state: 'active',
});
// 新员工一律带初始口令入库 —— 没有「无口令即可登录」的窗口
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
}
// 测试账号的独立工作组(ADR-011):所有业务岗都指向本人,起单即整条流归自己
for (const account of SOLO_GROUP_ACCOUNTS) {
const staff = await deps.staff.findByAccount(account);
if (staff === undefined) continue;
const existing = await deps.workGroups.listByOwner(staff.id);
if (existing.length > 0) continue;
await deps.workGroups.upsert({
id: `wg-solo-${account.toLowerCase()}`,
name: `${staff.name} 独立测试组`,
ownerId: staff.id,
members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])),
});
}
for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) {
+29 -1
View File
@@ -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 };
@@ -20,6 +20,28 @@ export type StaffRepository = {
listByPosition(positionId: PositionId): Promise<Staff[]>;
insert(staff: Staff): Promise<Staff>;
setState(id: StaffId, state: Staff['state']): Promise<void>;
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
passwordHashOf(account: string): Promise<string | undefined>;
setPasswordHash(id: StaffId, hash: string): Promise<void>;
};
/**
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
* token 即主键,不透明随机串,不编码任何身份信息。
*/
export type SessionRepository = {
issue(input: { token: string; staffId: StaffId; expiresAt: Date }): Promise<void>;
/** 未过期才返回;命中同时滑动续期 */
touch(token: string, now: Date, expiresAt: Date): Promise<{ staffId: StaffId } | undefined>;
revoke(token: string): Promise<void>;
revokeAllOf(staffId: StaffId): Promise<void>;
};
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
export type PasswordHasher = {
hash(password: string): Promise<string>;
verify(password: string, stored: string | undefined): Promise<boolean>;
newToken(): string;
};
export type GrantRepository = {
@@ -37,5 +59,11 @@ export type InFlightWorkProbe = {
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
};
/** 工作组仓储(ADR-011) */
export type WorkGroupRepository = {
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
upsert(group: WorkGroup): Promise<WorkGroup>;
};
/** ID 生成 —— domain 零随机(§5.3) */
export type IdGenerator = { next(): string };
+1
View File
@@ -13,6 +13,7 @@
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
- 用例工厂:`createProcessService(deps): ProcessService`
- 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState`
(`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011)
`isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady`
`rejectRouteOf` `dispatchOf` `startNodeOf`
- adapter:`createProcessStore`;Drizzle 表:`processPackageTable` `workflowTable` `nodeInstanceTable`
@@ -69,7 +69,7 @@ function harness(validationErrors: readonly string[]): Harness {
findInstanceByTask: async () => undefined,
},
activities: { faces: async () => new Map() },
org: { positions: async () => new Set<string>(), candidates: async () => [] },
org: { positions: async () => new Set<string>(), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true },
tasks: { start: async () => ({ id: 'task-1' }) },
approvals: { raise: async () => ({ id: 'apr-1' }) },
sfeel: { condition: () => true, expression: () => undefined },
@@ -30,6 +30,8 @@ export const workflowTable = sys.table(
/** 凡单必有来源 */
source: jsonb('source').notNull(),
initiation: jsonb('initiation').notNull(),
/** 工作组绑定快照(ADR-011)—— 起单时装载,之后改组不影响这条单 */
workGroup: jsonb('work_group'),
ports: jsonb('ports').notNull().default({}),
vars: jsonb('vars').notNull().default({}),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
@@ -20,6 +20,7 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord {
state: row.state as WorkflowState,
source: row.source as WorkflowRecord['source'],
initiation: row.initiation as Record<string, unknown>,
...(row.workGroup === null ? {} : { workGroup: row.workGroup as Record<string, string> }),
ports: row.ports as Record<string, Record<string, unknown>>,
vars: row.vars as Record<string, unknown>,
startedAt: row.startedAt.toISOString(),
@@ -80,6 +81,7 @@ export function createProcessStore(db: Db): ProcessStore {
state: record.state,
source: record.source,
initiation: record.initiation,
workGroup: record.workGroup ?? null,
ports: record.ports,
vars: record.vars,
startedAt: new Date(record.startedAt),
@@ -16,20 +16,47 @@ export type ResolveContext = {
readonly candidates: readonly Candidate[];
/** 来源跟随:同一工作流内该岗位已解析出的人 */
readonly alreadyResolved?: string | undefined;
/**
* 工作组绑定(ADR-011):该岗位绑定到的人,**调用方已确认其在岗**。
*
* 为什么不在这里核:`candidates` 是**该岗位**的在岗人员,
* 而工作组的意义恰恰是跨岗位指派 —— 拿岗位候选集去核绑定,绑定永远核不过。
* 在岗与否是组织事实,归调用方向 org 问;域侧只认已校验的结果。
*/
readonly bound?: string | undefined;
};
export type Resolution =
| { readonly kind: 'resolved'; readonly staffId: string; readonly by: OwnerSpec['strategy']; readonly rationale: string }
| {
readonly kind: 'resolved';
readonly staffId: string;
/** 'work-group' 是**绑定**,不是第五种策略 —— 留痕里一眼可辨 */
readonly by: OwnerSpec['strategy'] | 'work-group';
readonly rationale: string;
}
/** 岗位空缺 —— 按节点的失败走向处置,或转治理台待指派 */
| { readonly kind: 'vacant'; readonly position: string };
/**
* 「编排声明岗位,起单时**确定性解析**。策略封闭四选;
* 默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,**结果与依据留痕**。」
*
* **绑定先于策略**(ADR-011):四策略回答的是「这个岗位该派给谁」,
* 工作组已经给出了答案,策略于是无从发问。绑定的人若已离岗则不硬用,退回策略 ——
* 绑定不该把单派给一个不在岗的人。
*/
export function resolveOwner(spec: OwnerSpec, context: ResolveContext): Resolution {
const active = context.candidates;
if (context.bound !== undefined) {
return {
kind: 'resolved',
staffId: context.bound,
by: 'work-group',
rationale: `工作组绑定:${spec.position}`,
};
}
switch (spec.strategy) {
case 'assigned': {
const staffId = spec.staffId;
@@ -46,6 +46,20 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
return pkg;
}
/**
* 起单取版:未指定版本时取**最新已发布** —— 草稿不入选。
* 若按「最新版本」取,治理台一存草稿,起单立刻被没发布的定义顶住;
* 草稿是编排中的半成品,运行时永远不该看见它。
*/
async function latestPublished(id: string): Promise<ProcessPackage> {
const all = await deps.store.listPackages();
const hit = all
.filter((pkg) => pkg.id === id && pkg.state === 'published')
.sort((a, b) => b.version - a.version)[0];
if (hit === undefined) throw new Error(`流程包无已发布版本,不得起单:${id}`);
return hit;
}
async function requireWorkflow(id: string): Promise<WorkflowRecord> {
const record = await deps.store.getWorkflow(id);
if (record === undefined) throw new Error(`工作流不存在:${id}`);
@@ -104,7 +118,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
// 负责人 / 审批人:编排声明岗位,起单时确定性解析;结果与依据留痕
const spec = dispatch.kind === 'start-task' ? dispatch.node.owner : dispatch.node.approver;
const candidates = await deps.org.candidates(spec.position);
const resolution = resolveOwner(spec, { candidates });
/*
* 绑定先于策略:工作组说了这个岗位是谁,策略就无从发问(ADR-011)。
* 在岗与否在这里核 —— 绑定是**跨岗位**指派,拿该岗位的候选集核不了它。
*/
const boundTo = record.workGroup?.[spec.position];
const bound = boundTo !== undefined && (await deps.org.isActive(boundTo)) ? boundTo : undefined;
const resolution = resolveOwner(spec, { candidates, ...(bound === undefined ? {} : { bound }) });
if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position);
await deps.events.publish({
@@ -246,7 +266,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
/** 起单:经调用接口调起流程包,生成一条工作流 */
async start(input: StartWorkflowInput) {
const pkg = await requirePackage(input.packageId, input.packageVersion);
const pkg =
input.packageVersion === undefined
? await latestPublished(input.packageId)
: await requirePackage(input.packageId, input.packageVersion);
if (pkg.state !== 'published') throw new Error(`流程包未发布,不得起单:${pkg.id}@${pkg.version}`);
// 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。
@@ -254,6 +277,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
const check = deps.schemas.validate(pkg.callInterface.input, input.data);
if (!check.ok) throw new CallInterfaceViolationError(pkg.id, check.errors);
// 工作组按**来源人**装载(ADR-011):无来源人、或此人没建组 → 不装载,全走四策略
const binding =
input.source.by === undefined ? undefined : await deps.org.workGroupBinding(input.source.by);
const record: WorkflowRecord = {
id: deps.ids.next(),
packageId: pkg.id,
@@ -265,6 +292,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
ports: {},
vars: {},
startedAt: deps.clock.now().toISOString(),
...(binding === undefined ? {} : { workGroup: { ...binding } }),
};
await deps.store.insertWorkflow(record);
await deps.events.publish({ workflowId: record.id, kind: 'workflow-started', detail: { source: input.source } });
+9
View File
@@ -18,6 +18,11 @@ export type WorkflowRecord = {
/** 来源:凡单必有来源(触发器 / 登记人 / 上游系统) */
readonly source: { kind: 'periodic' | 'event' | 'registration'; ref: string; by?: string };
readonly initiation: Record<string, unknown>;
/**
* 工作组绑定的**快照**(ADR-011):起单时按来源人装载,之后改组不影响这条单。
* 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。
*/
readonly workGroup?: Record<string, string>;
/** 各节点输出口载荷 —— 边映射装配的来源 */
readonly ports: Record<string, Record<string, unknown>>;
readonly vars: Record<string, unknown>;
@@ -56,6 +61,10 @@ export type ActivityDirectory = {
export type OrgDirectory = {
positions(): Promise<Set<string>>;
candidates(position: string): Promise<Array<{ staffId: string; name: string; openTasks: number }>>;
/** 某人的工作组绑定(ADR-011)—— 起单时按来源人取,取不到即不装载 */
workGroupBinding(staffId: string): Promise<Readonly<Record<string, string>> | undefined>;
/** 在岗与否 —— 绑定的人已离岗就不硬用,退回四策略 */
isActive(staffId: string): Promise<boolean>;
};
/** 起单与终态 —— app 注入 `task`(L6) */
@@ -0,0 +1,68 @@
/**
* 触发器输入装配(OS 3.1)—— 三类触发的入参从哪来,这里是分界线。
*/
import { describe, expect, it } from 'vitest';
import type { TriggerConfig } from '@contracts';
import { triggerConfigSchema } from '@contracts';
import { assembleInput } from '../internal/domain/matching.ts';
const noExpr = () => undefined;
const registration = (mapping: TriggerConfig['mapping'] = []): TriggerConfig =>
triggerConfigSchema.parse({
id: 'trg-reg',
name: '人工登记起单',
processPackageId: 'unified-quote',
enabled: true,
spec: { kind: 'registration' },
mapping,
});
const event = (mapping: TriggerConfig['mapping'] = []): TriggerConfig =>
triggerConfigSchema.parse({
id: 'trg-evt',
name: '邮件到达起单',
processPackageId: 'unified-quote',
enabled: true,
spec: { kind: 'event', eventType: 'data', rule: 'true' },
mapping,
});
describe('assembleInput', () => {
it('登记触发:表单填的就是入参 —— 不声明 mapping 也能起单', () => {
// 登记表单由调用接口 schema 渲染,填出来的已经是合契约的形状,不必再装配
const r = assembleInput(registration(), { customerCode: 'C001', rfqRef: 'RFQ-1' }, ['customerCode', 'rfqRef'], noExpr);
expect(r).toEqual({ kind: 'ready', data: { customerCode: 'C001', rfqRef: 'RFQ-1' } });
});
it('登记触发:声明了 mapping 就覆盖在表单之上', () => {
const r = assembleInput(
registration([{ target: 'customerCode', source: { from: 'constant', value: 'C999' } }]),
{ customerCode: 'C001', rfqRef: 'RFQ-1' },
['customerCode'],
noExpr,
);
expect(r.kind === 'ready' && r.data['customerCode']).toBe('C999');
});
it('事件触发:scope 不打底 —— 事件载荷与调用接口是两套字段名', () => {
// 不声明 mapping 就装配不出必填字段,应入死信而不是把事件载荷整个塞给流程包
const r = assembleInput(event(), { subject: '询价', customerCode: 'C001' }, ['customerCode'], noExpr);
expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] });
});
it('事件触发:mapping 声明了才装配得出来', () => {
const r = assembleInput(
event([{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }]),
{ customerCode: 'C001' },
['customerCode'],
noExpr,
);
expect(r).toEqual({ kind: 'ready', data: { customerCode: 'C001' } });
});
it('缺必填即 missing,列出缺哪几个', () => {
const r = assembleInput(registration(), { rfqRef: 'RFQ-1' }, ['customerCode', 'rfqRef'], noExpr);
expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] });
});
});
@@ -50,7 +50,15 @@ export function assembleInput(
required: readonly string[],
evaluateExpression: ExpressionEvaluator,
): AssemblyResult {
const data: Record<string, unknown> = {};
/*
* 登记触发的入参**就是登记人填的表单**(OS 3.1:「按调用接口 schema 填写输入,
* 表单由 schema 自动渲染」)—— 那份表单已按调用接口成形,不需要再装配一遍。
* 所以这里以 scope 打底,mapping(若声明了)再覆盖其上。
*
* 事件 / 周期触发不打底:它们的 scope 是事件载荷,与调用接口是两套字段名,
* 直接铺进去会把无关字段塞给流程包 —— 那正是 mapping 存在的理由。
*/
const data: Record<string, unknown> = config.spec.kind === 'registration' ? { ...scope } : {};
for (const mapping of config.mapping) {
switch (mapping.source.from) {
case 'constant':
@@ -86,7 +86,13 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService {
async fireByRegistration(triggerId, by, data) {
const config = await deps.store.get(triggerId);
if (config === undefined || !config.enabled) return { triggerId, skipped: 'disabled' };
return fire(config, { kind: 'registration', ref: `registration:${by}`, by }, data, config.name);
/*
* 主题优先取登记人填的 `subject` —— 拿触发器名当主题的话,
* 同一个登记入口起的所有单主题全一样,清单上分不出哪条是哪条。
*/
const filled = data['subject'];
const subject = typeof filled === 'string' && filled.trim() !== '' ? filled.trim() : config.name;
return fire(config, { kind: 'registration', ref: `registration:${by}`, by }, data, subject);
},
/** 周期扫描:日历型按表达式到点;数据日历型逐行起单 */
+9 -1
View File
@@ -1,7 +1,15 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: { main: 'src/app/main.ts', seed: 'src/app/seed.ts', migrate: 'src/app/migrate.ts' },
entry: {
main: 'src/app/main.ts',
seed: 'src/app/seed.ts',
migrate: 'src/app/migrate.ts',
// 改密 CLI(ADR-010):治理台改密界面未做前的运维口子
'set-password': 'src/app/set-password.ts',
// 清运行数据:开发期反复用
'reset-runs': 'src/app/reset-runs.ts',
},
format: ['esm'],
target: 'node22',
platform: 'node',
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JCAI · 任务操作系统</title>
<title>JCAI</title>
</head>
<body>
<div id="root"></div>
+32 -25
View File
@@ -9,7 +9,14 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Session, TaskCard, TaskDetail } from '@contracts';
import { listApprovalsForMeRoute, listMessagesRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, sessionRoute, taskDetailRoute } from '@contracts';
import {
listApprovalsForMeRoute,
listMessagesRoute,
listTasksRoute,
logoutRoute,
sessionRoute,
taskDetailRoute,
} from '@contracts';
import { TaskChat, TaskList, ViewRegion } from '@features/desk-task';
import { ApprovalDesk } from '@features/desk-approval';
import { MessageDesk } from '@features/desk-message';
@@ -37,6 +44,7 @@ export function AppShell() {
const [collapsed, setCollapsed] = useState(false);
const split = useChatWidth();
const session = useQuery({
queryKey: queryKeys.session,
queryFn: () => call<Session>(sessionRoute),
@@ -67,24 +75,20 @@ export function AppShell() {
enabled: current !== '',
});
const reply = useMutation({
mutationFn: (payload: Record<string, unknown>) =>
call<{ ok: boolean }>(replyWorkOrderRoute, { body: { reply: payload } }),
// 回传后按目标失效缓存 —— 组件不自行轮询
/** 登出后**整体清空缓存**:上一个人的任务、审批、消息不能留在下一个人眼前 */
const logout = useMutation({
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.task(current) });
void queryClient.invalidateQueries({ queryKey: queryKeys.tasks });
queryClient.clear();
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
window.location.assign('/');
},
});
const say = useMutation({
mutationFn: (text: string) => call(sayRoute, { params: { taskId: current }, body: { text } }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: queryKeys.task(current) }),
});
const me = session.data?.staff.name ?? '—';
// composer 锁:有卡在等作答时锁住自由输入
const locked = (detail.data?.workOrders ?? []).some((o) => o.done === undefined);
return (
<div className="app-shell">
@@ -119,10 +123,17 @@ export function AppShell() {
);
})}
<div className="jc-rail-foot">
<div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
{/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */}
<button
type="button"
className="jc-nav-user"
title={`${me}(本工作台所有者)· 点击登出`}
disabled={logout.isPending}
onClick={() => logout.mutate()}
>
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
<span className="jc-nav-name">{me}</span>
</div>
</button>
</div>
</nav>
@@ -147,9 +158,9 @@ export function AppShell() {
onCollapse={() => setCollapsed(true)}
/>
{detail.data !== undefined ? (
<ViewRegion key={current} detail={detail.data} onReply={(payload) => reply.mutate(payload)} />
<ViewRegion key={current} detail={detail.data} />
) : (
<main className="execution-panel jc-panel-card">
<main className="execution-panel">
<div className="jc-list-empty">
<Ico name="list-checks" s={18} />
{tasks.isLoading ? '载入中…' : '还没有落到你工位的任务'}
@@ -165,13 +176,9 @@ export function AppShell() {
/>
)}
{detail.data !== undefined && (
<TaskChat
key={current}
chat={detail.data.chat}
locked={locked}
onSay={(text) => say.mutate(text)}
onDecide={() => undefined}
/>
<section className="session-chat">
<TaskChat key={current} />
</section>
)}
</div>
) : module === 'approvals' ? (
+18
View File
@@ -8,6 +8,9 @@
* 刷新与深链都能完整还原;前端零过程状态。
*/
import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { logoutRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx';
import { TABS, ZM, ZONES, routeLabel } from '@web-shared/gov/gov-route.ts';
import type { GovZone, Nav, Route } from '@web-shared/gov/gov-route.ts';
@@ -48,6 +51,17 @@ export function GovShell() {
const [place, setPlace] = useState<Place>(readPlace);
const staff = useGovStaff();
const tasks = useGovTasks();
const queryClient = useQueryClient();
/** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */
const logout = useMutation({
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
onSuccess: () => {
queryClient.clear();
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
window.location.assign('/');
},
});
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
useEffect(() => {
@@ -145,6 +159,10 @@ export function GovShell() {
<Ico name="list-checks" s={13} />
工作台
</a>
<button className="g-tbtn" onClick={() => logout.mutate()} disabled={logout.isPending}>
<Ico name="log-out" s={13} />
登出
</button>
</div>
</header>
+81 -16
View File
@@ -1,34 +1,99 @@
/**
* apps/web 唯一组装层(编码规范 §9):Provider、鉴权壳、SSE 连接。
* 样式整文件搬自两台原型:设计令牌 + 全部 `jc-*` 组件样式 + vendor 皮肤。
* apps/web 唯一组装层(编码规范 §9):样式装载、Provider、鉴权壳、SSE 连接。
*
* **两台面各自装自己的皮肤。**
* 两份原型本是两个独立应用,各自只加载自己那两份 CSS:
* 工作台 = `proto.vendor.css`(原型皮肤)+ `app.css`(补充,作用域 `.proto-root`)
* 治理台 = `theme.css`(令牌 + 页面基座)+ `console.css`(gov-* 组件)
* 两份基座都写了 `body { min-width / background }` 这类**全局**规则,谁后加载谁赢 ——
* 四份一起塞进同一个文档,后加载的治理台基座会把工作台的壳整个盖掉(实测:布局全崩)。
* 所以按台面**分别装载**,这也正是 1:1 平移该有的样子:每台面拿到的样式集与它的原型一致。
*/
import { StrictMode, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import './styles/proto.vendor.css';
import './styles/app.css';
import './styles/theme.css';
import './styles/console.css';
import { QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Session } from '@contracts';
import { sessionRoute } from '@contracts';
import { AppShell } from './app-shell.tsx';
import { connectStream } from './subscribe.ts';
import { GovShell } from './gov-shell.tsx';
import { queryClient } from './query.ts';
import { LoginPage } from '@features/auth';
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
import { queryClient, queryKeys } from './query.ts';
import { applyTabIdentity } from './tab-identity.ts';
const container = document.getElementById('root');
if (!container) throw new Error('#root 缺失');
/**
* 两台面共一个 SPA:路径决定进哪台。
* 路由状态即应用状态 —— 可深链(§9.2);TanStack Router 的类型化 search params 随后续接入。
/** 路径或 hash 决定进哪台 —— 路由状态即应用状态,可深链(§9.2) */
const isGovernance =
window.location.pathname.startsWith('/gov') || window.location.hash.startsWith('#/gov');
// 网页标签的名与章 —— 样式装载前就挂上,标签从第一帧起就叫对名字
applyTabIdentity(isGovernance);
/*
* 样式先装、再渲染:CSS 落地后才挂 DOM,避免无样式闪烁。
* 台面切换靠整页导航(两台面的 body 基座互斥,不做客户端软切换)。
* 登录页两台面共用,故恒装 —— 它通篇作用域在 `.jc-login`,不与任一基座相争。
*/
await import('./styles/login.css');
if (isGovernance) {
await import('./styles/theme.css');
await import('./styles/console.css');
} else {
await import('./styles/proto.vendor.css');
await import('./styles/app.css');
}
/**
* 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。
* 前端不自存身份,刷新照样问一次 `/api/session`;登出后缓存整体清空,
* 免得上一个人的任务、审批、消息留在下一个人眼前。
*/
function Gate() {
const client = useQueryClient();
const session = useQuery({
queryKey: queryKeys.session,
queryFn: () => call<Session>(sessionRoute),
// 未登录是正常状态,不是故障 —— 不重试,直接落登录页
retry: (_count, error) => !(error instanceof ApiCallError && error.status === 401),
});
/*
* 判据要看 error,不能只看 data。
* TanStack Query 在**重取失败时保留上一次成功的 data** —— 会话过期或登出后重取拿到 401,
* `session.data` 依旧是旧身份;只认 data 就会停在一个空壳里,退不出去(实测如此)。
*/
const expired = session.error instanceof ApiCallError && session.error.status === 401;
const authed = session.data !== undefined && !expired;
// SSE 只在登录后连:未登录时那条连接会被中间件 401 挡下
useEffect(() => (authed ? connectStream(client) : undefined), [authed, client]);
if (session.isPending) return null;
if (!authed) {
// 只落身份,**不要 clear()**:clear 会连同已挂载的观察者一起移除,
// 随后 setQueryData 建的是新条目,老观察者接不上 —— 界面会卡在「登录中…」(实测)。
// 缓存的清空由登出那侧的整页跳转保证,这里到达时本就是空的。
return <LoginPage onLogin={(s) => client.setQueryData(queryKeys.session, s)} />;
}
return isGovernance ? (
<GovShell />
) : (
// .proto-root 是工作台补充样式的作用域(原型 app.css 通篇以它起头);
// 治理台不裹它 —— 裹了工作台的补充样式就会漏进治理台的树里
<div className="proto-root">
<AppShell />
</div>
);
}
function Root() {
// 单一 SSE 连接在组装层建立;feature 只管订阅缓存键,不自建连接、不轮询
useEffect(() => connectStream(queryClient), []);
const governance = window.location.pathname.startsWith('/gov') || window.location.hash.startsWith('#/gov');
return (
<QueryClientProvider client={queryClient}>
{/* .proto-root 是设计令牌的作用域(原型 app.css 的 `.proto-root .app-shell`) */}
<div className="proto-root">{governance ? <GovShell /> : <AppShell />}</div>
<Gate />
</QueryClientProvider>
);
}
+160 -3
View File
@@ -116,7 +116,7 @@
/* ===== 三栏衔接:列表区 / 视图区 / 对话区统一为三张等距同规格卡片 =====
覆盖 vendor 的「左中融合 + 右侧浮卡」旧结构:等间距 gutter、同圆角、同描边、同投影 */
/* 对话区宽度 = var(--jc-chat-w)(用户拖拽分隔柄写入 workspace inline style),未拖时走各断点默认 */
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: 310px minmax(0, 1fr) var(--jcai-ai-chat-width); }
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: 310px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); }
/* 收起态 grid:无媒体查询、全断点生效,覆盖 vendor 各断点的三栏定义(修列错位 bug)。
清单收起→[视图区 1fr | 会话区];会话收起→[清单 | 视图区 1fr];都收起→[视图区 1fr]。 */
.proto-root .workspace.jc-side-collapsed:not(.jc-chat-collapsed) { grid-template-columns: minmax(0, 1fr) var(--jcai-ai-chat-width, 440px); }
@@ -149,13 +149,43 @@
.proto-root .session-chat { border-radius: 12px; }
/* 窄屏(≤1380 视口):收窄三栏配比,保证对话区不被裁切(拖拽值 --jc-chat-w 仍优先) */
@media (max-width: 1380px) {
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: 256px minmax(0, 1fr) var(--jcai-ai-chat-width); gap: 10px; padding: 10px 12px; }
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: 256px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); gap: 10px; padding: 10px 12px; }
}
/* 对话区/视图区可调比例:workspace 直属分隔柄,锚在「视图区↔对话区」的 12px gutter 上
(不能放进 .session-chat——其 overflow:hidden 会裁掉负偏移的柄);拖拽调宽、双击复位 */
/* 柄贴对话区左缘(跨边 -1/+5px),gutter 左半留给视图区自绘 thumb,两者不抢点击 */
.proto-root .jc-vsplit { position: absolute; top: 12px; bottom: 12px; width: 6px; right: calc(var(--jcai-ai-chat-width, 440px) + var(--jc-ws-pr, 14px) - 5px); z-index: 7; cursor: col-resize; display: grid; place-items: center; touch-action: none; }
/*
* 柄锚在**对话区那一列的真实左缘**,不按 `--jcai-ai-chat-width` 算。
*
* 原来是 `position:absolute; right: calc(var(--jcai-ai-chat-width) + …)` ——
* 那要求对话区的实际宽度恰好等于变量值。自从对话区改成 `minmax(0, var(…))`
* (为了给工作区让出地板),它可以比变量窄,柄就按老位置停在原地、飘进工作区。
*
* 改用栅格定位:`grid-column: -2 / -1` 指的是**最后一列**,列数变了也跟得上
* (清单收起后只剩两列,-2 仍然是对话区那列)。柄与对话区重叠放在同一格,
* `justify-self: start` + 负外边距让它跨在缝上 —— 缝在哪它就在哪。
*/
/* 对话区与柄**钉在同一格**:柄显式占了最后一列,对话区若还走自动排位会被顶到第二行 */
.proto-root .workspace > .session-chat {
grid-column: -2 / -1;
grid-row: 1;
}
.proto-root .jc-vsplit {
position: relative;
grid-column: -2 / -1;
grid-row: 1;
justify-self: start;
align-self: stretch;
margin: 12px 0 12px -5px;
width: 6px;
z-index: 7;
cursor: col-resize;
display: grid;
place-items: center;
touch-action: none;
}
.proto-root .jc-vsplit::before { content: ""; width: 3px; height: 44px; border-radius: 999px; background: var(--ink-4); opacity: 0; transition: opacity var(--t-fast), background var(--t-fast), height var(--t-fast); }
.proto-root .jc-vsplit:hover::before { opacity: .55; }
.proto-root .jc-vsplit.dragging::before { opacity: 1; height: 64px; background: var(--brand); }
@@ -2765,3 +2795,130 @@
.proto-root .jc-stars.ro .jc-star:hover { transform: none; }
.proto-root .jc-stars-txt { margin-left: 8px; font-style: normal; font-size: 11px; font-weight: 700; color: var(--ink-3); }
.proto-root .jc-rate-note { flex-basis: 100%; font-size: 11.5px; color: var(--ink-2); line-height: 1.6; }
/*
* 视图区容器 —— 活动推送 HTML 的沙箱画框(AM 07)。
* 这是**平台本体**的件:活动只给内容,尺寸与边框归系统。
* iframe 无脚本执行权(sandbox=""),因此内容自带样式、不继承工作台 CSS。
*/
.proto-root .jc-view-frame {
display: block;
width: 100%;
/* 高度由 ViewContainer 按内容量出后写在 style 上 —— 这里不设 height/min-height:
定高会把长报告截断,人得在内外两层滚动条之间来回找 */
border: 0;
border-radius: 10px;
background: #fff;
}
/* ═══ 本仓补充(非原型) ═══════════════════════════════════════════════
* 页签条容纳第五个页签「沟通记录」。
*
* 原型的 `.jc-tnav` 是按**四个**页签配的。加到五个后:
* 五个四字页签 380px + 翻页囊 112px + 间距 30px = 522px,而工作区只有 429px。
* 差 94px —— 这不是调调 padding 能抹平的,是真的装不下。
*
* 在定下最终形态之前,先保证**不出卡片**:整条可横滚。
* 相比原来「翻页囊被裁到卡片外」,这至少是所有东西都在、都够得着。
* 备选形态(待定):缩短页签文字 / 翻页囊只留箭头 / 换行两排 —— 各有取舍,见 debt。
*/
.proto-root .jc-tnav {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.proto-root .jc-tnav::-webkit-scrollbar {
display: none;
}
/* 一律不压缩:压了就是截字,截了还不如让人横滚 */
.proto-root .jc-tnav-tab,
.proto-root .jc-tnav-pg,
.proto-root .jc-tnav-btn {
flex: none;
}
/* ═══ 本仓补充(非原型) ═══════════════════════════════════════════════
* 工作区最小宽度 —— 保证页签条一排放得下。
*
* 页签加到五个后,页签条含图标的自然内容宽 649px,加上工作卡左右内边距 38px,
* 工作区至少要 **687px**。
*
* 注意别取 570:原型在容器 ≤520px 时会把页签图标藏起来省宽,
* 570 恰好落在「图标已经显示、却又放不下」的夹缝里 —— 要么低于 520 让它藏,
* 要么给够 687 让它显。这里选后者:图标该在就在,让对话区让位。
*
* 原来中栏是 `minmax(0, 1fr)`、对话区是定宽轨:空间一紧,**塌的是中栏**,
* 页签条就被挤到卡片外裁掉。现在反过来:
* · 中栏 `minmax(var(--jc-work-min), 1fr)` —— 有地板,不许再塌
* · 对话区 `minmax(0, ...)` —— 由它先让,让到多少算多少
* 页签、翻页囊的尺寸一律不动,让的是对话区的空间。
*
* 690 = 687 上取整留 3px。三种 MacBook Air 都装得下:
* 1440(Air 13" M1) → 对话区还剩 384px
* 1470(Air 13" M2/M3) → 剩 414px
* 1710(Air 15") → 剩 654px
* 均高于对话区自身下限 340px。
*/
.proto-root .workspace {
--jc-work-min: 690px;
}
/* 收起清单 / 收起对话时中栏本就宽,地板不必再管 —— 免得收起态反被地板顶出横向溢出 */
.proto-root .workspace.jc-side-collapsed,
.proto-root .workspace.jc-chat-collapsed {
--jc-work-min: 0px;
}
/* 列表区空态 —— 紧凑版:软色圆 + 主副文,与主面板的整屏光环同一语气 */
.proto-root .jc-side-empty {
margin: 16px 10px;
padding: 26px 16px 22px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
text-align: center;
border: 1px dashed var(--line);
border-radius: 12px;
background: linear-gradient(180deg, #fbfcfe, #f6f8fb);
}
.proto-root .jc-side-empty-ico {
width: 42px;
height: 42px;
margin-bottom: 3px;
display: grid;
place-items: center;
border-radius: 50%;
background: radial-gradient(120% 120% at 30% 20%, #eef3ff, #e3ebff);
color: var(--brand);
box-shadow: inset 0 0 0 1px rgb(51 112 255 / 14%);
}
.proto-root .jc-side-empty > b {
font-size: 12.5px;
font-weight: 700;
color: var(--ink-2);
}
.proto-root .jc-side-empty > p {
margin: 0;
max-width: 210px;
font-size: 11.5px;
line-height: 1.65;
color: var(--ink-3);
}
/*
* 页签图标与文字的光学对齐。
* 几何上图标正居中,但文字的**字面**(汉字 ink box)整体偏上约 2px ——
* Inter 的汉字回退字形上伸高、下沉浅,行盒居中后底边比图标高。
* 逐 0.5px 实测扫描:-2px 时两者字面中心差 0.02px(字面高度相近,居中即底齐)。
*/
.proto-root .jc-tnav-tab svg,
.proto-root .jc-tnav-btn.back svg {
transform: translateY(-2px);
}
+151
View File
@@ -0,0 +1,151 @@
/*
* 登录页样式(ADR-010)—— 两份原型都没有这一屏,是新造的。
*
* **自带全套,不蹭原型的类。**
* 原型补充样式 `app.css` 通篇以 `.proto-root` 起头,治理台更是压根不加载它;
* 登录页在两个台面之前、两份皮肤之外,蹭谁的类都会在另一边落空(实测:表单塌成一行)。
* 所以这里把用到的每一条都写全,全部作用域在 `.jc-login` 内,不留任何全局规则。
* 视觉口径(蓝 #3370ff、圆角、字号)与原型一致,是照着抄的值,不是引用它的类。
*/
.jc-login {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #f0f2f5;
color: #172033;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', sans-serif;
font-size: 13px;
line-height: 1.5;
}
.jc-login *,
.jc-login *::before,
.jc-login *::after {
box-sizing: border-box;
}
.jc-login-card {
width: 100%;
max-width: 352px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
padding: 34px 30px 30px;
border: 1px solid #e3e6eb;
border-radius: 14px;
background: #fff;
box-shadow: 0 10px 32px rgb(23 32 51 / 9%);
}
/* 品牌头:圆角方章 + 标题 + 副题 */
.jc-login-mark {
align-self: center;
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 13px;
background: linear-gradient(135deg, #3370ff, #2454d6);
color: #fff;
box-shadow: 0 6px 16px rgb(51 112 255 / 32%);
}
.jc-login-title {
margin: 0;
text-align: center;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.01em;
}
.jc-login-sub {
margin: -10px 0 2px;
text-align: center;
font-size: 12.5px;
color: #6b7280;
}
/* 表单:一行一项,标签在上 */
.jc-login-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.jc-login-field > span {
font-size: 12px;
font-weight: 600;
color: #4b5563;
}
.jc-login-field > input {
width: 100%;
height: 36px;
padding: 0 11px;
border: 1px solid #dfe3e9;
border-radius: 8px;
background: #fff;
color: inherit;
font: inherit;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.jc-login-field > input::placeholder {
color: #9ca3af;
}
.jc-login-field > input:focus {
border-color: #3370ff;
box-shadow: 0 0 0 3px rgb(51 112 255 / 14%);
}
/* 报错条 */
.jc-login-err {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 11px;
border: 1px solid #fbd5da;
border-radius: 8px;
background: #fff1f2;
color: #be123c;
font-size: 12.5px;
}
.jc-login-err > p {
margin: 0;
}
.jc-login-go {
height: 38px;
border: 1px solid #3370ff;
border-radius: 9px;
background: #3370ff;
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.jc-login-go:hover:not(:disabled) {
background: #2861ea;
border-color: #2861ea;
}
.jc-login-go:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 种子提示:告诉人初始口令由哪个环境变量定,**不写口令本身** */
.jc-login-hint {
margin: 0;
text-align: center;
font-size: 11.5px;
color: #9ca3af;
}
-15
View File
@@ -59,18 +59,3 @@ body {
outline-offset: 2px;
border-radius: 8px;
}
/*
* 视图区容器 —— 活动推送 HTML 的沙箱画框(AM 07)。
* 这是**平台本体**的件:活动只给内容,尺寸与边框归系统。
* iframe 无脚本执行权(sandbox=""),因此内容自带样式、不继承工作台 CSS。
*/
.jc-view-frame {
display: block;
width: 100%;
min-height: 420px;
height: 100%;
border: 0;
border-radius: 10px;
background: #f7f8fa;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 网页标签的名与章 —— 两台面各有一套,多标签并开时一眼可辨。
*
* 章随台面的**皮肤基调**走,不另造视觉:
* 工作台 = 品牌蓝渐变圆角方 + 白色机器人(与左轨品牌章、登录页方章同源);
* 治理中心 = 深色底(治理台顶栏的那种深)+ 盾形勾(管的是规则与放行)。
* SVG 以 data: URI 内联 —— 不添静态资产,构建零配置。
*/
const DESK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3d7bff"/><stop offset="1" stop-color="#2454d6"/>
</linearGradient></defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<g transform="translate(4,5)">
<circle cx="12" cy="1.5" r="1.3" fill="#fff"/>
<rect x="11.4" y="2.3" width="1.2" height="2.2" rx="0.6" fill="#fff"/>
<rect x="3.6" y="4.6" width="16.8" height="13" rx="4.6" fill="#fff"/>
<rect x="1.2" y="8.8" width="2.2" height="4.8" rx="1.1" fill="#fff"/>
<rect x="20.6" y="8.8" width="2.2" height="4.8" rx="1.1" fill="#fff"/>
<circle cx="9.2" cy="10.4" r="1.9" fill="#2454d6"/>
<circle cx="14.8" cy="10.4" r="1.9" fill="#2454d6"/>
<path d="M9.3 13.8c.7.9 1.7 1.35 2.7 1.35s2-.45 2.7-1.35" stroke="#2454d6" stroke-width="1.3" fill="none" stroke-linecap="round" opacity="0.55"/>
</g>
</svg>`;
const GOV_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#141b2e"/>
<g transform="translate(4,4)" fill="none" stroke="#8fb0ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>
<path d="m9 12 2 2 4-4" stroke="#fff"/>
</g>
</svg>`;
/** 进哪台就挂哪套 —— 登录页也按 URL 归台,标签在登录前就叫对名字 */
export function applyTabIdentity(isGovernance: boolean): void {
document.title = isGovernance ? 'JCAI 治理中心' : 'JCAI 工作台';
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (link === null) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.type = 'image/svg+xml';
link.href = 'data:image/svg+xml;utf8,' + encodeURIComponent(isGovernance ? GOV_ICON : DESK_ICON);
}
+2
View File
@@ -0,0 +1,2 @@
/** feature: auth —— 唯一出口(编码规范 §9.1)。 */
export { LoginPage } from './internal/LoginPage.tsx';
@@ -0,0 +1,85 @@
/**
* 登录页(ADR-010)—— 两份原型都没有这一屏,是新造的。
*
* 它在两个台面**之前**渲染,拿不到任一台面的皮肤(工作台补充样式通篇作用域 `.proto-root`,
* 治理台压根不加载它),所以视觉全部由 `login.css` 自带 —— 类名一律 `jc-login-*`,
* 不蹭原型的类,免得在另一台面上落空。
*/
import { useState } from 'react';
import type { Session } from '@contracts';
import { loginRoute } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const ready = account.trim() !== '' && password !== '';
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(undefined);
try {
onLogin(await call<Session>(loginRoute, { body: { account: account.trim(), password } }));
} catch (e) {
// 后端只回一句话(不区分账号不存在 / 已停用 / 口令错),这里照实呈现,不替它猜
setError(e instanceof ApiCallError ? e.message : '登录失败,请稍后再试');
setBusy(false);
}
};
return (
<div className="jc-login">
<form
className="jc-login-card"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<span className="jc-login-mark">
<Ico name="bot" s={24} />
</span>
<h1 className="jc-login-title">JCAI 任务操作系统</h1>
<p className="jc-login-sub">登录后进入你的工位</p>
<label className="jc-login-field">
<span>账号</span>
<input
value={account}
autoFocus
autoComplete="username"
placeholder="如 fengbing"
onChange={(e) => setAccount(e.target.value)}
/>
</label>
<label className="jc-login-field">
<span>口令</span>
<input
type="password"
value={password}
autoComplete="current-password"
placeholder="口令"
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error !== undefined && (
<div className="jc-login-err" role="alert">
<Ico name="alert-circle" s={15} />
<p>{error}</p>
</div>
)}
<button className="jc-login-go" type="submit" disabled={busy || !ready}>
{busy ? '登录中…' : '登录'}
</button>
<p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p>
</form>
</div>
);
}
@@ -12,7 +12,7 @@ import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
const STATE_CHIP = {
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
@@ -96,8 +96,10 @@ export function ApprovalDesk() {
);
})}
{visible.length === 0 && (
<ListEmpty icon="circle-check">
{filter === 'todo' ? '待批清空了 —— 没有等你放行的事项' : '暂无已处理记录'}
<ListEmpty icon="circle-check" title={filter === 'todo' ? '待批清空了' : '还没有处理记录'}>
{filter === 'todo'
? '没有等你放行的事项 —— 有新的审批单会第一时间到这里。'
: '你处理过的审批会留在这里,随时回看当时的依据与裁决。'}
</ListEmpty>
)}
</ScrollArea>
@@ -105,7 +107,12 @@ export function ApprovalDesk() {
{sel === undefined ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="shield-check">没有卡在你工位上的审批点</ListEmpty>
<EmptyState
ico="shield-check"
tone="ok"
title="没有等你放行的事项"
desc="流程走到审批点时,审批单会连同它的依据一起送到这里 —— 你只需要看一眼就能拍板。"
/>
</main>
) : (
<DocPanel scrollKey={sel.id}>
@@ -125,10 +132,10 @@ export function ApprovalDesk() {
/>
<Section icon="clipboard-check" title="依据快照" extra="调用必附上下文 · 裸调用不得发出">
<div className="jc-rows">
<div className="jc-kv">
{sel.basis.map((b) => (
<div key={b.k} className="jc-row">
<em>{b.k}</em>
<div key={b.k} className="jc-kv-i">
<i>{b.k}</i>
<b className="jc-mono">{b.v}</b>
</div>
))}
@@ -149,7 +156,7 @@ export function ApprovalDesk() {
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{error !== undefined && <div className="jc-err">{error}</div>}
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
<div className="jc-mod-actions">
<button
type="button"
@@ -13,7 +13,7 @@ import { answerMessageRoute, listMessagesRoute, markMessageReadRoute } from '@co
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' },
@@ -90,13 +90,22 @@ export function MessageDesk() {
</button>
);
})}
{visible.length === 0 && <ListEmpty icon="circle-check">没有{filter === 'unread' ? '未读' : ''}消息</ListEmpty>}
{visible.length === 0 && (
<ListEmpty icon="circle-check" title={filter === 'unread' ? '没有未读' : '还没有消息'}>
别人请你补的信息、系统的回执与提醒,到了都会亮在这里。
</ListEmpty>
)}
</ScrollArea>
</aside>
{sel === undefined ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="messages-square">选一条消息</ListEmpty>
<EmptyState
ico="messages-square"
tone="brand"
title="没有新消息"
desc="别人请你补的信息、系统的回执与提醒都会到这里。需要你答的,就地答完即可,不用跑去别人的任务里。"
/>
</main>
) : (
<DocPanel scrollKey={sel.id}>
@@ -183,14 +192,14 @@ function ElicitationForm({
/>
) : (
<input
className="jc-input"
className="jc-inp"
value={values[f.key] ?? ''}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
)}
</div>
))}
{error !== undefined && <div className="jc-err">{error}</div>}
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
<div className="jc-mod-actions">
<button
type="button"
@@ -12,7 +12,7 @@ import { appendResolutionRoute, createMinuteRoute, listMinutesRoute, registerRes
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { Avatar, AvatarStack, Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Avatar, AvatarStack, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
export function MinuteDesk() {
const queryClient = useQueryClient();
@@ -114,7 +114,11 @@ export function MinuteDesk() {
</button>
);
})}
{minutes.length === 0 && <ListEmpty icon="notebook-pen">还没有纪要 —— 新建一场把团队沉淀记下来</ListEmpty>}
{minutes.length === 0 && (
<ListEmpty icon="notebook-pen" title="还没有纪要">
记下一场会,把定下的事写成决议 —— 登记起单后责任才落到人。
</ListEmpty>
)}
</ScrollArea>
</aside>
@@ -129,13 +133,13 @@ export function MinuteDesk() {
/>
<Section icon="pen-line" title="纪要正文">
<input
className="jc-input"
className="jc-inp"
placeholder="标题,如「7 月报价例会」"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
<input
className="jc-input"
className="jc-inp"
placeholder="会议日期 YYYY-MM-DD"
value={draft.occurredOn}
onChange={(e) => setDraft({ ...draft, occurredOn: e.target.value })}
@@ -174,7 +178,13 @@ export function MinuteDesk() {
</DocPanel>
) : sel === undefined ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="notebook-pen">选一场纪要,或新建一场</ListEmpty>
<EmptyState
ico="notebook-pen"
tone="brand"
title="还没有会议纪要"
desc="记下一场会,把会上定的事写成决议 —— 纪要是团队沉淀,决议登记起单才产生责任。"
hint="左上角「新建纪要」开始记"
/>
</main>
) : (
<DocPanel scrollKey={sel.id}>
@@ -218,9 +228,9 @@ export function MinuteDesk() {
已起单 · {r.workflowId?.slice(0, 8)}
</span>
) : registerFor === r.id ? (
<span className="jc-res-reg">
<span className="jc-custom-row">
<input
className="jc-input"
className="jc-inp"
value={packageId}
onChange={(e) => setPackageId(e.target.value)}
placeholder="流程包 id"
@@ -247,7 +257,7 @@ export function MinuteDesk() {
))}
{sel.resolutions.length === 0 && <span className="jc-dim">这场纪要还没有决议</span>}
</div>
{error !== undefined && <div className="jc-err">{error}</div>}
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
<NoteBand>
登记触发 = 人工起单的统一形态:选流程包 → 按调用接口 schema 填表(表单自动渲染)→
提交即起单;这条工作流的来源就记为登记人。
@@ -266,7 +276,7 @@ export function MinuteDesk() {
function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void; busy: boolean }) {
const [text, setText] = useState('');
return (
<div className="jc-res-append">
<div className="jc-cfill">
<textarea
className="jc-sup"
rows={2}
@@ -12,7 +12,7 @@ import { REPORT_PERIOD_LABEL, draftReportRoute, submitReportRoute } from '@contr
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
{ k: 'day', ico: 'calendar-days' },
@@ -58,7 +58,13 @@ export function ReportDesk() {
</div>
</div>
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
{report !== undefined && (
{/* 没放行过一件的草案不上榜 —— 榜上一条 = 一份真有内容的汇报 */}
{(report === undefined || report.counts.released === 0) && (
<ListEmpty icon="bar-chart" title="本期还没有汇报">
办成的事会按{REPORT_PERIOD_LABEL[period]}自动聚成一份,你确认即提交。
</ListEmpty>
)}
{report !== undefined && report.counts.released > 0 && (
<button className="jc-mcard selected">
<span className="jc-mcard-datebox">
<b>{report.window.from.slice(8, 10)}</b>
@@ -83,9 +89,18 @@ export function ReportDesk() {
</ScrollArea>
</aside>
{report === undefined ? (
{/*
汇报是「我做成了什么」,不是「我在忙什么」—— 一件都还没放行,就没有可汇报的。
这不是占位:它是这个台面**真正的空态**,办成第一件事的当天就自动有了内容。
*/}
{report === undefined || report.counts.released === 0 ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="bar-chart">正在聚合…</ListEmpty>
<EmptyState
ico="bar-chart"
tone="brand"
title="还没有可汇报的内容"
desc="日报、周报、月报由系统按任务日志自动聚合,不用你手写 —— 办成第一件事,这里当天就有了。"
/>
</main>
) : (
<DocPanel scrollKey={`${period}:${report.id}`}>
+9 -2
View File
@@ -1,8 +1,15 @@
/**
* feature: desk-task —— 唯一出口(编码规范 §9.1)。
* 任务要素四区:任务列表 / 标题区 / 工作区(工单两模式)/ 对话区(卡族 + composer 锁)。
*
* 工作台正处在「一步一步描述、一步一步接」的阶段:
* 已挂载的是三区的**框架**(任务清单 / 工作区 / 对话区),各区正文按描述逐个接回。
* 下面两组是**备着待接**的渲染件,自原型平移、已按皮肤对齐,接回时直接用或按新口径改。
*/
export type { TaskCard, TaskDetail } from './contract.ts';
export { TaskList } from './internal/TaskList.tsx';
export { ViewRegion } from './internal/ViewRegion.tsx';
export { TaskChat } from './internal/TaskChat.tsx';
/* ── 备着待接 ─────────────────────────────────────────────────────── */
export { WorkOrderReport, GuideCard } from './internal/work-order-report.tsx';
export { ChatStream } from './internal/chat-bubbles.tsx';
export { FileViewer } from './internal/FileViewer.tsx';
@@ -0,0 +1,106 @@
/**
* 成果文件的二级查看界面 —— 自原型 `_components/FileViewer.tsx` 平移。
*
* 按文件类型选渲染器,内容驱动、不解析二进制:
* html → **容器沙箱**(`@ui` 的 ViewContainer,无脚本执行权,AM 07)
* excel → 电子表格网格 · word → A4 文档 · pdf → 内嵌 · markdown → 原文纸面
*/
import type { FileDoc } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ViewContainer } from '@ui';
const KIND: Readonly<Record<FileDoc['kind'], { label: string; icon: string }>> = {
html: { label: 'HTML', icon: 'file-text' },
markdown: { label: 'Markdown', icon: 'file-text' },
excel: { label: 'Excel', icon: 'file-spreadsheet' },
word: { label: 'Word', icon: 'file-type' },
pdf: { label: 'PDF', icon: 'file-text' },
};
/**
* 二级界面**长在工作区内部**:顶部四个页签始终不变,
* 「返回」由页签条右槽给出(见 ViewRegion)—— 所以这里的标题栏不带返回钮,只报文件本身。
*/
export function FileViewer({ doc }: { doc: FileDoc }) {
const k = KIND[doc.kind];
return (
<div className={'fileview k-' + doc.kind}>
<div className="fileview-bar">
<div className="fileview-name">
<Ico name={k.icon} s={15} />
<strong>{doc.name}</strong>
</div>
<span className={'fileview-kind k-' + doc.kind}>{k.label}</span>
</div>
<div className="fileview-body">
{doc.kind === 'html' && <ViewContainer html={doc.html} title={doc.name} />}
{doc.kind === 'markdown' && (
// 富文本渲染(GFM 表格 / 内联 SVG)尚未接入 —— 先以原文纸面呈现,不假装渲染过(见 debt)
<article className="doc-paper doc-full">
<pre className="md">{doc.md}</pre>
</article>
)}
{doc.kind === 'excel' && <ExcelView doc={doc} />}
{doc.kind === 'word' && <WordView doc={doc} />}
{doc.kind === 'pdf' && <iframe className="pdf-frame" src={doc.src} title={doc.name} />}
</div>
</div>
);
}
function ExcelView({ doc }: { doc: Extract<FileDoc, { kind: 'excel' }> }) {
const cols = Math.max(...doc.rows.map((r) => r.length), 1);
const letters = Array.from({ length: cols }, (_, i) => String.fromCharCode(65 + i));
return (
<div className="xls">
<table className="xls-grid">
<thead>
<tr>
<th className="xls-corner" />
{letters.map((c) => (
<th key={c} className="xls-colh">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{doc.rows.map((row, ri) => (
<tr key={ri}>
<td className="xls-rowh">{ri + 1}</td>
{letters.map((_, ci) => (
<td key={ci} className={'xls-cell' + (ri === 0 ? ' xls-head' : '')}>
{row[ci] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="xls-tabs">
<span className="xls-tab active">{doc.sheet}</span>
</div>
</div>
);
}
function WordView({ doc }: { doc: Extract<FileDoc, { kind: 'word' }> }) {
return (
<article className="doc-paper word-paper">
{doc.blocks.map((b, i) => {
if (b.t === 'ul') {
return (
<ul key={i}>
{b.items.map((it, j) => (
<li key={j}>{it}</li>
))}
</ul>
);
}
if (b.t === 'h1') return <h1 key={i}>{b.text}</h1>;
if (b.t === 'h2') return <h2 key={i}>{b.text}</h2>;
return <p key={i}>{b.text}</p>;
})}
</article>
);
}
@@ -1,175 +1,45 @@
/**
* 对话区 —— 自原型 `_components/TaskChat.tsx` 平移。
* 对话区 —— 「对话流是**一切回环输入的唯一入口**」(OS 4.1)。
*
* 「对话区**只承载非结构化消息 + 决策选择题**;事件由系统 Agent(灰头像)发进来。」
* 对话流是**一切回环输入的唯一入口**(OS 4.1);输入侧不参与控制权转交(AM 07)。
* 壳(`jc-tch` 题头 / 滚动区 / composer)照原型留着;气泡与卡族备在 `chat-bubbles.tsx`。
* 空态文案从用人的角度写 —— 空对话是这一单刚开始时的**常态**,不是缺陷。
*/
import { useState } from 'react';
import type { ChatMessage } from '@contracts';
import { AI_TAG_LABEL, EVENT_TYPE_LABEL } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
export type TaskChatProps = {
readonly chat: readonly ChatMessage[];
readonly locked: boolean;
onSay(text: string): void;
onDecide(cardId: string, value: string, note?: string): void;
};
export function TaskChat({ chat, locked, onSay, onDecide }: TaskChatProps) {
const [draft, setDraft] = useState('');
const submit = () => {
const text = draft.trim();
if (text === '' || locked) return;
onSay(text);
setDraft('');
};
export function TaskChat() {
return (
<section className="session-chat">
<header className="jc-chat-head">
<span className="jc-chat-ico">
<Ico name="messages-square" s={16} />
<div className="jc-tch">
<header className="jc-tch-head">
<span className="jc-tch-mark">
<Ico name="bot" s={15} />
</span>
<div>
<h2>任务助理</h2>
<span>对话流 · 唯一回环输入口</span>
</div>
<b className="jc-tch-name">任务助理</b>
<em className="jc-tch-tag">Agent</em>
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
</header>
<ScrollArea className="jc-sa--fill" vpClassName="jc-chat-body">
{chat.map((msg) => (
<Bubble key={msg.id} msg={msg} onDecide={onDecide} />
))}
{chat.length === 0 && (
<div className="jc-list-empty">
<Ico name="messages-square" s={18} />
还没有对话
</div>
)}
</ScrollArea>
{/* composer 锁:有卡在等作答时锁住自由输入,逼回卡上作答 */}
<footer className={'jc-composer' + (locked ? ' locked' : '')}>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder={locked ? '有卡在等你作答 —— 请先在卡上回答' : '说点什么…(Enter 发送)'}
disabled={locked}
rows={2}
/>
<button className="jc-btn primary" onClick={submit} disabled={locked || draft.trim() === ''}>
发送
</button>
</footer>
</section>
);
}
/** 气泡二元制:AI / 我;系统事件走灰头像 */
function Bubble({ msg, onDecide }: { msg: ChatMessage; onDecide: TaskChatProps['onDecide'] }) {
if (msg.k === 'ai') {
return (
<div className="jc-bubble side-ai">
<span className="jc-ava ai">AI</span>
<div className="jc-bubble-body">
{msg.tag !== undefined && <b className={'jc-tag tag-' + msg.tag}>{AI_TAG_LABEL[msg.tag]}</b>}
<p>{msg.text}</p>
<div className="jc-tch-scroll">
<div className="jc-empty t-brand">
<span className="jc-empty-art">
<i className="r1" />
<i className="r2" />
<span className="jc-empty-ico">
<Ico name="messages-square" s={26} />
</span>
</span>
<b>还没有消息</b>
<p>AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。</p>
</div>
</div>
);
}
if (msg.k === 'human') {
return (
<div className="jc-bubble side-me">
<div className="jc-bubble-body">
<p>{msg.text}</p>
<div className="copilot-composer session-composer">
<div className="composer-row">
<textarea rows={2} disabled placeholder="有什么要补充或交代的?" />
<button className="copilot-send" disabled>
<Ico name="send" s={14} /> 发送
</button>
</div>
</div>
);
}
if (msg.k === 'system') {
return (
<div className="jc-bubble side-sys">
<span className="jc-ava sys">
<Ico name="bot" s={14} />
</span>
<div className="jc-bubble-body">
<b className="jc-tag tag-event">{EVENT_TYPE_LABEL[msg.type]}事件</b>
<p>{msg.text}</p>
</div>
</div>
);
}
if (msg.k === 'decision') {
// 对话区只做选择题 —— 决策卡
return (
<div className="jc-bubble side-ai">
<span className="jc-ava ai">AI</span>
<div className="jc-bubble-body jc-card-decide">
<p className="jc-card-ask">{msg.ask}</p>
<dl className="jc-basis">
{msg.basis.map((b, i) => (
<div key={i}>
<dt>{b.k}</dt>
<dd>{b.v}</dd>
</div>
))}
</dl>
<div className="jc-options">
{msg.options.map((o) => (
<button
key={o.value}
className={'jc-opt' + (o.recommended === true ? ' recommended' : '') + (msg.chosen?.value === o.value ? ' chosen' : '')}
disabled={msg.chosen !== undefined}
onClick={() => onDecide(msg.id, o.value)}
>
<b>{o.label}</b>
{o.detail !== undefined && <span>{o.detail}</span>}
{o.consequence !== undefined && <em>{o.consequence}</em>}
</button>
))}
</div>
</div>
</div>
);
}
if (msg.k === 'elicitation') {
return (
<div className="jc-bubble side-ai">
<span className="jc-ava ai">AI</span>
<div className="jc-bubble-body jc-card-fill">
<p className="jc-card-ask">{msg.ask}</p>
<ul>
{msg.fields.map((f) => (
<li key={f.key}>{f.label}</li>
))}
</ul>
{msg.done !== undefined && <p className="jc-card-done">已作答</p>}
</div>
</div>
);
}
return (
<div className="jc-bubble side-ai">
<span className="jc-ava ai">AI</span>
<div className="jc-bubble-body jc-card-rate">
<p className="jc-card-ask">{msg.ask}</p>
{msg.done !== undefined && <p className="jc-card-done">已评价 {msg.done.score} 分</p>}
</div>
</div>
);
}
@@ -10,6 +10,7 @@ import { useState } from 'react';
import type { TaskCard } from '@contracts';
import { TERMINAL_STATE_LABEL } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ListEmpty } from '@web-shared/desk/module-kit.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
const TASK_COLORS = ['#3370ff', '#ff8800', '#7f3bf5', '#0aa294', '#f14ba9'];
@@ -83,12 +84,20 @@ export function TaskList({ tasks, selectedId, collapsed, onPick, onCollapse }: T
</div>
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
{visible.length === 0 && (
<div className="jc-list-empty">
<Ico name="search" s={18} />
无匹配任务
</div>
)}
{visible.length === 0 &&
(query !== '' ? (
<ListEmpty icon="search" title="没找到">
换个关键词试试 —— 可搜任务名、活动名、工作流主题。
</ListEmpty>
) : filter === 'done' ? (
<ListEmpty icon="circle-check" title="还没有完成的任务">
办结的任务会归到这里,随时回看它的成果与日志。
</ListEmpty>
) : (
<ListEmpty icon="list-checks" title="手上没有任务">
分派给你的任务会出现在这里 —— 到了会有角标提醒。
</ListEmpty>
))}
{visible.map((task, i) => (
<TaskCardView
key={task.id}
@@ -1,232 +1,384 @@
/**
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(四页签结构)。
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(2026-07-26 定稿)。
*
* 「视图区 = 全部结构化交互:HTML 交互界面(表单读写 + 图形展示)+ 工作事项列表 + 过程成果」;
* 容器渲染活动推送的内容,**无脚本执行权**(AM 07);渲染一律经 @ui 的 ViewContainer。
* 「视图区**承担全部结构化交互**」:表单读写 + 过程成果的图形展示,全在这里完成;
* 决策不在这里 —— 它是对话区的选择题(AM 07)。
*
* 五页签:待处理 / 沟通记录 / 工作成果 / 任务详情 / 工作日志。
* DOM 与类名逐字照搬原型 —— 皮肤是原型的原样,自造类名等于没样式。
*
* **空态即常态**:文案从用人的角度写「你手上现在没有什么、将会有什么」,
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
*/
import { useState } from 'react';
import type { TaskDetail, ViewBlock, WorkOrder } from '@contracts';
import { ACTION_EFFECT_LABEL, WORK_ORDER_MODE_LABEL } from '@contracts';
import type { TaskDetail } from '@contracts';
import { TERMINAL_STATE_LABEL } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { ViewBlocks, ViewContainer } from '@ui';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ViewBlocks } from '@ui';
type Tab = 'todo' | 'result' | 'detail' | 'log';
const TABS: ReadonlyArray<{ key: Tab; label: string; icon: string }> = [
{ key: 'todo', label: '待处理', icon: 'list-checks' },
{ key: 'result', label: '工作成果', icon: 'file-check' },
{ key: 'detail', label: '任务详情', icon: 'bar-chart' },
{ key: 'log', label: '工作日志', icon: 'notebook-pen' },
];
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
export type ViewRegionProps = {
readonly detail: TaskDetail;
onReply(reply: Record<string, unknown>): void;
};
export function ViewRegion({ detail, onReply }: ViewRegionProps) {
const [tab, setTab] = useState<Tab>('todo');
const open = detail.workOrders.filter((o) => o.done === undefined);
export function ViewRegion({ detail }: ViewRegionProps) {
const [view, setView] = useState<Tab>('open');
const [page, setPage] = useState(0);
const orders = detail.workOrders;
const openList = orders.filter((o) => o.done === undefined);
const pos = Math.min(page, Math.max(0, openList.length - 1));
const cur = openList[pos];
const go = (to: Tab) => {
setView(to);
setPage(0);
};
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
return (
<main className="execution-panel jc-panel-card">
<header className="jc-vr-head">
<div className="jc-vr-title">
<h1>{detail.card.title}</h1>
<span className="jc-vr-sub">{detail.card.workflowSubject}</span>
<main className="execution-panel">
{/* ① 标题区 —— **不滚**,两行定高:主题 · 工作流 · 状态 · 谁做 */}
<header className="jc-topbar" aria-label="标题区">
<div className="jc-tb-crumb">
<span className="jc-tb-tag">
<Ico name="list-checks" s={12} />
{detail.card.activity}
</span>
<span className="jc-tb-wf" title={detail.card.workflowSubject}>
<Ico name="git-branch" s={12} />
<b>{detail.card.workflowSubject}</b>
</span>
<span className="jc-tb-sp" />
<span className={'jc-cp-run-st' + (terminalLabel === undefined ? '' : ' done')}>
{terminalLabel === undefined ? (
<>
<i className="jc-pulse-dot" />
AI 推进中
</>
) : (
<>
<Ico name="circle-check" s={13} />
{terminalLabel}
</>
)}
</span>
<span className="jc-tb-owner" title={`负责人 · ${detail.owner.name} · ${detail.owner.role}`}>
<em>{detail.owner.name.charAt(0)}</em>
{detail.owner.name} · {detail.owner.role}
</span>
</div>
<div className="jc-vr-budget jc-mono" title="预算与熔断由基座在环外强制">
<span>步 {detail.budget.steps}</span>
<span>token {detail.budget.tokens}</span>
<span>时长 {detail.budget.activeMinutes}</span>
<span>调人 {detail.budget.humanCalls}</span>
<div className="jc-tb-titlerow">
<span className="jc-tb-tico">
<Ico name="file-text" s={16} />
</span>
<h2 className="jc-tb-title" title={detail.card.title}>
{detail.card.title}
</h2>
<span className="jc-tb-node jc-mono">
{detail.card.position}/{detail.card.total}
</span>
</div>
</header>
<nav className="jc-vr-tabs" role="tablist">
{TABS.map((t) => (
<button key={t.key} className={tab === t.key ? 'active' : ''} onClick={() => setTab(t.key)} role="tab">
<Ico name={t.icon} s={14} />
<span>{t.label}</span>
{t.key === 'todo' && open.length > 0 ? <b className="jc-vr-tab-badge">{open.length}</b> : null}
{/* ② 工作区 —— 页签与点位条**不滚**,只有正文滚 */}
<section className="wf-card jc-wc-fill">
<div className="jc-tnav">
<button
type="button"
role="tab"
aria-selected={view === 'open'}
className={'jc-tnav-tab' + (view === 'open' ? ' on' : '')}
onClick={() => go('open')}
>
<Ico name="list-checks" s={14} /> 待处理{openList.length > 0 && <em>{openList.length}</em>}
</button>
))}
</nav>
<button
type="button"
role="tab"
aria-selected={view === 'comms'}
className={'jc-tnav-tab' + (view === 'comms' ? ' on' : '')}
onClick={() => go('comms')}
>
<Ico name="mail" s={14} /> 沟通记录
</button>
<button
type="button"
role="tab"
aria-selected={view === 'done'}
className={'jc-tnav-tab' + (view === 'done' ? ' on' : '')}
onClick={() => go('done')}
>
<Ico name="circle-check" s={14} /> 工作成果
</button>
<button
type="button"
role="tab"
aria-selected={view === 'wf'}
className={'jc-tnav-tab' + (view === 'wf' ? ' on' : '')}
onClick={() => go('wf')}
>
<Ico name="git-branch" s={14} /> 任务详情
</button>
<button
type="button"
role="tab"
aria-selected={view === 'log'}
className={'jc-tnav-tab' + (view === 'log' ? ' on' : '')}
onClick={() => go('log')}
>
<Ico name="archive" s={14} /> 工作日志
</button>
<span className="jc-tnav-sp" />
{/*
翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。
按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。
*/}
{view === 'open' && (
<span className="jc-tnav-pg" aria-label="翻页">
<button
type="button"
className="jc-tnav-btn"
disabled={pos === 0}
onClick={() => setPage(pos - 1)}
aria-label="上一件"
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={pos >= openList.length - 1}
onClick={() => setPage(pos + 1)}
aria-label="下一件"
>
<Ico name="chevron-right" s={15} />
</button>
</span>
)}
</div>
<ScrollArea className="jc-sa--fill" vpClassName="jc-vr-body">
{tab === 'todo' &&
(open.length === 0 ? (
<div className="jc-list-empty">
<Ico name="list-checks" s={18} />
当前没有等你的工作事项
</div>
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
<ScrollArea
key={`${view}:${cur?.id ?? '-'}`}
className="jc-sa--fill"
vpClassName="wf-body"
inset={-6}
>
{view === 'comms' ? (
/* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */
<EmptyState
ico="mail"
tone="brand"
title="还没有往来记录"
desc="与客户、供应商的邮件和消息都会归到这里,原件一并存档 —— 谈过什么、什么时候谈的,回头都查得到。"
/>
) : view === 'wf' ? (
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
) : view === 'log' ? (
<EmptyState
ico="archive"
tone="brand"
title="还没有进展"
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
/>
) : view === 'done' ? (
<EmptyState
ico="archive"
tone="brand"
title="还没有成果"
desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
/>
) : (
open.map((order) => <WorkOrderCard key={order.id} order={order} onReply={onReply} />)
))}
{/*
活动推的 HTML 优先:那是它按当下局面组织的**完整一屏**;
没推 HTML 时退回结构化块。两者都经 ViewContainer 渲染 —— feature 不碰 innerHTML。
*/}
{tab === 'result' &&
(detail.viewHtml === undefined ? (
<ViewBlocks blocks={detail.view as ViewBlock[]} />
) : (
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 视图`} />
))}
{tab === 'detail' && (
<div className="jc-vr-detail">
<ViewBlocks blocks={detail.view as ViewBlock[]} />
</div>
)}
{tab === 'log' && (
<ol className="jc-log">
{detail.log.map((line) => (
<li key={line.id} className={'jc-log-item side-' + line.side}>
<span className="jc-log-side">{line.sideLabel}</span>
<span className="jc-log-title">{line.title}</span>
<time className="jc-mono">{line.at.slice(11, 19)}</time>
</li>
))}
</ol>
)}
</ScrollArea>
<EmptyState
ico="list-checks"
tone="brand"
title="手上没有待办"
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
hint="AI 正在往下推进,轮到你时会第一时间出现"
/>
)}
</ScrollArea>
</section>
</main>
);
}
/** 工单两模式:执行 = 指导书 + 空表单;验收 = 可修订报告 + 判据核对 + 放行三选一 */
function WorkOrderCard({ order, onReply }: { order: WorkOrder; onReply(reply: Record<string, unknown>): void }) {
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(
(order.mode === 'execution' ? order.fields : order.fields).map((f) => [f.key, f.ai ?? '']),
),
);
const [comment, setComment] = useState('');
const set = (key: string, value: string) => setValues((v) => ({ ...v, [key]: value }));
/* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */
function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) {
return (
<article className="jc-wo jc-panel-card">
<header className="jc-wo-head">
<span className={'jc-wo-mode mode-' + order.mode}>{WORK_ORDER_MODE_LABEL[order.mode]}</span>
<h3>{order.title}</h3>
</header>
{/* 调用必附上下文 —— 依据快照 */}
<dl className="jc-wo-basis">
{order.basis.map((b, i) => (
<div key={i}>
<dt>{b.k}</dt>
<dd>{b.v}</dd>
</div>
))}
</dl>
{order.mode === 'execution' ? (
<>
<p className="jc-wo-ask">{order.ask}</p>
{order.standard !== undefined && <p className="jc-wo-standard">完成标准:{order.standard}</p>}
{order.steps !== undefined && (
<ol className="jc-wo-steps">
{order.steps.map((s, i) => (
<li key={i}>{s}</li>
))}
</ol>
)}
<div className="jc-wo-form">
{order.fields.map((f) => (
<label key={f.key} className={'jc-field' + (f.cols === 2 ? ' half' : '')}>
<span>
{f.label}
{f.required === true ? <i className="jc-req">*</i> : null}
</span>
{f.kind === 'textarea' ? (
<textarea value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} rows={3} />
) : f.kind === 'radio' || f.kind === 'select' ? (
<select value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)}>
<option value="">请选择</option>
{(f.options ?? []).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} placeholder={f.placeholder} />
)}
{f.hint !== undefined && <em className="jc-hint">{f.hint}</em>}
</label>
))}
</div>
<footer className="jc-wo-foot">
<button
className="jc-btn primary"
onClick={() => onReply({ mode: 'execution', workOrderId: order.id, values })}
>
{order.submitLabel ?? '提交'}
</button>
</footer>
</>
) : (
<>
{/* 判据逐条核对 —— 成果清单的成色标记 */}
<ul className="jc-criteria">
{order.criteria.map((c, i) => (
<li key={i} className={c.pass ? 'pass' : 'fail'}>
<b className="jc-lv">{c.level}</b>
<span>{c.label}</span>
<i>{c.pass ? '✓' : '○'}</i>
</li>
))}
</ul>
{order.actions !== undefined && order.actions.length > 0 && (
<ul className="jc-actions" title="预检全过才发卡:这里是已验证的动作清单">
{order.actions.map((a, i) => (
<li key={i}>
<b className={'jc-eff eff-' + a.effect}>{ACTION_EFFECT_LABEL[a.effect]}</b>
<span>{a.label}</span>
<em>{a.detail}</em>
</li>
))}
</ul>
)}
<label className="jc-field">
<span>意见(驳回必填)</span>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} />
</label>
<footer className="jc-wo-foot">
{/* 三选一:放行通过 / 驳回重做 / 驳回迭代 —— 完成只出自放行通过 */}
<button
className="jc-btn primary"
onClick={() =>
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'pass', values, selectedPorts: {} })
}
>
{order.passLabel ?? '放行通过'}
</button>
<button
className="jc-btn"
onClick={() =>
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'revise', values, comment, selectedPorts: {} })
}
>
驳回迭代
</button>
<button
className="jc-btn danger"
onClick={() =>
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'redo', values, comment, selectedPorts: {} })
}
>
驳回重做
</button>
</footer>
</>
)}
</article>
<div className="jc-wfd-sec">
<span className="jc-wfd-sec-ico">
<Ico name={ico} s={12} />
</span>
<b>{label}</b>
<i className="ln" />
{aside}
</div>
);
}
function WfDetail({ detail, openN, doneN }: { detail: TaskDetail; openN: number; doneN: number }) {
return (
<div className="jc-wfd-body">
<KpiBand budget={detail.budget} />
<WSec
ico="git-branch"
label="流程链"
aside={
<span className="jc-wfd-pos">
<i className="jc-pulse-dot" />
当前 · {detail.card.position}/{detail.card.total} {detail.card.activity}
</span>
}
/>
<div className="jc-wfg2">
{detail.chain.map((n, k) => (
<div
key={n.nodeId}
className={`jc-map-n s-${n.state === 'current' ? 'cur' : n.state}${n.kind === 'approval' ? ' apr' : ''}`}
title={`${n.nodeId} ${n.label} · ${n.ownerName} · ${n.ownerRole}${n.kind === 'approval' ? '(审批点)' : ''}`}
>
{n.state === 'current' && (
<span className="jc-map-me">
<i className="jc-pulse-dot" />
你在这
</span>
)}
<div className="jc-map-nh">
<em className="jc-mono">{k + 1}</em>
{n.state === 'done' ? (
<span className="jc-map-ok">
<Ico name="check" s={11} />
</span>
) : n.kind === 'approval' ? (
<span className="jc-map-ap">
<Ico name="shield-check" s={11} />
</span>
) : null}
</div>
<b>{n.label}</b>
<span className="jc-map-who">
<i>{n.ownerName.charAt(0)}</i>
{n.ownerName} · {n.ownerRole}
</span>
</div>
))}
{detail.chain.length === 0 && <p className="jc-ck-none">这单不在流程上(直接起单)</p>}
</div>
<div className="jc-duo2">
<div className="jc-panel">
<div className="jc-panel-h">
<span className="jc-panel-ico">
<Ico name="bar-chart" s={12} />
</span>
<b>过程视图</b>
<em>活动推送</em>
</div>
<ViewBlocks blocks={detail.view} />
</div>
<div className="jc-panel">
<div className="jc-panel-h">
<span className="jc-panel-ico">
<Ico name="notebook-pen" s={12} />
</span>
<b>上下文</b>
<em className="jc-mono">{detail.card.id}</em>
</div>
<div className="jc-file-rows">
<div className="jc-file-row">
<i>
<Ico name="target" s={13} />
</i>
<em>本单目标</em>
<p>{detail.goal}</p>
</div>
<div className="jc-file-row">
<i>
<Ico name="git-branch" s={13} />
</i>
<em>所属工作流</em>
<p>
{detail.card.workflowSubject}
<span>
序位 {detail.card.position}/{detail.card.total}
</span>
</p>
</div>
<div className="jc-file-row">
<i>
<Ico name="activity" s={13} />
</i>
<em>运行状态</em>
<p>
<b>已派 {openN + doneN} 件</b> · 办完 {doneN} · 待办 {openN}
<span>AI 边推进边派工,件数还会增加</span>
</p>
</div>
<div className="jc-file-row">
<i>
<Ico name="hash" s={13} />
</i>
<em>编号</em>
<p className="jc-mono">
{detail.card.id}
<span className="jc-mono">起于 {detail.card.startedAt.slice(0, 16).replace('T', ' ')}</span>
</p>
</div>
</div>
</div>
</div>
</div>
);
}
/** KPI 指标带:四层卡解剖(维度徽 → 指标名 → 大数 → 限额条);限额是基座在环外强制的 */
function KpiBand({ budget }: { budget: TaskDetail['budget'] }) {
const meter = (p: string) => {
const [a = '0', z = '0'] = p.split('/').map((x) => x.trim());
const r = Math.max(0, Math.min(1, Number(a) / (Number(z) || 1)));
return { a, z, r, tone: r >= 0.85 ? 'hot' : r >= 0.6 ? 'warm' : 'cool' };
};
const KPI = [
{ dim: '步数', icon: 'activity', ...meter(budget.steps) },
{ dim: 'Tokens', icon: 'cpu', ...meter(budget.tokens) },
{ dim: '持续时长', icon: 'clock', ...meter(budget.activeMinutes) },
{ dim: '调人次数', icon: 'users', ...meter(budget.humanCalls) },
];
return (
<div className="jc-kpis">
{KPI.map((k) => (
<div key={k.dim} className="jc-kpi">
<span className="jc-kpi-dim d-act">
<Ico name={k.icon} s={10} />
当前活动
</span>
<em>{k.dim}</em>
<b className="jc-mono">
{k.a}
<i>/ {k.z}</i>
</b>
<span className={'jc-ck-bar t-' + k.tone}>
<i style={{ width: `${Math.round(k.r * 100)}%` }} />
</span>
</div>
))}
</div>
);
}
/* ═════════════════ 工单报告页 ═════════════════ */
@@ -0,0 +1,643 @@
/**
* 对话流的气泡与卡族 —— 自原型 `_components/TaskChat.tsx` 平移(气泡二元制,2026-07-26 定稿)。
*
* **当前未挂载**:对话区正处在「一步一步描述、一步一步接」的阶段,暂为空占位。
* 这一份留着等接回 —— 描述到对话该长什么样时直接用,或按新口径改。
*
* 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。
* AI 的内容只有两种形态、不允许中间态:
* · 纯文本气泡 —— 结论 / 说明 / 回执
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 评价单
* 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。
* ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。
*
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
*/
import { useEffect, useRef, useState } from 'react';
import type { AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
/** 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径) */
const TAG_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
conclusion: { label: '阶段结论', cls: 'auto' },
guidance: { label: '派了工单', cls: 'exe' },
criterion: { label: '请你验收', cls: 'acc' },
};
/** ISO → HH:MM(署名行只要时分) */
const hm = (iso: string) => iso.slice(11, 16);
export type TaskChatProps = {
readonly chat: readonly ChatMessage[];
readonly orders: readonly WorkOrder[];
/** AI 正在回话 —— 请求在途即亮「输入中」 */
readonly thinking: boolean;
onSay(text: string): void;
onReplyCard(reply: ChatCardReply): void;
onReplyOrder(reply: WorkOrderReply): void;
/** 点卡上的「打开工单」→ 视图区定位到那张工单 */
onLocate(orderId: string): void;
};
export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReplyOrder, onLocate }: TaskChatProps) {
const [text, setText] = useState('');
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
boxRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' });
}, [chat.length, thinking]);
// ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单
const blocked = chat.some(
(m) => (m.k === 'elicitation' && m.done === undefined) || (m.k === 'decision' && m.chosen === undefined),
);
const send = () => {
const t = text.trim();
if (t === '' || blocked) return;
setText('');
onSay(t);
};
return (
<div className="jc-tch">
<header className="jc-tch-head">
<span className="jc-tch-mark">
<Ico name="bot" s={15} />
</span>
<b className="jc-tch-name">任务助理</b>
<em className="jc-tch-tag">Agent</em>
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
</header>
<div className="jc-tch-scroll" ref={boxRef}>
{chat.map((m) => (
<Msg
key={m.id}
m={m}
orders={orders}
onReplyCard={onReplyCard}
onReplyOrder={onReplyOrder}
onLocate={onLocate}
/>
))}
{thinking && (
<article className="jc-ag">
<ChatAvatar who="ai" />
<div className="jc-ag-col">
<div className="jc-msg-meta">AI</div>
<span className="jc-typing">
<i />
<i />
<i />
</span>
</div>
</article>
)}
</div>
<div className="copilot-composer session-composer">
{blocked && (
<div className="jc-comp-lock">
<Ico name="lock" s={12} /> 有一张卡在等你(补全 / 决策)——处理完就能继续对话
</div>
)}
<div className="composer-row">
<textarea
rows={2}
value={text}
disabled={blocked}
placeholder={blocked ? '先处理上面的卡片…' : '有什么要补充或交代的?(Enter 发送)'}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}>
<Ico name="send" s={14} /> 发送
</button>
</div>
</div>
</div>
);
}
/* ── Agent 侧包装:头像 + (署名行 → 内容流) ── */
function Ag({ meta, tag, children }: { meta: string; tag?: AiTag | undefined; children: React.ReactNode }) {
const t = tag === undefined ? undefined : TAG_UI[tag];
return (
<article className="jc-ag">
<ChatAvatar who="ai" />
<div className="jc-ag-col">
<div className="jc-msg-meta">
{meta}
{t !== undefined && <b className={'jc-mtag m-' + t.cls}>{t.label}</b>}
</div>
{children}
</div>
</article>
);
}
function Msg({
m,
orders,
onReplyCard,
onReplyOrder,
onLocate,
}: {
m: ChatMessage;
orders: readonly WorkOrder[];
onReplyCard: (reply: ChatCardReply) => void;
onReplyOrder: (reply: WorkOrderReply) => void;
onLocate: (orderId: string) => void;
}) {
// 人的消息:右侧品牌气泡(纯文本)
if (m.k === 'human') {
return (
<article className="copilot-msg human">
<div className="jc-msg-col r">
<div className="jc-msg-meta r">{hm(m.at)}</div>
<div className="copilot-bubble">
<p className="jc-content">{m.text}</p>
</div>
</div>
<ChatAvatar who="user" />
</article>
);
}
// 系统事件:灰头像 + 灰调文本气泡。三类唤醒事件在章上标明是哪一类
if (m.k === 'system') {
return (
<article className="jc-ag">
<div className="chat-avatar jc-ava-sys" aria-label="系统">
<Ico name="server" s={15} />
</div>
<div className="jc-ag-col">
<div className="jc-msg-meta">
系统 · {hm(m.at)}
<b className="jc-mtag m-sys">{EVENT_TYPE_LABEL[m.type]}事件</b>
</div>
<div className="jc-ag-b sys">
<p className="jc-content">{m.text}</p>
</div>
</div>
</article>
);
}
// AI 发言:一次回复 = 一个气泡 —— 文字在上、表单卡嵌在**同一气泡**里
if (m.k === 'ai') {
const order = m.ref === undefined ? undefined : orders.find((o) => o.id === m.ref);
const settled = order?.done !== undefined;
return (
<Ag meta={`AI · ${hm(m.at)}`} tag={m.tag}>
<div className={'jc-ag-b' + (order === undefined ? '' : ' card')}>
<p className="jc-content">{m.text}</p>
{order !== undefined &&
(order.mode === 'acceptance' && m.tag === 'criterion' && !settled ? (
<AcceptForm order={order} onReplyOrder={onReplyOrder} onLocate={onLocate} />
) : (
<WoCard order={order} onLocate={onLocate} />
))}
</div>
</Ag>
);
}
// 补全:问题文字在上,表单卡嵌在同一气泡里
if (m.k === 'elicitation') {
return (
<Ag meta={`AI · ${hm(m.at)}`}>
<div className="jc-ag-b card">
<p className="jc-content">{m.ask}</p>
<div className="jc-fc t-fill">
<div className="jc-fc-h">
<Ico name="square-pen" s={13} />
<b>请你补全</b>
<em>{m.fields.length} 项</em>
</div>
<div className="jc-fc-b">
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
{m.done === undefined ? (
<FillForm
fields={m.fields}
onDone={(values) => onReplyCard({ k: 'elicitation', cardId: m.id, values })}
onCant={(reason) => onReplyCard({ k: 'elicitation', cardId: m.id, values: {}, reason })}
/>
) : m.done.reason === undefined ? (
<p className="jc-chosen">
<Ico name="circle-check" s={14} /> 你补了:
<b>{m.fields.map((f) => m.done?.values[f.key] ?? '—').join(';')}</b>
<code className="jc-mono">{hm(m.done.at)}</code>
</p>
) : (
<p className="jc-chosen cant">
<Ico name="alert-circle" s={14} /> 你说明了没法填:
<b>{m.done.reason}</b>
<code className="jc-mono">{hm(m.done.at)}</code>
</p>
)}
</div>
</div>
</div>
</Ag>
);
}
// 任务评价:星级打分 + 改进建议,并入活动记忆
if (m.k === 'rating') {
return (
<Ag meta={`AI · ${hm(m.at)}`}>
<div className="jc-ag-b card">
<p className="jc-content">{m.ask}</p>
<div className="jc-fc t-rate">
<div className="jc-fc-h">
<Ico name="star" s={13} />
<b>任务评价</b>
<em>5 分制</em>
</div>
<div className="jc-fc-b">
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
{m.done === undefined ? (
<RateForm
onDone={(score, note) =>
onReplyCard({ k: 'rating', cardId: m.id, score, ...(note === '' ? {} : { note }) })
}
/>
) : (
<p className="jc-chosen">
<span className="jc-stars ro">
{[1, 2, 3, 4, 5].map((n) => (
<i key={n} className={'jc-star' + (n <= (m.done?.score ?? 0) ? ' on' : '')}>
<Ico name="star" s={16} />
</i>
))}
</span>
<b>{m.done.score} 分</b>
{m.done.note !== undefined && <span className="jc-rate-note">{m.done.note}</span>}
<code className="jc-mono">{hm(m.done.at)}</code>
</p>
)}
</div>
</div>
</div>
</Ag>
);
}
// 决策:问题文字在上,选择卡嵌在同一气泡里
const chosenLabel =
m.chosen === undefined
? ''
: (m.options.find((o) => o.value === m.chosen?.value)?.label ??
(m.chosen.value.startsWith('custom:') ? m.chosen.value.slice(7) : m.chosen.value));
return (
<Ag meta={`AI · ${hm(m.at)}`}>
<div className="jc-ag-b card">
<p className="jc-content">{m.ask}</p>
<div className="jc-fc t-dec">
<div className="jc-fc-h">
<Ico name="git-branch" s={13} />
<b>需要你定</b>
<em>
{m.options.length + (m.allowCustom ? 1 : 0)} 选 1
</em>
</div>
<div className="jc-fc-b">
<Kv items={m.basis} />
{m.recommend !== undefined && (
<p className="jc-fc-note">
<Ico name="sparkles" s={13} />
<span>
<b>AI 建议</b>
{m.recommend}
</span>
</p>
)}
{m.chosen === undefined ? (
<div className="jc-branches">
{m.options.map((o, i) => (
<button
key={o.value}
className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
>
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
<span className="jc-branch-bd">
<b>
{o.label}
{o.recommended === true && <em>推荐</em>}
</b>
{o.detail !== undefined && <p>{o.detail}</p>}
{o.consequence !== undefined && (
<p className="jc-conseq">
<Ico name="arrow-right" s={12} /> {o.consequence}
</p>
)}
</span>
<Ico name="chevron-right" s={14} />
</button>
))}
{m.allowCustom && (
<CustomOption
n={String.fromCharCode(65 + m.options.length)}
onPick={(txt) => onReplyCard({ k: 'decision', cardId: m.id, value: 'custom:' + txt, note: txt })}
/>
)}
</div>
) : (
<p className="jc-chosen">
<Ico name="circle-check" s={14} /> 你选了:
<b>{chosenLabel}</b>
<code className="jc-mono">{hm(m.chosen.at)}</code>
</p>
)}
</div>
</div>
</div>
</Ag>
);
}
/* 参考信息:表单卡内的结构化 kv 表 */
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
return (
<div className="jc-fc-kv">
<i>参考信息</i>
<div className="jc-fc-kv-g">
{items.map((b, i) => (
<span key={i}>
<em>{b.k}</em>
<b>{b.v}</b>
</span>
))}
</div>
</div>
);
}
/* 工单卡:题头 + 单体 + 打开动作 —— 派单 / 已裁决的验收都用它 */
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
const result = order.done?.result;
const bad = result !== undefined && isRejection(result);
const exe = order.mode === 'execution';
return (
<div className={'jc-fc ' + (exe ? 't-exe' : 't-acc')}>
<div className="jc-fc-h">
<Ico name={exe ? 'file-text' : 'shield-check'} s={13} />
<b>工单 · {exe ? '执行' : '验收'}</b>
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
</em>
</div>
<div className="jc-fc-b">
<p className="jc-fc-q strong">{order.title}</p>
</div>
<div className="jc-fc-ft">
<button className="jc-btn jc-btn-ghost jc-fc-open" onClick={() => onLocate(order.id)}>
<Ico name="panel-left-open" s={13} /> 打开工单 <Ico name="arrow-right" s={12} />
</button>
</div>
</div>
);
}
/* 验收单:报告入口 + 判据表 + 落地动作 + 裁决(通过 / 重做)—— 驳回必附意见 */
function AcceptForm({
order,
onReplyOrder,
onLocate,
}: {
order: Extract<WorkOrder, { mode: 'acceptance' }>;
onReplyOrder: (reply: WorkOrderReply) => void;
onLocate: (id: string) => void;
}) {
const [why, setWhy] = useState('');
const passN = order.criteria.filter((c) => c.pass).length;
return (
<div className="jc-fc t-acc">
<div className="jc-fc-h">
<Ico name="shield-check" s={13} />
<b>请你验收</b>
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
{passN}/{order.criteria.length} 达标
</em>
</div>
<div className="jc-fc-b">
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
<span className="jc-fc-doc-ico">
<Ico name="file-text" s={15} />
</span>
<span className="jc-fc-doc-bd">
<em>成果报告 · 视图区可读可编辑</em>
<b>{order.title}</b>
</span>
<Ico name="chevron-right" s={14} />
</button>
<ul className="jc-rp-crit sm">
{order.criteria.map((c, i) => (
<li key={i} className={c.pass ? 'ok' : 'no'}>
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
<span className="t">{c.label}</span>
<span className="r">
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
{c.pass ? '达标' : '未达标'}
</span>
</li>
))}
</ul>
{order.actions !== undefined && order.actions.length > 0 && (
<Kv
items={order.actions.map((a) => ({
k: a.effect === 'write' ? '通过即执行 · 写' : '通过即执行 · 读',
v: a.label,
}))}
/>
)}
<textarea
className="jc-note"
placeholder="要重做?写明原因(也可以在下面对话里聊)"
value={why}
onChange={(e) => setWhy(e.target.value)}
/>
</div>
<div className="jc-fc-ft">
<button
className="jc-btn jc-btn-bad"
disabled={why.trim() === ''}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'redo',
values: {},
comment: why.trim(),
selectedPorts: {},
})
}
>
重做
</button>
<span className="jc-fc-sp" />
<button
className="jc-btn jc-btn-ok"
onClick={() =>
onReplyOrder({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'pass',
values: {},
selectedPorts: {},
})
}
>
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
</button>
</div>
</div>
);
}
/* 决策的收尾项:自定义口径 */
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
const [txt, setTxt] = useState('');
return (
<div className="jc-branch jc-branch-custom">
<span className="jc-branch-n jc-mono">{n}</span>
<span className="jc-branch-bd">
<b>都不合适,我来定</b>
<span className="jc-custom-row">
<input
value={txt}
placeholder="写下你的口径…"
onChange={(e) => setTxt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
}}
/>
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}>
就按这个
</button>
</span>
</span>
</div>
);
}
/* 补全卡的小表单:就地作答;「没法填」是并列的第二条路(两条必走其一) */
function FillForm({
fields,
onDone,
onCant,
}: {
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined }>;
onDone: (values: Record<string, string>) => void;
onCant: (reason: string) => void;
}) {
const [vals, setVals] = useState<Record<string, string>>({});
const [cant, setCant] = useState(false);
const [reason, setReason] = useState('');
const ok = fields.every((f) => (vals[f.key] ?? '').trim() !== '');
if (cant) {
return (
<div className="jc-cfill">
<label className="jc-cfill-f">
<span>没法填的原因(AI 会据此调整需求)</span>
<textarea
className="jc-note"
rows={2}
value={reason}
autoFocus
placeholder="如:没和客户谈过价位 / 这个数据在别人手里…"
onChange={(e) => setReason(e.target.value)}
/>
</label>
<div className="jc-cfill-acts">
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
← 还是填吧
</button>
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}>
<Ico name="send" s={13} /> 让 AI 调整需求
</button>
</div>
</div>
);
}
return (
<div className="jc-cfill">
{fields.map((f) => (
<label key={f.key} className="jc-cfill-f">
<span>{f.label}</span>
<input
value={vals[f.key] ?? ''}
placeholder={f.placeholder}
onChange={(e) => setVals({ ...vals, [f.key]: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter' && ok) onDone(vals);
}}
/>
</label>
))}
<div className="jc-cfill-acts">
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
没法填?告诉 AI 原因
</button>
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}>
<Ico name="check" s={13} /> 补给 AI
</button>
</div>
</div>
);
}
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(0);
const [note, setNote] = useState('');
const TXT = ['', '很不顺畅,返工严重', '不顺畅,返工明显', '一般,多处需人工介入', '顺畅,个别节点需修正', '非常顺畅,全程无需返工'];
const cur = hover !== 0 ? hover : score;
return (
<div className="jc-cfill">
<div className="jc-stars" role="radiogroup" aria-label="整体评分">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={'jc-star' + (n <= cur ? ' on' : '')}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => setScore(n)}
aria-label={`${n} 分`}
>
<Ico name="star" s={20} />
</button>
))}
<em className="jc-stars-txt">{cur !== 0 ? `${cur} 分 · ${TXT[cur]}` : '点星打分'}</em>
</div>
<label className="jc-cfill-f">
<span>改进建议(会进入活动记忆,用于优化下一单)</span>
<textarea
className="jc-note"
rows={2}
value={note}
placeholder="如:报价台账自动关联客户项目号;英文报价邮件模板固化为标准件"
onChange={(e) => setNote(e.target.value)}
/>
</label>
<div className="jc-cfill-acts">
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}>
<Ico name="check" s={13} /> 提交评价
</button>
</div>
</div>
);
}
@@ -0,0 +1,449 @@
/**
* 工单报告页 —— 一张工单的完整呈现:题头 / 元信息 / 依据指标带 / 正文 / 填报表单 / 落款动作。
*
* 自原型 `_components/ViewRegion.tsx` 的 `Report` 与卡组二平移,DOM 与类名逐字照搬。
*
* **当前未挂载**:工作区正处在「一步一步描述、一步一步接」的阶段,待处理页签暂为空占位。
* 这一份留在这里等着接回来 —— 描述到工单该长什么样时,直接用或按新口径改,不必重写。
*/
import { useState } from 'react';
import type { FormField, TaskDetail, ViewBlock, WorkOrder, WorkOrderReply } from '@contracts';
import { ACTION_EFFECT_LABEL, WORK_ORDER_RESULT_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ViewBlocks, ViewContainer } from '@ui';
function Part({ label, aside }: { label: string; aside?: string | undefined }) {
return (
<div className="jc-rp-part">
<b>{label}</b>
<i className="ln" />
{aside !== undefined && <em>{aside}</em>}
</div>
);
}
/** 控件原始值 → 报告正文文字(AI 拟值 / 人改值都经它落到报告上) */
function fieldText(f: FormField, raw: string): string {
if (raw === '') return '—';
if (f.kind === 'radio' || f.kind === 'select') return f.options?.find((o) => o.value === raw)?.label ?? raw;
if (f.kind === 'check')
return (
raw
.split(',')
.filter(Boolean)
.map((v) => f.options?.find((o) => o.value === v)?.label ?? v)
.join('、') || '—'
);
if (f.kind === 'money' || f.kind === 'number') return raw + (f.unit === undefined ? '' : ' ' + f.unit);
if (f.kind === 'file') return raw.split('|').filter(Boolean).join('、') || '—';
return raw;
}
/**
* 卡组一 · 报告 —— 待办区两种模式:
* 验收 = **报告模式**:AI 给出填好的报告;「修改」进编辑态,裁决在对话区。
* 执行 = **表单模式**:作业指导书(卡组二)+ 待填写表单;「提交」即生成执行报告。
*/
export function WorkOrderReport({
order,
detail,
typeLine,
blocks,
html,
srcTitle,
values,
onField,
onReply,
}: {
order: WorkOrder;
detail: TaskDetail;
typeLine: string;
blocks: readonly ViewBlock[];
/**
* 活动推的那一屏 —— 它**既是独立文件**(工作成果里一行一份、点开单看),
* **也是交互视图的组成部分**:办这张工单时,人要在同一页里看着它填、照着它判。
*/
html?: string | undefined;
srcTitle?: string | undefined;
values: Readonly<Record<string, string>>;
onField: (key: string, value: string) => void;
onReply: (reply: WorkOrderReply) => void;
}) {
const [editing, setEditing] = useState(false);
const isAcc = order.mode === 'acceptance';
const disabled = order.done !== undefined;
const fields = isAcc ? [] : order.fields;
/** 执行 = 空占位表单:值只来自人填(或办结留痕),不吃 AI 拟值 */
const val = (f: FormField) => values[f.key] ?? order.done?.values?.[f.key] ?? '';
const ok = fields.every((f) => f.required !== true || val(f).trim() !== '');
const result = order.done?.result;
const rejected = result !== undefined && isRejection(result);
const stampTxt = result === undefined ? null : WORK_ORDER_RESULT_LABEL[result];
const groups: Array<{ name: string | null; list: FormField[] }> = [];
fields.forEach((f) => {
const g = f.group ?? null;
const last = groups[groups.length - 1];
if (last !== undefined && last.name === g) last.list.push(f);
else groups.push({ name: g, list: [f] });
});
return (
<article className={'jc-report' + (disabled ? ' sealed' : '')}>
<header className="jc-rp-head">
<div className="jc-rp-badge">
<Ico name={isAcc ? 'shield-check' : 'file-text'} s={17} />
</div>
<div className="jc-rp-tit">
<em>
{detail.card.activity} · {typeLine}
</em>
<h3>{order.title}</h3>
</div>
{stampTxt === null ? (
<span className="jc-rp-wait">
<i className="jc-pulse-dot" />
{isAcc ? '待验收' : '待执行'}
</span>
) : (
<span className={'jc-rp-stamp' + (rejected ? ' bad' : '')}>{stampTxt}</span>
)}
</header>
<div className="jc-rp-meta">
<span>
<i>编号</i>
<b className="jc-mono">{order.id.toUpperCase()}</b>
</span>
<span>
<i>所属</i>
<b className="jc-mono">{detail.card.id}</b>
</span>
<span>
<i>编制</i>
<b>{isAcc ? `AI 拟 · ${detail.owner.name}审定` : `${detail.owner.name}填报 · AI 定稿`}</b>
</span>
<span>
<i>派发</i>
<b className="jc-mono">{order.at.slice(5, 16).replace('T', ' ')}</b>
</span>
{order.done !== undefined && (
<span>
<i>办结</i>
<b className="jc-mono">{order.done.at.slice(5, 16).replace('T', ' ')}</b>
</span>
)}
</div>
{/* 关键指标带 —— 调用必附上下文(OS 05) */}
{order.basis.length > 0 && (
<div className="jc-rp-stats">
{order.basis.map((b, i) => (
<div key={i} className="jc-rp-stat">
<i>{b.k}</i>
<b>{b.v}</b>
</div>
))}
</div>
)}
<div className="jc-rp-bd">
{disabled && isAcc && (
<p className={'jc-rp-verdict' + (rejected ? ' bad' : '')}>
<Ico name={rejected ? 'circle-x' : 'circle-check'} s={15} />
<b>{rejected ? '退回重做' : '验收通过 · 系统已执行落地动作'}</b>
</p>
)}
{/* 验收报告的判据逐条核对 —— 成果清单的成色标记 */}
{isAcc && order.criteria.length > 0 && (
<>
<Part label="验收判据" aside={`${order.criteria.filter((c) => c.pass).length}/${order.criteria.length} 达标`} />
<ul className="jc-rp-crit">
{order.criteria.map((c, i) => (
<li key={i} className={c.pass ? 'ok' : 'no'}>
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
<span className="t">{c.label}</span>
<span className="r">
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
{c.pass ? '达标' : '未达标'}
</span>
</li>
))}
</ul>
</>
)}
{/* 预检全过才发卡:这里是**已验证**的落地动作清单(OS 7.3) */}
{isAcc && order.actions !== undefined && order.actions.length > 0 && (
<>
<Part label="通过即执行的落地动作" />
<div className="jc-kv">
{order.actions.map((a, i) => (
<div key={i} className="jc-kv-i">
<i>
{ACTION_EFFECT_LABEL[a.effect]} · {a.label}
</i>
<b>{a.detail}</b>
</div>
))}
</div>
</>
)}
{html !== undefined && (
<div>
<Part label={isAcc ? '成果内容' : '数据与分析'} aside="活动汇报报告" />
<ViewContainer html={html} title={`${detail.card.activity} · 汇报报告`} />
</div>
)}
{blocks.length > 0 && (
<div>
<Part label={html === undefined ? (isAcc ? '成果内容' : '数据与分析') : '结构化明细'} aside={srcTitle} />
<ViewBlocks blocks={blocks} editing={isAcc && editing && !disabled} />
</div>
)}
{groups.map((g, gi) => (
<div key={gi}>
<Part label={g.name ?? '填报内容'} />
{!disabled ? (
<div className="jc-grid">
{g.list.map((f) => (
<div key={f.key} className={'jc-fld' + (f.cols === 2 ? ' half' : '')}>
<label className="jc-fld-l" htmlFor={f.key}>
{f.label}
{f.required === true && <em className="jc-req">必填</em>}
</label>
{f.hint !== undefined && <p className="jc-fld-h">{f.hint}</p>}
<Control f={f} v={val(f)} onField={onField} />
</div>
))}
</div>
) : (
<dl className="jc-rp-vals">
{g.list.map((f) => (
<div key={f.key} className="row">
<dt>{f.label}</dt>
<dd>{fieldText(f, val(f).trim())}</dd>
</div>
))}
</dl>
)}
</div>
))}
</div>
<footer className="jc-rp-foot">
<div className="jc-rp-sign">
<span className="jc-rp-ava">{detail.owner.name.charAt(0)}</span>
<div>
<b>
{detail.owner.name} · {detail.owner.role}
</b>
<span>
{disabled
? `已于 ${(order.done?.at ?? order.at).slice(5, 16).replace('T', ' ')} 办结 · 已记入工作日志`
: isAcc
? editing
? '改完记得保存 —— 裁决在对话区的验收单上'
: '可修改报告内容;验收裁决在对话区'
: '按作业指导书填写,提交即生成执行报告'}
</span>
</div>
</div>
{!disabled &&
(isAcc ? (
editing ? (
<div className="jc-rp-acts2">
<button className="jc-btn jc-btn-ghost" onClick={() => setEditing(false)}>
完成编辑
</button>
</div>
) : (
<button className="jc-btn jc-btn-ghost jc-rp-act" onClick={() => setEditing(true)}>
<Ico name="pen-line" s={13} /> 修改
</button>
)
) : (
<button
className="jc-btn jc-rp-submit"
disabled={!ok}
onClick={() =>
onReply({
mode: 'execution',
workOrderId: order.id,
values: Object.fromEntries(fields.map((f) => [f.key, val(f)])),
})
}
>
<Ico name="check" s={14} /> {order.submitLabel ?? '提交'}
</button>
))}
</footer>
</article>
);
}
/* ═════════════════ 卡组二 · 作业指导书 ═════════════════ */
export function GuideCard({ order }: { order: Extract<WorkOrder, { mode: 'execution' }> }) {
return (
<section className="jc-side jc-side-exe">
<header className="jc-side-h">
<span className="jc-side-ico">
<Ico name="clipboard-check" s={14} />
</span>
<b>作业指导书</b>
{order.steps !== undefined && order.steps.length > 0 && <em>{order.steps.length} 步</em>}
</header>
<div className="jc-side-b">
<p className="jc-side-ask">{order.ask}</p>
{order.steps !== undefined && order.steps.length > 0 && (
<ol className="jc-steps">
{order.steps.map((x, i) => (
<li key={i}>
<em>{i + 1}</em>
<span>{x}</span>
</li>
))}
</ol>
)}
{order.standard !== undefined && (
<p className="jc-rp-std">
<Ico name="target" s={13} />
<span>
<b>算做完的标准</b>
{order.standard}
</span>
</p>
)}
</div>
</section>
);
}
/* 单个控件:8 种 kind */
function Control({ f, v, onField }: { f: FormField; v: string; onField: (k: string, v: string) => void }) {
const set = (x: string) => onField(f.key, x);
if (f.kind === 'radio')
return (
<div className="jc-radios">
{f.options?.map((o) => (
<button
key={o.value}
type="button"
className={'jc-radio' + (v === o.value ? ' on' : '') + (o.negative === true ? ' neg' : '')}
onClick={() => set(o.value)}
>
<Ico name={v === o.value ? 'circle-check' : 'circle-dot'} s={13} />
{o.label}
</button>
))}
</div>
);
if (f.kind === 'check')
return (
<div className="jc-checks">
{f.options?.map((o) => {
const on = v.split(',').filter(Boolean).includes(o.value);
return (
<button
key={o.value}
type="button"
className={'jc-check' + (on ? ' on' : '')}
onClick={() => {
const cur = v.split(',').filter(Boolean);
set((on ? cur.filter((x) => x !== o.value) : [...cur, o.value]).join(','));
}}
>
<span className="jc-check-box">{on && <Ico name="check" s={11} />}</span>
{o.label}
</button>
);
})}
</div>
);
if (f.kind === 'select')
return (
<div className="jc-sel-wrap">
<select id={f.key} className="jc-sel" value={v} onChange={(e) => set(e.target.value)}>
<option value="">{f.placeholder ?? '请选择…'}</option>
{f.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Ico name="chevron-down" s={14} />
</div>
);
if (f.kind === 'textarea')
return (
<textarea
id={f.key}
className="jc-ta"
rows={3}
placeholder={f.placeholder}
value={v}
onChange={(e) => set(e.target.value)}
/>
);
if (f.kind === 'number' || f.kind === 'money')
return (
<div className="jc-num">
{f.kind === 'money' && <span className="jc-num-pre">¥</span>}
<input
id={f.key}
type="number"
inputMode="decimal"
placeholder={f.placeholder}
value={v}
onChange={(e) => set(e.target.value)}
/>
{f.unit !== undefined && <span className="jc-num-unit">{f.unit}</span>}
</div>
);
if (f.kind === 'file') {
const files = v.split('|').filter(Boolean);
return (
<div className="jc-up">
{files.map((n, i) => (
<span key={i} className="jc-up-chip">
<Ico name="paperclip" s={12} />
{n}
<button type="button" aria-label="移除" onClick={() => set(files.filter((_, x) => x !== i).join('|'))}>
×
</button>
</span>
))}
<label className="jc-up-drop">
<input
type="file"
multiple
onChange={(e) => {
const ns = [...(e.target.files ?? [])].map((x) => x.name);
if (ns.length > 0) set([...files, ...ns].join('|'));
e.target.value = '';
}}
/>
<Ico name="upload" s={14} /> 点击上传文件(可多个)
</label>
</div>
);
}
if (f.kind === 'date')
return <input id={f.key} type="date" className="jc-inp" value={v} onChange={(e) => set(e.target.value)} />;
return (
<input id={f.key} className="jc-inp" placeholder={f.placeholder} value={v} onChange={(e) => set(e.target.value)} />
);
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
/**
* 聊天消息头像 —— 自原型 `_components/ChatAvatar.tsx` 平移(成对设计)。
* 圆角方形 + 实心白色剪影:AI = 机器人(飞书蓝),员工 = 人像半身(浅蓝绿)。
* `<g transform>` 校正图标在框内垂直居中。
*/
export function ChatAvatar({ who }: { who: 'ai' | 'user' }) {
const isAi = who === 'ai';
return (
<div
className={'chat-avatar ' + (isAi ? 'chat-avatar-ai' : 'chat-avatar-user')}
aria-label={isAi ? 'AI 助手' : '员工'}
>
{isAi ? (
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<g transform="translate(0,2.35)">
<circle cx="12" cy="3" r="1.1" fill="#fff" />
<rect x="11.45" y="3.7" width="1.1" height="1.9" rx="0.55" fill="#fff" />
<rect x="4.3" y="5.6" width="15.4" height="11.8" rx="4.3" fill="#fff" />
<rect x="2.2" y="9.4" width="2.1" height="4.5" rx="1.05" fill="#fff" />
<rect x="19.7" y="9.4" width="2.1" height="4.5" rx="1.05" fill="#fff" />
<circle cx="9.4" cy="10.9" r="1.7" fill="#245bdb" />
<circle cx="14.6" cy="10.9" r="1.7" fill="#245bdb" />
<path
d="M9.5 14c.65.85 1.55 1.25 2.5 1.25s1.85-.4 2.5-1.25"
stroke="#245bdb"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
opacity="0.55"
/>
</g>
</svg>
) : (
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<g transform="translate(0,-0.5)">
<circle cx="12" cy="8.2" r="3.7" fill="#fff" />
<path
d="M5.5 19.4c0-3.6 2.9-5.8 6.5-5.8s6.5 2.2 6.5 5.8c0 .6-.5 1.1-1.1 1.1H6.6c-.6 0-1.1-.5-1.1-1.1z"
fill="#fff"
/>
</g>
</svg>
)}
</div>
);
}
+5 -112
View File
@@ -1,112 +1,5 @@
// 共享内联 lucide 图标(会话面板 / 工作区驾驶舱 / 设置等共用)。
export function Ico({ name, s = 18 }: { name: string; s?: number }) {
const p: Record<string, React.ReactNode> = {
"chevron-right": (<path d="m9 18 6-6-6-6" />),
"chevron-down": (<path d="m6 9 6 6 6-6" />),
check: (<path d="M20 6 9 17l-5-5" />),
"circle-check": (<><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></>),
star: (<><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" /></>),
sparkles: (<><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z" /><path d="M20 3v4" /><path d="M22 5h-4" /><path d="M4 17v2" /><path d="M5 18H3" /></>),
terminal: (<><polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" /></>),
clock: (<><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></>),
info: (<><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></>),
"clipboard-check": (<><rect width="8" height="4" x="8" y="2" rx="1" ry="1" /><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><path d="m9 14 2 2 4-4" /></>),
target: (<><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></>),
"list-checks": (<><path d="m3 17 2 2 4-4" /><path d="m3 7 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></>),
plus: (<><path d="M5 12h14" /><path d="M12 5v14" /></>),
"panel-left-close": (<><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m16 15-3-3 3-3" /></>),
"sliders-horizontal": (<><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line x1="21" x2="12" y1="12" y2="12" /><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line x1="12" x2="3" y1="20" y2="20" /><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line x1="16" x2="16" y1="18" y2="22" /></>),
filter: (<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />),
"arrow-left": (<><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></>),
"file-spreadsheet": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M8 13h2" /><path d="M14 13h2" /><path d="M8 17h2" /><path d="M14 17h2" /></>),
"file-type": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M9 13v-1h6v1" /><path d="M11 18h2" /><path d="M12 12v6" /></>),
wrench: (<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />),
"git-branch": (<><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></>),
search: (<><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></>),
bell: (<><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></>),
settings: (<><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></>),
user: (<><circle cx="12" cy="12" r="10" /><circle cx="12" cy="10" r="3" /><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662" /></>),
"user-round": (<><circle cx="12" cy="8" r="5" /><path d="M20 21a8 8 0 0 0-16 0" /></>),
bot: (<><path d="M12 8V4H8" /><rect width="16" height="12" x="4" y="8" rx="2" /><path d="M2 14h2" /><path d="M20 14h2" /><path d="M15 13v2" /><path d="M9 13v2" /></>),
dashboard: (<><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></>),
mail: (<><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></>),
"mail-open": (<><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z" /><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" /></>),
inbox: (<><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>),
"shield-alert": (<><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="M12 8v4" /><path d="M12 16h.01" /></>),
send: (<><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" /><path d="m21.854 2.147-10.94 10.939" /></>),
archive: (<><rect width="20" height="5" x="2" y="3" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" /><path d="M10 12h4" /></>),
"file-text": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" /></>),
"pen-line": (<><path d="M12 20h9" /><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z" /></>),
"messages-square": (<><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z" /><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1" /></>),
"notebook-pen": (<><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></>),
"calendar-days": (<><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /><path d="M8 14h.01" /><path d="M12 14h.01" /><path d="M16 14h.01" /><path d="M8 18h.01" /><path d="M12 18h.01" /><path d="M16 18h.01" /></>),
hourglass: (<><path d="M5 22h14" /><path d="M5 2h14" /><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22" /><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2" /></>),
play: (<polygon points="6 3 20 12 6 21 6 3" />),
paperclip: (<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />),
"building-2": (<><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z" /><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" /><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2" /><path d="M10 6h4" /><path d="M10 10h4" /><path d="M10 14h4" /><path d="M10 18h4" /></>),
"panel-left-open": (<><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m14 9 3 3-3 3" /></>),
"trash-2": (<><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" /></>),
calculator: (<><rect width="16" height="20" x="4" y="2" rx="2" /><line x1="8" x2="16" y1="6" y2="6" /><line x1="16" x2="16" y1="14" y2="18" /><path d="M16 10h.01" /><path d="M12 10h.01" /><path d="M8 10h.01" /><path d="M12 14h.01" /><path d="M8 14h.01" /><path d="M12 18h.01" /><path d="M8 18h.01" /></>),
/* —— 治理中心扩充图标 —— */
gauge: (<><path d="m12 14 4-4" /><path d="M3.34 19a10 10 0 1 1 17.32 0" /></>),
database: (<><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5" /><path d="M3 12a9 3 0 0 0 18 0" /></>),
layers: (<><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z" /><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65" /><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65" /></>),
users: (<><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></>),
"share-2": (<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /><line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></>),
cpu: (<><rect width="16" height="16" x="4" y="4" rx="2" /><rect width="6" height="6" x="9" y="9" rx="1" /><path d="M15 2v2" /><path d="M15 20v2" /><path d="M2 15h2" /><path d="M2 9h2" /><path d="M20 15h2" /><path d="M20 9h2" /><path d="M9 2v2" /><path d="M9 20v2" /></>),
zap: (<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />),
"git-merge": (<><circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M6 21V9a9 9 0 0 0 9 9" /></>),
"refresh-cw": (<><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></>),
"rotate-ccw": (<><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></>),
"alert-triangle": (<><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>),
"shield-check": (<><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></>),
scan: (<><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path d="M21 17v2a2 2 0 0 1-2 2h-2" /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><path d="M7 12h10" /></>),
"file-check": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="m9 15 2 2 4-4" /></>),
"trending-up": (<><path d="M16 7h6v6" /><path d="m22 7-8.5 8.5-5-5L2 17" /></>),
ban: (<><circle cx="12" cy="12" r="10" /><path d="m4.9 4.9 14.2 14.2" /></>),
key: (<><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" /><path d="m21 2-9.6 9.6" /><circle cx="7.5" cy="15.5" r="5.5" /></>),
workflow: (<><rect width="8" height="8" x="3" y="3" rx="2" /><path d="M7 11v4a2 2 0 0 0 2 2h4" /><rect width="8" height="8" x="13" y="13" rx="2" /></>),
"arrow-right": (<><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></>),
"circle-dot": (<><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="3" /></>),
/* —— 治理中心补充图标(运行 / 连接 / 岗位 / 事件 / 度量)—— */
activity: (<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />),
route: (<><circle cx="6" cy="19" r="3" /><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" /><circle cx="18" cy="5" r="3" /></>),
plug: (<><path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" /><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" /></>),
radio: (<><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" /><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" /><circle cx="12" cy="12" r="2" /><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" /><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" /></>),
"git-commit": (<><circle cx="12" cy="12" r="3" /><line x1="3" x2="9" y1="12" y2="12" /><line x1="15" x2="21" y1="12" y2="12" /></>),
history: (<><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></>),
"user-check": (<><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><polyline points="16 11 18 13 22 9" /></>),
"user-plus": (<><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><line x1="19" x2="19" y1="8" y2="14" /><line x1="22" x2="16" y1="11" y2="11" /></>),
power: (<><path d="M12 2v10" /><path d="M18.4 6.6a9 9 0 1 1-12.77.04" /></>),
"alert-circle": (<><circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" /></>),
"bar-chart": (<><line x1="12" x2="12" y1="20" y2="10" /><line x1="18" x2="18" y1="20" y2="4" /><line x1="6" x2="6" y1="20" y2="16" /></>),
shuffle: (<><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22" /><path d="m18 2 4 4-4 4" /><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2" /><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8" /><path d="m18 14 4 4-4 4" /></>),
pin: (<><path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" /></>),
timer: (<><line x1="10" x2="14" y1="2" y2="2" /><line x1="12" x2="15" y1="14" y2="11" /><circle cx="12" cy="14" r="8" /></>),
repeat: (<><path d="m17 2 4 4-4 4" /><path d="M3 11v-1a4 4 0 0 1 4-4h14" /><path d="m7 22-4-4 4-4" /><path d="M21 13v1a4 4 0 0 1-4 4H3" /></>),
briefcase: (<><path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /><rect width="20" height="14" x="2" y="6" rx="2" /></>),
fingerprint: (<><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4" /><path d="M14 13.12c0 2.38 0 6.38-1 8.88" /><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02" /><path d="M2 12a10 10 0 0 1 18-6" /><path d="M2 16h.01" /><path d="M21.8 16c.2-2 .131-5.354 0-6" /><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2" /><path d="M8.65 22c.21-.66.45-1.32.57-2" /><path d="M9 6.8a6 6 0 0 1 9 5.2v2" /></>),
server: (<><rect width="20" height="8" x="2" y="2" rx="2" /><rect width="20" height="8" x="2" y="14" rx="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" /></>),
network: (<><rect x="16" y="16" width="6" height="6" rx="1" /><rect x="2" y="16" width="6" height="6" rx="1" /><rect x="9" y="2" width="6" height="6" rx="1" /><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" /><path d="M12 12V8" /></>),
eye: (<><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></>),
flag: (<><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" x2="4" y1="22" y2="15" /></>),
lock: (<><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></>),
percent: (<><line x1="19" x2="5" y1="5" y2="19" /><circle cx="6.5" cy="6.5" r="2.5" /><circle cx="17.5" cy="17.5" r="2.5" /></>),
hash: (<><line x1="4" x2="20" y1="9" y2="9" /><line x1="4" x2="20" y1="15" y2="15" /><line x1="10" x2="8" y1="3" y2="21" /><line x1="16" x2="14" y1="3" y2="21" /></>),
"external-link": (<><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></>),
upload: (<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></>),
puzzle: (<path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z" />),
"circle-x": (<><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6" /><path d="m9 9 6 6" /></>),
"chevron-left": (<path d="m15 18-6-6 6-6" />),
"calendar-clock": (<><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" /><path d="M16 2v4" /><path d="M8 2v4" /><path d="M3 10h5" /><circle cx="16" cy="16" r="6" /><path d="M16 14v2l1 1" /></>),
"file-search": (<><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h5" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><circle cx="16" cy="17" r="3" /><path d="m21 22-1.5-1.5" /></>),
boxes: (<><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="m7 16.5-4.74-2.85" /><path d="m7 16.5 5-3" /><path d="M7 16.5v5.17" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5-5-3" /><path d="m17 16.5 4.74-2.85" /><path d="M17 16.5v5.17" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" /><path d="M12 8 7.26 5.15" /><path d="m12 8 4.74-2.85" /><path d="M12 13.5V8" /></>),
"square-pen": (<><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" /></>),
"log-out": (<><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" x2="9" y1="12" y2="12" /></>),
};
return (
<svg xmlns="http://www.w3.org/2000/svg" width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{p[name]}
</svg>
);
}
/**
* 内联 lucide 图标 —— 本体已迁入 `@ui`(无业务语义的展示原语,编码规范 §2)。
* 此处保留纯再导出,让既有的 `@web-shared/Ico.tsx` 引用点不动。
*/
export { Ico } from '@ui';
+59 -4
View File
@@ -137,11 +137,26 @@ export function SidebarHead({ icon, title, sub }: { icon: string; title: string;
);
}
export function ListEmpty({ icon, children }: { icon: string; children: ReactNode }) {
/**
* 列表区空态 —— 侧栏窄,用紧凑版:软色圆里一枚图标 + 主句 + 一小段说明。
* 与主面板的 `EmptyState`(整屏光环)同一语气,尺寸各归其位。
*/
export function ListEmpty({
icon,
title,
children,
}: {
icon: string;
title?: string;
children: ReactNode;
}) {
return (
<div className="jc-list-empty">
<Ico name={icon} s={22} />
<span>{children}</span>
<div className="jc-side-empty">
<span className="jc-side-empty-ico">
<Ico name={icon} s={18} />
</span>
{title !== undefined && <b>{title}</b>}
<p>{children}</p>
</div>
);
}
@@ -169,3 +184,43 @@ export function NoteBand({ children }: { children: ReactNode }) {
</div>
);
}
/**
* 空态整屏 —— 层叠光环 + 图标 + 主副文 + 提示囊(自原型 `EmptyState` 平移)。
*
* **空态是产品的常态,不是缺陷**:文案一律从用人的角度写「你手上现在没有什么」,
* 而不是从开发角度写「这块还没做」—— 后者是给自己看的,前者才是用户要的那句话。
*/
export function EmptyState({
ico,
tone,
title,
desc,
hint,
}: {
ico: string;
tone: 'ok' | 'brand';
title: string;
desc: string;
hint?: string | undefined;
}) {
return (
<div className={'jc-empty t-' + tone}>
<span className="jc-empty-art">
<i className="r1" />
<i className="r2" />
<span className="jc-empty-ico">
<Ico name={ico} s={26} />
</span>
</span>
<b>{title}</b>
<p>{desc}</p>
{hint !== undefined && (
<span className="jc-empty-hint">
<Ico name="sparkles" s={12} />
{hint}
</span>
)}
</div>
);
}
+2 -2
View File
@@ -125,9 +125,9 @@ export interface FlowDiagramProps {
/** 点击活动节点(下钻);不传则节点不可点。 */
onNodeClick?: (nodeId: string) => void;
/** 双击活动节点就地改文字(标题/小字/主办);不传则不可改。 */
onEditNode?: (nodeId: string, patch: { label: string; owner?: string }) => Promise<void>;
onEditNode?: ((nodeId: string, patch: { label: string; owner?: string }) => Promise<void>) | undefined;
/** 执行人候选(L1 业务能力)——传了则执行字段只能下拉选缩写,不开自由填。 */
ownerOptions?: SpPosition[];
ownerOptions?: SpPosition[] | undefined;
ariaLabel: string;
}
+465
View File
@@ -0,0 +1,465 @@
// lucide-react 图标换成 @ui 的内联 Ico —— 本仓不引 lucide 包(1:1 平移规则:视觉同、依赖归一)
import { Ico } from "@ui";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts";
import type { SpPosition } from "./support.tsx";
import { OwnerSelect } from "./support.tsx";
import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts";
import "diagram-js/assets/diagram-js.css";
/**
* 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈
* (bpmn.js 的绘图层机制,阉割掉 BPMN 数据层)。这里只管:
* 工具条(撤销/重做/适配视图/取消/保存)、双击属性卡(字段范围与画布一致:
* 活动 名称/SOP↔Case/执行人;网关 名称/×|+/判据/判定;卡点 名称/去向;连线 标签/颜色)、
* 删除确认(节点已有下钻内容时)、键盘(Del 删除、⌘Z 撤销、⇧⌘Z 重做)。
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type Any = any;
export interface FlowEditorProps {
/** 进入编辑时的图(编译产物/手绘/空);内核转手绘工作副本。 */
initial: FlowGraph | null | undefined;
/** 已存在的下钻(operations)键:铸新号要避开;删这些节点要确认。 */
drilledIds: string[];
/** idMap = 编辑期活动改号映射(自动顺序编号),服务端据此重键 operations。 */
onSave: (graph: FlowGraph, idMap: Record<string, string>) => Promise<void>;
onCancel: () => void;
/** 执行人候选(L1 业务能力)——传了则活动属性卡的执行字段只能下拉选缩写。 */
positions?: SpPosition[] | undefined;
}
interface PropState {
element: Any;
isEdge: boolean;
left: number;
top: number;
}
export function FlowEditor({ initial, drilledIds, onSave, onCancel, positions }: FlowEditorProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<FlowEditorHandles | null>(null);
const [ready, setReady] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [propDirty, setPropDirty] = useState(false);
const [hasShapes, setHasShapes] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prop, setProp] = useState<PropState | null>(null);
const drilled = useMemo(() => new Set(drilledIds), [drilledIds]);
const drilledRef = useRef(drilled);
drilledRef.current = drilled;
// 内核装配(一次):diagram-js modeling 全栈 + 导入种子图。
useEffect(() => {
const container = canvasRef.current;
if (!container) return;
const requestRemove = (raw: Any[]) => {
// 标签映射到宿主(旁注不单独删——文字随元素生死)。
const elements = [...new Set(raw.map((el: Any) => el.labelTarget ?? el))];
const hit = elements.filter((el: Any) => !el.waypoints && drilledRef.current.has(el.businessObject?.id));
if (hit.length > 0) {
const names = hit.map((el: Any) => el.businessObject.id).join("、");
if (!window.confirm(`节点 ${names} 已有下钻内容(任务卡/指导书等),删除后将一并移除。确定删除?`)) return;
}
const modeling = editor.diagram.get("modeling");
modeling.removeElements(elements.slice());
};
// 小盘"编辑"入口 / 网关拉线后自动弹卡:按元素位置(画布坐标→容器坐标)打开属性卡。
const requestProps = (raw: Any) => {
const element = raw?.labelTarget ?? raw;
if (!element || !element.parent) return;
if (!element.businessObject) element.businessObject = {};
const rect = wrapRef.current?.getBoundingClientRect();
if (!rect) return;
const vb = editor.diagram.get("canvas").viewbox();
const px = (element.x + (element.width ?? 0) / 2 - vb.x) * vb.scale;
const py = (element.y + (element.height ?? 0) / 2 - vb.y) * vb.scale;
setProp({
element,
isEdge: Boolean(element.waypoints),
left: Math.max(8, Math.min(px - 140, rect.width - 300)),
top: Math.max(8, Math.min(py + 16, rect.height - 250)),
});
};
const editor = createFlowEditor(initial, { container, reserved: drilledRef.current, requestRemove, requestProps });
editorRef.current = editor;
const eventBus = editor.diagram.get("eventBus");
const commandStack = editor.diagram.get("commandStack");
const elementRegistry = editor.diagram.get("elementRegistry");
const canvas = editor.diagram.get("canvas");
// 从网关拉出的线是分支:连好立刻弹属性卡,当场写分支文字(通过/拒绝…)。
eventBus.on("commandStack.connection.create.postExecuted", (event: Any) => {
const connection = event.context?.connection;
if (connection?.source?.businessObject?.kind === "gateway") {
setTimeout(() => requestProps(connection), 50);
}
});
const refresh = () => {
setCanUndo(commandStack.canUndo());
setCanRedo(commandStack.canRedo());
setHasShapes(elementRegistry.getAll().some((el: Any) => el.parent && !el.waypoints));
};
refresh();
eventBus.on("commandStack.changed", refresh);
// 双击 = 属性卡(节点/连线;双击旁注/线标签落到其宿主;字段范围与展示态一致)。
eventBus.on("element.dblclick", (event: Any) => {
const element = event.element?.labelTarget ?? event.element;
if (!element || !element.parent) return;
if (!element.businessObject) element.businessObject = {};
const rect = wrapRef.current?.getBoundingClientRect();
const oe: MouseEvent | undefined = event.originalEvent;
if (!rect) return;
setProp({
element,
isEdge: Boolean(element.waypoints),
left: Math.max(8, Math.min((oe?.clientX ?? rect.left + 60) - rect.left, rect.width - 300)),
top: Math.max(8, Math.min((oe?.clientY ?? rect.top + 60) - rect.top + 10, rect.height - 250)),
});
});
canvas.zoom("fit-viewport", "auto");
setReady(true);
return () => {
editorRef.current = null;
editor.destroy();
setReady(false);
};
// initial 只作首次种子——编辑期间外部刷新不重建画布(草稿在命令栈里)。
}, []);
// 键盘:Del 删除(过输入框)、⌘/Ctrl+Z 撤销、⇧⌘Z / Ctrl+Y 重做、Esc 关属性卡。
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
const editor = editorRef.current;
if (!editor) return;
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "z") {
e.preventDefault();
const commandStack = editor.diagram.get("commandStack");
if (e.shiftKey) commandStack.redo();
else commandStack.undo();
return;
}
if (mod && e.key.toLowerCase() === "y") {
e.preventDefault();
editor.diagram.get("commandStack").redo();
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
const selection = [...new Set(editor.diagram.get("selection").get().map((el: Any) => el.labelTarget ?? el))];
if (selection.length > 0) {
e.preventDefault();
const hit = selection.filter((el: Any) => !el.waypoints && drilledRef.current.has(el.businessObject?.id));
if (hit.length > 0) {
const names = hit.map((el: Any) => el.businessObject.id).join("、");
if (!window.confirm(`节点 ${names} 已有下钻内容,删除后将一并移除。确定删除?`)) return;
}
editor.diagram.get("modeling").removeElements(selection.slice());
}
return;
}
if (e.key === "Escape") setProp(null);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const dirty = canUndo || propDirty;
const canSave = dirty && hasShapes && !busy && ready;
const run = (fn: (editor: FlowEditorHandles) => void) => {
const editor = editorRef.current;
if (editor) fn(editor);
};
return (
<div className="flow-editor">
<div className="fe-bar">
<button type="button" className="be-btn ghost" disabled={!canUndo || busy} title="撤销(⌘Z)" onClick={() => run((ed) => ed.diagram.get("commandStack").undo())}>
<Ico name="undo-2" s={12} /> 撤销
</button>
<button type="button" className="be-btn ghost" disabled={!canRedo || busy} title="重做(⇧⌘Z)" onClick={() => run((ed) => ed.diagram.get("commandStack").redo())}>
<Ico name="redo-2" s={12} /> 重做
</button>
<button type="button" className="be-btn ghost" title="适配视图(空白处拖拽平移,⌘+滚轮缩放)" onClick={() => run((ed) => ed.diagram.get("canvas").zoom("fit-viewport", "auto"))}>
<Ico name="maximize-2" s={12} /> 适配
</button>
<span className="fe-hint">
左侧元素盘拖放落子 · 点选节点用旁边小盘 追加/连线/删除 · 拖节点自动修线 · 拖线段调走线 · 双击改属性
</span>
<button
type="button"
className="be-btn ghost"
disabled={busy}
onClick={() => {
if (!dirty || window.confirm("放弃本次绘图修改?")) onCancel();
}}
>
<Ico name="x" s={12} /> 取消
</button>
<button
type="button"
className="be-btn solid"
disabled={!canSave}
onClick={() => {
const editor = editorRef.current;
if (!editor) return;
setBusy(true);
setError(null);
const { graph, idMap } = editor.serialize();
onSave(graph, idMap).catch((err) => {
setError(err instanceof Error ? err.message : "保存失败");
setBusy(false);
});
}}
>
<Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"}
</button>
</div>
{error && <p className="fe-error">{error}</p>}
<div ref={wrapRef} className="fe-canvas">
<div ref={canvasRef} className="fe-djs" />
{prop && (
<PropCard
key={`${prop.element.id}-${prop.top}`}
prop={prop}
registry={editorRef.current?.diagram.get("elementRegistry")}
positions={positions}
onClose={() => setProp(null)}
onApply={(patch) => {
const editor = editorRef.current;
if (!editor) return;
const bo = prop.element.businessObject as Record<string, unknown>;
for (const [k, v] of Object.entries(patch)) {
if (v === undefined) delete bo[k];
else bo[k] = v;
}
editor.diagram.get("eventBus").fire("elements.changed", { elements: [prop.element] });
// 文字变了 → 同步外部标签(重量尺寸;连线标签按有无文字建/撤)。
syncElementLabel(editor.diagram, prop.element);
setPropDirty(true);
setProp(null);
}}
/>
)}
</div>
<p className="fe-foot">珊格 10px · 只有直线与 90° 折线 · 连线从节点小盘拖出 · Del 删除 · ⌘Z 撤销</p>
</div>
);
}
/* ---- 属性卡(双击节点/连线):gw-pop 纸卡,字段范围与画布展示一致 ---- */
const KIND_NAME: Record<FlowNodeKind, string> = {
activity: "活动",
gateway: "网关",
start: "起始事件",
end: "结束事件",
"end-bad": "异常终止",
callout: "卡点",
};
function PropCard({
prop,
registry,
positions,
onClose,
onApply,
}: {
prop: PropState;
registry: Any;
positions?: SpPosition[] | undefined;
onClose: () => void;
onApply: (patch: Record<string, unknown>) => void;
}) {
const node = prop.isEdge ? undefined : (prop.element.businessObject as FlowNode);
const edge = prop.isEdge ? (prop.element.businessObject as FlowEdge) : undefined;
const [label, setLabel] = useState(node?.label ?? edge?.label ?? "");
const [owner, setOwner] = useState(node?.owner ?? "");
const [flavor, setFlavor] = useState<"sop" | "case" | "approval">(
node?.flavor === "case" ? "case" : node?.flavor === "approval" ? "approval" : "sop",
);
const [mode, setMode] = useState<"x" | "and">(node?.mode === "and" ? "and" : "x");
const [basis, setBasis] = useState(node?.basis ?? "");
const [to, setTo] = useState(node?.to ?? "");
const [tone, setTone] = useState<string>(edge?.tone ?? "");
// 数据线(进/出/查:台账规范名顿号分隔)——L2 只讲流转与数据对象;
// 过程细节(分工/逐条)属 L3 指导书内部,节点级 per/assign 已退役(保存即清遗留值)。
const [dataIn, setDataIn] = useState((node?.in ?? []).join("、"));
const [dataOut, setDataOut] = useState((node?.out ?? []).join("、"));
const [dataUse, setDataUse] = useState((node?.use ?? []).join("、"));
const targets: string[] = useMemo(() => {
if (!registry) return [];
const ids: string[] = [];
for (const el of registry.getAll() as Any[]) {
const bo = el.businessObject as FlowNode | undefined;
if (el.parent && !el.waypoints && bo?.id && bo.id !== node?.id) ids.push(bo.id);
}
return ids.sort();
}, [registry, node?.id]);
const save = () => {
if (node) {
const patch: Record<string, unknown> = {};
const text = label.trim();
if (text) patch.label = text.slice(0, 16);
if (node.kind === "activity") {
patch.owner = owner.trim() ? owner.trim().slice(0, 16) : undefined;
patch.flavor = flavor === "sop" ? undefined : flavor;
const lines = (raw: string) =>
raw.split(/[、,,\n]+/).map((part) => part.trim().slice(0, 20)).filter(Boolean).slice(0, 5);
const din = lines(dataIn);
const dout = lines(dataOut);
const duse = lines(dataUse);
patch.in = din.length ? din : undefined;
patch.out = dout.length ? dout : undefined;
patch.use = duse.length ? duse : undefined;
// 活动调用整体退役(2026-07 终拍,user「所有节点都是内置节点」):保存即清遗留 wait。
patch.wait = undefined;
patch.per = undefined;
patch.assign = undefined;
} else if (node.kind === "gateway") {
patch.owner = owner.trim() ? owner.trim().slice(0, 12) : undefined;
patch.mode = mode === "and" ? "and" : undefined;
patch.basis = mode === "x" && basis.trim() ? basis.trim().slice(0, 120) : undefined;
} else if (node.kind === "callout") {
patch.to = to || undefined;
}
onApply(patch);
} else if (edge) {
onApply({
label: label.trim() ? label.trim().slice(0, 10) : undefined,
tone: tone === "ok" || tone === "bad" || tone === "main" ? tone : undefined,
});
}
};
return (
<div className="gw-pop fe-prop" style={{ left: prop.left, top: prop.top }} role="dialog" aria-label="属性">
<div className="gp-hd">
<span className="gp-dia" aria-hidden>
{node ? (node.kind === "gateway" ? "◇" : node.kind === "callout" || node.kind === "activity" ? "▭" : "●") : "→"}
</span>
<span className="gp-nm">{node ? `${node.id} · ${KIND_NAME[node.kind]}` : "连线"}</span>
</div>
<div className="gp-edit-row">
<span className="lb">{node ? "名称" : "标签"}</span>
<input
value={label}
maxLength={node ? 16 : 10}
placeholder={edge ? "分支名(可空,如 通过/拒绝)" : undefined}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
{node?.kind === "activity" && (
<>
<div className="gp-edit-row">
<span className="lb">类型</span>
<select value={flavor} onChange={(e) => setFlavor(e.target.value as "sop" | "case" | "approval")}>
<option value="sop">标准活动 · flow(蓝 · 照谱执行)</option>
<option value="case">案例活动 · loop(绿 · 临场演绎)</option>
<option value="approval">审批点(琥珀 · 人裁节点)</option>
</select>
</div>
<div className="gp-edit-row">
<span className="lb">执行</span>
{positions ? (
<OwnerSelect value={owner} positions={positions} onChange={setOwner} />
) : (
<input value={owner} maxLength={16} placeholder="执行人岗位缩写(可空)" onChange={(e) => setOwner(e.target.value)} />
)}
</div>
<div className="gp-edit-row">
<span className="lb">进</span>
<input value={dataIn} placeholder="吃进的流转件,顿号分隔 ≤5(规范名)" onChange={(e) => setDataIn(e.target.value)} />
</div>
<div className="gp-edit-row">
<span className="lb">出</span>
<input value={dataOut} placeholder="产出的流转件;可带状态词:单据名(已受理)" onChange={(e) => setDataOut(e.target.value)} />
</div>
<div className="gp-edit-row">
<span className="lb">查</span>
<input value={dataUse} placeholder="照着查/判的底座与规则,顿号分隔" onChange={(e) => setDataUse(e.target.value)} />
</div>
</>
)}
{node?.kind === "gateway" && (
<>
<div className="gp-edit-row">
<span className="lb">类型</span>
<select value={mode} onChange={(e) => setMode(e.target.value as "x" | "and")}>
<option value="x">判断 ×(按判据走一条)</option>
<option value="and">并行 +(全部同时走)</option>
</select>
</div>
{mode === "x" && (
<div className="gp-edit-row">
<span className="lb">判据</span>
<textarea value={basis} maxLength={120} rows={2} placeholder="白话可判的一句;卡点写死后果" onChange={(e) => setBasis(e.target.value)} />
</div>
)}
<div className="gp-edit-row">
<span className="lb">判定</span>
<input value={owner} maxLength={12} placeholder={mode === "and" ? "(并行可空)" : "判定人缩写"} onChange={(e) => setOwner(e.target.value)} />
</div>
</>
)}
{node?.kind === "callout" && (
<div className="gp-edit-row">
<span className="lb">去向</span>
<select value={to} onChange={(e) => setTo(e.target.value)}>
<option value="">(不标去向)</option>
{targets.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
</div>
)}
{edge && (
<div className="gp-edit-row">
<span className="lb">颜色</span>
<select value={tone} onChange={(e) => setTone(e.target.value)}>
<option value="">钢灰(主线)</option>
<option value="ok">绿(通过/接受)</option>
<option value="bad">红(退回/拒绝)</option>
</select>
</div>
)}
<div className="gp-btns">
<button type="button" className="be-btn ghost" onClick={onClose}>
取消
</button>
<button type="button" className="be-btn solid" disabled={node ? !label.trim() : false} onClick={save}>
保存
</button>
</div>
</div>
);
}
+112 -3
View File
@@ -1,16 +1,125 @@
/**
* 编排画布 —— diagram-js 画布件的宿主(编码规范 §6.1「PRD 画布件 1:1 平移」)。
*
* 原型的 `HostExample.FlowFigureBlock` 同时带手绘编辑器;此处先接**只读态**:
* 流程包 → `FlowGraph`(`pkg-to-graph`)→ 布局引擎算几何 → `FlowDiagram` 绘制。
* 节点实例落位以角注呈现,几何不因运行态而变——**图是定义,标注是运行**。
* `FlowFigureBlock` 自原型 `HostExample` 平移:区块小标题右侧「编辑」芯片进手绘编辑器
* (FlowEditor,画布内工具栏含 取消/保存绘图);展示态 hero/cap 框 + FlowDiagram
* (单击节点下钻/选中、双击活动就地改文字);图例贴在流程图正下方。
* 必须包在 `.process-desk > .process-doc` 纸面容器内(flow-canvas.css 规则 scoped 到它)。
*
* `FlowFigure` 保留为只读便捷壳(任务页的流程图用它)。
*/
import { useState } from 'react';
import type { ProcessPackage } from '@contracts';
import { Ico } from '@ui';
import { FlowDiagram } from './FlowDiagram.tsx';
import { FlowEditor } from './FlowEditor.tsx';
import type { FlowGraph } from './flow-layout.ts';
import type { SpPosition } from './support.tsx';
import { packageToGraph } from './pkg-to-graph.ts';
import type { InstanceMark } from './pkg-to-graph.ts';
import './flow-canvas.css';
export function FlowFigureBlock({
title,
oneLiner,
graph,
drilledIds,
positions,
canEdit,
onNodeClick,
onEditNodeText,
onSaveFlowGraph,
}: {
title: string;
oneLiner?: string | undefined;
graph: FlowGraph | null;
/** 已建立下钻内容的节点 id(删除这些节点时编辑器会二次确认) */
drilledIds: string[];
positions?: SpPosition[] | undefined;
canEdit: boolean;
onNodeClick: (nodeId: string) => void;
/** 双击流程图活动就地改文字(标题 / 主办) */
onEditNodeText?: ((nodeId: string, patch: { label: string; owner?: string }) => Promise<void>) | undefined;
/** 手工绘图:整图保存(idMap = 本次编辑会话 原id→现id,宿主用它重键下钻内容) */
onSaveFlowGraph?: ((graph: FlowGraph, idMap: Record<string, string>) => Promise<void>) | undefined;
}) {
const [editing, setEditing] = useState(false);
return (
<>
<div className="view-lbl">
流程图 · 顺序
{canEdit && onSaveFlowGraph !== undefined && !editing && (
<button
type="button"
className="dc2-addbtn"
title="手工绘图(珊格正交:活动/网关/起止/卡点,拖拽连线);画布内工具栏有 取消/保存绘图"
onClick={() => setEditing(true)}
>
<Ico name="pen-line" s={11} /> 编辑
</button>
)}
</div>
{editing && onSaveFlowGraph !== undefined ? (
<div className="hero">
<div className="cap">
<span>FIG — {title}流程 · 手工绘图</span>
<span>珊格 10px · 直线与直角折线</span>
</div>
<FlowEditor
initial={graph}
drilledIds={drilledIds}
positions={positions}
onSave={async (next, idMap) => {
await onSaveFlowGraph(next, idMap);
setEditing(false);
}}
onCancel={() => setEditing(false)}
/>
</div>
) : (
<div className="hero">
<div className="cap">
<span>FIG — {title}流程</span>
<span>{oneLiner ?? '活动 · 网关 · 顺序流'}</span>
</div>
<div className="fig-body">
<FlowDiagram
graph={graph}
variant="business"
onNodeClick={onNodeClick}
onEditNode={canEdit ? onEditNodeText : undefined}
ownerOptions={positions}
ariaLabel={`${title}业务流程图`}
/>
</div>
</div>
)}
{/* 图例贴在流程图正下方,图标与绘图工具(元素盘)同款 SVG */}
<div className="legend">
<span>
<b>图例</b>
</span>
<span><i className="lg-i fe-pi-start" aria-hidden /> 起始</span>
<span><i className="lg-i fe-pi-end" aria-hidden /> 结束</span>
<span><i className="lg-i fe-pi-sop" aria-hidden /> 标准活动</span>
<span><i className="lg-i fe-pi-case" aria-hidden /> 案例活动</span>
<span><i className="lg-i fe-pi-approval" aria-hidden /> 审批点</span>
<span><i className="lg-i fe-pi-gwx" aria-hidden /> 判断网关</span>
<span><i className="lg-i fe-pi-gwand" aria-hidden /> 并行网关</span>
<span><i className="lg-i fe-pi-callout" aria-hidden /> 卡点</span>
<span><i className="lg-i lg-flow" aria-hidden /> 顺序流</span>
<span className="sep">|</span>
<span>
<b>责任</b>
</span>
<span>右上角标 责任人</span>
</div>
</>
);
}
/** 只读壳:流程包 → 图 → 画(任务页 / 工作流实例页用) */
export function FlowFigure({
pkg,
instances,
@@ -0,0 +1,909 @@
import Diagram from "diagram-js";
import BaseRenderer from "diagram-js/lib/draw/BaseRenderer";
import BaseLayouter from "diagram-js/lib/layout/BaseLayouter";
import CroppingConnectionDocking from "diagram-js/lib/layout/CroppingConnectionDocking";
import { repairConnection, withoutRedundantPoints } from "diagram-js/lib/layout/ManhattanLayout";
import { getMid, getOrientation } from "diagram-js/lib/layout/LayoutUtil";
import CommandInterceptor from "diagram-js/lib/command/CommandInterceptor";
import RuleProvider from "diagram-js/lib/features/rules/RuleProvider";
import OrderingProvider from "diagram-js/lib/features/ordering/OrderingProvider";
import ModelingModule from "diagram-js/lib/features/modeling";
import RulesModule from "diagram-js/lib/features/rules";
import LabelSupportModule from "diagram-js/lib/features/label-support";
import MoveModule from "diagram-js/lib/features/move";
import OutlineModule from "diagram-js/lib/features/outline";
import SelectionModule from "diagram-js/lib/features/selection";
import InteractionEventsModule from "diagram-js/lib/features/interaction-events";
import HoverFixModule from "diagram-js/lib/features/hover-fix";
import ConnectModule from "diagram-js/lib/features/connect";
import ConnectionPreviewModule from "diagram-js/lib/features/connection-preview";
import BendpointsModule from "diagram-js/lib/features/bendpoints";
import CreateModule from "diagram-js/lib/features/create";
import PaletteModule from "diagram-js/lib/features/palette";
import ContextPadModule from "diagram-js/lib/features/context-pad";
import GridSnappingModule from "diagram-js/lib/features/grid-snapping";
import SnappingModule from "diagram-js/lib/features/snapping";
import AutoPlaceModule from "diagram-js/lib/features/auto-place";
import MoveCanvasModule from "diagram-js/lib/navigation/movecanvas";
import ZoomScrollModule from "diagram-js/lib/navigation/zoomscroll";
import {
estTextWidth,
MANUAL_GRID,
MANUAL_SIZES,
type FlowEdge,
type FlowGraph,
type FlowNode,
type FlowNodeKind,
type FlowTone,
} from "./flow-layout.ts";
import { cleanPoints, mintNodeId, orderActivityIds, seedManualGraph, snap, swapIdTokens, type OrderNode } from "./manual-flow.ts";
import { arrowHeadPoints, CAPTION_STYLE, FLOW_MONO, paintNode, svgEl, TONE_COLOR } from "./flow-node-paint.ts";
/**
* 手工绘图编辑器内核:照 bpmn-js 的绘图层架构组装 diagram-js——
* modeling+命令栈(撤销/重做)、move 拖拽预览、connect 拖线、bendpoints 折点/线段拖拽、
* palette 拖放落子、context-pad 元素旁快捷盘(追加/连线/删除)、grid-snapping 珊格吸附、
* 曼哈顿正交布线 + 裁剪停靠(线永远直角、端点扣框边)。
* 阉割的数据层:没有 moddle/XML——businessObject 就是我们的 FlowNode/FlowEdge,
* 保存时从 elementRegistry 序列化回 manual FlowGraph(吸附珊格),走原 flow 路由校验落库。
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type Any = any;
export interface NewNodeSpec {
kind: FlowNodeKind;
flavor?: "case" | "approval";
mode?: "x" | "and";
}
const DEFAULT_LABEL: Record<FlowNodeKind, string> = {
activity: "新活动",
gateway: "判断",
start: "开始",
end: "结束",
"end-bad": "终止",
callout: "卡点",
};
function specNode(spec: NewNodeSpec): FlowNode {
return {
id: "",
kind: spec.kind,
label: spec.kind === "gateway" && spec.mode === "and" ? "并行" : DEFAULT_LABEL[spec.kind],
...(spec.flavor ? { flavor: spec.flavor } : {}),
...(spec.mode === "and" ? { mode: "and" as const } : {}),
} as FlowNode;
}
/** 注册表当前的 BO 集合(铸新号避开已用编号;调用方再叠加下钻 operations 键)。 */
function usedIds(elementRegistry: Any): FlowGraph {
const nodes: FlowNode[] = [];
for (const el of elementRegistry.getAll()) {
const bo = el.businessObject as FlowNode | undefined;
if (bo?.id) nodes.push(bo);
}
return { nodes, rows: [], anchors: [], edges: [], manual: true };
}
/* ---------- 渲染器:与展示态共用 flow-node-paint 画笔 ---------- */
class FlowEditorRenderer extends BaseRenderer {
static $inject = ["eventBus"];
constructor(eventBus: Any) {
super(eventBus, 2000);
}
override canRender(): boolean {
return true;
}
override drawShape(parent: SVGElement, element: Any): SVGElement {
// 外部 label 元素(起止/网关旁注、连线标签):可选中可拖动,文字随宿主 BO。
if (element.labelTarget) {
const group = svgEl("g", {});
const host = element.labelTarget;
const bo = (host.businessObject ?? {}) as Partial<FlowNode & FlowEdge>;
const text = bo.label ?? "";
let fill = "#3A4452";
let font = CAPTION_STYLE.gateway!.font;
let size = 10;
let weight: number | undefined = 600;
if (host.waypoints) {
fill = TONE_COLOR[(bo.tone as FlowTone) ?? "main"];
font = FLOW_MONO;
size = 9.8;
weight = undefined;
} else {
const kind = (bo as FlowNode).kind;
const role = kind === "gateway" ? "gateway" : kind;
const style = CAPTION_STYLE[role] ?? CAPTION_STYLE.gateway!;
fill = style.fill;
font = style.font;
size = style.size("business");
weight = style.weight;
}
// 透明命中垫(文字太细,给一块可点可拖的区域)。
group.append(svgEl("rect", { x: 0, y: 0, width: element.width, height: element.height, fill: "transparent" }));
group.append(
svgEl(
"text",
{
x: element.width / 2,
y: element.height / 2 + 3.5,
"text-anchor": "middle",
"font-family": font,
"font-size": size,
...(weight ? { "font-weight": weight } : {}),
fill,
"pointer-events": "none",
},
text,
),
);
parent.appendChild(group);
return group;
}
const bo = (element.businessObject ?? { id: "", kind: "activity", label: "" }) as FlowNode;
const group = svgEl("g", {});
paintNode(group, bo, element.width, element.height, "business");
parent.appendChild(group);
return group;
}
override drawConnection(parent: SVGElement, connection: Any): SVGElement {
const bo = (connection.businessObject ?? {}) as Partial<FlowEdge>;
const waypoints = (connection.waypoints ?? []) as Array<{ x: number; y: number }>;
const group = svgEl("g", {});
if (waypoints.length < 2) {
parent.appendChild(group);
return group;
}
const color = TONE_COLOR[bo.tone ?? "main"];
const d = `M${waypoints[0]!.x},${waypoints[0]!.y} ${waypoints
.slice(1)
.map((p) => `L${p.x},${p.y}`)
.join(" ")}`;
// 加宽透明命中层(线段拖拽/选中手感);标签文字是独立 label 元素,不在此画。
group.append(svgEl("path", { d, fill: "none", stroke: "transparent", "stroke-width": 12 }));
group.append(svgEl("path", { d, fill: "none", stroke: color, "stroke-width": 1.4 }));
group.append(svgEl("polygon", { points: arrowHeadPoints(waypoints), fill: color, "pointer-events": "none" }));
parent.appendChild(group);
return group;
}
/** 裁剪停靠(CroppingConnectionDocking)按此路径求交点——菱形/圆按真形状,线才扣在框边。 */
override getShapePath(shape: Any): string {
const bo = (shape.businessObject ?? {}) as Partial<FlowNode>;
const { x, y, width: w, height: h } = shape;
if (bo.kind === "gateway") {
return `M${x + w / 2},${y}L${x + w},${y + h / 2}L${x + w / 2},${y + h}L${x},${y + h / 2}Z`;
}
if (bo.kind === "start" || bo.kind === "end" || bo.kind === "end-bad") {
const r = w / 2;
const cx = x + r;
const cy = y + r;
return `M${cx},${cy}m0,${-r}a${r},${r},0,1,1,0,${2 * r}a${r},${r},0,1,1,0,${-2 * r}z`;
}
return `M${x},${y}l${w},0l0,${h}l${-w},0z`;
}
override getConnectionPath(connection: Any): string {
const waypoints = (connection.waypoints ?? []) as Array<{ x: number; y: number }>;
if (waypoints.length < 2) return "";
return `M${waypoints[0]!.x},${waypoints[0]!.y}${waypoints
.slice(1)
.map((p) => `L${p.x},${p.y}`)
.join("")}`;
}
}
const rendererModule = {
__init__: ["flowEditorRenderer"],
flowEditorRenderer: ["type", FlowEditorRenderer],
};
/* ---------- 外部 label:起止/网关旁注、连线标签(可拖动换位、随宿主移动) ---------- */
const CAPTIONED_KINDS = new Set<FlowNodeKind>(["start", "end", "end-bad", "gateway"]);
function labelDims(text: string): { width: number; height: number } {
return { width: Math.max(24, Math.ceil(estTextWidth(text || " ", 10)) + 10), height: 16 };
}
function labelOf(host: Any): Any | null {
return host.label ?? host.labels?.[0] ?? null;
}
/** 缺省标签中心:节点=正下方;连线=最长段中点上方一点。 */
function defaultLabelMid(host: Any): { x: number; y: number } {
if (host.waypoints) {
const pts = host.waypoints as Array<{ x: number; y: number }>;
let best = 1;
let bl = -1;
for (let i = 1; i < pts.length; i++) {
const L = Math.abs(pts[i]!.x - pts[i - 1]!.x) + Math.abs(pts[i]!.y - pts[i - 1]!.y);
if (L > bl) {
bl = L;
best = i;
}
}
return { x: (pts[best - 1]!.x + pts[best]!.x) / 2, y: (pts[best - 1]!.y + pts[best]!.y) / 2 - 10 };
}
return { x: host.x + host.width / 2, y: host.y + host.height + 10 };
}
/** 新建带旁注的节点时自动挂标签(palette/追加/撤销重做都经命令栈,一并生效)。 */
class FlowLabelBehavior extends CommandInterceptor {
static override $inject = ["eventBus", "modeling"];
constructor(eventBus: Any, modeling: Any) {
super(eventBus);
// 拖拽连出的线没有 businessObject——统一在创建时种一个空 BO,
// 否则属性卡打不开(分支文字/颜色没入口),序列化也要处处兜底。
this.preExecute(
"connection.create",
(context: Any) => {
const connection = context.connection;
if (connection && !connection.businessObject) connection.businessObject = {};
},
true,
);
this.postExecuted(
"shape.create",
(context: Any) => {
const shape = context.shape;
const bo = shape?.businessObject as FlowNode | undefined;
if (!bo || shape.labelTarget || !CAPTIONED_KINDS.has(bo.kind)) return;
if (labelOf(shape)) return;
modeling.createLabel(shape, defaultLabelMid(shape), {
...labelDims(bo.label ?? ""),
businessObject: bo,
});
},
true,
);
}
}
/**
* 属性卡改完文字后同步标签:重量尺寸、建/删连线标签(文字清空=撤签)。
* 节点旁注常驻(起止/网关必有名);连线标签跟随 label 字段有无。
*/
export function syncElementLabel(diagram: Any, host: Any): void {
const modeling = diagram.get("modeling") as Any;
const eventBus = diagram.get("eventBus") as Any;
const bo = host.businessObject as Partial<FlowNode & FlowEdge>;
const existing = labelOf(host);
const text = bo.label ?? "";
if (host.waypoints) {
if (text && !existing) {
modeling.createLabel(host, defaultLabelMid(host), { ...labelDims(text), businessObject: bo });
return;
}
if (!text && existing) {
modeling.removeShape(existing);
return;
}
}
if (existing) {
const dims = labelDims(text);
const mid = { x: existing.x + existing.width / 2, y: existing.y + existing.height / 2 };
existing.width = dims.width;
existing.height = dims.height;
existing.x = Math.round(mid.x - dims.width / 2);
existing.y = Math.round(mid.y - dims.height / 2);
eventBus.fire("elements.changed", { elements: [existing] });
}
}
/* ---------- 规则:能连什么、能放哪(阉割版——自由绘,只拦真错误) ---------- */
function isRoot(element: Any): boolean {
return !element || !element.parent;
}
function duplicateEdge(source: Any, target: Any, except?: Any): boolean {
return (source?.outgoing ?? []).some((c: Any) => c !== except && c.target === target);
}
class FlowRules extends RuleProvider {
static override $inject = ["eventBus"];
override init(): void {
this.addRule("shape.resize", () => false);
this.addRule(["shape.create", "elements.create"], (context: Any) => {
const target = context.target;
const elements: Any[] = context.elements ?? (context.shape ? [context.shape] : []);
if (elements.some((el) => el.waypoints)) return false;
return isRoot(target) || target === undefined ? true : false;
});
this.addRule("elements.move", (context: Any) => {
const shapes: Any[] = context.shapes ?? [];
// 标签是注记:自由摆放,允许落在任何元素上方(不算嵌套)。
if (shapes.length > 0 && shapes.every((s) => s.labelTarget)) return true;
const target = context.target;
if (target === undefined || isRoot(target)) return true;
return false;
});
this.addRule("connection.start", (context: Any) => {
const source = context.source;
return Boolean(source && !source.waypoints && !isRoot(source));
});
this.addRule("connection.create", (context: Any) => {
const { source, target } = context;
if (!source || !target || source === target) return false;
if (source.waypoints || target.waypoints) return false;
if (isRoot(source) || isRoot(target)) return false;
if (duplicateEdge(source, target)) return false;
return true;
});
this.addRule("connection.reconnect", (context: Any) => {
const { source, target, connection } = context;
if (!source || !target || source === target) return false;
if (source.waypoints || target.waypoints) return false;
if (duplicateEdge(source, target, connection)) return false;
return true;
});
this.addRule("connection.updateWaypoints", () => true);
}
}
const rulesModule = {
__depends__: [RulesModule],
__init__: ["flowRules"],
flowRules: ["type", FlowRules],
};
/* ---------- 布线:曼哈顿正交(bpmn 同款 repairConnection),移动节点自动修线 ---------- */
class FlowLayouter extends BaseLayouter {
override layoutConnection(connection: Any, hints: Any = {}): Array<{ x: number; y: number }> {
const source = hints.source ?? connection.source;
const target = hints.target ?? connection.target;
const start = hints.connectionStart ?? getMid(source);
const end = hints.connectionEnd ?? getMid(target);
const waypoints = hints.waypoints ?? connection.waypoints ?? [];
// 上下关系为主走竖向端口,否则横向(近似 bpmn 的 preferredLayouts 选择)。
const orientation = getOrientation(source, target, 0) as string;
const vertical = orientation === "top" || orientation === "bottom";
const preferred = hints.preferredLayouts ?? [vertical ? "v:v" : "h:h"];
const repaired =
repairConnection(source, target, start, end, waypoints, { ...hints, preferredLayouts: preferred }) ??
[start, end];
return withoutRedundantPoints(repaired);
}
}
/** 连线建/修后按真实形状裁剪端点(bpmn 的 crop 行为)。 */
class FlowCropper extends CommandInterceptor {
static override $inject = ["eventBus", "connectionDocking"];
constructor(eventBus: Any, connectionDocking: Any) {
super(eventBus);
// 注意:第三参 unwrap=true 时回调直接收 context(不是 event)。
const crop = (context: Any) => {
const connection = context.connection;
if (!connection?.source || !connection?.target) return;
try {
connection.waypoints = connectionDocking.getCroppedWaypoints(connection);
} catch {
// 裁剪失败(极端交点)保持原折线——渲染仍正交。
}
};
this.executed(["connection.create", "connection.layout", "connection.reconnect"], crop, true);
}
}
/** 连线垫底、节点在上(免得线盖住框)。 */
class FlowOrdering extends OrderingProvider {
static override $inject = ["eventBus"];
override getOrdering(element: Any, newParent: Any): Any {
if (element.waypoints) return { parent: newParent, index: 0 };
return null;
}
}
const modelingModule = {
__depends__: [ModelingModule, LabelSupportModule],
__init__: ["flowCropper", "flowOrdering", "flowLabelBehavior"],
layouter: ["type", FlowLayouter],
connectionDocking: ["type", CroppingConnectionDocking],
flowCropper: ["type", FlowCropper],
flowOrdering: ["type", FlowOrdering],
flowLabelBehavior: ["type", FlowLabelBehavior],
};
/* ---------- 元素盘(palette):拖放/点击落子 ---------- */
export const PALETTE_SPECS: Array<{ key: string; label: string; className: string; spec: NewNodeSpec }> = [
{ key: "create.sop", label: "标准活动 · flow(蓝 · 照谱执行)", className: "fe-pi fe-pi-sop", spec: { kind: "activity" } },
{ key: "create.case", label: "案例活动 · loop(绿 · 临场演绎)", className: "fe-pi fe-pi-case", spec: { kind: "activity", flavor: "case" } },
{ key: "create.approval", label: "审批点(琥珀 · 人裁节点)", className: "fe-pi fe-pi-approval", spec: { kind: "activity", flavor: "approval" } },
{ key: "create.gw-x", label: "判断网关 ×(按判据走一条)", className: "fe-pi fe-pi-gwx", spec: { kind: "gateway", mode: "x" } },
{ key: "create.gw-and", label: "并行网关 +(全部同时走)", className: "fe-pi fe-pi-gwand", spec: { kind: "gateway", mode: "and" } },
{ key: "create.start", label: "起始事件", className: "fe-pi fe-pi-start", spec: { kind: "start" } },
{ key: "create.end", label: "结束事件", className: "fe-pi fe-pi-end", spec: { kind: "end" } },
{ key: "create.callout", label: "卡点(红 · 标去向)", className: "fe-pi fe-pi-callout", spec: { kind: "callout" } },
];
class FlowPalette {
static $inject = ["palette", "create", "elementFactory", "elementRegistry", "flowEditorConfig"];
private _create: Any;
private _elementFactory: Any;
private _elementRegistry: Any;
private _config: Any;
constructor(palette: Any, create: Any, elementFactory: Any, elementRegistry: Any, flowEditorConfig: Any) {
this._create = create;
this._elementFactory = elementFactory;
this._elementRegistry = elementRegistry;
this._config = flowEditorConfig;
palette.registerProvider(this);
}
getPaletteEntries(): Record<string, Any> {
const entries: Record<string, Any> = {};
for (const item of PALETTE_SPECS) {
const action = (event: Any) => {
const bo = specNode(item.spec);
bo.id = mintNodeId(item.spec.kind, usedIds(this._elementRegistry), this._config.reserved);
const size = MANUAL_SIZES[item.spec.kind];
const shape = this._elementFactory.createShape({ width: size.w, height: size.h, businessObject: bo });
this._create.start(event, shape);
};
entries[item.key] = {
group: "flow",
className: item.className,
title: `${item.label} — 拖到画布或点击后点画布落子`,
action: { dragstart: action, click: action },
};
}
return entries;
}
}
const paletteModule = {
__depends__: [PaletteModule, CreateModule],
__init__: ["flowPalette"],
flowPalette: ["type", FlowPalette],
};
/* ---------- 上下文小盘(context-pad):选中元素旁的 追加/连线/删除 ---------- */
class FlowContextPad {
static $inject = ["contextPad", "connect", "elementFactory", "elementRegistry", "autoPlace", "flowEditorConfig"];
private _connect: Any;
private _elementFactory: Any;
private _elementRegistry: Any;
private _autoPlace: Any;
private _config: Any;
constructor(contextPad: Any, connect: Any, elementFactory: Any, elementRegistry: Any, autoPlace: Any, flowEditorConfig: Any) {
this._connect = connect;
this._elementFactory = elementFactory;
this._elementRegistry = elementRegistry;
this._autoPlace = autoPlace;
this._config = flowEditorConfig;
contextPad.registerProvider(this);
}
getContextPadEntries(element: Any): Record<string, Any> {
const entries: Record<string, Any> = {};
// 标签元素不出小盘(拖动换位、双击改字就够了;删除随宿主)。
if (element.labelTarget) return entries;
const removeElements = () => this._config.requestRemove([element]);
const openProps = () => this._config.requestProps(element);
if (element.waypoints) {
entries["edit"] = {
group: "edit",
className: "fe-ci fe-ci-edit",
title: "编辑分支文字/颜色(双击线也可以)",
action: { click: openProps },
};
entries["delete"] = {
group: "edit",
className: "fe-ci fe-ci-del",
title: "删除连线",
action: { click: removeElements },
};
return entries;
}
entries["edit"] = {
group: "edit",
className: "fe-ci fe-ci-edit",
title: "编辑属性(双击节点也可以)",
action: { click: openProps },
};
const appendEntry = (key: string, title: string, className: string, spec: NewNodeSpec) => {
entries[key] = {
group: "append",
className,
title,
action: {
click: () => {
const bo = specNode(spec);
bo.id = mintNodeId(spec.kind, usedIds(this._elementRegistry), this._config.reserved);
const size = MANUAL_SIZES[spec.kind];
const shape = this._elementFactory.createShape({ width: size.w, height: size.h, businessObject: bo });
this._autoPlace.append(element, shape);
},
},
};
};
appendEntry("append.sop", "后接标准活动(自动连线)", "fe-ci fe-pi-sop", { kind: "activity" });
appendEntry("append.case", "后接案例活动(自动连线)", "fe-ci fe-pi-case", { kind: "activity", flavor: "case" });
appendEntry("append.approval", "后接审批点(自动连线)", "fe-ci fe-pi-approval", { kind: "activity", flavor: "approval" });
appendEntry("append.gw", "后接判断网关 ×(自动连线)", "fe-ci fe-pi-gwx", { kind: "gateway", mode: "x" });
appendEntry("append.end", "后接结束事件(自动连线)", "fe-ci fe-pi-end", { kind: "end" });
entries["connect"] = {
group: "edit",
className: "fe-ci fe-ci-connect",
title: "连线(拖到目标节点)",
action: {
click: (event: Any) => this._connect.start(event, element),
dragstart: (event: Any) => this._connect.start(event, element),
},
};
entries["delete"] = {
group: "edit",
className: "fe-ci fe-ci-del",
title: "删除节点(连带其连线)",
action: { click: removeElements },
};
return entries;
}
}
const contextPadModule = {
__depends__: [ContextPadModule, ConnectModule, AutoPlaceModule],
__init__: ["flowContextPad"],
flowContextPad: ["type", FlowContextPad],
};
/* ---------- 珊格底纹图层 ---------- */
class FlowGridVisual {
static $inject = ["canvas"];
constructor(canvas: Any) {
const layer = canvas.getLayer("flow-grid", -3) as SVGElement;
const defs = svgEl("defs", {});
const pattern = svgEl("pattern", {
id: "fe-grid-dots",
width: MANUAL_GRID * 2,
height: MANUAL_GRID * 2,
patternUnits: "userSpaceOnUse",
});
pattern.append(svgEl("circle", { cx: 1, cy: 1, r: 1, fill: "#DCE3EC" }));
defs.append(pattern);
layer.append(defs);
layer.append(
svgEl("rect", {
x: -4000,
y: -4000,
width: 12000,
height: 12000,
fill: "url(#fe-grid-dots)",
"pointer-events": "none",
}),
);
}
}
const gridVisualModule = {
__init__: ["flowGridVisual"],
flowGridVisual: ["type", FlowGridVisual],
};
/* ---------- 组装 / 导入 / 序列化 ---------- */
export interface FlowEditorHandles {
diagram: Any;
/** 从 elementRegistry 序列化回 manual FlowGraph(坐标/折点吸附珊格,去重去悬空);
* idMap = 本次编辑期活动的 旧id→新id(自动顺序编号所致),服务端据此重键 operations。 */
serialize: () => { graph: FlowGraph; idMap: Record<string, string> };
destroy: () => void;
}
export interface FlowEditorOptions {
container: HTMLElement;
/** 已存在的下钻(operations)键:铸号避开。 */
reserved: ReadonlySet<string>;
/** 删除请求(带下钻确认)由 React 壳接管:确认后自己调 modeling.removeElements。 */
requestRemove: (elements: Any[]) => void;
/** 打开某元素的属性卡(小盘"编辑"入口 / 网关拉线后自动弹卡)。 */
requestProps: (element: Any) => void;
}
export function createFlowEditor(initial: FlowGraph | null | undefined, options: FlowEditorOptions): FlowEditorHandles {
// React 壳的回调/上下文作为 DI value 供给(palette/context-pad 注入 flowEditorConfig)。
const configModule = {
flowEditorConfig: [
"value",
{
reserved: options.reserved,
requestRemove: options.requestRemove,
requestProps: options.requestProps,
},
],
};
const diagram = new Diagram({
canvas: { container: options.container },
modules: [
configModule,
rendererModule,
rulesModule,
modelingModule,
gridVisualModule,
InteractionEventsModule,
HoverFixModule,
SelectionModule,
OutlineModule,
MoveModule,
ConnectModule,
ConnectionPreviewModule,
BendpointsModule,
CreateModule,
paletteModule,
contextPadModule,
GridSnappingModule,
SnappingModule,
AutoPlaceModule,
MoveCanvasModule,
ZoomScrollModule,
],
} as Any);
const canvas = diagram.get("canvas") as Any;
const elementFactory = diagram.get("elementFactory") as Any;
const elementRegistry = diagram.get("elementRegistry") as Any;
const eventBus = diagram.get("eventBus") as Any;
const root = elementFactory.createRoot({ id: "flow-edit-root" });
canvas.setRootElement(root);
// 追加(autoPlace)落点:源右侧 90px,撞车往下让(简版 bpmn AutoPlaceUtil)。
eventBus.on("autoPlace", (event: Any) => {
const { source, shape } = event;
const x = snap(source.x + source.width + 90 + shape.width / 2);
let y = snap(source.y + source.height / 2);
const overlaps = (px: number, py: number) =>
elementRegistry
.getAll()
.some(
(el: Any) =>
!el.waypoints &&
el.parent &&
Math.abs(el.x + el.width / 2 - px) < (el.width + shape.width) / 2 + 20 &&
Math.abs(el.y + el.height / 2 - py) < (el.height + shape.height) / 2 + 20,
);
let guard = 0;
while (overlaps(x, y) && guard++ < 20) y = snap(y + 70);
return { x, y };
});
// 导入既有图(种子):不进命令栈(不可撤销的基线)。
const seeded = seedManualGraph(initial);
const shapeById = new Map<string, Any>();
const addLabelFor = (host: Any, bo: { label?: string; lx?: number; ly?: number }) => {
const dims = labelDims(bo.label ?? "");
const mid = bo.lx !== undefined && bo.ly !== undefined ? { x: bo.lx, y: bo.ly } : defaultLabelMid(host);
const label = elementFactory.createLabel({
labelTarget: host,
businessObject: bo,
x: Math.round(mid.x - dims.width / 2),
y: Math.round(mid.y - dims.height / 2),
width: dims.width,
height: dims.height,
});
canvas.addShape(label, root);
};
for (const node of seeded.nodes) {
const size = MANUAL_SIZES[node.kind];
const shape = elementFactory.createShape({
x: node.x ?? 0,
y: node.y ?? 0,
width: size.w,
height: size.h,
businessObject: node,
});
canvas.addShape(shape, root);
shapeById.set(node.id, shape);
if (CAPTIONED_KINDS.has(node.kind)) addLabelFor(shape, node);
}
for (const edge of seeded.edges) {
const source = shapeById.get(edge.from);
const target = shapeById.get(edge.to);
if (!source || !target || !edge.points || edge.points.length < 2) continue;
const connection = elementFactory.createConnection({
source,
target,
waypoints: edge.points.map((p) => ({ x: p.x, y: p.y })),
businessObject: edge,
});
canvas.addConnection(connection, root);
if (edge.label) addLabelFor(connection, edge);
}
/* ---- 活动自动顺序编号:结构一变(命令栈)就按流向重排 A1..An ----
* 编号是真 id:卡点去向/判据文字同步换号;进场时记住每个节点的原始 id,
* 保存时产出 旧id→新id 映射,服务端据此把 operations(两件套)跟着重键。 */
const origIds = new WeakMap<Any, string>();
for (const el of elementRegistry.getAll()) {
if (el.parent && !el.waypoints && !el.labelTarget && el.businessObject?.id) {
origIds.set(el, el.businessObject.id as string);
}
}
const renumberAll = () => {
const shapes = elementRegistry
.getAll()
.filter((el: Any) => el.parent && !el.waypoints && !el.labelTarget && el.businessObject?.id);
const nodes: OrderNode[] = shapes.map((el: Any) => ({
id: el.businessObject.id,
kind: el.businessObject.kind,
x: el.x + el.width / 2,
y: el.y + el.height / 2,
}));
const edges = elementRegistry
.getAll()
.filter((el: Any) => el.waypoints && el.parent && el.source?.businessObject?.id && el.target?.businessObject?.id)
.map((el: Any) => ({ from: el.source.businessObject.id, to: el.target.businessObject.id }));
const mapping = orderActivityIds(nodes, edges);
if (mapping.size === 0) return;
const changed: Any[] = [];
for (const el of shapes) {
const bo = el.businessObject as FlowNode;
let touched = false;
if (mapping.has(bo.id)) {
bo.id = mapping.get(bo.id)!;
touched = true;
}
if (bo.to && mapping.has(bo.to)) {
bo.to = mapping.get(bo.to)!;
touched = true;
}
if (bo.basis) {
const swapped = swapIdTokens(bo.basis, mapping);
if (swapped !== bo.basis) {
bo.basis = swapped;
touched = true;
}
}
if (touched) {
changed.push(el);
const label = labelOf(el);
if (label) changed.push(label);
}
}
if (changed.length) eventBus.fire("elements.changed", { elements: changed });
};
renumberAll();
eventBus.on("commandStack.changed", renumberAll);
const serialize = (): { graph: FlowGraph; idMap: Record<string, string> } => {
// 画布是无限域(可有负坐标)——先求整体包围,统一平移到非负(保相对几何/正交),再吸附珊格。
let minX = Infinity;
let minY = Infinity;
for (const el of elementRegistry.getAll()) {
if (!el.parent) continue;
if (el.waypoints) {
for (const p of el.waypoints as Array<{ x: number; y: number }>) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
}
} else {
minX = Math.min(minX, el.x);
minY = Math.min(minY, el.y);
}
}
const pad = MANUAL_GRID * 2;
const dx = Number.isFinite(minX) && minX < pad ? pad - minX : 0;
const dy = Number.isFinite(minY) && minY < pad ? pad - minY : 0;
// 标签位(注记中心,随整体平移;不吸附网格):从 label 元素读回宿主 BO 的 lx/ly。
const labelMidOf = (host: Any): { lx: number; ly: number } | null => {
const label = labelOf(host);
if (!label) return null;
return {
lx: Math.max(0, Math.round(label.x + label.width / 2 + dx)),
ly: Math.max(0, Math.round(label.y + label.height / 2 + dy)),
};
};
const nodes: FlowNode[] = [];
const edges: FlowEdge[] = [];
const seen = new Set<string>();
for (const el of elementRegistry.getAll()) {
if (!el.parent) continue; // root
if (el.waypoints || el.labelTarget) continue;
const bo = el.businessObject as FlowNode;
if (!bo?.id || seen.has(bo.id)) continue;
seen.add(bo.id);
const node: FlowNode = { ...bo, x: Math.max(0, snap(el.x + dx)), y: Math.max(0, snap(el.y + dy)) };
const mid = labelMidOf(el);
if (mid) {
node.lx = mid.lx;
node.ly = mid.ly;
} else {
delete node.lx;
delete node.ly;
}
nodes.push(node);
}
const ids = new Set(nodes.map((n) => n.id));
const pairs = new Set<string>();
for (const el of elementRegistry.getAll()) {
if (!el.waypoints || !el.parent) continue;
const from = el.source?.businessObject?.id as string | undefined;
const to = el.target?.businessObject?.id as string | undefined;
if (!from || !to || from === to || !ids.has(from) || !ids.has(to)) continue;
const key = `${from}→${to}`;
if (pairs.has(key)) continue;
pairs.add(key);
const bo = (el.businessObject ?? {}) as FlowEdge;
const points = cleanPoints(
(el.waypoints as Array<{ x: number; y: number }>).map((p) => ({
x: Math.max(0, snap(p.x + dx)),
y: Math.max(0, snap(p.y + dy)),
})),
);
if (points.length < 2) continue;
const edge: FlowEdge = { ...bo, from, to, points };
const mid = labelMidOf(el);
if (mid && edge.label) {
edge.lx = mid.lx;
edge.ly = mid.ly;
} else {
delete edge.lx;
delete edge.ly;
}
edges.push(edge);
}
// 卡点去向指向已删节点的,摘除。
for (const node of nodes) {
if (node.to && node.to !== "E" && !ids.has(node.to)) delete node.to;
}
// 编辑期改号映射(自动顺序编号):旧id→新id,服务端据此重键 operations。
const idMap: Record<string, string> = {};
for (const el of elementRegistry.getAll()) {
if (!el.parent || el.waypoints || el.labelTarget) continue;
const orig = origIds.get(el);
const cur = el.businessObject?.id as string | undefined;
if (orig && cur && orig !== cur) idMap[orig] = cur;
}
return { graph: { nodes, rows: [], anchors: [], edges, manual: true }, idMap };
};
// 开发/自动化调试句柄(E2E 直查 registry/commandStack;销毁时清理)。
(window as Any).__feDiagram = diagram;
return {
diagram,
serialize,
destroy: () => {
if ((window as Any).__feDiagram === diagram) delete (window as Any).__feDiagram;
diagram.destroy();
},
};
}
+9 -9
View File
@@ -25,28 +25,28 @@ export interface FlowNode {
* 编排层五要素之一,零开发、不进活动库。它挂在 kind:"activity" 下**仅因共用同一套方框几何**
* (蛇形折行与尺寸计算),不表示它在模型上属于活动。
*/
flavor?: "case" | "approval";
flavor?: "case" | "approval" | undefined;
/** Case 节点引用的库案例 id(cases[].id):L2 只是引用,下钻编辑共享库案例。 */
caseRef?: string;
label: string;
sub?: string;
sub?: string | undefined;
code?: string;
ghost?: boolean;
note?: string;
/** (遗留)仅卡点:旧编译器按方向着色的产物——卡点现统一红,渲染忽略。 */
tone?: "ok" | "bad";
/** 仅卡点:去向节点 id(回跳返工/前跳直达)——渲染时框内标 →id。 */
to?: string;
to?: string | undefined;
/** 责任下沉:活动=主办(A);网关=判定人。活动框内角标,网关随判据进浮层。 */
owner?: string;
owner?: string | undefined;
/** 判据下沉:仅网关——判定标准(节点数据;画布不再弹窗,导出派生表可见)。 */
basis?: string;
basis?: string | undefined;
/** 仅网关:x=判断(×,默认)/ and=并行(+,所有出口同时走)。 */
mode?: "x" | "and";
mode?: "x" | "and" | undefined;
/** 数据线下沉(仅活动):进/出=流转,查=底座——L2 图后「数据 · 台账与加工」段由此派生。 */
in?: string[];
out?: string[];
use?: string[];
in?: string[] | undefined;
out?: string[] | undefined;
use?: string[] | undefined;
/** 执行基数(exec-spec):item=逐条办;缺省=跟单办。 */
per?: "item";
/** 人机分工:auto 自动 / assist 帮我做 / human 我来做(缺省)。 */
@@ -0,0 +1,136 @@
/**
* 画布图 → 流程包:**合并**,不是重建。
*
* 编辑器只动「画得出来的」:节点增删改名、连线、几何、注记。
* 语义里画布装不下的部分 —— 边映射(装配)、失败走向、驳回走向、催办阶梯、
* 负责人策略、调用接口 —— 一律**从旧包按 id 保留**;id 变过号的经 idMap 跟过去。
* 所以保存永远不会把「画布上看不见的声明」抹掉。
*
* 卡点(callout)与行分组/锚定/折线点属于画布事实,语义 schema 不收:
* 整张手绘图原样存进 `pkg.diagram`(手绘几何即呈现真相),流转判断不读它。
*/
import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
import { activityIdSchema, nodeIdSchema } from '@contracts';
import type { FlowGraph } from './flow-layout.ts';
/** 新画的活动还没绑活动库条目:先以标签占位软引用,闭合校验会标「未安装」,发布被拦 */
const placeholderActivityId = (label: string) =>
activityIdSchema.parse(`未安装:${label.trim() === '' ? '未命名' : label.trim()}`);
export function mergeGraphIntoPackage(
pkg: ProcessPackage,
graph: FlowGraph,
idMap: Readonly<Record<string, string>> = {},
): ProcessPackage {
// 旧节点按「现 id」可查:编辑器改过号的,先把旧包的 id 翻译成新号
const renamed = new Map<string, ProcessNode>();
for (const n of pkg.nodes) renamed.set(idMap[n.id] ?? n.id, n);
const priorEdge = new Map<string, Edge>();
for (const e of pkg.edges) {
const from = idMap[e.from] ?? e.from;
const to = idMap[e.to] ?? e.to;
priorEdge.set(`${from}→${to}`, e);
}
// 画布节点里只有五要素入语义;卡点(callout)留在 diagram
const semantic = graph.nodes.filter((n) => n.kind !== 'callout');
const degree = (id: string, dir: 'in' | 'out') =>
graph.edges.filter((e) => (dir === 'in' ? e.to === id : e.from === id)).length;
const nodes: ProcessNode[] = semantic.map((n) => {
const prior = renamed.get(n.id);
const id = nodeIdSchema.parse(n.id);
const layout = {
row: prior?.layout?.row ?? 0,
col: prior?.layout?.col ?? 0,
...(n.x === undefined ? {} : { x: n.x }),
...(n.y === undefined ? {} : { y: n.y }),
};
const base = {
id,
label: n.label,
...(n.sub === undefined ? {} : { sub: n.sub }),
layout,
};
if (n.kind === 'start') return { ...base, kind: 'start' as const };
if (n.kind === 'end' || n.kind === 'end-bad') {
return {
...base,
kind: 'end' as const,
...(prior?.kind === 'end' && prior.output !== undefined ? { output: prior.output } : {}),
};
}
if (n.kind === 'gateway') {
const priorGw = prior?.kind === 'gateway' ? prior : undefined;
return {
...base,
kind: 'gateway' as const,
gateway: (n.mode === 'and' ? 'parallel' : 'exclusive') as 'parallel' | 'exclusive',
// 分叉/汇合看度数:出边多为叉,入边多为汇;旧声明优先(度数打平时不猜)
mode: priorGw?.mode ?? (degree(n.id, 'out') > degree(n.id, 'in') ? ('fork' as const) : ('join' as const)),
...(n.basis === undefined ? {} : { basis: n.basis }),
};
}
if (n.flavor === 'approval') {
const priorAp = prior?.kind === 'approval' ? prior : undefined;
return {
...base,
kind: 'approval' as const,
approver: {
position: n.owner ?? priorAp?.approver.position ?? '未声明',
strategy: priorAp?.approver.strategy ?? ('sole' as const),
},
// 驳回走向是声明式功能属性,画布不改它 —— 没有旧声明就先「终止」,发布前在属性外补
reject: priorAp?.reject ?? { kind: 'terminate' as const },
...(priorAp?.nudge === undefined ? {} : { nudge: priorAp.nudge }),
...(priorAp?.message === undefined ? {} : { message: priorAp.message }),
};
}
const priorAct = prior?.kind === 'activity' ? prior : undefined;
return {
...base,
kind: 'activity' as const,
activityId: priorAct?.activityId ?? placeholderActivityId(n.label),
owner: {
position: n.owner ?? priorAct?.owner.position ?? '未声明',
strategy: priorAct?.owner.strategy ?? ('sole' as const),
...(priorAct?.owner.staffId === undefined ? {} : { staffId: priorAct.owner.staffId }),
},
failure: priorAct?.failure ?? { kind: 'terminate' as const },
...(priorAct?.message === undefined ? {} : { message: priorAct.message }),
...(n.in === undefined || n.in.length === 0 ? {} : { in: [...n.in] }),
...(n.out === undefined || n.out.length === 0 ? {} : { out: [...n.out] }),
};
});
const semanticIds = new Set(semantic.map((n) => n.id));
const edges: Edge[] = graph.edges
.filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to))
.map((e) => {
const prior = priorEdge.get(`${e.from}→${e.to}`);
return {
id: prior?.id ?? `e-${e.from}-${e.to}`,
from: nodeIdSchema.parse(e.from),
to: nodeIdSchema.parse(e.to),
// 装配映射与条件是「画布上看不见的声明」—— 原样保留,画布删不掉它们
mapping: prior?.mapping ?? [],
...(prior?.condition === undefined ? {} : { condition: prior.condition }),
reopen: prior?.reopen ?? false,
...(e.label === undefined ? {} : { label: e.label }),
...(e.tone === 'ok' || e.tone === 'bad' ? { tone: e.tone } : {}),
};
});
return {
...pkg,
nodes,
edges,
// 手绘图原样存证:卡点/行分组/锚定/折线点都在这里,呈现以它为真相
diagram: JSON.parse(JSON.stringify(graph)) as Record<string, unknown>,
};
}
+266
View File
@@ -0,0 +1,266 @@
import {
MANUAL_GRID,
layoutFlow,
manualNodeSize,
type FlowEdge,
type FlowGraph,
type FlowNode,
type FlowNodeKind,
} from "./flow-layout.ts";
/**
* 手绘流程图的纯几何底座(零 DOM,可单测):
* 珊格吸附、端口(节点四边中点;手绘尺寸取网格友好值,端口天然落珊格)、
* 正交路由/端点重接(编译图→手绘种子用)、编号铸造(A#/G#/X#/B# 未用最小号,避开下钻键)。
* 交互(拖拽/连线/折点)不在这里——编辑器内核 flow-editor-core 用 diagram-js modeling 全栈
* (bpmn.js 的绘图层机制);保存序列化时借这里的 snap/cleanPoints 收口。
* 服务端 manual-flow.ts 的校验(网格/正交/引用)是同一契约的落库硬门。
*/
export const snap = (v: number): number => Math.round(v / MANUAL_GRID) * MANUAL_GRID;
export type PortSide = "n" | "e" | "s" | "w";
export interface Pt {
x: number;
y: number;
}
export function nodeBox(node: FlowNode): { x: number; y: number; w: number; h: number } {
const size = manualNodeSize(node);
return { x: node.x ?? 0, y: node.y ?? 0, w: size.w, h: size.h };
}
export function nodeCenter(node: FlowNode): Pt {
const b = nodeBox(node);
return { x: b.x + b.w / 2, y: b.y + b.h / 2 };
}
export function portPoint(node: FlowNode, side: PortSide): Pt {
const b = nodeBox(node);
switch (side) {
case "n":
return { x: b.x + b.w / 2, y: b.y };
case "s":
return { x: b.x + b.w / 2, y: b.y + b.h };
case "e":
return { x: b.x + b.w, y: b.y + b.h / 2 };
case "w":
return { x: b.x, y: b.y + b.h / 2 };
}
}
/** 按目标方位取水平/垂直向端口。 */
function portToward(node: FlowNode, toward: Pt, axis: "h" | "v"): Pt {
const c = nodeCenter(node);
if (axis === "h") return portPoint(node, toward.x >= c.x ? "e" : "w");
return portPoint(node, toward.y >= c.y ? "s" : "n");
}
/** 折线清理:去零段、并共线;至少保两点。 */
export function cleanPoints(points: Pt[]): Pt[] {
const pts = points.map((p) => ({ x: p.x, y: p.y }));
let changed = true;
while (changed && pts.length > 2) {
changed = false;
for (let i = pts.length - 2; i >= 0; i--) {
if (pts[i]!.x === pts[i + 1]!.x && pts[i]!.y === pts[i + 1]!.y) {
pts.splice(i + 1, 1);
changed = true;
}
}
for (let i = pts.length - 3; i >= 0; i--) {
const [a, b, c] = [pts[i]!, pts[i + 1]!, pts[i + 2]!];
if ((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) {
pts.splice(i + 1, 1);
changed = true;
}
}
}
return pts;
}
/** 两节点间的默认正交路由:同轴直线,否则 Z 形(中线拐两折)。种子重接兜底用。 */
export function routeBetween(from: FlowNode, to: FlowNode): Pt[] {
const fc = nodeCenter(from);
const tc = nodeCenter(to);
const dx = tc.x - fc.x;
const dy = tc.y - fc.y;
if (Math.abs(dx) >= Math.abs(dy)) {
const exit = portPoint(from, dx >= 0 ? "e" : "w");
const enter = portPoint(to, dx >= 0 ? "w" : "e");
if (exit.y === enter.y) return [exit, enter];
const midX = snap((exit.x + enter.x) / 2);
return cleanPoints([exit, { x: midX, y: exit.y }, { x: midX, y: enter.y }, enter]);
}
const exit = portPoint(from, dy >= 0 ? "s" : "n");
const enter = portPoint(to, dy >= 0 ? "n" : "s");
if (exit.x === enter.x) return [exit, enter];
const midY = snap((exit.y + enter.y) / 2);
return cleanPoints([exit, { x: exit.x, y: midY }, { x: enter.x, y: midY }, enter]);
}
/**
* 端点重接:节点换尺寸/吸附后,保留中段走线,只把首末段重新扣回端口
* (按首末段原方向选端口,邻点顺轴平移保正交)。两点直线直接整条重路由。
*/
export function attachEndpoints(points: Pt[], from: FlowNode, to: FlowNode): Pt[] {
if (points.length <= 2) return routeBetween(from, to);
const pts = points.map((p) => ({ x: p.x, y: p.y }));
const headHorizontal = pts[0]!.y === pts[1]!.y;
const head = portToward(from, pts[1]!, headHorizontal ? "h" : "v");
pts[0] = { ...head };
if (headHorizontal) pts[1] = { x: pts[1]!.x, y: head.y };
else pts[1] = { x: head.x, y: pts[1]!.y };
const n = pts.length;
const tailHorizontal = pts[n - 1]!.y === pts[n - 2]!.y;
const tail = portToward(to, pts[n - 2]!, tailHorizontal ? "h" : "v");
pts[n - 1] = { ...tail };
if (tailHorizontal) pts[n - 2] = { x: pts[n - 2]!.x, y: tail.y };
else pts[n - 2] = { x: tail.x, y: pts[n - 2]!.y };
const cleaned = cleanPoints(pts);
return cleaned.length >= 2 ? cleaned : routeBetween(from, to);
}
const ID_PREFIX: Partial<Record<FlowNodeKind, string>> = {
activity: "A",
gateway: "G",
callout: "X",
"end-bad": "B",
};
/* ---- 活动自动顺序编号:沿流向拓扑序,几何 (y,x) 决胜 ---- */
export interface OrderNode {
id: string;
kind: FlowNodeKind;
/** 节点中心(决胜用)。 */
x: number;
y: number;
}
/**
* 计算活动的规范编号 A1..An:从入度 0 的节点(起始)沿连线做拓扑遍历,
* 同时就绪者按 (y,x) 取先(上面的先、左边的先)——汇合点等所有来路编号完才轮到,
* 所以网关分支链先占号、汇后主线随后,与剧本世界的编号纪律同构;
* 几何即排序把手:想换号,挪节点。环用 (剩余入度,y,x) 最小者破;
* 不可达的活动排在最后。只返回**有变化**的 旧id→新id 映射。
*/
export function orderActivityIds(nodes: OrderNode[], edges: Array<{ from: string; to: string }>): Map<string, string> {
const byId = new Map(nodes.map((n) => [n.id, n]));
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0]));
const out = new Map<string, string[]>();
for (const e of edges) {
if (!byId.has(e.from) || !byId.has(e.to) || e.from === e.to) continue;
out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1);
}
const geo = (id: string): [number, number] => {
const n = byId.get(id)!;
return [n.y, n.x];
};
const byGeo = (a: string, b: string) => geo(a)[0] - geo(b)[0] || geo(a)[1] - geo(b)[1];
const visited = new Set<string>();
const ready = new Set<string>(nodes.filter((n) => (indeg.get(n.id) ?? 0) === 0).map((n) => n.id));
const mapping = new Map<string, string>();
let seq = 0;
const visit = (id: string) => {
visited.add(id);
ready.delete(id);
const node = byId.get(id)!;
if (node.kind === "activity") mapping.set(id, `A${++seq}`);
for (const t of out.get(id) ?? []) {
indeg.set(t, (indeg.get(t) ?? 1) - 1);
if ((indeg.get(t) ?? 0) <= 0 && !visited.has(t)) ready.add(t);
}
};
while (visited.size < nodes.length) {
const candidates = [...ready].filter((id) => !visited.has(id));
if (candidates.length > 0) {
visit(candidates.sort(byGeo)[0]!);
} else {
// 环/汇合死锁:挑未访问中 (剩余入度, y, x) 最小者破环。
const rest = nodes.filter((n) => !visited.has(n.id)).map((n) => n.id);
rest.sort((a, b) => (indeg.get(a) ?? 0) - (indeg.get(b) ?? 0) || byGeo(a, b));
visit(rest[0]!);
}
}
for (const [oldId, newId] of [...mapping]) {
if (oldId === newId) mapping.delete(oldId);
}
return mapping;
}
/** 文本里的 id 引用按映射换号:占位符两段式(防级联),长 id 先换(防 A10 被 A1 误伤)。 */
export function swapIdTokens(text: string, mapping: ReadonlyMap<string, string>): string {
const pairs = [...mapping.entries()].filter(([from, to]) => from !== to).sort((a, b) => b[0].length - a[0].length);
let outText = text;
pairs.forEach(([from], i) => {
outText = outText.split(from).join(`⟦${i}⟧`);
});
pairs.forEach(([, to], i) => {
outText = outText.split(`⟦${i}⟧`).join(to);
});
return outText;
}
/** 铸新编号:前缀取未用最小号;起/止先用 S/E,再 S2/E2…;避开 reserved(下钻 operations 键)。 */
export function mintNodeId(kind: FlowNodeKind, graph: FlowGraph, reserved: ReadonlySet<string>): string {
const used = new Set([...graph.nodes.map((n) => n.id), ...reserved]);
if (kind === "start" || kind === "end") {
const base = kind === "start" ? "S" : "E";
if (!used.has(base)) return base;
for (let n = 2; ; n++) if (!used.has(`${base}${n}`)) return `${base}${n}`;
}
const prefix = ID_PREFIX[kind] ?? "N";
for (let n = 1; ; n++) if (!used.has(`${prefix}${n}`)) return `${prefix}${n}`;
}
/**
* 进入绘图模式的种子:
* - 已是手绘图 → 深拷贝;
* - 自动布局图(编译产物/旧数据)→ 按当前布局几何转手绘:节点取布局位置换手绘尺寸(保中心),
* 连线沿布局折线吸附珊格再把端点扣回新端口——用户从"看到的样子"起步,而不是被重排;
* - 空 → 空白画布。
*/
export function seedManualGraph(graph: FlowGraph | null | undefined): FlowGraph {
if (!graph || graph.nodes.length === 0) {
return { nodes: [], rows: [], anchors: [], edges: [], manual: true };
}
if (graph.manual) return JSON.parse(JSON.stringify(graph)) as FlowGraph;
const layout = layoutFlow(graph, "business");
let minY = Infinity;
for (const shape of layout.shapes) minY = Math.min(minY, shape.y);
for (const c of layout.connections) for (const p of c.waypoints) minY = Math.min(minY, p.y);
const off = minY < MANUAL_GRID * 2 ? Math.ceil((MANUAL_GRID * 2 - minY) / MANUAL_GRID) * MANUAL_GRID : 0;
const nodes: FlowNode[] = layout.shapes.map((shape) => {
const size = manualNodeSize(shape.node);
const cx = shape.x + shape.w / 2;
const cy = shape.y + shape.h / 2 + off;
return {
...shape.node,
x: Math.max(0, snap(cx - size.w / 2)),
y: Math.max(0, snap(cy - size.h / 2)),
};
});
const byId = (id: string) => nodes.find((n) => n.id === id);
const edges: FlowEdge[] = [];
for (const connection of layout.connections) {
const from = byId(connection.edge.from);
const to = byId(connection.edge.to);
if (!from || !to) continue;
const pts = connection.waypoints.map((p) => ({ x: Math.max(0, snap(p.x)), y: Math.max(0, snap(p.y + off)) }));
edges.push({ ...connection.edge, points: attachEndpoints(pts, from, to) });
}
return { nodes, rows: [], anchors: [], edges, manual: true };
}
+58 -17
View File
@@ -20,23 +20,31 @@ export type InstanceMark = {
/** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */
const ROW_WIDTH = 4;
export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark[] = []): FlowGraph {
export function packageToGraph(
pkg: ProcessPackage,
marks: readonly InstanceMark[] = [],
/** 活动范式(activityId → loop|flow):案例活动画绿 —— 范式住活动库,包里只有引用 */
paradigms?: ReadonlyMap<string, string>,
): FlowGraph {
const markOf = new Map(marks.map((m) => [m.nodeId, m]));
/*
* 手绘图存证优先:编辑器保存过,**手绘几何即呈现真相**。
* 运行标注仍要每次现算(它是运行态,不入存证),合到存证节点的 note 上。
*/
const stored = readStoredDiagram(pkg.diagram);
if (stored !== undefined) {
return {
...stored,
nodes: stored.nodes.map((n) => {
const state = stateOf(markOf.get(n.id));
return state === undefined ? n : { ...n, note: state };
}),
};
}
const nodes: FlowNode[] = pkg.nodes.map((n) => {
const mark = markOf.get(n.id);
const state =
mark === undefined || !mark.activated
? undefined
: mark.terminal !== undefined
? mark.terminal === 'released'
? '已放行'
: '已终止'
: mark.running
? '运行中'
: mark.waiting
? '等待中'
: '已激活';
const state = stateOf(markOf.get(n.id));
if (n.kind === 'gateway') {
return {
@@ -45,7 +53,8 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
label: n.label,
// 并行网关画 +,异或网关画 ×(判断)
mode: n.gateway === 'parallel' ? 'and' : 'x',
...(n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }),
...(n.sub === undefined ? (n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }) : { sub: n.sub }),
...(n.basis === undefined ? {} : { basis: n.basis }),
...(state === undefined ? {} : { note: state }),
};
}
@@ -58,13 +67,20 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
kind: 'activity',
label: n.label,
code: n.id,
...(n.sub === undefined ? {} : { sub: n.sub }),
...(state === undefined ? {} : { note: state }),
};
if (n.kind === 'approval') {
// approval **不是一种活动**:它挂在 kind:"activity" 下仅因共用同一套方框几何
return { ...base, flavor: 'approval', owner: n.approver.position };
}
return { ...base, owner: n.owner.position };
return {
...base,
owner: n.owner.position,
...(paradigms?.get(n.activityId) === 'loop' ? { flavor: 'case' as const } : {}),
...(n.in === undefined ? {} : { in: [...n.in] }),
...(n.out === undefined ? {} : { out: [...n.out] }),
};
});
/** 蛇形折行:偶数行 L→R、奇数行 R→L —— 行的划分交给拓扑序 */
@@ -76,13 +92,38 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
from: e.from,
to: e.to,
// 回边按声明走「回 A4 重开新实例」,视觉上标红以区分正向流转
...(e.reopen ? { tone: 'bad' as const } : {}),
...(e.tone !== undefined ? { tone: e.tone } : e.reopen ? { tone: 'bad' as const } : {}),
...(e.label === undefined ? (e.condition === undefined ? {} : { label: e.condition }) : { label: e.label }),
}));
return { nodes, rows, anchors: [], edges };
}
/**
* 手绘图存证的在场校验:`diagram` 由本仓 `graph-to-pkg` 序列化而来,结构即 FlowGraph;
* 这里只验骨架在不在(nodes/edges 数组、非空),不逐字段复检 —— 写入侧是唯一生产者。
*/
function readStoredDiagram(d: Record<string, unknown> | undefined): FlowGraph | undefined {
if (d === undefined) return undefined;
const nodes = d['nodes'];
const edges = d['edges'];
if (!Array.isArray(nodes) || nodes.length === 0 || !Array.isArray(edges)) return undefined;
return {
nodes,
edges,
rows: Array.isArray(d['rows']) ? d['rows'] : [],
anchors: Array.isArray(d['anchors']) ? d['anchors'] : [],
manual: d['manual'] === true,
} as FlowGraph;
}
/** 运行标注:节点实例态 → 画布角注文字;未激活不标 */
function stateOf(mark: InstanceMark | undefined): string | undefined {
if (mark === undefined || !mark.activated) return undefined;
if (mark.terminal !== undefined) return mark.terminal === 'released' ? '已放行' : '已终止';
return mark.running ? '运行中' : mark.waiting ? '等待中' : '已激活';
}
/** 拓扑序:回边不参与定序(否则成环),同层按声明顺序稳定排列 */
function topologicalOrder(pkg: ProcessPackage): string[] {
const ids = pkg.nodes.map((n) => n.id as string);
+5 -1
View File
@@ -108,7 +108,11 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
{ key: 'emp', label: '员工', ico: 'users' },
{ key: 'roles', label: '岗位授权', ico: 'key' },
],
workflows: [{ key: 'pkgs', label: '流程包', ico: 'boxes' }],
workflows: [
{ key: 'overview', label: '总览', ico: 'gauge' },
{ key: 'pkgs', label: '流程包', ico: 'boxes' },
{ key: 'todo', label: '需处置', ico: 'flag' },
],
triggers: [{ key: 'rules', label: '起单规则', ico: 'zap' }],
library: [{ key: 'shelf', label: '活动货架', ico: 'boxes' }],
tools: [{ key: 'catalog', label: '能力目录', ico: 'wrench' }],
+1 -1
View File
@@ -389,7 +389,7 @@ export type ActSpec = {
primary?: boolean;
danger?: boolean;
disabled?: boolean;
ask?: AskSpec;
ask?: AskSpec | undefined;
onClick?: () => void;
};
+10 -2
View File
@@ -4,8 +4,16 @@
import { useCallback, useEffect, useState } from "react";
const MIN_W = 340;
// 上限随视口收敛:给 视图区(≥480) + 清单列 + 间距 留足空间,避免拖到中栏塌陷
const maxW = () => Math.max(MIN_W, Math.min(720, window.innerWidth - 830));
/**
* 视图区的地板 —— 与 app.css 的 `--jc-work-min` 同一个数,两处必须一致:
* CSS 管**默认布局**不塌,这里管**拖拽**不越界。
* 690 的来处:页签条含图标的自然内容 649px + 工作卡内边距 38px = 687,上取整留 3px。
*/
const WORK_MIN = 690;
/** 左轨 86 + 清单 256 + 间距与内边距 ≈ 40 */
const CHROME = 86 + 256 + 40;
// 上限随视口收敛:先把视图区的地板扣掉,剩下的才是对话区能占的
const maxW = () => Math.max(MIN_W, Math.min(720, window.innerWidth - CHROME - WORK_MIN));
export function useChatWidth(storageKey = "jc-chat-w") {
const [w, setW] = useState<number | null>(null); // null = 未拖拽,走断点默认
@@ -0,0 +1,12 @@
CREATE TABLE "sys"."session" (
"id" text PRIMARY KEY NOT NULL,
"staff_id" text NOT NULL,
"issued_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sys"."staff" ADD COLUMN "password_hash" text;--> statement-breakpoint
ALTER TABLE "sys"."session" ADD CONSTRAINT "session_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "sys"."staff"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "session_staff_idx" ON "sys"."session" USING btree ("staff_id");--> statement-breakpoint
CREATE INDEX "session_expires_idx" ON "sys"."session" USING btree ("expires_at");
@@ -0,0 +1,11 @@
CREATE TABLE "sys"."work_group" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"owner_id" text NOT NULL,
"members" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sys"."workflow" ADD COLUMN "work_group" jsonb;--> statement-breakpoint
ALTER TABLE "sys"."work_group" ADD CONSTRAINT "work_group_owner_id_staff_id_fk" FOREIGN KEY ("owner_id") REFERENCES "sys"."staff"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "work_group_owner_idx" ON "sys"."work_group" USING btree ("owner_id");

Some files were not shown because too many files have changed in this diff Show More