From 6ce01f0b0dc96a726cbbf4a3974c30c56569b481 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 11:45:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E5=8A=A0=E6=95=B0=E6=8D=AE=E5=89=96=E6=9E=90=EF=BC=9A=E9=80=89?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E3=80=81=E7=A1=AE=E8=AE=A4=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E5=90=8E=E5=BC=80=E5=A7=8B=EF=BC=8C=E8=BF=9B=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E5=86=99=E5=B7=B2=E7=94=A8=E6=97=B6=E9=95=BF=E5=B9=B6=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=97=A0=E6=B3=95=E4=B8=AD=E9=80=94=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=EF=BC=8C=E6=8A=A5=E5=91=8A=E5=88=97=E5=87=BA=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E3=80=81=E5=81=9C=E6=AD=A2=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E3=80=81=E6=AF=8F=E5=BC=A0=E8=A1=A8=E7=9A=84=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E6=A0=B8=E5=AE=9E=E3=80=81=E7=A0=81=E5=80=BC=E5=80=99=E9=80=89?= =?UTF-8?q?=E3=80=81=E4=B8=9A=E5=8A=A1=E6=97=A5=E6=9C=9F=E6=8F=90=E8=AE=AE?= =?UTF-8?q?=E5=92=8C=E8=B7=B3=E8=BF=87=E7=9A=84=E9=A1=B9=EF=BC=9B=E5=90=84?= =?UTF-8?q?=E7=B1=BB=20409=20=E5=86=99=E6=98=8E=E4=B8=8B=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E5=BC=80=E5=90=AF=E7=9A=84=E5=B0=B1=E5=9C=B0?= =?UTF-8?q?=E6=89=93=E5=BC=80=E8=AE=BE=E7=BD=AE=EF=BC=9B=E7=A0=81=E5=80=BC?= =?UTF-8?q?=E5=90=AB=E4=B9=89=E4=B8=BA=E7=A9=BA=E6=97=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E3=80=8C=E5=90=AB=E4=B9=89=E5=BE=85=E5=A1=AB=E5=86=99=E3=80=8D?= =?UTF-8?q?=E5=B9=B6=E5=8F=AF=E9=80=90=E4=B8=AA=E5=A1=AB=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/client.ts | 9 +- frontend/src/lib/terms.ts | 157 ++++++- frontend/src/pages/catalog/CatalogPage.tsx | 140 +++++- frontend/src/pages/catalog/CodesDialog.tsx | 79 ++++ frontend/src/pages/catalog/ColumnTable.tsx | 55 ++- frontend/src/pages/catalog/ProfileDialog.tsx | 450 +++++++++++++++++++ frontend/src/pages/catalog/TableDetail.tsx | 68 ++- frontend/src/pages/catalog/TableIndex.tsx | 13 +- frontend/src/pages/catalog/model.ts | 9 +- frontend/src/pages/catalog/profile.ts | 34 ++ 10 files changed, 980 insertions(+), 34 deletions(-) create mode 100644 frontend/src/pages/catalog/CodesDialog.tsx create mode 100644 frontend/src/pages/catalog/ProfileDialog.tsx diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 163ce524..65c50584 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,5 @@ import type { - ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogList, CatalogNotes, + ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogList, CatalogNotes, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord, ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut, RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging, @@ -676,6 +676,13 @@ export const api = { draft: (sourceId: string, body: { tables?: string[]; use_model: boolean }, opts?: RequestOptions) => request(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`, { method: 'POST', body: JSON.stringify(body), ...opts }), + /** + * 同步剖析:对业务库发少量只读查询,核对关系、取码值候选,结论写进目录。不给 tables 时按使用次数取前 10 张 + * 有待核实关系的表。没开启、数据源停用、没有表结构、同一个数据源正在剖析、快照被改动时回 409(detail 是一句中文) + */ + profile: (sourceId: string, body: { tables?: string[] }, opts?: RequestOptions) => + request(`/datasources/${encodeURIComponent(sourceId)}/catalog/profile`, + { method: 'POST', body: JSON.stringify(body), ...opts }), }, runs: { diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 46aa72b9..e6c4a6ec 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2121,7 +2121,7 @@ export const CATALOG_TEXT = { keysPlaceholder: '列名,用顿号或逗号分隔', keysUnknown: (cols: string) => `表结构中没有这些列:${cols}`, codesPlaceholder: '1=已支付', - codesHint: '每行一个,写成「码值=含义」', + codesHint: '每行一个,写成「码值=含义」,含义可暂不填写', codesInvalid: (line: number) => `第 ${formatNumber(line)} 行应写成「码值=含义」`, codesDuplicate: (code: string) => `码值 ${code} 重复`, dateColumnRequired: '填写规则或时区时,日期列必填', @@ -2209,4 +2209,159 @@ export const PROFILE_TEXT = { savedOn: (name: string) => `「${name}」已开启数据剖析`, savedOff: (name: string) => `「${name}」已关闭数据剖析`, saved: (name: string) => `已保存「${name}」的数据剖析设置`, + // ---- 数据目录页:范围与确认 + action: '数据剖析', + actionHint: '对业务库发少量只读查询:核实推断的关联关系、取码值候选、提议业务日期', + profileSelected: (n: number) => `剖析所选(${formatNumber(n)})`, + title: '数据剖析', + intro: '对业务库发少量只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。' + + '结论写入数据目录;已确认、已驳回的项不会被改动。', + scope: '剖析范围', + scopeSelected: (n: number) => `已选的 ${formatNumber(n)} 张表`, + scopeFiltered: (n: number) => `当前筛选结果中的 ${formatNumber(n)} 张表`, + scopeDefault: '使用次数最多、有待核实关联关系的表', + scopeDefaultHint: (n: number) => `由服务端挑选,至多 ${formatNumber(n)} 张`, + scopeTooMany: (max: number) => `超过 ${formatNumber(max)} 张,请缩小范围`, + budget: '本次预算', + budgetQueries: (queries: number, timeout: number) => + `最多 ${formatNumber(queries)} 条只读查询,单条不超过 ${formatNumber(timeout)} 秒`, + budgetTotal: (total: number) => `总时长不超过 ${formatNumber(total)} 秒,到时即停止`, + budgetSample: (n: number) => `每条关联关系抽样 ${formatNumber(n)} 个键值`, + budgetScan: (n: number) => (n > 0 ? `行数超过 ${formatNumber(n)} 的表不做整表统计` : '不做整表统计'), + budgetFrom: (name: string) => `按「${name}」的数据剖析设置`, + editSettings: '修改设置', + noCancel: '剖析在服务端一次完成,开始后无法中途停止。', + start: '开始剖析', + again: '重新剖析', + disabledTitle: '这个数据源未开启数据剖析', + disabledBody: '剖析会对业务库发出只读查询,需要先在数据源设置中开启。', + enable: '开启数据剖析', + // ---- 进行中 + running: '正在剖析…', + elapsed: (used: string, total: string) => `已用时 ${used}(总时长上限 ${total})`, + runningHint: '剖析在服务端一次完成,无法中途停止。关闭此窗口不影响剖析,完成后会提示结果。', + background: '在后台继续', + headerRunning: (used: string) => `正在剖析 ${used}`, + headerRunningHint: '数据剖析进行中,点击查看', + leavePage: '离开本页不会中止剖析,结论仍会写入数据目录,但不再显示本次的报告。', + // ---- 报告 + done: '剖析完成', + stoppedTitle: (reason: string) => `剖析已停止:${reason}`, + summary: (used: number, max: number, tables: number) => + `用了 ${formatNumber(used)} / ${formatNumber(max)} 条查询,剖析 ${formatNumber(tables)} 张表`, + changes: (added: number, updated: number, removed: number) => + `新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`, + noChange: '目录没有变化', + toastDone: (summary: string) => `数据剖析完成:${summary}`, + toastStopped: (reason: string) => `数据剖析已停止:${reason}`, + toastFailed: '数据剖析未能完成', + viewReport: '查看报告', + close: '完成', + tableQueries: (n: number) => `${formatNumber(n)} 条查询`, + rowsStats: (n: number) => `约 ${formatNumber(n)} 行`, + rowsCount: (n: number) => `${formatNumber(n)} 行`, + rowsAtLeast: (n: number) => `超过 ${formatNumber(n - 1)} 行`, + rowsHint: { stats: '数据库统计信息中的估算值', count: '计数到整表统计行数上限为止' } as Record, + openTable: '打开', + openTableLabel: (name: string) => `打开「${name}」`, + noFindings: '没有新的发现', + findingRelations: '关联关系', + findingCodes: '码值候选', + findingDate: '业务日期', + relationVerified: '升为已验证', + relationKept: '保持推断', + relationConfirmed: '已人工确认,只补充覆盖率和基数', + relationLowCoverage: '覆盖率不足,可能不是这条关系', + relationNotUnique: '被指向列未核实唯一', + coverage: (pct: string) => `覆盖率 ${pct}`, + sampled: (sample: number, matched: number) => `抽样 ${formatNumber(sample)} 个键值,对上 ${formatNumber(matched)} 个`, + cardinalityUnknown: '基数未核实', + codeRows: (n: number) => `${formatNumber(n)} 行`, + codesSummary: (values: number, rows: number) => `${formatNumber(values)} 个取值,共 ${formatNumber(rows)} 行`, + codesPendingN: (n: number) => `${formatNumber(n)} 个含义待填写`, + fillMeanings: '填写含义', + fillMeaningsLabel: (col: string) => `填写含义:列 ${col} 的码值`, + dateProposal: (col: string) => `提议按 ${col} 作为业务日期`, + dateSpan: (min: string, max: string) => `取值从 ${min} 到 ${max}`, + dateRanges: '日期列的取值范围', + skippedTitle: (n: number) => `跳过 ${formatNumber(n)} 项`, + moreSkipped: (n: number) => `…另有 ${formatNumber(n)} 项`, + tableError: '这张表未能剖析', + // ---- 无法开始(409) + blocked: { + disabled: '未开启数据剖析', + inactive: '数据源已停用', + noSchema: '还没有表结构', + busy: '这个数据源正在剖析', + tampered: '数据文件核对未通过', + other: '无法开始剖析', + } as Record, + blockedNext: { + disabled: '剖析会对业务库发出只读查询,需要先在数据源设置中开启。开启后即可在这里剖析。', + inactive: '停用的数据源无法剖析。请在数据源设置中启用后重试。', + noSchema: '请回到数据源卡片点击「探查结构」,探查完成后重试。', + busy: '同一个数据源同一时间只能进行一次剖析。请等待当前剖析完成后重试。', + tampered: '数据文件与登记的版本不一致,已拒绝查询。请重新上传表格,或联系管理员核对数据文件。', + other: '请稍后重试。', + } as Record, + goSource: '前往数据源', + retry: '重试', + failedTitle: '剖析未能完成', + backToSetup: '返回', +} + +/** 整次剖析中途停下的原因 */ +export const PROFILE_STOP_LABEL: Record = { + budget: '查询次数已用完', + deadline: '总时长已用完', + failed: '连续多条查询失败', +} + +/** 停下之后怎么办 */ +export const PROFILE_STOP_NEXT: Record = { + budget: '其余检查未执行。可在数据剖析设置中调高查询次数上限,或缩小剖析范围后重新剖析。', + deadline: '其余检查未执行。可在数据剖析设置中调高总时长上限,或缩小剖析范围后重新剖析。', + failed: '其余检查未执行。请先在数据源卡片上测试连接,确认可用后重新剖析。', +} + +/** 跳过一项的原因(skipped.reason)。整句说明在 skipped.detail 里 */ +export const PROFILE_SKIP_REASON_LABEL: Record = { + budget: '查询次数用完', + deadline: '总时长用完', + failed: '连续失败', + timeout: '查询超时', + error: '查询失败', + rejected: '未通过安全守卫', + masked: '列已遮罩', + too_large: '表太大', + view: '视图', + unsupported: '暂不支持', + no_data: '没有数据', + missing: '表结构中没有', + high_cardinality: '取值太多', +} + +/** 跳过的是哪一类检查(skipped.kind) */ +export const PROFILE_SKIP_KIND_LABEL: Record = { + relation: '关联关系', + codes: '码值', + date: '日期列', + row_estimate: '行数估算', + table: '整张表', +} + +/** 码值候选:剖析只知道出现过哪些取值,含义等人填 */ +export const CODES_TEXT = { + pending: '含义待填写', + pendingN: (n: number) => `${formatNumber(n)} 个含义待填写`, + fill: '填写含义', + fillLabel: (col: string) => `填写含义:列 ${col} 的码值`, + title: (col: string) => `列 ${col} 的码值含义`, + hint: '保存后这一项记为人工填写、已确认。暂不清楚的含义可以留空,之后再填。', + head: { code: '码值', meaning: '含义' }, + note: '来源说明', + placeholder: '含义待填写', + saved: (n: number) => (n ? `已填写 ${formatNumber(n)} 个码值的含义` : '已保存'), + save: '保存', + cancel: '取消', } diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index 7b83c11b..04f06dc8 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -1,15 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { ArrowLeft, BookMarked, Database, Sparkles } from 'lucide-react' +import { ArrowLeft, BookMarked, Database, ScanSearch, Sparkles } from 'lucide-react' import clsx from 'clsx' import { ApiError, api } from '../../api/client' 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 { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, toast, useTicker } from '../../components/ui' +import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog' +import { CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT } from '../../lib/terms' import { StatusLegend, SystemNotesNotice } from './parts' import { DraftDialog } from './DraftDialog' +import { ProfileDialog } from './ProfileDialog' +import type { ProfileJob } from './ProfileDialog' +import { ProfileSettingsDialog } from './ProfileSettings' +import { profileBlockOf, profileSettingsOf, secondsText } from './profile' import { TableDetail } from './TableDetail' import { TableIndex } from './TableIndex' import { filterCounts, progressOf, rowFromDetail, visibleRows } from './model' @@ -65,6 +69,24 @@ export function CatalogPage() { const [drafting, setDrafting] = useState(false) /** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */ const [drafted, setDrafted] = useState<{ seq: number; tables: Set }>({ seq: 0, tables: new Set() }) + const [profiling, setProfiling] = useState(false) + const [profileSettingsOpen, setProfileSettingsOpen] = useState(false) + /** 一次剖析的状态。关掉弹窗剖析照常进行:状态留在页面上,页头有入口,完成后提示并可再打开报告 */ + const [job, setJob] = useState<(ProfileJob & { sourceId: string }) | null>(null) + /** 剖析设置刚存过:数据源列表重取回来之前按存下的这份算(预算、开没开) */ + const [savedOptions, setSavedOptions] = useState<{ sourceId: string; options: Record } | null>(null) + /** 从剖析报告点「填写含义」:打开那张表并弹出那一列的码值 */ + const [fillCodes, setFillCodes] = useState<{ table: string; column: string; seq: number } | null>(null) + const profilingRef = useRef(profiling) + profilingRef.current = profiling + const alive = useRef(true) + // 开发模式的严格模式会先卸载再挂载一次:挂载时要重新置为真 + useEffect(() => { + alive.current = true + return () => { alive.current = false } + }, []) + const sourceRef = useRef(sourceId) + sourceRef.current = sourceId const rows = useMemo(() => data?.tables ?? [], [data]) const visible = useMemo(() => visibleRows(rows, query, filter, sort), [rows, query, filter, sort]) @@ -87,6 +109,77 @@ export function CatalogPage() { void load() }, [load]) + const options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options + const profileSettings = useMemo(() => profileSettingsOf(options), [options]) + const myJob = job?.sourceId === sourceId ? job : null + const jobRunning = myJob?.phase === 'running' + useTicker(1000, jobRunning) + + /** + * 剖析:同步请求,做完才返回。期间关掉弹窗不影响;回来时页面已经换了数据源或卸载了,只提示、不再改这一页。 + * 写进去的表重新载入清单和正在看的详情(编辑中的不动,同起草) + */ + const startProfile = useCallback(async (tables: string[] | null) => { + const src = sourceId + const startedAt = Date.now() + setJob({ sourceId: src, phase: 'running', startedAt, tables, settings: profileSettings }) + let next: ProfileJob + try { + const report = await api.dataCatalog.profile(src, tables ? { tables } : {}) + next = { phase: 'done', report, ms: Date.now() - startedAt } + } catch (e) { + next = e instanceof ApiError && e.status === 409 + ? { phase: 'blocked', kind: profileBlockOf(e.message), message: e.message, tables } + : { phase: 'failed', error: e, tables } + } + const here = alive.current && sourceRef.current === src + if (here) setJob({ ...next, sourceId: src }) + if (next.phase === 'done') { + const r = next.report + const touched = r.tables.filter((t) => !t.error && t.added + t.updated + t.removed > 0).map((t) => t.table_name) + if (here) { + if (touched.length) setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(touched) })) + void load() + } + // 弹窗开着就看报告;关了(在后台继续)才提示,并给「查看报告」 + if (!here || !profilingRef.current) { + const summary = PT.summary(r.queries_used, r.settings.max_queries, r.tables.length) + const text = r.stopped ? PT.toastStopped(PROFILE_STOP_LABEL[r.stopped] ?? r.stopped) : PT.toastDone(summary) + const show = r.stopped ? toast.warn : toast.ok + show(text, here ? { key: `profile:${src}`, duration: 10000, action: { label: PT.viewReport, onClick: () => setProfiling(true) } } + : { key: `profile:${src}` }) + } + } else if (!here || !profilingRef.current) { + toast.error(next.phase === 'blocked' ? next.message : next.error, here + ? { key: `profile:${src}`, action: { label: PT.viewReport, onClick: () => setProfiling(true) } } : { key: `profile:${src}` }) + } + }, [sourceId, profileSettings, load]) + + const openProfile = useCallback(() => { + // 上一次的结果(报告、被拒)看过了,再点就是重新开始;正在剖析时打开的是进度 + setJob((cur) => (cur && cur.phase === 'running' && cur.sourceId === sourceId ? cur : null)) + setProfiling(true) + }, [sourceId]) + + const onProfileSettingsSaved = useCallback((row: any) => { + setProfileSettingsOpen(false) + setSavedOptions({ sourceId, options: row?.options ?? {} }) + void useCatalog.getState().reload('datasources') + // 因为没开启被拒的:开了就回到选范围,可以直接开始 + setJob((cur) => (cur && cur.phase === 'blocked' && cur.kind === 'disabled' ? null : cur)) + }, [sourceId]) + + const openFromReport = useCallback((t: string) => { + setProfiling(false) + openTable(t) + }, [openTable]) + + const fillFromReport = useCallback((t: string, column: string) => { + setProfiling(false) + setFillCodes((cur) => ({ table: t, column, seq: (cur?.seq ?? 0) + 1 })) + openTable(t) + }, [openTable]) + // 上一张 / 下一张按清单当前的筛选和排序走;正在看的表不在筛选结果里(刚确认完、从关联关系跳过来)时按全部表走 const [prev, next] = useMemo(() => { if (!table) return [null, null] @@ -104,6 +197,19 @@ export function CatalogPage() { {CT.draft} ) + // 页头的「数据剖析」:剖析进行中换成计时,点开看进度(关掉弹窗剖析照常进行) + const headerProfile = jobRunning && myJob?.phase === 'running' + ? ( + + ) + : ( + + ) // 页头的「起草」窄屏只留图标,标题才放得下 const headerDraft = ( + + + )}> +
+
+ +
+ {item.note && ( +
+ {KT.note}:{item.note} +
+ )} +
+ + + + + + + + + {codes.map((k, i) => ( + + + + + ))} + +
{KT.head.code}{KT.head.meaning}
+ + + !(initial[c] ?? '').trim())} + onChange={(e) => setValue((v) => ({ ...v, [k]: e.target.value }))} + // 回车保存;输入法组字时的回车是选词,不算 + onKeyDown={(e) => { if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); save() } }} + data-code={k} /> +
+
+

{KT.hint}

+
+ + ) +} diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx index e5903287..0ae5a9e7 100644 --- a/frontend/src/pages/catalog/ColumnTable.tsx +++ b/frontend/src/pages/catalog/ColumnTable.tsx @@ -1,13 +1,13 @@ import { memo } from 'react' import type { ReactNode } from 'react' -import { AlertTriangle, CheckCheck, Lock } from 'lucide-react' +import { AlertTriangle, CheckCheck, Lock, PencilLine } 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, + CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT, } from '../../lib/terms' import { ItemMark } from './parts' import type { ReviewTarget } from './parts' @@ -32,7 +32,7 @@ export interface ColumnModel { type ColumnTexts = Partial> -export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: { +export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange }: { columns: ColumnModel[] /** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */ form: Record | null @@ -44,6 +44,8 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote systemNotes: boolean onReview: (target: ReviewTarget, action: CatalogReviewAction) => void onConfirmColumn: (col: string) => void + /** 码值里有含义空着的:打开逐个填写含义的弹窗 */ + onFillCodes: (col: string) => void onChange: (col: string, field: ColumnField, value: string) => void }) { return ( @@ -66,7 +68,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote {columns.map((c) => ( + systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange} /> ))} @@ -74,7 +76,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote ) } -const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onChange }: { +const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange }: { col: ColumnModel editing: boolean /** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */ @@ -85,6 +87,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble systemNotes: boolean onReview: (target: ReviewTarget, action: CatalogReviewAction) => void onConfirmColumn: (col: string) => void + onFillCodes: (col: string) => void onChange: (col: string, field: ColumnField, value: string) => void }) { const { name, structure, items } = col @@ -166,7 +169,10 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble )} {cell( 'codes', - } />} />, + ( + } label={KT.fillLabel(name)} + onFill={codes?.status !== 'rejected' && !busy ? () => onFillCodes(name) : undefined} /> + )} />,