基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
+207
-195
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
|
||||
# ADR-010 鉴权会话方案
|
||||
|
||||
状态:已接受(2026-07-27)
|
||||
背景出处:编码规范 §4「鉴权定调:单组织 cookie session —— 登录、会话与岗位权限校验中间件在
|
||||
apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制」;
|
||||
§12 留白清单「鉴权会话与凭证加密方案 —— 实现期再定,各建 ADR 后方可动工」。
|
||||
|
||||
## 问题
|
||||
|
||||
在此之前系统**没有登录、也没有登出**。中间件只做一件事:
|
||||
|
||||
```ts
|
||||
const match = /(?:^|;\s*)jcai_account=([^;]+)/.exec(cookie);
|
||||
c.set('account', match?.[1] ?? DEFAULT_LOGIN_ACCOUNT);
|
||||
```
|
||||
|
||||
三个后果,前两个是缺功能,第三个是安全缺陷:
|
||||
|
||||
1. 没有登录口 —— 谁打开页面都是种子登录者冯兵,无法验多人协作
|
||||
(A1 冯兵 → A3 林佩萱 → AP 丁子乔 的流转根本走不通)
|
||||
2. 没有登出口 —— cookie 从未被写入,也就无从清除
|
||||
3. **身份可任意伪造** —— cookie 是明文账号,浏览器里改一个字符即可冒充任何人,
|
||||
包括审批人。「驳回必附意见」「完成只出自放行通过」这类规矩全部形同虚设:
|
||||
规矩管的是**这个人**做了什么,而这个人是谁可以随手改写
|
||||
|
||||
## 决策
|
||||
|
||||
### 一、凭据:口令 + scrypt
|
||||
|
||||
`staff` 增列 `password_hash`。散列用 Node 内置 `crypto.scrypt`(N=16384, r=8, p=1),
|
||||
格式 `scrypt$N$r$p$<salt-b64>$<hash-b64>`,比对走 `timingSafeEqual`。
|
||||
|
||||
**不引第三方散列库**:scrypt 是 Node 标准库自带的内存硬散列,参数与格式自描述,
|
||||
零依赖即可满足 ≤50 人单组织的口令存储要求。
|
||||
|
||||
种子口令取自环境变量 `JCAI_SEED_PASSWORD`,缺省 `jcai-dev-2026`,播种时在 stdout 明示。
|
||||
**源码里不写任何真实口令**。
|
||||
|
||||
### 二、会话:服务端会话表 + 不透明 token
|
||||
|
||||
新表 `sys.session`:`id`(即 token,32 字节随机 base64url)、`staff_id`、
|
||||
`issued_at`、`expires_at`、`last_seen_at`。cookie `jcai_session`,
|
||||
`HttpOnly; SameSite=Lax; Path=/`,有效期 12 小时,每次命中滑动续期。
|
||||
|
||||
**为什么不用签名 cookie(HMAC 自包含)**:登出必须**立刻**失效。签名 cookie 在到期前
|
||||
无法作废,除非另建吊销表 —— 那还不如直接用会话表。会话表还顺带解决另一件事:
|
||||
`org` 停用员工时可以一并踢掉他的在途会话,否则被停用的人凭旧 cookie 能继续办事。
|
||||
|
||||
### 三、归属:会话归 `org`,中间件归 app
|
||||
|
||||
会话是**身份**的一部分,而 `org` 已经拥有 staff / position / grant 与 `loadSession`。
|
||||
所以 `login` / `logout` / `resolveSession` / `setPassword` 落 `org` 出口;
|
||||
app 层只放中间件与两条路由 —— 与 §4「中间件在 app 层实现,权限判定读 `org` 出口」一致。
|
||||
|
||||
### 四、免鉴权白名单
|
||||
|
||||
只三条,其余一律 401:
|
||||
|
||||
| 路由 | 理由 |
|
||||
|---|---|
|
||||
| `GET /api/health` | 存活探针 |
|
||||
| `POST /api/login` | 登录本身 |
|
||||
| `POST /api/ingest/mail/:channelId` | **外界边界**:代替真实 IMAP 拉件,不是人的请求 |
|
||||
|
||||
`/api/stream`(SSE)此前完全无鉴权,现纳入鉴权 —— 它推的是「哪个目标动了」,
|
||||
虽不含业务载荷,但目标 ID 本身即信息。
|
||||
|
||||
### 五、明文 `jcai_account` cookie 立即废止,不走三步弃用
|
||||
|
||||
三步弃用是给**契约变更**留的缓冲期。这一条是安全缺陷修复:保留兼容期
|
||||
等于保留一条「改 cookie 即可冒充审批人」的旁路。同理,`DEFAULT_LOGIN_ACCOUNT`
|
||||
不再作为请求身份的兜底,只保留其种子语义(播种时谁是第一个员工)。
|
||||
|
||||
## 后果
|
||||
|
||||
- 前端必须有登录页:`/api/session` 返回 401 即落登录页,登录成功后进原台面
|
||||
- 开发期用 curl 打接口需要先 `POST /api/login` 拿 cookie,不能再靠缺省身份
|
||||
- 口令重置目前只在种子期给出;治理台的「员工」面后续可加重置动作(未做,见 debt)
|
||||
- 未做:失败次数限制 / 锁定、口令强度校验、首登强制改密。单组织 ≤50 人、
|
||||
部署在内网的前提下先不做,列入 `docs/debt.md`
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-011 工作组:起单时的岗位绑定
|
||||
|
||||
状态:已接受(2026-07-27)
|
||||
背景出处:OS 03「编排声明岗位,**起单时确定性解析**。策略封闭四选;
|
||||
默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,结果与依据留痕」。
|
||||
|
||||
## 问题
|
||||
|
||||
流程包声明的是**岗位**(`owner.position`),谁来做由四策略在节点起单时算。
|
||||
这对生产是对的,对两类场景却不够:
|
||||
|
||||
1. **一单一队**:同一条工作流的各节点,业务上常常要固定给同一拨人
|
||||
(客户 A 的报价,前后都归这个商务、这个采购)。`follow-source` 只能固定
|
||||
*同一岗位*的人,跨岗位的搭档关系表达不了。
|
||||
2. **测试与演练**:要让一条流的所有节点都落到同一个人身上,好一个人从头走到尾。
|
||||
现状做不到 —— 每个岗位都会解析到该岗位上的人。
|
||||
|
||||
## 决策
|
||||
|
||||
引入**工作组**:一组「岗位 → 员工」的绑定,起单时装载,随这条工作流走。
|
||||
|
||||
### 一、绑定先于策略,但不是第五种策略
|
||||
|
||||
OS 03 说「策略封闭四选」,这条不动。工作组不是策略,是**岗位的人选已经定了** ——
|
||||
四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,于是策略无从发问。
|
||||
解析顺序因此是:
|
||||
|
||||
```
|
||||
工作组给了这个岗位的人 且 此人在岗 → 用他(by: 'work-group')
|
||||
否则 → 四策略照常(by: 四选之一)
|
||||
```
|
||||
|
||||
工作组给的人**已离岗**时不硬用,退回四策略 —— 绑定不该把单派给一个不在岗的人。
|
||||
|
||||
### 二、快照装载,不引用
|
||||
|
||||
起单时把成员表**拷进工作流记录**,之后改组不影响已起的单。
|
||||
理由与版本信封一致(AM 9.1):留痕要能解释「当时为什么派给他」,
|
||||
而不是被今天的组织调整改写。
|
||||
|
||||
### 3、按来源人装载
|
||||
|
||||
「凡单必有来源」(OS 3.1)。起单时取 `source.by`(登记人 / 触发来源人)的工作组:
|
||||
|
||||
| 来源 | 装载 |
|
||||
|---|---|
|
||||
| 登记触发(人工起单) | 登记人的工作组 |
|
||||
| 事件 / 周期触发,带来源人 | 该人的工作组 |
|
||||
| 无来源人 | **不装载** —— 全走四策略 |
|
||||
|
||||
没有工作组的员工起单也不装载。**向后兼容**:此前起的单、以及所有没建组的人,
|
||||
行为与从前完全一致。
|
||||
|
||||
### 四、归属 `org`
|
||||
|
||||
工作组是「人和岗位」的事,`org`(L1)已经拥有 staff / position / grant。
|
||||
`process` 经既有的 `OrgGateway` 端口多问一句 `workGroupOf(staffId)`,不新增依赖方向。
|
||||
|
||||
## 后果
|
||||
|
||||
- `WorkflowRecord` 增 `workGroup?: Record<岗位 code, 员工 id>`(快照,可空)
|
||||
- `Resolution.by` 从 `OwnerStrategy` 放宽到 `OwnerStrategy | 'work-group'` ——
|
||||
留痕里能一眼看出这单是绑定派的还是策略派的
|
||||
- 一人一组即可造出「整条流都归我」的测试组:所有岗位都指向同一人
|
||||
- 未做:工作组的治理台管理界面(建组 / 改组 / 指派默认组)。
|
||||
目前只能经播种或直接调用例创建,列入 `docs/debt.md`
|
||||
@@ -15,3 +15,41 @@
|
||||
| M5-2 | loop 的 Agent 回路直接经 `ModelGateway`,未用 Mastra `Agent` | 编码规范 §6.1 规定用 Mastra Agent;`AgentPort` 端口不变,换实现时 usecase 不动;当前实现仍过两缝(录制回放可见) | M6 前 | 进行中 |
|
||||
| M5-1 | `process` 未用 Mastra workflow 编译器,流转由自持确定性状态机 + `sys.node_instance` 承担 | 编码规范 §6.1 规定流程包编译为 Mastra 图;二者语义等价(挂起/恢复由节点实例表承担),先落可测的自持实现,Mastra 编译器作为可替换 adapter 后补 —— usecase 不动 | M5 收尾 | 进行中 |
|
||||
| M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 |
|
||||
|
||||
## 鉴权(ADR-010 落地后的已知欠账)
|
||||
|
||||
- **登录失败无次数限制**:可无限次试口令。单组织 ≤50 人、内网部署下暂缓;
|
||||
接入外网前必须补(按账号 + 来源 IP 双维度退避)。
|
||||
- **无口令强度校验、无首登强制改密**:全员初始口令相同(`JCAI_SEED_PASSWORD`),
|
||||
谁都没改过就等于没有口令。治理台「员工」面需补:改密 / 重置 / 强制改密标记。
|
||||
- **`setPassword` 已在 org 出口就位但无路由**:改密只能经播种或直接调用例,界面上没有入口。
|
||||
- **会话无「登出全部设备」**:`revokeAllOf` 已实现(停用员工时调),但本人无从触发。
|
||||
|
||||
## 工作组(ADR-011 落地后的已知欠账)
|
||||
|
||||
- **没有治理台管理界面**:建组 / 改组 / 指派默认组只能经播种或直接调用例。
|
||||
`saveWorkGroup` 已在 org 出口就位,缺的是「员工」面下的一个子页。
|
||||
- **一人一组**:`workGroupOf` 取该人名下稳定的第一条;起单时选哪个组这一步没做。
|
||||
- **组成员离岗无提醒**:绑定的人离岗时静默退回四策略,不告诉任何人。
|
||||
|
||||
## 工作成果 · 文件
|
||||
|
||||
- **Markdown 只以原文纸面呈现**:富文本渲染(GFM 表格 / 内联 SVG)要 `react-markdown` +
|
||||
`remark-gfm` + `rehype-raw` 三个依赖,当前成果文件绝大多数是 HTML,先不引。
|
||||
引入前不假装渲染过 —— FileViewer 里以 `<pre>` 出原文。
|
||||
- **过程成果(deliverable)尚无来源**:活动还不发布 deliverable,所以成果文件目前
|
||||
只有活动推的那一屏。装配已接好(`desk.ts` 的 `results`),活动一产出就自动进列表。
|
||||
|
||||
## 沟通记录(占位)
|
||||
|
||||
工作区第二个页签「沟通记录」目前是空占位。它要汇集这一单**与外界的往来**:
|
||||
邮件线程、通道消息、原件存档 —— 数据在 `channel` 与 `event` 里已有,尚未做投影。
|
||||
界面上明说「还没接」,不伪装成「暂无数据」。
|
||||
|
||||
## 工作区页签条装不下五个页签
|
||||
|
||||
五个四字页签 380px + 翻页囊 112px + 间距 30px = 522px,工作区仅 429px,差 94px。
|
||||
当前先做成**整条可横滚**(不裁、不溢出卡片),最终形态待定,备选:
|
||||
- 缩短页签文字(沟通 / 成果 / 详情 / 日志)—— 省 ~100px,但文案变短
|
||||
- 翻页囊只留箭头、页码收进 title —— 省 ~50px,但看不见「第几件/共几件」
|
||||
- 页签条换行两排 —— 全都放得下,但工作区正文少一行高
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
# 三面一体(设计中心 / 运营中心 / 工作台)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 按 `docs/产品设计.md` 把治理中心拆成设计中心(`/design`)与运营中心(`/ops`)两个台面,实现到**编排层**为止 —— 壳、两张首页、触发器归位、台账动态位、工作组管理;活动内部(活动开发、契约深化)不在本计划内。
|
||||
|
||||
**Architecture:** 纯前端信息架构重排 + 三处小后端增量(触发器 upsert 路由、度量路由补齐八指标、工作组读写路由 —— 全部是既有模块用例的 HTTP 面,零模块语义改动)。壳层由一份 surface 配置驱动:`surfaces.ts` 定静态区表与权限亮灯,`gov-shell` 参数化成两壳,台账导航项按已发布数据域运行时生成。
|
||||
|
||||
**Tech Stack:** React 19 + Vite + TanStack Query(前端零过程状态,服务端投影);Hono + zod 契约(`packages/contracts/src/api/governance.ts`);vitest(纯逻辑单测);皮肤复用 `console.css` 的 `g-*` 类,**不新造类名**。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 五门必须全绿才算一个任务完成:`pnpm typecheck && pnpm lint && pnpm depcruise && pnpm api:check && pnpm test`(api:check 报契约变更时:同步 CONTRACT.md → `pnpm api:check --update` → `git add api-report`)。
|
||||
- 编码规范 §12.5 禁用:`any`、`as unknown as`、`@ts-ignore`、domain 层 IO、全局可变状态、删测试、改门禁配置。
|
||||
- `exactOptionalPropertyTypes: true`:显式赋 `undefined` 的可选属性要写成 `field?: T | undefined`。
|
||||
- ESM 相对导入必须带扩展名(`./x.ts` / `./x.tsx`);web 别名:`@contracts` `@web-shared` `@features` `@ui`。
|
||||
- 不新增任何 npm 依赖。
|
||||
- 治理台界面只用 `console.css` 已有的 `g-*` 类(自造类名 = 没样式);共享件从 `@web-shared/gov/primitives.tsx` 取(Chip/St/Note/Sec/Kpi/Card/Rows/NList/NRow/Entry/PageHead/Todo/Clear/QueryGate/Prop/Field/Seg/ListEdit/Acts)。
|
||||
- 空态文案用**用户视角**(「还没有…,会有什么」),不写「还没接」。
|
||||
- 授权模型不动:九治理功能 × 读/写/治;台面入口按「持有该面任一功能的任意授权」亮灯(动作蕴含 govern ⊃ write ⊃ read,有任意 grant 即至少可读)。
|
||||
- 提交信息:中文一句话,风格同 `git log`(如「三面拆分:…」),结尾加
|
||||
`Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` 与 `Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m`。
|
||||
- 服务地址:API `:8787`(`pnpm --filter @app/api dev`),Web `:5273`(`cd apps/web && npx vite --port 5273 --strictPort`;5173 被别的项目占用)。测试账号 `Chaihao` / `ch19900614`。
|
||||
- 浏览器验证一律先 `POST /api/login` 拿 cookie(HttpOnly 会话;curl 用 `-c/-b /tmp/ch.cookie`)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 基线入库
|
||||
|
||||
工作区里有约 90 个未提交文件(鉴权、工作组、工作台修复、编排工作区、产品设计文档等)。**先整体入库一次**,否则后续每个任务的提交都会混入无关变更,评审无从做起。
|
||||
|
||||
**Files:**
|
||||
- Modify: 无(纯 git 操作)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 干净的工作区 —— 后续任务的 `git add` 只含本任务文件。
|
||||
|
||||
- [ ] **Step 1: 确认五门全绿(基线必须是好的)**
|
||||
|
||||
Run: `pnpm typecheck && pnpm lint && pnpm depcruise && pnpm test`
|
||||
Expected: 全部通过(api:check 已在上一轮 `--update` 并 `git add`,若再报差异则先 `pnpm api:check --update && git add api-report`)。
|
||||
|
||||
- [ ] **Step 2: 整体提交**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||||
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 确认干净**
|
||||
|
||||
Run: `git status --porcelain | wc -l`
|
||||
Expected: `0`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: surface 配置与纯逻辑(surfaces.ts)
|
||||
|
||||
三面骨架的唯一事实源:区归属、权限亮灯、旧链接改写 —— 全部纯函数,先测后写。
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/shared/gov/surfaces.ts`
|
||||
- Create: `apps/web/src/shared/gov/__tests__/surfaces.test.ts`
|
||||
- Modify: `apps/web/src/shared/gov/gov-route.ts`(GovZone 增减 + TABS/ZM 相应条目)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `gov-route.ts` 的 `GovZone`、`ZONES`、`TABS`、`ZM`;`@contracts` 的 `Session`、`GovernanceFunction`。
|
||||
- Produces(后续任务按名引用):
|
||||
```ts
|
||||
export type Surface = 'design' | 'ops';
|
||||
export const SURFACE_META: Readonly<Record<Surface, { path: string; brand: string; searchHint: string }>>;
|
||||
export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>>;
|
||||
export const SURFACE_FNS: Readonly<Record<Surface, readonly GovernanceFunction[]>>;
|
||||
export function canSeeSurface(session: Session | undefined, surface: Surface): boolean;
|
||||
export function surfaceOfZone(zone: GovZone): Surface;
|
||||
/** 旧 #/gov/<zone>/… 深链 → 新台面完整 URL(pathname+hash);非 gov 哈希返回 undefined */
|
||||
export function legacyGovTarget(hash: string): string | undefined;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 改 `gov-route.ts` 的区表(surfaces 的类型依赖它,先动它)**
|
||||
|
||||
在 `gov-route.ts`:
|
||||
1. `GOV_ZONES` 改为:
|
||||
```ts
|
||||
export const GOV_ZONES = [
|
||||
'blueprint', 'workflows', 'library', 'tools', 'data', // 设计中心
|
||||
'cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system', // 运营中心
|
||||
] as const;
|
||||
```
|
||||
(删 `'triggers'` 与 `'memory'` —— 触发器定义并入 workflows,处置并入 tasks;度量升驾驶舱,活动记忆归知识库。)
|
||||
2. `ZONES` 数组同步(label/icon):`blueprint 蓝图/route`、`cockpit 驾驶舱/gauge`、`ledgers 台账/database`、`knowledge 知识库/layers`;其余沿用;删 triggers/memory 两行。
|
||||
3. `ZM` 增四条、删两条(desc 从 `docs/产品设计.md` 二、三节的定位文字取;`ledgers` 的 desc:「行数据 · 版本 · 引用 —— 域的 schema 在设计中心改,这里做运营动作」)。
|
||||
4. `TABS` 增删:
|
||||
```ts
|
||||
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
|
||||
cockpit: [{ key: 'dash', label: '驾驶舱', ico: 'gauge' }],
|
||||
knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }],
|
||||
ledgers: [], // 动态:页签即数据域,由壳层运行时生成
|
||||
workflows: [ …原三条…, { key: 'rules', label: '调起规则', ico: 'zap' } ],
|
||||
tasks: [ …原四条…, { key: 'trg', label: '起单开关', ico: 'zap' } ],
|
||||
```
|
||||
(`TABS` 类型放宽为 `ReadonlyArray<…>` 可空数组即可,`readPlace` 对空 tabs 的兜底在 Task 4 处理。)
|
||||
|
||||
- [ ] **Step 2: 写失败测试**
|
||||
|
||||
`apps/web/src/shared/gov/__tests__/surfaces.test.ts`:
|
||||
```ts
|
||||
/**
|
||||
* 三面骨架的唯一事实源 —— 区归属、亮灯、旧链接改写。
|
||||
* 这里守的是「配置自洽」:每个区恰属一面;亮灯只看该面功能;旧深链一条不死。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Session } from '@contracts';
|
||||
import { sessionSchema } from '@contracts';
|
||||
import { GOV_ZONES } from '../gov-route.ts';
|
||||
import { SURFACE_ZONES, canSeeSurface, legacyGovTarget } from '../surfaces.ts';
|
||||
|
||||
/** 经 schema 构造合法会话 —— §12.5 禁 `as unknown as`,测试同样遵守 */
|
||||
const sessionWith = (fns: string[]): Session =>
|
||||
sessionSchema.parse({
|
||||
staff: { id: 's-1', account: 'a', name: 'n', positionId: 'p-1', state: 'active' },
|
||||
position: { id: 'p-1', code: 'PM', name: '项目经理' },
|
||||
grants: fns.map((fn) => ({
|
||||
positionId: 'p-1',
|
||||
object: { kind: 'governance', function: fn },
|
||||
action: 'read',
|
||||
})),
|
||||
});
|
||||
|
||||
describe('SURFACE_ZONES', () => {
|
||||
it('每个区恰属一面,不重不漏', () => {
|
||||
const all = [...SURFACE_ZONES.design, ...SURFACE_ZONES.ops];
|
||||
expect([...all].sort()).toEqual([...GOV_ZONES].sort());
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
});
|
||||
it('设计面没有任何运行态区', () => {
|
||||
for (const z of ['tasks', 'cockpit', 'ledgers'] as const) {
|
||||
expect(SURFACE_ZONES.design).not.toContain(z);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSeeSurface —— 入口按读权亮灯', () => {
|
||||
it('持有该面任一功能的授权即亮', () => {
|
||||
expect(canSeeSurface(sessionWith(['process']), 'design')).toBe(true);
|
||||
expect(canSeeSurface(sessionWith(['task']), 'ops')).toBe(true);
|
||||
});
|
||||
it('只有另一面的授权不亮', () => {
|
||||
expect(canSeeSurface(sessionWith(['task']), 'design')).toBe(false);
|
||||
});
|
||||
it('trigger 与 data 两面都算(定义归设计、处置与行数据归运营)', () => {
|
||||
expect(canSeeSurface(sessionWith(['trigger']), 'design')).toBe(true);
|
||||
expect(canSeeSurface(sessionWith(['trigger']), 'ops')).toBe(true);
|
||||
expect(canSeeSurface(sessionWith(['data']), 'ops')).toBe(true);
|
||||
});
|
||||
it('无会话不亮', () => {
|
||||
expect(canSeeSurface(undefined, 'design')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacyGovTarget —— 旧 #/gov 深链一条不死', () => {
|
||||
it('设计态区改写到 /design', () => {
|
||||
expect(legacyGovTarget('#/gov/workflows/pkgs/pkg:unified-quote%404')).toBe(
|
||||
'/design#/design/workflows/pkgs/pkg:unified-quote%404',
|
||||
);
|
||||
});
|
||||
it('运行态区改写到 /ops', () => {
|
||||
expect(legacyGovTarget('#/gov/tasks/board')).toBe('/ops#/ops/tasks/board');
|
||||
});
|
||||
it('已删的区落到对应新家:triggers→设计·workflows/rules,memory→运营·cockpit', () => {
|
||||
expect(legacyGovTarget('#/gov/triggers/rules')).toBe('/design#/design/workflows/rules');
|
||||
expect(legacyGovTarget('#/gov/memory/metrics')).toBe('/ops#/ops/cockpit/dash');
|
||||
expect(legacyGovTarget('#/gov/memory/layers')).toBe('/ops#/ops/knowledge/layers');
|
||||
});
|
||||
it('裸 #/gov 落运营首页;非 gov 哈希不管', () => {
|
||||
expect(legacyGovTarget('#/gov')).toBe('/ops#/ops/cockpit/dash');
|
||||
expect(legacyGovTarget('#/whatever')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
- [ ] **Step 3: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run apps/web/src/shared/gov 2>&1 | tail -5`
|
||||
Expected: FAIL(`surfaces.ts` 不存在 / gov-route 缺新区)。
|
||||
|
||||
- [ ] **Step 4: 写实现 `surfaces.ts`**
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 三面骨架的唯一事实源(docs/产品设计.md §1/§6)。
|
||||
* 区归属、权限亮灯、旧链接改写都从这里出 —— 壳层只消费,不各写各的。
|
||||
*/
|
||||
import type { Session } from '@contracts';
|
||||
import type { GovZone } from './gov-route.ts';
|
||||
|
||||
export type Surface = 'design' | 'ops';
|
||||
|
||||
export const SURFACE_META: Readonly<Record<Surface, { path: string; brand: string; searchHint: string }>> = {
|
||||
design: { path: '/design', brand: '设计中心', searchHint: '检索定义:流程包 / 活动 / 工具 / 数据域…' },
|
||||
ops: { path: '/ops', brand: '运营中心', searchHint: '检索运行:任务 / 工作流 / 员工 / 台账…' },
|
||||
};
|
||||
|
||||
export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>> = {
|
||||
design: ['blueprint', 'workflows', 'library', 'tools', 'data'],
|
||||
ops: ['cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 亮灯功能集:trigger 与 data 两面都算 ——
|
||||
* 触发器「定义归设计、处置归运营」;数据「schema 归设计、行数据归运营」。
|
||||
*/
|
||||
export const SURFACE_FNS: Readonly<Record<Surface, readonly string[]>> = {
|
||||
design: ['process', 'activity', 'tool', 'data', 'trigger'],
|
||||
ops: ['task', 'staff', 'memory', 'system', 'trigger', 'data'],
|
||||
};
|
||||
|
||||
export function canSeeSurface(session: Session | undefined, surface: Surface): boolean {
|
||||
if (session === undefined) return false;
|
||||
// 动作蕴含 govern ⊃ write ⊃ read:持有任意动作的 grant 即至少可读,入口即亮
|
||||
return session.grants.some(
|
||||
(g) => g.object.kind === 'governance' && (SURFACE_FNS[surface] as readonly string[]).includes(g.object.function),
|
||||
);
|
||||
}
|
||||
|
||||
export function surfaceOfZone(zone: GovZone): Surface {
|
||||
return (SURFACE_ZONES.design as readonly string[]).includes(zone) ? 'design' : 'ops';
|
||||
}
|
||||
|
||||
/** 已删区的搬迁表:旧深链按内容归新家 */
|
||||
const LEGACY_ZONE: Readonly<Record<string, { surface: Surface; zone: string; tab?: string }>> = {
|
||||
triggers: { surface: 'design', zone: 'workflows', tab: 'rules' },
|
||||
memory: { surface: 'ops', zone: 'cockpit', tab: 'dash' },
|
||||
};
|
||||
|
||||
export function legacyGovTarget(hash: string): string | undefined {
|
||||
const parts = hash.replace(/^#\/?/, '').split('/').filter((s) => s !== '');
|
||||
if (parts[0] !== 'gov') return undefined;
|
||||
const zone = parts[1];
|
||||
if (zone === undefined) return '/ops#/ops/cockpit/dash';
|
||||
|
||||
const moved = LEGACY_ZONE[zone];
|
||||
if (moved !== undefined) {
|
||||
// memory 的两个旧页签各归其位
|
||||
const tab = zone === 'memory' && parts[2] === 'layers' ? undefined : moved.tab;
|
||||
const target = zone === 'memory' && parts[2] === 'layers' ? { surface: 'ops' as const, zone: 'knowledge', tab: 'layers' } : { ...moved, tab };
|
||||
return `${SURFACE_META[target.surface].path}#/${target.surface}/${target.zone}/${target.tab ?? ''}`.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
const surface = surfaceOfZone(zone as GovZone);
|
||||
const rest = parts.slice(1).join('/');
|
||||
return `${SURFACE_META[surface].path}#/${surface}/${rest}`;
|
||||
}
|
||||
```
|
||||
(实现时按测试逐条对齐 —— 特别是 `#/gov/memory/layers` 与 `#/gov/triggers/rules` 两条的期望输出。)
|
||||
|
||||
- [ ] **Step 5: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run apps/web/src/shared/gov 2>&1 | tail -5`
|
||||
Expected: PASS(全部用例)。
|
||||
随后 `pnpm typecheck` —— gov-route 改动会把还引用 `triggers`/`memory` 区的旧代码打红(gov-shell / PkgPage 的 `nav.jump('triggers', …)` 等)。**本任务只修类型层面**:PkgPage 里 `nav.jump('triggers', 'rules', …)` 改为 `nav.jump('workflows', 'rules', …)`;gov-shell 的 TriggersHome/MemoryHome 引用暂时改挂到新区键(`knowledge`→MemoryHome、`cockpit`→MemoryHome,Task 5/8 再真正拆)。tsc 必须归零。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/shared/gov/surfaces.ts apps/web/src/shared/gov/__tests__/surfaces.test.ts \
|
||||
apps/web/src/shared/gov/gov-route.ts apps/web/src/features/gov-process/internal/panel.tsx \
|
||||
apps/web/src/app/gov-shell.tsx
|
||||
git commit -m "三面拆分①:surface 配置与区表 —— 归属/亮灯/旧链改写皆纯函数先测后写"
|
||||
```
|
||||
(此后每个提交都带 Global Constraints 里的两行 footer,下略。)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 网页标签三章 + 入口路由(/design、/ops、/gov 跳转)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/app/tab-identity.ts`
|
||||
- Modify: `apps/web/src/app/main.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `legacyGovTarget`、`SURFACE_META`(Task 2)。
|
||||
- Produces: `applyTabIdentity(surface: 'desk' | Surface): void`;`main.tsx` 导出的常量 `surface: 'desk' | Surface`(Gate 组件与 Task 4 的壳选择用)。
|
||||
|
||||
- [ ] **Step 1: `tab-identity.ts` 扩为三章**
|
||||
|
||||
签名 `applyTabIdentity(surface: 'desk' | 'design' | 'ops')`。新增两枚 SVG(与现有 `DESK_ICON`/`GOV_ICON` 同构;`GOV_ICON` 弃用删除):
|
||||
```ts
|
||||
/** 设计中心:深靛底 + 路网(定义企业的流转) */
|
||||
const DESIGN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#232b6e"/>
|
||||
<g transform="translate(4,4)" fill="none" stroke="#9db1ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="19" r="3"/><circle cx="18" cy="5" r="3"/>
|
||||
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" stroke="#fff"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
/** 运营中心:深色底 + 仪表(看企业今天跑得怎样) */
|
||||
const OPS_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="#7fe0c3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 14 4-4" stroke="#fff"/>
|
||||
<path d="M3.34 19a10 10 0 1 1 17.32 0"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
```
|
||||
标题:`JCAI 工作台` / `JCAI 设计中心` / `JCAI 运营中心`。
|
||||
|
||||
- [ ] **Step 2: `main.tsx` 入口判台**
|
||||
|
||||
替换现有 `isGovernance` 段:
|
||||
```ts
|
||||
import { legacyGovTarget, SURFACE_META } from '@web-shared/gov/surfaces.ts';
|
||||
import type { Surface } from '@web-shared/gov/surfaces.ts';
|
||||
|
||||
/** 旧 /gov 链接一条不死:带着深链改写到新台面(replace,不留历史) */
|
||||
const legacy = window.location.pathname.startsWith('/gov')
|
||||
? (legacyGovTarget(window.location.hash) ?? '/ops#/ops/cockpit/dash')
|
||||
: undefined;
|
||||
if (legacy !== undefined) window.location.replace(legacy);
|
||||
|
||||
const surface: 'desk' | Surface = window.location.pathname.startsWith(SURFACE_META.design.path)
|
||||
? 'design'
|
||||
: window.location.pathname.startsWith(SURFACE_META.ops.path)
|
||||
? 'ops'
|
||||
: 'desk';
|
||||
applyTabIdentity(surface);
|
||||
```
|
||||
样式装载:`surface === 'desk'` 装 `proto.vendor.css + app.css`,否则装 `theme.css + console.css`(两台治理面同一套皮肤)。`Gate` 里 `isGovernance` 相应改为 `surface !== 'desk'`,`GovShell` 暂以 `<GovShell />` 原样渲染(Task 4 传 surface)。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
```bash
|
||||
lsof -ti:8787 >/dev/null || (nohup pnpm --filter @app/api dev >/tmp/api.log 2>&1 &)
|
||||
lsof -ti:5273 >/dev/null || (cd apps/web && nohup npx vite --port 5273 --strictPort >/tmp/web.log 2>&1 &)
|
||||
sleep 8
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5273/design # 200
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5273/ops # 200
|
||||
```
|
||||
浏览器(agent-browser)验证:开 `/design` 标签题为「JCAI 设计中心」、`/ops` 为「JCAI 运营中心」;开 `http://localhost:5273/gov#/gov/workflows/pkgs` 应落到 `/design#/design/workflows/pkgs`。
|
||||
Run: `pnpm typecheck && pnpm lint`,Expected: 通过。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/app/tab-identity.ts apps/web/src/app/main.tsx
|
||||
git commit -m "三面拆分②:三章标签与入口路由,/gov 深链整链改写"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 壳参数化 —— 两套左轨、动态台账项、顶栏互跳亮灯
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/app/gov-shell.tsx`
|
||||
- Modify: `apps/web/src/app/main.tsx`(把 `surface` 传给壳)
|
||||
- Modify: `apps/web/src/app/app-shell.tsx`(工作台轨底加两面入口,按亮灯)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SURFACE_META / SURFACE_ZONES / canSeeSurface`(Task 2);`useGovObjects`(域清单);`sessionRoute` + `queryKeys.session`(亮灯与轨底身份)。
|
||||
- Produces: `GovShell({ surface }: { surface: Surface })`;hash 格式 `#/<surface>/<zone>/<tab>/<k>:<id>…`;`Place.tab` 对 `ledgers` 区 = 域名。
|
||||
|
||||
- [ ] **Step 1: `readPlace`/`writePlace` 带 surface**
|
||||
|
||||
```ts
|
||||
function readPlace(surface: Surface): Place {
|
||||
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||
const parts = raw.split('/').filter((s) => s !== '');
|
||||
// parts[0] 是台面名;区限定在本台面的区表内,越界落回本台首区
|
||||
const zones = SURFACE_ZONES[surface];
|
||||
const zone = (zones.includes(parts[1] as GovZone) ? parts[1] : zones[0]) as GovZone;
|
||||
const tabs = TABS[zone];
|
||||
// ledgers 的页签是数据域(动态):不校验,原样收;静态区仍校验并兜底到首签
|
||||
const tab = zone === 'ledgers' ? (parts[2] ?? '') : (tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]?.key ?? '');
|
||||
…stack 解析不变…
|
||||
}
|
||||
const writePlace = (surface: Surface, p: Place): string =>
|
||||
['#', surface, p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 壳按 surface 渲染**
|
||||
|
||||
`GovShell({ surface })`:
|
||||
- 左轨:`SURFACE_ZONES[surface]` 过滤 `ZONES`;品牌字 `SURFACE_META[surface].brand`;轨章图标 design=`route`、ops=`gauge`。
|
||||
- **ledgers 动态项**(仅 ops):`useGovObjects()` 取 `objects`,`domains = [...new Set(objects.map(o => o.domain))]`;左轨在 `ledgers` 位置渲染一枚分组小标题「台账」+ 每域一个按钮(`goZone` 改为 `go({zone:'ledgers', tab: domain, stack: []})`,高亮条件 `zone==='ledgers' && tab===domain`)。域为空时渲染一条灰字「随数据域发布自动生成」。
|
||||
- 顶栏:`工作台` 链接保留;新增另一面按钮(design 壳给「运营中心」→ `/ops`,ops 壳给「设计中心」→ `/design`),以 `canSeeSurface(session, …)` 决定渲染;`告警` 按钮仅 ops 壳渲染。session 取法:`useQuery({ queryKey: queryKeys.session, queryFn: () => call<Session>(sessionRoute) })`。
|
||||
- 轨底身份:删除 `account === 'fengbing'` 硬编码(现存 bug),改用上面的 session(`session.data?.staff.name`)。
|
||||
- 面包屑首项:`治理台` 改为 `SURFACE_META[surface].brand`。
|
||||
- `ZoneHome` 映射:`blueprint: BlueprintHome`(Task 6 前先渲染 `<Note>企业蓝图建设中</Note>` 的内联占位组件)、`cockpit/knowledge: MemoryHome`(Task 5 拆)、`ledgers: LedgersHome`(Task 10 前内联占位)、其余不变;`triggers` 分支删除。
|
||||
- `Detail`:不动(所有 `Route.k` 仍有效;`trigger` 详情页仍可从两面进)。
|
||||
|
||||
- [ ] **Step 3: 工作台入口亮灯**
|
||||
|
||||
`app-shell.tsx` 轨底(`jc-rail-foot` 内、身份钮上方)加:
|
||||
```tsx
|
||||
{canSeeSurface(session.data, 'design') && (
|
||||
<a className="jc-nav-user" href="/design" title="设计中心"><span className="jc-nav-ava">设</span><span className="jc-nav-name">设计</span></a>
|
||||
)}
|
||||
{canSeeSurface(session.data, 'ops') && (
|
||||
<a className="jc-nav-user" href="/ops" title="运营中心"><span className="jc-nav-ava">运</span><span className="jc-nav-name">运营</span></a>
|
||||
)}
|
||||
```
|
||||
(复用 `jc-nav-user` 皮肤,不造新类。)
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
浏览器:
|
||||
1. `/ops` 登录柴浩(TEST 岗九功能全 govern)→ 左轨 = 驾驶舱/任务/员工/台账(报价域、共享域)/知识库/系统;顶栏见「设计中心」「工作台」。
|
||||
2. `/design` → 左轨 = 蓝图/流程包/活动库/工具箱/数据域;无告警钮。
|
||||
3. 点台账「报价域」→ hash 变 `#/ops/ledgers/quote_intake…`(域名),再点「共享域」切换。
|
||||
4. 工作台 `/` 轨底出现 设计/运营 两枚入口。
|
||||
Run: `pnpm typecheck && pnpm lint && pnpm exec vitest run apps/web`,Expected: 通过。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/app/gov-shell.tsx apps/web/src/app/main.tsx apps/web/src/app/app-shell.tsx
|
||||
git commit -m "三面拆分③:壳参数化两台,台账随域动态生成,顶栏互跳按权亮灯"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 驾驶舱(运营首页)+ 度量路由补齐八指标
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/contracts/src/api/governance.ts`(`govMetricsRoute` 输出补三字段)
|
||||
- Modify: `apps/api/src/app/routes/governance-ops.ts`(metrics handler 补映射)
|
||||
- Modify: `apps/web/src/shared/gov/gov-api.ts`(`usePkgReports` 自 gov-process 面板上移并更名 `useGovPkgReports` —— 驾驶舱的未闭合草稿数用它;实现体见 Task 6 Step 1 的代码块,**在本任务完成上移**,Task 6 只消费)
|
||||
- Modify: `apps/web/src/features/gov-process/internal/panel.tsx`(改引上移后的 hook,删本地副本)
|
||||
- Modify: `apps/web/src/features/gov-memory/internal/panel.tsx`(拆出 CockpitHome / KnowledgeHome)
|
||||
- Modify: `apps/web/src/features/gov-memory/index.ts`(出口)
|
||||
- Modify: `apps/web/src/app/gov-shell.tsx`(cockpit/knowledge 挂新组件)
|
||||
- Modify: `api-report/`(api:check --update)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `container.memory.metrics`(已计算八指标,现路由只暴露六)、`useGovTasks`(异常工作流数)、`useGovPackages` + `validateProcessRoute`(未闭合草稿数)。
|
||||
- Produces: `govMetricsRoute.output` 增 `breakpoint: z.number()`, `unitCost: z.object({ tokens: z.number(), minutes: z.number(), humanCalls: z.number() })`, `terminalMix: z.record(z.string(), z.number())`;组件 `CockpitHome({ tab, nav }: HomeProps)`、`KnowledgeHome({ tab, nav }: HomeProps)`。
|
||||
|
||||
- [ ] **Step 1: 契约与 handler**
|
||||
|
||||
`govMetricsRoute.output` 补三字段(全部 memory 模块已算,handler 里从 `metrics` 结果透传;对照 `packages/contracts/src/api/workbench.ts` 的 `reportMetricsSchema` 字段名保持一致)。跑 `pnpm api:check` 确认只报此处差异 → 同步 `apps/api/src/modules/memory/CONTRACT.md` 影响半径一句 → `pnpm api:check --update && git add api-report`。
|
||||
|
||||
- [ ] **Step 2: 拆 gov-memory 面板**
|
||||
|
||||
`CockpitHome` = 原 metrics 页签内容 + 顶部加一排 KPI:
|
||||
```tsx
|
||||
<div className="g-kpis">
|
||||
<Kpi ico="alert-triangle" tone="danger" label="异常工作流" value={abnormal} sub="失败走向未消化,待人工处置" />
|
||||
<Kpi ico="git-merge" tone="warn" label="未闭合草稿" value={draftBad} sub="边不闭合不得发布" />
|
||||
<Kpi ico="activity" tone="brand" label="在途任务" value={running} sub="运行中 + 等待中" />
|
||||
</div>
|
||||
```
|
||||
`abnormal` 自 `useGovTasks().data.workflows.filter(w=>w.state==='abnormal').length`;`draftBad` 自 `useGovPackages` + `useGovPkgReports`(本任务上移到 gov-api.ts);`running` 自 tasks。八指标卡沿用原 metrics 渲染,补 `断点率/单位成本/终态分布` 三卡(数据来自 Step 1 扩的路由)。点「异常工作流」卡 → `nav.jump('tasks','todo')`。
|
||||
`KnowledgeHome` = 原 layers 页签内容原样搬入。原 `MemoryHome` 删除。
|
||||
**死信卡不做**(无查询数据源),在 Step 4 记入 debt。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
curl:`curl -s -b /tmp/ch.cookie http://localhost:8787/api/gov/metrics | python3 -m json.tool` → 九个键齐(六旧 + 三新)。
|
||||
浏览器:`/ops` 开屏即驾驶舱(KPI 排 + 八指标);知识库页签只剩活动记忆。
|
||||
Run: 五门,Expected: 全绿。
|
||||
|
||||
- [ ] **Step 4: 记欠账 + 提交**
|
||||
|
||||
`docs/debt.md` 追加:「驾驶舱·死信卡:死信目前只送治理者消息要素,无计数查询面 —— 驾驶舱首版不含死信卡,补上需给 system_message 一个按 kind 计数的治理路由」。
|
||||
```bash
|
||||
git add packages/contracts apps/api/src/app/routes/governance-ops.ts apps/api/src/modules/memory/CONTRACT.md \
|
||||
api-report apps/web/src/features/gov-memory apps/web/src/app/gov-shell.tsx docs/debt.md
|
||||
git commit -m "三面拆分④:驾驶舱首页 —— 度量路由补齐八指标,异常/草稿/在途三卡"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 企业蓝图(设计首页)
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/features/gov-blueprint/index.ts`
|
||||
- Create: `apps/web/src/features/gov-blueprint/internal/panel.tsx`
|
||||
- Modify: `apps/web/src/app/gov-shell.tsx`(blueprint 挂 BlueprintHome)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useGovPackages / useGovActivities / useGovTools / useGovObjects / useGovTriggers` + `useGovPkgReports(packages)`。
|
||||
- Produces: `useGovPkgReports(packages: readonly ProcessPackage[]): UseQueryResult<Map<string, ClosureReport>>`;`BlueprintHome({ tab, nav }: HomeProps)`。
|
||||
|
||||
- [ ] **Step 1: 确认 hook 已上移(Task 5 完成);若按乱序执行先补上移**
|
||||
|
||||
`gov-api.ts` 的 `useGovPkgReports`(实现体即原 gov-process 面板里的 `usePkgReports`,原地删除并改引用):
|
||||
```ts
|
||||
export const useGovPkgReports = (packages: readonly ProcessPackage[]): UseQueryResult<Map<string, ClosureReport>> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'process-reports', packages.map((p) => `${p.id}@${p.version}`).join(',')],
|
||||
enabled: packages.length > 0,
|
||||
queryFn: async () =>
|
||||
new Map(
|
||||
await Promise.all(
|
||||
packages.map(
|
||||
async (p) => [`${p.id}@${p.version}`, await call<ClosureReport>(validateProcessRoute, { body: { pkg: p } })] as const,
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: BlueprintHome**
|
||||
|
||||
因果链一屏(全部用既有 `g-*` 件):
|
||||
```tsx
|
||||
export function BlueprintHome({ nav }: HomeProps) {
|
||||
const pkgs = useGovPackages(); const acts = useGovActivities();
|
||||
const tools = useGovTools(); const objs = useGovObjects(); const trgs = useGovTriggers();
|
||||
const reports = useGovPkgReports(pkgs.data?.packages ?? []);
|
||||
const drafts = (pkgs.data?.packages ?? []).filter((p) => p.state !== 'published');
|
||||
const draftBad = drafts.filter((p) => reports.data?.get(`${p.id}@${p.version}`)?.publishable === false);
|
||||
const violations = [...(reports.data?.values() ?? [])].flatMap((r) => r.violations);
|
||||
const domains = [...new Set((objs.data?.objects ?? []).map((o) => o.domain))];
|
||||
return (
|
||||
<>
|
||||
<Note>战略 → 方法 → 能力 → 对象:流程包决定企业以哪些流转运转,活动库决定每件事怎么干,工具箱决定系统能对世界做什么,数据域决定企业运营什么。设计中心没有任何运行态数字。</Note>
|
||||
<div className="g-kpis">
|
||||
<Kpi ico="workflow" tone="brand" label="流程包" value={pkgs.data?.packages.length ?? 0} sub={`草稿 ${drafts.length} · 未闭合 ${draftBad.length}`} />
|
||||
<Kpi ico="boxes" tone="collab" label="活动" value={acts.data?.activities.length ?? 0} sub="一个活动 = 一个任务的定义" />
|
||||
<Kpi ico="wrench" tone="auto" label="工具" value={tools.data?.tools.length ?? 0} sub="读类自由调用 · 写类登记意向" />
|
||||
<Kpi ico="database" tone="ok" label="数据域" value={domains.length} sub="发布即在运营中心长出台账位" />
|
||||
</div>
|
||||
<Sec ico="flag" title="待补清单" extra="闭合校验与契约缺口聚在这里,点进即改">
|
||||
{violations.length === 0 ? (
|
||||
<Clear>无待补 —— 全部流程包闭合校验通过。</Clear>
|
||||
) : (
|
||||
<div className="g-todo">
|
||||
{draftBad.map((p) => (
|
||||
<Todo key={`${p.id}@${p.version}`} tone="warn" ico="git-merge"
|
||||
title={`${p.name} v${p.version} · 闭合校验未过`}
|
||||
desc={(reports.data?.get(`${p.id}@${p.version}`)?.violations ?? []).map((v) => v.message).join(';')}
|
||||
acts={[{ label: '去编排', ico: 'route', onClick: () => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` }) }]} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Sec>
|
||||
<Sec ico="zap" title="调起规则" extra={`${(trgs.data?.triggers ?? []).length} 条 —— 定义随流程包,处置在运营中心`}>
|
||||
<div className="g-entries">
|
||||
<Entry ico="zap" title="查看全部调起规则" desc="周期 / 事件 / 登记三类 · 条件用 S-FEEL 子集" onClick={() => nav.jump('workflows', 'rules')} />
|
||||
</div>
|
||||
</Sec>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
(工具/活动的 output 字段名以 `GovToolsOutput`/`GovActivitiesOutput` 实际为准 —— 写前 `grep listToolsRoute -A10` 校对。)
|
||||
|
||||
- [ ] **Step 3: 验证 + 提交**
|
||||
|
||||
浏览器:`/design` 开屏即蓝图(四卡 + 待补清单;v5 草稿若仍未发布应出现在待补或草稿计数里)。五门全绿。
|
||||
```bash
|
||||
git add apps/web/src/shared/gov/gov-api.ts apps/web/src/features/gov-blueprint apps/web/src/features/gov-process/internal/panel.tsx apps/web/src/app/gov-shell.tsx
|
||||
git commit -m "三面拆分⑤:企业蓝图首页 —— 四库因果链与待补清单"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 触发器 upsert 路由(后端)
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/contracts/src/api/governance.ts`
|
||||
- Modify: `apps/api/src/app/routes/governance-ops.ts`
|
||||
- Modify: `api-report/`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `container.trigger.upsert(config: TriggerConfig)`(模块出口已有,播种在用)。
|
||||
- Produces:
|
||||
```ts
|
||||
export const upsertTriggerRoute = {
|
||||
method: 'PUT',
|
||||
path: '/api/gov/triggers',
|
||||
body: z.object({ trigger: triggerConfigSchema }),
|
||||
output: z.object({ trigger: triggerConfigSchema }),
|
||||
} as const satisfies RouteContract;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 契约 + handler**
|
||||
|
||||
governance-ops.ts(紧挨 `toggleTriggerRoute`,复用 `governTrigger` 守卫):
|
||||
```ts
|
||||
/** 调起规则的定义面(设计中心):新建 / 改规则 / 改装配 —— 启停仍走 toggle */
|
||||
app.put(upsertTriggerRoute.path, async (c) => {
|
||||
const denied = await governTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = upsertTriggerRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json({ trigger: await container.trigger.upsert(parsed.data.trigger) });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: curl 验证**
|
||||
|
||||
```bash
|
||||
curl -s -b /tmp/ch.cookie -X PUT http://localhost:8787/api/gov/triggers \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"trigger":{"id":"trg-rfq-manual","name":"人工登记起单(统一报价)","enabled":true,"processPackageId":"unified-quote","mapping":[],"spec":{"kind":"registration"}}}'
|
||||
```
|
||||
Expected: 200 + trigger 回显;错 schema 给 400;无权给 403。
|
||||
|
||||
- [ ] **Step 3: api:check + 提交**
|
||||
|
||||
CONTRACT.md:trigger 模块面未变(路由在 app 层),无需动;`pnpm api:check --update && git add api-report`。五门全绿。
|
||||
```bash
|
||||
git add packages/contracts apps/api/src/app/routes/governance-ops.ts api-report
|
||||
git commit -m "三面拆分⑥:触发器 upsert 路由 —— 定义面的 HTTP 口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 调起规则(设计侧)—— workflows/rules 页签 + 包详情可编辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/features/gov-trigger/internal/panel.tsx`(TriggersHome 改造为 `TriggerRulesHome` 定义面:列表 + 编辑表单;`TriggerPage` 保留)
|
||||
- Modify: `apps/web/src/features/gov-trigger/index.ts`
|
||||
- Modify: `apps/web/src/features/gov-process/internal/panel.tsx`(WorkflowsHome 增 `tab === 'rules'` 分支渲染 `TriggerRulesHome`;PkgPage「调起它的触发器」段加「编辑」进入行内表单)
|
||||
- Modify: `apps/web/src/app/gov-shell.tsx`(TriggersHome 引用清理)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `upsertTriggerRoute`(Task 7)、`useGovTriggers`、`toggleTriggerRoute`(启停不在此面,只读展示状态)。
|
||||
- Produces: `TriggerRulesHome({ nav }: { nav: Nav })` —— 供 workflows 区 rules 页签与蓝图入口复用。
|
||||
|
||||
- [ ] **Step 1: TriggerRulesHome(定义面)**
|
||||
|
||||
列表(NList/NRow):名称 / 三类章 / 规则原文 / 目标包;行点开 `nav.push({k:'trigger', id})`。行尾「编辑」展开行内表单(Prop/Field 版式):
|
||||
- `name` 文本;`processPackageId` 下拉(useGovPackages 的 id 去重);
|
||||
- `spec` 按 kind 三分支:registration 无参;event 给 `eventType`(data/time/human/control 下拉)+ `rule` textarea;periodic-calendar 给 `calendar` 文本;periodic-data 给 domain/object/dateField/offset/filter 五格(domain/object 下拉自 useGovObjects);
|
||||
- `mapping` 用 `ListEdit` 编辑 `target=source.path` 形式的行,提交时解析为 `{target, source:{from:'initiation', path}}`(定义面先支持起单载荷映射这一种 —— 其余 source 类型显示只读,注明「在流程包边映射里维护」);
|
||||
- 保存:`call(upsertTriggerRoute, { body: { trigger } })` → 失效 `['gov','triggers']`;`AskBand` 确认带(影响:此后命中规则即按新定义起单;可再改;即时生效)。
|
||||
**启停开关不出现在此面**(那是运营的事,Task 9)。
|
||||
|
||||
- [ ] **Step 2: 接线**
|
||||
|
||||
WorkflowsHome:`if (tab === 'rules') return <TriggerRulesHome nav={nav} />;`。
|
||||
PkgPage「调起它的触发器」段:每行加「编辑规则」按钮 → `nav.jump('workflows', 'rules')`(编辑集中在 rules 页签,包详情不重复摆表单);无绑定时的 Note 文案追加「在调起规则页签新建」。
|
||||
|
||||
- [ ] **Step 3: 验证 + 提交**
|
||||
|
||||
浏览器:`/design` → 流程包 → 调起规则:改 `trg-rfq-manual` 的 name 存盘,刷新仍在;`curl …/api/gov/triggers` 回显新名。工作台起单验证不受影响(`POST /api/triggers/trg-rfq-manual/register` 仍 200)。五门全绿。
|
||||
```bash
|
||||
git add apps/web/src/features/gov-trigger apps/web/src/features/gov-process/internal/panel.tsx apps/web/src/app/gov-shell.tsx
|
||||
git commit -m "三面拆分⑦:调起规则并入流程包区 —— 触发器定义可编辑"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 起单开关(运营侧)—— tasks/trg 页签
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/features/gov-trigger/internal/panel.tsx`(新增 `TriggerOpsTab`:启停 + 状态)
|
||||
- Modify: `apps/web/src/features/gov-trigger/index.ts`
|
||||
- Modify: `apps/web/src/features/gov-task/internal/panel.tsx`(TasksHome 增 `tab === 'trg'` 分支)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useGovTriggers`、`toggleTriggerRoute`(既有:`POST /api/gov/triggers/:id/toggle`,body `{enabled}` —— 写前 grep 确认参数形状)。
|
||||
- Produces: `TriggerOpsTab({ nav }: { nav: Nav })`。
|
||||
|
||||
- [ ] **Step 1: TriggerOpsTab**
|
||||
|
||||
NList 每触发器一行:三类章 / 规则原文 / 目标包 / `St` 状态章;行尾「启用/停用」按钮走 `AskBand` 确认(影响文案照原型:「触发器不入流程包结构…此后不再起新单;已起的照常跑完」),`useMutation` → toggle → 失效 `['gov','triggers']`。行点开仍 `nav.push({k:'trigger', id})`。「编辑规则」入口按 `canSeeSurface(session,'design')` 亮灯,点了整页跳 `/design#/design/workflows/rules`。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
浏览器:`/ops` → 任务 → 起单开关:停用 `trg-rfq-manual` → 工作台登记起单应被拒(`fireByRegistration` 回 `skipped:'disabled'`);再启用恢复。五门全绿。
|
||||
```bash
|
||||
git add apps/web/src/features/gov-trigger apps/web/src/features/gov-task/internal/panel.tsx
|
||||
git commit -m "三面拆分⑧:起单开关进运营·任务区 —— 定义与处置各归其位"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 台账动态位 + 数据域定义面瘦身
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/features/gov-data/internal/panel.tsx`(拆三件:`DataHome` 定义面、`LedgersHome` 运营面、`ObjPage` 按来源分呈现)
|
||||
- Modify: `apps/web/src/features/gov-data/index.ts`
|
||||
- Modify: `apps/web/src/app/gov-shell.tsx`(ledgers 挂 `LedgersHome`,替换 Task 4 的占位)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useGovObjects`(域/对象清单)、`useGovLedger(domain, object)`(列 / 主键 / 行 / 总数)。
|
||||
- Produces: `LedgersHome({ domain, nav }: { domain: string; nav: Nav })` —— 壳层以 `tab` 为域名传入;`DataHome` 保持 `HomeProps` 签名。
|
||||
|
||||
- [ ] **Step 1: LedgersHome(运营面)**
|
||||
|
||||
按传入 `domain` 过滤对象,每对象一张 `NRow`(名称 / 行数徽 —— 行数取 `useGovLedger` 的 `total`,懒加载:仅当前域的对象查询);点行 `nav.push({k:'obj', id: `${domain}.${object}`})` 进 `ObjPage` 看行数据(现 ObjPage 已是行浏览,复用)。空域文案:「这个域还没有对象 —— 对象随活动安装物化」。
|
||||
|
||||
- [ ] **Step 2: DataHome 瘦身(定义面)**
|
||||
|
||||
设计侧只留定义信息:域分组 → 对象卡(列结构 `columns`、主键、被哪些活动声明 —— 声明反查暂无查询面,此卡先列 `columns/primaryKey`,行数与行浏览入口**移除**);卡底加一行灰字「行数据在运营中心 · 台账」。`ObjPage` 从设计侧不再可达(设计侧对象卡不下钻行数据)。
|
||||
|
||||
- [ ] **Step 3: 验证 + 提交**
|
||||
|
||||
浏览器:`/ops` 台账·报价域 → 对象行数与行浏览正常;`/design` 数据域 → 只见结构,无行数据入口。五门全绿。
|
||||
```bash
|
||||
git add apps/web/src/features/gov-data apps/web/src/app/gov-shell.tsx
|
||||
git commit -m "三面拆分⑨:台账动态位入运营,数据域瘦身为纯定义面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 工作组管理(员工区新页签 + 读写路由)
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/contracts/src/api/governance.ts`(两条路由)
|
||||
- Modify: `apps/api/src/app/routes/governance-ops.ts`(handler,app 层组合 org 出口)
|
||||
- Modify: `apps/web/src/features/gov-staff/internal/panel.tsx`(PeopleHome 增 `tab === 'groups'`)
|
||||
- Modify: `apps/web/src/shared/gov/gov-route.ts`(people TABS 增 `{ key: 'groups', label: '工作组', ico: 'users' }`)
|
||||
- Modify: `api-report/`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `container.org.listStaff() / workGroupOf(staffId) / saveWorkGroup({id?, name, ownerId, members})`(均在 org 出口);`workGroupSchema`(@contracts)。
|
||||
- Produces:
|
||||
```ts
|
||||
export const listWorkGroupsRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/work-groups',
|
||||
output: z.object({ groups: z.array(workGroupSchema.extend({ ownerName: z.string() })) }),
|
||||
} as const satisfies RouteContract;
|
||||
export const saveWorkGroupRoute = {
|
||||
method: 'PUT',
|
||||
path: '/api/gov/work-groups',
|
||||
body: z.object({ ownerId: staffIdSchema, name: z.string().min(1), members: z.record(z.string(), staffIdSchema) }),
|
||||
output: z.object({ group: workGroupSchema }),
|
||||
} as const satisfies RouteContract;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: handler(守卫 `guard('staff', 'read'/'govern')`)**
|
||||
|
||||
```ts
|
||||
app.get(listWorkGroupsRoute.path, async (c) => {
|
||||
const denied = await readStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const { staff } = await container.org.listStaff();
|
||||
const groups = [];
|
||||
for (const s of staff) {
|
||||
const g = await container.org.workGroupOf(s.id);
|
||||
if (g !== undefined) groups.push({ ...g, ownerName: s.name });
|
||||
}
|
||||
return c.json({ groups });
|
||||
});
|
||||
|
||||
app.put(saveWorkGroupRoute.path, async (c) => {
|
||||
const denied = await governStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
// 一人一组:已有组即更新(id 复用),没有即新建 —— 与 org.workGroupOf 的取组口径一致
|
||||
const existing = await container.org.workGroupOf(parsed.data.ownerId);
|
||||
const group = await container.org.saveWorkGroup({
|
||||
...(existing === undefined ? {} : { id: existing.id }),
|
||||
name: parsed.data.name,
|
||||
ownerId: parsed.data.ownerId,
|
||||
members: parsed.data.members,
|
||||
});
|
||||
return c.json({ group });
|
||||
});
|
||||
```
|
||||
(`readStaff`/`governStaff` 守卫若不存在,按文件内既有 `guard('staff', …)` 工厂补两行。)
|
||||
|
||||
- [ ] **Step 2: 界面(people/groups 页签)**
|
||||
|
||||
列表:每组一张 `Card`(组名 · 组长 ownerName · 成员表 `Rows`:岗位 code → 员工名,员工名经 `useGovStaff` 的 id→name 映射)。编辑:选组长(下拉全员)→ 逐岗位下拉选员工(岗位集 = `useGovStaff().positions` 的 code;可留空 = 该岗位不绑定)→ `AskBand` 确认(影响:「仅新起的工作流;在途不受影响 —— 绑定是起单时的快照」)→ PUT → 失效 `['gov','work-groups']` 键(新增 hook `useGovWorkGroups` 于 gov-api.ts)。空态:「还没有工作组 —— 建一个,起单时整条流可以按组绑定到人」。
|
||||
|
||||
- [ ] **Step 3: 验证 + 提交**
|
||||
|
||||
curl:GET 应见播种的 `wg-solo-ai`/`wg-solo-chaihao`;PUT 改柴浩组名后 GET 回显。浏览器:`/ops` 员工 → 工作组页签可看可改;改完用柴浩起一单,`sys.workflow.work_group` 快照应含新成员表。`pnpm api:check --update && git add api-report`(org 模块 CONTRACT.md 影响半径补一句:app 层新增工作组读写路由)。五门全绿。
|
||||
```bash
|
||||
git add packages/contracts apps/api/src/app/routes/governance-ops.ts apps/api/src/modules/org/CONTRACT.md \
|
||||
api-report apps/web/src/features/gov-staff apps/web/src/shared/gov/gov-route.ts apps/web/src/shared/gov/gov-api.ts
|
||||
git commit -m "三面拆分⑩:工作组管理落员工区 —— ADR-011 欠账清账"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 收口 —— 走查、门禁、文档对账
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/debt.md`(核销已清项:工作组界面;新增项见各任务)
|
||||
- Modify: `docs/产品设计.md`(如实现与设计有出入,改设计或记偏差 —— 文档与实现不许各说各话)
|
||||
|
||||
**Interfaces:** 无新接口。
|
||||
|
||||
- [ ] **Step 1: 全链走查(浏览器,柴浩账号)**
|
||||
|
||||
1. `/` 工作台:轨底两枚入口 → 进 `/ops`;
|
||||
2. `/ops` 驾驶舱开屏 → 任务/员工/台账×2/知识库/系统逐区开一遍,无空白页、无 `(无)`;
|
||||
3. 起单开关停/启验证一轮;
|
||||
4. `/design` 蓝图开屏 → 流程包(编排工作区仍可用:v5 草稿点开、属性卡、编辑芯片)→ 调起规则改名 → 活动库/工具箱/数据域逐区开;
|
||||
5. 旧链接 `/gov#/gov/workflows/pkgs`、`/gov#/gov/memory/layers` 各自落对;
|
||||
6. 工作台起一单 → `/ops` 驾驶舱在途 +1。
|
||||
|
||||
- [ ] **Step 2: 五门 + 文档**
|
||||
|
||||
Run: `pnpm gen:tiers && pnpm typecheck && pnpm lint && pnpm depcruise && pnpm api:check && pnpm test`
|
||||
Expected: 全绿(api:check 无未入库差异)。
|
||||
`docs/debt.md`:划掉「工作组无治理台界面」;确认「驾驶舱死信卡」「声明反查无查询面」两条在列。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add docs
|
||||
git commit -m "三面拆分⑪:收口 —— 走查通过,债务台账对齐"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自审记录(写完计划后跑过)
|
||||
|
||||
- **规格覆盖**:产品设计.md §5 前端改动清单五项 → Task 3/4(壳与标签)、5/6(两首页)、7/8/9(触发器归位)、10(台账+数据域)、11(工作组);§6 权限亮灯 → Task 2/4;§1 `/gov` 跳转 → Task 3。驾驶舱「死信卡」为已声明偏差(Task 5 Step 4 记 debt)。
|
||||
- **占位扫描**:无 TBD;两处「写前 grep 校对」是对既有代码形状的核对动作,不是留白。
|
||||
- **类型一致性**:`canSeeSurface(session, surface)` 三处消费(Task 4 壳、Task 4 工作台、Task 9 编辑入口)同签名;`useGovPkgReports` 的**上移动作归 Task 5**,Task 6 只消费 —— 前向依赖已消除。
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
# 产品设计 · 三面一体
|
||||
|
||||
> 2026-07-27 定稿。本文档回答一个问题:**这个产品由哪几张脸组成,每张脸对谁、说什么。**
|
||||
> 背景:治理中心此前是平铺的九个区,设计态与运行态混在一排 —— 定义企业的人和看企业跑的人
|
||||
> 挤在同一套导航里,谁都找不到自己的第一屏。本次拆分把它按心智一分为二,与工作台合成三面。
|
||||
|
||||
---
|
||||
|
||||
## 一、概念模型:三面一体
|
||||
|
||||
产品按一条因果链排成三面:
|
||||
|
||||
```
|
||||
设计中心 ──定义──▶ 运营中心 ──派发──▶ 工作台
|
||||
(企业怎么运转) (今天跑得怎样) (在企业里干活)
|
||||
类 · 规则 实例 · 监控 人 · 劳动
|
||||
```
|
||||
|
||||
- **设计产生运营的形状**:设计侧每发布一个数据域,运营侧自动长出一个台账位;
|
||||
每发布一个流程包,运营侧的驾驶舱就多一类可监控的流。
|
||||
- **运营产生工作台的内容**:流程跑起来,任务、审批、消息才落到人的工位上。
|
||||
- **工作台产生驾驶舱的数字**:人把活干完,八指标才有读数 —— 圈闭合。
|
||||
|
||||
| 面 | 地址 | 网页标签 | 一句话 | 谁用 |
|
||||
|---|---|---|---|---|
|
||||
| 设计中心 | `/design` | 深靛底 + 路网章 | 定义企业:战略 → 方法 → 能力 → 对象 | 业务架构师 / 管理者 |
|
||||
| 运营中心 | `/ops` | 深色底 + 仪表章 | 看企业跑:驾驶舱 + 处置 + 沉淀 | PM / 当班主管 |
|
||||
| 工作台 | `/` | 蓝渐变 + 机器人章 | 干活:任务五要素 | 全员 |
|
||||
|
||||
- `/gov` 保留为跳转(302 → `/ops`),旧链接不断。
|
||||
- 三面共用一套登录会话(ADR-010);顶栏互跳,**入口按权限亮灯**(见第六节)。
|
||||
- 网页标签三章并立:多标签并开时,靠图标即可分辨"我在定义、在监控、还是在干活"。
|
||||
|
||||
## 二、设计中心 `/design`
|
||||
|
||||
**左轨 = 企业蓝图(首页)+ 四库。**
|
||||
|
||||
### 企业蓝图(首页)
|
||||
|
||||
一屏把四库串成因果链,回答"这家企业被定义成什么样、哪里还没定义完":
|
||||
|
||||
```
|
||||
战略 ────── 方法 ────── 能力 ────── 对象
|
||||
流程包 → 活动库 → 工具箱 → 数据域
|
||||
↓健康度 ↓健康度 ↓健康度 ↓健康度
|
||||
未闭合草稿 未安装引用 未授权工具 未发布域
|
||||
```
|
||||
|
||||
- 每库一张卡:总数、健康度、最近变更;点卡即入库。
|
||||
- 下方是**待补清单**:闭合校验未过、契约缺口、名称未匹配的活动引用 —— 全部聚在这里,
|
||||
点进即改。设计者的"需处置"不散落在四库里。
|
||||
|
||||
### 四库
|
||||
|
||||
| 库 | 定位 | 内容 |
|
||||
|---|---|---|
|
||||
| **流程包** | 战略 —— 企业以哪些流转运转 | 现工作流区整体迁入:编排工作区(画布 + 属性卡)、边映射校验、版本谱系、导出依赖文件。**触发器的定义并入本库**:包详情"调起它的触发器"从只读变可编辑(规则 / 输入装配 / 目标版本),另设「调起规则」页签横览全部触发器。触发器不再是独立一区 |
|
||||
| **活动库** | 方法 —— 每件事怎么干 | 现活动库区平移:货架、契约面、范式、资产版本谱系、安装两审 |
|
||||
| **工具箱** | 能力 —— 系统能对世界做什么 | 现工具区平移:读/写目录、白名单授权、幂等口径、引用反查 |
|
||||
| **数据域** | 对象 —— 企业运营什么 | 现数据区的**定义面**:域与对象 schema、字段、放行规则、被谁引用。**行数据不在这里** —— 那是运营的事 |
|
||||
|
||||
### 一条纪律
|
||||
|
||||
设计中心里**没有任何运行态数字**(在跑几单、30 天起单数、死信数都不出现)。
|
||||
设计者看的是定义完不完整,不是今天忙不忙 —— 运行态数字一旦混进来,
|
||||
设计页面就会退化成第二个监控页。
|
||||
|
||||
## 三、运营中心 `/ops`
|
||||
|
||||
**左轨 = 驾驶舱(首页)+ 固定位 + 动态位 + 固定位。**
|
||||
|
||||
```
|
||||
◉ 驾驶舱(首页)
|
||||
── 固定 ──
|
||||
任务
|
||||
员工
|
||||
── 台账(动态,随数据域长) ──
|
||||
报价域
|
||||
共享域
|
||||
…
|
||||
── 固定 ──
|
||||
知识库
|
||||
系统
|
||||
```
|
||||
|
||||
### 驾驶舱(首页)
|
||||
|
||||
经营第一屏,从现"记忆 · 度量仪表"升格而来:
|
||||
|
||||
- **度量八指标**(OS 8.3,零埋点):自主率 / 纠正率 / 有效自主率 / 打扰率 / 断点率 /
|
||||
DMN 覆盖率 / 单位成本 / 终态分布;
|
||||
- **异常面**:异常工作流、死信、需处置件数 —— 点数字直达处置页。
|
||||
|
||||
### 固定位
|
||||
|
||||
| 位 | 内容 |
|
||||
|---|---|
|
||||
| **任务** | 现任务管理区平移:运行看板 / 任务 / 审批单 / 需处置。**触发器的处置并入**:启停开关、近 30 天起单、死信重投 —— 定义在设计中心,开关在这里 |
|
||||
| **员工** | 现员工区平移(员工 / 岗位授权),**补上工作组管理**(ADR-011 欠账:建组 / 改组 —— 起单时的岗位绑定) |
|
||||
| **知识库** | 活动记忆四层 + 技能资产 + 候选增量的回放与放行。"记忆的终点是成为模型"这条进化管线的**人工放行口**在这里 |
|
||||
| **系统** | LLM 实例 / 通道实例,现系统区平移 |
|
||||
|
||||
### 动态位:台账 ×N
|
||||
|
||||
按**数据域**动态生成导航项 —— 设计侧一发布新域,运营侧自动长出一位。
|
||||
点进去是该域全部对象的行数据、版本历史、被哪些任务引用。
|
||||
域的 schema 不在这里改(那是设计的事);这里做的是运营动作:查行、看版本、追引用。
|
||||
|
||||
> 这是"设计产生运营的形状"最直观的一处:导航本身就是被设计出来的。
|
||||
> 数据来源:`ledger.listObjects`(已有),无需新后端能力。
|
||||
|
||||
## 四、与工作台的联动
|
||||
|
||||
- **三面互跳**:每面顶栏(或轨底)有另两面的入口,按权限亮灯。跳转带上下文:
|
||||
运营中心看某任务 → "去工作台处理"直达该单;工作台任务详情 → 有运营权者见"去运营中心看这条流"。
|
||||
- **同一语言**:工作台里人看到的任务、审批、台账字段,与运营中心管理的是**同一份投影、同一套词**。
|
||||
运营中心是工作台的管理者视角,不是另一套系统 —— 字段名、状态名、终态五名逐字一致。
|
||||
- **闭环叙事**:设计中心发布流程包 → 运营中心看它跑、放行知识沉淀 →
|
||||
工作台的人把活干完 → 数字回到驾驶舱。产品讲的就是这一个圈。
|
||||
|
||||
## 五、现状迁移映射
|
||||
|
||||
**后端模块、授权模型、数据一概不动** —— 这是一次纯前端的信息架构重排:
|
||||
|
||||
| 现九区 | 去向 |
|
||||
|---|---|
|
||||
| 工作流 | 设计 · 流程包 |
|
||||
| 活动库 | 设计 · 活动库 |
|
||||
| 工具 | 设计 · 工具箱 |
|
||||
| 数据(schema 面) | 设计 · 数据域 |
|
||||
| 数据(行数据面) | 运营 · 台账 ×N |
|
||||
| 触发器(规则 / 装配定义) | 设计 · 流程包 - 调起规则 |
|
||||
| 触发器(启停 / 死信处置) | 运营 · 任务 - 需处置 |
|
||||
| 任务 | 运营 · 任务 |
|
||||
| 员工 | 运营 · 员工(+ 工作组管理,新) |
|
||||
| 记忆(度量仪表) | 运营 · 驾驶舱(首页) |
|
||||
| 记忆(活动记忆) | 运营 · 知识库 |
|
||||
| 系统 | 运营 · 系统 |
|
||||
|
||||
前端改动清单:
|
||||
|
||||
1. 壳层:`gov-shell` 一分为二(`/design` 与 `/ops` 各一套左轨与路由),`/gov` 302 → `/ops`;
|
||||
2. 网页标签:`tab-identity` 从两章扩为三章;
|
||||
3. 新页两张:企业蓝图(设计首页)、驾驶舱(运营首页)—— 数据全部来自既有接口的再聚合;
|
||||
4. 新界面一处:工作组管理(员工位内;`org.saveWorkGroup` 已在出口,缺的只是界面);
|
||||
5. 触发器编辑面:从独立区改为流程包详情内的可编辑段(启停路由已有;规则与装配的
|
||||
编辑需补一条 upsert 路由 —— `trigger.upsert` 用例已在模块出口,只缺 HTTP 面)。
|
||||
|
||||
## 六、权限
|
||||
|
||||
**授权模型不动**:仍是九治理功能 × 读/写/治三动作(客体三类中的 governance 类)。
|
||||
变化只在**归属与亮灯**:
|
||||
|
||||
- 九功能按上表归属两面;
|
||||
- 台面入口按"是否持有该面任一功能的读权"亮灯 —— 无权者看不见入口,有读权者进去只读,
|
||||
动作仍逐功能校验(读是 read,动才是 govern);
|
||||
- 不新增"设计权 / 运营权"这类粗粒度开关,不做两层叠加 —— 单组织 ≤50 人用不上,
|
||||
且现有 grants 数据零迁移。
|
||||
|
||||
## 七、边界(本设计不做的)
|
||||
|
||||
- **不动后端**:模块、契约、授权、数据库都保持原样;
|
||||
- **不做对象级动态导航**:台账动态位止于"域"粒度,对象在域内切换;
|
||||
- **不做设计中心的运行态回显**:哪怕"看起来有用"也不加 —— 纪律见第二节;
|
||||
- **不做工作台的管理者混入**:运营能力永远住运营中心,不往工作台塞管理页签。
|
||||
|
||||
## 八、落地顺序建议
|
||||
|
||||
1. **壳先行**:两套左轨 + 三章标签 + `/gov` 跳转 + 权限亮灯(纯搬迁,一次可验);
|
||||
2. **两张首页**:驾驶舱(度量升格)→ 企业蓝图(四库健康度);
|
||||
3. **归位改造**:触发器定义并入流程包、台账动态位、数据域定义面瘦身;
|
||||
4. **补欠账**:工作组管理界面。
|
||||
|
||||
每步落完都可独立验收;顺序可按当期在做的活动开发穿插。
|
||||
|
||||
---
|
||||
|
||||
## 附 · 决策记录
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 拆分形态 | 两个独立台面(+工作台成三面) | 产品叙事最干净:定义 / 监控 / 干活是三种心智;权限天然分界 |
|
||||
| 触发器归属 | 定义归设计(流程包内),处置归运营 | 同一对象两种动作各归其位;避免两边各放一份 |
|
||||
| 度量归属 | 升为运营首页(驾驶舱) | "看企业今天跑得怎样"需要开屏答案;与活动记忆分开是两种心智 |
|
||||
| 动态位形态 | 域 = 导航项,随发布长 | "设计产生运营的形状"要在导航上直接可见;对象级太碎 |
|
||||
| 权限粒度 | 保留九功能粒度,入口按读权亮灯 | grants 零迁移,细粒度保住;≤50 人不需要两层模型 |
|
||||
| 设计中心 IA | 蓝图首页 + 四库平铺 | 因果链要有一屏能讲;四库直达的稳定导航同时保住 |
|
||||
Reference in New Issue
Block a user