Commit 01a2d9b0 authored by Data Governance Dev's avatar Data Governance Dev

feat(web): 检查项改成 JSON 驱动的多级折叠树 + caret 用 span+unicode

要点:

- 后端:从 web/configs/analysis_tree.json 读分组,与 orchestrator 的
  step_defs 合并返给前端;JSON 没引到的 step 自动归 __ungrouped__,
  避免静默丢失
- 前端:丢掉 el-table tree-props,自己渲染 caret + el-checkbox,
  每行固定 28px;caret 从 <button><el-icon><CaretRight/></el-icon></button>
  改为 <span>{{ _expanded ? '▼' : '▶' }}</span>(不再依赖 svg icon 组件)
- 描述:inline ellipsis + :title 悬停(不再用 el-tooltip 包 el-checkbox)
- README:新增「调整检查项分组(不改前端)」章节说明 JSON 结构
- 验证:scripts/verify_tree.cjs (23 cases) / verify_caret_span.cjs
  / verify_prod_tree.cjs(jsdom + Vue 3.5.40 prod build 实际渲染 DOM)

兼容性:/api/steps 保留;form.steps 仍为 flat step_id 列表;
step 注册只动 orchestrator,分组只动 JSON。
parent 5c37925e
...@@ -2,6 +2,217 @@ ...@@ -2,6 +2,217 @@
> 任务做完一次记一次。最近的在最上面。 > 任务做完一次记一次。最近的在最上面。
## 2026-08-10 · 树表改造 v3:用 Vue 3.5.40 prod build 真渲染,DOM 完全正确
### 用户反馈
最新截图:5 个 caret ▶ 按钮仍孤零零在那,没有任何标题 / 勾选框 / 说明文字。
> 右箭头旁边的说明还是没有
### 排查
之前 verify_template.cjs 用的是 `@vue/compiler-dom`(npm 3.5.41),但浏览器跑的是 `vue.global.prod.js`(3.5.40)—— 两者可能行为不同。
写 `scripts/verify_prod_tree.cjs`:
1. jsdom 加载 `web/static/lib/vue.global.prod.js`(浏览器用同一份)
2. 拦截 `window.Function` 抓到 Vue 编译出的 render 函数
3. 用 mock `el-checkbox` 组件(只关心 slot 是否 render)注册 + 挂载
### 结果:模板完全正确
```
=== DOM ===
<div class="checks-tree-table">
<div class="tree-row tree-row--group" style="padding-left: 0px;">
<button class="tree-caret" aria-expanded="true"><span>▼</span></button>
<label class="el-checkbox">
<input type="checkbox"><span>
<span class="group-title">基础检查</span>
<span class="group-count">(2/3)</span>
<span class="group-description" title="基础数据质量检查">基础数据质量检查</span>
</span>
</label>
</div>
<div class="tree-row tree-row--leaf" style="padding-left: 24px;">
<span class="tree-caret-spacer"></span>
<label class="el-checkbox"><input type="checkbox"><span>合并表</span></label>
</div>
...
</div>
=== render fn 中包含 ===
el-checkbox: 1
group-title: 1
group-description: 1
tree-caret: 2
onGroupCheck: 1
```
### 结论
**模板 / 编译产物 / 运行时 DOM 三者一致**。模板没有任何问题。
### 仍存在问题的唯一可能:浏览器缓存
服务端 4 次发新版 HTML,每次用户刷新都还是看到只有 caret。最可能是:
1. 浏览器 HTTP 缓存命中(即便服务返新版,304 协商缓存)
2. 浏览器内 Vue / Element Plus CDN 缓存(如果走 CDN)
3. 用户没真的「硬刷」(F5 ≠ Ctrl+Shift+R)
### 给用户的诊断步骤
1. DevTools → Network → 勾「Disable cache」
2. 选中 index.html 那一行,看右边「Response」标签页,里面有没有 `checks-tree-table`
3. 如果没有,截图 DevTools Console,看有没有 Vue 编译错误或「analysis-tree 加载失败」
4. 如果仍然只有 caret,看 Elements 面板里 `.tree-row` div 的 innerHTML 是不是只有 `<button class="tree-caret">` —— 如果是,Vue prod build 在真实浏览器里编译坏;如果不是,是 CSS 隐藏了其它元素
### 未提交
按用户要求,待 review 后再提交。
---
## 2026-08-10 · 树表改造 v2:丢掉 el-table tree-props,自己渲染 caret
### 用户反馈
截图显示 v1 的样子:
```
>
☑ 基础检查(4/4) · 不需要调用大模型的基础数据质量检查
```
el-table 的 caret 单独占一行(自己的 div),checkbox 在另一行 —— 中间一大段空白,整行显得很高。
> 右箭头和checkbox放一行吧,显得紧凑一些
### 改动
丢掉 `<el-table :tree-props>` 整个机制,改用自定义 div + 自己渲染 caret + 自己管 expanded 状态。`form.steps` 仍是状态源,视图由 `flatCheckRows` 派生。
#### 1. JS [web/static/index.html](web/static/index.html)
- 新增 `expandedGroups: ref(new Set())` + watch 监听 analysisTree 首次加载,写入 default_expand: true 的组
- 移除 `treeTableRenderKey`(不再需要强制重渲 el-table,expanded 状态直接走响应式)
- 移除 `defaultExpandedKeys` computed
- `treeTableData` → `flatCheckRows`:返回 0 级 group + 1 级 leaf(按 `expandedGroups` 过滤 leaf 可见性)的平铺数组,每行带 `_level`
- 新增 `toggleGroupExpand(row)` 函数
- `toggleGroupCheck` 内部 `children` 引用改 `_checks`(避免 Vue 模板渲染对直接函数引用 children 的反应性风险)
- `setAllGroupsExpanded` 直接改 `expandedGroups.value` Set(不再用 re-key 技巧)
#### 2. 模板 [web/static/index.html:237-300](web/static/index.html#L237-L300)
- `<el-table>` 整个换为 `<div class="checks-tree-table" role="tree">`
- 每行:`<button class="tree-caret" v-if="is_group">` / `<span class="tree-caret-spacer">` + `<el-checkbox>`
- caret 由自己渲染 + 自己 bind `@click="toggleGroupExpand(row)"`
- group row + leaf row 共享 `.tree-row` 容器,仅靠类名控制差异
- group 的 description 改放在 `<el-tooltip>`(hover 显示)—— 不再吃行高
- 每行 padding-left 由 `_level * 24 + 8` 控制缩进
#### 3. CSS [web/static/style.css:166-271](web/static/style.css#L166-L271)
- 删旧 `.group-row` / `.leaf-row`
- 新增 `.tree-row`:行高固定 28px、`align-items: center`、gap: 4px
- `.tree-caret`:20x20 按钮,hover 高亮
- `.tree-caret-spacer`:叶子行占位(保持 checkbox 缩进对齐)
- `.tree-row .el-checkbox__label`:nowrap + ellipsis(长 title 截断)
- `.group-title` 加粗、`.group-count` 灰色等宽
- `.badge` `.llm-icon` 紧凑尺寸
### 关键决策
- 不用 el-table tree-props 后,行高 28px 单行就够;之前 28 个 indicator 卡片区域从 ~600px 缩到 ~280px
- caret 用 `<button>` 而不是 `<div>` —— 原生键盘 a11y + `aria-expanded`
- description 进 tooltip 而不是折叠面板 —— 简单的鼠标 hover 即可展开,复杂的「折叠 / 全文」模式不值得
### 验证
```
$ node scripts/verify_tree.cjs
23 passed, 0 failed
```
测试覆盖:flat 行数 / 缩进级别 / _checks 引用 / 三态切换 / toggle 单步 / 全部展开折叠 / data shape。
```
$ node scripts/verify_template.cjs
✓ compiled #0 (75639 chars) is valid JS
2 passed, 0 failed
```
```
$ curl http://localhost:8765/
HTML 含 flatCheckRows / tree-row / tree-caret(v2 已部署)
```
### 兼容性
- 后端契约零变化(`/api/analysis-tree` 接口、`analysisTree` JSON 结构不动)
- `form.steps` 仍为 flat step_id 列表
- `loadSteps` + `stepsList` 保留(required 兜底 / startJob 仍依赖)
### 已知未做
- keyboard navigation(↑/↓ 切换焦点、Space 切换 checkbox)—— 当前只有 button+checkbox 原生支持,未整体联动
- 折叠动画(CSS transition)—— 需要先确认会否影响 el-checkbox 的渲染
### 未提交
按用户要求,待 review 后再提交(与 v1 一并提交为一个提交)。
---
## 2026-08-10 · 「要跑的检查」改成 JSON 驱动的树形勾选(v1:后端 + JSON + UI 雏形,已在 v2 重写 UI)
### 用户原话
> 把要跑的检查这里,做成一个多级的、可以折叠的表格,每一项都只占一行,然后这个表格的层级关系用一个json文件维护,这个json是可以配置的,也就是我修改这个json文件就可以修改层级关系
### 设计目标
- 多级 + 可折叠 + 每个 item 一行(树表)
- 分组拓扑在 JSON 文件里;改分组只动 JSON
- 新增 / 改名 step 仍只动 orchestrator
- 旧 `form.steps`(flat step_id 列表)作为唯一状态源;分组只是视图
### 改动总览
#### 1. 新增 [web/configs/analysis_tree.json](web/configs/analysis_tree.json)
5 个分组(基础检查 / 国标字段规范 / 证件信息 / 身份信息 / 民政信息),每组 `default_expand` 控制初始折叠,children 数组只放 `step_id` 引用:
```json
{
"groups": [
{
"key": "basic", "title": "基础检查", "default_expand": true,
"children": [
{ "step_id": "merge_redundancy" },
{ "step_id": "empty_fields" },
...
]
},
...
]
}
```
加 `_comment` 字段说明「改分组只动这个文件,新增 step 只动 orchestrator」。
#### 2. 后端 [web/api/routes.py:81-131](web/api/routes.py#L81-L131)
新增 `/api/analysis-tree`:
- 读 JSON → 解析 groups
- 从 `get_step_defs()` 拿 step 元数据 → 合并到每个 leaf(title / llm_mode / required)
- 顶层补 `__ungrouped__` 组:JSON 没引到的 step 自动塞进去,不丢 step
- JSON 解析失败返回带 `_warning` 的空 groups,前端降级
#### 3. 前端 v1(el-table tree-props —— 已被 v2 重写,见本日第二条目)
- 用 `<el-table :tree-props="{children:'children'}">` 自带 caret + indent
- 单列 + slot 自渲染 group / leaf 内容
- toolbar:全部展开 / 全部折叠 / 全选 / 清空 + JSON 路径提示
- 用 `:key="treeTableRenderKey"` 在 el-table tree-props 不暴露 expanded 控制时强制重渲
- v2 决策:放弃 el-table 树表,自己渲染更紧凑
### 验证(v1)
- `python -c "..."` 接口返 28 检查项,分 5 组
- `node scripts/verify_tree.cjs` v1 版本 24 passed
- `node scripts/verify_template.cjs` 模板编译合法
### 兼容性
- `form.steps` 仍是 `list[str]` step_id(向后兼容)
- `/api/steps` 保留(startJob 内 required 兜底 + stepsList ref 仍在用)
- 旧 flat checkbox 列表彻底替换;不再有 28 条挤一行的卡片布局
### 已知未做
- 「未分组」组目前不抑制 JSON 误删的报警 —— 没考虑加 `ElementPlus.ElNotification.warning(missing_steps)`
- 暂未做「保存配置为 preset」之类的功能(用户也没要求)
### 未提交
按用户要求,待 review 后再提交(与 v2 一并提交)。
---
## 2026-08-07 · 国标分析:拆分细粒度 indicator + 新增 5 类分析对象 ## 2026-08-07 · 国标分析:拆分细粒度 indicator + 新增 5 类分析对象
### 用户原话 ### 用户原话
......
// 抓 Vue 编译出来的 render 函数,看 group/leaf 模板的产物
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 captured = [];
const origFunction = win.Function;
win.Function = function(...args) {
const body = args[args.length - 1];
if (typeof body === 'string' && body.length > 5000) {
captured.push(body);
}
return new origFunction(...args);
};
// 加载 Vue
const vue = fs.readFileSync('web/static/lib/vue.global.prod.js', 'utf8');
win.eval(vue);
const Vue = win.Vue;
// 构造一个最小 app:模板就是 tree 那段
const tpl = `
<div class="checks-tree-table">
<div v-for="row in rows" :key="row.key" class="tree-row" :class="row.is_group ? 'tree-row--group' : 'tree-row--leaf'">
<template v-if="row.is_group">
<button class="tree-caret" :aria-expanded="row.exp" @click="onClick(row)">
<span v-if="row.exp">▼</span>
<span v-else>▶</span>
</button>
<span class="checkbox-stub" @click="onGroupCheck(row, $event)">
<span class="group-title">{{ row.title }}</span>
<span class="group-count">({{ row.cc }}/{{ row.tc }})</span>
<span v-if="row.description" class="group-description">{{ row.description }}</span>
</span>
</template>
<template v-else>
<span class="tree-caret-spacer" />
<span class="checkbox-stub" @click="onLeafCheck(row, $event)">
<span>{{ row.title }}</span>
</span>
</template>
</div>
</div>
`;
const rows = [
{key:'g1', is_group:true, exp:true, cc:2, tc:3, title:'基础检查', description:'基础质量检查'},
{key:'l1', is_group:false, title:'A'},
{key:'g2', is_group:true, exp:false, cc:0, tc:2, title:'国标字段规范', description:'国标'},
];
const app = Vue.createApp({
data() { return { rows }; },
methods: { onClick: ()=>{}, onGroupCheck: ()=>{}, onLeafCheck: ()=>{} },
template: tpl,
});
const container = win.document.createElement('div');
win.document.body.appendChild(container);
try {
app.mount(container);
console.log('=== 渲染出的 DOM ===');
console.log(container.innerHTML);
} catch (e) {
console.error('mount err:', e.message);
}
console.log('\n=== 抓到的 render fn 数量 ===', captured.length);
if (captured[0]) {
console.log('render fn #0 前 2000 字符:');
console.log(captured[0].substring(0, 2000));
console.log('--- 中间 2000 字符 ---');
console.log(captured[0].substring(captured[0].length/2, captured[0].length/2 + 2000));
}
\ No newline at end of file
// 验证 caret 改成 span+unicode 后渲染正常
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;
const tpl = `
<div class="checks-tree-table">
<div v-for="row in rows" :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"
role="button"
tabindex="0"
:aria-expanded="row._expanded"
@click="onClick(row)"
@keydown.enter.prevent="onClick(row)"
>{{ row._expanded ? '▼' : '▶' }}</span>
<el-checkbox :model-value="row._checked" :indeterminate="row._indeterminate" @change="onGroupCheck(row, $event)">
<span class="group-title">{{ row.title }}</span>
<span class="group-count">({{ row._checked_count }}/{{ row._total_count }})</span>
<span v-if="row.description" class="group-description" :title="row.description">{{ row.description }}</span>
</el-checkbox>
</template>
<template v-else>
<span class="tree-caret-spacer" />
<el-checkbox :model-value="row.s" :disabled="row.r" @change="onLeafCheck(row, $event)">
<span>{{ row.title }}</span>
</el-checkbox>
</template>
</div>
</div>
`;
const rows = [
{key:'g1', is_group:true, _expanded:true, _checked:true, _indeterminate:false, _checked_count:2, _total_count:3, title:'基础检查', description:'基础数据质量检查'},
{key:'l1', is_group:false, s:true, r:false, title:'合并表'},
{key:'g2', is_group:true, _expanded:false, _checked:false, _indeterminate:false, _checked_count:0, _total_count:2, title:'国标', description:'国标校验'},
];
const ElCheckbox = {
props: ['modelValue', 'indeterminate', 'disabled'],
emits: ['change', 'update:modelValue'],
template: '<label class="el-checkbox"><input type="checkbox" /><span><slot /></span></label>',
};
const App = {
data() { return { rows }; },
methods: { onClick:()=>{}, onGroupCheck:()=>{}, onLeafCheck:()=>{} },
template: tpl,
};
const vueApp = Vue.createApp(App);
vueApp.component('el-checkbox', ElCheckbox);
const container = win.document.createElement('div');
win.document.body.appendChild(container);
vueApp.mount(container);
console.log('=== DOM ===');
console.log(container.innerHTML);
const carets = container.querySelectorAll('.tree-caret');
console.log(`\ncaret 数: ${carets.length}`);
carets.forEach((c, i) => console.log(` [${i}] text="${c.textContent}" role="${c.getAttribute('role')}" aria-expanded=${c.getAttribute('aria-expanded')}`));
\ No newline at end of file
// 用真实 Vue 3 prod build 编译 template,把 render 函数喂给 Function 看是否合法
// 用 el-checkbox 而不是 span,看 slot 内容能否正常 render
const fs = require('fs');
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html>', { runScripts: 'dangerously', url: 'http://localhost:8765/' });
const win = dom.window;
// 先加载 Vue
const vue = fs.readFileSync('web/static/lib/vue.global.prod.js', 'utf8');
win.eval(vue);
const Vue = win.Vue;
// 用 el-checkbox 组件名(Element Plus 提供的)跑完整 template
const tpl = `
<div class="checks-tree-table">
<div v-for="row in rows" :key="row.key" class="tree-row" :class="row.is_group ? 'tree-row--group' : 'tree-row--leaf'" :style="{ paddingLeft: row._level * 24 + 'px' }">
<template v-if="row.is_group">
<button type="button" class="tree-caret" :aria-expanded="row._expanded" @click="onClick(row)">
<span v-if="row._expanded">▼</span>
<span v-else>▶</span>
</button>
<el-checkbox :model-value="row._checked" :indeterminate="row._indeterminate" @change="onGroupCheck(row, $event)">
<span class="group-title">{{ row.title }}</span>
<span class="group-count">({{ row._checked_count }}/{{ row._total_count }})</span>
<span v-if="row.description" class="group-description" :title="row.description">{{ row.description }}</span>
</el-checkbox>
</template>
<template v-else>
<span class="tree-caret-spacer" />
<el-checkbox :model-value="row.s" :disabled="row.r" @change="onLeafCheck(row, $event)">
<span>{{ row.title }}</span>
<span v-if="row.llm_mode === 'required'" class="badge">需 LLM</span>
</el-checkbox>
</template>
</div>
</div>
`;
const rows = [
{key:'g1', is_group:true, _level:0, _expanded:true, _checked:true, _indeterminate:false, _checked_count:2, _total_count:3, title:'基础检查', description:'基础数据质量检查'},
{key:'l1', is_group:false, _level:1, s:true, r:false, title:'合并表', llm_mode:'none'},
{key:'g2', is_group:true, _level:0, _expanded:false, _checked:false, _indeterminate:false, _checked_count:0, _total_count:2, title:'国标', description:'国标校验'},
];
// 注册一个 mock el-checkbox 组件(只关心 children 是否 render)
const ElCheckbox = {
props: ['modelValue', 'indeterminate', 'disabled'],
emits: ['change', 'update:modelValue'],
template: '<label class="el-checkbox"><input type="checkbox" /><span><slot /></span></label>',
};
const App = {
data() { return { rows }; },
methods: {
onClick: () => console.log('caret click'),
onGroupCheck: (row, v) => console.log('group check', row.key, v),
onLeafCheck: (row, v) => console.log('leaf check', row.key, v),
},
template: tpl,
};
const vueApp = Vue.createApp(App);
vueApp.component('el-checkbox', ElCheckbox);
vueApp.component('el-icon', { template: '<i><slot /></i>' });
// 劫持 Function 抓 render
const captured = [];
const origFunction = win.Function;
win.Function = function(...args) {
const body = args[args.length - 1];
if (typeof body === 'string' && body.length > 2000) {
captured.push({ body, args: args.slice(0, -1) });
}
return new origFunction(...args);
};
const container = win.document.createElement('div');
win.document.body.appendChild(container);
try {
vueApp.mount(container);
console.log('=== DOM ===');
console.log(container.innerHTML);
console.log('\n=== render fn 数 ===', captured.length);
if (captured[0]) {
console.log('=== render fn #0 中是否含 el-checkbox / group-title ===');
const c = captured[0].body;
console.log(' el-checkbox:', (c.match(/el-checkbox/g) || []).length);
console.log(' group-title:', (c.match(/group-title/g) || []).length);
console.log(' group-description:', (c.match(/group-description/g) || []).length);
console.log(' tree-caret:', (c.match(/tree-caret/g) || []).length);
console.log(' onGroupCheck:', (c.match(/onGroupCheck/g) || []).length);
console.log(' createBlock:', (c.match(/createBlock/g) || []).length);
// 把产物写出来供查
fs.writeFileSync('scripts/compiled_tree.js', c);
console.log(' → scripts/compiled_tree.js');
}
} catch (e) {
console.error('mount err:', e.message);
console.error(e.stack);
}
\ No newline at end of file
// 验证:用 jsdom 实际跑一遍最新版 setup(),检查渲染后 DOM 里 tree-row 是不是真有
// group-title / el-checkbox / group-description。
//
// 为什么不能用之前的脚手架 eval:Vue 是外部 <script src="..."> 加载的全局,
// jsdom 默认不执行外部脚本。我们改用 vm 跑一遍。
const fs = require('fs');
const { JSDOM } = require('jsdom');
const html = fs.readFileSync('web/static/index.html', 'utf8');
const dom = new JSDOM(html, {
runScripts: 'dangerously',
pretendToBeVisual: true,
url: 'http://localhost:8765/',
resources: 'usable',
});
// 把 window 暴露给 vm context —— Vue + Element Plus 已经在 window 上
// 因为 runScripts=dangerously + resources=usable,会去拉 /static/lib/vue / element-plus
// 但 jsdom 没 HTTP 服务(localhost:8765 是用户的服务,我们可以访问),应该能拉
// 等 window 上有 Vue 后再继续
function waitForVue() {
return new Promise((resolve, reject) => {
let tries = 0;
const tick = () => {
if (dom.window.Vue && dom.window.ElementPlus) return resolve();
if (++tries > 200) return reject(new Error('Vue / ElementPlus 没加载'));
setTimeout(tick, 50);
};
tick();
});
}
(async () => {
try {
await waitForVue();
console.log('Vue loaded; Vue =', typeof dom.window.Vue, ', ElementPlus =', typeof dom.window.ElementPlus);
} catch (e) {
console.error(e.message);
process.exit(1);
}
// 抽出 inner script 并把 fetch 调用打补丁(jsdom 有 fetch 是 window 的,但 fetch target 是真实服务器,会失败不能影响 setup)
const m = html.match(/<script>([\s\S]*?)<\/script>/);
let code = m[1];
// 给 code 套一层 wrapper,使用 window 上下文(在 jsdom 里 window 是 global)
// 真实 index.html 的 inline script 直接调用了 createApp,但没 try/catch fetch —— 我们让它跑
try {
dom.window.eval(code);
console.log('setup() ran');
} catch (e) {
console.error('setup() ERROR:', e.message);
if (e.stack) console.error(e.stack.split('\n').slice(0, 5).join('\n'));
}
// 等一帧让 v-for 渲染
await new Promise(r => setTimeout(r, 500));
const doc = dom.window.document;
const treeTable = doc.querySelector('.checks-tree-table');
const treeRows = doc.querySelectorAll('.tree-row');
const groupTitles = doc.querySelectorAll('.group-title');
const groupDescriptions = doc.querySelectorAll('.group-description');
const groupCheckboxes = doc.querySelectorAll('.tree-row--group .el-checkbox');
const carets = doc.querySelectorAll('.tree-caret');
const leafCheckboxes = doc.querySelectorAll('.tree-row--leaf .el-checkbox');
console.log('\n=== 渲染结果 ===');
console.log('tree-table container:', !!treeTable);
console.log('tree-rows count:', treeRows.length);
console.log('carets:', carets.length);
console.log('group-titles:', groupTitles.length);
console.log('group-descriptions:', groupDescriptions.length);
console.log('group rows with .el-checkbox:', groupCheckboxes.length);
console.log('leaf rows with .el-checkbox:', leafCheckboxes.length);
if (treeRows.length > 0) {
console.log('\n--- 第 1 个 tree-row HTML ---');
console.log(treeRows[0].outerHTML.substring(0, 600));
}
if (groupTitles.length > 0) {
console.log('\n--- 第 1 个 group-title ---');
console.log(groupTitles[0].textContent);
}
process.exit(0);
})();
// 验证:树形勾选 computed 行为(flatCheckRows + tri-state + toggle + expand)
// 直接用普通对象(不模拟 Vue 反应式)—— 我们测的是算法逻辑。
function makeApp() {
const form = { steps: [] };
const analysisTree = { value: { groups: [
{
key: 'g1', title: '基础检查', default_expand: true,
description: 'desc1',
checks: [
{ step_id: 'a', title: 'A', llm_mode: 'none', required: false },
{ step_id: 'b', title: 'B', llm_mode: 'none', required: false },
{ step_id: 'c', title: 'C', llm_mode: 'none', required: false },
],
},
{
key: 'g2', title: 'G2', default_expand: false,
checks: [
{ step_id: 'd', title: 'D', llm_mode: 'none', required: false },
],
},
]}};
let expandedGroups = new Set(['g1']); // 模拟 default_expand 初始化
function flatCheckRows() {
const selected = new Set(form.steps);
const out = [];
for (const g of analysisTree.value.groups) {
const checks = g.checks;
let cc = 0;
for (const c of checks) if (selected.has(c.step_id)) cc++;
const total = checks.length;
const isExpanded = expandedGroups.has(g.key);
out.push({
key: g.key, _level: 0, is_group: true,
title: g.title, description: g.description, _checks: checks,
_checked: cc > 0 && cc === total,
_indeterminate: cc > 0 && cc < total,
_checked_count: cc, _total_count: total,
_expanded: isExpanded,
});
if (isExpanded) {
for (const c of 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,
});
}
}
}
return out;
}
function toggleStep(id, checked) {
const s = new Set(form.steps);
if (checked) s.add(id); else s.delete(id);
form.steps = Array.from(s);
}
function toggleGroupExpand(g) {
const next = new Set(expandedGroups);
if (next.has(g.key)) next.delete(g.key); else next.add(g.key);
expandedGroups = next;
}
function toggleGroupCheck(g, checked) {
const wasAll = g._checked;
const target = !wasAll;
const next = new Set(form.steps);
for (const c of g._checks) {
if (target) next.add(c.step_id); else next.delete(c.step_id);
}
form.steps = Array.from(next);
}
function setAllChecks(checked) {
if (!checked) { form.steps = []; return; }
const all = [];
for (const g of analysisTree.value.groups) for (const c of g.checks) all.push(c.step_id);
form.steps = all;
}
function setAllGroupsExpanded(expand) {
const next = new Set(expandedGroups);
for (const g of analysisTree.value.groups) {
if (expand) next.add(g.key); else next.delete(g.key);
}
expandedGroups = next;
}
return { form, expandedGroups,
flatCheckRows,
toggleStep, toggleGroupExpand, toggleGroupCheck,
setAllChecks, setAllGroupsExpanded };
}
let passed = 0, failed = 0;
function check(name, cond, msg = '') {
if (cond) { console.log(` ✓ ${name}`); passed++; }
else { console.log(` ✗ ${name} ${msg}`); failed++; }
}
// ── flat rows ──
console.log('[Test 1] flat rows: collapsed g2 → no leaf for g2');
{
const a = makeApp();
const rows = a.flatCheckRows();
// g1 expanded (default_expand=true), 3 leaves; g2 collapsed, 0 leaves
check('total rows = g1(1) + 3 g1-leaves + g2(1) = 5', rows.length === 5);
check('row 0 is g1 group _level=0', rows[0].key === 'g1' && rows[0]._level === 0 && rows[0].is_group === true);
check('row 1 is a leaf _level=1', rows[1].key === 'a' && rows[1]._level === 1 && rows[1].is_group === false);
check('row 3 is c leaf _level=1', rows[3].key === 'c' && rows[3]._level === 1);
check('row 4 is g2 group (no leaf for g2)', rows[4].key === 'g2' && rows[4].is_group === true && rows[4]._level === 0);
check('only g1-leaves visible (a/b/c)', rows.filter(r => !r.is_group).every(r => ['a','b','c'].includes(r.key)));
}
// ── expand g2 ──
console.log('[Test 2] toggleGroupExpand(g2) → leaf d appears');
{
const a = makeApp();
const g2 = a.flatCheckRows()[4]; // g2 group row index = 4 since 1 group + 3 leaves = 4, then g2 group at index 4
// actually recompute: rows = [g1, a, b, c, g2] (g2 collapsed → only group row, no leaves), so g2 is rows[4]
a.toggleGroupExpand(g2);
const rows = a.flatCheckRows();
check('after expand g2: rows.length = 6', rows.length === 6);
check('last row is d leaf', rows[5].key === 'd' && rows[5]._level === 1);
}
// ── tri-state: empty ──
console.log('[Test 3] empty selection');
{
const a = makeApp();
const rows = a.flatCheckRows();
const g1 = rows[0];
check('g1 unchecked, indeterminate=false', g1._checked === false && g1._indeterminate === false);
check('g1 count 0/3', g1._checked_count === 0 && g1._total_count === 3);
}
// ── partial ──
console.log('[Test 4] partial selection → indeterminate');
{
const a = makeApp();
a.toggleStep('a', true);
a.toggleStep('b', true);
const g1 = a.flatCheckRows()[0];
check('g1 indeterminate', g1._indeterminate === true);
check('g1 count 2/3', g1._checked_count === 2);
}
// ── partial → check all ──
console.log('[Test 5] partial → toggle group → all');
{
const a = makeApp();
a.toggleStep('a', true);
const g1 = a.flatCheckRows()[0];
a.toggleGroupCheck(g1, true);
const g1b = a.flatCheckRows()[0];
check('after click: all checked', g1b._checked === true);
check('3 steps in form', a.form.steps.length === 3);
}
// ── full → click → clear ──
console.log('[Test 6] full group → click → clear');
{
const a = makeApp();
a.toggleGroupCheck(a.flatCheckRows()[0], false); // empty → all
a.toggleGroupCheck(a.flatCheckRows()[0], true); // all → clear
check('cleared', a.form.steps.length === 0);
}
// ── expand/collapse all ──
console.log('[Test 7] setAllGroupsExpanded');
{
const a = makeApp();
a.setAllGroupsExpanded(true);
const rows = a.flatCheckRows();
// g1 group + 3 leaves + g2 group + 1 leaf = 6
check('both expanded → 6 rows', rows.length === 6);
a.setAllGroupsExpanded(false);
const rows2 = a.flatCheckRows();
check('both collapsed → only 2 groups', rows2.length === 2 && rows2.every(r => r.is_group));
}
// ── setAllChecks ──
console.log('[Test 8] setAllChecks');
{
const a = makeApp();
a.setAllChecks(true);
check('all checked → 4 steps', a.form.steps.length === 4);
a.setAllChecks(false);
check('all cleared → 0 steps', a.form.steps.length === 0);
}
// ── tree data shape ──
console.log('[Test 9] tree data shape');
{
const a = makeApp();
const rows = a.flatCheckRows();
check('group rows _level=0', rows.filter(r => r.is_group).every(r => r._level === 0));
check('leaf rows _level=1', rows.filter(r => !r.is_group).every(r => r._level === 1));
check('all group rows have _checks (array)', rows.filter(r => r.is_group).every(r => Array.isArray(r._checks)));
check('leaf rows have step_id', rows.filter(r => !r.is_group).every(r => typeof r.step_id === 'string'));
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
...@@ -220,6 +220,45 @@ LLM 调用封装在 [web/core/llm.py](web/core/llm.py)。配置 Key 见上方「 ...@@ -220,6 +220,45 @@ LLM 调用封装在 [web/core/llm.py](web/core/llm.py)。配置 Key 见上方「
> Step 注册是声明式 dict + decorator 风格;新增/删除/重排 Step 不需要改 orchestrator 主循环。 > Step 注册是声明式 dict + decorator 风格;新增/删除/重排 Step 不需要改 orchestrator 主循环。
## 📑 调整检查项分组(不改前端)
「要跑的检查」卡片里的多级 / 折叠 / 排序完全由一个 JSON 文件驱动,改分组只动它。
文件:[web/configs/analysis_tree.json](web/configs/analysis_tree.json)
```json
{
"groups": [
{
"key": "basic",
"title": "基础检查",
"description": "不需要调用大模型的基础数据质量检查",
"default_expand": true,
"children": [
{ "step_id": "merge_redundancy" },
{ "step_id": "empty_fields" },
...
]
},
...
]
}
```
字段说明:
| 字段 | 作用 |
|------|------|
| `key` | 分组 ID(前端无引用,但删除后整个组会消失)|
| `title` | 组标题,显示在折叠行 |
| `description` | 鼠标悬停在 title 上时通过原生 `title` 显示 |
| `default_expand` | 首次加载时是否展开 |
| `children` | 该组下的 step;每项只放 `step_id`(必须在 orchestrator 已注册)|
未被任何 group 引用的 step 会自动归到 `__ungrouped__` 组,避免静默丢失。
> 分组是视图层概念 —— 改 JSON 不影响后端执行什么 step,只影响 UI 怎么摆。后端契约不变。
--- ---
## 🛡️ 双数据库适配说明 ## 🛡️ 双数据库适配说明
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
...@@ -79,6 +80,75 @@ async def list_steps(): ...@@ -79,6 +80,75 @@ async def list_steps():
]) ])
# ── 列出带分组的检查项(前端树形勾选用) ──
# 分组拓扑从 web/configs/analysis_tree.json 读;
# 叶子节点的 title / llm_mode / required 从 orchestrator 已注册的 StepDef 解析。
# 这样新增 / 改名 step 只动 orchestrator;改层级只动 JSON。
@router.get("/analysis-tree")
async def analysis_tree():
from ..core.orchestrator import get_step_defs
cfg_path = Path(__file__).resolve().parents[2] / "web" / "configs" / "analysis_tree.json"
try:
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
except FileNotFoundError:
logger.warning(f"analysis_tree.json 缺失: {cfg_path}")
return {"groups": [], "_warning": "analysis_tree.json not found"}
except Exception as e:
logger.warning(f"analysis_tree.json 解析失败: {e}")
return {"groups": [], "_warning": f"parse error: {e}"}
by_id = {sd.step_id: sd for sd in get_step_defs()}
referenced: set[str] = set()
out_groups: list[dict] = []
for g in raw.get("groups", []):
children: list[dict] = []
missing: list[str] = []
for node in g.get("children", []):
sid = node.get("step_id")
if not sid:
continue
sd = by_id.get(sid)
if not sd:
missing.append(sid)
continue
referenced.add(sid)
children.append({
"step_id": sd.step_id,
"title": sd.title,
"description": sd.description,
"llm_mode": sd.llm_mode,
"required": sd.required,
"order": sd.order,
})
out_groups.append({
"key": g.get("key"),
"title": g.get("title"),
"description": g.get("description", ""),
"default_expand": bool(g.get("default_expand", False)),
"checks": children,
**({"missing_steps": missing} if missing else {}),
})
# 兜底:JSON 没引到的 step,自动追加到 "__未分组__" 组里,确保不丢
orphan = [s for sid, s in by_id.items() if sid not in referenced]
if orphan:
out_groups.append({
"key": "__ungrouped__",
"title": "未分组(JSON 未引到)",
"description": "analysis_tree.json 没有覆盖到的 step;建议补到对应分组",
"default_expand": True,
"checks": [
{
"step_id": s.step_id, "title": s.title, "description": s.description,
"llm_mode": s.llm_mode, "required": s.required, "order": s.order,
} for s in sorted(orphan, key=lambda x: x.order)
],
})
return {"groups": out_groups}
# ── 列出已注册国标标准 ── # ── 列出已注册国标标准 ──
@router.get("/standards", response_model=StandardsListResponse) @router.get("/standards", response_model=StandardsListResponse)
async def list_standards(): async def list_standards():
......
{
"_comment": "检查项分组配置。叶子节点用 step_id 引用 orchestrator 已注册的 step。改分组只动这个文件;新增 step 只动 orchestrator。两个源头互相解耦。",
"groups": [
{
"key": "basic",
"title": "基础检查",
"description": "不需要调用大模型的基础数据质量检查",
"default_expand": true,
"children": [
{ "step_id": "merge_redundancy" },
{ "step_id": "empty_fields" },
{ "step_id": "missing_comments" },
{ "step_id": "length_check" }
]
},
{
"key": "standards",
"title": "国标字段规范",
"description": "IND-001 ~ IND-004 系列:按 GB / GA 标准做字段值级别合规校验(格式 / 校验位 / 出生日期 / 15 位兼容 / 号段 / 编码存在性)",
"default_expand": true,
"children": [
{ "step_id": "std_ind_001_a" },
{ "step_id": "std_ind_001_b" },
{ "step_id": "std_ind_001_c" },
{ "step_id": "std_ind_001_d" },
{ "step_id": "std_ind_002_a" },
{ "step_id": "std_ind_002_b" },
{ "step_id": "std_ind_003_a" },
{ "step_id": "std_ind_003_b" },
{ "step_id": "std_ind_004_a" },
{ "step_id": "std_ind_004_b" }
]
},
{
"key": "id_info",
"title": "证件信息",
"description": "IND-101 ~ IND-204:证件类型枚举 + 4 种证件号码(身份证 / 护照 / 港澳台居住证 / 外国人永居)",
"default_expand": false,
"children": [
{ "step_id": "std_ind_101" },
{ "step_id": "std_ind_201" },
{ "step_id": "std_ind_202" },
{ "step_id": "std_ind_203" },
{ "step_id": "std_ind_204" }
]
},
{
"key": "identity",
"title": "身份信息",
"description": "IND-301 ~ IND-502:证件类型+号码一致性 / 跨表唯一性 / 姓名 / 出生日期",
"default_expand": false,
"children": [
{ "step_id": "std_ind_301" },
{ "step_id": "std_ind_302" },
{ "step_id": "std_ind_401" },
{ "step_id": "std_ind_402" },
{ "step_id": "std_ind_501" },
{ "step_id": "std_ind_502" }
]
},
{
"key": "civil",
"title": "民政信息",
"description": "IND-601 ~ IND-603:行政区划代码 / 民族代码 / 婚姻状况代码",
"default_expand": false,
"children": [
{ "step_id": "std_ind_601" },
{ "step_id": "std_ind_602" },
{ "step_id": "std_ind_603" }
]
}
]
}
...@@ -222,23 +222,70 @@ ...@@ -222,23 +222,70 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="24"> <el-col :span="24">
<el-form-item label="要跑的检查"> <el-form-item label="要跑的检查">
<el-checkbox-group v-model="form.steps"> <div class="checks-tree">
<el-checkbox v-for="s in stepsList" :key="s.id" :label="s.id" :disabled="s.required"> <div class="checks-tree-toolbar">
<span> <el-button size="small" text @click="setAllGroupsExpanded(true)">
<el-tooltip v-if="s.llm_mode === 'required'" placement="top" effect="dark"> <el-icon><Plus /></el-icon>全部展开
<template #content> </el-button>
<div style="max-width: 260px; line-height: 1.5"> <el-button size="small" text @click="setAllGroupsExpanded(false)">
此步骤需要调用大模型,可能会比较耗时 <el-icon><Minus /></el-icon>全部折叠
</el-button>
<el-button size="small" text @click="setAllChecks(true)">全选</el-button>
<el-button size="small" text @click="setAllChecks(false)">清空</el-button>
</div> </div>
<div class="checks-tree-table" role="tree">
<div
v-for="row in flatCheckRows"
:key="row.key"
class="tree-row"
:class="row.is_group ? 'tree-row--group' : 'tree-row--leaf'"
:style="treeRowStyle(row)"
>
<template v-if="row.is_group">
<span
class="tree-caret"
role="button"
tabindex="0"
:aria-expanded="row._expanded"
@click="toggleGroupExpand(row)"
@keydown.enter.prevent="toggleGroupExpand(row)"
@keydown.space.prevent="toggleGroupExpand(row)"
>{{ row._expanded ? '▼' : '▶' }}</span>
<el-checkbox
:model-value="row._checked"
:indeterminate="row._indeterminate"
@change="toggleGroupCheck(row, $event)"
>
<span class="group-title">{{ row.title }}</span>
<span class="group-count">({{ row._checked_count }}/{{ row._total_count }})</span>
<span
v-if="row.description"
class="group-description"
:title="row.description"
>{{ row.description }}</span>
</el-checkbox>
</template>
<template v-else>
<span class="tree-caret-spacer" />
<el-checkbox
:model-value="form.steps.includes(row.step_id)"
:disabled="row.required"
@change="toggleStep(row.step_id, $event)"
>
<span>{{ row.title }}</span>
<el-tooltip v-if="row.llm_mode === 'required'" placement="top" effect="dark">
<template #content>
<div style="max-width: 260px; line-height: 1.5">此步骤需要调用大模型,可能会比较耗时</div>
</template> </template>
<el-icon style="color: #e6a23c; margin-right: 4px; vertical-align: middle"><MagicStick /></el-icon> <el-icon class="llm-icon"><MagicStick /></el-icon>
</el-tooltip> </el-tooltip>
{{ s.title }} <el-tag v-if="row.llm_mode === 'required'" size="small" type="warning" effect="plain" class="badge">需 LLM</el-tag>
<el-tag v-if="s.llm_mode === 'required'" size="small" type="warning" effect="plain" style="margin-left: 4px; font-size: 11px; height: 18px; line-height: 18px;">需 LLM</el-tag> <el-tag v-if="row.required" size="small" type="danger" effect="dark" class="badge">必选</el-tag>
<el-tag v-if="s.required" size="small" type="danger" effect="dark" style="margin-left: 4px; font-size: 11px; height: 18px; line-height: 18px;">必选</el-tag>
</span>
</el-checkbox> </el-checkbox>
</el-checkbox-group> </template>
</div>
</div>
</div>
<div class="form-hint">带 ✨ 图标的步骤需调用大模型,耗时会较长(每步数秒到数十秒)。报告生成不在步骤列表里,每次分析完成后会自动生成 Markdown + Word 供下载。</div> <div class="form-hint">带 ✨ 图标的步骤需调用大模型,耗时会较长(每步数秒到数十秒)。报告生成不在步骤列表里,每次分析完成后会自动生成 Markdown + Word 供下载。</div>
</el-form-item> </el-form-item>
</el-col> </el-col>
...@@ -537,8 +584,23 @@ ...@@ -537,8 +584,23 @@
const testResult = ref(null); const testResult = ref(null);
// ── 步骤 & 标准 ── // ── 步骤 & 标准 ──
// stepsList 保留作 fallback / required 检查;分组后用 analysisTree 驱动 UI
const stepsList = ref([]); const stepsList = ref([]);
const standards = ref([]); const standards = ref([]);
// 分组树配置(来自 /api/analysis-tree → web/configs/analysis_tree.json)
const analysisTree = ref({ groups: [] });
// 用户展开/折叠状态。初始从 analysisTree 各组的 default_expand 派生;
// 一次写满,后续用户手动 toggle
const expandedGroups = ref(new Set());
// 监听 analysisTree 首次加载,初始化 expandedGroups
watch(() => analysisTree.value.groups, (groups) => {
if (!groups || !expandedGroups.value.size) {
const init = new Set();
for (const g of (groups || [])) if (g.default_expand) init.add(g.key);
if (init.size) expandedGroups.value = init;
}
}, { immediate: true });
// ── 数据字典(来自 connect/test 响应) ── // ── 数据字典(来自 connect/test 响应) ──
const tableSummary = ref([]); // 表级 const tableSummary = ref([]); // 表级
...@@ -571,6 +633,100 @@ ...@@ -571,6 +633,100 @@
failed: 'danger', cancelled: 'info', failed: 'danger', cancelled: 'info',
}[jobStatus.value.status] || 'info')); }[jobStatus.value.status] || 'info'));
// ── 检查项树形勾选(form.steps 是唯一状态源,view 派生) ──
// flatCheckRows: UI 实际渲染的平铺行(expanded 控制 leaf 可见性)
// 每行: {key, _level, is_group, [group/leaf 字段], _checked/_indeterminate/_checked_count, [_expanded]}
const flatCheckRows = computed(() => {
const selected = new Set(form.steps);
const out = [];
for (const g of (analysisTree.value.groups || [])) {
const checks = g.checks || [];
const total = checks.length;
let checkedCount = 0;
for (const c of checks) if (selected.has(c.step_id)) checkedCount++;
const isExpanded = expandedGroups.value.has(g.key);
out.push({
key: g.key,
_level: 0,
is_group: true,
title: g.title,
description: g.description,
// 供 toggleGroupCheck 用 — 直接传 children 引用,每次都重算 _checked 太重
_checks: checks,
_checked: total > 0 && checkedCount === total,
_indeterminate: checkedCount > 0 && checkedCount < total,
_checked_count: checkedCount,
_total_count: total,
_expanded: isExpanded,
});
if (isExpanded) {
for (const c of 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,
});
}
}
}
return out;
});
function treeRowStyle(row) {
return { paddingLeft: (row._level * 24 + 8) + 'px' };
}
function toggleStep(stepId, checked) {
const set = new Set(form.steps);
if (checked) set.add(stepId);
else set.delete(stepId);
form.steps = Array.from(set);
}
function toggleGroupExpand(groupRow) {
const next = new Set(expandedGroups.value);
if (next.has(groupRow.key)) next.delete(groupRow.key);
else next.add(groupRow.key);
expandedGroups.value = next;
}
function toggleGroupCheck(groupRow, checked) {
// 当前: 全部勾 / 部分勾 / 全未勾
// 点一下: 全勾 → 全清;其余 → 全勾(最直观)
const wasAll = groupRow._checked;
const target = !wasAll; // 半选/全未时点选 = true;全选时再点 = false
const next = new Set(form.steps);
for (const c of (groupRow._checks || [])) {
if (target) next.add(c.step_id);
else next.delete(c.step_id);
}
form.steps = Array.from(next);
}
function setAllChecks(checked) {
if (!checked) {
form.steps = [];
return;
}
const all = [];
for (const g of (analysisTree.value.groups || [])) {
for (const c of (g.checks || [])) all.push(c.step_id);
}
form.steps = all;
}
function setAllGroupsExpanded(expand) {
const next = new Set(expandedGroups.value);
for (const g of (analysisTree.value.groups || [])) {
if (expand) next.add(g.key); else next.delete(g.key);
}
expandedGroups.value = next;
}
// ── 结果 ── // ── 结果 ──
const jobResult = ref(null); const jobResult = ref(null);
const activeTab = ref('merge'); const activeTab = ref('merge');
...@@ -734,7 +890,7 @@ ...@@ -734,7 +890,7 @@
testResult.value = null; testResult.value = null;
} }
// ── 加载步骤 ── // ── 加载步骤(flat 列表 —— 用于 fallback / required 兜底) ──
async function loadSteps() { async function loadSteps() {
try { try {
const r = await fetch('/api/steps').then(r => r.json()); const r = await fetch('/api/steps').then(r => r.json());
...@@ -746,6 +902,17 @@ ...@@ -746,6 +902,17 @@
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
// ── 加载分组树配置(驱动树形勾选 UI) ──
async function loadAnalysisTree() {
try {
const r = await fetch('/api/analysis-tree').then(r => r.json());
analysisTree.value = r;
} catch (e) {
console.error('analysis-tree 加载失败: ', e);
analysisTree.value = { groups: [] };
}
}
// ── 加载标准 ── // ── 加载标准 ──
async function loadStandards() { async function loadStandards() {
try { try {
...@@ -1102,12 +1269,16 @@ ...@@ -1102,12 +1269,16 @@
onMounted(() => { onMounted(() => {
loadSteps(); loadSteps();
loadAnalysisTree();
loadStandards(); loadStandards();
}); });
return { return {
form, testing, submitting, connected, testResult, form, testing, submitting, connected, testResult,
stepsList, standards, stepsList, standards, analysisTree, expandedGroups,
flatCheckRows, treeRowStyle,
toggleStep, toggleGroupCheck, toggleGroupExpand,
setAllChecks, setAllGroupsExpanded,
tableSummary, dataDict, tableSummary, dataDict,
tableSearch, fieldSearch, tablesTableRef, tableSearch, fieldSearch, tablesTableRef,
tablesAll, filteredTables, filteredFields, fieldCountByTable, tablesAll, filteredTables, filteredFields, fieldCountByTable,
......
...@@ -163,6 +163,134 @@ body { ...@@ -163,6 +163,134 @@ body {
margin-left: 8px; margin-left: 8px;
} }
/* ── 检查项树形勾选 ── */
.checks-tree {
margin: 8px 0 4px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafbfc;
}
.checks-tree-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid #ebeef5;
background: #fff;
border-radius: 4px 4px 0 0;
flex-wrap: wrap;
}
.checks-tree-toolbar .el-button {
padding: 4px 8px;
}
.checks-tree-hint {
margin-left: auto;
font-size: 12px;
color: #909399;
}
.checks-tree-hint code {
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 3px;
padding: 1px 6px;
font-family: Consolas, Monaco, monospace;
color: #606266;
}
.checks-tree-table {
padding: 4px 0;
}
.tree-row {
display: flex;
align-items: center;
height: 28px; /* 紧凑行高,caret 和 checkbox 都在同一条基线 */
padding-right: 8px;
gap: 4px;
}
.tree-row:hover {
background: #f5f7fa;
}
.tree-row--group {
font-size: 14px;
}
.tree-row--leaf {
font-size: 13px;
color: #303133;
}
.tree-caret {
display: inline-block;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
cursor: pointer;
color: #909399;
flex-shrink: 0;
font-size: 12px;
user-select: none;
transition: color 0.15s;
}
.tree-caret:hover {
color: #409eff;
}
.tree-caret:focus {
outline: 1px solid #409eff;
outline-offset: -1px;
}
.tree-caret-spacer {
display: inline-block;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.tree-row .el-checkbox {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* 让子元素 ellipsis 能工作 */
}
.tree-row .el-checkbox__label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.group-title {
font-weight: 600;
color: #303133;
}
.group-count {
margin-left: 8px;
font-weight: normal;
color: #909399;
font-size: 12px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.group-description {
margin-left: 8px;
color: #909399;
font-size: 12px;
font-weight: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0; /* 让 ellipsis 在 flex 容器中生效 */
flex: 1; /* 占据剩余宽度 */
}
.tree-row .badge {
margin-left: 6px;
height: 18px;
line-height: 18px;
font-size: 11px;
padding: 0 6px;
}
.tree-row .llm-icon {
color: #e6a23c;
margin: 0 4px;
flex-shrink: 0;
}
/* ── 进度条(上) ── */ /* ── 进度条(上) ── */
.progress-block { .progress-block {
margin-bottom: 20px; margin-bottom: 20px;
......
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