Commit 714f714a authored by Data Governance Dev's avatar Data Governance Dev

refactor(web): 分析结果 Tab 通用协议 + 文档

把 6 个 tab (merge / redundancy / empty / missing / length / standards)
的展示抽出成通用协议,让每个 step 在 data 里嵌入 _protocol 自描述渲染,
前端一个通用 renderer 按协议渲染;新增 step 只需写 data + 嵌 _protocol,
零前端改动。

后端
- 新建 web/core/tab_protocol.py:dataclass (TabProtocol / KpiSpec /
  SummarySpec / TableSpec / ColumnSpec / RenderSpec / LinkageSpec) +
  _eval_path() 路径表达式求值 + make_protocol() 一站式 helper
- 5 个 step 文件在 return 里嵌入 _protocol
  - step2_merge_redundancy.py:2 tab(merge_candidates + redundancy_fields)
  - step4_empty_fields.py:1 tab + 2 子表 + linkage;summary 加 flat 副本
  - step5_missing_comments.py:1 tab
  - step6_length_check.py:1 tab
  - step7_standards.py:1 tab + 2 子表 + linkage
- orchestrator.py 删 _build_overview 里 KPI 聚合逻辑
- models.py OverviewStats 缩为 5 字段(database / host / db_type /
  total_tables / total_fields / executed_at)

前端 web/static/index.html 大改
- 8 个硬编码 KPI 卡 → v-for='k in displayedKpis'
- 6 个硬编码 <el-tab-pane>(~450 行)→ v-for='t in displayedTabs'
- 删 TAB_STEP_MAP / perTableStats / flatEmptyFields / perTableRows /
  filteredViolations
- 新增 displayedTabs / displayedKpis / tabStatus computed
- 新增 evalPathExpr / resolvePath / resolveRows / renderSummary /
  resolveRenderPath 工具函数
- 10 种 render.kind 在 template 里以 v-else-if 分发(text / code / tag /
  tag_list / two_line / three_line / progress / percent / ratio / number)
- 3 个嵌套展开行(merge / standards / empty_fields)保留硬编码 —
  展开行内容太特定,WORKLOG 记为后续协议扩展方向

文档与验证
- 新建 docs/TAB_PROTOCOL.md:完整协议规范 + 新增 step 模板 + 真实走读
- 新建 scripts/verify_protocol.cjs(12 case)+ verify_e2e.cjs(9 case)
  + verify_render.cjs(12 case)+ verify_template.cjs(2 case)——
  共 35 个 case 全过
- verify_template.cjs 用 jsdom + 真实 vue.global.prod.js + Function 劫持
  截获编译产物,专门兜底「页面变白 / 编译出坏 JS」回归
- WORKLOG 追加 v-else fallback 编译 bug 修复(Vue 3.5.x 在长 v-else-if
  链末尾输出 : () 无条件空 call expression)—— 把 v-else 改为
  v-else-if="!cond" 显式条件

构建
- package.json + package-lock.json:jsdom + @vue/compiler-dom(verify
  脚本的 dev 依赖)
- .gitignore 加 scripts/compiled_*.js(verify_template 失败时的 debug
  产物)
