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
| 验收标准 | 任务 |
|---|---|