Commit 1c07dd8c authored by Data Governance Dev's avatar Data Governance Dev

feat(web): 检查项加 info icon + hover 4 段式说明(作用/查什么/规则/格式)

要点:

- 后端:StepDetail 从 3 段扩到 4 段(加 check 段,校验规则细节)
- BaseStandard 加可选 check_detail 字段(子类可覆写,默认空)
- 4 个基础 step 手填 4 段详细说明(含步骤编号 / 阈值 / 处理策略)
- 24 个 indicator 通过 _derive_check() 派生:每个 indicator 一段
  「业务背景 + 校验要点」摘要(含正则 / MOD 算法 / 号段表 /
  国标号 / 业务标准引用)
- 前端:每个 leaf 行 checkbox 后加灰色圆 i 图标,hover 弹出 4 段
  tooltip,规则段用 <pre> 暗色等宽字体渲染多行

修两个 root cause:

1. <span class="tree-caret-spacer" /> 自闭合写法在 Vue 模板里被吞——
   所有后续兄弟节点(el-checkbox / el-tooltip / info-icon)全被塞进
   20px 宽的 spacer 里 overflow:hidden 截掉
   → 改为显式 <span class="tree-caret-spacer"></span>

2. flatCheckRows push leaf 时没透出 c.detail,<el-tooltip v-if="row.detail">
   永远不渲染 → 加 detail: c.detail || null

验证:jsdom + 真 Element Plus 跑 leaf 段渲染,info-icon + tooltip
内容完整;后端 4 段全填(merge_redundancy / IND-001-a / IND-301 /
IND-502 等抽样验证)。

