Commit 69eea339 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): rule 表加「规则名称」列 → 前端展示「违反哪条规则」

数据明细(不合规行)当前只展示「哪个字段不合规」,没有「哪条规则被违反」。
desc 是给 LLM 看的自然语言(含「必须 18 位」指令式描述),
不适合直接给用户看。加独立字段 name 给前端展示,desc 继续喂 LLM。

【schema 变更】
- rule.name TEXT NOT NULL DEFAULT ''(老规则保持空串,迁移不破坏存量)
- _migrate_rule_name():ALTER TABLE ADD COLUMN name(幂等,123 条老规则验证保留)

【API 校验】
- RulePayload.name: str = PydField(..., min_length=1) —— 必填
- RuleOut.name: str = "" —— 老规则空串兜底

【检测引擎链路】
- CompiledFieldRules.rules 5 元 → 6 元(追加 name)
- _load_task_compiled snapshot / rule_list 都带 name
- _evaluate_row 返回 issues: {field, name, desc}
  → bad_rows 透传到前端「数据明细」备注列

【前端 UI】
- RuleModal:在「规则说明」上方加「规则名称」输入框(红星必填)
  - hasValidRules 加 name.trim() 校验
  - onSave 序列化带 name
  - namePlaceholder 按 rule_type 给不同示例
  - scoped .required 红星样式(不污染全局)
- ResultTable:ruleHint / errTip / 备注列 issues 优先展示 name
- FieldConfigDrawer:ruleHint 同步升级「name(desc)」格式

【兼容策略】
- 老规则 name=空串,UI 三处自动回退 desc 显示
- 编辑老规则保存时强制补 name(避免历史脏数据持续累积)

