合入数据目录阶段 1C:数据目录界面——表清单筛选排序与分批起草,表详情逐项确认、驳回、编辑,乐观锁冲突可重新载入,导入表格的说明只读展示

This commit is contained in:
ThearchyHelios
2026-10-03 10:58:19 +08:00
15 changed files with 3874 additions and 7 deletions
+4
View File
@@ -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() {
<Route path="/runs/:runId" element={<RunsPage />} />
<Route path="/data" element={<DataPage />} />
<Route path="/data/:tab" element={<DataPage />} />
{/* 数据源的数据目录:两段以上的 /data 地址不会落到上面的标签页 */}
<Route path="/data/catalog/:sourceId" element={<CatalogPage />} />
<Route path="/data/catalog/:sourceId/:table" element={<CatalogPage />} />
<Route path="/tools" element={<ToolsPage />} />
<Route path="/tools/:tab" element={<ToolsPage />} />
<Route path="/tools/:tab/:id" element={<ToolsPage />} />
+30 -1
View File
@@ -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<PurgeRawOut>(`/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<CatalogList>(`/datasources/${encodeURIComponent(sourceId)}/catalog`, opts),
/** 单表目录连同表结构 */
get: (sourceId: string, table: string, opts?: RequestOptions) =>
get<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}`, opts),
/** 整份提交:值改过的项、新填的项记为人工填写、已确认;提交里没有的项删掉 */
put: (sourceId: string, table: string, notes: CatalogNotes, ifVersion: number) =>
put<CatalogDetail>(`/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<CatalogDetail>(`/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<CatalogDraftOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`,
{ method: 'POST', body: JSON.stringify(body), ...opts }),
},
runs: {
/**
* status 可以给多个(逗号连接);q 按工作流名模糊匹配;before 是翻页游标
+3 -2
View File
@@ -1597,8 +1597,9 @@ export function PageHeader({ icon, title, subtitle, actions }: {
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-elev text-dim" aria-hidden>
{icon}
</span>
<h1 className="shrink-0 text-sm font-semibold">{title}</h1>
{subtitle && <p className="min-w-0 truncate text-xs text-faint" title={subtitle}>{subtitle}</p>}
{/* 放不下时先收说明(shrink 权重大得多),说明收没了标题再截断,不把整行撑出视口 */}
<h1 className="min-w-0 truncate text-sm font-semibold" title={title}>{title}</h1>
{subtitle && <p className="min-w-0 shrink-[1000] truncate text-xs text-faint" title={subtitle}>{subtitle}</p>}
<span className="flex-1" />
{actions}
</header>
+312 -1
View File
@@ -42,7 +42,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 = {
@@ -1837,3 +1839,312 @@ export const RECIPE_CHOICE_LABEL = {
colStore: { '': '默认', canonical: '规范写法', raw: '原文' } as Record<string, string>,
axisChecks: { contiguous: '逐日连续', covers_context: '恰好覆盖统计期' } as Record<string, string>,
}
// ===========================================================================
// 业务数据目录(数据源卡片上的「数据目录」)
//
// 术语:
// - 「数据目录」:每张表、每一列的业务说明和表之间的关联关系。只记数据事实,不放计算公式(公式在口径卡里)。
// - 表级:中文名、说明、粒度(一行代表什么)、业务主键、表类型、业务日期、有效记录条件、去重规则。
// - 列级:中文名、含义、单位、度量类型、码值。度量类型的 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<CatalogStatus, string> = {
proposed: '推断',
verified: '已验证',
confirmed: '已确认',
rejected: '已驳回',
}
export const CATALOG_STATUS_HINT: Record<CatalogStatus, string> = {
proposed: '只作提示,确认后才参与 SQL 检查',
verified: '有确证(外键约束或数据剖析),参与 SQL 检查',
confirmed: '人工确认,参与 SQL 检查',
rejected: '人工驳回,不提供给助手,之后起草也不会再提出',
}
export const CATALOG_SOURCE_LABEL: Record<CatalogSource, string> = {
comment: '数据库注释',
fk: '外键约束',
name: '命名推断',
profile: '数据剖析',
llm: '模型起草',
human: '人工填写',
}
export const CATALOG_KIND_LABEL: Record<CatalogTableKind, string> = {
fact: '明细表',
dimension: '维度表',
snapshot: '快照表',
log: '日志表',
config: '配置表',
}
export const CATALOG_KIND_HINT: Record<CatalogTableKind, string> = {
fact: '一行是一笔业务事件,如订单、入园、销售',
dimension: '描述业务对象,如门店、票种、渠道',
snapshot: '按时点记录的状态,如每日库存',
log: '系统操作或变更记录',
config: '参数、映射等配置',
}
export const CATALOG_MEASURE_LABEL: Record<CatalogMeasure, string> = {
flow: '可累加',
stock: '存量',
ratio: '比率',
identifier: '标识',
status: '状态',
attribute: '属性',
}
export const CATALOG_MEASURE_HINT: Record<CatalogMeasure, string> = {
flow: '可以跨期加总,如金额、人数',
stock: '时点值,不能跨期加总,如库存、余额',
ratio: '不能直接加总,如折扣率、转化率',
identifier: '编号或外键,不参与计算',
status: '状态码,按码值解释',
attribute: '描述性属性,如名称、类别',
}
export const CATALOG_CARDINALITY_LABEL: Record<CatalogCardinality, string> = {
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<keyof typeof CATALOG_TABLE_FIELD_LABEL, string> = {
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}`,
reviewWhileEditing: '正在编辑这张表,保存或取消后再逐项审阅',
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: '取消',
cancelEditTitle: '放弃对这张表的修改?',
cancelEditAction: '放弃修改',
changedMark: '已修改',
changes: (n: number) => `${formatNumber(n)} 处修改`,
noChanges: '尚未修改',
saved: '已保存,改动过的项已记为人工确认',
invalidCount: (n: number) => `${formatNumber(n)} 处格式不正确`,
keysPlaceholder: '列名,用顿号或逗号分隔',
keysUnknown: (cols: string) => `表结构中没有这些列:${cols}`,
codesPlaceholder: '1=已支付',
codesHint: '每行一个,写成「码值=含义」',
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: '放弃修改并重新载入',
}
+8 -2
View File
@@ -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
<History size={11} aria-hidden /> {VERSIONS_TEXT.open}
</button>
)}
{/* 数据目录是独立页面:一个库上百张表、一张表几十列,逐表审阅要整屏的宽度。窄屏只留图标 */}
<button className="btn btn-sm btn-ghost" onClick={() => navigate(`/data/catalog/${encodeURIComponent(row.id)}`)}
title={CATALOG_TEXT.openHint} aria-label={CATALOG_TEXT.open} data-catalog-open="">
<BookMarked size={11} aria-hidden /> <span className="hidden sm:inline">{CATALOG_TEXT.open}</span>
</button>
{uploaded && row.import_mode === 'recipe' ? (
// 按配方导入的源:每月按已确认的配方重放,不走简单上传(服务端也会拒绝)
<>
+265
View File
@@ -0,0 +1,265 @@
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, 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, 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'
// ===========================================================================
// 数据目录页(/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<CatalogList | null>(null)
const [loadError, setLoadError] = useState<unknown>(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<ListFilter>('all')
const [sort, setSort] = useState<ListSort>('usage')
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [drafting, setDrafting] = useState(false)
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
const [drafted, setDrafted] = useState<{ seq: number; tables: Set<string> }>({ 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 [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
const draftButton = (primary = false) => (
<button type="button" className={clsx('btn btn-sm', primary && 'btn-primary')} onClick={() => setDrafting(true)} data-catalog-draft="">
<Sparkles size={11} aria-hidden /> {CT.draft}
</button>
)
// 页头的「起草」窄屏只留图标,标题才放得下
const headerDraft = (
<button type="button" className="btn btn-sm" onClick={() => setDrafting(true)} aria-label={CT.draft} title={CT.draft} data-catalog-draft="">
<Sparkles size={11} aria-hidden /> <span className="hidden sm:inline">{CT.draft}</span>
</button>
)
let body: ReactNode
if (!data) {
body = loadError
? (loadError instanceof ApiError && loadError.status === 404
? <EmptyState icon={<BookMarked size={22} />} title={CT.sourceMissing} body={CT.sourceMissingBody} offline={false}
action={<button className="btn btn-sm" onClick={() => navigate('/data')}>{CT.back}</button>} />
: <div className="mx-auto max-w-xl p-6"><ErrorState error={loadError} onRetry={() => void load()} /></div>)
: <div className="p-4"><Skeleton rows={8} height={36} gap={8} /></div>
} else if (!hasTables) {
// 没有表结构:先去探查。上传的表格没有「探查结构」,只能重新上传
body = (
<div data-catalog-no-schema="">
<EmptyState
icon={<Database size={22} />}
title={CT.noSchemaTitle}
body={source?.origin === 'upload' ? CT.noSchemaUploadBody : CT.noSchemaBody(data.schema_note ?? '')}
offline={false}
action={<button className="btn btn-sm btn-primary" onClick={() => navigate(back)} data-catalog-go-introspect="">{CT.back}</button>}
/>
</div>
)
} else {
body = (
<div className="flex min-h-0 flex-1">
<aside className={clsx('min-h-0 w-full flex-col border-r lg:flex lg:w-[380px] lg:shrink-0 xl:w-[420px]', table ? 'hidden' : 'flex')}>
{empty && (
// 窄屏没有右栏的概览,空目录的下一步写在清单上面
<div className="border-b px-3 py-2.5 text-xs lg:hidden" data-catalog-empty-banner="">
<p className="font-medium">{CT.emptyTitle}</p>
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{CT.emptyBody}</p>
<div className="mt-2">{draftButton(true)}</div>
</div>
)}
<TableIndex
rows={visible}
total={rows.length}
counts={counts}
query={query}
onQuery={setQuery}
filter={filter}
onFilter={setFilter}
sort={sort}
onSort={setSort}
selected={selected}
onSelected={setSelected}
active={table}
onOpen={openTable}
onDraft={() => setDrafting(true)}
/>
</aside>
<section className={clsx('min-h-0 min-w-0 flex-1 flex-col lg:flex', table ? 'flex' : 'hidden')} aria-label={table ?? CT.overviewTitle}>
{table
? (
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} prev={prev} next={next}
onOpen={openTable} onBack={() => navigate(base)} onDetail={onDetail} />
)
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
</section>
</div>
)
}
return (
<div className="flex h-full flex-col" data-catalog-page={sourceId}>
<PageHeader
icon={<BookMarked size={13} />}
title={CT.title(source?.name ?? sourceId)}
subtitle={CT.subtitle}
actions={(
<div className="flex shrink-0 items-center gap-1.5">
{hasTables && headerDraft}
<button type="button" className="btn btn-sm btn-ghost" onClick={() => navigate(back)} aria-label={CT.back} data-catalog-back="">
<ArrowLeft size={12} aria-hidden /> <span className="hidden sm:inline">{CT.back}</span>
</button>
</div>
)}
/>
{body}
{drafting && data && (
<DraftDialog
sourceId={sourceId}
rows={rows}
visible={visible}
selected={[...selected]}
filtered={filtered}
onClose={() => setDrafting(false)}
onDrafted={onDrafted}
/>
)}
</div>
)
}
/** 没有选中表时的右栏:空目录时引导起草;否则写审阅进度,给出下一张该审的表 */
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 (
<div className="relative min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-xl space-y-4 p-6" data-catalog-overview="">
{empty
? <EmptyState icon={<BookMarked size={22} />} title={CT.emptyTitle} body={CT.emptyBody} offline={false} action={draft} className="!py-8" />
: (
<div className="space-y-3">
<h2 className="text-sm font-semibold">{CT.overviewTitle}</h2>
<p className="text-xs leading-relaxed text-dim">{CT.overviewBody}</p>
<p className="tnum text-xs text-dim" data-catalog-stats={`${stats.pending},${stats.done},${stats.none}`}>
{CT.overviewStats(rows.length, stats.pending, stats.done, stats.none)}
</p>
<ProgressBar pending={stats.pending} done={stats.done} none={stats.none} />
{pendingNext && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<button type="button" className="btn btn-primary btn-sm" onClick={() => onOpen(pendingNext.table_name)}
data-catalog-review-next={pendingNext.table_name}>
{CT.reviewNext(pendingNext.label ?? pendingNext.table_name)}
</button>
<span className="text-2xs text-faint">{CT.reviewNextHint}</span>
</div>
)}
</div>
)}
{systemNotes && <SystemNotesNotice />}
<div className="rounded-lg border bg-panel p-3">
<div className="mb-2 text-2xs font-medium text-dim">{CT.legend}</div>
<StatusLegend detailed />
</div>
</div>
</div>
)
}
/** 审阅进度:有未确认项 / 全部已确认 / 没有目录的表各占多少 */
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
? <span className="h-full" style={{ width: `${(n / total) * 100}%`, background: color }} title={`${label} ${n}`} />
: null)
return (
<div className="flex h-1.5 overflow-hidden rounded-full bg-hover" aria-hidden>
{seg(done, 'var(--st-done)', CT.filter.done)}
{seg(pending, 'var(--st-waiting)', CT.filter.pending)}
{seg(none, 'var(--border-strong)', CT.filter.none)}
</div>
)
}
+211
View File
@@ -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<Record<ColumnField, string>>
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: {
columns: ColumnModel[]
/** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */
form: Record<string, ColumnTexts> | null
initial: Record<string, ColumnTexts> | null
/** 格式问题:键 c:<列名>:<字段> */
problems: Record<string, string>
busy: boolean
/** 导入表格的源:列的说明是系统生成的,只读地写在列名下面 */
systemNotes: boolean
onReview: (target: ReviewTarget, action: CatalogReviewAction) => void
onConfirmColumn: (col: string) => void
onChange: (col: string, field: ColumnField, value: string) => void
}) {
return (
<div className="relative overflow-x-auto rounded-lg border" data-catalog-columns="">
<table className="w-full min-w-[860px] table-fixed border-collapse text-xs">
<thead>
<tr className="border-b bg-elev text-left text-2xs text-faint">
<th scope="col" className="w-[21%] px-3 py-2 font-medium">{CT.columnHead.name} / {CT.columnHead.type}</th>
<th scope="col" className="w-[14%] px-2 py-2 font-medium">{CT.columnHead.label}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.columnHead.meaning}</th>
<th scope="col" className="w-[8%] px-2 py-2 font-medium">{CT.columnHead.unit}</th>
<th scope="col" className="w-[11%] px-2 py-2 font-medium">{CT.columnHead.measure}</th>
<th scope="col" className="w-[17%] px-2 py-2 font-medium" title={form ? CT.codesHint : undefined}>
{CT.columnHead.codes}
{form && <span className="ml-1 font-normal">· {CT.codesHint}</span>}
</th>
<th scope="col" className="w-9 px-1 py-2"><span className="sr-only">{CT.confirmColumn}</span></th>
</tr>
</thead>
<tbody>
{columns.map((c) => (
<ColumnRow key={c.name} col={c} editing={!!form} draft={form?.[c.name]} initial={initial?.[c.name]} problems={problems} busy={busy}
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onChange={onChange} />
))}
</tbody>
</table>
</div>
)
}
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<string, string>
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 (
<td className="px-2 py-1.5 align-top" data-cell={f} data-status={it?.status}>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">{editing ? input : view}</div>
{it && !changed && (
<span className="pt-0.5">
<ItemMark compact disabled={busy || editing} disabledHint={editing ? CT.reviewWhileEditing : undefined}
target={{ path: columnPath(name, f), where, source: it.source, status: it.status, note: it.note, updated_at: it.updated_at }}
onReview={(a) => onReview({ path: columnPath(name, f), where, source: it.source, status: it.status }, a)} />
</span>
)}
</div>
{problem && <div className="mt-1 text-2xs leading-relaxed text-[var(--err)]" role="alert">{problem}</div>}
</td>
)
}
const text = (f: ColumnField, opts?: { mono?: boolean; narrow?: boolean }) => {
const it = items?.[f] as CatalogItem<string> | undefined
const rejected = it?.status === 'rejected'
const value = draft?.[f] ?? ''
return cell(
f,
<Value item={it} render={(v) => <span className={clsx('whitespace-pre-wrap break-words', opts?.mono && 'mono')}>{v}</span>} />,
<input
className={clsx('field !px-1.5 !py-1', opts?.mono && 'mono')}
value={value}
placeholder={rejected ? CT.rejectedPlaceholder(String(it?.value ?? '')) : ''}
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f])}
aria-invalid={problems[`c:${name}:${f}`] ? true : undefined}
onChange={(e) => onChange(name, f, e.target.value)}
data-input={f}
/>,
)
}
const measure = items?.measure
const codes = items?.codes
return (
<tr className="border-b border-hairline last:border-b-0 hover:bg-hover/40" data-column={name} data-pending={pending}>
<th scope="row" className="px-3 py-1.5 text-left align-top font-normal">
<div className="flex flex-wrap items-center gap-1">
<span className="mono text-xs font-medium text-fg [overflow-wrap:anywhere]">{name}</span>
{structure?.pk && <span className="chip !px-1.5 !py-0 !text-2xs">{CT.pk}</span>}
</div>
{structure && <div className="mono mt-0.5 truncate text-2xs text-faint" title={structure.type}>{structure.type || '—'}</div>}
{comment && (
<div className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-dim" data-system-note="" title={CT.systemTag}>
<Lock size={10} className="mt-[3px] shrink-0 text-faint" aria-hidden />
<span><span className="text-faint">{CT.systemTag}:</span>{comment}</span>
</div>
)}
{!structure && (
<div className="mt-1 flex items-center gap-1 text-2xs text-[var(--warn)]" data-column-missing="">
<AlertTriangle size={10} aria-hidden /> {CT.columnMissing}
</div>
)}
</th>
{text('label')}
{text('meaning')}
{text('unit')}
{cell(
'measure',
<Value item={measure} render={(v) => <span title={CATALOG_MEASURE_HINT[v as CatalogMeasure]}>{CATALOG_MEASURE_LABEL[v as CatalogMeasure] ?? v}</span>} />,
<select className="field !px-1 !py-1" value={draft?.measure ?? ''}
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL.measure)}
onChange={(e) => onChange(name, 'measure', e.target.value)} data-input="measure">
<option value="">{CT.measureNone}</option>
{MEASURES.map((m) => <option key={m} value={m}>{CATALOG_MEASURE_LABEL[m]}</option>)}
</select>,
)}
{cell(
'codes',
<Value item={codes} render={(v) => <Codes value={v as Record<string, string>} />} />,
<textarea className="field mono !min-h-0 !px-1.5 !py-1 !text-2xs" rows={Math.min(4, Math.max(1, (draft?.codes ?? '').split('\n').length))}
value={draft?.codes ?? ''}
placeholder={codes?.status === 'rejected' ? CT.rejectedPlaceholder(codesInline(codes.value)) : CT.codesPlaceholder}
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL.codes)}
aria-invalid={problems[`c:${name}:codes`] ? true : undefined}
onChange={(e) => onChange(name, 'codes', e.target.value)} data-input="codes" />,
)}
<td className="px-1 py-1.5 align-top">
{!editing && pending > 0 && (
<IconButton label={CT.confirmColumnLabel(name, pending)} icon={<CheckCheck size={13} />} disabled={busy}
onClick={() => onConfirmColumn(name)} data-confirm-column={name} />
)}
</td>
</tr>
)
}, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy
&& a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onChange === b.onChange
// 格式问题每次都是新对象:只比这一列的几格
&& COLUMN_FIELDS.every((f) => a.problems[`c:${a.col.name}:${f}`] === b.problems[`c:${b.col.name}:${f}`]))
const codesInline = (v: Record<string, string> | undefined) => Object.entries(v ?? {}).map(([k, x]) => `${k}=${x}`).join(';')
/** 一项的值:没有写「—」;被驳回的划掉、淡色 */
export function Value<T>({ item, render }: { item: CatalogItem<T> | undefined; render: (v: T) => ReactNode }) {
if (!item) return <span className="text-faint"><span aria-hidden>—</span><span className="sr-only">{CT.empty}</span></span>
return <span className={clsx(item.status === 'rejected' && 'text-faint line-through')}>{render(item.value)}</span>
}
function Codes({ value }: { value: Record<string, string> }) {
const entries = Object.entries(value)
return (
<span className="flex flex-wrap gap-1" title={entries.map(([k, v]) => `${k}=${v}`).join('\n')}>
{entries.slice(0, CODES_SHOWN).map(([k, v]) => (
<span key={k} className="inline-flex max-w-full items-center gap-1 rounded border bg-bg px-1 text-2xs">
<span className="mono text-dim">{k}</span>
<span className="truncate">{v}</span>
</span>
))}
{entries.length > CODES_SHOWN && <span className="text-2xs text-faint">+{entries.length - CODES_SHOWN}</span>}
</span>
)
}
+220
View File
@@ -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<Scope>(() => scopes[0].key)
const [useModel, setUseModel] = useState(false)
const [phase, setPhase] = useState<Phase>('setup')
const [out, setOut] = useState<Outcome>(() => 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'
? (
<>
<button className="btn" onClick={onClose}>{CT.cancel}</button>
<button className="btn btn-primary" onClick={() => void run()} disabled={!tables.length} data-draft-start="">
<Sparkles size={12} aria-hidden /> {CT.draftStart(tables.length)}
</button>
</>
)
: phase === 'running'
? <button className="btn" onClick={stop} disabled={stopping} data-draft-stop="">{CT.draftStop}</button>
: <button className="btn btn-primary" onClick={onClose} data-autofocus data-draft-close="">{CT.draftClose}</button>
return (
<Modal open onClose={requestClose} title={CT.draftTitle} width={560} footer={footer}>
<div className="space-y-4" data-draft-dialog={phase}>
{phase === 'setup' && (
<>
<fieldset>
<legend className="label">{CT.draftScope}</legend>
<div className="space-y-1.5" role="radiogroup" aria-label={CT.draftScope}>
{scopes.map((s) => (
<label key={s.key} className={clsx('flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-xs',
scope === s.key ? 'border-[var(--accent)] bg-accent-soft' : 'hover:bg-hover')} data-draft-scope={s.key}>
<input type="radio" name="catalog-draft-scope" checked={scope === s.key} onChange={() => setScope(s.key)} />
<span>{s.label}</span>
</label>
))}
</div>
</fieldset>
<p className="text-2xs leading-relaxed text-faint">{CT.draftBase}</p>
<label className="flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2.5 text-xs hover:bg-hover" data-draft-model="">
<input type="checkbox" className="mt-0.5" checked={useModel} onChange={(e) => setUseModel(e.target.checked)} />
<span className="min-w-0">
<span className="font-medium">{CT.useModel}</span>
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{CT.useModelHint}</span>
</span>
</label>
</>
)}
{phase !== 'setup' && (
<div className="space-y-3" aria-live="polite">
<div className="flex items-center gap-2 text-xs">
{phase === 'running' && <Spinner size={13} />}
<span className="font-medium" data-draft-title="">
{phase === 'running'
? (stopping ? CT.draftStopping : CT.drafting)
: out.failure ? CT.draftFailed : out.stopped ? CT.draftStopped(out.done, out.total) : CT.draftDone}
</span>
<span className="flex-1" />
<span className="tnum text-2xs text-faint" data-draft-progress={`${out.done}/${out.total}`}>{CT.draftProgress(out.done, out.total)}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={CT.draftProgress(out.done, out.total)}
aria-valuemin={0} aria-valuemax={out.total} aria-valuenow={out.done}>
<div className="h-full rounded-full transition-[width] duration-300"
style={{ width: `${out.total ? (out.done / out.total) * 100 : 0}%`, background: out.failure ? 'var(--err)' : 'var(--accent)' }} />
</div>
<p className="tnum text-sm" data-draft-summary="">
{out.added + out.updated + out.removed === 0 && phase === 'done' && !out.failure
? CT.draftNoChange
: CT.draftSummary(out.added, out.updated, out.removed)}
</p>
{out.model && <p className="text-2xs text-faint">{CT.draftModelUsed(out.model)}</p>}
{out.modelSkipped && (
<Notice tone="warn" attr={{ 'data-draft-model-error': '' }}>{CT.draftModelSkipped(out.modelSkipped)}</Notice>
)}
{out.failure && <Notice tone="err" attr={{ 'data-draft-failure': '' }}>{out.failure}</Notice>}
{out.tableErrors.length > 0 && (
<Notice tone="err" attr={{ 'data-draft-table-errors': String(out.tableErrors.length) }}>
<p>{CT.draftTableErrors(out.tableErrors.length)}</p>
<ErrorList rows={out.tableErrors} pick={(r) => r.error ?? ''} />
</Notice>
)}
{out.modelErrors.length > 0 && (
<Notice tone="warn" attr={{ 'data-draft-table-model-errors': String(out.modelErrors.length) }}>
<p>{CT.draftModelErrors(out.modelErrors.length)}</p>
<ErrorList rows={out.modelErrors} pick={(r) => r.model_error ?? ''} />
</Notice>
)}
</div>
)}
</div>
</Modal>
)
}
function ErrorList({ rows, pick }: { rows: CatalogDraftRow[]; pick: (r: CatalogDraftRow) => string }) {
return (
<ul className="mt-1 space-y-0.5 text-dim">
{rows.slice(0, LIST_MAX).map((r) => (
<li key={r.table_name} className="break-words"><span className="mono">{r.table_name}</span>:{pick(r)}</li>
))}
{rows.length > LIST_MAX && <li>{CT.moreTables(rows.length - LIST_MAX)}</li>}
</ul>
)
}
+662
View File
@@ -0,0 +1,662 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, ListChecks, Lock, Pencil, Plus, RotateCw, Search } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
import type {
CatalogBusinessDate, CatalogCardinality, CatalogDetail, CatalogItem, CatalogRelation, CatalogReviewAction, CatalogTableKind,
CatalogTableRow,
} from '../../types'
import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confirmDialog, toast } from '../../components/ui'
import { useLeaveGuard } from '../../lib/leave'
import { formatDateTime, formatNumber } from '../../lib/format'
import {
CATALOG_CARDINALITY_LABEL, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT, CATALOG_TABLE_FIELD_LABEL,
CATALOG_TEXT as CT,
} from '../../lib/terms'
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
import type { ReviewTarget } from './parts'
import { ColumnTable, Value } from './ColumnTable'
import type { ColumnModel } from './ColumnTable'
import {
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
relationDraft, relationPath, tablePath, unknownKeys, validateForm,
} from './model'
import type { ColumnField, EditForm, RelationDraft, TableField, TableKey } from './model'
// ===========================================================================
// 单表详情:表级各项、列表格、关联关系。每一项标出来源和状态,点状态标识做单项确认 / 驳回 / 恢复(审阅接口);
// 「确认本表全部推断」「确认本列推断」和直接编辑都是整份提交(编辑即确认:改过的项服务端记为人工填写、已确认)。
//
// 写操作都带读到的版本。409 说明别人刚改过:不静默覆盖,横幅写明并给「重新载入」;编辑中的修改留着,重新载入前
// 先问一句。编辑中换表、离开页面走全站的离开前确认。
// ===========================================================================
const KINDS = Object.keys(CATALOG_KIND_LABEL) as CatalogTableKind[]
const CARDINALITIES = Object.keys(CATALOG_CARDINALITY_LABEL) as CatalogCardinality[]
const PROBLEM_TEXT = {
tooLong: CT.tooLong,
codesInvalid: CT.codesInvalid,
codesDuplicate: CT.codesDuplicate,
dateColumnRequired: CT.dateColumnRequired,
relationIncomplete: CT.relationIncomplete,
relationMismatch: CT.relationMismatch,
relationDuplicate: CT.relationDuplicate,
}
type Editing = { initial: EditForm; form: EditForm }
type Conflict = 'edit' | 'review'
export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen, onBack, onDetail }: {
sourceId: string
table: string
/** 全部表:关联关系的目标表能点就跳过去,编辑时给目标表做候选 */
rows: CatalogTableRow[]
/** 起草刚写进了这些表:正在看的表在里面、又没在编辑,就重新载入 */
drafted: { seq: number; tables: Set<string> }
prev: string | null
next: string | null
onOpen: (table: string) => void
/** 窄屏:回到表清单 */
onBack: () => void
/** 载入或写入之后的单表目录:清单据此更新这一行 */
onDetail: (d: CatalogDetail) => void
}) {
const [detail, setDetail] = useState<CatalogDetail | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
const [busy, setBusy] = useState('')
const [conflict, setConflict] = useState<Conflict | null>(null)
const [writeError, setWriteError] = useState<string | null>(null)
const [edit, setEdit] = useState<Editing | null>(null)
const [live, setLive] = useState('')
const [colQuery, setColQuery] = useState('')
const [onlyPending, setOnlyPending] = useState(false)
const loadSeq = useRef(0)
const onDetailRef = useRef(onDetail)
onDetailRef.current = onDetail
const load = useCallback(async () => {
const seq = ++loadSeq.current
try {
const d = await api.dataCatalog.get(sourceId, table)
if (seq !== loadSeq.current) return
setDetail(d)
setLoadError(null)
setConflict(null)
onDetailRef.current(d)
} catch (e) {
if (seq !== loadSeq.current) return
setLoadError(e)
}
}, [sourceId, table])
useEffect(() => {
setDetail(null)
setLoadError(null)
setEdit(null)
setConflict(null)
setWriteError(null)
setColQuery('')
setOnlyPending(false)
setLive('')
void load()
}, [load])
// 起草写进了这张表:没在编辑就重新载入;在编辑就不动,保存时服务端按版本号拦下,横幅里再重新载入
const editingRef = useRef(false)
editingRef.current = !!edit
// 只认新的一次起草:换表时上面那个 effect 已经载入过,不再重复取
const draftSeen = useRef(drafted.seq)
useEffect(() => {
if (drafted.seq === draftSeen.current) return
draftSeen.current = drafted.seq
if (drafted.tables.has(table) && !editingRef.current) void load()
}, [drafted, table, load])
const changes = edit ? changeCount(edit.initial, edit.form) : 0
const problems = useMemo(() => (edit ? validateForm(edit.form, PROBLEM_TEXT) : {}), [edit])
const problemCount = Object.keys(problems).length
useLeaveGuard(changes > 0, () => confirmDialog({
title: CT.leaveTitle, consequences: CT.leaveConsequences, confirmLabel: CT.leaveConfirm, danger: true,
}))
/** 写操作:成功换上返回的目录;409 挂冲突横幅,422 显示服务端原话,其余弹提示。只用 ref 和 setState,自身不变 */
const write = useCallback(async (key: string, call: () => Promise<CatalogDetail>): Promise<CatalogDetail | null> => {
setBusy(key)
setWriteError(null)
try {
const d = await call()
setDetail(d)
setConflict(null)
onDetailRef.current(d)
return d
} catch (e) {
if (e instanceof ApiError && e.status === 409) setConflict(editingRef.current ? 'edit' : 'review')
else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) setWriteError(e.message)
else toast.error(e)
return null
} finally {
setBusy('')
}
}, [])
const detailRef = useRef(detail)
detailRef.current = detail
const review = useCallback(async (t: ReviewTarget, action: CatalogReviewAction) => {
const d = detailRef.current
if (!d) return
const done = await write(t.path, () => api.dataCatalog.review(sourceId, table, { path: t.path, action, if_version: d.version }))
if (done) setLive(action === 'reset' && t.source === 'human' ? CT.removed(t.where) : CT.reviewed[action](t.where))
}, [sourceId, table, write])
const confirmColumn = useCallback(async (col: string) => {
const d = detailRef.current
if (!d) return
const paths = new Set(COLUMN_FIELDS.map((f) => columnPath(col, f)))
const { notes, n } = confirmProposed(d.notes, (p) => paths.has(p))
if (!n) return
const done = await write(`column:${col}`, () => api.dataCatalog.put(sourceId, table, notes, d.version))
if (done) setLive(CT.confirmedN(n))
}, [sourceId, table, write])
const setColumn = useCallback((col: string, f: ColumnField, v: string) => {
setEdit((e) => e && ({ ...e, form: { ...e.form, columns: { ...e.form.columns, [col]: { ...e.form.columns[col], [f]: v } } } }))
}, [])
const setTable = (k: TableKey, v: string) => setEdit((e) => e && ({ ...e, form: { ...e.form, table: { ...e.form.table, [k]: v } } }))
const setRelations = (fn: (rs: RelationDraft[]) => RelationDraft[]) =>
setEdit((e) => e && ({ ...e, form: { ...e.form, relations: fn(e.form.relations) } }))
const columns: ColumnModel[] = useMemo(() => {
if (!detail) return []
const st = detail.structure?.columns ?? []
const known = new Set(st.map((c) => c.name))
return [
...st.map((c) => ({ name: c.name, structure: c, items: detail.notes.columns?.[c.name] })),
...Object.keys(detail.notes.columns ?? {}).filter((n) => !known.has(n))
.map((n) => ({ name: n, structure: null, items: detail.notes.columns?.[n] })),
]
}, [detail])
const shownColumns = useMemo(() => {
const q = colQuery.trim().toLowerCase()
return columns.filter((c) => {
if (onlyPending && !COLUMN_FIELDS.some((f) => c.items?.[f]?.status === 'proposed')) return false
if (!q) return true
const label = c.items?.label?.value ?? ''
return c.name.toLowerCase().includes(q) || label.toLowerCase().includes(q)
})
}, [columns, colQuery, onlyPending])
if (!detail) {
return (
<div className="min-h-0 flex-1 overflow-y-auto p-4" data-catalog-detail={table} data-loading="">
<button type="button" className="btn btn-sm mb-3 lg:hidden" onClick={onBack}><ArrowLeft size={12} aria-hidden /> {CT.backToList}</button>
{loadError
? (loadError instanceof ApiError && loadError.status === 404
? <EmptyState title={loadError.message} offline={false} />
: <ErrorState error={loadError} onRetry={() => void load()} />)
: <Skeleton rows={6} height={28} gap={10} />}
</div>
)
}
const notes = detail.notes
const counts = countsOf(notes)
const label = notes.label && notes.label.status !== 'rejected' ? notes.label.value : null
const kind = notes.kind && notes.kind.status !== 'rejected' ? notes.kind.value : null
const st = detail.structure
const systemComment = detail.system_notes ? st?.comment?.trim() : ''
const startEdit = () => {
const f = formFromNotes(notes, st?.columns ?? [])
setEdit({ initial: f, form: f })
setWriteError(null)
}
const cancelEdit = async () => {
if (changes > 0 && !(await confirmDialog({ title: CT.cancelEditTitle, confirmLabel: CT.cancelEditAction, danger: true }))) return
setEdit(null)
setConflict(null)
setWriteError(null)
}
const save = async () => {
if (!edit || problemCount || !changes) return
const body = buildNotes(notes, edit.initial, edit.form)
const done = await write('save', () => api.dataCatalog.put(sourceId, table, body, detail.version))
if (done) {
setEdit(null)
toast.ok(CT.saved)
}
}
const confirmAll = async () => {
const { notes: body, n } = confirmProposed(notes)
if (!n) return
const ok = await confirmDialog({
title: CT.confirmAllTitle(n, label ?? table), consequences: CT.confirmAllConsequences, confirmLabel: CT.confirmAllAction(n),
})
if (!ok) return
const done = await write('confirm-all', () => api.dataCatalog.put(sourceId, table, body, detail.version))
if (done) toast.ok(CT.confirmedN(n))
}
const reload = async () => {
if (changes > 0 && !(await confirmDialog({ title: CT.reloadDiscardTitle, confirmLabel: CT.reloadDiscardAction, danger: true }))) return
setEdit(null)
setWriteError(null)
await load()
}
const targetOf = (name: string) => {
const lower = name.toLowerCase()
return rows.find((r) => r.table_name === name || r.qualified === name)
?? rows.find((r) => r.table_name.toLowerCase() === lower || r.qualified.toLowerCase() === lower)
}
return (
<div className="flex min-h-0 flex-1 flex-col" data-catalog-detail={table} data-version={detail.version}
data-editing={edit ? 'true' : 'false'}>
<header className="space-y-2 border-b bg-panel px-4 py-3">
<div className="flex items-start gap-2">
<button type="button" className="btn btn-sm btn-ghost -ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label={CT.backToList} data-catalog-detail-back="">
<ArrowLeft size={12} aria-hidden />
</button>
<div className="min-w-0 flex-1">
<h2 className="flex flex-wrap items-center gap-2 text-sm font-semibold" data-detail-title="">
<span className={clsx('break-all', !label && 'mono')}>{label ?? detail.table_name}</span>
{kind && <span className="chip" title={CATALOG_KIND_HINT[kind]}>{CATALOG_KIND_LABEL[kind]}</span>}
{st?.is_view && <span className="chip">{CT.view}</span>}
</h2>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-faint">
{label && <span className="mono break-all text-dim">{st?.qualified ?? detail.table_name}</span>}
<span className="tnum" title={CT.usageHint}>{CT.usage(detail.usage)}</span>
<span className="tnum">
{detail.updated_at
? (detail.updated_by ? CT.updatedBy(detail.updated_by, formatDateTime(detail.updated_at)) : CT.updatedAt(formatDateTime(detail.updated_at)))
: CT.notStarted}
</span>
<CountBadges counts={counts} />
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button type="button" className="btn btn-sm btn-ghost" disabled={!prev} onClick={() => prev && onOpen(prev)}
aria-label={CT.prev} title={prev ? `${CT.prev}:${prev}` : CT.prev} data-catalog-prev="">
<ChevronLeft size={13} aria-hidden /><span className="hidden xl:inline">{CT.prev}</span>
</button>
<button type="button" className="btn btn-sm btn-ghost" disabled={!next} onClick={() => next && onOpen(next)}
aria-label={CT.next} title={next ? `${CT.next}:${next}` : CT.next} data-catalog-next="">
<span className="hidden xl:inline">{CT.next}</span><ChevronRight size={13} aria-hidden />
</button>
</div>
</div>
{!edit && (
<div className="flex flex-wrap items-center gap-1.5">
{counts.proposed > 0 && (
<button type="button" className="btn btn-sm" disabled={!!busy} onClick={() => void confirmAll()} data-catalog-confirm-all={counts.proposed}>
{busy === 'confirm-all' ? <Spinner size={11} /> : <ListChecks size={12} aria-hidden />} {CT.confirmAll(counts.proposed)}
</button>
)}
<button type="button" className="btn btn-sm" disabled={!!busy} onClick={startEdit} data-catalog-edit="">
<Pencil size={11} aria-hidden /> {CT.edit}
</button>
<span className="text-2xs text-faint">{CT.editHint}</span>
</div>
)}
</header>
<div className="relative min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 p-4">
<p className="sr-only" aria-live="polite" data-catalog-live="">{live}</p>
{conflict && (
<Notice tone="err" attr={{ 'data-catalog-conflict': conflict }}>
<p className="font-medium">{CT.conflictTitle}</p>
<p className="mt-0.5 text-dim">{conflict === 'edit' ? CT.conflictEditing : CT.conflictReview}</p>
<button type="button" className="btn btn-sm mt-2" onClick={() => void reload()} data-catalog-reload="">
<RotateCw size={11} aria-hidden /> {CT.reload}
</button>
</Notice>
)}
{writeError && <Notice tone="err" attr={{ 'data-catalog-write-error': '' }}>{writeError}</Notice>}
{!detail.in_schema && (
<Notice tone="warn" attr={{ 'data-catalog-missing': '' }}>{CT.missingBody}</Notice>
)}
{detail.system_notes && <SystemNotesNotice />}
{edit && <Notice tone="info">{CT.editHint}。{CT.clearHint}。</Notice>}
<section aria-labelledby="catalog-sec-table" data-catalog-section="table">
<div className="mb-2 flex items-center gap-2">
<h3 id="catalog-sec-table" className="text-xs font-semibold">{CT.sectionTable}</h3>
<span className="flex-1" />
{!edit && <StatusLegend />}
</div>
<div className="rounded-lg border bg-panel">
{systemComment && (
<div className="flex items-start gap-2 border-b px-3 py-2.5 text-xs" data-system-table-note="">
<Lock size={12} className="mt-0.5 shrink-0 text-faint" aria-hidden />
<div className="min-w-0">
<div className="text-2xs text-faint">{CT.systemTableNote} · {CT.systemTag}</div>
<p className="mt-0.5 whitespace-pre-wrap break-words leading-relaxed text-dim">{systemComment}</p>
</div>
</div>
)}
<dl className="divide-y divide-[var(--hairline)]">
{TABLE_FIELDS.map((f) => (
<TableFieldRow key={f} field={f} item={notes[f] as CatalogItem | undefined} edit={edit} busy={!!busy}
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review} />
))}
</dl>
</div>
</section>
<section aria-labelledby="catalog-sec-columns" data-catalog-section="columns">
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 id="catalog-sec-columns" className="text-xs font-semibold">{CT.sectionColumns}</h3>
<span className="tnum text-2xs text-faint" data-column-count="">{CT.columnCount(shownColumns.length, columns.length)}</span>
<span className="flex-1" />
<div className="relative">
<Search size={11} className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-faint" aria-hidden />
<input type="search" className="field !w-48 !py-1 pl-6 !text-2xs" placeholder={CT.columnFilter} aria-label={CT.columnFilter}
value={colQuery} onChange={(e) => setColQuery(e.target.value)} data-column-filter="" />
</div>
<label className="inline-flex items-center gap-1.5 text-2xs text-dim">
<input type="checkbox" checked={onlyPending} onChange={(e) => setOnlyPending(e.target.checked)} data-only-pending="" />
{CT.onlyPending}
</label>
</div>
{shownColumns.length
? (
<ColumnTable columns={shownColumns} form={edit?.form.columns ?? null} initial={edit?.initial.columns ?? null}
problems={problems} busy={!!busy} systemNotes={detail.system_notes}
onReview={review} onConfirmColumn={confirmColumn} onChange={setColumn} />
)
: <p className="rounded-lg border px-3 py-6 text-center text-xs text-faint">{CT.noColumns}</p>}
</section>
<section aria-labelledby="catalog-sec-relations" data-catalog-section="relations">
<div className="mb-2 flex items-center gap-2">
<h3 id="catalog-sec-relations" className="text-xs font-semibold">{CT.sectionRelations}</h3>
<span className="tnum text-2xs text-faint">{formatNumber((edit ? edit.form.relations : notes.relations ?? []).length)}</span>
</div>
{edit
? <RelationEditor drafts={edit.form.relations} initial={edit.initial.relations} problems={problems} rows={rows}
onChange={setRelations} />
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review} />}
</section>
</div>
</div>
{edit && (
<footer className="flex flex-wrap items-center gap-2 border-t bg-panel px-4 py-2.5" data-catalog-edit-bar="">
<span className="tnum text-xs text-dim" data-catalog-changes={changes}>{changes ? CT.changes(changes) : CT.noChanges}</span>
{problemCount > 0 && (
<span className="tnum inline-flex items-center gap-1 text-xs text-[var(--err)]" data-catalog-problems={problemCount}>
<AlertTriangle size={11} aria-hidden /> {CT.invalidCount(problemCount)}
</span>
)}
<span className="flex-1" />
<button type="button" className="btn btn-sm" onClick={() => void cancelEdit()} disabled={busy === 'save'} data-catalog-cancel="">
{CT.cancelEdit}
</button>
<button type="button" className="btn btn-sm btn-primary" onClick={() => void save()}
disabled={!changes || problemCount > 0 || busy === 'save'} data-catalog-save="">
{busy === 'save' ? <><Spinner size={11} /> {CT.saving}</> : CT.save}
</button>
</footer>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// 表级各项
// ---------------------------------------------------------------------------
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview }: {
field: TableField
item: CatalogItem | undefined
edit: Editing | null
busy: boolean
problems: Record<string, string>
/** 表结构里的列:业务日期的候选、业务主键的核对 */
columns: { name: string }[]
onChange: (k: TableKey, v: string) => void
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
}) {
const where = CT.whereTable(CATALOG_TABLE_FIELD_LABEL[field])
const keys: TableKey[] = field === 'business_date' ? [...DATE_KEYS] : [field as TableKey]
const changed = !!edit && keys.some((k) => (edit.form.table[k] ?? '') !== (edit.initial.table[k] ?? ''))
const problem = keys.map((k) => problems[`t:${k}`]).find(Boolean)
const inputId = `catalog-field-${field}`
const rejected = item?.status === 'rejected'
const placeholder = rejected ? CT.rejectedPlaceholder(plainValue(field, item?.value)) : CATALOG_TABLE_FIELD_HINT[field]
let input: ReactNode = null
if (edit) {
const v = (k: TableKey) => edit.form.table[k] ?? ''
if (field === 'kind') {
input = (
<select id={inputId} className="field" value={v('kind')} onChange={(e) => onChange('kind', e.target.value)} data-input="kind">
<option value="">{CT.kindNone}</option>
{KINDS.map((k) => <option key={k} value={k}>{CATALOG_KIND_LABEL[k]}({CATALOG_KIND_HINT[k]})</option>)}
</select>
)
} else if (field === 'business_date') {
input = (
<div className="grid gap-2 sm:grid-cols-[1fr_1.4fr_1fr]">
<div>
<label className="label" htmlFor={inputId}>{CT.dateColumn}</label>
<input id={inputId} className="field mono" list="catalog-columns" value={v('business_date.column')}
aria-invalid={problems['t:business_date.column'] ? true : undefined}
onChange={(e) => onChange('business_date.column', e.target.value)} data-input="business_date.column" />
</div>
<div>
<label className="label" htmlFor={`${inputId}-rule`}>{CT.dateRule}</label>
<input id={`${inputId}-rule`} className="field" value={v('business_date.rule')}
onChange={(e) => onChange('business_date.rule', e.target.value)} data-input="business_date.rule" />
</div>
<div>
<label className="label" htmlFor={`${inputId}-tz`}>{CT.dateTimezone}</label>
<input id={`${inputId}-tz`} className="field" value={v('business_date.timezone')} placeholder="Asia/Shanghai"
onChange={(e) => onChange('business_date.timezone', e.target.value)} data-input="business_date.timezone" />
</div>
<datalist id="catalog-columns">{columns.map((c) => <option key={c.name} value={c.name} />)}</datalist>
</div>
)
} else if (field === 'description') {
input = (
<textarea id={inputId} className="field" rows={3} value={v('description')} placeholder={placeholder}
aria-invalid={problem ? true : undefined} onChange={(e) => onChange('description', e.target.value)} data-input="description" />
)
} else {
const k = field as TableKey
input = (
<input id={inputId} className={clsx('field', (field === 'valid_filter' || field === 'keys') && 'mono')} value={v(k)}
placeholder={field === 'keys' && !rejected ? CT.keysPlaceholder : placeholder}
aria-invalid={problem ? true : undefined} onChange={(e) => onChange(k, e.target.value)} data-input={field} />
)
}
}
const unknown = edit && field === 'keys' ? unknownKeys(edit.form.table.keys ?? '', columns) : []
return (
<div className="grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]" data-field={field} data-status={item?.status}>
<dt className="pt-0.5 text-2xs text-faint" title={CATALOG_TABLE_FIELD_HINT[field]}>
{edit && field !== 'business_date'
? <label htmlFor={inputId}>{CATALOG_TABLE_FIELD_LABEL[field]}</label>
: CATALOG_TABLE_FIELD_LABEL[field]}
</dt>
<dd className="min-w-0">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 text-xs">{edit ? input : <FieldValue field={field} item={item} />}</div>
{item && !changed && (
<ItemMark target={{ path: tablePath(field), where, source: item.source, status: item.status, note: item.note, updated_at: item.updated_at }}
disabled={busy || !!edit} disabledHint={edit ? CT.reviewWhileEditing : undefined}
onReview={(a) => onReview({ path: tablePath(field), where, source: item.source, status: item.status }, a)} />
)}
{changed && <span className="chip shrink-0 !text-2xs" style={{ color: 'var(--accent)' }} data-changed="">{CT.changedMark}</span>}
</div>
{problem && <div className="mt-1 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
{!problem && unknown.length > 0 && (
<div className="mt-1 text-2xs text-[var(--warn)]" data-keys-unknown="">{CT.keysUnknown(unknown.join('、'))}</div>
)}
</dd>
</div>
)
}
function plainValue(field: TableField, v: unknown): string {
if (v == null) return ''
if (field === 'keys' && Array.isArray(v)) return v.join('、')
if (field === 'kind') return CATALOG_KIND_LABEL[v as CatalogTableKind] ?? String(v)
if (field === 'business_date' && typeof v === 'object') return (v as CatalogBusinessDate).column
return String(v)
}
function FieldValue({ field, item }: { field: TableField; item: CatalogItem | undefined }) {
return (
<Value item={item} render={(v: any) => {
if (field === 'keys') {
return (
<span className="flex flex-wrap gap-1">
{(v as string[]).map((k) => <span key={k} className="mono rounded border bg-bg px-1 text-2xs">{k}</span>)}
</span>
)
}
if (field === 'kind') {
return <span>{CATALOG_KIND_LABEL[v as CatalogTableKind] ?? v}<span className="ml-1.5 text-2xs text-faint">{CATALOG_KIND_HINT[v as CatalogTableKind]}</span></span>
}
if (field === 'business_date') {
const d = v as CatalogBusinessDate
return (
<span className="flex flex-wrap items-center gap-x-2">
<span className="mono">{d.column}</span>
{d.rule && <span className="text-dim">{CT.dateRule}:{d.rule}</span>}
{d.timezone && <span className="text-dim">{CT.dateTimezone}:{d.timezone}</span>}
</span>
)
}
if (field === 'valid_filter') return <code className="mono break-all rounded bg-bg px-1 text-2xs">{String(v)}</code>
return <span className="whitespace-pre-wrap break-words leading-relaxed">{String(v)}</span>
}} />
)
}
// ---------------------------------------------------------------------------
// 关联关系
// ---------------------------------------------------------------------------
function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
relations: CatalogRelation[]
busy: boolean
targetOf: (name: string) => CatalogTableRow | undefined
onOpen: (table: string) => void
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
}) {
if (!relations.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-relations-empty="">{CT.noRelations}</p>
return (
<div className="relative overflow-x-auto rounded-lg border" data-catalog-relations="">
<table className="w-full min-w-[640px] border-collapse text-xs">
<thead>
<tr className="border-b bg-elev text-left text-2xs text-faint">
<th scope="col" className="px-3 py-2 font-medium">{CT.relationHead.from}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.to}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.toColumns}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.cardinality}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.coverage}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.source} / {CT.relationHead.status}</th>
</tr>
</thead>
<tbody>
{relations.map((r) => {
const target = targetOf(r.to_table)
const where = CT.whereRelation(r.to_table)
const rejected = r.status === 'rejected'
return (
<tr key={r.id} className="border-b border-hairline last:border-b-0" data-relation={r.id} data-status={r.status}>
<td className={clsx('mono px-3 py-1.5 align-top', rejected && 'text-faint line-through')}>{r.columns.join(', ')}</td>
<td className="px-2 py-1.5 align-top">
{target
? (
<button type="button" className={clsx('mono text-left underline decoration-dotted underline-offset-2 hover:text-[var(--accent)]', rejected && 'text-faint line-through')}
onClick={() => onOpen(target.table_name)} title={CT.openTable(target.label ?? target.table_name)} data-relation-target={target.table_name}>
{r.to_table}
</button>
)
: <span className={clsx('mono', rejected && 'text-faint line-through')}>{r.to_table}</span>}
{target?.label && <div className="text-2xs text-faint">{target.label}</div>}
</td>
<td className={clsx('mono px-2 py-1.5 align-top', rejected && 'text-faint line-through')}>{r.to_columns.join(', ')}</td>
<td className="px-2 py-1.5 align-top">{r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : <span className="text-faint">{CT.cardinalityNone}</span>}</td>
<td className="tnum px-2 py-1.5 align-top">
{typeof r.coverage === 'number' ? CT.coverageValue(Math.round(r.coverage * 100)) : <span className="text-faint">—</span>}
</td>
<td className="px-2 py-1.5 align-top">
<ItemMark target={{ path: relationPath(r.id), where, source: r.source, status: r.status, note: r.note, updated_at: r.updated_at }}
disabled={busy} onReview={(a) => onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
function RelationEditor({ drafts, initial, problems, rows, onChange }: {
drafts: RelationDraft[]
initial: RelationDraft[]
problems: Record<string, string>
rows: CatalogTableRow[]
onChange: (fn: (rs: RelationDraft[]) => RelationDraft[]) => void
}) {
const before = new Map(initial.map((r) => [r.key, r]))
const patch = (key: string, p: Partial<RelationDraft>) => onChange((rs) => rs.map((r) => (r.key === key ? { ...r, ...p } : r)))
return (
<div className="space-y-2" data-relation-editor="">
{drafts.map((r) => {
const b = before.get(r.key)
const changed = !b || b.columns !== r.columns || b.to_table !== r.to_table || b.to_columns !== r.to_columns || b.cardinality !== r.cardinality
const problem = problems[`r:${r.key}`]
return (
<div key={r.key} className="rounded-lg border bg-panel p-2.5" data-relation-draft={r.key}>
<div className="grid items-end gap-2 sm:grid-cols-[1fr_1fr_1fr_8rem_auto]">
<label className="min-w-0">
<span className="label">{CT.relationHead.from}</span>
<input className="field mono" value={r.columns} placeholder={CT.relationColumns}
onChange={(e) => patch(r.key, { columns: e.target.value })} data-input="columns" />
</label>
<label className="min-w-0">
<span className="label">{CT.relationTarget}</span>
<input className="field mono" list="catalog-tables" value={r.to_table}
onChange={(e) => patch(r.key, { to_table: e.target.value })} data-input="to_table" />
</label>
<label className="min-w-0">
<span className="label">{CT.relationHead.toColumns}</span>
<input className="field mono" value={r.to_columns} placeholder={CT.relationToColumns}
onChange={(e) => patch(r.key, { to_columns: e.target.value })} data-input="to_columns" />
</label>
<label className="min-w-0">
<span className="label">{CT.relationHead.cardinality}</span>
<select className="field" value={r.cardinality} onChange={(e) => patch(r.key, { cardinality: e.target.value })} data-input="cardinality">
<option value="">{CT.cardinalityNone}</option>
{CARDINALITIES.map((c) => <option key={c} value={c}>{CATALOG_CARDINALITY_LABEL[c]}</option>)}
</select>
</label>
<div className="flex items-center gap-1.5 pb-1">
{r.orig && !changed && <StatusChip status={r.orig.status} source={r.orig.source} />}
{changed && <span className="chip !text-2xs" style={{ color: 'var(--accent)' }}>{CT.changedMark}</span>}
<DeleteButton label={CT.removeRelation} onClick={() => onChange((rs) => rs.filter((x) => x.key !== r.key))} />
</div>
</div>
{problem && <div className="mt-1.5 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
</div>
)
})}
<datalist id="catalog-tables">{rows.map((t) => <option key={t.table_name} value={t.table_name}>{t.label ?? ''}</option>)}</datalist>
<button type="button" className="btn btn-sm" onClick={() => onChange((rs) => [...rs, relationDraft(null)])} data-add-relation="">
<Plus size={11} aria-hidden /> {CT.addRelation}
</button>
</div>
)
}
+224
View File
@@ -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<ListFilter, number>
query: string
onQuery: (q: string) => void
filter: ListFilter
onFilter: (f: ListFilter) => void
sort: ListSort
onSort: (s: ListSort) => void
selected: Set<string>
onSelected: (next: Set<string>) => void
active: string | undefined
onOpen: (table: string) => void
onDraft: () => void
}) {
const radio = useRadioGroup(LIST_FILTERS, filter, onFilter)
const listRef = useRef<HTMLUListElement>(null)
const allRef = useRef<HTMLInputElement>(null)
const lastClicked = useRef<number | null>(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<HTMLElement>(`[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<HTMLInputElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>('[data-catalog-open-row]')[j]?.focus()
}, [])
return (
<div className="flex min-h-0 flex-1 flex-col" data-catalog-list="">
<div className="space-y-2 border-b px-3 py-2.5">
<div className="relative">
<Search size={12} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-faint" aria-hidden />
<input
type="search"
className="field pl-7"
placeholder={CT.search}
aria-label={CT.search}
value={query}
onChange={(e) => onQuery(e.target.value)}
data-catalog-search=""
/>
</div>
<div role="radiogroup" aria-label={CT.filterLabel} className="flex flex-wrap gap-1" data-catalog-filter={filter}>
{LIST_FILTERS.map((f) => (
<button
key={f}
type="button"
{...radio(f)}
title={CT.filterHint[f]}
onClick={() => onFilter(f)}
data-filter={f}
className={clsx(
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-2xs outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
filter === f ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover hover:text-fg',
)}
>
{CT.filter[f]}
<span className="tnum text-faint">{formatNumber(counts[f])}</span>
</button>
))}
</div>
<div className="flex items-center gap-2 text-2xs text-faint">
<input ref={allRef} type="checkbox" checked={all} onChange={toggleAll} disabled={!rows.length}
aria-label={CT.selectAll} title={CT.selectAll} data-catalog-select-all="" />
<span className="tnum" data-catalog-count="">
{selected.size ? CT.selected(selected.size) : CT.tableCount(rows.length, total)}
</span>
{selected.size > 0 && (
<button type="button" className="inline-flex items-center gap-0.5 rounded px-1 hover:bg-hover hover:text-fg"
onClick={() => onSelected(new Set())} data-catalog-clear="">
<X size={10} aria-hidden /> {CT.clearSelection}
</button>
)}
<span className="flex-1" />
<label className="sr-only" htmlFor="catalog-sort">{CT.sortLabel}</label>
<select id="catalog-sort" className="field !w-auto !py-0.5 !text-2xs" value={sort}
onChange={(e) => onSort(e.target.value as ListSort)} data-catalog-sort="">
{LIST_SORTS.map((s) => <option key={s} value={s}>{CT.sort[s]}</option>)}
</select>
</div>
</div>
<ul ref={listRef} className="relative min-h-0 flex-1 overflow-y-auto" aria-label={CT.tableCount(rows.length, total)}>
{rows.map((r, i) => (
<Row key={r.table_name} row={r} index={i} active={r.table_name === active} checked={selected.has(r.table_name)}
onToggle={toggle} onOpen={onOpen} onKey={onRowKey} />
))}
</ul>
{selected.size > 0 && (
<div className="flex items-center gap-2 border-t px-3 py-2">
<button type="button" className="btn btn-primary btn-sm" onClick={onDraft} data-catalog-draft-selected="">
<Sparkles size={11} aria-hidden /> {CT.draftSelected(selected.size)}
</button>
</div>
)}
</div>
)
}
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<HTMLInputElement>) => void
onOpen: (table: string) => void
onKey: (i: number, e: ReactKeyboardEvent<HTMLButtonElement>) => void
}) {
const progress = progressOf(r.counts)
return (
<li
className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
data-catalog-row={r.table_name}
data-progress={progress}
>
{active && <span className="absolute inset-y-0 left-0 w-0.5 bg-[var(--accent)]" aria-hidden />}
<span className="flex shrink-0 items-start pl-3 pr-1 pt-2.5">
<input type="checkbox" checked={checked} onChange={() => {}} onClick={(e) => onToggle(index, e)}
aria-label={CT.selectTable(r.table_name)} data-catalog-select={r.table_name} />
</span>
<button
type="button"
className="min-w-0 flex-1 px-2 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]"
onClick={() => onOpen(r.table_name)}
onKeyDown={(e) => onKey(index, e)}
aria-current={active ? 'true' : undefined}
data-catalog-open-row=""
>
<span className="flex items-center gap-2">
<span className={clsx('min-w-0 flex-1 truncate text-xs', r.label ? 'font-medium text-fg' : 'text-faint')} data-row-label="">
{r.label ?? CT.noLabel}
</span>
{progress === 'none'
? <span className="shrink-0 text-2xs text-faint">{CT.noCatalog}</span>
: <CountBadges counts={r.counts} className="shrink-0" />}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-2xs text-faint">
<span className="mono min-w-0 truncate" title={r.qualified}>{r.table_name}</span>
{r.kind && <span className="shrink-0">· {CATALOG_KIND_LABEL[r.kind] ?? r.kind}</span>}
{r.is_view && <span className="shrink-0">· {CT.view}</span>}
{r.relations > 0 && <span className="shrink-0 tnum">· {CT.relationCount(r.relations)}</span>}
<span className="flex-1" />
<span className="tnum shrink-0" title={CT.usageHint} data-row-usage={r.usage}>{CT.usage(r.usage)}</span>
</span>
{!r.in_schema && (
<span className="mt-0.5 flex items-center gap-1 text-2xs text-[var(--warn)]" data-row-missing="">
<AlertTriangle size={10} aria-hidden /> {CT.missing}
</span>
)}
</button>
</li>
)
})
+395
View File
@@ -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<Record<TableField, number>> = { label: 500, description: 2000, grain: 500, valid_filter: 500, dedup: 500 }
const COLUMN_MAX: Partial<Record<ColumnField, number>> = { 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<CatalogItem, 'source' | 'status'> & { 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<T>(item: CatalogItem<T> | 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<ListFilter, 'all'> {
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<ListFilter, number> {
const out: Record<ListFilter, number> = { 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<TableField, 'business_date'> | DateKey
export interface RelationDraft {
/** 表单里的键:已有的关系用编号,新加的用临时键 */
key: string
orig: CatalogRelation | null
columns: string
to_table: string
to_columns: string
cardinality: string
}
export interface EditForm {
table: Partial<Record<TableKey, string>>
/** 列名 → 字段 → 文字 */
columns: Record<string, Partial<Record<ColumnField, string>>>
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, string>): string {
return Object.entries(v).map(([k, x]) => `${k}=${x}`).join('\n')
}
/** 码值的文字:每行一个「码值=含义」(全角等号也认)。返回解析结果和第一处错误 */
export function parseCodes(text: string): { value: Record<string, string>; error: { line: number } | { dup: string } | null } {
const value: Record<string, string> = {}
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<TableField, 'business_date'>
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<string, string>()
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: { name: string }[]): 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<TableField, 'business_date'>, 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 = <T,>(o: CatalogItem<T> | undefined, a: string, b: string, value: () => T): CatalogItem<T> | undefined => {
if (a === b) return o
if (!b.trim()) return undefined
return o ? { ...o, value: value() } : ({ value: value() } as CatalogItem<T>)
}
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<CatalogBusinessDate> | 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<CatalogBusinessDate>)
}
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<string, unknown>)[f] = next
}
const columns: NonNullable<CatalogNotes['columns']> = {}
const names = new Set([...Object.keys(orig.columns ?? {}), ...Object.keys(form.columns)])
for (const col of names) {
const items: Record<string, unknown> = {}
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
}
+304
View File
@@ -0,0 +1,304 @@
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 { Notice } from '../../components/ui'
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<CatalogStatus, string> = {
proposed: 'var(--st-waiting)',
verified: 'var(--accent)',
confirmed: 'var(--st-done)',
rejected: 'var(--st-failed)',
}
const STATUS_ICON: Record<CatalogStatus, LucideIcon> = {
proposed: CircleDashed,
verified: ShieldCheck,
confirmed: CircleCheck,
rejected: CircleX,
}
export function StatusIcon({ status, size = 12 }: { status: CatalogStatus; size?: number }) {
const Icon = STATUS_ICON[status]
return <Icon size={size} className="shrink-0" style={{ color: STATUS_TONE[status] }} aria-hidden />
}
/** 状态的文字标识:图标 + 「推断」,可带来源「· 命名推断」 */
export function StatusChip({ status, source, className }: { status: CatalogStatus; source?: CatalogSource; className?: string }) {
const tone = STATUS_TONE[status]
return (
<span
className={clsx('inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border px-1.5 text-2xs leading-[18px]', className)}
style={{ color: tone, borderColor: `color-mix(in srgb, ${tone} 40%, var(--border))`, background: `color-mix(in srgb, ${tone} 8%, transparent)` }}
data-status={status}
>
<StatusIcon status={status} size={11} />
{CATALOG_STATUS_LABEL[status]}
{source && <span className="text-faint">· {CATALOG_SOURCE_LABEL[source]}</span>}
</span>
)
}
/** 清单一行上的状态计数:只列有项的状态,读屏念「推断 12 项」 */
export function CountBadges({ counts, className }: { counts: CatalogCounts; className?: string }) {
const shown = STATUSES.filter((s) => counts[s] > 0)
if (!shown.length) return null
return (
<span className={clsx('inline-flex items-center gap-2 text-2xs', className)} data-counts={STATUSES.map((s) => counts[s]).join(',')}>
{shown.map((s) => (
<span key={s} className="tnum inline-flex items-center gap-0.5" style={{ color: STATUS_TONE[s] }}
title={CT.countTitle(CATALOG_STATUS_LABEL[s], counts[s])}>
<StatusIcon status={s} size={11} />
<span aria-hidden>{formatNumber(counts[s])}</span>
<span className="sr-only">{CT.countTitle(CATALOG_STATUS_LABEL[s], counts[s])}</span>
</span>
))}
</span>
)
}
/** 四种状态的图例:列表格里只画图标,图例就近说明每种状态意味着什么 */
export function StatusLegend({ className, detailed = false }: { className?: string; detailed?: boolean }) {
return (
<ul className={clsx(detailed ? 'space-y-1.5' : 'flex flex-wrap items-center gap-x-3 gap-y-1', 'text-2xs text-faint', className)}
aria-label={CT.legend} data-status-legend="">
{STATUSES.map((s) => (
<li key={s} className="flex items-start gap-1.5">
<span className="mt-0.5"><StatusIcon status={s} size={11} /></span>
<span>
<span style={{ color: STATUS_TONE[s] }}>{CATALOG_STATUS_LABEL[s]}</span>
{detailed && <span>:{CATALOG_STATUS_HINT[s]}</span>}
</span>
</li>
))}
</ul>
)
}
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, disabledHint, onReview }: {
target: ReviewTarget
compact?: boolean
/** 正在提交别的操作、正在编辑:弹层照常能看,操作按钮禁用 */
disabled?: boolean
/** 禁用的原因,写在操作按钮下面 */
disabledHint?: string
onReview: (action: CatalogReviewAction) => void
}) {
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
const trigger = useRef<HTMLButtonElement>(null)
const panel = useRef<HTMLDivElement>(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()
}
// 先按触发按钮下方放,量过弹层的高度后,下方放不下就翻到上方。返回 false 表示触发按钮已经滚出视野
const place = (): boolean => {
const r = trigger.current?.getBoundingClientRect()
const el = panel.current
if (!r || !el) return false
if (r.bottom < 0 || r.top > window.innerHeight) return false
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((p) => (p && p.top === top && p.left === left ? p : { top, left }))
return true
}
const placeRef = useRef(place)
placeRef.current = place
useLayoutEffect(() => {
if (open) placeRef.current()
}, [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)) return
if (!placeRef.current()) 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])
// 定好位置以后把焦点交给第一个能点的操作(只在打开那一下)
const placed = !!pos
useEffect(() => {
if (!open || !placed) return
const first = panel.current?.querySelector<HTMLButtonElement>('[data-review-action]:not([disabled])')
;(first ?? panel.current)?.focus({ preventScroll: true })
}, [open, placed])
const onKey = (e: ReactKeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
close()
return
}
const els = [...(panel.current?.querySelectorAll<HTMLButtonElement>('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 (
<>
<button
ref={trigger}
type="button"
className={clsx(
'inline-flex shrink-0 items-center gap-1 rounded-full border text-2xs leading-[18px] outline-none',
'focus-visible:ring-2 focus-visible:ring-[var(--accent)] hover:brightness-110',
compact ? 'h-[18px] w-[18px] justify-center' : 'px-1.5',
)}
style={{ color: tone, borderColor: `color-mix(in srgb, ${tone} 40%, var(--border))`, background: `color-mix(in srgb, ${tone} 8%, transparent)` }}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={label}
title={label}
data-item-mark={target.path}
data-status={status}
onClick={() => (open ? close() : setOpen(true))}
>
<StatusIcon status={status} size={11} />
{!compact && (
<>
{CATALOG_STATUS_LABEL[status]}
<span className="text-faint">· {CATALOG_SOURCE_LABEL[source]}</span>
</>
)}
</button>
{open && createPortal(
<div
ref={panel}
role="dialog"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={onKey}
className="fade-up fixed z-[60] w-72 rounded-lg border bg-elev p-3 text-xs shadow-elev-2 outline-none"
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999 }}
data-item-panel={target.path}
>
<div id={titleId} className="font-medium text-fg">{CT.itemTitle(target.where)}</div>
<div className="mt-2 space-y-1.5 text-2xs leading-relaxed">
<p className="flex min-w-0 flex-wrap items-center gap-1.5">
<StatusChip status={status} />
<span className="text-dim">{CATALOG_STATUS_HINT[status]}</span>
</p>
<p className="text-dim">{CT.itemSource(CATALOG_SOURCE_LABEL[source])}</p>
{target.updated_at && <p className="tnum text-faint">{CT.itemUpdated(formatDateTime(target.updated_at))}</p>}
{target.note && <p className="whitespace-pre-wrap break-words rounded border bg-bg px-2 py-1 text-dim">{target.note}</p>}
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
<ActionButton icon={<Check size={11} />} disabled={disabled || status === 'confirmed'} onClick={() => act('confirm')} action="confirm">
{CT.confirm}
</ActionButton>
<ActionButton icon={<X size={11} />} disabled={disabled || status === 'rejected'} onClick={() => act('reject')} action="reject">
{CT.reject}
</ActionButton>
<ActionButton icon={human ? <Trash2 size={11} /> : <RotateCcw size={11} />} disabled={disabled || !canReset}
onClick={() => act('reset')} action="reset" title={human ? CT.removeHint : CT.resetHint}>
{human ? CT.remove : CT.reset}
</ActionButton>
</div>
{disabled && disabledHint
? <p className="mt-2 text-2xs leading-relaxed text-faint" data-review-disabled="">{disabledHint}</p>
: canReset && <p className="mt-2 text-2xs leading-relaxed text-faint">{human ? CT.removeHint : CT.resetHint}</p>}
</div>,
document.body,
)}
</>
)
}
function ActionButton({ icon, children, disabled, onClick, action, title }: {
icon: ReactNode; children: ReactNode; disabled: boolean; onClick: () => void; action: CatalogReviewAction; title?: string
}) {
return (
<button type="button" className="btn btn-sm" disabled={disabled} onClick={onClick} data-review-action={action} title={title}>
<span aria-hidden>{icon}</span>
{children}
</button>
)
}
/** 导入表格的源:说明是系统生成的,只读 */
export function SystemNotesNotice() {
return (
<Notice tone="info" attr={{ 'data-catalog-system-notes': '' }}>
<p className="font-medium">{CT.systemTitle}</p>
<p className="mt-0.5 text-dim">{CT.systemBody}</p>
<p className="mt-0.5 text-dim">{CT.systemCovered}</p>
</Notice>
)
}
+175
View File
@@ -2530,3 +2530,178 @@ export interface ProvenanceMergeHop {
row: number | null
column: string | null
}
// ===========================================================================
// 业务数据目录(/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<T = unknown> {
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<string>
meaning?: CatalogItem<string>
unit?: CatalogItem<string>
measure?: CatalogItem<CatalogMeasure>
/** 码值 → 含义 */
codes?: CatalogItem<Record<string, string>>
}
/** 一条关联关系。编号由两端的表和列算出来,改了指向就是另一条 */
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<string>
description?: CatalogItem<string>
grain?: CatalogItem<string>
keys?: CatalogItem<string[]>
kind?: CatalogItem<CatalogTableKind>
business_date?: CatalogItem<CatalogBusinessDate>
/** SQL 条件片段 */
valid_filter?: CatalogItem<string>
dedup?: CatalogItem<string>
columns?: Record<string, CatalogColumnNotes>
relations?: CatalogRelation[]
}
/** 各状态的项数(表级项、列级项、关系都算),四种状态都有键 */
export type CatalogCounts = Record<CatalogStatus, number>
/** 表清单的一行。表结构里的每张表各一行(没有目录的也列);目录还在、表已不在的 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
}
+2 -1
View File
@@ -79,13 +79,14 @@ const ORDER = [
'runs', // 记录
'manage', // 数据、工具、知识、设置
'versions', // 上传表格的版本:回滚、清单、移除一期、清除原件
'catalog', // 数据目录:表清单、起草、单项审阅、批量确认、编辑即确认、409
'ui', // 各页面端到端,连真数据
]
// 各项的耗时(秒)。只决定排队顺序:长的先开跑,短的填空,结果不受影响。新加的检查不在表里就按 60 秒算;
// 拆开的两项看 SPLIT 里各份的 est。2026-09-29 八道并行、关浏览器不干等之后的实测
const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
File diff suppressed because it is too large Load Diff