From b90d235d6418007c6e4701699508793c27c80a23 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 09:56:21 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=201C=EF=BC=9A=E5=89=8D=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=B1=82=E4=B8=8E=E7=B1=BB=E5=9E=8B=EF=BC=8C=E8=A1=A8?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E3=80=81=E5=8D=95=E8=A1=A8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E3=80=81=E6=95=B4=E4=BB=BD=E6=8F=90=E4=BA=A4=E3=80=81=E5=8D=95?= =?UTF-8?q?=E9=A1=B9=E5=AE=A1=E9=98=85=E3=80=81=E8=B5=B7=E8=8D=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/client.ts | 31 ++++++- frontend/src/types.ts | 175 +++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 031f64be..163ce524 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import type { - ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord, + ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogList, CatalogNotes, + CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord, ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut, RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging, } from '../types' @@ -649,6 +650,34 @@ export const api = { post(`/datasources/${encodeURIComponent(sourceId)}/imports/${encodeURIComponent(importId)}/purge-raw`, body), }, + /** + * 业务数据目录(每个数据源一份,按表存)。写操作都带读到的版本(if_version,这张表还没有目录时为 0):别人在这期间 + * 改过时回 409,detail 是给人看的话(「表「x」的数据目录刚被修改过,请重新载入后再提交」);格式不对回 422;源或表 + * 不存在回 404。署名走请求头 X-Actor(request 统一带上) + */ + dataCatalog: { + /** 表清单:表结构里的每张表各一行,使用次数多的在前;没有表结构时 tables 为空、schema_note 写明原因 */ + list: (sourceId: string, opts?: RequestOptions) => + get(`/datasources/${encodeURIComponent(sourceId)}/catalog`, opts), + /** 单表目录连同表结构 */ + get: (sourceId: string, table: string, opts?: RequestOptions) => + get(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}`, opts), + /** 整份提交:值改过的项、新填的项记为人工填写、已确认;提交里没有的项删掉 */ + put: (sourceId: string, table: string, notes: CatalogNotes, ifVersion: number) => + put(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}`, + { notes, if_version: ifVersion }), + /** 单项审阅。path:表级项写字段名(grain);列级项 columns.<列名>.<字段>;关系 relations.<编号> */ + review: (sourceId: string, table: string, body: { path: string; action: CatalogReviewAction; if_version: number }) => + post(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/review`, body), + /** + * 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。 + * 人工确认、驳回过的项不动。没有表结构时回 409 + */ + draft: (sourceId: string, body: { tables?: string[]; use_model: boolean }, opts?: RequestOptions) => + request(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`, + { method: 'POST', body: JSON.stringify(body), ...opts }), + }, + runs: { /** * status 可以给多个(逗号连接);q 按工作流名模糊匹配;before 是翻页游标 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c250a8bd..2efc1e0c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2477,3 +2477,178 @@ export interface EvidenceProvenance { cell_source: ProvenanceCellSource | null checks: ProvenanceCheck[] } + +// =========================================================================== +// 业务数据目录(/api/datasources/{id}/catalog,后端 app/data/catalog.py)。 +// 每张表一份业务说明:中文名、粒度、业务主键、列的含义和度量类型、表与表的关联关系……每一项都注明来源和状态。 +// 目录只记数据事实,不放计算公式(公式在口径卡里)。 +// =========================================================================== + +/** 项的来源:数据库注释、外键约束、命名推断、数据剖析、模型起草、人工填写 */ +export type CatalogSource = 'comment' | 'fk' | 'name' | 'profile' | 'llm' | 'human' +/** 项的状态:推断、已验证(外键约束、数据剖析)、已确认(人工)、已驳回(人工) */ +export type CatalogStatus = 'proposed' | 'verified' | 'confirmed' | 'rejected' +/** 表类型:明细(事实)、维度、快照、日志、配置 */ +export type CatalogTableKind = 'fact' | 'dimension' | 'snapshot' | 'log' | 'config' +/** 列的度量类型:可累加(流量)、存量、比率、标识、状态、属性 */ +export type CatalogMeasure = 'flow' | 'stock' | 'ratio' | 'identifier' | 'status' | 'attribute' +/** 关系的基数(从本表看过去) */ +export type CatalogCardinality = 'many_to_one' | 'one_to_one' | 'one_to_many' + +/** 目录里的一项:值连同来源、状态 */ +export interface CatalogItem { + value: T + source: CatalogSource + status: CatalogStatus + note?: string + updated_at?: string +} + +/** 业务日期:按哪一列算,规则和时区写成文字 */ +export interface CatalogBusinessDate { + column: string + rule?: string + timezone?: string +} + +/** 一列的目录 */ +export interface CatalogColumnNotes { + label?: CatalogItem + meaning?: CatalogItem + unit?: CatalogItem + measure?: CatalogItem + /** 码值 → 含义 */ + codes?: CatalogItem> +} + +/** 一条关联关系。编号由两端的表和列算出来,改了指向就是另一条 */ +export interface CatalogRelation { + id: string + columns: string[] + to_table: string + to_columns: string[] + cardinality: CatalogCardinality | null + /** 0 到 1:本表这几列的值在被指向表里找得到的比例(数据剖析给出) */ + coverage: number | null + source: CatalogSource + status: CatalogStatus + note?: string + updated_at?: string +} + +/** 一张表的目录(notes)。字段固定,服务端不认识的一律拒收 */ +export interface CatalogNotes { + label?: CatalogItem + description?: CatalogItem + grain?: CatalogItem + keys?: CatalogItem + kind?: CatalogItem + business_date?: CatalogItem + /** SQL 条件片段 */ + valid_filter?: CatalogItem + dedup?: CatalogItem + columns?: Record + relations?: CatalogRelation[] +} + +/** 各状态的项数(表级项、列级项、关系都算),四种状态都有键 */ +export type CatalogCounts = Record + +/** 表清单的一行。表结构里的每张表各一行(没有目录的也列);目录还在、表已不在的 in_schema 为 false,排在最后 */ +export interface CatalogTableRow { + table_name: string + qualified: string + is_view: boolean + in_schema: boolean + /** 中文名(被驳回的不算) */ + label: string | null + label_status: CatalogStatus | null + kind: CatalogTableKind | null + counts: CatalogCounts + /** 没被驳回的关联关系条数 */ + relations: number + /** 被运行查询过的次数(同一次运行里结果相同的重复查询只算一次) */ + usage: number + /** 这张表还没有目录时为 0 */ + version: number + updated_at: string | null + updated_by: string | null +} + +/** GET /datasources/{id}/catalog */ +export interface CatalogList { + /** 使用次数多的在前 */ + tables: CatalogTableRow[] + /** 导入表格的源:表和列的说明由系统按核对结果生成,只读 */ + system_notes: boolean + /** 没有表结构时的原因(「尚未探查结构」「结构探查失败:…」);有表结构时为 null */ + schema_note: string | null +} + +/** 表结构里的一列 */ +export interface CatalogStructureColumn { + name: string + type: string + pk: boolean + not_null: boolean + /** 数据库注释;导入表格的源是系统按核对结果生成的说明 */ + comment: string | null +} + +export interface CatalogForeignKey { + columns: string[] + to_table: string + to_columns: string[] +} + +export interface CatalogStructure { + qualified: string | null + is_view: boolean + /** 表注释;导入表格的源是系统按核对结果生成的说明 */ + comment: string | null + columns: CatalogStructureColumn[] + primary_key: string[] + /** 升级前探查的缓存里没有:null 表示不知道,空列表表示读过、没有 */ + foreign_keys: CatalogForeignKey[] | null + unique: string[][] | null +} + +/** GET /datasources/{id}/catalog/{table};PUT、审阅也返回这个形状 */ +export interface CatalogDetail { + table_name: string + in_schema: boolean + notes: CatalogNotes + version: number + updated_at: string | null + updated_by: string | null + /** 表结构里已经没有这张表时为 null */ + structure: CatalogStructure | null + system_notes: boolean + usage: number +} + +/** 单项审阅:确认、驳回、撤销审阅(回到来源的初始状态;人工填写的项直接删掉) */ +export type CatalogReviewAction = 'confirm' | 'reject' | 'reset' + +/** 起草结果里的一张表 */ +export interface CatalogDraftRow { + table_name: string + added: number + updated: number + removed: number + version: number + /** 这张表没有起草(表结构里没有、写入一直冲突) */ + error: string | null + /** 只是模型那部分失败,其余来源照常写入 */ + model_error: string | null +} + +/** POST /datasources/{id}/catalog/draft */ +export interface CatalogDraftOut { + tables: CatalogDraftRow[] + total: { added: number; updated: number; removed: number } + model_used: boolean + model: string | null + /** 模型整体用不了(没有配置、已停用……):只按注释、外键和命名起草 */ + model_error: string | null +} From db192ffc81b78f02a04bec0ef69a231b57ee9ba3 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:13:36 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=201C=EF=BC=9A=E6=95=B0=E6=8D=AE=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E4=B8=8E=E8=A1=A8=E6=B8=85=E5=8D=95=EF=BC=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E5=8D=A1=E7=89=87=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E3=80=81=E6=90=9C=E7=B4=A2=E7=AD=9B=E9=80=89=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E3=80=81=E5=A4=9A=E9=80=89=E5=88=86=E6=89=B9=E8=B5=B7=E8=8D=89?= =?UTF-8?q?=E3=80=81=E7=A9=BA=E7=8A=B6=E6=80=81=E4=B8=8E=E6=8E=A2=E6=9F=A5?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 4 + frontend/src/lib/terms.ts | 308 +++++++++++++++- frontend/src/pages/DataSourcesTab.tsx | 10 +- frontend/src/pages/catalog/CatalogPage.tsx | 245 +++++++++++++ frontend/src/pages/catalog/DraftDialog.tsx | 220 ++++++++++++ frontend/src/pages/catalog/TableIndex.tsx | 224 ++++++++++++ frontend/src/pages/catalog/model.ts | 395 +++++++++++++++++++++ frontend/src/pages/catalog/parts.tsx | 293 +++++++++++++++ 8 files changed, 1696 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/catalog/CatalogPage.tsx create mode 100644 frontend/src/pages/catalog/DraftDialog.tsx create mode 100644 frontend/src/pages/catalog/TableIndex.tsx create mode 100644 frontend/src/pages/catalog/model.ts create mode 100644 frontend/src/pages/catalog/parts.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0167cf3b..b1648d10 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { ChatPage } from './pages/ChatPage' import { StudioPage } from './pages/StudioPage' import { RunsPage } from './pages/RunsPage' import { DataPage } from './pages/DataPage' +import { CatalogPage } from './pages/catalog/CatalogPage' import { ToolsPage } from './pages/ToolsPage' import { KnowledgePage } from './pages/KnowledgePage' import { SettingsPage } from './pages/SettingsPage' @@ -105,6 +106,9 @@ export default function App() { } /> } /> } /> + {/* 数据源的数据目录:两段以上的 /data 地址不会落到上面的标签页 */} + } /> + } /> } /> } /> } /> diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index c9df87d7..b1b9d288 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -38,7 +38,9 @@ * 枚举值(formal / degraded / withheld 等)不动,只换显示。 */ -import type { NodeType, SnapshotReasonCode, ToolTrust } from '../types' +import type { + CatalogCardinality, CatalogMeasure, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode, ToolTrust, +} from '../types' import { formatNumber } from './format' export const TERMS = { @@ -1797,3 +1799,307 @@ export const RECIPE_CHOICE_LABEL = { colStore: { '': '默认', canonical: '规范写法', raw: '原文' } as Record, axisChecks: { contiguous: '逐日连续', covers_context: '恰好覆盖统计期' } as Record, } + +// =========================================================================== +// 业务数据目录(数据源卡片上的「数据目录」) +// +// 术语: +// - 「数据目录」:每张表、每一列的业务说明和表之间的关联关系。只记数据事实,不放计算公式(公式在口径卡里)。 +// - 表级:中文名、说明、粒度(一行代表什么)、业务主键、表类型、业务日期、有效记录条件、去重规则。 +// - 列级:中文名、含义、单位、度量类型、码值。度量类型的 flow 写「可累加」,不写「流量」;表类型的 fact 写「明细表」。 +// - 关联关系:本表字段 → 目标表的字段,带基数(多对一……)和覆盖率。 +// - 状态:推断 / 已验证 / 已确认 / 已驳回。来源:数据库注释 / 外键约束 / 命名推断 / 数据剖析 / 模型起草 / 人工填写。 +// - 动作:起草(批量生成推断项)、确认、驳回、恢复(回到起草时的状态)。直接编辑即确认。 +// =========================================================================== + +export const CATALOG_TERMS = { + catalog: '数据目录', + grain: '粒度', + keys: '业务主键', + measure: '度量类型', + codes: '码值', + relation: '关联关系', + cardinality: '基数', + coverage: '覆盖率', + draft: '起草', +} as const + +/** 项的状态。四种状态在界面上用同一套样式(pages/catalog/parts.tsx 的 StatusMark) */ +export const CATALOG_STATUS_LABEL: Record = { + proposed: '推断', + verified: '已验证', + confirmed: '已确认', + rejected: '已驳回', +} + +export const CATALOG_STATUS_HINT: Record = { + proposed: '只作提示,确认后才参与 SQL 检查', + verified: '有确证(外键约束或数据剖析),参与 SQL 检查', + confirmed: '人工确认,参与 SQL 检查', + rejected: '人工驳回,不提供给助手,之后起草也不会再提出', +} + +export const CATALOG_SOURCE_LABEL: Record = { + comment: '数据库注释', + fk: '外键约束', + name: '命名推断', + profile: '数据剖析', + llm: '模型起草', + human: '人工填写', +} + +export const CATALOG_KIND_LABEL: Record = { + fact: '明细表', + dimension: '维度表', + snapshot: '快照表', + log: '日志表', + config: '配置表', +} + +export const CATALOG_KIND_HINT: Record = { + fact: '一行是一笔业务事件,如订单、入园、销售', + dimension: '描述业务对象,如门店、票种、渠道', + snapshot: '按时点记录的状态,如每日库存', + log: '系统操作或变更记录', + config: '参数、映射等配置', +} + +export const CATALOG_MEASURE_LABEL: Record = { + flow: '可累加', + stock: '存量', + ratio: '比率', + identifier: '标识', + status: '状态', + attribute: '属性', +} + +export const CATALOG_MEASURE_HINT: Record = { + flow: '可以跨期加总,如金额、人数', + stock: '时点值,不能跨期加总,如库存、余额', + ratio: '不能直接加总,如折扣率、转化率', + identifier: '编号或外键,不参与计算', + status: '状态码,按码值解释', + attribute: '描述性属性,如名称、类别', +} + +export const CATALOG_CARDINALITY_LABEL: Record = { + many_to_one: '多对一', + one_to_one: '一对一', + one_to_many: '一对多', +} + +/** 表级各项,按显示顺序 */ +export const CATALOG_TABLE_FIELD_LABEL = { + label: '中文名', + description: '说明', + grain: '粒度', + keys: '业务主键', + kind: '表类型', + business_date: '业务日期', + valid_filter: '有效记录条件', + dedup: '去重规则', +} as const + +export const CATALOG_TABLE_FIELD_HINT: Record = { + label: '表的业务名称', + description: '这张表记录什么、怎么用', + grain: '一行代表什么,如「一张门票的一次入园」', + keys: '唯一确定一行业务记录的列', + kind: '明细、维度、快照、日志或配置', + business_date: '按哪一列归属到日期', + valid_filter: '有效记录的 SQL 条件,如 status <> 9', + dedup: '同一笔业务出现多行时如何取舍', +} + +/** 列级各项,按显示顺序 */ +export const CATALOG_COLUMN_FIELD_LABEL = { + label: '中文名', + meaning: '含义', + unit: '单位', + measure: '度量类型', + codes: '码值', +} as const + +export const CATALOG_TEXT = { + // ---- 入口 + open: '数据目录', + openHint: '记录每张表、每一列的业务含义和表之间的关联关系,助手建图和查询时读取', + // ---- 页面 + title: (name: string) => `「${name}」的数据目录`, + subtitle: '先起草,再按使用次数逐表确认。推断的项只作提示,确认后才参与 SQL 检查', + back: '返回数据源', + sourceMissing: '该数据源不存在', + sourceMissingBody: '可能已被删除,请返回数据源列表查看。', + noSchemaTitle: '还没有表结构', + noSchemaBody: (note: string) => `${note.replace(/[。.]$/, '')}。数据目录按表结构逐表记录,请先回到数据源卡片点击「探查结构」。`, + noSchemaUploadBody: '这个表格还没有可用的表结构,请重新上传后再编写数据目录。', + emptyTitle: '还没有数据目录', + emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以请助手的模型起草中文名和含义。', + // ---- 导入表格的说明 + systemTitle: '导入时生成的说明', + systemBody: '这个数据源由导入表格生成,表和列的说明由系统按核对结果生成,只读,不能在数据目录中改写。', + systemCovered: '目录中与说明重复的项(表的粒度和说明、列的含义和单位)确认后才提供给助手。', + systemTag: '导入时生成', + systemTableNote: '表说明', + // ---- 表清单 + search: '搜索表名或中文名', + filterLabel: '按状态筛选', + filter: { all: '全部', pending: '有未确认项', done: '全部已确认', none: '没有目录' }, + filterHint: { + all: '全部表', + pending: '还有推断状态的项', + done: '有目录,且没有推断状态的项', + none: '还没有任何目录项', + }, + sortLabel: '排序', + sort: { usage: '按使用次数', pending: '按未确认项', name: '按表名' }, + tableCount: (shown: number, total: number) => + (shown === total ? `${formatNumber(total)} 张表` : `${formatNumber(shown)} / ${formatNumber(total)} 张表`), + selectAll: '全选当前结果', + selectTable: (name: string) => `选择 ${name}`, + selected: (n: number) => `已选 ${formatNumber(n)} 张`, + clearSelection: '取消选择', + noLabel: '未填写中文名', + view: '视图', + missing: '表结构中已没有这张表', + noCatalog: '没有目录', + usage: (n: number) => `使用 ${formatNumber(n)} 次`, + usageHint: '被运行查询过的次数,同一次运行中结果相同的重复查询只算一次', + relationCount: (n: number) => `${formatNumber(n)} 个关联`, + countTitle: (label: string, n: number) => `${label} ${formatNumber(n)} 项`, + noMatch: '没有符合条件的表', + noMatchBody: '请换个关键词或筛选条件。', + // ---- 概览(没有选中表时) + overviewTitle: '选择一张表开始审阅', + overviewBody: '按使用次数从高到低逐表确认。推断的项只作提示,确认后才参与 SQL 检查。', + overviewStats: (total: number, pending: number, done: number, none: number) => + `共 ${formatNumber(total)} 张表:${formatNumber(pending)} 张有未确认项,${formatNumber(done)} 张全部已确认,${formatNumber(none)} 张没有目录`, + reviewNext: (name: string) => `审阅「${name}」`, + reviewNextHint: '使用次数最多、还有未确认项的表', + legend: '状态说明', + // ---- 起草 + draft: '起草', + draftSelected: (n: number) => `起草所选(${formatNumber(n)})`, + draftTitle: '起草数据目录', + draftScope: '起草范围', + scopeSelected: (n: number) => `已选的 ${formatNumber(n)} 张表`, + scopeFiltered: (n: number) => `当前筛选结果中的 ${formatNumber(n)} 张表`, + scopeTop: (n: number) => `使用次数最多的 ${formatNumber(n)} 张表`, + scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`, + draftBase: '按数据库注释、外键约束和列名推断起草。已确认、已驳回的项不会被改动;起草出的项都是推断,需要逐项确认。', + useModel: '用模型起草', + useModelHint: '请助手的模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释),不发送数据;耗时较长,并产生模型费用。', + draftStart: (n: number) => `起草 ${formatNumber(n)} 张表`, + drafting: '正在起草…', + draftProgress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`, + draftStop: '停止', + draftStopping: '当前这一批完成后停止…', + draftDone: '起草完成', + draftStopped: (done: number, total: number) => `已停止:完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,其余未起草`, + draftFailed: '起草中断', + draftSummary: (added: number, updated: number, removed: number) => + `新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`, + draftNoChange: '没有变化:起草结果与现有目录一致', + draftModelSkipped: (reason: string) => `模型未参与起草:${reason.replace(/[。.]$/, '')}。已按注释、外键和命名起草。`, + draftModelUsed: (model: string) => `模型:${model}`, + draftTableErrors: (n: number) => `${formatNumber(n)} 张表未能起草`, + draftModelErrors: (n: number) => `${formatNumber(n)} 张表的模型起草失败,已按注释、外键和命名起草`, + draftClose: '完成', + moreTables: (n: number) => `…另有 ${formatNumber(n)} 张`, + cancel: '取消', + draftBackground: '起草仍在进行,当前这一批完成后停止', + // ---- 表详情 + backToList: '返回表清单', + prev: '上一张', + next: '下一张', + updatedBy: (who: string, at: string) => `${who} 于 ${at} 修改`, + updatedAt: (at: string) => `${at} 修改`, + notStarted: '尚未建立目录', + missingBody: '表结构中已没有这张表(可能已删除或改名)。目录仍然保留,可以查看、驳回或删除其中的项。', + sectionTable: '表级信息', + sectionColumns: '列', + sectionRelations: '关联关系', + columnCount: (shown: number, total: number) => + (shown === total ? `${formatNumber(total)} 列` : `${formatNumber(shown)} / ${formatNumber(total)} 列`), + columnFilter: '筛选列名或中文名', + onlyPending: '只看有推断项的列', + columnHead: { name: '列名', type: '类型', label: '中文名', meaning: '含义', unit: '单位', measure: '度量类型', codes: '码值' }, + pk: '主键', + columnMissing: '表结构中已没有这一列', + noColumns: '没有符合条件的列', + empty: '未填写', + relationHead: { from: '本表字段', to: '目标表', toColumns: '目标字段', cardinality: '基数', coverage: '覆盖率', source: '来源', status: '状态' }, + noRelations: '还没有关联关系。起草会按外键约束和列名推断,也可以在编辑时添加。', + openTable: (name: string) => `打开「${name}」`, + coverageValue: (pct: number) => `${formatNumber(pct)}%`, + dateColumn: '日期列', + dateRule: '规则', + dateTimezone: '时区', + // ---- 单项审阅 + itemTitle: (where: string) => `${where}的来源和状态`, + itemSource: (s: string) => `来源:${s}`, + itemUpdated: (at: string) => `更新于 ${at}`, + confirm: '确认', + reject: '驳回', + reset: '恢复', + resetHint: '回到起草时的状态', + remove: '删除', + removeHint: '人工填写的项没有起草时的状态,恢复即删除', + reviewed: { + confirm: (where: string) => `已确认${where}`, + reject: (where: string) => `已驳回${where}`, + reset: (where: string) => `已恢复${where}`, + }, + removed: (where: string) => `已删除${where}`, + whereTable: (field: string) => `表的${field}`, + whereColumn: (col: string, field: string) => `列 ${col} 的${field}`, + whereRelation: (to: string) => `指向 ${to} 的关联关系`, + // ---- 批量确认 + confirmAll: (n: number) => `确认本表全部推断(${formatNumber(n)})`, + confirmAllTitle: (n: number, table: string) => `确认「${table}」的 ${formatNumber(n)} 项推断?`, + confirmAllConsequences: ['确认后这些项参与 SQL 检查', '来源保持不变,状态改为已确认', '之后仍可逐项恢复'], + confirmAllAction: (n: number) => `确认 ${formatNumber(n)} 项`, + confirmColumn: '确认本列推断', + confirmColumnLabel: (col: string, n: number) => `确认列 ${col} 的 ${formatNumber(n)} 项推断`, + confirmedN: (n: number) => `已确认 ${formatNumber(n)} 项`, + // ---- 编辑 + edit: '编辑', + editHint: '保存后,改动过的项记为人工填写、已确认', + clearHint: '清空一项即删除;不希望起草再次提出的项,请用「驳回」', + save: '保存', + saving: '正在保存…', + cancelEdit: '取消', + changes: (n: number) => `${formatNumber(n)} 处修改`, + noChanges: '尚未修改', + saved: '已保存,改动过的项已记为人工确认', + invalidCount: (n: number) => `${formatNumber(n)} 处格式不正确`, + keysPlaceholder: '列名,用顿号或逗号分隔', + keysUnknown: (cols: string) => `表结构中没有这些列:${cols}`, + codesPlaceholder: '每行一个,如 1=已支付', + codesInvalid: (line: number) => `第 ${formatNumber(line)} 行应写成「码值=含义」`, + codesDuplicate: (code: string) => `码值 ${code} 重复`, + dateColumnRequired: '填写规则或时区时,日期列必填', + tooLong: (n: number) => `不超过 ${formatNumber(n)} 字`, + kindNone: '未填写', + measureNone: '未填写', + cardinalityNone: '未知', + addRelation: '添加关联关系', + removeRelation: '删除这条关联关系', + relationColumns: '本表字段,用逗号分隔', + relationTarget: '目标表', + relationToColumns: '目标字段,用逗号分隔', + relationIncomplete: '本表字段、目标表和目标字段都要填写', + relationMismatch: '两端的字段数不一致', + relationDuplicate: '与另一条关联关系重复', + rejectedPlaceholder: (value: string) => `已驳回:${value}`, + leaveTitle: '数据目录有未保存的修改', + leaveConsequences: ['离开后修改将丢失'], + leaveConfirm: '放弃修改并离开', + // ---- 冲突与错误 + conflictTitle: '这张表的数据目录刚被其他人修改过', + conflictEditing: '你的修改尚未保存。重新载入会放弃你的修改并显示最新内容;如需保留,请先记下要改的地方。', + conflictReview: '刚才的操作未生效,请重新载入后再操作。', + reload: '重新载入', + reloadDiscardTitle: '放弃未保存的修改并重新载入?', + reloadDiscardAction: '放弃修改并重新载入', +} diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx index a9ebaee7..de2cd9b1 100644 --- a/frontend/src/pages/DataSourcesTab.tsx +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { - AlertTriangle, ChevronRight, Database, EyeOff, FileSpreadsheet, History, Info, KeyRound, Layers, Lock, Plug, Plus, + AlertTriangle, BookMarked, ChevronRight, Database, EyeOff, FileSpreadsheet, History, Info, KeyRound, Layers, Lock, Plug, Plus, RefreshCw, Search, Table2, Upload, X, } from 'lucide-react' import clsx from 'clsx' @@ -21,7 +21,7 @@ import { checkHealth, forgetHealth, healthFromServer, setHealth, useHealth } fro import type { HealthRecord } from '../lib/health' import { workflowList, workflowsMentioning } from '../lib/mentions' import { - RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, RECIPE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT, + CATALOG_TEXT, RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, RECIPE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT, } from '../lib/terms' import { useRunClock } from '../run/useRunClock' import { ImportWizard } from './import/ImportWizard' @@ -279,6 +279,7 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci // 后端记着上次测的结果:换了浏览器、清了缓存也还在。本机刚测过的更新就用本机的 const { record, checkingSince } = useHealth(healthKey, healthFromServer(row)) const workflows = useCatalog((s) => s.workflows) + const navigate = useNavigate() const [busy, setBusy] = useState('') const uploaded = isUploadedTable(row) // 传上来的表通常只有一两张,直接摊开;库动辄几十上百个对象,默认收着 @@ -489,6 +490,11 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci {VERSIONS_TEXT.open} )} + {/* 数据目录是独立页面:一个库上百张表、一张表几十列,逐表审阅要整屏的宽度。窄屏只留图标 */} + {uploaded && row.import_mode === 'recipe' ? ( // 按配方导入的源:每月按已确认的配方重放,不走简单上传(服务端也会拒绝) <> diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx new file mode 100644 index 00000000..68ae6e0e --- /dev/null +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { ArrowLeft, BookMarked, Database, Sparkles } from 'lucide-react' +import clsx from 'clsx' +import { ApiError, api } from '../../api/client' +import type { CatalogDetail, CatalogList } from '../../types' +import { EmptyState, ErrorState, Notice, PageHeader, Skeleton, toast } from '../../components/ui' +import { useDatasources, useOnReconnect } from '../../store/catalog' +import { CATALOG_TEXT as CT } from '../../lib/terms' +import { StatusLegend } from './parts' +import { DraftDialog } from './DraftDialog' +import { TableIndex } from './TableIndex' +import { filterCounts, progressOf, rowFromDetail, visibleRows } from './model' +import type { ListFilter, ListSort } from './model' + +// =========================================================================== +// 数据目录页(/data/catalog/:sourceId,选中一张表时 /data/catalog/:sourceId/:table)。 +// +// 用独立页面而不是弹窗:一个库 150 多张表、一张表 50 列,审阅是按使用次数一张接一张做的长活,要整屏的宽度 +// 放左栏的表清单和右栏的列表格;地址里带着表名,可以把某张表直接发给同事核对,浏览器的前进后退、刷新都不丢 +// 位置;编辑到一半换表、离开页面,走全站统一的离开前确认。 +// +// 宽屏左右两栏;窄屏(< lg)一次只显示一栏:没选表时是清单,选了表是详情(详情里有「返回表清单」)。 +// =========================================================================== + +export function CatalogPage() { + const { sourceId = '', table } = useParams() + const navigate = useNavigate() + const { list: sources } = useDatasources() + const source = sources.find((s) => s.id === sourceId) + const [data, setData] = useState(null) + const [loadError, setLoadError] = useState(null) + const loadSeq = useRef(0) + const hasData = useRef(false) + + const load = useCallback(async () => { + const seq = ++loadSeq.current + try { + const next = await api.dataCatalog.list(sourceId) + if (seq !== loadSeq.current) return + hasData.current = true + setData(next) + setLoadError(null) + } catch (e) { + if (seq !== loadSeq.current) return + // 首次没取到才整页换成错误;之后的刷新失败保留旧清单,只弹提示 + if (hasData.current) toast.error(e) + else setLoadError(e) + } + }, [sourceId]) + useEffect(() => { + hasData.current = false + setData(null) + setLoadError(null) + void load() + }, [load]) + useOnReconnect(() => { void load() }) + + const [query, setQuery] = useState('') + const [filter, setFilter] = useState('all') + const [sort, setSort] = useState('usage') + const [selected, setSelected] = useState>(() => new Set()) + const [drafting, setDrafting] = useState(false) + /** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */ + const [drafted, setDrafted] = useState<{ seq: number; tables: Set }>({ seq: 0, tables: new Set() }) + + const rows = useMemo(() => data?.tables ?? [], [data]) + const visible = useMemo(() => visibleRows(rows, query, filter, sort), [rows, query, filter, sort]) + const counts = useMemo(() => filterCounts(rows, query), [rows, query]) + const base = `/data/catalog/${encodeURIComponent(sourceId)}` + const openTable = useCallback((t: string) => navigate(`${base}/${encodeURIComponent(t)}`), [navigate, base]) + const back = source?.origin === 'upload' ? '/data/tables' : '/data/databases' + + /** 写入、审阅之后按返回的单表目录更新清单里那一行 */ + const onDetail = useCallback((d: CatalogDetail) => { + setData((cur) => cur && ({ + ...cur, + tables: cur.tables.map((r) => (r.table_name === d.table_name ? rowFromDetail(d, r) : r)), + })) + }, []) + + const onDrafted = useCallback((tables: string[]) => { + setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) })) + setSelected(new Set()) + void load() + }, [load]) + + const filtered = !!query.trim() || filter !== 'all' + const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none') + const hasTables = rows.length > 0 + const draftButton = (primary = false) => ( + + ) + + let body: ReactNode + if (!data) { + body = loadError + ? (loadError instanceof ApiError && loadError.status === 404 + ? } title={CT.sourceMissing} body={CT.sourceMissingBody} offline={false} + action={} /> + :
void load()} />
) + :
+ } else if (!hasTables) { + // 没有表结构:先去探查。上传的表格没有「探查结构」,只能重新上传 + body = ( +
+ } + title={CT.noSchemaTitle} + body={source?.origin === 'upload' ? CT.noSchemaUploadBody : CT.noSchemaBody(data.schema_note ?? '')} + offline={false} + action={} + /> +
+ ) + } else { + const pendingNext = rows.find((r) => r.in_schema && r.counts.proposed > 0) + const stats = { pending: 0, done: 0, none: 0 } + for (const r of rows) stats[progressOf(r.counts)]++ + body = ( +
+ +
+
+
+ {empty + ? ( + } title={CT.emptyTitle} body={CT.emptyBody} offline={false} + action={draftButton(true)} className="!py-8" /> + ) + : ( +
+

{CT.overviewTitle}

+

{CT.overviewBody}

+

+ {CT.overviewStats(rows.length, stats.pending, stats.done, stats.none)} +

+ + {pendingNext && ( +
+ + {CT.reviewNextHint} +
+ )} +
+ )} + {data.system_notes && } +
+
{CT.legend}
+ +
+
+
+
+
+ ) + } + + return ( +
+ } + title={CT.title(source?.name ?? sourceId)} + subtitle={CT.subtitle} + actions={( +
+ {hasTables && draftButton()} + +
+ )} + /> + {body} + {drafting && data && ( + setDrafting(false)} + onDrafted={onDrafted} + /> + )} +
+ ) +} + +/** 导入表格的源:说明是系统生成的,只读 */ +export function SystemNotesNotice() { + return ( + +

{CT.systemTitle}

+

{CT.systemBody}

+

{CT.systemCovered}

+
+ ) +} + +/** 审阅进度:有未确认项 / 全部已确认 / 没有目录的表各占多少 */ +function ProgressBar({ pending, done, none }: { pending: number; done: number; none: number }) { + const total = pending + done + none || 1 + const seg = (n: number, color: string, label: string) => (n > 0 + ? + : null) + return ( +
+ {seg(done, 'var(--st-done)', CT.filter.done)} + {seg(pending, 'var(--st-waiting)', CT.filter.pending)} + {seg(none, 'var(--border-strong)', CT.filter.none)} +
+ ) +} diff --git a/frontend/src/pages/catalog/DraftDialog.tsx b/frontend/src/pages/catalog/DraftDialog.tsx new file mode 100644 index 00000000..ab588758 --- /dev/null +++ b/frontend/src/pages/catalog/DraftDialog.tsx @@ -0,0 +1,220 @@ +import { useMemo, useRef, useState } from 'react' +import { Sparkles } from 'lucide-react' +import clsx from 'clsx' +import { api } from '../../api/client' +import type { CatalogDraftRow, CatalogTableRow } from '../../types' +import { Modal, Notice, Spinner } from '../../components/ui' +import { errorMessage } from '../../lib/errors' +import { CATALOG_TEXT as CT } from '../../lib/terms' + +// =========================================================================== +// 起草数据目录。接口是同步的,一次请求起草完才返回;表多时分批调用,每批回来就更新进度和累计的新增、更新、删除, +// 中途可以停止(当前这一批做完后不再发下一批)。模型整体用不了时第一批就会知道:之后的批次不再请模型,结果里 +// 写明原因。已经写进去的批次不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。 +// =========================================================================== + +/** 每批几张表。只按注释、外键和命名起草时只看探查缓存,很快;请模型时服务端每 4 张一批、最多 3 批并发 */ +const BATCH_PLAIN = 40 +const BATCH_MODEL = 12 +/** 服务端不给 tables 时取前 20 张,这里给出同样的范围,进度才算得出来 */ +const TOP_N = 20 +/** 结果里逐张列出的表最多几张,其余合成一句 */ +const LIST_MAX = 8 + +type Scope = 'selected' | 'filtered' | 'top' | 'all' +type Phase = 'setup' | 'running' | 'done' + +interface Outcome { + done: number + total: number + added: number + updated: number + removed: number + tableErrors: CatalogDraftRow[] + modelErrors: CatalogDraftRow[] + modelSkipped: string | null + model: string | null + failure: string | null + stopped: boolean +} + +const fresh = (total: number): Outcome => ({ + done: 0, total, added: 0, updated: 0, removed: 0, tableErrors: [], modelErrors: [], modelSkipped: null, model: null, + failure: null, stopped: false, +}) + +export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClose, onDrafted }: { + sourceId: string + /** 全部表(服务端的顺序:使用次数多的在前) */ + rows: CatalogTableRow[] + /** 当前筛选结果 */ + visible: CatalogTableRow[] + selected: string[] + /** 清单上有没有生效的搜索或筛选:有时才给「当前筛选结果」这一项 */ + filtered: boolean + onClose: () => void + /** 有批次写进去了:重取清单、刷新正在看的表 */ + onDrafted: (tables: string[]) => void +}) { + // 表结构里已经没有的表起草不了(服务端会回「表结构里没有」),不算进范围 + const live = useMemo(() => rows.filter((r) => r.in_schema).map((r) => r.table_name), [rows]) + const liveSet = useMemo(() => new Set(live), [live]) + const scopes = useMemo(() => { + const out: { key: Scope; label: string; tables: string[] }[] = [] + const sel = selected.filter((t) => liveSet.has(t)) + if (sel.length) out.push({ key: 'selected', label: CT.scopeSelected(sel.length), tables: sel }) + const vis = visible.filter((r) => r.in_schema).map((r) => r.table_name) + if (filtered && vis.length && vis.length !== live.length) out.push({ key: 'filtered', label: CT.scopeFiltered(vis.length), tables: vis }) + if (live.length > TOP_N) out.push({ key: 'top', label: CT.scopeTop(TOP_N), tables: live.slice(0, TOP_N) }) + out.push({ key: 'all', label: CT.scopeAll(live.length), tables: live }) + return out + }, [selected, visible, filtered, live, liveSet]) + const [scope, setScope] = useState(() => scopes[0].key) + const [useModel, setUseModel] = useState(false) + const [phase, setPhase] = useState('setup') + const [out, setOut] = useState(() => fresh(0)) + const [stopping, setStopping] = useState(false) + const stopRef = useRef(false) + const tables = scopes.find((s) => s.key === scope)?.tables ?? scopes[0].tables + + const run = async () => { + stopRef.current = false + setStopping(false) + setPhase('running') + const acc = fresh(tables.length) + setOut({ ...acc }) + const touched: string[] = [] + let withModel = useModel + let i = 0 + while (i < tables.length) { + if (stopRef.current) { acc.stopped = true; break } + const batch = tables.slice(i, i + (withModel ? BATCH_MODEL : BATCH_PLAIN)) + try { + const r = await api.dataCatalog.draft(sourceId, { tables: batch, use_model: withModel }) + acc.added += r.total.added + acc.updated += r.total.updated + acc.removed += r.total.removed + acc.tableErrors.push(...r.tables.filter((t) => t.error)) + acc.modelErrors.push(...r.tables.filter((t) => !t.error && t.model_error)) + if (r.model) acc.model = r.model + // 模型整体用不了(没配置、已停用……):后面的批次不再请模型,免得每批都撞一次同样的错 + if (withModel && r.model_error && !r.model_used) { acc.modelSkipped = r.model_error; withModel = false } + touched.push(...r.tables.filter((t) => !t.error).map((t) => t.table_name)) + } catch (e) { + acc.failure = errorMessage(e) + break + } + i += batch.length + acc.done = i + setOut({ ...acc }) + } + setOut({ ...acc }) + setPhase('done') + if (touched.length) onDrafted(touched) + } + + const stop = () => { + stopRef.current = true + setStopping(true) + } + + // 进行中关窗 = 停止:当前这一批做完再关,结果照常显示 + const requestClose = () => (phase === 'running' ? stop() : onClose()) + + const footer = phase === 'setup' + ? ( + <> + + + + ) + : phase === 'running' + ? + : + + return ( + +
+ {phase === 'setup' && ( + <> +
+ {CT.draftScope} +
+ {scopes.map((s) => ( + + ))} +
+
+

{CT.draftBase}

+ + + )} + + {phase !== 'setup' && ( +
+
+ {phase === 'running' && } + + {phase === 'running' + ? (stopping ? CT.draftStopping : CT.drafting) + : out.failure ? CT.draftFailed : out.stopped ? CT.draftStopped(out.done, out.total) : CT.draftDone} + + + {CT.draftProgress(out.done, out.total)} +
+
+
+
+

+ {out.added + out.updated + out.removed === 0 && phase === 'done' && !out.failure + ? CT.draftNoChange + : CT.draftSummary(out.added, out.updated, out.removed)} +

+ {out.model &&

{CT.draftModelUsed(out.model)}

} + {out.modelSkipped && ( + {CT.draftModelSkipped(out.modelSkipped)} + )} + {out.failure && {out.failure}} + {out.tableErrors.length > 0 && ( + +

{CT.draftTableErrors(out.tableErrors.length)}

+ r.error ?? ''} /> +
+ )} + {out.modelErrors.length > 0 && ( + +

{CT.draftModelErrors(out.modelErrors.length)}

+ r.model_error ?? ''} /> +
+ )} +
+ )} +
+ + ) +} + +function ErrorList({ rows, pick }: { rows: CatalogDraftRow[]; pick: (r: CatalogDraftRow) => string }) { + return ( +
    + {rows.slice(0, LIST_MAX).map((r) => ( +
  • {r.table_name}:{pick(r)}
  • + ))} + {rows.length > LIST_MAX &&
  • {CT.moreTables(rows.length - LIST_MAX)}
  • } +
+ ) +} diff --git a/frontend/src/pages/catalog/TableIndex.tsx b/frontend/src/pages/catalog/TableIndex.tsx new file mode 100644 index 00000000..5a2c3804 --- /dev/null +++ b/frontend/src/pages/catalog/TableIndex.tsx @@ -0,0 +1,224 @@ +import { memo, useCallback, useEffect, useRef } from 'react' +import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react' +import { AlertTriangle, Search, Sparkles, X } from 'lucide-react' +import clsx from 'clsx' +import type { CatalogTableRow } from '../../types' +import { useRadioGroup } from '../../components/ui' +import { formatNumber } from '../../lib/format' +import { CATALOG_KIND_LABEL, CATALOG_TEXT as CT } from '../../lib/terms' +import { CountBadges } from './parts' +import { LIST_FILTERS, LIST_SORTS, progressOf } from './model' +import type { ListFilter, ListSort } from './model' + +// =========================================================================== +// 表清单(数据目录页的左栏):搜索、按审阅进度筛选、排序、多选后批量起草。 +// 一行两层:上面是中文名和各状态的项数,下面是表名、类型、关联数和使用次数。键盘:↑↓ 在行之间移动, +// Home / End 到两头,Enter 打开;复选框按住 Shift 点选可以连选一段。 +// =========================================================================== + +export function TableIndex({ + rows, total, counts, query, onQuery, filter, onFilter, sort, onSort, selected, onSelected, active, onOpen, onDraft, +}: { + /** 筛选、排序后的行 */ + rows: CatalogTableRow[] + total: number + /** 各筛选项下的表数(按搜索词算) */ + counts: Record + query: string + onQuery: (q: string) => void + filter: ListFilter + onFilter: (f: ListFilter) => void + sort: ListSort + onSort: (s: ListSort) => void + selected: Set + onSelected: (next: Set) => void + active: string | undefined + onOpen: (table: string) => void + onDraft: () => void +}) { + const radio = useRadioGroup(LIST_FILTERS, filter, onFilter) + const listRef = useRef(null) + const allRef = useRef(null) + const lastClicked = useRef(null) + const picked = rows.filter((r) => selected.has(r.table_name)).length + const all = rows.length > 0 && picked === rows.length + + useEffect(() => { + if (allRef.current) allRef.current.indeterminate = picked > 0 && !all + }, [picked, all]) + + // 打开的表滚进视野(从详情里点「下一张」、从关联关系跳过来时) + useEffect(() => { + if (!active) return + const el = listRef.current?.querySelector(`[data-catalog-row="${CSS.escape(active)}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [active]) + + const toggleAll = () => { + const next = new Set(selected) + if (all) for (const r of rows) next.delete(r.table_name) + else for (const r of rows) next.add(r.table_name) + onSelected(next) + } + + // 行是 memo 的:两个回调读最新的 rows、selected,自身保持不变,勾一行不会让 150 多行全部重画 + const latest = useRef({ rows, selected, onSelected }) + latest.current = { rows, selected, onSelected } + + const toggle = useCallback((i: number, e: ReactMouseEvent) => { + const { rows: list, selected: sel, onSelected: set } = latest.current + const name = list[i].table_name + const next = new Set(sel) + const on = !sel.has(name) + // Shift 点选:从上一次点的那行到这一行,统一设成这一下的状态 + if (e.shiftKey && lastClicked.current != null && lastClicked.current < list.length) { + const [a, b] = [Math.min(lastClicked.current, i), Math.max(lastClicked.current, i)] + for (let k = a; k <= b; k++) { + if (on) next.add(list[k].table_name) + else next.delete(list[k].table_name) + } + } else if (on) next.add(name) + else next.delete(name) + lastClicked.current = i + set(next) + }, []) + + const onRowKey = useCallback((i: number, e: ReactKeyboardEvent) => { + const n = latest.current.rows.length + let j = -1 + if (e.key === 'ArrowDown') j = Math.min(n - 1, i + 1) + else if (e.key === 'ArrowUp') j = Math.max(0, i - 1) + else if (e.key === 'Home') j = 0 + else if (e.key === 'End') j = n - 1 + if (j < 0 || e.altKey || e.metaKey || e.ctrlKey) return + e.preventDefault() + listRef.current?.querySelectorAll('[data-catalog-open-row]')[j]?.focus() + }, []) + + return ( +
+
+
+ + onQuery(e.target.value)} + data-catalog-search="" + /> +
+
+ {LIST_FILTERS.map((f) => ( + + ))} +
+
+ + + {selected.size ? CT.selected(selected.size) : CT.tableCount(rows.length, total)} + + {selected.size > 0 && ( + + )} + + + +
+
+ +
    + {rows.map((r, i) => ( + + ))} +
