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:
+4
-1
@@ -72,7 +72,10 @@ export type ReleaseWriteResult = {
|
||||
export type LedgerService = {
|
||||
applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>;
|
||||
describe(address: ObjectAddress): Promise<MaterializedObject | undefined>;
|
||||
listObjects(): Promise<ObjectAddress[]>;
|
||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||
listObjects(): Promise<Array<ObjectAddress & {
|
||||
label?: string | undefined;
|
||||
}>>;
|
||||
systemWrite(write: SystemWrite): Promise<void>;
|
||||
draftWrite(write: DraftWrite): Promise<void>;
|
||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
||||
|
||||
+4
-1
@@ -25,7 +25,10 @@ export type ObjectRef = {
|
||||
export type LedgerStore = {
|
||||
/** 已物化对象的列与主键;未物化返回 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>;
|
||||
select(query: LedgerQuery): Promise<{
|
||||
|
||||
@@ -1626,10 +1626,12 @@ export declare const listToolsRoute: {
|
||||
export declare const listLedgerObjectsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/data/objects";
|
||||
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
|
||||
readonly output: z.ZodObject<{
|
||||
objects: z.ZodArray<z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
@@ -1641,9 +1643,11 @@ export declare const browseLedgerRoute: {
|
||||
object: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
columns: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
type: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
primaryKey: z.ZodArray<z.ZodString>;
|
||||
rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
|
||||
@@ -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 });
|
||||
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],
|
||||
rows: page.rows,
|
||||
total: page.total,
|
||||
|
||||
@@ -35,3 +35,10 @@
|
||||
标识符过 `IDENT` 正则 + `quoteIdent`
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
|
||||
近变(2026-07-28):**人读名落库** —— 物化时把声明里的 `label` 写成 PG 的表/列注释,
|
||||
`describe` 与 `listObjects` 一并读回(`MaterializedObject.label` /
|
||||
`MaterializedColumn.label` / `listObjects()` 出参加 `label?`)。
|
||||
此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。
|
||||
用注释而非另建元数据表:名字与表同生共死,不会对不上。
|
||||
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
|
||||
|
||||
@@ -91,7 +91,8 @@ export type ReleaseWriteResult = {
|
||||
export type LedgerService = {
|
||||
applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>;
|
||||
describe(address: ObjectAddress): Promise<MaterializedObject | undefined>;
|
||||
listObjects(): Promise<ObjectAddress[]>;
|
||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||
listObjects(): Promise<Array<ObjectAddress & { label?: string | undefined }>>;
|
||||
|
||||
systemWrite(write: SystemWrite): Promise<void>;
|
||||
draftWrite(write: DraftWrite): Promise<void>;
|
||||
|
||||
@@ -63,13 +63,21 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
const store: LedgerStore = {
|
||||
async describe(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
|
||||
where table_schema = $1 and table_name = $2 order by ordinal_position`,
|
||||
// 列注释即人读名:与列定义同一次查询取回,不多跑一趟
|
||||
const cols = await runner.query<{ column_name: string; data_type: string; label: string | null }>(
|
||||
`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],
|
||||
);
|
||||
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 }>(
|
||||
`select a.attname from pg_index i
|
||||
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)}`],
|
||||
);
|
||||
|
||||
const label = tableLabel.rows[0]?.label ?? undefined;
|
||||
const materialized: MaterializedObject = {
|
||||
domain: ref.domain,
|
||||
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),
|
||||
};
|
||||
return materialized;
|
||||
},
|
||||
|
||||
async listObjects() {
|
||||
const res = await runner.query<{ table_name: string }>(
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = $1 and table_name not like '\\_\\_%' order by table_name`,
|
||||
// 表注释即人读名 —— 与表名同一次查询取回,治理台不必逐个 describe 才知道叫什么
|
||||
const res = await runner.query<{ table_name: string; label: string | null }>(
|
||||
`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],
|
||||
);
|
||||
return res.rows.flatMap((r) => {
|
||||
const idx = r.table_name.indexOf('__');
|
||||
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) {
|
||||
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) {
|
||||
switch (change.kind) {
|
||||
case 'create-table': {
|
||||
@@ -110,12 +141,22 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
|
||||
: '';
|
||||
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;
|
||||
}
|
||||
case 'add-column':
|
||||
await runner.query(
|
||||
`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;
|
||||
case 'widen-column':
|
||||
await runner.query(
|
||||
|
||||
@@ -185,11 +185,15 @@ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): M
|
||||
export type MaterializedColumn = {
|
||||
readonly name: string;
|
||||
readonly type: LedgerFieldType;
|
||||
/** 人读名 —— 落在 PG 的列注释上,建表/加列时随声明写下 */
|
||||
readonly label?: string | undefined;
|
||||
};
|
||||
|
||||
export type MaterializedObject = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
/** 人读名 —— 落在 PG 的表注释上;没有就退回对象键 */
|
||||
readonly label?: string | undefined;
|
||||
readonly columns: readonly MaterializedColumn[];
|
||||
readonly primaryKey: readonly string[];
|
||||
};
|
||||
|
||||
@@ -20,7 +20,8 @@ export type ObjectRef = { readonly domain: string; readonly object: string };
|
||||
export type LedgerStore = {
|
||||
/** 已物化对象的列与主键;未物化返回 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>;
|
||||
|
||||
|
||||
@@ -55,8 +55,9 @@ export function DataHome({ nav }: HomeProps) {
|
||||
<Entry
|
||||
key={o.object}
|
||||
ico="table"
|
||||
title={o.object}
|
||||
desc="点开看列结构与主键;行数据在运营中心"
|
||||
// 人读名优先;没登记 label 才退回对象键 —— 治理台不该拿 `intake_record` 给人看
|
||||
title={o.label ?? o.object}
|
||||
desc={`${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>
|
||||
{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>
|
||||
)}
|
||||
@@ -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 total = page.data?.total;
|
||||
return (
|
||||
<NRow
|
||||
tone={total !== undefined && total > 0 ? 'brand' : 'mute'}
|
||||
ico="table"
|
||||
title={object}
|
||||
title={label ?? page.data?.label ?? object}
|
||||
sub={`${domain}.${object}`}
|
||||
cols={[
|
||||
{ k: '行数', v: total === undefined ? '…' : String(total) },
|
||||
@@ -137,8 +144,8 @@ export function ObjPage({ id }: { id: string }) {
|
||||
<>
|
||||
<PageHead
|
||||
ico="table"
|
||||
kicker={`数据对象 · ${domain}`}
|
||||
title={object ?? ''}
|
||||
kicker={`${domainShort(domain ?? '')}域 · ${domain}.${object}`}
|
||||
title={page.data?.label ?? object ?? ''}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="columns">{columns.length} 列</Chip>
|
||||
@@ -152,8 +159,11 @@ export function ObjPage({ id }: { id: string }) {
|
||||
<div className="g-rows">
|
||||
{columns.map((c) => (
|
||||
<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>
|
||||
@@ -167,7 +177,9 @@ export function ObjPage({ id }: { id: string }) {
|
||||
<thead>
|
||||
<tr>
|
||||
{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>
|
||||
</thead>
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.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 };
|
||||
|
||||
@@ -294,7 +295,8 @@ function TriggerForm({
|
||||
>
|
||||
{(objects.data?.objects ?? []).map((o) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -361,7 +361,10 @@ export const listToolsRoute = {
|
||||
export const listLedgerObjectsRoute = {
|
||||
method: 'GET',
|
||||
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;
|
||||
|
||||
export const browseLedgerRoute = {
|
||||
@@ -369,7 +372,8 @@ export const browseLedgerRoute = {
|
||||
path: '/api/gov/data/:domain/:object',
|
||||
params: z.object({ domain: z.string(), object: z.string() }),
|
||||
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()),
|
||||
rows: z.array(z.record(z.string(), z.unknown())),
|
||||
total: z.number().int(),
|
||||
|
||||
Reference in New Issue
Block a user