合入集成分支(阶段 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:
ThearchyHelios
2026-10-03 12:35:47 +08:00
51 changed files with 4803 additions and 147 deletions
+9 -1
View File
@@ -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: {
+19
View File
@@ -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) => {
+6 -2
View File
@@ -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 />}
+1
View File
@@ -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)}
+65
View File
@@ -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
View File
@@ -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: '这些检查的依据尚未确认,仅供参考。在数据目录中确认相关项后,检查结论更可靠',
}
+65 -13
View File
@@ -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}
+7 -2
View File
@@ -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>
)
+135 -5
View File
@@ -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>
)
}
+39 -16
View File
@@ -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>
)
}
+63 -5
View File
@@ -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 }}
+10 -3
View File
@@ -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>
+6 -3
View File
@@ -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()
+175
View File
@@ -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
}
+45 -24
View File
@@ -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>
)
}
+4 -3
View File
@@ -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>
))}
+21 -1
View File
@@ -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>
+74
View File
@@ -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>
)
}
+6 -3
View File
@@ -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('、')
}
/** 一项的值写成一行字(过程里的明细、卡片里的改前改后都用它)。没有值写「未填写」 */
+84 -9
View File
@@ -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':
+141
View File
@@ -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
}