Commit a0c39eb1 authored by Data Governance Dev's avatar Data Governance Dev

refactor(web): 布局调整 + 还原字段名/注释关键字匹配 + 删除字段 checkbox

按 2026-08-12 用户反馈:
- 「分析配置」卡片上移到「连接数据库」下方、「数据字典浏览器」上方
- 还原回旧版「字段名 + 字段注释关键字模糊匹配」自动选字段行为
- 删除右表格字段 checkbox 及全链路 column_filter

前端 (web/static/index.html):
- 卡片顺序:连接数据库 → 分析配置 → 数据字典浏览器 → 进度 → 结果
- 删右表格 selection 列、工具栏(全选/清空/反选)、fieldsTableRef、form.columns
- startJob() 不再带 columns,只看 tables.length

后端:
- web/core/models.py: 删 ConnectRequest.columns
- web/core/job_manager.py: 不再透传 columns
- web/core/orchestrator.py: 移除 columns / column_filter 构造与参数
- 6 个 step 文件(step2/4/5/6/7)移除 column_filter 参数
- step7_standards.py: 恢复 _collect_hit_columns + has_name/has_cmt 守卫

文档:
- docs/WORKLOG.md: 追加本次记录
parent 8728514f
......@@ -2,6 +2,112 @@
> 任务做完一次记一次。最近的在最上面。
## 2026-08-12 · 布局调整 + 还原字段名/注释关键字匹配 + 删除右表格 checkbox
### 需求
> "1. 修改页面布局,把分析配置放到连接数据库正下方,数据字典浏览器的上方,2. 针对每一个分析配置,还是改成用字段名和字段注释模糊匹配的形式(之前的版本可以参考)"
(沟通后明确:① step4/5/6/7 全部还原回关键字匹配;② 右表格 checkbox 整个删掉。)
把前一轮「用户勾选字段才分析」的方案**整体回滚**:
1. 调整前端布局,分析配置移到「连接数据库」下方、「数据字典浏览器」上方。
2. 分析引擎回到「字段名 + 字段注释关键字模糊匹配」自动选字段的旧行为。
### 改动
#### 1. 布局调整(前端)
[web/static/index.html](web/static/index.html) 卡片顺序改为:
1. 连接数据库
2. **分析配置**(含「开始分析」按钮)
3. 数据字典浏览器(用户勾选要跑的表)
4. 分析进度 + 实时日志
5. 分析结果
「开始分析 / 重新分析 / 取消任务」按钮跟着分析配置一起上移;不再藏在数据字典浏览器里。
#### 2. 删除右表格 checkbox + column_filter 全链路回滚
**前端 [web/static/index.html](web/static/index.html):**
- 删除右表格的 `<el-table-column type="selection" />`、工具栏「全选 / 清空 / 反选」、`fieldsTableRef`、`form.columns: Set`。
- `startJob()` / `testConnection()` 不再带 `columns` 字段;按钮 `:disabled` 只看 `connected + tables.length`。
- 右表格标题文字从「已选 X 个字段」回退到「当前展示 N 张选中表的 M 个字段」。
**后端 [web/core/models.py](web/core/models.py):**
- `ConnectRequest.columns: list[dict[str, str]]` 删除。
**后端 [web/core/job_manager.py](web/core/job_manager.py):**
- 不再透传 `columns=job.req.columns or None`。
**后端 [web/core/orchestrator.py](web/core/orchestrator.py):**
- `run_governance_workflow()` 移除 `columns` / `column_filter` 构造和参数。
- 6 个 step runner 签名(`_run_merge_redundancy` / `_run_empty_fields` / `_run_missing_comments` / `_run_length_check` / `_run_standards_one` / `_run_standards`)移除 `column_filter` 参数。
- 日志只输出「用户勾选表数: N 张」。
**6 个 step 实现文件** —— 全部移除 `column_filter` 参数和过滤块:
- [web/core/step_impl/step2_merge_redundancy.py](web/core/step_impl/step2_merge_redundancy.py)
- [web/core/step_impl/step4_empty_fields.py](web/core/step_impl/step4_empty_fields.py)
- [web/core/step_impl/step5_missing_comments.py](web/core/step_impl/step5_missing_comments.py)
- [web/core/step_impl/step6_length_check.py](web/core/step_impl/step6_length_check.py)
- [web/core/step_impl/step7_standards.py](web/core/step_impl/step7_standards.py) —— 同时恢复旧的 `_collect_hit_columns` + `has_name / has_cmt` 守卫
#### 3. Step 7 恢复 `applies_to_fields + comment_keywords` 关键字匹配
[web/core/step_impl/step7_standards.py](web/core/step_impl/step7_standards.py) 把上一轮新加的 `_select_target_columns` 删除,恢复旧版 `_collect_hit_columns`:
- 字段名命中:column_name ∈ `applies_to_fields`(精确)。
- 注释命中:comment_keywords 任一 substring(不区分大小写)出现在 column_comment 里。
- 两路并集命中字段;命中依据写入 `basis` 字段便于审计。
- 单值类指标(`IND-301/302` 不匹配)走 `has_name / has_cmt` 守卫:两者都为空时跳过该 standard 并写 WARN 日志。
### 验证
```bash
# Python 烟雾测试:所有 column_filter 残留都应 = 0
python -c "
import inspect
from web.core.step_impl.step2_merge_redundancy import run_step2
from web.core.step_impl.step4_empty_fields import run_step4
from web.core.step_impl.step5_missing_comments import run_step5
from web/core.step_impl.step6_length_check import run_step6
from web.core.step_impl.step7_standards import run_step7, run_step7_for_indicator
for fn in [run_step2, run_step4, run_step5, run_step6, run_step7, run_step7_for_indicator]:
print(fn.__name__, 'column_filter' in inspect.signature(fn).parameters)
"
```
后端结果:
```
run_step2 False
run_step4 False
run_step5 False
run_step6 False
run_step7 False
run_step7_for_indicator False
```
前端 HTML 关键词搜索:
| 关键字 | 次数(应=0) |
|---|---|
| `form.columns` | 0 |
| `fieldsTableRef` | 0 |
| `syncFieldsWithTables` | 0 |
| `selectAllFields / clearAllFields / invertFieldSelection` | 0 |
| `selectedColumnCount` | 0 |
| `onColumnSelectionChange` | 0 |
大括号配平 225 / 225。
### 不动的地方
- 左表格(表选择)的 selection 列保留 —— 用户还是要勾选「跑哪些表」。
- 分析配置卡片的勾选 / 展开状态机不变。
- Step 5(缺注释)依旧不走 LLM。
- Step 2(表合并)依旧走 LLM(`required`),UI 上仍然隐藏。
---
## 2026-08-12 · 实时日志默认折叠
### 需求
......
......@@ -231,7 +231,6 @@ class JobManager:
tables=job.req.tables,
run_dir=run_dir,
enable_llm=job.req.enable_llm,
columns=job.req.columns or None,
on_log=lambda entry: self._sync_log(job, entry),
on_step_start=lambda step_id, title: self._sync_step_start(job, step_id, title),
on_step_done=lambda step_id, title, ok: self._sync_step_done(job, step_id, title, ok),
......
......@@ -27,10 +27,6 @@ class ConnectRequest(BaseModel):
steps: Optional[list[str]] = None
# 必填:要分析的表(前端从数据字典里勾选;空 = 禁止启动,由前端拦截)
tables: list[str] = Field(default_factory=list)
# 可选:要分析的字段(前端从右表格勾选;[(table, column), ...])。
# - 空 = 走「选中表的全量字段」向后兼容(不勾字段表格时使用)
# - 非空 = 仅跑用户勾选的字段,不再按关键字 / 注释关键字推断(2026-08-11 起改)
columns: list[dict[str, str]] = Field(default_factory=list)
# 可选:是否启用 LLM 增强(默认 True,缺 Key 时自动降级)
enable_llm: bool = True
# 可选:报告标题
......
......@@ -95,58 +95,51 @@ def get_step_defs() -> list[StepDef]:
return sorted(_STEPS.values(), key=lambda s: s.order)
# ── Step 函数:参数统一 (cfg, dict_data, llm, log, cancel_event, table_filter, column_filter) ──
# ── Step 函数:参数统一 (cfg, dict_data, llm, log, cancel_event, table_filter) ──
# 每个 _run_* 接收 orchestrator 提供的统一上下文,做最薄的适配(调真正的 step_impl)
def _run_merge_redundancy(*, cfg, dict_data, llm, log, cancel_event, table_filter, column_filter):
def _run_merge_redundancy(*, cfg, dict_data, llm, log, cancel_event, table_filter):
from .step_impl.step2_merge_redundancy import run_step2
data = run_step2(dict_data, llm=llm, log=log, table_filter=table_filter,
column_filter=column_filter)
data = run_step2(dict_data, llm=llm, log=log, table_filter=table_filter)
return {"section_key": "merge_candidates", "data": data}
def _run_empty_fields(*, cfg, dict_data, llm, log, cancel_event, table_filter, column_filter):
def _run_empty_fields(*, cfg, dict_data, llm, log, cancel_event, table_filter):
from .step_impl.step4_empty_fields import run_step4
data = run_step4(cfg, log=log, dict_data=dict_data,
table_filter=table_filter, column_filter=column_filter)
data = run_step4(cfg, log=log, dict_data=dict_data, table_filter=table_filter)
return {"section_key": "empty_fields", "data": data}
def _run_missing_comments(*, cfg, dict_data, llm, log, cancel_event, # noqa: ARG001
table_filter, column_filter):
def _run_missing_comments(*, cfg, dict_data, llm, log, cancel_event, table_filter): # noqa: ARG001
"""缺失注释字段检查(纯规则,不需要 LLM,llm 参数保留仅为签名统一)"""
from .step_impl.step5_missing_comments import run_step5
data = run_step5(dict_data, log=log,
table_filter=table_filter, column_filter=column_filter)
data = run_step5(dict_data, log=log, table_filter=table_filter)
return {"section_key": "missing_comments", "data": data}
def _run_length_check(*, cfg, dict_data, llm, log, cancel_event, table_filter, column_filter):
def _run_length_check(*, cfg, dict_data, llm, log, cancel_event, table_filter):
from .step_impl.step6_length_check import run_step6
data = run_step6(dict_data, log=log,
table_filter=table_filter, column_filter=column_filter)
data = run_step6(dict_data, log=log, table_filter=table_filter)
return {"section_key": "length_issues", "data": data}
def _run_standards_one(standard_id: str):
"""返回单个 indicator step 的 runner(捕获 standard_id 闭包)。"""
def _runner(*, cfg, dict_data, llm, log, cancel_event, table_filter, column_filter):
def _runner(*, cfg, dict_data, llm, log, cancel_event, table_filter):
from .step_impl.step7_standards import run_step7_for_indicator
return run_step7_for_indicator(
standard_id, cfg, dict_data=dict_data, log=log,
table_filter=table_filter, column_filter=column_filter,
standard_id, cfg, dict_data=dict_data, log=log, table_filter=table_filter,
)
return _runner
def _run_standards(*, cfg, dict_data, llm, log, cancel_event, table_filter, column_filter): # noqa: ARG001
def _run_standards(*, cfg, dict_data, llm, log, cancel_event, table_filter): # noqa: ARG001
"""旧版「国家标准校验」聚合入口 —— 已被 per-indicator 模式取代,保留以兼容历史调用。
实际 orchestrator 不再注册此 step;用户勾选的是 std_ind_* 单 indicator。
"""
from .step_impl.step7_standards import run_step7
data = run_step7(cfg, dict_data=dict_data, log=log,
table_filter=table_filter, column_filter=column_filter)
data = run_step7(cfg, dict_data=dict_data, log=log, table_filter=table_filter)
return {"section_key": "standard_violations", "data": data}
......@@ -582,7 +575,6 @@ async def run_governance_workflow(
tables: list[str],
run_dir: Path,
enable_llm: bool = True,
columns: list[dict[str, str]] | None = None,
on_log: Callable[[dict], None] | None = None,
on_step_start: Callable[[str, str], None] | None = None,
on_step_done: Callable[[str, str, bool], None] | None = None,
......@@ -598,9 +590,6 @@ async def run_governance_workflow(
tables: 用户勾选的表名列表(不可为空 —— 由前端 / 路由层兜底校验)
run_dir: 本次运行的输出目录
enable_llm: 是否允许 Step 调用 LLM
columns: 用户从右表格勾选的字段列表 `[{"table_name": ..., "column_name": ...}, ...]`。
空 = 走「选中表的全量字段」向后兼容;
非空 = 仅跑这些字段,**不再按关键字 / 注释关键字推断**(2026-08-11 改)
Returns:
{
......@@ -637,14 +626,7 @@ async def run_governance_workflow(
cache = DataDictCache.get_instance()
dict_data = cache.get_or_extract(cfg, log=lambda lvl, msg, **_kw: log(lvl, msg))
table_filter: set[str] | None = set(tables) if tables else None
column_filter: set[tuple[str, str]] | None = (
{(c["table_name"], c["column_name"]) for c in columns
if c.get("table_name") and c.get("column_name")}
if columns else None
)
log("INFO",
f"用户勾选表数: {len(tables)} 张, "
f"字段数: {len(column_filter) if column_filter else '全部(按 table_filter 全表)'}")
log("INFO", f"用户勾选表数: {len(tables)} 张")
# ── 解析要跑的步骤 ──
requested = list(steps) if steps else [s.step_id for s in get_step_defs()]
......@@ -737,7 +719,6 @@ async def run_governance_workflow(
log=log,
cancel_event=cancel_event,
table_filter=table_filter,
column_filter=column_filter,
),
)
section_key = output.get("section_key")
......
......@@ -137,13 +137,11 @@ OLD_PREFIXES = ["t_", "mall_", "project_"]
def run_step2(dict_data: dict, llm: LLMClient | None = None,
log: Callable | None = None,
table_filter: set[str] | None = None,
column_filter: set[tuple[str, str]] | None = None) -> dict:
table_filter: set[str] | None = None) -> dict:
"""合并候选 + 冗余字段分析。
Args:
table_filter: 前端勾选的表名集合(None/空 = 全部)。
column_filter: 前端从右表格勾选的字段集合(None/空 = 选中表的全量字段)。
"""
columns = dict_data.get("data_dictionary", [])
table_summary = dict_data.get("table_summary", [])
......@@ -161,15 +159,6 @@ def run_step2(dict_data: dict, llm: LLMClient | None = None,
f"按 table_filter 过滤后: {len(table_summary)} 张表, {len(columns)} 个字段",
step="merge_redundancy")
# 字段级过滤:保留被勾选的字段(或全表字段)
if column_filter:
columns = [c for c in columns
if (c["table_name"], c["column_name"]) in column_filter]
if log:
log("INFO",
f"按 column_filter 过滤后: {len(columns)} 个字段",
step="merge_redundancy")
by_table: dict[str, list[dict]] = defaultdict(list)
for row in columns:
by_table[row["table_name"]].append(row)
......
......@@ -178,14 +178,12 @@ def run_step4(cfg: DBConfig, log: Callable | None = None,
high_threshold: float = 0.80,
mid_threshold: float = 0.50,
sample_size: int = 3,
table_filter: set[str] | None = None,
column_filter: set[tuple[str, str]] | None = None) -> dict:
table_filter: set[str] | None = None) -> dict:
"""扫描所有表的空字段情况(纯程序化,不依赖 LLM)
Args:
dict_data: 必填 —— orchestrator 在任务启动时已通过 DataDictCache 拿到(或现场重抽)。
table_filter: 前端勾选的表名集合(None/空 = 全部)。
column_filter: 前端从右表格勾选的字段集合(None/空 = 选中表的全量字段)。
"""
if log:
log("INFO",
......@@ -211,15 +209,6 @@ def run_step4(cfg: DBConfig, log: Callable | None = None,
f"按 table_filter 过滤后: {len(table_summary)} 张表, {len(columns)} 个字段",
step="empty_fields")
# 字段级过滤
if column_filter:
columns = [c for c in columns
if (c["table_name"], c["column_name"]) in column_filter]
if log:
log("INFO",
f"按 column_filter 过滤后: {len(columns)} 个字段",
step="empty_fields")
by_table: dict[str, list[dict]] = defaultdict(list)
for row in columns:
by_table[row["table_name"]].append(row)
......
......@@ -101,15 +101,13 @@ def _is_missing(comment: str | None) -> bool:
def run_step5(dict_data: dict,
log: Callable | None = None,
table_filter: set[str] | None = None,
column_filter: set[tuple[str, str]] | None = None) -> dict:
table_filter: set[str] | None = None) -> dict:
"""缺失注释字段检查(纯规则,无 LLM)。
Args:
dict_data: 数据字典(含 data_dictionary 字段)。
log: 日志回调(可选)。
table_filter: 前端勾选的表名集合(None/空 = 全部)。
column_filter: 前端从右表格勾选的字段集合(None/空 = 选中表的全量字段)。
"""
columns = dict_data.get("data_dictionary", [])
if table_filter:
......@@ -118,13 +116,6 @@ def run_step5(dict_data: dict,
log("INFO",
f"按 table_filter 过滤后: {len(columns)} 个字段",
step="missing_comments")
if column_filter:
columns = [c for c in columns
if (c["table_name"], c["column_name"]) in column_filter]
if log:
log("INFO",
f"按 column_filter 过滤后: {len(columns)} 个字段",
step="missing_comments")
if not columns:
if log:
log("WARN", "未获取到任何字段元数据,跳过", step="missing_comments")
......
......@@ -229,24 +229,15 @@ def _match_rule(col_name: str, col_comment: str, rule: LengthRule) -> tuple[bool
def run_step6(dict_data: dict, log: Callable | None = None,
table_filter: set[str] | None = None,
column_filter: set[tuple[str, str]] | None = None) -> dict:
table_filter: set[str] | None = None) -> dict:
"""字段长度检查(纯规则匹配)。
Args:
table_filter: 前端勾选的表名集合(None/空 = 全部)。
column_filter: 前端从右表格勾选的字段集合(None/空 = 选中表的全量字段)。
"""
columns = dict_data.get("data_dictionary", [])
if table_filter:
columns = [c for c in columns if c["table_name"] in table_filter]
if column_filter:
columns = [c for c in columns
if (c["table_name"], c["column_name"]) in column_filter]
if log:
log("INFO",
f"按 column_filter 过滤后: {len(columns)} 个字段",
step="length_check")
if not columns:
if log:
log("WARN", "未获取到任何字段元数据,跳过", step="length_check")
......
......@@ -463,21 +463,57 @@ def _empty_indicator_data() -> dict:
}
def _select_target_columns(
def _collect_hit_columns(
std_instance: BaseStandard,
all_columns: list[dict],
) -> list[dict]:
"""2026-08-11 改:直接返回上游已按 column_filter 过滤的 all_columns。
) -> tuple[list[dict], str]:
"""按 applies_to_fields(字段名) + comment_keywords(字段注释 substring,
不区分大小写)两路并集命中字段;返回 (命中字段列表, 命中原因描述)。
历史:早期版本会按 standard.applies_to_fields(字段名)和
comment_keywords(字段注释 substring)做关键字匹配来「推断这个 indicator
该跑哪些字段」。这种推断对用户不可见、经常猜错、且难解释为什么这个字段
被检查。
改为:用户从右表格勾选什么就检查什么。前端 columns 已在 orchestrator
入口处过滤好 all_columns,这里只做轻量包装。
2026-08-12 恢复:用户决定走回关键字匹配方案,每个 indicator 用自身
的 applies_to_fields(精确字段名)+ comment_keywords(注释 substring)
自动推断「应该检查哪些字段」。前端不再需要逐字段勾选。
"""
return list(all_columns)
applicable = list(getattr(std_instance, "applies_to_fields", []) or [])
comment_kws = [k.strip().lower() for k in (getattr(std_instance, "comment_keywords", []) or []) if k]
applicable_set = set(applicable)
hits: list[dict] = []
seen: set[tuple[str, str]] = set()
hit_kinds: set[str] = set()
# 第 1 路:按字段名
if applicable_set:
for c in all_columns:
col_name = c.get("column_name", "")
if col_name in applicable_set:
key = (c.get("table_name", ""), col_name)
if key not in seen:
seen.add(key)
hits.append({**c, "_hit_kind": "name", "_hit_key": col_name})
hit_kinds.add("name")
# 第 2 路:按字段注释(substring 不区分大小写)
if comment_kws:
for c in all_columns:
cc = (c.get("column_comment") or "").lower()
if not cc:
continue
for kw in comment_kws:
if kw and kw in cc:
key = (c.get("table_name", ""), c.get("column_name", ""))
if key not in seen:
seen.add(key)
hits.append({**c, "_hit_kind": "comment", "_hit_key": kw})
hit_kinds.add("comment")
break
reason_bits = []
if "name" in hit_kinds:
reason_bits.append(f"字段名匹配 {applicable}")
if "comment" in hit_kinds:
reason_bits.append(f"注释匹配 {comment_kws}")
return hits, " / ".join(reason_bits) or "(无命中条件)"
def _run_round1_one(
......@@ -500,8 +536,8 @@ def _run_round1_one(
f" · {std_id} ({getattr(std_instance, 'group', '通用')}) 启动",
step=indicator_step_id(std_id))
# 取要检查的字段:2026-08-11 起改为「用户勾选字段」,不再按关键字推断
hit_cols = _select_target_columns(std_instance, all_columns)
# 找该 indicator 命中的所有字段(applies_to_fields ∪ comment_keywords)
hit_cols, hit_reason = _collect_hit_columns(std_instance, all_columns)
# 单 (table, field) 命中后仍按 MAX_TABLES_PER_FIELD 截断同一字段的表数;
# 不同表名 → 不同字段对 → 都保留
......@@ -516,15 +552,18 @@ def _run_round1_one(
fields_to_check.append(c)
if not fields_to_check:
applicable = list(getattr(std_instance, "applies_to_fields", []) or [])
cmt_kws = [k for k in (getattr(std_instance, "comment_keywords", []) or []) if k]
if log:
log("DEBUG",
f" · {std_id} 用户未勾选任何字段,跳过",
f" · {std_id} 未匹配任何字段(字段名={applicable} / "
f"注释关键字={cmt_kws}),跳过",
step=indicator_step_id(std_id))
return bucket
if log:
log("DEBUG",
f" · {std_id} 待检查 {len(fields_to_check)} 个字段(用户勾选)",
f" · {std_id} 命中 {len(fields_to_check)} 个字段({hit_reason})",
step=indicator_step_id(std_id))
rec = {
......@@ -1309,8 +1348,7 @@ def _run_round2_uniqueness(
def run_step7(cfg: DBConfig, dict_data: dict, log: Callable | None = None,
table_filter: set[str] | None = None,
column_filter: set[tuple[str, str]] | None = None) -> dict:
table_filter: set[str] | None = None) -> dict:
"""旧版全量入口(向后兼容):跑所有 indicator,按 group 输出 4 tab。
新代码应直接调 run_step7_for_indicator(standard_id, ...) 跑单个 indicator。
......@@ -1322,13 +1360,6 @@ def run_step7(cfg: DBConfig, dict_data: dict, log: Callable | None = None,
log("INFO",
f"按 table_filter 过滤后: {len(columns)} 个字段",
step="standards")
if column_filter:
columns = [c for c in columns
if (c["table_name"], c["column_name"]) in column_filter]
if log:
log("INFO",
f"按 column_filter 过滤后: {len(columns)} 个字段",
step="standards")
if not columns:
return _EMPTY_FALLBACK()
......@@ -1398,13 +1429,12 @@ def run_step7_for_indicator(
dict_data: dict,
log: Callable | None = None,
table_filter: set[str] | None = None,
column_filter: set[tuple[str, str]] | None = None,
) -> dict:
"""跑单个 indicator(orchestrator 每个 step 调一次)。
Args:
standard_id: 例如 "IND-001-a" / "IND-301" / "IND-302"
cfg / dict_data / log / table_filter / column_filter: 同 run_step7
cfg / dict_data / log / table_filter: 同 run_step7
Returns:
{section_key, data} —— data 已注入 _protocol(1 tab)
......@@ -1412,13 +1442,6 @@ def run_step7_for_indicator(
columns = dict_data.get("data_dictionary", [])
if table_filter:
columns = [c for c in columns if c["table_name"] in table_filter]
if column_filter:
columns = [c for c in columns
if (c["table_name"], c["column_name"]) in column_filter]
if log:
log("INFO",
f"按 column_filter 过滤后: {len(columns)} 个字段",
step=indicator_step_id(standard_id))
cls = load_standard_class(standard_id)
if cls is None:
......@@ -1450,8 +1473,16 @@ def run_step7_for_indicator(
elif standard_id == "IND-902":
data = _run_round3_cross_field_one(cfg, by_name, log)
else:
# 单值类:2026-08-11 起不再按 applies_to_fields / comment_keywords 关键字推断,
# 直接跑用户勾选的字段(columns 已在 orchestrator 入口按 column_filter 过滤过)。
# 单值类:字段名 OR 注释命中都可(applies_to_fields / comment_keywords 二选一非空)
has_name = bool(getattr(std_instance, "applies_to_fields", None))
has_cmt = bool(getattr(std_instance, "comment_keywords", None))
if not (has_name or has_cmt):
if log:
log("WARN",
f"{standard_id} 没有 applies_to_fields / comment_keywords 也没匹配 "
f"IND-301/302 模板,跳过",
step=indicator_step_id(standard_id))
return _empty_indicator_step_result(standard_id)
data = _run_round1_one(cfg, columns, std_instance, log)
return {"section_key": section_key, "data": _wrap_single_indicator(std_instance, data)}
......
......@@ -115,121 +115,7 @@
</el-form>
</el-card>
<!-- 卡片 2:数据字典浏览器(连接成功后才有数据) -->
<el-card v-if="connected && dataDict.length > 0" class="section-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><Document /></el-icon>
<span class="card-title">数据字典浏览器</span>
<span class="dict-status">
共 {{ tablesAll.length }} 张表,{{ dataDict.length }} 个字段
(已勾选 <strong style="color: #409eff">{{ form.tables.length }}</strong> 张)
</span>
<div style="margin-left: auto">
<el-button size="small" @click="selectAllTables" :disabled="form.tables.length === tablesAll.length">全选</el-button>
<el-button size="small" @click="selectNoTables" :disabled="form.tables.length === 0">清空</el-button>
<el-button size="small" @click="selectTablesInvert">反选</el-button>
</div>
</div>
</template>
<div class="dict-pane">
<!-- 左:表列表(带勾选) -->
<div class="dict-pane-left">
<div class="dict-toolbar">
<el-input v-model="tableSearch" placeholder="按表名 / 表注释筛选" clearable size="small" />
</div>
<el-table
ref="tablesTableRef"
:data="filteredTables"
stripe size="small" height="520"
@selection-change="onTableSelectionChange"
:row-key="row => row.table_name">
<!-- 注意:selection 列必须放在最后 —— 放在带 default slot 的列前面时,
Element Plus 会算错 header 的 colspan / scope,导致 body 行
的 selection cell 不渲染(只显示 header 的全选框)。 -->
<!-- 表名 / 表注释 分两列展示,避免挤在一个 cell 里导致行高过高 -->
<el-table-column label="表名" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<code class="dict-name" :title="row.table_name">{{ row.table_name }}</code>
</template>
</el-table-column>
<el-table-column label="表注释" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.table_comment" class="dict-comment" :title="row.table_comment">{{ row.table_comment }}</span>
<span v-else class="dict-comment dict-comment--empty">(无注释)</span>
</template>
</el-table-column>
<el-table-column label="字段数" width="72" align="right">
<template #default="{ row }">
<el-tag size="small" type="info" effect="plain">{{ fieldCountByTable[row.table_name] || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column type="selection" width="44" reserve-selection />
</el-table>
</div>
<!-- 右:选中表的字段 -->
<div class="dict-pane-right">
<div class="dict-toolbar">
<span class="dict-status">
<template v-if="form.tables.length === 0">
请在左侧勾选要分析的表 —— 不勾选任何表则无法启动分析
</template>
<template v-else>
当前展示 {{ form.tables.length }} 张选中表的 {{ filteredFields.length }} 个字段(已选 <strong style="color: #409eff">{{ selectedColumnCount }}</strong>)
</template>
</span>
<div class="dict-toolbar-buttons">
<el-button size="small" @click="selectAllFields" :disabled="form.tables.length === 0">全选</el-button>
<el-button size="small" @click="clearAllFields" :disabled="form.tables.length === 0">清空</el-button>
<el-button size="small" @click="invertFieldSelection" :disabled="form.tables.length === 0">反选</el-button>
</div>
<el-input v-model="fieldSearch" placeholder="按字段名 / 字段注释筛选" clearable size="small" style="flex: 0 0 240px" />
</div>
<el-table
ref="fieldsTableRef"
:data="filteredFields"
stripe size="small" height="520"
@selection-change="onColumnSelectionChange"
:row-key="row => row.table_name + '||' + row.column_name"
>
<el-table-column prop="table_name" label="表名" width="200" show-overflow-tooltip fixed>
<template #default="{ row }">
<code class="dict-name" :title="row.table_name">{{ row.table_name }}</code>
</template>
</el-table-column>
<el-table-column prop="column_name" label="字段名" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<code class="dict-name--field" :title="row.column_name">{{ row.column_name }}</code>
</template>
</el-table-column>
<el-table-column prop="column_type" label="类型" width="120">
<template #default="{ row }">
<code style="font-size: 12px">{{ row.column_type }}</code>
</template>
</el-table-column>
<el-table-column prop="is_nullable" label="可空" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_nullable === 'YES'" type="warning" size="small" effect="plain">YES</el-tag>
<el-tag v-else type="info" size="small" effect="plain">NO</el-tag>
</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="ordinal_position" label="序号" width="64" align="right" />
<!-- selection 列必须放在最后 —— Element Plus 的 colspan / scope 计算 bug -->
<el-table-column type="selection" width="44" reserve-selection />
</el-table>
</div>
</div>
</el-card>
<!-- 卡片 3:分析配置 + 启动 -->
<!-- 卡片 2:分析配置 + 启动 -->
<el-card v-if="connected && dataDict.length > 0" class="section-card" shadow="hover">
<template #header>
<div class="card-header">
......@@ -332,19 +218,122 @@
</el-col>
</el-row>
<el-form-item>
<el-button type="success" @click="startJob" :disabled="!connected || form.tables.length === 0 || form.columns.size === 0" :loading="submitting">
<el-icon><VideoPlay /></el-icon>开始分析({{ form.tables.length }} 张表 / {{ selectedColumnCount }} 个字段 / {{ form.steps.length }} 项检查)
<el-button type="success" @click="startJob" :disabled="!connected || form.tables.length === 0" :loading="submitting">
<el-icon><VideoPlay /></el-icon>开始分析({{ form.tables.length }} 张表 / {{ form.steps.length }} 项检查)
</el-button>
<el-button v-if="currentJob && jobStatus.status === 'running'" @click="cancelJob" type="danger" plain>
<el-icon><Close /></el-icon>取消任务
</el-button>
<el-button v-if="!submitting && (jobResult || (currentJob && jobStatus.status !== 'running'))" @click="resetAndStart" type="warning" plain :disabled="form.tables.length === 0 || form.columns.size === 0">
<el-button v-if="!submitting && (jobResult || (currentJob && jobStatus.status !== 'running'))" @click="resetAndStart" type="warning" plain :disabled="form.tables.length === 0">
<el-icon><Refresh /></el-icon>重新分析
</el-button>
</el-form-item>
<el-alert v-if="!connected" type="info" :closable="false" show-icon :title="'请先测试连接'" />
<el-alert v-if="connected && form.tables.length === 0" type="warning" :closable="false" show-icon :title="'请在「数据字典浏览器」中勾选至少一张表'" />
<el-alert v-if="connected && form.tables.length > 0 && form.columns.size === 0" type="warning" :closable="false" show-icon :title="'请在「数据字典浏览器」右侧勾选至少一个字段'" />
</el-card>
<!-- 卡片 3:数据字典浏览器(连接成功后才有数据) -->
<el-card v-if="connected && dataDict.length > 0" class="section-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><Document /></el-icon>
<span class="card-title">数据字典浏览器</span>
<span class="dict-status">
共 {{ tablesAll.length }} 张表,{{ dataDict.length }} 个字段
(已勾选 <strong style="color: #409eff">{{ form.tables.length }}</strong> 张)
</span>
<div style="margin-left: auto">
<el-button size="small" @click="selectAllTables" :disabled="form.tables.length === tablesAll.length">全选</el-button>
<el-button size="small" @click="selectNoTables" :disabled="form.tables.length === 0">清空</el-button>
<el-button size="small" @click="selectTablesInvert">反选</el-button>
</div>
</div>
</template>
<div class="dict-pane">
<!-- 左:表列表(带勾选) -->
<div class="dict-pane-left">
<div class="dict-toolbar">
<el-input v-model="tableSearch" placeholder="按表名 / 表注释筛选" clearable size="small" />
</div>
<el-table
ref="tablesTableRef"
:data="filteredTables"
stripe size="small" height="520"
@selection-change="onTableSelectionChange"
:row-key="row => row.table_name">
<!-- 注意:selection 列必须放在最后 —— 放在带 default slot 的列前面时,
Element Plus 会算错 header 的 colspan / scope,导致 body 行
的 selection cell 不渲染(只显示 header 的全选框)。 -->
<!-- 表名 / 表注释 分两列展示,避免挤在一个 cell 里导致行高过高 -->
<el-table-column label="表名" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<code class="dict-name" :title="row.table_name">{{ row.table_name }}</code>
</template>
</el-table-column>
<el-table-column label="表注释" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.table_comment" class="dict-comment" :title="row.table_comment">{{ row.table_comment }}</span>
<span v-else class="dict-comment dict-comment--empty">(无注释)</span>
</template>
</el-table-column>
<el-table-column label="字段数" width="72" align="right">
<template #default="{ row }">
<el-tag size="small" type="info" effect="plain">{{ fieldCountByTable[row.table_name] || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column type="selection" width="44" reserve-selection />
</el-table>
</div>
<!-- 右:选中表的字段(只读浏览,2026-08-12 取消字段级 checkbox) -->
<div class="dict-pane-right">
<div class="dict-toolbar">
<span class="dict-status">
<template v-if="form.tables.length === 0">
请在左侧勾选要分析的表 —— 不勾选任何表则无法启动分析
</template>
<template v-else>
当前展示 {{ form.tables.length }} 张选中表的 {{ filteredFields.length }} 个字段
</template>
</span>
<el-input v-model="fieldSearch" placeholder="按字段名 / 字段注释筛选" clearable size="small" style="flex: 0 0 240px" />
</div>
<el-table
:data="filteredFields"
stripe size="small" height="520"
>
<el-table-column prop="table_name" label="表名" width="200" show-overflow-tooltip fixed>
<template #default="{ row }">
<code class="dict-name" :title="row.table_name">{{ row.table_name }}</code>
</template>
</el-table-column>
<el-table-column prop="column_name" label="字段名" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<code class="dict-name--field" :title="row.column_name">{{ row.column_name }}</code>
</template>
</el-table-column>
<el-table-column prop="column_type" label="类型" width="120">
<template #default="{ row }">
<code style="font-size: 12px">{{ row.column_type }}</code>
</template>
</el-table-column>
<el-table-column prop="is_nullable" label="可空" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_nullable === 'YES'" type="warning" size="small" effect="plain">YES</el-tag>
<el-tag v-else type="info" size="small" effect="plain">NO</el-tag>
</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="ordinal_position" label="序号" width="64" align="right" />
</el-table>
</div>
</div>
</el-card>
<!-- 卡片 4:进度 + 日志 -->
......@@ -670,8 +659,7 @@
// ── 表单 ──
// steps 现在是 step_id 字符串列表(由后端 /api/steps 动态决定)
// tables 由用户在「数据字典浏览器」里勾选,传给后端做 table_filter
// columns 由用户在右表格勾选的字段(Set<"table||column">),提交时转
// [{table_name, column_name}, ...] 发给后端做 column_filter
// 字段选择交给各 step 自己按 applies_to_fields / comment_keywords 命中(2026-08-12 起)
// host/user/password/database 的默认值从 /api/db-defaults 拉取(见 web/configs/db_defaults.yaml),
// 下面这些是 JS 内置兜底值(API 失败 / 配置缺失时使用)
const form = reactive({
......@@ -685,7 +673,6 @@
connect_timeout: 10,
steps: [],
tables: [],
columns: new Set(),
});
const testing = ref(false);
......@@ -715,12 +702,6 @@
}
watch(() => form.db_type, onDbTypeChange);
// 2026-08-11 新增:勾选表时联动勾选该表所有字段(取消表则移除该表所有字段),
// 配合用户手动勾/取消,组成最终的「要分析的字段集合」发到后端做 column_filter。
watch(() => form.tables, (newTables, oldTables) => {
syncFieldsWithTables(newTables, oldTables);
});
// ── 步骤 & 标准 ──
// stepsList 保留作 fallback / required 检查;分组后用 analysisTree 驱动 UI
const stepsList = ref([]);
......@@ -1143,120 +1124,12 @@
}
}
// ── 数据字典:字段勾选同步 + 与表勾选联动 ──
// form.columns 是 Set<"table_name||column_name">,提交时转
// [{table_name, column_name}, ...] 发给后端做 column_filter。
//
// 联动规则(2026-08-11 起按用户要求:默认都勾 + 用户可手动减):
// 勾上表 A → A 的所有字段默认勾上
// 取消表 A → A 的所有字段从勾选集合移除
// 用户在右表格手动勾/取消单字段 → 单独生效,不联动回表
const fieldsTableRef = ref(null);
const selectedColumnCount = computed(() => form.columns.size);
function colKey(t, c) { return `${t}||${c}`; }
function onColumnSelectionChange(selectedRows) {
// Element Plus 给的是「当前可见行中已勾选的」全量列表(不是 diff),
// 所以我们用它来重置 Set;未在 selectedRows 中的字段会被反勾。
const next = new Set();
for (const r of selectedRows) {
next.add(colKey(r.table_name, r.column_name));
}
form.columns = next;
}
// 当 tables 变化时联动 fields:新增表的全字段默认勾上,取消表的字段全部移除
function syncFieldsWithTables(newTables, oldTables) {
const newSet = new Set(newTables);
const oldSet = new Set(oldTables || []);
const added = [...newSet].filter(t => !oldSet.has(t));
const removed = [...oldSet].filter(t => !newSet.has(t));
// 先清掉已取消表的所有字段
if (removed.length) {
for (const k of [...form.columns]) {
const t = k.split('||')[0];
if (removed.includes(t)) form.columns.delete(k);
}
}
// 新增表的全字段加入;不覆盖已存在的(保留用户的手动选择)
if (added.length) {
const fieldsByTable = new Map();
for (const r of dataDict.value) {
if (!fieldsByTable.has(r.table_name)) fieldsByTable.set(r.table_name, []);
fieldsByTable.get(r.table_name).push(r);
}
for (const t of added) {
for (const f of fieldsByTable.get(t) || []) {
form.columns.add(colKey(t, f.column_name));
}
}
}
// Element Plus 的勾选状态要靠我们重新刷一遍 el-table
refreshFieldsTableSelection();
}
// 把 form.columns 的状态同步回 el-table 的可视行勾选框
// (用户改 tables / 点工具栏按钮后都得调一次)
function refreshFieldsTableSelection() {
if (!fieldsTableRef.value) return;
fieldsTableRef.value.clearSelection();
// 只勾当前可见的字段(不在 filteredFields 里的即便在 Set 里也不显示勾)
for (const r of filteredFields.value) {
if (form.columns.has(colKey(r.table_name, r.column_name))) {
fieldsTableRef.value.toggleRowSelection(r, true);
}
}
}
function selectAllFields() {
if (!fieldsTableRef.value) return;
fieldsTableRef.value.clearSelection();
const seen = new Set();
for (const r of filteredFields.value) {
const k = colKey(r.table_name, r.column_name);
if (seen.has(k)) continue;
seen.add(k);
form.columns.add(k);
fieldsTableRef.value.toggleRowSelection(r, true);
}
}
function clearAllFields() {
if (!fieldsTableRef.value) return;
fieldsTableRef.value.clearSelection();
// 仅清掉当前可见字段的勾选;不在 filteredFields 里的保留
for (const r of filteredFields.value) {
form.columns.delete(colKey(r.table_name, r.column_name));
}
}
function invertFieldSelection() {
if (!fieldsTableRef.value) return;
fieldsTableRef.value.clearSelection();
const toSelect = [];
for (const r of filteredFields.value) {
const k = colKey(r.table_name, r.column_name);
if (form.columns.has(k)) {
form.columns.delete(k);
} else {
form.columns.add(k);
toSelect.push(r);
}
}
for (const r of toSelect) {
fieldsTableRef.value.toggleRowSelection(r, true);
}
}
// ── 断开重连:清空数据字典浏览器和已勾选的表 ──
function disconnectDataDict() {
connected.value = false;
tableSummary.value = [];
dataDict.value = [];
form.tables = [];
form.columns = new Set();
jobResult.value = null;
currentJob.value = null;
testResult.value = null;
......@@ -1322,13 +1195,11 @@
currentJob.value = null;
jobResult.value = null;
try {
// 测连接时不传 tables / columns(后端会忽略;columns 是 Set,
// JSON.stringify 会序列化成 {} 让 Pydantic 报 422,这里显式置空)。
const { columns, tables, ...rest } = form;
// 测连接时不传 tables(后端会忽略)
const r = await fetch('/api/connect/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...rest, tables: [], columns: [] }),
body: JSON.stringify({ ...form, tables: [] }),
}).then(r => r.json());
testResult.value = { ok: r.ok, msg: r.message };
connected.value = r.ok;
......@@ -1337,11 +1208,9 @@
dataDict.value = r.data_dictionary || [];
// 新数据库:清空勾选
form.tables = [];
form.columns = new Set();
// 等下一帧再清空 el-table 的内部选中状态
nextTick(() => {
if (tablesTableRef.value) tablesTableRef.value.clearSelection();
if (fieldsTableRef.value) fieldsTableRef.value.clearSelection();
});
} else {
tableSummary.value = [];
......@@ -1356,15 +1225,11 @@
// ── 启动分析 ──
async function startJob() {
// 前端兜底:tables / columns 任一为空都直接拦下(后端也会校验)
// 前端兜底:tables 为空直接拦下(后端也会校验)
if (form.tables.length === 0) {
ElementPlus.ElMessage.error('请先在「数据字典浏览器」中勾选要分析的表');
return;
}
if (form.columns.size === 0) {
ElementPlus.ElMessage.error('请在「数据字典浏览器」右侧勾选至少一个字段');
return;
}
submitting.value = true;
jobResult.value = null;
logs.value = [];
......@@ -1374,22 +1239,11 @@
form.steps.push(s.id);
}
}
// 把 Set<"table||column"> 转换为后端需要的 [{table_name, column_name}, ...]
const payload = {
...form,
columns: [...form.columns].map(k => {
const idx = k.indexOf('||');
return {
table_name: k.substring(0, idx),
column_name: k.substring(idx + 2),
};
}),
};
try {
const r = await fetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify(form),
}).then(r => r.json());
if (!r || !r.job_id) {
ElementPlus.ElMessage.error(r?.detail || '启动失败');
......@@ -1691,11 +1545,9 @@
toggleStep, toggleGroupCheck, toggleGroupExpand,
setAllChecks, setAllGroupsExpanded,
tableSummary, dataDict,
tableSearch, fieldSearch, tablesTableRef, fieldsTableRef,
tableSearch, fieldSearch, tablesTableRef,
tablesAll, filteredTables, filteredFields, fieldCountByTable,
onTableSelectionChange, selectAllTables, selectNoTables, selectTablesInvert,
onColumnSelectionChange, selectAllFields, clearAllFields, invertFieldSelection,
selectedColumnCount,
disconnectDataDict,
currentJob, jobStatus, statusTagType,
logs, logExpanded, showStandardsDialog,
......
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