mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
数据目录页加数据剖析:选范围、确认预算后开始,进行中写已用时长并说明无法中途停止,报告列出查询用量、停止原因、每张表的关系核实、码值候选、业务日期提议和跳过的项;各类 409 写明下一步,未开启的就地打开设置;码值含义为空时显示「含义待填写」并可逐个填写
This commit is contained in:
@@ -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<CatalogDraftOut>(`/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<CatalogProfileOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/profile`,
|
||||
{ method: 'POST', body: JSON.stringify(body), ...opts }),
|
||||
},
|
||||
|
||||
runs: {
|
||||
|
||||
+156
-1
@@ -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<string, string>,
|
||||
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<string, string>,
|
||||
blockedNext: {
|
||||
disabled: '剖析会对业务库发出只读查询,需要先在数据源设置中开启。开启后即可在这里剖析。',
|
||||
inactive: '停用的数据源无法剖析。请在数据源设置中启用后重试。',
|
||||
noSchema: '请回到数据源卡片点击「探查结构」,探查完成后重试。',
|
||||
busy: '同一个数据源同一时间只能进行一次剖析。请等待当前剖析完成后重试。',
|
||||
tampered: '数据文件与登记的版本不一致,已拒绝查询。请重新上传表格,或联系管理员核对数据文件。',
|
||||
other: '请稍后重试。',
|
||||
} as Record<string, string>,
|
||||
goSource: '前往数据源',
|
||||
retry: '重试',
|
||||
failedTitle: '剖析未能完成',
|
||||
backToSetup: '返回',
|
||||
}
|
||||
|
||||
/** 整次剖析中途停下的原因 */
|
||||
export const PROFILE_STOP_LABEL: Record<string, string> = {
|
||||
budget: '查询次数已用完',
|
||||
deadline: '总时长已用完',
|
||||
failed: '连续多条查询失败',
|
||||
}
|
||||
|
||||
/** 停下之后怎么办 */
|
||||
export const PROFILE_STOP_NEXT: Record<string, string> = {
|
||||
budget: '其余检查未执行。可在数据剖析设置中调高查询次数上限,或缩小剖析范围后重新剖析。',
|
||||
deadline: '其余检查未执行。可在数据剖析设置中调高总时长上限,或缩小剖析范围后重新剖析。',
|
||||
failed: '其余检查未执行。请先在数据源卡片上测试连接,确认可用后重新剖析。',
|
||||
}
|
||||
|
||||
/** 跳过一项的原因(skipped.reason)。整句说明在 skipped.detail 里 */
|
||||
export const PROFILE_SKIP_REASON_LABEL: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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: '取消',
|
||||
}
|
||||
|
||||
@@ -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<string> }>({ 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<string, unknown> } | 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() {
|
||||
<Sparkles size={11} aria-hidden /> {CT.draft}
|
||||
</button>
|
||||
)
|
||||
// 页头的「数据剖析」:剖析进行中换成计时,点开看进度(关掉弹窗剖析照常进行)
|
||||
const headerProfile = jobRunning && myJob?.phase === 'running'
|
||||
? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setProfiling(true)} title={PT.headerRunningHint}
|
||||
aria-label={PT.headerRunning(secondsText(Date.now() - myJob.startedAt))} data-catalog-profile="running">
|
||||
<Spinner size={11} /> <span className="tnum hidden sm:inline">{PT.headerRunning(secondsText(Date.now() - myJob.startedAt))}</span>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="btn btn-sm" onClick={openProfile} aria-label={PT.action} title={PT.actionHint} data-catalog-profile="">
|
||||
<ScanSearch size={11} aria-hidden /> <span className="hidden sm:inline">{PT.action}</span>
|
||||
</button>
|
||||
)
|
||||
// 页头的「起草」窄屏只留图标,标题才放得下
|
||||
const headerDraft = (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setDrafting(true)} aria-label={CT.draft} title={CT.draft} data-catalog-draft="">
|
||||
@@ -159,12 +265,13 @@ export function CatalogPage() {
|
||||
active={table}
|
||||
onOpen={openTable}
|
||||
onDraft={() => setDrafting(true)}
|
||||
onProfile={openProfile}
|
||||
/>
|
||||
</aside>
|
||||
<section className={clsx('min-h-0 min-w-0 flex-1 flex-col lg:flex', table ? 'flex' : 'hidden')} aria-label={table ?? CT.overviewTitle}>
|
||||
{table
|
||||
? (
|
||||
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} prev={prev} next={next}
|
||||
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} 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} />}
|
||||
@@ -181,6 +288,7 @@ export function CatalogPage() {
|
||||
subtitle={CT.subtitle}
|
||||
actions={(
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{hasTables && headerProfile}
|
||||
{hasTables && headerDraft}
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => navigate(back)} aria-label={CT.back} data-catalog-back="">
|
||||
<ArrowLeft size={12} aria-hidden /> <span className="hidden sm:inline">{CT.back}</span>
|
||||
@@ -200,6 +308,28 @@ export function CatalogPage() {
|
||||
onDrafted={onDrafted}
|
||||
/>
|
||||
)}
|
||||
{profiling && data && (
|
||||
<ProfileDialog
|
||||
sourceName={source?.name ?? sourceId}
|
||||
settings={profileSettings}
|
||||
rows={rows}
|
||||
visible={visible}
|
||||
selected={[...selected]}
|
||||
filtered={filtered}
|
||||
job={myJob}
|
||||
onStart={(tables) => void startProfile(tables)}
|
||||
onClose={() => setProfiling(false)}
|
||||
onSettings={() => setProfileSettingsOpen(true)}
|
||||
onGoSource={() => navigate(back)}
|
||||
onOpenTable={openFromReport}
|
||||
onFillCodes={fillFromReport}
|
||||
onReset={() => setJob(null)}
|
||||
/>
|
||||
)}
|
||||
{profileSettingsOpen && source && (
|
||||
<ProfileSettingsDialog row={{ id: source.id, name: source.name, options }} onClose={() => setProfileSettingsOpen(false)}
|
||||
onSaved={onProfileSettingsSaved} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react'
|
||||
import type { CatalogItem } from '../../types'
|
||||
import { isComposing, Modal, Spinner } from '../../components/ui'
|
||||
import { CODES_TEXT as KT } from '../../lib/terms'
|
||||
import { StatusChip } from './parts'
|
||||
|
||||
// ===========================================================================
|
||||
// 填写码值的含义。数据剖析只知道列里出现过哪些取值(码值候选的含义是空串),含义要人填:逐个码值一个输入框,
|
||||
// 比在编辑模式里改「码值=含义」的多行文字省事。保存走整份提交,这一项记为人工填写、已确认;
|
||||
// 暂不清楚的含义可以留空,表格里照旧显示「含义待填写」。
|
||||
// ===========================================================================
|
||||
|
||||
export function CodesDialog({ column, item, saving, onSave, onClose }: {
|
||||
column: string
|
||||
item: CatalogItem<Record<string, string>>
|
||||
saving: boolean
|
||||
/** 新的码值对照(码值不变,只改含义) */
|
||||
onSave: (value: Record<string, string>) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [initial] = useState(() => ({ ...item.value }))
|
||||
const [value, setValue] = useState<Record<string, string>>(initial)
|
||||
const codes = Object.keys(initial)
|
||||
const dirty = codes.some((k) => (value[k] ?? '').trim() !== (initial[k] ?? '').trim())
|
||||
const save = () => {
|
||||
if (!dirty || saving) return
|
||||
onSave(Object.fromEntries(codes.map((k) => [k, (value[k] ?? '').trim()])))
|
||||
}
|
||||
return (
|
||||
<Modal open onClose={onClose} dirty={dirty} width={520} title={KT.title(column)}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onClose}>{KT.cancel}</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!dirty || saving} onClick={save} data-codes-save="">
|
||||
{saving ? <Spinner size={11} /> : null} {KT.save}
|
||||
</button>
|
||||
</>
|
||||
)}>
|
||||
<div className="space-y-3" data-codes-dialog={column}>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-2xs text-faint">
|
||||
<StatusChip status={item.status} source={item.source} />
|
||||
</div>
|
||||
{item.note && (
|
||||
<div className="rounded border bg-bg px-2 py-1.5 text-2xs leading-relaxed text-dim">
|
||||
<span className="text-faint">{KT.note}:</span>{item.note}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-elev text-left text-2xs text-faint">
|
||||
<th scope="col" className="w-[32%] px-3 py-1.5 font-medium">{KT.head.code}</th>
|
||||
<th scope="col" className="px-2 py-1.5 font-medium">{KT.head.meaning}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{codes.map((k, i) => (
|
||||
<tr key={k} className="border-b border-hairline last:border-b-0">
|
||||
<th scope="row" className="mono px-3 py-1 text-left font-normal [overflow-wrap:anywhere]">
|
||||
<label htmlFor={`codes-${column}-${i}`}>{k}</label>
|
||||
</th>
|
||||
<td className="px-2 py-1">
|
||||
<input id={`codes-${column}-${i}`} className="field !py-1" value={value[k] ?? ''} placeholder={KT.placeholder}
|
||||
maxLength={500} autoFocus={i === codes.findIndex((c) => !(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} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-2xs leading-relaxed text-faint">{KT.hint}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<ColumnField, string>>
|
||||
|
||||
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<string, ColumnTexts> | 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
|
||||
<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} />
|
||||
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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',
|
||||
<Value item={codes} render={(v) => <Codes value={v as Record<string, string>} />} />,
|
||||
<Value item={codes} render={(v) => (
|
||||
<Codes value={v as Record<string, string>} label={KT.fillLabel(name)}
|
||||
onFill={codes?.status !== 'rejected' && !busy ? () => onFillCodes(name) : undefined} />
|
||||
)} />,
|
||||
<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}
|
||||
@@ -183,7 +189,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
</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
|
||||
&& a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onFillCodes === b.onFillCodes
|
||||
&& a.onChange === b.onChange
|
||||
// 格式问题每次都是新对象:只比这一列的几格
|
||||
&& COLUMN_FIELDS.every((f) => a.problems[`c:${a.col.name}:${f}`] === b.problems[`c:${b.col.name}:${f}`]))
|
||||
|
||||
@@ -195,17 +202,33 @@ export function Value<T>({ item, render }: { item: CatalogItem<T> | undefined; r
|
||||
return <span className={clsx(item.status === 'rejected' && 'text-faint line-through')}>{render(item.value)}</span>
|
||||
}
|
||||
|
||||
function Codes({ value }: { value: Record<string, string> }) {
|
||||
/** 含义还空着的码值个数(数据剖析给的码值候选只有取值,含义等人填) */
|
||||
export const pendingCodes = (v: Record<string, string> | undefined): number =>
|
||||
Object.values(v ?? {}).filter((x) => !String(x ?? '').trim()).length
|
||||
|
||||
function Codes({ value, label, onFill }: { value: Record<string, string>; label: string; onFill?: () => void }) {
|
||||
const entries = Object.entries(value)
|
||||
const pending = pendingCodes(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 className="block">
|
||||
<span className="flex flex-wrap gap-1" title={entries.map(([k, v]) => `${k}=${v.trim() || KT.pending}`).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" data-code={k}>
|
||||
<span className="mono text-dim">{k}</span>
|
||||
{v.trim()
|
||||
? <span className="truncate">{v}</span>
|
||||
: <span className="truncate" style={{ color: 'var(--st-waiting)' }} data-code-pending="">{KT.pending}</span>}
|
||||
</span>
|
||||
))}
|
||||
{entries.length > CODES_SHOWN && <span className="text-2xs text-faint">+{entries.length - CODES_SHOWN}</span>}
|
||||
</span>
|
||||
{pending > 0 && onFill && (
|
||||
<button type="button" className="mt-1 inline-flex items-center gap-1 rounded px-1 text-2xs text-dim underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
onClick={onFill} aria-label={label} data-codes-fill="">
|
||||
<PencilLine size={10} aria-hidden /> {KT.fill}
|
||||
<span className="tnum text-faint">({KT.pendingN(pending)})</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowUpRight, CircleCheck, OctagonX, ScanSearch, ShieldAlert, TriangleAlert } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type {
|
||||
CatalogProfileCodesFinding, CatalogProfileDateFinding, CatalogProfileOut, CatalogProfileRelationFinding, CatalogProfileSettings,
|
||||
CatalogProfileSize, CatalogProfileSkip, CatalogProfileTable, CatalogTableRow,
|
||||
} from '../../types'
|
||||
import { ErrorState, Modal, Notice, Spinner, useTicker } from '../../components/ui'
|
||||
import {
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_TEXT as CT, PROFILE_SKIP_KIND_LABEL, PROFILE_SKIP_REASON_LABEL, PROFILE_STOP_LABEL, PROFILE_STOP_NEXT, PROFILE_TEXT as PT,
|
||||
} from '../../lib/terms'
|
||||
import { STATUS_TONE, StatusChip } from './parts'
|
||||
import { PROFILE_DEFAULT_TABLES, PROFILE_MAX_TABLES, coverageText, secondsText } from './profile'
|
||||
import type { ProfileBlock } from './profile'
|
||||
|
||||
// ===========================================================================
|
||||
// 数据目录页的「数据剖析」。接口是同步的:一次请求做完整个剖析才返回,服务端不支持中途停止,所以这里不给「停止」,
|
||||
// 进行中只写已用时长和总时长上限,并明说无法中途停止。关掉窗口剖析照常进行(状态在页面上,页头有进度入口),
|
||||
// 完成后弹提示、可以再打开报告。
|
||||
//
|
||||
// 四段:选范围、看预算、确认开始 → 进行中 → 报告(用了几条查询、为什么停、每张表的发现和跳过的项);
|
||||
// 开始不了(409)时写明是哪一种情况和下一步,未开启的就地打开剖析设置。
|
||||
// ===========================================================================
|
||||
|
||||
/** 一次剖析的状态。放在页面上而不是弹窗里:关掉弹窗剖析照常进行,完成后还能再打开报告 */
|
||||
export type ProfileJob =
|
||||
| { phase: 'running'; startedAt: number; tables: string[] | null; settings: CatalogProfileSettings }
|
||||
| { phase: 'done'; report: CatalogProfileOut; ms: number }
|
||||
| { phase: 'blocked'; kind: ProfileBlock; message: string; tables: string[] | null }
|
||||
| { phase: 'failed'; error: unknown; tables: string[] | null }
|
||||
|
||||
type Scope = 'selected' | 'filtered' | 'default'
|
||||
|
||||
/** 报告里每类最多逐条列几项,其余合成一句 */
|
||||
const SKIP_SHOWN = 6
|
||||
const CODES_SHOWN = 12
|
||||
|
||||
export function ProfileDialog({
|
||||
sourceName, settings, rows, visible, selected, filtered, job, onStart, onClose, onSettings, onGoSource, onOpenTable, onFillCodes, onReset,
|
||||
}: {
|
||||
sourceName: string
|
||||
/** 这个数据源此刻的剖析设置(数据源列表里读的) */
|
||||
settings: CatalogProfileSettings
|
||||
rows: CatalogTableRow[]
|
||||
visible: CatalogTableRow[]
|
||||
selected: string[]
|
||||
filtered: boolean
|
||||
/** null:还没开始(选范围) */
|
||||
job: ProfileJob | null
|
||||
/** tables 为 null 时由服务端挑表 */
|
||||
onStart: (tables: string[] | null) => void
|
||||
onClose: () => void
|
||||
/** 打开这个数据源的剖析设置 */
|
||||
onSettings: () => void
|
||||
/** 去数据源卡片(停用、没有表结构、快照被改动时) */
|
||||
onGoSource: () => void
|
||||
onOpenTable: (table: string) => void
|
||||
onFillCodes: (table: string, column: string) => void
|
||||
/** 回到选范围 */
|
||||
onReset: () => void
|
||||
}) {
|
||||
const live = useMemo(() => rows.filter((r) => r.in_schema).map((r) => r.table_name), [rows])
|
||||
const scopes = useMemo(() => {
|
||||
const liveSet = new Set(live)
|
||||
const out: { key: Scope; label: string; hint?: string; tables: string[] | null }[] = []
|
||||
const sel = selected.filter((t) => liveSet.has(t))
|
||||
if (sel.length) out.push({ key: 'selected', label: PT.scopeSelected(sel.length), tables: sel })
|
||||
const vis = visible.filter((r) => r.in_schema).map((r) => r.table_name)
|
||||
if (filtered && vis.length && vis.length !== live.length) out.push({ key: 'filtered', label: PT.scopeFiltered(vis.length), tables: vis })
|
||||
out.push({ key: 'default', label: PT.scopeDefault, hint: PT.scopeDefaultHint(PROFILE_DEFAULT_TABLES), tables: null })
|
||||
return out
|
||||
}, [live, selected, visible, filtered])
|
||||
const [scope, setScope] = useState<Scope>(() => scopes[0].key)
|
||||
const picked = scopes.find((s) => s.key === scope) ?? scopes[0]
|
||||
const tooMany = (picked.tables?.length ?? 0) > PROFILE_MAX_TABLES
|
||||
const running = job?.phase === 'running'
|
||||
useTicker(1000, running)
|
||||
|
||||
let body: ReactNode
|
||||
let footer: ReactNode
|
||||
if (!job) {
|
||||
body = (
|
||||
<>
|
||||
<p className="text-xs leading-relaxed text-dim">{PT.intro}</p>
|
||||
{!settings.enabled && (
|
||||
<Notice tone="warn" attr={{ 'data-profile-disabled': '' }}>
|
||||
<p className="font-medium">{PT.disabledTitle}</p>
|
||||
<p className="mt-0.5 text-dim">{PT.disabledBody}</p>
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={onSettings} data-profile-enable="">{PT.enable}</button>
|
||||
</Notice>
|
||||
)}
|
||||
<fieldset>
|
||||
<legend className="label">{PT.scope}</legend>
|
||||
<div className="space-y-1.5" role="radiogroup" aria-label={PT.scope}>
|
||||
{scopes.map((s) => {
|
||||
const over = (s.tables?.length ?? 0) > PROFILE_MAX_TABLES
|
||||
return (
|
||||
<label key={s.key} className={clsx('flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-xs',
|
||||
scope === s.key ? 'border-[var(--accent)] bg-accent-soft' : 'hover:bg-hover')} data-profile-scope={s.key}>
|
||||
<input type="radio" className="mt-0.5" name="catalog-profile-scope" checked={scope === s.key} onChange={() => setScope(s.key)} />
|
||||
<span className="min-w-0">
|
||||
<span>{s.label}</span>
|
||||
{(s.hint || over) && (
|
||||
<span className={clsx('mt-0.5 block text-2xs', over ? 'text-[var(--err)]' : 'text-faint')}>
|
||||
{over ? PT.scopeTooMany(PROFILE_MAX_TABLES) : s.hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
{settings.enabled && <Budget settings={settings} sourceName={sourceName} onSettings={onSettings} />}
|
||||
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-faint">
|
||||
<ShieldAlert size={11} className="mt-px shrink-0" aria-hidden /> {PT.noCancel}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
footer = (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onClose}>{CT.cancel}</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!settings.enabled || tooMany} onClick={() => onStart(picked.tables)}
|
||||
data-profile-start="">
|
||||
<ScanSearch size={12} aria-hidden /> {PT.start}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
} else if (job.phase === 'running') {
|
||||
const used = Date.now() - job.startedAt
|
||||
const max = job.settings.max_total_s * 1000
|
||||
body = (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Spinner size={13} />
|
||||
<span className="font-medium" data-profile-title="">{PT.running}</span>
|
||||
<span className="flex-1" />
|
||||
<span className="tnum text-2xs text-faint" data-profile-elapsed={Math.floor(used / 1000)}>
|
||||
{PT.elapsed(secondsText(used), secondsText(max))}
|
||||
</span>
|
||||
</div>
|
||||
{/* 不是进度:服务端不报进度,这条只量已用时长占总时长上限的多少,剖析最晚在上限处停下 */}
|
||||
<div className="h-1 overflow-hidden rounded-full bg-hover" role="meter" aria-label={PT.elapsed(secondsText(used), secondsText(max))}
|
||||
aria-valuemin={0} aria-valuemax={job.settings.max_total_s} aria-valuenow={Math.min(job.settings.max_total_s, Math.floor(used / 1000))}>
|
||||
<div className="h-full rounded-full bg-[var(--border-strong)] transition-[width] duration-1000 ease-linear"
|
||||
style={{ width: `${Math.min(100, (used / max) * 100)}%` }} />
|
||||
</div>
|
||||
<Notice tone="info" attr={{ 'data-profile-no-cancel': '' }}>{PT.runningHint}</Notice>
|
||||
<p className="text-2xs leading-relaxed text-faint">{PT.leavePage}</p>
|
||||
</div>
|
||||
)
|
||||
footer = <button type="button" className="btn" onClick={onClose} data-autofocus data-profile-background="">{PT.background}</button>
|
||||
} else if (job.phase === 'done') {
|
||||
body = <Report report={job.report} ms={job.ms} onSettings={onSettings} onOpenTable={onOpenTable} onFillCodes={onFillCodes} />
|
||||
footer = (
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost mr-auto" onClick={onReset} data-profile-again="">{PT.again}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onClose} data-autofocus data-profile-close="">{PT.close}</button>
|
||||
</>
|
||||
)
|
||||
} else if (job.phase === 'blocked') {
|
||||
const action = job.kind === 'disabled'
|
||||
? <button type="button" className="btn btn-sm btn-primary" onClick={onSettings} data-profile-enable="">{PT.enable}</button>
|
||||
: job.kind === 'busy' || job.kind === 'other'
|
||||
? <button type="button" className="btn btn-sm" onClick={() => onStart(job.tables)} data-profile-retry="">{PT.retry}</button>
|
||||
: <button type="button" className="btn btn-sm" onClick={onGoSource} data-profile-go-source="">{PT.goSource}</button>
|
||||
body = (
|
||||
<Notice tone={job.kind === 'busy' ? 'info' : 'warn'} attr={{ 'data-profile-blocked': job.kind }}>
|
||||
<p className="font-medium">{PT.blocked[job.kind]}</p>
|
||||
<p className="mt-0.5 text-dim">{job.message}</p>
|
||||
{job.kind !== 'other' && <p className="mt-0.5 text-dim">{PT.blockedNext[job.kind]}</p>}
|
||||
<div className="mt-2">{action}</div>
|
||||
</Notice>
|
||||
)
|
||||
footer = (
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost mr-auto" onClick={onReset} data-profile-back="">{PT.backToSetup}</button>
|
||||
<button type="button" className="btn" onClick={onClose}>{PT.close}</button>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<div data-profile-failed="">
|
||||
<p className="mb-2 text-xs font-medium">{PT.failedTitle}</p>
|
||||
<ErrorState error={job.error} onRetry={() => onStart(job.tables)} />
|
||||
</div>
|
||||
)
|
||||
footer = (
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost mr-auto" onClick={onReset}>{PT.backToSetup}</button>
|
||||
<button type="button" className="btn" onClick={onClose}>{PT.close}</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={PT.title} width={640} footer={footer}>
|
||||
<div className="space-y-4" data-profile-dialog={job?.phase ?? 'setup'}>{body}</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 确认前写明预算:按这个数据源的剖析设置,最多发几条查询、单条和总时长、抽样规模、整表统计的门槛 */
|
||||
function Budget({ settings: s, sourceName, onSettings }: { settings: CatalogProfileSettings; sourceName: string; onSettings: () => void }) {
|
||||
const lines = [PT.budgetQueries(s.max_queries, s.query_timeout_s), PT.budgetTotal(s.max_total_s), PT.budgetSample(s.sample_size),
|
||||
PT.budgetScan(s.max_scan_rows)]
|
||||
return (
|
||||
<div className="rounded-lg border px-3 py-2.5" data-profile-budget={`${s.max_queries},${s.query_timeout_s},${s.max_total_s}`}>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-2xs">
|
||||
<span className="font-medium text-dim">{PT.budget}</span>
|
||||
<span className="text-faint">{PT.budgetFrom(sourceName)}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="rounded px-1 text-2xs text-dim underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
onClick={onSettings} data-profile-settings-open="">{PT.editSettings}</button>
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
{lines.map((l) => (
|
||||
<li key={l} className="flex items-start gap-1.5">
|
||||
<span className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-[var(--text-faint)]" aria-hidden />
|
||||
<span className="tnum">{l}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 报告
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Report({ report: r, ms, onSettings, onOpenTable, onFillCodes }: {
|
||||
report: CatalogProfileOut
|
||||
ms: number
|
||||
onSettings: () => void
|
||||
onOpenTable: (table: string) => void
|
||||
onFillCodes: (table: string, column: string) => void
|
||||
}) {
|
||||
const changed = r.total.added + r.total.updated + r.total.removed
|
||||
const stop = r.stopped
|
||||
// 一项发现、一项跳过都没有的表合成一行,几十张表时报告不被空卡片撑长
|
||||
const quiet = r.tables.filter((t) => !t.error && !t.findings.length && !t.skipped.length && !t.date_ranges.length)
|
||||
const shown = r.tables.filter((t) => !quiet.includes(t))
|
||||
return (
|
||||
<div className="space-y-3" data-profile-report={stop ?? 'done'}>
|
||||
<div className="flex items-start gap-2">
|
||||
{stop
|
||||
? <TriangleAlert size={15} className="mt-px shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden />
|
||||
: <CircleCheck size={15} className="mt-px shrink-0" style={{ color: 'var(--st-done)' }} aria-hidden />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" data-profile-title="">{stop ? PT.stoppedTitle(PROFILE_STOP_LABEL[stop] ?? stop) : PT.done}</p>
|
||||
<p className="tnum mt-0.5 text-xs text-dim" data-profile-summary={`${r.queries_used}/${r.settings.max_queries}`}>
|
||||
{PT.summary(r.queries_used, r.settings.max_queries, r.tables.length)}
|
||||
<span className="text-faint"> · {secondsText(ms)}</span>
|
||||
</p>
|
||||
<p className="tnum mt-0.5 text-xs" data-profile-changes={`${r.total.added},${r.total.updated},${r.total.removed}`}>
|
||||
{changed ? PT.changes(r.total.added, r.total.updated, r.total.removed) : <span className="text-faint">{PT.noChange}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{stop && (
|
||||
<Notice tone="warn" attr={{ 'data-profile-stopped': stop }}>
|
||||
<p>{PROFILE_STOP_NEXT[stop] ?? ''}</p>
|
||||
{stop !== 'failed' && (
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={onSettings} data-profile-settings-open="">{PT.editSettings}</button>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
{r.note && <Notice tone="info" attr={{ 'data-profile-note': '' }}>{r.note}</Notice>}
|
||||
{shown.map((t) => <TableReport key={t.table_name} t={t} onOpenTable={onOpenTable} onFillCodes={onFillCodes} />)}
|
||||
{quiet.length > 0 && (
|
||||
<p className="text-2xs leading-relaxed text-faint" data-profile-quiet={quiet.length}>
|
||||
{PT.noFindings}:<span className="mono">{quiet.map((t) => t.table_name).join('、')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sizeText(size: CatalogProfileSize | null): { text: string; hint?: string } | null {
|
||||
if (!size) return null
|
||||
if (size.rows != null) return { text: size.method === 'stats' ? PT.rowsStats(size.rows) : PT.rowsCount(size.rows), hint: PT.rowsHint[size.method] }
|
||||
if (size.at_least != null) return { text: PT.rowsAtLeast(size.at_least), hint: PT.rowsHint.count }
|
||||
return null
|
||||
}
|
||||
|
||||
function TableReport({ t, onOpenTable, onFillCodes }: {
|
||||
t: CatalogProfileTable
|
||||
onOpenTable: (table: string) => void
|
||||
onFillCodes: (table: string, column: string) => void
|
||||
}) {
|
||||
const relations = t.findings.filter((f): f is CatalogProfileRelationFinding => f.kind === 'relation')
|
||||
const codes = t.findings.filter((f): f is CatalogProfileCodesFinding => f.kind === 'codes')
|
||||
const date = t.findings.find((f): f is CatalogProfileDateFinding => f.kind === 'business_date')
|
||||
const size = sizeText(t.row_estimate)
|
||||
const changed = t.added + t.updated + t.removed
|
||||
return (
|
||||
<section className="rounded-lg border" data-profile-table={t.table_name} aria-label={t.table_name}>
|
||||
<header className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b bg-elev px-3 py-1.5">
|
||||
<h3 className="mono min-w-0 text-xs font-medium [overflow-wrap:anywhere]">{t.table_name}</h3>
|
||||
<span className="tnum text-2xs text-faint">
|
||||
{PT.tableQueries(t.queries)}
|
||||
{size && <span title={size.hint}> · {size.text}</span>}
|
||||
{changed > 0 && <span> · {PT.changes(t.added, t.updated, t.removed)}</span>}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{!t.error && (
|
||||
<button type="button" className="inline-flex items-center gap-0.5 rounded px-1 text-2xs text-dim hover:bg-hover hover:text-fg"
|
||||
onClick={() => onOpenTable(t.table_name)} aria-label={PT.openTableLabel(t.table_name)} data-profile-open={t.table_name}>
|
||||
{PT.openTable} <ArrowUpRight size={10} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-2.5 px-3 py-2">
|
||||
{t.error && (
|
||||
<p className="flex items-start gap-1.5 text-xs text-[var(--err)]" data-profile-table-error="">
|
||||
<OctagonX size={12} className="mt-0.5 shrink-0" aria-hidden /> <span>{t.error}</span>
|
||||
</p>
|
||||
)}
|
||||
{relations.length > 0 && (
|
||||
<Group title={PT.findingRelations}>
|
||||
{relations.map((f) => <RelationLine key={f.path} f={f} />)}
|
||||
</Group>
|
||||
)}
|
||||
{codes.length > 0 && (
|
||||
<Group title={PT.findingCodes}>
|
||||
{codes.map((f) => <CodesLine key={f.path} f={f} onFill={() => onFillCodes(t.table_name, f.column)} />)}
|
||||
</Group>
|
||||
)}
|
||||
{date && (
|
||||
<Group title={PT.findingDate}>
|
||||
<li className="text-xs" data-profile-finding="business_date">
|
||||
<StatusChip status={date.status} className="mr-1.5 align-middle" />
|
||||
{PT.dateProposal(date.column)}
|
||||
<span className="tnum text-dim">,{PT.dateSpan(date.min.slice(0, 19), date.max.slice(0, 19))}</span>
|
||||
</li>
|
||||
</Group>
|
||||
)}
|
||||
{!date && t.date_ranges.length > 0 && (
|
||||
<Group title={PT.dateRanges}>
|
||||
{t.date_ranges.map((d) => (
|
||||
<li key={d.column} className="tnum text-xs text-dim" data-profile-date-range={d.column}>
|
||||
<span className="mono text-fg">{d.column}</span>:{PT.dateSpan(d.min.slice(0, 19), d.max.slice(0, 19))}
|
||||
</li>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{!t.error && !t.findings.length && !t.date_ranges.length && (
|
||||
<p className="text-2xs text-faint">{PT.noFindings}</p>
|
||||
)}
|
||||
{t.skipped.length > 0 && <Skipped list={t.skipped} />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-2xs font-medium text-faint">{title}</div>
|
||||
<ul className="space-y-1.5">{children}</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationLine({ f }: { f: CatalogProfileRelationFinding }) {
|
||||
const outcome = f.confirmed
|
||||
? { text: PT.relationConfirmed, tone: 'var(--text-dim)' }
|
||||
: f.status === 'verified'
|
||||
? { text: PT.relationVerified, tone: STATUS_TONE.verified }
|
||||
: { text: `${PT.relationKept}:${f.coverage < 0.95 ? PT.relationLowCoverage : PT.relationNotUnique}`, tone: STATUS_TONE.proposed }
|
||||
return (
|
||||
<li className="text-xs" data-profile-finding="relation" data-status={f.status} data-path={f.path}>
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
<StatusChip status={f.status} />
|
||||
<span className="mono min-w-0 [overflow-wrap:anywhere]">{f.target}</span>
|
||||
</div>
|
||||
<div className="tnum mt-0.5 text-2xs text-dim">
|
||||
<span data-profile-coverage="">{PT.coverage(coverageText(f.coverage))}</span>
|
||||
{' · '}
|
||||
<span data-profile-cardinality="">{f.cardinality ? CATALOG_CARDINALITY_LABEL[f.cardinality] : PT.cardinalityUnknown}</span>
|
||||
{' · '}
|
||||
{PT.sampled(f.sample, f.matched)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs" style={{ color: outcome.tone }} data-profile-outcome="">{outcome.text}</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function CodesLine({ f, onFill }: { f: CatalogProfileCodesFinding; onFill: () => void }) {
|
||||
// 服务端在 summary 里写了几个含义待填写(别的来源写过的含义会沿用);认不出时按都待填写
|
||||
const pending = Number(f.summary.match(/(\d+)\s*个含义待填写/)?.[1] ?? (f.summary.includes('待填写') ? f.values.length : 0))
|
||||
return (
|
||||
<li className="text-xs" data-profile-finding="codes" data-column={f.column}>
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
<StatusChip status={f.status} />
|
||||
<span className="mono font-medium">{f.column}</span>
|
||||
<span className="tnum text-2xs text-faint">{PT.codesSummary(f.values.length, f.rows)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{f.values.slice(0, CODES_SHOWN).map((v) => (
|
||||
<span key={v.value} className="inline-flex items-center gap-1 rounded border bg-bg px-1 text-2xs">
|
||||
<span className="mono">{v.value}</span>
|
||||
<span className="tnum text-faint">{PT.codeRows(v.rows)}</span>
|
||||
</span>
|
||||
))}
|
||||
{f.values.length > CODES_SHOWN && <span className="text-2xs text-faint">+{f.values.length - CODES_SHOWN}</span>}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{pending > 0 && <span className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-codes-pending={pending}>{PT.codesPendingN(pending)}</span>}
|
||||
<button type="button" className="btn btn-xs" onClick={onFill} aria-label={PT.fillMeaningsLabel(f.column)} data-profile-fill={f.column}>
|
||||
{PT.fillMeanings}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** 跳过的项:原因(短)+ 哪一项 + 服务端给的整句。多了折起来,每类停下的原因只列前几项 */
|
||||
function Skipped({ list }: { list: CatalogProfileSkip[] }) {
|
||||
const [all, setAll] = useState(false)
|
||||
const shown = all ? list : list.slice(0, SKIP_SHOWN)
|
||||
return (
|
||||
<div data-profile-skipped={list.length}>
|
||||
<div className="mb-1 text-2xs font-medium text-faint">{PT.skippedTitle(list.length)}</div>
|
||||
<ul className="space-y-1">
|
||||
{shown.map((s, i) => (
|
||||
<li key={`${s.kind}:${s.target}:${i}`} className="flex items-start gap-1.5 text-2xs" data-profile-skip={s.reason}>
|
||||
<span className="chip mt-px shrink-0 !px-1.5 !py-0" style={{ color: 'var(--text-dim)' }}>
|
||||
{PROFILE_SKIP_REASON_LABEL[s.reason] ?? s.reason}
|
||||
</span>
|
||||
<span className="min-w-0 leading-relaxed">
|
||||
<span className="text-faint">{PROFILE_SKIP_KIND_LABEL[s.kind] ?? s.kind} </span>
|
||||
<span className="mono text-dim [overflow-wrap:anywhere]">{s.target}</span>
|
||||
<span className="block text-dim">{s.detail}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{list.length > SKIP_SHOWN && !all && (
|
||||
<button type="button" className="mt-1 rounded px-1 text-2xs text-dim underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
onClick={() => setAll(true)}>
|
||||
{PT.moreSkipped(list.length - SKIP_SHOWN)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ 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,
|
||||
CATALOG_TEXT as CT, CODES_TEXT as KT,
|
||||
} from '../../lib/terms'
|
||||
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
|
||||
import type { ReviewTarget } from './parts'
|
||||
import { ColumnTable, Value } from './ColumnTable'
|
||||
import { ColumnTable, Value, pendingCodes } from './ColumnTable'
|
||||
import type { ColumnModel } from './ColumnTable'
|
||||
import { CodesDialog } from './CodesDialog'
|
||||
import { coverageText } from './profile'
|
||||
import {
|
||||
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
|
||||
relationDraft, relationPath, tablePath, unknownKeys, validateForm,
|
||||
@@ -48,13 +50,15 @@ const PROBLEM_TEXT = {
|
||||
type Editing = { initial: EditForm; form: EditForm }
|
||||
type Conflict = 'edit' | 'review'
|
||||
|
||||
export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen, onBack, onDetail }: {
|
||||
export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, next, onOpen, onBack, onDetail }: {
|
||||
sourceId: string
|
||||
table: string
|
||||
/** 全部表:关联关系的目标表能点就跳过去,编辑时给目标表做候选 */
|
||||
rows: CatalogTableRow[]
|
||||
/** 起草刚写进了这些表:正在看的表在里面、又没在编辑,就重新载入 */
|
||||
drafted: { seq: number; tables: Set<string> }
|
||||
/** 从剖析报告点「填写含义」过来:载入后打开这一列的码值弹窗(seq 让同一列连点两次也会再开) */
|
||||
fillCodes?: { table: string; column: string; seq: number } | null
|
||||
prev: string | null
|
||||
next: string | null
|
||||
onOpen: (table: string) => void
|
||||
@@ -72,6 +76,8 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen
|
||||
const [live, setLive] = useState('')
|
||||
const [colQuery, setColQuery] = useState('')
|
||||
const [onlyPending, setOnlyPending] = useState(false)
|
||||
/** 正在填写哪一列的码值含义 */
|
||||
const [codesFor, setCodesFor] = useState<string | null>(null)
|
||||
const loadSeq = useRef(0)
|
||||
const onDetailRef = useRef(onDetail)
|
||||
onDetailRef.current = onDetail
|
||||
@@ -100,6 +106,7 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen
|
||||
setColQuery('')
|
||||
setOnlyPending(false)
|
||||
setLive('')
|
||||
setCodesFor(null)
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
@@ -114,6 +121,15 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen
|
||||
if (drafted.tables.has(table) && !editingRef.current) void load()
|
||||
}, [drafted, table, load])
|
||||
|
||||
// 剖析报告里点了「填写含义」:这张表载入后打开那一列的码值弹窗。编辑中不打开,免得同一项两处同时改
|
||||
const fillSeen = useRef(0)
|
||||
useEffect(() => {
|
||||
if (!fillCodes || fillCodes.table !== table || fillCodes.seq === fillSeen.current) return
|
||||
if (!detail || detail.table_name !== table || editingRef.current) return
|
||||
fillSeen.current = fillCodes.seq
|
||||
if (detail.notes.columns?.[fillCodes.column]?.codes) setCodesFor(fillCodes.column)
|
||||
}, [fillCodes, table, detail])
|
||||
|
||||
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
|
||||
@@ -162,6 +178,41 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen
|
||||
if (done) setLive(CT.confirmedN(n))
|
||||
}, [sourceId, table, write])
|
||||
|
||||
const fillCodesOf = useCallback((col: string) => setCodesFor(col), [])
|
||||
|
||||
/**
|
||||
* 填好的码值含义整份提交:只换这一列码值的值,服务端记为人工填写、已确认。别人刚改过(409)时收起弹窗、挂冲突横幅;
|
||||
* 其余失败弹窗留着,填的字不丢
|
||||
*/
|
||||
const saveCodes = useCallback(async (col: string, value: Record<string, string>) => {
|
||||
const d = detailRef.current
|
||||
const item = d?.notes.columns?.[col]?.codes
|
||||
if (!d || !item) return
|
||||
const notes = structuredClone(d.notes)
|
||||
notes.columns![col] = { ...notes.columns![col], codes: { ...item, value } }
|
||||
setBusy(`codes:${col}`)
|
||||
setWriteError(null)
|
||||
try {
|
||||
const next = await api.dataCatalog.put(sourceId, table, notes, d.version)
|
||||
setDetail(next)
|
||||
setConflict(null)
|
||||
onDetailRef.current(next)
|
||||
setCodesFor(null)
|
||||
const filled = Math.max(0, pendingCodes(item.value) - pendingCodes(value))
|
||||
toast.ok(KT.saved(filled))
|
||||
setLive(KT.saved(filled))
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
setCodesFor(null)
|
||||
setConflict('review')
|
||||
} else {
|
||||
toast.error(e)
|
||||
}
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}, [sourceId, table])
|
||||
|
||||
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 } } } }))
|
||||
}, [])
|
||||
@@ -367,7 +418,7 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen
|
||||
? (
|
||||
<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} />
|
||||
onReview={review} onConfirmColumn={confirmColumn} onFillCodes={fillCodesOf} onChange={setColumn} />
|
||||
)
|
||||
: <p className="rounded-lg border px-3 py-6 text-center text-xs text-faint">{CT.noColumns}</p>}
|
||||
</section>
|
||||
@@ -385,6 +436,11 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{codesFor && notes.columns?.[codesFor]?.codes && (
|
||||
<CodesDialog column={codesFor} item={notes.columns[codesFor]!.codes!} saving={busy === `codes:${codesFor}`}
|
||||
onSave={(v) => void saveCodes(codesFor, v)} onClose={() => setCodesFor(null)} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
@@ -588,7 +644,9 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
|
||||
<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>}
|
||||
{typeof r.coverage === 'number'
|
||||
? <span data-relation-coverage={r.coverage}>{coverageText(r.coverage)}</span>
|
||||
: <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 }}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { AlertTriangle, Search, Sparkles, X } from 'lucide-react'
|
||||
import { AlertTriangle, ScanSearch, Search, Sparkles, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { CatalogTableRow } from '../../types'
|
||||
import { useRadioGroup } from '../../components/ui'
|
||||
import { formatNumber } from '../../lib/format'
|
||||
import { CATALOG_KIND_LABEL, CATALOG_TEXT as CT } from '../../lib/terms'
|
||||
import { CATALOG_KIND_LABEL, CATALOG_TEXT as CT, PROFILE_TEXT as PT } from '../../lib/terms'
|
||||
import { CountBadges } from './parts'
|
||||
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
|
||||
import type { ListFilter, ListSort } from './model'
|
||||
@@ -17,7 +17,7 @@ import type { ListFilter, ListSort } from './model'
|
||||
// ===========================================================================
|
||||
|
||||
export function TableIndex({
|
||||
rows, total, counts, query, onQuery, filter, onFilter, sort, onSort, selected, onSelected, active, onOpen, onDraft,
|
||||
rows, total, counts, query, onQuery, filter, onFilter, sort, onSort, selected, onSelected, active, onOpen, onDraft, onProfile,
|
||||
}: {
|
||||
/** 筛选、排序后的行 */
|
||||
rows: CatalogTableRow[]
|
||||
@@ -35,6 +35,8 @@ export function TableIndex({
|
||||
active: string | undefined
|
||||
onOpen: (table: string) => void
|
||||
onDraft: () => void
|
||||
/** 剖析所选(数据剖析弹窗,范围默认是已选的表) */
|
||||
onProfile?: () => void
|
||||
}) {
|
||||
const radio = useRadioGroup(LIST_FILTERS, filter, onFilter)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
@@ -162,6 +164,11 @@ export function TableIndex({
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={onDraft} data-catalog-draft-selected="">
|
||||
<Sparkles size={11} aria-hidden /> {CT.draftSelected(selected.size)}
|
||||
</button>
|
||||
{onProfile && (
|
||||
<button type="button" className="btn btn-sm" onClick={onProfile} data-catalog-profile-selected="">
|
||||
<ScanSearch size={11} aria-hidden /> {PT.profileSelected(selected.size)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -189,15 +189,18 @@ function codesText(v: Record<string, string>): string {
|
||||
return Object.entries(v).map(([k, x]) => `${k}=${x}`).join('\n')
|
||||
}
|
||||
|
||||
/** 码值的文字:每行一个「码值=含义」(全角等号也认)。返回解析结果和第一处错误 */
|
||||
/**
|
||||
* 码值的文字:每行一个「码值=含义」(全角等号也认)。含义可以空着(「2=」):数据剖析只知道列里出现过哪些取值,
|
||||
* 含义等人填,服务端也收空含义。返回解析结果和第一处错误
|
||||
*/
|
||||
export function parseCodes(text: string): { value: Record<string, string>; error: { line: number } | { dup: string } | null } {
|
||||
const value: Record<string, string> = {}
|
||||
const lines = text.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
const m = line.match(/^(.+?)\s*[==]\s*(.+)$/)
|
||||
if (!m) return { value, error: { line: i + 1 } }
|
||||
const m = line.match(/^(.+?)\s*[==]\s*(.*)$/)
|
||||
if (!m || !m[1].trim()) return { value, error: { line: i + 1 } }
|
||||
const code = m[1].trim()
|
||||
if (code in value) return { value, error: { dup: code } }
|
||||
value[code] = m[2].trim()
|
||||
|
||||
@@ -134,6 +134,40 @@ export function profileFieldOfRejection(message: string): 'enabled' | CatalogPro
|
||||
return 'section'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 剖析:范围、报告里的读数、409 的分类
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 不指定表时服务端剖析几张(PROFILE_DEFAULT_TABLES) */
|
||||
export const PROFILE_DEFAULT_TABLES = 10
|
||||
/** 一次最多指定几张表(接口 tables 的 max_length) */
|
||||
export const PROFILE_MAX_TABLES = 200
|
||||
|
||||
/** 覆盖率写成百分数:最多一位小数,不到 100% 的不进成 100%(同服务端 _percent) */
|
||||
export function coverageText(ratio: number): string {
|
||||
const text = (ratio * 100).toFixed(1).replace(/\.0$/, '')
|
||||
return text === '100' && ratio < 1 ? '99.9%' : `${text}%`
|
||||
}
|
||||
|
||||
/** 已用时长的读数:「不到 1 秒」「8 秒」「1 分 05 秒」 */
|
||||
export function secondsText(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
if (s < 1) return '不到 1 秒'
|
||||
return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${String(s % 60).padStart(2, '0')} 秒`
|
||||
}
|
||||
|
||||
/** 剖析接口回 409 的几种情况(按服务端的原话认:catalog_profile.ensure_enabled、api/catalog.py、engine.SnapshotTampered) */
|
||||
export type ProfileBlock = 'disabled' | 'inactive' | 'noSchema' | 'busy' | 'tampered' | 'other'
|
||||
|
||||
export function profileBlockOf(message: string): ProfileBlock {
|
||||
if (message.includes('未开启数据剖析')) return 'disabled'
|
||||
if (message.includes('已停用')) return 'inactive'
|
||||
if (message.includes('正在进行数据剖析')) return 'busy'
|
||||
if (message.includes('探查结构')) return 'noSchema'
|
||||
if (/已拒绝(?:这次)?查询/.test(message)) return 'tampered'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/** 设置里有没有剖析设置这一项(options 的其余键原样带上) */
|
||||
export function withProfileOption(options: Record<string, unknown> | null | undefined, value: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
const { [PROFILE_OPTION]: _old, ...rest } = options ?? {}
|
||||
|
||||
Reference in New Issue
Block a user