接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me

This commit is contained in:
shanqin
2026-08-26 13:29:36 +08:00
parent f3397a60ef
commit f01286a89d
40 changed files with 2502 additions and 480 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
* feature `gov-data` 唯一出口(编码规范 §9.1)。
* 数据的两副面孔:结构定义在设计中心(数据域),行数据在运营中心(台账 · 某域页签)。
*/
export { DataHome, LedgersHome, ObjPage } from './internal/panel.tsx';
export { DataHome, LedgersHome, ObjPage, RowPage } from './internal/panel.tsx';
+200 -57
View File
@@ -11,7 +11,7 @@
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
*/
import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
import { Chip, Clear, Kpi, Kpis, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { Ico } from '@web-shared/Ico.tsx';
import { useGovDataByActivity, useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts';
@@ -23,9 +23,20 @@ export type HomeProps = { tab: string; nav: Nav };
* 记录浏览的格子文本 —— JSON 列(决策链地图 / 互动时间线)从前被 String() 成
* `[object Object]`(2026-08-24 用户报):对象与数组原样 stringify 摆出来,
* 超长截断进 title(截了才加省略号,悬停看全文)。这是取值,不是判断。
* 空值(null/undefined)摆 `-`(2026-08-24 用户定)—— 空格会让人疑心是没加载出来。
* ISO 时间串(如信号日期 `2026-05-31T16:00:00.000Z`)格式化成 `YYYY-MM-DD HH:MM:SS`
* 本地时间(2026-08-24 用户定)—— ISO 原串是机器的写法,不是给人扫的。
*/
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
const fmtDateTime = (iso: string): string => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const cellText = (v: unknown): string => {
if (v === null || v === undefined) return '';
if (v === null || v === undefined) return '-';
if (typeof v === 'string' && ISO_RE.test(v)) return fmtDateTime(v);
if (typeof v === 'object') {
try {
return JSON.stringify(v);
@@ -77,23 +88,44 @@ const atMinute = (iso: string): string => {
return Number.isNaN(d.getTime()) ? '' : `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
/** 数字列(右对齐 + 等宽)—— 依据 schema 声明的列类型,不是按值猜 */
const NUM_TYPES: ReadonlySet<string> = new Set(['number', 'integer', 'real', 'numeric', 'decimal', 'float', 'bigint']);
const isNumCol = (type: string): boolean => NUM_TYPES.has(type.toLowerCase());
/** 类型徽分色:数字蓝 / json 紫 / 其余灰(语义色只进徽标,§7) */
const tyTone = (t: string): string => (isNumCol(t) ? ' k-num' : t.toLowerCase() === 'json' ? ' k-json' : '');
export function DataHome({ nav }: HomeProps) {
const q = useGovDataByActivity();
const activities = q.data?.activities ?? [];
const unclaimed = q.data?.unclaimed ?? [];
// 一张表都没声明的活动不占地方 —— 那多半是还没写到台账的槽位
const withTables = activities.filter((a) => a.tables.length > 0);
// 页顶汇总带:四个数全是下面清单的合计,纯呈现,不另取数
const tableCount = withTables.reduce((n, a) => n + a.tables.length, 0);
const formalRows = withTables.reduce((n, a) => n + a.tables.reduce((m, t) => m + (t.rows ?? 0), 0), 0);
const draftRows = withTables.reduce((n, a) => n + a.tables.reduce((m, t) => m + t.drafts, 0), 0);
return (
<QueryGate query={q}>
<>
<div className="g-data">
<Kpis>
<Kpi ico="layers" label="声明活动" value={withTables.length} unit="个"
hint="库里有声明台账表的在装活动数 —— 一张表都没声明的活动不占这个数。" />
<Kpi ico="database" label="声明表" value={tableCount} unit="张"
hint="所有在装活动声明的台账表合计。库里存在、但没有活动认领的表(无人声明)不计入。" />
<Kpi ico="hash" label="正式行" value={formalRows} unit="行"
hint="各声明表里已过放行门、真正生效的行数合计。声明了但一次没写过(未物化)的表按 0 计。" />
<Kpi ico="activity" label="在办行" value={draftRows} unit="行" {...(draftRows > 0 ? { tone: 'brand' } : {})}
hint="还躺在各单草稿层、尚未放行的行数合计 —— 单子放行后,这些行才并入正式行。" />
</Kpis>
{withTables.map((a) => {
const formal = a.tables.reduce((n, t) => n + (t.rows ?? 0), 0);
const drafting = a.tables.reduce((n, t) => n + t.drafts, 0);
return (
<section key={a.activityId} className="g-actdata">
<div className="g-actdata-h">
<Ico name="list-checks" s={15} />
<span className="g-actdata-i">
<Ico name="list-checks" s={14} />
</span>
<b>{a.name}</b>
<span className="g-mono">
{a.activityId}
@@ -119,7 +151,10 @@ export function DataHome({ nav }: HomeProps) {
<b>{t.label ?? t.object}</b>
{badge !== undefined && <i className={'g-tbl-k t-' + badge.tone}>{badge.label}</i>}
</span>
<span className="g-tbl-a g-mono">
<span
className="g-tbl-a g-mono"
title={`${t.domain}.${t.object}${t.primaryKey.length > 0 ? ` · 主键 ${t.primaryKey.join(' + ')}` : ''}`}
>
{t.domain}.{t.object}
{t.primaryKey.length > 0 && <em> · 主键 {t.primaryKey.join(' + ')}</em>}
</span>
@@ -134,6 +169,9 @@ export function DataHome({ nav }: HomeProps) {
)}
{t.drafts > 0 && <i className="draft">在办 {t.drafts} 行</i>}
</span>
<span className="g-tbl-x">
<Ico name="chevron-right" s={14} />
</span>
</button>
);
})}
@@ -157,39 +195,12 @@ export function DataHome({ nav }: HomeProps) {
);
})}
{unclaimed.length > 0 && (
<section className="g-actdata t-u">
<div className="g-actdata-h">
<Ico name="help-circle" s={15} />
<b>无人声明</b>
<span className="n">{unclaimed.length} 张表</span>
<p>库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。</p>
</div>
<div className="g-actdata-tables">
{unclaimed.map((o) => (
<button
key={`${o.domain}.${o.object}`}
type="button"
className="g-tbl"
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
>
<span className="g-tbl-t">
<b>{o.label ?? o.object}</b>
</span>
<span className="g-tbl-a g-mono">
{o.domain}.{o.object}
</span>
<span className="g-tbl-n">{o.rows === undefined ? <em className="none">读不到</em> : <><b>{o.rows}</b> 行</>}</span>
</button>
))}
</div>
</section>
)}
{/* 「无人声明」的壳表不在此呈现(2026-08-24 用户定:隐藏) —— 数据仍在 unclaimed 里,要看时把这段渲染加回来 */}
{withTables.length === 0 && unclaimed.length === 0 && (
{withTables.length === 0 && (
<Clear>还没有活动声明台账 —— 装一个活动包,它声明的表就出现在这里;第一次写入时表才真建出来。</Clear>
)}
</>
</div>
</QueryGate>
);
}
@@ -250,15 +261,19 @@ function LedgerObjectRow({ domain, object, label, nav }: { domain: string; objec
/* ==================== 对象详情(两面共用) ==================== */
export function ObjPage({ id }: { id: string }) {
export function ObjPage({ id, nav }: { id: string; nav: Nav }) {
const [domain, object] = id.split('.');
const objects = useGovObjects();
const page = useGovLedger(domain, object);
const known = (objects.data?.objects ?? []).some((o) => o.domain === domain && o.object === object);
if (!known) return <Note>对象未物化。</Note>;
const columns = page.data?.columns ?? [];
const pkSet = new Set(page.data?.primaryKey ?? []);
/* 浏览表只留 6-8 条重要数据(2026-08-25 用户定):主键列永远在最前 —— 它是「这一行是谁」;
其余按声明顺序补足,至多 8 列,全量字段进三级页看 */
const showCols = [...columns.filter((c) => pkSet.has(c.name)), ...columns.filter((c) => !pkSet.has(c.name))].slice(0, 8);
return (
<>
<div className="g-obj">
<PageHead
ico="table"
kicker={`${domainShort(domain ?? '')}域 · ${domain}.${object}`}
@@ -272,20 +287,14 @@ export function ObjPage({ id }: { id: string }) {
}
desc="声明物化:最大化合并、只增不破坏——不删列、不改窄、不换主键。此处只读正式版,跨任务只见过门的事实。"
/>
<Card ico="columns" title="模式">
<div className="g-rows">
{columns.map((c) => (
<div key={c.name}>
{/* 人读名在前、字段键在后:填表的人看名字,写映射的人看键 */}
<em>{c.label ?? c.name}</em>
<b className="mono">
{c.name} · {c.type}
</b>
</div>
))}
</div>
</Card>
<Sec ico="table" title="记录浏览" extra={`前 ${(page.data?.rows ?? []).length} 行`}>
<Sec
ico="table"
title="记录浏览"
/* 接口只回前 50 行:截断时写明「前 N / 共 M」,全量就在这页时不虚报「前」(2026-08-24 用户定) */
extra={(page.data?.total ?? 0) > (page.data?.rows ?? []).length
? `前 ${(page.data?.rows ?? []).length} 行 · 共 ${page.data?.total ?? 0} 行`
: `共 ${(page.data?.rows ?? []).length} 行`}
>
{(page.data?.rows ?? []).length === 0 ? (
<Clear>这张表还没有记录 —— 任务放行后,过门的事实才落进来。</Clear>
) : (
@@ -293,25 +302,40 @@ export function ObjPage({ id }: { id: string }) {
<table className="g-dbtable">
<thead>
<tr>
{columns.map((c) => (
<th key={c.name} title={`${c.name} · ${c.type}`}>
{showCols.map((c) => (
<th key={c.name} className={isNumCol(c.type) ? 'num' : undefined} title={`${c.name} · ${c.type}`}>
{/* 主键列名前挂一枚小钥匙 —— 全表只有它能当「这一行是谁」 */}
{pkSet.has(c.name) && <Ico name="key" s={10} />}
{c.label ?? c.name}
</th>
))}
{/* 操作列钉在最右(2026-08-25 用户定):整行数据的入口,三级页看全量 */}
<th className="op">操作</th>
</tr>
</thead>
<tbody>
{(page.data?.rows ?? []).map((row, i) => (
<tr key={i}>
{columns.map((c) => {
{showCols.map((c) => {
const empty = row[c.name] === null || row[c.name] === undefined;
const full = cellText(row[c.name]);
const long = full.length > 120;
return (
<td key={c.name} className="g-mono" title={long ? full : undefined}>
<td
key={c.name}
className={(isNumCol(c.type) ? 'g-mono num' : 'g-mono') + (empty ? ' empty' : '')}
title={long ? full : undefined}
>
{long ? `${full.slice(0, 120)}…` : full}
</td>
);
})}
<td className="op">
<button type="button" className="g-rowbtn" onClick={() => nav.push({ k: 'row', id: `${id}.${i}` })}>
查看详情
<Ico name="chevron-right" s={12} />
</button>
</td>
</tr>
))}
</tbody>
@@ -319,6 +343,125 @@ export function ObjPage({ id }: { id: string }) {
</div>
)}
</Sec>
</>
<Sec ico="columns" title="模式">
{/* 转置矩阵(2026-08-24 用户定):行 = 参数名/字段/数据类型,列 = 每个字段;
一列就是一列的完整定义,横着扫就是整表的结构 */}
<div className="g-sx">
<table>
<tbody>
<tr>
<th>参数名</th>
{columns.map((c) => (
<td key={c.name} className="nm">
{c.label ?? c.name}
</td>
))}
</tr>
<tr>
<th>字段</th>
{columns.map((c) => (
<td key={c.name} className="g-mono key">
{c.name}
</td>
))}
</tr>
<tr>
<th>数据类型</th>
{columns.map((c) => (
<td key={c.name}>
<i className={'ty' + tyTone(c.type)}>{c.type}</i>
</td>
))}
</tr>
</tbody>
</table>
</div>
</Sec>
</div>
);
}
/* ==================== 记录详情(三级页) ==================== */
/**
* 一行记录的全量视图(2026-08-25 用户定):浏览表只摆 8 列要点,
* 这页把**所有字段**摊开 —— 一字段一行:参数名 / 字段 / 数据类型 / 完整值。
* 值不截断、可折行(JSON 整串摆全)—— 详情页的天职就是「看全」。
*
* 没有单行接口:复用台账浏览(`useGovLedger`)的同一份查询缓存按下标取行,
* 从浏览页点进来时零额外请求;深链/刷新时重新拉第一页,下标依旧对得上。
*/
export function RowPage({ id }: { id: string }) {
const at = id.lastIndexOf('.');
const objId = at > 0 ? id.slice(0, at) : id;
const rowIndex = at > 0 ? Number(id.slice(at + 1)) : Number.NaN;
const [domain, object] = objId.split('.');
const objects = useGovObjects();
const page = useGovLedger(domain, object);
const known = (objects.data?.objects ?? []).some((o) => o.domain === domain && o.object === object);
if (!known) return <Note>对象未物化。</Note>;
const columns = page.data?.columns ?? [];
const pkSet = new Set(page.data?.primaryKey ?? []);
const rows = page.data?.rows ?? [];
const row = Number.isInteger(rowIndex) ? rows[rowIndex] : undefined;
if (page.data !== undefined && row === undefined) {
return <Note tone="warn">这条记录不在当前页里 —— 数据可能已变动,返回「记录浏览」重新进入。</Note>;
}
/** 标题优先摆主键值 —— 那是「这一行是谁」;没有主键就摆行号 */
const pkText =
row === undefined || pkSet.size === 0
? ''
: [...pkSet]
.map((k) => cellText(row[k]))
.filter((s) => s !== '-')
.join(' · ');
return (
<div className="g-obj g-rowpage">
<PageHead
ico="table"
kicker={`${domainShort(domain ?? '')}域 · ${domain}.${object} · ${page.data?.label ?? object ?? ''}`}
title={pkText !== '' ? pkText : `第 ${rowIndex + 1} 行记录`}
chips={
<>
<Chip ico="hash">第 {rowIndex + 1} 行</Chip>
<Chip ico="columns">{columns.length} 列</Chip>
<Chip ico="key">主键 {(page.data?.primaryKey ?? []).join(', ') || '—'}</Chip>
</>
}
desc="这条记录的全部字段 —— 值完整呈现、不加截断;结构口径以「模式」为准,此处只读正式版。"
/>
<Sec ico="list" title="字段明细" extra={`共 ${columns.length} 列`}>
<div className="g-dbwrap">
<table className="g-dbtable g-fdt">
<thead>
<tr>
<th>参数名</th>
<th>字段</th>
<th>数据类型</th>
<th>值</th>
</tr>
</thead>
<tbody>
{columns.map((c) => {
const empty = row === undefined || row[c.name] === null || row[c.name] === undefined;
return (
<tr key={c.name}>
<td className="nm">
{pkSet.has(c.name) && <Ico name="key" s={10} />}
{c.label ?? c.name}
</td>
<td className="g-mono key">{c.name}</td>
<td>
<i className={'ty' + tyTone(c.type)}>{c.type}</i>
</td>
<td className={'g-mono val' + (empty ? ' empty' : '')}>{row === undefined ? '' : cellText(row[c.name])}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Sec>
</div>
);
}