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:
@@ -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 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)
|
||||
|
||||
| 验收标准 | 任务 |
|
||||
|---|---|
|
||||
|
||||
@@ -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 分钟儿童小剧场,三幕结构:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user