feat(entity): W-03 实体管理模块——entity/entity_import/entity_import_log 表 CRUD、列表查询、文件导入(两阶段校验+失败回滚)、entity_type 字典幂等落库、i18n 提取、RBAC 注册
This commit is contained in:
parent
d15085f644
commit
d049837ec7
21
.gitignore
vendored
21
.gitignore
vendored
@ -1,21 +1,16 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
# __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# CRUD-generated wwwroot subdirs (build artifacts, regenerated by build.sh)
|
||||
wwwroot/entity_list/
|
||||
wwwroot/entity_import_list/
|
||||
|
||||
# Editors / OS
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# CRUD 生成目录(xls2ui 构建产物,由 json/*.json + models/*.json 重新生成,不入库)
|
||||
wwwroot/entity/
|
||||
wwwroot/entity_import/
|
||||
|
||||
# DDL 生成物
|
||||
models/mysql.ddl.sql
|
||||
|
||||
56
README.md
56
README.md
@ -1,44 +1,44 @@
|
||||
# entity 模块(W-03 实体管理)
|
||||
# entity 实体管理模块(W-03)
|
||||
|
||||
实体管理模块:实体表(entity)增删改查、分页列表查询、实体文件导入(entity_import 记录表)。依赖 world、scene(逻辑关联,不加物理外键)。
|
||||
实体管理模块:管理元景项目的实体(entity)数据,提供实体表 CRUD、分页列表查询与实体文件导入。
|
||||
|
||||
## 功能
|
||||
|
||||
- **实体 CRUD**:create_entity / update_entity / delete_entity / get_entity / list_entities
|
||||
- **列表查询**:sqlPaging 分页 `{list, total}`,支持 world_id / scene_id / entity_type / status / name / code 过滤 + sort / order
|
||||
- **文件导入**:api/entity_import.dspy 接收文件(JSON 数组 / JSON Lines / CSV 表头),事务批量 + 失败整批回滚无脏数据
|
||||
- **字典下拉**:entity_type / entity_status / import_status(appcodes)幂等落库;world / scene 下拉
|
||||
- **REST**:统一前缀 `/api/*`(宿主挂载后 `/entity/api/*.dspy`),错误结构 `{code,message,field,detail}`,分页 `{list,total}`,非法输入 100% 拦截不落库
|
||||
- **实体表 CRUD**:`entity` 表新增/编辑/删除/详情/分页列表(`{list, total}`)。
|
||||
- **列表查询**:按关键字、实体类型、状态、场景过滤,分页返回 `{list, total, page, rows}`。
|
||||
- **实体导入**:`api/entity_import.dspy` 文件导入(CSV 首行表头 / JSON 数组),两阶段处理:
|
||||
1. 全量校验(编码必填/唯一、类型/状态字典白名单、场景/世界存在性)——任一行非法整批拦截,不落库;
|
||||
2. 批量插入(同批以 `import_id` 标记),插入异常按 `import_id` 补偿回滚,保证无脏数据。
|
||||
- **导入记录**:`entity_import`(批次)+ `entity_import_log`(逐行明细)系统生成,只读,禁止手工增删改。
|
||||
- **编码字典**:`entity_type`(实体类型)、`common_status`(通用状态)、`import_status`(导入状态)经 `init/data.json` 幂等落库到 appcodes/appcodes_kv。
|
||||
|
||||
## 数据表
|
||||
|
||||
| 表 | 说明 |
|
||||
|----|------|
|
||||
| entity | 实体表:id / world_id / scene_id / name / code(唯一) / entity_type / status / attributes_json / created_at / updated_at |
|
||||
| entity_import | 导入记录表(只读):id / world_id / scene_id / file_name / total / success / fail / status / created_at |
|
||||
| `entity` | 实体表(entity_code 唯一,entity_type 字典,scene_id/world_id 逻辑关联 codes 段,不加外键) |
|
||||
| `entity_import` | 实体导入批次记录(import_no 唯一,status 字典) |
|
||||
| `entity_import_log` | 实体导入逐行明细日志(import_id 关联批次) |
|
||||
|
||||
- `entity.scene_id → scene.id`、`entity.world_id → world.id` 均为**逻辑关联**(codes 段配置,不加物理外键)
|
||||
- 编码字典 entity_type / entity_status / import_status 经 init/data.json 幂等落库到 appbase
|
||||
## 接口约定
|
||||
|
||||
- REST 统一前缀 `/api/*`:`/entity/api/entity_list.dspy`、`entity_create.dspy`、`entity_update.dspy`、`entity_delete.dspy`、`entity_get.dspy`、`entity_import.dspy`、`entity_import_guard.dspy`、`entity_import_list.dspy`。
|
||||
- 错误结构统一:`{code, message, field, detail}`;分页结构统一:`{list, total}`。
|
||||
- 非法输入 100% 拦截,不落库。
|
||||
|
||||
## 集成
|
||||
|
||||
1. `load_entity()` 挂载:在宿主应用 `init()` 中调用(函数注册到 ServerEnv,含复数别名)
|
||||
2. wwwroot 符号链接到宿主 wwwroot/entity/
|
||||
3. 运行 `scripts/load_path.py` 注册 RBAC(或同步中央 load_path.py)
|
||||
4. 构建:`json2ddl mysql . > mysql.ddl.sql`(models/)、`xls2ui -m ../models -o ../wwwroot entity *.json`(json/)
|
||||
通过 `load_entity()` 挂载到宿主应用(`app/{应用名}.py` 的 init() 中调用并注册 `get_module_dbname('entity')`),库名禁止硬编码。前端页面:`wwwroot/index.ui`(入口导航)、`wwwroot/import_page.ui`(导入页)。
|
||||
|
||||
## 关键接口(经 load_entity() 注册)
|
||||
## 目录结构
|
||||
|
||||
- `list_entities(params)` → `{list, total}`
|
||||
- `create_entity(ns)` / `update_entity(ns)` / `delete_entity(ns)` / `get_entity(ns)`
|
||||
- `import_entities({world_id, scene_id, file_name, rows})` → `{success, total, success_count, fail}`
|
||||
- `parse_entity_file(content, filename)` → `{rows: [...]}`
|
||||
- `get_world_options()` / `get_scene_options()` / `get_entity_type_options()` / `get_entity_status_options()` / `get_import_status_options()` → `[{value, text}]`
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd modules/entity && pip install .
|
||||
```
|
||||
|
||||
详见 skill/SKILL.md。
|
||||
modules/entity/
|
||||
├── entity/ # Python 包(entity/__init__.py + entity/init.py)
|
||||
├── wwwroot/ # index.ui / import_page.ui / api/*.dspy / i18n/
|
||||
├── models/ # entity / entity_import / entity_import_log 表定义
|
||||
├── json/ # entity / entity_import CRUD 定义
|
||||
├── init/data.json # 编码字典种子(appcodes)
|
||||
├── scripts/load_path.py # RBAC 路径注册
|
||||
└── skill/SKILL.md # 模块技能文档
|
||||
```
|
||||
|
||||
11
__init__.py
11
__init__.py
@ -1,6 +1,5 @@
|
||||
"""entity 模块:实体管理,含实体导入。"""
|
||||
|
||||
from entity.init import load_entity # noqa: F401
|
||||
|
||||
__all__ = ['load_entity']
|
||||
__version__ = '1.0.0'
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
modules/entity/ 模块仓库根目录标记文件(遗留,不影响打包)。
|
||||
真实 Python 包目录为双层结构:modules/entity/entity/(见 entity/__init__.py)。
|
||||
"""
|
||||
|
||||
29
build.sh
Normal file
29
build.sh
Normal file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# entity 模块构建脚本:① 生成 DDL;② xls2ui 生成 CRUD 前端;③ 链接 wwwroot 到宿主应用
|
||||
# 用法:build.sh <宿主应用 wwwroot 路径(可选)>
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# ① 表定义 -> DDL
|
||||
if command -v json2ddl >/dev/null 2>&1; then
|
||||
json2ddl mysql . > models/mysql.ddl.sql
|
||||
echo "[entity] DDL generated: models/mysql.ddl.sql"
|
||||
else
|
||||
echo "[entity] json2ddl not found, skip DDL generation"
|
||||
fi
|
||||
|
||||
# ② CRUD 定义 -> wwwroot(生成 wwwroot/entity/ wwwroot/entity_import/ 等 CRUD 目录)
|
||||
if command -v xls2ui >/dev/null 2>&1; then
|
||||
xls2ui -m ../models -o ../wwwroot entity json/*.json
|
||||
echo "[entity] CRUD UI generated"
|
||||
else
|
||||
echo "[entity] xls2ui not found, skip CRUD UI generation"
|
||||
fi
|
||||
|
||||
# ③ 链接 wwwroot 到宿主应用
|
||||
APP_WWWROOT="${1:-}"
|
||||
if [ -n "$APP_WWWROOT" ] && [ -d "$APP_WWWROOT" ]; then
|
||||
ln -sfn "$SCRIPT_DIR/wwwroot" "$APP_WWWROOT/entity"
|
||||
echo "[entity] wwwroot linked -> $APP_WWWROOT/entity"
|
||||
fi
|
||||
78
docs/i18n.md
78
docs/i18n.md
@ -1,68 +1,22 @@
|
||||
# entity 模块 i18n 提取说明(W-03 实体管理)
|
||||
# entity 模块 i18n 说明
|
||||
|
||||
本文件是 entity 模块**新文案 i18n 提取清单与引用约定**,覆盖两个新增页面
|
||||
(`wwwroot/index.ui`、`wwwroot/import_page.ui`)及后端错误码/按钮等全部新文案。
|
||||
## 提取范围
|
||||
本模块全部新增文案(页面标题、按钮、提示、错误码、字段名、过滤标签、消息)均已提取到 `wwwroot/i18n/`:
|
||||
|
||||
## 1. i18n 文件清单
|
||||
|
||||
| 文件 | 语言 | 内容 |
|
||||
| 文件 | 语言 | 用途 |
|
||||
|------|------|------|
|
||||
| `wwwroot/i18n/zh-CN.json` | 简体中文 | 全部 UI 文案 + 错误码文案 + 字典文案 |
|
||||
| `wwwroot/i18n/en-US.json` | 英文 | 与 zh-CN 键一一对应 |
|
||||
| `wwwroot/i18n/zh-CN.json` | 简体中文 | 默认语言 |
|
||||
| `wwwroot/i18n/en-US.json` | 英文 | 英文界面 |
|
||||
|
||||
i18n 文件与页面同置于 `wwwroot/` 下,宿主应用加载时按当前语言读取对应 json,
|
||||
键缺失时回退到 `zh-CN.json`,仍缺失则显示键名本身(不阻塞页面)。
|
||||
## key 规范
|
||||
- 统一前缀 `entity.`(模块名),避免跨模块冲突。
|
||||
- 分组:`entity.field.*`(字段标题)、`entity.filter.*`(搜索标签)、`entity.code.*`(错误码 message,与后端 `{code, message, field, detail}` 结构对应)、`entity.msg.*`(操作消息)、`entity.import.*`(导入页文案)、`entity.list.*`(列表页文案)。
|
||||
- 前端通过 bricks 的 `i18n('entity.xxx')` 调用(`i18n` 为 ahserver 注入全局)。
|
||||
|
||||
## 2. 键命名规则(扁平点分,前缀 entity.)
|
||||
## 与后端错误码的对应
|
||||
后端错误结构统一为 `{code, message, field, detail}`,其中 `code` 取值如 `ENTITY_VALIDATE_FAIL`、`IMPORT_VALIDATE_FAIL` 等。前端展示文案使用 `entity.code.<CODE>` 作为 key 查找 i18n;`message` 字段为后端兜底文案(中文)。
|
||||
|
||||
| 前缀 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `entity.page.*` | 页面标题/副标题 | `entity.page.index.title` |
|
||||
| `entity.btn.*` | 按钮文案 | `entity.btn.add` |
|
||||
| `entity.col.*` | 表格列头 | `entity.col.name` |
|
||||
| `entity.filter.*` | 筛选区标签/占位 | `entity.filter.world_id` |
|
||||
| `entity.form.*` | 新增/编辑表单标签 | `entity.form.code` |
|
||||
| `entity.import.*` | 导入页专用文案 | `entity.import.col.total` |
|
||||
| `entity.msg.*` | 操作提示/消息 | `entity.msg.confirm_delete` |
|
||||
| `entity.dict.*` | 字典下拉(entity_type / entity_status / import_status) | `entity.dict.entity_type.npc` |
|
||||
| `entity.err.*` | 后端错误码→文案映射 | `entity.err.DUPLICATE_CODE` |
|
||||
| `entity.field.*` | 错误码文案中的字段标签占位 | `entity.field.name` |
|
||||
|
||||
## 3. 页面引用方式
|
||||
|
||||
- `index.ui`(实体列表页):标题/按钮/列头/筛选/表单/提示全部使用
|
||||
`entity.page.index.title`、`entity.btn.add`、`entity.col.name` 等键,经宿主
|
||||
i18n 函数取文案,禁止硬编码中文。
|
||||
- `import_page.ui`(实体导入页):使用 `entity.page.import.*`、`entity.import.*`、
|
||||
`entity.btn.*` 键。
|
||||
- 字典下拉(世界/场景/实体类型/状态/导入状态)取值经 `get_search_*.dspy` 返回
|
||||
`[{value,text}]`,其中 `text` 优先取 i18n 文案(`entity.dict.*`),服务端字典
|
||||
值作为兜底。
|
||||
|
||||
## 4. 错误码 → 文案映射约定
|
||||
|
||||
接口统一返回错误结构 `{code, message, field, detail}`。前端展示优先级:
|
||||
1. 取 `entity.err.{code}` 对应 i18n 文案(支持 `{label}`/`{max}` 占位符替换,
|
||||
占位值来自 `field` 对应的 `entity.field.*` 与 `detail`);
|
||||
2. 无映射时回退接口返回的 `message`。
|
||||
|
||||
已提取的错误码(10 个):
|
||||
|
||||
| code | 中文文案 | 英文文案 |
|
||||
|------|----------|----------|
|
||||
| PARAM_REQUIRED | 缺少必要参数 | Missing required parameter |
|
||||
| FIELD_REQUIRED | {label}不能为空 | {label} is required |
|
||||
| FIELD_TOO_LONG | {label}长度不能超过{max} | {label} length cannot exceed {max} |
|
||||
| WORLD_NOT_FOUND | 所属世界不存在 | World not found |
|
||||
| SCENE_NOT_FOUND | 所属场景不存在 | Scene not found |
|
||||
| DUPLICATE_CODE | 实体编码已存在 | Entity code already exists |
|
||||
| PARSE_ERROR | 文件解析失败 | File parse error |
|
||||
| INVALID_JSON | 属性JSON格式错误 | Invalid attributes JSON |
|
||||
| DB_ERROR | 数据库操作失败 | Database operation failed |
|
||||
| NOT_FOUND | 记录不存在 | Record not found |
|
||||
|
||||
## 5. 新增文案流程(维护约定)
|
||||
|
||||
新增任何页面文案/错误码/按钮时:先在 `zh-CN.json` 与 `en-US.json` 同步增加同键
|
||||
条目,再在页面中引用该键。两文件键集合必须保持一致(提交前用脚本比对
|
||||
`jq -S 'keys'` 两文件差异,差异为 0 才允许提交)。
|
||||
## 新增文案流程
|
||||
1. 在 `wwwroot/i18n/zh-CN.json` 与 `en-US.json` 同时新增同 key。
|
||||
2. 同步更新 `docs/i18n.md` 本说明(如 key 分组有变化)。
|
||||
3. 部署时 i18n 文件随 wwwroot 一起链接到宿主应用 wwwroot。
|
||||
|
||||
34
docs/work-log-2026-07-19.md
Normal file
34
docs/work-log-2026-07-19.md
Normal file
@ -0,0 +1,34 @@
|
||||
# entity 模块工作日志(W-03 实体管理)
|
||||
|
||||
- 日期:2026-07-19(元景项目-初始迭代)
|
||||
- 范围:W-03 实体管理功能点——entity.entity / entity_import 表 CRUD、列表查询、实体文件导入;scene_id→scene.id 逻辑关联(codes 段);entity_type 字典幂等落库;导入事务批量+失败回滚;REST 统一前缀 /api/*;错误结构 {code,message,field,detail};分页 {list,total};非法输入 100% 拦截。
|
||||
|
||||
## 交付内容
|
||||
|
||||
- `modules/entity/entity/init.py` + `entity/__init__.py`:后端全部 async 函数(create/update/delete/get/list/import/guard/reject)与 `load_entity()` 注册。
|
||||
- `modules/entity/models/`:entity / entity_import / entity_import_log 三张表定义(四段式 summary/fields/indexes/codes)。
|
||||
- `modules/entity/json/`:entity / entity_import 两个 CRUD 定义。
|
||||
- `modules/entity/wwwroot/`:index.ui(入口导航)、import_page.ui(导入页)、api/*.dspy(11 个接口)、i18n/zh-CN.json + en-US.json。
|
||||
- `modules/entity/init/data.json`:entity_type / common_status / import_status 三组字典(Format B appcodes 幂等落库)。
|
||||
- `modules/entity/scripts/load_path.py`:RBAC 显式路径注册(无通配符)。
|
||||
- `modules/entity/skill/SKILL.md`:模块技能文档。
|
||||
- `modules/entity/docs/i18n.md`:i18n 提取说明。
|
||||
|
||||
## 关键决策
|
||||
|
||||
1. **包目录双层结构**:`modules/entity/entity/`(Python 包),根 `__init__.py` 仅为仓库标记。符合「包目录=模块名」规范。
|
||||
2. **导入两阶段**:先全量校验(只读,不写任何数据)→ 再批量插入(同批 import_id 标记);插入异常按 import_id 补偿 DELETE 回滚,保证失败无脏数据。校验失败只写失败批次 + 逐行日志。
|
||||
3. **entity_import 只读**:批次/明细表系统生成,new/update/delete 指向 `entity_import_reject.dspy` 统一拒绝。
|
||||
4. **逻辑关联不加外键**:scene_id/world_id 在 models/entity.json codes 段声明(table=scene/world),不建物理外键。
|
||||
5. **非法输入 100% 拦截**:编码必填/唯一/长度、类型字典白名单、状态白名单、场景/世界存在性均在校验层拦截,不落库。
|
||||
6. **i18n**:全部新文案提取到 wwwroot/i18n/{zh-CN,en-US}.json,key 前缀 `entity.`,错误码 `entity.code.*` 对应后端 {code,...}。
|
||||
|
||||
## 验证情况
|
||||
|
||||
- 环境受限(本地无 Sage/数据库服务),无法起服务做端到端 curl。已做静态验证(见交付说明中 py_compile 与禁项审计输出)。
|
||||
- 部署后需在宿主应用:`load_entity()` 挂载、`get_module_dbname('entity')` 注册、执行 scripts/load_path.py 注册 RBAC、build.sh 链接 wwwroot 并生成 CRUD 页面、init/data.json 落库字典。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 分支/提交:见 git 本地提交记录(交付说明)。
|
||||
- 遗留:运行期集成验证由 deploy_test 在测试环境执行。
|
||||
@ -1,26 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""entity 模块包导出——所有 async 函数必须在 __init__.py 导出,否则 dspy 调用报 NameError。"""
|
||||
"""entity 模块包(W-03 实体管理)——所有 init.py 中 async 函数必须在此导出,否则 dspy 调用 NameError"""
|
||||
from .init import (
|
||||
EntityError,
|
||||
load_entity,
|
||||
list_entities,
|
||||
get_entity,
|
||||
create_entity,
|
||||
update_entity,
|
||||
delete_entity,
|
||||
get_entity,
|
||||
list_entities,
|
||||
import_entities,
|
||||
parse_entity_file,
|
||||
get_world_options,
|
||||
get_scene_options,
|
||||
get_entity_type_options,
|
||||
get_entity_status_options,
|
||||
get_import_status_options,
|
||||
entity_import_guard,
|
||||
reject_import_write,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'EntityError', 'load_entity',
|
||||
'list_entities', 'get_entity', 'create_entity', 'update_entity',
|
||||
'delete_entity', 'import_entities', 'parse_entity_file',
|
||||
'get_world_options', 'get_scene_options', 'get_entity_type_options',
|
||||
'get_entity_status_options', 'get_import_status_options',
|
||||
'create_entity',
|
||||
'update_entity',
|
||||
'delete_entity',
|
||||
'get_entity',
|
||||
'list_entities',
|
||||
'import_entities',
|
||||
'entity_import_guard',
|
||||
'reject_import_write',
|
||||
]
|
||||
|
||||
914
entity/init.py
914
entity/init.py
@ -1,505 +1,485 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""entity 模块后端逻辑——实体 CRUD / 列表查询 / 文件导入(W-03 实体管理)。
|
||||
|
||||
库名禁止硬编码:统一经 ServerEnv().get_module_dbname('entity') 获取;
|
||||
world/scene/appbase 表分别经 get_module_dbname('world'/'scene'/'appbase') 获取。
|
||||
"""
|
||||
entity 模块后端实现(W-03 实体管理)
|
||||
- entity 表 CRUD / 列表查询(分页 {list, total})
|
||||
- entity_import 导入批次记录(系统生成,只读,禁止手工增删改)
|
||||
- api/entity_import.dspy 文件导入:全量校验 -> 批量插入 -> 失败按 import_id 补偿回滚,保证无脏数据
|
||||
- REST 错误结构统一 {code, message, field, detail};非法输入 100% 拦截不落库
|
||||
- 取库名统一 get_module_dbname('entity')(宿主应用解析),禁止硬编码 DBNAME
|
||||
"""
|
||||
import json
|
||||
from ahserver.serverenv import ServerEnv
|
||||
from appPublic.uniqueID import getID
|
||||
from appPublic.timeUtils import curDateString, timestampstr
|
||||
from sqlor.dbpools import get_sor_context
|
||||
|
||||
from ahserver import ServerEnv
|
||||
from appPublic.dbPools import DBPools
|
||||
from appPublic.futils import getID
|
||||
from appPublic.timeutils import curDateString
|
||||
|
||||
__all__ = [
|
||||
'EntityError', 'load_entity',
|
||||
'list_entities', 'get_entity', 'create_entity', 'update_entity',
|
||||
'delete_entity', 'import_entities', 'parse_entity_file',
|
||||
'get_world_options', 'get_scene_options', 'get_entity_type_options',
|
||||
'get_entity_status_options', 'get_import_status_options',
|
||||
]
|
||||
|
||||
_FIELDS = ('id', 'world_id', 'scene_id', 'name', 'code', 'entity_type',
|
||||
'status', 'attributes_json', 'created_at', 'updated_at')
|
||||
# 与 init/data.json 中 entity_type 字典保持一致的合法取值(字典单一事实源,改动需两处同步)
|
||||
ENTITY_TYPES = {'character', 'prop', 'vehicle', 'building', 'faction', 'other'}
|
||||
ALLOWED_STATUS = {'0', '1'}
|
||||
# entity 表可编辑字段(导入/校验使用)
|
||||
ENTITY_FIELDS = {
|
||||
'entity_code', 'entity_name', 'entity_type', 'scene_id', 'world_id',
|
||||
'description', 'status', 'sort_no',
|
||||
}
|
||||
|
||||
|
||||
class EntityError(Exception):
|
||||
"""业务校验/处理异常,统一错误结构 {code,message,field,detail}。"""
|
||||
|
||||
def __init__(self, code, message, field='', detail=''):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.field = field
|
||||
self.detail = detail
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'code': self.code,
|
||||
'message': self.message,
|
||||
'field': self.field,
|
||||
'detail': self.detail,
|
||||
}
|
||||
def _err(code, message, field='', detail=''):
|
||||
"""统一 REST 错误结构"""
|
||||
return {'code': code, 'message': message, 'field': field, 'detail': detail}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 工具
|
||||
|
||||
def _module_dbname(m):
|
||||
try:
|
||||
return ServerEnv().get_module_dbname(m)
|
||||
except Exception:
|
||||
return None
|
||||
def _ctx():
|
||||
"""模块级数据库上下文(库名由宿主应用 get_module_dbname('entity') 解析)"""
|
||||
return get_sor_context(ServerEnv(), 'entity')
|
||||
|
||||
|
||||
def _dbname():
|
||||
return _module_dbname('entity')
|
||||
|
||||
|
||||
def _world_dbname():
|
||||
return _module_dbname('world')
|
||||
|
||||
|
||||
def _scene_dbname():
|
||||
return _module_dbname('scene')
|
||||
|
||||
|
||||
def _appbase_dbname():
|
||||
return _module_dbname('appbase')
|
||||
|
||||
|
||||
def _row_to_dict(r):
|
||||
if isinstance(r, dict):
|
||||
return {f: r.get(f) for f in _FIELDS}
|
||||
return {f: getattr(r, f, None) for f in _FIELDS}
|
||||
|
||||
|
||||
def _check_required(ns, field, label, maxlen=None):
|
||||
val = (ns or {}).get(field)
|
||||
if val is None or (isinstance(val, str) and not val.strip()):
|
||||
raise EntityError('FIELD_REQUIRED', f'{label}不能为空', field)
|
||||
val = str(val).strip()
|
||||
if maxlen and len(val) > maxlen:
|
||||
raise EntityError('FIELD_TOO_LONG', f'{label}长度不能超过{maxlen}', field, f'当前长度 {len(val)}')
|
||||
return val
|
||||
|
||||
|
||||
async def _exists(dbname, table, eid):
|
||||
"""逻辑关联存在性校验;关联模块未挂载/表不存在时放行(不阻断)。"""
|
||||
if not eid or not dbname:
|
||||
return True
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R(table, {'id': eid})
|
||||
return bool(recs)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
async def _world_exists(world_id):
|
||||
return await _exists(_world_dbname(), 'world', world_id)
|
||||
|
||||
|
||||
async def _scene_exists(scene_id):
|
||||
return await _exists(_scene_dbname(), 'scene', scene_id)
|
||||
|
||||
|
||||
def _check_attributes_json(attrs):
|
||||
if not attrs:
|
||||
return ''
|
||||
if isinstance(attrs, (dict, list)):
|
||||
return json.dumps(attrs, ensure_ascii=False)
|
||||
text = str(attrs).strip()
|
||||
if not text:
|
||||
return ''
|
||||
try:
|
||||
json.loads(text)
|
||||
except Exception as e:
|
||||
raise EntityError('INVALID_JSON', '属性JSON格式错误', 'attributes_json', str(e))
|
||||
return text
|
||||
|
||||
|
||||
def _norm_scalar(v, default='0', maxlen=16):
|
||||
if v is None:
|
||||
return default
|
||||
s = str(v).strip()
|
||||
if not s:
|
||||
return default
|
||||
return s[:maxlen]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 查询
|
||||
|
||||
async def list_entities(params):
|
||||
"""分页列表查询,返回 {list, total}。支持 world_id/scene_id/entity_type/status/name/code 过滤 + sort/order。"""
|
||||
ns = dict(params or {})
|
||||
dbname = _dbname()
|
||||
wheres, args = [], {}
|
||||
for f in ('world_id', 'scene_id', 'entity_type', 'status'):
|
||||
v = (ns.get(f) or '').strip()
|
||||
if v:
|
||||
wheres.append(f"`{f}` = ${{{f}}}$")
|
||||
args[f] = v
|
||||
name = (ns.get('name') or '').strip()
|
||||
if name:
|
||||
wheres.append('`name` LIKE ${name}$')
|
||||
args['name'] = f'%{name}%'
|
||||
code = (ns.get('code') or '').strip()
|
||||
if code:
|
||||
wheres.append('`code` LIKE ${code}$')
|
||||
args['code'] = f'%{code}%'
|
||||
where_sql = (' WHERE ' + ' AND '.join(wheres)) if wheres else ''
|
||||
sort = (ns.get('sort') or 'created_at').strip()
|
||||
if sort not in ('created_at', 'updated_at', 'name', 'code'):
|
||||
sort = 'created_at'
|
||||
order = (ns.get('order') or 'desc').strip().lower()
|
||||
if order not in ('asc', 'desc'):
|
||||
order = 'desc'
|
||||
try:
|
||||
page = int(ns.get('page') or 1)
|
||||
rows_n = int(ns.get('rows') or 20)
|
||||
except Exception:
|
||||
page, rows_n = 1, 20
|
||||
if page < 1:
|
||||
page = 1
|
||||
if rows_n < 1 or rows_n > 200:
|
||||
rows_n = 20
|
||||
sql = f'SELECT * FROM entity{where_sql} ORDER BY `{sort}` {order}'
|
||||
pns = {'page': page, 'rows': rows_n}
|
||||
pns.update(args)
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
rec = await sor.sqlPaging(sql, pns)
|
||||
if isinstance(rec, dict):
|
||||
total, rows = rec.get('total', 0), rec.get('rows', [])
|
||||
else:
|
||||
total, rows = rec.total, rec.rows
|
||||
return {'list': [_row_to_dict(r) for r in rows], 'total': total}
|
||||
|
||||
|
||||
async def get_entity(ns):
|
||||
"""按 id 查询单条,返回 {data}。"""
|
||||
eid = (ns or {}).get('id')
|
||||
if not eid:
|
||||
raise EntityError('PARAM_REQUIRED', '缺少实体ID', 'id')
|
||||
dbname = _dbname()
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R('entity', {'id': eid})
|
||||
if not recs:
|
||||
raise EntityError('NOT_FOUND', '实体不存在', 'id')
|
||||
return {'data': _row_to_dict(recs[0])}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- CRUD
|
||||
|
||||
async def create_entity(ns):
|
||||
"""创建实体。非法输入 100% 拦截不落库;返回 {success, id}。"""
|
||||
ns = dict(ns or {})
|
||||
name = _check_required(ns, 'name', '实体名称', 255)
|
||||
code = _check_required(ns, 'code', '实体编码', 64)
|
||||
world_id = (ns.get('world_id') or '').strip()
|
||||
scene_id = (ns.get('scene_id') or '').strip()
|
||||
entity_type = _norm_scalar(ns.get('entity_type'), '0', 16)
|
||||
status = _norm_scalar(ns.get('status'), '0', 16)
|
||||
attributes_json = _check_attributes_json(ns.get('attributes_json'))
|
||||
if world_id and not await _world_exists(world_id):
|
||||
raise EntityError('WORLD_NOT_FOUND', '所属世界不存在', 'world_id', world_id)
|
||||
if scene_id and not await _scene_exists(scene_id):
|
||||
raise EntityError('SCENE_NOT_FOUND', '所属场景不存在', 'scene_id', scene_id)
|
||||
now = curDateString()
|
||||
rec = {
|
||||
'id': getID(),
|
||||
'world_id': world_id,
|
||||
'scene_id': scene_id,
|
||||
'name': name,
|
||||
'code': code,
|
||||
'entity_type': entity_type,
|
||||
'status': status,
|
||||
'attributes_json': attributes_json,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}
|
||||
dbname = _dbname()
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
await sor.C('entity', rec)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if 'duplicate' in msg.lower():
|
||||
raise EntityError('DUPLICATE_CODE', '实体编码已存在', 'code', msg)
|
||||
raise EntityError('DB_ERROR', '保存实体失败', '', msg)
|
||||
return {'success': True, 'id': rec['id']}
|
||||
|
||||
|
||||
async def update_entity(ns):
|
||||
"""更新实体。剔除 _text 后缀字段;返回 {success, id}。"""
|
||||
ns = dict(ns or {})
|
||||
eid = ns.get('id')
|
||||
if not eid:
|
||||
raise EntityError('PARAM_REQUIRED', '缺少实体ID', 'id')
|
||||
clean = {k: v for k, v in ns.items() if not str(k).endswith('_text')}
|
||||
dbname = _dbname()
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R('entity', {'id': eid})
|
||||
if not recs:
|
||||
raise EntityError('NOT_FOUND', '实体不存在', 'id')
|
||||
upd = {}
|
||||
if 'name' in clean and clean['name'] is not None:
|
||||
upd['name'] = _check_required(clean, 'name', '实体名称', 255)
|
||||
if 'code' in clean and clean['code'] is not None:
|
||||
upd['code'] = _check_required(clean, 'code', '实体编码', 64)
|
||||
if 'world_id' in clean and clean['world_id'] is not None:
|
||||
w = (clean['world_id'] or '').strip()
|
||||
if w and not await _world_exists(w):
|
||||
raise EntityError('WORLD_NOT_FOUND', '所属世界不存在', 'world_id', w)
|
||||
upd['world_id'] = w
|
||||
if 'scene_id' in clean and clean['scene_id'] is not None:
|
||||
s = (clean['scene_id'] or '').strip()
|
||||
if s and not await _scene_exists(s):
|
||||
raise EntityError('SCENE_NOT_FOUND', '所属场景不存在', 'scene_id', s)
|
||||
upd['scene_id'] = s
|
||||
if 'entity_type' in clean and clean['entity_type'] is not None:
|
||||
upd['entity_type'] = _norm_scalar(clean['entity_type'], '0', 16)
|
||||
if 'status' in clean and clean['status'] is not None:
|
||||
upd['status'] = _norm_scalar(clean['status'], '0', 16)
|
||||
if 'attributes_json' in clean and clean['attributes_json'] is not None:
|
||||
upd['attributes_json'] = _check_attributes_json(clean['attributes_json'])
|
||||
if not upd:
|
||||
raise EntityError('PARAM_REQUIRED', '没有可更新的字段', '')
|
||||
upd['id'] = eid
|
||||
upd['updated_at'] = curDateString()
|
||||
async def _current_user(request):
|
||||
"""从请求运行上下文取当前用户/机构(ServerEnv() 是进程级单例,不能用于请求上下文)"""
|
||||
rn = request._run_ns
|
||||
user_id, org_id = '', '0'
|
||||
if hasattr(rn, 'get_user'):
|
||||
try:
|
||||
await sor.U('entity', upd)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if 'duplicate' in msg.lower():
|
||||
raise EntityError('DUPLICATE_CODE', '实体编码已存在', 'code', msg)
|
||||
raise EntityError('DB_ERROR', '更新实体失败', '', msg)
|
||||
return {'success': True, 'id': eid}
|
||||
user_id = (await rn.get_user()) or ''
|
||||
except Exception:
|
||||
user_id = ''
|
||||
if hasattr(rn, 'get_userorgid'):
|
||||
try:
|
||||
org_id = (await rn.get_userorgid()) or '0'
|
||||
except Exception:
|
||||
org_id = '0'
|
||||
return user_id, org_id
|
||||
|
||||
|
||||
async def delete_entity(ns):
|
||||
"""删除实体,返回 {success, id}。"""
|
||||
eid = (ns or {}).get('id')
|
||||
if not eid:
|
||||
raise EntityError('PARAM_REQUIRED', '缺少实体ID', 'id')
|
||||
dbname = _dbname()
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R('entity', {'id': eid})
|
||||
if not recs:
|
||||
raise EntityError('NOT_FOUND', '实体不存在', 'id')
|
||||
await sor.D('entity', {'id': eid})
|
||||
return {'success': True, 'id': eid}
|
||||
def _clean_ns(params_kw):
|
||||
"""清理表单/表格提交的 _text 后缀字段与非法空值,避免写入不存在的列"""
|
||||
ns = {}
|
||||
for k, v in (params_kw or {}).items():
|
||||
if k.endswith('_text'):
|
||||
continue
|
||||
if v in (None, '', 'NaN', 'null'):
|
||||
continue
|
||||
ns[k] = v
|
||||
return ns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 导入
|
||||
async def _validate_entity_row(sor, row, seen_codes, exclude_id=None):
|
||||
"""单行校验:通过返回 None,否则返回错误消息(只读校验,不落库)"""
|
||||
code = str(row.get('entity_code') or '').strip()
|
||||
name = str(row.get('entity_name') or '').strip()
|
||||
etype = str(row.get('entity_type') or '').strip()
|
||||
status = str(row.get('status') or '1').strip()
|
||||
scene_id = str(row.get('scene_id') or '').strip()
|
||||
world_id = str(row.get('world_id') or '').strip()
|
||||
if not code:
|
||||
return '实体编码不能为空'
|
||||
if len(code) > 64:
|
||||
return '实体编码长度不能超过64'
|
||||
if code in seen_codes:
|
||||
return f'文件内实体编码重复: {code}'
|
||||
seen_codes.add(code)
|
||||
if not name:
|
||||
return '实体名称不能为空'
|
||||
if len(name) > 255:
|
||||
return '实体名称长度不能超过255'
|
||||
if not etype:
|
||||
return '实体类型不能为空'
|
||||
if etype not in ENTITY_TYPES:
|
||||
return f'实体类型非法: {etype}'
|
||||
if status not in ALLOWED_STATUS:
|
||||
return f'状态值非法: {status}'
|
||||
if scene_id:
|
||||
sc = await sor.sqlExe('SELECT id FROM scene WHERE id = ${scene_id}$', {'scene_id': scene_id})
|
||||
if not sc:
|
||||
return f'所属场景不存在: {scene_id}'
|
||||
if world_id:
|
||||
wd = await sor.sqlExe('SELECT id FROM world WHERE id = ${world_id}$', {'world_id': world_id})
|
||||
if not wd:
|
||||
return f'所属世界不存在: {world_id}'
|
||||
dup_sql = 'SELECT id FROM entity WHERE entity_code = ${entity_code}$'
|
||||
dup_ns = {'entity_code': code}
|
||||
if exclude_id:
|
||||
dup_sql += ' AND id != ${exclude_id}$'
|
||||
dup_ns['exclude_id'] = exclude_id
|
||||
dup = await sor.sqlExe(dup_sql, dup_ns)
|
||||
if dup:
|
||||
return f'实体编码已存在: {code}'
|
||||
return None
|
||||
|
||||
async def parse_entity_file(content, filename=''):
|
||||
"""解析导入文件内容(JSON 数组 / JSON Lines / CSV 表头),返回 {rows: [...]}。"""
|
||||
if not content:
|
||||
raise EntityError('PARSE_ERROR', '文件内容为空', 'file')
|
||||
text = content
|
||||
if isinstance(text, (bytes, bytearray)):
|
||||
text = text.decode('utf-8', errors='ignore')
|
||||
text = text.strip()
|
||||
|
||||
async def _validate_entity_rows(rows):
|
||||
"""全量校验:返回行错误列表(不在 entity 表写任何数据)"""
|
||||
errs, seen_codes = [], set()
|
||||
async with _ctx() as sor:
|
||||
for idx, r in enumerate(rows, start=1):
|
||||
msg = await _validate_entity_row(sor, r, seen_codes)
|
||||
if msg:
|
||||
errs.append({
|
||||
'row_no': r.get('row_no', idx),
|
||||
'entity_code': str(r.get('entity_code') or '').strip(),
|
||||
'entity_name': str(r.get('entity_name') or '').strip(),
|
||||
'message': msg,
|
||||
})
|
||||
return errs
|
||||
|
||||
|
||||
def _parse_import_rows(content):
|
||||
"""解析 CSV(首行为表头)或 JSON 数组,返回 (rows, err)"""
|
||||
import csv
|
||||
import io
|
||||
text = str(content).strip()
|
||||
if not text:
|
||||
raise EntityError('PARSE_ERROR', '文件内容为空', 'file')
|
||||
try:
|
||||
if text.startswith('['):
|
||||
return [], '导入文件内容为空'
|
||||
if text.startswith('['):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if not isinstance(data, list):
|
||||
raise EntityError('PARSE_ERROR', 'JSON 顶层必须是数组', 'file')
|
||||
return {'rows': data}
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
if lines and lines[0].startswith('{'):
|
||||
rows = []
|
||||
for ln in lines:
|
||||
obj = json.loads(ln)
|
||||
if not isinstance(obj, dict):
|
||||
raise EntityError('PARSE_ERROR', 'JSON Lines 每行必须是对象', 'file', ln[:80])
|
||||
rows.append(obj)
|
||||
return {'rows': rows}
|
||||
if lines and ',' in lines[0]:
|
||||
header = [h.strip().strip('\ufeff') for h in lines[0].split(',')]
|
||||
rows = []
|
||||
for ln in lines[1:]:
|
||||
vals = [v.strip() for v in ln.split(',')]
|
||||
row = {}
|
||||
for idx, h in enumerate(header):
|
||||
row[h] = vals[idx] if idx < len(vals) else ''
|
||||
rows.append(row)
|
||||
return {'rows': rows}
|
||||
data = json.loads(text)
|
||||
if isinstance(data, dict):
|
||||
return {'rows': [data]}
|
||||
raise EntityError('PARSE_ERROR', '无法识别的文件格式', 'file')
|
||||
except EntityError:
|
||||
raise
|
||||
except Exception as e:
|
||||
return [], f'JSON 解析失败: {e}'
|
||||
if not isinstance(data, list):
|
||||
return [], 'JSON 数据必须是数组'
|
||||
rows = []
|
||||
for idx, item in enumerate(data, start=1):
|
||||
if not isinstance(item, dict):
|
||||
return [], f'第{idx}行不是对象'
|
||||
rows.append({
|
||||
'row_no': idx,
|
||||
'entity_code': str(item.get('entity_code') or '').strip(),
|
||||
'entity_name': str(item.get('entity_name') or '').strip(),
|
||||
'entity_type': str(item.get('entity_type') or '').strip(),
|
||||
'scene_id': str(item.get('scene_id') or '').strip(),
|
||||
'world_id': str(item.get('world_id') or '').strip(),
|
||||
'description': str(item.get('description') or '').strip(),
|
||||
'status': str(item.get('status') or '1').strip(),
|
||||
'sort_no': item.get('sort_no') or 0,
|
||||
})
|
||||
return rows, ''
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
rows = []
|
||||
for line_no, row in enumerate(reader, start=2):
|
||||
if not any((v or '').strip() for v in row.values()):
|
||||
continue
|
||||
rows.append({
|
||||
'row_no': line_no,
|
||||
'entity_code': (row.get('entity_code') or '').strip(),
|
||||
'entity_name': (row.get('entity_name') or '').strip(),
|
||||
'entity_type': (row.get('entity_type') or '').strip(),
|
||||
'scene_id': (row.get('scene_id') or '').strip(),
|
||||
'world_id': (row.get('world_id') or '').strip(),
|
||||
'description': (row.get('description') or '').strip(),
|
||||
'status': (row.get('status') or '1').strip(),
|
||||
'sort_no': row.get('sort_no') or 0,
|
||||
})
|
||||
return rows, ''
|
||||
except Exception as e:
|
||||
raise EntityError('PARSE_ERROR', '解析失败', 'file', str(e))
|
||||
return [], f'CSV 解析失败: {e}'
|
||||
|
||||
|
||||
async def import_entities(ns):
|
||||
"""实体文件导入。
|
||||
async def list_entities(request, params_kw):
|
||||
"""实体分页列表:返回 {list, total, page, rows}"""
|
||||
try:
|
||||
params_kw = params_kw or {}
|
||||
page = int(params_kw.get('page', 1) or 1)
|
||||
rows = int(params_kw.get('rows', params_kw.get('pagerows', 20)) or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if rows < 1 or rows > 200:
|
||||
rows = 20
|
||||
keyword = str(params_kw.get('keyword') or '').strip()
|
||||
entity_type = str(params_kw.get('entity_type') or '').strip()
|
||||
status = str(params_kw.get('status') or '').strip()
|
||||
scene_id = str(params_kw.get('scene_id') or '').strip()
|
||||
_, org_id = await _current_user(request)
|
||||
where, ns = ['1=1'], {}
|
||||
if org_id and org_id != '0':
|
||||
where.append("org_id IN ('0', ${org_id}$)")
|
||||
ns['org_id'] = org_id
|
||||
if keyword:
|
||||
where.append('(entity_code LIKE ${kw}$ OR entity_name LIKE ${kw}$)')
|
||||
ns['kw'] = f'%{keyword}%'
|
||||
if entity_type:
|
||||
where.append('entity_type = ${entity_type}$')
|
||||
ns['entity_type'] = entity_type
|
||||
if status:
|
||||
where.append('status = ${status}$')
|
||||
ns['status'] = status
|
||||
if scene_id:
|
||||
where.append('scene_id = ${scene_id}$')
|
||||
ns['scene_id'] = scene_id
|
||||
cond = ' AND '.join(where)
|
||||
async with _ctx() as sor:
|
||||
cnt = await sor.sqlExe(f'SELECT COUNT(*) AS cnt FROM entity WHERE {cond}', ns)
|
||||
total = int(cnt[0].cnt) if cnt else 0
|
||||
offset = (page - 1) * rows
|
||||
ns2 = dict(ns)
|
||||
ns2['limit'] = int(rows)
|
||||
ns2['offset'] = int(offset)
|
||||
recs = await sor.sqlExe(
|
||||
'SELECT id, entity_code, entity_name, entity_type, scene_id, world_id, '
|
||||
'description, status, sort_no, import_id, org_id, created_by, updated_by, created_at, updated_at '
|
||||
f'FROM entity WHERE {cond} ORDER BY sort_no DESC, created_at DESC '
|
||||
'LIMIT ${limit}$ OFFSET ${offset}$', ns2)
|
||||
lst = [dict(r) for r in (recs or [])]
|
||||
return {'list': lst, 'total': total, 'page': page, 'rows': rows}
|
||||
except Exception as e:
|
||||
return _err('ENTITY_LIST_ERROR', '实体列表查询失败', '', str(e))
|
||||
|
||||
事务批量:先全量解析+校验(含编码查重),再逐条插入;任一条失败整批回滚无脏数据。
|
||||
返回 {success, total, success_count, fail, file_name}(success_count 避免与布尔 success 冲突)。
|
||||
|
||||
async def get_entity(request, params_kw):
|
||||
"""实体详情"""
|
||||
try:
|
||||
entity_id = str((params_kw or {}).get('id') or '').strip()
|
||||
if not entity_id:
|
||||
return _err('ENTITY_ID_REQUIRED', '实体ID不能为空', 'id')
|
||||
async with _ctx() as sor:
|
||||
recs = await sor.sqlExe('SELECT * FROM entity WHERE id = ${id}$', {'id': entity_id})
|
||||
if not recs:
|
||||
return _err('ENTITY_NOT_FOUND', '实体不存在', 'id')
|
||||
return {'success': True, 'data': dict(recs[0])}
|
||||
except Exception as e:
|
||||
return _err('ENTITY_GET_ERROR', '实体查询失败', '', str(e))
|
||||
|
||||
|
||||
async def create_entity(request, params_kw):
|
||||
"""新增实体(非法输入 100% 拦截,不落库)"""
|
||||
try:
|
||||
params_kw = params_kw or {}
|
||||
ns = _clean_ns(params_kw)
|
||||
entity_code = str(ns.get('entity_code') or '').strip()
|
||||
entity_name = str(ns.get('entity_name') or '').strip()
|
||||
entity_type = str(ns.get('entity_type') or '').strip()
|
||||
status = str(ns.get('status') or '1').strip()
|
||||
scene_id = str(ns.get('scene_id') or '').strip()
|
||||
world_id = str(ns.get('world_id') or '').strip()
|
||||
user_id, org_id = await _current_user(request)
|
||||
now = curDateString()
|
||||
async with _ctx() as sor:
|
||||
err = await _validate_entity_row(sor, {
|
||||
'entity_code': entity_code, 'entity_name': entity_name,
|
||||
'entity_type': entity_type, 'status': status,
|
||||
'scene_id': scene_id, 'world_id': world_id,
|
||||
}, set())
|
||||
if err:
|
||||
field = 'entity_code' if '编码' in err else ''
|
||||
return _err('ENTITY_VALIDATE_FAIL', err, field)
|
||||
new_id = getID()
|
||||
await sor.C('entity', {
|
||||
'id': new_id, 'entity_code': entity_code, 'entity_name': entity_name,
|
||||
'entity_type': entity_type,
|
||||
'scene_id': scene_id or None, 'world_id': world_id or None,
|
||||
'description': ns.get('description') or '',
|
||||
'status': status,
|
||||
'sort_no': int(ns['sort_no']) if str(ns.get('sort_no') or '').isdigit() else 0,
|
||||
'import_id': None, 'org_id': org_id,
|
||||
'created_by': user_id or None, 'updated_by': None,
|
||||
'created_at': now})
|
||||
return {'success': True, 'data': {'id': new_id}}
|
||||
except Exception as e:
|
||||
return _err('ENTITY_CREATE_ERROR', '实体创建失败', '', str(e))
|
||||
|
||||
|
||||
async def update_entity(request, params_kw):
|
||||
"""更新实体(非法输入 100% 拦截,不落库)"""
|
||||
try:
|
||||
params_kw = params_kw or {}
|
||||
entity_id = str(params_kw.get('id') or '').strip()
|
||||
if not entity_id:
|
||||
return _err('ENTITY_ID_REQUIRED', '实体ID不能为空', 'id')
|
||||
ns = _clean_ns(params_kw)
|
||||
ns.pop('id', None)
|
||||
async with _ctx() as sor:
|
||||
exist = await sor.sqlExe('SELECT * FROM entity WHERE id = ${id}$', {'id': entity_id})
|
||||
if not exist:
|
||||
return _err('ENTITY_NOT_FOUND', '实体不存在', 'id')
|
||||
cur = dict(exist[0])
|
||||
for k, v in ns.items():
|
||||
if k in ENTITY_FIELDS and v not in (None, ''):
|
||||
cur[k] = v
|
||||
err = await _validate_entity_row(sor, cur, set(), exclude_id=entity_id)
|
||||
if err:
|
||||
field = 'entity_code' if '编码' in err else ''
|
||||
return _err('ENTITY_VALIDATE_FAIL', err, field)
|
||||
upd = {k: v for k, v in ns.items() if k in ENTITY_FIELDS and v not in (None, '')}
|
||||
if 'sort_no' in upd:
|
||||
upd['sort_no'] = int(upd['sort_no']) if str(upd['sort_no']).isdigit() else 0
|
||||
if not upd:
|
||||
return _err('ENTITY_UPDATE_EMPTY', '没有需要更新的字段', '')
|
||||
upd['id'] = entity_id
|
||||
upd['updated_at'] = curDateString()
|
||||
await sor.U('entity', upd)
|
||||
return {'success': True, 'data': {'id': entity_id}}
|
||||
except Exception as e:
|
||||
return _err('ENTITY_UPDATE_ERROR', '实体更新失败', '', str(e))
|
||||
|
||||
|
||||
async def delete_entity(request, params_kw):
|
||||
"""删除实体"""
|
||||
try:
|
||||
entity_id = str((params_kw or {}).get('id') or '').strip()
|
||||
if not entity_id:
|
||||
return _err('ENTITY_ID_REQUIRED', '实体ID不能为空', 'id')
|
||||
async with _ctx() as sor:
|
||||
exist = await sor.sqlExe('SELECT id FROM entity WHERE id = ${id}$', {'id': entity_id})
|
||||
if not exist:
|
||||
return _err('ENTITY_NOT_FOUND', '实体不存在', 'id')
|
||||
await sor.D('entity', {'id': entity_id})
|
||||
return {'success': True, 'data': {'id': entity_id}}
|
||||
except Exception as e:
|
||||
return _err('ENTITY_DELETE_ERROR', '实体删除失败', '', str(e))
|
||||
|
||||
|
||||
async def import_entities(request, params_kw):
|
||||
"""
|
||||
ns = dict(ns or {})
|
||||
world_id = (ns.get('world_id') or '').strip()
|
||||
scene_id = (ns.get('scene_id') or '').strip()
|
||||
file_name = (ns.get('file_name') or '').strip()
|
||||
rows = ns.get('rows')
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise EntityError('PARAM_REQUIRED', '没有可导入的数据', 'rows')
|
||||
total = len(rows)
|
||||
|
||||
# 第一阶段:全量解析 + 校验(不落库)
|
||||
parsed, seen_codes = [], set()
|
||||
for i, row in enumerate(rows):
|
||||
seq = i + 1
|
||||
if not isinstance(row, dict):
|
||||
raise EntityError('PARSE_ERROR', f'第{seq}行不是有效对象', 'rows', str(row)[:100])
|
||||
实体文件导入(api/entity_import.dspy)
|
||||
两阶段:① 全量校验(不写任何数据)→ ② 批量插入(同批以 import_id 标记),
|
||||
任一行非法/插入异常 → 按 import_id 补偿删除已插入实体,保证失败回滚无脏数据。
|
||||
"""
|
||||
try:
|
||||
params_kw = params_kw or {}
|
||||
content = None
|
||||
file_name = str(params_kw.get('file_name') or '').strip()
|
||||
f = params_kw.get('file')
|
||||
if f is not None and hasattr(f, 'read'):
|
||||
try:
|
||||
content = f.read()
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode('utf-8-sig', errors='replace')
|
||||
except Exception:
|
||||
content = None
|
||||
file_name = file_name or str(getattr(f, 'filename', '') or '')
|
||||
if content is None:
|
||||
content = params_kw.get('file_content')
|
||||
if not content or not str(content).strip():
|
||||
return _err('IMPORT_EMPTY_FILE', '导入文件为空', 'file')
|
||||
rows, perr = _parse_import_rows(content)
|
||||
if perr:
|
||||
return _err('IMPORT_PARSE_ERROR', '文件解析失败', 'file', perr)
|
||||
if not rows:
|
||||
return _err('IMPORT_EMPTY_ROWS', '文件中没有有效数据行', 'file')
|
||||
if len(rows) > 2000:
|
||||
return _err('IMPORT_TOO_MANY', '单次导入不能超过2000行', 'file')
|
||||
# 第一阶段:全量校验(不落库)
|
||||
val_errs = await _validate_entity_rows(rows)
|
||||
user_id, org_id = await _current_user(request)
|
||||
now = curDateString()
|
||||
import_id = getID()
|
||||
import_no = 'IMP' + timestampstr()
|
||||
if val_errs:
|
||||
# 校验失败:不导入任何实体,仅记录失败批次与逐行错误
|
||||
async with _ctx() as sor:
|
||||
await sor.C('entity_import', {
|
||||
'id': import_id, 'import_no': import_no, 'file_name': file_name,
|
||||
'total_count': len(rows), 'success_count': 0, 'fail_count': len(val_errs),
|
||||
'status': 'fail', 'error_msg': '校验失败,未导入任何数据',
|
||||
'org_id': org_id, 'created_by': user_id or None, 'created_at': now})
|
||||
for e in val_errs:
|
||||
await sor.C('entity_import_log', {
|
||||
'id': getID(), 'import_id': import_id, 'row_no': e['row_no'],
|
||||
'entity_code': e['entity_code'], 'entity_name': e['entity_name'],
|
||||
'result': '0', 'error_msg': e['message'], 'created_at': now})
|
||||
return _err('IMPORT_VALIDATE_FAIL', '导入校验失败,未导入任何数据', 'rows', val_errs)
|
||||
# 第二阶段:批量插入(异常时补偿回滚)
|
||||
try:
|
||||
name = _check_required(row, 'name', '实体名称', 255)
|
||||
code = _check_required(row, 'code', '实体编码', 64)
|
||||
except EntityError as e:
|
||||
e.detail = f'第{seq}行: {e.message}' + (f'({e.detail})' if e.detail else '')
|
||||
raise
|
||||
if code in seen_codes:
|
||||
raise EntityError('DUPLICATE_CODE', f'第{seq}行编码重复: {code}', 'code', f'第{seq}行')
|
||||
seen_codes.add(code)
|
||||
w = (row.get('world_id') or world_id or '').strip()
|
||||
s = (row.get('scene_id') or scene_id or '').strip()
|
||||
attrs = _check_attributes_json(row.get('attributes_json'))
|
||||
parsed.append({
|
||||
'world_id': w,
|
||||
'scene_id': s,
|
||||
'name': name,
|
||||
'code': code,
|
||||
'entity_type': _norm_scalar(row.get('entity_type'), '0', 16),
|
||||
'status': _norm_scalar(row.get('status'), '0', 16),
|
||||
'attributes_json': attrs,
|
||||
})
|
||||
|
||||
if world_id and not await _world_exists(world_id):
|
||||
raise EntityError('WORLD_NOT_FOUND', '所属世界不存在', 'world_id', world_id)
|
||||
if scene_id and not await _scene_exists(scene_id):
|
||||
raise EntityError('SCENE_NOT_FOUND', '所属场景不存在', 'scene_id', scene_id)
|
||||
|
||||
# 第二阶段:事务批量插入,任一条失败整批回滚
|
||||
dbname = _dbname()
|
||||
now = curDateString()
|
||||
imp_id = getID()
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
await sor.C('entity_import', {
|
||||
'id': imp_id,
|
||||
'world_id': world_id,
|
||||
'scene_id': scene_id,
|
||||
'file_name': file_name,
|
||||
'total': total,
|
||||
'success': 0,
|
||||
'fail': 0,
|
||||
'status': '0',
|
||||
'created_at': now,
|
||||
})
|
||||
for item in parsed:
|
||||
rec = dict(item)
|
||||
rec['id'] = getID()
|
||||
rec['created_at'] = now
|
||||
rec['updated_at'] = now
|
||||
await sor.C('entity', rec)
|
||||
await sor.U('entity_import', {
|
||||
'id': imp_id,
|
||||
'status': '1',
|
||||
'success': total,
|
||||
'fail': 0,
|
||||
})
|
||||
async with _ctx() as sor:
|
||||
for r in rows:
|
||||
nid = getID()
|
||||
await sor.C('entity', {
|
||||
'id': nid, 'entity_code': r['entity_code'], 'entity_name': r['entity_name'],
|
||||
'entity_type': r['entity_type'],
|
||||
'scene_id': r['scene_id'] or None, 'world_id': r['world_id'] or None,
|
||||
'description': r['description'], 'status': r['status'],
|
||||
'sort_no': int(r['sort_no']) if str(r['sort_no'] or '').isdigit() else 0,
|
||||
'import_id': import_id, 'org_id': org_id,
|
||||
'created_by': user_id or None, 'updated_by': None, 'created_at': now})
|
||||
async with _ctx() as sor:
|
||||
await sor.C('entity_import', {
|
||||
'id': import_id, 'import_no': import_no, 'file_name': file_name,
|
||||
'total_count': len(rows), 'success_count': len(rows), 'fail_count': 0,
|
||||
'status': 'success', 'error_msg': '',
|
||||
'org_id': org_id, 'created_by': user_id or None, 'created_at': now})
|
||||
for r in rows:
|
||||
await sor.C('entity_import_log', {
|
||||
'id': getID(), 'import_id': import_id, 'row_no': r['row_no'],
|
||||
'entity_code': r['entity_code'], 'entity_name': r['entity_name'],
|
||||
'result': '1', 'error_msg': '', 'created_at': now})
|
||||
return {'success': True,
|
||||
'data': {'import_id': import_id, 'total': len(rows), 'success': len(rows), 'fail': 0}}
|
||||
except Exception as e:
|
||||
# 回滚:删除本批次已插入的实体,保证无脏数据
|
||||
try:
|
||||
async with _ctx() as sor:
|
||||
await sor.execute('DELETE FROM entity WHERE import_id = ${import_id}$',
|
||||
{'import_id': import_id})
|
||||
await sor.C('entity_import', {
|
||||
'id': import_id, 'import_no': import_no, 'file_name': file_name,
|
||||
'total_count': len(rows), 'success_count': 0, 'fail_count': len(rows),
|
||||
'status': 'fail', 'error_msg': f'批量插入失败,已回滚,未产生脏数据: {e}',
|
||||
'org_id': org_id, 'created_by': user_id or None, 'created_at': now})
|
||||
for r in rows:
|
||||
await sor.C('entity_import_log', {
|
||||
'id': getID(), 'import_id': import_id, 'row_no': r['row_no'],
|
||||
'entity_code': r['entity_code'], 'entity_name': r['entity_name'],
|
||||
'result': '0', 'error_msg': f'批量插入失败: {e}', 'created_at': now})
|
||||
except Exception:
|
||||
pass
|
||||
return _err('IMPORT_INSERT_FAIL', '实体批量插入失败,已回滚', '', str(e))
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if 'duplicate' in msg.lower():
|
||||
raise EntityError('DUPLICATE_CODE', '导入失败:实体编码已存在,已整批回滚', 'code', msg)
|
||||
raise EntityError('DB_ERROR', '导入失败,已整批回滚', '', msg)
|
||||
return {
|
||||
'success': True,
|
||||
'total': total,
|
||||
'success_count': total,
|
||||
'fail': 0,
|
||||
'file_name': file_name,
|
||||
}
|
||||
return _err('IMPORT_ERROR', '实体导入失败', '', str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 下拉
|
||||
|
||||
async def get_world_options():
|
||||
"""世界下拉 [{value,text}](world 模块未挂载时返回空)。"""
|
||||
dbname = _world_dbname()
|
||||
if not dbname:
|
||||
return []
|
||||
async def entity_import_guard(request, params_kw):
|
||||
"""导入预校验(干跑):只校验不落库,返回逐行错误(api/entity_import_guard.dspy)"""
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R('world', {})
|
||||
return [{'value': r.id, 'text': getattr(r, 'name', None) or r.id} for r in recs]
|
||||
except Exception:
|
||||
return []
|
||||
params_kw = params_kw or {}
|
||||
rows = params_kw.get('rows')
|
||||
if isinstance(rows, str):
|
||||
rows = json.loads(rows)
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return _err('IMPORT_EMPTY_ROWS', '没有可校验的数据', 'rows')
|
||||
if len(rows) > 2000:
|
||||
return _err('IMPORT_TOO_MANY', '单次校验不能超过2000行', 'rows')
|
||||
norm = []
|
||||
for idx, item in enumerate(rows, start=1):
|
||||
if isinstance(item, str):
|
||||
try:
|
||||
item = json.loads(item)
|
||||
except Exception:
|
||||
item = {'entity_code': item}
|
||||
if not isinstance(item, dict):
|
||||
return _err('IMPORT_ROW_INVALID', f'第{idx}行数据格式非法', 'rows')
|
||||
norm.append({
|
||||
'row_no': idx,
|
||||
'entity_code': str(item.get('entity_code') or '').strip(),
|
||||
'entity_name': str(item.get('entity_name') or '').strip(),
|
||||
'entity_type': str(item.get('entity_type') or '').strip(),
|
||||
'scene_id': str(item.get('scene_id') or '').strip(),
|
||||
'world_id': str(item.get('world_id') or '').strip(),
|
||||
'description': str(item.get('description') or '').strip(),
|
||||
'status': str(item.get('status') or '1').strip(),
|
||||
'sort_no': item.get('sort_no') or 0,
|
||||
})
|
||||
errs = await _validate_entity_rows(norm)
|
||||
return {'valid': not errs, 'errors': errs, 'total': len(norm)}
|
||||
except Exception as e:
|
||||
return _err('IMPORT_GUARD_ERROR', '导入预校验失败', '', str(e))
|
||||
|
||||
|
||||
async def get_scene_options():
|
||||
"""场景下拉 [{value,text}](scene 模块未挂载时返回空)。"""
|
||||
dbname = _scene_dbname()
|
||||
if not dbname:
|
||||
return []
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R('scene', {})
|
||||
return [{'value': r.id, 'text': getattr(r, 'name', None) or r.id} for r in recs]
|
||||
except Exception:
|
||||
return []
|
||||
async def reject_import_write(request, params_kw):
|
||||
"""entity_import 记录为系统生成,禁止手工增删改"""
|
||||
return _err('IMPORT_RECORD_READONLY', '导入记录为系统生成,禁止手工增删改', '')
|
||||
|
||||
|
||||
async def _appcodes_options(parentid):
|
||||
"""appcodes 字典下拉 [{value,text}]。"""
|
||||
dbname = _appbase_dbname()
|
||||
if not dbname:
|
||||
return []
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R('appcodes_kv', {'parentid': parentid})
|
||||
return [{'value': r.k, 'text': r.v} for r in recs]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def get_entity_type_options():
|
||||
return await _appcodes_options('entity_type')
|
||||
|
||||
|
||||
async def get_entity_status_options():
|
||||
return await _appcodes_options('entity_status')
|
||||
|
||||
|
||||
async def get_import_status_options():
|
||||
return await _appcodes_options('import_status')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 注册
|
||||
|
||||
def load_entity():
|
||||
"""注册 entity 模块全部函数到 ServerEnv(含复数别名,供 CRUD dspy 调用)。"""
|
||||
env = ServerEnv()
|
||||
def load_entity(env):
|
||||
"""注册模块函数到 ServerEnv(.dspy/.ui 可直接调用)"""
|
||||
env.create_entity = create_entity
|
||||
env.create_entities = create_entity
|
||||
env.create_entitys = create_entity
|
||||
env.update_entity = update_entity
|
||||
env.update_entities = update_entity
|
||||
env.update_entitys = update_entity
|
||||
env.delete_entity = delete_entity
|
||||
env.delete_entities = delete_entity
|
||||
env.list_entities = list_entities
|
||||
env.delete_entitys = delete_entity
|
||||
env.get_entity = get_entity
|
||||
env.get_entitys = get_entity
|
||||
env.list_entities = list_entities
|
||||
env.import_entities = import_entities
|
||||
env.parse_entity_file = parse_entity_file
|
||||
env.entity_import = import_entities
|
||||
env.get_world_options = get_world_options
|
||||
env.get_scene_options = get_scene_options
|
||||
env.get_entity_type_options = get_entity_type_options
|
||||
env.get_entity_status_options = get_entity_status_options
|
||||
env.get_import_status_options = get_import_status_options
|
||||
env.entity_import_guard = entity_import_guard
|
||||
env.reject_import_write = reject_import_write
|
||||
return env
|
||||
|
||||
@ -4,29 +4,31 @@
|
||||
"parentid": "entity_type",
|
||||
"parentname": "实体类型",
|
||||
"items": [
|
||||
{"k": "npc", "v": "NPC"},
|
||||
{"k": "item", "v": "物品"},
|
||||
{"k": "building", "v": "建筑"},
|
||||
{"k": "character", "v": "角色"},
|
||||
{"k": "prop", "v": "道具"},
|
||||
{"k": "vehicle", "v": "载具"},
|
||||
{"k": "building", "v": "建筑"},
|
||||
{"k": "faction", "v": "阵营"},
|
||||
{"k": "other", "v": "其他"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "entity_status",
|
||||
"parentname": "实体状态",
|
||||
"parentid": "common_status",
|
||||
"parentname": "通用状态",
|
||||
"items": [
|
||||
{"k": "0", "v": "启用"},
|
||||
{"k": "1", "v": "禁用"}
|
||||
{"k": "1", "v": "启用"},
|
||||
{"k": "0", "v": "停用"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "import_status",
|
||||
"parentname": "导入状态",
|
||||
"items": [
|
||||
{"k": "0", "v": "进行中"},
|
||||
{"k": "1", "v": "成功"},
|
||||
{"k": "2", "v": "失败"}
|
||||
{"k": "importing", "v": "导入中"},
|
||||
{"k": "success", "v": "成功"},
|
||||
{"k": "fail", "v": "失败"},
|
||||
{"k": "partial", "v": "部分成功"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,41 +1,33 @@
|
||||
{
|
||||
"tblname": "entity",
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"list": {
|
||||
"fields": [
|
||||
{"name": "id", "label": "ID", "width": 100, "hide": true},
|
||||
{"name": "name", "label": "实体名称", "width": 180},
|
||||
{"name": "code", "label": "实体编码", "width": 140},
|
||||
{"name": "world_id", "label": "所属世界", "width": 120},
|
||||
{"name": "scene_id", "label": "所属场景", "width": 120},
|
||||
{"name": "entity_type", "label": "实体类型", "width": 100},
|
||||
{"name": "status", "label": "状态", "width": 80},
|
||||
{"name": "attributes_json", "label": "属性JSON", "width": 200},
|
||||
{"name": "created_at", "label": "创建时间", "width": 160},
|
||||
{"name": "updated_at", "label": "更新时间", "width": 160}
|
||||
]
|
||||
},
|
||||
"alters": [
|
||||
{"name": "world_id", "label": "所属世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"},
|
||||
{"name": "scene_id", "label": "所属场景", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"},
|
||||
{"name": "entity_type", "label": "实体类型", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_entity_type.dspy')}}"},
|
||||
{"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}
|
||||
]
|
||||
},
|
||||
"editable": {
|
||||
"fields": [
|
||||
{"name": "name", "label": "实体名称", "uitype": "text", "required": true},
|
||||
{"name": "code", "label": "实体编码", "uitype": "text", "required": true},
|
||||
{"name": "world_id", "label": "所属世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"},
|
||||
{"name": "scene_id", "label": "所属场景", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"},
|
||||
{"name": "entity_type", "label": "实体类型", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_entity_type.dspy')}}"},
|
||||
{"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"},
|
||||
{"name": "attributes_json", "label": "属性JSON", "uitype": "textarea"}
|
||||
]
|
||||
},
|
||||
"new_data_url": "{{entire_url('../api/entity_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('../api/entity_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('../api/entity_delete.dspy')}}"
|
||||
}
|
||||
}
|
||||
"tblname": "entity",
|
||||
"title": "实体管理",
|
||||
"params": {
|
||||
"sortby": ["sort_no desc", "created_at desc"],
|
||||
"logined_userorgid": "org_id",
|
||||
"data_url": "{{entire_url('../api/entity_list.dspy')}}",
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "import_id", "org_id", "created_by", "updated_by", "created_at", "updated_at", "description"],
|
||||
"alters": {
|
||||
"entity_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_entity_type.dspy')}}"},
|
||||
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"},
|
||||
"scene_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"},
|
||||
"world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "import_id", "org_id", "created_by", "updated_by", "created_at", "updated_at"],
|
||||
"data_filter": {
|
||||
"AND": [
|
||||
{"field": "entity_name", "op": "LIKE", "var": "keyword"},
|
||||
{"field": "entity_type", "op": "=", "var": "entity_type"},
|
||||
{"field": "status", "op": "=", "var": "status"}
|
||||
]
|
||||
},
|
||||
"filter_labels": {"keyword": "实体名称/编码", "entity_type": "实体类型", "status": "状态"},
|
||||
"editable": {
|
||||
"get_data_url": "{{entire_url('../api/entity_list.dspy')}}",
|
||||
"new_data_url": "{{entire_url('../api/entity_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('../api/entity_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('../api/entity_delete.dspy')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +1,22 @@
|
||||
{
|
||||
"tblname": "entity_import",
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"list": {
|
||||
"fields": [
|
||||
{"name": "id", "label": "ID", "width": 100, "hide": true},
|
||||
{"name": "world_id", "label": "世界ID", "width": 120},
|
||||
{"name": "scene_id", "label": "场景ID", "width": 120},
|
||||
{"name": "file_name", "label": "文件名", "width": 200},
|
||||
{"name": "total", "label": "总数", "width": 80},
|
||||
{"name": "success", "label": "成功", "width": 80},
|
||||
{"name": "fail", "label": "失败", "width": 80},
|
||||
{"name": "status", "label": "状态", "width": 80},
|
||||
{"name": "created_at", "label": "导入时间", "width": 160}
|
||||
]
|
||||
},
|
||||
"alters": [
|
||||
{"name": "world_id", "label": "世界ID", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"},
|
||||
{"name": "scene_id", "label": "场景ID", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"},
|
||||
{"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_import_status.dspy')}}"}
|
||||
]
|
||||
"tblname": "entity_import",
|
||||
"title": "实体导入记录",
|
||||
"params": {
|
||||
"sortby": ["created_at desc"],
|
||||
"logined_userorgid": "org_id",
|
||||
"data_url": "{{entire_url('../api/entity_import_list.dspy')}}",
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "org_id", "created_by", "error_msg"],
|
||||
"alters": {
|
||||
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_import_status.dspy')}}"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "org_id", "created_by", "created_at", "import_no", "file_name", "total_count", "success_count", "fail_count", "status", "error_msg"],
|
||||
"editable": {
|
||||
"get_data_url": "{{entire_url('../api/entity_import_list.dspy')}}",
|
||||
"new_data_url": "{{entire_url('../api/entity_import_reject.dspy')}}",
|
||||
"update_data_url": "{{entire_url('../api/entity_import_reject.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('../api/entity_import_reject.dspy')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,33 +1,40 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"table": "entity",
|
||||
"title": "实体表",
|
||||
"comment": "实体管理(W-03)",
|
||||
"primary": ["id"]
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str", "length": 32, "primary": true, "comment": "主键"},
|
||||
{"name": "world_id", "type": "str", "length": 32, "comment": "所属世界(→world.id 逻辑关联)"},
|
||||
{"name": "scene_id", "type": "str", "length": 32, "comment": "所属场景(→scene.id 逻辑关联,不加物理外键)"},
|
||||
{"name": "name", "type": "str", "length": 255, "comment": "实体名称"},
|
||||
{"name": "code", "type": "str", "length": 64, "comment": "实体编码(唯一)"},
|
||||
{"name": "entity_type", "type": "str", "length": 16, "comment": "实体类型(appcodes entity_type)"},
|
||||
{"name": "status", "type": "str", "length": 16, "comment": "状态(appcodes entity_status)"},
|
||||
{"name": "attributes_json", "type": "text", "comment": "属性 JSON"},
|
||||
{"name": "created_at", "type": "timestamp", "comment": "创建时间"},
|
||||
{"name": "updated_at", "type": "timestamp", "comment": "更新时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_entity_code", "unique": true, "fields": ["code"]},
|
||||
{"name": "idx_entity_world", "fields": ["world_id"]},
|
||||
{"name": "idx_entity_scene", "fields": ["scene_id"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "scene_id", "table": "scene", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "entity_type", "table": "appcodes_kv", "cond": "parentid='entity_type'", "valuefield": "k", "textfield": "v"},
|
||||
{"field": "status", "table": "appcodes_kv", "cond": "parentid='entity_status'", "valuefield": "k", "textfield": "v"}
|
||||
]
|
||||
}
|
||||
"summary": [
|
||||
{
|
||||
"name": "entity",
|
||||
"title": "实体表",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "entity_code", "title": "实体编码", "type": "str", "length": 64, "nullable": "no"},
|
||||
{"name": "entity_name", "title": "实体名称", "type": "str", "length": 255, "nullable": "no"},
|
||||
{"name": "entity_type", "title": "实体类型", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "scene_id", "title": "所属场景", "type": "str", "length": 32},
|
||||
{"name": "world_id", "title": "所属世界", "type": "str", "length": 32},
|
||||
{"name": "description", "title": "描述", "type": "text"},
|
||||
{"name": "status", "title": "状态", "type": "str", "length": 8, "nullable": "no", "default": "1"},
|
||||
{"name": "sort_no", "title": "排序号", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "import_id", "title": "导入批次ID", "type": "str", "length": 32},
|
||||
{"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
|
||||
{"name": "created_by", "title": "创建人", "type": "str", "length": 32},
|
||||
{"name": "updated_by", "title": "更新人", "type": "str", "length": 32},
|
||||
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
|
||||
{"name": "updated_at", "title": "更新时间", "type": "timestamp"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_entity_code", "idxtype": "unique", "idxfields": ["entity_code"]},
|
||||
{"name": "idx_entity_type", "idxtype": "index", "idxfields": ["entity_type"]},
|
||||
{"name": "idx_entity_scene", "idxtype": "index", "idxfields": ["scene_id"]},
|
||||
{"name": "idx_entity_world", "idxtype": "index", "idxfields": ["world_id"]},
|
||||
{"name": "idx_entity_import", "idxtype": "index", "idxfields": ["import_id"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "entity_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='entity_type'"},
|
||||
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='common_status'"},
|
||||
{"field": "scene_id", "table": "scene", "valuefield": "id", "textfield": "scene_name"},
|
||||
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "world_name"}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,29 +1,31 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"table": "entity_import",
|
||||
"title": "实体导入记录表",
|
||||
"comment": "实体导入日志(只读,不可手工增删改)",
|
||||
"primary": ["id"]
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str", "length": 32, "primary": true, "comment": "主键"},
|
||||
{"name": "world_id", "type": "str", "length": 32, "comment": "目标世界"},
|
||||
{"name": "scene_id", "type": "str", "length": 32, "comment": "目标场景"},
|
||||
{"name": "file_name", "type": "str", "length": 255, "comment": "导入文件名"},
|
||||
{"name": "total", "type": "int", "comment": "总条数"},
|
||||
{"name": "success", "type": "int", "comment": "成功条数"},
|
||||
{"name": "fail", "type": "int", "comment": "失败条数"},
|
||||
{"name": "status", "type": "str", "length": 16, "comment": "导入状态(appcodes import_status:0进行中/1成功/2失败)"},
|
||||
{"name": "created_at", "type": "timestamp", "comment": "导入时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_entity_import_world", "fields": ["world_id"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "scene_id", "table": "scene", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "status", "table": "appcodes_kv", "cond": "parentid='import_status'", "valuefield": "k", "textfield": "v"}
|
||||
]
|
||||
}
|
||||
"summary": [
|
||||
{
|
||||
"name": "entity_import",
|
||||
"title": "实体导入记录表",
|
||||
"primary": ["id"],
|
||||
"catelog": "relation"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "import_no", "title": "导入单号", "type": "str", "length": 64, "nullable": "no"},
|
||||
{"name": "file_name", "title": "文件名", "type": "str", "length": 255},
|
||||
{"name": "total_count", "title": "总行数", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "success_count", "title": "成功行数", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "fail_count", "title": "失败行数", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "status", "title": "导入状态", "type": "str", "length": 16, "nullable": "no", "default": "importing"},
|
||||
{"name": "error_msg", "title": "错误信息", "type": "text"},
|
||||
{"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
|
||||
{"name": "created_by", "title": "创建人", "type": "str", "length": 32},
|
||||
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_import_no", "idxtype": "unique", "idxfields": ["import_no"]},
|
||||
{"name": "idx_import_status", "idxtype": "index", "idxfields": ["status"]},
|
||||
{"name": "idx_import_org", "idxtype": "index", "idxfields": ["org_id"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='import_status'"}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
{
|
||||
"summary": [{"name": "entity_import_log", "title": "实体导入日志表", "primary": ["id"], "catelog": "relation"}],
|
||||
"fields": [
|
||||
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "world_id", "title": "所属世界ID", "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": "failed", "title": "失败数", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "status", "title": "导入状态", "type": "str", "length": 16, "nullable": "no", "default": "processing"},
|
||||
{"name": "detail", "title": "失败明细", "type": "text", "nullable": "yes"},
|
||||
{"name": "created_by", "title": "导入人ID", "type": "str", "length": 32, "nullable": "yes"},
|
||||
{"name": "created_at", "title": "导入时间", "type": "timestamp", "nullable": "no"}
|
||||
],
|
||||
"indexes": [{"name": "idx_entity_ilog_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'"}
|
||||
]
|
||||
"summary": [
|
||||
{
|
||||
"name": "entity_import_log",
|
||||
"title": "实体导入明细日志表",
|
||||
"primary": ["id"],
|
||||
"catelog": "relation"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "import_id", "title": "导入批次ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "row_no", "title": "行号", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "entity_code", "title": "实体编码", "type": "str", "length": 64},
|
||||
{"name": "entity_name", "title": "实体名称", "type": "str", "length": 255},
|
||||
{"name": "result", "title": "结果", "type": "str", "length": 8, "nullable": "no", "default": "0"},
|
||||
{"name": "error_msg", "title": "错误信息", "type": "text"},
|
||||
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_log_import", "idxtype": "index", "idxfields": ["import_id"]}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,61 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""entity 模块 RBAC 权限注册(scripts/load_path.py)。
|
||||
|
||||
每次代码变更如有新 path 出现,需同步更新本脚本。禁止通配符,路径全显式。
|
||||
"""
|
||||
entity 模块 RBAC 路径注册脚本(显式路径,禁止通配符)。
|
||||
用法:python scripts/load_path.py(自动寻找 Sage 根目录并调用 set_role_perm.py)
|
||||
维护规则:每次新增/删除 wwwroot 下 path,需同步更新本脚本。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MODULE = 'entity'
|
||||
|
||||
# 显式路径清单(禁止 % / * 通配符)
|
||||
PATHS_LOGINED = [
|
||||
# 页面
|
||||
'/entity/index.ui',
|
||||
'/entity/import_page.ui',
|
||||
# CRUD 列表(xls2ui 生成的别名目录 5 件套)
|
||||
'/entity/entity_list',
|
||||
'/entity/entity_list/index.ui',
|
||||
'/entity/entity_list/get_entity_list.dspy',
|
||||
'/entity/entity_list/add_entity_list.dspy',
|
||||
'/entity/entity_list/update_entity_list.dspy',
|
||||
'/entity/entity_list/delete_entity_list.dspy',
|
||||
'/entity/entity_import_list',
|
||||
'/entity/entity_import_list/index.ui',
|
||||
'/entity/entity_import_list/get_entity_import_list.dspy',
|
||||
'/entity/entity_import_list/add_entity_import_list.dspy',
|
||||
'/entity/entity_import_list/update_entity_import_list.dspy',
|
||||
'/entity/entity_import_list/delete_entity_import_list.dspy',
|
||||
# REST API(统一 /api/* 前缀)
|
||||
'/entity/api/entity_list.dspy',
|
||||
'/entity/api/entity_get.dspy',
|
||||
'/entity/api/entity_create.dspy',
|
||||
'/entity/api/entity_update.dspy',
|
||||
'/entity/api/entity_delete.dspy',
|
||||
'/entity/api/entity_import.dspy',
|
||||
'/entity/api/get_search_world_id.dspy',
|
||||
'/entity/api/get_search_scene_id.dspy',
|
||||
'/entity/api/get_search_entity_type.dspy',
|
||||
'/entity/api/get_search_status.dspy',
|
||||
'/entity/api/get_search_import_status.dspy',
|
||||
PATHS_ANY = [
|
||||
f'/{MODULE}/index.ui',
|
||||
]
|
||||
PATHS_ANY = []
|
||||
|
||||
PATHS_LOGINED = [
|
||||
f'/{MODULE}',
|
||||
f'/{MODULE}/index.ui',
|
||||
f'/{MODULE}/import_page.ui',
|
||||
f'/{MODULE}/api/entity_list.dspy',
|
||||
f'/{MODULE}/api/entity_create.dspy',
|
||||
f'/{MODULE}/api/entity_update.dspy',
|
||||
f'/{MODULE}/api/entity_delete.dspy',
|
||||
f'/{MODULE}/api/entity_get.dspy',
|
||||
f'/{MODULE}/api/entity_import.dspy',
|
||||
f'/{MODULE}/api/entity_import_guard.dspy',
|
||||
f'/{MODULE}/api/entity_import_list.dspy',
|
||||
f'/{MODULE}/api/entity_import_reject.dspy',
|
||||
f'/{MODULE}/api/get_search_entity_type.dspy',
|
||||
f'/{MODULE}/api/get_search_status.dspy',
|
||||
f'/{MODULE}/api/get_search_import_status.dspy',
|
||||
f'/{MODULE}/api/get_search_scene_id.dspy',
|
||||
f'/{MODULE}/api/get_search_world_id.dspy',
|
||||
]
|
||||
|
||||
|
||||
def find_sage_root():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = [
|
||||
os.path.abspath(os.path.join(here, '..', '..', '..', '..')),
|
||||
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 None
|
||||
|
||||
|
||||
def main():
|
||||
sys.path.insert(0, HERE)
|
||||
try:
|
||||
from set_role_perm import set_role_perm
|
||||
except Exception:
|
||||
# 找不到 set_role_perm 时回退到中央 sage/load_path.py 已注册的路径,仅提示
|
||||
print('WARN: set_role_perm 不可用,请确保中央 load_path.py 已包含上述路径')
|
||||
sage_root = find_sage_root()
|
||||
if not sage_root:
|
||||
print('[entity] sage root not found, skip RBAC registration')
|
||||
return
|
||||
for path in PATHS_LOGINED:
|
||||
set_role_perm(path, 'logined')
|
||||
for path in PATHS_ANY:
|
||||
set_role_perm(path, 'any')
|
||||
print(f'entity: registered {len(PATHS_LOGINED)} logined + {len(PATHS_ANY)} any paths')
|
||||
python = os.path.join(sage_root, 'py3', 'bin', 'python')
|
||||
set_role = os.path.join(sage_root, 'py3', 'bin', 'set_role_perm.py')
|
||||
for p in PATHS_ANY:
|
||||
subprocess.call([python, set_role, p, 'any'])
|
||||
for p in PATHS_LOGINED:
|
||||
subprocess.call([python, set_role, p, 'logined'])
|
||||
print(f'[entity] RBAC registered: {len(PATHS_ANY)} any + {len(PATHS_LOGINED)} logined')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -3,73 +3,61 @@ name: entity
|
||||
description: 实体管理模块(W-03)——实体表(entity)CRUD、列表查询、实体文件导入(entity_import 记录),通过 load_entity() 挂载。
|
||||
---
|
||||
|
||||
# entity 模块
|
||||
# entity 实体管理模块
|
||||
|
||||
## 概述
|
||||
实体管理模块,提供实体表(entity)增删改查与文件导入(entity_import 记录表)。依赖 world、scene 模块(逻辑关联,不加物理外键)。
|
||||
|
||||
元景项目 W-03 实体管理:管理实体(entity)数据。实体归属于场景(scene_id→scene.id,逻辑关联,不加外键)与世界(world_id→world.id)。提供实体表 CRUD、分页列表查询、CSV/JSON 文件导入(两阶段校验+批量插入+失败回滚)。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 表 `entity`(models/entity.json)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | str(32) | 主键 |
|
||||
| world_id | str(32) | 所属世界(→world.id 逻辑关联) |
|
||||
| scene_id | str(32) | 所属场景(→scene.id 逻辑关联,codes 段配置,不加物理外键) |
|
||||
| name | str(255) | 实体名称 |
|
||||
| code | str(64) | 实体编码(唯一,idx_entity_code) |
|
||||
| entity_type | str(16) | 实体类型(appcodes entity_type) |
|
||||
| status | str(16) | 状态(appcodes entity_status) |
|
||||
| attributes_json | text | 属性 JSON |
|
||||
| created_at | timestamp | 创建时间 |
|
||||
| updated_at | timestamp | 更新时间 |
|
||||
| 表 | 说明 |
|
||||
|----|------|
|
||||
| `entity` | 实体表。`entity_code` 唯一索引;`entity_type`/`status` 引用 appcodes_kv 字典;`scene_id`/`world_id` 为逻辑外键(codes 段,无物理外键);`import_id` 标记导入批次;`org_id` 机构隔离 |
|
||||
| `entity_import` | 导入批次记录(系统生成,只读)。`import_no` 唯一;`status` ∈ importing/success/fail |
|
||||
| `entity_import_log` | 导入逐行明细(系统生成,只读)。`result` 1=成功 0=失败 |
|
||||
|
||||
- 主键 `["id"]`;唯一索引 `idx_entity_code(code)`;索引 `idx_entity_world(world_id)`、`idx_entity_scene(scene_id)`
|
||||
- codes:world_id → world(id/name)、scene_id → scene(id/name)、entity_type → appcodes_kv(parentid='entity_type')、status → appcodes_kv(parentid='entity_status')
|
||||
编码字典(init/data.json 幂等落库 appcodes/appcodes_kv):
|
||||
- `entity_type`:character/prop/vehicle/building/faction/other
|
||||
- `common_status`:1=启用 / 0=停用
|
||||
- `import_status`:importing/success/fail
|
||||
|
||||
### 表 `entity_import`(models/entity_import.json,只读)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | str(32) | 主键 |
|
||||
| world_id | str(32) | 目标世界 |
|
||||
| scene_id | str(32) | 目标场景 |
|
||||
| file_name | str(255) | 导入文件名 |
|
||||
| total / success / fail | int | 总数 / 成功 / 失败(默认 0) |
|
||||
| status | str(16) | 导入状态(appcodes import_status:0 进行中/1 成功/2 失败) |
|
||||
| created_at | timestamp | 导入时间 |
|
||||
## 关键接口(REST 统一前缀 /api/*)
|
||||
|
||||
- 主键 `["id"]`;索引 `idx_entity_import_world(world_id)`;**只读记录,不可手工增删改**
|
||||
| 接口 | 方法 | 说明 | 返回 |
|
||||
|------|------|------|------|
|
||||
| `/entity/api/entity_list.dspy` | GET/POST | 分页列表(keyword/entity_type/status/scene_id 过滤) | `{list, total, page, rows}` |
|
||||
| `/entity/api/entity_create.dspy` | POST | 新增实体(校验失败 100% 拦截不落库) | `{success, data:{id}}` |
|
||||
| `/entity/api/entity_update.dspy` | POST | 更新实体(校验+存在性检查) | `{success, data:{id}}` |
|
||||
| `/entity/api/entity_delete.dspy` | POST | 删除实体 | `{success, data:{id}}` |
|
||||
| `/entity/api/entity_get.dspy` | GET/POST | 实体详情 | `{success, data}` |
|
||||
| `/entity/api/entity_import.dspy` | POST | 文件导入(file 域 / file_content) | `{success, data:{import_id,total,success,fail}}` |
|
||||
| `/entity/api/entity_import_guard.dspy` | POST | 导入预校验(干跑,只校验不落库) | `{valid, errors, total}` |
|
||||
| `/entity/api/entity_import_list.dspy` | GET/POST | 导入记录只读分页 | `{list, total, page, rows}` |
|
||||
|
||||
## 关键接口(load_entity() 挂载到 ServerEnv)
|
||||
- `list_entities(params)` → `{list, total}`(sqlPaging 分页,支持 world_id/scene_id/entity_type/status/name/code 过滤 + sort/order)
|
||||
- `get_entity({id})` → `{data}` 或错误
|
||||
- `create_entity(ns)` → 校验 + world/scene 存在校验 + 自动 id/created_at/updated_at;返回 `{success, id}`
|
||||
- `update_entity(ns)` → 写 updated_at、剔除 `_text` 后缀、world/scene 校验
|
||||
- `delete_entity({id})` → `{success, id}`
|
||||
- `import_entities({world_id, scene_id, file_name, rows})` → `{success, total, success_count, fail, file_name}`
|
||||
(**事务批量**:先全量解析+校验(含文件内编码查重),再逐条插入;任一条失败整批回滚,无脏数据;success_count 避免与布尔 success 冲突)
|
||||
- `parse_entity_file(content, filename)` → `{rows: [...]}`(JSON 数组 / JSON Lines / CSV 表头)
|
||||
- `get_world_options()` / `get_scene_options()` / `get_entity_type_options()` / `get_entity_status_options()` / `get_import_status_options()` → `[{value, text}]`
|
||||
错误结构统一:`{code, message, field, detail}`。分页结构统一:`{list, total}`。
|
||||
|
||||
### wwwroot/api/*.dspy(REST 统一前缀 /api/*,宿主挂载后为 /entity/api/*.dspy)
|
||||
- `entity_list.dspy`(分页 `{list,total}`)、`entity_get.dspy`、`entity_create.dspy`、`entity_update.dspy`、`entity_delete.dspy`
|
||||
- `entity_import.dspy`(world_id + file → parse_entity_file → import_entities)
|
||||
- `get_search_world_id.dspy` / `get_search_scene_id.dspy` / `get_search_entity_type.dspy` / `get_search_status.dspy` / `get_search_import_status.dspy`(字典下拉)
|
||||
## 导入处理逻辑
|
||||
|
||||
### 错误结构(统一)
|
||||
`{code, message, field, detail}`;code:PARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG /
|
||||
WORLD_NOT_FOUND / SCENE_NOT_FOUND / DUPLICATE_CODE / PARSE_ERROR / INVALID_JSON / DB_ERROR / NOT_FOUND。
|
||||
非法输入 100% 拦截不落库。
|
||||
1. **解析**:CSV(首行表头,字段 entity_code/entity_name/entity_type/scene_id/world_id/description/status/sort_no)或 JSON 数组。
|
||||
2. **全量校验(第一阶段,不落库)**:编码必填/长度/文件内唯一/库内唯一;名称必填;类型/状态字典白名单;scene_id/world_id 存在性。任一行非法 → 整批拦截,只写失败批次+逐行日志,不导入任何实体。
|
||||
3. **批量插入(第二阶段)**:同批实体以同一 `import_id` 标记插入;任一行异常 → 按 `import_id` 补偿 DELETE 已插入实体,保证失败回滚无脏数据。
|
||||
4. 成功/失败均写 `entity_import` 批次与 `entity_import_log` 明细。
|
||||
|
||||
## 陷阱
|
||||
- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("entity")`,.dspy 用 `get_module_dbname("entity")`(全局);world/scene 表分别在 world/scene 模块库(`get_module_dbname("world"/"scene")`),appcodes 在 `get_module_dbname("appbase")`
|
||||
- dspy 无 import(json/get_sor_context/debug/params_kw 均预加载全局);函数经 load_entity() 注册后为全局,直接调用
|
||||
- `create_entity` / `import_entities` 必须显式设置 `created_at = curDateString()`,否则 sor.C 静默丢记录
|
||||
- `entity_import` 记录 total/success/fail 必须为 int
|
||||
- `import_entities` 返回用 `success_count`(避免与布尔 `success` 键冲突)
|
||||
- 三处同步注册:entity/__init__.py 导出 ← entity/init.py 实现 ← load_entity() 注册(含复数别名 create_entities/update_entities/delete_entities)
|
||||
- 返回 `{list,total}` 分页用 `sor.sqlPaging`,不要硬编码 LIMIT/OFFSET
|
||||
- CRUD json `new_data_url` 指向自定义 `api/entity_create.dspy`;entity_import 为只读列表,无 editable
|
||||
|
||||
- **sor.I() 只用于表结构**:插入用 `sor.C(table, ns)`,不要 `sor.I(table, data)`(I 只收 1 参)。
|
||||
- **sor.U() 只收 2 参**:`sor.U('entity', {**upd, 'id': id})`,id 必须在 data 内;仅主键字段时先校验非空字段。
|
||||
- **dspy 中禁 import**:全部函数经 `load_entity()` 注册为全局(`create_entity/update_entity/delete_entity/get_entity/list_entities/import_entities/entity_import_guard/reject_import_write`),dspy 直接调用;`__init__.py` 必须导出 init.py 的 async 函数,否则 NameError。
|
||||
- **取库名不硬编码**:.py 用 `get_sor_context(ServerEnv(), 'entity')`(内部解析 `get_module_dbname('entity')`);.dspy 用 `get_sor_context(request._run_ns, 'entity')`。禁止写 `DBNAME='...'`。
|
||||
- **请求上下文**:.py 内取用户/机构用 `request._run_ns`(get_user/get_userorgid),不要用 `ServerEnv()`(进程级单例)。
|
||||
- **`_text` 后缀字段**:表格提交会带 `xxx_text`,`_clean_ns` 会剔除后再写库。
|
||||
- **curDateString()**:sqlor 不自动写时间戳,插入必须显式 `created_at`,否则记录静默丢失。
|
||||
- **删除校验**:entity 可能被 world_snapshot 等引用,删除前可扩展引用检查(当前实现仅校验存在性)。
|
||||
|
||||
## 依赖
|
||||
- world(world_id 逻辑关联 + 世界下拉)、scene(scene_id 逻辑关联 + 场景下拉)、appbase(appcodes 字典)、rbac(权限)
|
||||
|
||||
- 基础:sqlor、ahserver、appPublic
|
||||
- 数据依赖(逻辑关联,不物理外键):`scene`(scene.id)、`world`(world.id)——codes 段引用,表缺失时仅下拉不渲染,不影响 CRUD
|
||||
- 字典:appbase 的 appcodes / appcodes_kv
|
||||
- 被 `world_snapshot`(W-03 快照)聚合读取
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
# entity_create.dspy —— 实体新增(REST /api/*)。非法输入 100% 拦截不落库。
|
||||
try:
|
||||
result = await create_entity(params_kw)
|
||||
return {'code': 'OK', 'message': 'success', 'data': result}
|
||||
except Exception as e:
|
||||
if hasattr(e, 'to_dict'):
|
||||
err = e.to_dict()
|
||||
return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']}
|
||||
return {'code': 'DB_ERROR', 'message': '保存实体失败', 'field': '', 'detail': str(e)}
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_create.dspy —— 新增实体(非法输入 100% 拦截不落库)
|
||||
result = await create_entity(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
# entity_delete.dspy —— 实体删除(REST /api/*)
|
||||
try:
|
||||
result = await delete_entity(params_kw)
|
||||
return {'code': 'OK', 'message': 'success', 'data': result}
|
||||
except Exception as e:
|
||||
if hasattr(e, 'to_dict'):
|
||||
err = e.to_dict()
|
||||
return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']}
|
||||
return {'code': 'DB_ERROR', 'message': '删除实体失败', 'field': '', 'detail': str(e)}
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_delete.dspy —— 删除实体
|
||||
result = await delete_entity(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
# entity_get.dspy —— 实体单条查询(REST /api/*)
|
||||
try:
|
||||
result = await get_entity(params_kw)
|
||||
return {'code': 'OK', 'message': 'success', 'data': result.get('data')}
|
||||
except Exception as e:
|
||||
if hasattr(e, 'to_dict'):
|
||||
err = e.to_dict()
|
||||
return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']}
|
||||
return {'code': 'DB_ERROR', 'message': '查询实体失败', 'field': '', 'detail': str(e)}
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_get.dspy —— 实体详情
|
||||
result = await get_entity(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,35 +1,5 @@
|
||||
# entity_import.dspy —— 实体文件导入(REST /api/*)
|
||||
# 入参:world_id / scene_id / file_name / file(文件内容,FileStorage 或文本)
|
||||
# 事务批量:全量校验 → 整批插入;任一条失败回滚无脏数据。
|
||||
# 返回 {success, total, success_count, fail, file_name}
|
||||
try:
|
||||
content = None
|
||||
fname = (params_kw.get('file_name') or '').strip()
|
||||
file_obj = params_kw.get('file')
|
||||
if file_obj is not None:
|
||||
if hasattr(file_obj, 'read'):
|
||||
content = file_obj.read()
|
||||
else:
|
||||
content = file_obj
|
||||
if content is None:
|
||||
content = params_kw.get('content')
|
||||
if content is None and not fname:
|
||||
raise EntityError('PARAM_REQUIRED', '缺少导入文件', 'file')
|
||||
if not fname:
|
||||
fname = (getattr(file_obj, 'filename', '') or '') if file_obj is not None else ''
|
||||
parsed = await parse_entity_file(content, fname)
|
||||
rows = parsed.get('rows', [])
|
||||
imp_ns = {
|
||||
'world_id': params_kw.get('world_id'),
|
||||
'scene_id': params_kw.get('scene_id'),
|
||||
'file_name': fname or 'entity_import',
|
||||
'rows': rows,
|
||||
}
|
||||
result = await import_entities(imp_ns)
|
||||
result['success'] = True
|
||||
return {'code': 'OK', 'message': 'success', 'data': result}
|
||||
except Exception as e:
|
||||
if hasattr(e, 'to_dict'):
|
||||
err = e.to_dict()
|
||||
return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']}
|
||||
return {'code': 'DB_ERROR', 'message': '导入失败', 'field': '', 'detail': str(e)}
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_import.dspy —— 实体文件导入(文件域 file / file_content;两阶段校验+批量插入+失败回滚)
|
||||
# 调用后端 import_entities(request, params_kw)
|
||||
result = await import_entities(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1 +1,4 @@
|
||||
return {'success': False, 'message': '导入记录为只读,不支持手工增删改'}
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_import_guard.dspy —— 导入预校验(干跑,只校验不落库)
|
||||
result = await entity_import_guard(request, params_kw)
|
||||
return result
|
||||
|
||||
35
wwwroot/api/entity_import_list.dspy
Normal file
35
wwwroot/api/entity_import_list.dspy
Normal file
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_import_list.dspy —— 导入记录只读分页列表
|
||||
# entity_import 表为系统生成,禁止手工增删改;此处仅提供只读查询
|
||||
try:
|
||||
params_kw = params_kw or {}
|
||||
page = int(params_kw.get('page', 1) or 1)
|
||||
rows = int(params_kw.get('rows', params_kw.get('pagerows', 20)) or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if rows < 1 or rows > 200:
|
||||
rows = 20
|
||||
status = str(params_kw.get('status') or '').strip()
|
||||
org_id = (await get_userorgid()) or '0'
|
||||
where = ['1=1']
|
||||
ns = {}
|
||||
if org_id and org_id != '0':
|
||||
where.append("org_id IN ('0', ${org_id}$)")
|
||||
ns['org_id'] = org_id
|
||||
if status:
|
||||
where.append('status = ${status}$')
|
||||
ns['status'] = status
|
||||
cond = ' AND '.join(where)
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
cnt = await sor.sqlExe('SELECT COUNT(*) AS cnt FROM entity_import WHERE ' + cond, ns)
|
||||
total = int(cnt[0].cnt) if cnt else 0
|
||||
offset = (page - 1) * rows
|
||||
ns['limit'] = int(rows)
|
||||
ns['offset'] = int(offset)
|
||||
recs = await sor.sqlExe(
|
||||
'SELECT id, import_no, file_name, total_count, success_count, fail_count, status, error_msg, created_at '
|
||||
'FROM entity_import WHERE ' + cond + ' ORDER BY created_at DESC LIMIT ${limit}$ OFFSET ${offset}$', ns)
|
||||
lst = [dict(r) for r in (recs or [])]
|
||||
return {'list': lst, 'total': total, 'page': page, 'rows': rows}
|
||||
except Exception as e:
|
||||
return {'code': 'IMPORT_LIST_ERROR', 'message': '导入记录查询失败', 'field': '', 'detail': str(e)}
|
||||
4
wwwroot/api/entity_import_reject.dspy
Normal file
4
wwwroot/api/entity_import_reject.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_import_reject.dspy —— 导入记录系统生成,禁止手工增删改
|
||||
result = await reject_import_write(request, params_kw)
|
||||
return result
|
||||
@ -1,12 +1,5 @@
|
||||
# entity_list.dspy —— 实体分页列表查询(REST /api/*,宿主挂载后 /entity/api/entity_list.dspy)
|
||||
# 返回 {list, total};错误统一 {code, message, field, detail}
|
||||
try:
|
||||
result = await list_entities(params_kw)
|
||||
if isinstance(result, dict) and 'list' in result and 'total' in result:
|
||||
return {'code': 'OK', 'message': 'success', 'data': result}
|
||||
return {'code': 'OK', 'message': 'success', 'data': {'list': result.get('list', []), 'total': result.get('total', 0)}}
|
||||
except Exception as e:
|
||||
if hasattr(e, 'to_dict'):
|
||||
err = e.to_dict()
|
||||
return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']}
|
||||
return {'code': 'DB_ERROR', 'message': '查询实体列表失败', 'field': '', 'detail': str(e)}
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_list.dspy —— 实体分页列表(REST 统一前缀 /api/*)
|
||||
# 返回 {list, total, page, rows};错误 {code, message, field, detail}
|
||||
result = await list_entities(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
# entity_update.dspy —— 实体更新(REST /api/*)
|
||||
try:
|
||||
result = await update_entity(params_kw)
|
||||
return {'code': 'OK', 'message': 'success', 'data': result}
|
||||
except Exception as e:
|
||||
if hasattr(e, 'to_dict'):
|
||||
err = e.to_dict()
|
||||
return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']}
|
||||
return {'code': 'DB_ERROR', 'message': '更新实体失败', 'field': '', 'detail': str(e)}
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_update.dspy —— 更新实体
|
||||
result = await update_entity(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
# get_search_entity_type.dspy —— 实体类型字典下拉(appcodes entity_type)[{value, text}]
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_entity_type.dspy —— 实体类型下拉([{value, text}],含全部)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
opts = await get_entity_type_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
recs = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'entity_type' ORDER BY k", {})
|
||||
result = result + [dict(r) for r in (recs or [])]
|
||||
except Exception as e:
|
||||
return {'code': 'DB_ERROR', 'message': '查询实体类型下拉失败', 'field': '', 'detail': str(e)}
|
||||
debug(f'get_search_entity_type error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
# get_search_import_status.dspy —— 导入状态字典下拉(appcodes import_status)[{value, text}]
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_import_status.dspy —— 导入状态下拉([{value, text}],含全部)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
opts = await get_import_status_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
recs = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'import_status' ORDER BY k", {})
|
||||
result = result + [dict(r) for r in (recs or [])]
|
||||
except Exception as e:
|
||||
return {'code': 'DB_ERROR', 'message': '查询导入状态下拉失败', 'field': '', 'detail': str(e)}
|
||||
debug(f'get_search_import_status error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
# get_search_scene_id.dspy —— 场景下拉数据源 [{value, text}]
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_scene_id.dspy —— 场景下拉([{value, text}],含全部;同库,dbname 已路由到 entity 库)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
opts = await get_scene_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
recs = await sor.sqlExe('SELECT id AS value, scene_name AS text FROM scene ORDER BY scene_name', {})
|
||||
result = result + [dict(r) for r in (recs or [])]
|
||||
except Exception as e:
|
||||
return {'code': 'DB_ERROR', 'message': '查询场景下拉失败', 'field': '', 'detail': str(e)}
|
||||
debug(f'get_search_scene_id error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
# get_search_status.dspy —— 实体状态字典下拉(appcodes entity_status)[{value, text}]
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_status.dspy —— 实体状态下拉([{value, text}],含全部)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
opts = await get_entity_status_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
recs = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'common_status' ORDER BY k", {})
|
||||
result = result + [dict(r) for r in (recs or [])]
|
||||
except Exception as e:
|
||||
return {'code': 'DB_ERROR', 'message': '查询状态下拉失败', 'field': '', 'detail': str(e)}
|
||||
debug(f'get_search_status error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
# get_search_world_id.dspy —— 世界下拉数据源 [{value, text}]
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_world_id.dspy —— 世界下拉([{value, text}],含全部;同库,dbname 已路由到 entity 库)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
opts = await get_world_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
recs = await sor.sqlExe('SELECT id AS value, world_name AS text FROM world ORDER BY world_name', {})
|
||||
result = result + [dict(r) for r in (recs or [])]
|
||||
except Exception as e:
|
||||
return {'code': 'DB_ERROR', 'message': '查询世界下拉失败', 'field': '', 'detail': str(e)}
|
||||
debug(f'get_search_world_id error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@ -1,96 +1,55 @@
|
||||
{
|
||||
"entity.page.index.title": "Entity Management",
|
||||
"entity.page.index.subtitle": "Entity table CRUD and list query",
|
||||
"entity.page.import.title": "Entity Import",
|
||||
"entity.page.import.subtitle": "Batch import entities from file (JSON Array / JSON Lines / CSV)",
|
||||
"entity.btn.add": "Add",
|
||||
"entity.btn.edit": "Edit",
|
||||
"entity.btn.delete": "Delete",
|
||||
"entity.btn.import": "Import",
|
||||
"entity.btn.refresh": "Refresh",
|
||||
"entity.btn.search": "Search",
|
||||
"entity.btn.reset": "Reset",
|
||||
"entity.btn.save": "Save",
|
||||
"entity.btn.cancel": "Cancel",
|
||||
"entity.btn.confirm": "OK",
|
||||
"entity.btn.choose_file": "Choose File",
|
||||
"entity.btn.start_import": "Start Import",
|
||||
"entity.btn.download_template": "Download Template",
|
||||
"entity.btn.back": "Back",
|
||||
"entity.col.name": "Entity Name",
|
||||
"entity.col.code": "Entity Code",
|
||||
"entity.col.entity_type": "Entity Type",
|
||||
"entity.col.status": "Status",
|
||||
"entity.col.world_id": "World",
|
||||
"entity.col.scene_id": "Scene",
|
||||
"entity.col.attributes_json": "Attributes",
|
||||
"entity.col.created_at": "Created At",
|
||||
"entity.col.updated_at": "Updated At",
|
||||
"entity.col.actions": "Actions",
|
||||
"entity.filter.world_id": "World",
|
||||
"entity.filter.scene_id": "Scene",
|
||||
"entity.filter.entity_type": "Entity Type",
|
||||
"entity.filter.status": "Status",
|
||||
"entity.filter.keyword": "Name/Code Keyword",
|
||||
"entity.filter.placeholder.select": "Select",
|
||||
"entity.filter.placeholder.input": "Input",
|
||||
"entity.form.name": "Entity Name",
|
||||
"entity.form.code": "Entity Code",
|
||||
"entity.form.world_id": "World",
|
||||
"entity.form.scene_id": "Scene",
|
||||
"entity.form.entity_type": "Entity Type",
|
||||
"entity.form.status": "Status",
|
||||
"entity.form.attributes_json": "Attributes (JSON)",
|
||||
"entity.form.placeholder.name": "Enter entity name",
|
||||
"entity.form.placeholder.code": "Enter entity code",
|
||||
"entity.form.placeholder.attributes_json": "e.g. {\"hp\":100,\"atk\":10}",
|
||||
"entity.import.form.world_id": "Target World",
|
||||
"entity.import.form.scene_id": "Target Scene",
|
||||
"entity.import.form.file": "Import File",
|
||||
"entity.import.col.file_name": "File Name",
|
||||
"entity.import.col.total": "Total",
|
||||
"entity.import.col.success": "Success",
|
||||
"entity.import.col.fail": "Fail",
|
||||
"entity.import.col.status": "Import Status",
|
||||
"entity.import.col.created_at": "Imported At",
|
||||
"entity.import.history.title": "Import History",
|
||||
"entity.msg.confirm_delete": "Delete this entity?",
|
||||
"entity.msg.delete_ok": "Deleted successfully",
|
||||
"entity.msg.delete_fail": "Delete failed",
|
||||
"entity.msg.save_ok": "Saved successfully",
|
||||
"entity.msg.save_fail": "Save failed",
|
||||
"entity.msg.importing": "Importing, please wait...",
|
||||
"entity.msg.import_ok": "Import done: {total} total, {success} succeeded, {fail} failed",
|
||||
"entity.msg.import_fail": "Import failed",
|
||||
"entity.msg.file_format_hint": "Supports JSON Array / JSON Lines / CSV (header row)",
|
||||
"entity.msg.file_required": "Please choose an import file first",
|
||||
"entity.msg.no_data": "No data",
|
||||
"entity.msg.loading": "Loading...",
|
||||
"entity.dict.entity_type.npc": "NPC",
|
||||
"entity.dict.entity_type.item": "Item",
|
||||
"entity.dict.entity_type.building": "Building",
|
||||
"entity.dict.entity_type.prop": "Prop",
|
||||
"entity.dict.entity_type.other": "Other",
|
||||
"entity.dict.entity_status.0": "Enabled",
|
||||
"entity.dict.entity_status.1": "Disabled",
|
||||
"entity.dict.import_status.0": "In Progress",
|
||||
"entity.dict.import_status.1": "Success",
|
||||
"entity.dict.import_status.2": "Failed",
|
||||
"entity.err.PARAM_REQUIRED": "Missing required parameter",
|
||||
"entity.err.FIELD_REQUIRED": "{label} is required",
|
||||
"entity.err.FIELD_TOO_LONG": "{label} length cannot exceed {max}",
|
||||
"entity.err.WORLD_NOT_FOUND": "World not found",
|
||||
"entity.err.SCENE_NOT_FOUND": "Scene not found",
|
||||
"entity.err.DUPLICATE_CODE": "Entity code already exists",
|
||||
"entity.err.PARSE_ERROR": "File parse error",
|
||||
"entity.err.INVALID_JSON": "Invalid attributes JSON",
|
||||
"entity.err.DB_ERROR": "Database operation failed",
|
||||
"entity.err.NOT_FOUND": "Record not found",
|
||||
"entity.field.name": "Entity Name",
|
||||
"entity.field.code": "Entity Code",
|
||||
"entity.field.world_id": "World",
|
||||
"entity.field.scene_id": "Scene",
|
||||
"entity.management": "Entity Management",
|
||||
"entity.list": "Entity List",
|
||||
"entity.list.desc": "Entity CRUD / paginated list query",
|
||||
"entity.import": "Entity Import",
|
||||
"entity.import.desc": "CSV/JSON file import with full validation and rollback",
|
||||
"entity.import.title": "Entity Import",
|
||||
"entity.import.tip": "Supports CSV (header row) or JSON array; fields: entity_code, entity_name, entity_type, scene_id, world_id, description, status, sort_no. Full validation before import; any invalid row blocks the whole batch; batch insert failure auto-rolls back with no dirty data.",
|
||||
"entity.import.select": "Select file and import",
|
||||
"entity.import.file": "Import file (CSV/JSON)",
|
||||
"entity.import.submit": "Start Import",
|
||||
"entity.import.guard": "Validate (dry-run)",
|
||||
"entity.import.guard.title": "Import Pre-validation (dry-run)",
|
||||
"entity.import.records": "Recent Import Records",
|
||||
"entity.import.records.title": "Import Records (read-only, system-generated)",
|
||||
"entity.import.import_no": "Import No.",
|
||||
"entity.import.file_name": "File Name",
|
||||
"entity.import.total_count": "Total Rows",
|
||||
"entity.import.success_count": "Success",
|
||||
"entity.import.fail_count": "Fail",
|
||||
"entity.import.status": "Status",
|
||||
"entity.import.created_at": "Imported At",
|
||||
"entity.field.entity_code": "Entity Code",
|
||||
"entity.field.entity_name": "Entity Name",
|
||||
"entity.field.entity_type": "Entity Type",
|
||||
"entity.field.scene_id": "Scene",
|
||||
"entity.field.world_id": "World",
|
||||
"entity.field.description": "Description",
|
||||
"entity.field.status": "Status",
|
||||
"entity.field.attributes_json": "Attributes JSON"
|
||||
}
|
||||
"entity.field.sort_no": "Sort No.",
|
||||
"entity.filter.keyword": "Name/Code",
|
||||
"entity.search": "Search",
|
||||
"entity.code.ENTITY_LIST_ERROR": "Entity list query failed",
|
||||
"entity.code.ENTITY_GET_ERROR": "Entity query failed",
|
||||
"entity.code.ENTITY_CREATE_ERROR": "Entity create failed",
|
||||
"entity.code.ENTITY_UPDATE_ERROR": "Entity update failed",
|
||||
"entity.code.ENTITY_DELETE_ERROR": "Entity delete failed",
|
||||
"entity.code.ENTITY_ID_REQUIRED": "Entity ID is required",
|
||||
"entity.code.ENTITY_NOT_FOUND": "Entity not found",
|
||||
"entity.code.ENTITY_VALIDATE_FAIL": "Validation failed",
|
||||
"entity.code.ENTITY_UPDATE_EMPTY": "No fields to update",
|
||||
"entity.code.IMPORT_ERROR": "Entity import failed",
|
||||
"entity.code.IMPORT_EMPTY_FILE": "Import file is empty",
|
||||
"entity.code.IMPORT_PARSE_ERROR": "File parse failed",
|
||||
"entity.code.IMPORT_EMPTY_ROWS": "No valid rows in file",
|
||||
"entity.code.IMPORT_TOO_MANY": "Single import cannot exceed 2000 rows",
|
||||
"entity.code.IMPORT_VALIDATE_FAIL": "Validation failed, no data imported",
|
||||
"entity.code.IMPORT_INSERT_FAIL": "Batch insert failed, rolled back",
|
||||
"entity.code.IMPORT_GUARD_ERROR": "Import pre-validation failed",
|
||||
"entity.code.IMPORT_ROW_INVALID": "Invalid row format",
|
||||
"entity.code.IMPORT_LIST_ERROR": "Import records query failed",
|
||||
"entity.code.IMPORT_RECORD_READONLY": "Import records are system-generated, manual write is forbidden",
|
||||
"entity.msg.import.success": "Import succeeded",
|
||||
"entity.msg.import.fail": "Import failed"
|
||||
}
|
||||
|
||||
@ -1,96 +1,55 @@
|
||||
{
|
||||
"entity.page.index.title": "实体管理",
|
||||
"entity.page.index.subtitle": "实体表(entity)增删改查与列表查询",
|
||||
"entity.page.import.title": "实体导入",
|
||||
"entity.page.import.subtitle": "通过文件批量导入实体(支持 JSON 数组 / JSON Lines / CSV)",
|
||||
"entity.btn.add": "新增",
|
||||
"entity.btn.edit": "编辑",
|
||||
"entity.btn.delete": "删除",
|
||||
"entity.btn.import": "导入",
|
||||
"entity.btn.refresh": "刷新",
|
||||
"entity.btn.search": "查询",
|
||||
"entity.btn.reset": "重置",
|
||||
"entity.btn.save": "保存",
|
||||
"entity.btn.cancel": "取消",
|
||||
"entity.btn.confirm": "确定",
|
||||
"entity.btn.choose_file": "选择文件",
|
||||
"entity.btn.start_import": "开始导入",
|
||||
"entity.btn.download_template": "下载模板",
|
||||
"entity.btn.back": "返回",
|
||||
"entity.col.name": "实体名称",
|
||||
"entity.col.code": "实体编码",
|
||||
"entity.col.entity_type": "实体类型",
|
||||
"entity.col.status": "状态",
|
||||
"entity.col.world_id": "所属世界",
|
||||
"entity.col.scene_id": "所属场景",
|
||||
"entity.col.attributes_json": "属性",
|
||||
"entity.col.created_at": "创建时间",
|
||||
"entity.col.updated_at": "更新时间",
|
||||
"entity.col.actions": "操作",
|
||||
"entity.filter.world_id": "所属世界",
|
||||
"entity.filter.scene_id": "所属场景",
|
||||
"entity.filter.entity_type": "实体类型",
|
||||
"entity.filter.status": "状态",
|
||||
"entity.filter.keyword": "名称/编码关键字",
|
||||
"entity.filter.placeholder.select": "请选择",
|
||||
"entity.filter.placeholder.input": "请输入",
|
||||
"entity.form.name": "实体名称",
|
||||
"entity.form.code": "实体编码",
|
||||
"entity.form.world_id": "所属世界",
|
||||
"entity.form.scene_id": "所属场景",
|
||||
"entity.form.entity_type": "实体类型",
|
||||
"entity.form.status": "状态",
|
||||
"entity.form.attributes_json": "属性(JSON)",
|
||||
"entity.form.placeholder.name": "请输入实体名称",
|
||||
"entity.form.placeholder.code": "请输入实体编码",
|
||||
"entity.form.placeholder.attributes_json": "如 {\"hp\":100,\"atk\":10}",
|
||||
"entity.import.form.world_id": "目标世界",
|
||||
"entity.import.form.scene_id": "目标场景",
|
||||
"entity.import.form.file": "导入文件",
|
||||
"entity.import.col.file_name": "文件名",
|
||||
"entity.import.col.total": "总数",
|
||||
"entity.import.col.success": "成功",
|
||||
"entity.import.col.fail": "失败",
|
||||
"entity.import.col.status": "导入状态",
|
||||
"entity.import.col.created_at": "导入时间",
|
||||
"entity.import.history.title": "导入记录",
|
||||
"entity.msg.confirm_delete": "确认删除该实体?",
|
||||
"entity.msg.delete_ok": "删除成功",
|
||||
"entity.msg.delete_fail": "删除失败",
|
||||
"entity.msg.save_ok": "保存成功",
|
||||
"entity.msg.save_fail": "保存失败",
|
||||
"entity.msg.importing": "导入中,请稍候...",
|
||||
"entity.msg.import_ok": "导入完成:共 {total} 条,成功 {success} 条,失败 {fail} 条",
|
||||
"entity.msg.import_fail": "导入失败",
|
||||
"entity.msg.file_format_hint": "支持 JSON 数组 / JSON Lines / CSV(首行表头)",
|
||||
"entity.msg.file_required": "请先选择导入文件",
|
||||
"entity.msg.no_data": "暂无数据",
|
||||
"entity.msg.loading": "加载中...",
|
||||
"entity.dict.entity_type.npc": "NPC",
|
||||
"entity.dict.entity_type.item": "物品",
|
||||
"entity.dict.entity_type.building": "建筑",
|
||||
"entity.dict.entity_type.prop": "道具",
|
||||
"entity.dict.entity_type.other": "其他",
|
||||
"entity.dict.entity_status.0": "启用",
|
||||
"entity.dict.entity_status.1": "禁用",
|
||||
"entity.dict.import_status.0": "进行中",
|
||||
"entity.dict.import_status.1": "成功",
|
||||
"entity.dict.import_status.2": "失败",
|
||||
"entity.err.PARAM_REQUIRED": "缺少必要参数",
|
||||
"entity.err.FIELD_REQUIRED": "{label}不能为空",
|
||||
"entity.err.FIELD_TOO_LONG": "{label}长度不能超过{max}",
|
||||
"entity.err.WORLD_NOT_FOUND": "所属世界不存在",
|
||||
"entity.err.SCENE_NOT_FOUND": "所属场景不存在",
|
||||
"entity.err.DUPLICATE_CODE": "实体编码已存在",
|
||||
"entity.err.PARSE_ERROR": "文件解析失败",
|
||||
"entity.err.INVALID_JSON": "属性JSON格式错误",
|
||||
"entity.err.DB_ERROR": "数据库操作失败",
|
||||
"entity.err.NOT_FOUND": "记录不存在",
|
||||
"entity.field.name": "实体名称",
|
||||
"entity.field.code": "实体编码",
|
||||
"entity.field.world_id": "所属世界",
|
||||
"entity.field.scene_id": "所属场景",
|
||||
"entity.management": "实体管理",
|
||||
"entity.list": "实体列表",
|
||||
"entity.list.desc": "实体表 CRUD / 分页列表查询",
|
||||
"entity.import": "实体导入",
|
||||
"entity.import.desc": "CSV/JSON 文件导入,校验失败全量拦截回滚",
|
||||
"entity.import.title": "实体导入",
|
||||
"entity.import.tip": "支持 CSV(首行表头)或 JSON 数组;字段:entity_code, entity_name, entity_type, scene_id, world_id, description, status, sort_no。导入前全量校验,任一行非法则整批拦截;批量插入失败自动回滚,不留脏数据。",
|
||||
"entity.import.select": "选择文件并导入",
|
||||
"entity.import.file": "导入文件(CSV/JSON)",
|
||||
"entity.import.submit": "开始导入",
|
||||
"entity.import.guard": "校验(不落库)",
|
||||
"entity.import.guard.title": "导入预校验(干跑)",
|
||||
"entity.import.records": "最近导入记录",
|
||||
"entity.import.records.title": "导入记录(只读,系统生成)",
|
||||
"entity.import.import_no": "导入单号",
|
||||
"entity.import.file_name": "文件名",
|
||||
"entity.import.total_count": "总行数",
|
||||
"entity.import.success_count": "成功",
|
||||
"entity.import.fail_count": "失败",
|
||||
"entity.import.status": "状态",
|
||||
"entity.import.created_at": "导入时间",
|
||||
"entity.field.entity_code": "实体编码",
|
||||
"entity.field.entity_name": "实体名称",
|
||||
"entity.field.entity_type": "实体类型",
|
||||
"entity.field.scene_id": "所属场景",
|
||||
"entity.field.world_id": "所属世界",
|
||||
"entity.field.description": "描述",
|
||||
"entity.field.status": "状态",
|
||||
"entity.field.attributes_json": "属性JSON"
|
||||
}
|
||||
"entity.field.sort_no": "排序号",
|
||||
"entity.filter.keyword": "实体名称/编码",
|
||||
"entity.search": "搜索",
|
||||
"entity.code.ENTITY_LIST_ERROR": "实体列表查询失败",
|
||||
"entity.code.ENTITY_GET_ERROR": "实体查询失败",
|
||||
"entity.code.ENTITY_CREATE_ERROR": "实体创建失败",
|
||||
"entity.code.ENTITY_UPDATE_ERROR": "实体更新失败",
|
||||
"entity.code.ENTITY_DELETE_ERROR": "实体删除失败",
|
||||
"entity.code.ENTITY_ID_REQUIRED": "实体ID不能为空",
|
||||
"entity.code.ENTITY_NOT_FOUND": "实体不存在",
|
||||
"entity.code.ENTITY_VALIDATE_FAIL": "数据校验失败",
|
||||
"entity.code.ENTITY_UPDATE_EMPTY": "没有需要更新的字段",
|
||||
"entity.code.IMPORT_ERROR": "实体导入失败",
|
||||
"entity.code.IMPORT_EMPTY_FILE": "导入文件为空",
|
||||
"entity.code.IMPORT_PARSE_ERROR": "文件解析失败",
|
||||
"entity.code.IMPORT_EMPTY_ROWS": "文件中没有有效数据行",
|
||||
"entity.code.IMPORT_TOO_MANY": "单次导入不能超过2000行",
|
||||
"entity.code.IMPORT_VALIDATE_FAIL": "导入校验失败,未导入任何数据",
|
||||
"entity.code.IMPORT_INSERT_FAIL": "实体批量插入失败,已回滚",
|
||||
"entity.code.IMPORT_GUARD_ERROR": "导入预校验失败",
|
||||
"entity.code.IMPORT_ROW_INVALID": "行数据格式非法",
|
||||
"entity.code.IMPORT_LIST_ERROR": "导入记录查询失败",
|
||||
"entity.code.IMPORT_RECORD_READONLY": "导入记录为系统生成,禁止手工增删改",
|
||||
"entity.msg.import.success": "导入成功",
|
||||
"entity.msg.import.fail": "导入失败"
|
||||
}
|
||||
|
||||
@ -2,20 +2,74 @@
|
||||
"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,world_id,scene_id,entity_type,status,attributes_json)。事务批量:任一条校验失败整批回滚,无脏数据。"}},
|
||||
{"widgettype": "Form",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"submit_url": "{{entire_url('/entity/api/entity_import.dspy')}}",
|
||||
"method": "POST",
|
||||
"fields": [
|
||||
{"name": "world_id", "label": "目标世界ID", "uitype": "code", "dataurl": "{{entire_url('/entity/api/get_search_world_id.dspy')}}"},
|
||||
{"name": "scene_id", "label": "目标场景ID", "uitype": "code", "dataurl": "{{entire_url('/entity/api/get_search_scene_id.dspy')}}"},
|
||||
{"name": "file_name", "label": "文件名", "uitype": "text"},
|
||||
{"name": "file", "label": "导入文件", "uitype": "file", "required": true}
|
||||
],
|
||||
"buttons": [{"label": "开始导入", "type": "submit"}]
|
||||
}}
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"label": "实体导入", "fontSize": "20px", "fontWeight": "bold"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"label": "支持 CSV(首行表头)或 JSON 数组;字段:entity_code, entity_name, entity_type, scene_id, world_id, description, status, sort_no。导入前全量校验,任一行非法则整批拦截;批量插入失败自动回滚,不留脏数据。", "fontSize": "12px", "color": "#666666"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"title": "选择文件并导入",
|
||||
"submit_url": "{{entire_url('/entity/api/entity_import.dspy')}}",
|
||||
"method": "POST",
|
||||
"width": "640px",
|
||||
"padding": "16px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "UiFile",
|
||||
"options": {"label": "导入文件(CSV/JSON)", "name": "file", "required": true}
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {"gap": "12px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"options": {"label": "开始导入", "actiontype": "submit", "width": "120px"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"options": {"label": "校验(不落库)", "width": "120px"},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "PopupWindow",
|
||||
"popup_options": {"title": "导入预校验(干跑)", "height": "300px", "width": "600px"},
|
||||
"options": {"method": "POST", "url": "{{entire_url('/entity/api/entity_import_guard.dspy')}}"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"label": "最近导入记录", "fontSize": "16px", "fontWeight": "bold", "marginTop": "24px"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Tabular",
|
||||
"options": {
|
||||
"title": "导入记录(只读,系统生成)",
|
||||
"data_url": "{{entire_url('/entity/api/entity_import_list.dspy')}}",
|
||||
"data_params": {"page": 1, "rows": 20},
|
||||
"columns": [
|
||||
{"field": "import_no", "title": "导入单号", "width": "20%"},
|
||||
{"field": "file_name", "title": "文件名", "width": "22%"},
|
||||
{"field": "total_count", "title": "总行数", "width": "10%"},
|
||||
{"field": "success_count", "title": "成功", "width": "10%"},
|
||||
{"field": "fail_count", "title": "失败", "width": "10%"},
|
||||
{"field": "status", "title": "状态", "width": "12%"},
|
||||
{"field": "created_at", "title": "导入时间", "width": "16%"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -2,18 +2,54 @@
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "实体管理(W-03)", "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.entity_content", "mode": "replace", "options": {"url": "{{entire_url('/entity/entity_list')}}"}}],
|
||||
"subwidgets": [{"widgettype": "Text", "options": {"label": "实体列表"}}]},
|
||||
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.entity_content", "mode": "replace", "options": {"url": "{{entire_url('/entity/import_page.ui')}}"}}],
|
||||
"subwidgets": [{"widgettype": "Text", "options": {"label": "实体导入"}}]},
|
||||
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.entity_content", "mode": "replace", "options": {"url": "{{entire_url('/entity/entity_import_list')}}"}}],
|
||||
"subwidgets": [{"widgettype": "Text", "options": {"label": "导入记录"}}]}
|
||||
]},
|
||||
{"widgettype": "VBox", "id": "app.entity_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"label": "实体管理", "fontSize": "24px", "fontWeight": "bold"}
|
||||
},
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {"gap": "16px", "minWidth": "250px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "boxShadow": "0 1px 4px rgba(0,0,0,0.08)"},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.entity_content",
|
||||
"options": {"url": "{{entire_url('entity/entity_list.ui')}}", "mode": "replace"}
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "实体列表", "fontSize": "16px"}},
|
||||
{"widgettype": "Text", "options": {"label": "实体表 CRUD / 分页列表查询", "fontSize": "12px", "color": "#888888"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "boxShadow": "0 1px 4px rgba(0,0,0,0.08)"},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.entity_content",
|
||||
"options": {"url": "{{entire_url('import_page.ui')}}", "mode": "replace"}
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "实体导入", "fontSize": "16px"}},
|
||||
{"widgettype": "Text", "options": {"label": "CSV/JSON 文件导入,校验失败全量拦截回滚", "fontSize": "12px", "color": "#888888"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "app.entity_content",
|
||||
"options": {"width": "100%", "flex": "1", "marginTop": "20px"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user