你要的「模拟输入邮件+附件触发起单」。后端**本来就齐了** —— `ingestMailRoute` 契约里早有 attachments,出参也早就回归属解析与三级消费结果。 所以这是纯前端加一个区:设计中心新增 `testbed`,一个页签「入站邮件」。 走的是与真通道**同一条**入站链路(通道归一 → 归属解析 → 统一事件流 → 三级消费)。 邮件通道是外界,模拟通道是 README 明许的;系统内部机制这里一行都不 mock —— 命中判定、起单、消息兜底全是真的。 ## 这一屏回答三个问题 投出去之后就摆三样:归属判到谁(哪一级命中 / 无主的原因 / 默认认领岗位)、 谁接了(任务监听 vs 触发器各命中几个)、没人接落哪儿(消息兜底)。 还有一格**起单前置自检**:投之前就告诉你会不会命中,不用投完了猜。 判据与真实配置同源 —— 通道 id、主题关键词、两个归属键正则,四条逐条给绿灯或说差什么。 预填的样例是一键能起单的那一封;把它改坏,自检立刻指出差哪条。 附件如实说明「只走**登记**」:名字/类型/大小/存证指针进事件信封,内容不解析 —— 解析 Excel/PDF 是活动的事,活动还没实现。不含糊过去。 ## 顺手修一个会静默分裂数据的 bug 通道的归属键正则 `RFQ-[A-Z0-9]+` 不含连字符,`RFQ-2607-501` 只抽到 `RFQ-2607` 就停。 而这个键会成为「受理结论」那一行的**主键** —— 截断了,同一单的数据就静默分裂成两行, 网关读到哪一行全看运气。改成分段续接 `RFQ-[A-Z0-9]+(?:-[A-Z0-9]+)*`, 实测 `RFQ-2607-501` / `RFQ-ABC123` 都整个吃下。 ## 我自己踩的两个 ① 出参类型手搓,`level`/`kind`/`position`/`rationale` **四个字段名全是编的** —— 页面上就摆出两个永远显示「—」的格子。改为直接用契约类型:归属解析真实是按 `outcome` 判别的联合(owned 给 rule+taskId,unowned 给 reason+可选 fallbackPosition)。 为此给契约补了 `IngestMailOutput` 导出(加法)。 ② JSX 正文里写 markdown 的 `**` 会显示成字面星号,换 `<b>`。这坑本轮踩过第二次。 另补两个缺失图标(flask-conical / split),不补就是空 SVG。 ## 实测 界面上点「投递这封信」→ 归属判**无主 · 三级都没对上 · 默认认领岗位 运营** (对新询价完全正确:没有在途任务可归)→ 触发器命中 1 个 → 起单。 起单入参 `customerCode: C014` / `rfqRef: RFQ-2607-501` 都抽对了。 一个值得记的观察:事件起单**没有「来源人」**,所以不装载工作组,A1 的 BD 岗位解析到 真正的 BD(冯兵),任务落他工位而不是我的 —— 这是对的,不是 bug。 要在自己工位上看,用「人工登记起单」那条路(登记人即来源)。 typecheck ✓ lint ✓ depcruise 376 模块 ✓ 498 测试 ✓ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
/**
|
|
* 三面骨架的唯一事实源(docs/产品设计.md §1 / §6)。
|
|
*
|
|
* 产品分三面:设计中心定义企业、运营中心看企业跑、工作台在企业里干活。
|
|
* 区归属、入口亮灯、旧链接改写都从这里出 —— 壳层只消费,不各写各的。
|
|
*/
|
|
import type { GovernanceFunction, Session } from '@contracts';
|
|
import { GOV_ZONES } from './gov-route.ts';
|
|
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: '检索运行:工作流 / 任务 / 员工 / 台账…',
|
|
},
|
|
};
|
|
|
|
/** 区归属:设计五区 + 运营六区,恰好铺满 `GOV_ZONES`(由测试守住) */
|
|
export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>> = {
|
|
design: ['blueprint', 'workflows', 'library', 'tools', 'data', 'testbed'],
|
|
ops: ['cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system'],
|
|
};
|
|
|
|
/**
|
|
* 亮灯功能集 —— 授权模型不动(仍是九功能 × 读/写/治),这里只说「哪个功能属哪一面」。
|
|
*
|
|
* `trigger` 与 `data` 两面都算:触发器**定义归设计、启停归运营**;
|
|
* 数据**结构归设计、行数据归运营**。同一功能的两种动作各归其位,不拆授权。
|
|
*/
|
|
export const SURFACE_FNS: Readonly<Record<Surface, readonly GovernanceFunction[]>> = {
|
|
design: ['process', 'activity', 'tool', 'data', 'trigger'],
|
|
ops: ['task', 'staff', 'memory', 'system', 'trigger', 'data'],
|
|
};
|
|
|
|
/**
|
|
* 台面入口是否亮灯。
|
|
*
|
|
* 判据是「持有该面任一功能的**任意**授权」—— 动作蕴含 govern ⊃ write ⊃ read,
|
|
* 有 grant 即至少可读,入口就该看得见;进去之后动作仍逐功能校验。
|
|
*/
|
|
export function canSeeSurface(session: Session | undefined, surface: Surface): boolean {
|
|
if (session === undefined) return false;
|
|
const fns = SURFACE_FNS[surface];
|
|
return session.grants.some((g) => g.object.kind === 'governance' && fns.includes(g.object.function));
|
|
}
|
|
|
|
export function surfaceOfZone(zone: GovZone): Surface {
|
|
return SURFACE_ZONES.design.includes(zone) ? 'design' : 'ops';
|
|
}
|
|
|
|
function isGovZone(value: string): value is GovZone {
|
|
return (GOV_ZONES as readonly string[]).includes(value);
|
|
}
|
|
|
|
/**
|
|
* 已删区的改嫁表:键是 `区` 或 `区/页签`,值是新台面下的 `台面/区/页签`。
|
|
* 精确到页签的先匹配 —— 记忆的两个旧页签各归其位(度量升驾驶舱,活动记忆归知识库)。
|
|
*/
|
|
const LEGACY_MOVES: Readonly<Record<string, string>> = {
|
|
triggers: 'design/workflows/rules',
|
|
memory: 'ops/cockpit/dash',
|
|
'memory/layers': 'ops/knowledge/layers',
|
|
};
|
|
|
|
/** 运营首页 —— 认不出的旧链接一律落这儿,不给白屏 */
|
|
const OPS_HOME = 'ops/cockpit/dash';
|
|
|
|
function toUrl(place: string): string {
|
|
const head = place.split('/')[0];
|
|
const surface: Surface = head === 'design' ? 'design' : 'ops';
|
|
return `${SURFACE_META[surface].path}#/${place}`;
|
|
}
|
|
|
|
/**
|
|
* 旧 `#/gov/<区>/…` 深链 → 新台面的完整 URL(pathname + hash)。
|
|
* 非 gov 哈希返回 `undefined`(不是我们的事,别乱改人家的地址)。
|
|
*/
|
|
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 toUrl(OPS_HOME);
|
|
|
|
const moved = LEGACY_MOVES[`${zone}/${parts[2] ?? ''}`] ?? LEGACY_MOVES[zone];
|
|
if (moved !== undefined) return toUrl(moved);
|
|
if (!isGovZone(zone)) return toUrl(OPS_HOME);
|
|
|
|
// 仍在的区:原样带着页签与下钻栈搬到新台面
|
|
return toUrl([surfaceOfZone(zone), ...parts.slice(1)].join('/'));
|
|
}
|