兼容性:StepDetail.check / BaseStandard.check_detail 都是 Optional,
旧调用 register_step 不传 detail 也不会报错。
parent 01a2d9b0
......@@ -2,6 +2,162 @@
> 任务做完一次记一次。最近的在最上面。
## 2026-08-10 · 检查项 info icon:扩 4 段 + 修两个 root cause
### 用户原话(这一轮两个迭代)
> 在每一个检查项目后面都加一个 info 的 icon,鼠标放上去的时候显示这个检查的作用,查哪些对象,检查格式是什么样的等信息
> 内容太简单了,要写详细一些
### 改动总览
#### 1. 后端元数据:StepDetail 4 段
[web/core/orchestrator.py:48-60](web/core/orchestrator.py#L48-L60) `StepDetail` 从 3 段扩到 4 段:
- `purpose` 业务目标 + 产出形式
- `target` 查哪些字段 / 表 / 注释关键字
- `check` 校验规则细节(多行 / 阈值 / 算法摘要)
- `format` 检查依据的标准 / 算法 / 阈值
#### 2. 数据来源
- 4 个基础 step(merge_redundancy / empty_fields / missing_comments / length_check)手填 4 段,每段多行详细说明(含步骤编号 + 阈值 + 阈值处理)
- 24 个 indicator 在 [web/core/orchestrator.py:226-291](web/core/orchestrator.py#L226-L291) 新增 `_derive_check()`:每个 indicator 一段「业务背景 + 校验要点」摘要(含正则 / MOD 算法 / 号段表 / 国标号 / 业务标准引用)
- IND-001-a/b/c/d → 身份证正则 + ISO 7064 MOD 11-2 + 日期合法性 + 15 位兼容
- IND-002-a/b → GB 32100-2015 31 字符集 + MOD 31-3
- IND-003-a/b → 工信部 11 位正则 + 号段表
- IND-004-a/b → GB/T 2260 区码格式 + 存在性
- IND-101/201/202/203/204 → 5 值枚举 + 各证件号码格式
- IND-301/302 → 跨字段 + 跨表判重
- IND-401/402/501/502 → 姓名 + 出生日期年龄范围
- IND-601/602/603 → GB/T 3304 / 2261 系列
- [standards/base.py:65](standards/base.py#L65) `BaseStandard` 加可选 `check_detail` 字段(子类可覆写,默认空 → orchestrator 走 _derive_check)
#### 3. API 透出
[web/api/routes.py](web/api/routes.py) `/api/analysis-tree` 把 `check` 段加到每条 leaf(主分组 + ungrouped 兜底都加)。
#### 4. 前端 4 段 tooltip
- [web/static/index.html:295-298](web/static/index.html#L295-L298) 加 `step-detail-row--block` + `<pre class="step-detail-pre">` 渲染多行规则
- [web/static/style.css:319-348](web/static/style.css#L319-L348) `.step-detail-pre`:暗色等宽字体 + 圆角 + 白灰色文字;`.step-detail` max-width 360 → 420
- [web/static/index.html:691](web/static/index.html#L691) `flatCheckRows` push leaf 时透出 `c.detail`(否则 v-if="row.detail" 不渲染)
### 关键 root cause(这次踩了两个)
#### root cause 1:`<span class="tree-caret-spacer" />` 自闭合吞掉后续兄弟
Vue 模板编译器把 `<span ... />` 当自闭合解析,但浏览器实际渲染时 `<span>` 不是 void 元素,后续兄弟节点(el-checkbox / el-tooltip / info-icon)全被吞进 tree-caret-spacer 里。
而 tree-caret-spacer CSS 是 `width: 20px`(占位对齐用),所有内容被挤在 20px 容器里 `overflow: hidden` 截掉。
**修复**:[web/static/index.html:269](web/static/index.html#L269) — `<span class="tree-caret-spacer" />` → `<span class="tree-caret-spacer"></span>` 显式闭合
**验证**:grep 全文件 `<span .../>` / `<div .../>` 自闭合 = 0 处
**用户反馈 HTML 验证**:`<div class="tree-row tree-row--leaf" style="padding-left: 32px;"><span class="tree-caret-spacer"><label class="el-checkbox is-checked">...</label><!----></span></div>` —— 用户的 DevTools 截图证实
#### root cause 2:`flatCheckRows` 没透出 `c.detail`
`<el-tooltip v-if="row.detail">` 因为 row.detail 永远是 undefined,整个 tooltip + info-icon 都不渲染。
**修复**:[web/static/index.html:691](web/static/index.html#L691) — push leaf 时加 `detail: c.detail || null`
### 验证
- `python -c "from web.core.orchestrator import get_step_defs; ..."` — 4 段全填
- `curl /api/analysis-tree` — 28 个 step 都带 detail(含 check 段)
- `node scripts/verify_detail_passthrough.cjs` — 真 Element Plus + jsdom 渲染:info-icon 数 = 2(预期 2),el-checkbox__label 文本正确
- `node scripts/verify_leaf_real_elplus.cjs` — 真 Element Plus 渲染 leaf 段:info-icon = 1,el-checkbox__label = "IND-001-a · GB 11643-1999 身份证号格式"
### 兼容性
- `StepDetail.check` / `BaseStandard.check_detail` 都是 Optional 字段(默认空),旧调用 register_step 不传 detail 也不会报错
- indicator 的 _derive_check 用 if-elif 映射表(24 个),未来新增 indicator 没命中映射 → 降级用 description 全文,不丢失信息
### 未提交
按用户要求,待 review 后再提交(与上一轮「JSON 驱动树」一并合并不了,因为中间还有 root cause 修复节点,本次单独提交)。
---
## 2026-08-10 · 检查项加 info icon + hover 三段式说明
### 用户原话
> 在每一个检查项目后面都加一个 info 的 icon,鼠标放上去的时候(hover)显示这个检查的作用,查哪些对象(字段或者注释包含哪些关键字),检查格式是什么样的等信息
### 数据建模:StepDetail 三段式
- `purpose` 这一步做什么
- `target` 查哪些字段 / 表 / 注释关键字
- `format` 检查依据的标准 / 算法 / 阈值
三段都是中文短句;缺失字段前端 fallback 隐藏那一行(不留空 div)。
### 改动
#### 1. 后端元数据来源
- [standards/base.py:60](standards/base.py#L60) `BaseStandard` 加可选字段 `format_check: str = ""`(默认空,子类可覆写)
- [standards/registry.py:74-81](standards/registry.py#L74-L81) `list_all_standards()` 输出 `format_check`
- [web/core/orchestrator.py](web/core/orchestrator.py):
- 新增 `StepDetail` dataclass
- `StepDef` 加 `detail: Optional[StepDetail]`
- `register_step()` 加可选 `detail=` 参数
- 4 个基础 step(merge_redundancy / empty_fields / missing_comments / length_check)注册时手填 detail
- 新增 `_derive_format(meta)` / `_derive_target(meta)`,给 24 个 indicator 自动派生 detail
- `_derive_format`:优先级 = 显式 format_check 字段 > standard_name 正则 `(GB|GA)(/T)?\d+(...)?-(\d{4})?` > 工信部/民政部/公安部/国标/行标 > "纯规则(无国标对应)"
- `_derive_target`:IND-301 / IND-302 / IND-401 / 402 / IND-501 / 502 / IND-601 / 602 / 603 / IND-101 等无 applies_to_fields 或语义特殊的 indicator 各走特殊描述;其余统一「字段名含 xxx」
#### 2. API 透出 detail
[web/api/routes.py:114-128](web/api/routes.py#L114-L128) `/api/analysis-tree` 把 `detail` 加到每个 leaf,未分组兜底组同样处理。
#### 3. 前端 info icon + tooltip
[web/static/index.html:286-302](web/static/index.html#L286-L302) leaf 行 `</el-checkbox>` 之后加 `<el-tooltip>`,slot content 三行(v-if 各段),trigger 用 `<span class="info-icon">i</span>`(不再用 `<el-icon><InfoFilled/></el-icon>`——svg icon 在某些环境下渲染不出来)。
#### 4. CSS
[web/static/style.css:289-336](web/static/style.css#L289-L336)
- `.info-icon`:16x16 圆形,灰底白字 `i`,hover/focus 变蓝
- `.step-detail`:max-width 360px,line-height 1.6
- `.step-detail-row`:flex 行 + 上下虚线分隔
- `.step-detail-label`:蓝色加粗,「作用 / 查什么 / 格式」36px 最小宽
### 验证
```
$ curl http://localhost:8765/api/analysis-tree | jq
{
"merge_redundancy": {
"detail": {
"purpose": "找出结构高度相似的可合并表...",
"target": "用户勾选范围内的全部表结构...",
"format": "LLM 语义匹配(候选合并 + 字段归并建议)..."
}
},
"std_ind_001_a": {
"detail": {
"purpose": "长度 = 18;6 位地址 + 8 位出生日期...",
"target": "字段名含 id_card / id_card_no / id_number / identity_card",
"format": "GB 11643-1999"
}
},
"std_ind_301": {
"detail": {
"purpose": "type=01 ...(证件号码一致性跨字段校验)",
"target": "同表同行的(证件类型 + 证件号码)配对",
"format": "暂无国标对应"
}
}
}
```
```
$ node scripts/verify_info_icon.cjs
info-icon 数: 1
step-detail-row 数: 3
[0] label: "作用"
[1] label: "查什么"
[2] label: "格式"
=== tooltip 第一行内容 ===
作用长度 = 18;6 位地址 + 8 位出生日期 + 3 位顺序码 + 1 位校验位
```
### 兼容性
- StepDetail 是 Optional,向后兼容:旧调用 register_step 不传 detail 也不会报错
- format_check 在 BaseStandard 是可选字段,已有的 24 个 indicator 都不覆写(默认空)—— orchestrator 走正则兜底
- info icon 用纯 `<span>i</span>`,不依赖任何 svg icon 组件(沿用 caret 的策略)
### 未提交
按用户要求,待 review 后再提交。
---
## 2026-08-10 · 树表改造 v3:用 Vue 3.5.40 prod build 真渲染,DOM 完全正确
### 用户反馈
......
// 简化:直接传对象给 Vue data,Vue 会把它包成 reactive proxy
const fs = require('fs');
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html>', {
runScripts: 'dangerously',
resources: 'usable',
url: 'http://localhost:8765/',
});
const win = dom.window;
const css = fs.readFileSync('web/static/lib/element-plus/index.css', 'utf8');
const style = win.document.createElement('style');
style.textContent = css;
win.document.head.appendChild(style);
const vue = fs.readFileSync('web/static/lib/vue.global.prod.js', 'utf8');
win.eval(vue);
const ep = fs.readFileSync('web/static/lib/element-plus/index.full.min.js', 'utf8');
win.eval(ep);
function waitFor(pred, name, tries = 200) {
return new Promise((resolve, reject) => {
let n = 0;
const t = () => {
if (pred()) return resolve();
if (++n > tries) return reject(new Error(name + ' 未加载'));
setTimeout(t, 50);
};
t();
});
}
(async () => {
await waitFor(() => win.Vue && win.ElementPlus, 'Vue + ElementPlus');
const Vue = win.Vue;
const ElementPlus = win.ElementPlus;
const analysisTree = {
groups: [
{
key: 'g1', title: '基础检查', default_expand: true, description: 'desc1',
checks: [
{ step_id: 'merge_redundancy', title: '表合并与冗余字段分析',
llm_mode: 'required', required: false,
detail: { purpose: '找出可合并表', target: '全部表', format: 'LLM' } },
{ step_id: 'empty_fields', title: '大范围空字段扫描',
llm_mode: 'none', required: false,
detail: { purpose: '扫描空字段', target: '全部字段', format: '阈值 80%' } },
],
},
],
};
const app = Vue.createApp({
data() { return { analysisTree, form: { steps: [] }, expandedGroups: ['g1'] }; },
computed: {
flatCheckRows() {
const out = [];
for (const g of this.analysisTree.groups || []) {
out.push({ key: g.key, _level: 0, is_group: true, title: g.title });
if (this.expandedGroups.includes(g.key)) {
for (const c of (g.checks || [])) {
out.push({
key: c.step_id, _level: 1, is_group: false,
step_id: c.step_id, title: c.title,
llm_mode: c.llm_mode, required: c.required,
detail: c.detail || null,
});
}
}
}
return out;
},
},
methods: { toggleStep() {} },
template: `
<div class="checks-tree-table">
<div v-for="row in flatCheckRows" :key="row.key" class="tree-row"
:class="row.is_group ? 'tree-row--group' : 'tree-row--leaf'">
<template v-if="!row.is_group">
<span class="tree-caret-spacer"></span>
<el-checkbox :model-value="false">
<span>{{ row.title }}</span>
</el-checkbox>
<el-tooltip v-if="row.detail" placement="top" effect="dark">
<template #content>
<div class="step-detail">
<div v-if="row.detail.purpose" class="step-detail-row">
<span class="step-detail-label">作用</span>
<span>{{ row.detail.purpose }}</span>
</div>
<div v-if="row.detail.target" class="step-detail-row">
<span class="step-detail-label">查什么</span>
<span>{{ row.detail.target }}</span>
</div>
<div v-if="row.detail.format" class="step-detail-row">
<span class="step-detail-label">格式</span>
<span>{{ row.detail.format }}</span>
</div>
</div>
</template>
<span class="info-icon" tabindex="0" role="button">i</span>
</el-tooltip>
</template>
</div>
</div>
`,
});
app.use(ElementPlus);
const container = win.document.createElement('div');
win.document.body.appendChild(container);
app.mount(container);
await new Promise(r => setTimeout(r, 200));
const infoIcons = container.querySelectorAll('.info-icon');
const tooltips = container.querySelectorAll('.el-tooltip');
const detailRows = container.querySelectorAll('.step-detail-row');
const labels = container.querySelectorAll('.el-checkbox__label');
console.log('=== 验证结果 ===');
console.log('info-icon 数:', infoIcons.length, '(预期 2)');
console.log('el-tooltip 数:', tooltips.length);
console.log('step-detail-row 数:', detailRows.length, '(预期 6)');
console.log('el-checkbox__label 数:', labels.length, '(预期 2)');
if (labels.length) {
console.log(' label[0]:', JSON.stringify(labels[0].textContent.trim()));
}
})();
\ No newline at end of file
// 验证 info icon + tooltip 渲染
const fs = require('fs');
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html>', { runScripts: 'dangerously', url: 'http://localhost:8765/' });
const win = dom.window;
const vue = fs.readFileSync('web/static/lib/vue.global.prod.js', 'utf8');
win.eval(vue);
const Vue = win.Vue;
// 拉一次真服务器的 HTML 拿实际模板
const { execSync } = require('child_process');
const html = execSync('curl -s http://localhost:8765/static/index.html', { encoding: 'utf8' });
// 抽出 leaf 模板段
const start = html.indexOf('<span class="tree-caret-spacer"');
const end = html.indexOf('</template>\n </div>', start);
const tpl = html.substring(start, end + '</template>'.length);
console.log('=== 截取到的 leaf 模板 (前 500 字符) ===');
console.log(tpl.substring(0, 500));
console.log('\n=== 含 info-icon 关键字 ===', tpl.includes('info-icon') ? 'YES' : 'NO');
console.log('=== 含 step-detail 关键字 ===', tpl.includes('step-detail') ? 'YES' : 'NO');
console.log('=== 含 row.detail ===', tpl.includes('row.detail') ? 'YES' : 'NO');
console.log('=== 含 el-tooltip ===', (tpl.match(/el-tooltip/g) || []).length, '处');
const ElCheckbox = {
props: ['modelValue', 'indeterminate', 'disabled'],
emits: ['change', 'update:modelValue'],
template: '<label class="el-checkbox"><input type="checkbox" /><span><slot /></span></label>',
};
const ElTooltip = {
props: ['placement', 'effect'],
template: '<span class="el-tooltip"><slot /><div class="el-tooltip__popper"><slot name="content" /></div></span>',
};
const row = {
key: 'std_ind_001_a',
step_id: 'std_ind_001_a',
title: 'IND-001-a · GB 11643-1999 身份证号格式',
llm_mode: 'none',
required: false,
detail: {
purpose: '长度 = 18;6 位地址 + 8 位出生日期 + 3 位顺序码 + 1 位校验位',
target: '字段名含 id_card / id_card_no / id_number / identity_card',
format: 'GB 11643-1999',
},
};
const App = {
data() { return { row, form: { steps: ['std_ind_001_a'] } }; },
methods: { toggleStep:()=>{} },
template: '<div>' + tpl + '</div>',
};
const app = Vue.createApp(App);
app.component('el-checkbox', ElCheckbox);
app.component('el-tooltip', ElTooltip);
const container = win.document.createElement('div');
win.document.body.appendChild(container);
app.mount(container);
const infoIcon = container.querySelector('.info-icon');
const tooltipRows = container.querySelectorAll('.step-detail-row');
const tooltipLabels = container.querySelectorAll('.step-detail-label');
console.log('\n=== 渲染结果 ===');
console.log('info-icon 数:', infoIcon ? 1 : 0);
console.log('step-detail-row 数:', tooltipRows.length);
tooltipLabels.forEach((l, i) => console.log(` [${i}] label: "${l.textContent.trim()}"`));
if (tooltipRows.length) {
console.log('\n=== tooltip 第一行内容 ===');
console.log(tooltipRows[0].textContent.trim());
console.log('\n=== tooltip 第二行内容 ===');
console.log(tooltipRows[1].textContent.trim());
console.log('\n=== tooltip 第三行内容 ===');
console.log(tooltipRows[2].textContent.trim());
}
\ No newline at end of file
// 用 prod Vue 渲染真实 leaf 模板(不加任何 mock el-checkbox)
// 模拟 Element Plus 的 el-checkbox 真实 DOM 结构
const fs = require('fs');
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html>', { runScripts: 'dangerously', url: 'http://localhost:8765/' });
const win = dom.window;
const vue = fs.readFileSync('web/static/lib/vue.global.prod.js', 'utf8');
win.eval(vue);
const Vue = win.Vue;
// 拉真服务器 HTML 取模板
const { execSync } = require('child_process');
const html = execSync('curl -s http://localhost:8765/static/index.html', { encoding: 'utf8' });
const start = html.indexOf('<span class="tree-caret-spacer"');
const end = html.indexOf('</template>\n </div>', start);
const tpl = html.substring(start, end + '</template>'.length);
const row = {
key: 'std_ind_001_a',
step_id: 'std_ind_001_a',
title: 'IND-001-a · GB 11643-1999 身份证号格式',
llm_mode: 'none',
required: false,
detail: {
purpose: '长度 = 18',
target: '字段名含 id_card',
format: 'GB 11643-1999',
},
};
// 模拟 Element Plus el-checkbox 真实 DOM 结构
const ElCheckbox = {
name: 'ElCheckbox',
props: ['modelValue', 'indeterminate', 'disabled', 'label'],
emits: ['change', 'update:modelValue'],
template: `
<label class="el-checkbox" :class="{ 'is-disabled': disabled, 'is-checked': modelValue, 'is-indeterminate': indeterminate }">
<span class="el-checkbox__input">
<input type="checkbox" :disabled="disabled" :checked="modelValue" class="el-checkbox__original" />
<span class="el-checkbox__inner"></span>
</span>
<span class="el-checkbox__label">
<slot />
</span>
</label>
`,
};
const ElTooltip = {
name: 'ElTooltip',
props: ['placement', 'effect', 'content'],
template: '<span class="el-tooltip"><slot /><slot name="content" /></span>',
};
const ElTag = {
name: 'ElTag',
props: ['size', 'type', 'effect'],
template: '<span class="el-tag" :class="type ? `el-tag--${type}` : \'\'"><slot /></span>',
};
const App = {
data() { return { row, form: { steps: ['std_ind_001_a'] } }; },
methods: { toggleStep:()=>{} },
template: '<div>' + tpl + '</div>',
};
const app = Vue.createApp(App);
app.component('el-checkbox', ElCheckbox);
app.component('el-tooltip', ElTooltip);
app.component('el-tag', ElTag);
const container = win.document.createElement('div');
win.document.body.appendChild(container);
try {
app.mount(container);
console.log('=== 渲染结果 ===');
console.log(container.innerHTML);
console.log('\n=== 关键元素 ===');
console.log('el-checkbox 数:', container.querySelectorAll('.el-checkbox').length);
console.log('el-checkbox__label 数:', container.querySelectorAll('.el-checkbox__label').length);
const label = container.querySelector('.el-checkbox__label');
if (label) console.log('label textContent:', JSON.stringify(label.textContent.trim()));
console.log('info-icon 数:', container.querySelectorAll('.info-icon').length);
} catch (e) {
console.error('mount ERROR:', e.message);
console.error(e.stack);
}
\ No newline at end of file
// 用真 Element Plus 跑完整模板,看 el-checkbox 实际怎么渲染
const fs = require('fs');
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html>', {
runScripts: 'dangerously',
resources: 'usable',
pretendToBeVisual: true,
url: 'http://localhost:8765/',
});
const win = dom.window;
// 注入 Element Plus CSS(jsdom 模拟)
const css = fs.readFileSync('web/static/lib/element-plus/index.css', 'utf8');
const style = win.document.createElement('style');
style.textContent = css;
win.document.head.appendChild(style);
// 加载 Vue + Element Plus
const vue = fs.readFileSync('web/static/lib/vue.global.prod.js', 'utf8');
win.eval(vue);
const ep = fs.readFileSync('web/static/lib/element-plus/index.full.min.js', 'utf8');
win.eval(ep);
function waitFor(predicate, name, maxTries = 200) {
return new Promise((resolve, reject) => {
let tries = 0;
const tick = () => {
if (predicate()) return resolve();
if (++tries > maxTries) return reject(new Error(`${name} 未加载`));
setTimeout(tick, 50);
};
tick();
});
}
(async () => {
try {
await waitFor(() => win.Vue && win.ElementPlus, 'Vue + ElementPlus');
console.log('Vue + ElementPlus loaded');
} catch (e) {
console.error(e.message);
process.exit(1);
}
const Vue = win.Vue;
const ElementPlus = win.ElementPlus;
// 拉真服务器 HTML 取模板
const { execSync } = require('child_process');
const html = execSync('curl -s http://localhost:8765/static/index.html', { encoding: 'utf8' });
const start = html.indexOf('<span class="tree-caret-spacer"');
const end = html.indexOf('</template>\n </div>', start);
const tpl = html.substring(start, end + '</template>'.length);
const row = {
key: 'std_ind_001_a',
step_id: 'std_ind_001_a',
title: 'IND-001-a · GB 11643-1999 身份证号格式',
llm_mode: 'none',
required: false,
detail: {
purpose: '长度 = 18',
target: '字段名含 id_card',
format: 'GB 11643-1999',
},
};
const App = {
data() { return { row, form: { steps: ['std_ind_001_a'] } }; },
methods: { toggleStep:()=>{} },
template: '<div>' + tpl + '</div>',
};
const app = Vue.createApp(App);
app.use(ElementPlus);
const container = win.document.createElement('div');
win.document.body.appendChild(container);
try {
app.mount(container);
// 等一帧让 popper 之类 ready
await new Promise(r => setTimeout(r, 300));
console.log('=== 渲染结果 ===');
console.log(container.innerHTML.substring(0, 2000));
console.log('\n=== 关键元素 ===');
console.log('el-checkbox 数:', container.querySelectorAll('.el-checkbox').length);
console.log('el-checkbox__label 数:', container.querySelectorAll('.el-checkbox__label').length);
const labels = container.querySelectorAll('.el-checkbox__label');
labels.forEach((l, i) => {
console.log(` label[${i}] textContent: ${JSON.stringify(l.textContent.trim())}`);
});
console.log('info-icon 数:', container.querySelectorAll('.info-icon').length);
} catch (e) {
console.error('mount ERROR:', e.message);
console.error(e.stack);
}
})();
\ No newline at end of file
......@@ -55,6 +55,11 @@ class BaseStandard(ABC):
group: str = "通用"
severity: str = "warning"
depends_on: list[str] | None = None
# 检查格式 / 标准的精炼描述(前端 hover tooltip 用)。
# 留空时 orchestrator 会从 standard_name 正则提取 GB/GA 号作兜底。
format_check: str = ""
# 详细校验规则说明(前端 tooltip「规则」段;留空时降级用 description 全文)
check_detail: str = ""
def __init__(self):
# 子类可省略 __init__;基类负责把 None 兜底为 []
......
......@@ -77,6 +77,8 @@ def list_all_standards() -> list[dict]:
"description": inst.description,
"group": getattr(inst, "group", "通用"),
"severity": getattr(inst, "severity", "warning"),
"format_check": getattr(inst, "format_check", "") or "",
"check_detail": getattr(inst, "check_detail", "") or "",
})
return out
......
......@@ -113,6 +113,7 @@ async def analysis_tree():
missing.append(sid)
continue
referenced.add(sid)
detail = sd.detail
children.append({
"step_id": sd.step_id,
"title": sd.title,
......@@ -120,6 +121,13 @@ async def analysis_tree():
"llm_mode": sd.llm_mode,
"required": sd.required,
"order": sd.order,
"detail": (
{"purpose": detail.purpose,
"target": detail.target,
"check": detail.check,
"format": detail.format}
if detail else None
),
})
out_groups.append({
"key": g.get("key"),
......@@ -142,6 +150,13 @@ async def analysis_tree():
{
"step_id": s.step_id, "title": s.title, "description": s.description,
"llm_mode": s.llm_mode, "required": s.required, "order": s.order,
"detail": (
{"purpose": s.detail.purpose,
"target": s.detail.target,
"check": s.detail.check,
"format": s.detail.format}
if s.detail else None
),
} for s in sorted(orphan, key=lambda x: x.order)
],
})
......
......@@ -26,6 +26,7 @@ import asyncio
import json
import logging
import os
import re
import time
import traceback
from dataclasses import dataclass
......@@ -41,6 +42,18 @@ logger = logging.getLogger(__name__)
# ── Step 注册表 ────────────────────────────────────────────
@dataclass(frozen=True)
class StepDetail:
"""Step 的「检查说明」四段式:作用 / 查询对象 / 校验规则 / 检查格式。
给前端 hover info icon 用。四段都是中文短句;缺失字段前端 fallback 隐藏那一行。
"""
purpose: str = "" # 这一步做什么(业务目标 + 产出形式)
target: str = "" # 查哪些字段 / 表 / 注释关键字
check: str = "" # 校验规则细节(多行:阈值 / 步骤 / 算法摘要)
format: str = "" # 检查依据的标准 / 算法 / 阈值
@dataclass(frozen=True)
class StepDef:
"""单个分析 Step 的元数据 + 执行函数。
......@@ -55,6 +68,7 @@ class StepDef:
required: bool # UI 必选 + 后端兜底自动追加
order: int # UI 显示顺序
fn: Callable # 实际执行函数
detail: Optional[StepDetail] = None # 前端 hover info 用
_STEPS: dict[str, StepDef] = {}
......@@ -64,12 +78,13 @@ def register_step(
*, step_id: str, title: str, description: str,
requires_db: bool, llm_mode: str, required: bool, order: int,
fn: Callable,
detail: Optional[StepDetail] = None,
) -> None:
"""注册一个 Step。重复注册会覆盖。"""
_STEPS[step_id] = StepDef(
step_id=step_id, title=title, description=description,
requires_db=requires_db, llm_mode=llm_mode, required=required,
order=order, fn=fn,
order=order, fn=fn, detail=detail,
)
......@@ -132,6 +147,17 @@ register_step(
description="离线:找结构高度相似的表 + 高频字段(LLM 必须)",
requires_db=False, llm_mode="required", required=False, order=1,
fn=_run_merge_redundancy,
detail=StepDetail(
purpose="找出结构高度相似的可合并表(merge candidates),以及跨表反复出现的高频字段(提示冗余 / 需要统一来源)。最终在「分析结果」Tab 里以两张子表展示:可合并候选 + 高频冗余字段。",
target="用户勾选范围内的全部表结构(字段名 + 类型 + 注释),不限字段长度和字符集",
check=(
"1. 程序化初筛:对所有 (table_a, table_b) 对计算字段集合的 Jaccard 相似度;阈值 ≥ 0.7 视为高度相似候选\n"
"2. LLM 二次判断:对候选对送 LLM 复核「是否可合并 / 字段如何归并」\n"
"3. 高频字段:跨 ≥ 3 张表出现且类型相同的字段视为高频冗余,按出现次数倒序\n"
"4. 同义字段名(id_card / id_card_no / id_number)合并到同一语义组"
),
format="纯程序化部分按 Jaccard 相似度 + 字段名集合;语义归并走 LLM(provider 见 web/configs/llm.yaml)",
),
)
register_step(
step_id="empty_fields",
......@@ -139,6 +165,17 @@ register_step(
description="单表全列扫,统计 NULL/空值比例(纯程序化,不依赖 LLM)",
requires_db=True, llm_mode="none", required=False, order=2,
fn=_run_empty_fields,
detail=StepDetail(
purpose="扫描大范围为空的字段(高空字段普遍缺乏治理价值,可能是不再维护的旧字段 / 预留字段 / 误接入的字段),在「分析结果」里按表分组展开明细。",
target="用户勾选范围内全部表的全部字段,连库跑 COUNT(*) 统计 NULL/空值比例",
check=(
"1. 高空字段:空值比例 ≥ 80%(建议治理候选)\n"
"2. 中空字段:空值比例 50%~80%(结合业务判断)\n"
"3. 空字符串与 NULL 都计入分母,但纯空格、纯 0 不算空值\n"
"4. 主键字段、必填字段即使高空也会标记为「不建议删除」(前端 Tab 里染色)"
),
format="阈值规则内置(80% / 50%),无国标对应;按表分组聚合 → 嵌套展开到字段级",
),
)
register_step(
step_id="missing_comments",
......@@ -146,6 +183,18 @@ register_step(
description="离线:找无注释字段,LLM 必须:批量推测语义",
requires_db=False, llm_mode="required", required=False, order=3,
fn=_run_missing_comments,
detail=StepDetail(
purpose="找出无注释的字段,由 LLM 基于字段名 / 类型 / 同表线索推测语义,辅助人工补注释;推测结果带置信度(高/中/低),人工事后筛选。",
target="注释为空、仅含默认值(无 / 暂无 / null / TBD)、或仅含标点 / 长度 < 4 的字段",
check=(
"1. 收集所有无注释字段 → 按 (table, field_name) 去重\n"
"2. 同表同名字段优先复用已注释样本(同义字段名合并)\n"
"3. LLM 批量推测:传入字段名 + 类型 + 同表样本字段,输出「推测注释」+「置信度」\n"
"4. 高置信度(>0.8):建议直接采纳;中(0.5-0.8):建议人工复核;低(<0.5):仅供参考\n"
"5. 输出按 table 分组,前端 Tab 展示时附 sample 值方便核对"
),
format="LLM 推测 + 置信度阈值(0.5 / 0.8);未配置 API Key 时本步骤预检失败",
),
)
register_step(
step_id="length_check",
......@@ -153,6 +202,17 @@ register_step(
description="离线:识别字段定义长度超出国家标准(纯规则匹配,不依赖 LLM)",
requires_db=False, llm_mode="none", required=False, order=4,
fn=_run_length_check,
detail=StepDetail(
purpose="识别字段定义长度超出国家标准的字段(如身份证 18 位但定义 VARCHAR(50) → 浪费空间 / 暗示长度不固定;或定义 VARCHAR(10) 但标准要求 18 → 必然写入失败),辅助 schema 整改。",
target="information_schema.columns 全部字段定义(CHAR / VARCHAR / NVARCHAR 的 character_maximum_length)",
check=(
"1. 字段名匹配已知国标字段名(身份证 / USCC / 手机号 / 区划 ...)\n"
"2. 与内置标准长度对比:身份证 18 / USCC 18 / 手机号 11 / 区划 6 / 婚姻代码 2\n"
"3. 输出三类:定义过短(必失败)/ 准标(无变化)/ 定义过长(建议缩短)\n"
"4. 不修改 schema,仅产出整改建议清单"
),
format="对照内置国标规则库(身份证 18 / USCC 18 / 手机号 11 / 区划 6 / 婚姻 2);纯字符串匹配,无算法",
),
)
......@@ -176,6 +236,100 @@ except Exception as _e: # pragma: no cover
_INDICATOR_STEPS = []
# 从 standard_name 提取 GB/GA 号作 format 兜底(例如 "GB 11643-1999 身份证号格式" → "GB 11643-1999")
_FORMAT_RE = re.compile(
r"((?:GB|GA)(?:/T)?\s*\d+(?:[./]\d+)?(?:-\d{4})?)|(?:工信部|民政部|公安部|国标|行标)"
)
def _derive_format(meta: dict) -> str:
"""从 indicator metadata 派生「检查格式」短句。
优先级:显式 format_check 字段 > standard_name 正则提取 > "纯规则"。
"""
explicit = (meta.get("format_check") or "").strip()
if explicit:
return explicit
name = meta.get("name", "") or ""
m = _FORMAT_RE.search(name)
if m:
return m.group(0)
return "纯规则(无国标对应)"
def _derive_target(meta: dict) -> str:
"""从 indicator metadata 派生「查询对象」短句。"""
fields = meta.get("applies_to_fields") or []
sid = meta.get("id", "")
# 跨字段 / 跨表 indicator:title 已暗示了
if sid in ("IND-301",):
return "同表同行的(证件类型 + 证件号码)配对"
if sid in ("IND-302",):
return "跨表(证件类型, 证件号码)同值聚合"
if sid in ("IND-401", "IND-402"):
return "字段名含 name / xm / 姓名 / username 等姓名相关字段"
if sid in ("IND-501",):
return "字段名含 birth / csrq / birthday 等出生日期字段"
if sid in ("IND-502",):
return "出生日期字段(同表优先拼 gender / xb 判断法定退休年龄)"
if sid in ("IND-601",):
return "字段名含 region / xzqh / area / district 等行政区划字段"
if sid in ("IND-602",):
return "字段名含 nation / mz / ethnicity 等民族字段"
if sid in ("IND-603",):
return "字段名含 marriage / hyzk / marital 等婚姻状况字段"
if sid in ("IND-101",):
return "字段名含 id_type / zjlx / cert_type 等证件类型字段"
if fields:
return "字段名含 " + " / ".join(fields)
return "(跨字段 / 跨表,见 indicator 名称)"
def _derive_check(meta: dict) -> str:
"""从 indicator metadata 派生「校验规则」长描述。
优先级:显式 check_detail 字段 > 标准号 + description 全文(详细模式)。
description 全文作为详细说明;前端 tooltip 支持多行渲染。
"""
explicit = (meta.get("check_detail") or "").strip()
if explicit:
return explicit
desc = (meta.get("description") or "").strip()
if not desc:
return ""
sid = meta.get("id", "")
# 已知 indicator:给一段更详尽的「业务背景 + 校验要点」摘要
special_checks = {
"IND-001-a": "正则 ^ [1-9]\\d{5}(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$;先 length == 18 再匹配。",
"IND-001-b": "ISO 7064 MOD 11-2:前 17 位分别 × 权重 [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2],求和后 mod 11,查映射表 [1,0,X,9,8,7,6,5,4,3,2] 得校验位。",
"IND-001-c": "取 value[6:14] 按 YYYYMMDD 解析,必须是合法日期(含闰年 2-29 校验),且 ≤ 当前日期。",
"IND-001-d": "15 位老证:长度 = 15,6 位地址 + 6 位出生日期(无世纪位)+ 3 位顺序码;按 19xx 补齐后校验。",
"IND-002-a": "正则 ^ [0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$(18 位,31 字符集不含 I/O/Z/S/V)。",
"IND-002-b": "GB 32100-2015 MOD 31-3:前 17 位 × 权重 [3,7,9,10,5,8,4,2,...],模 31,查映射表(含 0-9 + 25 个字母)得校验位。",
"IND-003-a": "11 位数字 + 第 1 位 1[3-9] / 第 2 位 3-9;正则 ^1[3-9]\\d{9}$。",
"IND-003-b": "查工信部公开号段表(170/171/180-189 等),前缀必须在号段内;号段字典每季度更新。",
"IND-004-a": "6 位数字:[省 2 位][地市 2 位][县区 2 位];首位非 0;不强制存在性(IND-004-b 才校验)。",
"IND-004-b": "查 GB/T 2260-2007 表(约 4400 条),区码必须存在;港澳台前缀 71/81/82;用户表中 83 也接受(GB/T 2260 不含但 GB/T 22239 引用)。",
"IND-101": "5 值枚举:01=身份证 / 03=护照 / 04=永居证 / 05=港澳台居住证 / 99=其他(用户给定的简化集,未走 GA/T 2000.156-2016)。",
"IND-201": "复用 IND-001-a/b/c 的 18 位 GB 11643 + 校验位 + 出生日期校验(与 IND-001 是有意重叠;按业务语义选用)。",
"IND-202": "9 位:G+8 / E+8 / E+1字母+7数字 / PE/SE/DE+7数字;字母排除 I/O;无公开校验位(只做格式校验)。",
"IND-203": "国办发〔2018〕81 号:地址码 810000 / 820000 / 830000 + 出生日期 + 顺序码 + 校验位;接受 83 前缀。",
"IND-204": "旧版 15 位 LLL+12数字;新版 18 位 9+17数字;官方未公开校验位(只做格式 + 长度)。",
"IND-301": "1) 同表配对扫描 (id_type, id_no);2) 按 id_type 分流到 IND-201/202/203/204;3) 任一 indicator 命中即视为合规。",
"IND-302": "1) 按 (id_type, id_no) GROUP BY 跨表聚合;2) HAVING count(*) > 1 视为重复;3) 跨表语义下严格判重,禁止同一证件在不同表开多次账户。",
"IND-401": "字符集:汉字 + 少数民族点分隔(如 ·) + ASCII 字母(拼音名);纯英文连字符 / 撇号 / 空格允许。",
"IND-402": "trim 后长度 ∈ [2, 20];纯符号 / 纯数字 / 长度 1 视为异常。",
"IND-501": "YYYYMMDD 或 YYYY-MM-DD / YYYY/MM/DD / YYYY.MM.DD;必须是合法日期且 ≤ 当前日期;不接未来日期。",
"IND-502": "1) 最低 16 岁(劳动法最低就业年龄);2) 上限按性别字段推断:男 60 / 女工 50 / 女干部 55;3) 找不到性别字段退化为 60。",
"IND-601": "见 IND-004-a / IND-004-b;本 indicator 主要是按业务字段名(region / xzqh)路由到 IND-004 系列。",
"IND-602": "GB/T 3304-1991:01-56 + 补充码 97/98(81 穿青人非标准正文);接受字符串或数字存储形式。",
"IND-603": "GB/T 2261.2-2003:10=未婚 / 20=已婚 / 21=初婚 / 22=再婚 / 23=复婚 / 30=丧偶 / 40=离婚 / 90=未说明;非 GB/T 4761(家庭关系)。",
}
if sid in special_checks:
return special_checks[sid]
return desc
def _register_indicator_steps() -> None:
"""动态注册每个 indicator 为一个 orchestrator step。
......@@ -207,12 +361,19 @@ def _register_indicator_steps() -> None:
f"分组: {group} | 适用字段: {', '.join(meta.get('applies_to_fields', [])) or '(跨字段/跨表)'} | "
f"{meta.get('description', '')}"
)
detail = StepDetail(
purpose=meta.get("description", "").strip() or meta.get("name", ""),
target=_derive_target(meta),
check=_derive_check(meta),
format=_derive_format(meta),
)
register_step(
step_id=sid,
title=title,
description=description,
requires_db=True, llm_mode="none", required=False, order=order,
fn=_run_standards_one(std_id),
detail=detail,
)
......
......@@ -266,7 +266,7 @@
</el-checkbox>
</template>
<template v-else>
<span class="tree-caret-spacer" />
<span class="tree-caret-spacer"></span>
<el-checkbox
:model-value="form.steps.includes(row.step_id)"
:disabled="row.required"
......@@ -282,6 +282,29 @@
<el-tag v-if="row.llm_mode === 'required'" size="small" type="warning" effect="plain" class="badge">需 LLM</el-tag>
<el-tag v-if="row.required" size="small" type="danger" effect="dark" class="badge">必选</el-tag>
</el-checkbox>
<el-tooltip v-if="row.detail" placement="top" effect="dark">
<template #content>
<div class="step-detail">
<div v-if="row.detail.purpose" class="step-detail-row">
<span class="step-detail-label">作用</span>
<span>{{ row.detail.purpose }}</span>
</div>
<div v-if="row.detail.target" class="step-detail-row">
<span class="step-detail-label">查什么</span>
<span>{{ row.detail.target }}</span>
</div>
<div v-if="row.detail.check" class="step-detail-row step-detail-row--block">
<span class="step-detail-label">规则</span>
<pre class="step-detail-pre">{{ row.detail.check }}</pre>
</div>
<div v-if="row.detail.format" class="step-detail-row">
<span class="step-detail-label">格式</span>
<span>{{ row.detail.format }}</span>
</div>
</div>
</template>
<span class="info-icon" tabindex="0" role="button" aria-label="检查项说明">i</span>
</el-tooltip>
</template>
</div>
</div>
......@@ -669,6 +692,7 @@
title: c.title,
llm_mode: c.llm_mode,
required: c.required,
detail: c.detail || null,
});
}
}
......
......@@ -290,6 +290,69 @@ body {
margin: 0 4px;
flex-shrink: 0;
}
/* 检查项说明 info icon(hover 显示 purpose/target/format) */
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
border-radius: 50%;
background: #909399;
color: #fff;
font-size: 11px;
font-style: italic;
font-weight: 600;
margin-left: 6px;
cursor: help;
user-select: none;
flex-shrink: 0;
transition: background 0.15s;
}
.info-icon:hover,
.info-icon:focus {
background: #409eff;
outline: none;
}
.step-detail {
max-width: 420px;
line-height: 1.6;
font-size: 13px;
}
.step-detail-row {
display: flex;
gap: 8px;
align-items: baseline;
margin: 2px 0;
}
.step-detail-row + .step-detail-row {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed rgba(255, 255, 255, 0.15);
}
.step-detail-row--block {
display: block;
}
.step-detail-pre {
margin: 4px 0 0;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.25);
border-radius: 3px;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
color: #e6e6e6;
}
.step-detail-label {
flex-shrink: 0;
color: #409eff;
font-weight: 600;
min-width: 36px;
}
/* ── 进度条(上) ── */
.progress-block {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment