#!/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())