UI②:数据对象全用中文名 —— label 随物化落 PG 注释,全链读回

此前 label 在建表那一步就丢了,治理台只剩 project__intake_record 这种键给人看。
用表/列注释而非另建元数据表:名字与表同生共死,不会对不上。
设计台/运营台/对象详情/触发器下拉一律中文名在前、键在后。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 02:33:09 +08:00
co-authored by Claude Fable 5
parent 188637cf26
commit ec0b78c550
12 changed files with 113 additions and 26 deletions
+4 -1
View File
@@ -72,7 +72,10 @@ export type ReleaseWriteResult = {
export type LedgerService = { export type LedgerService = {
applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>; applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>;
describe(address: ObjectAddress): Promise<MaterializedObject | undefined>; describe(address: ObjectAddress): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectAddress[]>; /** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
listObjects(): Promise<Array<ObjectAddress & {
label?: string | undefined;
}>>;
systemWrite(write: SystemWrite): Promise<void>; systemWrite(write: SystemWrite): Promise<void>;
draftWrite(write: DraftWrite): Promise<void>; draftWrite(write: DraftWrite): Promise<void>;
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>; releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
+4 -1
View File
@@ -25,7 +25,10 @@ export type ObjectRef = {
export type LedgerStore = { export type LedgerStore = {
/** 已物化对象的列与主键;未物化返回 undefined */ /** 已物化对象的列与主键;未物化返回 undefined */
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>; describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectRef[]>; /** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
listObjects(): Promise<Array<ObjectRef & {
label?: string | undefined;
}>>;
/** 执行「只增不破坏」的变更计划 */ /** 执行「只增不破坏」的变更计划 */
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>; applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
select(query: LedgerQuery): Promise<{ select(query: LedgerQuery): Promise<{
+4
View File
@@ -1626,10 +1626,12 @@ export declare const listToolsRoute: {
export declare const listLedgerObjectsRoute: { export declare const listLedgerObjectsRoute: {
readonly method: "GET"; readonly method: "GET";
readonly path: "/api/gov/data/objects"; readonly path: "/api/gov/data/objects";
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
readonly output: z.ZodObject<{ readonly output: z.ZodObject<{
objects: z.ZodArray<z.ZodObject<{ objects: z.ZodArray<z.ZodObject<{
domain: z.ZodString; domain: z.ZodString;
object: z.ZodString; object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>; }, z.core.$strip>;
}; };
@@ -1641,9 +1643,11 @@ export declare const browseLedgerRoute: {
object: z.ZodString; object: z.ZodString;
}, z.core.$strip>; }, z.core.$strip>;
readonly output: z.ZodObject<{ readonly output: z.ZodObject<{
label: z.ZodOptional<z.ZodString>;
columns: z.ZodArray<z.ZodObject<{ columns: z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
type: z.ZodString; type: z.ZodString;
label: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
primaryKey: z.ZodArray<z.ZodString>; primaryKey: z.ZodArray<z.ZodString>;
rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>; rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
+6 -1
View File
@@ -441,7 +441,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
// 记录浏览只读正式版 —— 跨任务只见过门的事实 // 记录浏览只读正式版 —— 跨任务只见过门的事实
const page = await container.ledger.query({ address, where: {}, orderBy: [], limit: 50, offset: 0 }); const page = await container.ledger.query({ address, where: {}, orderBy: [], limit: 50, offset: 0 });
return c.json({ return c.json({
columns: described.columns.map((col) => ({ name: col.name, type: col.type })), ...(described.label === undefined ? {} : { label: described.label }),
columns: described.columns.map((col) => ({
name: col.name,
type: col.type,
...(col.label === undefined ? {} : { label: col.label }),
})),
primaryKey: [...described.primaryKey], primaryKey: [...described.primaryKey],
rows: page.rows, rows: page.rows,
total: page.total, total: page.total,
+7
View File
@@ -35,3 +35,10 @@
标识符过 `IDENT` 正则 + `quoteIdent` 标识符过 `IDENT` 正则 + `quoteIdent`
影响半径: index/contract/port 未变 → 对外零影响 影响半径: index/contract/port 未变 → 对外零影响
近变(2026-07-28):**人读名落库** —— 物化时把声明里的 `label` 写成 PG 的表/列注释,
`describe` 与 `listObjects` 一并读回(`MaterializedObject.label` /
`MaterializedColumn.label` / `listObjects()` 出参加 `label?`)。
此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。
用注释而非另建元数据表:名字与表同生共死,不会对不上。
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
+2 -1
View File
@@ -91,7 +91,8 @@ export type ReleaseWriteResult = {
export type LedgerService = { export type LedgerService = {
applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>; applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>;
describe(address: ObjectAddress): Promise<MaterializedObject | undefined>; describe(address: ObjectAddress): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectAddress[]>; /** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
listObjects(): Promise<Array<ObjectAddress & { label?: string | undefined }>>;
systemWrite(write: SystemWrite): Promise<void>; systemWrite(write: SystemWrite): Promise<void>;
draftWrite(write: DraftWrite): Promise<void>; draftWrite(write: DraftWrite): Promise<void>;
@@ -63,13 +63,21 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
const store: LedgerStore = { const store: LedgerStore = {
async describe(ref) { async describe(ref) {
const table = tableNameOf(ref); const table = tableNameOf(ref);
const cols = await runner.query<{ column_name: string; data_type: string }>( // 列注释即人读名:与列定义同一次查询取回,不多跑一趟
`select column_name, data_type from information_schema.columns const cols = await runner.query<{ column_name: string; data_type: string; label: string | null }>(
where table_schema = $1 and table_name = $2 order by ordinal_position`, `select c.column_name, c.data_type,
col_description(($1 || '.' || quote_ident($2))::regclass, c.ordinal_position) as label
from information_schema.columns c
where c.table_schema = $1 and c.table_name = $2 order by c.ordinal_position`,
[LEDGER_SCHEMA, table], [LEDGER_SCHEMA, table],
); );
if (cols.rows.length === 0) return undefined; if (cols.rows.length === 0) return undefined;
const tableLabel = await runner.query<{ label: string | null }>(
`select obj_description(($1 || '.' || quote_ident($2))::regclass) as label`,
[LEDGER_SCHEMA, table],
);
const pk = await runner.query<{ attname: string }>( const pk = await runner.query<{ attname: string }>(
`select a.attname from pg_index i `select a.attname from pg_index i
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey) join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
@@ -77,30 +85,53 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
[`${LEDGER_SCHEMA}.${quoteIdent(table)}`], [`${LEDGER_SCHEMA}.${quoteIdent(table)}`],
); );
const label = tableLabel.rows[0]?.label ?? undefined;
const materialized: MaterializedObject = { const materialized: MaterializedObject = {
domain: ref.domain, domain: ref.domain,
object: ref.object, object: ref.object,
columns: cols.rows.map((c) => ({ name: c.column_name, type: FROM_PG[c.data_type] ?? 'text' })), ...(label === null || label === undefined ? {} : { label }),
columns: cols.rows.map((c) => ({
name: c.column_name,
type: FROM_PG[c.data_type] ?? 'text',
...(c.label === null ? {} : { label: c.label }),
})),
primaryKey: pk.rows.map((p) => p.attname), primaryKey: pk.rows.map((p) => p.attname),
}; };
return materialized; return materialized;
}, },
async listObjects() { async listObjects() {
const res = await runner.query<{ table_name: string }>( // 表注释即人读名 —— 与表名同一次查询取回,治理台不必逐个 describe 才知道叫什么
`select table_name from information_schema.tables const res = await runner.query<{ table_name: string; label: string | null }>(
where table_schema = $1 and table_name not like '\\_\\_%' order by table_name`, `select t.table_name,
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label
from information_schema.tables t
where t.table_schema = $1 and t.table_name not like '\\_\\_%' order by t.table_name`,
[LEDGER_SCHEMA], [LEDGER_SCHEMA],
); );
return res.rows.flatMap((r) => { return res.rows.flatMap((r) => {
const idx = r.table_name.indexOf('__'); const idx = r.table_name.indexOf('__');
if (idx <= 0) return []; if (idx <= 0) return [];
return [{ domain: r.table_name.slice(0, idx), object: r.table_name.slice(idx + 2) }]; return [
{
domain: r.table_name.slice(0, idx),
object: r.table_name.slice(idx + 2),
...(r.label === null ? {} : { label: r.label }),
},
];
}); });
}, },
async applyChanges(ref, changes) { async applyChanges(ref, changes) {
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`; const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
/*
* 注释即人读名;没有 label 就不写,读回来是 null,呈现自然退回键名。
* `comment on` 是**工具语句**,不吃 $1 占位符 —— 只能拼字面量,故自己转义单引号。
*/
const comment = async (target: string, label: string | undefined) => {
if (label === undefined || label.trim() === '') return;
await runner.query(`comment on ${target} is '${label.replaceAll("'", "''")}'`);
};
for (const change of changes) { for (const change of changes) {
switch (change.kind) { switch (change.kind) {
case 'create-table': { case 'create-table': {
@@ -110,12 +141,22 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})` ? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
: ''; : '';
await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`); await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`);
/*
* 人读名落**表/列注释** —— 声明里带的 label 不能在建表这一步丢掉:
* 丢了,治理台就只剩 `project__intake_record` 这种键给人看。
* 用注释而不是另建元数据表:名字与表同生共死,不会对不上。
*/
await comment(`table ${table}`, change.object.label);
for (const f of change.object.fields) {
await comment(`column ${table}.${quoteIdent(f.name)}`, f.label);
}
break; break;
} }
case 'add-column': case 'add-column':
await runner.query( await runner.query(
`alter table ${table} add column if not exists ${quoteIdent(change.field.name)} ${PG_TYPE[change.field.type]}`, `alter table ${table} add column if not exists ${quoteIdent(change.field.name)} ${PG_TYPE[change.field.type]}`,
); );
await comment(`column ${table}.${quoteIdent(change.field.name)}`, change.field.label);
break; break;
case 'widen-column': case 'widen-column':
await runner.query( await runner.query(
@@ -185,11 +185,15 @@ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): M
export type MaterializedColumn = { export type MaterializedColumn = {
readonly name: string; readonly name: string;
readonly type: LedgerFieldType; readonly type: LedgerFieldType;
/** 人读名 —— 落在 PG 的列注释上,建表/加列时随声明写下 */
readonly label?: string | undefined;
}; };
export type MaterializedObject = { export type MaterializedObject = {
readonly domain: string; readonly domain: string;
readonly object: string; readonly object: string;
/** 人读名 —— 落在 PG 的表注释上;没有就退回对象键 */
readonly label?: string | undefined;
readonly columns: readonly MaterializedColumn[]; readonly columns: readonly MaterializedColumn[];
readonly primaryKey: readonly string[]; readonly primaryKey: readonly string[];
}; };
+2 -1
View File
@@ -20,7 +20,8 @@ export type ObjectRef = { readonly domain: string; readonly object: string };
export type LedgerStore = { export type LedgerStore = {
/** 已物化对象的列与主键;未物化返回 undefined */ /** 已物化对象的列与主键;未物化返回 undefined */
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>; describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectRef[]>; /** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
listObjects(): Promise<Array<ObjectRef & { label?: string | undefined }>>;
/** 执行「只增不破坏」的变更计划 */ /** 执行「只增不破坏」的变更计划 */
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>; applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
@@ -55,8 +55,9 @@ export function DataHome({ nav }: HomeProps) {
<Entry <Entry
key={o.object} key={o.object}
ico="table" ico="table"
title={o.object} // 人读名优先;没登记 label 才退回对象键 —— 治理台不该拿 `intake_record` 给人看
desc="点开看列结构与主键;行数据在运营中心" title={o.label ?? o.object}
desc={`${o.domain}.${o.object}`}
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })} onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
/> />
))} ))}
@@ -95,7 +96,13 @@ export function LedgersHome({ tab, nav }: HomeProps) {
) : ( ) : (
<NList> <NList>
{inDomain.map((o) => ( {inDomain.map((o) => (
<LedgerObjectRow key={`${o.domain}.${o.object}`} domain={o.domain} object={o.object} nav={nav} /> <LedgerObjectRow
key={`${o.domain}.${o.object}`}
domain={o.domain}
object={o.object}
label={o.label}
nav={nav}
/>
))} ))}
</NList> </NList>
)} )}
@@ -105,14 +112,14 @@ export function LedgersHome({ tab, nav }: HomeProps) {
} }
/** 一个数据对象一行:行数是运营侧最想先看到的那个数 */ /** 一个数据对象一行:行数是运营侧最想先看到的那个数 */
function LedgerObjectRow({ domain, object, nav }: { domain: string; object: string; nav: Nav }) { function LedgerObjectRow({ domain, object, label, nav }: { domain: string; object: string; label?: string | undefined; nav: Nav }) {
const page = useGovLedger(domain, object); const page = useGovLedger(domain, object);
const total = page.data?.total; const total = page.data?.total;
return ( return (
<NRow <NRow
tone={total !== undefined && total > 0 ? 'brand' : 'mute'} tone={total !== undefined && total > 0 ? 'brand' : 'mute'}
ico="table" ico="table"
title={object} title={label ?? page.data?.label ?? object}
sub={`${domain}.${object}`} sub={`${domain}.${object}`}
cols={[ cols={[
{ k: '行数', v: total === undefined ? '…' : String(total) }, { k: '行数', v: total === undefined ? '…' : String(total) },
@@ -137,8 +144,8 @@ export function ObjPage({ id }: { id: string }) {
<> <>
<PageHead <PageHead
ico="table" ico="table"
kicker={`数据对象 · ${domain}`} kicker={`${domainShort(domain ?? '')}域 · ${domain}.${object}`}
title={object ?? ''} title={page.data?.label ?? object ?? ''}
chips={ chips={
<> <>
<Chip ico="columns">{columns.length} 列</Chip> <Chip ico="columns">{columns.length} 列</Chip>
@@ -152,8 +159,11 @@ export function ObjPage({ id }: { id: string }) {
<div className="g-rows"> <div className="g-rows">
{columns.map((c) => ( {columns.map((c) => (
<div key={c.name}> <div key={c.name}>
<em>{c.name}</em> {/* 人读名在前、字段键在后:填表的人看名字,写映射的人看键 */}
<b className="mono">{c.type}</b> <em>{c.label ?? c.name}</em>
<b className="mono">
{c.name} · {c.type}
</b>
</div> </div>
))} ))}
</div> </div>
@@ -167,7 +177,9 @@ export function ObjPage({ id }: { id: string }) {
<thead> <thead>
<tr> <tr>
{columns.map((c) => ( {columns.map((c) => (
<th key={c.name}>{c.name}</th> <th key={c.name} title={`${c.name} · ${c.type}`}>
{c.label ?? c.name}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
@@ -41,6 +41,7 @@ import {
} from '@web-shared/gov/primitives.tsx'; } 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 { useGovObjects, useGovPackages, useGovTriggers } from '@web-shared/gov/gov-api.ts'; import { useGovObjects, useGovPackages, useGovTriggers } from '@web-shared/gov/gov-api.ts';
import { domainShort } from '@web-shared/gov/domains.ts';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
@@ -294,7 +295,8 @@ function TriggerForm({
> >
{(objects.data?.objects ?? []).map((o) => ( {(objects.data?.objects ?? []).map((o) => (
<option key={`${o.domain}.${o.object}`} value={`${o.domain}.${o.object}`}> <option key={`${o.domain}.${o.object}`} value={`${o.domain}.${o.object}`}>
{o.domain}.{o.object} {/* 选表的人认中文名,键跟在后面供对映射 */}
{domainShort(o.domain)}·{o.label ?? o.object}({o.domain}.{o.object})
</option> </option>
))} ))}
</select> </select>
+6 -2
View File
@@ -361,7 +361,10 @@ export const listToolsRoute = {
export const listLedgerObjectsRoute = { export const listLedgerObjectsRoute = {
method: 'GET', method: 'GET',
path: '/api/gov/data/objects', path: '/api/gov/data/objects',
output: z.object({ objects: z.array(z.object({ domain: z.string(), object: z.string() })) }), /** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
output: z.object({
objects: z.array(z.object({ domain: z.string(), object: z.string(), label: z.string().optional() })),
}),
} as const satisfies RouteContract; } as const satisfies RouteContract;
export const browseLedgerRoute = { export const browseLedgerRoute = {
@@ -369,7 +372,8 @@ export const browseLedgerRoute = {
path: '/api/gov/data/:domain/:object', path: '/api/gov/data/:domain/:object',
params: z.object({ domain: z.string(), object: z.string() }), params: z.object({ domain: z.string(), object: z.string() }),
output: z.object({ output: z.object({
columns: z.array(z.object({ name: z.string(), type: z.string() })), label: z.string().optional(),
columns: z.array(z.object({ name: z.string(), type: z.string(), label: z.string().optional() })),
primaryKey: z.array(z.string()), primaryKey: z.array(z.string()),
rows: z.array(z.record(z.string(), z.unknown())), rows: z.array(z.record(z.string(), z.unknown())),
total: z.number().int(), total: z.number().int(),