diff --git a/.gitignore b/.gitignore
index 9b8116e..a919e8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ ui-src/node_modules/
ui-src/dist/
admin-src/node_modules/
admin-src/dist/
+.gstack/
+genasset
diff --git a/docs/superpowers/plans/2026-08-13-story-theater-v2.md b/docs/superpowers/plans/2026-08-13-story-theater-v2.md
index 454c3cb..a2b8b1b 100644
--- a/docs/superpowers/plans/2026-08-13-story-theater-v2.md
+++ b/docs/superpowers/plans/2026-08-13-story-theater-v2.md
@@ -1387,7 +1387,171 @@ git commit -m "feat: 全自动演出——开场自动进入/回复自动继续/
---
-## 验收对照(spec v2 + v2.1)
+### Task 10: 纯语音演绎 + 估时兜底 + 8080 托管(v2.2)
+
+> 用户走查反馈:① 必须点跳过才出选项(speak onEnd 部分环境不回调,逐句推进靠 20s forceNext 兜底,体验像卡死);② 剧情不需要文字展示区,直接语音播放;③ 前端应与后端共用 :8080。规格:spec「v2.2 修订」章节。
+
+**Files:**
+- Modify: `ui-src/src/components/StoryPlayer.vue`(纯语音 + 估时兜底)
+- Modify: `ui-src/src/pages/play/play.vue`(移除 :show-pinyin 传参)
+- Modify: `main.go`(AddStaticPath 托管前端产物)
+
+- [ ] **Step 1: StoryPlayer 改纯语音演绎**
+
+`StoryPlayer.vue` 整体重写为:无文字展示区(删 RubyText 气泡、逐字高亮、pinyin),保留角色 + mood 表情 + 跳过;句推进改「speak onEnd + 文本长度估时兜底」:
+
+```vue
+
+
+ 跳过 ›
+
+
+
+ {{ character.name.slice(0, 1) }}
+ {{ moodEmoji }}
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: play.vue 移除 StoryPlayer 的 :show-pinyin 传参**
+
+两处 ``(剧情流 + 终局演绎)删除 `:show-pinyin="showPinyin"` 属性。
+
+- [ ] **Step 3: main.go 托管前端产物(8080 单端口)**
+
+`main.go` 在 `controller.Register(s)` 后加:
+
+```go
+ // 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口)
+ if _, err := os.Stat("ui-src/dist"); err == nil {
+ s.AddStaticPath("/", "ui-src/dist")
+ }
+```
+
+- [ ] **Step 4: 构建 + 验证**
+
+Run: `cd ui-src && npx vite build --mode h5 2>&1 | tail -3 && cd .. && go build ./...`
+Expected: 构建成功;产物在 `ui-src/dist`
+
+重启后端后走查 `http://localhost:8080`(H5 构建,前后端同端口):
+1. 打开即应用首页(非 404)
+2. 第 1 计关卡:剧情纯语音无文字区;台词播完选项自动出现(不点跳过)
+3. 模拟 onEnd 不回调(playwright 注入 stub speechSynthesis:speak 不发声、不发事件):逐句按估时兜底推进,选项最终自动出现,不卡死
+4. 终局台词演绎后自动跳结算(8080 端口同样生效)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui-src/src/components/StoryPlayer.vue ui-src/src/pages/play/play.vue main.go
+git commit -m "feat: 剧情纯语音演绎(无文字区)+ 句推进估时兜底 + 前端产物由 :8080 托管(v2.2)"
+```
+
+---
+
+## 验收对照(spec v2 + v2.1 + v2.2)
| 验收标准 | 任务 |
|---|---|
diff --git a/docs/superpowers/specs/2026-08-13-story-theater-design.md b/docs/superpowers/specs/2026-08-13-story-theater-design.md
index 3af2648..c2cf562 100644
--- a/docs/superpowers/specs/2026-08-13-story-theater-design.md
+++ b/docs/superpowers/specs/2026-08-13-story-theater-design.md
@@ -124,6 +124,27 @@
---
+## v2.2 修订(2026-08-13):纯语音演绎 + 单端口托管
+
+> 用户走查反馈(v2.1 落地后):① **「必须点跳过按钮才显示出选项」**——StoryPlayer 自然播完不触发 done(speak onEnd 在部分浏览器环境不回调,逐句推进只能靠 20s forceNext 兜底,体验像卡死);② **「剧情也不应该显示文字,直接语音播放就可以了不需要文字展示区」**;③ **「前端应该跟后端共用端口」**(后端 :8080,前端产物由后端托管)。
+
+### 核心转变
+
+| 维度 | v2.1 | v2.2 |
+|---|---|---|
+| 剧情呈现 | 气泡文字 + 逐字高亮 + 拼音 | 纯语音演绎:无文字展示区,角色立绘 + mood 表情 + 逐句朗读 |
+| 句推进 | speak onEnd + 逐字高亮双门控 | speak onEnd 驱动 + **文本长度估时兜底**(320ms/字 + 1.2s,最短 2.5s);20s forceNext 保留为最后防线 |
+| 部署 | H5 dev server 5173(开发代理) | 前端产物 `ui-src/dist` 由后端 :8080 `AddStaticPath` 托管(生产单端口) |
+
+### 验收标准(v2.2)
+
+1. 剧情无任何文字展示:只有角色 + 表情徽章 + 语音
+2. 台词播完后选项自动出现(无需点跳过);跳过按钮仅作可选的手动加速
+3. speak onEnd 不回调的环境(模拟浏览器静音策略/无语音)下按估时兜底自动推进,不卡死
+4. `http://localhost:8080` 直接打开应用(前后端同端口)
+
+---
+
## 设计总览
每关 = 一部 2-3 分钟儿童小剧场,三幕结构:
diff --git a/main.go b/main.go
index 0d52a7e..b21d59f 100644
--- a/main.go
+++ b/main.go
@@ -26,5 +26,14 @@ func main() {
s := g.Server()
controller.Register(s)
+ // 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口)
+ // 注:根路径须用 SetServerRoot——AddStaticPath("/", ...) 因前缀防误匹配守卫只服务根路径,
+ // /assets 等子路径全部 404;素材目录 ui-src/static 以 /static 前缀单独挂载
+ if _, err := os.Stat("ui-src/dist"); err == nil {
+ s.SetServerRoot("ui-src/dist")
+ }
+ if _, err := os.Stat("ui-src/static"); err == nil {
+ s.AddStaticPath("/static", "ui-src/static")
+ }
s.Run()
}
diff --git a/ui-src/src/components/SceneTheater.vue b/ui-src/src/components/SceneTheater.vue
index fd60171..231b93d 100644
--- a/ui-src/src/components/SceneTheater.vue
+++ b/ui-src/src/components/SceneTheater.vue
@@ -91,7 +91,8 @@ export default {
this.$emit('done')
}
this.minTimer = setTimeout(() => { this.ready = true; if (this.speechDone) finishAuto() }, 1200)
- this.autoTimer = setTimeout(finishAuto, 15000)
+ // 兜底按文本长度估时(onEnd 缺失时按朗读预期时长自动进入,不卡 15s)
+ this.autoTimer = setTimeout(finishAuto, Math.max(4000, this.content.length * 320 + 2000))
const onSpeechEnd = () => {
this.speechDone = true
if (this.ready) finishAuto()
diff --git a/ui-src/src/components/StoryPlayer.vue b/ui-src/src/components/StoryPlayer.vue
index 379cc4d..c152dbe 100644
--- a/ui-src/src/components/StoryPlayer.vue
+++ b/ui-src/src/components/StoryPlayer.vue
@@ -7,43 +7,27 @@
{{ character.name.slice(0, 1) }}
{{ moodEmoji }}
-
-
-
-
- 📖 旁白
-
-
-
- {{ line.speaker }}
-
-
-
-
+