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 @@ ...@@ -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 完全正确 ## 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): ...@@ -55,6 +55,11 @@ class BaseStandard(ABC):
group: str = "通用" group: str = "通用"
severity: str = "warning" severity: str = "warning"
depends_on: list[str] | None = None depends_on: list[str] | None = None
# 检查格式 / 标准的精炼描述(前端 hover tooltip 用)。
# 留空时 orchestrator 会从 standard_name 正则提取 GB/GA 号作兜底。
format_check: str = ""
# 详细校验规则说明(前端 tooltip「规则」段;留空时降级用 description 全文)
check_detail: str = ""
def __init__(self): def __init__(self):
# 子类可省略 __init__;基类负责把 None 兜底为 [] # 子类可省略 __init__;基类负责把 None 兜底为 []
......
...@@ -77,6 +77,8 @@ def list_all_standards() -> list[dict]: ...@@ -77,6 +77,8 @@ def list_all_standards() -> list[dict]:
"description": inst.description, "description": inst.description,
"group": getattr(inst, "group", "通用"), "group": getattr(inst, "group", "通用"),
"severity": getattr(inst, "severity", "warning"), "severity": getattr(inst, "severity", "warning"),
"format_check": getattr(inst, "format_check", "") or "",
"check_detail": getattr(inst, "check_detail", "") or "",
}) })
return out return out
......
...@@ -113,6 +113,7 @@ async def analysis_tree(): ...@@ -113,6 +113,7 @@ async def analysis_tree():
missing.append(sid) missing.append(sid)
continue continue
referenced.add(sid) referenced.add(sid)
detail = sd.detail
children.append({ children.append({
"step_id": sd.step_id, "step_id": sd.step_id,
"title": sd.title, "title": sd.title,
...@@ -120,6 +121,13 @@ async def analysis_tree(): ...@@ -120,6 +121,13 @@ async def analysis_tree():
"llm_mode": sd.llm_mode, "llm_mode": sd.llm_mode,
"required": sd.required, "required": sd.required,
"order": sd.order, "order": sd.order,
"detail": (
{"purpose": detail.purpose,
"target": detail.target,
"check": detail.check,
"format": detail.format}
if detail else None
),
}) })
out_groups.append({ out_groups.append({
"key": g.get("key"), "key": g.get("key"),
...@@ -142,6 +150,13 @@ async def analysis_tree(): ...@@ -142,6 +150,13 @@ async def analysis_tree():
{ {
"step_id": s.step_id, "title": s.title, "description": s.description, "step_id": s.step_id, "title": s.title, "description": s.description,
"llm_mode": s.llm_mode, "required": s.required, "order": s.order, "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) } for s in sorted(orphan, key=lambda x: x.order)
], ],
}) })
......
...@@ -26,6 +26,7 @@ import asyncio ...@@ -26,6 +26,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
import time import time
import traceback import traceback
from dataclasses import dataclass from dataclasses import dataclass
...@@ -41,6 +42,18 @@ logger = logging.getLogger(__name__) ...@@ -41,6 +42,18 @@ logger = logging.getLogger(__name__)
# ── Step 注册表 ──────────────────────────────────────────── # ── Step 注册表 ────────────────────────────────────────────
@dataclass(frozen=True)
class StepDetail:
"""Step 的「检查说明」四段式:作用 / 查询对象 / 校验规则 / 检查格式。
给前端 hover info icon 用。四段都是中文短句;缺失字段前端 fallback 隐藏那一行。
"""
purpose: str = "" # 这一步做什么(业务目标 + 产出形式)
target: str = "" # 查哪些字段 / 表 / 注释关键字
check: str = "" # 校验规则细节(多行:阈值 / 步骤 / 算法摘要)
format: str = "" # 检查依据的标准 / 算法 / 阈值
@dataclass(frozen=True) @dataclass(frozen=True)
class StepDef: class StepDef:
"""单个分析 Step 的元数据 + 执行函数。 """单个分析 Step 的元数据 + 执行函数。
...@@ -55,6 +68,7 @@ class StepDef: ...@@ -55,6 +68,7 @@ class StepDef:
required: bool # UI 必选 + 后端兜底自动追加 required: bool # UI 必选 + 后端兜底自动追加
order: int # UI 显示顺序 order: int # UI 显示顺序
fn: Callable # 实际执行函数 fn: Callable # 实际执行函数
detail: Optional[StepDetail] = None # 前端 hover info 用
_STEPS: dict[str, StepDef] = {} _STEPS: dict[str, StepDef] = {}
...@@ -64,12 +78,13 @@ def register_step( ...@@ -64,12 +78,13 @@ def register_step(
*, step_id: str, title: str, description: str, *, step_id: str, title: str, description: str,
requires_db: bool, llm_mode: str, required: bool, order: int, requires_db: bool, llm_mode: str, required: bool, order: int,
fn: Callable, fn: Callable,
detail: Optional[StepDetail] = None,
) -> None: ) -> None:
"""注册一个 Step。重复注册会覆盖。""" """注册一个 Step。重复注册会覆盖。"""
_STEPS[step_id] = StepDef( _STEPS[step_id] = StepDef(
step_id=step_id, title=title, description=description, step_id=step_id, title=title, description=description,
requires_db=requires_db, llm_mode=llm_mode, required=required, 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( ...@@ -132,6 +147,17 @@ register_step(
description="离线:找结构高度相似的表 + 高频字段(LLM 必须)", description="离线:找结构高度相似的表 + 高频字段(LLM 必须)",
requires_db=False, llm_mode="required", required=False, order=1, requires_db=False, llm_mode="required", required=False, order=1,
fn=_run_merge_redundancy, 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( register_step(
step_id="empty_fields", step_id="empty_fields",
...@@ -139,6 +165,17 @@ register_step( ...@@ -139,6 +165,17 @@ register_step(
description="单表全列扫,统计 NULL/空值比例(纯程序化,不依赖 LLM)", description="单表全列扫,统计 NULL/空值比例(纯程序化,不依赖 LLM)",
requires_db=True, llm_mode="none", required=False, order=2, requires_db=True, llm_mode="none", required=False, order=2,
fn=_run_empty_fields, 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( register_step(
step_id="missing_comments", step_id="missing_comments",
...@@ -146,6 +183,18 @@ register_step( ...@@ -146,6 +183,18 @@ register_step(
description="离线:找无注释字段,LLM 必须:批量推测语义", description="离线:找无注释字段,LLM 必须:批量推测语义",
requires_db=False, llm_mode="required", required=False, order=3, requires_db=False, llm_mode="required", required=False, order=3,
fn=_run_missing_comments, 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( register_step(
step_id="length_check", step_id="length_check",
...@@ -153,6 +202,17 @@ register_step( ...@@ -153,6 +202,17 @@ register_step(
description="离线:识别字段定义长度超出国家标准(纯规则匹配,不依赖 LLM)", description="离线:识别字段定义长度超出国家标准(纯规则匹配,不依赖 LLM)",
requires_db=False, llm_mode="none", required=False, order=4, requires_db=False, llm_mode="none", required=False, order=4,
fn=_run_length_check, 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 ...@@ -176,6 +236,100 @@ except Exception as _e: # pragma: no cover
_INDICATOR_STEPS = [] _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: def _register_indicator_steps() -> None:
"""动态注册每个 indicator 为一个 orchestrator step。 """动态注册每个 indicator 为一个 orchestrator step。
...@@ -207,12 +361,19 @@ def _register_indicator_steps() -> None: ...@@ -207,12 +361,19 @@ def _register_indicator_steps() -> None:
f"分组: {group} | 适用字段: {', '.join(meta.get('applies_to_fields', [])) or '(跨字段/跨表)'} | " f"分组: {group} | 适用字段: {', '.join(meta.get('applies_to_fields', [])) or '(跨字段/跨表)'} | "
f"{meta.get('description', '')}" 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( register_step(
step_id=sid, step_id=sid,
title=title, title=title,
description=description, description=description,
requires_db=True, llm_mode="none", required=False, order=order, requires_db=True, llm_mode="none", required=False, order=order,
fn=_run_standards_one(std_id), fn=_run_standards_one(std_id),
detail=detail,
) )
......
...@@ -266,7 +266,7 @@ ...@@ -266,7 +266,7 @@
</el-checkbox> </el-checkbox>
</template> </template>
<template v-else> <template v-else>
<span class="tree-caret-spacer" /> <span class="tree-caret-spacer"></span>
<el-checkbox <el-checkbox
:model-value="form.steps.includes(row.step_id)" :model-value="form.steps.includes(row.step_id)"
:disabled="row.required" :disabled="row.required"
...@@ -282,6 +282,29 @@ ...@@ -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.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-tag v-if="row.required" size="small" type="danger" effect="dark" class="badge">必选</el-tag>
</el-checkbox> </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> </template>
</div> </div>
</div> </div>
...@@ -669,6 +692,7 @@ ...@@ -669,6 +692,7 @@
title: c.title, title: c.title,
llm_mode: c.llm_mode, llm_mode: c.llm_mode,
required: c.required, required: c.required,
detail: c.detail || null,
}); });
} }
} }
......
...@@ -290,6 +290,69 @@ body { ...@@ -290,6 +290,69 @@ body {
margin: 0 4px; margin: 0 4px;
flex-shrink: 0; 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 { .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