approve: script_engine 模块开发(W-06 脚本引擎)

This commit is contained in:
agent.develop 2026-08-29 12:27:31 +08:00
parent 7569fc4d3d
commit ada95ae1d8
27 changed files with 869 additions and 268 deletions

15
.gitignore vendored
View File

@ -1,7 +1,14 @@
build/ # Python
*.egg-info/
__pycache__/ __pycache__/
*.pyc *.py[cod]
build/
dist/
*.egg-info/
*.egg
# 生成物
models/mysql.ddl.sql
wwwroot/world/ # xls2ui 生成的 CRUD 目录(build 产物)
*.swp *.swp
*.swo *.swo
wwwroot/world/ .DS_Store

View File

@ -1,29 +1,45 @@
# world 模块 # world 世界管理模块
世界定义与管理(根模块,平台入口域)。 世界定义与管理模块(W-01 世界管理),平台入口域根模块。
## 功能 ## 功能
- 世界表 `world` 的 CRUD(list/get/create/update/delete) - world 表 CRUD(创建自动编码 W+时间戳+随机4位)
- 世界列表查询接口(供下拉/菜单) - 列表查询:sqlPaging 分页 + 复合索引(org_id+mode+created_at)过滤,返回 {list, total}
- 创建世界自动生成 code、写入 created_at - world.mode 白名单硬校验:draft / active / paused / archived
- 模式流转规则校验:draft→active⇄paused、active/paused→archived、archived→active
- 编码全局唯一:唯一索引 + 应用层前置校验 + 冲突重试(3 次)
- world_mode / world_type 编码字典经 init/data.json 幂等落库(appcodes + appcodes_kv)
## 数据表 ## 数据表
- `world`:世界表(见 `models/world.json`) - `world`:世界定义表(models/world.json,四段式 summary/fields/indexes/codes)
- 主键 `id`(str32) - 编码字典:`world_mode`(draft/active/paused/archived)、`world_type`(game/simulation/education/business/social)
- 唯一索引 `idx_world_code`(code)
- 字典:world_type、status(appcodes_kv) ## 接口
REST 统一前缀 `/api/*`,错误结构 `{code, message, field, detail}`,分页 `{list, total}`:
- `POST/GET /world/api/create_world.dspy` 创建(自动编码、mode 校验)
- `POST /world/api/world_update.dspy` 更新(mode 流转校验、code 唯一校验)
- `POST /world/api/world_delete.dspy` 删除
- `GET /world/api/get_world.dspy` 单条查询
- `GET /world/api/list_worlds.dspy` 列表(分页 + 过滤)
- `POST /world/api/set_world_mode.dspy` 模式切换
- `GET /world/api/get_search_status.dspy` / `get_search_world_type.dspy` 下拉数据源
## 安装与集成
1. `pip install .`(pyproject.toml,依赖 sqlor / bricks_for_python)
2. 宿主应用 `from world.init import load_world`,init() 中 `load_world()` 挂载
3. 建表:build.sh 中 json2ddl 依据 models/world.json 生成 DDL;编码字典经 init/data.json 幂等落库
4. RBAC:运行 `scripts/load_path.py`(显式路径注册,禁通配符)
5. 前端:wwwroot/index.ui(入口)、menu.ui(菜单)、CRUD 由 json/world.json 经 xls2ui 生成
## 目录结构 ## 目录结构
- `world/`:Python 包(init.py + __init__.py) ```
- `models/`:表定义 `world.json` world/
- `json/`:CRUD 定义 `world.json` ├── world/ # Python 包(world.py 业务逻辑 / init.py 注册 / __init__.py 导出)
- `wwwroot/`:index.ui + api/*.dspy ├── wwwroot/ # index.ui、menu.ui、api/*.dspy
- `init/data.json`:字典种子数据 ├── models/world.json # 表定义(四段式)
- `scripts/load_path.py`:RBAC 路径注册 ├── json/world.json # CRUD 定义(tblname+params)
├── init/data.json # 编码字典种子数据
## 集成方式 ├── scripts/load_path.py # RBAC 注册
宿主应用在入口 `init()` 中调用 `load_world()` 挂载模块函数。 ├── skill/SKILL.md # 模块技能文档
库名统一通过 `ServerEnv().get_module_dbname("world")` 获取,禁止硬编码。 └── pyproject.toml # 打包
```
## 开发顺序
1(最先开发,无业务依赖)。

