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
xcsandClaude Fable 5 f3397a60ef E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-25 01:23:30 +08:00

336 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 } from '@features/gov-memory';
import { ChanPage, LlmPage, ModelsHome, SystemHome } from '@features/gov-system';
import { Btn } from '@web-shared/Btn.tsx';
/* ── 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>
<Btn className="g-tbtn" onClick={() => logout.mutate()} busy={logout.isPending}>
<Ico name="log-out" s={13} />
登出
</Btn>
</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,
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} />;
case 'tool':
return <ToolPage id={route.id} />;
case 'obj':
return <ObjPage id={route.id} />;
case 'llm':
return <LlmPage id={route.id} />;
case 'chan':
return <ChanPage id={route.id} />;
}
}