+ + {selected.size > 0 && ( +
+ +
+ )} +
+ ) +} + +const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen, onKey }: { + row: CatalogTableRow + index: number + active: boolean + checked: boolean + onToggle: (i: number, e: ReactMouseEvent) => void + onOpen: (table: string) => void + onKey: (i: number, e: ReactKeyboardEvent) => void +}) { + const progress = progressOf(r.counts) + return ( +
  • + {active && } + + {}} onClick={(e) => onToggle(index, e)} + aria-label={CT.selectTable(r.table_name)} data-catalog-select={r.table_name} /> + + +
  • + ) +}) diff --git a/frontend/src/pages/catalog/model.ts b/frontend/src/pages/catalog/model.ts new file mode 100644 index 00000000..c9cc7cc5 --- /dev/null +++ b/frontend/src/pages/catalog/model.ts @@ -0,0 +1,395 @@ +import type { + CatalogBusinessDate, CatalogColumnNotes, CatalogCounts, CatalogDetail, CatalogItem, CatalogNotes, CatalogRelation, CatalogSource, CatalogStatus, + CatalogStructureColumn, CatalogTableRow, +} from '../../types' + +// =========================================================================== +// 数据目录界面的纯函数:清单的筛选和排序、单表目录摊平成「项」、编辑表单和目录之间的来回转换、表单校验。 +// 规则跟着服务端(backend/app/data/catalog.py)走:字段、长度上限、审阅路径的写法、各来源的初始状态都以那边为准, +// 这里只是让界面在提交前就说得出问题,最终以服务端的校验为准。 +// =========================================================================== + +export const TABLE_FIELDS = ['label', 'description', 'grain', 'keys', 'kind', 'business_date', 'valid_filter', 'dedup'] as const +export type TableField = typeof TABLE_FIELDS[number] +export const COLUMN_FIELDS = ['label', 'meaning', 'unit', 'measure', 'codes'] as const +export type ColumnField = typeof COLUMN_FIELDS[number] +export const STATUSES: readonly CatalogStatus[] = ['proposed', 'verified', 'confirmed', 'rejected'] + +/** 文字项的长度上限(服务端 _TABLE_VALUE_RULES / _COLUMN_VALUE_RULES) */ +const TABLE_MAX: Partial> = { label: 500, description: 2000, grain: 500, valid_filter: 500, dedup: 500 } +const COLUMN_MAX: Partial> = { label: 500, meaning: 500, unit: 50 } + +/** 各来源起草出来的项的初始状态(服务端 _INITIAL_STATUS):外键约束算有确证,人工填写即确认,其余都是推断 */ +export function initialStatus(source: CatalogSource): CatalogStatus { + return source === 'fk' ? 'verified' : source === 'human' ? 'confirmed' : 'proposed' +} + +// --------------------------------------------------------------------------- +// 审阅路径:表级项直接写字段名;列级项 columns.<列名>.<字段>(服务端按最后一个句点切,列名里可以有句点); +// 关系 relations.<编号> +// --------------------------------------------------------------------------- + +export const tablePath = (f: TableField) => f +export const columnPath = (col: string, f: ColumnField) => `columns.${col}.${f}` +export const relationPath = (id: string) => `relations.${id}` + +type AnyItem = Pick & { note?: string; updated_at?: string } + +/** 一张表的目录摊平成 [路径, 项]:表级项、列级项、关系,各自按原来的顺序 */ +export function slotsOf(notes: CatalogNotes | null | undefined): { path: string; item: AnyItem }[] { + const out: { path: string; item: AnyItem }[] = [] + if (!notes) return out + for (const f of TABLE_FIELDS) { + const it = notes[f] + if (isItem(it)) out.push({ path: tablePath(f), item: it }) + } + for (const [col, items] of Object.entries(notes.columns ?? {})) { + for (const f of COLUMN_FIELDS) { + const it = items?.[f] + if (isItem(it)) out.push({ path: columnPath(col, f), item: it }) + } + } + for (const rel of notes.relations ?? []) { + if (rel && rel.id) out.push({ path: relationPath(rel.id), item: rel }) + } + return out +} + +const isItem = (v: unknown): v is AnyItem => !!v && typeof v === 'object' && 'status' in (v as object) + +/** 各状态的项数,四种状态都有键(服务端 status_counts) */ +export function countsOf(notes: CatalogNotes | null | undefined): CatalogCounts { + const c: CatalogCounts = { proposed: 0, verified: 0, confirmed: 0, rejected: 0 } + for (const { item } of slotsOf(notes)) if (item.status in c) c[item.status]++ + return c +} + +/** 没被驳回的项的值 */ +function liveValue(item: CatalogItem | undefined): T | null { + return item && item.status !== 'rejected' ? item.value : null +} + +/** 写入或审阅之后,按返回的单表目录更新清单里的那一行(不必为一项审阅重取 150 多张表的清单) */ +export function rowFromDetail(d: CatalogDetail, prev: CatalogTableRow): CatalogTableRow { + const label = d.notes.label + return { + ...prev, + label: liveValue(label), + label_status: label && label.status !== 'rejected' ? label.status : null, + kind: liveValue(d.notes.kind), + counts: countsOf(d.notes), + relations: (d.notes.relations ?? []).filter((r) => r.status !== 'rejected').length, + version: d.version, + updated_at: d.updated_at, + updated_by: d.updated_by, + } +} + +// --------------------------------------------------------------------------- +// 清单:筛选、排序 +// --------------------------------------------------------------------------- + +export type ListFilter = 'all' | 'pending' | 'done' | 'none' +export const LIST_FILTERS: readonly ListFilter[] = ['all', 'pending', 'done', 'none'] +export type ListSort = 'usage' | 'pending' | 'name' +export const LIST_SORTS: readonly ListSort[] = ['usage', 'pending', 'name'] + +/** + * 一张表的审阅进度:pending 还有推断状态的项;done 有目录、没有推断项(已验证的外键关系不需要逐项确认, + * 全部驳回的也算审阅过);none 一项都没有。三者互斥 + */ +export function progressOf(c: CatalogCounts): Exclude { + if (c.proposed > 0) return 'pending' + return c.verified + c.confirmed + c.rejected > 0 ? 'done' : 'none' +} + +export function matchesQuery(r: CatalogTableRow, q: string): boolean { + const needle = q.trim().toLowerCase() + if (!needle) return true + return [r.table_name, r.qualified, r.label ?? ''].some((s) => s.toLowerCase().includes(needle)) +} + +/** + * 筛选并排序。默认保持服务端的顺序(使用次数多的在前,次数相同按表结构里的顺序);表结构里已经没有的表 + * 不论怎么排都在最后 + */ +export function visibleRows(rows: CatalogTableRow[], q: string, filter: ListFilter, sort: ListSort): CatalogTableRow[] { + const order = new Map(rows.map((r, i) => [r.table_name, i])) + const picked = rows.filter((r) => matchesQuery(r, q) && (filter === 'all' || progressOf(r.counts) === filter)) + const idx = (r: CatalogTableRow) => order.get(r.table_name) ?? 0 + return picked.sort((a, b) => { + if (a.in_schema !== b.in_schema) return a.in_schema ? -1 : 1 + if (sort === 'pending' && a.counts.proposed !== b.counts.proposed) return b.counts.proposed - a.counts.proposed + if (sort === 'name') return a.table_name.localeCompare(b.table_name, 'en') + return idx(a) - idx(b) + }) +} + +/** 各筛选项下有几张表(筛选按钮上的数字,按搜索词算) */ +export function filterCounts(rows: CatalogTableRow[], q: string): Record { + const out: Record = { all: 0, pending: 0, done: 0, none: 0 } + for (const r of rows) { + if (!matchesQuery(r, q)) continue + out.all++ + out[progressOf(r.counts)]++ + } + return out +} + +// --------------------------------------------------------------------------- +// 批量确认:把选中的推断项改成已确认,整份提交(服务端把「值没动、状态改成确认」当作单项审阅,来源不变) +// --------------------------------------------------------------------------- + +export function confirmProposed(notes: CatalogNotes, pick: (path: string) => boolean = () => true): { notes: CatalogNotes; n: number } { + const copy: CatalogNotes = structuredClone(notes) + let n = 0 + const touch = (path: string, it: AnyItem | undefined) => { + if (it && it.status === 'proposed' && pick(path)) { it.status = 'confirmed'; n++ } + } + for (const f of TABLE_FIELDS) touch(tablePath(f), copy[f] as AnyItem | undefined) + for (const [col, items] of Object.entries(copy.columns ?? {})) { + for (const f of COLUMN_FIELDS) touch(columnPath(col, f), items?.[f] as AnyItem | undefined) + } + for (const rel of copy.relations ?? []) touch(relationPath(rel.id), rel) + return { notes: copy, n } +} + +// --------------------------------------------------------------------------- +// 编辑表单:每一项一段文字。进入编辑时按目录生成(被驳回的项留空,原值放在占位里),提交时和进入时的文字逐项 +// 比较——没动的原样交回(保留来源和状态,被驳回的照旧驳回),改了的交新值(服务端记为人工填写、已确认), +// 清空的不交(服务端删掉这一项) +// --------------------------------------------------------------------------- + +/** 表级的表单键:业务日期拆成三格 */ +export const DATE_KEYS = ['business_date.column', 'business_date.rule', 'business_date.timezone'] as const +type DateKey = typeof DATE_KEYS[number] +export type TableKey = Exclude | DateKey + +export interface RelationDraft { + /** 表单里的键:已有的关系用编号,新加的用临时键 */ + key: string + orig: CatalogRelation | null + columns: string + to_table: string + to_columns: string + cardinality: string +} + +export interface EditForm { + table: Partial> + /** 列名 → 字段 → 文字 */ + columns: Record>> + relations: RelationDraft[] +} + +const splitList = (s: string) => s.split(/[、,,;;\s]+/).map((x) => x.trim()).filter(Boolean) +const joinList = (v: string[]) => v.join('、') + +function codesText(v: Record): string { + return Object.entries(v).map(([k, x]) => `${k}=${x}`).join('\n') +} + +/** 码值的文字:每行一个「码值=含义」(全角等号也认)。返回解析结果和第一处错误 */ +export function parseCodes(text: string): { value: Record; error: { line: number } | { dup: string } | null } { + const value: Record = {} + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue + const m = line.match(/^(.+?)\s*[==]\s*(.+)$/) + if (!m) return { value, error: { line: i + 1 } } + const code = m[1].trim() + if (code in value) return { value, error: { dup: code } } + value[code] = m[2].trim() + } + return { value, error: null } +} + +const itemText = (it: CatalogItem | undefined, render: (v: any) => string): string => + (it && it.status !== 'rejected' ? render(it.value) : '') + +function tableText(notes: CatalogNotes, k: TableKey): string { + if (k.startsWith('business_date.')) { + const d = notes.business_date + if (!d || d.status === 'rejected') return '' + return (d.value as any)?.[k.slice('business_date.'.length)] ?? '' + } + const f = k as Exclude + return itemText(notes[f] as CatalogItem | undefined, (v) => (f === 'keys' ? joinList(v) : String(v ?? ''))) +} + +function columnText(items: CatalogColumnNotes | undefined, f: ColumnField): string { + const it = items?.[f] as CatalogItem | undefined + return itemText(it, (v) => (f === 'codes' ? codesText(v) : String(v ?? ''))) +} + +const TABLE_KEYS: readonly TableKey[] = ['label', 'description', 'grain', 'keys', 'kind', ...DATE_KEYS, 'valid_filter', 'dedup'] + +export function formFromNotes(notes: CatalogNotes, structure: CatalogStructureColumn[]): EditForm { + const table: EditForm['table'] = {} + for (const k of TABLE_KEYS) table[k] = tableText(notes, k) + const columns: EditForm['columns'] = {} + const names = [...structure.map((c) => c.name), ...Object.keys(notes.columns ?? {})] + for (const col of names) { + if (columns[col]) continue + const items = notes.columns?.[col] + columns[col] = Object.fromEntries(COLUMN_FIELDS.map((f) => [f, columnText(items, f)])) + } + const relations = (notes.relations ?? []).map((r) => relationDraft(r)) + return { table, columns, relations } +} + +export function relationDraft(r: CatalogRelation | null, key?: string): RelationDraft { + return { + key: r?.id ?? key ?? `new-${Math.random().toString(36).slice(2, 8)}`, + orig: r, + columns: r ? r.columns.join(', ') : '', + to_table: r?.to_table ?? '', + to_columns: r ? r.to_columns.join(', ') : '', + cardinality: r?.cardinality ?? '', + } +} + +const sameRelation = (a: RelationDraft, b: RelationDraft) => + a.columns === b.columns && a.to_table === b.to_table && a.to_columns === b.to_columns && a.cardinality === b.cardinality + +/** 表单里改了几处:表级、列级各算一格,关系增删改各算一条 */ +export function changeCount(initial: EditForm, form: EditForm): number { + let n = 0 + for (const k of TABLE_KEYS) if ((initial.table[k] ?? '') !== (form.table[k] ?? '')) n++ + for (const [col, fields] of Object.entries(form.columns)) { + for (const f of COLUMN_FIELDS) if ((initial.columns[col]?.[f] ?? '') !== (fields[f] ?? '')) n++ + } + const before = new Map(initial.relations.map((r) => [r.key, r])) + const after = new Set(form.relations.map((r) => r.key)) + for (const r of form.relations) { + const b = before.get(r.key) + if (!b || !sameRelation(b, r)) n++ + } + for (const k of before.keys()) if (!after.has(k)) n++ + return n +} + +/** 表单的格式问题:键 t:<表单键> / c:<列名>:<字段> / r:<关系键> → 说明。空对象表示可以提交 */ +export interface FormProblems { [key: string]: string } + +export interface ProblemText { + tooLong: (n: number) => string + codesInvalid: (line: number) => string + codesDuplicate: (code: string) => string + dateColumnRequired: string + relationIncomplete: string + relationMismatch: string + relationDuplicate: string +} + +export function validateForm(form: EditForm, text: ProblemText): FormProblems { + const out: FormProblems = {} + for (const [k, max] of Object.entries(TABLE_MAX) as [TableKey, number][]) { + if ((form.table[k] ?? '').trim().length > max) out[`t:${k}`] = text.tooLong(max) + } + const [dc, dr, dt] = DATE_KEYS.map((k) => (form.table[k] ?? '').trim()) + if (!dc && (dr || dt)) out['t:business_date.column'] = text.dateColumnRequired + for (const [col, fields] of Object.entries(form.columns)) { + for (const [f, max] of Object.entries(COLUMN_MAX) as [ColumnField, number][]) { + if ((fields[f] ?? '').trim().length > max) out[`c:${col}:${f}`] = text.tooLong(max) + } + const codes = fields.codes ?? '' + if (codes.trim()) { + const { error } = parseCodes(codes) + if (error) out[`c:${col}:codes`] = 'line' in error ? text.codesInvalid(error.line) : text.codesDuplicate(error.dup) + } + } + const seen = new Map() + for (const r of form.relations) { + const cols = splitList(r.columns) + const to = splitList(r.to_columns) + if (!cols.length || !r.to_table.trim() || !to.length) { out[`r:${r.key}`] = text.relationIncomplete; continue } + if (cols.length !== to.length) { out[`r:${r.key}`] = text.relationMismatch; continue } + // 同一条关系(两端的表和列相同)服务端会算出同一个编号,重复提交会被拒收 + const sig = JSON.stringify([r.to_table.trim().toLowerCase(), + cols.map((c, i) => [c.toLowerCase(), to[i].toLowerCase()]).sort()]) + if (seen.has(sig)) out[`r:${r.key}`] = text.relationDuplicate + else seen.set(sig, r.key) + } + return out +} + +/** 业务主键里写了表结构中没有的列(只提醒,不拦:表结构会变,目录里先写着的列等结构同步后自然对上) */ +export function unknownKeys(text: string, structure: CatalogStructureColumn[]): string[] { + if (!structure.length) return [] + const names = new Set(structure.map((c) => c.name.toLowerCase())) + return splitList(text).filter((k) => !names.has(k.toLowerCase())) +} + +function parseTable(k: Exclude, text: string): unknown { + return k === 'keys' ? splitList(text) : text.trim() +} + +function parseColumn(f: ColumnField, text: string): unknown { + return f === 'codes' ? parseCodes(text).value : text.trim() +} + +/** + * 按表单生成要提交的整份目录。orig 是进入编辑时的目录,initial 是当时生成的表单。 + * 改过的项保留原来的来源、状态交上去(服务端发现值变了,一律改记人工填写、已确认,客户端写的来源和状态不作数) + */ +export function buildNotes(orig: CatalogNotes, initial: EditForm, form: EditForm): CatalogNotes { + const out: CatalogNotes = {} + const put = (o: CatalogItem | undefined, a: string, b: string, value: () => T): CatalogItem | undefined => { + if (a === b) return o + if (!b.trim()) return undefined + return o ? { ...o, value: value() } : ({ value: value() } as CatalogItem) + } + for (const f of TABLE_FIELDS) { + if (f === 'business_date') { + const a = DATE_KEYS.map((k) => initial.table[k] ?? '') + const b = DATE_KEYS.map((k) => (form.table[k] ?? '').trim()) + const same = a.every((x, i) => x === (form.table[DATE_KEYS[i]] ?? '')) + const o = orig.business_date + let next: CatalogItem | undefined + if (same) next = o + else if (!b.some(Boolean)) next = undefined + else { + const value = { column: b[0], ...(b[1] ? { rule: b[1] } : {}), ...(b[2] ? { timezone: b[2] } : {}) } + next = o ? { ...o, value } : ({ value } as CatalogItem) + } + if (next) out.business_date = next + continue + } + const o = orig[f] as CatalogItem | undefined + const next = put(o, initial.table[f] ?? '', form.table[f] ?? '', () => parseTable(f, form.table[f] ?? '')) + if (next) (out as Record)[f] = next + } + const columns: NonNullable = {} + const names = new Set([...Object.keys(orig.columns ?? {}), ...Object.keys(form.columns)]) + for (const col of names) { + const items: Record = {} + for (const f of COLUMN_FIELDS) { + const o = orig.columns?.[col]?.[f] as CatalogItem | undefined + const a = initial.columns[col]?.[f] ?? '' + const b = form.columns[col]?.[f] ?? a + const next = put(o, a, b, () => parseColumn(f, b)) + if (next) items[f] = next + } + if (Object.keys(items).length) columns[col] = items + } + if (Object.keys(columns).length) out.columns = columns + const before = new Map(initial.relations.map((r) => [r.key, r])) + const relations: CatalogRelation[] = [] + for (const r of form.relations) { + const b = before.get(r.key) + if (r.orig && b && sameRelation(b, r)) { relations.push(r.orig); continue } + relations.push({ + ...(r.orig ?? { coverage: null }), + // 编号由服务端按两端重算;新加的先给一个临时编号(不含句点) + id: r.orig?.id ?? r.key, + columns: splitList(r.columns), + to_table: r.to_table.trim(), + to_columns: splitList(r.to_columns), + cardinality: (r.cardinality || null) as CatalogRelation['cardinality'], + } as CatalogRelation) + } + if (relations.length) out.relations = relations + return out +} diff --git a/frontend/src/pages/catalog/parts.tsx b/frontend/src/pages/catalog/parts.tsx new file mode 100644 index 00000000..e3936f2b --- /dev/null +++ b/frontend/src/pages/catalog/parts.tsx @@ -0,0 +1,293 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { Check, CircleCheck, CircleDashed, CircleX, RotateCcw, ShieldCheck, Trash2, X } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import clsx from 'clsx' +import type { CatalogCounts, CatalogReviewAction, CatalogSource, CatalogStatus } from '../../types' +import { formatDateTime, formatNumber } from '../../lib/format' +import { + CATALOG_SOURCE_LABEL, CATALOG_STATUS_HINT, CATALOG_STATUS_LABEL, CATALOG_TEXT as CT, +} from '../../lib/terms' +import { STATUSES, initialStatus } from './model' + +// =========================================================================== +// 数据目录的公共小件:状态标识(四种状态一套样式,颜色走语义令牌)、清单上的状态计数、图例、 +// 单项的来源和状态弹层(确认 / 驳回 / 恢复)。 +// =========================================================================== + +/** + * 四种状态的颜色和图标。推断要人去看,用「等待」的琥珀;已验证(外键约束、数据剖析)是机器给的确证,用强调蓝; + * 已确认是人工拍板,用「完成」的绿;已驳回用「失败」的红,值划掉 + */ +export const STATUS_TONE: Record = { + proposed: 'var(--st-waiting)', + verified: 'var(--accent)', + confirmed: 'var(--st-done)', + rejected: 'var(--st-failed)', +} +const STATUS_ICON: Record = { + proposed: CircleDashed, + verified: ShieldCheck, + confirmed: CircleCheck, + rejected: CircleX, +} + +export function StatusIcon({ status, size = 12 }: { status: CatalogStatus; size?: number }) { + const Icon = STATUS_ICON[status] + return +} + +/** 状态的文字标识:图标 + 「推断」,可带来源「· 命名推断」 */ +export function StatusChip({ status, source, className }: { status: CatalogStatus; source?: CatalogSource; className?: string }) { + const tone = STATUS_TONE[status] + return ( + + + {CATALOG_STATUS_LABEL[status]} + {source && · {CATALOG_SOURCE_LABEL[source]}} + + ) +} + +/** 清单一行上的状态计数:只列有项的状态,读屏念「推断 12 项」 */ +export function CountBadges({ counts, className }: { counts: CatalogCounts; className?: string }) { + const shown = STATUSES.filter((s) => counts[s] > 0) + if (!shown.length) return null + return ( + counts[s]).join(',')}> + {shown.map((s) => ( + + + {formatNumber(counts[s])} + {CT.countTitle(CATALOG_STATUS_LABEL[s], counts[s])} + + ))} + + ) +} + +/** 四种状态的图例:列表格里只画图标,图例就近说明每种状态意味着什么 */ +export function StatusLegend({ className, detailed = false }: { className?: string; detailed?: boolean }) { + return ( +
      + {STATUSES.map((s) => ( +
    • + + + {CATALOG_STATUS_LABEL[s]} + {detailed && :{CATALOG_STATUS_HINT[s]}} + +
    • + ))} +
    + ) +} + +export interface ReviewTarget { + /** 审阅路径 */ + path: string + /** 给人看的位置:「表的粒度」「列 amount 的度量类型」 */ + where: string + source: CatalogSource + status: CatalogStatus + note?: string + updated_at?: string +} + +/** + * 单项的来源和状态。触发按钮就是状态标识(compact 时只有图标,列表格里每格一个);点开是一个小弹层: + * 位置、来源、状态说明、更新时间,以及确认 / 驳回 / 恢复。弹层挂在 body 上、按触发按钮的位置固定定位, + * 不会被表格的横向滚动裁掉;滚动或改窗口大小时收起。键盘:Enter 打开,焦点落到第一个可点的操作, + * ←→↑↓ 在操作之间移动,Tab 在弹层里循环,Esc 收起并把焦点还给触发按钮 + */ +export function ItemMark({ target, compact = false, disabled = false, onReview }: { + target: ReviewTarget + compact?: boolean + /** 正在提交别的操作:弹层照常能看,操作按钮禁用 */ + disabled?: boolean + onReview: (action: CatalogReviewAction) => void +}) { + const [open, setOpen] = useState(false) + const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + const trigger = useRef(null) + const panel = useRef(null) + const titleId = useId() + const { status, source } = target + const human = source === 'human' + // 非人工的项:已经是来源的初始状态时「恢复」没有意义 + const canReset = human || status !== initialStatus(source) + + const close = (focusBack = true) => { + setOpen(false) + setPos(null) + if (focusBack) trigger.current?.focus() + } + + // 先按触发按钮下方放,量过弹层的高度后,下方放不下就翻到上方 + useLayoutEffect(() => { + if (!open) return + const r = trigger.current?.getBoundingClientRect() + const el = panel.current + if (!r || !el) return + const w = el.offsetWidth + const h = el.offsetHeight + const left = Math.max(8, Math.min(r.left, window.innerWidth - w - 8)) + const below = r.bottom + 4 + const top = below + h > window.innerHeight - 8 && r.top - h - 4 > 8 ? r.top - h - 4 : below + setPos({ top, left }) + }, [open]) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent) => { + const t = e.target as Node + if (!panel.current?.contains(t) && !trigger.current?.contains(t)) close(false) + } + // 弹层自己里面的滚动不算(它不滚);页面或表格一滚,固定定位就对不上触发按钮了 + const onScroll = (e: Event) => { if (!panel.current?.contains(e.target as Node)) close(false) } + const onResize = () => close(false) + document.addEventListener('mousedown', onDown) + window.addEventListener('scroll', onScroll, true) + window.addEventListener('resize', onResize) + return () => { + document.removeEventListener('mousedown', onDown) + window.removeEventListener('scroll', onScroll, true) + window.removeEventListener('resize', onResize) + } + }, [open]) + + // 定好位置以后把焦点交给第一个能点的操作 + useEffect(() => { + if (!open || !pos) return + const first = panel.current?.querySelector('[data-review-action]:not([disabled])') + ;(first ?? panel.current)?.focus({ preventScroll: true }) + }, [open, pos]) + + const onKey = (e: ReactKeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + close() + return + } + const els = [...(panel.current?.querySelectorAll('button:not([disabled])') ?? [])] + if (!els.length) return + const i = els.indexOf(document.activeElement as HTMLButtonElement) + if (e.key === 'Tab') { + e.preventDefault() + els[(i + (e.shiftKey ? -1 : 1) + els.length) % els.length]?.focus() + } else if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'].includes(e.key)) { + e.preventDefault() + const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1 + els[(i + step + els.length) % els.length]?.focus() + } + } + + const act = (a: CatalogReviewAction) => { + close() + onReview(a) + } + + const label = `${target.where}:${CATALOG_STATUS_LABEL[status]},${CT.itemSource(CATALOG_SOURCE_LABEL[source])}` + const tone = STATUS_TONE[status] + return ( + <> + + {open && createPortal( +
    +
    {CT.itemTitle(target.where)}
    +
    +
    +
    状态
    +
    + + {CATALOG_STATUS_HINT[status]} +
    +
    +
    +
    来源
    +
    {CT.itemSource(CATALOG_SOURCE_LABEL[source])}
    +
    + {target.updated_at && ( +
    +
    更新时间
    +
    {CT.itemUpdated(formatDateTime(target.updated_at))}
    +
    + )} + {target.note && ( +
    +
    备注
    +
    {target.note}
    +
    + )} +
    +
    + } disabled={disabled || status === 'confirmed'} onClick={() => act('confirm')} action="confirm"> + {CT.confirm} + + } disabled={disabled || status === 'rejected'} onClick={() => act('reject')} action="reject"> + {CT.reject} + + : } disabled={disabled || !canReset} + onClick={() => act('reset')} action="reset" title={human ? CT.removeHint : CT.resetHint}> + {human ? CT.remove : CT.reset} + +
    +

    {human ? CT.removeHint : CT.resetHint}

    +
    , + document.body, + )} + + ) +} + +function ActionButton({ icon, children, disabled, onClick, action, title }: { + icon: ReactNode; children: ReactNode; disabled: boolean; onClick: () => void; action: CatalogReviewAction; title?: string +}) { + return ( + + ) +} From 2fd578e024e3306ad533f42aa3233354eee01b41 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:28:33 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=201C=EF=BC=9A=E5=8D=95=E8=A1=A8=E8=AF=A6?= =?UTF-8?q?=E6=83=85=EF=BC=8C=E8=A1=A8=E7=BA=A7=E9=A1=B9=E3=80=81=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E3=80=81=E5=85=B3=E8=81=94=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E7=9A=84=E6=9D=A5=E6=BA=90=E5=92=8C=E7=8A=B6=E6=80=81=EF=BC=8C?= =?UTF-8?q?=E5=8D=95=E9=A1=B9=E5=AE=A1=E9=98=85=E3=80=81=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E3=80=81=E7=BC=96=E8=BE=91=E5=8D=B3=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=EF=BC=8C409=20=E6=97=B6=E6=8F=90=E7=A4=BA=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E8=BD=BD=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/terms.ts | 6 +- frontend/src/pages/catalog/CatalogPage.tsx | 106 ++-- frontend/src/pages/catalog/ColumnTable.tsx | 211 +++++++ frontend/src/pages/catalog/TableDetail.tsx | 658 +++++++++++++++++++++ frontend/src/pages/catalog/TableIndex.tsx | 2 +- frontend/src/pages/catalog/model.ts | 2 +- frontend/src/pages/catalog/parts.tsx | 47 +- 7 files changed, 971 insertions(+), 61 deletions(-) create mode 100644 frontend/src/pages/catalog/ColumnTable.tsx create mode 100644 frontend/src/pages/catalog/TableDetail.tsx diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index b1b9d288..20e162ad 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2069,13 +2069,17 @@ export const CATALOG_TEXT = { save: '保存', saving: '正在保存…', cancelEdit: '取消', + cancelEditTitle: '放弃对这张表的修改?', + cancelEditAction: '放弃修改', + changedMark: '已修改', changes: (n: number) => `${formatNumber(n)} 处修改`, noChanges: '尚未修改', saved: '已保存,改动过的项已记为人工确认', invalidCount: (n: number) => `${formatNumber(n)} 处格式不正确`, keysPlaceholder: '列名,用顿号或逗号分隔', keysUnknown: (cols: string) => `表结构中没有这些列:${cols}`, - codesPlaceholder: '每行一个,如 1=已支付', + codesPlaceholder: '1=已支付', + codesHint: '每行一个,写成「码值=含义」', codesInvalid: (line: number) => `第 ${formatNumber(line)} 行应写成「码值=含义」`, codesDuplicate: (code: string) => `码值 ${code} 重复`, dateColumnRequired: '填写规则或时区时,日期列必填', diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index 68ae6e0e..d55df138 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -4,12 +4,13 @@ import { useNavigate, useParams } from 'react-router-dom' import { ArrowLeft, BookMarked, Database, Sparkles } from 'lucide-react' import clsx from 'clsx' import { ApiError, api } from '../../api/client' -import type { CatalogDetail, CatalogList } from '../../types' -import { EmptyState, ErrorState, Notice, PageHeader, Skeleton, toast } from '../../components/ui' +import type { CatalogDetail, CatalogList, CatalogTableRow } from '../../types' +import { EmptyState, ErrorState, PageHeader, Skeleton, toast } from '../../components/ui' import { useDatasources, useOnReconnect } from '../../store/catalog' import { CATALOG_TEXT as CT } from '../../lib/terms' -import { StatusLegend } from './parts' +import { StatusLegend, SystemNotesNotice } from './parts' import { DraftDialog } from './DraftDialog' +import { TableDetail } from './TableDetail' import { TableIndex } from './TableIndex' import { filterCounts, progressOf, rowFromDetail, visibleRows } from './model' import type { ListFilter, ListSort } from './model' @@ -86,6 +87,15 @@ export function CatalogPage() { void load() }, [load]) + // 上一张 / 下一张按清单当前的筛选和排序走;正在看的表不在筛选结果里(刚确认完、从关联关系跳过来)时按全部表走 + const [prev, next] = useMemo(() => { + if (!table) return [null, null] + const list = visible.some((r) => r.table_name === table) ? visible : visibleRows(rows, '', 'all', sort) + const i = list.findIndex((r) => r.table_name === table) + if (i < 0) return [null, null] + return [list[i - 1]?.table_name ?? null, list[i + 1]?.table_name ?? null] + }, [table, visible, rows, sort]) + const filtered = !!query.trim() || filter !== 'all' const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none') const hasTables = rows.length > 0 @@ -117,9 +127,6 @@ export function CatalogPage() {
    ) } else { - const pendingNext = rows.find((r) => r.in_schema && r.counts.proposed > 0) - const stats = { pending: 0, done: 0, none: 0 } - for (const r of rows) stats[progressOf(r.counts)]++ body = (
    -
    -
    - {empty - ? ( - } title={CT.emptyTitle} body={CT.emptyBody} offline={false} - action={draftButton(true)} className="!py-8" /> - ) - : ( -
    -

    {CT.overviewTitle}

    -

    {CT.overviewBody}

    -

    - {CT.overviewStats(rows.length, stats.pending, stats.done, stats.none)} -

    - - {pendingNext && ( -
    - - {CT.reviewNextHint} -
    - )} -
    - )} - {data.system_notes && } -
    -
    {CT.legend}
    - -
    -
    -
    + {table + ? ( + navigate(base)} onDetail={onDetail} /> + ) + : }
    ) @@ -218,14 +198,48 @@ export function CatalogPage() { ) } -/** 导入表格的源:说明是系统生成的,只读 */ -export function SystemNotesNotice() { +/** 没有选中表时的右栏:空目录时引导起草;否则写审阅进度,给出下一张该审的表 */ +function Overview({ rows, empty, systemNotes, draft, onOpen }: { + rows: CatalogTableRow[] + empty: boolean + systemNotes: boolean + draft: ReactNode + onOpen: (table: string) => void +}) { + const pendingNext = rows.find((r) => r.in_schema && r.counts.proposed > 0) + const stats = { pending: 0, done: 0, none: 0 } + for (const r of rows) stats[progressOf(r.counts)]++ return ( - -

    {CT.systemTitle}

    -

    {CT.systemBody}

    -

    {CT.systemCovered}

    -
    +
    +
    + {empty + ? } title={CT.emptyTitle} body={CT.emptyBody} offline={false} action={draft} className="!py-8" /> + : ( +
    +

    {CT.overviewTitle}

    +

    {CT.overviewBody}

    +

    + {CT.overviewStats(rows.length, stats.pending, stats.done, stats.none)} +

    + + {pendingNext && ( +
    + + {CT.reviewNextHint} +
    + )} +
    + )} + {systemNotes && } +
    +
    {CT.legend}
    + +
    +
    +
    ) } diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx new file mode 100644 index 00000000..36d04258 --- /dev/null +++ b/frontend/src/pages/catalog/ColumnTable.tsx @@ -0,0 +1,211 @@ +import { memo } from 'react' +import type { ReactNode } from 'react' +import { AlertTriangle, CheckCheck, Lock } from 'lucide-react' +import clsx from 'clsx' +import type { + CatalogColumnNotes, CatalogItem, CatalogMeasure, CatalogReviewAction, CatalogStructureColumn, +} from '../../types' +import { IconButton } from '../../components/ui' +import { + CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, +} from '../../lib/terms' +import { ItemMark } from './parts' +import type { ReviewTarget } from './parts' +import { COLUMN_FIELDS, columnPath } from './model' +import type { ColumnField } from './model' + +// =========================================================================== +// 单表详情的列表格:列名 / 类型、中文名、含义、单位、度量类型、码值。每格的值旁边是只有图标的状态标识,点开看来源、 +// 做确认 / 驳回 / 恢复;有推断项的行末尾有「确认本列推断」。编辑时每格换成输入框。 +// 一张表 50 列时每次按键都会重画整张表:行是 memo 的,只有改动的那一行重画。 +// =========================================================================== + +const MEASURES = Object.keys(CATALOG_MEASURE_LABEL) as CatalogMeasure[] +const CODES_SHOWN = 4 + +export interface ColumnModel { + name: string + /** 表结构里的这一列;表结构里已经没有、目录还留着的为 null */ + structure: CatalogStructureColumn | null + items: CatalogColumnNotes | undefined +} + +type ColumnTexts = Partial> + +export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: { + columns: ColumnModel[] + /** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */ + form: Record | null + initial: Record | null + /** 格式问题:键 c:<列名>:<字段> */ + problems: Record + busy: boolean + /** 导入表格的源:列的说明是系统生成的,只读地写在列名下面 */ + systemNotes: boolean + onReview: (target: ReviewTarget, action: CatalogReviewAction) => void + onConfirmColumn: (col: string) => void + onChange: (col: string, field: ColumnField, value: string) => void +}) { + return ( +
    + + + + + + + + + + + + + + {columns.map((c) => ( + + ))} + +
    {CT.columnHead.name} / {CT.columnHead.type}{CT.columnHead.label}{CT.columnHead.meaning}{CT.columnHead.unit}{CT.columnHead.measure} + {CT.columnHead.codes} + {form && · {CT.codesHint}} + {CT.confirmColumn}
    +
    + ) +} + +const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: { + col: ColumnModel + editing: boolean + /** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */ + draft: ColumnTexts | undefined + initial: ColumnTexts | undefined + problems: Record + busy: boolean + systemNotes: boolean + onReview: (target: ReviewTarget, action: CatalogReviewAction) => void + onConfirmColumn: (col: string) => void + onChange: (col: string, field: ColumnField, value: string) => void +}) { + const { name, structure, items } = col + const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length + const comment = systemNotes ? structure?.comment?.trim() : '' + const cell = (f: ColumnField, view: ReactNode, input: ReactNode) => { + const it = items?.[f] as CatalogItem | undefined + const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f]) + const problem = problems[`c:${name}:${f}`] + const changed = editing && (draft?.[f] ?? '') !== (initial?.[f] ?? '') + return ( + +
    +
    {editing ? input : view}
    + {it && !changed && ( + + onReview({ path: columnPath(name, f), where, source: it.source, status: it.status }, a)} /> + + )} +
    + {problem &&
    {problem}
    } + + ) + } + const text = (f: ColumnField, opts?: { mono?: boolean; narrow?: boolean }) => { + const it = items?.[f] as CatalogItem | undefined + const rejected = it?.status === 'rejected' + const value = draft?.[f] ?? '' + return cell( + f, + {v}} />, + onChange(name, f, e.target.value)} + data-input={f} + />, + ) + } + const measure = items?.measure + const codes = items?.codes + return ( + + +
    + {name} + {structure?.pk && {CT.pk}} +
    + {structure &&
    {structure.type || '—'}
    } + {comment && ( +
    + + {CT.systemTag}:{comment} +
    + )} + {!structure && ( +
    + {CT.columnMissing} +
    + )} + + {text('label')} + {text('meaning')} + {text('unit')} + {cell( + 'measure', + {CATALOG_MEASURE_LABEL[v as CatalogMeasure] ?? v}} />, + , + )} + {cell( + 'codes', + } />} />, +