feat: 元景 demo 模块(W-0x 开发产出)

This commit is contained in:
pipeline-agent 2026-08-29 21:10:23 +08:00
commit 03d3e3ae28
33 changed files with 1635 additions and 0 deletions

16
.gitignore vendored Normal file
View 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
View 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/endedcamera_modeviewer_countextra_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`:注册 RBAClogined中央 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
View 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
View 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
View 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
View 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-posbody `{"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 模块可用时)或 200world 模块未挂载时跳过校验,防御式)。
## W-07b 结束演示(状态隔离)— POST /demo/api/stop.dspy
- 正例 W-07b-pos对进行中会话 `{"session_id":"<sid>"}` → 期望 `status=200``data.overlay_cleared=true`demo_session.status=endeddemo_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-2fields 非法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-1action 非法):`action="kick"``status=400` message 含 "action 只能为 join/leave"。
- 反例 W-07j-neg-2会话不存在`status=404`
## 状态机合法性补充
- W-07a 开始后 → previewW-07f 暂停 → paused继续 → previewW-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/datafail 一律落 sd_bugs关联 task_id

View 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/exceptworld 模块未挂载时跳过),不阻塞演示。
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.pyload_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.jsonCRUDscripts/load_path.pyRBAC 显式路径scripts/demo_ddl.sql
- pyproject.toml / README.md / skill/SKILL.md / .gitignore
## 验证情况(环境受限说明)
- 本工作空间无 /d/scense/scense_app 部署目录与数据库连接,无法在此完整启动宿主应用做接口联调。
- 已完成的静态验证:模型 JSON 四段式summary/fields/indexes/codesdspy 无 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
View 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": ""
}
}

View 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
View 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
View 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": []
}

View 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
View 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
View 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
View 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
View 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/leaveviewer_count 实时回写
- `fullscreen.dspy` — enabled true/false写入 extra_json
- `state.dspy` — 前端 500ms 轮询,聚合实体状态+脚本+在线人数
## 状态机(会话级)
preview ⇄ pausedpause.dspypreview/paused → endedstop.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→400dspy 里必须 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 actiontypebricks script actiontype 内禁止 fetch/setInterval。

19
wwwroot/api/camera.dspy Normal file
View 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}

View 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}

View 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}

View 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
View 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
View 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
View 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}

View 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}

View 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}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"}}
]
}