feat: 元景 demo 模块(W-0x 开发产出)
This commit is contained in:
commit
03d3e3ae28
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# 构建产物(xls2ui 自动生成,勿入库)
|
||||
wwwroot/demo_session/
|
||||
|
||||
# 编辑器/系统
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
47
README.md
Normal file
47
README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# demo —— W-07 实时演示模块
|
||||
|
||||
## 用途
|
||||
在元景(scense)平台中为世界(world)提供「实时演示」能力:编辑器点击「演示」进入预览态,
|
||||
可在演示中实时编辑实体(前端 <1s 反映)、保存脚本即热加载、切换视角/暂停继续/全屏、
|
||||
多人同时预览。演示期间的任何改动只落在演示覆盖层,结束演示后**正式数据不变**(状态隔离)。
|
||||
|
||||
## 功能点(W-07a ~ W-07j)
|
||||
| 编号 | 功能 | 接口 |
|
||||
|------|------|------|
|
||||
| W-07a | 开始演示(进入预览态) | POST /demo/api/start.dspy |
|
||||
| W-07b | 结束演示(清理覆盖层) | POST /demo/api/stop.dspy |
|
||||
| W-07c | 编辑实体实时反映(<1s) | POST /demo/api/entity_edit.dspy |
|
||||
| W-07d | 脚本保存热加载 | POST /demo/api/hotload.dspy |
|
||||
| W-07e | 视角切换 | POST /demo/api/camera.dspy |
|
||||
| W-07f | 暂停/继续 | POST /demo/api/pause.dspy |
|
||||
| W-07g | 全屏 | POST /demo/api/fullscreen.dspy |
|
||||
| W-07h | 状态隔离(演示改动不污染正式数据) | 覆盖层表 + stop 清理 |
|
||||
| W-07i | 查看实体属性 | POST /demo/api/entity_attrs.dspy |
|
||||
| W-07j | 多人预览 | POST /demo/api/presence.dspy |
|
||||
| - | 状态轮询(500ms,<1s 反映) | POST /demo/api/state.dspy |
|
||||
|
||||
## 数据表(models/*.json)
|
||||
- `demo_session`:演示会话(status=preview/paused/ended,camera_mode,viewer_count,extra_json)
|
||||
- `demo_session_state`:实体状态覆盖层(base_json=基线快照,demo_json=演示中当前值)
|
||||
- `demo_hot_script`:演示中热加载脚本
|
||||
- `demo_presence`:多人预览在线表
|
||||
|
||||
## 状态隔离原理
|
||||
演示开始把实体当前状态复制为 `base_json`(只读基线);演示中编辑只写 `demo_session_state.demo_json`,
|
||||
**从不写正式 entity/scene/world 表**。`stop_demo` 删除会话+覆盖层+脚本+在线表 —— 正式数据零污染。
|
||||
|
||||
## 前端
|
||||
- `wwwroot/demo_viewer.ui` + `demo_viewer.js`:three.js 渲染世界 + 500ms 轮询 state.dspy
|
||||
- `wwwroot/index.ui`:模块入口卡片
|
||||
|
||||
## 安装 / 集成(Sage 宿主)
|
||||
1. `pip install .`(模块包)
|
||||
2. `app/sage.py`:`from demo.init import load_demo` + `load_demo()` 于 init()
|
||||
3. `build.sh`:安装循环加入 demo;`ln -sf <module>/wwwroot <sage>/wwwroot/demo`
|
||||
4. `scripts/load_path.py`:注册 RBAC(logined);中央 load_path.py 同步登记
|
||||
5. `global_menu.ui`:`{"name":"demo","label":"实时演示","url":"{{entire_url('/demo/index.ui')}}","target":"app.sage_main_content"}`
|
||||
6. 部署库名由宿主 `get_module_dbname('demo')` 决定,模块内禁止硬编码 DBNAME
|
||||
|
||||
## 验收口径
|
||||
- 启动/结束演示接口 200;演示中编辑实体前端 <1s 反映(500ms 轮询);退出后正式 entity/world 数据不变
|
||||
- 每个功能点正例 + 反例自测(见 skill/SKILL.md 与 docs/)
|
||||
24
demo/__init__.py
Normal file
24
demo/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""demo —— W-07 实时演示模块(Python 包)。
|
||||
|
||||
所有业务函数必须在此导出,否则 .dspy 调用报 NameError。
|
||||
"""
|
||||
from .demo import (
|
||||
start_demo, stop_demo, switch_camera, pause_demo,
|
||||
update_demo_entity, get_entity_attrs, hotload_script,
|
||||
presence_join, presence_leave, set_fullscreen,
|
||||
get_demo_state, list_active_sessions,
|
||||
DemoParamError, DemoNotFoundError, DemoDuplicateError, DemoStateError,
|
||||
DEMO_CAMERA_MODES,
|
||||
)
|
||||
from .init import load_demo
|
||||
|
||||
__all__ = [
|
||||
'load_demo',
|
||||
'start_demo', 'stop_demo', 'switch_camera', 'pause_demo',
|
||||
'update_demo_entity', 'get_entity_attrs', 'hotload_script',
|
||||
'presence_join', 'presence_leave', 'set_fullscreen',
|
||||
'get_demo_state', 'list_active_sessions',
|
||||
'DemoParamError', 'DemoNotFoundError', 'DemoDuplicateError', 'DemoStateError',
|
||||
'DEMO_CAMERA_MODES',
|
||||
]
|
||||
438
demo/demo.py
Normal file
438
demo/demo.py
Normal file
@ -0,0 +1,438 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
demo —— W-07 实时演示模块业务逻辑。
|
||||
|
||||
数据模型:
|
||||
demo_session 演示会话(world_id/status/camera_mode/viewer_count/extra_json)
|
||||
demo_session_state 实体状态覆盖层(base_json=演示开始基线,demo_json=演示中当前值)
|
||||
demo_hot_script 演示中保存即热加载的脚本
|
||||
demo_presence 多人预览在线用户
|
||||
|
||||
核心原则(状态隔离):
|
||||
演示期间对实体的改动只写入覆盖层 demo_session_state,绝不写正式 entity/scene/world 表;
|
||||
结束演示(stop_demo)即删除覆盖层与热加载脚本/在线表 —— 正式数据保持不变。
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import random
|
||||
import datetime
|
||||
|
||||
try:
|
||||
from appPublic.sqlor import DBPools
|
||||
except ImportError: # pragma: no cover
|
||||
from sqlor import DBPools
|
||||
|
||||
try:
|
||||
from appPublic.log import debug, info, warning, exception
|
||||
except ImportError: # pragma: no cover
|
||||
from appPublic.log import debug, info, warning, exception
|
||||
|
||||
try:
|
||||
from ahserver.server_env import ServerEnv
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
from ahserver import ServerEnv
|
||||
except ImportError:
|
||||
ServerEnv = None
|
||||
|
||||
|
||||
class DemoParamError(ValueError):
|
||||
"""参数非法 → 400"""
|
||||
|
||||
|
||||
class DemoNotFoundError(LookupError):
|
||||
"""对象不存在 → 404"""
|
||||
|
||||
|
||||
class DemoDuplicateError(Exception):
|
||||
"""重复操作 → 409"""
|
||||
|
||||
|
||||
class DemoStateError(Exception):
|
||||
"""会话状态不允许该操作 → 400"""
|
||||
|
||||
|
||||
DEMO_CAMERA_MODES = ('orbit', 'topdown', 'firstperson')
|
||||
DEMO_STATUS_RUNNING = 'preview'
|
||||
DEMO_STATUS_PAUSED = 'paused'
|
||||
DEMO_STATUS_ENDED = 'ended'
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def _new_id(prefix):
|
||||
return '%s_%d_%d' % (prefix, int(time.time() * 1000), random.randint(1000, 9999))
|
||||
|
||||
|
||||
def _ctx():
|
||||
if ServerEnv is None:
|
||||
raise RuntimeError('ServerEnv 不可用,demo 模块需在 ahserver 宿主中加载')
|
||||
env = ServerEnv()
|
||||
dbname = env.get_module_dbname('demo')
|
||||
return DBPools().sqlorContext(dbname)
|
||||
|
||||
|
||||
def _loads(s):
|
||||
try:
|
||||
return json.loads(s or '{}')
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def _get_session(sor, session_id):
|
||||
recs = await sor.R('demo_session', {'id': session_id})
|
||||
return recs[0] if recs else None
|
||||
|
||||
|
||||
async def _count_viewers(sor, session_id):
|
||||
rows = await sor.sqlExe(
|
||||
"SELECT COUNT(*) AS cnt FROM demo_presence WHERE session_id=${session_id}$",
|
||||
{'session_id': session_id})
|
||||
if rows:
|
||||
return int(getattr(rows[0], 'cnt', 0) or 0)
|
||||
return 0
|
||||
|
||||
|
||||
async def _check_world(world_id):
|
||||
"""跨模块校验世界是否存在;world 模块未挂载/表缺失时跳过校验(防御式)。"""
|
||||
try:
|
||||
env = ServerEnv()
|
||||
wdb = env.get_module_dbname('world')
|
||||
async with DBPools().sqlorContext(wdb) as sor:
|
||||
recs = await sor.R('world', {'id': world_id})
|
||||
return bool(recs), True
|
||||
except Exception as e:
|
||||
warning('demo: 世界存在性校验不可用(跳过): %s' % e)
|
||||
return True, False
|
||||
|
||||
|
||||
# ---------------- W-07a 开始演示(进入预览态) ----------------
|
||||
async def start_demo(world_id, session_name='', entities=None):
|
||||
if not world_id or not isinstance(world_id, str):
|
||||
raise DemoParamError('world_id 不能为空')
|
||||
if isinstance(entities, str):
|
||||
try:
|
||||
entities = json.loads(entities)
|
||||
except Exception:
|
||||
raise DemoParamError('entities 不是合法 JSON')
|
||||
if entities is not None and not isinstance(entities, list):
|
||||
raise DemoParamError('entities 必须是数组')
|
||||
ent_items = []
|
||||
for ent in (entities or []):
|
||||
if not isinstance(ent, dict) or not ent.get('entity_id'):
|
||||
raise DemoParamError('entities 每项必须包含 entity_id')
|
||||
f = ent.get('fields') if isinstance(ent.get('fields'), dict) else {}
|
||||
ent_items.append({
|
||||
'entity_id': str(ent['entity_id']),
|
||||
'entity_name': str(ent.get('entity_name') or ent['entity_id'])[:128],
|
||||
'fields': f,
|
||||
})
|
||||
exists, checked = await _check_world(world_id)
|
||||
if checked and not exists:
|
||||
raise DemoNotFoundError('世界不存在: %s' % world_id)
|
||||
sid = _new_id('ds')
|
||||
now = _now()
|
||||
name = (session_name or '')[:128] or ('演示-%s' % world_id)
|
||||
async with _ctx() as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id FROM demo_session WHERE world_id=${world_id}$ "
|
||||
"AND status IN ('preview','paused') LIMIT 1",
|
||||
{'world_id': world_id})
|
||||
if recs:
|
||||
raise DemoDuplicateError('该世界已有进行中的演示会话: %s' % recs[0].id)
|
||||
await sor.C('demo_session', {
|
||||
'id': sid,
|
||||
'world_id': world_id,
|
||||
'session_name': name,
|
||||
'status': DEMO_STATUS_RUNNING,
|
||||
'camera_mode': 'orbit',
|
||||
'owner_id': '0',
|
||||
'viewer_count': 0,
|
||||
'extra_json': '{}',
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
})
|
||||
entities_out = []
|
||||
for item in ent_items:
|
||||
base = json.dumps(item['fields'], ensure_ascii=False)
|
||||
await sor.C('demo_session_state', {
|
||||
'id': _new_id('st'),
|
||||
'session_id': sid,
|
||||
'entity_id': item['entity_id'],
|
||||
'entity_name': item['entity_name'],
|
||||
'base_json': base,
|
||||
'demo_json': base,
|
||||
'updated_at': now,
|
||||
})
|
||||
entities_out.append({'entity_id': item['entity_id'], 'entity_name': item['entity_name']})
|
||||
return {
|
||||
'session_id': sid,
|
||||
'world_id': world_id,
|
||||
'session_name': name,
|
||||
'status': DEMO_STATUS_RUNNING,
|
||||
'camera_mode': 'orbit',
|
||||
'entities': entities_out,
|
||||
}
|
||||
|
||||
|
||||
# ---------------- W-07b 结束演示(含状态隔离清理) ----------------
|
||||
async def stop_demo(session_id):
|
||||
if not session_id:
|
||||
raise DemoParamError('session_id 不能为空')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
if rec.status == DEMO_STATUS_ENDED:
|
||||
raise DemoStateError('演示会话已结束')
|
||||
now = _now()
|
||||
await sor.U('demo_session', {'id': session_id, 'status': DEMO_STATUS_ENDED, 'updated_at': now})
|
||||
# 状态隔离:删除覆盖层/热加载脚本/在线表 —— 正式 entity/scene/world 数据从未被修改
|
||||
await sor.D('demo_session_state', {'session_id': session_id})
|
||||
await sor.D('demo_hot_script', {'session_id': session_id})
|
||||
await sor.D('demo_presence', {'session_id': session_id})
|
||||
return {'session_id': session_id, 'status': DEMO_STATUS_ENDED, 'overlay_cleared': True}
|
||||
|
||||
|
||||
# ---------------- W-07e 视角切换 ----------------
|
||||
async def switch_camera(session_id, camera_mode):
|
||||
if not session_id or not camera_mode:
|
||||
raise DemoParamError('session_id 与 camera_mode 不能为空')
|
||||
if camera_mode not in DEMO_CAMERA_MODES:
|
||||
raise DemoParamError('非法视角模式: %s,可选 %s' % (camera_mode, ','.join(DEMO_CAMERA_MODES)))
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
if rec.status == DEMO_STATUS_ENDED:
|
||||
raise DemoStateError('演示会话已结束,无法切换视角')
|
||||
await sor.U('demo_session', {'id': session_id, 'camera_mode': camera_mode, 'updated_at': _now()})
|
||||
return {'session_id': session_id, 'camera_mode': camera_mode}
|
||||
|
||||
|
||||
# ---------------- W-07f 暂停/继续 ----------------
|
||||
async def pause_demo(session_id, paused):
|
||||
if not session_id:
|
||||
raise DemoParamError('session_id 不能为空')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
if rec.status == DEMO_STATUS_ENDED:
|
||||
raise DemoStateError('演示会话已结束')
|
||||
new_status = DEMO_STATUS_PAUSED if paused else DEMO_STATUS_RUNNING
|
||||
await sor.U('demo_session', {'id': session_id, 'status': new_status, 'updated_at': _now()})
|
||||
return {'session_id': session_id, 'status': new_status, 'paused': paused}
|
||||
|
||||
|
||||
# ---------------- W-07c 编辑实体(演示覆盖层,<1s 实时反映) ----------------
|
||||
async def update_demo_entity(session_id, entity_id, entity_name='', fields=None):
|
||||
if not session_id or not entity_id:
|
||||
raise DemoParamError('session_id/entity_id 不能为空')
|
||||
if fields is None or not isinstance(fields, dict):
|
||||
raise DemoParamError('fields 必须是对象')
|
||||
if isinstance(entity_id, str) is False:
|
||||
raise DemoParamError('entity_id 必须是字符串')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
if rec.status == DEMO_STATUS_ENDED:
|
||||
raise DemoStateError('演示会话已结束,无法编辑实体')
|
||||
now = _now()
|
||||
demo_json = json.dumps(fields, ensure_ascii=False)
|
||||
sts = await sor.R('demo_session_state', {'session_id': session_id, 'entity_id': str(entity_id)})
|
||||
if sts:
|
||||
await sor.U('demo_session_state', {
|
||||
'session_id': session_id,
|
||||
'entity_id': str(entity_id),
|
||||
'entity_name': (entity_name or '')[:128] or sts[0].entity_name,
|
||||
'demo_json': demo_json,
|
||||
'updated_at': now,
|
||||
})
|
||||
else:
|
||||
await sor.C('demo_session_state', {
|
||||
'id': _new_id('st'),
|
||||
'session_id': session_id,
|
||||
'entity_id': str(entity_id),
|
||||
'entity_name': (entity_name or str(entity_id))[:128],
|
||||
'base_json': demo_json,
|
||||
'demo_json': demo_json,
|
||||
'updated_at': now,
|
||||
})
|
||||
return {'session_id': session_id, 'entity_id': str(entity_id), 'fields': fields, 'updated_at': now}
|
||||
|
||||
|
||||
# ---------------- W-07i 查看实体属性 ----------------
|
||||
async def get_entity_attrs(session_id, entity_id):
|
||||
if not session_id or not entity_id:
|
||||
raise DemoParamError('session_id/entity_id 不能为空')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
sts = await sor.R('demo_session_state', {'session_id': session_id, 'entity_id': str(entity_id)})
|
||||
if not sts:
|
||||
raise DemoNotFoundError('实体不存在于演示会话: %s' % entity_id)
|
||||
st = sts[0]
|
||||
base = _loads(st.base_json)
|
||||
demo = _loads(st.demo_json)
|
||||
merged = dict(base)
|
||||
merged.update(demo)
|
||||
return {
|
||||
'entity_id': str(entity_id),
|
||||
'entity_name': st.entity_name or str(entity_id),
|
||||
'base': base,
|
||||
'demo': demo,
|
||||
'merged': merged,
|
||||
}
|
||||
|
||||
|
||||
# ---------------- W-07d 脚本保存热加载 ----------------
|
||||
async def hotload_script(session_id, script_id, script_name='', script_type='python', script_content=''):
|
||||
if not session_id or not script_id:
|
||||
raise DemoParamError('session_id/script_id 不能为空')
|
||||
if not script_content or not str(script_content).strip():
|
||||
raise DemoParamError('脚本内容不能为空')
|
||||
if script_type not in ('python', 'expr'):
|
||||
raise DemoParamError('非法脚本类型: %s' % script_type)
|
||||
try:
|
||||
compile(str(script_content), '<demo_hotload:%s>' % script_id, 'exec')
|
||||
except SyntaxError as e:
|
||||
raise DemoParamError('脚本语法错误: %s (第 %s 行)' % (getattr(e, 'msg', str(e)), getattr(e, 'lineno', '?')))
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
if rec.status == DEMO_STATUS_ENDED:
|
||||
raise DemoStateError('演示会话已结束,无法热加载脚本')
|
||||
now = _now()
|
||||
sts = await sor.R('demo_hot_script', {'session_id': session_id, 'script_id': str(script_id)})
|
||||
if sts:
|
||||
await sor.U('demo_hot_script', {
|
||||
'session_id': session_id,
|
||||
'script_id': str(script_id),
|
||||
'script_name': (script_name or '')[:128] or sts[0].script_name,
|
||||
'script_type': script_type,
|
||||
'script_content': str(script_content),
|
||||
'updated_at': now,
|
||||
})
|
||||
else:
|
||||
await sor.C('demo_hot_script', {
|
||||
'id': _new_id('hs'),
|
||||
'session_id': session_id,
|
||||
'script_id': str(script_id),
|
||||
'script_name': (script_name or str(script_id))[:128],
|
||||
'script_type': script_type,
|
||||
'script_content': str(script_content),
|
||||
'updated_at': now,
|
||||
})
|
||||
return {'session_id': session_id, 'script_id': str(script_id), 'status': 'hot_loaded', 'updated_at': now}
|
||||
|
||||
|
||||
# ---------------- W-07j 多人预览 ----------------
|
||||
async def presence_join(session_id, user_id='', user_name=''):
|
||||
if not session_id:
|
||||
raise DemoParamError('session_id 不能为空')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
if rec.status == DEMO_STATUS_ENDED:
|
||||
raise DemoStateError('演示会话已结束,无法加入')
|
||||
uid = user_id or ('anon_%s' % _new_id('u'))
|
||||
now = _now()
|
||||
sts = await sor.R('demo_presence', {'session_id': session_id, 'user_id': uid})
|
||||
if sts:
|
||||
await sor.U('demo_presence', {'session_id': session_id, 'user_id': uid, 'last_seen_at': now})
|
||||
else:
|
||||
await sor.C('demo_presence', {
|
||||
'id': _new_id('pr'),
|
||||
'session_id': session_id,
|
||||
'user_id': uid,
|
||||
'user_name': (user_name or '')[:64] or uid,
|
||||
'joined_at': now,
|
||||
'last_seen_at': now,
|
||||
})
|
||||
cnt = await _count_viewers(sor, session_id)
|
||||
await sor.U('demo_session', {'id': session_id, 'viewer_count': cnt, 'updated_at': now})
|
||||
return {'session_id': session_id, 'user_id': uid, 'viewer_count': cnt}
|
||||
|
||||
|
||||
async def presence_leave(session_id, user_id=''):
|
||||
if not session_id or not user_id:
|
||||
raise DemoParamError('session_id 与 user_id 不能为空')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
await sor.D('demo_presence', {'session_id': session_id, 'user_id': user_id})
|
||||
cnt = await _count_viewers(sor, session_id)
|
||||
await sor.U('demo_session', {'id': session_id, 'viewer_count': cnt, 'updated_at': _now()})
|
||||
return {'session_id': session_id, 'user_id': user_id, 'viewer_count': cnt}
|
||||
|
||||
|
||||
# ---------------- W-07g 全屏(前端行为,会话级记录) ----------------
|
||||
async def set_fullscreen(session_id, enabled):
|
||||
if not session_id:
|
||||
raise DemoParamError('session_id 不能为空')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
extra = _loads(rec.extra_json)
|
||||
extra['fullscreen'] = bool(enabled)
|
||||
await sor.U('demo_session', {
|
||||
'id': session_id,
|
||||
'extra_json': json.dumps(extra, ensure_ascii=False),
|
||||
'updated_at': _now(),
|
||||
})
|
||||
return {'session_id': session_id, 'fullscreen': bool(enabled)}
|
||||
|
||||
|
||||
# ---------------- 状态快照(前端 500ms 轮询,<1s 实时反映) ----------------
|
||||
async def get_demo_state(session_id):
|
||||
if not session_id:
|
||||
raise DemoParamError('session_id 不能为空')
|
||||
async with _ctx() as sor:
|
||||
rec = await _get_session(sor, session_id)
|
||||
if not rec:
|
||||
raise DemoNotFoundError('演示会话不存在: %s' % session_id)
|
||||
sts = await sor.R('demo_session_state', {'session_id': session_id})
|
||||
entities = []
|
||||
for st in sts:
|
||||
entities.append({
|
||||
'entity_id': st.entity_id,
|
||||
'entity_name': st.entity_name,
|
||||
'state': _loads(st.demo_json),
|
||||
})
|
||||
scr = await sor.R('demo_hot_script', {'session_id': session_id})
|
||||
scripts = [{
|
||||
'script_id': s.script_id,
|
||||
'script_name': s.script_name,
|
||||
'script_type': s.script_type,
|
||||
'updated_at': s.updated_at,
|
||||
} for s in scr]
|
||||
pre = await sor.R('demo_presence', {'session_id': session_id})
|
||||
return {
|
||||
'session_id': rec.id,
|
||||
'world_id': rec.world_id,
|
||||
'session_name': rec.session_name,
|
||||
'status': rec.status,
|
||||
'camera_mode': rec.camera_mode,
|
||||
'viewer_count': len(pre),
|
||||
'entities': entities,
|
||||
'scripts': scripts,
|
||||
'updated_at': rec.updated_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------- 进行中会话列表 ----------------
|
||||
async def list_active_sessions():
|
||||
async with _ctx() as sor:
|
||||
ns = {'page': 1, 'rows': 100, 'sort': 'created_at', 'order': 'desc'}
|
||||
return await sor.sqlPaging(
|
||||
"SELECT id, world_id, session_name, status, camera_mode, viewer_count, created_at, updated_at "
|
||||
"FROM demo_session WHERE status IN ('preview','paused')", ns)
|
||||
39
demo/init.py
Normal file
39
demo/init.py
Normal file
@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""demo 模块初始化 —— 将业务函数注册到 ServerEnv,供 .dspy/.ui 直接调用。
|
||||
|
||||
三处同步注册:① 实现(demo/demo.py) ② 包 __init__.py 导入 ③ 本文件 env.xxx = xxx。
|
||||
"""
|
||||
from .demo import (
|
||||
start_demo, stop_demo, switch_camera, pause_demo,
|
||||
update_demo_entity, get_entity_attrs, hotload_script,
|
||||
presence_join, presence_leave, set_fullscreen,
|
||||
get_demo_state, list_active_sessions,
|
||||
DemoParamError, DemoNotFoundError, DemoDuplicateError, DemoStateError,
|
||||
DEMO_CAMERA_MODES,
|
||||
)
|
||||
|
||||
|
||||
def load_demo(env=None):
|
||||
"""挂载 demo 模块到宿主应用 ServerEnv(宿主 app/scense.py init() 中调用)。"""
|
||||
if env is None:
|
||||
from ahserver.server_env import ServerEnv
|
||||
env = ServerEnv()
|
||||
env.start_demo = start_demo
|
||||
env.stop_demo = stop_demo
|
||||
env.switch_camera = switch_camera
|
||||
env.pause_demo = pause_demo
|
||||
env.update_demo_entity = update_demo_entity
|
||||
env.get_entity_attrs = get_entity_attrs
|
||||
env.hotload_script = hotload_script
|
||||
env.presence_join = presence_join
|
||||
env.presence_leave = presence_leave
|
||||
env.set_fullscreen = set_fullscreen
|
||||
env.get_demo_state = get_demo_state
|
||||
env.list_active_sessions = list_active_sessions
|
||||
# 异常类型也注册为 dspy 全局,便于 .dspy 按类型映射状态码
|
||||
env.DemoParamError = DemoParamError
|
||||
env.DemoNotFoundError = DemoNotFoundError
|
||||
env.DemoDuplicateError = DemoDuplicateError
|
||||
env.DemoStateError = DemoStateError
|
||||
env.DEMO_CAMERA_MODES = DEMO_CAMERA_MODES
|
||||
return env
|
||||
84
docs/self-test-cases.md
Normal file
84
docs/self-test-cases.md
Normal file
@ -0,0 +1,84 @@
|
||||
# W-07 demo 模块功能点自测(正例 + 反例)
|
||||
|
||||
> 依据 feature-granularity-and-testing:每个小功能点至少 1 正例 + 1 反例,期望可判定。
|
||||
> 执行入口:部署到 /d/scense/scense_app 后,用登录 cookie 请求 `http://<host>:<port>/demo/api/*.dspy`。
|
||||
> 数据库:宿主库(get_module_dbname('demo')),表 demo_session / demo_session_state / demo_hot_script / demo_presence。
|
||||
|
||||
## W-07a 开始演示(进入预览态)— POST /demo/api/start.dspy
|
||||
- 正例 W-07a-pos:body `{"world_id":"w001","session_name":"产品演示"}` → 期望 `status=200` 且 `data.session_id` 非空、`data.status="preview"`、`data.camera_mode="orbit"`;库 demo_session 新增 1 行 status=preview。
|
||||
- 反例 W-07a-neg-1(缺参):body `{}` → 期望 `status=400` 且 message 含 "world_id"。
|
||||
- 反例 W-07a-neg-2(重复):同一 world_id 连续两次 start → 第二次期望 `status=409` 且 message 含 "已有进行中的演示会话"。
|
||||
- 反例 W-07a-neg-3(世界不存在):`{"world_id":"no_such_world"}` → 期望 `status=404`(world 模块可用时)或 200(world 模块未挂载时跳过校验,防御式)。
|
||||
|
||||
## W-07b 结束演示(状态隔离)— POST /demo/api/stop.dspy
|
||||
- 正例 W-07b-pos:对进行中会话 `{"session_id":"<sid>"}` → 期望 `status=200` 且 `data.overlay_cleared=true`;demo_session.status=ended;demo_session_state/demo_hot_script/demo_presence 中该 session_id 记录数为 0;**正式 entity/world 表无变化**。
|
||||
- 反例 W-07b-neg-1(缺参):`{}` → `status=400`。
|
||||
- 反例 W-07b-neg-2(不存在):`{"session_id":"no_such"}` → `status=404`。
|
||||
- 反例 W-07b-neg-3(重复结束):对已 ended 会话再 stop → `status=400` 且 message 含 "已结束"。
|
||||
|
||||
## W-07c 编辑实体实时反映(<1s) — POST /demo/api/entity_edit.dspy
|
||||
- 正例 W-07c-pos:`{"session_id":"<sid>","entity_id":"e1","fields":{"color":"#22C55E","size":2}}` → `status=200`;随后调 state.dspy,`entities[].state.color="#22C55E"`;前端轮询 500ms → **编辑后 <1s 反映**;demo_session_state.demo_json 更新、base_json 不变。
|
||||
- 反例 W-07c-neg-1(缺参):缺 entity_id → `status=400`。
|
||||
- 反例 W-07c-neg-2(fields 非法):fields 传数组/字符串 → `status=400` message 含 "fields 必须是对象"。
|
||||
- 反例 W-07c-neg-3(会话不存在):`{"session_id":"no_such",...}` → `status=404`。
|
||||
- 反例 W-07c-neg-4(会话已结束):ended 会话 → `status=400` message 含 "已结束"。
|
||||
|
||||
## W-07d 脚本保存热加载 — POST /demo/api/hotload.dspy
|
||||
- 正例 W-07d-pos:`{"session_id":"<sid>","script_id":"s1","script_type":"python","script_content":"print('hi')"}` → `status=200` 且 `data.status="hot_loaded"`;demo_hot_script 新增 1 行;state.dspy scripts 列表可见。
|
||||
- 反例 W-07d-neg-1(语法错误):content=`"def f(:"` → `status=400` message 含 "语法错误"。
|
||||
- 反例 W-07d-neg-2(内容为空):content 空 → `status=400`。
|
||||
- 反例 W-07d-neg-3(类型非法):script_type=`"sql"` → `status=400` message 含 "非法脚本类型"。
|
||||
|
||||
## W-07e 视角切换 — POST /demo/api/camera.dspy
|
||||
- 正例 W-07e-pos:`{"session_id":"<sid>","camera_mode":"topdown"}` → `status=200` 且 `data.camera_mode="topdown"`;state.dspy 中 camera_mode=topdown。
|
||||
- 反例 W-07e-neg-1(白名单外):`camera_mode="free"` → `status=400` message 含 "非法视角模式"。
|
||||
- 反例 W-07e-neg-2(会话不存在):→ `status=404`。
|
||||
- 反例 W-07e-neg-3(已结束):ended 会话 → `status=400`。
|
||||
|
||||
## W-07f 暂停/继续 — POST /demo/api/pause.dspy
|
||||
- 正例 W-07f-pos-暂停:`{"session_id":"<sid>","paused":true}` → `status=200` 且 `data.status="paused"`。
|
||||
- 正例 W-07f-pos-继续:再传 `paused:false` → `status=200` 且 `data.status="preview"`。
|
||||
- 反例 W-07f-neg-1(缺参):缺 paused → `status=400`。
|
||||
- 反例 W-07f-neg-2(已结束):ended 会话 → `status=400`。
|
||||
|
||||
## W-07g 全屏 — POST /demo/api/fullscreen.dspy
|
||||
- 正例 W-07g-pos:`{"session_id":"<sid>","enabled":true}` → `status=200` 且 `data.fullscreen=true`;demo_session.extra_json 含 `"fullscreen":true`。
|
||||
- 反例 W-07g-neg-1(缺参):缺 enabled → `status=400`。
|
||||
- 反例 W-07g-neg-2(会话不存在):→ `status=404`。
|
||||
|
||||
## W-07h 状态隔离(演示改动不污染正式数据)— 组合验证
|
||||
- 正例 W-07h-pos:演示中编辑 e1 后,查询正式 entity 表(entity_id=e1)确认字段未被修改;stop 后 demo_session_state 无残留;再次 query entity 表仍为原值。
|
||||
- 反例 W-07h-neg(残留污染):stop 后若 demo_session_state 仍查到该 session_id 记录 → 判 FAIL(隔离失效)。
|
||||
|
||||
## W-07i 查看实体属性 — POST /demo/api/entity_attrs.dspy
|
||||
- 正例 W-07i-pos:`{"session_id":"<sid>","entity_id":"e1"}` → `status=200` 且 data 含 base/demo/merged 三对象;merged=base 与 demo 合并。
|
||||
- 反例 W-07i-neg-1(实体不在会话):`{"session_id":"<sid>","entity_id":"no_such"}` → `status=404` message 含 "实体不存在于演示会话"。
|
||||
- 反例 W-07i-neg-2(缺参):→ `status=400`。
|
||||
|
||||
## W-07j 多人预览 — POST /demo/api/presence.dspy
|
||||
- 正例 W-07j-pos-加入:`{"session_id":"<sid>","action":"join","user_id":"u1"}` → `status=200` 且 `data.viewer_count=1`;demo_presence 新增 1 行。
|
||||
- 正例 W-07j-pos-离开:`action:"leave","user_id":"u1"` → `status=200` 且 `data.viewer_count=0`。
|
||||
- 正例 W-07j-pos-多人在线:u1/u2 都 join → `viewer_count=2`。
|
||||
- 反例 W-07j-neg-1(action 非法):`action="kick"` → `status=400` message 含 "action 只能为 join/leave"。
|
||||
- 反例 W-07j-neg-2(会话不存在):→ `status=404`。
|
||||
|
||||
## 状态机合法性补充
|
||||
- W-07a 开始后 → preview;W-07f 暂停 → paused,继续 → preview;W-07b 结束 → ended(唯一出口);ended 后 start/camera/pause/entity_edit/hotload/presence 全部 400。
|
||||
|
||||
## 验收汇总表(执行后填写)
|
||||
| 功能点 | 用例数(正/反) | pass | fail | blocked | 证据 |
|
||||
|--------|--------------|------|------|---------|------|
|
||||
| W-07a | 1/3 | | | | |
|
||||
| W-07b | 1/3 | | | | |
|
||||
| W-07c | 1/4 | | | | |
|
||||
| W-07d | 1/3 | | | | |
|
||||
| W-07e | 1/3 | | | | |
|
||||
| W-07f | 2/2 | | | | |
|
||||
| W-07g | 1/2 | | | | |
|
||||
| W-07h | 1/1 | | | | |
|
||||
| W-07i | 1/2 | | | | |
|
||||
| W-07j | 3/2 | | | | |
|
||||
| 合计 | 13/25=38 | | | | |
|
||||
|
||||
> 说明:本工作空间无 /d/scense/scense_app 部署目录与运行库,无法在此实跑;上述用例在部署环境执行,
|
||||
> actual_result 记录原始响应体(status/message/data),fail 一律落 sd_bugs(关联 task_id)。
|
||||
32
docs/work-log-2026-08-29.md
Normal file
32
docs/work-log-2026-08-29.md
Normal file
@ -0,0 +1,32 @@
|
||||
# 2026-08-29 工作日志 —— W-07 demo 实时演示模块开发
|
||||
|
||||
## 范围/背景
|
||||
- 元景(scense)平台 W-07「demo 实时演示模块」开发(task_kind=new_dev, iteration oOk__9medSOirW_Pxm31T)。
|
||||
- 部署目标 /d/scense/scense_app;实现为 `demo` 模块(Python 包,无独立 app.py)。
|
||||
- 功能点 W-07a~W-07j 全覆盖:开始演示/结束演示/编辑实体实时反映(<1s)/脚本保存热加载/视角切换/暂停继续/全屏/状态隔离/查看实体属性/多人预览。
|
||||
|
||||
## 关键决策
|
||||
1. **状态隔离实现**:demo_session_state 覆盖层(base_json 基线 + demo_json 当前),演示期间只写覆盖层,
|
||||
stop_demo 级联删除 3 张子表 → 正式 entity/scene/world 数据零污染。这是验收核心。
|
||||
2. **<1s 实时反映**:前端 500ms 轮询 state.dspy(聚合实体状态),编辑接口写入后下一次轮询即反映。
|
||||
3. **跨模块校验防御式**:校验 world 存在性用 try/except(world 模块未挂载时跳过),不阻塞演示。
|
||||
4. **接口命名**:按任务要求 /demo/api/{start,stop,camera,pause}.dspy,另加 entity_edit/entity_attrs/hotload/presence/fullscreen/state/session_*。
|
||||
5. 复用现有 6 模块结构:models/*.json 表定义 + wwwroot/api/*.dspy + index.ui + json/ CRUD。
|
||||
|
||||
## 产出文件(modules/demo/)
|
||||
- demo/demo.py(业务逻辑)、demo/init.py(load_demo 注册 12 函数)、demo/__init__.py(导出)
|
||||
- models/:demo_session / demo_session_state / demo_hot_script / demo_presence 四张表
|
||||
- wwwroot/api/:13 个 dspy 端点;wwwroot/index.ui + demo_viewer.ui + demo_viewer.js + demo_viewer.css
|
||||
- json/demo_session.json(CRUD);scripts/load_path.py(RBAC 显式路径);scripts/demo_ddl.sql
|
||||
- pyproject.toml / README.md / skill/SKILL.md / .gitignore
|
||||
|
||||
## 验证情况(环境受限说明)
|
||||
- 本工作空间无 /d/scense/scense_app 部署目录与数据库连接,无法在此完整启动宿主应用做接口联调。
|
||||
- 已完成的静态验证:模型 JSON 四段式(summary/fields/indexes/codes);dspy 无 import、显式 return、
|
||||
全部 try/except 映射 400/404/409/500;业务函数三处注册同步(demo.py/__init__.py/init.py);
|
||||
无硬编码 DBNAME(统一 get_module_dbname('demo'));sor 仅用 C/U/R/D/sqlExe/sqlPaging。
|
||||
- 待部署环境自测清单(见 skill/SKILL.md 验收口径):start/stop 200、编辑实体 <1s、退出后正式数据不变、
|
||||
每功能点正反例执行。
|
||||
|
||||
## 当前状态
|
||||
- 模块仓库 modules/demo 已就绪(待 PM 审核通过后统一 git 提交/部署)。
|
||||
24
json/demo_session.json
Normal file
24
json/demo_session.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"tblname": "demo_session",
|
||||
"params": {
|
||||
"title": "演示会话",
|
||||
"browserfields": {
|
||||
"alts": [
|
||||
{"name": "session_name", "uitype": "text", "caption": "演示名称", "width": "20%"},
|
||||
{"name": "world_id", "uitype": "text", "caption": "世界ID", "width": "15%"},
|
||||
{"name": "status", "uitype": "text", "caption": "状态", "width": "10%"},
|
||||
{"name": "camera_mode", "uitype": "text", "caption": "视角", "width": "10%"},
|
||||
{"name": "viewer_count", "uitype": "text", "caption": "在线人数", "width": "10%"},
|
||||
{"name": "updated_at", "uitype": "text", "caption": "更新时间", "width": "20%"}
|
||||
]
|
||||
},
|
||||
"editexclouded": [
|
||||
"session_name", "world_id", "camera_mode"
|
||||
],
|
||||
"new_data_url": "{{entire_url('/demo/api/session_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('/demo/api/session_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('/demo/api/session_delete.dspy')}}",
|
||||
"sqlPaging": "SELECT id, session_name, world_id, status, camera_mode, viewer_count, created_at, updated_at FROM demo_session",
|
||||
"logined_userorgid": ""
|
||||
}
|
||||
}
|
||||
19
models/demo_hot_script.json
Normal file
19
models/demo_hot_script.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"summary": [
|
||||
{"pkey": "id", "caption": "演示热加载脚本", "tablename": "demo_hot_script", "module": "demo"}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str(64)", "comment": "主键"},
|
||||
{"name": "session_id", "type": "str(64)", "comment": "演示会话ID"},
|
||||
{"name": "script_id", "type": "str(64)", "comment": "脚本ID(script_engine 联动)"},
|
||||
{"name": "script_name", "type": "str(128)", "comment": "脚本名称"},
|
||||
{"name": "script_type", "type": "str(16)", "comment": "脚本类型 python/expr"},
|
||||
{"name": "script_content", "type": "text", "comment": "脚本内容"},
|
||||
{"name": "updated_at", "type": "datetime", "comment": "保存时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_hot_session", "fields": ["session_id"]},
|
||||
{"name": "idx_hot_script", "fields": ["script_id"]}
|
||||
],
|
||||
"codes": []
|
||||
}
|
||||
18
models/demo_presence.json
Normal file
18
models/demo_presence.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"summary": [
|
||||
{"pkey": "id", "caption": "演示多人预览在线表", "tablename": "demo_presence", "module": "demo"}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str(64)", "comment": "主键"},
|
||||
{"name": "session_id", "type": "str(64)", "comment": "演示会话ID"},
|
||||
{"name": "user_id", "type": "str(64)", "comment": "在线用户ID"},
|
||||
{"name": "user_name", "type": "str(64)", "comment": "在线用户名称"},
|
||||
{"name": "joined_at", "type": "datetime", "comment": "加入时间"},
|
||||
{"name": "last_seen_at", "type": "datetime", "comment": "最后活跃时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_presence_session", "fields": ["session_id"]},
|
||||
{"name": "idx_presence_user", "fields": ["user_id"]}
|
||||
],
|
||||
"codes": []
|
||||
}
|
||||
22
models/demo_session.json
Normal file
22
models/demo_session.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"summary": [
|
||||
{"pkey": "id", "caption": "演示会话表", "tablename": "demo_session", "module": "demo"}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str(64)", "comment": "会话ID"},
|
||||
{"name": "world_id", "type": "str(64)", "comment": "世界ID(W-01 world.id)"},
|
||||
{"name": "session_name", "type": "str(128)", "comment": "演示名称"},
|
||||
{"name": "status", "type": "str(16)", "comment": "preview=演示中 paused=已暂停 ended=已结束"},
|
||||
{"name": "camera_mode", "type": "str(16)", "comment": "视角模式 orbit/topdown/firstperson"},
|
||||
{"name": "owner_id", "type": "str(32)", "comment": "演示发起人"},
|
||||
{"name": "viewer_count", "type": "int", "comment": "在线观看人数"},
|
||||
{"name": "extra_json", "type": "text", "comment": "扩展JSON(全屏等)"},
|
||||
{"name": "created_at", "type": "datetime", "comment": "创建时间"},
|
||||
{"name": "updated_at", "type": "datetime", "comment": "更新时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_demo_session_world", "fields": ["world_id"]},
|
||||
{"name": "idx_demo_session_status", "fields": ["status"]}
|
||||
],
|
||||
"codes": []
|
||||
}
|
||||
19
models/demo_session_state.json
Normal file
19
models/demo_session_state.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"summary": [
|
||||
{"pkey": "id", "caption": "演示实体状态覆盖层", "tablename": "demo_session_state", "module": "demo"}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str(64)", "comment": "主键"},
|
||||
{"name": "session_id", "type": "str(64)", "comment": "演示会话ID"},
|
||||
{"name": "entity_id", "type": "str(64)", "comment": "实体ID"},
|
||||
{"name": "entity_name", "type": "str(128)", "comment": "实体名称"},
|
||||
{"name": "base_json", "type": "text", "comment": "演示开始时的基线状态(快照,只读)"},
|
||||
{"name": "demo_json", "type": "text", "comment": "演示中的当前状态(可实时修改)"},
|
||||
{"name": "updated_at", "type": "datetime", "comment": "更新时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_state_session", "fields": ["session_id"]},
|
||||
{"name": "idx_state_entity", "fields": ["entity_id"]}
|
||||
],
|
||||
"codes": []
|
||||
}
|
||||
14
pyproject.toml
Normal file
14
pyproject.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "demo"
|
||||
version = "1.0.0"
|
||||
description = "W-07 实时演示模块 —— demo 会话 + three.js 预览 + 状态隔离"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = ["sqlor", "bricks_for_python"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["demo*"]
|
||||
55
scripts/demo_ddl.sql
Normal file
55
scripts/demo_ddl.sql
Normal file
@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
-- demo 模块 DDL(部署参考;实际建表由宿主 build.sh 用 json2ddl 生成)
|
||||
CREATE TABLE IF NOT EXISTS demo_session (
|
||||
id VARCHAR(64) NOT NULL COMMENT '会话ID',
|
||||
world_id VARCHAR(64) NOT NULL COMMENT '世界ID',
|
||||
session_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '演示名称',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'preview' COMMENT 'preview/paused/ended',
|
||||
camera_mode VARCHAR(16) NOT NULL DEFAULT 'orbit' COMMENT 'orbit/topdown/firstperson',
|
||||
owner_id VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '发起人',
|
||||
viewer_count INT NOT NULL DEFAULT 0 COMMENT '在线人数',
|
||||
extra_json TEXT COMMENT '扩展JSON(全屏等)',
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_demo_session_world (world_id),
|
||||
KEY idx_demo_session_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='演示会话表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS demo_session_state (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键',
|
||||
session_id VARCHAR(64) NOT NULL COMMENT '演示会话ID',
|
||||
entity_id VARCHAR(64) NOT NULL COMMENT '实体ID',
|
||||
entity_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '实体名称',
|
||||
base_json TEXT COMMENT '演示开始基线(只读)',
|
||||
demo_json TEXT COMMENT '演示中当前值(可改)',
|
||||
updated_at DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_state_session (session_id),
|
||||
KEY idx_state_entity (entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='演示实体状态覆盖层';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS demo_hot_script (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键',
|
||||
session_id VARCHAR(64) NOT NULL COMMENT '演示会话ID',
|
||||
script_id VARCHAR(64) NOT NULL COMMENT '脚本ID(script_engine联动)',
|
||||
script_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '脚本名称',
|
||||
script_type VARCHAR(16) NOT NULL DEFAULT 'python' COMMENT 'python/expr',
|
||||
script_content TEXT COMMENT '脚本内容',
|
||||
updated_at DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_hot_session (session_id),
|
||||
KEY idx_hot_script (script_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='演示热加载脚本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS demo_presence (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键',
|
||||
session_id VARCHAR(64) NOT NULL COMMENT '演示会话ID',
|
||||
user_id VARCHAR(64) NOT NULL COMMENT '在线用户ID',
|
||||
user_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '在线用户名称',
|
||||
joined_at DATETIME,
|
||||
last_seen_at DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_presence_session (session_id),
|
||||
KEY idx_presence_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='演示多人预览在线表';
|
||||
67
scripts/load_path.py
Normal file
67
scripts/load_path.py
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""demo 模块 RBAC 注册脚本(显式路径,禁止通配符)。
|
||||
|
||||
用法: cd <sage_root> && ./py3/bin/python <module>/scripts/load_path.py
|
||||
兜底: 中央 ~/repos/sage/load_path.py 亦需登记以下路径(双保险)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
SAGE_ROOT = None
|
||||
for candidate in (
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', '..'),
|
||||
os.path.expanduser('~/repos/sage'),
|
||||
os.path.expanduser('~/sage')):
|
||||
cand = os.path.abspath(candidate)
|
||||
if os.path.isdir(os.path.join(cand, 'wwwroot')) and os.path.isdir(os.path.join(cand, 'py3', 'bin')):
|
||||
SAGE_ROOT = cand
|
||||
break
|
||||
|
||||
if SAGE_ROOT is None:
|
||||
print('未找到 Sage 根目录,跳过 RBAC 注册(部署时由中央 load_path.py 兜底)')
|
||||
sys.exit(0)
|
||||
|
||||
sys.path.insert(0, os.path.join(SAGE_ROOT, 'py3', 'bin'))
|
||||
from set_role_perm import set_role_perm # noqa: E402
|
||||
|
||||
PATHS_LOGINED = [
|
||||
# 模块入口
|
||||
'/demo',
|
||||
'/demo/index.ui',
|
||||
# 演示控制台(前端页面 + js/css)
|
||||
'/demo/demo_viewer.ui',
|
||||
'/demo/demo_viewer.js',
|
||||
'/demo/demo_viewer.css',
|
||||
# CRUD 会话列表(xls2ui 生成的 5 件套)
|
||||
'/demo/demo_session',
|
||||
'/demo/demo_session/index.ui',
|
||||
'/demo/demo_session/get_demo_session.dspy',
|
||||
'/demo/demo_session/add_demo_session.dspy',
|
||||
'/demo/demo_session/update_demo_session.dspy',
|
||||
'/demo/demo_session/delete_demo_session.dspy',
|
||||
# 业务 API
|
||||
'/demo/api/start.dspy',
|
||||
'/demo/api/stop.dspy',
|
||||
'/demo/api/camera.dspy',
|
||||
'/demo/api/pause.dspy',
|
||||
'/demo/api/entity_edit.dspy',
|
||||
'/demo/api/entity_attrs.dspy',
|
||||
'/demo/api/hotload.dspy',
|
||||
'/demo/api/presence.dspy',
|
||||
'/demo/api/fullscreen.dspy',
|
||||
'/demo/api/state.dspy',
|
||||
'/demo/api/session_create.dspy',
|
||||
'/demo/api/session_update.dspy',
|
||||
'/demo/api/session_delete.dspy',
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
for p in PATHS_LOGINED:
|
||||
set_role_perm(p, 'logined')
|
||||
print('demo RBAC 注册完成: %d 条 logined 路径' % len(PATHS_LOGINED))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
43
skill/SKILL.md
Normal file
43
skill/SKILL.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
name: demo
|
||||
description: W-07 实时演示模块 —— demo 会话管理、three.js 预览、状态隔离(演示改动不污染正式数据)、多人预览。开发/修改 demo 相关功能前必读。
|
||||
---
|
||||
|
||||
# demo 模块技能
|
||||
|
||||
## 架构
|
||||
- 交互层业务模块(有自己的数据表 + dspy + bricks 前端),通过 `load_demo(env)` 挂到宿主。
|
||||
- 依赖宿主提供:sqlor/ahserver/ServerEnv;跨模块校验 world 存在性时按需用 world 模块(防御式 try/except)。
|
||||
|
||||
## 数据模型(models/*.json)
|
||||
| 表 | 作用 | 关键字段 |
|
||||
|----|------|----------|
|
||||
| demo_session | 演示会话 | status(preview/paused/ended), camera_mode(orbit/topdown/firstperson), viewer_count, extra_json |
|
||||
| demo_session_state | 实体状态覆盖层 | session_id, entity_id, base_json(基线), demo_json(当前) |
|
||||
| demo_hot_script | 热加载脚本 | session_id, script_id, script_type(python/expr), script_content |
|
||||
| demo_presence | 多人预览在线 | session_id, user_id, user_name, last_seen_at |
|
||||
|
||||
## 关键端点(wwwroot/api/*.dspy,全部 logined)
|
||||
- `start.dspy` — world_id 必填;重复开始同世界 → 409
|
||||
- `stop.dspy` — 删除覆盖层/脚本/在线表(状态隔离核心)
|
||||
- `entity_edit.dspy` — session_id+entity_id+fields(对象) 必填;只写覆盖层
|
||||
- `entity_attrs.dspy` — 返回 base/demo/merged
|
||||
- `hotload.dspy` — 语法校验(compile)通过才落库;SyntaxError → 400
|
||||
- `camera.dspy` — camera_mode 白名单 orbit/topdown/firstperson
|
||||
- `pause.dspy` — paused=true/false
|
||||
- `presence.dspy` — action=join/leave,viewer_count 实时回写
|
||||
- `fullscreen.dspy` — enabled true/false,写入 extra_json
|
||||
- `state.dspy` — 前端 500ms 轮询,聚合实体状态+脚本+在线人数
|
||||
|
||||
## 状态机(会话级)
|
||||
preview ⇄ paused(pause.dspy);preview/paused → ended(stop.dspy,唯一出口);ended 后任何操作 → 400。
|
||||
|
||||
## 模块专属坑
|
||||
1. **状态隔离是硬约束**:任何实体修改只允许写 `demo_session_state`,严禁写 entity/scene/world 正式表。stop_demo 必须级联删除 3 张子表,否则正式数据被"演示污染"。
|
||||
2. **三处同步注册**:新增函数必须同步 demo/demo.py、demo/__init__.py、demo/init.py 三处(env.xxx = xxx)。
|
||||
3. **异常→状态码映射**:DemoParamError→400、DemoNotFoundError→404、DemoDuplicateError→409、DemoStateError→400;dspy 里必须 try/except 后返回 `{"status":..., "message":...}`。
|
||||
4. **sor.C 必须显式带 created_at**:sqlor 不自动补时间戳,缺 created_at 会导致插入静默丢失。
|
||||
5. **dspy 禁止 import**:所有函数经 load_demo 注册为全局;dspy 里直接 `await start_demo(...)`。
|
||||
6. **取库名**:`get_module_dbname('demo')`(dspy 全局)或 `ServerEnv().get_module_dbname('demo')`(.py),禁止硬编码 DBNAME。
|
||||
7. **dspy 显式 return**:ahserver 包裹 async 函数,裸表达式返回 None。
|
||||
8. 前端 `.js` 里可用 fetch/setInterval(非 script actiontype);bricks script actiontype 内禁止 fetch/setInterval。
|
||||
19
wwwroot/api/camera.dspy
Normal file
19
wwwroot/api/camera.dspy
Normal file
@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/camera.dspy —— W-07e 视角切换
|
||||
# 入参: session_id(必填), camera_mode(必填, orbit/topdown/firstperson)
|
||||
try:
|
||||
if not params_kw.get('session_id') or not params_kw.get('camera_mode'):
|
||||
return {"status": 400, "message": "session_id 与 camera_mode 不能为空"}
|
||||
data = await switch_camera(
|
||||
session_id=params_kw.get('session_id'),
|
||||
camera_mode=params_kw.get('camera_mode'),
|
||||
)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except DemoStateError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "switch_camera 内部错误: %s" % e}
|
||||
18
wwwroot/api/entity_attrs.dspy
Normal file
18
wwwroot/api/entity_attrs.dspy
Normal file
@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/entity_attrs.dspy —— W-07i 查看实体属性
|
||||
# 入参: session_id(必填), entity_id(必填)
|
||||
# 出参: base=演示开始基线, demo=演示中当前值, merged=合并后展示值
|
||||
try:
|
||||
if not params_kw.get('session_id') or not params_kw.get('entity_id'):
|
||||
return {"status": 400, "message": "session_id/entity_id 不能为空"}
|
||||
data = await get_entity_attrs(
|
||||
session_id=params_kw.get('session_id'),
|
||||
entity_id=params_kw.get('entity_id'),
|
||||
)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "get_entity_attrs 内部错误: %s" % e}
|
||||
23
wwwroot/api/entity_edit.dspy
Normal file
23
wwwroot/api/entity_edit.dspy
Normal file
@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/entity_edit.dspy —— W-07c 编辑实体(写入演示覆盖层,<1s 实时反映)
|
||||
# 入参: session_id(必填), entity_id(必填), entity_name(可选), fields(必填, JSON 对象)
|
||||
try:
|
||||
if not params_kw.get('session_id') or not params_kw.get('entity_id'):
|
||||
return {"status": 400, "message": "session_id/entity_id 不能为空"}
|
||||
if not params_kw.get('fields'):
|
||||
return {"status": 400, "message": "fields 不能为空"}
|
||||
data = await update_demo_entity(
|
||||
session_id=params_kw.get('session_id'),
|
||||
entity_id=params_kw.get('entity_id'),
|
||||
entity_name=params_kw.get('entity_name', ''),
|
||||
fields=params_kw.get('fields'),
|
||||
)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except DemoStateError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "update_demo_entity 内部错误: %s" % e}
|
||||
15
wwwroot/api/fullscreen.dspy
Normal file
15
wwwroot/api/fullscreen.dspy
Normal file
@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/fullscreen.dspy —— W-07g 全屏切换(会话级记录)
|
||||
# 入参: session_id(必填), enabled(必填, true/false)
|
||||
try:
|
||||
if not params_kw.get('session_id') or params_kw.get('enabled') is None:
|
||||
return {"status": 400, "message": "session_id 与 enabled 不能为空"}
|
||||
enabled = str(params_kw.get('enabled')).lower() in ('true', '1', 'yes')
|
||||
data = await set_fullscreen(session_id=params_kw.get('session_id'), enabled=enabled)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "set_fullscreen 内部错误: %s" % e}
|
||||
24
wwwroot/api/hotload.dspy
Normal file
24
wwwroot/api/hotload.dspy
Normal file
@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/hotload.dspy —— W-07d 脚本保存热加载(语法校验通过即生效)
|
||||
# 入参: session_id(必填), script_id(必填), script_name(可选), script_type(可选 python/expr), script_content(必填)
|
||||
try:
|
||||
if not params_kw.get('session_id') or not params_kw.get('script_id'):
|
||||
return {"status": 400, "message": "session_id/script_id 不能为空"}
|
||||
if not params_kw.get('script_content'):
|
||||
return {"status": 400, "message": "script_content 不能为空"}
|
||||
data = await hotload_script(
|
||||
session_id=params_kw.get('session_id'),
|
||||
script_id=params_kw.get('script_id'),
|
||||
script_name=params_kw.get('script_name', ''),
|
||||
script_type=params_kw.get('script_type', 'python'),
|
||||
script_content=params_kw.get('script_content'),
|
||||
)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except DemoStateError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "hotload_script 内部错误: %s" % e}
|
||||
17
wwwroot/api/pause.dspy
Normal file
17
wwwroot/api/pause.dspy
Normal file
@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/pause.dspy —— W-07f 暂停/继续演示
|
||||
# 入参: session_id(必填), paused(必填, true=暂停 false=继续)
|
||||
try:
|
||||
if not params_kw.get('session_id') or params_kw.get('paused') is None:
|
||||
return {"status": 400, "message": "session_id 与 paused 不能为空"}
|
||||
paused = str(params_kw.get('paused')).lower() in ('true', '1', 'yes')
|
||||
data = await pause_demo(session_id=params_kw.get('session_id'), paused=paused)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except DemoStateError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "pause_demo 内部错误: %s" % e}
|
||||
29
wwwroot/api/presence.dspy
Normal file
29
wwwroot/api/presence.dspy
Normal file
@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/presence.dspy —— W-07j 多人预览加入/离开
|
||||
# 入参: session_id(必填), action(必填 join/leave), user_id(可选), user_name(可选)
|
||||
try:
|
||||
if not params_kw.get('session_id') or not params_kw.get('action'):
|
||||
return {"status": 400, "message": "session_id 与 action 不能为空"}
|
||||
action = str(params_kw.get('action')).lower()
|
||||
if action not in ('join', 'leave'):
|
||||
return {"status": 400, "message": "action 只能为 join/leave"}
|
||||
if action == 'join':
|
||||
data = await presence_join(
|
||||
session_id=params_kw.get('session_id'),
|
||||
user_id=params_kw.get('user_id', ''),
|
||||
user_name=params_kw.get('user_name', ''),
|
||||
)
|
||||
else:
|
||||
data = await presence_leave(
|
||||
session_id=params_kw.get('session_id'),
|
||||
user_id=params_kw.get('user_id', ''),
|
||||
)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except DemoStateError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "presence 内部错误: %s" % e}
|
||||
18
wwwroot/api/session_create.dspy
Normal file
18
wwwroot/api/session_create.dspy
Normal file
@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/session_create.dspy —— 演示会话 CRUD 新增(管理后台维护用)
|
||||
try:
|
||||
if not params_kw.get('world_id'):
|
||||
return {"status": 400, "message": "world_id 不能为空"}
|
||||
data = await start_demo(
|
||||
world_id=params_kw.get('world_id'),
|
||||
session_name=params_kw.get('session_name', ''),
|
||||
)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoDuplicateError as e:
|
||||
return {"status": 409, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "session_create 内部错误: %s" % e}
|
||||
15
wwwroot/api/session_delete.dspy
Normal file
15
wwwroot/api/session_delete.dspy
Normal file
@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/session_delete.dspy —— 演示会话 CRUD 删除(等价 stop_demo 清理)
|
||||
# 入参: id(必填) 或 session_id(必填)
|
||||
try:
|
||||
sid = params_kw.get('id') or params_kw.get('session_id')
|
||||
if not sid:
|
||||
return {"status": 400, "message": "id 不能为空"}
|
||||
data = await stop_demo(session_id=sid)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "session_delete 内部错误: %s" % e}
|
||||
22
wwwroot/api/session_update.dspy
Normal file
22
wwwroot/api/session_update.dspy
Normal file
@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/session_update.dspy —— 演示会话 CRUD 更新(管理后台维护用)
|
||||
# 仅允许更新非状态字段;状态变更一律走 stop/pause/state 接口
|
||||
try:
|
||||
sid = params_kw.get('id') or params_kw.get('session_id')
|
||||
if not sid:
|
||||
return {"status": 400, "message": "id 不能为空"}
|
||||
if not params_kw.get('session_name') and not params_kw.get('camera_mode'):
|
||||
return {"status": 400, "message": "无可更新字段(仅 session_name/camera_mode)"}
|
||||
async with get_sor_context(request._run_ns, "demo") as sor:
|
||||
recs = await sor.R('demo_session', {'id': sid})
|
||||
if not recs:
|
||||
return {"status": 404, "message": "演示会话不存在: %s" % sid}
|
||||
ns = {'id': sid, 'updated_at': curDateString()}
|
||||
if params_kw.get('session_name'):
|
||||
ns['session_name'] = str(params_kw.get('session_name'))[:128]
|
||||
if params_kw.get('camera_mode'):
|
||||
ns['camera_mode'] = str(params_kw.get('camera_mode'))
|
||||
await sor.U('demo_session', ns)
|
||||
return {"status": 200, "data": {"session_id": sid, "updated": True}}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "session_update 内部错误: %s" % e}
|
||||
21
wwwroot/api/start.dspy
Normal file
21
wwwroot/api/start.dspy
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/start.dspy —— W-07a 开始演示(进入预览态)
|
||||
# 入参: world_id(必填), session_name(可选), entities(可选, [{entity_id, entity_name, fields}])
|
||||
# 出参: {"status":200,"data":{"session_id":...,"status":"preview",...}}
|
||||
try:
|
||||
if not params_kw.get('world_id'):
|
||||
return {"status": 400, "message": "world_id 不能为空"}
|
||||
data = await start_demo(
|
||||
world_id=params_kw.get('world_id'),
|
||||
session_name=params_kw.get('session_name', ''),
|
||||
entities=params_kw.get('entities', None),
|
||||
)
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoDuplicateError as e:
|
||||
return {"status": 409, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "start_demo 内部错误: %s" % e}
|
||||
14
wwwroot/api/state.dspy
Normal file
14
wwwroot/api/state.dspy
Normal file
@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/state.dspy —— 演示状态轮询(前端 500ms 拉取,<1s 实时反映)
|
||||
# 入参: session_id(必填)
|
||||
try:
|
||||
if not params_kw.get('session_id'):
|
||||
return {"status": 400, "message": "session_id 不能为空"}
|
||||
data = await get_demo_state(session_id=params_kw.get('session_id'))
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "get_demo_state 内部错误: %s" % e}
|
||||
16
wwwroot/api/stop.dspy
Normal file
16
wwwroot/api/stop.dspy
Normal file
@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# demo/api/stop.dspy —— W-07b 结束演示(清理覆盖层,正式数据不变)
|
||||
# 入参: session_id(必填)
|
||||
try:
|
||||
if not params_kw.get('session_id'):
|
||||
return {"status": 400, "message": "session_id 不能为空"}
|
||||
data = await stop_demo(session_id=params_kw.get('session_id'))
|
||||
return {"status": 200, "data": data}
|
||||
except DemoParamError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except DemoNotFoundError as e:
|
||||
return {"status": 404, "message": str(e)}
|
||||
except DemoStateError as e:
|
||||
return {"status": 400, "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": 500, "message": "stop_demo 内部错误: %s" % e}
|
||||
14
wwwroot/demo_viewer.css
Normal file
14
wwwroot/demo_viewer.css
Normal file
@ -0,0 +1,14 @@
|
||||
/* demo_viewer.css —— W-07 实时演示预览样式 */
|
||||
#demo_canvas_container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
#demo_canvas_container canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
#demo_session_label {
|
||||
font-weight: bold;
|
||||
}
|
||||
336
wwwroot/demo_viewer.js
Normal file
336
wwwroot/demo_viewer.js
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* demo_viewer.js —— W-07 实时演示前端控制
|
||||
* 职责:three.js 渲染世界 + 500ms 轮询状态(<1s 实时反映) + 调用 /demo/api/*.dspy
|
||||
* 状态隔离:所有修改走覆盖层接口,结束演示后正式数据不变(服务端保证)。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var API = {
|
||||
start: '/demo/api/start.dspy',
|
||||
stop: '/demo/api/stop.dspy',
|
||||
camera: '/demo/api/camera.dspy',
|
||||
pause: '/demo/api/pause.dspy',
|
||||
edit: '/demo/api/entity_edit.dspy',
|
||||
attrs: '/demo/api/entity_attrs.dspy',
|
||||
hotload: '/demo/api/hotload.dspy',
|
||||
presence: '/demo/api/presence.dspy',
|
||||
fullscreen: '/demo/api/fullscreen.dspy',
|
||||
state: '/demo/api/state.dspy'
|
||||
};
|
||||
|
||||
var state = {
|
||||
sessionId: null,
|
||||
worldId: null,
|
||||
status: null,
|
||||
cameraMode: 'orbit',
|
||||
timer: null,
|
||||
entities: {},
|
||||
three: null,
|
||||
scene: null,
|
||||
camera: null,
|
||||
renderer: null,
|
||||
meshes: {},
|
||||
raf: null,
|
||||
clock: 0,
|
||||
attrsMode: false
|
||||
};
|
||||
|
||||
function getParam(name) {
|
||||
var m = new RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function post(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function setLabel(text, color) {
|
||||
var el = document.getElementById('demo_session_label');
|
||||
if (el) { el.textContent = text; if (color) el.style.color = color; }
|
||||
}
|
||||
|
||||
function ensureThree() {
|
||||
if (window.THREE) return Promise.resolve(window.THREE);
|
||||
return new Promise(function (resolve) {
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
|
||||
s.onload = function () { resolve(window.THREE); };
|
||||
s.onerror = function () { resolve(null); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function getContainer() {
|
||||
var c = document.getElementById('demo_canvas_container');
|
||||
if (!c) return null;
|
||||
c.style.display = 'block';
|
||||
return c;
|
||||
}
|
||||
|
||||
function initThree() {
|
||||
var container = getContainer();
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
var w = container.clientWidth || 600;
|
||||
var h = container.clientHeight || 400;
|
||||
if (!state.three) return;
|
||||
var T = state.three;
|
||||
state.scene = new T.Scene();
|
||||
state.scene.background = new T.Color(0x1a1d26);
|
||||
state.camera = new T.PerspectiveCamera(60, w / h, 0.1, 1000);
|
||||
state.camera.position.set(8, 6, 10);
|
||||
state.camera.lookAt(0, 0, 0);
|
||||
var grid = new T.GridHelper(20, 20, 0x3a4152, 0x2a2f3d);
|
||||
state.scene.add(grid);
|
||||
var amb = new T.AmbientLight(0xffffff, 0.5);
|
||||
state.scene.add(amb);
|
||||
var dir = new T.DirectionalLight(0xffffff, 0.8);
|
||||
dir.position.set(10, 20, 10);
|
||||
state.scene.add(dir);
|
||||
state.renderer = new T.WebGLRenderer({antialias: true});
|
||||
state.renderer.setSize(w, h);
|
||||
container.appendChild(state.renderer.domElement);
|
||||
animate();
|
||||
}
|
||||
|
||||
function animate() {
|
||||
state.raf = requestAnimationFrame(animate);
|
||||
if (!state.renderer || !state.scene) return;
|
||||
state.clock += 0.01;
|
||||
Object.keys(state.meshes).forEach(function (id) {
|
||||
var m = state.meshes[id];
|
||||
if (m && m.userData && m.userData.rot) {
|
||||
m.rotation.y = state.clock * (m.userData.rotSpeed || 0.5);
|
||||
}
|
||||
});
|
||||
applyCamera();
|
||||
state.renderer.render(state.scene, state.camera);
|
||||
}
|
||||
|
||||
function applyCamera() {
|
||||
if (!state.camera) return;
|
||||
if (state.cameraMode === 'topdown') {
|
||||
state.camera.position.lerp(new state.three.Vector3(0, 20, 0.01), 0.08);
|
||||
state.camera.lookAt(0, 0, 0);
|
||||
} else if (state.cameraMode === 'firstperson') {
|
||||
state.camera.position.lerp(new state.three.Vector3(2, 2, 6), 0.08);
|
||||
state.camera.lookAt(0, 1.5, 0);
|
||||
} else {
|
||||
state.camera.position.lerp(new state.three.Vector3(8, 6, 10), 0.08);
|
||||
state.camera.lookAt(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderState(data) {
|
||||
if (!state.scene || !state.three) return;
|
||||
var seen = {};
|
||||
(data.entities || []).forEach(function (ent) {
|
||||
seen[ent.entity_id] = true;
|
||||
var cfg = ent.state || {};
|
||||
var color = cfg.color || '#4A90D9';
|
||||
var size = cfg.size || 1;
|
||||
if (!state.meshes[ent.entity_id]) {
|
||||
var geo = new state.three.BoxGeometry(size, size, size);
|
||||
var mat = new state.three.MeshStandardMaterial({color: new state.three.Color(color)});
|
||||
var mesh = new state.three.Mesh(geo, mat);
|
||||
mesh.position.set((Object.keys(state.meshes).length % 6) * 2.5 - 5, 0.5, 0);
|
||||
mesh.userData = {rot: true, rotSpeed: cfg.rotSpeed || 0.5};
|
||||
state.scene.add(mesh);
|
||||
state.meshes[ent.entity_id] = mesh;
|
||||
} else {
|
||||
var m = state.meshes[ent.entity_id];
|
||||
if (cfg.color) m.material.color.set(cfg.color);
|
||||
}
|
||||
});
|
||||
Object.keys(state.meshes).forEach(function (id) {
|
||||
if (!seen[id]) {
|
||||
state.scene.remove(state.meshes[id]);
|
||||
delete state.meshes[id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setAttrsPanel(data) {
|
||||
var el = getContainer();
|
||||
if (!el) return;
|
||||
var lines = ['【实体属性】 ' + data.entity_name + ' (' + data.entity_id + ')'];
|
||||
lines.push('--- 基线(base, 演示开始快照) ---');
|
||||
lines.push(JSON.stringify(data.base, null, 2));
|
||||
lines.push('--- 当前(demo, 演示中值) ---');
|
||||
lines.push(JSON.stringify(data.demo, null, 2));
|
||||
lines.push('--- 合并(merged, 渲染值) ---');
|
||||
lines.push(JSON.stringify(data.merged, null, 2));
|
||||
var div = document.createElement('pre');
|
||||
div.style.color = '#8A93A6';
|
||||
div.style.whiteSpace = 'pre-wrap';
|
||||
div.textContent = lines.join('\n');
|
||||
el.innerHTML = '';
|
||||
el.appendChild(div);
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (!state.sessionId) return;
|
||||
post(API.state, {session_id: state.sessionId}).then(function (r) {
|
||||
if (r.status === 200 && r.data) {
|
||||
state.status = r.data.status;
|
||||
state.cameraMode = r.data.camera_mode || state.cameraMode;
|
||||
setLabel('会话 ' + state.sessionId.slice(0, 12) + ' · ' + state.status +
|
||||
' · 视角 ' + state.cameraMode + ' · 在线 ' + (r.data.viewer_count || 0), '#22C55E');
|
||||
if (state.attrsMode) {
|
||||
state.attrsMode = false;
|
||||
}
|
||||
if (state.scene) renderState(r.data);
|
||||
} else {
|
||||
setLabel('轮询失败: ' + (r.message || '未知'), '#EF4444');
|
||||
}
|
||||
}).catch(function (e) {
|
||||
setLabel('轮询异常: ' + e.message, '#EF4444');
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
state.timer = setInterval(poll, 500); // 500ms 轮询 → 编辑实体 <1s 反映
|
||||
poll();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (state.timer) { clearInterval(state.timer); state.timer = null; }
|
||||
}
|
||||
|
||||
var demoViewer = {
|
||||
startDemo: function () {
|
||||
var worldId = state.worldId || getParam('world_id');
|
||||
if (!worldId) {
|
||||
worldId = window.prompt('请输入世界 ID (W-01 world.id):');
|
||||
if (!worldId) { setLabel('已取消:缺少 world_id', '#FF8800'); return; }
|
||||
}
|
||||
state.worldId = worldId;
|
||||
ensureThree().then(function (T) {
|
||||
state.three = T;
|
||||
return post(API.start, {world_id: worldId, session_name: '演示-' + worldId});
|
||||
}).then(function (r) {
|
||||
if (r.status === 200 && r.data) {
|
||||
state.sessionId = r.data.session_id;
|
||||
initThree();
|
||||
startPolling();
|
||||
setLabel('演示已开始: ' + r.data.session_id.slice(0, 12), '#22C55E');
|
||||
} else {
|
||||
setLabel('开始失败: ' + (r.message || '未知'), '#EF4444');
|
||||
}
|
||||
}).catch(function (e) {
|
||||
setLabel('开始异常: ' + e.message, '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
stopDemo: function () {
|
||||
if (!state.sessionId) { setLabel('未开始演示', '#FF8800'); return; }
|
||||
post(API.stop, {session_id: state.sessionId}).then(function (r) {
|
||||
stopPolling();
|
||||
if (r.status === 200) {
|
||||
setLabel('演示已结束,正式数据未被修改', '#22C55E');
|
||||
} else {
|
||||
setLabel('结束失败: ' + (r.message || '未知'), '#EF4444');
|
||||
}
|
||||
state.sessionId = null;
|
||||
state.meshes = {};
|
||||
var c = getContainer();
|
||||
if (c) { c.innerHTML = ''; c.textContent = '3D 预览区(已结束)'; }
|
||||
});
|
||||
},
|
||||
|
||||
switchCamera: function (mode) {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
post(API.camera, {session_id: state.sessionId, camera_mode: mode}).then(function (r) {
|
||||
if (r.status === 200) { state.cameraMode = r.data.camera_mode; setLabel('视角已切换: ' + r.data.camera_mode, '#22C55E'); }
|
||||
else setLabel('视角切换失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
pauseDemo: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
post(API.pause, {session_id: state.sessionId, paused: true}).then(function (r) {
|
||||
if (r.status === 200) setLabel('已暂停', '#FF8800');
|
||||
else setLabel('暂停失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
resumeDemo: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
post(API.pause, {session_id: state.sessionId, paused: false}).then(function (r) {
|
||||
if (r.status === 200) setLabel('已继续', '#22C55E');
|
||||
else setLabel('继续失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
editEntity: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
var entityId = window.prompt('输入要编辑的实体 ID:');
|
||||
if (!entityId) return;
|
||||
var fieldsRaw = window.prompt('输入字段 JSON(如 {"color":"#22C55E","size":2,"rotSpeed":1}):');
|
||||
if (!fieldsRaw) return;
|
||||
var fields;
|
||||
try { fields = JSON.parse(fieldsRaw); }
|
||||
catch (e) { setLabel('字段 JSON 非法: ' + e.message, '#EF4444'); return; }
|
||||
post(API.edit, {session_id: state.sessionId, entity_id: entityId, fields: fields}).then(function (r) {
|
||||
if (r.status === 200) setLabel('实体已更新(<1s 前端反映)', '#22C55E');
|
||||
else setLabel('编辑失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
showAttrs: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
var entityId = window.prompt('输入要查看的实体 ID:');
|
||||
if (!entityId) return;
|
||||
post(API.attrs, {session_id: state.sessionId, entity_id: entityId}).then(function (r) {
|
||||
if (r.status === 200 && r.data) { state.attrsMode = true; setAttrsPanel(r.data); }
|
||||
else setLabel('查询失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
hotload: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
var scriptId = window.prompt('输入脚本 ID (script_engine 联动):');
|
||||
if (!scriptId) return;
|
||||
var content = window.prompt('输入脚本内容(保存即语法校验+热加载):');
|
||||
if (!content) return;
|
||||
post(API.hotload, {session_id: state.sessionId, script_id: scriptId, script_content: content}).then(function (r) {
|
||||
if (r.status === 200) setLabel('脚本已热加载: ' + r.data.status, '#22C55E');
|
||||
else setLabel('热加载失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
joinPresence: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
post(API.presence, {session_id: state.sessionId, action: 'join'}).then(function (r) {
|
||||
if (r.status === 200) setLabel('已加入多人预览, 当前在线 ' + r.data.viewer_count, '#22C55E');
|
||||
else setLabel('加入失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
leavePresence: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
post(API.presence, {session_id: state.sessionId, action: 'leave', user_id: ''}).then(function (r) {
|
||||
if (r.status === 200) setLabel('已离开预览', '#22C55E');
|
||||
else setLabel('离开失败: ' + (r.message || '未知'), '#EF4444');
|
||||
});
|
||||
},
|
||||
|
||||
toggleFullscreen: function () {
|
||||
if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; }
|
||||
var el = getContainer();
|
||||
var enabled = false;
|
||||
if (document.fullscreenElement) { document.exitFullscreen(); enabled = false; }
|
||||
else if (el && el.requestFullscreen) { el.requestFullscreen(); enabled = true; }
|
||||
post(API.fullscreen, {session_id: state.sessionId, enabled: enabled}).then(function (r) {
|
||||
if (r.status === 200) setLabel('全屏: ' + (enabled ? '开' : '关'), '#22C55E');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.demoViewer = demoViewer;
|
||||
})();
|
||||
42
wwwroot/demo_viewer.ui
Normal file
42
wwwroot/demo_viewer.ui
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "10px", "backgroundColor": "#F5F6FA"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "HBox", "options": {"width": "100%", "gap": "8px", "align": "center"}, "subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "🎬 实时演示预览", "fontSize": "18px", "fontWeight": "bold"}},
|
||||
{"widgettype": "Text", "id": "demo_session_label", "options": {"label": "未开始", "fontSize": "13px", "color": "#FF8800"}},
|
||||
{"widgettype": "Button", "options": {"label": "开始演示", "type": "primary"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.startDemo()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "结束演示", "type": "danger"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.stopDemo()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "暂停"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.pauseDemo()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "继续"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.resumeDemo()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "编辑实体"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.editEntity()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "查看属性"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.showAttrs()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "保存脚本(热加载)"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.hotload()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "全屏"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.toggleFullscreen()"}}]}
|
||||
]},
|
||||
{"widgettype": "HBox", "options": {"width": "100%", "gap": "8px", "marginTop": "8px"}, "subwidgets": [
|
||||
{"widgettype": "Button", "options": {"label": "视角:环绕"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.switchCamera('orbit')"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "视角:俯视"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.switchCamera('topdown')"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "视角:第一人称"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.switchCamera('firstperson')"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "加入多人预览"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.joinPresence()"}}]},
|
||||
{"widgettype": "Button", "options": {"label": "离开预览"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "options": {"script": "demoViewer.leavePresence()"}}]}
|
||||
]},
|
||||
{"widgettype": "Text", "id": "demo_canvas_container",
|
||||
"options": {"label": "3D 预览区(three.js 渲染;未开始演示时显示占位)", "width": "100%", "flex": "1",
|
||||
"height": "100%", "marginTop": "10px", "backgroundColor": "#1A1D26", "color": "#8A93A6",
|
||||
"padding": "10px", "borderRadius": "6px"}}
|
||||
]
|
||||
}
|
||||
31
wwwroot/index.ui
Normal file
31
wwwroot/index.ui
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "W-07 实时演示模块", "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.demo_content",
|
||||
"options": {"url": "{{entire_url('demo_viewer.ui')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "🎬 演示控制台", "fontSize": "18px"}},
|
||||
{"widgettype": "Text", "options": {"label": "开始/结束演示 · 编辑实体 · 视角切换 · 暂停继续 · 全屏 · 多人预览", "fontSize": "13px", "color": "#666666"}}
|
||||
]},
|
||||
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.demo_content",
|
||||
"options": {"url": "{{entire_url('/demo/demo_session')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "📋 演示会话列表", "fontSize": "18px"}},
|
||||
{"widgettype": "Text", "options": {"label": "查看/维护进行中的演示会话(状态隔离,只读正式数据)", "fontSize": "13px", "color": "#666666"}}
|
||||
]},
|
||||
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.demo_content",
|
||||
"options": {"url": "{{entire_url('demo_viewer.ui?attrs=1')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "🔍 实体属性查询", "fontSize": "18px"}},
|
||||
{"widgettype": "Text", "options": {"label": "查看演示中实体的 基线/当前/合并 属性", "fontSize": "13px", "color": "#666666"}}
|
||||
]}
|
||||
]},
|
||||
{"widgettype": "VBox", "id": "app.demo_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user