From 2fd578e024e3306ad533f42aa3233354eee01b41 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:28:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=201C=EF=BC=9A=E5=8D=95=E8=A1=A8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=EF=BC=8C=E8=A1=A8=E7=BA=A7=E9=A1=B9=E3=80=81=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E3=80=81=E5=85=B3=E8=81=94=E5=85=B3=E7=B3=BB=E7=9A=84?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E5=92=8C=E7=8A=B6=E6=80=81=EF=BC=8C=E5=8D=95?= =?UTF-8?q?=E9=A1=B9=E5=AE=A1=E9=98=85=E3=80=81=E6=89=B9=E9=87=8F=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E3=80=81=E7=BC=96=E8=BE=91=E5=8D=B3=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=EF=BC=8C409=20=E6=97=B6=E6=8F=90=E7=A4=BA=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=BD=BD=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/terms.ts | 6 +- frontend/src/pages/catalog/CatalogPage.tsx | 106 ++-- frontend/src/pages/catalog/ColumnTable.tsx | 211 +++++++ frontend/src/pages/catalog/TableDetail.tsx | 658 +++++++++++++++++++++ frontend/src/pages/catalog/TableIndex.tsx | 2 +- frontend/src/pages/catalog/model.ts | 2 +- frontend/src/pages/catalog/parts.tsx | 47 +- 7 files changed, 971 insertions(+), 61 deletions(-) create mode 100644 frontend/src/pages/catalog/ColumnTable.tsx create mode 100644 frontend/src/pages/catalog/TableDetail.tsx 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 && } -
-
{CT.legend}
- -
-
-
+ {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 && } +
+
{CT.legend}
+ +
+
+
) } 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 ( +
+ + + + + + + + + + + + + + {columns.map((c) => ( + + ))} + +
{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}
+
+ ) +} + +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 && ( +
+ {CT.columnMissing} +
+ )} + + {text('label')} + {text('meaning')} + {text('unit')} + {cell( + 'measure', + {CATALOG_MEASURE_LABEL[v as CatalogMeasure] ?? v}} />, + , + )} + {cell( + 'codes', + } />} />, +