diff --git a/reports/guide-formal-20260910/.gitignore b/reports/guide-formal-20260910/.gitignore
new file mode 100644
index 0000000..9351210
--- /dev/null
+++ b/reports/guide-formal-20260910/.gitignore
@@ -0,0 +1,3 @@
+# 本地实测导出包及已修复失败尝试,仅保留在当前设备供追溯。
+formal-offline.zip
+*failure*.png
diff --git a/reports/guide-formal-20260910/01-formal-created.png b/reports/guide-formal-20260910/01-formal-created.png
new file mode 100644
index 0000000..9619236
Binary files /dev/null and b/reports/guide-formal-20260910/01-formal-created.png differ
diff --git a/reports/guide-formal-20260910/02-formal-assets.png b/reports/guide-formal-20260910/02-formal-assets.png
new file mode 100644
index 0000000..10ef3be
Binary files /dev/null and b/reports/guide-formal-20260910/02-formal-assets.png differ
diff --git a/reports/guide-formal-20260910/03-formal-fox.png b/reports/guide-formal-20260910/03-formal-fox.png
new file mode 100644
index 0000000..9245e62
Binary files /dev/null and b/reports/guide-formal-20260910/03-formal-fox.png differ
diff --git a/reports/guide-formal-20260910/04-formal-published.png b/reports/guide-formal-20260910/04-formal-published.png
new file mode 100644
index 0000000..ad9c916
Binary files /dev/null and b/reports/guide-formal-20260910/04-formal-published.png differ
diff --git a/reports/guide-formal-20260910/05-reader-notes.png b/reports/guide-formal-20260910/05-reader-notes.png
new file mode 100644
index 0000000..30f63fa
Binary files /dev/null and b/reports/guide-formal-20260910/05-reader-notes.png differ
diff --git a/reports/guide-formal-20260910/06-reader-layout.png b/reports/guide-formal-20260910/06-reader-layout.png
new file mode 100644
index 0000000..18cfc8c
Binary files /dev/null and b/reports/guide-formal-20260910/06-reader-layout.png differ
diff --git a/reports/guide-formal-20260910/07-reader-interactive.png b/reports/guide-formal-20260910/07-reader-interactive.png
new file mode 100644
index 0000000..9426a0e
Binary files /dev/null and b/reports/guide-formal-20260910/07-reader-interactive.png differ
diff --git a/reports/guide-formal-20260910/08-http-integration.png b/reports/guide-formal-20260910/08-http-integration.png
new file mode 100644
index 0000000..7283aca
Binary files /dev/null and b/reports/guide-formal-20260910/08-http-integration.png differ
diff --git a/reports/guide-formal-20260910/09-reader-model.png b/reports/guide-formal-20260910/09-reader-model.png
new file mode 100644
index 0000000..9426a0e
Binary files /dev/null and b/reports/guide-formal-20260910/09-reader-model.png differ
diff --git a/reports/guide-formal-20260910/10-two-scene-resources.png b/reports/guide-formal-20260910/10-two-scene-resources.png
new file mode 100644
index 0000000..aa9f171
Binary files /dev/null and b/reports/guide-formal-20260910/10-two-scene-resources.png differ
diff --git a/reports/guide-formal-20260910/11-reader-truck.png b/reports/guide-formal-20260910/11-reader-truck.png
new file mode 100644
index 0000000..3b5b8fc
Binary files /dev/null and b/reports/guide-formal-20260910/11-reader-truck.png differ
diff --git a/reports/guide-formal-20260910/12-reading-completed.png b/reports/guide-formal-20260910/12-reading-completed.png
new file mode 100644
index 0000000..687cdb2
Binary files /dev/null and b/reports/guide-formal-20260910/12-reading-completed.png differ
diff --git a/reports/guide-formal-20260910/13-http-conflict.png b/reports/guide-formal-20260910/13-http-conflict.png
new file mode 100644
index 0000000..cd07b19
Binary files /dev/null and b/reports/guide-formal-20260910/13-http-conflict.png differ
diff --git a/reports/guide-formal-20260910/14-formal-templates.png b/reports/guide-formal-20260910/14-formal-templates.png
new file mode 100644
index 0000000..891eaee
Binary files /dev/null and b/reports/guide-formal-20260910/14-formal-templates.png differ
diff --git a/reports/guide-formal-20260910/15-offline-fox-scene.png b/reports/guide-formal-20260910/15-offline-fox-scene.png
new file mode 100644
index 0000000..d7969a3
Binary files /dev/null and b/reports/guide-formal-20260910/15-offline-fox-scene.png differ
diff --git a/reports/guide-formal-20260910/16-offline-truck-scene.png b/reports/guide-formal-20260910/16-offline-truck-scene.png
new file mode 100644
index 0000000..6246b92
Binary files /dev/null and b/reports/guide-formal-20260910/16-offline-truck-scene.png differ
diff --git a/reports/guide-formal-20260910/17-template-editor.png b/reports/guide-formal-20260910/17-template-editor.png
new file mode 100644
index 0000000..7998baf
Binary files /dev/null and b/reports/guide-formal-20260910/17-template-editor.png differ
diff --git a/reports/guide-formal-20260910/18-formal-video.png b/reports/guide-formal-20260910/18-formal-video.png
new file mode 100644
index 0000000..d2fd077
Binary files /dev/null and b/reports/guide-formal-20260910/18-formal-video.png differ
diff --git a/reports/guide-formal-20260910/create.json b/reports/guide-formal-20260910/create.json
new file mode 100644
index 0000000..2f41cdb
--- /dev/null
+++ b/reports/guide-formal-20260910/create.json
@@ -0,0 +1,15 @@
+{
+ "checks": [
+ {
+ "name": "新建请求实际进入内容服务",
+ "pass": true,
+ "detail": "真实 HTTP 请求成功;响应内容已脱敏。"
+ },
+ {
+ "name": "正式后台可查询新工程",
+ "pass": true,
+ "detail": ""
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/final.json b/reports/guide-formal-20260910/final.json
new file mode 100644
index 0000000..cf07818
--- /dev/null
+++ b/reports/guide-formal-20260910/final.json
@@ -0,0 +1,70 @@
+{
+ "checks": [
+ {
+ "name": "重新打开恢复上次第三页阅读位置",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "笔记编辑保存成功",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "笔记删除成功",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "取消收藏写回服务器",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "动态阅读同步第四页和百分百页进度",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "教学只读模式允许目录定位和选择动画",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "教学动态版式切换卡车步骤",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "浏览器完成阅读并更新状态",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "HTTP 面板实际 PUT 阅读记录",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "HTTP 面板旧版本返回 409",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "错误 HTTP 方法明确返回 405",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "正式模板库可用",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "无浏览器脚本异常",
+ "pass": true,
+ "detail": ""
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/formal.ofd b/reports/guide-formal-20260910/formal.ofd
new file mode 100644
index 0000000..1e94a61
Binary files /dev/null and b/reports/guide-formal-20260910/formal.ofd differ
diff --git a/reports/guide-formal-20260910/index.html b/reports/guide-formal-20260910/index.html
new file mode 100644
index 0000000..9c909c8
--- /dev/null
+++ b/reports/guide-formal-20260910/index.html
@@ -0,0 +1,9 @@
+
作业指导正式接口与教学闭环 · 实测报告
+
+开发环境 · 2026-09-10 — 2026-09-11 · 顺序实施与回归
作业指导正式接口与教学闭环
已完成编辑器接入、教学阅读记录、真实 HTTP 联调三项实施。正式发布工程 412,版本 V3.0.0(版本 ID 472)。
58/58E2E 检查点通过
391后端测试通过
17关键截图
3保留复核工程
+实施内容
| 顺序 | 落地结果 | 验证方式 |
|---|
| 1. 内容、资源、版本 | 完整编辑文档写入正式内容服务;上传资源进入受控存储;模板、模型和场景从正式接口加载;保存、发布、历史版本及恢复使用服务端版本。场景资源固定到发布版本,修改草稿不改变已发布教学内容。 | 新建、模板生成、保存重开、GLB/图片/视频上传、固定场景引用、修订与发布、OFD 和离线 ZIP 实际下载。 |
| 2. 教学阅读闭环 | 教学端消费正式发布文档与受控资源;阅读位置、笔记、收藏按用户和发布版本独立保存;记录开始/完成、反馈与处理,提供授权聚合统计。 | 翻页恢复、笔记增改删、收藏取消、动态步骤同步进度、学员权限及数据隔离、反馈回复、完成记录。 |
| 3. 真实 HTTP 联调 | 接口页面通过统一 HTTP 客户端调用 Gateway/业务服务,展示真实响应。涵盖内容、资源、版本、教学、个人阅读记录、统计与交付物。 | 浏览器实际 GET/PUT;旧版本返回 409;错误方法返回 405;未经授权内容和统计接口返回 403。 |
接口沿用平台统一登录会话与权限体系。第三方系统需取得平台会话;数字人开放应用 APPID 不用于这组作业指导接口。
+本轮修复
- 完整文档校验及正式资源 URI 保留;教学投影转换为受控下载地址,避免文件存储地址直接暴露或刷新后丢失。
- 大 GLB 流式下载被默认 30 秒中断;异步流响应超时调整为可配置的 30 分钟。
- 保存与发布并发造成旧版本冲突;发布前等待自动保存队列完成。
- 阅读器初始化零页记录、只读目录/动画选择被拦截、动态页进度未同步、笔记删除后编辑框残留。
- 离线模型切换后的矩阵与镜头适配错误,导致场景偏出画面。
- 图片、视频海报的正式 URI 被过滤;视频完成加载后动态预览未刷新。已验证受控视频实际播放。
- 发布弹窗与动态白纸内容的暗色主题文字对比度。
+测试结果
浏览器为 Edge / Playwright,主要视口 1920×1080。管理操作使用内部维护账号;教学权限使用临时学员账号,测试后已清理。HTTP 通过 Web 6180 → Gateway 6100 → Tran 6102。
mvn -pl ut_tran -am test package:391 项通过,0 失败、0 错误、0 跳过。
pnpm run build:类型检查、Vite 构建与模型编辑器发布资源校验通过;保留既有 chunk 大小及运行时资源 URL 警告。
| 检查点 | 结果 |
|---|
| 新建请求实际进入内容服务 真实 HTTP 请求成功;响应内容已脱敏。 | 通过 |
| 正式后台可查询新工程 | 通过 |
| 检查点 | 结果 |
|---|
| 上传 09-Fox.glb 真实 HTTP 请求成功;响应内容已脱敏。 | 通过 |
| 上传 08-CesiumMilkTruck.glb 真实 HTTP 请求成功;响应内容已脱敏。 | 通过 |
| 刷新后受控模型资源恢复显示 | 通过 |
| 检查点 | 结果 |
|---|
| 新修订保持原发布版本可阅读 | 通过 |
| 狐狸与卡车场景固定版本已保存 {"old":"469","published":"469","assets":7,"bindings":["160@463","159@209"],"version":12} | 通过 |
| 检查点 | 结果 |
|---|
| 正式发布事务完成 真实 HTTP 请求成功;响应内容已脱敏。 | 通过 |
| 教学指导书列表出现正式发布工程 | 通过 |
| 发布版本提供 OFD 与离线 ZIP | 通过 |
| 下载正式交付物 OFD | 通过 |
| 下载正式交付物 OFFLINE_HTML | 通过 |
教学阅读及真实 HTTP 查询 JSON 证据
| 检查点 | 结果 |
|---|
| 发布版本提供两份正式交付物 | 通过 |
| 阅读位置、收藏、笔记真实写入 | 通过 |
| 旧乐观锁版本被拒绝 | 通过 |
| 旧发布版本不能覆盖当前阅读记录 | 通过 |
| 收藏和笔记进入真实统计 | 通过 |
| 浏览器笔记保存请求成功 | 通过 |
| 浏览器翻页位置已同步 | 通过 |
| 教学动态阅读显示三维画布 | 通过 |
| 对外接口面板返回真实工程数据 | 通过 |
| 浏览器无脚本异常 | 通过 |
| 检查点 | 结果 |
|---|
| 学员不能进入内容编辑接口 | 通过 |
| 学员可以阅读正式发布版本 | 通过 |
| 学员投影没有文件存储 URI | 通过 |
| 学员看不到其他用户的笔记和收藏 | 通过 |
| 学员能保存本人的阅读记录 | 通过 |
| 学员写入不覆盖教员记录 | 通过 |
| 学员不能查看教员聚合统计 | 通过 |
| 开始阅读产生正式记录 success | 通过 |
| 阅读完成记录成功 success | 通过 |
| 学员反馈写入正式服务 | 通过 |
| 教员回复并处理反馈 success | 通过 |
| 学员通过教学接口下载 GLB | 通过 |
| 临时测试学员已清理 success | 通过 |
阅读、笔记、进度及 HTTP 写入 JSON 证据
| 检查点 | 结果 |
|---|
| 重新打开恢复上次第三页阅读位置 | 通过 |
| 笔记编辑保存成功 | 通过 |
| 笔记删除成功 | 通过 |
| 取消收藏写回服务器 | 通过 |
| 动态阅读同步第四页和百分百页进度 | 通过 |
| 教学只读模式允许目录定位和选择动画 | 通过 |
| 教学动态版式切换卡车步骤 | 通过 |
| 浏览器完成阅读并更新状态 | 通过 |
| HTTP 面板实际 PUT 阅读记录 | 通过 |
| HTTP 面板旧版本返回 409 | 通过 |
| 错误 HTTP 方法明确返回 405 | 通过 |
| 正式模板库可用 | 通过 |
| 无浏览器脚本异常 | 通过 |
| 检查点 | 结果 |
|---|
| 模板新建实际进入正式内容服务 | 通过 |
| 模板初稿进入编辑器并保存 | 通过 |
| 模板章节和步骤不丢失 | 通过 |
| 检查点 | 结果 |
|---|
| 图片与视频上传、截图引用保存后重开保持 | 通过 |
| OFD 预览读取受控图片和截图 | 通过 |
| 正式视频通过受控字节恢复并实际播放 | 通过 |
| 检查点 | 结果 |
|---|
| 15-offline-fox-scene 实测场景-狐狸野外训练区-20260905.glb · 186 个网格 · 拖拽旋转 / 滚轮缩放 | 通过 |
| 16-offline-truck-scene 实测场景-牛奶卡车检验区-20260905.glb · 37 个网格 · 拖拽旋转 / 滚轮缩放 | 通过 |
| 离线资源不依赖业务服务器或公网 | 通过 |
| 无脚本异常 | 通过 |
检查点来自分阶段脚本,覆盖关键链路,不表示所有边缘输入均已穷举。create/scenes 等早期证据对应创建及 V1→V2 阶段;最新发布、阅读和离线回归对应 V3。
+
+交付与迁移
下载实测正式 OFD · 下载实测正式离线 ZIP(本地文件) · 发布摘要 · ZIP 完整性证据
离线 ZIP:71,380,256 字节;4 个 GLB 文件通过头部与长度检查。从正式 V3 下载后解压并运行,禁止访问业务服务及公网仍能加载两个场景。镜头修复代码已经包含在正式交付包中。
SHA-256:7227683a98f98e48aa31c3839fcb63782572fe6a9d9f7f48949fbae07e64f19b
| 开发环境迁移 | 说明 |
|---|
| 20260826_017_teaching_guide_reading_closed_loop.sql | 补执行既有阅读与反馈表迁移,先回滚试执行再正式执行。 |
| 20260826_018_content_guide_template_library.sql | 补执行既有正式模板库迁移,保留 4 个模板。 |
| 20260911_001_guide_reader_state.sql | 新增按用户/项目/发布版本隔离的阅读位置、笔记、收藏及进度表;完整 DDL、数据库设计、迁移记录和 Wiki 已同步。 |
其他环境部署前先检查既有表,避免重复执行。新增流下载配置 UT_CONTENT_STREAM_TIMEOUT 默认 30m。本轮不代表其他环境已完成数据库迁移。
+复核说明与保留数据
- 工程 412:正式闭环-狐狸与卡车指导书-20260910,已发布 V3.0.0 / 472。
- 工程 413:正式模板新建回归-20260911,保留草稿。
- 工程 414:正式图片视频资源回归-20260911,保留草稿。
- 临时学员账号已清理,正式测试阅读及反馈历史保留。阅读页进度与“完成阅读”分别记录,不等同于考核成绩。
- 既有内部维护账号头像存在单独的 404,属于原账号头像资源问题;本轮指导书资源、视频及交付下载检查通过。没有将该请求计入“无网络错误”的结论。
- 本轮未自动提交或推送 Git。大型测试 ZIP 仅保留在本地报告目录,不纳入 Git。
需要核对的数据差异:场景 160@463 名为“实测场景-狐狸野外训练区-20260905”,当前发布内容实际是维修车间与装备,不包含狐狸。已按其固定发布版本验证,未修改源场景;狐狸模型在指导书独立步骤中验证通过。牛奶卡车场景使用 159@209。
三项实施均在本次授权范围内,没有新增待批准的大幅功能调整。旧浏览器本地工程如需迁移,使用显式导入新工程;无法从服务端恢复的本地文件需重新上传。
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/media.json b/reports/guide-formal-20260910/media.json
new file mode 100644
index 0000000..9da38a4
--- /dev/null
+++ b/reports/guide-formal-20260910/media.json
@@ -0,0 +1,17 @@
+{
+ "projectId": "414",
+ "checks": [
+ {
+ "name": "图片与视频上传、截图引用保存后重开保持",
+ "pass": true
+ },
+ {
+ "name": "OFD 预览读取受控图片和截图",
+ "pass": true
+ },
+ {
+ "name": "正式视频通过受控字节恢复并实际播放",
+ "pass": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/offline-browser.json b/reports/guide-formal-20260910/offline-browser.json
new file mode 100644
index 0000000..d6e0031
--- /dev/null
+++ b/reports/guide-formal-20260910/offline-browser.json
@@ -0,0 +1,25 @@
+{
+ "checks": [
+ {
+ "name": "15-offline-fox-scene",
+ "pass": true,
+ "detail": "实测场景-狐狸野外训练区-20260905.glb · 186 个网格 · 拖拽旋转 / 滚轮缩放"
+ },
+ {
+ "name": "16-offline-truck-scene",
+ "pass": true,
+ "detail": "实测场景-牛奶卡车检验区-20260905.glb · 37 个网格 · 拖拽旋转 / 滚轮缩放"
+ },
+ {
+ "name": "离线资源不依赖业务服务器或公网",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "无脚本异常",
+ "pass": true,
+ "detail": ""
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/offline.json b/reports/guide-formal-20260910/offline.json
new file mode 100644
index 0000000..92c4cf0
--- /dev/null
+++ b/reports/guide-formal-20260910/offline.json
@@ -0,0 +1,36 @@
+{
+ "integrity": "passed",
+ "fixedCameraRuntime": true,
+ "zipBytes": 71380256,
+ "sha256": "7227683a98f98e48aa31c3839fcb63782572fe6a9d9f7f48949fbae07e64f19b",
+ "models": [
+ {
+ "file": "assets/models/model-001.glb",
+ "size": 162852,
+ "validGlb": true,
+ "hasFox": true,
+ "hasMilkTruck": false
+ },
+ {
+ "file": "assets/models/model-002.glb",
+ "size": 68091456,
+ "validGlb": true,
+ "hasFox": false,
+ "hasMilkTruck": false
+ },
+ {
+ "file": "assets/models/model-003.glb",
+ "size": 318276,
+ "validGlb": true,
+ "hasFox": false,
+ "hasMilkTruck": true
+ },
+ {
+ "file": "assets/models/model-004.glb",
+ "size": 1034616,
+ "validGlb": true,
+ "hasFox": false,
+ "hasMilkTruck": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/permissions.json b/reports/guide-formal-20260910/permissions.json
new file mode 100644
index 0000000..4c365e8
--- /dev/null
+++ b/reports/guide-formal-20260910/permissions.json
@@ -0,0 +1,69 @@
+{
+ "checks": [
+ {
+ "name": "学员不能进入内容编辑接口",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "学员可以阅读正式发布版本",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "学员投影没有文件存储 URI",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "学员看不到其他用户的笔记和收藏",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "学员能保存本人的阅读记录",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "学员写入不覆盖教员记录",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "学员不能查看教员聚合统计",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "开始阅读产生正式记录",
+ "pass": true,
+ "detail": "success"
+ },
+ {
+ "name": "阅读完成记录成功",
+ "pass": true,
+ "detail": "success"
+ },
+ {
+ "name": "学员反馈写入正式服务",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "教员回复并处理反馈",
+ "pass": true,
+ "detail": "success"
+ },
+ {
+ "name": "学员通过教学接口下载 GLB",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "临时测试学员已清理",
+ "pass": true,
+ "detail": "success"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/publish.json b/reports/guide-formal-20260910/publish.json
new file mode 100644
index 0000000..5619bf5
--- /dev/null
+++ b/reports/guide-formal-20260910/publish.json
@@ -0,0 +1,30 @@
+{
+ "checks": [
+ {
+ "name": "正式发布事务完成",
+ "pass": true,
+ "detail": "真实 HTTP 请求成功;响应内容已脱敏。"
+ },
+ {
+ "name": "教学指导书列表出现正式发布工程",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "发布版本提供 OFD 与离线 ZIP",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "下载正式交付物 OFD",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "下载正式交付物 OFFLINE_HTML",
+ "pass": true,
+ "detail": ""
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/release.json b/reports/guide-formal-20260910/release.json
new file mode 100644
index 0000000..c535a68
--- /dev/null
+++ b/reports/guide-formal-20260910/release.json
@@ -0,0 +1,53 @@
+{
+ "projectId": "412",
+ "versionId": "472",
+ "versionCode": "V3.0.0",
+ "status": "PUBLISHED",
+ "dependencies": [
+ {
+ "projectId": "160",
+ "versionId": "463"
+ },
+ {
+ "projectId": "159",
+ "versionId": "209"
+ }
+ ],
+ "assets": [
+ {
+ "name": "09-Fox.glb",
+ "sizeBytes": 162852,
+ "type": "MODEL_FILE"
+ },
+ {
+ "name": "08-CesiumMilkTruck.glb",
+ "sizeBytes": 369980,
+ "type": "MODEL_FILE"
+ },
+ {
+ "name": "capture.jpeg",
+ "sizeBytes": 25174,
+ "type": "IMAGE"
+ },
+ {
+ "name": "实测场景-狐狸野外训练区-20260905.glb",
+ "sizeBytes": 81483944,
+ "type": "MODEL_FILE"
+ },
+ {
+ "name": "实测场景-牛奶卡车检验区-20260905.glb",
+ "sizeBytes": 1330380,
+ "type": "MODEL_FILE"
+ },
+ {
+ "name": "正式闭环-狐狸与卡车指导书-20260910_V3.0.0.ofd",
+ "sizeBytes": 53551,
+ "type": "DOCUMENT"
+ },
+ {
+ "name": "正式闭环-狐狸与卡车指导书-20260910_V3.0.0_动态版式离线包.zip",
+ "sizeBytes": 71380256,
+ "type": "DOCUMENT"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/report-preview.png b/reports/guide-formal-20260910/report-preview.png
new file mode 100644
index 0000000..acba5fd
Binary files /dev/null and b/reports/guide-formal-20260910/report-preview.png differ
diff --git a/reports/guide-formal-20260910/report-validation.json b/reports/guide-formal-20260910/report-validation.json
new file mode 100644
index 0000000..088700d
--- /dev/null
+++ b/reports/guide-formal-20260910/report-validation.json
@@ -0,0 +1,6 @@
+{
+ "images": 17,
+ "brokenImages": [],
+ "lanPageLoaded": true,
+ "viewport": "1440x1000"
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/review.json b/reports/guide-formal-20260910/review.json
new file mode 100644
index 0000000..c3c171f
--- /dev/null
+++ b/reports/guide-formal-20260910/review.json
@@ -0,0 +1,55 @@
+{
+ "checks": [
+ {
+ "name": "发布版本提供两份正式交付物",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "阅读位置、收藏、笔记真实写入",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "旧乐观锁版本被拒绝",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "旧发布版本不能覆盖当前阅读记录",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "收藏和笔记进入真实统计",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "浏览器笔记保存请求成功",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "浏览器翻页位置已同步",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "教学动态阅读显示三维画布",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "对外接口面板返回真实工程数据",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "浏览器无脚本异常",
+ "pass": true,
+ "detail": ""
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/scenes.json b/reports/guide-formal-20260910/scenes.json
new file mode 100644
index 0000000..d070215
--- /dev/null
+++ b/reports/guide-formal-20260910/scenes.json
@@ -0,0 +1,14 @@
+{
+ "checks": [
+ {
+ "name": "新修订保持原发布版本可阅读",
+ "pass": true,
+ "detail": ""
+ },
+ {
+ "name": "狐狸与卡车场景固定版本已保存",
+ "pass": true,
+ "detail": "{\"old\":\"469\",\"published\":\"469\",\"assets\":7,\"bindings\":[\"160@463\",\"159@209\"],\"version\":12}"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/template.json b/reports/guide-formal-20260910/template.json
new file mode 100644
index 0000000..9960b1a
--- /dev/null
+++ b/reports/guide-formal-20260910/template.json
@@ -0,0 +1,17 @@
+{
+ "projectId": "413",
+ "checks": [
+ {
+ "name": "模板新建实际进入正式内容服务",
+ "pass": true
+ },
+ {
+ "name": "模板初稿进入编辑器并保存",
+ "pass": true
+ },
+ {
+ "name": "模板章节和步骤不丢失",
+ "pass": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/guide-formal-20260910/upload.json b/reports/guide-formal-20260910/upload.json
new file mode 100644
index 0000000..943475f
--- /dev/null
+++ b/reports/guide-formal-20260910/upload.json
@@ -0,0 +1,20 @@
+{
+ "checks": [
+ {
+ "name": "上传 09-Fox.glb",
+ "pass": true,
+ "detail": "真实 HTTP 请求成功;响应内容已脱敏。"
+ },
+ {
+ "name": "上传 08-CesiumMilkTruck.glb",
+ "pass": true,
+ "detail": "真实 HTTP 请求成功;响应内容已脱敏。"
+ },
+ {
+ "name": "刷新后受控模型资源恢复显示",
+ "pass": true,
+ "detail": ""
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/tools/guide-formal-final.mjs b/tools/guide-formal-final.mjs
new file mode 100644
index 0000000..9b7994b
--- /dev/null
+++ b/tools/guide-formal-final.mjs
@@ -0,0 +1,70 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import {chromium,expect} from '@playwright/test'
+import {login,call} from './role-permissions-live.mjs'
+const report=path.resolve('reports/guide-formal-20260910'), fixture=JSON.parse(fs.readFileSync('../../.codex-tmp/guide-formal/fixture.json'))
+const session=await login('root'), token=session.accessToken, checks=[],errors=[]
+const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
+const context=await browser.newContext({viewport:{width:1920,height:1080}})
+await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
+const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message))
+const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
+const shot=name=>page.screenshot({path:path.join(report,name+'.png')})
+const openReader=async()=>{
+ await page.goto('http://127.0.0.1:6180/teaching/guides')
+ await page.getByPlaceholder('搜索指导书名称或编码').fill('正式闭环-狐狸与卡车指导书-20260910')
+ await page.getByPlaceholder('搜索指导书名称或编码').press('Enter')
+ await expect(page.locator('.guide-card-v2')).toHaveCount(1)
+ await page.locator('.guide-card-v2 button').first().click()
+ await expect(page.locator('.legacy-published-reader .ofd-preview-pager')).toBeVisible({timeout:60000})
+}
+try{
+ const state=(await call(`tran/v1/teaching/guides/${fixture.id}/reader-state`,token)).data
+ await call(`tran/v1/teaching/guides/${fixture.id}/reader-state`,token,{...state,pageId:'page:2',pageIndex:2,pageCount:4,favorite:true,notes:[{id:'final-1',pageId:'page:1',text:'定位核对记录'},{id:'final-2',pageId:'page:2',text:'编辑与删除测试记录'}]},'PUT')
+ await openReader()
+ await expect(page.locator('.legacy-published-reader .ofd-preview-pager')).toContainText('3 / 4')
+ check('重新打开恢复上次第三页阅读位置',true)
+ await page.getByRole('button',{name:/笔记 2/}).click()
+ const note=page.locator('.reader-note').last()
+ await note.getByRole('button',{name:'编辑',exact:true}).click()
+ await page.getByPlaceholder('记录当前页的阅读笔记').fill('已修改:复查牛奶卡车后确认记录。')
+ const edited=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
+ await page.getByRole('button',{name:'保存修改',exact:true}).click();check('笔记编辑保存成功',(await edited).ok())
+ await expect(note).toContainText('已修改')
+ const removed=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
+ await note.getByRole('button',{name:'删除',exact:true}).click();check('笔记删除成功',(await removed).ok())
+ await expect(page.locator('.reader-note')).toHaveCount(1)
+ await page.locator('.el-drawer__close-btn:visible').click()
+ const unfavorite=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
+ await page.getByRole('button',{name:'取消收藏',exact:true}).click();check('取消收藏写回服务器',(await unfavorite).ok())
+ await page.locator('.legacy-published-reader [data-action="preview-tab"][data-tab="interactive"]').click()
+ await page.locator('.legacy-published-reader [data-action="interactive-next"]').click()
+ await page.locator('.legacy-published-reader canvas').first().scrollIntoViewIfNeeded()
+ await page.waitForTimeout(3000);await shot('11-reader-truck')
+ const position=await call(`tran/v1/teaching/guides/${fixture.id}/reader-state`,token)
+ check('动态阅读同步第四页和百分百页进度',position.data?.pageIndex===3&&position.data?.progress===100)
+ await expect(page.locator('.legacy-published-reader [data-action="interactive-step"]').first()).toBeEnabled()
+ await expect(page.locator('.legacy-published-reader [data-model-clip-select]')).toBeEnabled()
+ check('教学只读模式允许目录定位和选择动画',true)
+ check('教学动态版式切换卡车步骤',await page.locator('.legacy-published-reader').textContent().then(t=>t.includes('牛奶卡车检查')))
+ if(await page.getByRole('button',{name:'标记阅读完成',exact:true}).count())await page.getByRole('button',{name:'标记阅读完成',exact:true}).click()
+ await expect(page.getByRole('button',{name:'已完成阅读',exact:true})).toBeDisabled()
+ await shot('12-reading-completed');check('浏览器完成阅读并更新状态',true)
+ await page.goto('http://127.0.0.1:6180/content/ofd/api')
+ await page.locator('.guide-http-page nav button').filter({hasText:'保存笔记、收藏与进度'}).click()
+ await page.locator('.guide-http-page .el-select').first().click()
+ await page.getByRole('option').filter({hasText:'正式闭环-狐狸与卡车指导书-20260910'}).click()
+ await page.getByRole('button',{name:'载入当前工程版本参数',exact:true}).click()
+ await expect(page.getByRole('textbox',{name:'请求 JSON'})).toHaveValue(/versionId/)
+ const saved=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
+ await page.getByRole('button',{name:'执行调用',exact:true}).click();check('HTTP 面板实际 PUT 阅读记录',(await saved).ok())
+ const conflict=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
+ await page.getByRole('button',{name:'执行调用',exact:true}).click();check('HTTP 面板旧版本返回 409',(await conflict).status()===409)
+ await shot('13-http-conflict')
+ const method=await call(`tran/v1/teaching/guides/${fixture.id}/reading/start`,token,{},'POST')
+ check('错误 HTTP 方法明确返回 405',method.status===405)
+ await page.goto('http://127.0.0.1:6180/content/ofd/templates')
+ await expect(page.locator('.template-card').first()).toBeVisible({timeout:30000})
+ await shot('14-formal-templates');check('正式模板库可用',true)
+}catch(e){check('最终浏览器回归',false,e.message);await shot('final-failure')}
+finally{check('无浏览器脚本异常',errors.length===0,errors.join('\n'));fs.writeFileSync(path.join(report,'final.json'),JSON.stringify({checks,errors},null,2));await browser.close()}
diff --git a/tools/guide-formal-live.mjs b/tools/guide-formal-live.mjs
new file mode 100644
index 0000000..a5f21f1
--- /dev/null
+++ b/tools/guide-formal-live.mjs
@@ -0,0 +1,77 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { chromium, expect } from '@playwright/test'
+import { login, call } from './role-permissions-live.mjs'
+const report=path.resolve('reports/guide-formal-20260910'),work=path.resolve('../../.codex-tmp/guide-formal')
+fs.mkdirSync(report,{recursive:true});fs.mkdirSync(work,{recursive:true})
+const phase=process.argv[2]||'create', fixturePath=path.join(work,'fixture.json')
+const fixture=fs.existsSync(fixturePath)?JSON.parse(fs.readFileSync(fixturePath)):{}
+const session=await login('root'),browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
+const context=await browser.newContext({viewport:{width:1920,height:1080}})
+await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
+const page=await context.newPage(),checks=[],errors=[]
+page.on('pageerror',e=>errors.push(e.message))
+page.on('response',r=>{if(r.status()>=400) console.log('HTTP',r.status(),new URL(r.url()).pathname)})
+await page.addInitScript(()=>{new MutationObserver(()=>{for(const el of document.querySelectorAll('.ofd-toast,.el-message'))if(!el.dataset.reported){el.dataset.reported='true';console.log('NOTICE',el.textContent)}}).observe(document,{childList:true,subtree:true})})
+page.on('console',m=>{if(m.text().startsWith('NOTICE'))console.log(m.text())})
+const shot=async name=>page.screenshot({path:path.join(report,name+'.png')})
+const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
+try{
+ if(phase==='create'){
+ await page.goto('http://127.0.0.1:6180/content/ofd')
+ await page.getByRole('button',{name:'新建指导书',exact:true}).click()
+ await page.locator('[data-wizard="name"]').fill('正式闭环-狐狸与卡车指导书-20260910')
+ await page.locator('[data-wizard="equipment"]').fill('狐狸与牛奶卡车功能测试素材')
+ await page.locator('[data-wizard="owner"]').fill('功能闭环测试')
+ await page.locator('[data-action="wizard-next"]').click()
+ await page.locator('[data-action="wizard-template"][data-template="__blank__"]').click()
+ await page.locator('[data-action="wizard-next"]').click()
+ const response=page.waitForResponse(r=>r.url().endsWith('/content/projects')&&r.request().method()==='POST')
+ await page.locator('[data-action="wizard-next"]').click()
+ const created=await response;check('新建请求实际进入内容服务',created.status()===200,await created.text())
+ await page.waitForURL(/\/ofd\/\d+\/studio/)
+ fixture.id=page.url().match(/\/ofd\/(\d+)\/studio/)[1];fs.writeFileSync(fixturePath,JSON.stringify(fixture))
+ const server=await call(`tran/v1/content/projects/${fixture.id}`,session.accessToken)
+ check('正式后台可查询新工程',server.status===200)
+ await shot('01-formal-created')
+ }
+ if(phase==='publish'){
+ await page.goto(`http://127.0.0.1:6180/content/ofd/${fixture.id}/studio?view=editor`)
+ await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
+ await page.locator('[data-action="save"]').click();await page.waitForTimeout(700)
+ await page.locator('[data-action="open-publish"]').click()
+ const response=page.waitForResponse(r=>r.url().endsWith('/publish')&&r.request().method()==='POST',{timeout:600000})
+ await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="publish"]').click()
+ const result=await response;check('正式发布事务完成',result.ok(),(await result.text()).slice(0,400))
+ await page.waitForTimeout(700);await shot('04-formal-published')
+ const guides=await call('tran/v1/teaching/guides?keyword='+encodeURIComponent('正式闭环-狐狸与卡车指导书-20260910'),session.accessToken)
+ check('教学指导书列表出现正式发布工程',guides.data?.records?.some(r=>r.projectId===fixture.id))
+ const assets=await call(`tran/v1/teaching/guides/${fixture.id}/deliveries`,session.accessToken)
+ check('发布版本提供 OFD 与离线 ZIP',assets.data?.length===2)
+ for(const format of ['OFD','OFFLINE_HTML']){
+ const result=await context.request.get(`http://127.0.0.1:6180/api/tran/v1/teaching/guides/${fixture.id}/delivery?format=${format}`,{headers:{Authorization:`Bearer ${session.accessToken}`},timeout:180000})
+ check('下载正式交付物 '+format,result.ok());if(result.ok())fs.writeFileSync(path.join(report,format==='OFD'?'formal.ofd':'formal-offline.zip'),await result.body())
+ }
+ }
+ if(phase==='upload'){
+ await page.goto(`http://127.0.0.1:6180/content/ofd/${fixture.id}/studio?view=editor`)
+ await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
+ for(const name of ['09-Fox.glb','08-CesiumMilkTruck.glb']){
+ const wait=page.waitForResponse(r=>r.url().includes('/assets/upload')&&r.request().method()==='POST')
+ await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',name))
+ const response=await wait;check('上传 '+name,response.ok(),(await response.text()).slice(0,300))
+ await page.waitForTimeout(1800)
+ }
+ await page.locator('[data-action="save"]').click();await page.waitForTimeout(1200)
+ await shot('02-formal-assets')
+ await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
+ await page.locator('[data-action="select-step"]').first().click()
+ await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click()
+ await page.locator('[data-action="library-insert"][data-kind="model"]').filter({hasText:'Fox'}).first().click()
+ await page.locator('[data-action="media-open"]').first().click()
+ await expect(page.locator('.ofd-media-panel canvas')).toBeVisible({timeout:30000})
+ await page.waitForTimeout(1200);await shot('03-formal-fox')
+ check('刷新后受控模型资源恢复显示',true)
+ }
+}catch(e){check(phase,false,e.message);console.log(await page.locator('.legacy-guide-page').evaluate(el=>el.__vueParentComponent?.setupState?.mountError).catch(()=>''));await shot(phase+'-failure').catch(()=>{})}
+finally{fs.writeFileSync(path.join(report,phase+'.json'),JSON.stringify({checks,errors},null,2));await browser.close()}
diff --git a/tools/guide-formal-media.mjs b/tools/guide-formal-media.mjs
new file mode 100644
index 0000000..9ed7c60
--- /dev/null
+++ b/tools/guide-formal-media.mjs
@@ -0,0 +1,48 @@
+import fs from 'node:fs'
+import {chromium,expect} from '@playwright/test'
+import {login} from './role-permissions-live.mjs'
+const session=await login('root'),checks=[]
+const browser=await chromium.launch({channel:'msedge',headless:true})
+const page=await browser.newPage({viewport:{width:1920,height:1080}})
+await page.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
+let id
+try{
+ await page.goto('http://127.0.0.1:6180/content/ofd')
+ await expect(page.getByRole('button',{name:'新建指导书',exact:true})).toBeVisible()
+ const result=process.env.GUIDE_MEDIA_ID ? await page.evaluate(async id=>{
+ const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts')
+ const store=new FormalGuideStore();await store.load(id)
+ const step=store.record.state.chapters[0].steps[0]
+ const valid=step.mediaBlocks.every(b=>b.kind==='video'?!!b.poster:!!b.imageSource)&&!JSON.stringify(step).includes('blob:')
+ store.dispose();return {id,valid}
+ },process.env.GUIDE_MEDIA_ID) : await page.evaluate(async()=>{
+ const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts')
+ const store=new FormalGuideStore();await store.createOFDProject({title:'正式图片视频资源回归-20260911'})
+ const canvas=document.createElement('canvas');canvas.width=400;canvas.height=240
+ const ctx=canvas.getContext('2d');ctx.fillStyle='#168a72';ctx.fillRect(0,0,400,240);ctx.fillStyle='white';ctx.font='24px sans-serif';ctx.fillText('Formal image resource',45,130)
+ const png=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'))
+ const image=await store.assetStore.put(png,{name:'guide-image.png'})
+ const video=await store.assetStore.put(await(await fetch('/videos/physical-training-real-main-720p.mp4')).blob(),{name:'guide-video.mp4'})
+ const state=structuredClone(store.record.state)
+ state.resources=[{...image,id:image.assetKey,name:'guide-image.png',type:'图片',category:'image',resourceUrl:`asset://${image.assetKey}`,blob:undefined},{...video,id:video.assetKey,name:'guide-video.mp4',type:'视频',category:'video',resourceUrl:`asset://${video.assetKey}`,blob:undefined}]
+ const step=state.chapters[0].steps[0];step.image=`asset://${image.assetKey}`
+ step.mediaBlocks=[{id:'image-block',kind:'image',imageSource:`asset://${image.assetKey}`,x:120,y:62,width:72,height:58},{id:'video-block',kind:'video',assetId:video.assetKey,assetName:'guide-video.mp4',poster:canvas.toDataURL('image/png'),x:120,y:130,width:72,height:58}]
+ await store.saveOFDProjectState(store.project.id,state)
+ const id=store.project.id;store.dispose();const loaded=new FormalGuideStore();await loaded.load(id)
+ const saved=loaded.record.state.chapters[0].steps[0]
+ const valid=saved.mediaBlocks.every(b=>b.kind==='video'?!!b.poster:!!b.imageSource)&&!JSON.stringify(saved).includes('blob:')
+ loaded.dispose();return {id,valid}
+ })
+ id=result.id;checks.push({name:'图片与视频上传、截图引用保存后重开保持',pass:result.valid})
+ await page.goto(`http://127.0.0.1:6180/content/ofd/${id}/studio?view=preview`)
+ await expect(page.locator('.ofd-preview-pager')).toBeVisible({timeout:60000})
+ checks.push({name:'OFD 预览读取受控图片和截图',pass:!(await page.locator('.ofd-analysis-empty').allTextContents()).some(t=>t.includes('失败'))})
+ await page.locator('[data-action="preview-tab"][data-tab="interactive"]').click()
+ const video=page.locator('.ofd-ia-media-item.video video')
+ await expect(video).toHaveAttribute('src',/^blob:/,{timeout:30000})
+ await expect.poll(()=>video.evaluate(v=>v.readyState)).toBeGreaterThanOrEqual(2)
+ await video.evaluate(v=>v.play());await expect.poll(()=>video.evaluate(v=>v.currentTime)).toBeGreaterThan(0)
+ await video.scrollIntoViewIfNeeded();await page.screenshot({path:'reports/guide-formal-20260910/18-formal-video.png'})
+ checks.push({name:'正式视频通过受控字节恢复并实际播放',pass:true})
+}catch(e){checks.push({name:'图片视频资源闭环',pass:false,detail:e.message});process.exitCode=1;await page.screenshot({path:'reports/guide-formal-20260910/media-failure.png'})}
+finally{console.log(checks);fs.writeFileSync('reports/guide-formal-20260910/media.json',JSON.stringify({projectId:id,checks},null,2));await browser.close()}
diff --git a/tools/guide-formal-offline.mjs b/tools/guide-formal-offline.mjs
new file mode 100644
index 0000000..bb0e215
--- /dev/null
+++ b/tools/guide-formal-offline.mjs
@@ -0,0 +1,20 @@
+import fs from 'node:fs'
+import {chromium,expect} from '@playwright/test'
+const report='reports/guide-formal-20260910',checks=[],errors=[],external=[]
+const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
+const page=await browser.newPage({viewport:{width:1440,height:1000}})
+page.on('pageerror',e=>errors.push(e.message))
+await page.route('**/*',route=>{const url=new URL(route.request().url());if(url.hostname!=='127.0.0.1'&&!['blob:','data:'].includes(url.protocol)){external.push(url.origin);return route.abort()}return route.continue()})
+try{
+ await page.goto('http://127.0.0.1:18769/')
+ await page.locator('[data-action="open-model-library"]').click()
+ await page.locator('[data-model-select]').scrollIntoViewIfNeeded()
+ for(const [index,name] of [['1','15-offline-fox-scene'],['3','16-offline-truck-scene']]){
+ await page.locator('[data-model-select]').selectOption(index)
+ await expect(page.locator('[data-model-status]')).not.toContainText(/正在|尚未|失败/,{timeout:90000})
+ await page.waitForTimeout(2000)
+ await page.screenshot({path:`${report}/${name}.png`})
+ checks.push({name,pass:true,detail:await page.locator('[data-model-status]').textContent()})
+ }
+}catch(e){checks.push({name:'离线场景浏览器读取',pass:false,detail:e.message});process.exitCode=1}
+finally{checks.push({name:'离线资源不依赖业务服务器或公网',pass:external.length===0,detail:external.join(',')},{name:'无脚本异常',pass:errors.length===0,detail:errors.join('\n')});console.log(checks);fs.writeFileSync(`${report}/offline-browser.json`,JSON.stringify({checks,errors},null,2));await browser.close()}
diff --git a/tools/guide-formal-permissions.mjs b/tools/guide-formal-permissions.mjs
new file mode 100644
index 0000000..12e93bd
--- /dev/null
+++ b/tools/guide-formal-permissions.mjs
@@ -0,0 +1,36 @@
+import fs from 'node:fs'
+import crypto from 'node:crypto'
+import {login,call} from './role-permissions-live.mjs'
+const fixture=JSON.parse(fs.readFileSync('../../.codex-tmp/guide-formal/fixture.json')),session=await login('root'),token=session.accessToken,checks=[]
+const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
+let user
+try{
+ const roles=(await call('auth/v1/roles/all',token)).data,role=(roles.records||roles).find(r=>r.code==='student')
+ const me=(await call('auth/v1/auth/me',token)).data,password='G!'+crypto.randomBytes(7).toString('hex'),username='guide_'+crypto.randomBytes(5).toString('hex')
+ const created=await call('auth/v1/users',token,{username,password,displayName:'作业指导闭环测试学员',departmentId:me.departmentId,roleIds:[role.id],defaultRoleId:role.id,enabled:true,remark:'UTE2E-GUIDE-FORMAL'})
+ if(created.status!==200)throw new Error('测试学员创建失败 '+created.message)
+ user=created.data
+ const logged=await call('auth/v1/auth/login',null,{userId:user.id,password,roleCode:'student',rememberMe:false})
+ const actor=logged.data?.accessToken
+ if(!actor)throw new Error('测试学员登录失败 '+logged.status+' '+logged.message)
+ const base=`tran/v1/teaching/guides/${fixture.id}`
+ const content=await call(`tran/v1/content/projects/${fixture.id}`,actor);check('学员不能进入内容编辑接口',content.status===403)
+ const detail=await call(base,actor);check('学员可以阅读正式发布版本',detail.status===200)
+ check('学员投影没有文件存储 URI',!JSON.stringify(detail.data).includes('fsvc://'))
+ const state=(await call(`${base}/reader-state`,actor)).data;check('学员看不到其他用户的笔记和收藏',state?.notes.length===0&&state.favorite===false)
+ const saved=await call(`${base}/reader-state`,actor,{...state,pageId:'page:0',pageIndex:0,pageCount:3,favorite:true,notes:[{id:'student-note',pageId:'page:0',text:'学员本人私人笔记'}]},'PUT');check('学员能保存本人的阅读记录',saved.status===200)
+ const owner=(await call(`${base}/reader-state`,token)).data;check('学员写入不覆盖教员记录',!owner.notes.some(n=>n.id==='student-note'))
+ const stats=await call(`${base}/reader-statistics`,actor);check('学员不能查看教员聚合统计',stats.status===403)
+ const start=await call(`${base}/reading/start`,actor,{},'PUT');check('开始阅读产生正式记录',start.status===200,start.message)
+ const complete=await call(`${base}/reading/complete`,actor,{},'PUT');check('阅读完成记录成功',complete.status===200,complete.message)
+ const feedback=await call(`${base}/feedback`,actor,{content:'实测反馈:狐狸和卡车指导书资源已加载,请复核说明。'});check('学员反馈写入正式服务',feedback.status===200)
+ if(feedback.data?.id){const managed=await call(`${base}/feedback/${feedback.data.id}`,token,{version:feedback.data.version,status:'HANDLED',reply:'已通过正式接口复核。'},'PUT');check('教员回复并处理反馈',managed.status===200,managed.message)}
+ const asset=detail.data?.assets.find(a=>a.type==='MODEL_FILE')
+ const response=await fetch(`http://127.0.0.1:6180/api/${base}/assets/${asset.assetCode}/content`,{headers:{Authorization:`Bearer ${actor}`}})
+ const bytes=await response.arrayBuffer();check('学员通过教学接口下载 GLB',response.ok&&new TextDecoder().decode(bytes.slice(0,4))==='glTF')
+ await call('auth/v1/auth/logout',actor,{})
+}catch(e){check('权限与阅读反馈流程',false,e.message)}
+finally{
+ if(user?.id){const current=await call(`auth/v1/users/${user.id}`,token);const removed=await call(`auth/v1/users/${user.id}`,token,{version:current.data.version},'DELETE');check('临时测试学员已清理',removed.status===200,removed.message)}
+ fs.writeFileSync('reports/guide-formal-20260910/permissions.json',JSON.stringify({checks},null,2))
+}
diff --git a/tools/guide-formal-report.mjs b/tools/guide-formal-report.mjs
new file mode 100644
index 0000000..be487e9
--- /dev/null
+++ b/tools/guide-formal-report.mjs
@@ -0,0 +1,46 @@
+import fs from 'node:fs'
+import path from 'node:path'
+const dir='reports/guide-formal-20260910'
+const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
+// 报告只保留结论,不携带完整接口载荷、内部存储地址或上传凭据。
+for(const file of ['create.json','upload.json','publish.json']){
+ const data=JSON.parse(fs.readFileSync(path.join(dir,file),'utf8'))
+ for(const check of data.checks||[])if(check.detail?.startsWith('{'))check.detail='真实 HTTP 请求成功;响应内容已脱敏。'
+ fs.writeFileSync(path.join(dir,file),JSON.stringify(data,null,2))
+}
+const suites=[['create.json','正式工程新建'],['upload.json','模型资源上传与恢复'],['scenes.json','固定场景版本与修订'],['publish.json','正式发布与交付物'],['review.json','教学阅读及真实 HTTP 查询'],['permissions.json','权限、私人记录与反馈'],['final.json','阅读、笔记、进度及 HTTP 写入'],['template.json','正式模板新建'],['media.json','图片、视频及截图引用'],['offline-browser.json','正式离线包运行']]
+const results=suites.map(([file,title])=>({file,title,...JSON.parse(fs.readFileSync(path.join(dir,file),'utf8'))}))
+const checks=results.flatMap(r=>r.checks),passed=checks.filter(c=>c.pass).length
+const release=JSON.parse(fs.readFileSync(path.join(dir,'release.json'),'utf8'))
+const offline=JSON.parse(fs.readFileSync(path.join(dir,'offline.json'),'utf8'))
+const shots=[
+ ['01-formal-created.png','正式指导书新建,写入内容服务'],
+ ['02-formal-assets.png','狐狸、牛奶卡车 GLB 上传至正式资源存储'],
+ ['03-formal-fox.png','刷新后狐狸模型恢复显示'],
+ ['04-formal-published.png','V3.0.0 正式发布及交付物'],
+ ['05-reader-notes.png','教学阅读:笔记与收藏'],
+ ['06-reader-layout.png','教学阅读:OFD 版式'],
+ ['09-reader-model.png','教学动态阅读:狐狸模型'],
+ ['10-two-scene-resources.png','两个已发布场景固定版本引用'],
+ ['11-reader-truck.png','教学动态阅读:牛奶卡车'],
+ ['12-reading-completed.png','完成阅读,进度与统计更新'],
+ ['08-http-integration.png','对外接口:真实 HTTP 查询'],
+ ['13-http-conflict.png','对外接口:旧版本写入返回 409'],
+ ['14-formal-templates.png','正式模板库'],
+ ['17-template-editor.png','模板生成章节与步骤并保存'],
+ ['15-offline-fox-scene.png','离线包:场景 160 当前发布内容(维修车间与装备)'],
+ ['16-offline-truck-scene.png','离线包:牛奶卡车场景'],
+ ['18-formal-video.png','图片与截图引用保持,视频实际播放'],
+]
+const html=`作业指导正式接口与教学闭环 · 实测报告
+
+开发环境 · 2026-09-10 — 2026-09-11 · 顺序实施与回归
作业指导正式接口与教学闭环
已完成编辑器接入、教学阅读记录、真实 HTTP 联调三项实施。正式发布工程 ${esc(release.projectId)},版本 ${esc(release.versionCode)}(版本 ID ${esc(release.versionId)})。
${passed}/${checks.length}E2E 检查点通过
391后端测试通过
17关键截图
3保留复核工程
+实施内容
| 顺序 | 落地结果 | 验证方式 |
|---|
| 1. 内容、资源、版本 | 完整编辑文档写入正式内容服务;上传资源进入受控存储;模板、模型和场景从正式接口加载;保存、发布、历史版本及恢复使用服务端版本。场景资源固定到发布版本,修改草稿不改变已发布教学内容。 | 新建、模板生成、保存重开、GLB/图片/视频上传、固定场景引用、修订与发布、OFD 和离线 ZIP 实际下载。 |
| 2. 教学阅读闭环 | 教学端消费正式发布文档与受控资源;阅读位置、笔记、收藏按用户和发布版本独立保存;记录开始/完成、反馈与处理,提供授权聚合统计。 | 翻页恢复、笔记增改删、收藏取消、动态步骤同步进度、学员权限及数据隔离、反馈回复、完成记录。 |
| 3. 真实 HTTP 联调 | 接口页面通过统一 HTTP 客户端调用 Gateway/业务服务,展示真实响应。涵盖内容、资源、版本、教学、个人阅读记录、统计与交付物。 | 浏览器实际 GET/PUT;旧版本返回 409;错误方法返回 405;未经授权内容和统计接口返回 403。 |
接口沿用平台统一登录会话与权限体系。第三方系统需取得平台会话;数字人开放应用 APPID 不用于这组作业指导接口。
+本轮修复
- 完整文档校验及正式资源 URI 保留;教学投影转换为受控下载地址,避免文件存储地址直接暴露或刷新后丢失。
- 大 GLB 流式下载被默认 30 秒中断;异步流响应超时调整为可配置的 30 分钟。
- 保存与发布并发造成旧版本冲突;发布前等待自动保存队列完成。
- 阅读器初始化零页记录、只读目录/动画选择被拦截、动态页进度未同步、笔记删除后编辑框残留。
- 离线模型切换后的矩阵与镜头适配错误,导致场景偏出画面。
- 图片、视频海报的正式 URI 被过滤;视频完成加载后动态预览未刷新。已验证受控视频实际播放。
- 发布弹窗与动态白纸内容的暗色主题文字对比度。
+测试结果
浏览器为 Edge / Playwright,主要视口 1920×1080。管理操作使用内部维护账号;教学权限使用临时学员账号,测试后已清理。HTTP 通过 Web 6180 → Gateway 6100 → Tran 6102。
mvn -pl ut_tran -am test package:391 项通过,0 失败、0 错误、0 跳过。
pnpm run build:类型检查、Vite 构建与模型编辑器发布资源校验通过;保留既有 chunk 大小及运行时资源 URL 警告。
${results.map(s=>`${esc(s.title)} JSON 证据
| 检查点 | 结果 |
${s.checks.map(c=>`| ${esc(c.name)}${c.detail?` ${esc(c.detail)} `:''} | ${c.pass?'通过':'失败'} |
`).join('')}
`).join('')}检查点来自分阶段脚本,覆盖关键链路,不表示所有边缘输入均已穷举。create/scenes 等早期证据对应创建及 V1→V2 阶段;最新发布、阅读和离线回归对应 V3。
+关键截图
点击图片查看原图。文件名中的“fox-scene”沿用测试场景名称,不代表该场景当前实际包含狐狸。
${shots.map(([file,title])=>`
${esc(title)}`).join('')}
+交付与迁移
下载实测正式 OFD · 下载实测正式离线 ZIP(本地文件) · 发布摘要 · ZIP 完整性证据
离线 ZIP:${Number(offline.zipBytes).toLocaleString('zh-CN')} 字节;4 个 GLB 文件通过头部与长度检查。从正式 V3 下载后解压并运行,禁止访问业务服务及公网仍能加载两个场景。镜头修复代码已经包含在正式交付包中。
SHA-256:${esc(offline.sha256)}
| 开发环境迁移 | 说明 |
|---|
| 20260826_017_teaching_guide_reading_closed_loop.sql | 补执行既有阅读与反馈表迁移,先回滚试执行再正式执行。 |
| 20260826_018_content_guide_template_library.sql | 补执行既有正式模板库迁移,保留 4 个模板。 |
| 20260911_001_guide_reader_state.sql | 新增按用户/项目/发布版本隔离的阅读位置、笔记、收藏及进度表;完整 DDL、数据库设计、迁移记录和 Wiki 已同步。 |
其他环境部署前先检查既有表,避免重复执行。新增流下载配置 UT_CONTENT_STREAM_TIMEOUT 默认 30m。本轮不代表其他环境已完成数据库迁移。
+复核说明与保留数据
- 工程 412:正式闭环-狐狸与卡车指导书-20260910,已发布 V3.0.0 / 472。
- 工程 413:正式模板新建回归-20260911,保留草稿。
- 工程 414:正式图片视频资源回归-20260911,保留草稿。
- 临时学员账号已清理,正式测试阅读及反馈历史保留。阅读页进度与“完成阅读”分别记录,不等同于考核成绩。
- 既有内部维护账号头像存在单独的 404,属于原账号头像资源问题;本轮指导书资源、视频及交付下载检查通过。没有将该请求计入“无网络错误”的结论。
- 本轮未自动提交或推送 Git。大型测试 ZIP 仅保留在本地报告目录,不纳入 Git。
需要核对的数据差异:场景 160@463 名为“实测场景-狐狸野外训练区-20260905”,当前发布内容实际是维修车间与装备,不包含狐狸。已按其固定发布版本验证,未修改源场景;狐狸模型在指导书独立步骤中验证通过。牛奶卡车场景使用 159@209。
三项实施均在本次授权范围内,没有新增待批准的大幅功能调整。旧浏览器本地工程如需迁移,使用显式导入新工程;无法从服务端恢复的本地文件需重新上传。
`
+fs.writeFileSync(path.join(dir,'index.html'),html)
+console.log(`Report generated: ${passed}/${checks.length} checks, ${shots.length} screenshots`)
+if(passed!==checks.length)process.exitCode=1
diff --git a/tools/guide-formal-review.mjs b/tools/guide-formal-review.mjs
new file mode 100644
index 0000000..a319d95
--- /dev/null
+++ b/tools/guide-formal-review.mjs
@@ -0,0 +1,56 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import {chromium,expect} from '@playwright/test'
+import {login,call} from './role-permissions-live.mjs'
+const report=path.resolve('reports/guide-formal-20260910'),work=path.resolve('../../.codex-tmp/guide-formal')
+const fixture=JSON.parse(fs.readFileSync(path.join(work,'fixture.json'))),checks=[],errors=[]
+const session=await login('root'),token=session.accessToken
+const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
+const base=`tran/v1/teaching/guides/${fixture.id}`
+const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
+const context=await browser.newContext({viewport:{width:1920,height:1080}})
+await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
+const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message))
+await page.addInitScript(()=>{new MutationObserver(()=>{for(const el of document.querySelectorAll('.ofd-toast,.el-message'))if(!el.dataset.reported){el.dataset.reported='true';console.log('NOTICE',el.textContent)}}).observe(document,{childList:true,subtree:true})})
+page.on('console',m=>{if(m.text().startsWith('NOTICE'))console.log(m.text())})
+page.on('response',r=>{if(r.status()>=400)console.log('HTTP',r.status(),new URL(r.url()).pathname)})
+const shot=async name=>page.screenshot({path:path.join(report,name+'.png'),fullPage:false})
+try{
+ const assets=await call(`${base}/deliveries`,token);check('发布版本提供两份正式交付物',assets.data?.length===2)
+ for(const format of (process.argv.includes('--browser-only')?[]:['OFD','OFFLINE_HTML'])){const r=await context.request.get(`http://127.0.0.1:6180/api/${base}/delivery?format=${format}`,{headers:{Authorization:`Bearer ${token}`},timeout:180000});check('统一文件服务交付物下载 '+format,r.ok());if(r.ok())fs.writeFileSync(path.join(report,format==='OFD'?'formal.ofd':'formal-offline.zip'),await r.body())}
+ const before=(await call(`${base}/reader-state`,token)).data
+ const command={...before,pageId:'page:1',pageIndex:1,pageCount:3,favorite:true,notes:[{id:'formal-note',pageId:'page:1',text:'正式版本笔记:核对狐狸模型与卡车资源,注意安全检查。'}]}
+ const saved=await call(`${base}/reader-state`,token,command,'PUT');check('阅读位置、收藏、笔记真实写入',saved.status===200&&saved.data.favorite&&saved.data.notes.length===1)
+ const stale=await call(`${base}/reader-state`,token,command,'PUT');check('旧乐观锁版本被拒绝',stale.status===409)
+ const release=await call(`${base}/reader-state`,token,{...command,versionId:'1'},'PUT');check('旧发布版本不能覆盖当前阅读记录',release.status===409)
+ const stats=await call(`${base}/reader-statistics`,token);check('收藏和笔记进入真实统计',stats.data?.favorites>=1&&stats.data?.notes>=1)
+ await page.goto('http://127.0.0.1:6180/teaching/guides')
+ await page.getByPlaceholder('搜索指导书名称或编码').fill('正式闭环-狐狸与卡车指导书-20260910')
+ await page.getByPlaceholder('搜索指导书名称或编码').press('Enter')
+ await expect(page.locator('.guide-card-v2')).toHaveCount(1)
+ await page.locator('.guide-card-v2 button').first().click()
+ await expect(page.locator('.legacy-published-reader .ofd-app')).toBeVisible({timeout:60000})
+ await expect(page.getByRole('button',{name:'取消收藏',exact:true})).toBeVisible()
+ await page.getByRole('button',{name:/笔记 1/}).click()
+ await expect(page.locator('.reader-note')).toContainText('正式版本笔记')
+ await shot('05-reader-notes')
+ await page.getByPlaceholder('记录当前页的阅读笔记').fill('浏览器实测新增笔记:页码和内容由正式服务保存。')
+ const noteResponse=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
+ await page.getByRole('button',{name:'添加笔记',exact:true}).click()
+ check('浏览器笔记保存请求成功',(await noteResponse).ok())
+ await page.locator('.el-drawer__close-btn:visible').click()
+ await page.locator('.legacy-published-reader [data-action="preview-next"]').click()
+ await page.waitForTimeout(1300);await shot('06-reader-layout')
+ const persisted=await call(`${base}/reader-state`,token);check('浏览器翻页位置已同步',persisted.data?.pageIndex===2)
+ await page.locator('.legacy-published-reader [data-action="preview-tab"][data-tab="interactive"]').click()
+ await page.waitForTimeout(3500);await shot('07-reader-interactive')
+ await page.locator('.legacy-published-reader canvas').first().scrollIntoViewIfNeeded()
+ await page.waitForTimeout(1200);await shot('09-reader-model')
+ check('教学动态阅读显示三维画布',await page.locator('.legacy-published-reader canvas').count()>0)
+ await page.goto('http://127.0.0.1:6180/content/ofd/api')
+ await page.getByRole('button',{name:'执行调用',exact:true}).click()
+ await expect(page.locator('.guide-http-page pre')).toContainText('records')
+ await shot('08-http-integration')
+ check('对外接口面板返回真实工程数据',await page.locator('.guide-http-page pre').textContent().then(t=>t.includes('GUIDE-')))
+}catch(e){check('浏览器闭环流程',false,e.message);await shot('failure-review')}
+finally{check('浏览器无脚本异常',errors.length===0,errors.join('\n'));fs.writeFileSync(path.join(report,'review.json'),JSON.stringify({checks,errors},null,2));await browser.close()}
diff --git a/tools/guide-formal-scenes.mjs b/tools/guide-formal-scenes.mjs
new file mode 100644
index 0000000..dea9181
--- /dev/null
+++ b/tools/guide-formal-scenes.mjs
@@ -0,0 +1,36 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import {chromium,expect} from '@playwright/test'
+import {login,call} from './role-permissions-live.mjs'
+const report=path.resolve('reports/guide-formal-20260910'),fixture=JSON.parse(fs.readFileSync('../../.codex-tmp/guide-formal/fixture.json'))
+const session=await login('root'),browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
+const context=await browser.newContext({viewport:{width:1920,height:1080}})
+await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
+const page=await context.newPage(),checks=[]
+const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
+try{
+ await page.goto('http://127.0.0.1:6180/content/ofd')
+ await expect(page.getByRole('button',{name:'新建指导书',exact:true})).toBeVisible()
+ const result=await page.evaluate(async id=>{
+ const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts')
+ const store=new FormalGuideStore()
+ try{
+ await store.load(id);await store.loadCatalog();const old=store.project.publishedVersionId
+ if(store.project.status==='PUBLISHED')await store.transitionOFDProject(id,'edit',null)
+ const state=JSON.parse(JSON.stringify(store.record.state)),first=state.chapters[0].steps[0]
+ first.image='';first.title='狐狸模型巡检'
+ const truck=state.resources.find(r=>r.name?.includes('MilkTruck'))
+ const second={...JSON.parse(JSON.stringify(first)),id:'formal-truck-step',title:'牛奶卡车检查',image:'',modelRef:{...first.modelRef,assetId:truck.id,nodeName:''},mediaBlocks:[]}
+ state.chapters[0].steps=[first,second]
+ for(const [step,key] of [[first,'160@463'],[second,'159@209']])step.mediaBlocks=[{id:'scene-'+step.id,kind:'scene',assetId:key,title:step.title+'场景',x:20,y:180,width:170,height:80}]
+ await store.saveOFDProjectState(id,state)
+ return {old,published:store.project.publishedVersionId,assets:store.project.assets.length,bindings:Object.keys(store.record.state.sceneBindings||{}),version:store.project.version}
+ }finally{store.dispose()}
+ },fixture.id)
+ check('新修订保持原发布版本可阅读',result.old===result.published)
+ check('狐狸与卡车场景固定版本已保存',result.bindings.length===2,JSON.stringify(result))
+ await page.goto(`http://127.0.0.1:6180/content/ofd/${fixture.id}/studio`)
+ await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
+ await page.screenshot({path:path.join(report,'10-two-scene-resources.png')})
+}catch(e){check('正式场景资源闭环',false,e.message);await page.screenshot({path:path.join(report,'failure-scenes.png')})}
+finally{fs.writeFileSync(path.join(report,'scenes.json'),JSON.stringify({checks},null,2));await browser.close()}
diff --git a/tools/guide-formal-template.mjs b/tools/guide-formal-template.mjs
new file mode 100644
index 0000000..109b4eb
--- /dev/null
+++ b/tools/guide-formal-template.mjs
@@ -0,0 +1,28 @@
+import fs from 'node:fs'
+import {chromium,expect} from '@playwright/test'
+import {login,call} from './role-permissions-live.mjs'
+const session=await login('root'),checks=[]
+const browser=await chromium.launch({channel:'msedge',headless:true})
+const page=await browser.newPage({viewport:{width:1920,height:1080}})
+await page.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
+let id
+try{
+ await page.goto('http://127.0.0.1:6180/content/ofd/templates')
+ await page.locator('.library-heading button').click()
+ await page.getByLabel('指导书名称',{exact:true}).fill('正式模板新建回归-20260911')
+ const created=page.waitForResponse(r=>r.url().includes('/guide-templates/')&&r.url().endsWith('/projects')&&r.request().method()==='POST')
+ await page.getByRole('button',{name:'创建并进入规划',exact:true}).click()
+ const response=await created;const body=await response.json();id=String(body.data.project.id)
+ checks.push({name:'模板新建实际进入正式内容服务',pass:response.ok()})
+ await page.waitForURL(/\/ofd\/\d+\/studio/)
+ await page.goto(`http://127.0.0.1:6180/content/ofd/${id}/studio?view=editor`)
+ await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
+ const before=await call(`tran/v1/content/projects/${id}`,session.accessToken)
+ const save=page.waitForResponse(r=>r.url().endsWith(`/projects/${id}`)&&r.request().method()==='PUT')
+ await page.locator('[data-action="save"]').click();const saved=await save
+ checks.push({name:'模板初稿进入编辑器并保存',pass:saved.ok()})
+ const after=await call(`tran/v1/content/projects/${id}`,session.accessToken)
+ checks.push({name:'模板章节和步骤不丢失',pass:JSON.stringify(before.data.currentVersion.content.chapters.map(c=>[c.id,c.title,c.steps.map(s=>[s.id,s.title])]))===JSON.stringify(after.data.currentVersion.content.chapters.map(c=>[c.id,c.title,c.steps.map(s=>[s.id,s.title])]))})
+ await page.screenshot({path:'reports/guide-formal-20260910/17-template-editor.png'})
+}catch(e){checks.push({name:'模板编制闭环',pass:false,detail:e.message});process.exitCode=1;await page.screenshot({path:'reports/guide-formal-20260910/template-failure.png'})}
+finally{console.log(checks);fs.writeFileSync('reports/guide-formal-20260910/template.json',JSON.stringify({projectId:id,checks},null,2));await browser.close()}