""" yida_page_schema.py — 宜搭自定义页面 schema 处理(builder + extractor)。 公开 API: Builder: - build_schema_content(source_code, compiled_code, form_uuid, existing_data_source=None) -> str - build_default_page_data_source(form_uuid) -> dict - merge_page_data_source(existing_data_source, generated_data_source) -> dict Extractor: - extract_source_code(schema) -> str - extract_compiled_code(schema) -> str - inject_source_code(schema, new_source, new_compiled) -> str 支持三种 schema 格式: 1. 标准格式:actions.module.source / actions.module.compiled 2. YidaCodeCanvas 格式:pages[0].componentsTree[*].props.code / runtimeCode 3. YidaAICanvas 格式:同 YidaCodeCanvas 布局 """ import copy import json import random import time # region: schema_builder # --------------------------------------------------------------------------- # 宜搭页面 schema JSON 外壳构建 # --------------------------------------------------------------------------- def _create_node_id_generator(): counter = [0] def next_node_id(): counter[0] += 1 ts = _base36(int(time.time() * 1000)) return 'node_oc' + ts + _base36(counter[0]) return next_node_id def _base36(n): if n == 0: return '0' chars = '0123456789abcdefghijklmnopqrstuvwxyz' result = [] while n > 0: result.append(chars[n % 36]) n //= 36 return ''.join(reversed(result)) def _generate_suffix(): ts = _base36(int(time.time() * 1000)) rand = _base36(random.randint(0, 36 ** 6)) return ts + rand.ljust(6, '0')[:6] def _get_global_data_source_fit_config(): fit_compiled = ( "'use strict';\n\nvar __preParser__ = function fit(response) {\n" " var content = response.content !== undefined ? response.content : response;\n" " var error = {\n" " message: response.errorMsg || response.errors && response.errors[0] " "&& response.errors[0].msg || response.content || " "'远程数据源请求出错,success is false'\n" " };\n var success = true;\n" " if (response.success !== undefined) {\n success = response.success;\n" " } else if (response.hasError !== undefined) {\n success = !response.hasError;\n" " }\n return {\n content: content,\n success: success,\n error: error\n };\n};" ) fit_source = ( "function fit(response) {\r\n" " const content = (response.content !== undefined) ? response.content : response;\r\n" " const error = {\r\n" " message: response.errorMsg ||\r\n" " (response.errors && response.errors[0] && response.errors[0].msg) ||\r\n" " response.content || '远程数据源请求出错,success is false',\r\n" " };\r\n" " let success = true;\r\n" " if (response.success !== undefined) {\r\n" " success = response.success;\r\n" " } else if (response.hasError !== undefined) {\r\n" " success = !response.hasError;\r\n" " }\r\n" " return {\r\n" " content,\r\n" " success,\r\n" " error,\r\n" " };\r\n" "}" ) return { 'fit': { 'compiled': fit_compiled, 'source': fit_source, 'type': 'js', 'error': {}, }, } def build_default_page_data_source(form_uuid): url_params = { 'id': 'VCB660714833IBHEOXK376TA7XJH2AXUWR8MMW', 'name': 'urlParams', 'description': '当前页面地址的参数:如 aliwork.com/APP_XXX/workbench?id=1&name=宜搭,' '可通过 this.state.urlParams.name 获取到宜搭', 'formUuid': form_uuid, 'protocal': 'URI', 'isReadonly': True, } timestamp = { 'id': '', 'name': 'timestamp', 'description': '', 'formUuid': form_uuid, 'protocal': 'VALUE', 'initialData': '', } return { 'offline': [], 'globalConfig': _get_global_data_source_fit_config(), 'online': [url_params, timestamp], 'list': [url_params, timestamp], 'sync': True, } def _is_built_in_page_data_source(item): if not item or not isinstance(item, dict): return False return item.get('name') in ('urlParams', 'timestamp') def _get_data_source_identity(item): if not item or not isinstance(item, dict): return '' if _is_built_in_page_data_source(item): return 'builtin:' + item['name'] if item.get('id'): return 'id:' + item['id'] if item.get('name') and item.get('protocal'): return 'name:' + item['name'] + '|protocal:' + item['protocal'] if item.get('name'): return 'name:' + item['name'] return json.dumps(item, ensure_ascii=False, sort_keys=True) def _merge_data_source_array(existing_items, generated_items): merged = copy.deepcopy(existing_items) if isinstance(existing_items, list) else [] seen = set() for item in merged: identity = _get_data_source_identity(item) if identity: seen.add(identity) for item in (generated_items if isinstance(generated_items, list) else []): identity = _get_data_source_identity(item) if not identity or identity not in seen: merged.append(copy.deepcopy(item)) if identity: seen.add(identity) return merged def merge_page_data_source(existing_data_source, generated_data_source): if not existing_data_source or not isinstance(existing_data_source, dict): return copy.deepcopy(generated_data_source) existing = copy.deepcopy(existing_data_source) generated = copy.deepcopy(generated_data_source) if generated_data_source else {} merged = {**generated, **existing} merged['offline'] = _merge_data_source_array(existing.get('offline'), generated.get('offline')) merged['online'] = _merge_data_source_array(existing.get('online'), generated.get('online')) merged['list'] = _merge_data_source_array(existing.get('list'), generated.get('list')) merged['globalConfig'] = { **(generated.get('globalConfig') or {}), **(existing.get('globalConfig') or {}), } merged['sync'] = existing.get('sync') if existing.get('sync') is not None else generated.get('sync') return merged def build_schema_content(source_code, compiled_code, form_uuid, existing_data_source=None): """把 sourceCode + compiledCode 包装成完整的宜搭自定义页面 schema JSON 字符串。""" next_node_id = _create_node_id_generator() constructor_code = ( "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" "}" ) page_data_source = merge_page_data_source( existing_data_source, build_default_page_data_source(form_uuid), ) schema = { 'schemaType': 'superform', 'schemaVersion': '5.0', 'pages': [ { 'utils': [ { 'name': 'legaoBuiltin', 'type': 'npm', 'content': { 'package': '@ali/vu-legao-builtin', 'version': '3.0.0', 'exportName': 'legaoBuiltin', }, }, { 'name': 'yidaPlugin', 'type': 'npm', 'content': { 'package': '@ali/vu-yida-plugin', 'version': '1.1.0', 'exportName': 'yidaPlugin', }, }, ], 'componentsMap': [ {'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'RootHeader'}, {'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'Jsx'}, {'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'RootContent'}, {'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'RootFooter'}, {'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'Page'}, ], 'componentsTree': [ { 'componentName': 'Page', 'id': next_node_id(), 'props': { 'contentBgColor': 'white', 'pageStyle': {'backgroundColor': '#f2f3f5'}, 'contentMargin': '0', 'contentPadding': '0', 'showTitle': False, 'contentPaddingMobile': '0', 'templateVersion': '1.0.0', 'contentMarginMobile': '0', 'className': 'page_' + _generate_suffix(), 'contentBgColorMobile': 'white', }, 'condition': True, 'css': ( 'body{background-color:#f2f3f5}' '.vc-page-yida-page{--yida-form-content-padding:0;' '--yida-form-content-margin:0;--yida-layout-padding:0}' '.vc-deep-container-entry.vc-rootcontent{padding:0!important;' 'margin-top:0!important;margin-right:0!important;' 'margin-bottom:0!important;margin-left:0!important}' ), 'methods': { '__initMethods__': { 'type': 'js', 'source': 'function (exports, module) { /*set actions code here*/ }', 'compiled': 'function (exports, module) { /*set actions code here*/ }', }, }, 'dataSource': page_data_source, 'lifeCycles': { 'constructor': { 'type': 'js', 'compiled': constructor_code, 'source': constructor_code, }, 'componentWillUnmount': { 'name': 'didUnmount', 'id': 'didUnmount', 'type': 'actionRef', 'params': {}, }, 'componentDidMount': { 'name': 'didMount', 'id': 'didMount', 'params': {}, 'type': 'actionRef', }, }, 'hidden': False, 'title': '', 'isLocked': False, 'conditionGroup': '', 'children': [ { 'componentName': 'RootHeader', 'id': next_node_id(), 'props': {}, 'condition': True, 'hidden': False, 'title': '', 'isLocked': False, 'conditionGroup': '', }, { 'componentName': 'RootContent', 'id': next_node_id(), 'props': {}, 'condition': True, 'hidden': False, 'title': '', 'isLocked': False, 'conditionGroup': '', 'children': [ { 'componentName': 'Jsx', 'id': next_node_id(), 'props': { 'render': { 'type': 'js', 'compiled': ( 'function main(){\n \n "use strict";\n\n' 'var __compiledFunc__ = function render() {\n' ' return this.renderJsx();\n};\n' ' return __compiledFunc__.apply(this, arguments);\n }' ), 'source': ( 'function render() {\n' ' return this.renderJsx();\n}' ), 'error': {}, }, '__style__': {}, 'fieldId': 'jsx_' + _generate_suffix(), }, 'condition': True, 'hidden': False, 'title': '', 'isLocked': False, 'conditionGroup': '', }, ], }, { 'componentName': 'RootFooter', 'id': next_node_id(), 'props': {}, 'condition': True, 'hidden': False, 'title': '', 'isLocked': False, 'conditionGroup': '', }, ], }, ], 'id': form_uuid, 'connectComponent': [], }, ], 'actions': { 'module': { 'compiled': compiled_code, 'source': source_code, }, 'type': 'FUNCTION', 'list': [ {'id': 'getCustomState', 'title': 'getCustomState'}, {'id': 'setCustomState', 'title': 'setCustomState'}, {'id': 'forceUpdate', 'title': 'forceUpdate'}, {'id': 'didMount', 'title': 'didMount'}, {'id': 'didUnmount', 'title': 'didUnmount'}, {'id': 'renderJsx', 'title': 'renderJsx'}, ], }, 'config': { 'connectComponent': [], }, } return json.dumps(schema, ensure_ascii=False) # endregion # region: schema_extractor # --------------------------------------------------------------------------- # 从 schema 中提取 / 注入源码(兼容标准 / YidaCodeCanvas / YidaAICanvas) # --------------------------------------------------------------------------- def _parse_schema(schema): if isinstance(schema, str): return json.loads(schema) if isinstance(schema, dict): return schema raise TypeError(f"schema must be dict or JSON string, got {type(schema).__name__}") def _find_code_canvas_node(data: dict): """在 componentsTree 中查找 YidaCodeCanvas 或 YidaAICanvas 节点""" try: root = data['pages'][0]['componentsTree'][0] found = _search_canvas_recursive(root) if found: return found except (KeyError, IndexError, TypeError): pass return None def _search_canvas_recursive(node: dict): if node.get('componentName') in ('YidaCodeCanvas', 'YidaAICanvas'): return node for child in node.get('children', []): found = _search_canvas_recursive(child) if found: return found return None def _detect_format(data: dict) -> str: """检测 schema 格式:'standard' 或 'code_canvas'""" canvas = _find_code_canvas_node(data) if canvas and canvas.get('props', {}).get('code'): return 'code_canvas' try: if data['actions']['module']['source']: return 'standard' except (KeyError, TypeError): pass raise ValueError( "Cannot detect schema format: neither actions.module.source " "nor YidaCodeCanvas/YidaAICanvas.props.code found." ) def extract_source_code(schema) -> str: """从宜搭自定义页面 schema 提取原始 JSX 源码。""" data = _parse_schema(schema) fmt = _detect_format(data) if fmt == 'code_canvas': canvas = _find_code_canvas_node(data) code = canvas.get('props', {}).get('code') if not code or not isinstance(code, str): raise ValueError("YidaCodeCanvas/YidaAICanvas.props.code is empty or not a string") return code try: source = data['actions']['module']['source'] except (KeyError, TypeError): raise ValueError("Cannot find actions.module.source") if not isinstance(source, str): raise ValueError(f"actions.module.source is not a string, got {type(source).__name__}") return source def extract_compiled_code(schema) -> str: """从宜搭自定义页面 schema 提取编译后代码。""" data = _parse_schema(schema) fmt = _detect_format(data) if fmt == 'code_canvas': canvas = _find_code_canvas_node(data) runtime_code = canvas.get('props', {}).get('runtimeCode') if not runtime_code or not isinstance(runtime_code, str): raise ValueError("YidaCodeCanvas/YidaAICanvas.props.runtimeCode is empty or not a string") return runtime_code try: compiled = data['actions']['module']['compiled'] except (KeyError, TypeError): raise ValueError("Cannot find actions.module.compiled") if not isinstance(compiled, str): raise ValueError(f"actions.module.compiled is not a string, got {type(compiled).__name__}") return compiled def inject_source_code(schema, new_source: str, new_compiled: str) -> str: """将新的源码和编译产物注入 schema,返回更新后的 JSON 字符串。""" data = _parse_schema(schema) data = copy.deepcopy(data) fmt = _detect_format(data) if fmt == 'code_canvas': canvas = _find_code_canvas_node(data) if not canvas: raise ValueError("Cannot find YidaCodeCanvas/YidaAICanvas node for injection") canvas['props']['code'] = new_source canvas['props']['runtimeCode'] = new_compiled else: if 'actions' not in data or not isinstance(data['actions'], dict): raise ValueError("Invalid schema: missing 'actions' key") if 'module' not in data['actions'] or not isinstance(data['actions']['module'], dict): raise ValueError("Invalid schema: missing 'actions.module' key") data['actions']['module']['source'] = new_source data['actions']['module']['compiled'] = new_compiled return json.dumps(data, ensure_ascii=False) # endregion __all__ = [ 'build_schema_content', 'build_default_page_data_source', 'merge_page_data_source', 'extract_source_code', 'extract_compiled_code', 'inject_source_code', ]