Commit 33b60dc0 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 规则类型加 'string' + 「跳过空值」开关

字符串规则 (rule_type='string'):
- RULE_TYPES 扩展;rule/code 列与 number/date 共用,存 Python def check(value) -> bool
- ai_rule.py 加 _STRING prompt 模板(字符串语义:len/startswith/re)
- RuleModal tab 加「字符串」+ desc/codePlaceholder/aiBtnLabel 分支 + Python 函数 hint 提示 value 是字符串

跳过空值开关 (skip_null):
- rule 表加 skip_null 列(INTEGER NOT NULL DEFAULT 0)+ 迁移函数
- run_rule() 入口短路 None/空 str/空 bytes(regex 在 _evaluate_row 内联短路)
- RulePayload/RuleOut + _replace/_copy/_row_to_out + _load_task_compiled 全链路透传
- RuleModal 「规则类型」行右侧加 checkbox;测试/保存透传 skip_null
- /api/rule/test 同步支持(用户在 RuleModal 测试空值时也走短路)

UI 修复:
- 「规则类型」行用嵌套 modifier .rule-type-row flex 化,不污染其他 grid cell 行
  (之前全局改 .rule-item-ctrl flex 把 .code-line/.test-line/.regex-line 全压塌)

测试:
- test_rule_types_e2e.py 加 4 个新用例(string 持久化 + dispatch + skip_null=False/True + regex skip_null)
- FAKE_TABLE_ROWS 加 2 行空值('' + None),覆盖 skip_null 场景
- test_rule_runner.py RULE_TYPES 常量断言更新

