三面拆分③:壳参数化两台,台账随域动态生成,顶栏互跳按权亮灯

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 21:00:25 +08:00
co-authored by Claude Fable 5
parent cbae9b1284
commit bb6760d7a4
4 changed files with 186 additions and 51 deletions
+29
View File
@@ -25,6 +25,7 @@ import { ReportDesk } from '@features/desk-report';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { useChatWidth } from '@web-shared/useChatWidth.ts'; import { useChatWidth } from '@web-shared/useChatWidth.ts';
import { SURFACE_META, canSeeSurface } from '@web-shared/gov/surfaces.ts';
import { queryKeys } from './query.ts'; import { queryKeys } from './query.ts';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report'; type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -123,6 +124,34 @@ export function AppShell() {
); );
})} })}
<div className="jc-rail-foot"> <div className="jc-rail-foot">
{/*
另两面的入口 —— 按授权亮灯:没有治理授权的人根本看不见,不给点了才说没权。
两枚并排、只留章不留名:轨底还得放身份钮,竖排三件会把左轨撑出滚动条(实测)。
*/}
{(canSeeSurface(session.data, 'design') || canSeeSurface(session.data, 'ops')) && (
<div className="jc-nav-user" style={{ flexDirection: 'row', gap: 6, paddingTop: 0 }}>
{canSeeSurface(session.data, 'design') && (
<a
className="jc-nav-ava"
href={SURFACE_META.design.path}
title="设计中心 —— 定义企业怎么运转"
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#4954e6,#232b6e)' }}
>
设
</a>
)}
{canSeeSurface(session.data, 'ops') && (
<a
className="jc-nav-ava"
href={SURFACE_META.ops.path}
title="运营中心 —— 看企业今天跑得怎样"
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#12b886,#0b7a5c)' }}
>
运
</a>
)}
</div>
)}
{/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */} {/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */}
<button <button
type="button" type="button"
+132 -35
View File
@@ -1,21 +1,28 @@
/** /**
* 治理台外壳 —— 自原型 `Governance.tsx` 第 389–514 行平移。 * 治理台外壳 —— 一份壳,两个台面(设计中心 / 运营中心)。
* *
* 左轨(九功能)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。 * 左轨(本台面的区)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」 * 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
* *
* §9.2「路由状态即应用状态」:当前区 / 页签 / 下钻栈全部编码进 URL 的 hash, * 两台面差的只是**区表与首页**,皮肤与骨架同一套 —— 故不复制一个壳,而是把台面当参数。
* 区归属、入口亮灯一律读 `surfaces.ts`,壳自己不作主张。
*
* §9.2「路由状态即应用状态」:台面 / 区 / 页签 / 下钻栈全部编码进 URL 的 hash,
* 刷新与深链都能完整还原;前端零过程状态。 * 刷新与深链都能完整还原;前端零过程状态。
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { logoutRoute } from '@contracts'; import type { Session } from '@contracts';
import { logoutRoute, sessionRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { Note } from '@web-shared/gov/primitives.tsx'; import { Note } from '@web-shared/gov/primitives.tsx';
import { TABS, ZM, ZONES, routeLabel } from '@web-shared/gov/gov-route.ts'; 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 type { GovZone, Nav, Route } from '@web-shared/gov/gov-route.ts';
import { useGovStaff, useGovTasks } from '@web-shared/gov/gov-api.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 { queryKeys } from './query.ts';
import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task'; import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task';
import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff'; import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff';
import { EdgesPage, PkgPage, WorkflowsHome } from '@features/gov-process'; import { EdgesPage, PkgPage, WorkflowsHome } from '@features/gov-process';
@@ -26,14 +33,16 @@ import { DataHome, ObjPage } from '@features/gov-data';
import { MemPage, MemoryHome } from '@features/gov-memory'; import { MemPage, MemoryHome } from '@features/gov-memory';
import { ChanPage, LlmPage, SystemHome } from '@features/gov-system'; import { ChanPage, LlmPage, SystemHome } from '@features/gov-system';
/* ── URL 编解码:`#/gov/<zone>/<tab>[/<k>:<id>…]` ──────────────────────── */ /* ── URL 编解码:`#/<surface>/<zone>/<tab>[/<k>:<id>…]` ────────────────── */
type Place = { zone: GovZone; tab: string; stack: Route[] }; type Place = { zone: GovZone; tab: string; stack: Route[] };
function readPlace(): Place { function readPlace(surface: Surface): Place {
const raw = window.location.hash.replace(/^#\/?/, ''); const raw = window.location.hash.replace(/^#\/?/, '');
const parts = raw.split('/').filter((s) => s !== ''); const parts = raw.split('/').filter((s) => s !== '');
const zone = (ZONES.find((z) => z.key === parts[1])?.key ?? 'tasks') as GovZone; // 区限定在**本台面**的区表内:越界(旧链接、手改地址)落回本台首区,不串台
const zones = SURFACE_ZONES[surface];
const zone = zones.find((z) => z === parts[1]) ?? zones[0]!;
const tabs = TABS[zone]; const tabs = TABS[zone];
// 台账区的页签是数据域(动态生成),静态表里没有 —— 认不出就原样收下,别兜到不存在的首签 // 台账区的页签是数据域(动态生成),静态表里没有 —— 认不出就原样收下,别兜到不存在的首签
const tab = tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]?.key ?? (parts[2] ?? ''); const tab = tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]?.key ?? (parts[2] ?? '');
@@ -46,13 +55,15 @@ function readPlace(): Place {
return { zone, tab, stack }; return { zone, tab, stack };
} }
const writePlace = (p: Place): string => const writePlace = (surface: Surface, p: Place): string =>
['#', 'gov', p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/'); ['#', surface, p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/');
export function GovShell() { export function GovShell({ surface }: { surface: Surface }) {
const [place, setPlace] = useState<Place>(readPlace); const [place, setPlace] = useState<Place>(() => readPlace(surface));
const staff = useGovStaff(); const session = useQuery({ queryKey: queryKeys.session, queryFn: () => call<Session>(sessionRoute) });
const tasks = useGovTasks(); // 运行态的两份数据只在运营中心取:设计中心不看运行数字(纪律),也不必替它要
const tasks = useGovTasks({ enabled: surface === 'ops' });
const objects = useGovObjects({ enabled: surface === 'ops' });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */ /** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */
@@ -67,24 +78,30 @@ export function GovShell() {
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态 // 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
useEffect(() => { useEffect(() => {
const onHash = () => setPlace(readPlace()); const onHash = () => setPlace(readPlace(surface));
window.addEventListener('hashchange', onHash); window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash);
}, []); }, [surface]);
const go = useCallback((next: Place) => { const go = useCallback(
window.location.hash = writePlace(next); (next: Place) => {
window.location.hash = writePlace(surface, next);
setPlace(next); setPlace(next);
}, []); },
[surface],
);
const push = useCallback( const push = useCallback(
(r: Route) => go({ ...readPlace(), stack: [...readPlace().stack, r] }), (r: Route) => {
[go], const p = readPlace(surface);
go({ ...p, stack: [...p.stack, r] });
},
[go, surface],
); );
const back = useCallback(() => { const back = useCallback(() => {
const p = readPlace(); const p = readPlace(surface);
go({ ...p, stack: p.stack.slice(0, -1) }); go({ ...p, stack: p.stack.slice(0, -1) });
}, [go]); }, [go, surface]);
const goZone = useCallback((z: GovZone) => go({ zone: z, tab: TABS[z][0]?.key ?? '', stack: [] }), [go]); 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 jump = useCallback((z: GovZone, tab: string, r?: Route) => go({ zone: z, tab, stack: r ? [r] : [] }), [go]);
const nav: Nav = { push, jump }; const nav: Nav = { push, jump };
@@ -101,17 +118,27 @@ export function GovShell() {
const { zone, tab, stack } = place; const { zone, tab, stack } = place;
const meta = ZM[zone]; const meta = ZM[zone];
const top = stack[stack.length - 1]; const top = stack[stack.length - 1];
const me = staff.data?.staff.find((s) => s.account === 'fengbing'); const me = session.data?.staff;
const other: Surface = surface === 'design' ? 'ops' : 'design';
/** 告警计数:异常工作流 + 非放行终态的任务 */ /** 告警计数:异常工作流 + 非放行终态的任务 —— 只有运营中心管这个 */
const alerts = const alerts =
(tasks.data?.workflows ?? []).filter((w) => w.state === 'abnormal').length + (tasks.data?.workflows ?? []).filter((w) => w.state === 'abnormal').length +
(tasks.data?.tasks ?? []).filter((t) => t.terminal !== undefined && t.terminal !== 'released').length; (tasks.data?.tasks ?? []).filter((t) => t.terminal !== undefined && t.terminal !== 'released').length;
/* 面包屑:治理台 › 区 › 页签 › 下钻各级 */ /** 台账的导航项按**已有数据域**长出来 —— 设计侧一发布新域,运营侧自动多一位 */
const crumbs: Array<{ label: string; go?: () => void }> = [{ label: '治理台', go: () => goZone(zone) }]; const domains = [...new Set((objects.data?.objects ?? []).map((o) => o.domain))].sort();
/* 面包屑:台面 › 区 › 页签 › 下钻各级 */
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: meta.title, go: () => go({ zone, tab, stack: [] }) });
crumbs.push({ label: TABS[zone].find((t) => t.key === tab)?.label ?? '', go: () => go({ zone, tab, stack: [] }) }); // 台账区的页签名就是域名 —— 静态表里查不到,直接用它本身
crumbs.push({
label: TABS[zone].find((t) => t.key === tab)?.label ?? tab,
go: () => go({ zone, tab, stack: [] }),
});
stack.forEach((r, i) => stack.forEach((r, i) =>
crumbs.push({ crumbs.push({
label: routeLabel(r), label: routeLabel(r),
@@ -121,20 +148,30 @@ export function GovShell() {
return ( return (
<div className="g-shell gov-root"> <div className="g-shell gov-root">
{/* ===== 深色左轨 ===== */} {/* ===== 深色左轨:本台面的区 ===== */}
<nav className="g-nav"> <nav className="g-nav">
<div className="g-nav-mark"> <div className="g-nav-mark">
<Ico name="shield-alert" s={20} /> <Ico name={surface === 'design' ? 'route' : 'gauge'} s={20} />
</div> </div>
<div className="g-nav-brand">治理台</div> <div className="g-nav-brand">{SURFACE_META[surface].brand}</div>
{ZONES.map((z) => ( {ZONES.filter((z) => SURFACE_ZONES[surface].includes(z.key)).map((z) =>
// 台账不是一个固定位,是一段**随数据域生长**的动态位 —— 每域一枚
z.key === 'ledgers' ? (
<LedgerRail
key={z.key}
domains={domains}
active={zone === 'ledgers' ? tab : undefined}
onPick={(domain) => go({ zone: 'ledgers', tab: domain, stack: [] })}
/>
) : (
<button key={z.key} className={'g-nav-b' + (zone === z.key ? ' on' : '')} onClick={() => goZone(z.key)}> <button key={z.key} className={'g-nav-b' + (zone === z.key ? ' on' : '')} onClick={() => goZone(z.key)}>
<span className="g-nav-ico"> <span className="g-nav-ico">
<Ico name={z.icon} s={18} /> <Ico name={z.icon} s={18} />
</span> </span>
<span>{z.label}</span> <span>{z.label}</span>
</button> </button>
))} ),
)}
<div className="g-nav-foot"> <div className="g-nav-foot">
<span className="g-nav-ava">{(me?.name ?? '—').slice(0, 1)}</span> <span className="g-nav-ava">{(me?.name ?? '—').slice(0, 1)}</span>
<span>{me?.name ?? '未登录'}</span> <span>{me?.name ?? '未登录'}</span>
@@ -149,14 +186,23 @@ export function GovShell() {
</span> </span>
<label className="g-search"> <label className="g-search">
<Ico name="search" s={14} /> <Ico name="search" s={14} />
<input placeholder="跨对象检索:工作流 / 任务 / 活动 / 工具 / 触发器 / 对象…" /> <input placeholder={SURFACE_META[surface].searchHint} />
<kbd>⌘K</kbd> <kbd>⌘K</kbd>
</label> </label>
<div className="g-top-r"> <div className="g-top-r">
{surface === 'ops' && (
<button className={'g-tbtn' + (alerts ? ' alert' : '')} onClick={() => jump('tasks', 'todo')}> <button className={'g-tbtn' + (alerts ? ' alert' : '')} onClick={() => jump('tasks', 'todo')}>
<Ico name="alert-triangle" s={13} /> <Ico name="alert-triangle" s={13} />
告警<b>{alerts}</b> 告警<b>{alerts}</b>
</button> </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="/"> <a className="g-tbtn" href="/">
<Ico name="list-checks" s={13} /> <Ico name="list-checks" s={13} />
工作台 工作台
@@ -220,6 +266,57 @@ export function GovShell() {
); );
} }
/**
* 台账段 —— 左轨里唯一的**动态位**:一域一枚,随数据域发布而生长。
*
* 这是「设计产生运营的形状」最直观的一处:导航本身就是被设计出来的。
*/
function LedgerRail({
domains,
active,
onPick,
}: {
domains: readonly string[];
active: string | undefined;
onPick: (domain: string) => void;
}) {
return (
<>
<div className="g-nav-brand" style={{ marginTop: 8, marginBottom: 4 }}>
台账
</div>
{domains.length === 0 ? (
<div className="g-nav-brand" style={{ fontWeight: 600, letterSpacing: 0, lineHeight: 1.5 }}>
随数据域
<br />
发布生长
</div>
) : (
domains.map((d) => (
<button
key={d}
className={'g-nav-b' + (active === d ? ' on' : '')}
onClick={() => onPick(d)}
title={`台账 · ${d}`}
>
<span className="g-nav-ico">
<Ico name="database" s={18} />
</span>
{/* 域名是 `活动_环节` 这种复合键,60px 的轨放不下一行 —— 按下划线断行,窄字号铺开 */}
<span style={{ fontSize: 10, lineHeight: 1.25, wordBreak: 'break-word' }}>
{d.split('_').map((seg, i) => (
<span key={seg} style={{ display: 'block' }}>
{i === 0 && d.includes('_') ? `${seg}_` : seg}
</span>
))}
</span>
</button>
))
)}
</>
);
}
/** 企业蓝图 —— Task 6 接真;在那之前先给设计者一句「这里将有什么」 */ /** 企业蓝图 —— Task 6 接真;在那之前先给设计者一句「这里将有什么」 */
function BlueprintPlaceholder() { function BlueprintPlaceholder() {
return ( return (
+1 -1
View File
@@ -97,7 +97,7 @@ function Gate() {
} }
return govSurface !== undefined ? ( return govSurface !== undefined ? (
<GovShell /> <GovShell surface={govSurface} />
) : ( ) : (
// .proto-root 是工作台补充样式的作用域(原型 app.css 通篇以它起头); // .proto-root 是工作台补充样式的作用域(原型 app.css 通篇以它起头);
// 治理台不裹它 —— 裹了工作台的补充样式就会漏进治理台的树里 // 治理台不裹它 —— 裹了工作台的补充样式就会漏进治理台的树里
+13 -4
View File
@@ -57,8 +57,13 @@ export type GovWorkflow = GovWorkflowOutput;
export type GovTaskLog = GovTaskLogOutput; export type GovTaskLog = GovTaskLogOutput;
export type GovGrants = GovGrantsOutput; export type GovGrants = GovGrantsOutput;
export const useGovTasks = (): UseQueryResult<GovTasks> => /** `enabled: false` 供设计中心用 —— 那一面不看运行态,别去要一份自己无权也无用的数据 */
useQuery({ queryKey: ['gov', 'tasks'], queryFn: () => call<GovTasks>(listGovTasksRoute) }); export const useGovTasks = (opts?: { enabled?: boolean }): UseQueryResult<GovTasks> =>
useQuery({
queryKey: ['gov', 'tasks'],
queryFn: () => call<GovTasks>(listGovTasksRoute),
enabled: opts?.enabled ?? true,
});
export const useGovApprovals = (): UseQueryResult<GovApprovals> => export const useGovApprovals = (): UseQueryResult<GovApprovals> =>
useQuery({ queryKey: ['gov', 'approvals'], queryFn: () => call<GovApprovals>(listApprovalsRoute) }); useQuery({ queryKey: ['gov', 'approvals'], queryFn: () => call<GovApprovals>(listApprovalsRoute) });
@@ -85,8 +90,12 @@ export const useGovActivities = (): UseQueryResult<GovActivities> =>
export const useGovTools = (): UseQueryResult<GovTools> => export const useGovTools = (): UseQueryResult<GovTools> =>
useQuery({ queryKey: ['gov', 'tools'], queryFn: () => call<GovTools>(listToolsRoute) }); useQuery({ queryKey: ['gov', 'tools'], queryFn: () => call<GovTools>(listToolsRoute) });
export const useGovObjects = (): UseQueryResult<GovObjects> => export const useGovObjects = (opts?: { enabled?: boolean }): UseQueryResult<GovObjects> =>
useQuery({ queryKey: ['gov', 'objects'], queryFn: () => call<GovObjects>(listLedgerObjectsRoute) }); useQuery({
queryKey: ['gov', 'objects'],
queryFn: () => call<GovObjects>(listLedgerObjectsRoute),
enabled: opts?.enabled ?? true,
});
export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> => export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> =>
useQuery({ useQuery({