Files
2026-09-02 11:44:52 +08:00

527 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
宜搭表单 schema 构造 — 字段组件装配进 Page > FormContainer 骨架。
主入口:
build_form_schema(form_title, fields, form_uuid, corp_id, app_type)
apply_changes_to_schema(schema, changes) — 增量操作
"""
from __future__ import annotations
import copy
import json
import re
from typing import Any, Optional
from yida_schema_common import (
build_components_map,
collect_component_names,
DATA_SOURCE_FIT_COMPILED,
DATA_SOURCE_FIT_SOURCE,
generate_field_id,
i18n,
next_node_id,
normalize_field_type,
UTILS_LEGAO_BUILTIN,
UTILS_YIDA_PLUGIN,
)
from yida_form_fields import build_field_component
# ---------------------------------------------------------------------------
# 骨架常量
# ---------------------------------------------------------------------------
_FORM_ACTIONS = {
"module": {
"source": (
'/**\n* 尊敬的用户,你好:页面 JS 面板是高阶用法。\n*/\n\n'
'export function didMount() {\n'
' console.log(`「页面 JS」:当前页面地址 ${location.href}`);\n'
'}'
),
"compiled": (
'"use strict";\n\nexports.__esModule = true;\nexports.didMount = didMount;\n'
'function didMount() {\n'
' console.log("\\u300C\\u9875\\u9762 JS\\u300D\\uFF1A\\u5F53\\u524D\\u9875\\u9762\\u5730\\u5740 " + location.href);\n'
'}\n'
),
},
"type": "FUNCTION",
"list": [{"id": "didMount", "title": "didMount"}],
}
_CONSTRUCTOR_SOURCE = (
"function constructor() {\n"
"var module = { exports: {} };\n"
"var _this = this;\n"
"this.__initMethods__(module.exports, module);\n"
"Object.keys(module.exports).forEach(function(item) {\n"
" if(typeof module.exports[item] === 'function'){\n"
" _this[item] = module.exports[item];\n"
" }\n"
"});\n\n"
"}"
)
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def build_form_schema(
form_title: str,
fields: list[dict[str, Any]],
form_uuid: str = "",
corp_id: str = "",
app_type: str = "",
*,
label_align: str = "top",
) -> dict[str, Any]:
"""
构造完整的表单 schema。
Args:
form_title: 表单标题
fields: 字段定义数组(每项含 type + label + ...
form_uuid: 表单 UUID
corp_id: 企业 ID(流水号需要)
app_type: 应用编码
label_align: 标签对齐方式 top/left
"""
# 1. 构造所有字段节点
field_nodes: list[dict[str, Any]] = []
used_colors: set[str] = set()
for field in fields:
node, used_colors = build_field_component(
field,
used_colors=used_colors,
app_type=app_type,
form_uuid=form_uuid,
corp_id=corp_id,
)
field_nodes.append(node)
# 2. 后处理:解析 @label: 引用
_resolve_field_id_references(field_nodes)
# 3. 后处理:补全流水号 formula(此时 corp_id/app_type/form_uuid 确定)
_fill_serial_number_formulas(field_nodes, corp_id, app_type, form_uuid)
# 4. 收集 componentsMap
all_component_names = ["Page", "RootHeader", "RootContent", "RootFooter", "FormContainer"]
all_component_names.extend(collect_component_names(field_nodes))
components_map = build_components_map(all_component_names)
# 5. 拼装骨架
schema: dict[str, Any] = {
"schemaType": "superform",
"schemaVersion": "5.0",
"pages": [
{
"utils": [UTILS_LEGAO_BUILTIN, UTILS_YIDA_PLUGIN],
"componentsMap": components_map,
"componentsTree": [
{
"componentName": "Page",
"id": next_node_id(),
"props": {
"templateVersion": "1.0.0",
"pageStyle": {"backgroundColor": "#f2f3f5"},
"titleName": i18n(form_title),
"titleDesc": i18n(""),
"titleColor": "light",
"titleBg": "https://img.alicdn.com/imgextra/i2/O1CN0143ATPP1wIa9TrVvzN_!!6000000006285-2-tps-3360-400.png_.webp",
"backgroundColorCustom": "#f1f2f3",
"sizePc": "medium",
"labelAlignPc": label_align,
"labelWidthPc": "130px",
"labelWeightPc": "normal",
"contentMargin": "12",
"contentPadding": "20",
"contentBgColor": "white",
"showTitle": True,
"labelAlignMobile": "left",
"labelWidthMobile": "100px",
"labelWeightMobile": "bold",
"contentMarginMobile": "12",
"contentPaddingMobile": "0",
"contentBgColorMobile": "white",
"className": "page_m8o991i5",
},
"dataSource": {
"offline": [],
"globalConfig": {
"fit": {
"compiled": DATA_SOURCE_FIT_COMPILED,
"source": DATA_SOURCE_FIT_SOURCE,
"type": "js",
"error": {},
},
},
"online": [],
"list": [],
"sync": True,
},
"methods": {
"__initMethods__": {
"type": "js",
"source": "function (exports, module) { /*set actions code here*/ }",
"compiled": "function (exports, module) { /*set actions code here*/ }",
},
},
"lifeCycles": {
"componentDidMount": {
"id": "didMount",
"name": "didMount",
"params": {},
"type": "actionRef",
},
"componentWillUnmount": "",
"constructor": {
"type": "js",
"compiled": _CONSTRUCTOR_SOURCE,
"source": _CONSTRUCTOR_SOURCE,
},
},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
"children": [
{
"componentName": "RootHeader",
"id": next_node_id(),
"props": {},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
},
{
"componentName": "RootContent",
"id": next_node_id(),
"props": {},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
"children": [
{
"componentName": "FormContainer",
"id": next_node_id(),
"props": {
"columns": 1,
"labelAlign": label_align,
"submitText": i18n("提交", "Submit"),
"fieldId": generate_field_id("formContainer"),
"aiFormConfig": {
"systemPrompt": "",
"model": "qwen",
},
"beforeSubmit": False,
"afterSubmit": False,
},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
"children": field_nodes,
},
],
},
{
"componentName": "RootFooter",
"id": next_node_id(),
"props": {},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
},
],
"css": "body{background-color:#f2f3f5}",
},
],
"componentAlias": {"items": []},
"id": form_uuid or "xxxx",
"connectComponent": [],
},
],
"actions": copy.deepcopy(_FORM_ACTIONS),
"config": {"connectComponent": []},
}
return schema
# ---------------------------------------------------------------------------
# 增量修改
# ---------------------------------------------------------------------------
def apply_changes_to_schema(
schema: dict[str, Any],
changes: list[dict[str, Any]],
*,
corp_id: str = "",
app_type: str = "",
form_uuid: str = "",
) -> dict[str, Any]:
"""
在已有 schema 上执行增量 changesadd/update/delete)。
返回修改后的 schema(原地修改)。
"""
form_container = _find_form_container(schema)
if form_container is None:
raise ValueError("schema 中找不到 FormContainer 节点")
children: list[dict[str, Any]] = form_container.get("children", [])
used_colors = _collect_existing_colors(children)
for change in changes:
action = change.get("action", "")
if action == "add":
field_def = change.get("field", {})
node, used_colors = build_field_component(
field_def,
used_colors=used_colors,
app_type=app_type,
form_uuid=form_uuid,
corp_id=corp_id,
)
after_label = change.get("after")
before_label = change.get("before")
insert_idx = len(children)
if after_label:
idx = _find_field_index_by_label(children, after_label)
if idx is not None:
insert_idx = idx + 1
elif before_label:
idx = _find_field_index_by_label(children, before_label)
if idx is not None:
insert_idx = idx
children.insert(insert_idx, node)
elif action == "update":
label = change.get("label", "")
table_label = change.get("tableLabel")
patches = change.get("changes", {})
target_list = children
if table_label:
table_node = _find_field_by_label(children, table_label)
if table_node and table_node.get("componentName") == "TableField":
target_list = table_node.get("children", [])
target = _find_field_by_label(target_list, label)
if target:
_apply_field_patches(target, patches, used_colors)
elif action == "delete":
label = change.get("label", "")
table_label = change.get("tableLabel")
target_list = children
if table_label:
table_node = _find_field_by_label(children, table_label)
if table_node and table_node.get("componentName") == "TableField":
target_list = table_node.get("children", [])
idx = _find_field_index_by_label(target_list, label)
if idx is not None:
target_list.pop(idx)
# 后处理
all_fields = form_container.get("children", [])
_resolve_field_id_references(all_fields)
_fill_serial_number_formulas(all_fields, corp_id, app_type, form_uuid)
# 更新 componentsMap
all_names = ["Page", "RootHeader", "RootContent", "RootFooter", "FormContainer"]
all_names.extend(collect_component_names(all_fields))
schema["pages"][0]["componentsMap"] = build_components_map(all_names)
return schema
# ---------------------------------------------------------------------------
# 后处理
# ---------------------------------------------------------------------------
def _resolve_field_id_references(field_nodes: list[dict[str, Any]]) -> None:
"""解析 @label:字段名 引用为真实 fieldId。"""
label_to_field_id: dict[str, str] = {}
def _collect(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
props = node.get("props", {})
label_obj = props.get("label", {})
label_text = label_obj.get("zh_CN", "") if isinstance(label_obj, dict) else str(label_obj)
field_id = props.get("fieldId", "")
if label_text and field_id:
label_to_field_id[label_text] = field_id
for child in node.get("children", []):
_collect([child])
_collect(field_nodes)
def _resolve(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
props = node.get("props", {})
filling_rules = props.get("dataFillingRules", {})
if isinstance(filling_rules, dict):
main_rules = filling_rules.get("mainRules", [])
for rule in main_rules:
for key in ("source", "sourceFieldId", "target", "targetFieldId"):
val = rule.get(key, "")
if isinstance(val, str) and val.startswith("@label:"):
name = val[7:]
if name in label_to_field_id:
rule[key] = label_to_field_id[name]
for child in node.get("children", []):
_resolve([child])
_resolve(field_nodes)
def _fill_serial_number_formulas(
field_nodes: list[dict[str, Any]],
corp_id: str,
app_type: str,
form_uuid: str,
) -> None:
"""确保 SerialNumberField 的 formula 包含正确的 corp_id/app_type/form_uuid。"""
if not corp_id or not app_type or not form_uuid:
return
def _walk(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
if node.get("componentName") == "SerialNumberField":
props = node.get("props", {})
field_id = props.get("fieldId", "")
serial_rule = props.get("serialNumberRule", [])
if serial_rule and field_id:
rule_json = json.dumps({"type": "custom", "value": serial_rule}).replace('"', '\\"')
props["formula"] = {
"expression": f'SERIALNUMBER("{corp_id}", "{app_type}", "{form_uuid}", "{field_id}", "{rule_json}")'
}
for child in node.get("children", []):
_walk([child])
_walk(field_nodes)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _find_form_container(schema: dict[str, Any]) -> Optional[dict[str, Any]]:
pages = schema.get("pages", [])
if not pages:
return None
tree = pages[0].get("componentsTree", [])
if not tree:
return None
def _search(node: dict[str, Any]) -> Optional[dict[str, Any]]:
if node.get("componentName") == "FormContainer":
return node
for child in node.get("children", []):
found = _search(child)
if found:
return found
return None
return _search(tree[0])
def _find_field_by_label(nodes: list[dict[str, Any]], label: str) -> Optional[dict[str, Any]]:
for node in nodes:
props = node.get("props", {})
label_obj = props.get("label", {})
label_text = label_obj.get("zh_CN", "") if isinstance(label_obj, dict) else str(label_obj)
if label_text == label:
return node
return None
def _find_field_index_by_label(nodes: list[dict[str, Any]], label: str) -> Optional[int]:
for idx, node in enumerate(nodes):
props = node.get("props", {})
label_obj = props.get("label", {})
label_text = label_obj.get("zh_CN", "") if isinstance(label_obj, dict) else str(label_obj)
if label_text == label:
return idx
return None
def _collect_existing_colors(nodes: list[dict[str, Any]]) -> set[str]:
colors: set[str] = set()
for node in nodes:
props = node.get("props", {})
for item in props.get("dataSource", []):
c = item.get("color")
if c:
colors.add(c)
for child in node.get("children", []):
colors.update(_collect_existing_colors([child]))
return colors
def _apply_field_patches(node: dict[str, Any], patches: dict[str, Any], used_colors: set[str]) -> None:
props = node.get("props", {})
if "label" in patches:
props["label"] = i18n(patches["label"])
if "required" in patches:
validation = props.get("validation", [])
has_req = any(v.get("type") == "required" for v in validation)
if patches["required"] and not has_req:
validation.append({"type": "required"})
props["validation"] = validation
elif not patches["required"] and has_req:
props["validation"] = [v for v in validation if v.get("type") != "required"]
if "behavior" in patches:
props["behavior"] = patches["behavior"]
if "options" in patches:
from yida_schema_common import build_option_data_source
component_name = node.get("componentName", "")
is_checkbox = component_name in ("CheckboxField", "MultiSelectField")
ds, _ = build_option_data_source(patches["options"], is_checkbox=is_checkbox, used_colors=used_colors)
props["dataSource"] = ds
props["isUseDataSourceColor"] = True
if "placeholder" in patches:
props["placeholder"] = i18n(patches["placeholder"])
if "suffix" in patches:
props["innerAfter"] = i18n(patches["suffix"])
if "prefix" in patches:
props["innerBefore"] = i18n(patches["prefix"])
if "format" in patches:
props["format"] = patches["format"]
if "multiple" in patches or "multi" in patches:
val = patches.get("multiple") or patches.get("multi")
props["multiple"] = val
if val:
props["mode"] = "multiple"
def is_empty_skeleton(schema: dict[str, Any]) -> bool:
"""判断 schema 是否是空骨架(无字段)。"""
fc = _find_form_container(schema)
if fc is None:
return True
children = fc.get("children", [])
return len(children) == 0