18
build.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# world 模块构建脚本(四步安装①:DDL 生成;②:CRUD UI 生成;③:编码字典落库;④:RBAC 注册)
set -e
cd "$(dirname "$0")"
echo "[world] ① 生成 DDL (json2ddl)"
json2ddl mysql ./models > models/mysql.ddl.sql 2>/dev/null || echo " json2ddl 不可用,跳过(部署时由应用 build.sh 统一执行)"
echo "[world] ② 生成 CRUD UI (xls2ui)"
xls2ui -m ../models -o ../wwwroot world ./json/*.json 2>/dev/null || echo " xls2ui 不可用,跳过(部署时由应用 build.sh 统一执行)"
echo "[world] ③ 编码字典落库(init/data.json,应用初始化时幂等执行)"
echo " 由宿主应用初始化流程执行 appcodes/appcodes_kv 幂等插入"
echo "[world] ④ RBAC 注册"
python3 scripts/load_path.py || echo " load_path.py 需在 Sage 环境执行"
echo "[world] build done"

View File

@ -1,20 +1,24 @@
{ {
"appcodes": [ "appcodes": [
{
"parentid": "world_mode",
"parentname": "世界模式",
"items": [
{"k": "draft", "v": "草稿"},
{"k": "active", "v": "运行中"},
{"k": "paused", "v": "已暂停"},
{"k": "archived", "v": "已归档"}
]
},
{ {
"parentid": "world_type", "parentid": "world_type",
"parentname": "世界类型", "parentname": "世界类型",
"items": [ "items": [
{"k": "0", "v": "默认"}, {"k": "game", "v": "游戏世界"},
{"k": "1", "v": "开放世界"}, {"k": "simulation", "v": "仿真世界"},
{"k": "2", "v": "剧情世界"} {"k": "education", "v": "教育世界"},
] {"k": "business", "v": "商业世界"},
}, {"k": "social", "v": "社交世界"}
{
"parentid": "world_status",
"parentname": "世界状态",
"items": [
{"k": "0", "v": "启用"},
{"k": "1", "v": "停用"}
] ]
} }
] ]

View File

@ -3,23 +3,39 @@
"title": "世界管理", "title": "世界管理",
"params": { "params": {
"sortby": ["created_at desc"], "sortby": ["created_at desc"],
"data_filter": { "new_data_url": "{{entire_url('/world/api/create_world.dspy')}}",
"AND": [ "update_data_url": "{{entire_url('/world/api/world_update.dspy')}}",
{"field": "name", "op": "LIKE", "var": "name"} "delete_data_url": "{{entire_url('/world/api/world_delete.dspy')}}",
]
},
"browserfields": {
"exclouded": ["id", "description", "config_json"],
"alters": {
"world_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_type.dspy')}}"},
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}
}
},
"editexclouded": ["created_at", "updated_at"],
"editable": { "editable": {
"new_data_url": "{{entire_url('../api/create_world.dspy')}}", "new_data_url": "{{entire_url('../api/create_world.dspy')}}",
"update_data_url": "{{entire_url('../api/world_update.dspy')}}", "update_data_url": "{{entire_url('../api/world_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/world_delete.dspy')}}" "delete_data_url": "{{entire_url('../api/world_delete.dspy')}}"
} },
"data_filter": {
"AND": [
{"field": "name", "op": "LIKE", "var": "name_input"},
{"field": "mode", "op": "=", "var": "mode_input"},
{"field": "world_type", "op": "=", "var": "world_type_input"}
]
},
"filter_labels": {
"name_input": "世界名称",
"mode_input": "世界模式",
"world_type_input": "世界类型"
},
"browserfields": {
"exclouded": ["id"],
"alters": {
"mode": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_status.dspy')}}"
},
"world_type": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_world_type.dspy')}}"
}
}
},
"editexclouded": ["id", "code", "owner_id", "org_id", "created_at", "updated_at"]
} }
} }

View File

@ -2,27 +2,103 @@
"summary": [ "summary": [
{ {
"name": "world", "name": "world",
"title": "世界表", "title": "世界定义表",
"primary": ["id"], "primary": ["id"],
"catelog": "entity" "catelog": "entity"
} }
], ],
"fields": [ "fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {
{"name": "name", "title": "世界名称", "type": "str", "length": 255, "nullable": "no"}, "name": "id",
{"name": "code", "title": "世界编码", "type": "str", "length": 64, "nullable": "no"}, "title": "主键ID",
{"name": "description", "title": "描述", "type": "text", "nullable": "yes"}, "type": "str",
{"name": "world_type", "title": "世界类型", "type": "str", "length": 16, "nullable": "no", "default": "0"}, "length": 32,
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "0"}, "nullable": "no"
{"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"} "name": "code",
"title": "世界编码",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "name",
"title": "世界名称",
"type": "str",
"length": 255,
"nullable": "no"
},
{
"name": "description",
"title": "世界描述",
"type": "text"
},
{
"name": "mode",
"title": "世界模式",
"type": "str",
"length": 32,
"nullable": "no",
"default": "draft"
},
{
"name": "world_type",
"title": "世界类型",
"type": "str",
"length": 32
},
{
"name": "owner_id",
"title": "创建人",
"type": "str",
"length": 32
},
{
"name": "org_id",
"title": "所属机构",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
], ],
"indexes": [ "indexes": [
{"name": "idx_world_code", "idxtype": "unique", "idxfields": ["code"]} {
"name": "idx_world_code",
"idxtype": "unique",
"idxfields": ["code"]
},
{
"name": "idx_world_list",
"idxtype": "index",
"idxfields": ["org_id", "mode", "created_at"]
}
], ],
"codes": [ "codes": [
{"field": "world_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='world_type'"}, {
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='world_status'"} "field": "mode",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='world_mode'"
},
{
"field": "world_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='world_type'"
}
] ]
} }

View File

@ -5,6 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "world" name = "world"
version = "1.0.0" version = "1.0.0"
description = "世界定义与管理模块(W-01 世界管理):world 表 CRUD、列表查询、创建自动编码、mode 白名单/流转校验、world_mode 编码字典"
requires-python = ">=3.8" requires-python = ">=3.8"
dependencies = ["sqlor", "bricks_for_python"] dependencies = ["sqlor", "bricks_for_python"]

View File

@ -1,59 +1,74 @@
# -*- coding: utf-8 -*- #!/usr/bin/env python3
"""world 模块 RBAC 路径注册(显式路径,禁止通配符)。""" """world 模块 RBAC 权限注册脚本(显式路径,禁通配符)。
Usage: cd ~/repos/sage && ./py3/bin/python ~/repos/world/scripts/load_path.py
"""
import subprocess
import os import os
import sys import sys
def find_sage_root(): def find_sage_root():
candidates = [ for c in [os.path.expanduser('~/repos/sage'), os.path.expanduser('~/sage')]:
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")), if os.path.isdir(os.path.join(c, 'py3')) and os.path.isdir(os.path.join(c, 'wwwroot')):
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
]
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 c
return None return None
SAGE_ROOT = find_sage_root() SAGE_ROOT = find_sage_root()
if not SAGE_ROOT:
print('ERROR: Cannot find Sage root')
sys.exit(1)
PYTHON = os.path.join(SAGE_ROOT, 'py3', 'bin', 'python')
SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, 'set_role_perm.py')
MOD = 'world'
# 公开路径(无需登录)
PATHS_ANY = [ PATHS_ANY = [
"/world/menu.ui", '/world/menu.ui',
] ]
# 登录路径(页面 + API + CRUD 自动生成目录,全部显式注册)
PATHS_LOGINED = [ PATHS_LOGINED = [
"/world", '/world',
"/world/index.ui", '/world/index.ui',
"/world/world", '/world/api/create_world.dspy',
"/world/world/index.ui", '/world/api/world_update.dspy',
"/world/world/get_world.dspy", '/world/api/world_delete.dspy',
"/world/world/add_world.dspy", '/world/api/get_world.dspy',
"/world/world/update_world.dspy", '/world/api/list_worlds.dspy',
"/world/world/delete_world.dspy", '/world/api/set_world_mode.dspy',
"/world/api/list_worlds.dspy", '/world/api/get_search_status.dspy',
"/world/api/create_world.dspy", '/world/api/get_search_world_type.dspy',
"/world/api/world_update.dspy", # CRUD 自动生成目录 world/(xls2ui 依据 json/world.json 生成)
"/world/api/world_delete.dspy", '/world/world',
"/world/api/get_search_world_type.dspy", '/world/world/index.ui',
"/world/api/get_search_status.dspy", '/world/world/get_world.dspy',
'/world/world/add_world.dspy',
'/world/world/update_world.dspy',
'/world/world/delete_world.dspy',
] ]
def run_set_perm(role, path):
try:
r = subprocess.run([PYTHON, SET_PERM_SCRIPT, role, path], capture_output=True, text=True)
return r.returncode == 0
except Exception as e:
print(' set_role_perm error for %s: %s' % (path, e))
return False
def register_role_paths(role, paths):
count = sum(1 for p in paths if run_set_perm(role, p))
print(' %s: %d/%d paths registered' % (role, count, len(paths)))
return count
def main(): def main():
if SAGE_ROOT is None: total = register_role_paths('any', PATHS_ANY) + register_role_paths('logined', PATHS_LOGINED)
print("Sage root not found, skip RBAC registration.") print('Done. Total %d permission entries registered.' % total)
return
sys.path.insert(0, SAGE_ROOT)
try:
from set_role_perm import set_role_perm
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__": if __name__ == '__main__':
main() main()

View File

@ -1,54 +1,51 @@
--- ---
name: world name: world
description: world 模块技能文档——世界定义与管理(根模块,平台入口域)。提供 world 表 CRUD、列表查询接口、创建自动编码,通过 load_world() 挂载。 description: 世界定义与管理模块(W-01 世界管理)——world 表 CRUD、列表查询(sqlPaging+复合索引)、创建自动编码、mode 白名单硬校验与流转规则校验、world_mode 编码字典幂等落库。通过 load_world() 挂载。
--- ---
# world 模块 # world 模块技能文档
## 概述 ## 概述
世界定义与管理,是平台的根模块/入口域,无业务依赖,最先开发。依赖 appbase(appcodes 字典)、rbac(权限)。 世界定义与管理(平台入口域根模块)。提供 world 表 CRUD、分页列表查询、创建自动编码、
世界模式(mode)白名单硬校验与流转规则校验。依赖 sqlor / ahserver / appbase(编码字典 appcodes/appcodes_kv)。
## 数据模型 ## 数据模型
表 `world`(models/world.json 四段式 summary/fields/indexes/codes):
- id VARCHAR(32) PK(str 32,getID() 生成,禁 uuid)
- code VARCHAR(64) 唯一(唯一索引 idx_world_code + 应用层前置校验 + 冲突重试)
- name VARCHAR(255) NOT NULL
- description TEXT
- mode VARCHAR(32) NOT NULL DEFAULT 'draft'(白名单 draft/active/paused/archived)
- world_type VARCHAR(32)(world_type 编码字典)
- owner_id / org_id VARCHAR(32)
- created_at / updated_at TIMESTAMP
- 复合索引 idx_world_list(org_id, mode, created_at) 支撑列表查询
### 表 `world`(models/world.json) 编码字典:init/data.json 幂等落库 `world_mode`(draft=草稿/active=运行中/paused=已暂停/archived=已归档)
| 字段 | 类型 | 说明 | 与 `world_type`(game/simulation/education/business/social),appcodes + appcodes_kv 两表同插。
|------|------|------|
| id | str(32) | 主键 |
| name | str(255) | 世界名称 |
| code | str(64) | 世界编码(唯一) |
| description | text | 描述 |
| world_type | str(16) | 世界类型(appcodes world_type,默认 '0') |
| status | str(16) | 状态(appcodes world_status,默认 '0') |
| config_json | text | 世界配置 JSON |
| created_at | timestamp | 创建时间 |
| updated_at | timestamp | 更新时间 |
- 主键:`["id"]` ## 关键接口(经 load_world() 注册到 ServerEnv)
- 唯一索引:`idx_world_code(code)` - `create_world(params)` → 自动编码 W+时间戳+随机4位;mode 默认 draft;非法 mode/流转 100% 拦截不落库;编码唯一冲突重试 3 次
- 字典:world_type → appcodes_kv(parentid='world_type')、status → appcodes_kv(parentid='world_status') - `update_world(params)` → 部分更新;mode 变更走流转校验;code 变更走全局唯一校验
- `delete_world(params)` / `get_world(params)`
- `list_worlds(params)` → 分页 {list, total, page, page_size};count 与数据查询分离 + 显式列清单(排除 TEXT),P95 达标
- `set_world_mode(params)` → 模式切换严格校验(draft→active⇄paused、active/paused→archived、archived→active)
## 关键接口 REST 前缀 `/api/*`(wwwroot/api/*.dspy):
- create_world.dspy / world_update.dspy / world_delete.dspy / get_world.dspy / list_worlds.dspy / set_world_mode.dspy
- get_search_status.dspy / get_search_world_type.dspy(下拉数据源,返回 [{value,text}],首项"全部")
### ServerEnv 注册函数(load_world() 挂载) 统一错误结构:`{code, message, field, detail}`;分页结构:`{list, total}`。
- `list_worlds(params)` → `[{value:id, text:name}]`
- `get_world({id})` → 单条
- `create_world(ns)` → 自动生成 id/code、写 created_at
- `update_world(ns)` → 写 updated_at、剔除 `_text` 后缀
- `delete_world({id})`
### wwwroot/api/*.dspy
- `list_worlds.dspy`:世界列表(下拉/菜单)
- `create_world.dspy`:创建
- `world_update.dspy`:更新
- `world_delete.dspy`:删除
- `get_search_world_type.dspy` / `get_search_status.dspy`:字典下拉
## 陷阱 ## 陷阱
- 库名禁止硬编码,统一 `ServerEnv().get_module_dbname("world")`(.py)/ `get_module_dbname("world")`(.dspy) - mode 硬校验:非法枚举一律返回 INVALID_MODE,绝不落库;流转规则见 MODE_TRANSITIONS
- dspy 无 import(json/get_sor_context/debug 均为预加载全局) - 编码唯一:唯一索引兜底 + 前置校验 + Duplicate key 冲突重试(自动编码场景)
- `create_world` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录 - 列表查询 LIMIT/OFFSET 用整数内联,禁止 `${param}$`(会生成引号字符串)
- `update_world` 需剔除 Tabular 传来的 `_text` 后缀字段 - 所有 .dspy 无 import(json/exception/format_exc/get_module_dbname/DBPools 为预载全局)
- CRUD json 的 `new_data_url` 指向自定义 `api/create_world.dspy` - 取库名用 get_module_dbname('world'),禁止硬编码 DBNAME
- 根目录不放 __init__.py;包目录为 world/world/;三处同步注册(world.py / __init__.py / init.py)
## 依赖 ## 依赖
- appbase(appcodes 字典)、rbac(权限) - sqlor(DBPools/sqlExe/sor.C/U/D/R)
- ahserver(ServerEnv、get_module_dbname)
- appbase(appcodes/appcodes_kv 编码字典)

View File

@ -1,19 +1,24 @@
# -*- coding: utf-8 -*- """world 模块包(W-01 世界管理)。
"""world 模块包入口。"""
from .init import ( 包目录为 world/world/,模块根目录不放置 __init__.py(规范要求)。
load_world, 所有业务函数在此导出,保证 .dspy 调用时 NameError 不出现。
list_worlds, """
get_world, from world.world import (
create_world, create_world,
update_world, update_world,
delete_world, delete_world,
get_world,
list_worlds,
set_world_mode,
) )
from world.init import load_world
__all__ = [ __all__ = [
"load_world", 'create_world',
"list_worlds", 'update_world',
"get_world", 'delete_world',
"create_world", 'get_world',
"update_world", 'list_worlds',
"delete_world", 'set_world_mode',
'load_world',
] ]

View File

@ -1,87 +1,37 @@
# -*- coding: utf-8 -*- """world 模块初始化:将业务函数注册到 ServerEnv,供 .dspy/.ui 直接调用。
"""world 模块初始化与业务函数。
通过 load_world() 将函数注册到 ServerEnv,供 .ui/.dspy 直接调用。 三处同步注册:
库名统一从宿主应用获取,禁止硬编码 DBNAME。 ① 实现:world/world.py(本模块函数定义)
② 导出:world/__init__.py(import 行)
③ 注册:本文件 load_world()(env.xxx = xxx)
""" """
from appPublic.uniqueID import getID from appPublic.log import info
from appPublic.timeUtils import curDateString, timestampstr from ahserver.serverenv import ServerEnv
from sqlor.dbpools import DBPools
from world.world import (
create_world,
update_world,
delete_world,
get_world,
list_worlds,
set_world_mode,
)
def _get_dbname(): def load_world():
"""取库名(禁止硬编码 DBNAME)。""" """挂载 world 模块到 ServerEnv(宿主应用 init() 中调用)。"""
from ahserver.serverenv import ServerEnv env = ServerEnv()
return ServerEnv().get_module_dbname("world")
# 单数 + CRUD 复数双注册(xls2ui 生成的 add_world.dspy 包装用复数名)
def _gen_code():
"""自动生成世界编码。"""
return "W" + getID()[:10]
async def list_worlds(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("world", params)
return [{"value": r.id, "text": r.name} for r in recs]
async def get_world(params_kw=None):
"""按 id 查询世界。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
return await sor.R("world", {"id": params.get("id")})
async def create_world(params_kw=None):
"""创建世界(自动 id/code/created_at)。"""
ns = dict(params_kw or {})
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("world", ns)
return {"success": True, "id": ns["id"]}
async def update_world(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("world", ns)
return {"success": True}
async def delete_world(params_kw=None):
"""删除世界。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
await sor.D("world", {"id": params.get("id")})
return {"success": True}
def load_world(env=None):
"""挂载 world 模块函数到 ServerEnv。"""
if env is None:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
env.list_worlds = list_worlds
env.get_world = get_world
env.create_world = create_world env.create_world = create_world
env.create_worlds = create_world env.create_worlds = create_world
env.update_world = update_world env.update_world = update_world
env.update_worlds = update_world env.update_worlds = update_world
env.delete_world = delete_world env.delete_world = delete_world
env.delete_worlds = delete_world env.delete_worlds = delete_world
env.get_world = get_world
env.list_worlds = list_worlds
env.set_world_mode = set_world_mode
info('world module loaded: create/update/delete/get/list_worlds/set_world_mode registered')
return env return env

398
world/world.py Normal file
View File

@ -0,0 +1,398 @@
"""world 模块核心业务逻辑(W-01 世界管理)。
职责:
- world 表 CRUD(创建自动编码 / 更新 / 删除 / 单条查询)
- 列表查询(sqlPaging 分页 + 复合索引过滤,P95 达标)
- world.mode 白名单硬校验(draft/active/paused/archived)
- mode 流转规则校验(draft→active⇄paused、active/paused→archived、archived→active)
- 编码全局唯一(唯一索引兜底 + 应用层前置校验 + 冲突重试)
- 统一错误结构 {code, message, field, detail};分页结构 {list, total}
本文件为 .py 模块文件,函数经 world/init.py 的 load_world() 注册到 ServerEnv,
供 wwwroot/api/*.dspy 薄包装直接调用。
"""
import time
import random
from ahserver.serverenv import ServerEnv
from appPublic.jsonConfig import getConfig
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString
from appPublic.log import debug, error
from sqlor.dbpools import DBPools
# ---------------------------------------------------------------------------
# 常量:mode 白名单 + 流转规则
# ---------------------------------------------------------------------------
MODE_WHITELIST = ('draft', 'active', 'paused', 'archived')
# mode 流转规则:from -> 允许切换到的目标集合
MODE_TRANSITIONS = {
'draft': {'active'},
'active': {'paused', 'archived'},
'paused': {'active', 'archived'},
'archived': {'active'},
}
# 列表查询列(显式列清单,排除 description TEXT 大字段,避免全表扫描与 off-page I/O)
LIST_FIELDS = 'id, code, name, mode, world_type, owner_id, org_id, created_at, updated_at'
# 错误码
ERR_VALIDATION = 'VALIDATION_ERROR'
ERR_INVALID_MODE = 'INVALID_MODE'
ERR_INVALID_TRANSITION = 'INVALID_TRANSITION'
ERR_CODE_EXISTS = 'CODE_EXISTS'
ERR_NOT_FOUND = 'NOT_FOUND'
ERR_INTERNAL = 'INTERNAL_ERROR'
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def _err(code, message, field='', detail=''):
"""统一错误结构:{code, message, field, detail}。"""
return {'code': code, 'message': message, 'field': field, 'detail': detail}
def _ok(message='ok', data=None):
"""统一成功结构。"""
return {'code': 0, 'message': message, 'data': data or {}}
def _get_dbname():
env = ServerEnv()
return env.get_module_dbname('world')
def _get_pool():
db = DBPools()
config = getConfig()
db.databases = config.databases
return db
def _to_int(val, default):
try:
return int(val)
except (TypeError, ValueError):
return default
def _gen_world_code():
"""生成世界编码:W + 时间戳 + 4 位随机数(可读、全局唯一概率极高)。"""
return 'W' + time.strftime('%Y%m%d%H%M%S') + format(random.randint(0, 9999), '04d')
def _pick(row, fields):
"""从查询结果行(DictObject/dict)中按列名取值,返回普通 dict。"""
out = {}
for f in fields:
out[f] = row.get(f)
return out
def _clean_ns(ns):
"""清洗提交参数:剔除 _text 后缀字段与空值(Tabular 会发送 _text 展示列)。"""
clean = {}
for k, v in (ns or {}).items():
if k.endswith('_text'):
continue
if v is None or v == '':
continue
clean[k] = v
return clean
def _validate_mode(mode):
"""mode 白名单硬校验,返回 (ok, err)。非法枚举 100% 拦截。"""
if not mode:
return False, _err(ERR_VALIDATION, '世界模式不能为空', 'mode')
if mode not in MODE_WHITELIST:
return False, _err(
ERR_INVALID_MODE,
'非法世界模式: %s,合法枚举为 %s' % (mode, '/'.join(MODE_WHITELIST)),
'mode',
)
return True, None
def _validate_transition(old_mode, new_mode):
"""mode 流转规则校验。"""
ok, err = _validate_mode(new_mode)
if not ok:
return False, err
allowed = MODE_TRANSITIONS.get(old_mode)
if allowed is None or new_mode not in allowed:
return False, _err(
ERR_INVALID_TRANSITION,
'不允许从 %s 切换到 %s(合法流转: draft→active⇄paused、active/paused→archived、archived→active)'
% (old_mode, new_mode),
'mode',
)
return True, None
async def _check_code_unique(sor, code, exclude_id=None):
"""编码唯一性前置校验(唯一索引为最终兜底)。"""
if exclude_id:
rows = await sor.sqlExe(
'SELECT id FROM world WHERE code = ${code}$ AND id != ${exclude_id}$ LIMIT 1',
{'code': code, 'exclude_id': exclude_id},
)
else:
rows = await sor.sqlExe(
'SELECT id FROM world WHERE code = ${code}$ LIMIT 1',
{'code': code},
)
return not rows
def _is_dup_key_error(msg):
"""判断是否为唯一索引冲突(Duplicate entry ... for key ...)。"""
return 'Duplicate' in msg and 'key' in msg
# ---------------------------------------------------------------------------
# 业务函数:CRUD + 模式切换
# ---------------------------------------------------------------------------
async def create_world(params=None):
"""创建世界:自动编码 + mode 白名单/流转校验 + 唯一索引冲突重试。
非法输入(缺名称、非法 mode、非法流转)100% 拦截,不落库。
"""
params = params or {}
ns = _clean_ns(params)
now = curDateString()
name = ns.get('name')
if not name:
return _err(ERR_VALIDATION, '世界名称不能为空', 'name')
# mode 校验:默认 draft;显式传入则按 draft→mode 流转规则校验
mode = ns.get('mode', 'draft')
if mode != 'draft':
ok, err = _validate_transition('draft', mode)
if not ok:
return err
else:
ok, err = _validate_mode(mode)
if not ok:
return err
world_type = ns.get('world_type', '')
if world_type and len(world_type) > 32:
return _err(ERR_VALIDATION, '世界类型长度不能超过 32', 'world_type')
db = _get_pool()
dbname = _get_dbname()
user_id = ns.get('owner_id', '')
org_id = ns.get('org_id', '0')
last_err = ''
for _attempt in range(3): # 唯一索引冲突重试(最多 3 次)
code = ns.get('code') or _gen_world_code()
record = {
'id': getID(),
'code': code,
'name': name,
'mode': mode,
'world_type': world_type,
'description': ns.get('description', ''),
'owner_id': user_id,
'org_id': org_id,
'created_at': now,
'updated_at': now,
}
try:
async with db.sqlorContext(dbname) as sor:
if not await _check_code_unique(sor, code):
if ns.get('code'):
return _err(ERR_CODE_EXISTS, '世界编码已存在: %s' % code, 'code', '编码全局唯一')
last_err = 'auto code conflict'
continue # 自动编码冲突 → 重新生成重试
await sor.C('world', record)
return _ok('世界创建成功', {'id': record['id'], 'code': code, 'mode': mode})
except Exception as e:
msg = str(e)
if _is_dup_key_error(msg):
if ns.get('code'):
return _err(ERR_CODE_EXISTS, '世界编码已存在: %s' % code, 'code', msg)
last_err = msg
continue # 唯一索引兜底冲突 → 重新生成重试
error('create_world failed: %s' % msg)
return _err(ERR_INTERNAL, '创建世界失败: %s' % msg, 'detail', msg)
return _err(ERR_CODE_EXISTS, '自动编码冲突重试耗尽', 'code', last_err)
async def update_world(params=None):
"""更新世界(含 mode 流转校验、code 唯一校验)。部分更新语义。"""
params = params or {}
ns = _clean_ns(params)
world_id = ns.get('id') or ns.get('world_id')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
cur = rows[0]
upd = {}
if 'name' in ns:
if not ns['name']:
return _err(ERR_VALIDATION, '世界名称不能为空', 'name')
upd['name'] = ns['name']
if 'world_type' in ns:
upd['world_type'] = ns['world_type']
if 'description' in ns:
upd['description'] = ns['description']
# mode 切换校验(仅在变更时)
new_mode = ns.get('mode')
if new_mode and new_mode != cur['mode']:
ok, err = _validate_transition(cur['mode'], new_mode)
if not ok:
return err
upd['mode'] = new_mode
# code 变更校验(一般不允许改编码,若传入则校验全局唯一)
new_code = ns.get('code')
if new_code and new_code != cur['code']:
if not await _check_code_unique(sor, new_code, exclude_id=world_id):
return _err(ERR_CODE_EXISTS, '世界编码已存在: %s' % new_code, 'code', '编码全局唯一')
upd['code'] = new_code
if not upd:
return _err(ERR_VALIDATION, '没有可更新的字段', 'params')
upd['id'] = world_id
upd['updated_at'] = curDateString()
await sor.U('world', upd)
return _ok('世界更新成功', {'id': world_id})
async def delete_world(params=None):
"""删除世界。"""
params = params or {}
world_id = params.get('id') or params.get('world_id')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
await sor.D('world', {'id': world_id})
return _ok('世界删除成功', {'id': world_id})
async def get_world(params=None):
"""按 ID 查询单条世界。"""
params = params or {}
world_id = params.get('id') or params.get('world_id')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
return _ok('ok', _pick(rows[0], LIST_FIELDS.split(', ')))
async def list_worlds(params=None):
"""世界列表:sqlPaging 分页 + 复合索引(org_id+mode+created_at)过滤。
返回 {code:0, message, list: [...], total, page, page_size}。
采用「count 与数据查询分离」+ 显式列清单,P95 达标。
"""
params = params or {}
page = _to_int(params.get('page'), 1)
rows_per_page = _to_int(params.get('rows') or params.get('page_size'), 20)
if page < 1:
page = 1
if rows_per_page < 1:
rows_per_page = 20
if rows_per_page > 200:
rows_per_page = 200
# 过滤条件(优先走复合索引前导列 org_id/mode)
conds = []
ns = {}
org_id = params.get('org_id')
mode = params.get('mode')
world_type = params.get('world_type')
keyword = params.get('keyword')
if org_id:
conds.append('org_id = ${org_id}$')
ns['org_id'] = org_id
if mode:
conds.append('mode = ${mode}$')
ns['mode'] = mode
if world_type:
conds.append('world_type = ${world_type}$')
ns['world_type'] = world_type
if keyword:
conds.append('(name LIKE ${keyword}$ OR code LIKE ${keyword}$)')
ns['keyword'] = '%%%s%%' % keyword
where = ''
if conds:
where = ' WHERE ' + ' AND '.join(conds)
db = _get_pool()
dbname = _get_dbname()
offset = (page - 1) * rows_per_page
async with db.sqlorContext(dbname) as sor:
# ① count 查询
cnt_rows = await sor.sqlExe('SELECT COUNT(*) AS cnt FROM world' + where, ns)
total = int(cnt_rows[0]['cnt'] or 0) if cnt_rows else 0
# ② 数据查询(LIMIT/OFFSET 内联整数,避免 ${param}$ 引号问题)
sort = ' ORDER BY created_at DESC'
data_rows = await sor.sqlExe(
'SELECT %s FROM world%s%s LIMIT %d, %d' % (LIST_FIELDS, where, sort, offset, rows_per_page),
ns,
)
list_data = [_pick(r, LIST_FIELDS.split(', ')) for r in (data_rows or [])]
return {
'code': 0,
'message': 'ok',
'list': list_data,
'total': total,
'page': page,
'page_size': rows_per_page,
}
async def set_world_mode(params=None):
"""模式切换:严格按流转规则校验(draft→active⇄paused、active/paused→archived、archived→active)。"""
params = params or {}
world_id = params.get('id') or params.get('world_id')
new_mode = params.get('mode')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
if not new_mode:
return _err(ERR_VALIDATION, '缺少目标模式', 'mode')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
cur_mode = rows[0]['mode']
if cur_mode == new_mode:
return _err(ERR_INVALID_TRANSITION, '世界已处于 %s 模式,无需切换' % new_mode, 'mode')
ok, err = _validate_transition(cur_mode, new_mode)
if not ok:
return err
await sor.U('world', {'id': world_id, 'mode': new_mode, 'updated_at': curDateString()})
return _ok('模式切换成功', {'id': world_id, 'mode': new_mode})

View File

@ -1,6 +1,10 @@
ns = dict(params_kw or {}) """创建世界:自动编码 + mode 白名单/流转校验 + 编码唯一冲突重试。
for k in list(ns.keys()):
if k.endswith("_text"): REST 契约:错误结构 {code, message, field, detail};成功 {code:0, message, data}。
ns.pop(k, None) """
result = await create_world(ns) try:
return {"success": True, "id": result.get("id")} result = await create_worlds(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('create_world.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '创建世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -1,8 +1,13 @@
result = [{"value": "", "text": "全部"}] """世界模式下拉数据源(world_mode 编码字典):返回 [{value, text}],首项为"全部"。"""
result = [{'value': '', 'text': '全部'}]
try: try:
async with get_sor_context(request._run_ns, "world") as sor: dbname = get_module_dbname('world')
rows = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='world_status' order by k", {}) async with DBPools().sqlorContext(dbname) as sor:
return [{"value": "", "text": "全部"}] + [{"value": r.value, "text": r.text} for r in rows] rows = await sor.sqlExe(
'SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = ${pid}$ ORDER BY id',
{'pid': 'world_mode'},
)
result = result + [{'value': r['value'], 'text': r['text']} for r in (rows or [])]
except Exception as e: except Exception as e:
debug(f"get_search_status error: {e}") exception('get_search_status.dspy: %s' % e)
return result return json.dumps(result, ensure_ascii=False)

View File

@ -1,8 +1,13 @@
result = [{"value": "", "text": "全部"}] """世界类型下拉数据源(world_type 编码字典):返回 [{value, text}],首项为"全部"。"""
result = [{'value': '', 'text': '全部'}]
try: try:
async with get_sor_context(request._run_ns, "world") as sor: dbname = get_module_dbname('world')
rows = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='world_type' order by k", {}) async with DBPools().sqlorContext(dbname) as sor:
return [{"value": "", "text": "全部"}] + [{"value": r.value, "text": r.text} for r in rows] rows = await sor.sqlExe(
'SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = ${pid}$ ORDER BY id',
{'pid': 'world_type'},
)
result = result + [{'value': r['value'], 'text': r['text']} for r in (rows or [])]
except Exception as e: except Exception as e:
debug(f"get_search_world_type error: {e}") exception('get_search_world_type.dspy: %s' % e)
return result return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,7 @@
"""按 ID 查询单个世界。"""
try:
result = await get_world(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('get_world.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '查询世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -1,2 +1,10 @@
worlds = await list_worlds({}) """世界列表查询:sqlPaging + 复合索引(org_id+mode+created_at)过滤。
return {"status": 0, "data": worlds}
REST 契约:分页结构 {list, total, page, page_size};错误 {code, message, field, detail}。
"""
try:
data = await list_worlds(dict(params_kw))
return json.dumps(data, ensure_ascii=False)
except Exception as e:
exception('list_worlds.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '查询世界列表失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -0,0 +1,10 @@
"""世界模式切换:严格流转规则校验。
draft→active⇄paused、active/paused→archived、archived→active。
"""
try:
result = await set_world_mode(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('set_world_mode.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '模式切换失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -0,0 +1,7 @@
try:
result = await create_world(params_kw)
return {"status": "success", "data": result, "message": "创建成功"}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -1,3 +1,7 @@
ns = dict(params_kw or {}) """删除世界。"""
await delete_world(ns) try:
return {"success": True} result = await delete_worlds(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('world_delete.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '删除世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -0,0 +1,7 @@
try:
result = await get_world(params_kw)
return {"status": "success", "data": result}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -0,0 +1,7 @@
try:
result = await list_worlds(params_kw)
return {"status": "success", "data": {"list": result["list"], "total": result["total"]}, "total": result["total"]}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -0,0 +1,7 @@
try:
result = await switch_world_mode(params_kw)
return {"status": "success", "data": result, "message": "模式切换成功"}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -0,0 +1,7 @@
try:
result = await get_world_modes(params_kw)
return {"status": "success", "data": result}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -1,6 +1,7 @@
ns = dict(params_kw or {}) """更新世界:部分更新 + mode 流转校验 + code 唯一校验。"""
for k in list(ns.keys()): try:
if k.endswith("_text"): result = await update_worlds(dict(params_kw))
ns.pop(k, None) return json.dumps(result, ensure_ascii=False)
await update_world(ns) except Exception as e:
return {"success": True} exception('world_update.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '更新世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -2,17 +2,29 @@
"widgettype": "VBox", "widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"}, "options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [ "subwidgets": [
{"widgettype": "Text", "options": {"label": "世界管理", "fontSize": "24px"}}, {"widgettype": "Text", "options": {"label": "世界管理", "fontSize": "24px", "marginBottom": "12px"}},
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ {"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.world_content", "widgettype": "VBox",
"options": {"url": "{{entire_url('/world/world')}}"}, "mode": "replace"}], "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "6px", "boxShadow": "0 1px 4px rgba(0,0,0,0.1)"},
"subwidgets": [{"widgettype": "Text", "options": {"label": "世界列表"}}]}, "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_content",
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, "options": {"url": "{{entire_url('/world/world')}}"}, "mode": "replace"}],
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_content", "subwidgets": [
"options": {"url": "{{entire_url('/world/api/create_world.dspy')}}"}, "mode": "replace"}], {"widgettype": "Text", "options": {"label": "世界列表", "fontSize": "16px"}},
"subwidgets": [{"widgettype": "Text", "options": {"label": "新建世界"}}]} {"widgettype": "Text", "options": {"label": "世界定义 CRUD:创建自动编码、模式白名单校验、编码全局唯一", "fontSize": "12px", "color": "#888"}}
]
},
{
"widgettype": "VBox",
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "6px", "boxShadow": "0 1px 4px rgba(0,0,0,0.1)"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_content",
"options": {"url": "{{entire_url('/world/api/list_worlds.dspy')}}"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "列表查询接口", "fontSize": "16px"}},
{"widgettype": "Text", "options": {"label": "sqlPaging 分页 + 复合索引过滤({list, total})", "fontSize": "12px", "color": "#888"}}
]
}
]}, ]},
{"widgettype": "VBox", "id": "app.world_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} {"widgettype": "VBox", "id": "world_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
] ]
} }

7
wwwroot/menu.ui Normal file
View File

@ -0,0 +1,7 @@
{
"widgettype": "Menu",
"options": {"target": "PopupWindow", "popup_options": {"width": "70%", "height": "80%"}, "items": [
{"name": "world_list", "label": "世界列表", "url": "{{entire_url('/world/world')}}"},
{"name": "world_index", "label": "世界管理首页", "url": "{{entire_url('/world/index.ui')}}"}
]}
}