approve: entity 模块开发(W-03 实体管理)
This commit is contained in:
parent
d049837ec7
commit
c40e2ce8a0
17
.gitignore
vendored
17
.gitignore
vendored
@ -1,16 +1 @@
|
||||
# __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# CRUD 生成目录(xls2ui 构建产物,由 json/*.json + models/*.json 重新生成,不入库)
|
||||
wwwroot/entity/
|
||||
wwwroot/entity_import/
|
||||
|
||||
# DDL 生成物
|
||||
models/mysql.ddl.sql
|
||||
忽略生成物/CRUD 目录
|
||||
45
README.md
45
README.md
@ -1,44 +1 @@
|
||||
# entity 实体管理模块(W-03)
|
||||
|
||||
实体管理模块:管理元景项目的实体(entity)数据,提供实体表 CRUD、分页列表查询与实体文件导入。
|
||||
|
||||
## 功能
|
||||
|
||||
- **实体表 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` | 实体表(entity_code 唯一,entity_type 字典,scene_id/world_id 逻辑关联 codes 段,不加外键) |
|
||||
| `entity_import` | 实体导入批次记录(import_no 唯一,status 字典) |
|
||||
| `entity_import_log` | 实体导入逐行明细日志(import_id 关联批次) |
|
||||
|
||||
## 接口约定
|
||||
|
||||
- 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% 拦截,不落库。
|
||||
|
||||
## 集成
|
||||
|
||||
通过 `load_entity()` 挂载到宿主应用(`app/{应用名}.py` 的 init() 中调用并注册 `get_module_dbname('entity')`),库名禁止硬编码。前端页面:`wwwroot/index.ui`(入口导航)、`wwwroot/import_page.ui`(导入页)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
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 # 模块技能文档
|
||||
```
|
||||
模块说明
|
||||
@ -1,5 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
modules/entity/ 模块仓库根目录标记文件(遗留,不影响打包)。
|
||||
真实 Python 包目录为双层结构:modules/entity/entity/(见 entity/__init__.py)。
|
||||
"""
|
||||
仓库根标记(双层结构说明)
|
||||
30
build.sh
30
build.sh
@ -1,29 +1 @@
|
||||
#!/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
|
||||
构建脚本(DDL+xls2ui+wwwroot链接)
|
||||
23
docs/i18n.md
23
docs/i18n.md
@ -1,22 +1 @@
|
||||
# entity 模块 i18n 说明
|
||||
|
||||
## 提取范围
|
||||
本模块全部新增文案(页面标题、按钮、提示、错误码、字段名、过滤标签、消息)均已提取到 `wwwroot/i18n/`:
|
||||
|
||||
| 文件 | 语言 | 用途 |
|
||||
|------|------|------|
|
||||
| `wwwroot/i18n/zh-CN.json` | 简体中文 | 默认语言 |
|
||||
| `wwwroot/i18n/en-US.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 注入全局)。
|
||||
|
||||
## 与后端错误码的对应
|
||||
后端错误结构统一为 `{code, message, field, detail}`,其中 `code` 取值如 `ENTITY_VALIDATE_FAIL`、`IMPORT_VALIDATE_FAIL` 等。前端展示文案使用 `entity.code.<CODE>` 作为 key 查找 i18n;`message` 字段为后端兜底文案(中文)。
|
||||
|
||||
## 新增文案流程
|
||||
1. 在 `wwwroot/i18n/zh-CN.json` 与 `en-US.json` 同时新增同 key。
|
||||
2. 同步更新 `docs/i18n.md` 本说明(如 key 分组有变化)。
|
||||
3. 部署时 i18n 文件随 wwwroot 一起链接到宿主应用 wwwroot。
|
||||
i18n 提取说明
|
||||
@ -1,34 +1 @@
|
||||
# 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,23 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""entity 模块包(W-03 实体管理)——所有 init.py 中 async 函数必须在此导出,否则 dspy 调用 NameError"""
|
||||
from .init import (
|
||||
create_entity,
|
||||
update_entity,
|
||||
delete_entity,
|
||||
get_entity,
|
||||
list_entities,
|
||||
import_entities,
|
||||
entity_import_guard,
|
||||
reject_import_write,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'create_entity',
|
||||
'update_entity',
|
||||
'delete_entity',
|
||||
'get_entity',
|
||||
'list_entities',
|
||||
'import_entities',
|
||||
'entity_import_guard',
|
||||
'reject_import_write',
|
||||
]
|
||||
包导出(防 dspy NameError)
|
||||
BIN
entity/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
entity/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
entity/__pycache__/init.cpython-310.pyc
Normal file
BIN
entity/__pycache__/init.cpython-310.pyc
Normal file
Binary file not shown.
486
entity/init.py
486
entity/init.py
@ -1,485 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
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
|
||||
|
||||
# 与 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',
|
||||
}
|
||||
|
||||
|
||||
def _err(code, message, field='', detail=''):
|
||||
"""统一 REST 错误结构"""
|
||||
return {'code': code, 'message': message, 'field': field, 'detail': detail}
|
||||
|
||||
|
||||
def _ctx():
|
||||
"""模块级数据库上下文(库名由宿主应用 get_module_dbname('entity') 解析)"""
|
||||
return get_sor_context(ServerEnv(), 'entity')
|
||||
|
||||
|
||||
async def _current_user(request):
|
||||
"""从请求运行上下文取当前用户/机构(ServerEnv() 是进程级单例,不能用于请求上下文)"""
|
||||
rn = request._run_ns
|
||||
user_id, org_id = '', '0'
|
||||
if hasattr(rn, 'get_user'):
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
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 _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:
|
||||
return [], '导入文件内容为空'
|
||||
if text.startswith('['):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
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:
|
||||
return [], f'CSV 解析失败: {e}'
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
实体文件导入(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:
|
||||
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:
|
||||
return _err('IMPORT_ERROR', '实体导入失败', '', str(e))
|
||||
|
||||
|
||||
async def entity_import_guard(request, params_kw):
|
||||
"""导入预校验(干跑):只校验不落库,返回逐行错误(api/entity_import_guard.dspy)"""
|
||||
try:
|
||||
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 reject_import_write(request, params_kw):
|
||||
"""entity_import 记录为系统生成,禁止手工增删改"""
|
||||
return _err('IMPORT_RECORD_READONLY', '导入记录为系统生成,禁止手工增删改', '')
|
||||
|
||||
|
||||
def load_entity(env):
|
||||
"""注册模块函数到 ServerEnv(.dspy/.ui 可直接调用)"""
|
||||
env.create_entity = create_entity
|
||||
env.create_entitys = create_entity
|
||||
env.update_entity = update_entity
|
||||
env.update_entitys = update_entity
|
||||
env.delete_entity = delete_entity
|
||||
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.entity_import_guard = entity_import_guard
|
||||
env.reject_import_write = reject_import_write
|
||||
return env
|
||||
后端实现:create/update/delete/get/list_entities/import_entities/entity_import_guard/reject_import_write + load_entity() 注册;_err() 统一错误结构;两阶段导入+回滚
|
||||
@ -1,34 +1 @@
|
||||
{
|
||||
"appcodes": [
|
||||
{
|
||||
"parentid": "entity_type",
|
||||
"parentname": "实体类型",
|
||||
"items": [
|
||||
{"k": "character", "v": "角色"},
|
||||
{"k": "prop", "v": "道具"},
|
||||
{"k": "vehicle", "v": "载具"},
|
||||
{"k": "building", "v": "建筑"},
|
||||
{"k": "faction", "v": "阵营"},
|
||||
{"k": "other", "v": "其他"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "common_status",
|
||||
"parentname": "通用状态",
|
||||
"items": [
|
||||
{"k": "1", "v": "启用"},
|
||||
{"k": "0", "v": "停用"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "import_status",
|
||||
"parentname": "导入状态",
|
||||
"items": [
|
||||
{"k": "importing", "v": "导入中"},
|
||||
{"k": "success", "v": "成功"},
|
||||
{"k": "fail", "v": "失败"},
|
||||
{"k": "partial", "v": "部分成功"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
entity_type/common_status/import_status 字典种子(Format B)
|
||||
@ -1,33 +1 @@
|
||||
{
|
||||
"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')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
实体 CRUD 定义(editable + data_filter + alters 下拉)
|
||||
@ -1,22 +1 @@
|
||||
{
|
||||
"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')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
导入记录只读 CRUD 定义
|
||||
@ -1,40 +1 @@
|
||||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
实体表四段式定义(codes 逻辑关联 scene/world,entity_code 唯一)
|
||||
@ -1,31 +1 @@
|
||||
{
|
||||
"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,23 +1 @@
|
||||
{
|
||||
"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,14 +1 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "entity"
|
||||
version = "1.0.0"
|
||||
description = "实体管理模块(W-03)——实体表 CRUD、列表查询、实体文件导入(entity_import 记录)"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = ["sqlor", "bricks_for_python"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["entity*"]
|
||||
打包配置 name=entity
|
||||
BIN
scripts/__pycache__/load_path.cpython-310.pyc
Normal file
BIN
scripts/__pycache__/load_path.cpython-310.pyc
Normal file
Binary file not shown.
@ -1,65 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
entity 模块 RBAC 路径注册脚本(显式路径,禁止通配符)。
|
||||
用法:python scripts/load_path.py(自动寻找 Sage 根目录并调用 set_role_perm.py)
|
||||
维护规则:每次新增/删除 wwwroot 下 path,需同步更新本脚本。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
MODULE = 'entity'
|
||||
|
||||
PATHS_ANY = [
|
||||
f'/{MODULE}/index.ui',
|
||||
]
|
||||
|
||||
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():
|
||||
sage_root = find_sage_root()
|
||||
if not sage_root:
|
||||
print('[entity] sage root not found, skip RBAC registration')
|
||||
return
|
||||
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__':
|
||||
main()
|
||||
RBAC 显式路径注册(无通配符)
|
||||
@ -1,63 +1 @@
|
||||
---
|
||||
name: entity
|
||||
description: 实体管理模块(W-03)——实体表(entity)CRUD、列表查询、实体文件导入(entity_import 记录),通过 load_entity() 挂载。
|
||||
---
|
||||
|
||||
# entity 实体管理模块
|
||||
|
||||
## 概述
|
||||
|
||||
元景项目 W-03 实体管理:管理实体(entity)数据。实体归属于场景(scene_id→scene.id,逻辑关联,不加外键)与世界(world_id→world.id)。提供实体表 CRUD、分页列表查询、CSV/JSON 文件导入(两阶段校验+批量插入+失败回滚)。
|
||||
|
||||
## 数据模型
|
||||
|
||||
| 表 | 说明 |
|
||||
|----|------|
|
||||
| `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=失败 |
|
||||
|
||||
编码字典(init/data.json 幂等落库 appcodes/appcodes_kv):
|
||||
- `entity_type`:character/prop/vehicle/building/faction/other
|
||||
- `common_status`:1=启用 / 0=停用
|
||||
- `import_status`:importing/success/fail
|
||||
|
||||
## 关键接口(REST 统一前缀 /api/*)
|
||||
|
||||
| 接口 | 方法 | 说明 | 返回 |
|
||||
|------|------|------|------|
|
||||
| `/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}` |
|
||||
|
||||
错误结构统一:`{code, message, field, detail}`。分页结构统一:`{list, total}`。
|
||||
|
||||
## 导入处理逻辑
|
||||
|
||||
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` 明细。
|
||||
|
||||
## 陷阱
|
||||
|
||||
- **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 等引用,删除前可扩展引用检查(当前实现仅校验存在性)。
|
||||
|
||||
## 依赖
|
||||
|
||||
- 基础:sqlor、ahserver、appPublic
|
||||
- 数据依赖(逻辑关联,不物理外键):`scene`(scene.id)、`world`(world.id)——codes 段引用,表缺失时仅下拉不渲染,不影响 CRUD
|
||||
- 字典:appbase 的 appcodes / appcodes_kv
|
||||
- 被 `world_snapshot`(W-03 快照)聚合读取
|
||||
模块技能文档
|
||||
@ -1,4 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_create.dspy —— 新增实体(非法输入 100% 拦截不落库)
|
||||
result = await create_entity(request, params_kw)
|
||||
return result
|
||||
新增
|
||||
@ -1,4 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_delete.dspy —— 删除实体
|
||||
result = await delete_entity(request, params_kw)
|
||||
return result
|
||||
删除
|
||||
@ -1,4 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_get.dspy —— 实体详情
|
||||
result = await get_entity(request, params_kw)
|
||||
return result
|
||||
详情
|
||||
@ -1,5 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_import.dspy —— 实体文件导入(文件域 file / file_content;两阶段校验+批量插入+失败回滚)
|
||||
# 调用后端 import_entities(request, params_kw)
|
||||
result = await import_entities(request, params_kw)
|
||||
return result
|
||||
文件导入(两阶段+回滚)
|
||||
@ -1,4 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_import_guard.dspy —— 导入预校验(干跑,只校验不落库)
|
||||
result = await entity_import_guard(request, params_kw)
|
||||
return result
|
||||
导入预校验(干跑)
|
||||
@ -1,35 +1 @@
|
||||
# -*- 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)}
|
||||
导入记录只读分页
|
||||
@ -1,4 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_import_reject.dspy —— 导入记录系统生成,禁止手工增删改
|
||||
result = await reject_import_write(request, params_kw)
|
||||
return result
|
||||
导入记录禁手工增删改
|
||||
@ -1,5 +1 @@
|
||||
# -*- 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
|
||||
分页列表 {list,total}
|
||||
@ -1,4 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# entity_update.dspy —— 更新实体
|
||||
result = await update_entity(request, params_kw)
|
||||
return result
|
||||
更新
|
||||
@ -1,10 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_entity_type.dspy —— 实体类型下拉([{value, text}],含全部)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
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:
|
||||
debug(f'get_search_entity_type error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
实体类型下拉
|
||||
@ -1,10 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_import_status.dspy —— 导入状态下拉([{value, text}],含全部)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
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:
|
||||
debug(f'get_search_import_status error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
导入状态下拉
|
||||
@ -1,10 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_scene_id.dspy —— 场景下拉([{value, text}],含全部;同库,dbname 已路由到 entity 库)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
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:
|
||||
debug(f'get_search_scene_id error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
场景下拉
|
||||
@ -1,10 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_status.dspy —— 实体状态下拉([{value, text}],含全部)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
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:
|
||||
debug(f'get_search_status error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
状态下拉
|
||||
@ -1,10 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# get_search_world_id.dspy —— 世界下拉([{value, text}],含全部;同库,dbname 已路由到 entity 库)
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
try:
|
||||
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:
|
||||
debug(f'get_search_world_id error: {e}')
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
世界下拉
|
||||
@ -1,55 +1 @@
|
||||
{
|
||||
"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.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"
|
||||
}
|
||||
英文 i18n(全部新文案)
|
||||
@ -1,55 +1 @@
|
||||
{
|
||||
"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.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": "导入失败"
|
||||
}
|
||||
简体中文 i18n(全部新文案)
|
||||
@ -1,75 +1 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"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%"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
导入页(上传+干跑校验+最近记录)
|
||||
@ -1,55 +1 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"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