forked from ChaiHao/BPOS-MVP
你要的「模拟输入邮件+附件触发起单」。后端**本来就齐了** —— `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
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
/**
|
||
* 治理台外壳 —— 一份壳,两个台面(设计中心 / 运营中心)。
|
||
*
|
||
* 左轨(本台面的区)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。
|
||
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
|
||
*
|
||
* 两台面差的只是**区表与首页**,皮肤与骨架同一套 —— 故不复制一个壳,而是把台面当参数。
|
||
* 区归属、入口亮灯一律读 `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} />;
|
||
}
|
||
}
|