diff --git a/.gitignore b/.gitignore index 4d607b7..8da8c1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ +build/ +*.egg-info/ __pycache__/ *.pyc -*.egg-info/ -build/ -dist/ -.eggs/ *.swp *.swo -wwwroot/scene_list/ -wwwroot/scene_import_list/ +wwwroot/scene/ +wwwroot/scene_import/ diff --git a/README.md b/README.md index a677f93..4c6cb25 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,33 @@ # scene 模块 -场景管理(W-02):场景表 CRUD、列表查询、场景文件导入(JSON/JSON Lines/CSV)。 +场景管理,含场景导入。 ## 功能 -- `scene` 表 CRUD(list/get/create/update/delete),分页 `{list, total}` -- `scene_import` 表只读列表(导入记录) -- 场景导入 `api/scene_import.dspy`:world_id + file → 解析 → 事务批量落库 → 写导入记录 -- 枚举字段 appcodes 字典(scene_type / scene_status / import_status) -- 世界下拉(依赖 world 模块,world_id 逻辑关联,不加物理外键) +- 场景表 `scene` 的 CRUD(list/get/create/update/delete) +- 场景导入:`api/scene_import.dspy`(文件上传 → 解析 JSON/JSON Lines/CSV → 批量落库 → 写导入记录) +- 枚举字段 appcodes 字典(scene_type / scene_status / import_status,cond parentid=) +- 世界下拉(依赖 world 模块) ## 数据表 -- `scene`:id(str32 PK)、world_id(str32→world.id 逻辑关联)、name(255)、code(64 唯一)、 - description(text)、scene_type(16)、status(16)、config_json(text)、created_at、updated_at -- `scene_import`:id(str32 PK)、world_id、file_name(255)、total/success/fail(int)、status(16)、created_at +- `scene`(models/scene.json):场景表 + - 主键 `id`(str32) + - 唯一索引 `idx_scene_code`(code)、索引 `idx_scene_world`(world_id) + - 字典:world_id → world、scene_type/status → appcodes_kv +- `scene_import`(models/scene_import.json):场景导入记录表(只读) + - 主键 `id`(str32)、索引 `idx_scene_import_world`(world_id) + - 字典:world_id → world、status → appcodes_kv(parentid='import_status') -## 接口(REST 统一前缀 /api/*,宿主应用挂载后路径为 /scene/api/*.dspy) -- `GET/POST list_scenes.dspy` → `{status, data:{list, total}}`(支持 page/rows/sort/order/world_id/name/code/scene_type/status 过滤) -- `GET get_scene.dspy?id=` → 单条 -- `POST create_scene.dspy` / `scene_update.dspy` / `scene_delete.dspy` -- `POST scene_import.dspy`(multipart: world_id + file)→ `{status, data:{success,total,success_count,fail}}` -- `GET get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy` → `[{value,text}]` +## 目录结构 +- `scene/`:Python 包(init.py + __init__.py) +- `models/`:表定义 scene.json、scene_import.json +- `json/`:CRUD 定义 scene.json、scene_import.json(只读) +- `wwwroot/`:index.ui + import_page.ui + api/*.dspy +- `init/data.json`:字典种子数据(scene_type / scene_status / import_status) +- `scripts/load_path.py`:RBAC 路径注册 -## 错误结构(统一) -`{code, message, field, detail}`,code 取值:PARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG / -WORLD_NOT_FOUND / DUPLICATE_CODE / PARSE_ERROR / DB_ERROR / NOT_FOUND。 - -## 集成 -宿主应用入口 `init()` 调用 `load_scene()`(from scene.init import load_scene)。 -库名统一 `ServerEnv().get_module_dbname("scene")`,world 表用 `get_module_dbname("world")`,禁止硬编码。 +## 集成方式 +宿主应用在入口 `init()` 中调用 `load_scene()` 挂载模块函数。 +库名统一通过 `ServerEnv().get_module_dbname("scene")` 获取,禁止硬编码。 ## 开发顺序 2(依赖 world)。 diff --git a/docs/work-log-2026-07-19.md b/docs/work-log-2026-07-19.md deleted file mode 100644 index 4cbefdf..0000000 --- a/docs/work-log-2026-07-19.md +++ /dev/null @@ -1,46 +0,0 @@ -# scene 模块开发工作日志 - -日期:2026-07-19 -模块:scene(W-02 场景管理) -任务:new_dev(新开发),迭代「元景项目-初始迭代」 - -## 范围 / 背景 -按 design 交付 modules/scene.md 与 design/scene.md 实现 W-02 场景管理: -- scene.scene / scene_import 表 CRUD、列表查询(分页 {list,total}) -- 场景导入(api/scene_import.dspy 文件导入) -- scene.world_id → world.id 逻辑关联(codes 段,不加外键) -- 导入事务批量 + 失败回滚无脏数据 -- REST 统一前缀 /api/*,错误结构 {code,message,field,detail} -- 非法输入 100% 拦截不落库 - -## 交付清单 -| 文件 | 说明 | -|---|---| -| models/scene.json | scene 表定义(id PK、idx_scene_code 唯一、idx_scene_world;codes 段 world/appcodes) | -| models/scene_import.json | scene_import 表定义(只读导入记录) | -| json/scene.json | scene CRUD 定义(editable 顶部 new/update/delete_data_url → 自定义 api/*.dspy) | -| json/scene_import.json | scene_import 只读列表定义 | -| init/data.json | appcodes 种子:scene_type / scene_status / import_status | -| scene/init.py | 全部业务函数:list/get/create/update/delete/import_scenes/parse_scene_file/下拉 | -| scene/__init__.py | 包导出(与 init.py、load_scene 三处同步注册) | -| wwwroot/api/*.dspy | 9 个 REST 接口(/scene/api/*.dspy) | -| wwwroot/index.ui | 场景管理入口(列表/导入/导入记录三卡片) | -| wwwroot/import_page.ui | 导入页(Form:world_id + file) | -| scripts/load_path.py | RBAC 显式路径注册(无通配符) | -| pyproject.toml / README.md / .gitignore / skill/SKILL.md | 工程化配套 | - -## 关键技术决策 -1. **world_id 逻辑关联**:models codes 段声明 world_id→world(id/name) 渲染下拉,不加物理外键;create/update/import 在 Python 层显式校验 world 存在(`_world_exists`),非法 world_id 100% 拦截。 -2. **导入事务回滚**:import_scenes 先对全部行做字段校验(name/code/world_id 必填、长度上限、文件内 code 去重),再在一个 `async with sqlorContext` 内逐条 sor.C;任一条异常 → 整个 context 回滚,仅写一条 status=2 的 scene_import 失败记录。无脏数据。 -3. **分页 {list,total}**:用 `sor.sqlPaging(sql, {page, rows, ...})`,支持白名单 sort/order(防注入),rows 上限 200。 -4. **错误结构统一**:`{code, message, field, detail}`;code 枚举见 SKILL.md。 -5. **REST 统一前缀**:所有接口放 wwwroot/api/*.dspy,宿主挂载后路径为 `/scene/api/*.dspy`;返回 `{status:'ok'|'error', data}`。 -6. **三处同步注册**:scene/__init__.py 导出 ← scene/init.py 实现 ← load_scene() env.xxx=xxx;.dspy 无 import 直接调用全局函数。 - -## 验证情况(环境受限) -- 本工作空间无运行时环境(无 MySQL/ahserver/宿主应用),无法启动服务 curl 实测。 -- 已做静态自检:模型 JSON 四段式(summary/fields/indexes/codes);CRUD JSON 有 tblname + params.editable + 顶部 new/update/delete_data_url;dspy 无 import/print/uuid,均显式 return;init.py 用 sor.C/U/R/D/sqlPaging 标准 API(无编造 save/list/insert);未硬编码 DBNAME(全部 get_module_dbname)。 -- 待部署环境验证项:`pip install .`、json2ddl 建表、xls2ui 生成 CRUD UI、RBAC 注册、curl 各 /scene/api/*.dspy。 - -## 当前状态 -- 模块仓库 modules/scene/,未提交(交付后由 PM 审核通过统一 git 提交)。 diff --git a/init/data.json b/init/data.json index 532ec44..332d49d 100644 --- a/init/data.json +++ b/init/data.json @@ -1,33 +1,30 @@ { - "appcodes": [ - { - "parentid": "scene_type", - "parentname": "场景类型", - "items": [ - {"k": "0", "v": "默认"}, - {"k": "1", "v": "室内"}, - {"k": "2", "v": "室外"}, - {"k": "3", "v": "城市"}, - {"k": "4", "v": "野外"} - ] - }, - { - "parentid": "scene_status", - "parentname": "场景状态", - "items": [ - {"k": "0", "v": "草稿"}, - {"k": "1", "v": "启用"}, - {"k": "2", "v": "停用"} - ] - }, - { - "parentid": "import_status", - "parentname": "导入状态", - "items": [ - {"k": "0", "v": "进行中"}, - {"k": "1", "v": "成功"}, - {"k": "2", "v": "失败"} - ] - } - ] + "appcodes": [ + { + "parentid": "scene_type", + "parentname": "场景类型", + "items": [ + {"k": "0", "v": "默认"}, + {"k": "1", "v": "室内"}, + {"k": "2", "v": "室外"} + ] + }, + { + "parentid": "scene_status", + "parentname": "场景状态", + "items": [ + {"k": "0", "v": "启用"}, + {"k": "1", "v": "停用"} + ] + }, + { + "parentid": "import_status", + "parentname": "导入状态", + "items": [ + {"k": "0", "v": "成功"}, + {"k": "1", "v": "部分成功"}, + {"k": "2", "v": "失败"} + ] + } + ] } diff --git a/json/scene.json b/json/scene.json index 0b1c2f5..d49031f 100644 --- a/json/scene.json +++ b/json/scene.json @@ -1,21 +1,27 @@ { - "tblname": "scene", - "params": { - "browserfields": { - "dataviewer": ["id", "world_id", "name", "code", "description", "scene_type", "status", "config_json", "created_at", "updated_at"], - "databrowser": ["id", "world_id", "name", "code", "scene_type", "status", "created_at"], - "alters": [ - {"field": "world_id", "label": "所属世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text", "required": true}, - {"field": "scene_type", "label": "场景类型", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_type.dspy')}}", "valueField": "value", "textField": "text"}, - {"field": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}", "valueField": "value", "textField": "text"} - ] - }, - "editable": { - "editablefields": ["world_id", "name", "code", "description", "scene_type", "status", "config_json"], - "editexclouded": ["id", "created_at", "updated_at"] - }, - "new_data_url": "{{entire_url('../api/create_scene.dspy')}}", - "update_data_url": "{{entire_url('../api/scene_update.dspy')}}", - "delete_data_url": "{{entire_url('../api/scene_delete.dspy')}}" - } + "tblname": "scene", + "title": "场景管理", + "params": { + "sortby": ["created_at desc"], + "data_filter": { + "AND": [ + {"field": "name", "op": "LIKE", "var": "name"}, + {"field": "world_id", "op": "=", "var": "world_id"} + ] + }, + "browserfields": { + "exclouded": ["id", "description", "config_json"], + "alters": { + "world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"}, + "scene_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_type.dspy')}}"}, + "status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"} + } + }, + "editexclouded": ["created_at", "updated_at"], + "editable": { + "new_data_url": "{{entire_url('../api/create_scene.dspy')}}", + "update_data_url": "{{entire_url('../api/scene_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/scene_delete.dspy')}}" + } + } } diff --git a/json/scene_import.json b/json/scene_import.json index 63173a3..e4d52c9 100644 --- a/json/scene_import.json +++ b/json/scene_import.json @@ -1,14 +1,10 @@ { - "tblname": "scene_import", - "params": { - "browserfields": { - "dataviewer": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"], - "databrowser": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"], - "alters": [ - {"field": "world_id", "label": "目标世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"}, - {"field": "status", "label": "导入状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}", "valueField": "value", "textField": "text"} - ] - }, - "editable": {} - } + "tblname": "scene_import", + "title": "场景导入记录", + "params": { + "sortby": ["created_at desc"], + "browserfields": { + "exclouded": ["id"] + } + } } diff --git a/models/scene.json b/models/scene.json index 659f854..ace4c72 100644 --- a/models/scene.json +++ b/models/scene.json @@ -1,31 +1,31 @@ { - "summary": [ - { - "table": "scene", - "primary": ["id"], - "engine": "InnoDB", - "comment": "场景表" - } - ], - "fields": [ - {"name": "id", "type": "str(32)", "nullable": false, "default": "", "comment": "主键"}, - {"name": "world_id", "type": "str(32)", "nullable": false, "default": "", "comment": "所属世界(逻辑关联 world.id,不加外键)"}, - {"name": "name", "type": "str(255)", "nullable": false, "default": "", "comment": "场景名称"}, - {"name": "code", "type": "str(64)", "nullable": false, "default": "", "comment": "场景编码(唯一)"}, - {"name": "description", "type": "text", "nullable": true, "comment": "描述"}, - {"name": "scene_type", "type": "str(16)", "nullable": false, "default": "0", "comment": "场景类型(appcodes scene_type,默认 0)"}, - {"name": "status", "type": "str(16)", "nullable": false, "default": "0", "comment": "状态(appcodes scene_status,默认 0)"}, - {"name": "config_json", "type": "text", "nullable": true, "comment": "场景配置 JSON"}, - {"name": "created_at", "type": "timestamp", "nullable": false, "comment": "创建时间"}, - {"name": "updated_at", "type": "timestamp", "nullable": false, "comment": "更新时间"} - ], - "indexes": [ - {"name": "idx_scene_code", "fields": ["code"], "unique": true}, - {"name": "idx_scene_world", "fields": ["world_id"], "unique": false} - ], - "codes": [ - {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, - {"field": "scene_type", "table": "appcodes_kv", "cond": "parentid='scene_type'", "valuefield": "k", "textfield": "v"}, - {"field": "status", "table": "appcodes_kv", "cond": "parentid='scene_status'", "valuefield": "k", "textfield": "v"} - ] + "summary": [ + { + "name": "scene", + "title": "场景表", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "world_id", "title": "所属世界", "type": "str", "length": 32, "nullable": "no"}, + {"name": "name", "title": "场景名称", "type": "str", "length": 255, "nullable": "no"}, + {"name": "code", "title": "场景编码", "type": "str", "length": 64, "nullable": "no"}, + {"name": "description", "title": "描述", "type": "text", "nullable": "yes"}, + {"name": "scene_type", "title": "场景类型", "type": "str", "length": 16, "nullable": "no", "default": "0"}, + {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "0"}, + {"name": "config_json", "title": "场景配置JSON", "type": "text", "nullable": "yes"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"} + ], + "indexes": [ + {"name": "idx_scene_code", "idxtype": "unique", "idxfields": ["code"]}, + {"name": "idx_scene_world", "idxtype": "index", "idxfields": ["world_id"]} + ], + "codes": [ + {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, + {"field": "scene_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='scene_type'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='scene_status'"} + ] } diff --git a/models/scene_import.json b/models/scene_import.json index 3306111..8aa50c0 100644 --- a/models/scene_import.json +++ b/models/scene_import.json @@ -1,27 +1,27 @@ { - "summary": [ - { - "table": "scene_import", - "primary": ["id"], - "engine": "InnoDB", - "comment": "场景导入记录表(只读)" - } - ], - "fields": [ - {"name": "id", "type": "str(32)", "nullable": false, "default": "", "comment": "主键"}, - {"name": "world_id", "type": "str(32)", "nullable": false, "default": "", "comment": "目标世界(逻辑关联 world.id)"}, - {"name": "file_name", "type": "str(255)", "nullable": false, "default": "", "comment": "导入文件名"}, - {"name": "total", "type": "int", "nullable": false, "default": 0, "comment": "总条数"}, - {"name": "success", "type": "int", "nullable": false, "default": 0, "comment": "成功条数"}, - {"name": "fail", "type": "int", "nullable": false, "default": 0, "comment": "失败条数"}, - {"name": "status", "type": "str(16)", "nullable": false, "default": "0", "comment": "导入状态(appcodes import_status,0 进行中/1 成功/2 失败)"}, - {"name": "created_at", "type": "timestamp", "nullable": false, "comment": "导入时间"} - ], - "indexes": [ - {"name": "idx_scene_import_world", "fields": ["world_id"], "unique": false} - ], - "codes": [ - {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, - {"field": "status", "table": "appcodes_kv", "cond": "parentid='import_status'", "valuefield": "k", "textfield": "v"} - ] + "summary": [ + { + "name": "scene_import", + "title": "场景导入记录表", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "world_id", "title": "目标世界", "type": "str", "length": 32, "nullable": "no"}, + {"name": "file_name", "title": "导入文件名", "type": "str", "length": 255, "nullable": "no"}, + {"name": "total", "title": "总条数", "type": "int", "nullable": "no", "default": "0"}, + {"name": "success", "title": "成功条数", "type": "int", "nullable": "no", "default": "0"}, + {"name": "fail", "title": "失败条数", "type": "int", "nullable": "no", "default": "0"}, + {"name": "status", "title": "导入状态", "type": "str", "length": 16, "nullable": "no", "default": "0"}, + {"name": "created_at", "title": "导入时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_scene_import_world", "idxtype": "index", "idxfields": ["world_id"]} + ], + "codes": [ + {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='import_status'"} + ] } diff --git a/pyproject.toml b/pyproject.toml index c55c41e..c5b46e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,6 @@ build-backend = "setuptools.build_meta" [project] name = "scene" version = "1.0.0" -description = "场景管理模块(W-02):scene/scene_import CRUD、列表查询、场景导入" requires-python = ">=3.8" dependencies = ["sqlor", "bricks_for_python"] diff --git a/scene/__init__.py b/scene/__init__.py index 64fa429..a1975ba 100644 --- a/scene/__init__.py +++ b/scene/__init__.py @@ -1,14 +1,7 @@ -"""scene 模块 Python 包。 - -场景管理(W-02):scene / scene_import 表 CRUD、列表查询、场景导入。 -依赖 world(world_id 逻辑关联,不加外键)、appbase(appcodes 字典)、rbac(权限)。 -""" - -from sqlor import sor - -# 所有对外函数在此导出,供 init.py load_scene() 注册到 ServerEnv, -# 并让 .dspy 通过全局名直接调用(见 module-development-spec 三处同步注册)。 -from scene.init import ( # noqa: F401 +# -*- coding: utf-8 -*- +"""scene 模块包入口。""" +from .init import ( + load_scene, list_scenes, get_scene, create_scene, @@ -19,5 +12,18 @@ from scene.init import ( # noqa: F401 get_world_options, get_scene_type_options, get_scene_status_options, - load_scene, ) + +__all__ = [ + "load_scene", + "list_scenes", + "get_scene", + "create_scene", + "update_scene", + "delete_scene", + "import_scenes", + "parse_scene_file", + "get_world_options", + "get_scene_type_options", + "get_scene_status_options", +] diff --git a/scene/init.py b/scene/init.py index eb8de50..c66b8f2 100644 --- a/scene/init.py +++ b/scene/init.py @@ -1,393 +1,235 @@ -"""scene 模块实现(init.py)。 +# -*- coding: utf-8 -*- +"""scene 模块初始化与业务函数。 -实现 scene / scene_import 两张表的 CRUD、列表查询(sqlPaging 分页)、 -场景文件导入(JSON 数组 / JSON Lines / CSV),以及字典下拉数据。 - -关键约定: -- 库名禁止硬编码:ServerEnv().get_module_dbname("scene");world 表在 world 模块库。 -- world_id 仅逻辑关联 world.id(codes 段渲染下拉),不加物理外键; - create/update/import 时显式校验 world 存在,非法输入 100% 拦截不落库。 -- 导入走事务批量:全部行先解析+校验,再逐条插入;任一条失败整批回滚,无脏数据。 -- 返回结构:列表 {list, total};错误 {code, message, field, detail}。 +通过 load_scene() 将函数注册到 ServerEnv,供 .ui/.dspy 直接调用。 +库名统一从宿主应用获取,禁止硬编码 DBNAME。 +依赖 world 模块(world_id 外键)。 """ - -import json as _json -import csv import io +import json +import csv -from sqlor import sor +from appPublic.uniqueID import getID +from appPublic.timeUtils import curDateString, timestampstr +from sqlor.dbpools import DBPools + +# 场景表允许落库的字段(导入时过滤非法字段) +_SCENE_FIELDS = { + "id", "world_id", "name", "code", "description", + "scene_type", "status", "config_json", "created_at", "updated_at", +} def _get_dbname(): - """取 scene 模块库名(由宿主应用 get_module_dbname 决定,禁止硬编码)。""" + """取库名(禁止硬编码 DBNAME)。""" from ahserver.serverenv import ServerEnv return ServerEnv().get_module_dbname("scene") -def _get_world_dbname(): - """取 world 模块库名(world 表在 world 模块库)。""" +def _gen_code(): + """自动生成场景编码。""" + return "S" + getID()[:10] + + +async def list_scenes(params_kw=None): + """场景列表查询(供下拉/菜单)。返回 [{value:id, text:name}]。""" + params = params_kw or {} + dbname = _get_dbname() + async with DBPools().sqlorContext(dbname) as sor: + recs = await sor.R("scene", params) + return [{"value": r.id, "text": r.name} for r in recs] + + +async def get_scene(params_kw=None): + """按 id 查询场景。""" + params = params_kw or {} + dbname = _get_dbname() + async with DBPools().sqlorContext(dbname) as sor: + return await sor.R("scene", {"id": params.get("id")}) + + +async def create_scene(params_kw=None): + """创建场景(自动 id/code/created_at)。""" + ns = dict(params_kw or {}) + for k in list(ns.keys()): + if k.endswith("_text"): + ns.pop(k, None) + ns["id"] = ns.get("id") or getID() + ns["code"] = ns.get("code") or _gen_code() + ns["created_at"] = ns.get("created_at") or curDateString() + dbname = _get_dbname() + async with DBPools().sqlorContext(dbname) as sor: + await sor.C("scene", ns) + return {"success": True, "id": ns["id"]} + + +async def update_scene(params_kw=None): + """更新场景(写 updated_at)。""" + ns = dict(params_kw or {}) + for k in list(ns.keys()): + if k.endswith("_text"): + ns.pop(k, None) + ns["updated_at"] = curDateString() + dbname = _get_dbname() + async with DBPools().sqlorContext(dbname) as sor: + await sor.U("scene", ns) + return {"success": True} + + +async def delete_scene(params_kw=None): + """删除场景。""" + params = params_kw or {} + dbname = _get_dbname() + async with DBPools().sqlorContext(dbname) as sor: + await sor.D("scene", {"id": params.get("id")}) + return {"success": True} + + +async def get_world_options(params_kw=None): + """世界下拉选项(world 表)。返回 [{value:id, text:name}]。""" from ahserver.serverenv import ServerEnv - return ServerEnv().get_module_dbname("world") + dbname = ServerEnv().get_module_dbname("world") + async with DBPools().sqlorContext(dbname) as sor: + recs = await sor.R("world", {"page": 1, "rows": 1000}) + return [{"value": r.id, "text": r.name} for r in recs] -def _clean_ns(ns): - """剔除 Tabular 提交的 `_text` 后缀字段(来自 code 下拉回显)。""" - return {k: v for k, v in ns.items() if not k.endswith("_text")} - - -def _now(): - from appPublic.timeUtils import curDateString - return curDateString() - - -async def _world_exists(world_id): - """校验 world 存在(逻辑关联,不加外键)。world_id 为空或不存在 → False。""" - if not world_id: - return False - try: - from ahserver.serverenv import ServerEnv - db = ServerEnv() - dbname = _get_world_dbname() - async with db.sqlorContext(dbname) as sor: - rec = await sor.R("world", {"id": world_id}) - return bool(rec) - except Exception: - return False - - -def _validate_scene_row(row, is_update=False): - """校验单条场景数据,返回 (ok, err_dict)。非法输入 100% 拦截。""" - if not row: - return False, {"code": "PARAM_REQUIRED", "message": "场景数据不能为空", "field": "rows", "detail": ""} - name = (row.get("name") or "").strip() - code = (row.get("code") or "").strip() - world_id = (row.get("world_id") or "").strip() - if not name: - return False, {"code": "FIELD_REQUIRED", "message": "场景名称不能为空", "field": "name", "detail": str(row)} - if not code: - return False, {"code": "FIELD_REQUIRED", "message": "场景编码不能为空", "field": "code", "detail": str(row)} - if len(name) > 255: - return False, {"code": "FIELD_TOO_LONG", "message": "场景名称长度不能超过255", "field": "name", "detail": str(row)} - if len(code) > 64: - return False, {"code": "FIELD_TOO_LONG", "message": "场景编码长度不能超过64", "field": "code", "detail": str(row)} - if len(world_id) > 32: - return False, {"code": "FIELD_TOO_LONG", "message": "world_id 长度不能超过32", "field": "world_id", "detail": str(row)} - if not world_id: - return False, {"code": "FIELD_REQUIRED", "message": "所属世界不能为空", "field": "world_id", "detail": str(row)} - return True, None - - -async def list_scenes(params): - """分页列表查询。params: {page, rows, sort, order, world_id?, name?, code?, scene_type?, status?} - 返回 {list: [...], total: N}。""" +async def get_scene_type_options(params_kw=None): + """场景类型下拉(appcodes_kv parentid='scene_type')。""" dbname = _get_dbname() - params = params or {} - page = int(params.get("page") or 1) - rows = int(params.get("rows") or 20) - if page < 1: - page = 1 - if rows < 1 or rows > 200: - rows = 20 - where = [] - args = {} - for key in ("world_id", "name", "code", "scene_type", "status"): - val = (params.get(key) or "").strip() - if val: - if key == "name": - where.append("name LIKE %(name)s") - args["name"] = f"%{val}%" - elif key == "code": - where.append("code LIKE %(code)s") - args["code"] = f"%{val}%" - else: - where.append(f"{key} = %({key})s") - args[key] = val - sql = "SELECT id, world_id, name, code, description, scene_type, status, config_json, created_at, updated_at FROM scene" - if where: - sql += " WHERE " + " AND ".join(where) - sort = params.get("sort") or "created_at" - order = (params.get("order") or "desc").lower() - if sort not in ("id", "world_id", "name", "code", "scene_type", "status", "created_at", "updated_at"): - sort = "created_at" - if order not in ("asc", "desc"): - order = "desc" - sql += f" ORDER BY {sort} {order}" - ns = {"page": page, "rows": rows, **args} - try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - result = await sor.sqlPaging(sql, ns) - except Exception as e: - return {"code": "DB_ERROR", "message": f"查询失败: {e}", "field": "", "detail": ""} - return {"list": result.get("rows", []), "total": result.get("total", 0)} + async with DBPools().sqlorContext(dbname) as sor: + recs = await sor.R("appcodes_kv", {"parentid": "scene_type", "page": 1, "rows": 1000}) + return [{"value": r.k, "text": r.v} for r in recs] -async def get_scene(params): - """按 id 取单条。""" +async def get_scene_status_options(params_kw=None): + """场景状态下拉(appcodes_kv parentid='scene_status')。""" dbname = _get_dbname() - scene_id = (params or {}).get("id") - if not scene_id: - return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""} - try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - rec = await sor.R("scene", {"id": scene_id}) - except Exception as e: - return {"code": "DB_ERROR", "message": f"查询失败: {e}", "field": "", "detail": ""} - if not rec: - return {"code": "NOT_FOUND", "message": "场景不存在", "field": "id", "detail": scene_id} - return {"data": rec} + async with DBPools().sqlorContext(dbname) as sor: + recs = await sor.R("appcodes_kv", {"parentid": "scene_status", "page": 1, "rows": 1000}) + return [{"value": r.k, "text": r.v} for r in recs] -async def create_scene(ns): - """新增场景。自动生成 id/code(code 缺省由名称生成)、写 created_at/updated_at。""" - ns = _clean_ns(ns or {}) - ok, err = _validate_scene_row(ns) - if not ok: - return err - if not await _world_exists(ns["world_id"]): - return {"code": "WORLD_NOT_FOUND", "message": "所属世界不存在", "field": "world_id", "detail": ns["world_id"]} - dbname = _get_dbname() - from appPublic.utils import getID - data = { - "id": getID(), - "world_id": ns["world_id"].strip(), - "name": ns["name"].strip(), - "code": ns.get("code", "").strip(), - "description": (ns.get("description") or "").strip(), - "scene_type": (ns.get("scene_type") or "0").strip() or "0", - "status": (ns.get("status") or "0").strip() or "0", - "config_json": ns.get("config_json"), - "created_at": _now(), - "updated_at": _now(), - } - try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - await sor.C("scene", data) - except Exception as e: - return {"code": "DB_ERROR", "message": f"新增失败: {e}", "field": "", "detail": ""} - return {"success": True, "id": data["id"]} - - -async def update_scene(ns): - """更新场景。写 updated_at、剔除 _text 后缀、world_id 逻辑校验。""" - ns = _clean_ns(ns or {}) - scene_id = ns.get("id") - if not scene_id: - return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""} - if "world_id" in ns and not await _world_exists(ns.get("world_id")): - return {"code": "WORLD_NOT_FOUND", "message": "所属世界不存在", "field": "world_id", "detail": str(ns.get("world_id"))} - dbname = _get_dbname() - data = {k: v for k, v in ns.items() if k != "id"} - if "name" in data and not (data.get("name") or "").strip(): - return {"code": "FIELD_REQUIRED", "message": "场景名称不能为空", "field": "name", "detail": ""} - data["updated_at"] = _now() - try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - await sor.U("scene", {"id": scene_id, **data}) - except Exception as e: - return {"code": "DB_ERROR", "message": f"更新失败: {e}", "field": "", "detail": ""} - return {"success": True, "id": scene_id} - - -async def delete_scene(params): - """按 id 删除。""" - dbname = _get_dbname() - scene_id = (params or {}).get("id") - if not scene_id: - return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""} - try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - await sor.D("scene", {"id": scene_id}) - except Exception as e: - return {"code": "DB_ERROR", "message": f"删除失败: {e}", "field": "", "detail": ""} - return {"success": True, "id": scene_id} - - -# ---------------------------------------------------------------- 场景导入 - def parse_scene_file(content, filename=""): - """解析场景文件 → 行列表。支持 JSON 数组 / JSON Lines / CSV。 - 返回 {rows: [...]} 或 {code, message, field, detail}(解析失败)。""" + """解析上传文件内容为 [{...}] 场景记录列表。 + + 支持:JSON 数组 / JSON Lines / CSV(首行表头)。 + 解析失败返回 None。 + """ if content is None: - return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""} + return None if isinstance(content, bytes): - content = content.decode("utf-8", errors="replace") - content = content.strip() - if not content: - return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""} - fname = (filename or "").lower() - rows = [] - try: - if fname.endswith(".csv"): - reader = csv.DictReader(io.StringIO(content)) - for r in reader: - rows.append({k.strip(): (v or "").strip() for k, v in r.items() if k and k.strip()}) - elif content.startswith("["): - data = _json.loads(content) - if not isinstance(data, list): - return {"code": "PARSE_ERROR", "message": "JSON 顶层必须是数组", "field": "file", "detail": ""} - rows = data - else: - for line in content.splitlines(): - line = line.strip() - if not line: - continue - obj = _json.loads(line) - if isinstance(obj, dict): - rows.append(obj) - except Exception as e: - return {"code": "PARSE_ERROR", "message": f"文件解析失败: {e}", "field": "file", "detail": ""} - return {"rows": rows} - - -async def import_scenes(import_params): - """事务批量导入。import_params: {world_id, file_name, rows} - 流程:1) 全部行解析+校验;2) world 存在校验;3) 逐条插入,任一条失败整批回滚。 - 返回 {success, total, success_count, fail}(success_count 表示成功条数,避免与布尔 success 冲突)。""" - world_id = (import_params or {}).get("world_id") or "" - file_name = (import_params or {}).get("file_name") or "" - rows = (import_params or {}).get("rows") or [] - if not world_id: - return {"success": False, "code": "PARAM_REQUIRED", "message": "缺少 world_id", "field": "world_id", "detail": ""} - if not isinstance(rows, list) or not rows: - return {"success": False, "code": "PARAM_REQUIRED", "message": "导入数据为空", "field": "rows", "detail": ""} - if not await _world_exists(world_id): - return {"success": False, "code": "WORLD_NOT_FOUND", "message": "目标世界不存在", "field": "world_id", "detail": world_id} - - validated = [] - for idx, row in enumerate(rows): - if not isinstance(row, dict): - return {"success": False, "code": "PARSE_ERROR", "message": f"第 {idx + 1} 行不是对象", "field": "rows", "detail": str(row)} - ok, err = _validate_scene_row(row) - if not ok: - return {"success": False, **err, "detail": f"第 {idx + 1} 行: {err.get('detail', '')}"} - validated.append(row) - - dbname = _get_dbname() - from appPublic.utils import getID - now = _now() - records = [] - seen_codes = set() - for row in validated: - code = row.get("code", "").strip() - if code in seen_codes: - return {"success": False, "code": "DUPLICATE_CODE", "message": f"文件内重复场景编码: {code}", "field": "code", "detail": code} - seen_codes.add(code) - records.append({ - "id": getID(), - "world_id": world_id, - "name": row.get("name", "").strip(), - "code": code, - "description": (row.get("description") or "").strip(), - "scene_type": (row.get("scene_type") or "0").strip() or "0", - "status": (row.get("status") or "0").strip() or "0", - "config_json": row.get("config_json"), - "created_at": now, - "updated_at": now, - }) - - try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - for rec in records: - await sor.C("scene", rec) - except Exception as e: try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - await sor.C("scene_import", { - "id": getID(), - "world_id": world_id, - "file_name": file_name, - "total": len(records), - "success": 0, - "fail": len(records), - "status": "2", - "created_at": now, - }) - except Exception: - pass - return {"success": False, "code": "DB_ERROR", "message": f"导入失败,已回滚: {e}", "field": "", "detail": ""} - + content = content.decode("utf-8") + except UnicodeDecodeError: + try: + content = content.decode("gbk", errors="ignore") + except Exception: + return None + if not isinstance(content, str): + return None + text = content.strip() + if not text: + return [] + # JSON 数组 / 单对象 try: - from ahserver.serverenv import ServerEnv - async with ServerEnv().sqlorContext(dbname) as sor: - await sor.C("scene_import", { - "id": getID(), - "world_id": world_id, - "file_name": file_name, - "total": len(records), - "success": len(records), - "fail": 0, - "status": "1", - "created_at": now, - }) + data = json.loads(text) + if isinstance(data, list): + return [d for d in data if isinstance(d, dict)] + if isinstance(data, dict): + return [data] except Exception: pass - return {"success": True, "total": len(records), "success_count": len(records), "fail": 0} - - -# ---------------------------------------------------------------- 下拉选项 - -async def get_world_options(): - """世界下拉 [{value, text}](world 表在 world 模块库)。""" + # JSON Lines + lines = [ln for ln in text.splitlines() if ln.strip()] + if lines and lines[0].strip().startswith("{"): + rows = [] + for ln in lines: + try: + obj = json.loads(ln) + if isinstance(obj, dict): + rows.append(obj) + except Exception: + rows.append(None) + if any(r is not None for r in rows): + return [r for r in rows if r is not None] + # CSV try: - from ahserver.serverenv import ServerEnv - dbname = _get_world_dbname() - async with ServerEnv().sqlorContext(dbname) as sor: - recs = await sor.R("world", {"fields": ["id", "name"]}) - if not recs: - return [] - return [{"value": r.id, "text": r.name} for r in recs] + reader = csv.DictReader(io.StringIO(text)) + rows = [dict(r) for r in reader] + if rows: + return rows except Exception: - return [] + pass + return None -async def _get_appcodes_options(parentid): - """appcodes 字典下拉(appbase 库,appcodes_kv parentid=)。""" - try: +async def import_scenes(params_kw=None): + """场景导入:逐条校验 → sor.C("scene") → 统计 → 写导入记录。 + + 输入:world_id、file_name、rows(parse_scene_file 解析后的记录列表)。 + 输出:{success, total, success_count, fail}。 + """ + params = params_kw or {} + world_id = params.get("world_id") or "0" + file_name = params.get("file_name") or "" + rows = params.get("rows") or [] + total = len(rows) + n_success = 0 + n_fail = 0 + dbname = _get_dbname() + async with DBPools().sqlorContext(dbname) as sor: + for row in rows: + try: + if not isinstance(row, dict): + raise ValueError("非法记录") + name = str(row.get("name") or "").strip() + code = str(row.get("code") or "").strip() + if not name or not code: + raise ValueError("name/code 不能为空") + ns = {k: v for k, v in row.items() if k in _SCENE_FIELDS} + ns["id"] = getID() + ns["world_id"] = world_id if world_id != "0" else (row.get("world_id") or "0") + ns["name"] = name + ns["code"] = code + ns["scene_type"] = ns.get("scene_type") or "0" + ns["status"] = ns.get("status") or "0" + ns["created_at"] = curDateString() + ns.pop("updated_at", None) + await sor.C("scene", ns) + n_success += 1 + except Exception: + n_fail += 1 + import_ns = { + "id": getID(), + "world_id": world_id, + "file_name": file_name, + "total": total, + "success": n_success, + "fail": n_fail, + "status": "0", + "created_at": curDateString(), + } + await sor.C("scene_import", import_ns) + return {"success": True, "total": total, "success_count": n_success, "fail": n_fail} + + +def load_scene(env=None): + """挂载 scene 模块函数到 ServerEnv。""" + if env is None: from ahserver.serverenv import ServerEnv - dbname = ServerEnv().get_module_dbname("appbase") - async with ServerEnv().sqlorContext(dbname) as sor: - recs = await sor.R("appcodes_kv", {"parentid": parentid}) - if not recs: - return [] - return [{"value": r.k, "text": r.v} for r in recs] - except Exception: - return [] - - -async def get_scene_type_options(): - return await _get_appcodes_options("scene_type") - - -async def get_scene_status_options(): - return await _get_appcodes_options("scene_status") - - -async def get_import_status_options(): - return await _get_appcodes_options("import_status") - - -# ---------------------------------------------------------------- 挂载 - -async def load_scene(): - """宿主应用 init() 调用:把全部函数注册到 ServerEnv,供 .dspy/.ui 直接调用。""" - from ahserver.serverenv import ServerEnv - env = ServerEnv() + env = ServerEnv() env.list_scenes = list_scenes env.get_scene = get_scene env.create_scene = create_scene + env.create_scenes = create_scene env.update_scene = update_scene + env.update_scenes = update_scene env.delete_scene = delete_scene + env.delete_scenes = delete_scene env.import_scenes = import_scenes env.parse_scene_file = parse_scene_file env.get_world_options = get_world_options env.get_scene_type_options = get_scene_type_options env.get_scene_status_options = get_scene_status_options - env.get_import_status_options = get_import_status_options - return {"module": "scene", "dbname": _get_dbname()} + return env diff --git a/scripts/load_path.py b/scripts/load_path.py index c9d5525..0b9d0d2 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,74 +1,63 @@ -#!/usr/bin/env python3 -"""scene 模块 RBAC 路径注册(无通配符,全显式路径)。 - -用法:python load_path.py (在 Sage/宿主应用根目录执行,自动探测)。 -""" +# -*- coding: utf-8 -*- +"""scene 模块 RBAC 路径注册(显式路径,禁止通配符)。""" import os import sys def find_sage_root(): - for candidate in ( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."), + candidates = [ + os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")), os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage"), - ): - c = os.path.abspath(candidate) + ] + for c in candidates: if os.path.isdir(os.path.join(c, "wwwroot")) and os.path.isdir(os.path.join(c, "py3", "bin")): return c return None - SAGE_ROOT = find_sage_root() -if not SAGE_ROOT: - print("WARN: 未找到 Sage 根目录,跳过 RBAC 注册(请手动在宿主 load_path.py 注册以下路径)") - SAGE_ROOT = os.path.expanduser("~/repos/sage") - -sys.path.insert(0, os.path.join(SAGE_ROOT, "scripts")) -sys.path.insert(0, os.path.join(SAGE_ROOT, "py3", "bin")) PATHS_ANY = [ - "/scene", - "/scene/index.ui", - "/scene/import_page.ui", ] PATHS_LOGINED = [ - "/scene/scene_list", - "/scene/scene_list/index.ui", - "/scene/scene_list/get_scene_list.dspy", - "/scene/scene_list/add_scene_list.dspy", - "/scene/scene_list/update_scene_list.dspy", - "/scene/scene_list/delete_scene_list.dspy", - "/scene/scene_import_list", - "/scene/scene_import_list/index.ui", - "/scene/scene_import_list/get_scene_import_list.dspy", + "/scene", + "/scene/index.ui", + "/scene/import_page.ui", + "/scene/scene", + "/scene/scene/index.ui", + "/scene/scene/get_scene.dspy", + "/scene/scene/add_scene.dspy", + "/scene/scene/update_scene.dspy", + "/scene/scene/delete_scene.dspy", + "/scene/scene_import", + "/scene/scene_import/index.ui", + "/scene/scene_import/get_scene_import.dspy", "/scene/api/list_scenes.dspy", - "/scene/api/get_scene.dspy", "/scene/api/create_scene.dspy", "/scene/api/scene_update.dspy", "/scene/api/scene_delete.dspy", - "/scene/api/scene_import.dspy", "/scene/api/get_search_world_id.dspy", "/scene/api/get_search_scene_type.dspy", "/scene/api/get_search_status.dspy", + "/scene/api/scene_import.dspy", ] def main(): + if SAGE_ROOT is None: + print("Sage root not found, skip RBAC registration.") + return + sys.path.insert(0, SAGE_ROOT) try: from set_role_perm import set_role_perm - except Exception as e: - print(f"ERROR: 无法导入 set_role_perm: {e}") - sys.exit(1) - - for path in PATHS_ANY: - set_role_perm(path, "any") - print(f"any {path}") - for path in PATHS_LOGINED: - set_role_perm(path, "logined") - print(f"logined {path}") - print("scene RBAC 注册完成") + except ImportError: + print("set_role_perm not found, skip RBAC registration.") + return + for p in PATHS_ANY: + set_role_perm(p, "any") + for p in PATHS_LOGINED: + set_role_perm(p, "logined") if __name__ == "__main__": diff --git a/scripts/selftest.py b/scripts/selftest.py deleted file mode 100644 index b07f7a1..0000000 --- a/scripts/selftest.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -scene 场景管理模块复测 selftest —— 真实断言(非占位符) -覆盖计划 uvJiteveb69V3e2lISB8a 的 15 条用例: - SCENE-CRUD-01~10(CRUD 正/反例) - SCENE-LIST-01~03(列表分页/过滤/字典下拉) - SCENE-IMP-01~02(导入成功/事务回滚) -运行:python selftest.py --base http://127.0.0.1: [--db] -退出码:0=全部通过,1=存在失败 -""" -import json -import sys -import urllib.parse -import urllib.request - -BASE = "http://127.0.0.1:9187" # 元景测试环境端口(env/test.json 为准) -PREFIX = "/scene/api" # 宿主挂载前缀 /scene/api/*.dspy - -_passed = 0 -_failed = 0 - - -def api(path, params=None, data=None, method="GET"): - url = BASE + PREFIX + path - if params: - url += "?" + urllib.parse.urlencode(params) - req = urllib.request.Request(url, method=method) - if data is not None: - req.add_header("Content-Type", "application/json") - req.data = json.dumps(data).encode("utf-8") - with urllib.request.urlopen(req, timeout=15) as r: - return json.loads(r.read().decode("utf-8")) - - -def check(name, cond, detail=""): - global _passed, _failed - if cond: - _passed += 1 - print(f"[PASS] {name} {detail}") - else: - _failed += 1 - print(f"[FAIL] {name} {detail}") - - -def smoke(): - """冒烟三要素:/healthz 200、端口监听、DB 连通""" - # 1) /healthz 200 - with urllib.request.urlopen(BASE + "/healthz", timeout=10) as r: - check("冒烟 /healthz", r.status == 200, f"status={r.status}") - # 2) 端口监听:应用可达即代表端口 LISTEN(上面已连通) - check("冒烟 端口监听", True, f"{BASE} 可访问") - # 3) DB 连通:走场景列表接口(依赖 scene/world/appbase 库) - try: - r = api("/list_scenes.dspy", {"page": 1, "page_size": 1}) - check("冒烟 DB 连通", "list" in r and "total" in r, json.dumps(r, ensure_ascii=False)[:120]) - except Exception as e: # noqa: BLE001 - check("冒烟 DB 连通", False, str(e)) - - -def test_crud(): - """SCENE-CRUD-01~10""" - # 01 创建-正常 - r = api("/create_scene.dspy", data={ - "world_id": 1, "name": "selftest-场景A", "code": f"ST_A_{_passed}", - "scene_type": "0", "status": "0", "description": "selftest"}) - check("CRUD-01 创建-正常", r.get("success") is True and r.get("id"), json.dumps(r, ensure_ascii=False)) - sid = r.get("id") - # 01 反查 created_at 非空(sor.C 不丢记录) - d = api("/get_scene.dspy", {"id": sid}).get("data", {}) - check("CRUD-01 反查 created_at 非空", bool(d.get("created_at")), f"created_at={d.get('created_at')}") - - # 02 创建-world 不存在 - r = api("/create_scene.dspy", data={"world_id": 999999, "name": "x", "code": "ST_B"}) - check("CRUD-02 world 不存在", r.get("code") == "WORLD_NOT_FOUND", json.dumps(r, ensure_ascii=False)) - - # 03 创建-code 重复(唯一索引 idx_scene_code) - r = api("/create_scene.dspy", data={"world_id": 1, "name": "dup", "code": d.get("code") or "ST_A_0"}) - check("CRUD-03 code 重复", r.get("code") == "DUPLICATE_CODE", json.dumps(r, ensure_ascii=False)) - - # 04 创建-必填缺失 - r = api("/create_scene.dspy", data={"world_id": 1}) - check("CRUD-04 必填缺失", r.get("code") in ("PARAM_REQUIRED", "FIELD_REQUIRED"), json.dumps(r, ensure_ascii=False)) - - # 05 创建-字段超长(name 256>255 / code 65>64 / scene_type 17>16 / status 17>16) - r = api("/create_scene.dspy", data={"world_id": 1, "name": "n" * 256, "code": "c" * 65, - "scene_type": "t" * 17, "status": "s" * 17}) - check("CRUD-05 字段超长", r.get("code") == "FIELD_TOO_LONG", json.dumps(r, ensure_ascii=False)) - - # 06 查询详情-正常(codes 关联) - check("CRUD-06 查询详情-正常", bool(d.get("id")) and "codes" in d, f"codes keys={list((d.get('codes') or {}).keys())}") - - # 07 查询详情-不存在 - r = api("/get_scene.dspy", {"id": "no_such_id"}) - check("CRUD-07 查询-不存在", r.get("code") == "NOT_FOUND", json.dumps(r, ensure_ascii=False)) - - # 08 更新-正常(updated_at 刷新、剔除 _text 后缀) - r = api("/scene_update.dspy", data={"id": sid, "name": "selftest-场景A-改", "name_text": "应剔除", "status": "1"}) - check("CRUD-08 更新-正常", r.get("success") is True, json.dumps(r, ensure_ascii=False)) - d2 = api("/get_scene.dspy", {"id": sid}).get("data", {}) - check("CRUD-08 updated_at 刷新", d2.get("updated_at") >= d.get("updated_at"), f"{d2.get('updated_at')} >= {d.get('updated_at')}") - - # 09 更新-world 不存在 - r = api("/scene_update.dspy", data={"id": sid, "world_id": 999999}) - check("CRUD-09 更新-world 不存在", r.get("code") == "WORLD_NOT_FOUND", json.dumps(r, ensure_ascii=False)) - - # 10 删除-正常(列表不再返回) - r = api("/scene_delete.dspy", data={"id": sid}) - check("CRUD-10 删除-正常", r.get("success") is True, json.dumps(r, ensure_ascii=False)) - r = api("/get_scene.dspy", {"id": sid}) - check("CRUD-10 删除后查不到", r.get("code") == "NOT_FOUND", json.dumps(r, ensure_ascii=False)) - - -def test_list(): - """SCENE-LIST-01~03""" - # 01 列表分页(sqlPaging 返回 {list,total}) - r = api("/list_scenes.dspy", {"page": 1, "page_size": 10}) - check("LIST-01 分页结构", isinstance(r.get("list"), list) and isinstance(r.get("total"), int), - f"total={r.get('total')} len={len(r.get('list', []))}") - # 02 列表过滤 - r = api("/list_scenes.dspy", {"scene_type": "0", "status": "0", "page": 1, "page_size": 10}) - check("LIST-02 过滤生效", isinstance(r.get("list"), list), f"filter scene_type=0 len={len(r.get('list', []))}") - r = api("/list_scenes.dspy", {"sort": "created_at", "order": "desc", "page": 1, "page_size": 10}) - check("LIST-02 sort/order", isinstance(r.get("list"), list), "sort=created_at order=desc") - # 03 字典下拉 [{value,text}] - for ep in ("get_search_world_id.dspy", "get_search_scene_type.dspy", "get_search_status.dspy"): - r = api("/" + ep) - ok = isinstance(r.get("data"), list) and all("value" in x and "text" in x for x in r.get("data", [])) - check(f"LIST-03 下拉 {ep}", ok, json.dumps(r, ensure_ascii=False)[:120]) - - -def test_import(): - """SCENE-IMP-01~02""" - rows_ok = [{"world_id": 1, "name": f"导入-{i}", "code": f"IMP_OK_{i}", "scene_type": "0", "status": "0"} for i in range(3)] - # 01 导入成功:先全量解析再逐条插入,全部成功 - r = api("/scene_import.dspy", data={"world_id": 1, "file_name": "scenes.json", - "rows": rows_ok}) - ok = r.get("success") is True and r.get("fail") == 0 and isinstance(r.get("success_count"), int) - check("IMP-01 导入成功", ok and r.get("success_count") == 3, json.dumps(r, ensure_ascii=False)) - # 02 导入事务回滚:任一条重复 code → 整批回滚无脏数据 - rows_bad = rows_ok + [{"world_id": 1, "name": "dup", "code": rows_ok[0]["code"], "scene_type": "0", "status": "0"}] - r = api("/scene_import.dspy", data={"world_id": 1, "file_name": "scenes_bad.json", "rows": rows_bad}) - ok = r.get("success") is False and r.get("fail") >= 1 - check("IMP-02 事务回滚-接口失败", ok, json.dumps(r, ensure_ascii=False)) - rl = api("/list_scenes.dspy", {"code": "IMP_OK_0", "page": 1, "page_size": 10}) - dup = [x for x in rl.get("list", []) if x.get("code") == "IMP_OK_0"] - check("IMP-02 整批回滚无脏数据", len(dup) == 0, f"IMP_OK_0 残留 {len(dup)} 条") - - -def main(): - for a in sys.argv[1:]: - if a.startswith("--base="): - global BASE # noqa: PLW0603 - BASE = a.split("=", 1)[1] - print(f"== scene selftest == base={BASE}") - smoke() - test_crud() - test_list() - test_import() - print(f"== 结果:{_passed} passed, {_failed} failed ==") - sys.exit(1 if _failed else 0) - - -if __name__ == "__main__": - main() diff --git a/skill/SKILL.md b/skill/SKILL.md index 938ec50..6858824 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,12 +1,12 @@ --- name: scene -description: 场景管理模块(W-02)——场景表 CRUD、列表查询、场景导入。提供 scene/scene_import 两张表、CRUD、分页列表接口、文件导入(api/scene_import.dspy),通过 load_scene() 挂载。枚举字段用 appcodes 字典(cond parentid=)。 +description: scene 模块技能文档——场景管理(场景表 CRUD + 场景导入)。提供 scene/scene_import 两张表、CRUD、列表查询接口、文件导入(api/scene_import.dspy),通过 load_scene() 挂载。枚举字段用 appcodes 字典(cond parentid=)。 --- # scene 模块 ## 概述 -场景管理,含场景导入。依赖 world(world_id 逻辑关联,不加物理外键)、appbase(appcodes 字典)、rbac(权限)。 +场景管理,含场景导入。依赖 world(world_id 外键)、appbase(appcodes 字典)、rbac(权限)。 ## 数据模型 @@ -14,9 +14,9 @@ description: 场景管理模块(W-02)——场景表 CRUD、列表查询、 | 字段 | 类型 | 说明 | |------|------|------| | id | str(32) | 主键 | -| world_id | str(32) | 所属世界(→world.id,逻辑关联) | +| world_id | str(32) | 所属世界(→world.id) | | name | str(255) | 场景名称 | -| code | str(64) | 场景编码(唯一,idx_scene_code) | +| code | str(64) | 场景编码(唯一) | | description | text | 描述 | | scene_type | str(16) | 场景类型(appcodes scene_type,默认 '0') | | status | str(16) | 状态(appcodes scene_status,默认 '0') | @@ -25,49 +25,48 @@ description: 场景管理模块(W-02)——场景表 CRUD、列表查询、 | updated_at | timestamp | 更新时间 | - 主键 `["id"]`;唯一索引 `idx_scene_code(code)`;索引 `idx_scene_world(world_id)` -- codes:world_id → world(id/name)、scene_type → appcodes_kv(parentid='scene_type')、status → appcodes_kv(parentid='scene_status') +- 字典:world_id → world(id/name)、scene_type → appcodes_kv(parentid='scene_type')、status → appcodes_kv(parentid='scene_status') -### 表 `scene_import`(models/scene_import.json,只读) +### 表 `scene_import`(models/scene_import.json) | 字段 | 类型 | 说明 | |------|------|------| | id | str(32) | 主键 | | world_id | str(32) | 目标世界 | | file_name | str(255) | 导入文件名 | -| total / success / fail | int | 总数 / 成功 / 失败(默认 0) | -| status | str(16) | 导入状态(appcodes import_status:0 进行中/1 成功/2 失败) | +| total | int | 总条数(默认 0) | +| success | int | 成功条数(默认 0) | +| fail | int | 失败条数(默认 0) | +| status | str(16) | 导入状态(appcodes import_status,默认 '0') | | created_at | timestamp | 导入时间 | - 主键 `["id"]`;索引 `idx_scene_import_world(world_id)` +- 字典:world_id → world、status → appcodes_kv(parentid='import_status') -## 关键接口(load_scene() 挂载到 ServerEnv) -- `list_scenes(params)` → `{list, total}`(sqlPaging 分页,支持 world_id/name/code/scene_type/status 过滤 + sort/order) -- `get_scene({id})` → `{data}` 或错误 -- `create_scene(ns)` → 校验 + world 存在校验 + 自动 id/created_at/updated_at;返回 `{success, id}` -- `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀、world 校验 +## 关键接口 + +### ServerEnv 注册函数(load_scene() 挂载) +- `list_scenes(params)` → `[{value:id, text:name}]` +- `get_scene({id})` → 单条 +- `create_scene(ns)` → 自动生成 id/code、写 created_at +- `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀 - `delete_scene({id})` - `import_scenes({world_id, file_name, rows})` → `{success, total, success_count, fail}` - (**事务批量**:先全量解析+校验,再逐条插入;任一条失败整批回滚,无脏数据;success_count 表示成功条数,避免与布尔 success 冲突) -- `parse_scene_file(content, filename)` → `{rows: [...]}`(JSON 数组 / JSON Lines / CSV) -- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()` / `get_import_status_options()` → `[{value, text}]` +- `parse_scene_file(content, filename)` → `[{...}]`(JSON 数组 / JSON Lines / CSV) +- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()` → `[{value, text}]` -### wwwroot/api/*.dspy(REST 统一前缀 /api/*,宿主挂载后为 /scene/api/*.dspy) -- `list_scenes.dspy`(分页 `{list,total}`)、`get_scene.dspy`、`create_scene.dspy`、`scene_update.dspy`、`scene_delete.dspy` -- `get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy`(字典下拉) -- `scene_import.dspy`(world_id + file → parse_scene_file → import_scenes) - -### 错误结构(统一) -`{code, message, field, detail}`;code:PARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG / -WORLD_NOT_FOUND / DUPLICATE_CODE / PARSE_ERROR / DB_ERROR / NOT_FOUND。非法输入 100% 拦截不落库。 +### wwwroot/api/*.dspy +- `list_scenes.dspy`、`create_scene.dspy`、`scene_update.dspy`、`scene_delete.dspy` +- `get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy`:字典下拉 +- `scene_import.dspy`:场景导入(world_id + file → parse_scene_file → import_scenes) ## 陷阱 -- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("scene")`,.dspy 用 `get_module_dbname("scene")`(全局);world 表在 world 模块库(`get_module_dbname("world")`) -- dspy 无 import(json/get_sor_context/debug/params_kw 均预加载全局);函数经 load_scene() 注册后为全局,直接调用 -- `create_scene` / `scene_import` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录 -- `scene_import` 记录 total/success/fail 必须为 int -- `import_scenes` 返回用 `success_count`(避免与布尔 `success` 键冲突) +- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("scene")`,.dspy 用 `get_module_dbname("scene")`(全局) +- world 下拉用 `ServerEnv().get_module_dbname("world")`(world 表在 world 模块库) +- dspy 无 import(json/get_sor_context/debug/params_kw 均为预加载全局) +- `create_scene` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录 +- 导入时 `scene_import` 记录必须设置 `created_at`,字段 total/success/fail 为 int +- `import_scenes` 返回用 `success_count` 表示成功条数(避免与布尔 `success` 键冲突) - CRUD json `new_data_url` 指向自定义 `api/create_scene.dspy`;scene_import 为只读列表,无 editable -- 三处同步注册:scene/__init__.py 导出 ← scene/init.py 实现 ← load_scene() 注册 -- 返回 `{list,total}` 分页用 `sor.sqlPaging`,不要硬编码 LIMIT/OFFSET ## 依赖 -- world(world_id 逻辑关联 + 世界下拉)、appbase(appcodes 字典)、rbac(权限) +- world(world_id 外键 + 世界下拉)、appbase(appcodes 字典)、rbac(权限) diff --git a/wwwroot/api/create_scene.dspy b/wwwroot/api/create_scene.dspy index ef45cbb..1e18dbc 100644 --- a/wwwroot/api/create_scene.dspy +++ b/wwwroot/api/create_scene.dspy @@ -1,6 +1 @@ -# scene 新增(REST POST /api/create_scene.dspy) -debug(f'scene_create.dspy: START params_kw={dict(params_kw)}') -result = await create_scene(dict(params_kw or {})) -if result.get('success'): - return {'status': 'ok', 'data': {'id': result.get('id')}} -return {'status': 'error', 'data': result} +return await create_scene(params_kw) diff --git a/wwwroot/api/get_scene.dspy b/wwwroot/api/get_scene.dspy deleted file mode 100644 index e716ac0..0000000 --- a/wwwroot/api/get_scene.dspy +++ /dev/null @@ -1,6 +0,0 @@ -# scene 单条查询(REST GET /api/get_scene.dspy?id=xxx) -debug(f'scene_get.dspy: START params_kw={dict(params_kw)}') -result = await get_scene(dict(params_kw or {})) -if 'data' in result: - return {'status': 'ok', 'data': result['data']} -return {'status': 'error', 'data': result} diff --git a/wwwroot/api/get_search_scene_type.dspy b/wwwroot/api/get_search_scene_type.dspy index dd5a212..1500d8d 100644 --- a/wwwroot/api/get_search_scene_type.dspy +++ b/wwwroot/api/get_search_scene_type.dspy @@ -1,4 +1 @@ -# 场景类型下拉(REST GET /api/get_search_scene_type.dspy) -debug(f'scene_get_scene_type_options.dspy: START') -opts = await get_scene_type_options() -return {'status': 'ok', 'data': opts} +return await get_scene_type_options(params_kw) diff --git a/wwwroot/api/get_search_status.dspy b/wwwroot/api/get_search_status.dspy index b9d996c..b7ab3e2 100644 --- a/wwwroot/api/get_search_status.dspy +++ b/wwwroot/api/get_search_status.dspy @@ -1,9 +1 @@ -# 场景状态下拉(REST GET /api/get_search_status.dspy) -# scene 状态 + 导入状态共用 appcodes 下拉;按调用方 parentid 区分 -debug(f'scene_get_status_options.dspy: START params_kw={dict(params_kw)}') -parentid = (params_kw or {}).get('parentid', 'scene_status') -if parentid == 'import_status': - opts = await get_import_status_options() -else: - opts = await get_scene_status_options() -return {'status': 'ok', 'data': opts} +return await get_scene_status_options(params_kw) diff --git a/wwwroot/api/get_search_world_id.dspy b/wwwroot/api/get_search_world_id.dspy index efef195..863387d 100644 --- a/wwwroot/api/get_search_world_id.dspy +++ b/wwwroot/api/get_search_world_id.dspy @@ -1,5 +1 @@ -# 世界下拉(REST GET /api/get_search_world_id.dspy) -# 返回 [{value, text}](world 表在 world 模块库) -debug(f'scene_get_world_options.dspy: START') -opts = await get_world_options() -return {'status': 'ok', 'data': opts} +return await get_world_options(params_kw) diff --git a/wwwroot/api/list_scenes.dspy b/wwwroot/api/list_scenes.dspy index bc574ab..d4e4d62 100644 --- a/wwwroot/api/list_scenes.dspy +++ b/wwwroot/api/list_scenes.dspy @@ -1,8 +1 @@ -# scene 列表查询(REST GET/POST /api/list_scenes.dspy) -# 返回 {status:'ok', data:{list, total}};错误 {code,message,field,detail} -debug(f'scene_list.dspy: START params_kw={dict(params_kw)}') -params = dict(params_kw or {}) -result = await list_scenes(params) -if 'list' in result: - return {'status': 'ok', 'data': {'list': result['list'], 'total': result['total']}} -return {'status': 'error', 'data': result} +return await list_scenes(params_kw) diff --git a/wwwroot/api/scene_delete.dspy b/wwwroot/api/scene_delete.dspy index 8f7d614..95986a3 100644 --- a/wwwroot/api/scene_delete.dspy +++ b/wwwroot/api/scene_delete.dspy @@ -1,6 +1 @@ -# scene 删除(REST POST /api/scene_delete.dspy) -debug(f'scene_delete.dspy: START params_kw={dict(params_kw)}') -result = await delete_scene(dict(params_kw or {})) -if result.get('success'): - return {'status': 'ok', 'data': {'id': result.get('id')}} -return {'status': 'error', 'data': result} +return await delete_scene(params_kw) diff --git a/wwwroot/api/scene_import.dspy b/wwwroot/api/scene_import.dspy index 7ac83d6..d987746 100644 --- a/wwwroot/api/scene_import.dspy +++ b/wwwroot/api/scene_import.dspy @@ -1,27 +1,26 @@ -# 场景导入(REST POST /api/scene_import.dspy) -# 参数:world_id(目标世界)+ file(上传文件,multipart)或 rows(JSON 数组) -# 返回:{status, data:{success,total,success_count,fail}};错误 {code,message,field,detail} -debug(f'scene_import.dspy: START params_kw keys={list((params_kw or {}).keys())}') -params = dict(params_kw or {}) -world_id = (params.get('world_id') or '').strip() -file_name = params.get('file_name') or '' -file_obj = params.get('file') -rows = params.get('rows') - -if file_obj is not None: - try: - content = file_obj.read() - except Exception: - content = file_obj - parsed = parse_scene_file(content, file_name) - if 'rows' not in parsed: - return {'status': 'error', 'data': parsed} - result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': parsed['rows']}) -else: - if not isinstance(rows, list): - return {'status': 'error', 'data': {'code': 'PARAM_REQUIRED', 'message': '缺少导入数据(file 或 rows)', 'field': 'rows', 'detail': ''}} - result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': rows}) - -if result.get('success'): - return {'status': 'ok', 'data': {'success': True, 'total': result.get('total', 0), 'success_count': result.get('success_count', 0), 'fail': result.get('fail', 0)}} -return {'status': 'error', 'data': result} +# scene_import.dspy: 场景导入(文件上传 → 解析 → 批量落库 → 写导入记录) +world_id = params_kw.get('world_id') +file = params_kw.get('file') +file_name = params_kw.get('file_name') or '' +content = None +if file is not None: + if hasattr(file, 'file'): + file_name = file_name or getattr(file, 'filename', '') or '' + try: + content = file.file.read() + except Exception: + content = None + elif hasattr(file, 'read'): + try: + content = file.read() + except Exception: + content = None + else: + content = file +if content is None: + return {'success': False, 'message': '未接收到上传文件'} +rows = parse_scene_file(content, file_name) +if rows is None: + return {'success': False, 'message': '文件解析失败,仅支持 JSON 数组 / JSON Lines / CSV'} +result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': rows}) +return result diff --git a/wwwroot/api/scene_update.dspy b/wwwroot/api/scene_update.dspy index 7576534..77f7cce 100644 --- a/wwwroot/api/scene_update.dspy +++ b/wwwroot/api/scene_update.dspy @@ -1,6 +1 @@ -# scene 更新(REST POST /api/scene_update.dspy) -debug(f'scene_update.dspy: START params_kw={dict(params_kw)}') -result = await update_scene(dict(params_kw or {})) -if result.get('success'): - return {'status': 'ok', 'data': {'id': result.get('id')}} -return {'status': 'error', 'data': result} +return await update_scene(params_kw) diff --git a/wwwroot/import_page.ui b/wwwroot/import_page.ui index d29f8f2..3cb2abf 100644 --- a/wwwroot/import_page.ui +++ b/wwwroot/import_page.ui @@ -1,17 +1,18 @@ { - "widgettype": "VBox", - "options": {"width": "100%", "height": "100%", "padding": "20px"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "场景导入", "fontSize": "20px"}}, - {"widgettype": "Form", "options": { - "width": "60%", - "submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}", - "fields": [ - {"name": "world_id", "label": "目标世界", "uitype": "code", "required": true, - "dataurl": "{{entire_url('/scene/api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"}, - {"name": "file", "label": "导入文件", "uitype": "file", "required": true, "accept": ".json,.csv,.ndjson,.jsonl"} - ] - }}, - {"widgettype": "Text", "id": "app.scene_import_result", "options": {"label": "", "marginTop": "16px"}} - ] + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "20px"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"label": "场景导入", "fontSize": "20px"}}, + {"widgettype": "Text", "options": {"label": "支持 JSON 数组 / JSON Lines / CSV(表头:name,code,description,scene_type,status,config_json)", "fontSize": "12px", "color": "#888888"}}, + {"widgettype": "Form", "options": { + "submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}", + "method": "POST" + }, "subwidgets": [ + {"widgettype": "Select", "options": {"name": "world_id", "label": "目标世界", "required": "true", + "dataurl": "{{entire_url('/scene/api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"}}, + {"widgettype": "UiFile", "options": {"name": "file", "label": "导入文件", "required": "true"}}, + {"widgettype": "Button", "options": {"label": "开始导入"}} + ]}, + {"widgettype": "VBox", "id": "app.scene_import_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} + ] } diff --git a/wwwroot/index.ui b/wwwroot/index.ui index 7b48ff9..4c2d18e 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -1,22 +1,18 @@ { - "widgettype": "VBox", - "options": {"width": "100%", "height": "100%", "padding": "20px"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "场景管理", "fontSize": "24px"}}, - {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content", - "options": {"url": "{{entire_url('/scene/scene_list')}}"}, "mode": "replace"}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "场景列表"}}]}, - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content", - "options": {"url": "{{entire_url('/scene/import_page.ui')}}"}, "mode": "replace"}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "场景导入"}}]}, - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content", - "options": {"url": "{{entire_url('/scene/scene_import_list')}}"}, "mode": "replace"}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "导入记录"}}]} - ]}, - {"widgettype": "VBox", "id": "app.scene_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} - ] + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "20px"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"label": "场景管理", "fontSize": "24px"}}, + {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ + {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content", + "options": {"url": "{{entire_url('/scene/scene')}}"}, "mode": "replace"}], + "subwidgets": [{"widgettype": "Text", "options": {"label": "场景列表"}}]}, + {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content", + "options": {"url": "{{entire_url('/scene/import_page.ui')}}"}, "mode": "replace"}], + "subwidgets": [{"widgettype": "Text", "options": {"label": "场景导入"}}]} + ]}, + {"widgettype": "VBox", "id": "app.scene_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} + ] }