后端 77 passed;vite build clean
parent 29987689
"""AI 生成规则代码(web3 / 2026-08-21) """AI 生成规则代码(web3 / 2026-08-21)
三种 rule_type 统一入口 `gen_rule()`: 四种 rule_type 统一入口 `gen_rule()`:
- regex : 走老 ai_regex.gen_regex(),返回单行正则字符串 - regex : 走老 ai_regex.gen_regex(),返回单行正则字符串
- number: LLM 生成 def check(value) -> bool 的 Python 函数(数值校验) - number: LLM 生成 def check(value) -> bool 的 Python 函数(数值校验)
- date : 同 number(用户代码里自己用 datetime.strptime 解析) - date : 同 number(用户代码里自己用 datetime.strptime 解析)
- string: 同 number(字符串校验:长度/前缀/中文/包含等)
强制走 LLM(用户决策:让模型持续见真实描述),拿到代码后用 rule_runner.validate_user_code() 强制走 LLM(用户决策:让模型持续见真实描述),拿到代码后用 rule_runner.validate_user_code()
真实校验语法 + 必含 check;编译/校验失败 → 把错误反馈给 LLM 让它修复,最多 5 次。 真实校验语法 + 必含 check;编译/校验失败 → 把错误反馈给 LLM 让它修复,最多 5 次。
...@@ -111,6 +112,45 @@ _DATE_USER_TEMPLATE_FIX = """上一次生成的代码有问题。 ...@@ -111,6 +112,45 @@ _DATE_USER_TEMPLATE_FIX = """上一次生成的代码有问题。
只输出修复后的函数代码,不要任何解释、不要 Markdown 代码块。""" 只输出修复后的函数代码,不要任何解释、不要 Markdown 代码块。"""
# 2026-08-21:字符串校验函数 —— 用户代码里 value 是字符串
# 与 number 区别只是 value 语义(不需要 try/except 数字转换;可以直接 str 操作)
_STRING_USER_TEMPLATE_INITIAL = """请把以下规则描述转换成一个 Python 字符串校验函数。
规则描述:「{desc}」
要求:
1. 定义函数 def check(value) -> bool:
- value 是从数据库读出来的原始字符串(也可能是 None / 非字符串,按需处理)
- 返回 True 表示合规,False 表示不合规
2. 函数要短小(一般不超过 10 行),只做这一项校验
3. 常见字符串校验操作:
- 长度:len(value)
- 前缀/后缀/包含:value.startswith(...) / value.endswith(...) / ... in value
- 大小写:value.upper() / value.lower()
- 正则(如果 regex 不好写):用 re 模块(已预注入,直接 re.search / re.match 即可)
- 中文/字符集判断:遍历字符 ord(c) > 127
4. 如果 value 不是字符串或为空,先 str(value) 转一下;非空才校验
5. 不要 import,需要正则就用 re 模块
只输出 def check(value) -> bool: 函数体的 Python 代码,不要任何解释、不要 Markdown 代码块。"""
_STRING_USER_TEMPLATE_FIX = """上一次生成的代码有问题。
规则描述:「{desc}」
上次生成的代码:
```
{bad_code}
```
运行时错误:`{error_msg}`
请输出修复后的 def check(value) -> bool: 函数。
要求:
1. 函数要短小,只做这一项校验
2. 修掉上面的错误(语法 / 缺 check / 运行时异常)
3. 仍然不要 import;需要正则就用 re 模块
只输出修复后的函数代码,不要任何解释、不要 Markdown 代码块。"""
# ── 从 LLM 返回中提取代码 ───────────────────────────────── # ── 从 LLM 返回中提取代码 ─────────────────────────────────
# LLM 偶尔会包一层 ```python ... ```,做容错提取 # LLM 偶尔会包一层 ```python ... ```,做容错提取
...@@ -153,13 +193,16 @@ def gen_rule(desc: str, rule_type: str) -> tuple[str, str]: ...@@ -153,13 +193,16 @@ def gen_rule(desc: str, rule_type: str) -> tuple[str, str]:
if not client.available: if not client.available:
return "", "LLM 未配置 API Key,请联系管理员配置或手动填写" return "", "LLM 未配置 API Key,请联系管理员配置或手动填写"
# number / date:选 prompt 模板 # number / date / string:选 prompt 模板
if rule_type == "number": if rule_type == "number":
tpl_init = _NUMBER_USER_TEMPLATE_INITIAL tpl_init = _NUMBER_USER_TEMPLATE_INITIAL
tpl_fix = _NUMBER_USER_TEMPLATE_FIX tpl_fix = _NUMBER_USER_TEMPLATE_FIX
else: # date elif rule_type == "date":
tpl_init = _DATE_USER_TEMPLATE_INITIAL tpl_init = _DATE_USER_TEMPLATE_INITIAL
tpl_fix = _DATE_USER_TEMPLATE_FIX tpl_fix = _DATE_USER_TEMPLATE_FIX
else: # string
tpl_init = _STRING_USER_TEMPLATE_INITIAL
tpl_fix = _STRING_USER_TEMPLATE_FIX
bad_code = "" bad_code = ""
error_msg = "" error_msg = ""
...@@ -196,7 +239,7 @@ def gen_rule(desc: str, rule_type: str) -> tuple[str, str]: ...@@ -196,7 +239,7 @@ def gen_rule(desc: str, rule_type: str) -> tuple[str, str]:
# ── 测试 ───────────────────────────────────────────── # ── 测试 ─────────────────────────────────────────────
def test_rule(rule_type: str, code_or_regex: str, value: str) -> dict: def test_rule(rule_type: str, code_or_regex: str, value: str, skip_null: bool = False) -> dict:
"""用 rule_type 对应的引擎校验 value。 """用 rule_type 对应的引擎校验 value。
返回 dict(与 test_regex 兼容的字段): 返回 dict(与 test_regex 兼容的字段):
...@@ -206,9 +249,13 @@ def test_rule(rule_type: str, code_or_regex: str, value: str) -> dict: ...@@ -206,9 +249,13 @@ def test_rule(rule_type: str, code_or_regex: str, value: str) -> dict:
- error: str 错误信息(成功时空串) - error: str 错误信息(成功时空串)
""" """
try: try:
passed = run_rule(rule_type, code_or_regex if rule_type == "regex" else None, passed = run_rule(
code_or_regex if rule_type in ("number", "date") else None, rule_type,
value) code_or_regex if rule_type == "regex" else None,
code_or_regex if rule_type in ("number", "date", "string") else None,
value,
skip_null=skip_null,
)
return {"ok": True, "match": passed, "pass_": passed, "error": ""} return {"ok": True, "match": passed, "pass_": passed, "error": ""}
except RuleRunError as e: except RuleRunError as e:
return {"ok": False, "match": False, "pass_": False, "error": str(e)} return {"ok": False, "match": False, "pass_": False, "error": str(e)}
......
"""规则执行引擎(web3) """规则执行引擎(web3)
三种 rule_type 走同一个入口 `run_rule()`,内部按类型 dispatch: 四种 rule_type 走同一个入口 `run_rule()`,内部按类型 dispatch:
- regex : re.compile(regex).search(value) → 命中 = 通过(True) - regex : re.compile(regex).search(value) → 命中 = 通过(True)
- number: 用户提供的 Python 函数 def check(value) → 数值校验 - number: 用户提供的 Python 函数 def check(value) → 数值校验
- date : 同 number → 日期校验(函数里自己 strptime) - date : 同 number → 日期校验(函数里自己 strptime)
- string: 同 number → 字符串校验(长度/前缀/中文/包含等)
设计要点: 设计要点:
- 数值 / 日期类规则的代码是用 LLM 生成的,**用户/AI 可能写出危险代码**; - 数值 / 日期 / 字符串类规则的代码是用 LLM 生成的,**用户/AI 可能写出危险代码**;
用受限命名空间 exec:白名单 builtins + 预注入 datetime/re/math/decimal 模块, 用受限命名空间 exec:白名单 builtins + 预注入 datetime/re/math/decimal 模块,
禁掉 open/eval/exec/__import__/getattr/globals 等危险入口 禁掉 open/eval/exec/__import__/getattr/globals 等危险入口
- 沙箱不杀进程(不搞 signal.alarm,Windows 不支持),只防「合理范围内的破坏」; - 沙箱不杀进程(不搞 signal.alarm,Windows 不支持),只防「合理范围内的破坏」;
真要做超时限制,得用 multiprocessing / subprocess,下个迭代再说 真要做超时限制,得用 multiprocessing / subprocess,下个迭代再说
- 任何错误(语法错 / 缺 check / 运行时异常)都包成 RuleRunError, - 任何错误(语法错 / 缺 check / 运行时异常)都包成 RuleRunError,
检测引擎拿 (passed, error_msg) 决定要不要把这一条规则标红 检测引擎拿 (passed, error_msg) 决定要不要把这一条规则标红
- skip_null(2026-08-21):值为 None/空 str 时直接 True 跳过;省掉每个 check 函数自己写 if value is None
""" """
from __future__ import annotations from __future__ import annotations
...@@ -192,14 +194,22 @@ def _patch_date_value(val) -> Optional[datetime.datetime]: ...@@ -192,14 +194,22 @@ def _patch_date_value(val) -> Optional[datetime.datetime]:
# ── 主入口 ────────────────────────────────────────────── # ── 主入口 ──────────────────────────────────────────────
def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: Any) -> bool: def run_rule(
rule_type: str,
regex: Optional[str],
code: Optional[str],
value: Any,
skip_null: bool = False,
) -> bool:
"""执行一条规则,返回 True = 通过 / False = 不通过。 """执行一条规则,返回 True = 通过 / False = 不通过。
参数: 参数:
rule_type: 'regex' | 'number' | 'date' rule_type: 'regex' | 'number' | 'date' | 'string'
regex: regex 类型用;其他类型传 None regex: regex 类型用;其他类型传 None
code: number/date 类型用;regex 传 None code: number/date/string 类型用;regex 传 None
value: 数据源该字段的原始值(字符串或 None,或 datetime 对象等) value: 数据源该字段的原始值(字符串或 None,或 datetime 对象等)
skip_null: 为 True 且 value 是 None/空字符串 → 直接 True 跳过,不进 check/正则
(2026-08-21:UI 上「跳过空值」开关,省掉每个 check 函数自己写 if value is None)
date 类型的 value 会被 _patch_date_value 预先归一化成 datetime 对象, date 类型的 value 会被 _patch_date_value 预先归一化成 datetime 对象,
用户 check(value) 收到的永远是 datetime.datetime,可以直接 value.date() / .year / .strftime() 用。 用户 check(value) 收到的永远是 datetime.datetime,可以直接 value.date() / .year / .strftime() 用。
...@@ -213,6 +223,10 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A ...@@ -213,6 +223,10 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
""" """
rt = rule_type or "regex" rt = rule_type or "regex"
# 2026-08-21:跳过空值开关 —— 空值直接 True,不跑规则
if skip_null and _is_null_like(value):
return True
if rt == "regex": if rt == "regex":
pattern_src = (regex or "").strip() or r"^.+$" # 空 regex = 非空校验 pattern_src = (regex or "").strip() or r"^.+$" # 空 regex = 非空校验
try: try:
...@@ -222,7 +236,7 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A ...@@ -222,7 +236,7 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
val_str = "" if value is None else str(value) val_str = "" if value is None else str(value)
return pat.search(val_str) is not None return pat.search(val_str) is not None
if rt in ("number", "date"): if rt in ("number", "date", "string"):
if not code or not code.strip(): if not code or not code.strip():
raise RuleRunError("规则代码为空,请填写 check(value) 函数") raise RuleRunError("规则代码为空,请填写 check(value) 函数")
if rt == "date": if rt == "date":
...@@ -231,11 +245,27 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A ...@@ -231,11 +245,27 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
if dt is None: if dt is None:
raise RuleRunError(f"无法解析为日期:{value!r}") raise RuleRunError(f"无法解析为日期:{value!r}")
return _exec_user_function(code, dt) return _exec_user_function(code, dt)
# number / string:原样传 value 进 check
return _exec_user_function(code, value) return _exec_user_function(code, value)
raise RuleRunError(f"未知的规则类型:{rule_type!r}") raise RuleRunError(f"未知的规则类型:{rule_type!r}")
def _is_null_like(v: Any) -> bool:
"""判定「空值」:None / 空字符串 / 空 bytes。
数值/日期字段的空值通常就是 None;字符串字段还可能是 ''(DB 里 VARCHAR 默认值)。
bytes 视为空字符串的二进制等价(部分数据源 date 列返回 b'')。
"""
if v is None:
return True
if isinstance(v, str) and v == "":
return True
if isinstance(v, (bytes, bytearray)) and len(v) == 0:
return True
return False
# ── 仅用于 AI 生成阶段 / 测试阶段的「纯语法校验」 ────────── # ── 仅用于 AI 生成阶段 / 测试阶段的「纯语法校验」 ──────────
def validate_user_code(code: str) -> tuple[bool, str]: def validate_user_code(code: str) -> tuple[bool, str]:
"""校验用户代码语法 + 必须含 check(value)。 """校验用户代码语法 + 必须含 check(value)。
......
...@@ -43,8 +43,9 @@ class CompiledFieldRules: ...@@ -43,8 +43,9 @@ class CompiledFieldRules:
字段大小写已归一为小写(与列名对齐)。 字段大小写已归一为小写(与列名对齐)。
""" """
field_key: str field_key: str
# 每条规则:(rule_type, regex_or_None, code_or_None, desc) # 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null)
rules: list[tuple[str, Optional[str], Optional[str], str]] = field(default_factory=list) # 2026-08-21:追加 skip_null(值为空时是否跳过)
rules: list[tuple[str, Optional[str], Optional[str], str, bool]] = field(default_factory=list)
@dataclass @dataclass
......
...@@ -95,6 +95,8 @@ def init_db() -> None: ...@@ -95,6 +95,8 @@ def init_db() -> None:
# 一次性迁移:rule 表加 rule_type / code 列(2026-08-21 扩展规则类型:数值 / 日期) # 一次性迁移:rule 表加 rule_type / code 列(2026-08-21 扩展规则类型:数值 / 日期)
# 老数据全部保持 rule_type='regex',code 留空 → 跟旧逻辑完全等价 # 老数据全部保持 rule_type='regex',code 留空 → 跟旧逻辑完全等价
_migrate_rule_type_and_code() _migrate_rule_type_and_code()
# 一次性迁移:rule 表加 skip_null 列(2026-08-21 「跳过空值」开关,默认 0 = 不跳过)
_migrate_rule_skip_null()
# 一次性迁移: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)
...@@ -184,6 +186,24 @@ def _migrate_rule_type_and_code() -> None: ...@@ -184,6 +186,24 @@ def _migrate_rule_type_and_code() -> None:
conn.exec_driver_sql("ALTER TABLE rule ADD COLUMN code TEXT") conn.exec_driver_sql("ALTER TABLE rule ADD COLUMN code TEXT")
def _migrate_rule_skip_null() -> None:
"""给 rule 表加 skip_null 列(2026-08-21 「跳过空值」开关)。
默认 0 = 不跳过(老规则行为不变:空值进 check 函数报错,记为不合规)。
ORM 已声明,Base.metadata.create_all 不会给已存在的表加列,所以走 ALTER TABLE ADD COLUMN。
"""
with engine.begin() as conn:
cols = {
row[1]: row
for row in conn.exec_driver_sql("PRAGMA table_info(rule)").fetchall()
}
if "skip_null" not in cols:
logger.info("[migrate] rule 缺 skip_null 列,补建(默认 0,老规则不受影响)")
conn.exec_driver_sql(
"ALTER TABLE rule ADD COLUMN skip_null INTEGER NOT NULL DEFAULT 0"
)
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)。
......
...@@ -69,12 +69,14 @@ CREATE INDEX idx_field_task ON field(task_id); ...@@ -69,12 +69,14 @@ CREATE INDEX idx_field_task ON field(task_id);
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- 4) rule · 规则(每条规则挂在一个 field 下) -- 4) rule · 规则(每条规则挂在一个 field 下)
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- rule_type 'regex' / 'number' / 'date' 三类校验规则(2026-08-21 扩展) -- rule_type 'regex' / 'number' / 'date' / 'string' 四类校验规则(2026-08-21 扩展)
-- regex: 仍是 Python re 模式,存在 regex 列(兼容老数据,默认值) -- regex: 仍是 Python re 模式,存在 regex 列(兼容老数据,默认值)
-- number: Python 函数 def check(value) -> bool,存在 code 列 -- number: Python 函数 def check(value) -> bool,存在 code 列
-- date: 同 number,存在 code 列 -- date: 同 number,存在 code 列
-- code number/date 类型的 Python 函数源码(必须含 def check(value): 返回 bool) -- string: 同 number,存在 code 列(字符串校验,长度/前缀/中文等)
-- regex regex 类型专用;number/date 留空 -- code number/date/string 类型的 Python 函数源码(必须含 def check(value): 返回 bool)
-- regex regex 类型专用;number/date/string 留空
-- skip_null 1 = 值为空(None/空 str)时直接判合规跳过;0 = 空值进 check 函数按不合规处理
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,
...@@ -82,6 +84,7 @@ CREATE TABLE rule ( ...@@ -82,6 +84,7 @@ CREATE TABLE rule (
regex TEXT, regex TEXT,
rule_type TEXT NOT NULL DEFAULT 'regex', rule_type TEXT NOT NULL DEFAULT 'regex',
code TEXT, code TEXT,
skip_null INTEGER NOT NULL DEFAULT 0,
ord INTEGER NOT NULL DEFAULT 0 ord INTEGER NOT NULL DEFAULT 0
); );
CREATE INDEX idx_rule_field ON rule(field_id); CREATE INDEX idx_rule_field ON rule(field_id);
......
...@@ -2,18 +2,23 @@ ...@@ -2,18 +2,23 @@
对应 schema.sql 里的: 对应 schema.sql 里的:
CREATE TABLE rule ( CREATE TABLE rule (
id, field_id, desc, regex, rule_type, code, ord id, field_id, desc, regex, rule_type, code, skip_null, ord
) )
设计: 设计:
- 每条规则属于一个 field(field_id FK + ON DELETE CASCADE) - 每条规则属于一个 field(field_id FK + ON DELETE CASCADE)
- desc:自然语言规则说明(如「必须是 18 位中国大陆身份证号」) - desc:自然语言规则说明(如「必须是 18 位中国大陆身份证号」)
- rule_type:'regex' / 'number' / 'date'(2026-08-21 扩展) - rule_type:'regex' / 'number' / 'date' / 'string'(2026-08-21 扩展)
- regex:传统正则(向后兼容,老数据全部为 regex) - regex: 传统正则(向后兼容,老数据全部为 regex)
- number:Python 函数 def check(value) -> bool,存 code 列 - number: Python 函数 def check(value) -> bool,存 code 列(数值校验)
- date: 同 number,存 code 列(用户代码里自己用 datetime.strptime 解析) - date: 同 number(用户代码里自己用 datetime 解析)
- string: 同 number(用户代码里处理字符串,如长度/前缀/中文等)
- regex:仅 rule_type='regex' 时使用(可空——「非空」这种规则 regex 留空 + 检测时由后端补 `^.+$`) - regex:仅 rule_type='regex' 时使用(可空——「非空」这种规则 regex 留空 + 检测时由后端补 `^.+$`)
- code: 仅 rule_type in ('number', 'date') 时使用,存 Python 函数源码 - code: 仅 rule_type in ('number','date','string') 时使用,存 Python 函数源码
- skip_null(2026-08-21):值为空(None/空字符串)时是否跳过该规则
- 默认 0(不跳过 —— 空值进 check 函数报错,记为不合规)
- 1 = 跳过(空值直接判合规,不走 check 函数)
- 对 regex 类型同样生效:空值直接 True,不跑正则
- ord:规则在 UI 上的显示顺序(保存时按数组顺序写入) - ord:规则在 UI 上的显示顺序(保存时按数组顺序写入)
""" """
from __future__ import annotations from __future__ import annotations
...@@ -23,7 +28,7 @@ from sqlalchemy import Column, Integer, Text, String, ForeignKey ...@@ -23,7 +28,7 @@ from sqlalchemy import Column, Integer, Text, String, ForeignKey
from web3.backend.db.database import Base from web3.backend.db.database import Base
# rule_type 的合法值集中在一处常量,ORM / 校验 / 提示文案都引用这一份 # rule_type 的合法值集中在一处常量,ORM / 校验 / 提示文案都引用这一份
RULE_TYPES = ("regex", "number", "date") RULE_TYPES = ("regex", "number", "date", "string")
DEFAULT_RULE_TYPE = "regex" DEFAULT_RULE_TYPE = "regex"
...@@ -37,7 +42,9 @@ class Rule(Base): ...@@ -37,7 +42,9 @@ class Rule(Base):
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,
) )
code = Column(Text, nullable=True) # Python 函数源码(number/date 用) code = Column(Text, nullable=True) # Python 函数源码(number/date/string 用)
# 2026-08-21:值为空(None/空 str)时跳过该规则,不走 check 直接判 True
skip_null = Column(Integer, nullable=False, default=0)
ord = Column(Integer, nullable=False, default=0) ord = Column(Integer, nullable=False, default=0)
def to_dict(self) -> dict: def to_dict(self) -> dict:
...@@ -47,6 +54,7 @@ class Rule(Base): ...@@ -47,6 +54,7 @@ class Rule(Base):
"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,
"skip_null": bool(self.skip_null),
"ord": self.ord, "ord": self.ord,
} }
......
...@@ -75,8 +75,9 @@ async def ai_gen_rule(req: GenRuleRequest): ...@@ -75,8 +75,9 @@ async def ai_gen_rule(req: GenRuleRequest):
# ── /rule/test ─────────────────────────────────────────── # ── /rule/test ───────────────────────────────────────────
class TestRuleRequest(BaseModel): class TestRuleRequest(BaseModel):
rule_type: str = Field(DEFAULT_RULE_TYPE, description=f"规则类型:{ ' / '.join(RULE_TYPES) }") rule_type: str = Field(DEFAULT_RULE_TYPE, description=f"规则类型:{ ' / '.join(RULE_TYPES) }")
code: str = Field(..., min_length=1, description="regex 时是正则;number/date 时是 Python 函数源码") code: str = Field(..., min_length=1, description="regex 时是正则;number/date/string 时是 Python 函数源码")
value: str = Field("", description="测试值") value: str = Field("", description="测试值")
skip_null: bool = Field(False, description="2026-08-21:值为空时是否跳过(前端「跳过空值」开关)")
class TestRuleResponse(BaseModel): class TestRuleResponse(BaseModel):
...@@ -93,7 +94,7 @@ async def ai_test_rule(req: TestRuleRequest): ...@@ -93,7 +94,7 @@ async def ai_test_rule(req: TestRuleRequest):
统一走 rule_runner.run_rule(),保证和检测引擎语义一致。 统一走 rule_runner.run_rule(),保证和检测引擎语义一致。
""" """
result = test_rule(req.rule_type, req.code, req.value) result = test_rule(req.rule_type, req.code, req.value, skip_null=req.skip_null)
return TestRuleResponse( return TestRuleResponse(
ok=result.get("ok", False), ok=result.get("ok", False),
pass_=result.get("pass_", False), pass_=result.get("pass_", False),
......
...@@ -150,6 +150,7 @@ def _load_task_compiled(db: Session, task_id: int): ...@@ -150,6 +150,7 @@ def _load_task_compiled(db: Session, task_id: int):
"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,
"skip_null": bool(r.skip_null),
} }
for r in rules_by_field[f.id] for r in rules_by_field[f.id]
], ],
...@@ -157,25 +158,27 @@ def _load_task_compiled(db: Session, task_id: int): ...@@ -157,25 +158,27 @@ def _load_task_compiled(db: Session, task_id: int):
for f in fields for f in fields
] ]
# 编译:regex 提前 compile;number/date 不编译(每行 exec) # 编译:regex 提前 compile;number/date/string 不编译(每行 exec)
# 注意: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(值为空时是否跳过该规则)
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]] = [] snapshots: list[tuple[str, Optional[str], Optional[str], str, bool]] = []
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)
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)) snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null))
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)) # None 标记"坏规则" snapshots.append((rt, None, None, desc, skip_null)) # None 标记"坏规则"
else: else:
snapshots.append((rt, None, str(r.code or ""), desc)) snapshots.append((rt, None, str(r.code or ""), desc, skip_null))
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 升序的去重小写列表
...@@ -222,13 +225,19 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any] ...@@ -222,13 +225,19 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
error_cells: list[str] = [] error_cells: list[str] = []
for cf in compiled_fields: for cf in compiled_fields:
val = raw.get(cf.field_key) val = raw.get(cf.field_key)
for rule_type, compiled_regex, code, desc in cf.rules: # 2026-08-21:字段级「跳过空值」开关(per-field,非 per-rule)
# —— 检测引擎目前是 per-rule;如果未来要 per-field,把 skip_null 上提到 Field 模型
# 现在是 per-rule:跑每条规则时单独判断
for rule_type, compiled_regex, code, desc, skip_null 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":
if compiled_regex is None: if compiled_regex is None:
passed = False passed = False
fail_desc = f"规则正则编译失败:{desc}" fail_desc = f"规则正则编译失败:{desc}"
elif skip_null and (val is None or val == ""):
# 2026-08-21:跳过空值开关 —— 空值直接合规,跳过正则
passed = True
else: else:
pat = re.compile(compiled_regex) pat = re.compile(compiled_regex)
val_str = "" if val is None else str(val) val_str = "" if val is None else str(val)
...@@ -237,7 +246,8 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any] ...@@ -237,7 +246,8 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
fail_desc = desc fail_desc = desc
else: else:
try: try:
passed = run_rule(rule_type, None, code, val) # 2026-08-21:number/date/string 类型把 skip_null 传给 run_rule,由沙箱入口短路
passed = run_rule(rule_type, None, code, val, skip_null=skip_null)
if not passed: if not passed:
fail_desc = desc fail_desc = desc
except RuleRunError as e: except RuleRunError as e:
......
...@@ -41,10 +41,12 @@ class RulePayload(BaseModel): ...@@ -41,10 +41,12 @@ class RulePayload(BaseModel):
desc: str = PydField(..., min_length=1, description="规则说明(自然语言)") desc: str = PydField(..., min_length=1, description="规则说明(自然语言)")
rule_type: str = PydField( rule_type: str = PydField(
"regex", "regex",
description="规则类型:regex / number / date(2026-08-21 扩展)", description="规则类型:regex / number / date / string(2026-08-21 扩展)",
) )
regex: Optional[str] = PydField(None, description="Python 正则表达式(regex 类型专用,可空)") regex: Optional[str] = PydField(None, description="Python 正则表达式(regex 类型专用,可空)")
code: Optional[str] = PydField(None, description="Python 函数源码(number/date 类型专用)") code: Optional[str] = PydField(None, description="Python 函数源码(number/date/string 类型专用)")
# 2026-08-21:值为空(None/空 str)时跳过该规则;默认 False(与老行为一致:空值进 check 报错)
skip_null: bool = PydField(False, description="值为空(None/空字符串)时是否跳过该规则")
class FieldPayload(BaseModel): class FieldPayload(BaseModel):
...@@ -98,6 +100,7 @@ class RuleOut(BaseModel): ...@@ -98,6 +100,7 @@ class RuleOut(BaseModel):
regex: Optional[str] regex: Optional[str]
rule_type: str = "regex" rule_type: str = "regex"
code: Optional[str] = None code: Optional[str] = None
skip_null: bool = False # 2026-08-21:跳过空值开关
ord: int ord: int
...@@ -174,13 +177,14 @@ def _replace_fields(db: Session, task: Task, fields_payload: list[FieldPayload]) ...@@ -174,13 +177,14 @@ def _replace_fields(db: Session, task: Task, fields_payload: list[FieldPayload])
for r_idx, r in enumerate(f.rules): for r_idx, r in enumerate(f.rules):
rt = (r.rule_type or "regex").strip() or "regex" rt = (r.rule_type or "regex").strip() or "regex"
regex_val = (r.regex or "").strip() or None if rt == "regex" else None regex_val = (r.regex or "").strip() or None if rt == "regex" else None
code_val = (r.code or "").strip() or None if rt in ("number", "date") else None code_val = (r.code or "").strip() or None if rt in ("number", "date", "string") else None
rule = Rule( rule = Rule(
field_id=field.id, field_id=field.id,
desc=r.desc.strip(), desc=r.desc.strip(),
regex=regex_val, regex=regex_val,
rule_type=rt, rule_type=rt,
code=code_val, code=code_val,
skip_null=1 if r.skip_null else 0,
ord=r_idx, ord=r_idx,
) )
db.add(rule) db.add(rule)
...@@ -207,6 +211,7 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None: ...@@ -207,6 +211,7 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None:
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,
skip_null=src_r.skip_null or 0, # 2026-08-21:复制时也带 skip_null
ord=r_idx, ord=r_idx,
)) ))
...@@ -236,6 +241,7 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta ...@@ -236,6 +241,7 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta
id=r.id, desc=r.desc, regex=r.regex, id=r.id, desc=r.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),
ord=r.ord, ord=r.ord,
) )
for r in db.query(Rule).filter(Rule.field_id == f.id).order_by(Rule.ord).all() for r in db.query(Rule).filter(Rule.field_id == f.id).order_by(Rule.ord).all()
......
/** /**
* AI 相关 API(2026-08-21 扩展规则类型) * AI 相关 API(2026-08-21 扩展规则类型)
* *
* 三类规则共用一对接口: * 四类规则共用一对接口:
* - genRule(desc, ruleType) → POST /api/ai/rule 后端走 LLM(regex 走老 ai_regex;number/date 走 ai_rule) * - genRule(desc, ruleType) → POST /api/ai/rule 后端走 LLM(regex 走老 ai_regex;number/date/string 走 ai_rule)
* - testRule(ruleType, code, value) → POST /api/rule/test * - testRule(ruleType, code, value, opts) → POST /api/rule/test
* *
* ruleType: 'regex' | 'number' | 'date' * ruleType: 'regex' | 'number' | 'date' | 'string'
* - regex: code = 单行正则字符串 * - regex: code = 单行正则字符串
* - number: code = Python 函数源码(含 def check(value) -> bool) * - number: code = Python 函数源码(含 def check(value) -> bool)
* - date: code = 同 number * - date: code = 同 number
* - string: code = 同 number(字符串校验:长度/前缀/中文/包含等)
* *
* 老端点 genRegex / testRegex 保留为薄壳(转发到新接口),兼容 2026-08-20 的 AI 正则流程。 * 老端点 genRegex / testRegex 保留为薄壳(转发到新接口),兼容 2026-08-20 的 AI 正则流程。
*/ */
import { http } from './client' import { http } from './client'
export const RULE_TYPES = ['regex', 'number', 'date'] export const RULE_TYPES = ['regex', 'number', 'date', 'string']
/** /**
* AI 生成规则代码 * AI 生成规则代码
* @param {string} desc 规则说明(自然语言) * @param {string} desc 规则说明(自然语言)
* @param {string} ruleType 'regex' | 'number' | 'date' * @param {string} ruleType 'regex' | 'number' | 'date' | 'string'
* @returns { ok: boolean, code: string, note: string } * @returns { ok: boolean, code: string, note: string }
* - ok=true:code 非空,可直接填到对应输入框 * - ok=true:code 非空,可直接填到对应输入框
* - ok=false:code 空字符串;note 是失败原因(前端展示) * - ok=false:code 空字符串;note 是失败原因(前端展示)
...@@ -32,14 +33,16 @@ export function genRule(desc, ruleType = 'regex') { ...@@ -32,14 +33,16 @@ export function genRule(desc, ruleType = 'regex') {
/** /**
* 用后端引擎校验 value 是否通过 * 用后端引擎校验 value 是否通过
* @param {string} ruleType * @param {string} ruleType
* @param {string} code regex → 正则;number/date → Python 函数源码 * @param {string} code regex → 正则;number/date/string → Python 函数源码
* @param {string} value 测试值 * @param {string} value 测试值
* @param {object} [opts]
* @param {boolean} [opts.skipNull] 2026-08-21:值为空(None/空 str)时是否跳过
* @returns { ok: boolean, pass: boolean, error: string } * @returns { ok: boolean, pass: boolean, error: string }
* - ok false = 代码/正则本身非法;error 是报错信息 * - ok false = 代码/正则本身非法;error 是报错信息
* - ok true + pass true/false = 是否通过 * - ok true + pass true/false = 是否通过
*/ */
export function testRule(ruleType, code, value) { export function testRule(ruleType, code, value, { skipNull = false } = {}) {
return http.post('/rule/test', { rule_type: ruleType, code, value }) return http.post('/rule/test', { rule_type: ruleType, code, value, skip_null: skipNull })
} }
/** /**
......
...@@ -14,10 +14,10 @@ ...@@ -14,10 +14,10 @@
<button class="btn-text danger" @click="removeRule(idx)">删除</button> <button class="btn-text danger" @click="removeRule(idx)">删除</button>
</div> </div>
<!-- 规则类型切换:regex / number / date --> <!-- 规则类型切换:regex / number / date / string -->
<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 rule-type-row">
<div class="rule-type-tabs"> <div class="rule-type-tabs">
<div <div
v-for="t in RULE_TYPES" v-for="t in RULE_TYPES"
...@@ -28,6 +28,11 @@ ...@@ -28,6 +28,11 @@
@click="rule.rule_type = t.value" @click="rule.rule_type = t.value"
>{{ t.label }}</div> >{{ t.label }}</div>
</div> </div>
<!-- 2026-08-21:跳过空值开关 —— 右侧独立 checkbox -->
<label class="skip-null-toggle" :title="'字段值为空(NULL / 空字符串)时直接判合规,不再跑本规则'">
<input type="checkbox" v-model="rule.skip_null" />
<span>跳过空值</span>
</label>
</div> </div>
</div> </div>
...@@ -58,7 +63,7 @@ ...@@ -58,7 +63,7 @@
</div> </div>
</div> </div>
<!-- number / date 类型:多行 Python 函数编辑框 --> <!-- number / date / string 类型:多行 Python 函数编辑框 -->
<div v-else class="rule-item-row"> <div v-else class="rule-item-row">
<span class="rule-item-label">Python 函数</span> <span class="rule-item-label">Python 函数</span>
<div class="rule-item-ctrl"> <div class="rule-item-ctrl">
...@@ -78,6 +83,12 @@ ...@@ -78,6 +83,12 @@
跟「今天」比用 <code>datetime.date.today()</code>。 跟「今天」比用 <code>datetime.date.today()</code>。
<strong style="color: #f56c6c;">不要再 <code>strptime</code>(value 已经是 datetime 对象,strptime 需要字符串)。</strong> <strong style="color: #f56c6c;">不要再 <code>strptime</code>(value 已经是 datetime 对象,strptime 需要字符串)。</strong>
</span> </span>
<span v-else-if="rule.rule_type === 'string'">
<strong>value 是字符串</strong>(后端从数据库读到的原始字符串)。
常用操作:<code>len(value)</code> / <code>value.startswith(...)</code> /
<code>value.endswith(...)</code> / <code>... in value</code>;
需要正则时直接用 <code>re</code> 模块(已预注入)。
</span>
<span v-else> <span v-else>
需要数学常量时用 math 模块;不需要 import。 需要数学常量时用 math 模块;不需要 import。
</span> </span>
...@@ -124,9 +135,10 @@ const emit = defineEmits(['update:open', 'save']) ...@@ -124,9 +135,10 @@ const emit = defineEmits(['update:open', 'save'])
// 规则类型定义集中在一处:tab 标签、AI 按钮文案、占位符都引用 // 规则类型定义集中在一处:tab 标签、AI 按钮文案、占位符都引用
const RULE_TYPES = [ const RULE_TYPES = [
{ value: 'regex', label: '正则', hint: '用 Python 正则表达式校验文本(身份证/手机号/邮箱 等)' }, { value: 'regex', label: '正则', hint: '用 Python 正则表达式校验文本(身份证/手机号/邮箱 等)' },
{ value: 'number', label: '数值', hint: '用 Python 函数 def check(value) -> bool 校验数值' }, { value: 'number', label: '数值', hint: '用 Python 函数 def check(value) -> bool 校验数值' },
{ value: 'date', label: '日期', hint: '用 Python 函数 + datetime.strptime 校验日期' }, { value: 'date', label: '日期', hint: '用 Python 函数 + datetime.strptime 校验日期' },
{ value: 'string', label: '字符串', hint: '用 Python 函数 def check(value) -> bool 校验字符串(长度/前缀/中文/包含 等)' },
] ]
const DEFAULT_RULE_TYPE = 'regex' const DEFAULT_RULE_TYPE = 'regex'
...@@ -143,7 +155,7 @@ const hasValidRules = computed(() => ...@@ -143,7 +155,7 @@ const hasValidRules = computed(() =>
ruleList.value.length > 0 && ruleList.value.every((r) => { ruleList.value.length > 0 && ruleList.value.every((r) => {
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 return r.code.trim() // number / date / string
}) })
) )
...@@ -151,6 +163,7 @@ const hasValidRules = computed(() => ...@@ -151,6 +163,7 @@ const hasValidRules = computed(() =>
function descPlaceholder(rt) { function descPlaceholder(rt) {
if (rt === 'number') return '如:年龄必须在 0 到 150 之间' if (rt === 'number') return '如:年龄必须在 0 到 150 之间'
if (rt === 'date') return '如:日期必须是 yyyy-MM-dd 格式且在 2020-01-01 之后' if (rt === 'date') return '如:日期必须是 yyyy-MM-dd 格式且在 2020-01-01 之后'
if (rt === 'string') return '如:手机号字段必须是 11 位数字;或:邮箱字段必须包含 @'
return '用自然语言描述规则,如:必须是18位中国大陆身份证号,最后一位可为X' return '用自然语言描述规则,如:必须是18位中国大陆身份证号,最后一位可为X'
} }
function codePlaceholder(rt) { function codePlaceholder(rt) {
...@@ -158,11 +171,15 @@ function codePlaceholder(rt) { ...@@ -158,11 +171,15 @@ function codePlaceholder(rt) {
// 后端已把 value 归一化成 datetime.datetime 对象,用户代码直接 .date() / .year / .strftime() 用即可 // 后端已把 value 归一化成 datetime.datetime 对象,用户代码直接 .date() / .year / .strftime() 用即可
return 'def check(value) -> bool:\n return value.date() < datetime.date.today()' return 'def check(value) -> bool:\n return value.date() < datetime.date.today()'
} }
if (rt === 'string') {
return 'def check(value) -> bool:\n if not isinstance(value, str):\n return False\n return len(value) == 11 and value.isdigit()'
}
return 'def check(value) -> bool:\n try:\n n = float(value)\n return 0 <= n <= 150\n except Exception:\n return False' return 'def check(value) -> bool:\n try:\n n = float(value)\n return 0 <= n <= 150\n except Exception:\n return False'
} }
function aiBtnLabel(rt) { function aiBtnLabel(rt) {
if (rt === 'number') return '✨ AI 生成数值校验函数' if (rt === 'number') return '✨ AI 生成数值校验函数'
if (rt === 'date') return '✨ AI 生成日期校验函数' if (rt === 'date') return '✨ AI 生成日期校验函数'
if (rt === 'string') return '✨ AI 生成字符串校验函数'
return '✨ AI 生成正则' return '✨ AI 生成正则'
} }
...@@ -175,6 +192,7 @@ watch(() => [props.open, props.field?.key], ([open]) => { ...@@ -175,6 +192,7 @@ watch(() => [props.open, props.field?.key], ([open]) => {
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 || '',
skip_null: !!r.skip_null, // 2026-08-21:跳过空值开关
testVal: '', testVal: '',
testResult: null, testResult: null,
})) }))
...@@ -189,6 +207,7 @@ function addRule() { ...@@ -189,6 +207,7 @@ function addRule() {
rule_type: DEFAULT_RULE_TYPE, rule_type: DEFAULT_RULE_TYPE,
regex: '', regex: '',
code: '', code: '',
skip_null: false, // 2026-08-21:跳过空值开关,默认关
testVal: '', testVal: '',
testResult: null, testResult: null,
}) })
...@@ -243,7 +262,8 @@ function testRule(idx) { ...@@ -243,7 +262,8 @@ function testRule(idx) {
rule.testResult = { text: '请输入测试值', cls: 'fail' } rule.testResult = { text: '请输入测试值', cls: 'fail' }
return return
} }
apiTestRule(rule.rule_type, code, rule.testVal).then((r) => { // 2026-08-21:把 skip_null 透传给后端;用户勾选「跳过空值」后,测试时空值也算通过
apiTestRule(rule.rule_type, code, rule.testVal, { skipNull: !!rule.skip_null }).then((r) => {
if (!r.ok) { if (!r.ok) {
rule.testResult = { text: r.error || '语法错误', cls: 'fail' } rule.testResult = { text: r.error || '语法错误', cls: 'fail' }
} else if (!r.pass) { } else if (!r.pass) {
...@@ -262,6 +282,7 @@ function onSave() { ...@@ -262,6 +282,7 @@ function onSave() {
const base = { const base = {
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:跳过空值开关
} }
if (r.rule_type === 'regex') { if (r.rule_type === 'regex') {
base.regex = r.regex.trim() base.regex = r.regex.trim()
...@@ -278,6 +299,17 @@ function onSave() { ...@@ -278,6 +299,17 @@ function onSave() {
</script> </script>
<style scoped> <style scoped>
/* 规则类型行 modifier:tabs 在左,「跳过空值」checkbox 推到最右侧。
只对这一行生效 —— 用嵌套 modifier 而不是把 .rule-item-ctrl 全局改成 flex,
否则 .code-line / .test-line / .regex-line 这些内部嵌套 flex 容器会被
flex 父按内容收缩塌陷,textarea 被压成几列宽。
(2026-08-21 踩坑:全局 .rule-item-ctrl flex 化破坏所有其他行的布局) */
.rule-item-ctrl.rule-type-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* 规则类型 tab —— 用 inline-block 撑开,pill 风格;与 .conn-tab 风格统一 */ /* 规则类型 tab —— 用 inline-block 撑开,pill 风格;与 .conn-tab 风格统一 */
.rule-type-tabs { .rule-type-tabs {
display: inline-flex; display: inline-flex;
...@@ -306,6 +338,23 @@ function onSave() { ...@@ -306,6 +338,23 @@ function onSave() {
background: #409eff; background: #409eff;
color: #fff; color: #fff;
} }
/* 2026-08-21:跳过空值 toggle —— checkbox + 文案,最右对齐 */
.skip-null-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #606266;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.skip-null-toggle input[type="checkbox"] {
cursor: pointer;
width: 14px;
height: 14px;
margin: 0;
}
/* Python 函数编辑框:等宽字体 + 稍高 + 灰底 */ /* Python 函数编辑框:等宽字体 + 稍高 + 灰底 */
.code-line { .code-line {
......
...@@ -344,7 +344,8 @@ class TestValidateUserCode: ...@@ -344,7 +344,8 @@ class TestValidateUserCode:
# ── RULE_TYPES 常量 ───────────────────────────────────── # ── RULE_TYPES 常量 ─────────────────────────────────────
def test_rule_types_constant(): def test_rule_types_constant():
assert RULE_TYPES == ("regex", "number", "date") # 2026-08-21 扩展:rule_type 加 'string'(字符串 → Python 函数校验)
assert RULE_TYPES == ("regex", "number", "date", "string")
# ── 未知类型 ──────────────────────────────────────────── # ── 未知类型 ────────────────────────────────────────────
......
This diff is collapsed.
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