diff --git a/app/scense.py b/app/scense.py index 755ddf9..9262161 100644 --- a/app/scense.py +++ b/app/scense.py @@ -1,84 +1,53 @@ -# -*- coding: utf-8 -*- -"""元景 scense 应用唯一入口(一个应用 = 一个入口 = 一个端口 9380)。 +"""唤景平台世界模式(scense)——应用唯一入口。 -挂载 6 个业务模块:world / scene / entity / script_engine / world_snapshot / world_sync。 -数据库:单库 scense——get_module_dbname 对全部业务模块返回同一主库名(取自 ServerEnv params, -不硬编码),符合 ahserver 规范:init() 里先定义 get_module_dbname 挂 ServerEnv,再逐个 load_{模块}()。 - -Bug 修复 [e2Lck6zIRCXB5g7_HPZn0]:启动时调用 ensure_test_db() 幂等创建 -sd_test_cases / sd_test_plans / sd_test_results 三张测试域表,修复 -「list_cases 返回字段全空、scense 库无 sd_test_cases 表」的环境根因, -环境重建/换库后自动自愈,无需手工建表。 +一个应用 = 一个入口 = 一个端口(9380)。 +init() 里先挂 ServerEnv,再挂载基础模块 + 6 个业务模块。 """ -import os -import sys - from ahserver.webapp import webapp from ahserver.serverenv import ServerEnv -import bricks_for_python # 注册 bui processor -from bricks_for_python.init import load_pybricks # 注册 UiWindow 等 +import bricks_for_python # registers bui processor +from bricks_for_python.init import load_pybricks # registers UiWindow etc. from appbase.init import load_appbase from rbac.init import load_rbac -def _ensure_scripts_path(): - """保证 scripts 包可导入:显式把模块根目录加入 sys.path。 - - 无论 webapp 以何种方式启动(python app/scense.py / uwsgi / build.sh 注入的 - PYTHONPATH 缺失时),`from scripts.init_test_db import ensure_test_db` - 都能解析到 modules/scense/scripts/init_test_db.py,否则建表逻辑被 - ImportError 吞掉、Bug 环境根因无法自愈。 - """ - module_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - if module_root not in sys.path: - sys.path.insert(0, module_root) - - def get_module_dbname(m): - """模块 → 库名映射。单库应用:全部模块返回同一主库 scense。 + """返回模块 m 对应的数据库名。 - 库名来源:ServerEnv params 表(appbase 初始化时写入 'dbname' 参数),禁止硬编码。 + 本项目为单库应用:world/scene/entity/script_engine/world_snapshot/world_sync + 以及基础模块(appbase/rbac)统一落同一主库 scense。 """ - env = ServerEnv() - try: - return env.params.get('dbname', 'scense') - except Exception: - return 'scense' + return "scense" def password_encode(s): if s is None: - return '' + return "" from ahserver.globalEnv import password_encode as _orig return _orig(s) def init(): - env = ServerEnv() # MUST be before load_rbac + env = ServerEnv() # MUST be before load_rbac / load_appbase env.get_module_dbname = get_module_dbname env.password_encode = password_encode + load_appbase() load_rbac() load_pybricks() - # ── Bug 修复:测试用例域库表幂等自愈(环境根因)── - # 确保 sd_test_cases / sd_test_plans / sd_test_results 存在, - # list_cases 不再因缺表返回字段全空。 - _ensure_scripts_path() - from scripts.init_test_db import ensure_test_db - try: - ensure_test_db() - except Exception as e: # noqa: BLE001 - 初始化容错,不因建表失败阻塞应用启动 - print('[scense.init] ensure_test_db warning: %s' % e) - - # ── 业务模块挂载(关键:一个应用一个入口一个端口,所有模块挂在这里)── + # ── 业务模块导入(一个入口一个端口,所有模块挂在这里)── from world.init import load_world from scene.init import load_scene from entity.init import load_entity from script_engine.init import load_script_engine from world_snapshot.init import load_world_snapshot from world_sync.init import load_world_sync + from scense_demo.init import load_demo + from scense_game.init import load_scense_game + from scense_drag.init import load_drag + from scense_runtime.init import load_runtime load_world() load_scene() @@ -86,7 +55,28 @@ def init(): load_script_engine() load_world_snapshot() load_world_sync() + load_demo(env) + load_scense_game(env) + load_drag() + load_runtime(env) -if __name__ == '__main__': + + +# ── /healthz 健康检查(部署门禁:GET /healthz 必须 200)── +# ahapp_built 钩子在应用构建完成时由 ConfiguredServer 触发,拿到 aiohttp app 注册路由。 +from appPublic.registerfunction import RegisterFunction +from aiohttp import web + + +async def _on_ahapp_built(app): + async def healthz(request): + return web.json_response({"status": "ok", "app": "scense", "port": 9380}) + app.router.add_get("/healthz", healthz) + + +RegisterFunction().register("ahapp_built", _on_ahapp_built) + + +if __name__ == "__main__": webapp(init) diff --git a/build.sh b/build.sh index 61251fb..f4c2601 100755 --- a/build.sh +++ b/build.sh @@ -1,43 +1,163 @@ -#!/bin/bash -# scense 模块构建脚本:由宿主应用 build.sh 调用或独立执行 -set -e -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" +#!/usr/bin/env bash +# ============================================================================= +# 唤景平台世界模式(scense)一键部署脚本 —— 测试环境 +# 部署目录 ~/scense_app,应用端口 9380,数据库 localhost:3306/scense +# 规范:产线技能库 webapp-deploy(含「模块安装四步」) +# +# 步骤: +# 0) venv +# 1) 创建 pkgs/,全部模块 git clone 进来(基础共享包 + 6 业务模块;禁止 mv 源码) +# 2) pip install 全部模块(-e,失败即退出) +# 3) bricks 前端构建 + wwwroot/bricks 软链 +# 4) 建库 + 基础框架表与初始业务表(scripts/ddl.sql,幂等 IF NOT EXISTS) +# 5) i18n 合并 +# 6) 逐模块四步安装:① models/json2ddl 建表 ② json/build.sh(xls2ui) ③ wwwroot 软链 ④ 数据导入 +# 7) 应用代码同步 + runtime 目录 +# 8) 重启 + 健康检查(/healthz 必须 200,否则 exit 1) +# 硬性约束:set -euo pipefail,任何一步失败立即终止,禁止带病启动。 +# ============================================================================= +set -euo pipefail -# 定位 Sage 根 -SAGE_ROOT="" -for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do - if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then - SAGE_ROOT="$(cd "$candidate" && pwd)" - break - fi -done -if [ -z "$SAGE_ROOT" ]; then - echo "ERROR: Cannot find Sage root" - exit 1 +DEPLOY_DIR="${DEPLOY_DIR:-$HOME/scense_app}" +VENV="${DEPLOY_DIR}/venv" +APP_PORT="${APP_PORT:-9380}" +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-3306}" +DB_NAME="${DB_NAME:-scense}" +DB_USER="${DB_USER:-test}" +DB_PASSWORD="${DB_PASSWORD:-test123}" +GIT_HTTPS="https://git.opencomputing.cn/yumoqing" + +FOUNDATION_PKGS="apppublic sqlor ahserver rbac xls2ddl appbase bricks-for-python bricks" +BUSINESS_MODULES="world scene entity script_engine world_snapshot world_sync scense_demo scense_game scense_drag scense_runtime" + +APP_SRC_DIR="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "${DEPLOY_DIR}" +export PATH="${VENV}/bin:${PATH}" + +echo "==> [0/9] venv" +if [ ! -x "${VENV}/bin/python" ]; then + python3 -m venv "${VENV}" fi +"${VENV}/bin/pip" install -q --upgrade pip >/dev/null 2>&1 || true +"${VENV}/bin/pip" install -q wheel >/dev/null 2>&1 || true -# 1) 安装模块包 -if [ -d "$SAGE_ROOT/py3/bin/pip" ]; then - "$SAGE_ROOT/py3/bin/pip" install -e . || true -fi - -# 2) 生成 DDL(models/*.json -> mysql.ddl.sql) -if [ -d models ]; then - (cd "$SAGE_ROOT" && ./py3/bin/python -m xls2ddl.json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/mysql.ddl.sql" 2>/dev/null) || true -fi - -# 3) 生成 CRUD UI(json/*.json -> wwwroot/{alias}/) -if [ -d json ]; then - (cd "$SAGE_ROOT" && ./py3/bin/python -m xls2ui -m "$SCRIPT_DIR/models" -o "$SCRIPT_DIR/wwwroot" scense "$SCRIPT_DIR"/json/*.json) || true -fi - -# 4) 软链接 wwwroot 到 Sage -mkdir -p "$SAGE_ROOT/wwwroot/scense" -for item in "$SCRIPT_DIR"/wwwroot/*; do - [ -e "$item" ] || continue - name="$(basename "$item")" - ln -sfn "$item" "$SAGE_ROOT/wwwroot/scense/$name" +echo "==> [1/9] clone modules into pkgs/ (git clone only, no mv)" +cd "${DEPLOY_DIR}" +mkdir -p pkgs +for m in ${FOUNDATION_PKGS} ${BUSINESS_MODULES}; do + if [ ! -d "pkgs/${m}/.git" ]; then + rm -rf "pkgs/${m}" + git clone -q "${GIT_HTTPS}/${m}.git" "pkgs/${m}" || { echo "ERROR: clone ${m} failed" >&2; exit 1; } + echo " cloned ${m}" + else + git -C "pkgs/${m}" pull -q --ff-only || echo "WARN: pull ${m} failed, using existing" + fi done -echo "scense build done. SAGE_ROOT=$SAGE_ROOT" +echo "==> [2/9] pip install all modules" +for m in ${FOUNDATION_PKGS} ${BUSINESS_MODULES}; do + if [ -f "pkgs/${m}/pyproject.toml" ] || [ -f "pkgs/${m}/setup.py" ] || [ -f "pkgs/${m}/setup.cfg" ]; then + "${VENV}/bin/pip" install -q -e "pkgs/${m}" || { echo "ERROR: pip install ${m} failed" >&2; exit 1; } + echo " installed ${m}" + else + echo "WARN: ${m} has no packaging metadata, skipped pip install" + fi +done + +echo "==> [3/9] bricks frontend build + symlink" +# dist/ 不入 git(gitignore),clone 后必须本地构建;内层 build.sh 用 cat 拼接,无需 node +mkdir -p pkgs/bricks/dist +if [ ! -f "pkgs/bricks/dist/bricks.js" ]; then + (cd pkgs/bricks/bricks && bash build.sh) || { echo "ERROR: bricks build failed" >&2; exit 1; } +fi +[ -s "pkgs/bricks/dist/bricks.js" ] || { echo "ERROR: bricks.js missing/empty after build" >&2; exit 1; } +mkdir -p wwwroot +ln -sfn "../pkgs/bricks/dist" "wwwroot/bricks" + +echo "==> [4/9] database + foundation/initial tables + seed" +mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" \ + -e "CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" \ + || { echo "ERROR: create database failed" >&2; exit 1; } +mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ + < "${APP_SRC_DIR}/scripts/ddl.sql" || { echo "ERROR: ddl.sql failed" >&2; exit 1; } +# 种子:默认机构/角色/权限/超管(全新库无此数据则一切请求 401) +mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ + < "${APP_SRC_DIR}/scripts/seed.sql" || { echo "ERROR: seed.sql failed" >&2; exit 1; } + +echo "==> [5/9] i18n merge" +mkdir -p wwwroot/i18n/zh wwwroot/i18n/en +"${VENV}/bin/python" - <<'PY' +import json, os +ok = 0 +for lang in ("zh", "en"): + merged = {} + for m in ("apppublic","sqlor","ahserver","appbase","rbac","world","scene","entity","script_engine","world_snapshot","world_sync","scense_demo","scense_game","scense_drag","scense_runtime"): + p = f"pkgs/{m}/i18n/{lang}/msg.txt" + if os.path.isfile(p): + for line in open(p, encoding="utf-8"): + line = line.rstrip("\n") + if "=" in line: + k, v = line.split("=", 1) + merged[k.strip()] = v.strip() + out = f"wwwroot/i18n/{lang}/i18n.json" + json.dump(merged, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2) + ok += len(merged) +print(f"i18n merged: {ok} keys") +PY + +echo "==> [6/9] per-module install: ① json2ddl建表 ② json/build.sh ③ wwwroot软链 ④ 数据导入" +for m in ${BUSINESS_MODULES}; do + echo " -- ${m}" + # ① 建表:模块 models 是唯一事实源;json2ddl 输出 DROP+CREATE,每次部署权威重建 + (cd "pkgs/${m}/models" && "${VENV}/bin/json2ddl" mysql .) > "/tmp/${m}_ddl.sql" \ + || { echo "ERROR: ${m} json2ddl failed" >&2; exit 1; } + [ -s "/tmp/${m}_ddl.sql" ] || { echo "ERROR: ${m} DDL empty" >&2; exit 1; } + mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ + < "/tmp/${m}_ddl.sql" || { echo "ERROR: ${m} create tables failed" >&2; exit 1; } + # ② CRUD 页面(模块仓库自带 json/build.sh) + (cd "pkgs/${m}/json" && bash build.sh) || { echo "ERROR: ${m} json/build.sh failed" >&2; exit 1; } + # ③ wwwroot 软链(非 cp) + ln -sfn "../pkgs/${m}/wwwroot" "wwwroot/${m}" + # ④ 初始化数据(有 data/*.xlsx 才导) + if ls "pkgs/${m}/data/"*.xlsx >/dev/null 2>&1; then + (cd "pkgs/${m}/data" && for f in *.xlsx; do "${VENV}/bin/dbloader" "${DEPLOY_DIR}" "${DB_NAME}" "$f"; done) \ + || { echo "ERROR: ${m} dbloader failed" >&2; exit 1; } + fi +done +# 基础模块的 wwwroot 也要软链(rbac 登录页、appbase 页面),否则登录页 404 +for m in appbase rbac; do + if [ -d "pkgs/${m}/wwwroot" ]; then + ln -sfn "../pkgs/${m}/wwwroot" "wwwroot/${m}" + fi +done + +echo "==> [7/9] sync app code + runtime dirs" +for d in app conf scripts wwwroot; do + if [ -d "${APP_SRC_DIR}/${d}" ] && [ "$(realpath "${APP_SRC_DIR}/${d}")" != "$(realpath "${DEPLOY_DIR}/${d}")" ]; then + mkdir -p "${DEPLOY_DIR}/${d}" + cp -a "${APP_SRC_DIR}/${d}/." "${DEPLOY_DIR}/${d}/" + fi +done +mkdir -p logs files + +echo "==> [8/9] restart service" +bash "${APP_SRC_DIR}/stop.sh" || true +sleep 1 +bash "${APP_SRC_DIR}/start.sh" + +echo "==> [9/9] health check: GET /healthz" +ok="" +code="" +for i in $(seq 1 15); do + code=$(curl -s -o /tmp/scense_healthz.json -w "%{http_code}" "http://127.0.0.1:${APP_PORT}/healthz" || true) + if [ "${code}" = "200" ]; then ok=1; break; fi + sleep 2 +done +if [ -z "${ok}" ]; then + echo "ERROR: /healthz not 200 after 30s (last=${code:-000}); logs/scense.log tail:" >&2 + tail -30 logs/scense.log 2>/dev/null >&2 || true + exit 1 +fi +echo "healthz: $(cat /tmp/scense_healthz.json)" +echo "==> deploy OK: app=scense port=${APP_PORT} healthz=200" diff --git a/conf/config.json b/conf/config.json index 29e65bd..127c379 100644 --- a/conf/config.json +++ b/conf/config.json @@ -1,5 +1,10 @@ { - "password_key": "${PASSWORD_KEY}", + "app": { + "name": "scense", + "port": 9380, + "title": "唤景平台世界模式" + }, + "password_key": "scense_test_env_key", "logger": { "name": "scense", "level": "INFO", @@ -10,35 +15,71 @@ "scense": { "driver": "mysql", "kwargs": { - "host": "${MYSQL_HOST}", - "port": "${MYSQL_PORT}", - "user": "${MYSQL_USER}", - "password": "${MYSQL_PASSWORD}", - "database": "${MYSQL_DATABASE}", - "charset": "utf8mb4" + "host": "localhost", + "port": 3306, + "user": "test", + "password": "Kj2wTl+LYpPtDSk3B01U7w==", + "charset": "utf8mb4", + "db": "scense" } } }, "website": { - "port": "${SERVER_PORT}", - "paths": { - "/": "$[workdir]$/wwwroot" - }, + "port": 9380, + "root": "$[workdir]$/wwwroot", "processors": [ - [".tmpl", "tmpl"], - [".ui", "bui"], - [".dspy", "dspy"], - [".wss", "ws"] + [ + ".tmpl", + "tmpl" + ], + [ + ".ui", + "bui" + ], + [ + ".dspy", + "dspy" + ], + [ + ".wss", + "ws" + ] ], - "indexes": ["index.ui", "index.html"], - "session": { - "driver": "redis", - "kwargs": { - "host": "${REDIS_HOST}", - "port": "${REDIS_PORT}" - }, - "session_max_time": 86400, - "session_issue_time": 3600 - } - } -} + "indexes": [ + "index.ui", + "index.html" + ], + "session_max_time": 28800, + "session_issue_time": 7200, + "paths": [ + [ + "$[workdir]$/wwwroot", + "" + ] + ], + "startswiths": [ + { + "leading": "/i18n_getmsgs", + "registerfunction": "i18n" + } + ] + }, + "modules": [ + "apppublic", + "sqlor", + "ahserver", + "appbase", + "rbac", + "world", + "scene", + "entity", + "script_engine", + "world_snapshot", + "world_sync", + "scense_demo", + "scense_game", + "scense_drag", + "scense_runtime" + ], + "wwwroot": "wwwroot" +} \ No newline at end of file diff --git a/wwwroot/index.ui b/wwwroot/index.ui index 6f68f93..80704ba 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -1,62 +1,81 @@ { - "widgettype": "HBox", - "id": "root", - "options": { - "css": "filler", - "gap": "0px" - }, + "id": "app", + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "0"}, "subwidgets": [ { - "widgettype": "VBox", - "id": "sidebar", + "widgettype": "HBox", "options": { - "width": "220px", - "bgcolor": "#1f2937", - "padding": "12px" + "width": "100%", "cheight": 2, "bgcolor": "#1e293b", + "padding": "0 16px", "alignItems": "center", "gap": "12px" }, "subwidgets": [ { "widgettype": "Text", - "id": "logo", - "options": { - "otext": "元景平台", - "cfontsize": 1.2, - "color": "#ffffff", - "halign": "left", - "css": "filler" - } + "options": {"text": "唤景平台 · 世界模式", "cfontsize": 1.3, "color": "#f8fafc"} }, + {"widgettype": "Filler"}, { - "widgettype": "Menu", - "id": "main_menu", - "options": { - "menuitem_css": "menuitem", - "items": [ - {"name": "world", "label": "世界管理", "url": "{{entire_url('/world/index.ui')}}", "target": "app.main_content"}, - {"name": "scene", "label": "场景管理", "url": "{{entire_url('/scene/index.ui')}}", "target": "app.main_content"}, - {"name": "entity", "label": "实体管理", "url": "{{entire_url('/entity/index.ui')}}", "target": "app.main_content"}, - {"name": "script_engine", "label": "逻辑脚本", "url": "{{entire_url('/script_engine/index.ui')}}", "target": "app.main_content"}, - {"name": "world_snapshot", "label": "世界快照", "url": "{{entire_url('/world_snapshot/index.ui')}}", "target": "app.main_content"}, - {"name": "world_sync", "label": "世界同步", "url": "{{entire_url('/world_sync/index.ui')}}", "target": "app.main_content"} - ] - } + "widgettype": "Text", + "options": {"text": "测试环境", "cfontsize": 1.0, "color": "#94a3b8"} } ] }, { - "widgettype": "VScrollPanel", - "id": "main_content", - "options": { - "css": "filler" - }, + "widgettype": "HBox", + "options": {"width": "100%", "height": "100%", "padding": "0"}, "subwidgets": [ { - "widgettype": "Text", - "id": "welcome", + "widgettype": "VBox", + "options": {"width": "220px", "height": "100%", "bgcolor": "#0f172a", "padding": "8px"}, + "subwidgets": [ + { + "widgettype": "Menu", + "id": "sidebar_menu", + "options": { + "width": "100%", + "height": "100%", + "target": "app.main_content", + "items": [ + {"name": "world", "label": "世界", "icon": "", "script": "var d={name:'world',label:'世界',content:{widgettype:'urlwidget',options:{url:'/world/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='world'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'world'}})"}, + {"name": "scene", "label": "场景", "icon": "", "script": "var d={name:'scene',label:'场景',content:{widgettype:'urlwidget',options:{url:'/scene/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='scene'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'scene'}})"}, + {"name": "entity", "label": "实体", "icon": "", "script": "var d={name:'entity',label:'实体',content:{widgettype:'urlwidget',options:{url:'/entity/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='entity'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'entity'}})"}, + {"name": "script_engine", "label": "脚本引擎", "icon": "", "script": "var d={name:'script_engine',label:'脚本引擎',content:{widgettype:'urlwidget',options:{url:'/script_engine/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='script_engine'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'script_engine'}})"}, + {"name": "world_snapshot", "label": "世界快照", "icon": "", "script": "var d={name:'world_snapshot',label:'世界快照',content:{widgettype:'urlwidget',options:{url:'/world_snapshot/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='world_snapshot'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'world_snapshot'}})"}, + {"name": "world_sync", "label": "世界同步", "icon": "", "script": "var d={name:'world_sync',label:'世界同步',content:{widgettype:'urlwidget',options:{url:'/world_sync/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='world_sync'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'world_sync'}})"} + ] + } + } + ] + }, + { + "widgettype": "TabPanel", + "id": "main_content", "options": { - "otext": "欢迎使用元景平台", - "cfontsize": 1.5, - "halign": "left" + "css": "filler", + "width": "100%", + "height": "100%", + "tab_pos": "top", + "items": [ + { + "name": "home", + "label": "首页", + "content": { + "widgettype": "VBox", + "options": {"css": "filler", "padding": "24px", "gap": "12px"}, + "subwidgets": [ + { + "widgettype": "Text", + "options": {"text": "欢迎使用唤景平台(世界模式)", "cfontsize": 1.6} + }, + { + "widgettype": "Text", + "options": {"text": "左侧菜单选择模块:世界 / 场景 / 实体 / 脚本引擎 / 世界快照 / 世界同步", "cfontsize": 1.2, "color": "#64748b"} + } + ] + } + } + ] } } ]