From 03d3e3ae282226f5e5305aa0f9e2f65494033aaf Mon Sep 17 00:00:00 2001 From: pipeline-agent Date: Sat, 29 Aug 2026 21:10:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=83=E6=99=AF=20demo=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=88W-0x=20=E5=BC=80=E5=8F=91=E4=BA=A7=E5=87=BA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 16 ++ README.md | 47 ++++ demo/__init__.py | 24 ++ demo/demo.py | 438 ++++++++++++++++++++++++++++++++ demo/init.py | 39 +++ docs/self-test-cases.md | 84 ++++++ docs/work-log-2026-08-29.md | 32 +++ json/demo_session.json | 24 ++ models/demo_hot_script.json | 19 ++ models/demo_presence.json | 18 ++ models/demo_session.json | 22 ++ models/demo_session_state.json | 19 ++ pyproject.toml | 14 + scripts/demo_ddl.sql | 55 ++++ scripts/load_path.py | 67 +++++ skill/SKILL.md | 43 ++++ wwwroot/api/camera.dspy | 19 ++ wwwroot/api/entity_attrs.dspy | 18 ++ wwwroot/api/entity_edit.dspy | 23 ++ wwwroot/api/fullscreen.dspy | 15 ++ wwwroot/api/hotload.dspy | 24 ++ wwwroot/api/pause.dspy | 17 ++ wwwroot/api/presence.dspy | 29 +++ wwwroot/api/session_create.dspy | 18 ++ wwwroot/api/session_delete.dspy | 15 ++ wwwroot/api/session_update.dspy | 22 ++ wwwroot/api/start.dspy | 21 ++ wwwroot/api/state.dspy | 14 + wwwroot/api/stop.dspy | 16 ++ wwwroot/demo_viewer.css | 14 + wwwroot/demo_viewer.js | 336 ++++++++++++++++++++++++ wwwroot/demo_viewer.ui | 42 +++ wwwroot/index.ui | 31 +++ 33 files changed, 1635 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 demo/__init__.py create mode 100644 demo/demo.py create mode 100644 demo/init.py create mode 100644 docs/self-test-cases.md create mode 100644 docs/work-log-2026-08-29.md create mode 100644 json/demo_session.json create mode 100644 models/demo_hot_script.json create mode 100644 models/demo_presence.json create mode 100644 models/demo_session.json create mode 100644 models/demo_session_state.json create mode 100644 pyproject.toml create mode 100644 scripts/demo_ddl.sql create mode 100644 scripts/load_path.py create mode 100644 skill/SKILL.md create mode 100644 wwwroot/api/camera.dspy create mode 100644 wwwroot/api/entity_attrs.dspy create mode 100644 wwwroot/api/entity_edit.dspy create mode 100644 wwwroot/api/fullscreen.dspy create mode 100644 wwwroot/api/hotload.dspy create mode 100644 wwwroot/api/pause.dspy create mode 100644 wwwroot/api/presence.dspy create mode 100644 wwwroot/api/session_create.dspy create mode 100644 wwwroot/api/session_delete.dspy create mode 100644 wwwroot/api/session_update.dspy create mode 100644 wwwroot/api/start.dspy create mode 100644 wwwroot/api/state.dspy create mode 100644 wwwroot/api/stop.dspy create mode 100644 wwwroot/demo_viewer.css create mode 100644 wwwroot/demo_viewer.js create mode 100644 wwwroot/demo_viewer.ui create mode 100644 wwwroot/index.ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3058a1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ + +# 构建产物(xls2ui 自动生成,勿入库) +wwwroot/demo_session/ + +# 编辑器/系统 +*.swp +*.swo +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f0288b --- /dev/null +++ b/README.md @@ -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 /wwwroot /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/) diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..9441c66 --- /dev/null +++ b/demo/__init__.py @@ -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', +] diff --git a/demo/demo.py b/demo/demo.py new file mode 100644 index 0000000..e368843 --- /dev/null +++ b/demo/demo.py @@ -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), '' % 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) diff --git a/demo/init.py b/demo/init.py new file mode 100644 index 0000000..4577411 --- /dev/null +++ b/demo/init.py @@ -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 diff --git a/docs/self-test-cases.md b/docs/self-test-cases.md new file mode 100644 index 0000000..0e129c3 --- /dev/null +++ b/docs/self-test-cases.md @@ -0,0 +1,84 @@ +# W-07 demo 模块功能点自测(正例 + 反例) + +> 依据 feature-granularity-and-testing:每个小功能点至少 1 正例 + 1 反例,期望可判定。 +> 执行入口:部署到 /d/scense/scense_app 后,用登录 cookie 请求 `http://:/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":""}` → 期望 `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":"","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":"","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":"","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":"","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":"","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":"","entity_id":"e1"}` → `status=200` 且 data 含 base/demo/merged 三对象;merged=base 与 demo 合并。 +- 反例 W-07i-neg-1(实体不在会话):`{"session_id":"","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":"","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)。 diff --git a/docs/work-log-2026-08-29.md b/docs/work-log-2026-08-29.md new file mode 100644 index 0000000..9e73f46 --- /dev/null +++ b/docs/work-log-2026-08-29.md @@ -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 提交/部署)。 diff --git a/json/demo_session.json b/json/demo_session.json new file mode 100644 index 0000000..8acede3 --- /dev/null +++ b/json/demo_session.json @@ -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": "" + } +} diff --git a/models/demo_hot_script.json b/models/demo_hot_script.json new file mode 100644 index 0000000..59f511a --- /dev/null +++ b/models/demo_hot_script.json @@ -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": [] +} diff --git a/models/demo_presence.json b/models/demo_presence.json new file mode 100644 index 0000000..ad50cd9 --- /dev/null +++ b/models/demo_presence.json @@ -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": [] +} diff --git a/models/demo_session.json b/models/demo_session.json new file mode 100644 index 0000000..450a58b --- /dev/null +++ b/models/demo_session.json @@ -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": [] +} diff --git a/models/demo_session_state.json b/models/demo_session_state.json new file mode 100644 index 0000000..ec1bdba --- /dev/null +++ b/models/demo_session_state.json @@ -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": [] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3bcc148 --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/scripts/demo_ddl.sql b/scripts/demo_ddl.sql new file mode 100644 index 0000000..2d7cefc --- /dev/null +++ b/scripts/demo_ddl.sql @@ -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='演示多人预览在线表'; diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..7312e33 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""demo 模块 RBAC 注册脚本(显式路径,禁止通配符)。 + +用法: cd && ./py3/bin/python /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() diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..6b6ddd8 --- /dev/null +++ b/skill/SKILL.md @@ -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。 diff --git a/wwwroot/api/camera.dspy b/wwwroot/api/camera.dspy new file mode 100644 index 0000000..27acd64 --- /dev/null +++ b/wwwroot/api/camera.dspy @@ -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} diff --git a/wwwroot/api/entity_attrs.dspy b/wwwroot/api/entity_attrs.dspy new file mode 100644 index 0000000..eb428a8 --- /dev/null +++ b/wwwroot/api/entity_attrs.dspy @@ -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} diff --git a/wwwroot/api/entity_edit.dspy b/wwwroot/api/entity_edit.dspy new file mode 100644 index 0000000..f5584d8 --- /dev/null +++ b/wwwroot/api/entity_edit.dspy @@ -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} diff --git a/wwwroot/api/fullscreen.dspy b/wwwroot/api/fullscreen.dspy new file mode 100644 index 0000000..8f243be --- /dev/null +++ b/wwwroot/api/fullscreen.dspy @@ -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} diff --git a/wwwroot/api/hotload.dspy b/wwwroot/api/hotload.dspy new file mode 100644 index 0000000..4e05604 --- /dev/null +++ b/wwwroot/api/hotload.dspy @@ -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} diff --git a/wwwroot/api/pause.dspy b/wwwroot/api/pause.dspy new file mode 100644 index 0000000..ada0a64 --- /dev/null +++ b/wwwroot/api/pause.dspy @@ -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} diff --git a/wwwroot/api/presence.dspy b/wwwroot/api/presence.dspy new file mode 100644 index 0000000..1a778e6 --- /dev/null +++ b/wwwroot/api/presence.dspy @@ -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} diff --git a/wwwroot/api/session_create.dspy b/wwwroot/api/session_create.dspy new file mode 100644 index 0000000..0f814d5 --- /dev/null +++ b/wwwroot/api/session_create.dspy @@ -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} diff --git a/wwwroot/api/session_delete.dspy b/wwwroot/api/session_delete.dspy new file mode 100644 index 0000000..0471984 --- /dev/null +++ b/wwwroot/api/session_delete.dspy @@ -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} diff --git a/wwwroot/api/session_update.dspy b/wwwroot/api/session_update.dspy new file mode 100644 index 0000000..6ebde94 --- /dev/null +++ b/wwwroot/api/session_update.dspy @@ -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} diff --git a/wwwroot/api/start.dspy b/wwwroot/api/start.dspy new file mode 100644 index 0000000..bc82836 --- /dev/null +++ b/wwwroot/api/start.dspy @@ -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} diff --git a/wwwroot/api/state.dspy b/wwwroot/api/state.dspy new file mode 100644 index 0000000..94e25ad --- /dev/null +++ b/wwwroot/api/state.dspy @@ -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} diff --git a/wwwroot/api/stop.dspy b/wwwroot/api/stop.dspy new file mode 100644 index 0000000..4b28094 --- /dev/null +++ b/wwwroot/api/stop.dspy @@ -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} diff --git a/wwwroot/demo_viewer.css b/wwwroot/demo_viewer.css new file mode 100644 index 0000000..a2d042a --- /dev/null +++ b/wwwroot/demo_viewer.css @@ -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; +} diff --git a/wwwroot/demo_viewer.js b/wwwroot/demo_viewer.js new file mode 100644 index 0000000..4ef0dbf --- /dev/null +++ b/wwwroot/demo_viewer.js @@ -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; +})(); diff --git a/wwwroot/demo_viewer.ui b/wwwroot/demo_viewer.ui new file mode 100644 index 0000000..52d0a86 --- /dev/null +++ b/wwwroot/demo_viewer.ui @@ -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"}} + ] +} diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..13942d1 --- /dev/null +++ b/wwwroot/index.ui @@ -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"}} + ] +}