fix: genasset 导入 Update 用 .Ctx(ctx)(GoFrame Update 参数歧义)+ 计划同步
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -257,7 +257,7 @@ func importScripts(fl genFlags) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
b2, _ := json.Marshal(lines)
|
b2, _ := json.Marshal(lines)
|
||||||
if _, err := g.DB().Model(consts.TableSceneNode).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(ctx); err != nil {
|
if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(); err != nil {
|
||||||
glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err)
|
glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,8 +179,49 @@ git commit -m "feat: 前端剧本解析 parseScript + RubyText 逐字高亮 high
|
|||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `ui-src/src/components/StoryPlayer.vue`
|
- Create: `ui-src/src/components/StoryPlayer.vue`
|
||||||
|
- Modify: `ui-src/src/utils/speech.js`(speak 加 onEnd 回调)
|
||||||
|
|
||||||
- [ ] **Step 1: 创建组件**
|
- [ ] **Step 1: speech.js 加 onEnd 回调**
|
||||||
|
|
||||||
|
`speech.js` 修改(逐句朗读需要「读完了再进下一句」的事件,仅定时器推进会在 TTS 慢时切掉上句音频):
|
||||||
|
|
||||||
|
```js
|
||||||
|
function playFile(url, onEnd) {
|
||||||
|
stopSpeak()
|
||||||
|
if (!innerAudio) innerAudio = uni.createInnerAudioContext()
|
||||||
|
innerAudio.stop()
|
||||||
|
innerAudio.src = url
|
||||||
|
if (onEnd) innerAudio.onEnded = () => onEnd()
|
||||||
|
innerAudio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
function speakBrowser(text, onEnd) {
|
||||||
|
stopSpeak()
|
||||||
|
// H5 专用:浏览器原生语音,零成本朗读(小程序端后续接 TTS 文件)
|
||||||
|
if (typeof window === 'undefined' || !window.speechSynthesis) {
|
||||||
|
if (onEnd) onEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const u = new SpeechSynthesisUtterance(text)
|
||||||
|
u.lang = 'zh-CN'
|
||||||
|
u.rate = 0.9
|
||||||
|
u.onend = u.onerror = () => { uttering = false; if (onEnd) onEnd() }
|
||||||
|
window.speechSynthesis.speak(u)
|
||||||
|
uttering = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// speak 朗读一段文本:有音频文件优先播文件,否则浏览器语音降级;onEnd 在读完时回调(无 TTS 能力时立即回调)
|
||||||
|
export function speak(text, fileUrl, onEnd) {
|
||||||
|
if (!text) return
|
||||||
|
if (fileUrl) {
|
||||||
|
playFile(fileUrl, onEnd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
speakBrowser(text, onEnd)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 创建组件**
|
||||||
|
|
||||||
完整创建 `ui-src/src/components/StoryPlayer.vue`:
|
完整创建 `ui-src/src/components/StoryPlayer.vue`:
|
||||||
|
|
||||||
@@ -218,6 +259,7 @@ import { speak, stopSpeak } from '../utils/speech.js'
|
|||||||
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
|
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
|
||||||
const PER_CHAR = 260 // 每字高亮推进毫秒(与 TTS 朗读大致同步)
|
const PER_CHAR = 260 // 每字高亮推进毫秒(与 TTS 朗读大致同步)
|
||||||
const PAUSE_MS = 700 // 句间停顿
|
const PAUSE_MS = 700 // 句间停顿
|
||||||
|
const MAX_LINE_MS = 20000 // 单句最长等待(TTS 无声兜底,防卡死)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'StoryPlayer',
|
name: 'StoryPlayer',
|
||||||
@@ -229,7 +271,7 @@ export default {
|
|||||||
},
|
},
|
||||||
emits: ['done'],
|
emits: ['done'],
|
||||||
data() {
|
data() {
|
||||||
return { lineIndex: 0, charIndex: 0, speaking: false, moodTick: false, timers: [] }
|
return { lineIndex: 0, charIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { hl: false, sp: false } }
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
moodEmoji() {
|
moodEmoji() {
|
||||||
@@ -263,23 +305,45 @@ export default {
|
|||||||
}
|
}
|
||||||
this.lineIndex = i
|
this.lineIndex = i
|
||||||
this.charIndex = 0
|
this.charIndex = 0
|
||||||
|
this.ended = false
|
||||||
|
this.lineDone = { hl: false, sp: false }
|
||||||
this.moodTick = true
|
this.moodTick = true
|
||||||
this.after(300, () => { this.moodTick = false })
|
this.after(350, () => { this.moodTick = false })
|
||||||
this.speaking = true
|
|
||||||
speak(line.text, '')
|
|
||||||
uni.pageScrollTo({ scrollTop: 99999, duration: 200 })
|
uni.pageScrollTo({ scrollTop: 99999, duration: 200 })
|
||||||
const total = Array.from(line.text).length
|
const total = Array.from(line.text).length
|
||||||
|
// 逐字高亮推进(纯视觉节奏)
|
||||||
const step = setInterval(() => {
|
const step = setInterval(() => {
|
||||||
this.charIndex++
|
this.charIndex++
|
||||||
if (this.charIndex > total) {
|
if (this.charIndex >= total) {
|
||||||
clearInterval(step)
|
clearInterval(step)
|
||||||
this.speaking = false
|
this.lineDone.hl = true
|
||||||
this.after(PAUSE_MS, () => this.playLine(i + 1))
|
this.tryNext(i)
|
||||||
}
|
}
|
||||||
}, PER_CHAR)
|
}, PER_CHAR)
|
||||||
this.timers.push(step)
|
this.timers.push(step)
|
||||||
|
// 朗读读完才进下一句(TTS 慢时高亮等朗读);onEnd 与高亮都完成 → 停顿后下一句
|
||||||
|
speak(line.text, '', () => {
|
||||||
|
this.lineDone.sp = true
|
||||||
|
this.tryNext(i)
|
||||||
|
})
|
||||||
|
// 兜底:TTS 无声/超时(20s)强制推进防卡死
|
||||||
|
this.after(MAX_LINE_MS, () => this.forceNext(i))
|
||||||
|
},
|
||||||
|
tryNext(i) {
|
||||||
|
if (this.ended || this.lineIndex !== i || !this.lineDone.hl || !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() {
|
finish() {
|
||||||
|
this.ended = true
|
||||||
this.clearTimers()
|
this.clearTimers()
|
||||||
stopSpeak()
|
stopSpeak()
|
||||||
this.$emit('done')
|
this.$emit('done')
|
||||||
@@ -290,7 +354,7 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.story { position: relative; padding: 24rpx 8rpx 32rpx; }
|
.story { position: relative; padding: 24rpx 8rpx 32rpx; }
|
||||||
.story-skip { position: absolute; top: 0; right: 0; font-size: 26rpx; color: #a08c74; background: #fff; border-radius: 30rpx; padding: 8rpx 24rpx; z-index: 2; }
|
.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 { 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-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-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; }
|
||||||
@@ -310,18 +374,24 @@ export default {
|
|||||||
</style>
|
</style>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 语法验证**
|
- [ ] **Step 3: 语法验证**
|
||||||
|
|
||||||
Run: `cd ui-src && npx vite build --mode h5 2>&1 | tail -3`
|
Run: `cd ui-src && npx vite build --mode h5 2>&1 | tail -3`
|
||||||
Expected: 构建成功(组件未被引用,仅语法检查)
|
Expected: 构建成功(组件未被引用,仅语法检查)
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add ui-src/src/components/StoryPlayer.vue
|
git add ui-src/src/components/StoryPlayer.vue ui-src/src/utils/speech.js
|
||||||
git commit -m "feat: StoryPlayer 台词流演绎器(气泡+朗读+逐字高亮+表情切换+跳过)"
|
git commit -m "feat: StoryPlayer 台词流演绎器(气泡+朗读+逐字高亮+表情切换+跳过)"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 审查记录(58b3e57 双审查:第一 APPROVE,第二 2 SHOULD-FIX,合并修复):
|
||||||
|
> - SHOULD-FIX1(已修):speak 无完成事件,TTS 慢时逐字定时器推进会切掉上句音频 → speech.js speak 加 onEnd 回调,StoryPlayer「高亮完成 + 朗读 onEnd 都满足 → 停顿 → 下一句」,MAX_LINE_MS=20s 超时 forceNext 兜底防 TTS 无声卡死;nextLine 加 lineIndex/ended 竞态保护
|
||||||
|
> - SHOULD-FIX2(已修):pageScrollTo 后跳过按钮滚出视口(4-8 岁孩子找不回)→ .story-skip 改 fixed(top 140rpx 避开 topbar)
|
||||||
|
> - NIT(已合并修):moodTick 300→350ms 复位(动画不截断);speaking 死状态删除
|
||||||
|
> - NIT(记录待 Task 6 走查):.story-char fixed 角色立牌可能与选项区重叠,走查确认,必要时降 absolute;character.name 非空由数据保证
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 4: play.vue 剧情流集成
|
### Task 4: play.vue 剧情流集成
|
||||||
@@ -373,7 +443,7 @@ computed 加:
|
|||||||
```html
|
```html
|
||||||
<!-- 节点:剧情流(script 非空)或 v1 卡片(回退) -->
|
<!-- 节点:剧情流(script 非空)或 v1 卡片(回退) -->
|
||||||
<view v-if="curNode && isStoryNode" class="node story-node">
|
<view v-if="curNode && isStoryNode" class="node story-node">
|
||||||
<StoryPlayer :script="storyScript" :character="curNode.character" :show-pinyin="showPinyin" @done="showOptions = true" />
|
<StoryPlayer :key="curNode.node_id" :script="storyScript" :character="curNode.character" :show-pinyin="showPinyin" @done="showOptions = true" />
|
||||||
<view v-if="showOptions" class="story-options">
|
<view v-if="showOptions" class="story-options">
|
||||||
<PropPick v-if="activeInteraction === 'PropPick'" :config="curNode.config" @done="interactionDone" />
|
<PropPick v-if="activeInteraction === 'PropPick'" :config="curNode.config" @done="interactionDone" />
|
||||||
<FindSpot v-else-if="activeInteraction === 'FindSpot'" :config="curNode.config" @done="interactionDone" />
|
<FindSpot v-else-if="activeInteraction === 'FindSpot'" :config="curNode.config" @done="interactionDone" />
|
||||||
@@ -434,6 +504,7 @@ computed 加:
|
|||||||
this.sceneFx = ''
|
this.sceneFx = ''
|
||||||
if (this.isStoryNode) {
|
if (this.isStoryNode) {
|
||||||
this.reply = fb
|
this.reply = fb
|
||||||
|
this.showOptions = false // 提交后隐藏选项区,防重复点击
|
||||||
} else {
|
} else {
|
||||||
this.feedback = fb
|
this.feedback = fb
|
||||||
}
|
}
|
||||||
@@ -494,6 +565,10 @@ git add ui-src/src/pages/play/play.vue
|
|||||||
git commit -m "feat: play.vue 剧情流集成——StoryPlayer + 选项嵌入 + 角色回应气泡(script 空回退 v1)"
|
git commit -m "feat: play.vue 剧情流集成——StoryPlayer + 选项嵌入 + 角色回应气泡(script 空回退 v1)"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 审查记录(5e19174 双审查一致):
|
||||||
|
> - BLOCKER(已修):连续剧情节点卡死——StoryPlayer 无 `script` watch、play.vue 无 `:key`,节点切换时 v-if 恒真组件不重建不重播,`done` 永不触发 → 选项区永不出现。修复:`<StoryPlayer :key="curNode.node_id" …/>` 强制按节点重建重播
|
||||||
|
> - SHOULD-FIX(已修):reply 出现后 showOptions 仍 true,ActionCard 仍可点击 → 可重复 submit。修复:submit 成功分支置 `showOptions = false`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 5: genasset 剧本生成管线
|
### Task 5: genasset 剧本生成管线
|
||||||
@@ -548,6 +623,7 @@ func TestValidateScript_FinalOK(t *testing.T) {
|
|||||||
lines := []scriptLine{
|
lines := []scriptLine{
|
||||||
{Speaker: "旁白", Text: "天亮了,城门缓缓打开。"},
|
{Speaker: "旁白", Text: "天亮了,城门缓缓打开。"},
|
||||||
{Speaker: "小军师", Text: "我们一起进城啦!", Emotion: "happy"},
|
{Speaker: "小军师", Text: "我们一起进城啦!", Emotion: "happy"},
|
||||||
|
{Speaker: "小军师", Text: "大家平平安安,真好!"},
|
||||||
}
|
}
|
||||||
if err := validateScript(lines, false, "小军师"); err != nil {
|
if err := validateScript(lines, false, "小军师"); err != nil {
|
||||||
t.Fatalf("合法终局剧本应通过: %v", err)
|
t.Fatalf("合法终局剧本应通过: %v", err)
|
||||||
@@ -677,6 +753,9 @@ func genScripts(fl genFlags) {
|
|||||||
sem := make(chan struct{}, 2)
|
sem := make(chan struct{}, 2)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
if !fl.force && fileExists(scriptPath(r["level_id"].Int64(), r["id"].Int64())) {
|
||||||
|
continue // 幂等:草稿已存在跳过(--force 覆盖;防重跑覆盖人工精修草稿)
|
||||||
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
go func(r gdb.Record) {
|
go func(r gdb.Record) {
|
||||||
@@ -861,7 +940,7 @@ func importScripts(fl genFlags) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
b2, _ := json.Marshal(lines)
|
b2, _ := json.Marshal(lines)
|
||||||
if _, err := g.DB().Model(consts.TableSceneNode).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(ctx); err != nil {
|
if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(); err != nil {
|
||||||
glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err)
|
glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -941,6 +1020,11 @@ git add cmd/genasset/prompts.go cmd/genasset/scripts.go cmd/genasset/scripts_tes
|
|||||||
git commit -m "feat: genasset 剧本管线——--only=scripts 草稿生成 + --import-scripts 校验注音回填"
|
git commit -m "feat: genasset 剧本管线——--only=scripts 草稿生成 + --import-scripts 校验注音回填"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 审查记录(c40d833 双审查:第一 APPROVE,第二 1 SHOULD-FIX,合并修复):
|
||||||
|
> - SHOULD-FIX(已修):genScripts 无草稿幂等检查,重跑覆盖人工精修草稿 → 循环内加「草稿存在且非 --force 则跳过」(fileExists(scriptPath) 检查),与 main.go 幂等文档契约一致
|
||||||
|
> - 计划偏差(implementer 修正合理):TestValidateScript_FinalOK 原数据 2 句违反 3-6 句校验,改 3 句
|
||||||
|
> - NIT(不修,记录):导入校验信任草稿自身 Decision/Character 字段(人工精修对象);互动入口节点(2/3/5/7/8)祈使指令类 content 强制末句提问可能套路化——Task 6 精修时留意;pinyin 字段为字符串的链路无单测(与 content_pinyin 同模式,风险低)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 6: 第 1 计试点生成 + 精修 + 端到端验证
|
### Task 6: 第 1 计试点生成 + 精修 + 端到端验证
|
||||||
|
|||||||
Reference in New Issue
Block a user