数据目录阶段 1C:单表详情,表级项、列表格、关联关系的来源和状态,单项审阅、批量确认、编辑即确认,409 时提示重新载入

This commit is contained in:
ThearchyHelios
2026-10-03 10:28:33 +08:00
parent db192ffc81
commit 2fd578e024
7 changed files with 971 additions and 61 deletions
+5 -1
View File
@@ -2069,13 +2069,17 @@ export const CATALOG_TEXT = {
save: '保存',
saving: '正在保存…',
cancelEdit: '取消',
cancelEditTitle: '放弃对这张表的修改?',
cancelEditAction: '放弃修改',
changedMark: '已修改',
changes: (n: number) => `${formatNumber(n)} 处修改`,
noChanges: '尚未修改',
saved: '已保存,改动过的项已记为人工确认',
invalidCount: (n: number) => `${formatNumber(n)} 处格式不正确`,
keysPlaceholder: '列名,用顿号或逗号分隔',
keysUnknown: (cols: string) => `表结构中没有这些列:${cols}`,
codesPlaceholder: '每行一个,如 1=已支付',
codesPlaceholder: '1=已支付',
codesHint: '每行一个,写成「码值=含义」',
codesInvalid: (line: number) => `第 ${formatNumber(line)} 行应写成「码值=含义」`,
codesDuplicate: (code: string) => `码值 ${code} 重复`,
dateColumnRequired: '填写规则或时区时,日期列必填',
+60 -46
View File
@@ -4,12 +4,13 @@ import { useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, BookMarked, Database, Sparkles } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
import type { CatalogDetail, CatalogList } from '../../types'
import { EmptyState, ErrorState, Notice, PageHeader, Skeleton, toast } from '../../components/ui'
import type { CatalogDetail, CatalogList, CatalogTableRow } from '../../types'
import { EmptyState, ErrorState, PageHeader, Skeleton, toast } from '../../components/ui'
import { useDatasources, useOnReconnect } from '../../store/catalog'
import { CATALOG_TEXT as CT } from '../../lib/terms'
import { StatusLegend } from './parts'
import { StatusLegend, SystemNotesNotice } from './parts'
import { DraftDialog } from './DraftDialog'
import { TableDetail } from './TableDetail'
import { TableIndex } from './TableIndex'
import { filterCounts, progressOf, rowFromDetail, visibleRows } from './model'
import type { ListFilter, ListSort } from './model'
@@ -86,6 +87,15 @@ export function CatalogPage() {
void load()
}, [load])
// 上一张 / 下一张按清单当前的筛选和排序走;正在看的表不在筛选结果里(刚确认完、从关联关系跳过来)时按全部表走
const [prev, next] = useMemo(() => {
if (!table) return [null, null]
const list = visible.some((r) => r.table_name === table) ? visible : visibleRows(rows, '', 'all', sort)
const i = list.findIndex((r) => r.table_name === table)
if (i < 0) return [null, null]
return [list[i - 1]?.table_name ?? null, list[i + 1]?.table_name ?? null]
}, [table, visible, rows, sort])
const filtered = !!query.trim() || filter !== 'all'
const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none')
const hasTables = rows.length > 0
@@ -117,9 +127,6 @@ export function CatalogPage() {
</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')}>
@@ -149,39 +156,12 @@ export function CatalogPage() {
/>
</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>
{table
? (
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} prev={prev} next={next}
onOpen={openTable} onBack={() => navigate(base)} onDetail={onDetail} />
)
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
</section>
</div>
)
@@ -218,14 +198,48 @@ export function CatalogPage() {
)
}
/** 导入表格的源:说明是系统生成的,只读 */
export function SystemNotesNotice() {
/** 没有选中表时的右栏:空目录时引导起草;否则写审阅进度,给出下一张该审的表 */
function Overview({ rows, empty, systemNotes, draft, onOpen }: {
rows: CatalogTableRow[]
empty: boolean
systemNotes: boolean
draft: ReactNode
onOpen: (table: string) => void
}) {
const pendingNext = rows.find((r) => r.in_schema && r.counts.proposed > 0)
const stats = { pending: 0, done: 0, none: 0 }
for (const r of rows) stats[progressOf(r.counts)]++
return (
<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>
<div className="relative min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-xl space-y-4 p-6" data-catalog-overview="">
{empty
? <EmptyState icon={<BookMarked size={22} />} title={CT.emptyTitle} body={CT.emptyBody} offline={false} action={draft} className="!py-8" />
: (
<div className="space-y-3">
<h2 className="text-sm font-semibold">{CT.overviewTitle}</h2>
<p className="text-xs leading-relaxed text-dim">{CT.overviewBody}</p>
<p className="tnum text-xs text-dim" data-catalog-stats={`${stats.pending},${stats.done},${stats.none}`}>
{CT.overviewStats(rows.length, stats.pending, stats.done, stats.none)}
</p>
<ProgressBar pending={stats.pending} done={stats.done} none={stats.none} />
{pendingNext && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<button type="button" className="btn btn-primary btn-sm" onClick={() => onOpen(pendingNext.table_name)}
data-catalog-review-next={pendingNext.table_name}>
{CT.reviewNext(pendingNext.label ?? pendingNext.table_name)}
</button>
<span className="text-2xs text-faint">{CT.reviewNextHint}</span>
</div>
)}
</div>
)}
{systemNotes && <SystemNotesNotice />}
<div className="rounded-lg border bg-panel p-3">
<div className="mb-2 text-2xs font-medium text-dim">{CT.legend}</div>
<StatusLegend detailed />
</div>
</div>
</div>
)
}
+211
View File
@@ -0,0 +1,211 @@
import { memo } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, CheckCheck, Lock } from 'lucide-react'
import clsx from 'clsx'
import type {
CatalogColumnNotes, CatalogItem, CatalogMeasure, CatalogReviewAction, CatalogStructureColumn,
} from '../../types'
import { IconButton } from '../../components/ui'
import {
CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT,
} from '../../lib/terms'
import { ItemMark } from './parts'
import type { ReviewTarget } from './parts'
import { COLUMN_FIELDS, columnPath } from './model'
import type { ColumnField } from './model'
// ===========================================================================
// 单表详情的列表格:列名 / 类型、中文名、含义、单位、度量类型、码值。每格的值旁边是只有图标的状态标识,点开看来源、
// 做确认 / 驳回 / 恢复;有推断项的行末尾有「确认本列推断」。编辑时每格换成输入框。
// 一张表 50 列时每次按键都会重画整张表:行是 memo 的,只有改动的那一行重画。
// ===========================================================================
const MEASURES = Object.keys(CATALOG_MEASURE_LABEL) as CatalogMeasure[]
const CODES_SHOWN = 4
export interface ColumnModel {
name: string
/** 表结构里的这一列;表结构里已经没有、目录还留着的为 null */
structure: CatalogStructureColumn | null
items: CatalogColumnNotes | undefined
}
type ColumnTexts = Partial<Record<ColumnField, string>>
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: {
columns: ColumnModel[]
/** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */
form: Record<string, ColumnTexts> | null
initial: Record<string, ColumnTexts> | null
/** 格式问题:键 c:<列名>:<字段> */
problems: Record<string, string>
busy: boolean
/** 导入表格的源:列的说明是系统生成的,只读地写在列名下面 */
systemNotes: boolean
onReview: (target: ReviewTarget, action: CatalogReviewAction) => void
onConfirmColumn: (col: string) => void
onChange: (col: string, field: ColumnField, value: string) => void
}) {
return (
<div className="relative overflow-x-auto rounded-lg border" data-catalog-columns="">
<table className="w-full min-w-[860px] table-fixed border-collapse text-xs">
<thead>
<tr className="border-b bg-elev text-left text-2xs text-faint">
<th scope="col" className="w-[21%] px-3 py-2 font-medium">{CT.columnHead.name} / {CT.columnHead.type}</th>
<th scope="col" className="w-[14%] px-2 py-2 font-medium">{CT.columnHead.label}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.columnHead.meaning}</th>
<th scope="col" className="w-[8%] px-2 py-2 font-medium">{CT.columnHead.unit}</th>
<th scope="col" className="w-[11%] px-2 py-2 font-medium">{CT.columnHead.measure}</th>
<th scope="col" className="w-[17%] px-2 py-2 font-medium" title={form ? CT.codesHint : undefined}>
{CT.columnHead.codes}
{form && <span className="ml-1 font-normal">· {CT.codesHint}</span>}
</th>
<th scope="col" className="w-9 px-1 py-2"><span className="sr-only">{CT.confirmColumn}</span></th>
</tr>
</thead>
<tbody>
{columns.map((c) => (
<ColumnRow key={c.name} col={c} editing={!!form} draft={form?.[c.name]} initial={initial?.[c.name]} problems={problems} busy={busy}
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onChange={onChange} />
))}
</tbody>
</table>
</div>
)
}
const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: {
col: ColumnModel
editing: boolean
/** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */
draft: ColumnTexts | undefined
initial: ColumnTexts | undefined
problems: Record<string, string>
busy: boolean
systemNotes: boolean
onReview: (target: ReviewTarget, action: CatalogReviewAction) => void
onConfirmColumn: (col: string) => void
onChange: (col: string, field: ColumnField, value: string) => void
}) {
const { name, structure, items } = col
const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length
const comment = systemNotes ? structure?.comment?.trim() : ''
const cell = (f: ColumnField, view: ReactNode, input: ReactNode) => {
const it = items?.[f] as CatalogItem | undefined
const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f])
const problem = problems[`c:${name}:${f}`]
const changed = editing && (draft?.[f] ?? '') !== (initial?.[f] ?? '')
return (
<td className="px-2 py-1.5 align-top" data-cell={f} data-status={it?.status}>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">{editing ? input : view}</div>
{it && !changed && (
<span className="pt-0.5">
<ItemMark compact disabled={busy || editing}
target={{ path: columnPath(name, f), where, source: it.source, status: it.status, note: it.note, updated_at: it.updated_at }}
onReview={(a) => onReview({ path: columnPath(name, f), where, source: it.source, status: it.status }, a)} />
</span>
)}
</div>
{problem && <div className="mt-1 text-2xs leading-relaxed text-[var(--err)]" role="alert">{problem}</div>}
</td>
)
}
const text = (f: ColumnField, opts?: { mono?: boolean; narrow?: boolean }) => {
const it = items?.[f] as CatalogItem<string> | undefined
const rejected = it?.status === 'rejected'
const value = draft?.[f] ?? ''
return cell(
f,
<Value item={it} render={(v) => <span className={clsx('whitespace-pre-wrap break-words', opts?.mono && 'mono')}>{v}</span>} />,
<input
className={clsx('field !px-1.5 !py-1', opts?.mono && 'mono')}
value={value}
placeholder={rejected ? CT.rejectedPlaceholder(String(it?.value ?? '')) : ''}
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f])}
aria-invalid={problems[`c:${name}:${f}`] ? true : undefined}
onChange={(e) => onChange(name, f, e.target.value)}
data-input={f}
/>,
)
}
const measure = items?.measure
const codes = items?.codes
return (
<tr className="border-b border-hairline last:border-b-0 hover:bg-hover/40" data-column={name} data-pending={pending}>
<th scope="row" className="px-3 py-1.5 text-left align-top font-normal">
<div className="flex flex-wrap items-center gap-1">
<span className="mono text-xs font-medium text-fg [overflow-wrap:anywhere]">{name}</span>
{structure?.pk && <span className="chip !px-1.5 !py-0 !text-2xs">{CT.pk}</span>}
</div>
{structure && <div className="mono mt-0.5 truncate text-2xs text-faint" title={structure.type}>{structure.type || '—'}</div>}
{comment && (
<div className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-dim" data-system-note="" title={CT.systemTag}>
<Lock size={10} className="mt-[3px] shrink-0 text-faint" aria-hidden />
<span><span className="text-faint">{CT.systemTag}:</span>{comment}</span>
</div>
)}
{!structure && (
<div className="mt-1 flex items-center gap-1 text-2xs text-[var(--warn)]" data-column-missing="">
<AlertTriangle size={10} aria-hidden /> {CT.columnMissing}
</div>
)}
</th>
{text('label')}
{text('meaning')}
{text('unit')}
{cell(
'measure',
<Value item={measure} render={(v) => <span title={CATALOG_MEASURE_HINT[v as CatalogMeasure]}>{CATALOG_MEASURE_LABEL[v as CatalogMeasure] ?? v}</span>} />,
<select className="field !px-1 !py-1" value={draft?.measure ?? ''}
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL.measure)}
onChange={(e) => onChange(name, 'measure', e.target.value)} data-input="measure">
<option value="">{CT.measureNone}</option>
{MEASURES.map((m) => <option key={m} value={m}>{CATALOG_MEASURE_LABEL[m]}</option>)}
</select>,
)}
{cell(
'codes',
<Value item={codes} render={(v) => <Codes value={v as Record<string, string>} />} />,
<textarea className="field mono !min-h-0 !px-1.5 !py-1 !text-2xs" rows={Math.min(4, Math.max(1, (draft?.codes ?? '').split('\n').length))}
value={draft?.codes ?? ''}
placeholder={codes?.status === 'rejected' ? CT.rejectedPlaceholder(codesInline(codes.value)) : CT.codesPlaceholder}
aria-label={CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL.codes)}
aria-invalid={problems[`c:${name}:codes`] ? true : undefined}
onChange={(e) => onChange(name, 'codes', e.target.value)} data-input="codes" />,
)}
<td className="px-1 py-1.5 align-top">
{!editing && pending > 0 && (
<IconButton label={CT.confirmColumnLabel(name, pending)} icon={<CheckCheck size={13} />} disabled={busy}
onClick={() => onConfirmColumn(name)} data-confirm-column={name} />
)}
</td>
</tr>
)
}, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy
&& a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onChange === b.onChange
// 格式问题每次都是新对象:只比这一列的几格
&& COLUMN_FIELDS.every((f) => a.problems[`c:${a.col.name}:${f}`] === b.problems[`c:${b.col.name}:${f}`]))
const codesInline = (v: Record<string, string> | undefined) => Object.entries(v ?? {}).map(([k, x]) => `${k}=${x}`).join(';')
/** 一项的值:没有写「—」;被驳回的划掉、淡色 */
export function Value<T>({ item, render }: { item: CatalogItem<T> | undefined; render: (v: T) => ReactNode }) {
if (!item) return <span className="text-faint"><span aria-hidden>—</span><span className="sr-only">{CT.empty}</span></span>
return <span className={clsx(item.status === 'rejected' && 'text-faint line-through')}>{render(item.value)}</span>
}
function Codes({ value }: { value: Record<string, string> }) {
const entries = Object.entries(value)
return (
<span className="flex flex-wrap gap-1" title={entries.map(([k, v]) => `${k}=${v}`).join('\n')}>
{entries.slice(0, CODES_SHOWN).map(([k, v]) => (
<span key={k} className="inline-flex max-w-full items-center gap-1 rounded border bg-bg px-1 text-2xs">
<span className="mono text-dim">{k}</span>
<span className="truncate">{v}</span>
</span>
))}
{entries.length > CODES_SHOWN && <span className="text-2xs text-faint">+{entries.length - CODES_SHOWN}</span>}
</span>
)
}
+658
View File
@@ -0,0 +1,658 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, ListChecks, Lock, Pencil, Plus, RotateCw, Search } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
import type {
CatalogBusinessDate, CatalogCardinality, CatalogDetail, CatalogItem, CatalogRelation, CatalogReviewAction, CatalogTableKind,
CatalogTableRow,
} from '../../types'
import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confirmDialog, toast } from '../../components/ui'
import { useLeaveGuard } from '../../lib/leave'
import { formatDateTime, formatNumber } from '../../lib/format'
import {
CATALOG_CARDINALITY_LABEL, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT, CATALOG_TABLE_FIELD_LABEL,
CATALOG_TEXT as CT,
} from '../../lib/terms'
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
import type { ReviewTarget } from './parts'
import { ColumnTable, Value } from './ColumnTable'
import type { ColumnModel } from './ColumnTable'
import {
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
relationDraft, relationPath, tablePath, unknownKeys, validateForm,
} from './model'
import type { ColumnField, EditForm, RelationDraft, TableField, TableKey } from './model'
// ===========================================================================
// 单表详情:表级各项、列表格、关联关系。每一项标出来源和状态,点状态标识做单项确认 / 驳回 / 恢复(审阅接口);
// 「确认本表全部推断」「确认本列推断」和直接编辑都是整份提交(编辑即确认:改过的项服务端记为人工填写、已确认)。
//
// 写操作都带读到的版本。409 说明别人刚改过:不静默覆盖,横幅写明并给「重新载入」;编辑中的修改留着,重新载入前
// 先问一句。编辑中换表、离开页面走全站的离开前确认。
// ===========================================================================
const KINDS = Object.keys(CATALOG_KIND_LABEL) as CatalogTableKind[]
const CARDINALITIES = Object.keys(CATALOG_CARDINALITY_LABEL) as CatalogCardinality[]
const PROBLEM_TEXT = {
tooLong: CT.tooLong,
codesInvalid: CT.codesInvalid,
codesDuplicate: CT.codesDuplicate,
dateColumnRequired: CT.dateColumnRequired,
relationIncomplete: CT.relationIncomplete,
relationMismatch: CT.relationMismatch,
relationDuplicate: CT.relationDuplicate,
}
type Editing = { initial: EditForm; form: EditForm }
type Conflict = 'edit' | 'review'
export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen, onBack, onDetail }: {
sourceId: string
table: string
/** 全部表:关联关系的目标表能点就跳过去,编辑时给目标表做候选 */
rows: CatalogTableRow[]
/** 起草刚写进了这些表:正在看的表在里面、又没在编辑,就重新载入 */
drafted: { seq: number; tables: Set<string> }
prev: string | null
next: string | null
onOpen: (table: string) => void
/** 窄屏:回到表清单 */
onBack: () => void
/** 载入或写入之后的单表目录:清单据此更新这一行 */
onDetail: (d: CatalogDetail) => void
}) {
const [detail, setDetail] = useState<CatalogDetail | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
const [busy, setBusy] = useState('')
const [conflict, setConflict] = useState<Conflict | null>(null)
const [writeError, setWriteError] = useState<string | null>(null)
const [edit, setEdit] = useState<Editing | null>(null)
const [live, setLive] = useState('')
const [colQuery, setColQuery] = useState('')
const [onlyPending, setOnlyPending] = useState(false)
const loadSeq = useRef(0)
const onDetailRef = useRef(onDetail)
onDetailRef.current = onDetail
const load = useCallback(async () => {
const seq = ++loadSeq.current
try {
const d = await api.dataCatalog.get(sourceId, table)
if (seq !== loadSeq.current) return
setDetail(d)
setLoadError(null)
setConflict(null)
onDetailRef.current(d)
} catch (e) {
if (seq !== loadSeq.current) return
setLoadError(e)
}
}, [sourceId, table])
useEffect(() => {
setDetail(null)
setLoadError(null)
setEdit(null)
setConflict(null)
setWriteError(null)
setColQuery('')
setOnlyPending(false)
setLive('')
void load()
}, [load])
// 起草写进了这张表:没在编辑就重新载入;在编辑就不动,保存时服务端按版本号拦下,横幅里再重新载入
const editingRef = useRef(false)
editingRef.current = !!edit
useEffect(() => {
if (drafted.seq && drafted.tables.has(table) && !editingRef.current) void load()
}, [drafted, table, load])
const changes = edit ? changeCount(edit.initial, edit.form) : 0
const problems = useMemo(() => (edit ? validateForm(edit.form, PROBLEM_TEXT) : {}), [edit])
const problemCount = Object.keys(problems).length
useLeaveGuard(changes > 0, () => confirmDialog({
title: CT.leaveTitle, consequences: CT.leaveConsequences, confirmLabel: CT.leaveConfirm, danger: true,
}))
/** 写操作:成功换上返回的目录;409 挂冲突横幅,422 显示服务端原话,其余弹提示。只用 ref 和 setState,自身不变 */
const write = useCallback(async (key: string, call: () => Promise<CatalogDetail>): Promise<CatalogDetail | null> => {
setBusy(key)
setWriteError(null)
try {
const d = await call()
setDetail(d)
setConflict(null)
onDetailRef.current(d)
return d
} catch (e) {
if (e instanceof ApiError && e.status === 409) setConflict(editingRef.current ? 'edit' : 'review')
else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) setWriteError(e.message)
else toast.error(e)
return null
} finally {
setBusy('')
}
}, [])
const detailRef = useRef(detail)
detailRef.current = detail
const review = useCallback(async (t: ReviewTarget, action: CatalogReviewAction) => {
const d = detailRef.current
if (!d) return
const done = await write(t.path, () => api.dataCatalog.review(sourceId, table, { path: t.path, action, if_version: d.version }))
if (done) setLive(action === 'reset' && t.source === 'human' ? CT.removed(t.where) : CT.reviewed[action](t.where))
}, [sourceId, table, write])
const confirmColumn = useCallback(async (col: string) => {
const d = detailRef.current
if (!d) return
const paths = new Set(COLUMN_FIELDS.map((f) => columnPath(col, f)))
const { notes, n } = confirmProposed(d.notes, (p) => paths.has(p))
if (!n) return
const done = await write(`column:${col}`, () => api.dataCatalog.put(sourceId, table, notes, d.version))
if (done) setLive(CT.confirmedN(n))
}, [sourceId, table, write])
const setColumn = useCallback((col: string, f: ColumnField, v: string) => {
setEdit((e) => e && ({ ...e, form: { ...e.form, columns: { ...e.form.columns, [col]: { ...e.form.columns[col], [f]: v } } } }))
}, [])
const setTable = (k: TableKey, v: string) => setEdit((e) => e && ({ ...e, form: { ...e.form, table: { ...e.form.table, [k]: v } } }))
const setRelations = (fn: (rs: RelationDraft[]) => RelationDraft[]) =>
setEdit((e) => e && ({ ...e, form: { ...e.form, relations: fn(e.form.relations) } }))
const columns: ColumnModel[] = useMemo(() => {
if (!detail) return []
const st = detail.structure?.columns ?? []
const known = new Set(st.map((c) => c.name))
return [
...st.map((c) => ({ name: c.name, structure: c, items: detail.notes.columns?.[c.name] })),
...Object.keys(detail.notes.columns ?? {}).filter((n) => !known.has(n))
.map((n) => ({ name: n, structure: null, items: detail.notes.columns?.[n] })),
]
}, [detail])
const shownColumns = useMemo(() => {
const q = colQuery.trim().toLowerCase()
return columns.filter((c) => {
if (onlyPending && !COLUMN_FIELDS.some((f) => c.items?.[f]?.status === 'proposed')) return false
if (!q) return true
const label = c.items?.label?.value ?? ''
return c.name.toLowerCase().includes(q) || label.toLowerCase().includes(q)
})
}, [columns, colQuery, onlyPending])
if (!detail) {
return (
<div className="min-h-0 flex-1 overflow-y-auto p-4" data-catalog-detail={table} data-loading="">
<button type="button" className="btn btn-sm mb-3 lg:hidden" onClick={onBack}><ArrowLeft size={12} aria-hidden /> {CT.backToList}</button>
{loadError
? (loadError instanceof ApiError && loadError.status === 404
? <EmptyState title={loadError.message} offline={false} />
: <ErrorState error={loadError} onRetry={() => void load()} />)
: <Skeleton rows={6} height={28} gap={10} />}
</div>
)
}
const notes = detail.notes
const counts = countsOf(notes)
const label = notes.label && notes.label.status !== 'rejected' ? notes.label.value : null
const kind = notes.kind && notes.kind.status !== 'rejected' ? notes.kind.value : null
const st = detail.structure
const systemComment = detail.system_notes ? st?.comment?.trim() : ''
const startEdit = () => {
const f = formFromNotes(notes, st?.columns ?? [])
setEdit({ initial: f, form: f })
setWriteError(null)
}
const cancelEdit = async () => {
if (changes > 0 && !(await confirmDialog({ title: CT.cancelEditTitle, confirmLabel: CT.cancelEditAction, danger: true }))) return
setEdit(null)
setConflict(null)
setWriteError(null)
}
const save = async () => {
if (!edit || problemCount || !changes) return
const body = buildNotes(notes, edit.initial, edit.form)
const done = await write('save', () => api.dataCatalog.put(sourceId, table, body, detail.version))
if (done) {
setEdit(null)
toast.ok(CT.saved)
}
}
const confirmAll = async () => {
const { notes: body, n } = confirmProposed(notes)
if (!n) return
const ok = await confirmDialog({
title: CT.confirmAllTitle(n, label ?? table), consequences: CT.confirmAllConsequences, confirmLabel: CT.confirmAllAction(n),
})
if (!ok) return
const done = await write('confirm-all', () => api.dataCatalog.put(sourceId, table, body, detail.version))
if (done) toast.ok(CT.confirmedN(n))
}
const reload = async () => {
if (changes > 0 && !(await confirmDialog({ title: CT.reloadDiscardTitle, confirmLabel: CT.reloadDiscardAction, danger: true }))) return
setEdit(null)
setWriteError(null)
await load()
}
const targetOf = (name: string) => {
const lower = name.toLowerCase()
return rows.find((r) => r.table_name === name || r.qualified === name)
?? rows.find((r) => r.table_name.toLowerCase() === lower || r.qualified.toLowerCase() === lower)
}
return (
<div className="flex min-h-0 flex-1 flex-col" data-catalog-detail={table} data-version={detail.version}
data-editing={edit ? 'true' : 'false'}>
<header className="space-y-2 border-b bg-panel px-4 py-3">
<div className="flex items-start gap-2">
<button type="button" className="btn btn-sm btn-ghost -ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label={CT.backToList} data-catalog-detail-back="">
<ArrowLeft size={12} aria-hidden />
</button>
<div className="min-w-0 flex-1">
<h2 className="flex flex-wrap items-center gap-2 text-sm font-semibold" data-detail-title="">
<span className={clsx('break-all', !label && 'mono')}>{label ?? detail.table_name}</span>
{kind && <span className="chip" title={CATALOG_KIND_HINT[kind]}>{CATALOG_KIND_LABEL[kind]}</span>}
{st?.is_view && <span className="chip">{CT.view}</span>}
</h2>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-faint">
{label && <span className="mono break-all text-dim">{st?.qualified ?? detail.table_name}</span>}
<span className="tnum" title={CT.usageHint}>{CT.usage(detail.usage)}</span>
<span className="tnum">
{detail.updated_at
? (detail.updated_by ? CT.updatedBy(detail.updated_by, formatDateTime(detail.updated_at)) : CT.updatedAt(formatDateTime(detail.updated_at)))
: CT.notStarted}
</span>
<CountBadges counts={counts} />
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button type="button" className="btn btn-sm btn-ghost" disabled={!prev} onClick={() => prev && onOpen(prev)}
aria-label={CT.prev} title={prev ? `${CT.prev}:${prev}` : CT.prev} data-catalog-prev="">
<ChevronLeft size={13} aria-hidden /><span className="hidden xl:inline">{CT.prev}</span>
</button>
<button type="button" className="btn btn-sm btn-ghost" disabled={!next} onClick={() => next && onOpen(next)}
aria-label={CT.next} title={next ? `${CT.next}:${next}` : CT.next} data-catalog-next="">
<span className="hidden xl:inline">{CT.next}</span><ChevronRight size={13} aria-hidden />
</button>
</div>
</div>
{!edit && (
<div className="flex flex-wrap items-center gap-1.5">
{counts.proposed > 0 && (
<button type="button" className="btn btn-sm" disabled={!!busy} onClick={() => void confirmAll()} data-catalog-confirm-all={counts.proposed}>
{busy === 'confirm-all' ? <Spinner size={11} /> : <ListChecks size={12} aria-hidden />} {CT.confirmAll(counts.proposed)}
</button>
)}
<button type="button" className="btn btn-sm" disabled={!!busy} onClick={startEdit} data-catalog-edit="">
<Pencil size={11} aria-hidden /> {CT.edit}
</button>
<span className="text-2xs text-faint">{CT.editHint}</span>
</div>
)}
</header>
<div className="relative min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 p-4">
<p className="sr-only" aria-live="polite" data-catalog-live="">{live}</p>
{conflict && (
<Notice tone="err" attr={{ 'data-catalog-conflict': conflict }}>
<p className="font-medium">{CT.conflictTitle}</p>
<p className="mt-0.5 text-dim">{conflict === 'edit' ? CT.conflictEditing : CT.conflictReview}</p>
<button type="button" className="btn btn-sm mt-2" onClick={() => void reload()} data-catalog-reload="">
<RotateCw size={11} aria-hidden /> {CT.reload}
</button>
</Notice>
)}
{writeError && <Notice tone="err" attr={{ 'data-catalog-write-error': '' }}>{writeError}</Notice>}
{!detail.in_schema && (
<Notice tone="warn" attr={{ 'data-catalog-missing': '' }}>{CT.missingBody}</Notice>
)}
{detail.system_notes && <SystemNotesNotice />}
{edit && <Notice tone="info">{CT.editHint}。{CT.clearHint}。</Notice>}
<section aria-labelledby="catalog-sec-table" data-catalog-section="table">
<div className="mb-2 flex items-center gap-2">
<h3 id="catalog-sec-table" className="text-xs font-semibold">{CT.sectionTable}</h3>
<span className="flex-1" />
{!edit && <StatusLegend />}
</div>
<div className="rounded-lg border bg-panel">
{systemComment && (
<div className="flex items-start gap-2 border-b px-3 py-2.5 text-xs" data-system-table-note="">
<Lock size={12} className="mt-0.5 shrink-0 text-faint" aria-hidden />
<div className="min-w-0">
<div className="text-2xs text-faint">{CT.systemTableNote} · {CT.systemTag}</div>
<p className="mt-0.5 whitespace-pre-wrap break-words leading-relaxed text-dim">{systemComment}</p>
</div>
</div>
)}
<dl className="divide-y divide-[var(--hairline)]">
{TABLE_FIELDS.map((f) => (
<TableFieldRow key={f} field={f} item={notes[f] as CatalogItem | undefined} edit={edit} busy={!!busy}
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review} />
))}
</dl>
</div>
</section>
<section aria-labelledby="catalog-sec-columns" data-catalog-section="columns">
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 id="catalog-sec-columns" className="text-xs font-semibold">{CT.sectionColumns}</h3>
<span className="tnum text-2xs text-faint" data-column-count="">{CT.columnCount(shownColumns.length, columns.length)}</span>
<span className="flex-1" />
<div className="relative">
<Search size={11} className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-faint" aria-hidden />
<input type="search" className="field !w-48 !py-1 pl-6 !text-2xs" placeholder={CT.columnFilter} aria-label={CT.columnFilter}
value={colQuery} onChange={(e) => setColQuery(e.target.value)} data-column-filter="" />
</div>
<label className="inline-flex items-center gap-1.5 text-2xs text-dim">
<input type="checkbox" checked={onlyPending} onChange={(e) => setOnlyPending(e.target.checked)} data-only-pending="" />
{CT.onlyPending}
</label>
</div>
{shownColumns.length
? (
<ColumnTable columns={shownColumns} form={edit?.form.columns ?? null} initial={edit?.initial.columns ?? null}
problems={problems} busy={!!busy} systemNotes={detail.system_notes}
onReview={review} onConfirmColumn={confirmColumn} onChange={setColumn} />
)
: <p className="rounded-lg border px-3 py-6 text-center text-xs text-faint">{CT.noColumns}</p>}
</section>
<section aria-labelledby="catalog-sec-relations" data-catalog-section="relations">
<div className="mb-2 flex items-center gap-2">
<h3 id="catalog-sec-relations" className="text-xs font-semibold">{CT.sectionRelations}</h3>
<span className="tnum text-2xs text-faint">{formatNumber((edit ? edit.form.relations : notes.relations ?? []).length)}</span>
</div>
{edit
? <RelationEditor drafts={edit.form.relations} initial={edit.initial.relations} problems={problems} rows={rows}
onChange={setRelations} />
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review} />}
</section>
</div>
</div>
{edit && (
<footer className="flex flex-wrap items-center gap-2 border-t bg-panel px-4 py-2.5" data-catalog-edit-bar="">
<span className="tnum text-xs text-dim" data-catalog-changes={changes}>{changes ? CT.changes(changes) : CT.noChanges}</span>
{problemCount > 0 && (
<span className="tnum inline-flex items-center gap-1 text-xs text-[var(--err)]" data-catalog-problems={problemCount}>
<AlertTriangle size={11} aria-hidden /> {CT.invalidCount(problemCount)}
</span>
)}
<span className="flex-1" />
<button type="button" className="btn btn-sm" onClick={() => void cancelEdit()} disabled={busy === 'save'} data-catalog-cancel="">
{CT.cancelEdit}
</button>
<button type="button" className="btn btn-sm btn-primary" onClick={() => void save()}
disabled={!changes || problemCount > 0 || busy === 'save'} data-catalog-save="">
{busy === 'save' ? <><Spinner size={11} /> {CT.saving}</> : CT.save}
</button>
</footer>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// 表级各项
// ---------------------------------------------------------------------------
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview }: {
field: TableField
item: CatalogItem | undefined
edit: Editing | null
busy: boolean
problems: Record<string, string>
/** 表结构里的列:业务日期的候选、业务主键的核对 */
columns: { name: string }[]
onChange: (k: TableKey, v: string) => void
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
}) {
const where = CT.whereTable(CATALOG_TABLE_FIELD_LABEL[field])
const keys: TableKey[] = field === 'business_date' ? [...DATE_KEYS] : [field as TableKey]
const changed = !!edit && keys.some((k) => (edit.form.table[k] ?? '') !== (edit.initial.table[k] ?? ''))
const problem = keys.map((k) => problems[`t:${k}`]).find(Boolean)
const inputId = `catalog-field-${field}`
const rejected = item?.status === 'rejected'
const placeholder = rejected ? CT.rejectedPlaceholder(plainValue(field, item?.value)) : CATALOG_TABLE_FIELD_HINT[field]
let input: ReactNode = null
if (edit) {
const v = (k: TableKey) => edit.form.table[k] ?? ''
if (field === 'kind') {
input = (
<select id={inputId} className="field" value={v('kind')} onChange={(e) => onChange('kind', e.target.value)} data-input="kind">
<option value="">{CT.kindNone}</option>
{KINDS.map((k) => <option key={k} value={k}>{CATALOG_KIND_LABEL[k]}({CATALOG_KIND_HINT[k]})</option>)}
</select>
)
} else if (field === 'business_date') {
input = (
<div className="grid gap-2 sm:grid-cols-[1fr_1.4fr_1fr]">
<div>
<label className="label" htmlFor={inputId}>{CT.dateColumn}</label>
<input id={inputId} className="field mono" list="catalog-columns" value={v('business_date.column')}
aria-invalid={problems['t:business_date.column'] ? true : undefined}
onChange={(e) => onChange('business_date.column', e.target.value)} data-input="business_date.column" />
</div>
<div>
<label className="label" htmlFor={`${inputId}-rule`}>{CT.dateRule}</label>
<input id={`${inputId}-rule`} className="field" value={v('business_date.rule')}
onChange={(e) => onChange('business_date.rule', e.target.value)} data-input="business_date.rule" />
</div>
<div>
<label className="label" htmlFor={`${inputId}-tz`}>{CT.dateTimezone}</label>
<input id={`${inputId}-tz`} className="field" value={v('business_date.timezone')} placeholder="Asia/Shanghai"
onChange={(e) => onChange('business_date.timezone', e.target.value)} data-input="business_date.timezone" />
</div>
<datalist id="catalog-columns">{columns.map((c) => <option key={c.name} value={c.name} />)}</datalist>
</div>
)
} else if (field === 'description') {
input = (
<textarea id={inputId} className="field" rows={3} value={v('description')} placeholder={placeholder}
aria-invalid={problem ? true : undefined} onChange={(e) => onChange('description', e.target.value)} data-input="description" />
)
} else {
const k = field as TableKey
input = (
<input id={inputId} className={clsx('field', (field === 'valid_filter' || field === 'keys') && 'mono')} value={v(k)}
placeholder={field === 'keys' && !rejected ? CT.keysPlaceholder : placeholder}
aria-invalid={problem ? true : undefined} onChange={(e) => onChange(k, e.target.value)} data-input={field} />
)
}
}
const unknown = edit && field === 'keys' ? unknownKeys(edit.form.table.keys ?? '', columns) : []
return (
<div className="grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]" data-field={field} data-status={item?.status}>
<dt className="pt-0.5 text-2xs text-faint" title={CATALOG_TABLE_FIELD_HINT[field]}>
{edit && field !== 'business_date'
? <label htmlFor={inputId}>{CATALOG_TABLE_FIELD_LABEL[field]}</label>
: CATALOG_TABLE_FIELD_LABEL[field]}
</dt>
<dd className="min-w-0">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 text-xs">{edit ? input : <FieldValue field={field} item={item} />}</div>
{item && !changed && (
<ItemMark target={{ path: tablePath(field), where, source: item.source, status: item.status, note: item.note, updated_at: item.updated_at }}
disabled={busy || !!edit}
onReview={(a) => onReview({ path: tablePath(field), where, source: item.source, status: item.status }, a)} />
)}
{changed && <span className="chip shrink-0 !text-2xs" style={{ color: 'var(--accent)' }} data-changed="">{CT.changedMark}</span>}
</div>
{problem && <div className="mt-1 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
{!problem && unknown.length > 0 && (
<div className="mt-1 text-2xs text-[var(--warn)]" data-keys-unknown="">{CT.keysUnknown(unknown.join('、'))}</div>
)}
</dd>
</div>
)
}
function plainValue(field: TableField, v: unknown): string {
if (v == null) return ''
if (field === 'keys' && Array.isArray(v)) return v.join('、')
if (field === 'kind') return CATALOG_KIND_LABEL[v as CatalogTableKind] ?? String(v)
if (field === 'business_date' && typeof v === 'object') return (v as CatalogBusinessDate).column
return String(v)
}
function FieldValue({ field, item }: { field: TableField; item: CatalogItem | undefined }) {
return (
<Value item={item} render={(v: any) => {
if (field === 'keys') {
return (
<span className="flex flex-wrap gap-1">
{(v as string[]).map((k) => <span key={k} className="mono rounded border bg-bg px-1 text-2xs">{k}</span>)}
</span>
)
}
if (field === 'kind') {
return <span>{CATALOG_KIND_LABEL[v as CatalogTableKind] ?? v}<span className="ml-1.5 text-2xs text-faint">{CATALOG_KIND_HINT[v as CatalogTableKind]}</span></span>
}
if (field === 'business_date') {
const d = v as CatalogBusinessDate
return (
<span className="flex flex-wrap items-center gap-x-2">
<span className="mono">{d.column}</span>
{d.rule && <span className="text-dim">{CT.dateRule}:{d.rule}</span>}
{d.timezone && <span className="text-dim">{CT.dateTimezone}:{d.timezone}</span>}
</span>
)
}
if (field === 'valid_filter') return <code className="mono break-all rounded bg-bg px-1 text-2xs">{String(v)}</code>
return <span className="whitespace-pre-wrap break-words leading-relaxed">{String(v)}</span>
}} />
)
}
// ---------------------------------------------------------------------------
// 关联关系
// ---------------------------------------------------------------------------
function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
relations: CatalogRelation[]
busy: boolean
targetOf: (name: string) => CatalogTableRow | undefined
onOpen: (table: string) => void
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
}) {
if (!relations.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-relations-empty="">{CT.noRelations}</p>
return (
<div className="relative overflow-x-auto rounded-lg border" data-catalog-relations="">
<table className="w-full min-w-[640px] border-collapse text-xs">
<thead>
<tr className="border-b bg-elev text-left text-2xs text-faint">
<th scope="col" className="px-3 py-2 font-medium">{CT.relationHead.from}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.to}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.toColumns}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.cardinality}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.coverage}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.source} / {CT.relationHead.status}</th>
</tr>
</thead>
<tbody>
{relations.map((r) => {
const target = targetOf(r.to_table)
const where = CT.whereRelation(r.to_table)
const rejected = r.status === 'rejected'
return (
<tr key={r.id} className="border-b border-hairline last:border-b-0" data-relation={r.id} data-status={r.status}>
<td className={clsx('mono px-3 py-1.5 align-top', rejected && 'text-faint line-through')}>{r.columns.join(', ')}</td>
<td className="px-2 py-1.5 align-top">
{target
? (
<button type="button" className={clsx('mono text-left underline decoration-dotted underline-offset-2 hover:text-[var(--accent)]', rejected && 'text-faint line-through')}
onClick={() => onOpen(target.table_name)} title={CT.openTable(target.label ?? target.table_name)} data-relation-target={target.table_name}>
{r.to_table}
</button>
)
: <span className={clsx('mono', rejected && 'text-faint line-through')}>{r.to_table}</span>}
{target?.label && <div className="text-2xs text-faint">{target.label}</div>}
</td>
<td className={clsx('mono px-2 py-1.5 align-top', rejected && 'text-faint line-through')}>{r.to_columns.join(', ')}</td>
<td className="px-2 py-1.5 align-top">{r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : <span className="text-faint">{CT.cardinalityNone}</span>}</td>
<td className="tnum px-2 py-1.5 align-top">
{typeof r.coverage === 'number' ? CT.coverageValue(Math.round(r.coverage * 100)) : <span className="text-faint">—</span>}
</td>
<td className="px-2 py-1.5 align-top">
<ItemMark target={{ path: relationPath(r.id), where, source: r.source, status: r.status, note: r.note, updated_at: r.updated_at }}
disabled={busy} onReview={(a) => onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
function RelationEditor({ drafts, initial, problems, rows, onChange }: {
drafts: RelationDraft[]
initial: RelationDraft[]
problems: Record<string, string>
rows: CatalogTableRow[]
onChange: (fn: (rs: RelationDraft[]) => RelationDraft[]) => void
}) {
const before = new Map(initial.map((r) => [r.key, r]))
const patch = (key: string, p: Partial<RelationDraft>) => onChange((rs) => rs.map((r) => (r.key === key ? { ...r, ...p } : r)))
return (
<div className="space-y-2" data-relation-editor="">
{drafts.map((r) => {
const b = before.get(r.key)
const changed = !b || b.columns !== r.columns || b.to_table !== r.to_table || b.to_columns !== r.to_columns || b.cardinality !== r.cardinality
const problem = problems[`r:${r.key}`]
return (
<div key={r.key} className="rounded-lg border bg-panel p-2.5" data-relation-draft={r.key}>
<div className="grid items-end gap-2 sm:grid-cols-[1fr_1fr_1fr_8rem_auto]">
<label className="min-w-0">
<span className="label">{CT.relationHead.from}</span>
<input className="field mono" value={r.columns} placeholder={CT.relationColumns}
onChange={(e) => patch(r.key, { columns: e.target.value })} data-input="columns" />
</label>
<label className="min-w-0">
<span className="label">{CT.relationTarget}</span>
<input className="field mono" list="catalog-tables" value={r.to_table}
onChange={(e) => patch(r.key, { to_table: e.target.value })} data-input="to_table" />
</label>
<label className="min-w-0">
<span className="label">{CT.relationHead.toColumns}</span>
<input className="field mono" value={r.to_columns} placeholder={CT.relationToColumns}
onChange={(e) => patch(r.key, { to_columns: e.target.value })} data-input="to_columns" />
</label>
<label className="min-w-0">
<span className="label">{CT.relationHead.cardinality}</span>
<select className="field" value={r.cardinality} onChange={(e) => patch(r.key, { cardinality: e.target.value })} data-input="cardinality">
<option value="">{CT.cardinalityNone}</option>
{CARDINALITIES.map((c) => <option key={c} value={c}>{CATALOG_CARDINALITY_LABEL[c]}</option>)}
</select>
</label>
<div className="flex items-center gap-1.5 pb-1">
{r.orig && !changed && <StatusChip status={r.orig.status} source={r.orig.source} />}
{changed && <span className="chip !text-2xs" style={{ color: 'var(--accent)' }}>{CT.changedMark}</span>}
<DeleteButton label={CT.removeRelation} onClick={() => onChange((rs) => rs.filter((x) => x.key !== r.key))} />
</div>
</div>
{problem && <div className="mt-1.5 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
</div>
)
})}
<datalist id="catalog-tables">{rows.map((t) => <option key={t.table_name} value={t.table_name}>{t.label ?? ''}</option>)}</datalist>
<button type="button" className="btn btn-sm" onClick={() => onChange((rs) => [...rs, relationDraft(null)])} data-add-relation="">
<Plus size={11} aria-hidden /> {CT.addRelation}
</button>
</div>
)
}
+1 -1
View File
@@ -150,7 +150,7 @@ export function TableIndex({
</div>
</div>
<ul ref={listRef} className="min-h-0 flex-1 overflow-y-auto" aria-label={CT.tableCount(rows.length, total)}>
<ul ref={listRef} className="relative min-h-0 flex-1 overflow-y-auto" aria-label={CT.tableCount(rows.length, total)}>
{rows.map((r, i) => (
<Row key={r.table_name} row={r} index={i} active={r.table_name === active} checked={selected.has(r.table_name)}
onToggle={toggle} onOpen={onOpen} onKey={onRowKey} />
+1 -1
View File
@@ -316,7 +316,7 @@ export function validateForm(form: EditForm, text: ProblemText): FormProblems {
}
/** 业务主键里写了表结构中没有的列(只提醒,不拦:表结构会变,目录里先写着的列等结构同步后自然对上) */
export function unknownKeys(text: string, structure: CatalogStructureColumn[]): string[] {
export function unknownKeys(text: string, structure: { name: string }[]): string[] {
if (!structure.length) return []
const names = new Set(structure.map((c) => c.name.toLowerCase()))
return splitList(text).filter((k) => !names.has(k.toLowerCase()))
+35 -12
View File
@@ -5,6 +5,7 @@ import { Check, CircleCheck, CircleDashed, CircleX, RotateCcw, ShieldCheck, Tras
import type { LucideIcon } from 'lucide-react'
import clsx from 'clsx'
import type { CatalogCounts, CatalogReviewAction, CatalogSource, CatalogStatus } from '../../types'
import { Notice } from '../../components/ui'
import { formatDateTime, formatNumber } from '../../lib/format'
import {
CATALOG_SOURCE_LABEL, CATALOG_STATUS_HINT, CATALOG_STATUS_LABEL, CATALOG_TEXT as CT,
@@ -104,7 +105,7 @@ export interface ReviewTarget {
/**
* 单项的来源和状态。触发按钮就是状态标识(compact 时只有图标,列表格里每格一个);点开是一个小弹层:
* 位置、来源、状态说明、更新时间,以及确认 / 驳回 / 恢复。弹层挂在 body 上、按触发按钮的位置固定定位,
* 不会被表格的横向滚动裁掉;滚动或改窗口大小时收起。键盘:Enter 打开,焦点落到第一个可点的操作,
* 不会被表格的横向滚动裁掉;滚动时跟着触发按钮走,滚出视野或改窗口大小时收起。键盘:Enter 打开,焦点落到第一个可点的操作,
* ←→↑↓ 在操作之间移动,Tab 在弹层里循环,Esc 收起并把焦点还给触发按钮
*/
export function ItemMark({ target, compact = false, disabled = false, onReview }: {
@@ -130,18 +131,25 @@ export function ItemMark({ target, compact = false, disabled = false, onReview }
if (focusBack) trigger.current?.focus()
}
// 先按触发按钮下方放,量过弹层的高度后,下方放不下就翻到上方
useLayoutEffect(() => {
if (!open) return
// 先按触发按钮下方放,量过弹层的高度后,下方放不下就翻到上方。返回 false 表示触发按钮已经滚出视野
const place = (): boolean => {
const r = trigger.current?.getBoundingClientRect()
const el = panel.current
if (!r || !el) return
if (!r || !el) return false
if (r.bottom < 0 || r.top > window.innerHeight) return false
const w = el.offsetWidth
const h = el.offsetHeight
const left = Math.max(8, Math.min(r.left, window.innerWidth - w - 8))
const below = r.bottom + 4
const top = below + h > window.innerHeight - 8 && r.top - h - 4 > 8 ? r.top - h - 4 : below
setPos({ top, left })
setPos((p) => (p && p.top === top && p.left === left ? p : { top, left }))
return true
}
const placeRef = useRef(place)
placeRef.current = place
useLayoutEffect(() => {
if (open) placeRef.current()
}, [open])
useEffect(() => {
@@ -150,8 +158,11 @@ export function ItemMark({ target, compact = false, disabled = false, onReview }
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 onScroll = (e: Event) => {
if (panel.current?.contains(e.target as Node)) return
if (!placeRef.current()) close(false)
}
const onResize = () => close(false)
document.addEventListener('mousedown', onDown)
window.addEventListener('scroll', onScroll, true)
@@ -163,12 +174,13 @@ export function ItemMark({ target, compact = false, disabled = false, onReview }
}
}, [open])
// 定好位置以后把焦点交给第一个能点的操作
// 定好位置以后把焦点交给第一个能点的操作(只在打开那一下)
const placed = !!pos
useEffect(() => {
if (!open || !pos) return
if (!open || !placed) return
const first = panel.current?.querySelector<HTMLButtonElement>('[data-review-action]:not([disabled])')
;(first ?? panel.current)?.focus({ preventScroll: true })
}, [open, pos])
}, [open, placed])
const onKey = (e: ReactKeyboardEvent) => {
if (e.key === 'Escape') {
@@ -273,7 +285,7 @@ export function ItemMark({ target, compact = false, disabled = false, onReview }
{human ? CT.remove : CT.reset}
</ActionButton>
</div>
<p className="mt-2 text-2xs leading-relaxed text-faint">{human ? CT.removeHint : CT.resetHint}</p>
{canReset && <p className="mt-2 text-2xs leading-relaxed text-faint">{human ? CT.removeHint : CT.resetHint}</p>}
</div>,
document.body,
)}
@@ -291,3 +303,14 @@ function ActionButton({ icon, children, disabled, onClick, action, title }: {
</button>
)
}
/** 导入表格的源:说明是系统生成的,只读 */
export function SystemNotesNotice() {
return (
<Notice tone="info" attr={{ 'data-catalog-system-notes': '' }}>
<p className="font-medium">{CT.systemTitle}</p>
<p className="mt-0.5 text-dim">{CT.systemBody}</p>
<p className="mt-0.5 text-dim">{CT.systemCovered}</p>
</Notice>
)
}