diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index b1b9d288..20e162ad 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -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: '填写规则或时区时,日期列必填',
diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx
index 68ae6e0e..d55df138 100644
--- a/frontend/src/pages/catalog/CatalogPage.tsx
+++ b/frontend/src/pages/catalog/CatalogPage.tsx
@@ -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() {
)
} 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 = (
-
-
- {empty
- ? (
-
} title={CT.emptyTitle} body={CT.emptyBody} offline={false}
- action={draftButton(true)} className="!py-8" />
- )
- : (
-
-
{CT.overviewTitle}
-
{CT.overviewBody}
-
- {CT.overviewStats(rows.length, stats.pending, stats.done, stats.none)}
-
-
- {pendingNext && (
-
-
- {CT.reviewNextHint}
-
- )}
-
- )}
- {data.system_notes &&
}
-
-
-
+ {table
+ ? (
+ navigate(base)} onDetail={onDetail} />
+ )
+ : }
)
@@ -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 (
-
- {CT.systemTitle}
- {CT.systemBody}
- {CT.systemCovered}
-
+
+
+ {empty
+ ?
} title={CT.emptyTitle} body={CT.emptyBody} offline={false} action={draft} className="!py-8" />
+ : (
+
+
{CT.overviewTitle}
+
{CT.overviewBody}
+
+ {CT.overviewStats(rows.length, stats.pending, stats.done, stats.none)}
+
+
+ {pendingNext && (
+
+
+ {CT.reviewNextHint}
+
+ )}
+
+ )}
+ {systemNotes &&
}
+
+
+
)
}
diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx
new file mode 100644
index 00000000..36d04258
--- /dev/null
+++ b/frontend/src/pages/catalog/ColumnTable.tsx
@@ -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>
+
+export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: {
+ columns: ColumnModel[]
+ /** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */
+ form: Record | null
+ initial: Record | null
+ /** 格式问题:键 c:<列名>:<字段> */
+ problems: Record
+ busy: boolean
+ /** 导入表格的源:列的说明是系统生成的,只读地写在列名下面 */
+ systemNotes: boolean
+ onReview: (target: ReviewTarget, action: CatalogReviewAction) => void
+ onConfirmColumn: (col: string) => void
+ onChange: (col: string, field: ColumnField, value: string) => void
+}) {
+ return (
+
+
+
+
+ | {CT.columnHead.name} / {CT.columnHead.type} |
+ {CT.columnHead.label} |
+ {CT.columnHead.meaning} |
+ {CT.columnHead.unit} |
+ {CT.columnHead.measure} |
+
+ {CT.columnHead.codes}
+ {form && · {CT.codesHint}}
+ |
+ {CT.confirmColumn} |
+
+
+
+ {columns.map((c) => (
+
+ ))}
+
+
+
+ )
+}
+
+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
+ 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 (
+
+
+ {editing ? input : view}
+ {it && !changed && (
+
+ onReview({ path: columnPath(name, f), where, source: it.source, status: it.status }, a)} />
+
+ )}
+
+ {problem && {problem} }
+ |
+ )
+ }
+ const text = (f: ColumnField, opts?: { mono?: boolean; narrow?: boolean }) => {
+ const it = items?.[f] as CatalogItem | undefined
+ const rejected = it?.status === 'rejected'
+ const value = draft?.[f] ?? ''
+ return cell(
+ f,
+ {v}} />,
+ onChange(name, f, e.target.value)}
+ data-input={f}
+ />,
+ )
+ }
+ const measure = items?.measure
+ const codes = items?.codes
+ return (
+
+ |
+
+ {name}
+ {structure?.pk && {CT.pk}}
+
+ {structure && {structure.type || '—'} }
+ {comment && (
+
+
+ {CT.systemTag}:{comment}
+
+ )}
+ {!structure && (
+
+ )}
+ |
+ {text('label')}
+ {text('meaning')}
+ {text('unit')}
+ {cell(
+ 'measure',
+ {CATALOG_MEASURE_LABEL[v as CatalogMeasure] ?? v}} />,
+ ,
+ )}
+ {cell(
+ 'codes',
+ } />} />,
+
+ )
+}, (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 | undefined) => Object.entries(v ?? {}).map(([k, x]) => `${k}=${x}`).join(';')
+
+/** 一项的值:没有写「—」;被驳回的划掉、淡色 */
+export function Value({ item, render }: { item: CatalogItem | undefined; render: (v: T) => ReactNode }) {
+ if (!item) return —{CT.empty}
+ return {render(item.value)}
+}
+
+function Codes({ value }: { value: Record }) {
+ const entries = Object.entries(value)
+ return (
+ `${k}=${v}`).join('\n')}>
+ {entries.slice(0, CODES_SHOWN).map(([k, v]) => (
+
+ {k}
+ {v}
+
+ ))}
+ {entries.length > CODES_SHOWN && +{entries.length - CODES_SHOWN}}
+
+ )
+}
diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx
new file mode 100644
index 00000000..f0721af6
--- /dev/null
+++ b/frontend/src/pages/catalog/TableDetail.tsx
@@ -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 }
+ prev: string | null
+ next: string | null
+ onOpen: (table: string) => void
+ /** 窄屏:回到表清单 */
+ onBack: () => void
+ /** 载入或写入之后的单表目录:清单据此更新这一行 */
+ onDetail: (d: CatalogDetail) => void
+}) {
+ const [detail, setDetail] = useState(null)
+ const [loadError, setLoadError] = useState(null)
+ const [busy, setBusy] = useState('')
+ const [conflict, setConflict] = useState(null)
+ const [writeError, setWriteError] = useState(null)
+ const [edit, setEdit] = useState(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): Promise => {
+ 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 (
+
+
+ {loadError
+ ? (loadError instanceof ApiError && loadError.status === 404
+ ?
+ :
void load()} />)
+ : }
+
+ )
+ }
+
+ 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 (
+
+
+
+
+
+
{live}
+ {conflict && (
+
+ {CT.conflictTitle}
+ {conflict === 'edit' ? CT.conflictEditing : CT.conflictReview}
+
+
+ )}
+ {writeError &&
{writeError}}
+ {!detail.in_schema && (
+
{CT.missingBody}
+ )}
+ {detail.system_notes &&
}
+ {edit &&
{CT.editHint}。{CT.clearHint}。}
+
+
+
+
{CT.sectionTable}
+
+ {!edit && }
+
+
+ {systemComment && (
+
+
+
+
{CT.systemTableNote} · {CT.systemTag}
+
{systemComment}
+
+
+ )}
+
+ {TABLE_FIELDS.map((f) => (
+
+ ))}
+
+
+
+
+
+
+
{CT.sectionColumns}
+
{CT.columnCount(shownColumns.length, columns.length)}
+
+
+
+ setColQuery(e.target.value)} data-column-filter="" />
+
+
+
+ {shownColumns.length
+ ? (
+
+ )
+ : {CT.noColumns}
}
+
+
+
+
+
{CT.sectionRelations}
+ {formatNumber((edit ? edit.form.relations : notes.relations ?? []).length)}
+
+ {edit
+ ?
+ : }
+
+
+
+
+ {edit && (
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 表级各项
+// ---------------------------------------------------------------------------
+
+function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview }: {
+ field: TableField
+ item: CatalogItem | undefined
+ edit: Editing | null
+ busy: boolean
+ problems: Record
+ /** 表结构里的列:业务日期的候选、业务主键的核对 */
+ 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 = (
+
+ )
+ } else if (field === 'business_date') {
+ input = (
+
+ )
+ } else if (field === 'description') {
+ input = (
+