feat(v2.2): 剧情纯语音演绎 + 单端口 8080 托管前端

- StoryPlayer 移除文字展示区,改为纯语音逐句推进(角色+表情+跳过)
- 每句 speak 追加估时兜底(onEnd 缺失时按文本长度放行,不卡死)
- SceneTheater 开场按文本估时自动进入(兜底 15s → max(4000, len*320+2000))
- main.go SetServerRoot 托管 ui-src/dist(AddStaticPath("/") 前缀守卫只服务根路径),
  /static 前缀挂载 ui-src/static 素材目录,前后端同端口 8080

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 09:06:19 +08:00
co-authored by Claude Opus 4.7
parent 8c19e826b3
commit a775fc7b5c
7 changed files with 214 additions and 50 deletions
@@ -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
<template>
<view class="story">
<view class="story-skip" @click="finish">跳过 </view>
<view v-if="character" class="story-char">
<image v-if="character.image" class="story-char-img" :src="character.image" mode="aspectFit" />
<view v-else class="story-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
<view class="story-mood" :class="{ pop: moodTick }">{{ moodEmoji }}</view>
</view>
<!-- v2.2 纯语音演绎:无文字展示区,speak 逐句推进 -->
</view>
</template>
<script>
import { personVisual } from '../utils/visual.js'
import { speak, stopSpeak } from '../utils/speech.js'
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
const PAUSE_MS = 700 // 句间停顿
const MAX_LINE_MS = 20000 // 单句最长等待(估时兜底也失效时强制推进)
export default {
name: 'StoryPlayer',
props: {
script: { type: Array, default: () => [] }, // [{speaker, text, pinyin, emotion}]
character: { type: Object, default: null } // {name, image, id}
},
emits: ['done'],
data() {
return { lineIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { sp: false, fb: false } }
},
computed: {
moodEmoji() {
const line = this.script[this.lineIndex]
return EMOJI[(line && line.emotion) || 'normal'] || EMOJI.normal
},
charColor() {
return personVisual(this.character && this.character.id ? this.character.id : 0).color
}
},
mounted() {
this.playLine(0)
},
beforeUnmount() {
this.clearTimers()
stopSpeak()
},
methods: {
clearTimers() {
this.timers.forEach(t => { clearTimeout(t); clearInterval(t) })
this.timers = []
},
after(ms, fn) {
this.timers.push(setTimeout(fn, ms))
},
playLine(i) {
const line = this.script[i]
if (!line) {
this.$emit('done')
return
}
this.lineIndex = i
this.ended = false
this.lineDone = { sp: false, fb: 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 缺失(浏览器静音策略/无语音)时按文本长度估时放行,不卡死
this.after(Math.max(2500, total * 320 + 1200), () => {
if (!this.lineDone.sp) {
this.lineDone.sp = true
this.tryNext(i)
}
})
this.after(MAX_LINE_MS, () => this.forceNext(i))
},
tryNext(i) {
if (this.ended || this.lineIndex !== i || !this.lineDone.sp) return
this.ended = true
this.clearTimers()
this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
},
forceNext(i) {
if (this.ended || this.lineIndex !== i) return
this.ended = true
this.clearTimers()
stopSpeak()
this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
},
finish() {
this.ended = true
this.clearTimers()
stopSpeak()
this.$emit('done')
}
}
}
</script>
<style scoped>
.story { position: relative; padding: 24rpx 8rpx 32rpx; }
.story-skip { position: fixed; top: calc(140rpx + env(safe-area-inset-top)); right: 24rpx; font-size: 26rpx; color: #a08c74; background: #fff; border-radius: 30rpx; padding: 8rpx 24rpx; z-index: 20; }
.story-char { position: fixed; right: 24rpx; bottom: 48rpx; z-index: 3; }
.story-char-img { width: 140rpx; height: 140rpx; border-radius: 50%; background: #f8f2e8; box-shadow: 0 8rpx 24rpx rgba(91, 70, 54, 0.15); }
.story-char-avatar { width: 140rpx; height: 140rpx; border-radius: 50%; color: #fff; font-size: 64rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.story-mood { position: absolute; right: -10rpx; bottom: -10rpx; font-size: 44rpx; background: #fff; border-radius: 50%; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(91, 70, 54, 0.2); }
.story-mood.pop { animation: mood-pop 0.35s ease; }
@keyframes mood-pop { 0% { transform: scale(0.3) rotate(-20deg); } 100% { transform: scale(1) rotate(0); } }
</style>
```
- [ ] **Step 2: play.vue 移除 StoryPlayer 的 :show-pinyin 传参**
两处 `<StoryPlayer ... :show-pinyin="showPinyin" ...>`(剧情流 + 终局演绎)删除 `: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 speechSynthesisspeak 不发声、不发事件):逐句按估时兜底推进,选项最终自动出现,不卡死
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
| 验收标准 | 任务 |
|---|---|
@@ -124,6 +124,27 @@
---
## v2.2 修订(2026-08-13):纯语音演绎 + 单端口托管
> 用户走查反馈(v2.1 落地后):① **「必须点跳过按钮才显示出选项」**——StoryPlayer 自然播完不触发 donespeak 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 分钟儿童小剧场,三幕结构: