Files
JX-BPOS/apps/web/src/app/gov-shell.tsx
T
ChaiHaoandClaude Opus 5 a9e9c7babd 设计中心加联调测试台:造入站邮件(带附件)投通道,看真结果
你要的「模拟输入邮件+附件触发起单」。后端**本来就齐了** ——
`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
2026-07-28 14:02:00 +08:00

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 治理台外壳 —— 一份壳,两个台面(设计中心 / 运营中心)。
*
* 左轨(本台面的区)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
*
* 两台面差的只是**区表与首页**,皮肤与骨架同一套 —— 故不复制一个壳,而是把台面当参数。
* 区归属、入口亮灯一律读 `surfaces.ts`,壳自己不作主张。
*
* §9.2「路由状态即应用状态」:台面 / 区 / 页签 / 下钻栈全部编码进 URL 的 hash,
* 刷新与深链都能完整还原;前端零过程状态。
*/
import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Session } from '@contracts';
import { logoutRoute, sessionRoute } 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';
import { SURFACE_META, SURFACE_ZONES, canSeeSurface } from '@web-shared/gov/surfaces.ts';
import type { Surface } from '@web-shared/gov/surfaces.ts';
import { useGovObjects, useGovTasks } from '@web-shared/gov/gov-api.ts';
import { domainShort, sortDomains } from '@web-shared/gov/domains.ts';
import { queryKeys } from './query.ts';
import { BlueprintHome } from '@features/gov-blueprint';
import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task';
import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff';
import { EdgesPage, PkgPage, WorkflowsHome } from '@features/gov-process';
import { TriggerPage } from '@features/gov-trigger';
import { ActPage, LibraryHome } from '@features/gov-activity';
import { ToolPage, ToolsHome } from '@features/gov-tool';
import { DataHome, LedgersHome, ObjPage } from '@features/gov-data';
import { TestbedHome } from '@features/gov-testbed';
import { CockpitHome, KnowledgeHome, MemPage } from '@features/gov-memory';
import { ChanPage, LlmPage, SystemHome } from '@features/gov-system';
/* ── URL 编解码:`#/<surface>/<zone>/<tab>[/<k>:<id>…]` ────────────────── */
type Place = { zone: GovZone; tab: string; stack: Route[] };
function readPlace(surface: Surface): Place {
const raw = window.location.hash.replace(/^#\/?/, '');
const parts = raw.split('/').filter((s) => s !== '');
// 区限定在**本台面**的区表内:越界(旧链接、手改地址)落回本台首区,不串台
const zones = SURFACE_ZONES[surface];
const zone = zones.find((z) => z === parts[1]) ?? zones[0]!;
const tabs = TABS[zone];
// 台账区的页签是数据域(动态生成),静态表里没有 —— 认不出就原样收下,别兜到不存在的首签
const tab = tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]?.key ?? (parts[2] ?? '');
const stack: Route[] = [];
for (const seg of parts.slice(3)) {
const at = seg.indexOf(':');
if (at <= 0) continue;
stack.push({ k: seg.slice(0, at), id: decodeURIComponent(seg.slice(at + 1)) } as Route);
}
return { zone, tab, stack };
}
const writePlace = (surface: Surface, p: Place): string =>
['#', surface, p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/');
export function GovShell({ surface }: { surface: Surface }) {
const [place, setPlace] = useState<Place>(() => readPlace(surface));
const session = useQuery({ queryKey: queryKeys.session, queryFn: () => call<Session>(sessionRoute) });
// 运行态的两份数据只在运营中心取:设计中心不看运行数字(纪律),也不必替它要
const tasks = useGovTasks({ enabled: surface === 'ops' });
const objects = useGovObjects({ enabled: surface === 'ops' });
const queryClient = useQueryClient();
/** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */
const logout = useMutation({
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
onSuccess: () => {
queryClient.clear();
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
window.location.assign('/');
},
});
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
useEffect(() => {
const onHash = () => setPlace(readPlace(surface));
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, [surface]);
const go = useCallback(
(next: Place) => {
window.location.hash = writePlace(surface, next);
setPlace(next);
},
[surface],
);
const push = useCallback(
(r: Route) => {
const p = readPlace(surface);
go({ ...p, stack: [...p.stack, r] });
},
[go, surface],
);
const back = useCallback(() => {
const p = readPlace(surface);
go({ ...p, stack: p.stack.slice(0, -1) });
}, [go, surface]);
const goZone = useCallback((z: GovZone) => go({ zone: z, tab: TABS[z][0]?.key ?? '', stack: [] }), [go]);
const jump = useCallback((z: GovZone, tab: string, r?: Route) => go({ zone: z, tab, stack: r ? [r] : [] }), [go]);
const nav: Nav = { push, jump };
// Esc 退一级 —— 与面包屑同义
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') back();
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [back]);
const { zone, tab, stack } = place;
const meta = ZM[zone];
const top = stack[stack.length - 1];
const me = session.data?.staff;
const other: Surface = surface === 'design' ? 'ops' : 'design';
/** 告警计数:异常工作流 + 非放行终态的任务 —— 只有运营中心管这个 */
const alerts =
(tasks.data?.workflows ?? []).filter((w) => w.state === 'abnormal').length +
(tasks.data?.tasks ?? []).filter((t) => t.terminal !== undefined && t.terminal !== 'released').length;
/** 台账的域页签按**已有域**长出来 —— 设计侧一发布新域,台账里自动多一个页签 */
const domains = sortDomains([...new Set((objects.data?.objects ?? []).map((o) => o.domain))]);
// 进台账没带域(左轨点进来)时落到首域 —— 页签与内容同一判定
const effectiveTab = zone === 'ledgers' && tab === '' ? (domains[0] ?? '') : tab;
/* 面包屑:台面 › 区 › 页签 › 下钻各级 */
const crumbs: Array<{ label: string; go?: () => void }> = [
{ label: SURFACE_META[surface].brand, go: () => goZone(zone) },
];
crumbs.push({ label: meta.title, go: () => go({ zone, tab, stack: [] }) });
// 台账区的页签名就是域键 —— 静态表里查不到,呈现走人读短名
crumbs.push({
label: TABS[zone].find((t) => t.key === tab)?.label ?? (zone === 'ledgers' ? domainShort(effectiveTab) : tab),
go: () => go({ zone, tab, stack: [] }),
});
stack.forEach((r, i) =>
crumbs.push({
label: routeLabel(r),
...(i === stack.length - 1 ? {} : { go: () => go({ zone, tab, stack: stack.slice(0, i + 1) }) }),
}),
);
return (
<div className="g-shell gov-root">
{/* ===== 深色左轨:本台面的区 ===== */}
<nav className="g-nav">
<div className="g-nav-mark">
<Ico name={surface === 'design' ? 'route' : 'gauge'} s={20} />
</div>
<div className="g-nav-brand">{SURFACE_META[surface].brand}</div>
{ZONES.filter((z) => SURFACE_ZONES[surface].includes(z.key)).map((z) => (
<button key={z.key} className={'g-nav-b' + (zone === z.key ? ' on' : '')} onClick={() => goZone(z.key)}>
<span className="g-nav-ico">
<Ico name={z.icon} s={18} />
</span>
<span>{z.label}</span>
</button>
))}
<div className="g-nav-foot">
<span className="g-nav-ava">{(me?.name ?? '—').slice(0, 1)}</span>
<span>{me?.name ?? '未登录'}</span>
</div>
</nav>
<div className="g-body">
{/* ===== 深色顶栏 ===== */}
<header className="g-top">
<span className="g-top-brand">
JCAI 操作系统<em>单组织 · ≤50 人 · 生产环境</em>
</span>
<label className="g-search">
<Ico name="search" s={14} />
<input placeholder={SURFACE_META[surface].searchHint} />
<kbd>⌘K</kbd>
</label>
<div className="g-top-r">
{surface === 'ops' && (
<button className={'g-tbtn' + (alerts ? ' alert' : '')} onClick={() => jump('tasks', 'todo')}>
<Ico name="alert-triangle" s={13} />
告警<b>{alerts}</b>
</button>
)}
{/* 三面互跳:另一面按授权亮灯,工作台人人可回 */}
{canSeeSurface(session.data, other) && (
<a className="g-tbtn" href={SURFACE_META[other].path}>
<Ico name={other === 'design' ? 'route' : 'gauge'} s={13} />
{SURFACE_META[other].brand}
</a>
)}
<a className="g-tbtn" href="/">
<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>
{/* ===== 工作面 ===== */}
<div className="g-page">
<div className="g-crumb">
{stack.length > 0 && (
<button className="g-back" onClick={back} title="返回上一级(Esc)">
<Ico name="arrow-left" s={15} />
</button>
)}
{crumbs.map((c, i) => (
<span key={i} className="g-crumb-i">
{i > 0 && <i className="sep">›</i>}
{c.go ? <button onClick={c.go}>{c.label}</button> : <b>{c.label}</b>}
</span>
))}
</div>
{top === undefined ? (
<>
<div className="g-head">
<span className="g-head-i" style={{ background: meta.accent }}>
<Ico name={meta.icon} s={19} />
</span>
<div className="g-head-t">
<h1>{meta.title}</h1>
<p>{meta.desc}</p>
</div>
</div>
<div className="g-tabs">
{zone === 'ledgers'
? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上
domains.map((d) => (
<button
key={d}
className={'g-tab' + (effectiveTab === d ? ' on' : '')}
title={`数据域 · ${d}`}
onClick={() => go({ zone, tab: d, stack: [] })}
>
<Ico name="table" s={13} />
{domainShort(d)}
</button>
))
: TABS[zone].map((t) => (
<button
key={t.key}
className={'g-tab' + (tab === t.key ? ' on' : '')}
onClick={() => go({ zone, tab: t.key, stack: [] })}
>
<Ico name={t.ico} s={13} />
{t.label}
</button>
))}
</div>
<ZoneHome zone={zone} tab={effectiveTab} nav={nav} />
</>
) : (
<div className="g-detail" key={`${stack.length}:${top.k}:${top.id}`}>
<Detail route={top} nav={nav} />
</div>
)}
</div>
</div>
</div>
);
}
function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) {
const P = {
// 设计中心
blueprint: BlueprintHome,
workflows: WorkflowsHome,
library: LibraryHome,
tools: ToolsHome,
data: DataHome,
testbed: TestbedHome,
// 运营中心
cockpit: CockpitHome,
tasks: TasksHome,
people: PeopleHome,
ledgers: LedgersHome,
knowledge: KnowledgeHome,
system: SystemHome,
}[zone];
return <P tab={tab} nav={nav} />;
}
function Detail({ route, nav }: { route: Route; nav: Nav }) {
switch (route.k) {
case 'wf':
case 'apr':
return <WfPage id={route.id} nav={nav} />;
case 'task':
return <TaskPage id={route.id} nav={nav} />;
case 'tasklog':
return <TaskLogPage id={route.id} />;
case 'emp':
return <EmpPage id={route.id} nav={nav} />;
case 'role':
return <RolePage id={route.id} />;
case 'pkg':
return <PkgPage id={route.id} nav={nav} />;
case 'edges':
return <EdgesPage id={route.id} />;
case 'trigger':
return <TriggerPage id={route.id} />;
case 'act':
return <ActPage id={route.id} nav={nav} />;
case 'tool':
return <ToolPage id={route.id} />;
case 'obj':
return <ObjPage id={route.id} />;
case 'mem':
return <MemPage id={route.id} />;
case 'llm':
return <LlmPage id={route.id} />;
case 'chan':
return <ChanPage id={route.id} />;
}
}