mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:51:57 +00:00
合入集成分支(阶段 2A 数据剖析、4B 剖析与 SQL 检查界面及修正)到 4A:两边功能都保留
冲突的解法: - 后端 api/catalog.py:同时导入 catalog_impact 和 catalog_profile、SnapshotTampered;data/catalog.py:__all__ 取两边的并集重排。 - 2A 放宽了码值校验(含义可以为空,剖析写进来的是「含义待填写」):目录修改提案的码值补充改为含义为空的码不覆盖已有含义、 只在原来没有这个码时加进去,补了测试;卡片和过程里的码值把空含义写成「含义待填写」,和目录页同一个说法。 - 前端 types.ts、terms.ts:两边各在同一处追加了新段落,按顺序都留下(提案、影响面、目录版本在前,剖析、码值、SQL 检查在后), 没有重复的键;client.ts、decode.ts、CatalogPage.tsx、TableDetail.tsx 的导入取并集。 - check-catalog.mjs:影响面和剖析、保存数据源三条假路由都留下,各节和最后核对真库数量的逻辑不变。 - 4A 的测试数据源改用 tests/fixtures/sources.py 的 drop_source 删除,顺带作废连接池。
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogImpact, CatalogList, CatalogNotes,
|
||||
CatalogPatchPreview, CatalogPatchSubmit, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord,
|
||||
CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview,
|
||||
EditRequest, ImportRecord,
|
||||
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
|
||||
RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging,
|
||||
} from '../types'
|
||||
@@ -687,6 +688,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: {
|
||||
|
||||
@@ -77,9 +77,28 @@ export function revealField(nodeId: string, field?: FieldRef | string | null, op
|
||||
const to = target.querySelector<HTMLElement>(FILLABLE) ?? target.querySelector<HTMLElement>(PRESSABLE)
|
||||
to?.focus({ preventScroll: true })
|
||||
}
|
||||
// 子键没有自己的一行、字段是一段 JSON(调用工具的「参数」,问题落在 args.sql 上):在文字里找到那个键,
|
||||
// 框内滚到那一行,聚焦时光标放在它的值开头
|
||||
if (!part && at.sub) revealJsonKey(target, at.sub, !!opts.focus)
|
||||
}))
|
||||
}
|
||||
|
||||
/** JSON 文字框里的某个键:滚到它那一行;focus 时把光标放在值的开头(不选中,免得一敲键就把整段 SQL 换掉) */
|
||||
function revealJsonKey(field: HTMLElement, key: string, focus: boolean): void {
|
||||
const box = field.querySelector<HTMLTextAreaElement>('textarea')
|
||||
if (!box) return
|
||||
const m = new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*:\\s*"?`).exec(box.value)
|
||||
if (!m) return
|
||||
const line = box.value.slice(0, m.index).split('\n').length - 1
|
||||
const height = parseFloat(getComputedStyle(box).lineHeight) || 16
|
||||
box.scrollTop = Math.max(0, (line - 1) * height)
|
||||
if (focus) {
|
||||
const at = m.index + m[0].length
|
||||
box.focus({ preventScroll: true })
|
||||
box.setSelectionRange(at, at)
|
||||
}
|
||||
}
|
||||
|
||||
// 选中 → 面板开不开。放在模块级:选中可能来自别处(问题面板定位、发布弹窗、快捷键),
|
||||
// 规则只写这一份
|
||||
useStudio.subscribe((s, prev) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCatalog } from '../store/catalog'
|
||||
import { Modal, Spinner, toast, useRadioGroup } from '../components/ui'
|
||||
import { humanizeError } from '../lib/errors'
|
||||
import { PUBLISH_FIX_TEXT as T, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL } from '../lib/terms'
|
||||
import { isSqlCheckCode, sqlRuleLabel } from '../lib/sqlcheck'
|
||||
import { setLocalActor, useLocalActor } from '../lib/actor'
|
||||
import {
|
||||
autoFixIds, choiceReady, contentSig, fixFieldLabel, fixFor, fixValueLines, fixValueText, isMissingEndpoint, mergeIssues,
|
||||
@@ -42,7 +43,8 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: {
|
||||
workflow: Workflow
|
||||
onClose: () => void
|
||||
onDone: () => void
|
||||
onLocate: (nodeId: string) => void
|
||||
/** 定位一条问题:选中节点,能落到具体配置项(调用工具的 SQL)就落到那一项 */
|
||||
onLocate: (issue: ValidationIssue) => void
|
||||
}) {
|
||||
const load = useStudio((s) => s.load)
|
||||
const dirty = useStudio((s) => s.dirty)
|
||||
@@ -208,7 +210,7 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: {
|
||||
</dl>
|
||||
|
||||
<div className="mt-3" data-preflight="dialog">
|
||||
<Preflight pf={pf} gate={gate} onLocate={(issue) => { if (issue.node_id) { onLocate(issue.node_id); onClose() } }} />
|
||||
<Preflight pf={pf} gate={gate} onLocate={(issue) => { if (issue.node_id) { onLocate(issue); onClose() } }} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -564,6 +566,8 @@ function PreflightRow({ issue, others, fix, where, pf, onLocate }: {
|
||||
aria-label={err ? '错误' : '提示'} />
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
||||
{where && !String(issue.message).includes(`「${where}」`) && <b className="font-semibold">「{where}」</b>}
|
||||
{/* 对照数据目录的 SQL 检查:先写中文规则名,不露规则编号 */}
|
||||
{isSqlCheckCode(issue.code) && <span className="font-medium" data-preflight-rule="">{sqlRuleLabel(issue.code)}:</span>}
|
||||
{issue.message}
|
||||
</span>
|
||||
{issue.node_id && <CornerDownRight size={10} className="mt-px shrink-0 text-faint" aria-hidden />}
|
||||
|
||||
@@ -310,6 +310,7 @@ function ToastCard({ item }: { item: ToastItem }) {
|
||||
<div
|
||||
className="fade-up pointer-events-auto flex items-start gap-2 rounded-lg border bg-panel px-3 py-2 shadow-elev-2"
|
||||
style={{ borderColor: item.kind === 'error' ? 'var(--err)' : item.kind === 'warn' ? 'var(--warn)' : 'var(--border)' }}
|
||||
data-toast={item.kind}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocus={() => setPaused(true)}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SqlCheckCode, SqlCheckItem, SqlCheckLevel } from '../types'
|
||||
import { SQL_CHECK_LEVEL_LABEL, SQL_CHECK_RULE_LABEL, SQL_CHECK_TEXT } from './terms'
|
||||
|
||||
// ===========================================================================
|
||||
// 基于数据目录的 SQL 检查(后端 data/sqlcheck.py)在界面上的读法:规则编号 → 中文规则名、级别 → 语义色,
|
||||
// 以及从证据接口、助手自查、发布前检查的问题里读出一条检查。证据面板、运行时间线、助手面板、发布前检查共用。
|
||||
// ===========================================================================
|
||||
|
||||
/** 七条规则的编号(服务端 CHECK_CODES),按严重程度大致排序 */
|
||||
export const SQL_CHECK_CODES: readonly SqlCheckCode[] = [
|
||||
'fanout_sum', 'stock_summed', 'join_unconfirmed', 'ratio_aggregated', 'missing_valid_filter', 'unknown_code', 'wrong_date_column',
|
||||
]
|
||||
|
||||
export const isSqlCheckCode = (code: unknown): code is SqlCheckCode =>
|
||||
typeof code === 'string' && (SQL_CHECK_CODES as readonly string[]).includes(code)
|
||||
|
||||
/** 级别:认不出的按「提示」(最轻的一档),不把拿不准的说重 */
|
||||
export const sqlCheckLevelOf = (v: unknown): SqlCheckLevel => (v === 'error' || v === 'warning' ? v : 'info')
|
||||
|
||||
/** 规则的中文名;服务端新加、这里还不认识的规则统称「SQL 检查」,不露编号 */
|
||||
export const sqlRuleLabel = (code: unknown): string => (isSqlCheckCode(code) ? SQL_CHECK_RULE_LABEL[code] : SQL_CHECK_TEXT.unknownRule)
|
||||
|
||||
/** 各级别的颜色(语义令牌) */
|
||||
export const SQL_CHECK_TONE: Record<SqlCheckLevel, string> = {
|
||||
error: 'var(--st-failed)',
|
||||
warning: 'var(--st-waiting)',
|
||||
info: 'var(--accent)',
|
||||
}
|
||||
|
||||
const LEVEL_RANK: Record<SqlCheckLevel, number> = { error: 0, warning: 1, info: 2 }
|
||||
|
||||
/** 错误在前,同级保持原来的顺序 */
|
||||
export function sortSqlChecks<T extends { level?: unknown }>(list: T[]): T[] {
|
||||
return list.map((c, i) => ({ c, i }))
|
||||
.sort((a, b) => LEVEL_RANK[sqlCheckLevelOf(a.c.level)] - LEVEL_RANK[sqlCheckLevelOf(b.c.level)] || a.i - b.i)
|
||||
.map((x) => x.c)
|
||||
}
|
||||
|
||||
/** 涉及的表和列:「orders.amount」「orders」 */
|
||||
export const sqlCheckWhere = (c: Pick<SqlCheckItem, 'table' | 'column'>): string =>
|
||||
(c.table ? (c.column ? `${c.table}.${c.column}` : c.table) : (c.column ?? ''))
|
||||
|
||||
/** 从任意对象读一条检查(证据接口的 checks、助手自查和发布前检查的问题);不是 SQL 检查的返回 null */
|
||||
export function sqlCheckOf(v: unknown): SqlCheckItem | null {
|
||||
if (!v || typeof v !== 'object') return null
|
||||
const o = v as Record<string, unknown>
|
||||
if (!isSqlCheckCode(o.code)) return null
|
||||
const str = (x: unknown) => (typeof x === 'string' && x ? x : undefined)
|
||||
return {
|
||||
code: o.code,
|
||||
level: sqlCheckLevelOf(o.level),
|
||||
message: String(o.message ?? ''),
|
||||
table: str(o.table) ?? '',
|
||||
...(str(o.column) ? { column: str(o.column) } : {}),
|
||||
...(str(o.relation_id) ? { relation_id: str(o.relation_id) } : {}),
|
||||
...(str(o.sql_excerpt) ? { sql_excerpt: str(o.sql_excerpt) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 一条检查写成一行(时间线的展开区、交给助手再修的清单):「错误 · 一对多关联后重复计算:……(涉及 orders.amount)」 */
|
||||
export function sqlCheckLine(c: SqlCheckItem): string {
|
||||
const where = sqlCheckWhere(c)
|
||||
return `${SQL_CHECK_LEVEL_LABEL[sqlCheckLevelOf(c.level)]} · ${sqlRuleLabel(c.code)}:${c.message}`
|
||||
+ (where ? `(${SQL_CHECK_TEXT.where} ${where})` : '')
|
||||
}
|
||||
+287
-2
@@ -43,7 +43,8 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
CatalogCardinality, CatalogMeasure, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode, ToolTrust,
|
||||
CatalogCardinality, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode,
|
||||
SqlCheckCode, SqlCheckLevel, ToolTrust,
|
||||
} from '../types'
|
||||
import { formatNumber } from './format'
|
||||
|
||||
@@ -247,6 +248,9 @@ export const EVIDENCE_TEXT = {
|
||||
/** 指标拿截断的查询结果整组算出来(计数、求和……):值只算到了取回的那部分,出具按缺口降档 */
|
||||
incomplete: '结果不完整',
|
||||
incompleteFallback: '基于被截断的查询结果计算,结果不完整',
|
||||
/** 指标所依据的查询对照数据目录查出了错误级的问题:值照算,出具按缺口降档 */
|
||||
sqlCheckFailed: 'SQL 检查未通过',
|
||||
sqlCheckFallback: '所依据的查询未通过 SQL 检查,结果不可靠',
|
||||
/** 输入是 Agent 交来、被截断切开的数组字段 */
|
||||
inputTruncated: '查询结果已截断,该字段只含取回的部分行',
|
||||
unshowable: (value: string) => `有值(${value}),但无法按口径卡的格式显示`,
|
||||
@@ -2127,7 +2131,7 @@ export const CATALOG_TEXT = {
|
||||
keysPlaceholder: '列名,用顿号或逗号分隔',
|
||||
keysUnknown: (cols: string) => `表结构中没有这些列:${cols}`,
|
||||
codesPlaceholder: '1=已支付',
|
||||
codesHint: '每行一个,写成「码值=含义」',
|
||||
codesHint: '每行一个,写成「码值=含义」,含义可暂不填写',
|
||||
codesInvalid: (line: number) => `第 ${formatNumber(line)} 行应写成「码值=含义」`,
|
||||
codesDuplicate: (code: string) => `码值 ${code} 重复`,
|
||||
dateColumnRequired: '填写规则或时区时,日期列必填',
|
||||
@@ -2241,3 +2245,284 @@ export const CATALOG_DRIFT_TEXT = {
|
||||
unchanged: '发布之后这些表的目录没有变化',
|
||||
empty: '这一版的 SQL 没有用到数据库表',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 数据剖析(数据源设置里的开关和预算;数据目录页的「数据剖析」)
|
||||
//
|
||||
// 术语:
|
||||
// - 「数据剖析」:对业务库发少量只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。
|
||||
// 动作写「剖析」,不写扫描、探测。默认关闭,按数据源开启。
|
||||
// - 预算五项:查询次数上限、单条查询时限、抽样键值数、整表统计行数上限、总时长上限。标签和服务端校验报错里的叫法
|
||||
// 一致(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<CatalogProfileNumberKey, string> = {
|
||||
max_queries: '条',
|
||||
query_timeout_s: '秒',
|
||||
sample_size: '个',
|
||||
max_scan_rows: '行',
|
||||
max_total_s: '秒',
|
||||
}
|
||||
|
||||
export const PROFILE_FIELD_HINT: Record<CatalogProfileNumberKey, string> = {
|
||||
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}」的数据剖析设置`,
|
||||
// ---- 数据目录页:范围与确认
|
||||
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: '取消',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 基于数据目录的 SQL 检查(后端 data/sqlcheck.py)
|
||||
//
|
||||
// 术语:
|
||||
// - 「SQL 检查」:对照数据目录找出能执行、数却可能不对的写法。七条规则,界面上写中文规则名,不露规则编号。
|
||||
// - 级别:错误(依据已确认,结果必然有误)、提醒(依据已确认,结果很可能有误)、提示(依据只是推断,仅供参考)。
|
||||
// 颜色走语义令牌:错误 --st-failed,提醒 --st-waiting,提示 --accent。
|
||||
// ===========================================================================
|
||||
|
||||
export const SQL_CHECK_RULE_LABEL: Record<SqlCheckCode, string> = {
|
||||
fanout_sum: '一对多关联后重复计算',
|
||||
stock_summed: '存量跨期加总',
|
||||
join_unconfirmed: '关联条件未经确认',
|
||||
ratio_aggregated: '比率直接加总或平均',
|
||||
missing_valid_filter: '未筛选有效记录',
|
||||
unknown_code: '码值不在码值表中',
|
||||
wrong_date_column: '未按业务日期统计',
|
||||
}
|
||||
|
||||
export const SQL_CHECK_RULE_HINT: Record<SqlCheckCode, string> = {
|
||||
fanout_sum: '沿一对多方向关联后,对「一」那一侧的度量求和或计数,会按明细行数重复计算',
|
||||
stock_summed: '对存量列求和却没有按业务日期分组,不同日期的存量被加在一起',
|
||||
join_unconfirmed: '关联条件对不上数据目录中有确证的关系,可能连错了列',
|
||||
ratio_aggregated: '对比率列直接求和或求平均,得不到正确的比率',
|
||||
missing_valid_filter: '表定义了有效记录条件,查询没有按它筛选',
|
||||
unknown_code: '按码值筛选时用了码值表中没有的值',
|
||||
wrong_date_column: '表定义了业务日期,查询却按另一个时间列分组或筛选',
|
||||
}
|
||||
|
||||
export const SQL_CHECK_LEVEL_LABEL: Record<SqlCheckLevel, string> = {
|
||||
error: '错误',
|
||||
warning: '提醒',
|
||||
info: '提示',
|
||||
}
|
||||
|
||||
export const SQL_CHECK_LEVEL_HINT: Record<SqlCheckLevel, string> = {
|
||||
error: '依据已确认,结果必然有误',
|
||||
warning: '依据已确认,结果很可能有误',
|
||||
info: '依据尚未确认(推断),仅供参考',
|
||||
}
|
||||
|
||||
export const SQL_CHECK_TEXT = {
|
||||
title: 'SQL 检查',
|
||||
/** 规则编号不认识(服务端新加了规则)时的叫法 */
|
||||
unknownRule: 'SQL 检查',
|
||||
listLabel: (n: number) => `SQL 检查发现 ${formatNumber(n)} 处问题`,
|
||||
where: '涉及',
|
||||
excerpt: 'SQL 片段',
|
||||
catalogHint: '检查依据来自数据目录。依据有误时,请在数据目录中修正对应项。',
|
||||
// ---- 运行时间线:指标所依据的查询没通过 SQL 检查(日志 metric_sql_check)
|
||||
metricTitle: (name: string, n = 1) =>
|
||||
(n > 1 ? `指标「${name}」所依据的查询有 ${formatNumber(n)} 处未通过 SQL 检查` : `指标「${name}」所依据的查询未通过 SQL 检查`),
|
||||
metricTitleAnon: '指标所依据的查询未通过 SQL 检查',
|
||||
metricNext: '指标照常计算,但结果不可靠,出具按缺口降档。请在证据面板的查询步骤中查看问题,修改 SQL 后重新运行;'
|
||||
+ '检查依据有误时,请在数据目录中修正对应项',
|
||||
// ---- 助手:搭图时查出的问题
|
||||
counts: (errors: number, warnings: number, infos: number) =>
|
||||
[errors ? `${formatNumber(errors)} 处错误` : '', warnings ? `${formatNumber(warnings)} 处提醒` : '',
|
||||
infos ? `${formatNumber(infos)} 处提示` : ''].filter(Boolean).join('、'),
|
||||
assistantTitle: (counts: string) => `SQL 检查:${counts}`,
|
||||
assistantNext: '请打开对应节点,在「参数」中修改 SQL;检查依据有误时,请在数据目录中修正对应项',
|
||||
assistantNextChat: '可在画布中打开此工作流,修改对应节点「参数」中的 SQL;检查依据有误时,请在数据目录中修正对应项',
|
||||
assistantInfoNext: '这些检查的依据尚未确认,仅供参考。在数据目录中确认相关项后,检查结论更可靠',
|
||||
}
|
||||
|
||||
@@ -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 ? '修改遮罩列' : '设置遮罩列'}
|
||||
</button>
|
||||
)}
|
||||
{profile.enabled && (
|
||||
<span className="chip" data-profile-on="" title={PROFILE_TEXT.chipHint(profile.max_queries, profile.query_timeout_s)}>
|
||||
<ScanSearch size={10} aria-hidden /> {PROFILE_TEXT.chip}
|
||||
</span>
|
||||
)}
|
||||
{uploaded && (
|
||||
// 数据剖析的设置同理:手工登记的库在编辑框里改
|
||||
<button type="button" className="text-2xs underline decoration-dotted underline-offset-2 hover:text-dim"
|
||||
onClick={() => setProfiling(true)} data-edit-profile="">
|
||||
{PROFILE_TEXT.open}
|
||||
</button>
|
||||
)}
|
||||
{!!row.tools?.length && <span className="mono">{row.tools.join(' · ')}</span>}
|
||||
</div>
|
||||
{masking && (
|
||||
<MaskColumnsDialog row={row} onClose={() => setMasking(false)}
|
||||
onSaved={(next) => { setMasking(false); onChange(next); syncCatalog() }} />
|
||||
)}
|
||||
{profiling && (
|
||||
<ProfileSettingsDialog row={row} onClose={() => setProfiling(false)}
|
||||
onSaved={(next) => { setProfiling(false); onChange(next); syncCatalog() }} />
|
||||
)}
|
||||
{versions && (
|
||||
// onChange 是父级的「更新这一行并刷新全局目录」:回滚之后表结构可能变了,检查器、问数据读的目录要跟上
|
||||
<VersionsDialog source={row} onClose={() => 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<string, string | string[]> = {}
|
||||
const options: Record<string, unknown> = {}
|
||||
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<string, string> = {
|
||||
|
||||
/**
|
||||
* 决定连到哪个库的那几项(提交体里的)。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 ? <Spinner size={11} /> : <Plug size={12} aria-hidden />} 测试连接
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={saving || blocked} onClick={() => void save()}
|
||||
title={blocked ? `缺少必填项:${missing.join('、') || '标识格式'}` : undefined}>
|
||||
<button className="btn btn-primary" disabled={saving || saveBlocked} onClick={() => void save()}
|
||||
title={saveHint} data-source-save="">
|
||||
{saving ? <Spinner size={11} /> : null} 保存
|
||||
</button>
|
||||
</>
|
||||
@@ -1318,6 +1364,12 @@ function SourceEditor({ source, kinds, onClose, onSaved }: {
|
||||
<MaskColumnsField sourceId={source.id} synced={!!source.table_count} value={form.maskColumns}
|
||||
onChange={(maskColumns) => set({ maskColumns })} />
|
||||
|
||||
<ProfileSettingsSection idPrefix="ds-profile" form={form.profile} serverError={profileError}
|
||||
onChange={(profile) => {
|
||||
if (profileError) setFieldError(null)
|
||||
set({ profile })
|
||||
}} />
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-lg border p-2.5"
|
||||
style={form.readonly ? undefined : { borderColor: 'var(--warn)', background: 'color-mix(in srgb, var(--warn) 7%, transparent)' }}>
|
||||
<input type="checkbox" className="mt-0.5" checked={form.readonly}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { LineageLayer } from '../canvas/LineageLayer'
|
||||
import { VersionsSheet } from '../canvas/VersionsSheet'
|
||||
import { PublishDialog } from '../canvas/PublishDialog'
|
||||
import { WorkflowPicker, confirmDiscard, createWorkflow, takeDiscarded } from '../canvas/WorkflowPicker'
|
||||
import { problemsOf, type Problem } from '../canvas/issues'
|
||||
import { fieldOfIssue, problemsOf, type Problem } from '../canvas/issues'
|
||||
import { STUDIO_SHORTCUTS, hintOf, studioShortcut, type StudioShortcutId } from '../canvas/shortcuts'
|
||||
import { AssistantPanel } from '../run/AssistantPanel'
|
||||
import { copilotProgress } from '../run/Composer'
|
||||
@@ -620,7 +620,12 @@ export function StudioPage() {
|
||||
{publishing && workflow && (
|
||||
<PublishDialog workflow={workflow} onClose={() => setPublishing(false)}
|
||||
onDone={() => { setPublishing(false); void refresh() }}
|
||||
onLocate={(id) => { select(id); focusNode(id) }} />
|
||||
onLocate={(issue) => {
|
||||
// 和问题面板同一个落点:选中节点、取景,问题落在某一项配置上(调用工具的 SQL)就翻到那一项
|
||||
if (!issue.node_id) return
|
||||
const node = useStudio.getState().nodes.find((n) => n.id === issue.node_id)
|
||||
revealField(issue.node_id, fieldOfIssue(issue, node))
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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, ListFilter as ListFilterIcon, Sparkles } from 'lucide-react'
|
||||
import { ArrowLeft, BookMarked, Database, ListFilter as ListFilterIcon, 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, usageSummary, 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]
|
||||
@@ -116,6 +209,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="">
|
||||
@@ -171,12 +277,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} />}
|
||||
@@ -193,6 +300,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>
|
||||
@@ -213,6 +321,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 whitespace-nowrap rounded px-1 text-2xs text-dim underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
onClick={onFill} aria-label={label} title={KT.pendingN(pending)} data-codes-fill={pending}>
|
||||
<PencilLine size={10} aria-hidden /> {KT.fill}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react'
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type { CatalogProfileNumberKey } from '../../types'
|
||||
import { Field, Modal, Spinner, toast } from '../../components/ui'
|
||||
import { PROFILE_FIELD_HINT, PROFILE_FIELD_LABEL, PROFILE_FIELD_UNIT, PROFILE_TEXT as PT } from '../../lib/terms'
|
||||
import {
|
||||
PROFILE_DEFAULTS, PROFILE_LIMITS, PROFILE_NUMBER_KEYS, hasProfileOption, profileFieldOfRejection, profileFormOf, profileOptionOf,
|
||||
profileProblems, profileSettingsOfForm, sameProfileForm, withProfileOption,
|
||||
} from './profile'
|
||||
import type { ProfileForm } from './profile'
|
||||
|
||||
// ===========================================================================
|
||||
// 数据剖析的设置:开关、五项预算、风险说明。数据源编辑框里是其中一段;导入表格的源没有编辑框,数据目录页在
|
||||
// 剖析被拒(未开启)时也要就地打开它,所以另有一个只改这一项的弹窗(options 的其余键原样带上)。
|
||||
// 剖析会对业务库发查询,开关默认关着;开启后五项预算才展开,风险说明写的是此刻填的数。
|
||||
// ===========================================================================
|
||||
|
||||
/** 保存被拒时的落点:某一项,或者整段(认不出是哪一项) */
|
||||
export type ProfileErrorKey = 'enabled' | CatalogProfileNumberKey | 'section'
|
||||
|
||||
export function ProfileSettingsSection({ idPrefix, form, onChange, serverError }: {
|
||||
/** 输入框 id 的前缀:报错时按 `${idPrefix}-${键}` 聚焦 */
|
||||
idPrefix: string
|
||||
form: ProfileForm
|
||||
onChange: (next: ProfileForm) => void
|
||||
/** 服务端拒收(422)的那一句,落在对应的项上 */
|
||||
serverError?: { key: ProfileErrorKey; message: string } | null
|
||||
}) {
|
||||
const problems = profileProblems(form)
|
||||
const errorOf = (k: CatalogProfileNumberKey) => (serverError?.key === k ? serverError.message : problems[k])
|
||||
// 开关关着时预算收起;但填错的项(或服务端点了名的项)要看得见,不然保存被拦下却找不到原因
|
||||
const open = form.enabled || Object.keys(problems).length > 0
|
||||
|| (!!serverError && serverError.key !== 'enabled' && serverError.key !== 'section')
|
||||
const s = profileSettingsOfForm(form)
|
||||
const setValue = (k: CatalogProfileNumberKey, v: string) => onChange({ ...form, values: { ...form.values, [k]: v } })
|
||||
const switchError = serverError && (serverError.key === 'enabled' || serverError.key === 'section') ? serverError.message : null
|
||||
|
||||
return (
|
||||
<fieldset className="rounded-lg border" data-field="catalog_profile" data-profile-settings={form.enabled ? 'on' : 'off'}>
|
||||
<legend className="sr-only">{PT.section}</legend>
|
||||
<label className="flex cursor-pointer items-start gap-2 p-2.5">
|
||||
<input id={`${idPrefix}-enabled`} type="checkbox" className="mt-0.5" checked={form.enabled}
|
||||
aria-describedby={`${idPrefix}-enabled-hint`} aria-invalid={switchError ? true : undefined}
|
||||
onChange={(e) => onChange({ ...form, enabled: e.target.checked })} data-profile-enabled="" />
|
||||
<span className="min-w-0 text-xs">
|
||||
{PT.section}
|
||||
<span id={`${idPrefix}-enabled-hint`} className="ml-1.5 text-2xs leading-relaxed text-faint">{PT.enableHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
{switchError && (
|
||||
<p className="px-2.5 pb-2 text-2xs leading-relaxed text-[var(--err)]" role="alert" data-profile-error="section">{switchError}</p>
|
||||
)}
|
||||
{open && (
|
||||
<div className="space-y-3 border-t px-2.5 py-2.5">
|
||||
{form.enabled && (
|
||||
<p className="flex items-start gap-1.5 rounded-md px-2 py-1.5 text-2xs leading-relaxed text-dim" data-profile-risk=""
|
||||
style={{ background: 'var(--st-waiting-soft)' }}>
|
||||
<ShieldAlert size={12} className="mt-px shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden />
|
||||
<span>{PT.risk(s.max_queries, s.query_timeout_s, s.max_total_s)}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{PROFILE_NUMBER_KEYS.map((k) => {
|
||||
const { min, max } = PROFILE_LIMITS[k]
|
||||
const unit = PROFILE_FIELD_UNIT[k]
|
||||
const err = errorOf(k)
|
||||
const id = `${idPrefix}-${k}`
|
||||
const hint = `${PT.range(min, max, unit, PROFILE_DEFAULTS[k])}${k === 'max_scan_rows' ? `,${PT.scanZero}` : ''}。${PROFILE_FIELD_HINT[k]}`
|
||||
return (
|
||||
<Field key={k} htmlFor={id} label={PROFILE_FIELD_LABEL[k]} hint={hint} error={err}>
|
||||
{(p) => (
|
||||
<div className="relative" data-profile-field={k}>
|
||||
<input {...p} className="field tnum pr-8" inputMode={PROFILE_LIMITS[k].integer ? 'numeric' : 'decimal'}
|
||||
autoComplete="off" spellCheck={false} value={form.values[k]}
|
||||
placeholder={PT.placeholder(PROFILE_DEFAULTS[k])}
|
||||
style={err ? { borderColor: 'var(--err)' } : undefined}
|
||||
onChange={(e) => setValue(k, e.target.value)} />
|
||||
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-2xs text-faint" aria-hidden>
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
/** 聚焦保存被拒的那一项(预算收着时先等它展开) */
|
||||
export function focusProfileField(idPrefix: string, key: ProfileErrorKey) {
|
||||
const id = key === 'section' ? `${idPrefix}-enabled` : `${idPrefix}-${key}`
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const el = document.getElementById(id)
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
el?.focus({ preventScroll: true })
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 只改数据剖析设置的弹窗:导入表格的源(没有编辑框)、数据目录页里剖析被拒时用。
|
||||
* 保存时 options 的其余键(遮罩列、schema……)原样带上,只换剖析设置这一项
|
||||
*/
|
||||
export function ProfileSettingsDialog({ row, onClose, onSaved }: {
|
||||
row: { id: string; name: string; options?: Record<string, unknown> | null }
|
||||
onClose: () => void
|
||||
onSaved: (row: any) => void
|
||||
}) {
|
||||
const [initial] = useState(() => profileFormOf(row.options))
|
||||
const [form, setForm] = useState<ProfileForm>(initial)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [serverError, setServerError] = useState<{ key: ProfileErrorKey; message: string } | null>(null)
|
||||
const dirty = !sameProfileForm(form, initial)
|
||||
const problems = Object.keys(profileProblems(form)).length
|
||||
const idPrefix = `profile-${row.id}`
|
||||
|
||||
const save = async () => {
|
||||
if (!dirty || problems) return
|
||||
setSaving(true)
|
||||
setServerError(null)
|
||||
try {
|
||||
const options = withProfileOption(row.options, profileOptionOf(form, hasProfileOption(row.options)))
|
||||
const next = await api.datasources.update(row.id, { options })
|
||||
onSaved(next)
|
||||
toast.ok(form.enabled === initial.enabled ? PT.saved(row.name) : form.enabled ? PT.savedOn(row.name) : PT.savedOff(row.name))
|
||||
} catch (e) {
|
||||
const key = e instanceof ApiError && e.status === 422 ? profileFieldOfRejection(e.message) : null
|
||||
if (key) {
|
||||
setServerError({ key, message: e instanceof ApiError ? e.message : '' })
|
||||
focusProfileField(idPrefix, key)
|
||||
} else {
|
||||
toast.error(e)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} dirty={dirty} width={560} title={PT.dialogTitle(row.name)}
|
||||
footer={(
|
||||
<>
|
||||
{problems > 0 && <span className="mr-auto text-2xs text-[var(--err)]" data-profile-invalid={problems}>{PT.invalid(problems)}</span>}
|
||||
<button type="button" className="btn" onClick={onClose}>取消</button>
|
||||
<button type="button" className="btn btn-primary" disabled={saving || !dirty || problems > 0} onClick={() => void save()}
|
||||
data-profile-save="">
|
||||
{saving ? <Spinner size={11} /> : null} 保存
|
||||
</button>
|
||||
</>
|
||||
)}>
|
||||
<div className="space-y-3" data-profile-settings-dialog={row.id}>
|
||||
<p className="text-2xs leading-relaxed text-faint">{PT.dialogBody}</p>
|
||||
<ProfileSettingsSection idPrefix={idPrefix} form={form} serverError={serverError}
|
||||
onChange={(next) => { setForm(next); setServerError(null) }} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -12,13 +12,15 @@ import { useLeaveGuard } from '../../lib/leave'
|
||||
import { formatDateTime, formatNumber } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT,
|
||||
} from '../../lib/terms'
|
||||
import { CatalogImpactList } from '../../components/CatalogImpact'
|
||||
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,
|
||||
@@ -49,13 +51,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
|
||||
@@ -73,6 +77,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
|
||||
@@ -101,6 +107,7 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen
|
||||
setColQuery('')
|
||||
setOnlyPending(false)
|
||||
setLive('')
|
||||
setCodesFor(null)
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
@@ -115,6 +122,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
|
||||
@@ -163,6 +179,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 } } } }))
|
||||
}, [])
|
||||
@@ -368,7 +419,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>
|
||||
@@ -394,6 +445,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>
|
||||
@@ -597,7 +653,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>
|
||||
|
||||
@@ -205,15 +205,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()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { CatalogProfileNumberKey, CatalogProfileSettings } from '../../types'
|
||||
import { PROFILE_FIELD_LABEL, PROFILE_TEXT } from '../../lib/terms'
|
||||
|
||||
// ===========================================================================
|
||||
// 数据剖析的设置:数据源 options.catalog_profile 和表单之间的来回转换、校验、保存被拒时认出是哪一项。
|
||||
// 规则跟着服务端(backend/app/data/catalog_profile.py 的 ProfileSettings、profile_settings、
|
||||
// profile_settings_problem)走:缺省即关闭;读的时候宽松(某一项不对就用缺省),写的时候严格(超出范围的拒收)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 数据源 options 里剖析设置的键 */
|
||||
export const PROFILE_OPTION = 'catalog_profile'
|
||||
|
||||
export const PROFILE_NUMBER_KEYS: readonly CatalogProfileNumberKey[] = [
|
||||
'max_queries', 'query_timeout_s', 'sample_size', 'max_scan_rows', 'max_total_s',
|
||||
]
|
||||
|
||||
/** 缺省值(服务端 ProfileSettings 的默认) */
|
||||
export const PROFILE_DEFAULTS: CatalogProfileSettings = {
|
||||
enabled: false, max_queries: 60, query_timeout_s: 10, sample_size: 2000, max_scan_rows: 100_000, max_total_s: 120,
|
||||
}
|
||||
|
||||
/** 各数值项的范围(服务端 _NUMBER_FIELDS):下限、上限、是否必须是整数 */
|
||||
export const PROFILE_LIMITS: Record<CatalogProfileNumberKey, { min: number; max: number; integer: boolean }> = {
|
||||
max_queries: { min: 1, max: 500, integer: true },
|
||||
query_timeout_s: { min: 1, max: 60, integer: false },
|
||||
sample_size: { min: 10, max: 10_000, integer: true },
|
||||
max_scan_rows: { min: 0, max: 10_000_000, integer: true },
|
||||
max_total_s: { min: 10, max: 600, integer: false },
|
||||
}
|
||||
|
||||
/** 一个数:数字和数字写成的文字都认(同服务端 _number)。不合规返回 null */
|
||||
function numberIn(value: unknown, key: CatalogProfileNumberKey): number | null {
|
||||
if (value == null || typeof value === 'boolean') return null
|
||||
const text = String(value).trim().replace(/,/g, '')
|
||||
if (!text) return null
|
||||
const n = Number(text)
|
||||
const { min, max, integer } = PROFILE_LIMITS[key]
|
||||
if (!Number.isFinite(n) || n < min || n > max) return null
|
||||
if (integer && !Number.isInteger(n)) return null
|
||||
return n
|
||||
}
|
||||
|
||||
const rawOf = (options: Record<string, unknown> | null | undefined): Record<string, unknown> | null => {
|
||||
const raw = options?.[PROFILE_OPTION]
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : null
|
||||
}
|
||||
|
||||
/** options → 生效的设置。宽松:某一项不对用缺省;开关只认 true */
|
||||
export function profileSettingsOf(options: Record<string, unknown> | null | undefined): CatalogProfileSettings {
|
||||
const raw = rawOf(options)
|
||||
const out: CatalogProfileSettings = { ...PROFILE_DEFAULTS, enabled: raw?.enabled === true }
|
||||
if (!raw) return out
|
||||
for (const key of PROFILE_NUMBER_KEYS) {
|
||||
const n = numberIn(raw[key], key)
|
||||
if (n != null) out[key] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 表单:数值项是文字(输入时允许写一半),空着表示用缺省
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProfileForm {
|
||||
enabled: boolean
|
||||
values: Record<CatalogProfileNumberKey, string>
|
||||
}
|
||||
|
||||
export function profileFormOf(options: Record<string, unknown> | null | undefined): ProfileForm {
|
||||
const raw = rawOf(options)
|
||||
const values = Object.fromEntries(PROFILE_NUMBER_KEYS.map((k) => {
|
||||
const v = raw?.[k]
|
||||
return [k, v == null || typeof v === 'object' ? '' : String(v)]
|
||||
})) as Record<CatalogProfileNumberKey, string>
|
||||
return { enabled: raw?.enabled === true, values }
|
||||
}
|
||||
|
||||
/** options 里已经有剖析设置这一项 */
|
||||
export const hasProfileOption = (options: Record<string, unknown> | null | undefined): boolean => !!rawOf(options)
|
||||
|
||||
/**
|
||||
* 表单 → 要写进 options 的设置。原来没有(stored 为假)、现在也没开、一项都没填的不写(不给 options 平白多一个键);
|
||||
* 其余写开关和填了的项。填的不是数时原样交上去,由服务端拒收并说明是哪一项(表单上已经先拦过一次)
|
||||
*/
|
||||
export function profileOptionOf(form: ProfileForm, stored: boolean): Record<string, unknown> | undefined {
|
||||
const filled = PROFILE_NUMBER_KEYS.filter((k) => form.values[k].trim())
|
||||
if (!stored && !form.enabled && !filled.length) return undefined
|
||||
const out: Record<string, unknown> = { enabled: form.enabled }
|
||||
for (const k of filled) {
|
||||
const text = form.values[k].trim().replace(/,/g, '')
|
||||
const n = Number(text)
|
||||
out[k] = Number.isFinite(n) ? n : form.values[k].trim()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 表单的格式问题:键 → 说明。空对象表示可以保存 */
|
||||
export function profileProblems(form: ProfileForm): Partial<Record<CatalogProfileNumberKey, string>> {
|
||||
const out: Partial<Record<CatalogProfileNumberKey, string>> = {}
|
||||
for (const k of PROFILE_NUMBER_KEYS) {
|
||||
if (!form.values[k].trim()) continue
|
||||
if (numberIn(form.values[k], k) == null) {
|
||||
const { min, max, integer } = PROFILE_LIMITS[k]
|
||||
out[k] = PROFILE_TEXT.rangeError(min, max, integer)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 表单此刻对应的设置:没填、填错的项按缺省(风险说明据此写出「最多 N 条」) */
|
||||
export function profileSettingsOfForm(form: ProfileForm): CatalogProfileSettings {
|
||||
const out: CatalogProfileSettings = { ...PROFILE_DEFAULTS, enabled: form.enabled }
|
||||
for (const k of PROFILE_NUMBER_KEYS) {
|
||||
const n = numberIn(form.values[k], k)
|
||||
if (n != null) out[k] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const sameProfileForm = (a: ProfileForm, b: ProfileForm): boolean =>
|
||||
a.enabled === b.enabled && PROFILE_NUMBER_KEYS.every((k) => a.values[k] === b.values[k])
|
||||
|
||||
/**
|
||||
* 服务端保存时拒收的 422(一句中文)说的是剖析设置的哪一项:
|
||||
* 「数据剖析的「查询次数上限」需要填写 1 到 500 之间的整数;当前为「0」」→ max_queries。
|
||||
* 说的是剖析设置、但认不出哪一项(格式不对、有认不出的项)返回 'section';说的不是剖析设置返回 null
|
||||
*/
|
||||
export function profileFieldOfRejection(message: string): 'enabled' | CatalogProfileNumberKey | 'section' | null {
|
||||
if (!message.includes('数据剖析')) return null
|
||||
const named = message.match(/「([^」]+)」/)?.[1] ?? ''
|
||||
for (const k of ['enabled', ...PROFILE_NUMBER_KEYS] as const) {
|
||||
if (named && named.startsWith(PROFILE_FIELD_LABEL[k])) return k
|
||||
}
|
||||
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 ?? {}
|
||||
return value ? { ...rest, [PROFILE_OPTION]: value } : rest
|
||||
}
|
||||
@@ -14,13 +14,16 @@ import { splitShrunk } from '../canvas/copilotMerge'
|
||||
import { confirmDialog, IconButton, StatusBadge, toast } from '../components/ui'
|
||||
import { formatCost, formatLapse, formatOffset, formatSpan, formatTokens, shortId, NONE } from '../lib/format'
|
||||
import { statusLabel } from '../lib/status'
|
||||
import { runClassLabel } from '../lib/terms'
|
||||
import { runClassLabel, SQL_CHECK_TEXT } from '../lib/terms'
|
||||
import { sortSqlChecks } from '../lib/sqlcheck'
|
||||
import type { RunUsage, ToolChange } from '../types'
|
||||
import { AssistantStream, type StreamTurn } from './AssistantStream'
|
||||
import { Composer } from './Composer'
|
||||
import { SqlCheckBody } from './SqlChecks'
|
||||
import { EvidenceHostContext, type EvidenceHost } from './evidenceHost'
|
||||
import {
|
||||
COPILOT_PHASE_TEXT, copilotOutcome, decodeCopilot, decodeRun, exitLabels, issueLine, SELF_CHECK_ROUNDS, type CopilotIssue, type CopilotOutcome,
|
||||
COPILOT_PHASE_TEXT, copilotOutcome, decodeCopilot, decodeRun, exitLabels, issueLine, issueSqlCheck, SELF_CHECK_ROUNDS, type CopilotIssue,
|
||||
type CopilotOutcome,
|
||||
} from './decode'
|
||||
import { catalogPatchesOf } from './catalogPatch'
|
||||
import { ApprovalCard } from './RunPanel'
|
||||
@@ -289,11 +292,13 @@ function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => voi
|
||||
)
|
||||
}
|
||||
const issues = out.check?.status === 'failed' ? out.check.issues : []
|
||||
// 对照数据目录的 SQL 检查:不挡运行的提醒、提示(错误交回模型改过,没改好的在上面那组)
|
||||
const sqlNotes = sortSqlChecks(out.sqlChecks)
|
||||
const { unasked, asked } = toolLossOf(out)
|
||||
// 画布一处没变(unchanged)就没有可撤的:给了按钮,撤掉的是这一轮之前的那一步
|
||||
const undo = source.outcome === 'applied'
|
||||
|| (out.kind === 'built' && source.outcome !== 'reverted' && source.outcome !== 'unchanged')
|
||||
if (!issues.length && !out.skipped.length && !unasked.length && !asked.length) {
|
||||
if (!issues.length && !out.skipped.length && !unasked.length && !asked.length && !sqlNotes.length) {
|
||||
return undo ? (
|
||||
<button type="button" className="btn btn-xs btn-ghost" onClick={() => undoCopilotTurn(source.id)}>
|
||||
<Undo2 size={11} aria-hidden /> 撤销这次生成
|
||||
@@ -306,6 +311,10 @@ function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => voi
|
||||
<div className="w-full space-y-1.5">
|
||||
{/* 没修好的问题直接摊开,不折叠:这是此刻唯一要做的事 */}
|
||||
{issues.length > 0 && <IssueList issues={issues} onLocate={locateIssue} />}
|
||||
{sqlNotes.length > 0 && (
|
||||
<IssueList issues={sqlNotes} onLocate={locateIssue} title={SQL_CHECK_TEXT.title}
|
||||
plain={sqlNotes.every((x) => x.level === 'info')} />
|
||||
)}
|
||||
{(unasked.length > 0 || asked.length > 0) && <ToolLossList unasked={unasked} asked={asked} onLocate={locateTools} />}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{repairable && (
|
||||
@@ -380,28 +389,40 @@ function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => voi
|
||||
)
|
||||
}
|
||||
|
||||
/** 自查没修好的问题,逐条带「定位」。问题落在哪个节点上认得出来才给;写节点名,认不出才写 id */
|
||||
function IssueList({ issues, onLocate }: { issues: CopilotIssue[]; onLocate: (x: CopilotIssue) => void }) {
|
||||
/**
|
||||
* 自查没修好的问题,逐条带「定位」。问题落在哪个节点上认得出来才给;写节点名,认不出才写 id。
|
||||
* 对照数据目录的 SQL 检查另画级别、中文规则名、涉及的表和列、SQL 片段(和证据面板同一套),「定位」落到 SQL 那一项。
|
||||
* title 有值时是不挡运行的那一组(SQL 检查的提醒、提示):全是提示时不用提醒色
|
||||
*/
|
||||
function IssueList({ issues, onLocate, title, plain = false }: {
|
||||
issues: CopilotIssue[]; onLocate: (x: CopilotIssue) => void; title?: string; plain?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ul data-issue-list="" className="space-y-0.5 rounded border px-2 py-1.5 text-2xs leading-relaxed"
|
||||
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))',
|
||||
background: 'var(--st-waiting-soft)' }}>
|
||||
{issues.map((x, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5" data-issue-node={x.nodeId} data-issue-field={x.field}>
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]" title={issueLine(x)}>
|
||||
{x.nodeId && <span className={clsx('text-dim', !x.label && 'mono')}>「{x.label ?? x.nodeId}」</span>}{x.message}
|
||||
</span>
|
||||
{x.nodeId && (
|
||||
<button type="button"
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-dim transition-colors hover:bg-hover hover:text-fg"
|
||||
title={x.field ? '打开该节点的设置,定位到出问题的配置项' : '在画布上定位这个节点'}
|
||||
onClick={() => onLocate(x)}>
|
||||
<Crosshair size={10} aria-hidden /> 定位
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div data-issue-list={title ? 'sql' : ''} className="rounded border px-2 py-1.5 text-2xs leading-relaxed"
|
||||
style={plain ? undefined : { borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))', background: 'var(--st-waiting-soft)' }}>
|
||||
{title && <div className="mb-1 font-medium text-fg">{title}</div>}
|
||||
<ul className="space-y-1">
|
||||
{issues.map((x, i) => {
|
||||
const sql = issueSqlCheck(x)
|
||||
return (
|
||||
<li key={i} className="flex items-start gap-1.5" data-issue-node={x.nodeId} data-issue-field={x.field} data-issue-code={x.code}>
|
||||
<div className="min-w-0 flex-1 [overflow-wrap:anywhere]" title={issueLine(x)}>
|
||||
{x.nodeId && <span className={clsx('text-dim', !x.label && 'mono')}>「{x.label ?? x.nodeId}」</span>}
|
||||
{sql ? <div className="mt-0.5"><SqlCheckBody check={sql} /></div> : x.message}
|
||||
</div>
|
||||
{x.nodeId && (
|
||||
<button type="button"
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-dim transition-colors hover:bg-hover hover:text-fg"
|
||||
title={x.field ? '打开该节点的设置,定位到出问题的配置项' : '在画布上定位这个节点'}
|
||||
onClick={() => onLocate(x)}>
|
||||
<Crosshair size={10} aria-hidden /> 定位
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CatalogImpactList } from '../components/CatalogImpact'
|
||||
import { CATALOG_PATCH_TEXT as T, CATALOG_STATUS_LABEL } from '../lib/terms'
|
||||
import type { CatalogPatchChange } from '../types'
|
||||
import {
|
||||
catalogTableHref, patchSubmit, patchTarget, patchValueText, patchWhere, type CatalogPatch,
|
||||
catalogTableHref, codeMeaning, patchSubmit, patchTarget, patchValueText, patchWhere, type CatalogPatch,
|
||||
} from './catalogPatch'
|
||||
|
||||
// ===========================================================================
|
||||
@@ -211,8 +211,9 @@ function CodesAfter({ before, after }: { before: unknown; after: unknown }) {
|
||||
{entries.map(([k, v], i) => (
|
||||
<span key={k}>
|
||||
{i > 0 && '、'}
|
||||
<span className={clsx(prev[k] !== v && 'font-medium text-[var(--accent)]')} data-code-changed={prev[k] !== v ? k : undefined}>
|
||||
{k}={v}
|
||||
<span className={clsx(prev[k] !== v && 'font-medium text-[var(--accent)]', !String(v ?? '').trim() && 'text-faint')}
|
||||
data-code-changed={prev[k] !== v ? k : undefined}>
|
||||
{k}={codeMeaning(v)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '../lib/evidence'
|
||||
import { humanizeError } from '../lib/errors'
|
||||
import { formatDateTime, formatNumber, NONE, shortId } from '../lib/format'
|
||||
import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, nodeTypeLabel } from '../lib/terms'
|
||||
import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, SQL_CHECK_TEXT, nodeTypeLabel } from '../lib/terms'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence'
|
||||
import type {
|
||||
@@ -29,6 +29,7 @@ import { ArtifactViewer, ResultTable } from './AssistantStream'
|
||||
import { useEvidenceHost } from './evidenceHost'
|
||||
import { EvidenceProvenance } from './EvidenceProvenance'
|
||||
import { CopyChip } from './Markdown'
|
||||
import { SqlCheckList } from './SqlChecks'
|
||||
|
||||
/**
|
||||
* 证据面板:点开报告里的一个片段,看它从哪来。
|
||||
@@ -1109,6 +1110,9 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
|
||||
// 拿截断的查询结果整组算出来的:值是真的算出来的,但只算到了取回的那部分,出具已按缺口降档
|
||||
const incomplete = step?.incomplete === true || entry?.incomplete === true
|
||||
const incompleteReason = step?.incomplete_reason || entry?.incomplete_reason || EVIDENCE_TEXT.incompleteFallback
|
||||
// 所依据的查询对照数据目录查出了错误级的问题:值照算,结果不可靠,出具同样按缺口降档。问题和改法在查询步骤里
|
||||
const sqlFailed = step?.sql_check_failed === true || entry?.sql_check_failed === true
|
||||
const sqlReason = step?.sql_check_reason || entry?.sql_check_reason || EVIDENCE_TEXT.sqlCheckFallback
|
||||
|
||||
return (
|
||||
<Part title={EVIDENCE_TEXT.metric} data-ev-metric="">
|
||||
@@ -1121,6 +1125,12 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
|
||||
{EVIDENCE_TEXT.incomplete}
|
||||
</span>
|
||||
)}
|
||||
{sqlFailed && (
|
||||
<span className="chip" data-ev-sql-check-failed="" title={sqlReason}
|
||||
style={{ color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' }}>
|
||||
{EVIDENCE_TEXT.sqlCheckFailed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(caliber || version || artifact || from) && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-2xs text-dim">
|
||||
@@ -1139,6 +1149,9 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
|
||||
{incomplete && (
|
||||
<p className="mt-1" style={{ color: 'var(--st-waiting)' }} data-ev-incomplete-reason="">{incompleteReason}</p>
|
||||
)}
|
||||
{sqlFailed && (
|
||||
<p className="mt-1" style={{ color: 'var(--st-waiting)' }} data-ev-sql-check-reason="">{sqlReason}</p>
|
||||
)}
|
||||
{unshowable && (
|
||||
<p className="mt-1" style={{ color: 'var(--st-waiting)' }} data-ev-unshowable="">
|
||||
{EVIDENCE_TEXT.unshowable(valueText(value))}
|
||||
@@ -1366,6 +1379,13 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep;
|
||||
{step.sql}
|
||||
</pre>
|
||||
)}
|
||||
{!!step.checks?.length && (
|
||||
// 这次查询对照数据目录查出的问题:说明、涉及的表和列、SQL 片段。没查出问题时接口不给这个键
|
||||
<div className="mb-1.5" data-ev-sql-checks="">
|
||||
<div className="mb-0.5 text-2xs text-faint">{SQL_CHECK_TEXT.title}</div>
|
||||
<SqlCheckList checks={step.checks} />
|
||||
</div>
|
||||
)}
|
||||
{!!step.merge?.warnings.length && (
|
||||
<div className="mb-1.5 text-2xs" data-ev-merge-warnings="">
|
||||
<div className="mb-0.5 text-faint">{MERGE_TEXT.executionWarnings}</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Info, OctagonX, TriangleAlert } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { SqlCheckItem, SqlCheckLevel } from '../types'
|
||||
import { SQL_CHECK_LEVEL_HINT, SQL_CHECK_LEVEL_LABEL, SQL_CHECK_RULE_HINT, SQL_CHECK_TEXT } from '../lib/terms'
|
||||
import { SQL_CHECK_TONE, isSqlCheckCode, sortSqlChecks, sqlCheckLevelOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
|
||||
|
||||
// ===========================================================================
|
||||
// SQL 检查的结果:级别标识(错误、提醒、提示,四通道:图标、文字、颜色、边框)、中文规则名、给人看的说明、涉及的表和列、
|
||||
// SQL 片段。证据面板的查询步骤和助手面板共用这一份。
|
||||
// ===========================================================================
|
||||
|
||||
const LEVEL_ICON: Record<SqlCheckLevel, LucideIcon> = { error: OctagonX, warning: TriangleAlert, info: Info }
|
||||
|
||||
/** 级别标识:图标 + 「错误 / 提醒 / 提示」,悬停写明依据的确证程度 */
|
||||
export function SqlLevelBadge({ level: raw, className }: { level: unknown; className?: string }) {
|
||||
const level = sqlCheckLevelOf(raw)
|
||||
const Icon = LEVEL_ICON[level]
|
||||
const tone = SQL_CHECK_TONE[level]
|
||||
return (
|
||||
<span className={clsx('inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full border px-1.5 text-2xs leading-[16px]', className)}
|
||||
style={{ color: tone, borderColor: `color-mix(in srgb, ${tone} 45%, var(--border))`, background: `color-mix(in srgb, ${tone} 8%, transparent)` }}
|
||||
title={SQL_CHECK_LEVEL_HINT[level]} data-sql-level={level}>
|
||||
<Icon size={10} aria-hidden />
|
||||
{SQL_CHECK_LEVEL_LABEL[level]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条检查的正文:规则名、说明、涉及的表和列、SQL 片段 */
|
||||
export function SqlCheckBody({ check: c, excerpt = true }: { check: SqlCheckItem; excerpt?: boolean }) {
|
||||
const where = sqlCheckWhere(c)
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
<SqlLevelBadge level={c.level} />
|
||||
<span className="font-medium text-fg" title={isSqlCheckCode(c.code) ? SQL_CHECK_RULE_HINT[c.code] : undefined} data-sql-rule="">
|
||||
{sqlRuleLabel(c.code)}
|
||||
</span>
|
||||
{where && (
|
||||
<span className="text-faint" data-sql-where={where}>
|
||||
{SQL_CHECK_TEXT.where} <span className="mono text-dim [overflow-wrap:anywhere]">{where}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c.message && <p className="mt-0.5 leading-relaxed text-dim [overflow-wrap:anywhere]" data-sql-message="">{c.message}</p>}
|
||||
{excerpt && c.sql_excerpt && (
|
||||
<pre className="mono mt-1 max-h-20 overflow-auto whitespace-pre-wrap rounded bg-bg px-1.5 py-1 leading-relaxed text-dim [overflow-wrap:anywhere]"
|
||||
aria-label={SQL_CHECK_TEXT.excerpt} title={SQL_CHECK_TEXT.excerpt} data-sql-excerpt="">
|
||||
{c.sql_excerpt}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 检查结果的清单:错误在前。每条一个淡色框,框的颜色跟级别走 */
|
||||
export function SqlCheckList({ checks, className }: { checks: SqlCheckItem[]; className?: string }) {
|
||||
const list = sortSqlChecks(checks)
|
||||
return (
|
||||
<ul className={clsx('space-y-1 text-2xs', className)} aria-label={SQL_CHECK_TEXT.listLabel(list.length)} data-sql-checks={list.length}>
|
||||
{list.map((c, i) => {
|
||||
const tone = SQL_CHECK_TONE[sqlCheckLevelOf(c.level)]
|
||||
return (
|
||||
<li key={`${c.code}:${c.table}:${c.column ?? ''}:${i}`} className="rounded border px-2 py-1.5"
|
||||
style={{ borderColor: `color-mix(in srgb, ${tone} 30%, var(--border))`, background: `color-mix(in srgb, ${tone} 5%, transparent)` }}
|
||||
data-sql-check={c.code} data-level={sqlCheckLevelOf(c.level)}>
|
||||
<SqlCheckBody check={c} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CatalogBusinessDate, CatalogCardinality, CatalogMeasure, CatalogPatchChange, CatalogTableKind } from '../types'
|
||||
import {
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_COLUMN_FIELD_LABEL, CATALOG_KIND_LABEL, CATALOG_MEASURE_LABEL, CATALOG_PATCH_TEXT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT, CODES_TEXT,
|
||||
} from '../lib/terms'
|
||||
|
||||
// ===========================================================================
|
||||
@@ -108,10 +108,13 @@ export function relationText(v: unknown): string {
|
||||
return `${left} → ${right}${card ? `(${card})` : ''}`
|
||||
}
|
||||
|
||||
/** 码值写成一行:1=有效、9=作废 */
|
||||
/** 码值的含义:空串是数据剖析写进来的候选,含义还要人填,和目录页同一个说法 */
|
||||
export const codeMeaning = (x: unknown) => (String(x ?? '').trim() ? String(x) : CODES_TEXT.pending)
|
||||
|
||||
/** 码值写成一行:1=有效、9=作废、8=含义待填写 */
|
||||
export function codesText(v: unknown): string {
|
||||
if (!v || typeof v !== 'object') return String(v)
|
||||
return Object.entries(v as Record<string, string>).map(([k, x]) => `${k}=${x}`).join('、')
|
||||
return Object.entries(v as Record<string, string>).map(([k, x]) => `${k}=${codeMeaning(x)}`).join('、')
|
||||
}
|
||||
|
||||
/** 一项的值写成一行字(过程里的明细、卡片里的改前改后都用它)。没有值写「未填写」 */
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { CatalogDriftTable, RunEvent, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
|
||||
import type { CatalogDriftTable, RunEvent, SqlCheckItem, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
|
||||
import type { NodeState, RunPhase } from './trace'
|
||||
import { formatDuration, formatNumber } from '../lib/format'
|
||||
import {
|
||||
CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel,
|
||||
CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, claimTally, evidenceTally,
|
||||
issuanceLabel, nodeTypeLabel,
|
||||
} from '../lib/terms'
|
||||
import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch'
|
||||
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
|
||||
import { isSqlCheckCode, sortSqlChecks, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
|
||||
|
||||
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
|
||||
// types.ts 里;这里再导出一遍,老引用不用改
|
||||
@@ -778,6 +780,20 @@ function explainLog(code: string | undefined, message: string): Pick<Step, 'titl
|
||||
fix: 'canvas',
|
||||
}
|
||||
}
|
||||
case 'metric_sql_check': {
|
||||
// 「指标「客单价」所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会
|
||||
// 重复计算),结果不可靠」;几处时是「所依据的查询有 2 处未通过 SQL 检查(…等),结果不可靠」(metrics.py
|
||||
// _sql_check_reason)。括号里的问题本身可能带括号:取到「,结果不可靠」前面最后一个右括号
|
||||
const name = message.match(/^指标「(.+?)」/)?.[1]
|
||||
const n = message.match(/有\s*(\d+)\s*处未通过 SQL 检查/)?.[1]
|
||||
const why = message.match(/未通过 SQL 检查(([\s\S]+))[,,]\s*结果不可靠\s*$/)?.[1]
|
||||
return {
|
||||
title: name ? SQL_CHECK_TEXT.metricTitle(name, n ? Number(n) : 1) : SQL_CHECK_TEXT.metricTitleAnon,
|
||||
sub: why,
|
||||
next: SQL_CHECK_TEXT.metricNext,
|
||||
fix: 'canvas',
|
||||
}
|
||||
}
|
||||
case 'judge_limit': {
|
||||
// 「结论句裁判已到上限(这份报告的裁判金额上限 $0.05):3 句没判,记为未裁判;已判的保留」(judge.py run_request)
|
||||
const n = message.match(/[::]\s*(\d+)\s*句(?:没判|未裁判)/)?.[1]
|
||||
@@ -2452,8 +2468,14 @@ export interface CopilotIssue {
|
||||
label?: string
|
||||
/** 落在节点的哪一项配置上(condition、tools、agents[1].tools):「定位」据此聚焦到检查器里那个输入框 */
|
||||
field?: string
|
||||
/** 机读代号:datasource_out_of_scope、tools_dropped…… */
|
||||
/** 机读代号:datasource_out_of_scope、tools_dropped……;SQL 检查是规则编号(fanout_sum……) */
|
||||
code?: string
|
||||
/** error / warning / info。老会话、老后端不给 */
|
||||
level?: 'error' | 'warning' | 'info'
|
||||
/** SQL 检查的问题另带:涉及的表和列、SQL 片段 */
|
||||
table?: string
|
||||
column?: string
|
||||
sqlExcerpt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2468,11 +2490,16 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
|
||||
}
|
||||
if (v && typeof v === 'object') {
|
||||
const o = v as Record<string, unknown>
|
||||
const str = (x: unknown) => (typeof x === 'string' && x ? x : undefined)
|
||||
return named({
|
||||
message: String(o.message ?? ''),
|
||||
...(o.node_id ? { nodeId: String(o.node_id) } : {}),
|
||||
...(typeof o.field === 'string' && o.field ? { field: o.field } : {}),
|
||||
...(typeof o.code === 'string' && o.code ? { code: o.code } : {}),
|
||||
...(str(o.field) ? { field: str(o.field) } : {}),
|
||||
...(str(o.code) ? { code: str(o.code) } : {}),
|
||||
...(o.level === 'error' || o.level === 'warning' || o.level === 'info' ? { level: o.level } : {}),
|
||||
...(str(o.table) ? { table: str(o.table) } : {}),
|
||||
...(str(o.column) ? { column: str(o.column) } : {}),
|
||||
...(str(o.sql_excerpt) ? { sqlExcerpt: str(o.sql_excerpt) } : {}),
|
||||
})
|
||||
}
|
||||
const text = String(v ?? '')
|
||||
@@ -2480,9 +2507,37 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
|
||||
return named(m ? { nodeId: m[1], message: m[2] || text } : { message: text })
|
||||
}
|
||||
|
||||
/** 一条自查问题写成一行:带节点名(认得出时),认不出才退到 id */
|
||||
export const issueLine = (x: CopilotIssue): string =>
|
||||
x.nodeId ? `「${x.label ?? x.nodeId}」${x.message}` : x.message
|
||||
/**
|
||||
* 一条自查问题写成一行:带节点名(认得出时),认不出才退到 id。SQL 检查的问题前面写中文规则名、后面写涉及的表和列,
|
||||
* 不露规则编号
|
||||
*/
|
||||
export const issueLine = (x: CopilotIssue): string => {
|
||||
const where = isSqlCheckCode(x.code) ? sqlCheckWhere({ table: x.table ?? '', column: x.column }) : ''
|
||||
const body = isSqlCheckCode(x.code)
|
||||
? `${sqlRuleLabel(x.code)}:${x.message}${where ? `(${SQL_CHECK_TEXT.where} ${where})` : ''}`
|
||||
: x.message
|
||||
return x.nodeId ? `「${x.label ?? x.nodeId}」${body}` : body
|
||||
}
|
||||
|
||||
/** 一条自查问题按 SQL 检查读(界面上画级别、规则名、表和列、SQL 片段);不是 SQL 检查的返回 null */
|
||||
export function issueSqlCheck(x: CopilotIssue): SqlCheckItem | null {
|
||||
if (!isSqlCheckCode(x.code)) return null
|
||||
return {
|
||||
code: x.code, level: x.level ?? 'error', message: x.message, table: x.table ?? '',
|
||||
...(x.column ? { column: x.column } : {}), ...(x.sqlExcerpt ? { sql_excerpt: x.sqlExcerpt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** final.issues 里对照数据目录的 SQL 检查结果(不挡运行的提醒、提示也在里面);自查没修好、已经列过的错误不再算 */
|
||||
function sqlIssuesOf(ops: CopilotOp[], labels: (id: string) => string | undefined): CopilotIssue[] {
|
||||
const final = [...ops].reverse().find((o) => o.op === 'final')
|
||||
const list: unknown[] = final && Array.isArray(final.issues) ? final.issues : []
|
||||
const failed = [...ops].reverse().find((o) => o.op === 'check' && o.status === 'failed')
|
||||
const listed = new Set((failed && Array.isArray(failed.issues) ? failed.issues : [])
|
||||
.map((x: unknown) => parseIssue(x)).map((x: CopilotIssue) => `${x.nodeId ?? ''}|${x.code ?? ''}|${x.message}`))
|
||||
return list.map((x) => parseIssue(x, labels))
|
||||
.filter((x) => isSqlCheckCode(x.code) && !listed.has(`${x.nodeId ?? ''}|${x.code ?? ''}|${x.message}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点 id → 名字。先认这一轮操作流里加过、改过的(问数据页没有画布,只有这一份),
|
||||
@@ -2817,6 +2872,23 @@ export function decodeCopilot(ops: CopilotOp[], opts?: {
|
||||
add({ id: `cm-${i}-${t}`, seq: i, kind: 'note', status: 'done', level: 'warn',
|
||||
title: clip(`已跳过一个步骤:节点类型「${t}」不存在`, 80) })
|
||||
}
|
||||
// 对照数据目录的 SQL 检查:错误交回模型改过(没改好的在自查那一行),不挡运行的提醒、提示照样要看得见
|
||||
const sql = sqlIssuesOf(ops.slice(0, i), labels)
|
||||
if (sql.length) {
|
||||
const n = { error: 0, warning: 0, info: 0 }
|
||||
for (const x of sql) n[x.level ?? 'error'] += 1
|
||||
const nodes = [...new Set(sql.map((x) => x.nodeId).filter(Boolean))]
|
||||
add({
|
||||
id: `cs-${i}`, seq: i, kind: 'note', status: 'done', code: 'sql_check',
|
||||
...(n.error || n.warning ? { level: 'warn' as const } : {}),
|
||||
title: SQL_CHECK_TEXT.assistantTitle(SQL_CHECK_TEXT.counts(n.error, n.warning, n.info)),
|
||||
detail: sortSqlChecks(sql).map(issueLine).join('\n'),
|
||||
// 问数据页没有画布:下一步说去画布里打开这个工作流
|
||||
next: n.error || n.warning ? (canvas ? SQL_CHECK_TEXT.assistantNext : SQL_CHECK_TEXT.assistantNextChat)
|
||||
: SQL_CHECK_TEXT.assistantInfoNext,
|
||||
...(nodes.length === 1 ? { nodeId: nodes[0], ...(canvas ? { fix: 'canvas' as const } : {}) } : {}),
|
||||
})
|
||||
}
|
||||
// 工具绑定变化单独成一行:改图回执以前只说「修改 1」,模型改提示词时漏写 tools、
|
||||
// 把查库的工具整个抹掉,要到运行结果不对才发现
|
||||
const changes = toolChangesOf(op)
|
||||
@@ -2913,6 +2985,8 @@ export interface CopilotOutcome {
|
||||
toolChanges: ToolChange[]
|
||||
/** 自查的「工具被去掉了」警告(tools_dropped):这一轮的要求里没提到去掉 */
|
||||
dropped: CopilotIssue[]
|
||||
/** 对照数据目录的 SQL 检查结果(final.issues),自查没修好、已经列在 check 里的不重复 */
|
||||
sqlChecks: CopilotIssue[]
|
||||
}
|
||||
|
||||
export function copilotOutcome(
|
||||
@@ -2920,7 +2994,7 @@ export function copilotOutcome(
|
||||
): CopilotOutcome {
|
||||
const res: CopilotOutcome = {
|
||||
kind: running ? 'running' : 'empty', skipped: [], added: 0, updated: 0, removed: 0,
|
||||
toolChanges: [], dropped: [],
|
||||
toolChanges: [], dropped: [], sqlChecks: [],
|
||||
}
|
||||
const labels = labelsOf(ops, labelOf)
|
||||
for (const op of ops) {
|
||||
@@ -2952,6 +3026,7 @@ export function copilotOutcome(
|
||||
res.toolChanges = toolChangesOf(op)
|
||||
// 同一批警告 final.issues 里也有一份:自查已经带回来了就不再算一遍
|
||||
if (!res.dropped.length) res.dropped = droppedOf(op)
|
||||
res.sqlChecks = sqlIssuesOf(ops, labels)
|
||||
if (!running) res.kind = 'built'
|
||||
break
|
||||
case 'error':
|
||||
|
||||
@@ -2147,6 +2147,12 @@ export interface EvidenceStep extends Omit<EvidenceInput, 'status'> {
|
||||
incomplete?: boolean
|
||||
/** metric:不完整的原因(人话,「基于被截断的查询结果计算(只取回了前 1000 行),结果不完整」) */
|
||||
incomplete_reason?: string
|
||||
/** metric:所依据的查询没通过 SQL 检查(有错误级的问题),值照算,出具已按缺口降档 */
|
||||
sql_check_failed?: boolean
|
||||
/** metric:没通过的原因(人话,「所依据的查询未通过 SQL 检查(…),结果不可靠」) */
|
||||
sql_check_reason?: string
|
||||
/** query:这次查询对照数据目录查出的问题。没查出问题时没有这个键 */
|
||||
checks?: SqlCheckItem[]
|
||||
inputs?: EvidenceInput[]
|
||||
/** 口径卡钉在哪个工作流的哪一版(metric;方案第 5 节的写法,接口实际给在 source 上) */
|
||||
caliber_from?: EvidenceCaliberSource | null
|
||||
@@ -2786,3 +2792,138 @@ export interface CatalogImpact {
|
||||
/** 直接引用的在前 */
|
||||
templates: CatalogImpactTemplate[]
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 数据剖析(后端 app/data/catalog_profile.py):对业务库发少量只读查询,核实推断的关联关系、取码值候选、
|
||||
// 提议业务日期。默认关闭,按数据源在 options.catalog_profile 里开启。
|
||||
// ===========================================================================
|
||||
|
||||
/** 剖析设置里的数值项 */
|
||||
export type CatalogProfileNumberKey = 'max_queries' | 'query_timeout_s' | 'sample_size' | 'max_scan_rows' | 'max_total_s'
|
||||
|
||||
/** 一个数据源的剖析设置(服务端 ProfileSettings.to_dict) */
|
||||
export interface CatalogProfileSettings {
|
||||
enabled: boolean
|
||||
max_queries: number
|
||||
query_timeout_s: number
|
||||
sample_size: number
|
||||
/** 0 表示一律不做整表统计 */
|
||||
max_scan_rows: number
|
||||
max_total_s: number
|
||||
}
|
||||
|
||||
/** 整次剖析中途停下的原因:查询次数用完、总时长用完、连续多条查询失败 */
|
||||
export type CatalogProfileStop = 'budget' | 'deadline' | 'failed'
|
||||
|
||||
/** 一张表的行数:stats 数据库的统计信息(估算);count 数到上限为止;unknown 没能得到 */
|
||||
export interface CatalogProfileSize {
|
||||
rows: number | null
|
||||
method: 'stats' | 'count' | 'unknown' | (string & {})
|
||||
/** 数到上限也没数完:至少这么多行 */
|
||||
at_least: number | null
|
||||
}
|
||||
|
||||
/** 关系的核对结论:覆盖率、基数,升为已验证(verified)或保持推断(proposed);人工确认过的只补覆盖率和基数 */
|
||||
export interface CatalogProfileRelationFinding {
|
||||
kind: 'relation'
|
||||
path: string
|
||||
target: string
|
||||
columns: string[]
|
||||
to_table: string
|
||||
to_columns: string[]
|
||||
status: CatalogStatus
|
||||
confirmed: boolean
|
||||
coverage: number
|
||||
cardinality: CatalogCardinality | null
|
||||
/** 抽了几个不同的键值、在被指向表里对上几个 */
|
||||
sample: number
|
||||
matched: number
|
||||
summary: string
|
||||
}
|
||||
|
||||
/** 码值候选:观察到的取值和行数,含义留给人填 */
|
||||
export interface CatalogProfileCodesFinding {
|
||||
kind: 'codes'
|
||||
path: string
|
||||
column: string
|
||||
values: { value: string; rows: number }[]
|
||||
rows: number
|
||||
status: CatalogStatus
|
||||
summary: string
|
||||
}
|
||||
|
||||
/** 业务日期提议:表里只有一个日期类列 */
|
||||
export interface CatalogProfileDateFinding {
|
||||
kind: 'business_date'
|
||||
path: string
|
||||
column: string
|
||||
min: string
|
||||
max: string
|
||||
status: CatalogStatus
|
||||
summary: string
|
||||
}
|
||||
|
||||
export type CatalogProfileFinding = CatalogProfileRelationFinding | CatalogProfileCodesFinding | CatalogProfileDateFinding
|
||||
|
||||
/** 没做的一项。detail 是可以直接显示的整句 */
|
||||
export interface CatalogProfileSkip {
|
||||
/** relation / codes / date / row_estimate / table */
|
||||
kind: string
|
||||
target: string
|
||||
path: string | null
|
||||
/** budget / deadline / failed / timeout / error / rejected / masked / too_large / view / unsupported / no_data / missing / high_cardinality */
|
||||
reason: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface CatalogProfileTable {
|
||||
table_name: string
|
||||
queries: number
|
||||
row_estimate: CatalogProfileSize | null
|
||||
findings: CatalogProfileFinding[]
|
||||
skipped: CatalogProfileSkip[]
|
||||
/** 日期类列的取值范围 */
|
||||
date_ranges: { column: string; min: string; max: string }[]
|
||||
added: number
|
||||
updated: number
|
||||
removed: number
|
||||
version: number
|
||||
/** 这张表没剖析(表结构里没有)或结果没写进去(写入一直冲突) */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** POST /datasources/{id}/catalog/profile */
|
||||
export interface CatalogProfileOut {
|
||||
profiled_at: string
|
||||
actor: string | null
|
||||
settings: CatalogProfileSettings
|
||||
queries_used: number
|
||||
stopped: CatalogProfileStop | null
|
||||
tables: CatalogProfileTable[]
|
||||
total: { added: number; updated: number; removed: number }
|
||||
/** 一张表都没有剖析时的说明 */
|
||||
note: string | null
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 基于数据目录的 SQL 检查(后端 app/data/sqlcheck.py):七条规则,编号稳定
|
||||
// ===========================================================================
|
||||
|
||||
export type SqlCheckCode = 'fanout_sum' | 'stock_summed' | 'join_unconfirmed' | 'ratio_aggregated' | 'missing_valid_filter'
|
||||
| 'unknown_code' | 'wrong_date_column'
|
||||
|
||||
/** error:依据有确证、结果必然有误;warning:依据有确证、很可能有误;info:依据只是推断 */
|
||||
export type SqlCheckLevel = 'error' | 'warning' | 'info'
|
||||
|
||||
/** 一条检查结果(证据接口查询步骤里的 checks、助手自查和发布前检查的问题)。交回模型改写用的那句不上界面,这里不收 */
|
||||
export interface SqlCheckItem {
|
||||
code: SqlCheckCode | (string & {})
|
||||
level: SqlCheckLevel | (string & {})
|
||||
/** 给人看的说明 */
|
||||
message: string
|
||||
/** schema_cache 里的表名 */
|
||||
table: string
|
||||
column?: string
|
||||
relation_id?: string
|
||||
sql_excerpt?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user