数据目录阶段 1C:数据目录页与表清单,数据源卡片入口、搜索筛选排序、多选分批起草、空状态与探查结构引导

This commit is contained in:
ThearchyHelios
2026-10-03 10:13:36 +08:00
parent b90d235d64
commit db192ffc81
8 changed files with 1696 additions and 3 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 />} />
+307 -1
View File
@@ -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: '放弃修改并重新载入',
}
+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' ? (
// 按配方导入的源:每月按已确认的配方重放,不走简单上传(服务端也会拒绝)
<>
+245
View File
@@ -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>
)
}
+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>
)
}
+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="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: 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
}
+293
View File
@@ -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>
)
}