From 5b83ef4ba85fa23675687a219194b0b259840a17 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 11:34:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=87=8C=E5=8A=A0=E6=95=B0=E6=8D=AE=E5=89=96=E6=9E=90=EF=BC=9A?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E5=92=8C=E4=BA=94=E9=A1=B9=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E5=B8=A6=E5=8D=95=E4=BD=8D=E4=B8=8E=E8=8C=83=E5=9B=B4=EF=BC=8C?= =?UTF-8?q?=E5=86=99=E6=98=8E=E4=BC=9A=E5=8F=91=E5=87=BA=E7=9A=84=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=9F=A5=E8=AF=A2=E4=B8=8A=E9=99=90=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E8=A2=AB=E6=8B=92=E6=97=B6=E6=8A=A5=E9=94=99=E8=90=BD?= =?UTF-8?q?=E5=88=B0=E5=AF=B9=E5=BA=94=E7=9A=84=E9=A1=B9=E4=B8=8A=EF=BC=9B?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=A1=A8=E6=A0=BC=E7=9A=84=E6=BA=90=E5=9C=A8?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=8A=E5=8D=95=E7=8B=AC=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/terms.ts | 64 ++++++- frontend/src/pages/DataSourcesTab.tsx | 78 +++++++-- .../src/pages/catalog/ProfileSettings.tsx | 162 ++++++++++++++++++ frontend/src/pages/catalog/profile.ts | 141 +++++++++++++++ frontend/src/types.ts | 135 +++++++++++++++ 5 files changed, 566 insertions(+), 14 deletions(-) create mode 100644 frontend/src/pages/catalog/ProfileSettings.tsx create mode 100644 frontend/src/pages/catalog/profile.ts diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 2d8cb0be..46aa72b9 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -43,7 +43,8 @@ */ import type { - CatalogCardinality, CatalogMeasure, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode, ToolTrust, + CatalogCardinality, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode, + ToolTrust, } from '../types' import { formatNumber } from './format' @@ -2148,3 +2149,64 @@ export const CATALOG_TEXT = { reloadDiscardTitle: '放弃未保存的修改并重新载入?', reloadDiscardAction: '放弃修改并重新载入', } + +// =========================================================================== +// 数据剖析(数据源设置里的开关和预算;数据目录页的「数据剖析」) +// +// 术语: +// - 「数据剖析」:对业务库发少量只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。 +// 动作写「剖析」,不写扫描、探测。默认关闭,按数据源开启。 +// - 预算五项:查询次数上限、单条查询时限、抽样键值数、整表统计行数上限、总时长上限。标签和服务端校验报错里的叫法 +// 一致(catalog_profile._NUMBER_FIELDS),保存被拒时按它认出是哪一项。 +// =========================================================================== + +export const PROFILE_FIELD_LABEL: Record<'enabled' | CatalogProfileNumberKey, string> = { + enabled: '开启数据剖析', + max_queries: '查询次数上限', + query_timeout_s: '单条查询时限', + sample_size: '抽样键值数', + max_scan_rows: '整表统计行数上限', + max_total_s: '总时长上限', +} + +export const PROFILE_FIELD_UNIT: Record = { + max_queries: '条', + query_timeout_s: '秒', + sample_size: '个', + max_scan_rows: '行', + max_total_s: '秒', +} + +export const PROFILE_FIELD_HINT: Record = { + max_queries: '一次剖析最多发出的查询条数,用完即停止', + query_timeout_s: '超过即中断这条查询;不超过数据源自身的查询时限', + sample_size: '核对一条关联关系时,从本表抽取的不同键值个数', + max_scan_rows: '不超过此行数的表才做去重计数、最小值和最大值等整表统计', + max_total_s: '一次剖析的总时长,到时即停止', +} + +export const PROFILE_TEXT = { + // ---- 设置 + section: '数据剖析', + enableHint: '默认关闭。开启后可在数据目录中核实推断的关联关系、取状态类列的码值候选', + risk: (queries: number, timeout: number, total: number) => + `开启后,每次剖析会对这个数据源发出只读查询:最多 ${formatNumber(queries)} 条,单条不超过 ${formatNumber(timeout)} 秒,` + + `总时长不超过 ${formatNumber(total)} 秒。不读取明细行,遮罩的列不取样。`, + range: (min: number, max: number, unit: string, def: number) => + `范围 ${formatNumber(min)}–${formatNumber(max)} ${unit},默认 ${formatNumber(def)}`, + scanZero: '填 0 表示一律不做整表统计', + placeholder: (def: number) => `默认 ${formatNumber(def)}`, + rangeError: (min: number, max: number, integer: boolean) => + `请填写 ${formatNumber(min)} 到 ${formatNumber(max)} 之间的${integer ? '整数' : '数'}`, + invalid: (n: number) => `数据剖析有 ${formatNumber(n)} 项设置不正确`, + // ---- 数据源卡片 + chip: '数据剖析已开启', + chipHint: (queries: number, timeout: number) => + `数据剖析已开启:每次最多 ${formatNumber(queries)} 条只读查询,单条不超过 ${formatNumber(timeout)} 秒`, + open: '设置数据剖析', + dialogTitle: (name: string) => `「${name}」的数据剖析设置`, + dialogBody: '设置保存在数据源上,对数据目录中的每次剖析生效。', + savedOn: (name: string) => `「${name}」已开启数据剖析`, + savedOff: (name: string) => `「${name}」已关闭数据剖析`, + saved: (name: string) => `已保存「${name}」的数据剖析设置`, +} diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx index de2cd9b1..3720cafc 100644 --- a/frontend/src/pages/DataSourcesTab.tsx +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { AlertTriangle, BookMarked, ChevronRight, Database, EyeOff, FileSpreadsheet, History, Info, KeyRound, Layers, Lock, Plug, Plus, - RefreshCw, Search, Table2, Upload, X, + RefreshCw, ScanSearch, Search, Table2, Upload, X, } from 'lucide-react' import clsx from 'clsx' import { ApiError, api, uploadDecision } from '../api/client' @@ -21,9 +21,16 @@ import { checkHealth, forgetHealth, healthFromServer, setHealth, useHealth } fro import type { HealthRecord } from '../lib/health' import { workflowList, workflowsMentioning } from '../lib/mentions' import { - CATALOG_TEXT, RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, RECIPE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT, + CATALOG_TEXT, PROFILE_TEXT, RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, RECIPE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, + VERSIONS_TEXT, } from '../lib/terms' import { useRunClock } from '../run/useRunClock' +import { focusProfileField, ProfileSettingsDialog, ProfileSettingsSection } from './catalog/ProfileSettings' +import type { ProfileErrorKey } from './catalog/ProfileSettings' +import { + hasProfileOption, PROFILE_OPTION, profileFieldOfRejection, profileFormOf, profileOptionOf, profileProblems, profileSettingsOf, +} from './catalog/profile' +import type { ProfileForm } from './catalog/profile' import { ImportWizard } from './import/ImportWizard' import type { WizardEntry } from './import/ImportWizard' import { VersionsDialog } from './import/VersionsDialog' @@ -285,9 +292,11 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci // 传上来的表通常只有一两张,直接摊开;库动辄几十上百个对象,默认收着 const [open, setOpen] = useState(() => uploaded && row.table_count > 0 && row.table_count <= 3) const [masking, setMasking] = useState(false) + const [profiling, setProfiling] = useState(false) /** 版本页(当前版本的各期、历史版本、导入记录) */ const [versions, setVersions] = useState(false) const masks = maskList(row.options?.[MASK_KEY]) + const profile = profileSettingsOf(row.options) const clock = useRunClock(!!busy) const busySince = useRef(0) useTicker(60_000) @@ -525,12 +534,28 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci {masks.length ? '修改遮罩列' : '设置遮罩列'} )} + {profile.enabled && ( + + {PROFILE_TEXT.chip} + + )} + {uploaded && ( + // 数据剖析的设置同理:手工登记的库在编辑框里改 + + )} {!!row.tools?.length && {row.tools.join(' · ')}} {masking && ( setMasking(false)} onSaved={(next) => { setMasking(false); onChange(next); syncCatalog() }} /> )} + {profiling && ( + setProfiling(false)} + onSaved={(next) => { setProfiling(false); onChange(next); syncCatalog() }} /> + )} {versions && ( // onChange 是父级的「更新这一行并刷新全局目录」:回滚之后表结构可能变了,检查器、问数据读的目录要跟上 setVersions(false)} onChange={onChange} onReload={onReload} /> @@ -940,11 +965,16 @@ interface SourceForm { sid: string /** options.mask_columns:证据面板展示查询原始行时遮掉的列 */ maskColumns: string[] - /** options 里除 schema / service_name / sid / mask_columns 之外的键,全部摊开可编辑 */ + /** options.catalog_profile:数据剖析的开关和预算 */ + profile: ProfileForm + /** 打开时 options 里已经有剖析设置:没开、也没填时也要照写(关掉之后写回 enabled: false) */ + profileStored: boolean + /** options 里除 schema / service_name / sid / mask_columns / catalog_profile 之外的键,全部摊开可编辑 */ advanced: [string, string][] } -const OWN_OPTION_KEYS = new Set(['schema', 'service_name', 'sid', MASK_KEY]) +// 剖析设置是一个对象,不能摊进「高级连接参数」:那里按文字回显,保存时会写成「[object Object]」被服务端拒收 +const OWN_OPTION_KEYS = new Set(['schema', 'service_name', 'sid', MASK_KEY, PROFILE_OPTION]) function toForm(source: any): SourceForm { const o = source.options ?? {} @@ -966,6 +996,8 @@ function toForm(source: any): SourceForm { serviceName: o.service_name ?? (oracle ? source.database ?? '' : ''), sid: o.sid ?? '', maskColumns: maskList(o[MASK_KEY]), + profile: profileFormOf(o), + profileStored: hasProfileOption(o), advanced: Object.entries(o) .filter(([k]) => !OWN_OPTION_KEYS.has(k)) .map(([k, v]) => [k, String(v ?? '')]), @@ -973,10 +1005,12 @@ function toForm(source: any): SourceForm { } function toBody(form: SourceForm, isNew: boolean, wasOracle = true): any { - const options: Record = {} + const options: Record = {} for (const [k, v] of form.advanced) if (k.trim() && v.trim()) options[k.trim()] = v.trim() if (form.schema.trim()) options.schema = form.schema.trim() if (form.maskColumns.length) options[MASK_KEY] = form.maskColumns + const profile = profileOptionOf(form.profile, form.profileStored) + if (profile) options[PROFILE_OPTION] = profile const oracle = form.kind === 'oracle' if (oracle) { // 只写选中的那一个,另一个不带:两处都有值时引擎听 service_name, @@ -1005,10 +1039,12 @@ function toBody(form: SourceForm, isNew: boolean, wasOracle = true): any { } /** - * 422 的中文 detail 说的是哪一项。眼下只有查询时限按字段拒(后端 query_timeout_problem), - * 认不出的交回 toast + * 422 的中文 detail 说的是哪一项:查询时限(后端 query_timeout_problem);数据剖析的某一项(profile_settings_problem, + * 记为 profile.<键>,它的「单条查询时限」也带「查询时限」四个字,要先认)。认不出的交回 toast */ function fieldOfRejection(message: string): string | null { + const profile = profileFieldOfRejection(message) + if (profile) return `profile.${profile}` if (/查询时限/.test(message)) return 'query_timeout_s' return null } @@ -1019,10 +1055,11 @@ const FIELD_LABEL: Record = { /** * 决定连到哪个库的那几项(提交体里的)。schema 不在内:它只影响探查哪一片,不影响 - * 连不连得上,后端判断测连接结果还作不作数时同样不看它;遮罩列只管面板上怎么显示,也不在内 + * 连不连得上,后端判断测连接结果还作不作数时同样不看它;遮罩列只管面板上怎么显示,数据剖析的设置只管剖析, + * 都不在内(服务端拼连接串时同样摘掉这几项) */ function connectionOf(body: any): string { - const { schema: _schema, [MASK_KEY]: _mask, ...options } = body.options ?? {} + const { schema: _schema, [MASK_KEY]: _mask, [PROFILE_OPTION]: _profile, ...options } = body.options ?? {} return JSON.stringify([ body.kind, body.host ?? null, body.port ?? null, body.database ?? null, body.username ?? null, options, !!body.password, @@ -1076,6 +1113,12 @@ function SourceEditor({ source, kinds, onClose, onSaved }: { } const required = (key: string) => needs.includes(key) const blocked = missing.length > 0 || !!nameError + // 剖析设置填错只拦保存,不拦测试连接(和连接无关) + const profileBad = Object.keys(profileProblems(form.profile)).length + const saveBlocked = blocked || profileBad > 0 + const saveHint = blocked ? `缺少必填项:${missing.join('、') || '标识格式'}` : profileBad ? PROFILE_TEXT.invalid(profileBad) : undefined + const profileError = fieldError?.key.startsWith('profile.') + ? { key: fieldError.key.slice('profile.'.length) as ProfileErrorKey, message: fieldError.message } : null const body = toBody(form, isNew, source.kind === 'oracle') const sig = JSON.stringify({ ...body, password: form.password }) const dirty = JSON.stringify(form) !== JSON.stringify(initial) @@ -1097,7 +1140,7 @@ function SourceEditor({ source, kinds, onClose, onSaved }: { } const save = async () => { - if (blocked) return + if (saveBlocked) return setSaving(true) setFieldError(null) try { @@ -1109,7 +1152,10 @@ function SourceEditor({ source, kinds, onClose, onSaved }: { } catch (e) { const rejected = e instanceof ApiError && e.status === 422 ? e.message : '' const key = rejected ? fieldOfRejection(rejected) : null - if (key) { + if (key?.startsWith('profile.')) { + setFieldError({ key, message: rejected }) + focusProfileField('ds-profile', key.slice('profile.'.length) as ProfileErrorKey) + } else if (key) { setFieldError({ key, message: rejected }) setAdvOpen(true) requestAnimationFrame(() => document.getElementById(`ds-adv-${key}`)?.focus()) @@ -1200,8 +1246,8 @@ function SourceEditor({ source, kinds, onClose, onSaved }: { title={blocked ? `缺少必填项:${missing.join('、') || '标识格式'}` : '使用当前配置测试连接,不保存'}> {test.since ? : } 测试连接 - @@ -1318,6 +1364,12 @@ function SourceEditor({ source, kinds, onClose, onSaved }: { set({ maskColumns })} /> + { + if (profileError) setFieldError(null) + set({ profile }) + }} /> +