【测试】
- test_rule_types_e2e 6 处 rule payload 补 name(Pydantic 必填后端会拒 422)
- pytest tests/ → 123 passed
- vite build → ✓ built in 9.64s
- 冒烟:init_db() 对线上 DB 跑迁移,老规则全保留默认 ''
parent cc4144fd
...@@ -43,9 +43,11 @@ class CompiledFieldRules: ...@@ -43,9 +43,11 @@ class CompiledFieldRules:
字段大小写已归一为小写(与列名对齐)。 字段大小写已归一为小写(与列名对齐)。
""" """
field_key: str field_key: str
# 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null) # 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null, name)
# 2026-08-21:追加 skip_null(值为空时是否跳过) # 2026-08-21:追加 skip_null(值为空时是否跳过)
rules: list[tuple[str, Optional[str], Optional[str], str, bool]] = field(default_factory=list) # 2026-08-24:追加 name(规则名称)→ 给 _evaluate_row 写到 issues 里,
# 前端「数据明细」按字段+规则名展示「违反哪条规则」
rules: list[tuple[str, Optional[str], Optional[str], str, bool, str]] = field(default_factory=list)
@dataclass @dataclass
......
...@@ -97,6 +97,8 @@ def init_db() -> None: ...@@ -97,6 +97,8 @@ def init_db() -> None:
_migrate_rule_type_and_code() _migrate_rule_type_and_code()
# 一次性迁移:rule 表加 skip_null 列(2026-08-21 「跳过空值」开关,默认 0 = 不跳过) # 一次性迁移:rule 表加 skip_null 列(2026-08-21 「跳过空值」开关,默认 0 = 不跳过)
_migrate_rule_skip_null() _migrate_rule_skip_null()
# 一次性迁移:rule 表加 name 列(2026-08-24 「规则名称」用于前端展示「违反哪条规则」,默认 '' 老规则 desc 兜底)
_migrate_rule_name()
# 一次性迁移:task 表加 checked_at 列(2026-08-21 接 UI「最近检测时间」) # 一次性迁移:task 表加 checked_at 列(2026-08-21 接 UI「最近检测时间」)
_migrate_task_checked_at() _migrate_task_checked_at()
# 一次性迁移:task 表加 table_comment 列(2026-08-21 接「数据表注释」UI) # 一次性迁移:task 表加 table_comment 列(2026-08-21 接「数据表注释」UI)
...@@ -204,6 +206,25 @@ def _migrate_rule_skip_null() -> None: ...@@ -204,6 +206,25 @@ def _migrate_rule_skip_null() -> None:
) )
def _migrate_rule_name() -> None:
"""给 rule 表加 name 列(2026-08-24 「规则名称」展示「违反哪条规则」用)。
NOT NULL DEFAULT '':老规则保持空串,迁移不破坏存量数据;
新建/编辑规则时由前端 RuleModal 强制非空 + API Pydantic 校验双保险。
UI 在 name 为空时用 desc 兜底显示。
"""
with engine.begin() as conn:
cols = {
row[1]: row
for row in conn.exec_driver_sql("PRAGMA table_info(rule)").fetchall()
}
if "name" not in cols:
logger.info("[migrate] rule 缺 name 列,补建(默认 '',老规则显示 desc 兜底)")
conn.exec_driver_sql(
"ALTER TABLE rule ADD COLUMN name TEXT NOT NULL DEFAULT ''"
)
def _migrate_task_checked_at() -> None: def _migrate_task_checked_at() -> None:
"""给 task 表加 checked_at 列(2026-08-21 接上「最近检测时间」UI)。 """给 task 表加 checked_at 列(2026-08-21 接上「最近检测时间」UI)。
......
...@@ -77,10 +77,14 @@ CREATE INDEX idx_field_task ON field(task_id); ...@@ -77,10 +77,14 @@ CREATE INDEX idx_field_task ON field(task_id);
-- code number/date/string 类型的 Python 函数源码(必须含 def check(value): 返回 bool) -- code number/date/string 类型的 Python 函数源码(必须含 def check(value): 返回 bool)
-- regex regex 类型专用;number/date/string 留空 -- regex regex 类型专用;number/date/string 留空
-- skip_null 1 = 值为空(None/空 str)时直接判合规跳过;0 = 空值进 check 函数按不合规处理 -- skip_null 1 = 值为空(None/空 str)时直接判合规跳过;0 = 空值进 check 函数按不合规处理
-- name 规则名称(2026-08-24):用于前端显示「违反哪条规则」
-- NOT NULL DEFAULT '':老规则(2026-08-24 之前)保持空串;
-- UI 在 name 为空时用 desc 兜底显示;新建规则必须非空(API 校验 + RuleModal 强制)
CREATE TABLE rule ( CREATE TABLE rule (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
field_id INTEGER NOT NULL REFERENCES field(id) ON DELETE CASCADE, field_id INTEGER NOT NULL REFERENCES field(id) ON DELETE CASCADE,
desc TEXT NOT NULL, desc TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
regex TEXT, regex TEXT,
rule_type TEXT NOT NULL DEFAULT 'regex', rule_type TEXT NOT NULL DEFAULT 'regex',
code TEXT, code TEXT,
......
...@@ -19,6 +19,10 @@ ...@@ -19,6 +19,10 @@
- 默认 0(不跳过 —— 空值进 check 函数报错,记为不合规) - 默认 0(不跳过 —— 空值进 check 函数报错,记为不合规)
- 1 = 跳过(空值直接判合规,不走 check 函数) - 1 = 跳过(空值直接判合规,不走 check 函数)
- 对 regex 类型同样生效:空值直接 True,不跑正则 - 对 regex 类型同样生效:空值直接 True,不跑正则
- name(2026-08-24):规则名称,给前端展示「违反哪条规则」用
- NOT NULL DEFAULT '':老规则保持空串(迁移不破坏存量数据)
- 新建/编辑时由 RuleModal 强制非空(前端)+ RulePayload 校验(API)
- 为空时 UI 用 desc 兜底显示
- ord:规则在 UI 上的显示顺序(保存时按数组顺序写入) - ord:规则在 UI 上的显示顺序(保存时按数组顺序写入)
""" """
from __future__ import annotations from __future__ import annotations
...@@ -38,6 +42,8 @@ class Rule(Base): ...@@ -38,6 +42,8 @@ class Rule(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
field_id = Column(Integer, ForeignKey("field.id", ondelete="CASCADE"), nullable=False) field_id = Column(Integer, ForeignKey("field.id", ondelete="CASCADE"), nullable=False)
desc = Column(Text, nullable=False) # 规则说明(自然语言) desc = Column(Text, nullable=False) # 规则说明(自然语言)
# 2026-08-24:规则名称,给前端展示「违反哪条规则」用;老规则默认空串(迁移兼容)
name = Column(Text, nullable=False, default="", server_default="")
regex = Column(Text, nullable=True) # Python 正则(regex 类型用) regex = Column(Text, nullable=True) # Python 正则(regex 类型用)
rule_type = Column( # 校验类型(2026-08-21 扩展) rule_type = Column( # 校验类型(2026-08-21 扩展)
String, nullable=False, default=DEFAULT_RULE_TYPE, String, nullable=False, default=DEFAULT_RULE_TYPE,
...@@ -51,6 +57,8 @@ class Rule(Base): ...@@ -51,6 +57,8 @@ class Rule(Base):
return { return {
"id": self.id, "id": self.id,
"desc": self.desc, "desc": self.desc,
# 2026-08-24:规则名称;空串表示「老规则未填」,UI 用 desc 兜底
"name": self.name or "",
"regex": self.regex, "regex": self.regex,
"rule_type": self.rule_type or DEFAULT_RULE_TYPE, "rule_type": self.rule_type or DEFAULT_RULE_TYPE,
"code": self.code, "code": self.code,
......
...@@ -147,6 +147,8 @@ def _load_task_compiled(db: Session, task_id: int): ...@@ -147,6 +147,8 @@ def _load_task_compiled(db: Session, task_id: int):
{ {
"id": r.id, "id": r.id,
"desc": r.desc, "desc": r.desc,
# 2026-08-24:规则名称,老规则空串;前端用 desc 兜底显示
"name": r.name or "",
"rule_type": r.rule_type or "regex", "rule_type": r.rule_type or "regex",
"regex": r.regex, "regex": r.regex,
"code": r.code, "code": r.code,
...@@ -162,23 +164,25 @@ def _load_task_compiled(db: Session, task_id: int): ...@@ -162,23 +164,25 @@ def _load_task_compiled(db: Session, task_id: int):
# 注意:r.rule_type / r.desc / r.regex / r.code 都是 SQLAlchemy Mapped 字段, # 注意:r.rule_type / r.desc / r.regex / r.code 都是 SQLAlchemy Mapped 字段,
# 运行时是 str,但静态类型是 InstrumentedAttribute —— 用 str() 包一下让 Pylance 也满意 # 运行时是 str,但静态类型是 InstrumentedAttribute —— 用 str() 包一下让 Pylance 也满意
# 2026-08-21:snapshot 追加 skip_null(值为空时是否跳过该规则) # 2026-08-21:snapshot 追加 skip_null(值为空时是否跳过该规则)
# 2026-08-24:snapshot 再追加 name(规则名称)→ _evaluate_row 写到 issues → 数据明细展示
compiled_fields: list[CompiledFieldRules] = [] compiled_fields: list[CompiledFieldRules] = []
for f, rules in fields_with_rules: for f, rules in fields_with_rules:
snapshots: list[tuple[str, Optional[str], Optional[str], str, bool]] = [] snapshots: list[tuple[str, Optional[str], Optional[str], str, bool, str]] = []
for r in rules: for r in rules:
rt = str(r.rule_type or "regex") rt = str(r.rule_type or "regex")
desc = str(r.desc or "") desc = str(r.desc or "")
skip_null = bool(r.skip_null) skip_null = bool(r.skip_null)
name = str(r.name or "")
if rt == "regex": if rt == "regex":
regex_src = str(r.regex or "").strip() or r"^.+$" regex_src = str(r.regex or "").strip() or r"^.+$"
try: try:
compiled_pat = re.compile(regex_src) compiled_pat = re.compile(regex_src)
snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null)) snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null, name))
except re.error as e: except re.error as e:
logger.warning(f"[queries] 规则 id={r.id} regex 编译失败:{e}") logger.warning(f"[queries] 规则 id={r.id} regex 编译失败:{e}")
snapshots.append((rt, None, None, desc, skip_null)) # None 标记"坏规则" snapshots.append((rt, None, None, desc, skip_null, name)) # None 标记"坏规则"
else: else:
snapshots.append((rt, None, str(r.code or ""), desc, skip_null)) snapshots.append((rt, None, str(r.code or ""), desc, skip_null, name))
compiled_fields.append(CompiledFieldRules(field_key=f.field_key.lower(), rules=snapshots)) compiled_fields.append(CompiledFieldRules(field_key=f.field_key.lower(), rules=snapshots))
# SELECT 列名:按 ord 升序的去重小写列表 # SELECT 列名:按 ord 升序的去重小写列表
...@@ -219,7 +223,10 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any] ...@@ -219,7 +223,10 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
"""对一行业务数据跑所有规则,返 (error_cells, issues)。 """对一行业务数据跑所有规则,返 (error_cells, issues)。
error_cells: 不合规字段 key 列表(去重保序) error_cells: 不合规字段 key 列表(去重保序)
issues: 每条不通过的规则的 {field, desc} issues: 每条不通过的规则的 {field, name, desc}
- name: 2026-08-24 新增,规则名称(前端展示「违反哪条规则」用);
老规则空串时由 UI 用 desc 兜底
- desc: 规则说明(自然语言;执行失败时是「规则执行失败:xxx」)
""" """
issues: list[dict] = [] issues: list[dict] = []
error_cells: list[str] = [] error_cells: list[str] = []
...@@ -228,7 +235,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any] ...@@ -228,7 +235,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
# 2026-08-21:字段级「跳过空值」开关(per-field,非 per-rule) # 2026-08-21:字段级「跳过空值」开关(per-field,非 per-rule)
# —— 检测引擎目前是 per-rule;如果未来要 per-field,把 skip_null 上提到 Field 模型 # —— 检测引擎目前是 per-rule;如果未来要 per-field,把 skip_null 上提到 Field 模型
# 现在是 per-rule:跑每条规则时单独判断 # 现在是 per-rule:跑每条规则时单独判断
for rule_type, compiled_regex, code, desc, skip_null in cf.rules: for rule_type, compiled_regex, code, desc, skip_null, name in cf.rules:
passed: Optional[bool] = None passed: Optional[bool] = None
fail_desc: Optional[str] = None fail_desc: Optional[str] = None
if rule_type == "regex": if rule_type == "regex":
...@@ -254,7 +261,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any] ...@@ -254,7 +261,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
passed = False passed = False
fail_desc = f"规则执行失败:{e}" fail_desc = f"规则执行失败:{e}"
if passed is False: if passed is False:
issues.append({"field": cf.field_key, "desc": fail_desc}) issues.append({"field": cf.field_key, "name": name, "desc": fail_desc})
if cf.field_key not in error_cells: if cf.field_key not in error_cells:
error_cells.append(cf.field_key) error_cells.append(cf.field_key)
return error_cells, issues return error_cells, issues
......
...@@ -39,6 +39,9 @@ router = APIRouter(prefix="/tasks", tags=["tasks"]) ...@@ -39,6 +39,9 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
class RulePayload(BaseModel): class RulePayload(BaseModel):
desc: str = PydField(..., min_length=1, description="规则说明(自然语言)") desc: str = PydField(..., min_length=1, description="规则说明(自然语言)")
# 2026-08-24:规则名称,前端展示「违反哪条规则」用;必填(min_length=1)
# 老规则(2026-08-24 之前)空名由迁移兜底(DB 默认 '' + UI desc 兜底显示)
name: str = PydField(..., min_length=1, description="规则名称(前端展示「违反哪条规则」用,必填)")
rule_type: str = PydField( rule_type: str = PydField(
"regex", "regex",
description="规则类型:regex / number / date / string(2026-08-21 扩展)", description="规则类型:regex / number / date / string(2026-08-21 扩展)",
...@@ -97,6 +100,8 @@ class FieldOut(BaseModel): ...@@ -97,6 +100,8 @@ class FieldOut(BaseModel):
class RuleOut(BaseModel): class RuleOut(BaseModel):
id: int id: int
desc: str desc: str
# 2026-08-24:规则名称;老规则可能是空串(迁移兼容),UI 用 desc 兜底
name: str = ""
regex: Optional[str] regex: Optional[str]
rule_type: str = "regex" rule_type: str = "regex"
code: Optional[str] = None code: Optional[str] = None
...@@ -181,6 +186,7 @@ def _replace_fields(db: Session, task: Task, fields_payload: list[FieldPayload]) ...@@ -181,6 +186,7 @@ def _replace_fields(db: Session, task: Task, fields_payload: list[FieldPayload])
rule = Rule( rule = Rule(
field_id=field.id, field_id=field.id,
desc=r.desc.strip(), desc=r.desc.strip(),
name=r.name.strip(), # 2026-08-24:规则名称(必填,Pydantic 已保证非空)
regex=regex_val, regex=regex_val,
rule_type=rt, rule_type=rt,
code=code_val, code=code_val,
...@@ -208,6 +214,7 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None: ...@@ -208,6 +214,7 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None:
db.add(Rule( db.add(Rule(
field_id=new_f.id, field_id=new_f.id,
desc=src_r.desc, desc=src_r.desc,
name=src_r.name or "", # 2026-08-24:复制时也带 name(老规则可能为空串)
regex=src_r.regex, regex=src_r.regex,
rule_type=src_r.rule_type or "regex", rule_type=src_r.rule_type or "regex",
code=src_r.code, code=src_r.code,
...@@ -238,7 +245,9 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta ...@@ -238,7 +245,9 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta
for f in db.query(Field).filter(Field.task_id == t.id).order_by(Field.ord).all(): for f in db.query(Field).filter(Field.task_id == t.id).order_by(Field.ord).all():
rules = [ rules = [
RuleOut( RuleOut(
id=r.id, desc=r.desc, regex=r.regex, id=r.id, desc=r.desc,
name=r.name or "", # 2026-08-24:规则名称(老规则空串,UI desc 兜底)
regex=r.regex,
rule_type=r.rule_type or "regex", rule_type=r.rule_type or "regex",
code=r.code, code=r.code,
skip_null=bool(r.skip_null), skip_null=bool(r.skip_null),
......
...@@ -72,10 +72,13 @@ function onSave() { ...@@ -72,10 +72,13 @@ function onSave() {
emit('save', list) emit('save', list)
} }
// 字段规则数 hover 提示文案 —— 跟 ResultTable.ruleHint 同款:有 ruleList 就列每条 desc,否则给通用文案 // 字段规则数 hover 提示文案 —— 跟 ResultTable.ruleHint 同款:
// 2026-08-24 起优先展示「规则名(desc)」,老规则空名时回退到 desc
function ruleHint(f) { function ruleHint(f) {
if (Array.isArray(f.ruleList) && f.ruleList.length > 0) { if (Array.isArray(f.ruleList) && f.ruleList.length > 0) {
return f.ruleList.map((r) => r.desc).join(';') return f.ruleList
.map((r) => (r.name && r.name.trim() ? `${r.name}(${r.desc})` : r.desc))
.join(';')
} }
return `该字段配置了 ${f.rules || 0} 条校验规则` return `该字段配置了 ${f.rules || 0} 条校验规则`
} }
......
...@@ -72,13 +72,15 @@ ...@@ -72,13 +72,15 @@
:style="tdStyle(col)" :style="tdStyle(col)"
> >
<!-- 备注列:所有 issues 列表 --> <!-- 备注列:所有 issues 列表 -->
<!-- 2026-08-24:优先展示规则名(name),老规则空名时回退到 desc -->
<template v-if="col.key === '__reason'"> <template v-if="col.key === '__reason'">
<template v-if="Array.isArray(row.issues) && row.issues.length > 0"> <template v-if="Array.isArray(row.issues) && row.issues.length > 0">
<span <span
v-for="(it, idx) in row.issues" v-for="(it, idx) in row.issues"
:key="idx" :key="idx"
class="issue-line" class="issue-line"
>• {{ it.field }}:{{ it.desc }}</span> :title="it.desc || ''"
>• {{ it.field }}:{{ it.name ? it.name : it.desc }}</span>
</template> </template>
<span v-else class="null-val">—</span> <span v-else class="null-val">—</span>
</template> </template>
...@@ -266,10 +268,13 @@ function isErrorCell(row, key) { ...@@ -266,10 +268,13 @@ function isErrorCell(row, key) {
// 错误描述:按 col.key(小写)在 row.issues 里找匹配,找不到给通用文案 // 错误描述:按 col.key(小写)在 row.issues 里找匹配,找不到给通用文案
// 注:后端 issues[].field 是小写 cf.field_key(db_adapter 归一过), // 注:后端 issues[].field 是小写 cf.field_key(db_adapter 归一过),
// 不用 col.cn(保留原大小写作为表头显示) // 不用 col.cn(保留原大小写作为表头显示)
// 2026-08-24:tooltip 优先展示「规则名(desc)」,老规则空名时只展示 desc
function errTip(row, key) { function errTip(row, key) {
if (!isErrorCell(row, key)) return '' if (!isErrorCell(row, key)) return ''
const issue = (row.issues || []).find((it) => it.field === key) const issue = (row.issues || []).find((it) => it.field === key)
return issue ? issue.desc : '该字段不合法' if (!issue) return '该字段不合法'
const label = issue.name && issue.name.trim() ? issue.name : issue.desc
return issue.name && issue.name.trim() ? `${label}:${issue.desc}` : label
} }
function cellClass(row, key) { function cellClass(row, key) {
return { return {
...@@ -277,10 +282,13 @@ function cellClass(row, key) { ...@@ -277,10 +282,13 @@ function cellClass(row, key) {
'cell-reason': key === '__reason', 'cell-reason': key === '__reason',
} }
} }
// 表头规则 tooltip:优先用 ruleList([{desc}]),否则给通用文案 // 表头规则 tooltip:2026-08-24 起优先展示「规则名」;name 为空时(老规则)回退到 desc
// 格式:`规则名(desc)` 或仅 desc —— 让用户一眼看到「这条规则叫什么」
function ruleHint(col) { function ruleHint(col) {
if (Array.isArray(col.ruleList) && col.ruleList.length > 0) { if (Array.isArray(col.ruleList) && col.ruleList.length > 0) {
return col.ruleList.map((r) => r.desc).join(';') return col.ruleList
.map((r) => (r.name && r.name.trim() ? `${r.name}(${r.desc})` : r.desc))
.join(';')
} }
return `该字段配置了 ${col.rules || 0} 条校验规则` return `该字段配置了 ${col.rules || 0} 条校验规则`
} }
......
...@@ -36,6 +36,20 @@ ...@@ -36,6 +36,20 @@
</div> </div>
</div> </div>
<!-- 2026-08-24:规则名称 → 给前端数据明细展示「违反哪条规则」用,必填 -->
<div class="rule-item-row">
<span class="rule-item-label"><span class="required">*</span>规则名称</span>
<div class="rule-item-ctrl">
<input
type="text"
class="text-input"
v-model="rule.name"
:placeholder="namePlaceholder(rule.rule_type)"
maxlength="64"
/>
</div>
</div>
<div class="rule-item-row"> <div class="rule-item-row">
<span class="rule-item-label">规则说明</span> <span class="rule-item-label">规则说明</span>
<div class="rule-item-ctrl"> <div class="rule-item-ctrl">
...@@ -158,15 +172,25 @@ const title = computed(() => { ...@@ -158,15 +172,25 @@ const title = computed(() => {
const ruleList = ref([]) const ruleList = ref([])
const aiLoading = ref({}) const aiLoading = ref({})
// 「至少有一条规则且 desc + code/regex 都填了」才能保存(避免保存空规则) // 「至少有一条规则且 name + desc + code/regex 都填了」才能保存(避免保存空规则)
// 2026-08-24:name 也是必填(前端展示「违反哪条规则」用)
const hasValidRules = computed(() => const hasValidRules = computed(() =>
ruleList.value.length > 0 && ruleList.value.every((r) => { ruleList.value.length > 0 && ruleList.value.every((r) => {
if (!r.name.trim()) return false
if (!r.desc.trim()) return false if (!r.desc.trim()) return false
if ((r.rule_type || 'regex') === 'regex') return r.regex.trim() if ((r.rule_type || 'regex') === 'regex') return r.regex.trim()
return r.code.trim() // number / date / string return r.code.trim() // number / date / string
}) })
) )
// 规则名称占位符:给个简短示例,按 rule_type 给不同建议
function namePlaceholder(rt) {
if (rt === 'number') return '如:年龄范围 0-150'
if (rt === 'date') return '如:日期格式合法'
if (rt === 'string') return '如:手机号格式合法'
return '如:身份证号 18 位'
}
// 规则说明 / 函数 / AI 按钮文案,按 rule_type 切换 // 规则说明 / 函数 / AI 按钮文案,按 rule_type 切换
function descPlaceholder(rt) { function descPlaceholder(rt) {
if (rt === 'number') return '如:年龄必须在 0 到 150 之间' if (rt === 'number') return '如:年龄必须在 0 到 150 之间'
...@@ -197,6 +221,9 @@ watch(() => [props.open, props.field?.key], ([open]) => { ...@@ -197,6 +221,9 @@ watch(() => [props.open, props.field?.key], ([open]) => {
// 后端 Pydantic 序列化用 snake_case(rule_type),前端也用同名 // 后端 Pydantic 序列化用 snake_case(rule_type),前端也用同名
ruleList.value = (props.field.rules || []).map((r) => ({ ruleList.value = (props.field.rules || []).map((r) => ({
...r, ...r,
// 2026-08-24:老规则(2026-08-24 之前)可能没有 name 字段(API 默认 ''),UI 上输入框空着;
// 保存时若仍为空会被 hasValidRules 拦截,强制用户补一个名称
name: r.name || '',
rule_type: r.rule_type || DEFAULT_RULE_TYPE, rule_type: r.rule_type || DEFAULT_RULE_TYPE,
code: r.code || '', code: r.code || '',
regex: r.regex || '', regex: r.regex || '',
...@@ -211,6 +238,7 @@ watch(() => [props.open, props.field?.key], ([open]) => { ...@@ -211,6 +238,7 @@ watch(() => [props.open, props.field?.key], ([open]) => {
function addRule() { function addRule() {
ruleList.value.push({ ruleList.value.push({
id: Date.now(), id: Date.now(),
name: '', // 2026-08-24:规则名称(必填)
desc: '', desc: '',
rule_type: DEFAULT_RULE_TYPE, rule_type: DEFAULT_RULE_TYPE,
regex: '', regex: '',
...@@ -288,6 +316,8 @@ function testRule(idx) { ...@@ -288,6 +316,8 @@ function testRule(idx) {
function onSave() { function onSave() {
const rules = ruleList.value.map((r) => { const rules = ruleList.value.map((r) => {
const base = { const base = {
// 2026-08-24:name 必填,hasValidRules 已保证 trim() 后非空
name: r.name.trim(),
desc: r.desc.trim(), desc: r.desc.trim(),
rule_type: r.rule_type || DEFAULT_RULE_TYPE, rule_type: r.rule_type || DEFAULT_RULE_TYPE,
skip_null: !!r.skip_null, // 2026-08-21:跳过空值开关 skip_null: !!r.skip_null, // 2026-08-21:跳过空值开关
...@@ -307,6 +337,19 @@ function onSave() { ...@@ -307,6 +337,19 @@ function onSave() {
</script> </script>
<style scoped> <style scoped>
/* 2026-08-24:必填红星 —— .form-label .required 是全局样式,这里 .rule-item-label 是 scoped,
沿用全局的视觉规范(#f56c6c)但不污染外面 */
.rule-item-label .required {
color: #f56c6c;
margin-right: 2px;
}
.rule-item-label {
width: 90px;
flex-shrink: 0;
text-align: right;
font-size: 13px;
color: #606266;
}
/* 规则类型行 modifier:tabs 在左,「跳过空值」checkbox 推到最右侧。 /* 规则类型行 modifier:tabs 在左,「跳过空值」checkbox 推到最右侧。
只对这一行生效 —— 用嵌套 modifier 而不是把 .rule-item-ctrl 全局改成 flex, 只对这一行生效 —— 用嵌套 modifier 而不是把 .rule-item-ctrl 全局改成 flex,
否则 .code-line / .test-line / .regex-line 这些内部嵌套 flex 容器会被 否则 .code-line / .test-line / .regex-line 这些内部嵌套 flex 容器会被
......
...@@ -119,6 +119,7 @@ def test_create_task_with_number_and_date_rules(client): ...@@ -119,6 +119,7 @@ def test_create_task_with_number_and_date_rules(client):
"key": "age", "key": "age",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "年龄范围",
"desc": "年龄 0-150", "desc": "年龄 0-150",
"rule_type": "number", "rule_type": "number",
"code": ( "code": (
...@@ -135,6 +136,7 @@ def test_create_task_with_number_and_date_rules(client): ...@@ -135,6 +136,7 @@ def test_create_task_with_number_and_date_rules(client):
"key": "birthday", "key": "birthday",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "生日不晚于今天",
"desc": "生日早于今天", "desc": "生日早于今天",
"rule_type": "date", "rule_type": "date",
"code": ( "code": (
...@@ -179,7 +181,7 @@ def test_default_rule_type_is_regex(client): ...@@ -179,7 +181,7 @@ def test_default_rule_type_is_regex(client):
"fields": [{ "fields": [{
"key": "id_card", "key": "id_card",
"show_default": True, "show_default": True,
"rules": [{"desc": "必须 18 位", "regex": r"^.{18}$"}], # 不传 rule_type "rules": [{"name": "18位校验", "desc": "必须 18 位", "regex": r"^.{18}$"}], # 不传 rule_type
}], }],
}) })
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
...@@ -205,6 +207,7 @@ def test_queries_run_dispatches_by_rule_type(client): ...@@ -205,6 +207,7 @@ def test_queries_run_dispatches_by_rule_type(client):
"key": "age", "key": "age",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "年龄范围",
"desc": "年龄 0-150", "desc": "年龄 0-150",
"rule_type": "number", "rule_type": "number",
"code": ( "code": (
...@@ -220,6 +223,7 @@ def test_queries_run_dispatches_by_rule_type(client): ...@@ -220,6 +223,7 @@ def test_queries_run_dispatches_by_rule_type(client):
"key": "birthday", "key": "birthday",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "生日不晚于今天",
"desc": "不能晚于今天", "desc": "不能晚于今天",
"rule_type": "date", "rule_type": "date",
"code": ( "code": (
...@@ -284,6 +288,7 @@ def test_queries_run_rule_failure_marks_row_bad_with_reason(client): ...@@ -284,6 +288,7 @@ def test_queries_run_rule_failure_marks_row_bad_with_reason(client):
"key": "age", "key": "age",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "好规则的名称",
"desc": "好规则的 desc", "desc": "好规则的 desc",
"rule_type": "number", "rule_type": "number",
"code": "def broken_syntax(:\n pass", # 故意坏 "code": "def broken_syntax(:\n pass", # 故意坏
...@@ -377,6 +382,7 @@ def test_create_task_with_string_rule(client): ...@@ -377,6 +382,7 @@ def test_create_task_with_string_rule(client):
"key": "name", "key": "name",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "姓名长度",
"desc": "姓名长度 2-20", "desc": "姓名长度 2-20",
"rule_type": "string", "rule_type": "string",
"code": ( "code": (
...@@ -414,6 +420,7 @@ def test_string_rule_dispatch_in_queries(client): ...@@ -414,6 +420,7 @@ def test_string_rule_dispatch_in_queries(client):
"key": "name", "key": "name",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "姓名长度下限",
"desc": "姓名长度 >= 2", "desc": "姓名长度 >= 2",
"rule_type": "string", "rule_type": "string",
"code": ( "code": (
...@@ -456,6 +463,7 @@ def test_string_rule_without_skip_null_catches_nulls(client): ...@@ -456,6 +463,7 @@ def test_string_rule_without_skip_null_catches_nulls(client):
"key": "name", "key": "name",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "姓名长度下限",
"desc": "姓名长度 >= 2", "desc": "姓名长度 >= 2",
"rule_type": "string", "rule_type": "string",
"code": "def check(value):\n return isinstance(value, str) and len(value) >= 2\n", "code": "def check(value):\n return isinstance(value, str) and len(value) >= 2\n",
...@@ -496,6 +504,7 @@ def test_skip_null_applies_to_regex_too(client): ...@@ -496,6 +504,7 @@ def test_skip_null_applies_to_regex_too(client):
"key": "name", "key": "name",
"show_default": True, "show_default": True,
"rules": [{ "rules": [{
"name": "姓名必须是中文",
"desc": "姓名必须是中文(≥1 个汉字)", "desc": "姓名必须是中文(≥1 个汉字)",
"rule_type": "regex", "rule_type": "regex",
"regex": r"^[一-龥]+$", "regex": r"^[一-龥]+$",
......
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