forked from ChaiHao/BPOS-MVP
基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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)');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 步 */
|
||||
|
||||
@@ -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 只推给有权看的人 */
|
||||
|
||||
@@ -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_-]+$/);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
/** 周期扫描:日历型按表达式到点;数据日历型逐行起单 */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'human') {
|
||||
return (
|
||||
<div className="jc-bubble side-me">
|
||||
<div className="jc-bubble-body">
|
||||
<p>{msg.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'system') {
|
||||
return (
|
||||
<div className="jc-bubble side-sys">
|
||||
<span className="jc-ava sys">
|
||||
<Ico name="bot" s={14} />
|
||||
<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>
|
||||
<div className="jc-bubble-body">
|
||||
<b className="jc-tag tag-event">{EVENT_TYPE_LABEL[msg.type]}事件</b>
|
||||
<p>{msg.text}</p>
|
||||
</span>
|
||||
<b>还没有消息</b>
|
||||
<p>AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。</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>}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
open.map((order) => <WorkOrderCard key={order.id} order={order} onReply={onReply} />)
|
||||
))}
|
||||
|
||||
<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" />
|
||||
{/*
|
||||
活动推的 HTML 优先:那是它按当下局面组织的**完整一屏**;
|
||||
没推 HTML 时退回结构化块。两者都经 ViewContainer 渲染 —— feature 不碰 innerHTML。
|
||||
翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。
|
||||
按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。
|
||||
*/}
|
||||
{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>
|
||||
{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>
|
||||
|
||||
{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>
|
||||
{/* 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="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
<div className="jc-wfd-sec">
|
||||
<span className="jc-wfd-sec-ico">
|
||||
<Ico name={ico} s={12} />
|
||||
</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>
|
||||
))}
|
||||
<b>{label}</b>
|
||||
<i className="ln" />
|
||||
{aside}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -389,7 +389,7 @@ export type ActSpec = {
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
ask?: AskSpec;
|
||||
ask?: AskSpec | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user