三面拆分⑫:UI 打磨 —— 补 10 枚缺失图标,蓝图改因果链,台账收成一个模块

图标注册表缺 arrow-left-right/columns/table/tag 等 10 名,用到即渲染成空 SVG —— 补齐;
蓝图四库改真箭头相连的因果链节点卡(卡即入口,删重复的入库段);
驾驶舱终态分布改五色堆叠条;运营数据收成左轨一枚「台账」,域下沉为 2-3 字短名页签
(受理→寻源→定价→议价→替代→共享,按流程序),进模块自动落首域。

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 22:02:49 +08:00
co-authored by Claude Fable 5
parent 4a19690389
commit 34e207c3e8
11 changed files with 266 additions and 170 deletions
+39 -84
View File
@@ -21,6 +21,7 @@ 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 { SURFACE_META, SURFACE_ZONES, canSeeSurface } from '@web-shared/gov/surfaces.ts';
import type { Surface } 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 { useGovObjects, useGovTasks } from '@web-shared/gov/gov-api.ts';
import { domainShort, sortDomains } from '@web-shared/gov/domains.ts';
import { queryKeys } from './query.ts'; import { queryKeys } from './query.ts';
import { BlueprintHome } from '@features/gov-blueprint'; import { BlueprintHome } from '@features/gov-blueprint';
import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task'; import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task';
@@ -126,17 +127,19 @@ export function GovShell({ surface }: { surface: Surface }) {
(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 domains = [...new Set((objects.data?.objects ?? []).map((o) => o.domain))].sort(); 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 }> = [ const crumbs: Array<{ label: string; go?: () => void }> = [
{ label: SURFACE_META[surface].brand, go: () => goZone(zone) }, { 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({ crumbs.push({
label: TABS[zone].find((t) => t.key === tab)?.label ?? tab, label: TABS[zone].find((t) => t.key === tab)?.label ?? (zone === 'ledgers' ? domainShort(effectiveTab) : tab),
go: () => go({ zone, tab, stack: [] }), go: () => go({ zone, tab, stack: [] }),
}); });
stack.forEach((r, i) => stack.forEach((r, i) =>
@@ -154,24 +157,14 @@ export function GovShell({ surface }: { surface: Surface }) {
<Ico name={surface === 'design' ? 'route' : 'gauge'} s={20} /> <Ico name={surface === 'design' ? 'route' : 'gauge'} s={20} />
</div> </div>
<div className="g-nav-brand">{SURFACE_META[surface].brand}</div> <div className="g-nav-brand">{SURFACE_META[surface].brand}</div>
{ZONES.filter((z) => SURFACE_ZONES[surface].includes(z.key)).map((z) => {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)}>
z.key === 'ledgers' ? ( <span className="g-nav-ico">
<LedgerRail <Ico name={z.icon} s={18} />
key={z.key} </span>
domains={domains} <span>{z.label}</span>
active={zone === 'ledgers' ? tab : undefined} </button>
onPick={(domain) => go({ zone: 'ledgers', tab: domain, stack: [] })} ))}
/>
) : (
<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"> <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>
@@ -242,18 +235,31 @@ export function GovShell({ surface }: { surface: Surface }) {
</div> </div>
</div> </div>
<div className="g-tabs"> <div className="g-tabs">
{TABS[zone].map((t) => ( {zone === 'ledgers'
<button ? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上
key={t.key} domains.map((d) => (
className={'g-tab' + (tab === t.key ? ' on' : '')} <button
onClick={() => go({ zone, tab: t.key, stack: [] })} key={d}
> className={'g-tab' + (effectiveTab === d ? ' on' : '')}
<Ico name={t.ico} s={13} /> title={`数据域 · ${d}`}
{t.label} onClick={() => go({ zone, tab: d, stack: [] })}
</button> >
))} <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> </div>
<ZoneHome zone={zone} tab={tab} nav={nav} /> <ZoneHome zone={zone} tab={effectiveTab} nav={nav} />
</> </>
) : ( ) : (
<div className="g-detail" key={`${stack.length}:${top.k}:${top.id}`}> <div className="g-detail" key={`${stack.length}:${top.k}:${top.id}`}>
@@ -266,57 +272,6 @@ export function GovShell({ surface }: { surface: Surface }) {
); );
} }
/**
* 台账段 —— 左轨里唯一的**动态位**:一域一枚,随数据域发布而生长。
*
* 这是「设计产生运营的形状」最直观的一处:导航本身就是被设计出来的。
*/
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>
))
)}
</>
);
}
function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) { function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) {
const P = { const P = {
// 设计中心 // 设计中心
+32
View File
@@ -458,3 +458,35 @@
.g-dbpage button:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); } .g-dbpage button:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); }
.g-dbpage button:disabled { opacity: .35; cursor: default; } .g-dbpage button:disabled { opacity: .35; cursor: default; }
.g-dbpage b { min-width: 36px; text-align: center; font-size: 11px; color: var(--ink-2); } .g-dbpage b { min-width: 36px; text-align: center; font-size: 11px; color: var(--ink-2); }
/* ═══════════ 三面补充(非原型)—— 蓝图因果链 / 终态堆叠条 / 数据域紧凑轨钮 ═══════════ */
/* 蓝图因果链:四库节点卡以真箭头相连 —— 箭头编码真实依赖(流程包→活动→工具→数据),节点即入口 */
.g-chain { display: flex; align-items: stretch; gap: 0; margin-bottom: 14px; }
.g-chain-node { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; background: var(--card); border: 1px solid var(--cline); border-radius: 13px; box-shadow: var(--cshadow); text-align: left; font: inherit; color: var(--ink); cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast); }
.g-chain-node:hover { border-color: var(--brand); transform: translateY(-1px); box-shadow: 0 4px 14px -6px rgba(51, 112, 255, .35); }
.g-chain-node:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.g-chain-k { display: flex; align-items: center; gap: 7px; font-size: 11px; font-weight: 600; color: var(--ink-3); }
.g-chain-k .ic { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; flex: none; background: var(--fill-2, #eef1f5); color: var(--ink-3); }
.g-chain-node.t-brand .ic { background: var(--brand-soft); color: var(--brand-deep); }
.g-chain-node.t-auto .ic { background: #e6f7f4; color: #0a6a64; }
.g-chain-node.t-collab .ic { background: #f0e9ff; color: #5b21cf; }
.g-chain-node.t-ok .ic { background: var(--ok-soft); color: var(--ok); }
.g-chain-v { font-size: 24px; font-weight: 800; font-family: var(--mono); letter-spacing: -.03em; line-height: 1; }
.g-chain-node.t-brand .g-chain-v { color: var(--brand-deep); }
.g-chain-node.t-auto .g-chain-v { color: #0a6a64; }
.g-chain-node.t-collab .g-chain-v { color: #5b21cf; }
.g-chain-node.t-ok .g-chain-v { color: var(--ok); }
.g-chain-h { font-size: 11px; line-height: 1.5; color: var(--ink-4); }
.g-chain-arr { flex: none; width: 26px; display: grid; place-items: center; color: var(--ink-4); }
@media (max-width: 1100px) {
.g-chain { flex-direction: column; }
.g-chain-arr { transform: rotate(90deg); width: auto; padding: 2px 0; }
}
/* 终态堆叠条:五终态五色一条,比例即信息 —— 装在 .g-kpi 卡里 */
.g-mixbar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--fill-2, #eef1f5); }
.g-mixbar i { display: block; min-width: 2px; }
.g-mixbar-legend { display: flex; flex-wrap: wrap; gap: 3px 10px; font-size: 10px; color: var(--ink-3); }
.g-mixbar-legend b { font-family: var(--mono); font-weight: 700; color: var(--ink-2); }
.g-mixbar-legend i { display: inline-block; width: 7px; height: 7px; border-radius: 2px; margin-right: 4px; }
@@ -9,7 +9,8 @@
* 这里**不出现任何运行态数字**(在跑几单、30 天起单数、死信数)。 * 这里**不出现任何运行态数字**(在跑几单、30 天起单数、死信数)。
* 设计者看的是定义完不完整,不是今天忙不忙 —— 混进运行数字,这页就退化成第二个驾驶舱。 * 设计者看的是定义完不完整,不是今天忙不忙 —— 混进运行数字,这页就退化成第二个驾驶舱。
*/ */
import { Clear, Entry, Kpi, Sec, Todo } from '@web-shared/gov/primitives.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { Clear, Sec, Todo } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { import {
useGovActivities, useGovActivities,
@@ -58,38 +59,67 @@ export function BlueprintHome({ nav }: HomeProps) {
const gaps = draftBad.length + missingActivities.length + uncalled.length; const gaps = draftBad.length + missingActivities.length + uncalled.length;
// 因果链那句话写在区说明里(ZM.blueprint.desc),此处不重复 —— 直接上四库读数 // 因果链是这一屏的主角:四库以真箭头相连 —— 箭头编码的是真实依赖
// (流程包引用活动、活动调工具、活动物化数据),节点卡即入口,点卡即入库。
return ( return (
<> <>
<div className="g-kpis"> <div className="g-chain">
<Kpi <button className="g-chain-node t-brand" onClick={() => nav.jump('workflows', 'pkgs')}>
ico="workflow" <span className="g-chain-k">
tone="brand" <span className="ic">
label="流程包 · 战略" <Ico name="workflow" s={13} />
value={packages.length} </span>
sub={`草稿 ${drafts.length} · 未闭合 ${draftBad.length}`} 战略 · 流程包
/> </span>
<Kpi <span className="g-chain-v">{packages.length}</span>
ico="boxes" <span className="g-chain-h">
tone="auto" 企业以哪些流转运转 —— 草稿 {drafts.length} · 未闭合 {draftBad.length}
label="活动 · 方法" </span>
value={activities.length} </button>
sub="一个活动 = 一个任务的定义" <span className="g-chain-arr">
/> <Ico name="chevron-right" s={15} />
<Kpi </span>
ico="wrench" <button className="g-chain-node t-auto" onClick={() => nav.jump('library', 'shelf')}>
tone="collab" <span className="g-chain-k">
label="工具 · 能力" <span className="ic">
value={toolRows.length} <Ico name="boxes" s={13} />
sub={`读类 ${toolRows.filter((t) => t.effect === 'read').length} · 写类 ${toolRows.filter((t) => t.effect === 'write').length}`} </span>
/> 方法 · 活动库
<Kpi </span>
ico="database" <span className="g-chain-v">{activities.length}</span>
tone="ok" <span className="g-chain-h">每件事怎么干 —— 一个活动 = 一个任务的定义</span>
label="数据域 · 对象" </button>
value={domains.length} <span className="g-chain-arr">
sub={`${objectRows.length} 个对象 · 发布即在运营中心长出台账位`} <Ico name="chevron-right" s={15} />
/> </span>
<button className="g-chain-node t-collab" onClick={() => nav.jump('tools', 'catalog')}>
<span className="g-chain-k">
<span className="ic">
<Ico name="wrench" s={13} />
</span>
能力 · 工具箱
</span>
<span className="g-chain-v">{toolRows.length}</span>
<span className="g-chain-h">
系统能对世界做什么 —— 读类 {toolRows.filter((t) => t.effect === 'read').length} · 写类{' '}
{toolRows.filter((t) => t.effect === 'write').length}
</span>
</button>
<span className="g-chain-arr">
<Ico name="chevron-right" s={15} />
</span>
<button className="g-chain-node t-ok" onClick={() => nav.jump('data', 'domains')}>
<span className="g-chain-k">
<span className="ic">
<Ico name="database" s={13} />
</span>
对象 · 数据域
</span>
<span className="g-chain-v">{domains.length}</span>
<span className="g-chain-h">
企业运营什么 —— {objectRows.length} 个对象,发布即入运营台账
</span>
</button>
</div> </div>
<Sec ico="flag" title="待补清单" extra="定义上的缺口聚在这里,点进即改 —— 不散落在四库"> <Sec ico="flag" title="待补清单" extra="定义上的缺口聚在这里,点进即改 —— 不散落在四库">
@@ -144,34 +174,6 @@ export function BlueprintHome({ nav }: HomeProps) {
)} )}
</Sec> </Sec>
<Sec ico="route" title="从这里进四库" extra="每库一页:定义在库里改,缺口在上面看">
<div className="g-entries">
<Entry
ico="workflow"
title="流程包 · 企业以哪些流转运转"
desc="五要素编排、版本谱系、边映射校验;调起规则也在本区"
onClick={() => nav.jump('workflows', 'pkgs')}
/>
<Entry
ico="boxes"
title="活动库 · 每件事怎么干"
desc="已安装活动的资产货架:契约面、范式、两条版本序列"
onClick={() => nav.jump('library', 'shelf')}
/>
<Entry
ico="wrench"
title="工具箱 · 系统能对世界做什么"
desc="读类过程中自由调用,写类只登记动作意向、放行后落地"
onClick={() => nav.jump('tools', 'catalog')}
/>
<Entry
ico="database"
title="数据域 · 企业运营什么"
desc="域与对象的结构定义;行数据在运营中心的台账看"
onClick={() => nav.jump('data', 'domains')}
/>
</div>
</Sec>
</> </>
); );
} }
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* feature `gov-data` 唯一出口(编码规范 §9.1)。 * feature `gov-data` 唯一出口(编码规范 §9.1)。
* 数据的两副面孔:结构定义在设计中心(数据域),行数据在运营中心(台账 · 某域)。 * 数据的两副面孔:结构定义在设计中心(数据域),行数据在运营中心(台账 · 某域页签)。
*/ */
export { DataHome, LedgersHome, ObjPage } from './internal/panel.tsx'; export { DataHome, LedgersHome, ObjPage } from './internal/panel.tsx';
@@ -2,7 +2,7 @@
* feature `gov-data` —— 数据的两副面孔,各归其台: * feature `gov-data` —— 数据的两副面孔,各归其台:
* *
* `DataHome`(设计中心 · 数据域):**定义面** —— 域 × 对象的结构:有哪些列、主键是什么、谁声明了它。 * `DataHome`(设计中心 · 数据域):**定义面** —— 域 × 对象的结构:有哪些列、主键是什么、谁声明了它。
* `LedgersHome`(运营中心 · 台账 · 某域):**运营面** —— 这张表现在有多少行、里面是什么。 * `LedgersHome`(运营中心 · 台账 · 某域页签):**运营面** —— 这张表现在有多少行、里面是什么。
* *
* 分开的理由:改 schema 和查行数据是两种心智。 * 分开的理由:改 schema 和查行数据是两种心智。
* 设计者问「这个对象定义全不全」,运营者问「今天这张表有几条」—— * 设计者问「这个对象定义全不全」,运营者问「今天这张表有几条」——
@@ -15,6 +15,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx'; import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts'; import { useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts';
import { domainShort, sortDomains } from '@web-shared/gov/domains.ts';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
@@ -27,7 +28,7 @@ export type HomeProps = { tab: string; nav: Nav };
export function DataHome({ nav }: HomeProps) { export function DataHome({ nav }: HomeProps) {
const q = useGovObjects(); const q = useGovObjects();
const objects = q.data?.objects ?? []; const objects = q.data?.objects ?? [];
const domains = [...new Set(objects.map((o) => o.domain))].sort(); const domains = sortDomains([...new Set(objects.map((o) => o.domain))]);
return ( return (
<QueryGate query={q}> <QueryGate query={q}>
@@ -40,8 +41,11 @@ export function DataHome({ nav }: HomeProps) {
<div key={d} className="g-sec"> <div key={d} className="g-sec">
<div className="g-band"> <div className="g-band">
<Ico name="database" s={13} /> <Ico name="database" s={13} />
{d} {domainShort(d)}
<span className="x">{objects.filter((o) => o.domain === d).length} 个对象 · 发布即在运营中心长出一位</span> <span className="g-mono" style={{ fontWeight: 500 }}>
{d}
</span>
<span className="x">{objects.filter((o) => o.domain === d).length} 个对象 · 发布即入运营台账</span>
</div> </div>
<NList> <NList>
{objects {objects
@@ -80,10 +84,10 @@ function ObjectSchemaRow({ domain, object, nav }: { domain: string; object: stri
); );
} }
/* ==================== 运营侧:台账(某一个域) ==================== */ /* ==================== 运营侧:台账(某一个域页签) ==================== */
/** /**
* 台账的一个域 —— 壳层把左轨选中的域名当页签传进来。 * 台账的一个域 —— 壳层把选中的域页签(键)传进来;没带域时壳层已落到首域。
* 这里看的是运营事实:这张表现在多少行,点进去逐行看。 * 这里看的是运营事实:这张表现在多少行,点进去逐行看。
*/ */
export function LedgersHome({ tab, nav }: HomeProps) { export function LedgersHome({ tab, nav }: HomeProps) {
@@ -95,8 +99,8 @@ export function LedgersHome({ tab, nav }: HomeProps) {
<QueryGate query={q}> <QueryGate query={q}>
<> <>
<Note> <Note>
<b>{domain}</b> 域的台账:行数据、主键与列结构。结构要改去设计中心 · 数据域; <b>{domainShort(domain)}</b>域(<span className="g-mono">{domain}</span>)的行数据、主键与列结构。
这里做的是运营动作 —— 查行、看版本、追引用。 结构要改去设计中心 · 数据域;这里做的是运营动作 —— 查行、看版本、追引用。
</Note> </Note>
{inDomain.length === 0 ? ( {inDomain.length === 0 ? (
<Clear>这个域还没有对象 —— 对象随活动首次写入而物化,建好就出现在这里。</Clear> <Clear>这个域还没有对象 —— 对象随活动首次写入而物化,建好就出现在这里。</Clear>
@@ -112,7 +116,7 @@ export function LedgersHome({ tab, nav }: HomeProps) {
); );
} }
/** 一个台账对象一行:行数是运营侧最想先看到的那个数 */ /** 一个数据对象一行:行数是运营侧最想先看到的那个数 */
function LedgerObjectRow({ domain, object, nav }: { domain: string; object: string; nav: Nav }) { function LedgerObjectRow({ domain, object, nav }: { domain: string; object: string; nav: Nav }) {
const page = useGovLedger(domain, object); const page = useGovLedger(domain, object);
const total = page.data?.total; const total = page.data?.total;
@@ -10,6 +10,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { TERMINAL_STATE_LABEL } from '@contracts'; import { TERMINAL_STATE_LABEL } from '@contracts';
import type { TerminalState } from '@contracts'; import type { TerminalState } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Chip, Clear, Kpi, NList, NRow, Note, PageHead, QueryGate, Todo } from '@web-shared/gov/primitives.tsx'; import { Chip, Clear, Kpi, NList, NRow, Note, PageHead, QueryGate, Todo } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { import {
@@ -25,6 +26,15 @@ export type HomeProps = { tab: string; nav: Nav };
const pct = (v: number | undefined) => Math.round((v ?? 0) * 100); const pct = (v: number | undefined) => Math.round((v ?? 0) * 100);
/** 终态五名的呈现色 —— 放行绿、驳回琥珀、停滞灰、耗尽红、终止墨,与状态章同族 */
const TERMINAL_TONE: Readonly<Record<string, string>> = {
released: 'var(--ok-accent)',
blocked: 'var(--warn-accent)',
stalled: '#8f959e',
exhausted: 'var(--danger-accent)',
terminated: '#41464e',
};
/* ---------------- 经营驾驶舱(运营首页) ---------------- */ /* ---------------- 经营驾驶舱(运营首页) ---------------- */
/** /**
@@ -119,18 +129,35 @@ export function CockpitHome({ nav }: HomeProps) {
unit="tok" unit="tok"
sub={`每放行一单:${(m?.unitCost.minutes ?? 0).toFixed(1)} 分钟 · 调人 ${(m?.unitCost.humanCalls ?? 0).toFixed(1)} 次`} sub={`每放行一单:${(m?.unitCost.minutes ?? 0).toFixed(1)} 分钟 · 调人 ${(m?.unitCost.humanCalls ?? 0).toFixed(1)} 次`}
/> />
<Kpi {/* 终态分布:比例即信息 —— 五终态五色一条,不把五个百分比塞进一行小字 */}
ico="flag" <div className="g-kpi">
label="终态分布" <span className="g-kpi-l">
value={mixTop.length === 0 ? '—' : `${pct(mixTop[0]![1])}%`} <Ico name="flag" s={12} />
sub={ 终态分布
mixTop.length === 0 </span>
? '还没有任务落终态' {mixTop.length === 0 ? (
: mixTop <>
.map(([k, v]) => `${TERMINAL_STATE_LABEL[k as TerminalState] ?? k} ${pct(v)}%`) <span className="g-kpi-v">—</span>
.join(' · ') <span className="g-kpi-s">还没有任务落终态</span>
} </>
/> ) : (
<>
<span className="g-mixbar">
{mixTop.map(([k, v]) => (
<i key={k} style={{ width: `${pct(v)}%`, background: TERMINAL_TONE[k] ?? '#c9d2dd' }} />
))}
</span>
<span className="g-mixbar-legend">
{mixTop.map(([k, v]) => (
<span key={k}>
<i style={{ background: TERMINAL_TONE[k] ?? '#c9d2dd' }} />
{TERMINAL_STATE_LABEL[k as TerminalState] ?? k} <b>{pct(v)}%</b>
</span>
))}
</span>
</>
)}
</div>
</div> </div>
<Note> <Note>
自主率与纠正率<b>成对读</b>,复合为有效自主率——单看自主率会奖励「猛冲后返工」。度量只是仪表,不载权力。 自主率与纠正率<b>成对读</b>,复合为有效自主率——单看自主率会奖励「猛冲后返工」。度量只是仪表,不载权力。
@@ -282,7 +282,7 @@ function TriggerForm({
)} )}
{draft.spec.kind === 'periodic-data' && ( {draft.spec.kind === 'periodic-data' && (
<> <>
<Field k="台账域 · 对象" hint="按这张表的日期字段逐行起单"> <Field k="数据域 · 对象" hint="按这张表的日期字段逐行起单">
<select <select
className="g-input" className="g-input"
value={`${draft.spec.domain}.${draft.spec.object}`} value={`${draft.spec.domain}.${draft.spec.object}`}
@@ -0,0 +1,30 @@
/**
* 数据域短名 —— 字典命中给人读名,字典外回落键尾段:轨上永远有个能认的名字。
*/
import { describe, expect, it } from 'vitest';
import { domainShort, sortDomains } from '../domains.ts';
describe('domainShort', () => {
it('已知域给 2-3 字人读名', () => {
expect(domainShort('quote_intake')).toBe('受理');
expect(domainShort('quote_sourcing')).toBe('寻源');
expect(domainShort('shared')).toBe('共享');
});
it('未知域回落到键的最后一段,超长截 4 字符', () => {
expect(domainShort('warehouse_ops')).toBe('ops');
expect(domainShort('some_verylongtail')).toBe('very');
expect(domainShort('solo')).toBe('solo');
});
});
describe('sortDomains —— 左轨按流程走向排', () => {
it('已知域按业务序,未知域殿后', () => {
expect(sortDomains(['shared', 'quote_pricing', 'zzz_custom', 'quote_intake'])).toEqual([
'quote_intake',
'quote_pricing',
'shared',
'zzz_custom',
]);
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* 数据域的人读短名 —— 左轨一枚钮只放得下 2-3 个字。
*
* 域键是活动的复合键(`quote_intake`),给人看要翻译;
* 字典之外的域回落到键的最后一段(截 4 字符)—— 新装的活动没配短名时,轨上先有个能认的名字。
*/
export const DOMAIN_SHORT: Readonly<Record<string, string>> = {
quote_intake: '受理',
quote_sourcing: '寻源',
quote_pricing: '定价',
quote_negotiate: '议价',
quote_alternative: '替代',
shared: '共享',
};
/** 业务序:受理 → 寻源 → 定价 → 议价 → 替代 → 共享 —— 左轨按流程走向排,不按字母 */
const DOMAIN_ORDER = ['quote_intake', 'quote_sourcing', 'quote_pricing', 'quote_negotiate', 'quote_alternative', 'shared'];
export function sortDomains(domains: readonly string[]): string[] {
return [...domains].sort((a, b) => {
const ia = DOMAIN_ORDER.indexOf(a);
const ib = DOMAIN_ORDER.indexOf(b);
// 字典外的域排在已知域之后,彼此按键序稳定
if (ia === -1 && ib === -1) return a.localeCompare(b);
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
}
export function domainShort(domain: string): string {
const hit = DOMAIN_SHORT[domain];
if (hit !== undefined) return hit;
const tail = domain.split('_').at(-1) ?? domain;
return tail.length <= 4 ? tail : tail.slice(0, 4);
}
+4 -4
View File
@@ -54,7 +54,7 @@ export const ZONES: ReadonlyArray<{ key: GovZone; label: string; icon: string }>
{ key: 'cockpit', label: '驾驶舱', icon: 'gauge' }, { key: 'cockpit', label: '驾驶舱', icon: 'gauge' },
{ key: 'tasks', label: '任务', icon: 'activity' }, { key: 'tasks', label: '任务', icon: 'activity' },
{ key: 'people', label: '员工', icon: 'users' }, { key: 'people', label: '员工', icon: 'users' },
{ key: 'ledgers', label: '台账', icon: 'database' }, { key: 'ledgers', label: '台账', icon: 'table' },
{ key: 'knowledge', label: '知识库', icon: 'layers' }, { key: 'knowledge', label: '知识库', icon: 'layers' },
{ key: 'system', label: '系统', icon: 'settings' }, { key: 'system', label: '系统', icon: 'settings' },
]; ];
@@ -75,9 +75,9 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
}, },
ledgers: { ledgers: {
title: '台账管理', title: '台账管理',
icon: 'database', icon: 'table',
accent: 'linear-gradient(140deg,#245bdb,#16307a)', accent: 'linear-gradient(140deg,#245bdb,#16307a)',
desc: '行数据 · 版本 · 引用——域的 schema 在设计中心改,这里做运营动作。导航按数据域动态生成:设计侧一发布新域,运营侧自动长出一位。', desc: '行数据 · 版本 · 引用——域的结构在设计中心改,这里做运营动作。域即页签,随数据域发布生长:设计侧一发布新域,台账里自动多一个域页签。',
}, },
knowledge: { knowledge: {
title: '知识库', title: '知识库',
@@ -157,7 +157,7 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
{ key: 'roles', label: '岗位授权', ico: 'key' }, { key: 'roles', label: '岗位授权', ico: 'key' },
{ key: 'groups', label: '工作组', ico: 'users' }, { key: 'groups', label: '工作组', ico: 'users' },
], ],
ledgers: [], ledgers: [], // 动态:页签即数据域(短名呈现),由壳层按已发布域生成
knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }], knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }],
system: [ system: [
{ key: 'llm', label: 'LLM 实例', ico: 'cpu' }, { key: 'llm', label: 'LLM 实例', ico: 'cpu' },
+10
View File
@@ -30,6 +30,16 @@ export function Ico({ name, s = 18 }: { name: string; s?: number }) {
bot: (<><path d="M12 8V4H8" /><rect width="16" height="12" x="4" y="8" rx="2" /><path d="M2 14h2" /><path d="M20 14h2" /><path d="M15 13v2" /><path d="M9 13v2" /></>), bot: (<><path d="M12 8V4H8" /><rect width="16" height="12" x="4" y="8" rx="2" /><path d="M2 14h2" /><path d="M20 14h2" /><path d="M15 13v2" /><path d="M9 13v2" /></>),
dashboard: (<><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></>), dashboard: (<><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></>),
hand: (<><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" /><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" /><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" /><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" /></>), hand: (<><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" /><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" /><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" /><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" /></>),
"arrow-left-right": (<><path d="M8 3 4 7l4 4" /><path d="M4 7h16" /><path d="m16 21 4-4-4-4" /><path d="M20 17H4" /></>),
"at-sign": (<><circle cx="12" cy="12" r="4" /><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" /></>),
columns: (<><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M12 3v18" /></>),
"help-circle": (<><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" /></>),
link: (<><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></>),
package: (<><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /><path d="M12 22V12" /><path d="m3.3 7 7.7 4.7a2 2 0 0 0 2 0L20.7 7" /><path d="m7.5 4.27 9 5.15" /></>),
pause: (<><rect x="14" y="4" width="4" height="16" rx="1" /><rect x="6" y="4" width="4" height="16" rx="1" /></>),
"scroll-text": (<><path d="M15 12h-5" /><path d="M15 8h-5" /><path d="M19 17V5a2 2 0 0 0-2-2H4" /><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" /></>),
table: (<><path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /></>),
tag: (<><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" /><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" /></>),
unplug: (<><path d="m19 5 3-3" /><path d="m2 22 3-3" /><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z" /><path d="M7.5 13.5 10 11" /><path d="M10.5 16.5 13 14" /><path d="m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z" /></>), unplug: (<><path d="m19 5 3-3" /><path d="m2 22 3-3" /><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z" /><path d="M7.5 13.5 10 11" /><path d="M10.5 16.5 13 14" /><path d="m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z" /></>),
coins: (<><circle cx="8" cy="8" r="6" /><path d="M18.09 10.37A6 6 0 1 1 10.34 18" /><path d="M7 6h1v4" /><path d="m16.71 13.88.7.71-2.82 2.82" /></>), coins: (<><circle cx="8" cy="8" r="6" /><path d="M18.09 10.37A6 6 0 1 1 10.34 18" /><path d="M7 6h1v4" /><path d="m16.71 13.88.7.71-2.82 2.82" /></>),
mail: (<><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></>), mail: (<><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></>),