parent edd3d2e5
......@@ -23,6 +23,8 @@ workflow/config.yaml
# ── Node / 前端 (留作扩展) ──
node_modules/
dist/
# verify_template.cjs 失败时写出的 debug 产物
scripts/compiled_*.js
# ── 系统文件 ──
.DS_Store
......
# 分析结果 Tab 协议(Tab Protocol)
> 让每个分析 step 自描述怎么渲染,前端按协议动态生成 tab。新增 step 只需「写 data + 嵌 `_protocol`」,零前端改动。
## 0. 设计目标
| 目标 | 做法 |
|------|------|
| 新增 step 不改前端 | step 在自己 `data` 里嵌入 `_protocol`,前端通用 renderer 解析 |
| 用户勾选部分 step 时只显示对应 tab | 前端从 `_protocol.tabs[*].step_id` 过滤 `steps_planned` |
| 新增 step 自带 KPI | `_protocol.tabs[*].kpis[]` 声明哪些指标上浮到顶部 KPI 卡片 |
| 不破坏现有 step8_report | 协议是 `_protocol` 字段,原有数据字段保持不变 |
---
## 1. 数据形状
```jsonc
{
"_protocol": {
"step_id": "merge_redundancy", // 与 StepInfo.id 对应
"tabs": [
{
"key": "merge_candidates", // 唯一 tab key
"title": "表合并候选",
"kpis": [ // 可选:哪些指标上浮到顶部 KPI
{ "css_class": "kpi-merge",
"label": "合并候选",
"value": "len(this.merge_candidates)" }
],
"summary": { // 可选:顶部 alert
"type": "info", // info | warning | error | success
"template": "共 {{N}} 个候选,涉及 {{T}} 张表",
"vars": {
"N": "len(this.merge_candidates)",
"T": "..."
}
},
"tables": [
{
"title": null, // 可选子标题
"source": "this.merge_candidates", // 数据路径
"default_sort": { "prop": "similarity", "order": "descending" },
"max_height": 560,
"columns": [
{ "type": "expand", "template": "..." },
{ "prop": "id", "label": "ID", "width": 100 },
{ "prop": "priority", "label": "优先级",
"render": { "kind": "tag",
"value_map": {
"P1": { "type": "danger", "text": "P1" },
"P2": { "type": "warning", "text": "P2" }
} } }
]
}
],
"linkage": { // 可选:跨表联动(standards 用)
"source_table": 0,
"clickable_path": "details_with_violations",
"clickable_id_props": ["table", "field"],
"target_table": 1,
"field_map": { "table": "table_name", "field": "column_name" },
"default_filter": { "kind": "limit", "value": 200 }
}
}
]
},
// 真实数据(原有字段不变)
"merge_candidates": [...],
"redundancy_fields": [...],
"decommission_candidates": {...}
}
```
---
## 2. 路径表达式(`value` / `vars.<name>` / `source`)
只支持三种语法,避免任意代码执行风险:
| 语法 | 含义 |
|------|------|
| `this.x` | `data["x"]` |
| `this.x.y` | `data["x"]["y"]`(可多级) |
| `len(this.x)` | `len(data["x"])` |
后端 `web/core/tab_protocol.py::_eval_path` 实现,其他语法抛 `ValueError`。
---
## 3. 列定义(`column`)
| 字段 | 类型 | 含义 |
|------|------|------|
| `prop` | `string` | 数据字段名 |
| `label` | `string` | 列标题 |
| `type` | `expand \| index \| selection` | 特殊列类型(无 prop) |
| `width` | `number \| string` | 固定宽度 |
| `min_width` | `number \| string` | 最小宽度 |
| `fixed` | `boolean \| "left" \| "right"` | 固定列 |
| `align` | `left \| right \| center` | 对齐 |
| `show_overflow_tooltip` | `boolean` | 溢出 tooltip |
| `sortable` | `boolean` | 可排序 |
| `render` | `RenderSpec` | 自定义渲染(见 §4) |
| `template` | `string` | 仅 `type="expand"` 时使用,HTML 模板,支持 `{{ path }}` |
### 列 vs render 的关系
- 无 `render` → 直接 `{{ row[prop] }}`
- 有 `render.kind` → 前端按 §4 的渲染器输出
---
## 4. Render 原语(`render.kind`)
| kind | 渲染 | 关键参数 |
|------|------|----------|
| `text` | 纯文本 | — |
| `code` | `<code>` 等宽高亮 | — |
| `tag` | `<el-tag>` + 枚举映射 | `value_map: { "<value>": { type, text, effect?, italic? } }` |
| `tag_list` | 字符串数组 → 多个 `<el-tag>` | — |
| `two_line` | 主值 + 灰色副值 + 空值 fallback | `main_path, sub_path, sub_empty_text?` |
| `progress` | `<el-progress>` 百分比条 | `value_path`(0-1), `status_from_value?` |
| `percent` | 阈值染色百分比 | `value_path`, `precision?`, `thresholds?` |
| `ratio` | `num / den` 对比 | `num_path, den_path, emphasize?` |
| `number` | 千分位格式化 | `format?` |
### 各原语示例
#### `tag` — 冗余字段分类
```json
{ "prop": "classification", "label": "分类", "width": 140,
"render": { "kind": "tag",
"value_map": {
"true_redundancy": { "type": "danger", "text": "真冗余", "effect": "dark" },
"suspicious": { "type": "warning", "text": "疑似冗余", "effect": "dark" },
"common_business": { "type": "success", "text": "业务共享", "effect": "plain" },
"common_base": { "type": "info", "text": "通用基础", "effect": "plain" }
},
"fallback": { "type": "danger", "text": "LLM 失败", "italic": true } } }
```
#### `two_line` — 表名 + 表注释
```json
{ "prop": "table_name", "label": "表名 / 表注释", "min_width": 220,
"render": { "kind": "two_line",
"main_path": "table_name",
"sub_path": "table_comment",
"sub_empty_text": "(无表注释)" } }
```
#### `percent` — 国标违规率
```json
{ "prop": "violation_rate", "label": "违规率", "width": 140, "align": "center",
"render": { "kind": "percent",
"value_path": "violation_rate",
"precision": 1,
"thresholds": [
{ "gt": 0.5, "color": "#f56c6c" },
{ "gt": 0.2, "color": "#e6a23c" },
{ "gt": -1, "color": "#67c23a" }
] } }
```
`gt > value` 用第一个匹配的颜色(按阈值从大到小排列)。
#### `progress` — 空值率进度条
```json
{ "prop": "empty_rate", "label": "空值率", "width": 220,
"render": { "kind": "progress",
"value_path": "empty_rate",
"status_from_value_path": "level" } }
```
`status_from_value_path` 读取该字段值决定 progress 状态:`"high"` → exception、`"mid"` → warning。
#### `ratio` — 实际长度 / 标准长度
```json
{ "label": "长度 (实际/标准)", "width": 130, "align": "center",
"render": { "kind": "ratio",
"num_path": "actual_length", // 红色
"den_path": "required_length",
"emphasize": "num" } }
```
#### `tag_list` — 合并候选涉及的表
```json
{ "prop": "tables", "label": "涉及表", "width": 300,
"render": { "kind": "tag_list" } }
```
---
## 5. 跨表联动(`linkage`)
用于「点击 A 表某项 → 过滤 B 表」场景(仅 standards tab 当前用)。
```json
"linkage": {
"source_table": 0, // 索引到 tables[]
"clickable_path": "details_with_violations", // A 表 row 里可点击的数组字段
"clickable_id_props": ["table", "field"], // 点击项的 ID 字段
"target_table": 1, // 被过滤的 B 表
"field_map": { // A → B 的字段映射
"table": "table_name",
"field": "column_name"
},
"default_filter": { "kind": "limit", "value": 200 } // 没点击时 B 显示前 N 条
}
```
前端实现:
- `selectedField = ref(null)` 当前选中的 `{ table, field }`
- `filteredViolations = computed(...)`:根据 `linkage.field_map` 过滤 target table
- 点击 A 表里 `clickable_path` 数组的某项 → 设置 `selectedField`
- 顶部"违规明细"标题旁加 closable `<el-tag>` 显示当前筛选
---
## 6. KPI 贡献(`kpis`)
KPI 顶部 8 张卡的来源从硬编码改为协议驱动:
```json
"kpis": [
{ "css_class": "kpi-merge",
"label": "合并候选",
"value": "len(this.merge_candidates)" }
]
```
前端:
- 渲染时遍历 `jobResult.sections[*]._protocol.tabs[*].kpis`
- **过滤** `step_id ∈ steps_planned`
- **去重**:同名 `css_class` 只保留第一个(避免冲突)
`overview` 字段剩余保留:`database / host / db_type / total_tables / total_fields / executed_at` —— 这些是报告头 / 通用概览用,不是 KPI 计数。
---
## 7. 新增 step 模板
假设要新增一个名为 `index_check` 的检查 step:
### 7.1 写 step 实现 `web/core/step_impl/step9_index_check.py`
```python
def run_step9(dict_data: dict, log=print, table_filter=None) -> dict:
# ... 业务逻辑
return {
# ── 协议 ──
"_protocol": {
"step_id": "index_check",
"tabs": [
{
"key": "index_issues",
"title": "索引问题",
"kpis": [
{"css_class": "kpi-index",
"label": "索引问题",
"value": "len(this.issues)"}
],
"summary": {
"type": "warning",
"template": "扫描 {{N}} 张表,发现 {{C}} 个索引问题",
"vars": {"N": "len(this.scanned_tables)",
"C": "len(this.issues)"}
},
"tables": [{
"source": "this.issues",
"columns": [
{"prop": "table_name", "label": "表名", "width": 200,
"render": {"kind": "two_line",
"main_path": "table_name",
"sub_path": "table_comment"}},
{"prop": "index_name", "label": "索引", "width": 180},
{"prop": "issue_type", "label": "问题类型", "width": 120,
"render": {"kind": "tag",
"value_map": {"redundant": {"type":"warning","text":"冗余"},
"missing": {"type":"danger","text":"缺失"}}}}
]
}]
}
]
},
# ── 数据 ──
"scanned_tables": [...],
"issues": [...],
}
```
### 7.2 在 orchestrator 注册 `web/core/orchestrator.py`
```python
def _run_index_check(*, cfg, dict_data, llm, log, cancel_event, table_filter):
from .step_impl.step9_index_check import run_step9
data = run_step9(dict_data, log=log, table_filter=table_filter)
return {"section_key": "index_issues", "data": data}
register_step(
step_id="index_check",
title="索引健康检查",
description="...",
requires_db=True, llm_mode="none", required=False, order=6,
fn=_run_index_check,
)
```
### 7.3 前端零改动
重启 `python -m web.app` 即可:
- 「要跑的检查」列表自动多一项「索引健康检查」
- 用户勾选后,分析结果 tab 自动多一项「索引问题」
- 顶部 KPI 自动多一张「索引问题」
---
## 8. 实施细节备忘(实施者参考)
### 后端 dataclass 镜像
`web/core/tab_protocol.py` 提供 Python dataclass + 一个 `make_protocol(...)` helper,让 step 写得类型安全。可选 —— 也可以直接 return dict。
### 前端 renderer 实现要点
- 用 `v-for` over `displayedTabs`(从 sections[*]._protocol.tabs 过滤 step_id)
- 列渲染通过 `v-if` 三元链或 `<component :is>`,按 `col.render.kind` 分发
- 不引入 Vue `h()` 函数 —— 用模板字符串更易读
- 缺字段 / 协议错误时显示「(协议错误:缺少 XXX)」,不崩溃
### 已发现的 bug(顺手修)
| bug | 现状 | 修复 |
|-----|------|------|
| `step8_report.py` 读 `sections["redundancy_fields"]` 恒空 | 用 `field` 路径从 `merge_candidates` 取 | 本次**不修**(step8 不在范围),WORKLOG 标注后续任务 |
| merge_candidates `row_counts` 死代码 | 前端模板 L367-373 渲染,后端不产出 | 协议 expand template 不渲染 `row_counts` |
| missing_comments `unpredictable_sample` key | docstring 写错但代码一致 | 文档以代码为准 |
---
## 9. 协议版本
- 当前:`1.0`
- 后续扩展方向:chart(`render.kind="chart"`)、inline-edit、`custom` 特判
\ No newline at end of file
......@@ -319,3 +319,128 @@ Vue 3 规定 `v-else` / `v-else-if` **必须是上一个 v-if/v-else-if 元素
### 未提交
按用户要求,待 review 后再提交。
---
## 2026-08-07 Tab Protocol 重构(分析结果通用协议)
### 目标(用户原话)
> 把分析结果的tab页的展示抽出成一个通用协议并形成文档,当用户选中部分检验项目的时候,只需要按照通用tab页标准准备数据,以及前端分析结果只显示选中了的项目,这样后续增加分析项目的时候只需要对着这个协议返回数据给前台就可以实现tab页扩展。
### 改动概览
#### 1. 新增 `web/core/tab_protocol.py`(~250 行)
- `dataclass(frozen=True)` 镜像:TabProtocol / KpiSpec / SummarySpec / TableSpec / ColumnSpec / RenderSpec / LinkageSpec / TagStyle / Threshold
- `_eval_path(expr, data)`:支持 `this.x / this.x.y / len(this.x)` 三种路径(regex 校验,不做任意表达式求值)
- `inject_protocol(data, *, step_id, tabs)`:给 step return dict 注入 `_protocol` 字段
- 自检通过:`python -m web.core.tab_protocol`
#### 2. `web/core/models.py`
- `OverviewStats` 字段从 12 个缩到 6 个:`database / host / db_type / total_tables / total_fields / executed_at`
- 8 个 step-specific KPI(high_empty_fields / missing_comments / length_issues / standard_violations / merge_candidates / redundancy_fields / mid_empty_fields / standards_applied)从 overview 移除,由各 step 在 `_protocol.kpis[]` 自描述
#### 3. `web/core/orchestrator.py`
- `_build_overview(sections, dict_data, cfg)` → `_build_overview(dict_data, cfg)`:不再做 KPI 聚合(sections 参数也删了)
- 各 step 在 `_run_*` 中返回的 `data` 已带 `_protocol`,无需 orchestrator 干预
#### 4. 5 个 step 文件加协议
- `step2_merge_redundancy.py`:2 tab(`merge_candidates` 带嵌套 expand、`redundancy_fields` 含 tag 分类 + source 来源)
- `step4_empty_fields.py`:1 tab 含 2 子表(flat 高空/中空 + 按表聚合含嵌套 expand);summary 加 `flat_high_empty_fields` / `flat_mid_empty_fields` 副本
- `step5_missing_comments.py`:1 tab(推测注释 + 置信度 + 依据)
- `step6_length_check.py`:1 tab(含 three_line 标准/规则/依据 渲染 + ratio actual/required)
- `step7_standards.py`:1 tab 含 2 子表 + `LinkageSpec`(source_table=0 violations_by_standard、target_table=1 violations、field_map={table→table_name, field→column_name}、default_filter=limit 200)
- 每个 step 加 `_wrap(data)` helper;fallback / 错误路径同样走协议
#### 5. 前端 `web/static/index.html` 大改
**删除**:
- 8 个硬编码 KPI 卡 → 改为 `v-for="k in displayedKpis"`(仅 2 张表/字段总数固定 + step 贡献动态)
- 6 个硬编码 `<el-tab-pane>`(~450 行重复 HTML)→ 改为 `v-for="t in displayedTabs"`
- `TAB_STEP_MAP` 硬编码映射(tab key → stepId 推不出来)
- `perTableStats / flatEmptyFields / perTableRows` 派生 computed(empty_fields 改为从 data 读取 flat 副本)
- `filteredViolations` computed(由新 `resolveRows()` 接管)
**新增**:
- `displayedTabs` computed:遍历 `sections[*]._protocol.tabs`,过滤 `step_id ∈ steps_planned`
- `displayedKpis` computed:聚合所有 tab 的 `kpis[]`,按 spec 求值(用 `evalPathExpr`)
- `evalPathExpr / resolvePath / resolveRows / renderSummary` 工具函数
- `resolveRender(col, row)`:按 `render.kind` 分发到 10 个 render 原语(text / code / tag / tag_list / two_line / three_line / progress / percent / ratio / number)
- `ColumnRender` 函数组件:把 VNode 渲染到 `<el-table-column>` 的 cell slot(绕过 `<component :is>` 不接受 VNode 的限制)
- 3 个嵌套展开行(merge / standards / empty_fields)保留 Vue 原生 template(展开行内容太特定,无法完全模板化;协议 expand 字段保留作为文档/参考)
#### 6. 验证脚本 `scripts/`
- `verify_protocol.cjs`:Node + vm 提取前端 4 个核心函数,单测 12 个 case(evalPathExpr 简单路径 / len() / 数字 / 嵌套;resolveRows 简单 + linkage 过滤;renderSummary 模板替换 + 千分位)
- `verify_e2e.cjs`:mock Vue.ref / Vue.computed,跑完整 setup(),断言 displayedTabs 数量/keys/stepId 过滤,displayedKpis 数值求值 → 9 cases
- `verify_render.cjs`:mock h()/resolveComponent(),跑 resolveRender 对 10 个 render kind + fallback → 17 cases
- 总计 **38 个 case 全部通过**
### 已知未做(后续任务)
- `step8_report.py` 仍是第二套硬编码(`sections.get("redundancy_fields", {})` 幽灵 key、`evidence/similar_to` 字段不产),下次单独迁移到协议读取
- 协议 expand 行内 field-link 点击高亮需要前端特判(协议不支持表达 onclick handler)
### 未提交
按用户要求,待 review 后再提交。
---
## Bug 修复:页面变白(Vue 模板编译器生成坏 JS)
### 现象
用户在浏览器刷新页面后报告「页面又白了」,DevTools 控制台:
```
VM6572:1022 Uncaught SyntaxError: Unexpected token ')' (at VM6572:1022:64)
at Function (<anonymous>)
at aX (vue.global.prod.js:13:318)
```
Vue 编译器生成的 render 函数里包含「`()`」—— 空 call expression,触发 SyntaxError,整个 app 挂载失败。
### 根因分析(两轮)
**第一轮错误诊断**:以为问题在重构时引入的 `ColumnRender` 组件(模块级 + 闭包)。删除该组件 + 在 template 里内联 10 个 `<template v-else-if>` 分支后,verify_template 用 `@vue/compiler-dom.compile()` 检查生成代码合法 —— **但用户页面仍然白**。
**真正的根因**:v-else 在深层 v-else-if 链末尾触发了 Vue 3 编译器的输出 bug。template 长这样:
```vue
<template v-if="...">...</template>
<template v-else-if="...">...</template> <!-- ×10 个分支 -->
<template v-else-if="col.render && col.render.kind === 'number'">...</template>
<template v-else>...</template> <!-- ← 这一行 -->
```
最后一个 `v-else` 没有条件。Vue 3 prod build(3.5.40)把它编译成嵌套三元链最末尾的 `: ()` —— 一个空 call expression,不是合法 JS:
```js
... : (col.render && col.render.kind === 'number')
? { name: "default", fn: ..., key: "12" }
: () ← SyntaxError: Unexpected token ')'
? { name: "default", fn: ..., key: "13" }
```
`@vue/compiler-dom`(npm 上的 3.5.41)单独跑同样的模板输出不带 bug 的代码(`95635` chars vs Vue prod 的 `69296` chars),所以之前用 `@vue/compiler-dom` 自测没抓到 —— 必须用真实 Vue prod build + jsdom 才能复现。
### 修复
template 末位 `<template v-else>` 改为显式条件:
```vue
<template v-else-if="!col.render || !col.render.kind" #default="{ row }">
{{ row[col.prop] }}
</template>
```
绕开 Vue 编译器对 `v-else` 在长 v-else-if 链末尾的输出 bug。语义等价(之前所有 `v-else-if` 都没匹配时才落到这一支)。
### 验证
重写 `scripts/verify_template.cjs`:
1. jsdom 跑真实 `vue.global.prod.js`(不是 npm 上拆出来的 `@vue/compiler-dom`)
2. 劫持 `window.Function` 构造器截获 Vue 编译出的 render 函数代码
3. 用 `new Function(code)` 验证每段代码是合法 JS
4. **失败时**会把那段代码写到 `scripts/compiled_N.bad.js` 方便排查
执行:
```
$ node scripts/verify_template.cjs
✓ captured at least 1 compiled render fn
✓ compiled #0 (69296 chars) is valid JS
2 passed, 0 failed
```
四个验证脚本合计 **35 个 case 全过**(2 template + 12 protocol + 12 render + 9 e2e)。
### 关键教训
1. **Vue prod build ≠ npm 上的 compiler-dom**:compiler-sfc 拆出来的 compiler-dom 跟 Vue runtime 里 bundle 的 compiler 版本/编译选项可能不同;做模板编译验证必须用真实 prod build。
2. **v-else 在长 v-else-if 链末尾要谨慎**:超过 ~5 层以后,Vue 3.5.x 的编译器会输出 `: ()`。要么用 `v-else-if="!cond"` 显式收尾,要么减少分支数。
3. **Function 劫持是个便宜的验证手段**:想要捕获「模板编译 → render 函数构造」这一步的 SyntaxError,劫持 `window.Function` 抓大于 1KB 的代码体即可。
### 未提交
按用户要求,待 review 后再提交。
{
"name": "数据治理",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@vue/compiler-dom": "^3.5.41",
"jsdom": "^30.0.1"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.2.1",
"@csstools/css-color-parser": "^4.1.9",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
"integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.41",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-core/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
"integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"dev": true,
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.10"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
{
"devDependencies": {
"@vue/compiler-dom": "^3.5.41",
"jsdom": "^30.0.1"
}
}
// scripts/verify_e2e.cjs
// E2E 验证:mock Vue + ElementPlus,跑 setup() 并断言 displayedTabs/displayedKpis。
//
// 我们不真正 mount DOM(无 jsdom),而是劫持 Vue 的 computed / ref 让返回的对象挂到 global,
// 然后主动触发 getter 读取 displayedTabs / displayedKpis 的最终值。
const fs = require('fs');
const path = require('path');
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'static', 'index.html'), 'utf8');
const scriptMatch = html.match(/<script>\s*\n([\s\S]*?)<\/script>/);
const scriptBody = scriptMatch[1];
// 抽出 setup 函数体(兼容 `setup() {` 方法简写 和 `function setup() {` 声明)
// 始终以 `function setup() { ... }` 形式返回,调用方需包成 setup() 调用
function extractFn(name) {
// 检测是否存在 `function ` 前缀
const rePrefix = new RegExp(`function\\s+${name}\\s*\\(\\s*([^)]*?)\\s*\\)\\s*\\{`);
const mPref = scriptBody.match(rePrefix);
const isFnDecl = !!mPref;
const re = new RegExp(`(?:function\\s+)?${name}\\s*\\(\\s*([^)]*?)\\s*\\)\\s*\\{`);
const m = scriptBody.match(re);
if (!m) throw new Error(`${name} not found`);
const headerEnd = m.index + m[0].length;
let depth = 1, i = headerEnd;
for (; i < scriptBody.length; i++) {
const c = scriptBody[i];
if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) {
const body = scriptBody.slice(m.index + (isFnDecl ? 0 : 0), i + 1);
// 若原本是方法简写(无 function 前缀),补上 function 关键字
return isFnDecl ? body : 'function ' + body;
}
}
}
throw new Error(`${name} unterminated`);
}
const setupBody = extractFn('setup');
console.log(`Extracted setup() (${setupBody.length} bytes)`);
// 把 setup 的 return 改成同时暴露到 global,然后调用方再注入 mock 数据
// 用 lastIndexOf 找最后一个 `return {`(setup 的实际 return)
const lastReturnIdx = setupBody.lastIndexOf('return {');
if (lastReturnIdx < 0) throw new Error('setup() has no return {');
const hookedSetup = setupBody.slice(0, lastReturnIdx) +
'globalThis.__jobResult = jobResult; globalThis.__jobStatus = jobStatus; globalThis.__stepsList = stepsList; globalThis.__activeTab = activeTab; globalThis.__selectedField = selectedField; globalThis.__displayedTabs = displayedTabs; globalThis.__displayedKpis = displayedKpis; globalThis.__tabStatus = tabStatus; ' +
setupBody.slice(lastReturnIdx);
// Mock 数据
const fakeJobResult = {
sections: {
merge_candidates: {
_protocol: {
step_id: 'merge_redundancy',
tabs: [
{
key: 'merge_candidates', title: '表合并候选',
kpis: [{ css_class: 'kpi-merge', label: '合并候选',
value: 'len(this.merge_candidates)' }],
tables: [{ source: 'this.merge_candidates',
columns: [{ prop: 'id', label: 'ID' }] }],
},
{
key: 'redundancy_fields', title: '冗余字段',
kpis: [{ css_class: 'kpi-redundancy', label: '冗余字段',
value: 'len(this.redundancy_fields)' }],
tables: [{ source: 'this.redundancy_fields',
columns: [{ prop: 'field', label: '字段' }] }],
},
],
},
merge_candidates: [{ id: 'M-1' }, { id: 'M-2' }],
redundancy_fields: [{ field: 'a' }, { field: 'b' }, { field: 'c' }],
},
empty_fields: {
_protocol: {
step_id: 'empty_fields',
tabs: [{
key: 'empty_fields', title: '大范围空字段',
kpis: [
{ css_class: 'kpi-empty', label: '高空',
value: 'this.summary.high_empty_fields_count' },
],
tables: [{ source: 'this.summary.flat_high_empty_fields',
columns: [{ prop: 'column_name', label: '字段' }] }],
}],
},
summary: { high_empty_fields_count: 7,
flat_high_empty_fields: [{ column_name: 'f1' }] },
},
length_issues: {
_protocol: {
step_id: 'length_check',
tabs: [{
key: 'length_issues', title: '字段长度异常',
kpis: [],
tables: [{ source: 'this.issues', columns: [] }],
}],
},
issues: [],
},
standard_violations: {
_protocol: {
step_id: 'standards',
tabs: [{
key: 'standards', title: '国家标准校验',
kpis: [],
tables: [
{ source: 'this.violations_by_standard', columns: [] },
{ source: 'this.violations', columns: [] },
],
linkage: {
source_table: 0, target_table: 1,
clickable_path: 'details_with_violations',
clickable_id_props: ['table', 'field'],
field_map: { table: 'table_name', field: 'column_name' },
default_filter_kind: 'limit', default_filter_value: 200,
},
}],
},
violations_by_standard: [{ standard: 'GB_11643' }],
violations: [],
},
},
overview: { total_tables: 10, total_fields: 100, host: 'localhost', database: 'smart-build' },
};
const fakeJobStatus = { steps_planned: ['merge_redundancy', 'empty_fields', 'standards'] };
// 我们用 vm 沙箱执行 setup()。
//
// 关键劫持:Vue.computed(fn) 必须返回一个 reactive 对象,访问 .value 时真的调用 fn。
// 但 fn 闭包了 ref.value —— 我们劫持 ref:让 ref(x) 返回一个 wrapper,setup() 拿到后
// 我们通过全局 setter 替换内部值。
const vm = require('vm');
const refs = {}; // 名字 -> reactive wrapper
const ctx = {
console,
// ── Vue mock ──
ref: (v) => {
const r = { __isRef: true, _v: v };
Object.defineProperty(r, 'value', {
get() { return r._v; },
set(nv) { r._v = nv; },
});
return r;
},
reactive: (o) => o,
computed: (fn) => {
const c = { __isComputed: true };
Object.defineProperty(c, 'value', { get() { return fn(); } });
return c;
},
watch: () => {},
onMounted: () => {},
nextTick: () => Promise.resolve(),
h: (type, props, children) => ({ __vnode: true, type, props, children }),
resolveComponent: (name) => name,
createApp: (def) => ({
use: () => {},
component: () => {},
mount: () => {},
}),
// ── 其他 mock ──
ElementPlus: { ElMessage: { error: () => {}, success: () => {}, warning: () => {} } },
ElementPlusIconsVue: {},
document: { createElement: () => ({ click: () => {}, remove: () => {} }),
body: { appendChild: () => {}, removeChild: () => {} } },
URL: { createObjectURL: () => '', revokeObjectURL: () => {} },
Blob: function() { return {}; },
fetch: () => Promise.resolve({ ok: true, blob: () => Promise.resolve({}) }),
setTimeout, clearTimeout, setInterval, clearInterval, Promise,
};
vm.createContext(ctx);
const setupWrap = `
${hookedSetup}
`;
try {
vm.runInContext(
`(function(){ ${setupWrap}; setup(); })()`,
ctx, { filename: 'setup-test.js' }
);
} catch (e) {
console.error('setup() threw:', e.message);
console.error('Stack:', e.stack.split('\n').slice(0, 12).join('\n'));
process.exit(1);
}
console.log('After setup, global keys:', Object.keys(ctx).filter(k => k.startsWith('__')));
console.log(' __jobResult type:', typeof ctx.__jobResult);
// 注入 mock 数据
ctx.__jobResult.value = fakeJobResult;
ctx.__jobStatus.value = fakeJobStatus;
// 断言
let pass = 0, fail = 0;
function eq(actual, expected, label) {
const a = JSON.stringify(actual), b = JSON.stringify(expected);
if (a === b) { console.log(` ✓ ${label}`); pass++; }
else { console.error(` ✗ ${label}: got ${a}, want ${b}`); fail++; }
}
console.log('\n--- E2E displayedTabs ---');
const tabs = ctx.__displayedTabs.value;
eq(tabs.length, 4, '4 tabs (merge + redundancy + empty + standards)');
eq(tabs.map(t => t.tabKey).sort(),
['empty_fields', 'merge_candidates', 'redundancy_fields', 'standards'].sort(),
'tab keys include merge + redundancy + empty + standards');
// 未被勾选的 length_check 不应该出现
const hasLength = tabs.some(t => t.tabKey === 'length_issues');
eq(hasLength, false, 'length_check not in steps_planned → tab filtered out');
// 每个 tab 都带 stepId / sectionKey
const allHaveStepId = tabs.every(t => t.stepId && t.sectionKey);
eq(allHaveStepId, true, 'every tab has stepId + sectionKey');
// standards tab 应该带 linkage
const stdTab = tabs.find(t => t.tabKey === 'standards');
eq(stdTab && stdTab.linkage && stdTab.linkage.target_table === 1, true,
'standards tab carries linkage with target_table=1');
console.log('\n--- E2E displayedKpis ---');
const kpis = ctx.__displayedKpis.value;
// 期望:merge tab 贡献 1, redundancy tab 贡献 1, empty tab 贡献 1, standards 贡献 0
// 总计 3 个 KPI
eq(kpis.length, 3, '3 KPIs total (merge + redundancy + empty)');
eq(kpis.find(k => k.css_class === 'kpi-merge')?.value, 2, 'kpi-merge value = 2 (len(merge_candidates))');
eq(kpis.find(k => k.css_class === 'kpi-redundancy')?.value, 3, 'kpi-redundancy value = 3');
eq(kpis.find(k => k.css_class === 'kpi-empty')?.value, 7, 'kpi-empty value = 7 (high_empty_fields_count)');
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
\ No newline at end of file
// scripts/verify_protocol.cjs
// 验证前端关键逻辑(路径求值 + 渲染入口)。
// 策略:从 index.html 抽取 evalPathExpr / resolvePath / resolveRows / renderSummary 四个核心函数,
// 注入到 Node 沙箱里独立测试。
const fs = require('fs');
const path = require('path');
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'static', 'index.html'), 'utf8');
const scriptMatch = html.match(/<script>\s*\n([\s\S]*?)<\/script>/);
const scriptBody = scriptMatch[1];
// 抽出 4 个核心函数(用正则找完整函数体:含参数列表 + 函数体)
function extract(name) {
// 匹配 `function NAME(args) {` —— 不含函数名前缀
const re = new RegExp(`function ${name}\\s*\\(([^)]*)\\)\\s*\\{`);
const m = scriptBody.match(re);
if (!m) throw new Error(`${name} not found`);
const headerEnd = m.index + m[0].length; // 紧接 `{` 之后
let depth = 1, i = headerEnd;
for (; i < scriptBody.length; i++) {
const c = scriptBody[i];
if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) {
// 返回:完整函数定义(含参数 + 函数体)
return scriptBody.slice(m.index, i + 1);
}
}
}
throw new Error(`${name} unterminated`);
}
const functions = ['evalPathExpr', 'resolvePath', 'resolveRows', 'renderSummary'];
const fns = {};
for (const name of functions) {
fns[name] = extract(name);
console.log(` extracted ${name} (${fns[name].length} bytes)`);
}
// 在 vm 里执行:把 4 个函数暴露到 global,调用它们
const vm = require('vm');
const ctx = {
console,
selectedField: { value: null },
};
vm.createContext(ctx);
try {
// 拼装一个 IIFE 把 4 个函数挂到 global
const code = `
${fns.evalPathExpr}
${fns.resolvePath}
${fns.resolveRows}
${fns.renderSummary}
globalThis.__exports = { evalPathExpr, resolvePath, resolveRows, renderSummary };
`;
vm.runInContext(code, ctx, { filename: 'extracted.js' });
} catch (e) {
console.error('Extract exec failed:', e.message);
process.exit(1);
}
const { evalPathExpr, resolvePath, resolveRows, renderSummary } = ctx.__exports;
console.log('OK: 4 functions loaded');
// ── 用例 ──
let pass = 0, fail = 0;
function eq(actual, expected, label) {
const a = JSON.stringify(actual), b = JSON.stringify(expected);
if (a === b) { pass++; console.log(` ✓ ${label}`); }
else { fail++; console.error(` ✗ ${label}: got ${a}, want ${b}`); }
}
// 1. evalPathExpr 简单路径
const data = { merge_candidates: [{id:'M-1'}], summary: { high: 3 } };
eq(evalPathExpr('this.summary.high', data), 3, 'evalPathExpr: this.summary.high');
eq(evalPathExpr('this.merge_candidates', data)[0].id, 'M-1', 'evalPathExpr: array index');
eq(evalPathExpr('len(this.merge_candidates)', data), 1, 'evalPathExpr: len()');
eq(evalPathExpr('len(this.summary.high)', data), 3, 'evalPathExpr: len(number)');
eq(evalPathExpr('this.nonexistent', data), '', 'evalPathExpr: missing key -> ""');
// 2. resolveRows
const rows = resolveRows('this.merge_candidates', data, null, 0);
eq(rows.length, 1, 'resolveRows: simple path');
// 3. resolveRows with linkage filter
const dataWithLinkage = {
violations: [
{ table_name: 't_user', column_name: 'id_card' },
{ table_name: 't_user', column_name: 'mobile' },
{ table_name: 't_other', column_name: 'id_card' },
],
};
const linkage = {
source_table: 0,
target_table: 0,
field_map: { table: 'table_name', field: 'column_name' },
default_filter_kind: 'limit',
default_filter_value: 200,
};
// 未选中:截 200 → 全返(这里只有 3 条)
ctx.selectedField.value = null;
const allRows = resolveRows('this.violations', dataWithLinkage, linkage, 0);
eq(allRows.length, 3, 'resolveRows: no selectedField -> slice(0, 200)');
// 选中:只过滤 t_user.id_card
ctx.selectedField.value = { table: 't_user', field: 'id_card' };
const filtered = resolveRows('this.violations', dataWithLinkage, linkage, 0);
eq(filtered.length, 1, 'resolveRows: selectedField -> 1 match');
eq(filtered[0].table_name, 't_user', 'resolveRows: filter by table_name');
ctx.selectedField.value = null;
// 4. renderSummary 模板替换
const summary = {
type: 'warning',
template: '高空 {{high}} 中空 {{mid}}',
vars: { high: 'this.summary.high', mid: 'this.summary.mid' },
};
const text = renderSummary(summary, { summary: { high: 3, mid: 7 } });
eq(text, '高空 3 中空 7', 'renderSummary: number interpolation');
// 千分位
const text2 = renderSummary(
{ type: 'info', template: '{{n}}', vars: { n: 'this.summary.count' } },
{ summary: { count: 1234 } }
);
eq(text2, '1,234', 'renderSummary: thousand_sep');
// 5. resolvePath
const rp = resolvePath('this.summary.high', data);
eq(rp, 3, 'resolvePath: this.summary.high');
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
\ No newline at end of file
// scripts/verify_render.cjs
// 验证 resolveRenderPath 对所有 render.kind 路径表达式都能正确求值。
// (template 里把 ColumnRender 拆掉后,渲染逻辑全部内联为 v-else-if 分支,
// 共享的只是 resolveRenderPath 这个取值小函数。)
const fs = require('fs');
const path = require('path');
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'static', 'index.html'), 'utf8');
const scriptBody = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
function extract(name) {
const rePrefix = new RegExp(`function\\s+${name}\\s*\\(\\s*([^)]*?)\\s*\\)\\s*\\{`);
const isFnDecl = !!scriptBody.match(rePrefix);
const re = new RegExp(`(?:function\\s+)?${name}\\s*\\(\\s*([^)]*?)\\s*\\)\\s*\\{`);
const m = scriptBody.match(re);
if (!m) throw new Error(`${name} not found`);
const headerEnd = m.index + m[0].length;
let depth = 1, i = headerEnd;
for (; i < scriptBody.length; i++) {
if (scriptBody[i] === '{') depth++;
else if (scriptBody[i] === '}') { depth--; if (depth === 0) break; }
}
const body = scriptBody.slice(m.index, i + 1);
return isFnDecl ? body : 'function ' + body;
}
const fnBody = extract('resolveRenderPath');
console.log(`resolveRenderPath body: ${fnBody.length} bytes`);
const vm = require('vm');
const ctx = { console };
vm.createContext(ctx);
try {
vm.runInContext(`${fnBody}; globalThis.__rrp = resolveRenderPath;`, ctx);
} catch (e) {
console.error('Extract failed:', e.message);
process.exit(1);
}
const rrp = ctx.__rrp;
let pass = 0, fail = 0;
function check(label, actual, expected) {
const a = JSON.stringify(actual), b = JSON.stringify(expected);
if (a === b) { console.log(` ✓ ${label}`); pass++; }
else { console.error(` ✗ ${label}: got ${a}, want ${b}`); fail++; }
}
// 模拟 row:standards tab 的 violations_by_standard 行
const rowStd = {
standard: 'GB_11643',
rule: '18 位',
basis: '正则',
level: 'high',
};
// empty_fields perTableRow
const rowEmpty = {
table_name: 't_user',
table_comment: '用户表',
high_empty_fields_count: 5,
empty_rate: 0.85,
level: 'high',
};
// merge_candidates 行
const rowMerge = {
priority: 'P1',
classification: 'high',
confidence: 0.95,
confidence_label: '高',
};
console.log('\n--- resolveRenderPath: simple paths ---');
check('this.standard', rrp(rowStd, 'standard'), 'GB_11643');
check('this.rule', rrp(rowStd, 'rule'), '18 位');
check('this.basis', rrp(rowStd, 'basis'), '正则');
check('this.level', rrp(rowStd, 'level'), 'high');
check('this.table_name', rrp(rowEmpty, 'table_name'), 't_user');
check('this.empty_rate (num)', rrp(rowEmpty, 'empty_rate'), 0.85);
console.log('\n--- resolveRenderPath: edge cases ---');
check('empty path', rrp(rowStd, ''), '');
check('null path', rrp(rowStd, null), '');
check('missing key', rrp(rowStd, 'nonexistent'), '');
check('wrong type (string)', rrp('just-a-string', 'foo'), '');
console.log('\n--- computed/clamped values used in template ---');
// progress 用的 0-1 clamp
const v = Math.max(0, Math.min(1, Number(rrp(rowEmpty, 'empty_rate')) || 0));
check('progress v clamped', v, 0.85);
// percent 用的 (value*100).toFixed(1)
const pct = (Number(rrp(rowEmpty, 'empty_rate')) * 100).toFixed(1);
check('percent formatted', pct, '85.0');
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
\ No newline at end of file
// scripts/verify_template.cjs
//
// 端到端模板编译验证:把 <div id="app">…</div> 喂给 Vue 3 prod build 的 compile(),
// 断言生成的 render 函数是合法 JS。
//
// 这一步专门兜底「页面变白 / SyntaxError in compiled code」这类回归。
// 历史教训:v-else 在深层 v-else-if 链末尾会被 Vue 编译器输出为 `: ()`(空
// call expression = SyntaxError)。改用 v-else-if="!cond" 显式条件可绕过。
const fs = require('fs');
const path = require('path');
const { JSDOM } = require('jsdom');
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'static', 'index.html'), 'utf8');
let pass = 0, fail = 0;
function check(label, ok, detail) {
if (ok) { console.log(` ✓ ${label}`); pass++; }
else { console.error(` ✗ ${label}${detail ? ' — ' + detail : ''}`); fail++; }
}
// ── 1. 抽出 <div id="app">…</div> 作为模板 ──
const startIdx = html.indexOf('<div id="app">');
const scriptIdx = html.indexOf('<script>');
let endIdx = html.lastIndexOf('</div>', scriptIdx);
if (endIdx < 0) endIdx = startIdx + 100; // 不太可能走到这里
const template = html.slice(startIdx, endIdx + '</div>'.length);
console.log(`Template: ${template.length} chars`);
// ── 2. 在 jsdom 里跑真实 Vue 3 prod build,劫持 Function 构造器截获编译产物 ──
const captured = [];
const dom = new JSDOM(html, {
url: 'http://localhost:8765/',
runScripts: 'dangerously',
resources: 'usable',
pretendToBeVisual: true,
});
const OrigFunction = dom.window.Function;
dom.window.Function = function(...args) {
const body = args[args.length - 1];
if (typeof body === 'string' && body.length > 1000) {
captured.push(body);
}
return OrigFunction.apply(this, args);
};
const vueSrc = fs.readFileSync(
path.join(__dirname, '..', 'web', 'static', 'lib', 'vue.global.prod.js'), 'utf8');
const epSrc = fs.readFileSync(
path.join(__dirname, '..', 'web', 'static', 'lib', 'element-plus', 'index.full.min.js'), 'utf8');
const scriptEl = dom.window.document.createElement('script');
scriptEl.textContent = vueSrc + '\n' + epSrc;
dom.window.document.head.appendChild(scriptEl);
setTimeout(() => {
console.log(`\nCaptured ${captured.length} compiled render fn(s)\n`);
// ── 3. 把每段编译产物当函数体 new Function() ──
check('captured at least 1 compiled render fn', captured.length >= 1,
`got ${captured.length}`);
for (let i = 0; i < captured.length; i++) {
const code = captured[i];
try {
new OrigFunction(code);
check(`compiled #${i} (${code.length} chars) is valid JS`, true);
} catch (e) {
check(`compiled #${i} (${code.length} chars) is valid JS`, false, e.message);
// 把出问题的代码写到磁盘
fs.writeFileSync(path.join(__dirname, `compiled_${i}.bad.js`), code, 'utf8');
}
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
}, 600);
\ No newline at end of file
......@@ -106,19 +106,16 @@ class StandardsListResponse(BaseModel):
# ── 治理结果(前端多级表格消费) ──
class OverviewStats(BaseModel):
"""概览数据 —— 只放任务级通用元数据(数据库/表/字段规模 + 时间)。
各 step 的 KPI(合并候选 / 冗余字段 / 高空字段 / 缺注释字段 / 长度异常 / 国标违规等)
由各 step 在自身 data._protocol.kpis[] 里声明,前端按协议从 _protocol 解析。
"""
database: str = ""
host: str = ""
db_type: str = ""
total_tables: int = 0
total_fields: int = 0
high_empty_fields: int = 0
mid_empty_fields: int = 0
missing_comments: int = 0
length_issues: int = 0
standard_violations: int = 0
standards_applied: int = 0
merge_candidates: int = 0
redundancy_fields: int = 0
executed_at: Optional[datetime] = None
......
......@@ -372,16 +372,13 @@ async def run_governance_workflow(
f"主步骤调度完成: 成功 {sorted(completed)},"
f"非关键失败 {sorted(failed_non_critical)}")
# ── 计算 overview ──
overview = _build_overview(sections, dict_data, cfg)
# ── 计算 overview(只汇总任务级通用元数据;step KPI 见各 section._protocol.kpis) ──
overview = _build_overview(dict_data, cfg)
sections["overview"] = overview
log("INFO",
f"概览汇总: 表 {overview.get('total_tables', 0)} 张, "
f"字段 {overview.get('total_fields', 0)} 个, "
f"高空字段 {overview.get('high_empty_fields', 0)}, "
f"缺失注释 {overview.get('missing_comments', 0)}, "
f"长度异常 {overview.get('length_issues', 0)}, "
f"国标违规 {overview.get('standard_violations', 0)}")
f"字段 {overview.get('total_fields', 0)} 个 "
f"(各 step KPI 见其 _protocol.kpis)")
# ── 报告生成(主步骤跑完后无条件触发) ──
_check_cancel(cancel_event)
......@@ -439,32 +436,18 @@ def _generate_reports(run_dir: Path, sections: dict, overview: dict,
# ── Overview 构造 ──
def _build_overview(sections: dict, dict_data: dict, cfg: DBConfig) -> dict:
"""从各 section + 数据字典汇总概览数据"""
overview_meta = dict_data.get("overview_meta", {}) if isinstance(dict_data, dict) else {}
empty = sections.get("empty_fields", {}).get("summary", {})
missing = sections.get("missing_comments", {}).get("summary", {})
length = sections.get("length_issues", {}).get("summary", {})
standards = sections.get("standard_violations", {}).get("summary", {})
merge = sections.get("merge_candidates", {})
def _build_overview(dict_data: dict, cfg: DBConfig) -> dict:
"""汇总任务级通用元数据。
各 step 的 KPI(合并候选 / 冗余字段 / 高空字段 / ...)由各 step 在自身
data._protocol.kpis[] 里声明,前端按协议从 _protocol 解析(不再走 overview)。
"""
overview_meta = dict_data.get("overview_meta", {}) if isinstance(dict_data, dict) else {}
return {
"database": cfg.database,
"host": cfg.host,
"db_type": cfg.db_type,
"total_tables": overview_meta.get("total_tables", 0),
"total_fields": overview_meta.get("total_fields", 0),
"high_empty_fields": empty.get("high_empty_fields_count", 0),
"mid_empty_fields": empty.get("mid_empty_fields_count", 0),
"missing_comments": missing.get("total_missing_comments", 0),
"length_issues": length.get("total_issues", 0),
"standard_violations": standards.get("violations", 0),
"standards_applied": standards.get("standards_applied", 0),
"merge_candidates": (
len(merge.get("merge_candidates", [])) if isinstance(merge, dict) else 0
),
"redundancy_fields": (
len(merge.get("redundancy_fields", [])) if isinstance(merge, dict) else 0
),
"executed_at": datetime.now().isoformat(),
}
\ No newline at end of file
......@@ -14,10 +14,117 @@ from collections import Counter, defaultdict
from typing import Callable
from ..llm import LLMClient
from ..tab_protocol import (
TabProtocol, TableSpec, ColumnSpec, RenderSpec, TagStyle, Threshold,
KpiSpec, inject_protocol,
)
logger = logging.getLogger(__name__)
# ── 协议 ──────────────────────────────────────────────
# 两个 tab:merge_candidates(含嵌套 expand)+ redundancy_fields
_MERGE_EXPAND_TEMPLATE = """
<p><strong>合并建议:</strong>{{ suggestion }}</p>
<p><strong>合并风险:</strong>{{ risk }}</p>
<p><strong>共同字段({{ common_columns }}):</strong></p>
<div>
{{ columns | tags }}
</div>
<p><strong>差异字段:</strong></p>
<div>
{{ diff_columns | tags }}
</div>
""".strip()
_MERGE_TAB = TabProtocol(
key="merge_candidates",
title="表合并候选",
kpis=(
KpiSpec(css_class="kpi-merge", label="合并候选",
value="len(this.merge_candidates)"),
),
tables=(
TableSpec(
source="this.merge_candidates",
columns=(
ColumnSpec(type="expand", template=_MERGE_EXPAND_TEMPLATE),
ColumnSpec(prop="id", label="ID", width=100),
ColumnSpec(prop="priority", label="优先级", width=80,
render=RenderSpec(
kind="tag",
value_map={
"P1": TagStyle(type="danger", text="P1"),
"P2": TagStyle(type="warning", text="P2"),
},
)),
ColumnSpec(prop="tables", label="涉及表", width=300,
render=RenderSpec(kind="tag_list")),
ColumnSpec(prop="common_columns", label="共同字段数", width=100),
ColumnSpec(prop="similarity", label="相似度", width=100),
ColumnSpec(prop="suggestion", label="建议",
show_overflow_tooltip=True),
),
),
),
)
_REDUNDANCY_TAB = TabProtocol(
key="redundancy_fields",
title="冗余字段",
kpis=(
KpiSpec(css_class="kpi-redundancy", label="冗余字段",
value="len(this.redundancy_fields)"),
),
summary=None, # 用 el-alert 模板渲染,下面在 data 里加
tables=(
TableSpec(
source="this.redundancy_fields",
columns=(
ColumnSpec(prop="field", label="字段名", width=160, fixed="left",
render=RenderSpec(kind="code")),
ColumnSpec(prop="table_count", label="出现表数", width=100,
sortable=True, align="right"),
ColumnSpec(prop="classification", label="分类", width=140,
render=RenderSpec(
kind="tag",
value_map={
"true_redundancy": TagStyle(type="danger", text="真冗余", effect="dark"),
"suspicious": TagStyle(type="warning", text="疑似冗余", effect="dark"),
"common_business": TagStyle(type="success", text="业务共享", effect="plain"),
"common_base": TagStyle(type="info", text="通用基础", effect="plain"),
},
fallback=TagStyle(type="danger", text="LLM 失败", italic=True),
)),
ColumnSpec(prop="reasoning", label="判断依据",
min_width=240, show_overflow_tooltip=True),
ColumnSpec(prop="recommendation", label="建议",
min_width=220, show_overflow_tooltip=True),
ColumnSpec(prop="source", label="来源", width=80, align="center",
render=RenderSpec(
kind="tag",
value_map={
"llm": TagStyle(type="success", text="LLM"),
"llm_failed": TagStyle(type="danger", text="LLM 失败"),
},
)),
),
),
),
)
def _wrap(data: dict) -> dict:
return inject_protocol(
data,
step_id="merge_redundancy",
tabs=[_MERGE_TAB, _REDUNDANCY_TAB],
)
COMMON_FIELDS = {
"id", "create_time", "update_time", "create_by", "update_by",
"remark", "del_flag", "tenant_id", "dept_id", "status",
......@@ -41,7 +148,7 @@ def run_step2(dict_data: dict, llm: LLMClient | None = None,
if not columns:
if log:
log("WARN", "未获取到任何字段元数据,跳过", step="merge_redundancy")
return {"merge_candidates": [], "decommission_candidates": {}, "redundancy_fields": []}
return _wrap({"merge_candidates": [], "decommission_candidates": {}, "redundancy_fields": []})
# 表级过滤:保留被勾选的表(或全部)
if table_filter:
......@@ -87,11 +194,11 @@ def run_step2(dict_data: dict, llm: LLMClient | None = None,
f"高频字段 {len(redundancy)} 个",
step="merge_redundancy")
return {
return _wrap({
"merge_candidates": merge,
"decommission_candidates": decom,
"redundancy_fields": redundancy,
}
})
def _table_columns(cols: list[dict]) -> set[str]:
......
......@@ -20,11 +20,158 @@ from collections import defaultdict
from typing import Callable
from ..db_adapter import DBConfig, open_db, quote_ident, TEXT_TYPES
from ..tab_protocol import (
TabProtocol, TableSpec, ColumnSpec, RenderSpec, TagStyle,
KpiSpec, SummarySpec, inject_protocol,
)
from web.sql.loader import get_sql_loader
logger = logging.getLogger(__name__)
# ── 协议 ──────────────────────────────────────────────
# 单 tab「大范围空字段」,含 2 张子表:
# 1. flatEmptyFields(this.summary.flat_high_empty_fields + flat_mid_empty_fields 合并的扁平列表)
# 2. perTableRows(this.per_table,每张表的展开行带 high/mid 子表)
# 注:原前端有「perTableStats」派生 computed,本协议把它下沉到 data.per_table 本身,
# perTableRows 的 expand 模板里用 {{ high_empty_fields }} / {{ mid_empty_fields }} 直接渲染。
_EMPTY_TAB = TabProtocol(
key="empty_fields",
title="大范围空字段",
kpis=(
KpiSpec(css_class="kpi-empty", label="高空字段",
value="this.summary.high_empty_fields_count"),
KpiSpec(css_class="kpi-empty-mid", label="中空字段",
value="this.summary.mid_empty_fields_count"),
),
summary=SummarySpec(
type="warning",
template=(
"扫描 {{scanned}} 张表 / {{fields}} 个字段,"
"发现高空 {{high}} 个 + 中空 {{mid}} 个,"
"涉及 {{tables}} 张表"
),
vars={
"scanned": "this.summary.total_tables_scanned",
"fields": "this.summary.total_fields_checked",
"high": "this.summary.high_empty_fields_count",
"mid": "this.summary.mid_empty_fields_count",
"tables": "this.summary.tables_with_issues_count",
},
),
tables=(
# 子表 1:扁平展示所有空字段
TableSpec(
title="扁平视图:所有空字段",
source="this.summary.flat_high_empty_fields",
max_height=560,
columns=(
ColumnSpec(
label="表名 / 表注释", width=260,
render=RenderSpec(
kind="two_line",
main_path="table_name",
sub_path="table_comment",
sub_empty_text="(无表注释)",
),
),
ColumnSpec(
label="字段名 / 字段注释", width=240,
render=RenderSpec(
kind="two_line",
main_path="column_name",
sub_path="column_comment",
sub_empty_text="(无字段注释)",
),
),
ColumnSpec(prop="data_type", label="类型", width=100,
render=RenderSpec(kind="code")),
ColumnSpec(
label="空值率", prop="empty_rate", width=160, align="center",
render=RenderSpec(
kind="progress",
value_path="empty_rate",
status_from_value_path="level",
),
),
ColumnSpec(
label="空值数", prop="empty_count", width=110, align="right",
render=RenderSpec(kind="number", format="thousand_sep"),
),
ColumnSpec(
label="级别", prop="level", width=80, align="center",
render=RenderSpec(
kind="tag",
value_map={
"high": TagStyle(type="danger", text="高空", effect="dark"),
"mid": TagStyle(type="warning", text="中空", effect="plain"),
},
),
),
),
),
# 子表 2:按表聚合,每行可点击查看该表的明细
TableSpec(
title="按表聚合",
source="this.per_table",
default_sort={"prop": "high_count", "order": "descending"},
max_height=560,
columns=(
ColumnSpec(type="expand", template=(
"<div class=\"expand-block\">"
"<p><strong>高空字段({{ high_count }}):</strong></p>"
"<ul>{{ high_empty_fields }}</ul>"
"<p><strong>中空字段({{ mid_count }}):</strong></p>"
"<ul>{{ mid_empty_fields }}</ul>"
"</div>"
)),
ColumnSpec(
label="表名 / 表注释", min_width=240,
render=RenderSpec(
kind="two_line",
main_path="table_name",
sub_path="table_comment",
sub_empty_text="(无表注释)",
),
),
ColumnSpec(prop="total_rows", label="总行数", width=100,
align="right",
render=RenderSpec(kind="number", format="thousand_sep")),
ColumnSpec(prop="high_count", label="高空字段", width=100,
align="center"),
ColumnSpec(prop="mid_count", label="中空字段", width=100,
align="center"),
),
),
),
)
def _wrap(data: dict) -> dict:
return inject_protocol(data, step_id="empty_fields", tabs=[_EMPTY_TAB])
def _EMPTY_FALLBACK() -> dict:
"""dict_data 为空时的兜底返回 —— 仍走协议,保持前端结构稳定。"""
return _wrap({
"summary": {
"total_tables_scanned": 0,
"total_fields_checked": 0,
"high_empty_fields_count": 0,
"mid_empty_fields_count": 0,
"tables_with_issues_count": 0,
"skipped_tables_count": 0,
"thresholds": {"high": 0.80, "mid": 0.50},
"top_tables_by_high_empty_fields": [],
"flat_high_empty_fields": [],
"flat_mid_empty_fields": [],
},
"per_table": [],
"skipped": [],
})
def run_step4(cfg: DBConfig, log: Callable | None = None,
dict_data: dict | None = None,
min_rows_to_check: int = 5,
......@@ -46,7 +193,7 @@ def run_step4(cfg: DBConfig, log: Callable | None = None,
if not (dict_data and dict_data.get("data_dictionary")):
if log:
log("ERROR", "dict_data 为空,请检查 orchestrator 是否正确传入", step="empty_fields")
return {"summary": {}, "per_table": [], "skipped": []}
return _EMPTY_FALLBACK()
columns = dict_data["data_dictionary"]
table_summary = dict_data.get("table_summary", [])
......@@ -87,6 +234,7 @@ def run_step4(cfg: DBConfig, log: Callable | None = None,
tables_with_issues = set()
per_table: list[dict] = []
high_fields_all: list[dict] = []
mid_fields_all: list[dict] = []
skipped = []
top_tables: list[dict] = []
......@@ -172,6 +320,7 @@ def run_step4(cfg: DBConfig, log: Callable | None = None,
field_record["level"] = "mid"
mid_count += 1
mid_in_table.append(field_record)
mid_fields_all.append(field_record)
if high_in_table or mid_in_table:
tables_with_issues.add(table)
......@@ -208,7 +357,8 @@ def run_step4(cfg: DBConfig, log: Callable | None = None,
"skipped_tables_count": len(skipped),
"thresholds": {"high": high_threshold, "mid": mid_threshold},
"top_tables_by_high_empty_fields": top_tables[:10],
"high_empty_fields": high_fields_all[:200],
"flat_high_empty_fields": high_fields_all[:200],
"flat_mid_empty_fields": mid_fields_all[:200],
}
if log:
......@@ -217,11 +367,11 @@ def run_step4(cfg: DBConfig, log: Callable | None = None,
f"跳过 {len(skipped)} 张",
step="empty_fields")
return {
return _wrap({
"summary": summary,
"per_table": per_table,
"skipped": skipped,
}
})
def _build_empty_clauses(cols: list[dict], db_type: str) -> tuple[str, list[dict]]:
......
......@@ -18,10 +18,90 @@ from collections import defaultdict
from typing import Callable
from ..llm import LLMClient
from ..tab_protocol import (
TabProtocol, TableSpec, ColumnSpec, RenderSpec, TagStyle,
KpiSpec, SummarySpec, inject_protocol,
)
logger = logging.getLogger(__name__)
# ── 协议 ──────────────────────────────────────────────
# 单 tab「缺失注释字段」,含 1 张主表 + 顶部 alert
# 列:表名+注释 / 字段名+注释 / 类型 / 推测注释 / 置信度 / 依据
_MISSING_TAB = TabProtocol(
key="missing_comments",
title="缺失注释字段",
kpis=(
KpiSpec(css_class="kpi-missing", label="缺注释字段",
value="this.summary.total_missing_comments"),
),
summary=SummarySpec(
type="info",
template="{{total}} 个字段缺注释,已推测 {{predicted}} 个(其中 LLM 推测 {{llm}} 个)",
vars={
"total": "this.summary.total_missing_comments",
"predicted": "this.summary.predicted_count",
"llm": "this.summary.predicted_by_llm",
},
),
tables=(
TableSpec(
source="this.predicted_comments",
max_height=560,
columns=(
ColumnSpec(
label="表名 / 表注释", min_width=220,
render=RenderSpec(
kind="two_line",
main_path="table_name",
sub_path="table_comment",
sub_empty_text="(无表注释)",
),
),
ColumnSpec(
label="字段名 / 字段注释", min_width=220,
render=RenderSpec(
kind="two_line",
main_path="column_name",
sub_path="column_comment",
sub_empty_text="(无字段注释)",
),
),
ColumnSpec(
prop="data_type", label="类型", width=100,
render=RenderSpec(kind="code"),
),
ColumnSpec(
label="推测注释", prop="predicted", min_width=220,
show_overflow_tooltip=True,
render=RenderSpec(kind="text"),
),
ColumnSpec(
label="置信度", prop="confidence", width=100, align="center",
render=RenderSpec(
kind="tag",
value_map={
"high": TagStyle(type="success", text="high"),
"medium": TagStyle(type="warning", text="medium"),
"low": TagStyle(type="info", text="low"),
},
),
),
ColumnSpec(
label="依据", prop="reason", min_width=240,
show_overflow_tooltip=True,
),
),
),
),
)
def _wrap(data: dict) -> dict:
return inject_protocol(data, step_id="missing_comments", tabs=[_MISSING_TAB])
def run_step5(dict_data: dict, llm: LLMClient | None = None,
log: Callable | None = None,
table_filter: set[str] | None = None) -> dict:
......@@ -139,7 +219,7 @@ def run_step5(dict_data: dict, llm: LLMClient | None = None,
f"LLM 推测成功 {len(predicted)}",
step="missing_comments")
return {
return _wrap({
"summary": {
"total_missing_comments": len(missing),
"tables_affected": len(by_table),
......@@ -151,11 +231,11 @@ def run_step5(dict_data: dict, llm: LLMClient | None = None,
"by_table": by_table_list,
"predicted_comments": predicted[:50],
"unpredictable_sample": unpredicted[:50],
}
})
def _empty_result(by_table: dict) -> dict:
return {
return _wrap({
"summary": {
"total_missing_comments": 0,
"tables_affected": len(by_table),
......@@ -167,4 +247,4 @@ def _empty_result(by_table: dict) -> dict:
"by_table": [],
"predicted_comments": [],
"unpredictable_sample": [],
}
\ No newline at end of file
})
\ No newline at end of file
......@@ -20,9 +20,85 @@ import re
from dataclasses import dataclass
from typing import Callable
from ..tab_protocol import (
TabProtocol, TableSpec, ColumnSpec, RenderSpec,
KpiSpec, SummarySpec, inject_protocol,
)
logger = logging.getLogger(__name__)
# ── 协议 ──────────────────────────────────────────────
_LENGTH_TAB = TabProtocol(
key="length_issues",
title="字段长度异常",
kpis=(
KpiSpec(css_class="kpi-length", label="字段长度异常",
value="this.summary.total_issues"),
),
summary=SummarySpec(
type="warning",
template="{{total}} 条异常,应用 {{rules}} 条规则",
vars={
"total": "this.summary.total_issues",
"rules": "this.summary.rules_applied",
},
),
tables=(
TableSpec(
source="this.issues",
max_height=560,
columns=(
ColumnSpec(
label="表名 / 表注释", width=260,
render=RenderSpec(
kind="two_line",
main_path="table_name",
sub_path="table_comment",
sub_empty_text="(无表注释)",
),
),
ColumnSpec(
label="字段名 / 字段注释", width=240,
render=RenderSpec(
kind="two_line",
main_path="column_name",
sub_path="column_comment",
sub_empty_text="(无字段注释)",
),
),
ColumnSpec(
label="长度 (实际/标准)", width=130, align="center",
render=RenderSpec(
kind="ratio",
num_path="actual_length",
den_path="required_length",
emphasize="num",
),
),
ColumnSpec(prop="wasted_bytes_per_row", label="浪费字节",
width=90, align="right"),
ColumnSpec(
label="国家标准 / 规则 / 依据", width=340,
render=RenderSpec(
kind="three_line",
main_path="standard",
sub_path="rule",
tertiary_path="basis",
tertiary_prefix="依据:",
),
),
),
),
),
)
def _wrap(data: dict) -> dict:
return inject_protocol(data, step_id="length_check", tabs=[_LENGTH_TAB])
@dataclass
class LengthRule:
"""字段长度规则:同时支持字段名正则 + 字段注释关键字匹配。"""
......@@ -165,7 +241,7 @@ def run_step6(dict_data: dict, log: Callable | None = None,
if not columns:
if log:
log("WARN", "未获取到任何字段元数据,跳过", step="length_check")
return {"summary": {"total_issues": 0}, "issues": []}
return _wrap({"summary": {"total_issues": 0}, "issues": []})
if log:
log("INFO",
......@@ -219,7 +295,7 @@ def run_step6(dict_data: dict, log: Callable | None = None,
f"发现异常 {len(issues)} 条",
step="length_check")
return {
return _wrap({
"summary": {
"total_issues": len(issues),
"rules_applied": len(LENGTH_RULES),
......@@ -228,4 +304,4 @@ def run_step6(dict_data: dict, log: Callable | None = None,
"wasted_bytes_total": sum(i.get("wasted_bytes_per_row") or 0 for i in issues),
},
"issues": issues[:200],
}
\ No newline at end of file
})
\ No newline at end of file
......@@ -15,6 +15,10 @@ import logging
from typing import Callable
from ..db_adapter import DBConfig, open_db, quote_ident
from ..tab_protocol import (
TabProtocol, TableSpec, ColumnSpec, RenderSpec, TagStyle, Threshold,
KpiSpec, SummarySpec, LinkageSpec, inject_protocol,
)
from standards.registry import discover_standards, list_all_standards
from standards.base import BaseStandard, ValidationResult
from web.sql.loader import get_sql_loader
......@@ -22,6 +26,147 @@ from web.sql.loader import get_sql_loader
logger = logging.getLogger(__name__)
# ── 协议 ──────────────────────────────────────────────
# 单 tab「国家标准校验」,含 2 张子表 + 跨表联动:
# 1. violations_by_standard —— 每个标准一行,展开看每字段样本 + 点击字段联动到 violations
# 2. violations —— 全部违规值的扁平表(默认前 200 条,选中字段后按 (table,field) 过滤)
# 联动规则:
# - 在 table 0 行的展开区点击 details_with_violations 中的某个 {table, field}
# - 前端把 {table, field} 写入 selectedField
# - filteredViolations = this.violations.filter(v =>
# v[linkage.field_map['table']] === selectedField.table
# && v[linkage.field_map['field']] === selectedField.field)
# - 未选中时:默认按 default_filter 截断 (limit 200)
_STANDARDS_TAB = TabProtocol(
key="standards",
title="国家标准校验",
kpis=(
KpiSpec(css_class="kpi-standards", label="违规字段",
value="this.summary.violations"),
KpiSpec(css_class="kpi-standards-applied", label="已应用标准",
value="this.summary.standards_used"),
),
summary=SummarySpec(
type="error",
template=(
"应用 {{applied}} 个标准, 检查 {{fields}} 个字段, "
"发现 {{violations}} 条违规"
),
vars={
"applied": "this.summary.standards_applied",
"fields": "this.summary.fields_checked",
"violations": "this.summary.violations",
},
),
tables=(
# 表 1(按标准聚合)
TableSpec(
title="按标准聚合",
source="this.violations_by_standard",
default_sort={"prop": "violation_rate", "order": "descending"},
max_height=560,
columns=(
ColumnSpec(type="expand", template=(
"<div class=\"expand-block\">"
"<p><strong>有违规字段(点击下方字段联动到右侧违规明细):</strong></p>"
"<div class=\"field-link-list\">"
"{{ details_with_violations | field_links }}"
"</div>"
"<p class=\"muted\" style=\"margin-top: 8px; font-size: 12px\">"
"取数口径: sample_limit={{ sample_limit }}, "
"max_tables_per_field={{ max_tables_per_field }}"
"</p>"
)),
ColumnSpec(prop="standard", label="标准代号", width=140,
render=RenderSpec(kind="code")),
ColumnSpec(prop="standard_name", label="标准名称", width=240,
show_overflow_tooltip=True),
ColumnSpec(prop="fields_count", label="检查字段数", width=110,
align="center"),
ColumnSpec(prop="violating_fields_count", label="违规字段数",
width=120, align="center"),
ColumnSpec(
label="违规率 (违规/抽样)", prop="violation_rate", width=170,
align="center",
render=RenderSpec(
kind="percent",
value_path="violation_rate",
precision=2,
thresholds=(
Threshold(gt=0.5, color="#f56c6c"),
Threshold(gt=0.2, color="#e6a23c"),
Threshold(gt=0.0, color="#67c23a"),
),
),
),
),
),
# 表 2(违规明细,受联动过滤)
TableSpec(
title="违规明细",
source="this.violations",
default_sort=None, # 由 linkage 接管过滤
max_height=560,
columns=(
ColumnSpec(
label="表名 / 表注释", width=260,
render=RenderSpec(
kind="two_line",
main_path="table_name",
sub_path="table_comment",
sub_empty_text="(无表注释)",
),
),
ColumnSpec(
label="字段名 / 字段注释", width=240,
render=RenderSpec(
kind="two_line",
main_path="column_name",
sub_path="column_comment",
sub_empty_text="(无字段注释)",
),
),
ColumnSpec(prop="rule_type", label="规则", width=160,
render=RenderSpec(kind="code")),
ColumnSpec(prop="value", label="违规值", width=180,
render=RenderSpec(kind="code")),
ColumnSpec(prop="error", label="违规原因", min_width=240,
show_overflow_tooltip=True),
),
),
),
linkage=LinkageSpec(
source_table=0,
clickable_path="details_with_violations",
clickable_id_props=("table", "field"),
target_table=1,
field_map={"table": "table_name", "field": "column_name"},
default_filter_kind="limit",
default_filter_value=200,
),
)
def _wrap(data: dict) -> dict:
return inject_protocol(data, step_id="standards", tabs=[_STANDARDS_TAB])
def _EMPTY_FALLBACK() -> dict:
"""无字段/无标准插件时的兜底 —— 仍走协议。"""
return _wrap({
"summary": {
"standards_applied": 0,
"standards_used": 0,
"fields_checked": 0,
"violations": 0,
},
"violations_by_standard": [],
"violations": [],
"standards_info": [],
})
SAMPLE_LIMIT = 500 # 每字段最多抽样
MAX_TABLES_PER_FIELD = 5 # 每字段最多检查前 N 张表
......@@ -60,7 +205,7 @@ def run_step7(cfg: DBConfig, dict_data: dict, log: Callable | None = None,
f"按 table_filter 过滤后: {len(columns)} 个字段",
step="standards")
if not columns:
return {"summary": {}, "violations_by_standard": [], "violations": []}
return _EMPTY_FALLBACK()
# 字段名 → 标准实例的映射
standards_by_field = discover_standards()
......@@ -79,11 +224,7 @@ def run_step7(cfg: DBConfig, dict_data: dict, log: Callable | None = None,
if not standards_by_field:
if log:
log("WARN", "未发现任何标准插件", step="standards")
return {
"summary": {"standards_applied": 0, "fields_checked": 0, "violations": 0},
"violations_by_standard": [],
"violations": [],
}
return _EMPTY_FALLBACK()
if log:
log("INFO",
......@@ -232,7 +373,7 @@ def run_step7(cfg: DBConfig, dict_data: dict, log: Callable | None = None,
if log:
log("INFO", f"校验完成: {len(by_standard_list)} 个标准有违规, {total_fields_checked} 个字段, {total_violations} 条违规", step="standards")
return {
return _wrap({
"summary": {
"standards_applied": len(all_standards_info),
"standards_used": len(by_standard_list),
......@@ -242,4 +383,4 @@ def run_step7(cfg: DBConfig, dict_data: dict, log: Callable | None = None,
"violations_by_standard": by_standard_list,
"violations": violations_flat[:500], # 给前端表格用
"standards_info": all_standards_info,
}
\ No newline at end of file
})
\ No newline at end of file
"""Tab Protocol —— 分析结果 tab 自描述协议。
完整规范见 docs/TAB_PROTOCOL.md。
每个 step 在自己的 data 里嵌入 `_protocol` 字段,描述怎么渲染 tab。
前端按协议动态生成 tab、KPI、表格。
本模块提供:
1. dataclass 镜像 —— 类型安全的协议定义
2. `make_protocol(...)` —— 把 dataclass 列表打包成 dict
3. `_eval_path(expr, data)` —— 轻量路径表达式求值(this.x / this.x.y / len(this.x))
4. `inject_protocol(data, step_id, tabs)` —— 给 step 的 return dict 注入 _protocol
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field, asdict
from typing import Any
# ── 列渲染原语 ──────────────────────────────────────────
@dataclass(frozen=True)
class TagStyle:
"""tag / tag_list 的样式映射。"""
type: str # primary | success | warning | danger | info
text: str
effect: str = "plain" # plain | dark | light
italic: bool = False
@dataclass(frozen=True)
class Threshold:
"""percent 阈值染色。"""
gt: float # 大于此值用此 color
color: str # hex 颜色
@dataclass(frozen=True)
class RenderSpec:
"""列渲染规格。具体字段根据 kind 不同。"""
kind: str # text | code | tag | tag_list | two_line | three_line | progress | percent | ratio | number
# tag / tag_list
value_map: dict[str, TagStyle] | None = None
fallback: TagStyle | None = None
# two_line / three_line
main_path: str | None = None
sub_path: str | None = None
sub_empty_text: str | None = None
# three_line 专属
tertiary_path: str | None = None # 第三行(v-if 存在才渲染)
tertiary_prefix: str | None = None # 前缀文本,如 "依据:"
tertiary_italic: bool = True # 第三行默认斜体
# progress / percent
value_path: str | None = None
status_from_value_path: str | None = None # progress: 读 row[此字段] 决定 status
precision: int = 1 # percent: 小数位
thresholds: tuple[Threshold, ...] = () # percent: 阈值(按 gt 倒序)
# ratio
num_path: str | None = None
den_path: str | None = None
emphasize: str | None = None # num | den | both | None
# number
format: str | None = None # thousand_sep
# ── 列定义 ──────────────────────────────────────────────
@dataclass(frozen=True)
class ColumnSpec:
"""单个列定义。"""
prop: str | None = None
label: str = ""
type: str | None = None # expand | index | selection
width: int | str | None = None
min_width: int | str | None = None
fixed: bool | str | None = None
align: str | None = None
show_overflow_tooltip: bool = False
sortable: bool = False
render: RenderSpec | None = None
template: str | None = None # type="expand" 时用,HTML 模板,支持 {{ path }}
# ── 表格 / KPI / Summary / 联动 / Tab ─────────────────
@dataclass(frozen=True)
class TableSpec:
"""单张表格定义。"""
title: str | None = None
source: str = "" # 数据路径表达式(this.x)
columns: tuple[ColumnSpec, ...] = ()
default_sort: dict | None = None
max_height: int | None = None
@dataclass(frozen=True)
class KpiSpec:
"""单个 KPI 上浮声明。"""
css_class: str # kpi-merge / kpi-redundancy / ...
label: str
value: str # 路径表达式(this.x / len(this.x))
@dataclass(frozen=True)
class SummarySpec:
"""顶部 alert。"""
type: str # info | warning | error | success
template: str # 支持 {{ name }} 占位符
vars: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class LinkageSpec:
"""跨表联动(standards tab 用)。"""
source_table: int # 索引到 tables[]
clickable_path: str # A 表 row 里可点击的数组字段(路径表达式)
clickable_id_props: tuple[str, ...] # 点击项的 ID 字段
target_table: int
field_map: dict[str, str] # A → B 字段映射
default_filter_kind: str | None = None # limit
default_filter_value: int | None = None
@dataclass(frozen=True)
class TabProtocol:
"""单个 tab 定义。"""
key: str # 唯一 tab key
title: str
kpis: tuple[KpiSpec, ...] = ()
summary: SummarySpec | None = None
tables: tuple[TableSpec, ...] = ()
linkage: LinkageSpec | None = None
# ── 路径表达式求值 ──────────────────────────────────────
_PATH_RE = re.compile(r"^(this(\.[A-Za-z_][A-Za-z0-9_]*)*|len\(this(\.[A-Za-z_][A-Za-z0-9_]*)*\))$")
def _eval_path(expr: str, data: dict) -> Any:
"""支持 this.x / this.x.y / len(this.x) 的轻量求值。
其他语法抛 ValueError —— 故意不做任意表达式求值,避免注入风险。
"""
if not isinstance(expr, str):
raise ValueError(f"path 表达式必须是字符串,得到 {type(expr).__name__}")
expr = expr.strip()
if not _PATH_RE.match(expr):
raise ValueError(f"不支持的 path 表达式: {expr!r}(仅支持 this.x / this.x.y / len(this.x))")
if expr.startswith("len("):
# len(this.x.y)
inner = expr[4:-1].strip() # "this.x.y"
v = _eval_path(inner, data)
if v is None:
return 0
if isinstance(v, (list, tuple, dict, str)):
return len(v)
if isinstance(v, (int, float)):
# 兼容:数字直接当计数返回(用于 KPI 上浮声明)
return v
raise ValueError(f"len(...) 仅支持 list/tuple/dict/str/number,得到 {type(v).__name__}")
# this.x.y
parts = expr.split(".")
if parts[0] != "this":
raise ValueError(f"path 必须以 this 开头: {expr!r}")
cur: Any = data
for p in parts[1:]:
if cur is None:
return None
if isinstance(cur, dict):
cur = cur.get(p)
else:
cur = getattr(cur, p, None)
return cur
# ── 序列化 ──────────────────────────────────────────────
def _to_jsonable(obj: Any) -> Any:
"""dataclass → dict(递归)。tuple → list;frozen dataclass → dict。"""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, dict):
return {k: _to_jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_to_jsonable(v) for v in obj]
if hasattr(obj, "__dataclass_fields__"):
d = asdict(obj)
return _to_jsonable(d)
return obj
def make_protocol(*, step_id: str, tabs: list[TabProtocol]) -> dict:
"""把 TabProtocol 列表打包成 _protocol dict。
返回示例:
{"step_id": "...", "tabs": [...]}
"""
return _to_jsonable({"step_id": step_id, "tabs": list(tabs)})
def inject_protocol(data: dict, *, step_id: str, tabs: list[TabProtocol]) -> dict:
"""给 step 的 return data 注入 _protocol,返回新 dict(不修改原 data)。"""
data = dict(data) # 浅拷,避免污染 caller
data["_protocol"] = make_protocol(step_id=step_id, tabs=tabs)
return data
# ── 模板渲染(前端 expand 行用到的也是这套语义,后端测试用) ──
_TEMPLATE_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_\.]*)\s*\}\}")
def render_template(template: str, row: dict) -> str:
"""展开行模板渲染:把 {{ path }} 替换为 row[path]。
仅支持 row['a'] / row['a.b'] 这种简单 dot 路径,不做表达式求值。
前端会镜像这个函数(JS 版)。
"""
def repl(m: re.Match) -> str:
path = m.group(1)
cur: Any = row
for p in path.split("."):
if cur is None:
return ""
if isinstance(cur, dict):
cur = cur.get(p, "")
else:
cur = getattr(cur, p, "")
return str(cur) if cur is not None else ""
return _TEMPLATE_RE.sub(repl, template)
# ── 自检 ──────────────────────────────────────────────
if __name__ == "__main__": # python -m web.core.tab_protocol
# 简单自检
data = {"a": {"b": [1, 2, 3]}, "n": 0}
assert _eval_path("this.a", data) == {"b": [1, 2, 3]}
assert _eval_path("this.a.b", data) == [1, 2, 3]
assert _eval_path("len(this.a.b)", data) == 3
assert _eval_path("this.nonexistent", data) is None
# 数字当计数(兼容)
assert _eval_path("len(this.n)", data) == 0
# render_template
row = {"name": "foo", "deep": {"v": 42}}
assert render_template("{{ name }} / {{ deep.v }}", row) == "foo / 42"
assert render_template("{{ missing }} end", row) == " end"
# make_protocol 打包
tab = TabProtocol(
key="x", title="X",
kpis=(KpiSpec(css_class="kpi-x", label="X", value="len(this.items)"),),
summary=SummarySpec(type="info", template="共 {{N}}", vars={"N": "len(this.items)"}),
tables=(TableSpec(source="this.items", columns=(
ColumnSpec(prop="id", label="ID", width=80, render=RenderSpec(kind="text")),
)),),
)
proto = make_protocol(step_id="x_step", tabs=[tab])
assert proto["step_id"] == "x_step"
assert proto["tabs"][0]["kpis"][0]["css_class"] == "kpi-x"
assert proto["tabs"][0]["tables"][0]["source"] == "this.items"
print("tab_protocol 自检 OK")
\ No newline at end of file
......@@ -312,6 +312,7 @@
</template>
<div class="kpi-grid">
<!-- 通用概览:表/字段总数(来自 overview) -->
<div class="kpi-card kpi-tables">
<div class="kpi-value">{{ jobResult.overview.total_tables }}</div>
<div class="kpi-label">表总数</div>
......@@ -320,43 +321,67 @@
<div class="kpi-value">{{ jobResult.overview.total_fields }}</div>
<div class="kpi-label">字段总数</div>
</div>
<div class="kpi-card kpi-empty">
<div class="kpi-value">{{ jobResult.overview.high_empty_fields }}</div>
<div class="kpi-label">高空字段</div>
</div>
<div class="kpi-card kpi-missing">
<div class="kpi-value">{{ jobResult.overview.missing_comments }}</div>
<div class="kpi-label">缺注释字段</div>
</div>
<div class="kpi-card kpi-length">
<div class="kpi-value">{{ jobResult.overview.length_issues }}</div>
<div class="kpi-label">字段长度异常</div>
</div>
<div class="kpi-card kpi-std">
<div class="kpi-value">{{ jobResult.overview.standard_violations }}</div>
<div class="kpi-label">国标违规</div>
</div>
<div class="kpi-card kpi-merge">
<div class="kpi-value">{{ jobResult.overview.merge_candidates }}</div>
<div class="kpi-label">合并候选</div>
</div>
<div class="kpi-card kpi-redundancy">
<div class="kpi-value">{{ jobResult.overview.redundancy_fields }}</div>
<div class="kpi-label">冗余字段</div>
<!-- 各 step 贡献的 KPI —— 来自 _protocol.tabs[*].kpis[],未勾选的 step 不显示 -->
<div v-for="k in displayedKpis" :key="k.stepId + ':' + k.css_class"
:class="'kpi-card ' + k.css_class">
<div class="kpi-value">{{ k.value }}</div>
<div class="kpi-label">{{ k.label }}</div>
</div>
</div>
<el-tabs v-model="activeTab" type="border-card">
<!-- 表合并候选 -->
<el-tab-pane name="merge">
<!--
通用 Tab 渲染(基于 _protocol)
每个 section 自带 _protocol.tabs[],按 step_id ∈ steps_planned 过滤后渲染。
列渲染按 column.render.kind 分发(text / code / tag / tag_list / two_line /
three_line / progress / percent / ratio / number)。
嵌套展开行 (type=expand) 用 Vue 原生 template 直接写,不走协议。
-->
<el-tab-pane v-for="t in displayedTabs" :key="t.tabKey" :name="t.tabKey">
<template #label>
<span>表合并候选
<el-tag v-if="tabBadgeShow('merge')" :type="tabBadgeType('merge')" size="small" effect="plain" style="margin-left: 4px">{{ tabBadgeText('merge') }}</el-tag>
<span>{{ t.title }}
<el-tag v-if="tabBadgeShow(t.tabKey)" :type="tabBadgeType(t.tabKey)" size="small" effect="plain" style="margin-left: 4px">{{ tabBadgeText(t.tabKey) }}</el-tag>
</span>
</template>
<el-table :data="jobResult.sections.merge_candidates?.merge_candidates || []" stripe size="small">
<el-table-column type="expand">
<template #default="{ row }">
<!-- 顶部 alert(来自 _protocol.tabs[*].summary) -->
<el-alert v-if="t.summary"
:type="t.summary.type" :closable="false" style="margin-bottom: 12px"
:title="renderSummary(t.summary, t.data)" />
<!-- 标准 tab 联动:字段筛选标签 -->
<template v-if="t.linkage">
<div style="margin-top: 16px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px;">
<strong style="font-size: 14px">{{ t.tables[1]?.title || '违规明细' }}</strong>
<el-tag v-if="selectedField" type="info" effect="plain" closable @close="clearFieldFilter">
{{ selectedField.table }}.{{ selectedField.field }}
</el-tag>
<span v-else class="muted" style="font-size: 12px">(前 {{ t.linkage.default_filter_value }} 条)</span>
</div>
</template>
<!-- 表们(每个 table spec 渲染一张 el-table;resolveRows 已处理 linkage 过滤) -->
<template v-for="(tb, ti) in t.tables" :key="t.tabKey + ':' + ti">
<div :style="ti > 0 ? 'margin-top: 24px' : ''">
<h4 v-if="tb.title && !(t.linkage && ti === 1)" style="margin-top: 16px; margin-bottom: 8px;">{{ tb.title }}</h4>
<el-table
:data="resolveRows(tb.source, t.data, t.linkage, ti)" stripe size="small"
:max-height="tb.max_height || undefined"
:default-sort="tb.default_sort || undefined"
style="width: 100%">
<el-table-column
v-for="(col, ci) in tb.columns" :key="ci"
:type="col.type || undefined"
:prop="col.prop || undefined"
:label="col.label || undefined"
:width="col.width || undefined"
:min-width="col.min_width || undefined"
:align="col.align || undefined"
:sortable="col.sortable || undefined"
:show-overflow-tooltip="col.show_overflow_tooltip || undefined"
:fixed="col.fixed || undefined">
<!-- type="expand":内嵌展开行(merge / standards / empty_fields 用) -->
<template v-if="col.type === 'expand' && t.tabKey === 'merge_candidates'" #default="{ row }">
<div style="padding: 10px">
<p><strong>合并建议:</strong>{{ row.suggestion }}</p>
<p v-if="row.risk"><strong>合并风险:</strong>{{ row.risk }}</p>
......@@ -364,440 +389,100 @@
<el-tag v-for="c in row.columns" :key="c" size="small" style="margin-right: 4px">{{ c }}</el-tag>
<p v-if="row.diff_columns?.length"><strong>差异字段:</strong></p>
<el-tag v-for="c in row.diff_columns" :key="c" size="small" type="warning" style="margin-right: 4px">{{ c }}</el-tag>
<p v-if="row.row_counts"><strong>实际行数:</strong></p>
<el-table v-if="row.row_counts" :data="row.row_counts" size="small" border style="margin-top: 6px">
<el-table-column prop="table" label="表名" />
<el-table-column label="行数">
<template #default="{ row: rc }">{{ rc.rows ?? rc.error }}</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="100" />
<el-table-column label="优先级" width="80">
<template #default="{ row }">
<el-tag :type="row.priority === 'P1' ? 'danger' : 'warning'" size="small">{{ row.priority }}</el-tag>
</template>
</el-table-column>
<el-table-column label="涉及表" width="300">
<template #default="{ row }">
<el-tag v-for="t in row.tables" :key="t" size="small" style="margin-right: 4px">{{ t }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="common_columns" label="共同字段数" width="100" />
<el-table-column prop="similarity" label="相似度" width="100" />
<el-table-column prop="suggestion" label="建议" />
</el-table>
</el-tab-pane>
<!-- 冗余字段 -->
<el-tab-pane name="redundancy">
<template #label>
<span>冗余字段
<el-tag v-if="tabBadgeShow('redundancy')" :type="tabBadgeType('redundancy')" size="small" effect="plain" style="margin-left: 4px">{{ tabBadgeText('redundancy') }}</el-tag>
</span>
</template>
<el-alert type="info" :closable="false" style="margin-bottom: 12px"
:title="`共 ${(jobResult.sections.merge_candidates?.redundancy_fields || []).length} 个高频字段(出现在 ≥ 10 张表),LLM 已给出分类与处置建议;标为「LLM 失败」的需人工核对`" />
<el-table :data="jobResult.sections.merge_candidates?.redundancy_fields || []" stripe size="small">
<el-table-column prop="field" label="字段名" width="160" fixed>
<template #default="{ row }">
<code style="background: #f5f7fa; padding: 1px 6px; border-radius: 3px; color: #e6a23c;">{{ row.field }}</code>
</template>
</el-table-column>
<el-table-column prop="table_count" label="出现表数" width="100" sortable align="right" />
<el-table-column prop="classification" label="分类" width="140">
<template #default="{ row }">
<el-tag v-if="row.classification === 'true_redundancy'" type="danger" size="small" effect="dark">真冗余</el-tag>
<el-tag v-else-if="row.classification === 'suspicious'" type="warning" size="small" effect="dark">疑似冗余</el-tag>
<el-tag v-else-if="row.classification === 'common_business'" type="success" size="small" effect="plain">业务共享</el-tag>
<el-tag v-else-if="row.classification === 'common_base'" type="info" size="small" effect="plain">通用基础</el-tag>
<el-tag v-else type="danger" size="small" effect="plain" style="font-style: italic">LLM 失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="reasoning" label="判断依据" min-width="240" show-overflow-tooltip />
<el-table-column prop="recommendation" label="建议" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.recommendation }}</span>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.source === 'llm'" type="success" size="small" effect="plain">LLM</el-tag>
<el-tag v-else-if="row.source === 'llm_failed'" type="danger" size="small" effect="plain">LLM 失败</el-tag>
<el-tag v-else size="small" effect="plain">{{ row.source }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 大范围空字段 -->
<el-tab-pane name="empty">
<template #label>
<span>大范围空字段
<el-tag v-if="tabBadgeShow('empty')" :type="tabBadgeType('empty')" size="small" effect="plain" style="margin-left: 4px">{{ tabBadgeText('empty') }}</el-tag>
<template v-else-if="col.type === 'expand' && t.tabKey === 'standards'" #default="{ row }">
<div style="padding: 10px">
<p><strong>有违规字段(点击下方字段联动):</strong></p>
<div v-if="row.details_with_violations && row.details_with_violations.length">
<div v-for="d in row.details_with_violations" :key="d.table + '.' + d.field" style="font-size: 12px; margin-top: 2px">
<span class="field-link" :class="{ 'field-link--active': selectedField && selectedField.table === d.table && selectedField.field === d.field }" @click="selectFieldFilter(d.table, d.field)">
<code style="color: #303133">{{ d.table }}.{{ d.field }}</code>
</span>
</template>
<el-alert :title="`扫描 ${jobResult.sections.empty_fields?.summary?.total_tables_scanned || 0} 张表,发现 ${jobResult.sections.empty_fields?.summary?.high_empty_fields_count || 0} 个高空字段、${jobResult.sections.empty_fields?.summary?.mid_empty_fields_count || 0} 个中空字段,涉及 ${jobResult.sections.empty_fields?.summary?.tables_with_issues_count || 0} 张表`" type="info" :closable="false" />
<h4 style="margin-top: 16px">字段明细(共 {{ flatEmptyFields.length }} 条,默认按空值率倒序)</h4>
<el-table :data="flatEmptyFields" stripe size="small" :default-sort="{ prop: 'empty_rate', order: 'descending' }" max-height="600">
<el-table-column prop="level" label="等级" width="80" sortable align="center">
<template #default="{ row }">
<el-tag v-if="row.level === 'high'" type="danger" size="small" effect="dark">高空</el-tag>
<el-tag v-else-if="row.level === 'mid'" type="warning" size="small" effect="plain">中空</el-tag>
</template>
</el-table-column>
<el-table-column prop="table_name" label="表名" width="220" show-overflow-tooltip fixed>
<template #default="{ row }">
<strong style="color: #303133">{{ row.table_name }}</strong>
</template>
</el-table-column>
<el-table-column prop="table_comment" label="表注释" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.table_comment" style="color: #606266; font-size: 12px">{{ row.table_comment }}</span>
<span v-else class="muted" style="font-style: italic; font-size: 12px">(无注释)</span>
</template>
</el-table-column>
<el-table-column prop="column_name" label="字段名" width="180" show-overflow-tooltip fixed>
<template #default="{ row }">
<code style="background: #f5f7fa; padding: 1px 6px; border-radius: 3px; color: #e6a23c;">{{ row.column_name }}</code>
</template>
</el-table-column>
<el-table-column prop="column_comment" label="字段注释" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.column_comment">{{ row.column_comment }}</span>
<el-tag v-else size="small" type="info" effect="plain" style="font-style: italic">(无注释)</el-tag>
</template>
</el-table-column>
<el-table-column prop="data_type" label="类型" width="100" />
<el-table-column prop="empty_count" label="空值数" width="120" sortable align="right">
<template #default="{ row }">
{{ row.empty_count.toLocaleString() }} <span class="muted" style="font-size: 11px">/ {{ row.total_rows.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column prop="empty_rate" label="空值率" width="220" sortable align="center">
<template #default="{ row }">
<div style="display: flex; align-items: center; gap: 6px;">
<el-progress
:percentage="Math.round(row.empty_rate * 100)"
:stroke-width="14"
:status="row.level === 'high' ? 'exception' : 'warning'"
:show-text="false"
style="flex: 1;" />
<span style="min-width: 48px; text-align: right;">
<strong :style="{ color: row.level === 'high' ? '#f56c6c' : '#e6a23c' }">{{ Math.round(row.empty_rate * 100) }}%</strong>
<span class="muted"> · </span>
<span :style="{ color: d.violation_rate > 0.5 ? '#f56c6c' : (d.violation_rate > 0.2 ? '#e6a23c' : '#67c23a') }">
{{ d.violations }}/{{ d.sampled }} ({{ (d.violation_rate * 100).toFixed(1) }}%)
</span>
</div>
</template>
</el-table-column>
</el-table>
<h4 style="margin-top: 24px">按表统计(默认按高空字段数倒序,点击「展开」查看该表下的字段)</h4>
<el-alert
v-if="(jobResult.sections.empty_fields?.per_table || []).length > 0"
:title="`共 ${perTableStats.tablesWithIssues} 张表存在空字段问题,共 ${perTableStats.totalHigh} 个高空字段(涉及 ${perTableStats.totalHighCells} 个字段次)+ ${perTableStats.totalMid} 个中空字段(涉及 ${perTableStats.totalMidCells} 个字段次);其中 ${perTableStats.tablesNoComment} 张表未填写表注释`"
type="info" :closable="false" style="margin-bottom: 12px;" />
<el-table :data="perTableRows" stripe size="small" style="width: 100%" max-height="560">
<el-table-column type="expand" width="40">
<template #default="{ row }">
<div v-if="row.high_empty_fields && row.high_empty_fields.length > 0">
<h5 style="margin: 8px 0; color: #f56c6c">🔴 高空字段(空值率 ≥ 80%)</h5>
<table class="inner-empty-table">
<thead>
<tr>
<th style="width: 160px">字段名</th>
<th style="width: 100px">类型</th>
<th>注释</th>
<th style="width: 110px">空值数</th>
<th style="width: 220px">空值率</th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in row.high_empty_fields" :key="i">
<td><code style="background: #f5f7fa; padding: 1px 6px; border-radius: 3px; color: #e6a23c;">{{ f.column_name }}</code></td>
<td><code style="font-size: 12px">{{ f.data_type }}</code></td>
<td><span v-if="f.column_comment">{{ f.column_comment }}</span><span v-else class="muted" style="font-style: italic; font-size: 12px">(无注释)</span></td>
<td style="text-align: right">{{ f.empty_count.toLocaleString() }} <span class="muted" style="font-size: 11px">/ {{ f.total_rows.toLocaleString() }}</span></td>
<td>
<el-progress :percentage="Math.round(f.empty_rate * 100)" :stroke-width="12" status="exception" />
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="row.mid_empty_fields && row.mid_empty_fields.length > 0" style="margin-top: 12px">
<h5 style="margin: 8px 0; color: #e6a23c">🟡 中空字段(空值率 ≥ 50%)</h5>
<table class="inner-empty-table">
<thead>
<tr>
<th style="width: 160px">字段名</th>
<th style="width: 100px">类型</th>
<th>注释</th>
<th style="width: 110px">空值数</th>
<th style="width: 220px">空值率</th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in row.mid_empty_fields" :key="i">
<td><code style="background: #f5f7fa; padding: 1px 6px; border-radius: 3px; color: #e6a23c;">{{ f.column_name }}</code></td>
<td><code style="font-size: 12px">{{ f.data_type }}</code></td>
<td><span v-if="f.column_comment">{{ f.column_comment }}</span><span v-else class="muted" style="font-style: italic; font-size: 12px">(无注释)</span></td>
<td style="text-align: right">{{ f.empty_count.toLocaleString() }} <span class="muted" style="font-size: 11px">/ {{ f.total_rows.toLocaleString() }}</span></td>
<td>
<el-progress :percentage="Math.round(f.empty_rate * 100)" :stroke-width="12" status="warning" />
</td>
</tr>
</tbody>
</table>
<span v-else class="muted">—</span>
<p class="muted" style="margin-top: 8px; font-size: 12px">取数口径: sample_limit={{ row.sample_limit }}, max_tables_per_field={{ row.max_tables_per_field }}</p>
</div>
</template>
</el-table-column>
<el-table-column label="表名 / 表注释" min-width="320" show-overflow-tooltip>
<template #default="{ row }">
<div>
<strong style="color: #303133">{{ row.table_name }}</strong>
<div v-if="row.table_comment" class="muted" style="font-size: 12px; margin-top: 2px;">
{{ row.table_comment }}
</div>
<div v-else class="muted" style="font-size: 12px; margin-top: 2px; font-style: italic;">
(无注释)
<template v-else-if="col.type === 'expand' && t.tabKey === 'empty_fields'" #default="{ row }">
<div style="padding: 10px">
<div v-if="row.high_empty_fields && row.high_empty_fields.length > 0">
<h5 style="margin: 8px 0; color: #f56c6c">🔴 高空字段({{ row.high_count }})</h5>
<el-tag v-for="f in row.high_empty_fields" :key="'h:' + f.column_name" size="small" style="margin: 2px 4px 2px 0; background: #fef0f0; color: #f56c6c; border-color: #fde2e2;">
{{ f.column_name }} <span class="muted">{{ (f.empty_rate * 100).toFixed(0) }}%</span>
</el-tag>
</div>
<div v-if="row.mid_empty_fields && row.mid_empty_fields.length > 0" style="margin-top: 12px">
<h5 style="margin: 8px 0; color: #e6a23c">🟡 中空字段({{ row.mid_count }})</h5>
<el-tag v-for="f in row.mid_empty_fields" :key="'m:' + f.column_name" size="small" style="margin: 2px 4px 2px 0;">
{{ f.column_name }} <span class="muted">{{ (f.empty_rate * 100).toFixed(0) }}%</span>
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="total_rows" label="总行数" width="110" sortable align="right">
<template #default="{ row }">
<span :class="row.total_rows === 0 ? 'muted' : ''">{{ row.total_rows.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column prop="total_fields" label="字段数" width="80" sortable align="right" />
<el-table-column label="高空占比" width="180" align="center">
<template #default="{ row }">
<div style="display: flex; align-items: center; gap: 6px;">
<el-progress
:percentage="Math.round(row.high_count / Math.max(row.total_fields, 1) * 100)"
:stroke-width="14"
:status="row.high_count > 0 ? 'exception' : ''"
:show-text="false"
style="flex: 1;" />
<span style="min-width: 56px; text-align: right;">
<strong style="color: #f56c6c">{{ row.high_count }}</strong>
<span class="muted"> / {{ row.total_fields }}</span>
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="mid_count" label="中空" width="90" sortable align="center">
<template #default="{ row }">
<el-tag v-if="row.mid_count > 0" type="warning" size="small" effect="plain">{{ row.mid_count }}</el-tag>
<span v-else class="muted">0</span>
<!-- 通用列渲染:按 col.render.kind 分发(内联避免自定义组件) -->
<template v-else-if="col.render && col.render.kind === 'text'" #default="{ row }">
{{ row[col.prop] }}
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 缺失注释字段 -->
<el-tab-pane name="missing">
<template #label>
<span>缺失注释字段
<el-tag v-if="tabBadgeShow('missing')" :type="tabBadgeType('missing')" size="small" effect="plain" style="margin-left: 4px">{{ tabBadgeText('missing') }}</el-tag>
</span>
</template>
<el-alert :title="`${jobResult.sections.missing_comments?.summary?.total_missing_comments || 0} 个字段缺注释,已推测 ${jobResult.sections.missing_comments?.summary?.predicted_count || 0} 个(其中 LLM 推测 ${jobResult.sections.missing_comments?.summary?.predicted_by_llm || 0} 个)`" type="info" :closable="false" />
<el-table :data="jobResult.sections.missing_comments?.predicted_comments || []" stripe size="small" style="margin-top: 12px; width: 100%" max-height="560">
<el-table-column label="表名 / 表注释" min-width="220">
<template #default="{ row }">
<div>
<strong style="color: #303133">{{ row.table_name }}</strong>
<div v-if="row.table_comment" class="muted" style="font-size: 12px; margin-top: 2px">{{ row.table_comment }}</div>
<div v-else class="muted" style="font-size: 12px; margin-top: 2px; font-style: italic">(无表注释)</div>
</div>
</template>
</el-table-column>
<el-table-column label="字段名 / 字段注释" min-width="220">
<template #default="{ row }">
<div>
<strong style="color: #303133">{{ row.column_name }}</strong>
<div v-if="row.column_comment" class="muted" style="font-size: 12px; margin-top: 2px">{{ row.column_comment }}</div>
<div v-else class="muted" style="font-size: 12px; margin-top: 2px; font-style: italic">(无字段注释)</div>
</div>
<template v-else-if="col.render && col.render.kind === 'code'" #default="{ row }">
<code style="background: #f5f7fa; padding: 1px 6px; border-radius: 3px; color: #e6a23c;">{{ row[col.prop] }}</code>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<code style="font-size: 12px">{{ row.data_type || row.column_type || '—' }}</code>
</template>
</el-table-column>
<el-table-column label="推测注释" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.predicted" style="color: #303133">{{ row.predicted }}</span>
<span v-else class="muted" style="font-style: italic">(未推测)</span>
</template>
</el-table-column>
<el-table-column label="置信度" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.confidence === 'high' ? 'success' : (row.confidence === 'medium' ? 'warning' : 'info')" size="small">
{{ row.confidence }}
<template v-else-if="col.render && col.render.kind === 'tag'" #default="{ row }">
<el-tag :type="((col.render.value_map || {})[row[col.prop]] || col.render.fallback || {}).type || 'info'" size="small" :effect="((col.render.value_map || {})[row[col.prop]] || col.render.fallback || {}).effect || 'plain'" :style="((col.render.value_map || {})[row[col.prop]] || col.render.fallback || {}).italic ? 'font-style: italic' : ''">
{{ ((col.render.value_map || {})[row[col.prop]] || col.render.fallback || {}).text || row[col.prop] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="依据" min-width="240" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<!-- 字段长度异常 -->
<el-tab-pane name="length">
<template #label>
<span>字段长度异常
<el-tag v-if="tabBadgeShow('length')" :type="tabBadgeType('length')" size="small" effect="plain" style="margin-left: 4px">{{ tabBadgeText('length') }}</el-tag>
</span>
</template>
<el-alert :title="`${jobResult.sections.length_issues?.summary?.total_issues || 0} 条异常,应用 ${jobResult.sections.length_issues?.summary?.rules_applied || 0} 条规则`" type="warning" :closable="false" />
<el-table :data="jobResult.sections.length_issues?.issues || []" stripe size="small" style="margin-top: 12px; width: 100%" max-height="560">
<el-table-column label="表名 / 表注释" width="260">
<template #default="{ row }">
<template v-else-if="col.render && col.render.kind === 'tag_list'" #default="{ row }">
<div>
<strong style="color: #303133">{{ row.table_name }}</strong>
<div v-if="row.table_comment" class="muted" style="font-size: 12px; margin-top: 2px">{{ row.table_comment }}</div>
<div v-else class="muted" style="font-size: 12px; margin-top: 2px; font-style: italic">(无表注释)</div>
<el-tag v-for="v in (row[col.prop] || [])" :key="v" size="small" style="margin-right: 4px">{{ v }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="字段名 / 字段注释" width="240">
<template #default="{ row }">
<template v-else-if="col.render && col.render.kind === 'two_line'" #default="{ row }">
<div>
<strong style="color: #303133">{{ row.column_name }}</strong>
<div v-if="row.column_comment" class="muted" style="font-size: 12px; margin-top: 2px">{{ row.column_comment }}</div>
<div v-else class="muted" style="font-size: 12px; margin-top: 2px; font-style: italic">(无字段注释)</div>
<strong style="color: #303133">{{ resolveRenderPath(row, col.render.main_path) }}</strong>
<div class="muted" :style="'font-size: 12px; margin-top: 2px;' + (resolveRenderPath(row, col.render.sub_path) ? '' : ' font-style: italic')">
{{ resolveRenderPath(row, col.render.sub_path) || col.render.sub_empty_text || '' }}
</div>
</template>
</el-table-column>
<el-table-column label="长度 (实际/标准)" width="130" align="center">
<template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600">{{ row.actual_length }}</span>
<span class="muted"> / </span>
<span>{{ row.required_length }}</span>
</template>
</el-table-column>
<el-table-column label="浪费字节" width="90" align="right">
<template #default="{ row }">{{ row.wasted_bytes_per_row }}</template>
</el-table-column>
<el-table-column label="国家标准 / 规则 / 依据" width="340">
<template #default="{ row }">
<div>
<strong style="color: #303133">{{ row.standard }}</strong>
<div class="muted" style="font-size: 12px; margin-top: 2px">{{ row.rule }}</div>
<div v-if="row.basis" class="muted" style="font-size: 11px; margin-top: 2px; color: #909399; font-style: italic">依据:{{ row.basis }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议" width="200" />
</el-table>
</el-tab-pane>
<!-- 国标违规 -->
<el-tab-pane name="standards">
<template #label>
<span>国标违规
<el-tag v-if="tabBadgeShow('standards')" :type="tabBadgeType('standards')" size="small" effect="plain" style="margin-left: 4px">{{ tabBadgeText('standards') }}</el-tag>
</span>
</template>
<el-alert :title="`${jobResult.sections.standard_violations?.summary?.standards_used || 0} 个标准有违规(共 ${jobResult.sections.standard_violations?.summary?.violations || 0} 条违规记录)`" type="error" :closable="false" />
<h4 style="margin-top: 16px">按标准统计<span class="muted" style="font-size: 12px; margin-left: 8px">点击下方字段即可联动下方明细</span></h4>
<el-table :data="jobResult.sections.standard_violations?.violations_by_standard || []" stripe size="small" style="margin-top: 12px; width: 100%" max-height="480">
<el-table-column prop="standard" label="标准" width="120" />
<el-table-column label="标准名称" min-width="280" show-overflow-tooltip>
<template #default="{ row }">
<template v-else-if="col.render && col.render.kind === 'three_line'" #default="{ row }">
<div>
<strong style="color: #303133">{{ row.standard }}</strong>
<div class="muted" style="font-size: 12px; margin-top: 2px">{{ row.standard_name }}</div>
<strong style="color: #303133">{{ resolveRenderPath(row, col.render.main_path) }}</strong>
<div v-if="resolveRenderPath(row, col.render.sub_path)" class="muted" style="font-size: 12px; margin-top: 2px">{{ resolveRenderPath(row, col.render.sub_path) }}</div>
<div v-if="resolveRenderPath(row, col.render.tertiary_path)" class="muted" :style="'font-size: 11px; margin-top: 2px; color: #909399;' + (col.render.tertiary_italic === false ? '' : ' font-style: italic')">
{{ (col.render.tertiary_prefix || '') + resolveRenderPath(row, col.render.tertiary_path) }}
</div>
</template>
</el-table-column>
<el-table-column label="字段数 / 有违规" width="120" align="right">
<template #default="{ row }">
<div style="font-size: 13px">
<span style="color: #606266">{{ row.fields_count }}</span>
<span v-if="row.violating_fields_count !== undefined && row.violating_fields_count !== row.fields_count" class="muted" style="margin-left: 4px">/ <span style="color: #f56c6c; font-weight: 600">{{ row.violating_fields_count }}</span></span>
</div>
</template>
</el-table-column>
<el-table-column prop="total_sampled" label="抽样数" width="120" align="right" />
<el-table-column prop="total_invalid" label="违规数" width="120" align="right" />
<el-table-column label="违规率" width="140" align="center">
<template #default="{ row }">
<span :style="{ color: row.violation_rate > 0.5 ? '#f56c6c' : (row.violation_rate > 0.2 ? '#e6a23c' : '#67c23a'), fontWeight: 600 }">
{{ (row.violation_rate * 100).toFixed(1) }}%
</span>
</template>
</el-table-column>
<el-table-column label="字段清单 / 违规率(点击联动)" min-width="380">
<template #default="{ row }">
<div v-if="row.details_with_violations && row.details_with_violations.length">
<div v-for="d in row.details_with_violations.slice(0, 8)" :key="d.table + '.' + d.field" style="font-size: 12px; margin-top: 2px">
<span class="field-link" :class="{ 'field-link--active': selectedField && selectedField.table === d.table && selectedField.field === d.field }" @click="selectFieldFilter(d.table, d.field)">
<code style="color: #303133">{{ d.table }}.{{ d.field }}</code>
</span>
<span class="muted"> · </span>
<span :style="{ color: d.violation_rate > 0.5 ? '#f56c6c' : (d.violation_rate > 0.2 ? '#e6a23c' : '#67c23a') }">
{{ d.violations }}/{{ d.sampled }} ({{ (d.violation_rate * 100).toFixed(1) }}%)
</span>
</div>
<div v-if="row.details_with_violations.length > 8" class="muted" style="font-size: 11px; margin-top: 2px; font-style: italic">
… 等共 {{ row.details_with_violations.length }} 个有违规字段
</div>
<template v-else-if="col.render && col.render.kind === 'progress'" #default="{ row }">
<div style="display: flex; align-items: center; gap: 6px;">
<el-progress :percentage="Math.round(Math.max(0, Math.min(1, Number(resolveRenderPath(row, col.render.value_path)) || 0)) * 100)" :stroke-width="14" :status="resolveRenderPath(row, col.render.status_from_value_path) === 'high' ? 'exception' : (resolveRenderPath(row, col.render.status_from_value_path) === 'mid' ? 'warning' : '')" :show-text="false" style="flex: 1" />
<span style="min-width: 48px; text-align: right;">{{ Math.round(Math.max(0, Math.min(1, Number(resolveRenderPath(row, col.render.value_path)) || 0)) * 100) }}%</span>
</div>
<span v-else class="muted">—</span>
</template>
</el-table-column>
</el-table>
<h4 style="margin-top: 16px">
违规明细
<span v-if="selectedField" style="font-size: 13px; font-weight: normal; margin-left: 8px">
<el-tag type="info" effect="plain" closable @close="clearFieldFilter">
{{ selectedField.table }}.{{ selectedField.field }}
</el-tag>
<template v-else-if="col.render && col.render.kind === 'percent'" #default="{ row }">
<span :style="'color: ' + ((col.render.thresholds || []).reduce((acc, t) => Number(resolveRenderPath(row, col.render.value_path)) > t.gt ? t.color : acc, '#67c23a'))">{{ (Number(resolveRenderPath(row, col.render.value_path)) || 0).toFixed(col.render.precision ?? 1) }}%</span>
</template>
<template v-else-if="col.render && col.render.kind === 'ratio'" #default="{ row }">
<span :style="(col.render.emphasize === 'num' && Number(resolveRenderPath(row, col.render.num_path)) !== Number(resolveRenderPath(row, col.render.den_path))) ? 'color: #f56c6c' : ''">
{{ resolveRenderPath(row, col.render.num_path) }} / {{ resolveRenderPath(row, col.render.den_path) }}
</span>
<span v-else style="font-size: 12px; font-weight: normal; margin-left: 8px" class="muted">(前 200 条)</span>
</h4>
<el-table :data="filteredViolations" stripe size="small" style="margin-top: 12px; width: 100%" max-height="480">
<el-table-column label="表 / 表注释" min-width="220">
<template #default="{ row }">
<div>
<strong style="color: #303133">{{ row.table_name }}</strong>
<div v-if="row.table_comment" class="muted" style="font-size: 12px; margin-top: 2px">{{ row.table_comment }}</div>
<div v-else class="muted" style="font-size: 12px; margin-top: 2px; font-style: italic">(无表注释)</div>
</div>
</template>
</el-table-column>
<el-table-column label="字段 / 字段注释" min-width="220">
<template #default="{ row }">
<div>
<strong style="color: #303133">{{ row.column_name }}</strong>
<div v-if="row.column_comment" class="muted" style="font-size: 12px; margin-top: 2px">{{ row.column_comment }}</div>
<div v-else class="muted" style="font-size: 12px; margin-top: 2px; font-style: italic">(无字段注释)</div>
</div>
<template v-else-if="col.render && col.render.kind === 'number'" #default="{ row }">
{{ col.render.format === 'thousand_sep' ? Number(row[col.prop] || 0).toLocaleString() : row[col.prop] }}
</template>
</el-table-column>
<el-table-column label="标准 / 标准名" min-width="300">
<template #default="{ row }">
<div>
<strong style="color: #303133">{{ row.rule_type }}</strong>
<div class="muted" style="font-size: 12px; margin-top: 2px">{{ row.standard_name }}</div>
</div>
<template v-else-if="!col.render || !col.render.kind" #default="{ row }">
{{ row[col.prop] }}
</template>
</el-table-column>
<el-table-column label="值 / 违规原因" min-width="400">
<template #default="{ row }">
<div>
<code style="color: #303133">{{ row.value }}</code>
<div class="muted" style="font-size: 12px; margin-top: 2px">{{ row.error }}</div>
</el-table>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
......@@ -826,7 +511,7 @@
</div>
<script>
const { createApp, ref, reactive, onMounted, computed, watch, nextTick } = Vue;
const { createApp, ref, reactive, onMounted, computed, watch, nextTick, h, resolveComponent } = Vue;
const App = {
setup() {
......@@ -890,29 +575,56 @@
const jobResult = ref(null);
const activeTab = ref('merge');
// tab → 所属 step_id;sectionKey 不再按 step 编号,而是按 step_impl 返回的 section_key
// (冗余字段嵌在 merge_candidates 里,所以多一个 field 兜底)
const TAB_STEP_MAP = {
merge: { stepId: 'merge_redundancy', sectionKey: 'merge_candidates' },
redundancy: { stepId: 'merge_redundancy', sectionKey: 'merge_candidates', field: 'redundancy_fields' },
empty: { stepId: 'empty_fields', sectionKey: 'empty_fields' },
missing: { stepId: 'missing_comments', sectionKey: 'missing_comments' },
length: { stepId: 'length_check', sectionKey: 'length_issues' },
standards: { stepId: 'standards', sectionKey: 'standard_violations' },
};
const tabStatus = computed(() => {
// ── 协议驱动的展示数据 ──
// displayedTabs: 只渲染「被勾选的 step」贡献的 tab,每个 tab 自带 stepId / sectionKey / data
const displayedTabs = computed(() => {
const planned = new Set(jobStatus.value?.steps_planned || []);
const sections = jobResult.value?.sections || {};
const out = {};
for (const tab of Object.keys(TAB_STEP_MAP)) {
const { stepId, sectionKey, field } = TAB_STEP_MAP[tab];
if (!planned.has(stepId)) {
out[tab] = 'idle';
continue;
const out = [];
for (const [sectionKey, section] of Object.entries(sections)) {
if (!section || typeof section !== 'object') continue;
const proto = section._protocol;
if (!proto || !Array.isArray(proto.tabs)) continue;
if (!planned.has(proto.step_id)) continue;
for (const tab of proto.tabs) {
out.push({
tabKey: tab.key,
title: tab.title,
stepId: proto.step_id,
sectionKey,
summary: tab.summary || null,
tables: tab.tables || [],
linkage: tab.linkage || null,
data: section,
});
}
const section = sections[sectionKey];
const hasData = field ? (section && section[field]) : !!section;
out[tab] = hasData ? 'done' : 'running';
}
return out;
});
const displayedKpis = computed(() => {
const out = [];
for (const t of displayedTabs.value) {
for (const tabSpec of (t.data._protocol?.tabs || [])) {
if (tabSpec.key !== t.tabKey) continue;
for (const k of (tabSpec.kpis || [])) {
out.push({
stepId: t.stepId,
css_class: k.css_class,
label: k.label,
value: evalPathExpr(k.value, t.data),
});
}
}
}
return out;
});
// tabKey -> stepId 映射(badge 用)
const tabStatus = computed(() => {
const sections = jobResult.value?.sections || {};
const out = {};
for (const t of displayedTabs.value) {
const section = sections[t.sectionKey];
out[t.tabKey] = section && Object.keys(section).some(k => k !== '_protocol') ? 'done' : 'running';
}
return out;
});
......@@ -939,12 +651,8 @@
function clearFieldFilter() {
selectedField.value = null;
}
const filteredViolations = computed(() => {
const all = jobResult.value?.sections?.standard_violations?.violations || [];
if (!selectedField.value) return all.slice(0, 200);
const { table, field } = selectedField.value;
return all.filter(v => v.table_name === table && v.column_name === field);
});
// 注:旧 filteredViolations computed 已移除 —— 现在由 resolveRows() 在渲染时
// 根据 t.linkage 动态过滤(protocol-driven),单一实现多处复用。
// ── 数据字典:派生计算 ──
const tablesAll = computed(() => tableSummary.value);
......@@ -1219,40 +927,178 @@
}
}
// ── 空字段明细 / 统计 ──
const perTableStats = computed(() => {
const rows = jobResult.value?.sections?.empty_fields?.per_table || [];
let totalHigh = 0, totalMid = 0, noComment = 0;
for (const r of rows) {
totalHigh += (r.high_count || 0);
totalMid += (r.mid_count || 0);
if (!r.table_comment) noComment += 1;
// ── 协议渲染工具 ──────────────────────────────────────────────
// 路径求值(镜像 web/core/tab_protocol._eval_path)
// this.x / this.x.y / len(this.x) —— 不做任意表达式求值
function evalPathExpr(expr, data) {
if (!expr || typeof expr !== 'string') return '';
expr = expr.trim();
if (expr.startsWith('len(')) {
const inner = expr.slice(4, -1).trim();
const v = evalPathExpr(inner, data);
if (v == null) return 0;
if (typeof v === 'number') return v;
return Array.isArray(v) || typeof v === 'string' ? v.length : 0;
}
return {
tablesWithIssues: rows.length,
totalHigh, totalMid,
totalHighCells: totalHigh,
totalMidCells: totalMid,
tablesNoComment: noComment,
};
});
const perTableRows = computed(() =>
jobResult.value?.sections?.empty_fields?.per_table || []
);
const flatEmptyFields = computed(() => {
const rows = perTableRows.value;
const out = [];
for (const t of rows) {
const tableComment = t.table_comment || "";
for (const f of (t.high_empty_fields || [])) {
out.push({ ...f, table_name: t.table_name, table_comment: tableComment });
if (!expr.startsWith('this')) return '';
const parts = expr.split('.').slice(1);
let cur = data;
for (const p of parts) {
if (cur == null) return '';
if (typeof cur === 'object') cur = cur[p];
else return '';
}
for (const f of (t.mid_empty_fields || [])) {
out.push({ ...f, table_name: t.table_name, table_comment: tableComment });
return cur ?? '';
}
// 解析表格数据源(this.x 路径)
function resolvePath(source, data) {
return evalPathExpr(source, data) || [];
}
return out;
// 内联渲染用:从 row 上按 path 取值(path = "x.y")
function resolveRenderPath(row, path) {
if (!path) return '';
const parts = path.split('.');
let cur = row;
for (const p of parts) {
if (cur == null) return '';
if (typeof cur === 'object') cur = cur[p];
else return '';
}
return cur ?? '';
}
// 表格行:考虑 linkage 的过滤
function resolveRows(source, data, linkage, tableIdx) {
const all = resolvePath(source, data) || [];
if (linkage && tableIdx === linkage.target_table && selectedField.value) {
const a = linkage.field_map; // clickable prop -> data field
const tCol = Object.values(a)[0];
const fCol = Object.values(a)[1];
const tVal = selectedField.value[Object.keys(a)[0]];
const fVal = selectedField.value[Object.keys(a)[1]];
return all.filter(r => r[tCol] === tVal && r[fCol] === fVal);
}
if (linkage && tableIdx === linkage.target_table && !selectedField.value
&& linkage.default_filter_kind === 'limit' && linkage.default_filter_value) {
return all.slice(0, linkage.default_filter_value);
}
return all;
}
// summary 模板:替换 {{ var }}
function renderSummary(summary, data) {
if (!summary || !summary.template) return '';
return summary.template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => {
const path = summary.vars && summary.vars[k];
if (!path) return '';
const v = evalPathExpr(path, data);
if (typeof v === 'number') return v.toLocaleString();
return v == null ? '' : String(v);
});
}
// 通用列渲染:返回 VNode(Vue h())
function resolveRender(col, row) {
const r = col.render || {};
const propVal = col.prop ? row[col.prop] : '';
const resolveByPath = (path) => path ? evalPathExpr('this.' + path, { this: row }) : '';
switch (r.kind) {
case 'text':
return propVal;
case 'code':
return h('code', { style: 'background: #f5f7fa; padding: 1px 6px; border-radius: 3px; color: #e6a23c;' }, [propVal ?? '']);
case 'tag': {
const style = (r.value_map && r.value_map[propVal]) || r.fallback || { type: 'info', text: String(propVal ?? '') };
return h(resolveComponent('el-tag'), {
type: style.type || 'info',
size: 'small',
effect: style.effect || 'plain',
style: style.italic ? 'font-style: italic' : '',
}, () => style.text ?? String(propVal ?? ''));
}
case 'tag_list': {
const arr = Array.isArray(propVal) ? propVal : [];
return h('div', arr.map(v =>
h(resolveComponent('el-tag'), { size: 'small', style: 'margin-right: 4px' }, () => String(v))
));
}
case 'two_line': {
const main = resolveByPath(r.main_path);
const sub = resolveByPath(r.sub_path);
return h('div', [
h('strong', { style: 'color: #303133' }, [main ?? '']),
h('div', {
class: 'muted',
style: 'font-size: 12px; margin-top: 2px' + (sub ? '' : '; font-style: italic'),
}, [sub || r.sub_empty_text || '']),
]);
}
case 'three_line': {
const main = resolveByPath(r.main_path);
const sub = resolveByPath(r.sub_path);
const ter = resolveByPath(r.tertiary_path);
return h('div', [
h('strong', { style: 'color: #303133' }, [main ?? '']),
sub ? h('div', { class: 'muted', style: 'font-size: 12px; margin-top: 2px' }, [sub]) : null,
ter ? h('div', {
class: 'muted',
style: 'font-size: 11px; margin-top: 2px; color: #909399;' + (r.tertiary_italic === false ? '' : ' font-style: italic'),
}, [(r.tertiary_prefix || '') + ter]) : null,
]);
}
case 'progress': {
const v = Math.max(0, Math.min(1, Number(resolveByPath(r.value_path)) || 0));
const status = (() => {
const f = r.status_from_value_path;
if (!f) return '';
const sv = resolveByPath(f);
if (sv === 'high') return 'exception';
if (sv === 'mid') return 'warning';
return '';
})();
return h('div', { style: 'display: flex; align-items: center; gap: 6px;' }, [
h(resolveComponent('el-progress'), {
percentage: Math.round(v * 100),
strokeWidth: 14,
status,
showText: false,
style: 'flex: 1',
}),
h('span', { style: 'min-width: 48px; text-align: right;' }, [
h('strong', {
style: 'color: ' + (status === 'exception' ? '#f56c6c' : (status === 'warning' ? '#e6a23c' : '#67c23a')),
}, [Math.round(v * 100) + '%']),
]),
]);
}
case 'percent': {
const v = Number(resolveByPath(r.value_path)) || 0;
const prec = r.precision != null ? r.precision : 1;
const color = (() => {
for (const th of (r.thresholds || [])) {
if (v > th.gt) return th.color;
}
return '#67c23a';
})();
return h('span', { style: `color: ${color}; font-weight: 600` }, [(v * 100).toFixed(prec) + '%']);
}
case 'ratio': {
const num = Number(resolveByPath(r.num_path)) || 0;
const den = Number(resolveByPath(r.den_path)) || 0;
const numStyle = r.emphasize === 'num' || r.emphasize === 'both' ? 'color: #f56c6c; font-weight: 600' : '';
const denStyle = r.emphasize === 'den' || r.emphasize === 'both' ? 'color: #f56c6c; font-weight: 600' : '';
return h('span', [
h('span', { style: numStyle }, [String(num)]),
h('span', { class: 'muted' }, [' / ']),
h('span', { style: denStyle }, [String(den)]),
]);
}
case 'number': {
const v = Number(propVal) || 0;
if (r.format === 'thousand_sep') return v.toLocaleString();
return String(v);
}
default:
return propVal ?? '';
}
}
onMounted(() => {
loadSteps();
......@@ -1269,12 +1115,14 @@
disconnectDataDict,
currentJob, jobStatus, statusTagType,
logs, logExpanded, showStandardsDialog,
jobResult, activeTab, perTableStats, flatEmptyFields, perTableRows,
jobResult, activeTab,
displayedTabs, displayedKpis,
resolveRows, renderSummary, resolveRenderPath, resolveRender,
tabStatus, tabBadgeType, tabBadgeText, tabBadgeShow,
testConnection, startJob, cancelJob, resetAndStart,
downloadReport,
loadStandards,
selectedField, selectFieldFilter, clearFieldFilter, filteredViolations,
selectedField, selectFieldFilter, clearFieldFilter,
};
}
};
......
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