mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:01:57 +00:00
数据目录阶段 1C:单表详情,表级项、列表格、关联关系的来源和状态,单项审阅、批量确认、编辑即确认,409 时提示重新载入
This commit is contained in:
@@ -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: '填写规则或时区时,日期列必填',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user