312 lines
11 KiB
Python
312 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
yida_form_inspector.py — 跨表单元数据巡查工具
|
||
|
||
为「自定义页面联动其他表单」场景提供元数据采集能力,让 AI 在生成 JSX 前就能
|
||
拿到目标应用下所有表单的 formUuid + 字段 ID,避免硬编码错误。
|
||
|
||
【调用前必读】references/yida-custom-page-codegen.md §9
|
||
跨表单联动 5 种模式(只读聚合 / 提交其他表 / Master-Detail / 多表 Dashboard /
|
||
跨表搬运)。本脚本只负责取字段元数据,JSX 写法以 codegen.md 为准——不要
|
||
看到 --help 就直接拼 JSX。
|
||
|
||
用法:
|
||
# 列出应用下全部表单(含 formUuid / formType / title)
|
||
python yida_form_inspector.py --action list-forms --app APP_X
|
||
|
||
# 查看单张表单的字段(fieldId / dataType / label)
|
||
python yida_form_inspector.py --action fields --app APP_X --form FORM-XXX
|
||
|
||
# 一次性导出多张表单的字段汇总(推荐:跨表整合页面用)
|
||
python yida_form_inspector.py --action bundle --app APP_X --forms FORM-A,FORM-B,FORM-C --output ./forms.json
|
||
|
||
# 直接生成可粘贴到 JSX 的 FIELDS 常量代码
|
||
python yida_form_inspector.py --action fields-snippet --app APP_X --form FORM-XXX
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
def _run_dws(args):
|
||
cmd = ["dws"] + args
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
except FileNotFoundError:
|
||
print("[FAIL] 找不到 'dws' 命令,请确认已安装并在 PATH 中", file=sys.stderr)
|
||
return None
|
||
except subprocess.TimeoutExpired:
|
||
print("[FAIL] dws 调用超时", file=sys.stderr)
|
||
return None
|
||
if result.returncode != 0:
|
||
err = result.stderr.strip() or result.stdout.strip()
|
||
print(f"[FAIL] dws 执行失败 (exit {result.returncode}): {err}", file=sys.stderr)
|
||
return None
|
||
try:
|
||
return json.loads(result.stdout)
|
||
except json.JSONDecodeError as exc:
|
||
print(f"[FAIL] 输出非 JSON: {exc}\n{result.stdout[:300]}", file=sys.stderr)
|
||
return None
|
||
|
||
|
||
def _list_forms(app, form_types=None):
|
||
args = ["yida", "app", "list-forms", "--app", app, "--format", "json"]
|
||
if form_types:
|
||
args.extend(["--form-types", form_types])
|
||
data = _run_dws(args)
|
||
if data is None:
|
||
return None
|
||
if isinstance(data, dict):
|
||
for key in ("forms", "data", "items", "list"):
|
||
if isinstance(data.get(key), list):
|
||
return data[key]
|
||
return [data]
|
||
if isinstance(data, list):
|
||
return data
|
||
return []
|
||
|
||
|
||
_FIELD_ID_VALUE_RE = re.compile(r'\b[A-Za-z]+Field_[A-Za-z0-9]+\b')
|
||
_COMPONENT_LIST_KEYS = ("components", "fields", "data", "items", "result", "children", "list")
|
||
|
||
|
||
def _walk_values(obj):
|
||
if isinstance(obj, dict):
|
||
yield obj
|
||
for value in obj.values():
|
||
yield from _walk_values(value)
|
||
elif isinstance(obj, list):
|
||
for item in obj:
|
||
yield from _walk_values(item)
|
||
|
||
|
||
def _find_nested_value(obj, keys):
|
||
if isinstance(obj, dict):
|
||
for key in keys:
|
||
value = obj.get(key)
|
||
if value not in (None, ""):
|
||
return value
|
||
for value in obj.values():
|
||
found = _find_nested_value(value, keys)
|
||
if found not in (None, ""):
|
||
return found
|
||
elif isinstance(obj, list):
|
||
for item in obj:
|
||
found = _find_nested_value(item, keys)
|
||
if found not in (None, ""):
|
||
return found
|
||
return None
|
||
|
||
|
||
def _normalize_i18n_label(label):
|
||
if isinstance(label, str):
|
||
raw = label.strip()
|
||
if raw.startswith("{") and raw.endswith("}"):
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return label
|
||
if isinstance(parsed, dict):
|
||
return (parsed.get("zh_CN") or parsed.get("zh-CN")
|
||
or parsed.get("text") or parsed.get("pureEn_US")
|
||
or parsed.get("en_US") or label)
|
||
return label
|
||
if isinstance(label, dict):
|
||
return (label.get("zh_CN") or label.get("zh-CN") or label.get("text")
|
||
or label.get("pureEn_US") or label.get("en_US") or "")
|
||
return label or ""
|
||
|
||
|
||
def _find_field_id(obj):
|
||
value = _find_nested_value(obj, ("fieldId", "fieldCode", "field_id", "fieldKey"))
|
||
if isinstance(value, str):
|
||
match = _FIELD_ID_VALUE_RE.search(value)
|
||
return match.group(0) if match else value
|
||
key_value = _find_nested_value(obj, ("key", "name", "id"))
|
||
if isinstance(key_value, str):
|
||
match = _FIELD_ID_VALUE_RE.search(key_value)
|
||
if match:
|
||
return match.group(0)
|
||
text = json.dumps(obj, ensure_ascii=False) if isinstance(obj, (dict, list)) else str(obj)
|
||
match = _FIELD_ID_VALUE_RE.search(text)
|
||
return match.group(0) if match else None
|
||
|
||
|
||
def _extract_component_items(data):
|
||
if isinstance(data, dict):
|
||
for key in _COMPONENT_LIST_KEYS:
|
||
value = data.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
if isinstance(value, dict):
|
||
nested = _extract_component_items(value)
|
||
if nested:
|
||
return nested
|
||
if isinstance(data, list):
|
||
return data
|
||
|
||
found = []
|
||
seen = set()
|
||
for item in _walk_values(data):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
field_id = _find_field_id(item)
|
||
if field_id and field_id not in seen:
|
||
seen.add(field_id)
|
||
found.append(item)
|
||
return found
|
||
|
||
|
||
def _components(app, form):
|
||
data = _run_dws(["yida", "form", "components", "--app", app,
|
||
"--form", form, "--format", "json"])
|
||
if data is None:
|
||
return None
|
||
return _extract_component_items(data)
|
||
|
||
|
||
def _normalize_field(comp):
|
||
field_id = _find_field_id(comp)
|
||
label = (_find_nested_value(comp, ("label", "title", "text", "displayName", "nameCn"))
|
||
or "")
|
||
label = _normalize_i18n_label(label)
|
||
return {
|
||
"fieldId": field_id,
|
||
"label": label,
|
||
"dataType": _find_nested_value(comp, ("dataType", "valueType")) or "",
|
||
"componentName": _find_nested_value(comp, ("componentName", "type", "component")) or "",
|
||
}
|
||
|
||
|
||
def _camel_safe(text, fallback):
|
||
if not text:
|
||
text = fallback
|
||
cleaned = re.sub(r"[^A-Za-z0-9]+", " ", text).strip()
|
||
if not cleaned:
|
||
return fallback
|
||
parts = cleaned.split()
|
||
return parts[0].lower() + "".join(p.capitalize() for p in parts[1:])
|
||
|
||
|
||
def _action_list_forms(args):
|
||
forms = _list_forms(args.app, args.form_types)
|
||
if forms is None:
|
||
return 1
|
||
summary = []
|
||
for f in forms:
|
||
summary.append({
|
||
"formUuid": f.get("formUuid") or f.get("formId") or f.get("uuid"),
|
||
"title": f.get("title") or f.get("name"),
|
||
"formType": f.get("formType") or f.get("type"),
|
||
"appType": f.get("appType") or args.app,
|
||
})
|
||
print(json.dumps({"ok": True, "app": args.app, "count": len(summary),
|
||
"forms": summary}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
def _action_fields(args):
|
||
if not args.form:
|
||
print("错误: --action fields 需要 --form", file=sys.stderr)
|
||
return 1
|
||
comps = _components(args.app, args.form)
|
||
if comps is None:
|
||
return 1
|
||
fields = [_normalize_field(c) for c in comps]
|
||
print(json.dumps({"ok": True, "app": args.app, "form": args.form,
|
||
"count": len(fields), "fields": fields},
|
||
ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
def _action_fields_snippet(args):
|
||
if not args.form:
|
||
print("错误: --action fields-snippet 需要 --form", file=sys.stderr)
|
||
return 1
|
||
comps = _components(args.app, args.form)
|
||
if comps is None:
|
||
return 1
|
||
lines = ["// 由 yida_form_inspector.py 自动生成",
|
||
f"// 表单: {args.form}",
|
||
"var FIELDS = {"]
|
||
used = set()
|
||
for idx, comp in enumerate(comps):
|
||
f = _normalize_field(comp)
|
||
if not f["fieldId"]:
|
||
continue
|
||
var_name = _camel_safe(f["label"], f"field{idx}")
|
||
base = var_name
|
||
n = 2
|
||
while var_name in used:
|
||
var_name = f"{base}{n}"
|
||
n += 1
|
||
used.add(var_name)
|
||
comment = f" // {f['label']} ({f['dataType']})" if f["label"] else ""
|
||
lines.append(f" {var_name}: '{f['fieldId']}',{comment}")
|
||
lines.append("};")
|
||
print("\n".join(lines))
|
||
return 0
|
||
|
||
|
||
def _action_bundle(args):
|
||
if not args.forms:
|
||
print("错误: --action bundle 需要 --forms FORM-A,FORM-B,...", file=sys.stderr)
|
||
return 1
|
||
form_ids = [s.strip() for s in args.forms.split(",") if s.strip()]
|
||
bundle = {"ok": True, "app": args.app, "forms": {}}
|
||
for fid in form_ids:
|
||
comps = _components(args.app, fid)
|
||
if comps is None:
|
||
bundle["forms"][fid] = {"ok": False, "error": "fetch_failed"}
|
||
continue
|
||
bundle["forms"][fid] = {
|
||
"ok": True,
|
||
"fields": [_normalize_field(c) for c in comps],
|
||
}
|
||
out = json.dumps(bundle, ensure_ascii=False, indent=2)
|
||
if args.output:
|
||
Path(args.output).write_text(out, encoding="utf-8")
|
||
print(f"[OK] 已写入 {args.output}({len(form_ids)} 张表单)")
|
||
else:
|
||
print(out)
|
||
return 0
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description=(
|
||
"跨表单元数据巡查 / FIELDS 常量生成 "
|
||
"【调用前必读】references/yida-custom-page-codegen.md §9"
|
||
"(跨表单联动 5 种模式),不要只看 --help 就拼 JSX"
|
||
),
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=__doc__,
|
||
)
|
||
ap.add_argument("--action", required=True,
|
||
choices=["list-forms", "fields", "fields-snippet", "bundle"])
|
||
ap.add_argument("--app", required=True, help="应用编码 appType")
|
||
ap.add_argument("--form", help="单表 formUuid(fields / fields-snippet 用)")
|
||
ap.add_argument("--forms", help="多表 formUuid 逗号分隔(bundle 用)")
|
||
ap.add_argument("--form-types",
|
||
help="list-forms 过滤:receipt / process / report / display 等")
|
||
ap.add_argument("--output", help="bundle 写入文件")
|
||
args = ap.parse_args()
|
||
|
||
handlers = {
|
||
"list-forms": _action_list_forms,
|
||
"fields": _action_fields,
|
||
"fields-snippet": _action_fields_snippet,
|
||
"bundle": _action_bundle,
|
||
}
|
||
return handlers[args.action](args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|