mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:41:59 +00:00
数据目录阶段 1C:数据目录页与表清单,数据源卡片入口、搜索筛选排序、多选分批起草、空状态与探查结构引导
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
+307
-1
@@ -38,7 +38,9 @@
|
||||
* 枚举值(formal / degraded / withheld 等)不动,只换显示。
|
||||
*/
|
||||
|
||||
import type { NodeType, SnapshotReasonCode, ToolTrust } from '../types'
|
||||
import type {
|
||||
CatalogCardinality, CatalogMeasure, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode, ToolTrust,
|
||||
} from '../types'
|
||||
import { formatNumber } from './format'
|
||||
|
||||
export const TERMS = {
|
||||
@@ -1797,3 +1799,307 @@ export const RECIPE_CHOICE_LABEL = {
|
||||
colStore: { '': '默认', canonical: '规范写法', raw: '原文' } as Record<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}`,
|
||||
whereTable: (field: string) => `表的${field}`,
|
||||
whereColumn: (col: string, field: string) => `列 ${col} 的${field}`,
|
||||
whereRelation: (to: string) => `指向 ${to} 的关联关系`,
|
||||
// ---- 批量确认
|
||||
confirmAll: (n: number) => `确认本表全部推断(${formatNumber(n)})`,
|
||||
confirmAllTitle: (n: number, table: string) => `确认「${table}」的 ${formatNumber(n)} 项推断?`,
|
||||
confirmAllConsequences: ['确认后这些项参与 SQL 检查', '来源保持不变,状态改为已确认', '之后仍可逐项恢复'],
|
||||
confirmAllAction: (n: number) => `确认 ${formatNumber(n)} 项`,
|
||||
confirmColumn: '确认本列推断',
|
||||
confirmColumnLabel: (col: string, n: number) => `确认列 ${col} 的 ${formatNumber(n)} 项推断`,
|
||||
confirmedN: (n: number) => `已确认 ${formatNumber(n)} 项`,
|
||||
// ---- 编辑
|
||||
edit: '编辑',
|
||||
editHint: '保存后,改动过的项记为人工填写、已确认',
|
||||
clearHint: '清空一项即删除;不希望起草再次提出的项,请用「驳回」',
|
||||
save: '保存',
|
||||
saving: '正在保存…',
|
||||
cancelEdit: '取消',
|
||||
changes: (n: number) => `${formatNumber(n)} 处修改`,
|
||||
noChanges: '尚未修改',
|
||||
saved: '已保存,改动过的项已记为人工确认',
|
||||
invalidCount: (n: number) => `${formatNumber(n)} 处格式不正确`,
|
||||
keysPlaceholder: '列名,用顿号或逗号分隔',
|
||||
keysUnknown: (cols: string) => `表结构中没有这些列:${cols}`,
|
||||
codesPlaceholder: '每行一个,如 1=已支付',
|
||||
codesInvalid: (line: number) => `第 ${formatNumber(line)} 行应写成「码值=含义」`,
|
||||
codesDuplicate: (code: string) => `码值 ${code} 重复`,
|
||||
dateColumnRequired: '填写规则或时区时,日期列必填',
|
||||
tooLong: (n: number) => `不超过 ${formatNumber(n)} 字`,
|
||||
kindNone: '未填写',
|
||||
measureNone: '未填写',
|
||||
cardinalityNone: '未知',
|
||||
addRelation: '添加关联关系',
|
||||
removeRelation: '删除这条关联关系',
|
||||
relationColumns: '本表字段,用逗号分隔',
|
||||
relationTarget: '目标表',
|
||||
relationToColumns: '目标字段,用逗号分隔',
|
||||
relationIncomplete: '本表字段、目标表和目标字段都要填写',
|
||||
relationMismatch: '两端的字段数不一致',
|
||||
relationDuplicate: '与另一条关联关系重复',
|
||||
rejectedPlaceholder: (value: string) => `已驳回:${value}`,
|
||||
leaveTitle: '数据目录有未保存的修改',
|
||||
leaveConsequences: ['离开后修改将丢失'],
|
||||
leaveConfirm: '放弃修改并离开',
|
||||
// ---- 冲突与错误
|
||||
conflictTitle: '这张表的数据目录刚被其他人修改过',
|
||||
conflictEditing: '你的修改尚未保存。重新载入会放弃你的修改并显示最新内容;如需保留,请先记下要改的地方。',
|
||||
conflictReview: '刚才的操作未生效,请重新载入后再操作。',
|
||||
reload: '重新载入',
|
||||
reloadDiscardTitle: '放弃未保存的修改并重新载入?',
|
||||
reloadDiscardAction: '放弃修改并重新载入',
|
||||
}
|
||||
|
||||
@@ -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' ? (
|
||||
// 按配方导入的源:每月按已确认的配方重放,不走简单上传(服务端也会拒绝)
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft, BookMarked, Database, Sparkles } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type { CatalogDetail, CatalogList } from '../../types'
|
||||
import { EmptyState, ErrorState, Notice, PageHeader, Skeleton, toast } from '../../components/ui'
|
||||
import { useDatasources, useOnReconnect } from '../../store/catalog'
|
||||
import { CATALOG_TEXT as CT } from '../../lib/terms'
|
||||
import { StatusLegend } from './parts'
|
||||
import { DraftDialog } from './DraftDialog'
|
||||
import { TableIndex } from './TableIndex'
|
||||
import { filterCounts, progressOf, rowFromDetail, visibleRows } from './model'
|
||||
import type { ListFilter, ListSort } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
// 数据目录页(/data/catalog/:sourceId,选中一张表时 /data/catalog/:sourceId/:table)。
|
||||
//
|
||||
// 用独立页面而不是弹窗:一个库 150 多张表、一张表 50 列,审阅是按使用次数一张接一张做的长活,要整屏的宽度
|
||||
// 放左栏的表清单和右栏的列表格;地址里带着表名,可以把某张表直接发给同事核对,浏览器的前进后退、刷新都不丢
|
||||
// 位置;编辑到一半换表、离开页面,走全站统一的离开前确认。
|
||||
//
|
||||
// 宽屏左右两栏;窄屏(< lg)一次只显示一栏:没选表时是清单,选了表是详情(详情里有「返回表清单」)。
|
||||
// ===========================================================================
|
||||
|
||||
export function CatalogPage() {
|
||||
const { sourceId = '', table } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { list: sources } = useDatasources()
|
||||
const source = sources.find((s) => s.id === sourceId)
|
||||
const [data, setData] = useState<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 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>
|
||||
)
|
||||
|
||||
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 {
|
||||
const pendingNext = rows.find((r) => r.in_schema && r.counts.proposed > 0)
|
||||
const stats = { pending: 0, done: 0, none: 0 }
|
||||
for (const r of rows) stats[progressOf(r.counts)]++
|
||||
body = (
|
||||
<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}>
|
||||
<div className="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={draftButton(true)} 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={() => openTable(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>
|
||||
)}
|
||||
{data.system_notes && <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>
|
||||
</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 && draftButton()}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 导入表格的源:说明是系统生成的,只读 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 审阅进度:有未确认项 / 全部已确认 / 没有目录的表各占多少 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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="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>
|
||||
)
|
||||
})
|
||||
@@ -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: CatalogStructureColumn[]): string[] {
|
||||
if (!structure.length) return []
|
||||
const names = new Set(structure.map((c) => c.name.toLowerCase()))
|
||||
return splitList(text).filter((k) => !names.has(k.toLowerCase()))
|
||||
}
|
||||
|
||||
function parseTable(k: Exclude<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
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, CircleCheck, CircleDashed, CircleX, RotateCcw, ShieldCheck, Trash2, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { CatalogCounts, CatalogReviewAction, CatalogSource, CatalogStatus } from '../../types'
|
||||
import { formatDateTime, formatNumber } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_SOURCE_LABEL, CATALOG_STATUS_HINT, CATALOG_STATUS_LABEL, CATALOG_TEXT as CT,
|
||||
} from '../../lib/terms'
|
||||
import { STATUSES, initialStatus } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
// 数据目录的公共小件:状态标识(四种状态一套样式,颜色走语义令牌)、清单上的状态计数、图例、
|
||||
// 单项的来源和状态弹层(确认 / 驳回 / 恢复)。
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 四种状态的颜色和图标。推断要人去看,用「等待」的琥珀;已验证(外键约束、数据剖析)是机器给的确证,用强调蓝;
|
||||
* 已确认是人工拍板,用「完成」的绿;已驳回用「失败」的红,值划掉
|
||||
*/
|
||||
export const STATUS_TONE: Record<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, onReview }: {
|
||||
target: ReviewTarget
|
||||
compact?: boolean
|
||||
/** 正在提交别的操作:弹层照常能看,操作按钮禁用 */
|
||||
disabled?: boolean
|
||||
onReview: (action: CatalogReviewAction) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
const trigger = useRef<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()
|
||||
}
|
||||
|
||||
// 先按触发按钮下方放,量过弹层的高度后,下方放不下就翻到上方
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
const r = trigger.current?.getBoundingClientRect()
|
||||
const el = panel.current
|
||||
if (!r || !el) return
|
||||
const w = el.offsetWidth
|
||||
const h = el.offsetHeight
|
||||
const left = Math.max(8, Math.min(r.left, window.innerWidth - w - 8))
|
||||
const below = r.bottom + 4
|
||||
const top = below + h > window.innerHeight - 8 && r.top - h - 4 > 8 ? r.top - h - 4 : below
|
||||
setPos({ top, left })
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as Node
|
||||
if (!panel.current?.contains(t) && !trigger.current?.contains(t)) close(false)
|
||||
}
|
||||
// 弹层自己里面的滚动不算(它不滚);页面或表格一滚,固定定位就对不上触发按钮了
|
||||
const onScroll = (e: Event) => { if (!panel.current?.contains(e.target as Node)) close(false) }
|
||||
const onResize = () => close(false)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// 定好位置以后把焦点交给第一个能点的操作
|
||||
useEffect(() => {
|
||||
if (!open || !pos) return
|
||||
const first = panel.current?.querySelector<HTMLButtonElement>('[data-review-action]:not([disabled])')
|
||||
;(first ?? panel.current)?.focus({ preventScroll: true })
|
||||
}, [open, pos])
|
||||
|
||||
const onKey = (e: ReactKeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
close()
|
||||
return
|
||||
}
|
||||
const els = [...(panel.current?.querySelectorAll<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>
|
||||
<dl className="mt-2 space-y-1.5 text-2xs leading-relaxed">
|
||||
<div className="flex items-start gap-2">
|
||||
<dt className="sr-only">状态</dt>
|
||||
<dd className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<StatusChip status={status} />
|
||||
<span className="text-dim">{CATALOG_STATUS_HINT[status]}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-2 text-dim">
|
||||
<dt className="sr-only">来源</dt>
|
||||
<dd>{CT.itemSource(CATALOG_SOURCE_LABEL[source])}</dd>
|
||||
</div>
|
||||
{target.updated_at && (
|
||||
<div className="flex gap-2 text-faint">
|
||||
<dt className="sr-only">更新时间</dt>
|
||||
<dd className="tnum">{CT.itemUpdated(formatDateTime(target.updated_at))}</dd>
|
||||
</div>
|
||||
)}
|
||||
{target.note && (
|
||||
<div className="rounded border bg-bg px-2 py-1 text-dim">
|
||||
<dt className="sr-only">备注</dt>
|
||||
<dd className="whitespace-pre-wrap break-words">{target.note}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user