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
+2
View File
@@ -11,3 +11,5 @@ ui-src/node_modules/
ui-src/dist/
admin-src/node_modules/
admin-src/dist/
.gstack/
genasset
@@ -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 分钟儿童小剧场,三幕结构:
+9
View File
@@ -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()
}
+2 -1
View File
@@ -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()
+13 -46
View File
@@ -7,43 +7,27 @@
<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>
<view class="story-lines">
<view v-for="(line, i) in script" :key="i" v-show="i === lineIndex || i === lineIndex - 1" class="story-line" :class="{ active: i === lineIndex, fading: i === lineIndex - 1 }">
<view v-if="line.speaker === '旁白'" class="bubble nar-bubble">
<view class="bubble-name">📖 旁白</view>
<RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
</view>
<view v-else class="bubble char-bubble">
<view class="bubble-name">{{ line.speaker }}</view>
<RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
</view>
</view>
</view>
<!-- v2.2 纯语音演绎无文字展示区speak 逐句推进 -->
</view>
</template>
<script>
import RubyText from './RubyText.vue'
import { personVisual } from '../utils/visual.js'
import { speak, stopSpeak } from '../utils/speech.js'
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
const PER_CHAR = 260 // 每字高亮推进毫秒(与 TTS 朗读大致同步)
const PAUSE_MS = 700 // 句间停顿
const MAX_LINE_MS = 20000 // 单句最长等待(TTS 无声兜底,防卡死
const MAX_LINE_MS = 20000 // 单句最长等待(估时兜底也失效时强制推进
export default {
name: 'StoryPlayer',
components: { RubyText },
props: {
script: { type: Array, default: () => [] }, // [{speaker, text, pinyin, emotion}]
character: { type: Object, default: null }, // {name, image, id}
showPinyin: { type: Boolean, default: true }
character: { type: Object, default: null } // {name, image, id}
},
emits: ['done'],
data() {
return { lineIndex: 0, charIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { hl: false, sp: false } }
return { lineIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { sp: false, fb: false } }
},
computed: {
moodEmoji() {
@@ -76,33 +60,26 @@ export default {
return
}
this.lineIndex = i
this.charIndex = 0
this.ended = false
this.lineDone = { hl: false, sp: false }
this.lineDone = { sp: false, fb: false }
this.moodTick = true
this.after(350, () => { this.moodTick = false })
uni.pageScrollTo({ scrollTop: 99999, duration: 200 })
const total = Array.from(line.text).length
// 逐字高亮推进(纯视觉节奏)
const step = setInterval(() => {
this.charIndex++
if (this.charIndex >= total) {
clearInterval(step)
this.lineDone.hl = true
this.tryNext(i)
}
}, PER_CHAR)
this.timers.push(step)
// 朗读读完才进下一句(TTS 慢时高亮等朗读);onEnd 与高亮都完成 → 停顿后下一句
speak(line.text, '', () => {
this.lineDone.sp = true
this.tryNext(i)
})
// 兜底:TTS 无声/超时(20s)强制推进防卡死
// 估时兜底: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.hl || !this.lineDone.sp) return
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) })
@@ -133,14 +110,4 @@ export default {
.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); } }
.story-lines { display: flex; flex-direction: column; gap: 24rpx; padding-bottom: 24rpx; }
.story-line { opacity: 0; transition: opacity 0.4s; }
.story-line.active { opacity: 1; }
.story-line.fading { opacity: 0.35; }
.bubble { border-radius: 24rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 16rpx rgba(91, 70, 54, 0.08); }
.bubble-name { font-size: 24rpx; font-weight: 700; color: #ff6b35; margin-bottom: 8rpx; }
.nar-bubble { background: #fff3d6; margin-left: 40rpx; margin-right: 40rpx; }
.char-bubble { background: #ffffff; margin-left: 32rpx; margin-right: 8rpx; }
.story-line.active .bubble { animation: bubble-in 0.35s ease; }
@keyframes bubble-in { 0% { opacity: 0; transform: translateY(24rpx) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }
</style>
+2 -2
View File
@@ -47,12 +47,12 @@
<!-- v2.1 终局演绎结局台词播完自动跳结算 -->
<view v-if="playingFinal && finalNode" class="node story-node final-story">
<StoryPlayer :key="'final-' + finalNode.node_id" :script="finalScript" :character="finalNode.character" :show-pinyin="showPinyin" @done="gotoResult" />
<StoryPlayer :key="'final-' + finalNode.node_id" :script="finalScript" :character="finalNode.character" @done="gotoResult" />
</view>
<!-- 节点剧情流script 非空 v1 卡片回退终局演绎时隐藏 -->
<view v-if="curNode && isStoryNode && !playingFinal" class="node story-node">
<StoryPlayer :key="curNode.node_id" :script="storyScript" :character="curNode.character" :show-pinyin="showPinyin" @done="showOptions = true" />
<StoryPlayer :key="curNode.node_id" :script="storyScript" :character="curNode.character" @done="showOptions = true" />
<view v-if="showOptions" class="story-options">
<PropPick v-if="activeInteraction === 'PropPick'" :config="curNode.config" @done="interactionDone" />
<FindSpot v-else-if="activeInteraction === 'FindSpot'" :config="curNode.config" @done="interactionDone" />