Archived
接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user