From 738671584659089e9df8040e6e46ff6deeda1f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 14 Aug 2026 09:13:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2.2):=20StoryPlayer=20=E6=8E=A8=E8=BF=9B?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E5=8A=A0=E5=9B=BA=EF=BC=88=E5=8F=8C=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=20SHOULD-FIX=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - speak 兜底定时器先于 speak 注册 + try/catch:speak 抛异常不再导致 剧情永久卡死(估时/forceNext 未注册的失败模式) - speak onEnd 与估时闭包加行号守卫(lineIndex===i && !ended):迟到的 上一句 onEnd 不再提前放行当前句 - SceneTheater 估时封顶 20s(与 MAX_LINE_MS 对齐),注释同步 v2.2 语义 - plan Task 10 更新为实际实现(SetServerRoot + /static 挂载) Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-08-13-story-theater-v2.md | 12 ++++++---- ui-src/src/components/SceneTheater.vue | 8 +++---- ui-src/src/components/StoryPlayer.vue | 22 +++++++++++++------ 3 files changed, 27 insertions(+), 15 deletions(-) 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 a2b8b1b..cf6781b 100644 --- a/docs/superpowers/plans/2026-08-13-story-theater-v2.md +++ b/docs/superpowers/plans/2026-08-13-story-theater-v2.md @@ -1392,9 +1392,10 @@ git commit -m "feat: 全自动演出——开场自动进入/回复自动继续/ > 用户走查反馈:① 必须点跳过才出选项(speak onEnd 部分环境不回调,逐句推进靠 20s forceNext 兜底,体验像卡死);② 剧情不需要文字展示区,直接语音播放;③ 前端应与后端共用 :8080。规格:spec「v2.2 修订」章节。 **Files:** -- Modify: `ui-src/src/components/StoryPlayer.vue`(纯语音 + 估时兜底) +- Modify: `ui-src/src/components/StoryPlayer.vue`(纯语音 + 估时兜底 + 审查加固) +- Modify: `ui-src/src/components/SceneTheater.vue`(估时兜底 + 20s 封顶) - Modify: `ui-src/src/pages/play/play.vue`(移除 :show-pinyin 传参) -- Modify: `main.go`(AddStaticPath 托管前端产物) +- Modify: `main.go`(SetServerRoot 托管前端产物 + /static 素材挂载) - [ ] **Step 1: StoryPlayer 改纯语音演绎** @@ -1522,12 +1523,15 @@ export default { - [ ] **Step 3: main.go 托管前端产物(8080 单端口)** -`main.go` 在 `controller.Register(s)` 后加: +`main.go` 在 `controller.Register(s)` 后加(**须用 SetServerRoot**——走查发现 GoFrame v2.10.2 `AddStaticPath("/", ...)` 因前缀防误匹配守卫 `uri[len(prefix)] != '/'` 只服务根路径,`/assets/*` 子路径全部 404;素材目录 `ui-src/static` 以 `/static` 前缀单独挂载,与后端 `scene.image` 返回的 `/static/generated/...` URL 对应): ```go // 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口) if _, err := os.Stat("ui-src/dist"); err == nil { - s.AddStaticPath("/", "ui-src/dist") + s.SetServerRoot("ui-src/dist") + } + if _, err := os.Stat("ui-src/static"); err == nil { + s.AddStaticPath("/static", "ui-src/static") } ``` diff --git a/ui-src/src/components/SceneTheater.vue b/ui-src/src/components/SceneTheater.vue index 231b93d..e7f0fbd 100644 --- a/ui-src/src/components/SceneTheater.vue +++ b/ui-src/src/components/SceneTheater.vue @@ -79,8 +79,8 @@ export default { }, mounted() { this.speaking = true - // v2.1:朗读结束自动进入决策幕——最短展示 1.2s,之后 speak onEnd 即进入; - // 无 TTS/onEnd 缺失最迟 15s 兜底(「跳过」仍可手动跳过) + // v2.1:朗读结束自动进入决策幕——最短展示 1.2s,之后 speak onEnd 即进入 + // v2.2:估时兜底——onEnd 缺失时按文本长度估时自动进入(最长 20s,不卡死;「跳过」仍可手动跳过) this.speechDone = false this.ready = false let done = false @@ -91,8 +91,8 @@ export default { this.$emit('done') } this.minTimer = setTimeout(() => { this.ready = true; if (this.speechDone) finishAuto() }, 1200) - // 兜底按文本长度估时(onEnd 缺失时按朗读预期时长自动进入,不卡 15s) - this.autoTimer = setTimeout(finishAuto, Math.max(4000, this.content.length * 320 + 2000)) + // 兜底按文本长度估时(onEnd 缺失时按朗读预期时长自动进入),封顶 20s 与 StoryPlayer MAX_LINE_MS 对齐 + this.autoTimer = setTimeout(finishAuto, Math.max(4000, Math.min(this.content.length * 320 + 2000, 20000))) 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 c152dbe..cf486c9 100644 --- a/ui-src/src/components/StoryPlayer.vue +++ b/ui-src/src/components/StoryPlayer.vue @@ -61,22 +61,30 @@ export default { } this.lineIndex = i this.ended = false - this.lineDone = { sp: false, fb: false } + this.lineDone = { sp: false } this.moodTick = true this.after(350, () => { this.moodTick = false }) const total = Array.from(line.text).length - speak(line.text, '', () => { - this.lineDone.sp = true - this.tryNext(i) - }) - // 估时兜底:onEnd 缺失(浏览器静音策略/无语音)时按文本长度估时放行,不卡死 + // 估时/forceNext 兜底定时器先于 speak 注册:speak 抛异常(stub 环境/无 TTS)也不会卡死 this.after(Math.max(2500, total * 320 + 1200), () => { - if (!this.lineDone.sp) { + if (this.lineIndex === i && !this.ended && !this.lineDone.sp) { this.lineDone.sp = true this.tryNext(i) } }) this.after(MAX_LINE_MS, () => this.forceNext(i)) + try { + speak(line.text, '', () => { + // 行号守卫:迟到的第 i 句 onEnd 不得提前放行当前句 + if (this.lineIndex === i && !this.ended) { + this.lineDone.sp = true + this.tryNext(i) + } + }) + } catch (e) { + this.lineDone.sp = true + this.tryNext(i) + } }, tryNext(i) { if (this.ended || this.lineIndex !== i || !this.lineDone.sp) return