This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/app/gov-shell.tsx
T
ChaiHaoandClaude Opus 5 f9b2c07731 三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节
**触发确认链路**(用户口述五步的第 4/5 步):
  接收邮件 → 分类识别 → 命中 RFQ 事件 → ④(可选,运营勾)系统消息到指定人
  工作台(带邮件内容)→ ⑤ 工作台点「确认起单」,流程才起。
  · 命中不等于必起:勾了「需人工确认」,装配好的入参存成待确认单
    (sys.trigger_pending,migration 0019),ConfirmDesk 端口把带来件内容的
    系统消息送指定人(eventRef 挂票号,消息列表就地贴确认状态与那枚钮)。
  · 先认领后起单:consumed_at 空才认得到 —— 两人同时点、一人点两次,都只起一条流;
    起单失败退回认领,不留死票。确认人记进 source.by,留痕可查。
  · 登记触发不走确认(登记那一下本身就是人在确认);勾了没指定人 = 配置错误入死信。
  · 4 条单测 + 全链实测:投信 → triggersHit=1 且零直起 → AI 工作台收到确认消息
    → 确认起单 → 二次确认幂等回同一流号 → source.by = AI。

**运营中心重排**(区名即运行时:工作流是流程包的运行时,任务是活动的运行时):
  · 新「工作流」区:在途看板(从任务区迁入)+ 运行配置 —— 每包的触发器
    起单开关 / 确认打勾 / 确认人,和节点负责人(sys.node_owner)。
  · 负责人三层:显式指派 > 运营配置 > 轮值 —— 合并点在 process.start,
    复用既有派发链一行不改。
  · 任务区瘦身成活动运行时(任务/审批/需处置);知识库区改名「数据」
    (与设计中心的知识库解开同名);系统区只剩通道实例。
  · 设计中心触发器详情页摘掉启停按钮 —— 设计只管设计,启停/确认全在运营。
  · 旧深链有改嫁:tasks/board → runs/board,tasks/trg → runs/conf。

reset-runs 补 sys.trigger_pending(待确认单是运行痕 —— 和上回漏 trace_span 同类洞)。
邮件分类 Agent(第 ② 步)现由抽键正则 + S-FEEL 代位,升级路径记进 debt.md。
产品设计.md 运营章、trigger/process 两份 CONTRACT.md 同步。

五闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 13:09:16 +08:00

338 lines
14 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 { RunsHome } from '@features/gov-runs';
import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff';
import { PkgPage, WorkflowsHome } from '@features/gov-process';
import { TriggerPage, TriggerRulesHome } 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 { ArchiveHome } from '@features/gov-archive';
import { TestbedHome } from '@features/gov-testbed';
import { CockpitHome, KnowledgeHome, MemPage } from '@features/gov-memory';
import { ChanPage, LlmPage, ModelsHome, 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' && TABS[zone].length <= 1 ? ' hidden' : '')}>
{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,
triggers: TriggerRulesHome,
workflows: WorkflowsHome,
library: LibraryHome,
tools: ToolsHome,
data: DataHome,
archive: ArchiveHome,
models: ModelsHome,
testbed: TestbedHome,
// 运营中心
cockpit: CockpitHome,
runs: RunsHome,
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 '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} />;
}
}