527 lines
20 KiB
Python
527 lines
20 KiB
Python
"""
|
||
宜搭表单 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 上执行增量 changes(add/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
|