128 KiB
M1.5 互动体验补齐 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把闯关从纯文字问答升级为完整儿童互动体验:拼音写时标注入库、朗读(浏览器语音降级)、场景/人物/道具视觉素材(oMLX 离线生成 SVG→PNG 打包入客户端)、对比点评(每个选择解释好处/坏处)、5 种触控互动(道具选择/步骤排序/拖拽放置/找线索/连线配对)、结算动画与路线回顾。
Architecture: 后端拼音走"写时一次性标注"(common/pinyin.go + biz/consts 多音字词表,逐字对齐 JSON 入库,*_pinyin 列);互动节点走"互动入口节点"模型——每关决策树入口前程序化插入一个互动节点(is_entry=1,两个出口选项:成功→原入口 / 失败→本关首个失败终局),后端 choose/判分零改动;视觉素材与对比点评由 cmd/genasset 离线管线一次性生成(本机 oMLX OpenAI 兼容接口,enable_thinking=false,SVG 经 sharp 转 PNG 入 ui-src/static/generated/,点评回填 node_option.feedback_pros/cons,运行时零 LLM 依赖);前端按 interaction_type 分发渲染 5 个 touch 组件,文本用 <ruby> 注音,无音频文件时用浏览器 SpeechSynthesis 朗读,反馈弹层三段式展示对比点评,结算页彩带 + 路线回顾。
Tech Stack: Go + GoFrame v2、SQLite、github.com/mozillazg/go-pinyin@v0.21.0(已入 go.mod)、uni-app Vue3 + Vite(H5)、CSS/SVG touch 交互(无游戏引擎)、本机 oMLX(Qwen3.5-9B-MLX-4bit,OpenAI 兼容)、sharp(devDependency,SVG→PNG)。
规格依据: 技术设计.md 4.6(拼音与朗读)、4.7(触控互动组件)、4.8(视觉素材与对比点评生成管线);已与用户确认决策:后端写时标注 / 浏览器语音降级 / 触控 5 种 / 全量 36 计 / 本机 oMLX qwen 离线生成素材 / 对比点评 pros-cons 两列。
文件结构
后端(新增/修改):
common/pinyin.go(新增)—AnnotatePinyin(text) string:词表最长匹配 + 逐字转拼音,返回逐字对齐 JSON 数组字符串common/pinyin_test.go(新增)— 标注单测common/migrate.go(新增)—EnsureColumn(ctx, table, col, sqlType)幂等加列biz/consts/pinyin.go(新增)—PinyinOverrides词表(36 计成语 + 种子专名)biz/dao/dao_strategy.go/dao_level.go/dao_scene_node.go/dao_node_option.go/dao_element.go(修改)— CREATE TABLE 加列 +EnsureColumn迁移biz/service/seed/seed.go(修改)— 插入时标注拼音;EnsureSeeded末尾调用annotateAll+ensureInteractionsbiz/service/seed/annotate.go(新增)—annotateAll补标(幂等)biz/service/seed/interaction.go(新增)—ensureInteractions+planInteraction(纯函数)biz/service/seed/interaction_test.go(新增)— 互动计划单测biz/service/level.go(修改)— Element/Option/Node/LevelDetail 加拼音字段,buildNode/elementsOf/Detail 读取biz/service/strategy.go(修改)— StrategyDetail 加 meaning_pinyin/teach_content_pinyinbiz/model/dto/level.go/dto/level_play.go/dto/strategy.go(修改)— VO 加拼音字段 + OptionVO 加 feedback_pros/consbiz/controller/level.go/strategy.go(修改)— VO 映射cmd/genasset/omlx.go/prompts.go/main.go(新增)— 离线生成管线(oMLX 素材 + 对比点评)config.yml(修改)— genasset 段
前端(新增/修改,全部在 ui-src/):
src/utils/speech.js(新增)— 朗读:文件优先(InnerAudioContext),否则浏览器 SpeechSynthesissrc/utils/visual.js(新增)— 场景/道具/人物占位视觉(emoji + 渐变色)src/utils/pinyin.js(新增)— pinyin JSON 解析 + 与原文 zipsrc/components/RubyText.vue(新增)—<ruby>注音文本组件src/components/SpeakButton.vue(新增)— 朗读按钮src/components/interactions/PropPick.vue/FindSpot.vue/DragPlace.vue/StepSort.vue/LinkMatch.vue(新增)— 5 个触控组件,统一 emitdone({success})src/pages/play/play.vue(修改)— 场景面板 + 互动分发 + 拼音/朗读 + 对比点评弹层 + 路线回顾累积 + 表情/道具图/动效src/pages/map/map.vue(修改)— 学堂弹层拼音 + 朗读src/pages/children/children.vue(修改)— 选择孩子时记录年龄段src/store/user.js(修改)— getChildAge/setChildAgesrc/pages/result/result.vue(修改)— 彩带动画 + 路线回顾展示scripts/svg2png.mjs(新增)— sharp SVG→PNG 转换(genasset 调用)package.json(修改)— devDependencies 加 sharp
Task 1: 拼音标注服务(common/pinyin.go + 词表 + 单测)
Files:
-
Create:
biz/consts/pinyin.go -
Create:
common/pinyin.go -
Test:
common/pinyin_test.go -
Step 1: 写词表
biz/consts/pinyin.go(36 计成语整体拼音 + 种子内容常用专名;标注时最长匹配优先)
package consts
// PinyinOverrides 拼音标注词表:整词 override(成语整体、专名),标注时最长匹配优先,
// 保证教育内容多音字准确(如「声东击西」不能拆成 zhong dong ji xi)。
var PinyinOverrides = map[string]string{
"瞒天过海": "mán tiān guò hǎi",
"围魏救赵": "wéi wèi jiù zhào",
"借刀杀人": "jiè dāo shā rén",
"以逸待劳": "yǐ yì dài láo",
"趁火打劫": "chèn huǒ dǎ jié",
"声东击西": "shēng dōng jī xī",
"无中生有": "wú zhōng shēng yǒu",
"暗渡陈仓": "àn dù chén cāng",
"隔岸观火": "gé àn guān huǒ",
"笑里藏刀": "xiào lǐ cáng dāo",
"李代桃僵": "lǐ dài táo jiāng",
"顺手牵羊": "shùn shǒu qiān yáng",
"打草惊蛇": "dǎ cǎo jīng shé",
"借尸还魂": "jiè shī huán hún",
"调虎离山": "diào hǔ lí shān",
"欲擒故纵": "yù qín gù zòng",
"抛砖引玉": "pāo zhuān yǐn yù",
"擒贼擒王": "qín zéi qín wáng",
"釜底抽薪": "fǔ dǐ chōu xīn",
"混水摸鱼": "hún shuǐ mō yú",
"金蝉脱壳": "jīn chán tuō qiào",
"关门捉贼": "guān mén zhuō zéi",
"远交近攻": "yuǎn jiāo jìn gōng",
"假道伐虢": "jiǎ dào fá guó",
"偷梁换柱": "tōu liáng huàn zhù",
"指桑骂槐": "zhǐ sāng mà huái",
"假痴不癫": "jiǎ chī bù diān",
"上屋抽梯": "shàng wū chōu tī",
"树上开花": "shù shàng kāi huā",
"反客为主": "fǎn kè wéi zhǔ",
"美人计": "měi rén jì",
"空城计": "kōng chéng jì",
"反间计": "fǎn jiàn jì",
"苦肉计": "kǔ ròu jì",
"连环计": "lián huán jì",
"走为上计": "zǒu wéi shàng jì",
// 种子内容常用专名(扫描 seed_36_ji/*.json 的人物/场景名补充,发现多音误标就加)
"小明": "xiǎo míng",
"小虎": "xiǎo hǔ",
"朵朵": "duǒ duǒ",
"幼儿园": "yòu ér yuán",
"足球场": "zú qiú chǎng",
"游乐场": "yóu lè chǎng",
"小兔子": "xiǎo tù zi",
"大哥哥": "dà gē ge",
}
- Step 2: 写失败单测
common/pinyin_test.go
package common
import (
"encoding/json"
"testing"
)
func parse(t *testing.T, s string) []string {
t.Helper()
var out []string
if err := json.Unmarshal([]byte(s), &out); err != nil {
t.Fatalf("拼音 JSON 解析失败: %v", err)
}
return out
}
func TestAnnotatePinyin_IdiomOverride(t *testing.T) {
// 词表命中整体转拼音(带声调)
pys := parse(t, AnnotatePinyin("声东击西"))
want := []string{"shēng", "dōng", "jī", "xī"}
if len(pys) != len(want) {
t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys)
}
for i := range want {
if pys[i] != want[i] {
t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i])
}
}
}
func TestAnnotatePinyin_Alignment(t *testing.T) {
// 逐字对齐:标点/非汉字对应空串
pys := parse(t, AnnotatePinyin("小明,你好!"))
want := []string{"xiǎo", "míng", "", "nǐ", "hǎo", ""}
if len(pys) != len(want) {
t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys)
}
for i := range want {
if pys[i] != want[i] {
t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i])
}
}
}
func TestAnnotatePinyin_NonHan(t *testing.T) {
pys := parse(t, AnnotatePinyin("123abc"))
if len(pys) != 6 {
t.Fatalf("长度应 6,实际 %d", len(pys))
}
for i, p := range pys {
if p != "" {
t.Fatalf("第 %d 字符应空串,实际 %q", i, p)
}
}
}
func TestAnnotatePinyin_Empty(t *testing.T) {
if AnnotatePinyin("") != "[]" {
t.Fatal("空文本应返回 []")
}
}
func TestAnnotatePinyin_MissingDictRune(t *testing.T) {
// 词典缺失字(兙 在 CJK 范围但 go-pinyin 词典未收录)保持空串,后续字不错位
pys := parse(t, AnnotatePinyin("兙好"))
want := []string{"", "hǎo"}
if len(pys) != len(want) {
t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys)
}
for i := range want {
if pys[i] != want[i] {
t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i])
}
}
}
- Step 3: 跑测试确认失败
Run: go test ./common/ -run TestAnnotatePinyin -v
Expected: FAIL(AnnotatePinyin 未定义)
- Step 4: 实现
common/pinyin.go
package common
import (
"encoding/json"
"sort"
"strings"
"unicode"
"github.com/mozillazg/go-pinyin"
"36wisdom/biz/consts"
)
// AnnotatePinyin 写时一次性标注:词表最长匹配优先(整体转拼音),未命中逐字转
// (pinyin.Tone 带声调);返回逐字对齐的 JSON 数组字符串,非汉字对应空串,
// 下标与原文 rune 一一对应。纯函数,文本未变结果不变(幂等)。
func AnnotatePinyin(text string) string {
runes := []rune(text)
out := make([]string, len(runes))
if len(runes) == 0 {
return "[]"
}
words := make([]string, 0, len(consts.PinyinOverrides))
for w := range consts.PinyinOverrides {
words = append(words, w)
}
sort.Slice(words, func(i, j int) bool { return len(words[i]) > len(words[j]) })
// 词表最长匹配切分:命中片段整体标注(词表拼音音节数须与字数一致)
for i := 0; i < len(runes); {
matched := false
for _, w := range words {
n := len([]rune(w))
if i+n > len(runes) || string(runes[i:i+n]) != w {
continue
}
pys := strings.Fields(consts.PinyinOverrides[w])
if len(pys) != n {
continue
}
copy(out[i:i+n], pys)
i += n
matched = true
break
}
if !matched {
i++
}
}
// 未命中汉字逐字转拼音:词典缺失字(生僻字等)SinglePinyin 返回空串,
// 该字自然保持 "",后续字不错位
args := pinyin.Args{Style: pinyin.Tone}
i := 0
for _, r := range text {
if out[i] == "" && unicode.Is(unicode.Han, r) {
if pys := pinyin.SinglePinyin(r, args); len(pys) > 0 {
out[i] = pys[0]
}
}
i++
}
b, _ := json.Marshal(out)
return string(b)
}
- Step 5: 跑测试确认通过
Run: go test ./common/ -run TestAnnotatePinyin -v
Expected: PASS(4 个用例全绿)
- Step 6: 提交
git add biz/consts/pinyin.go common/pinyin.go common/pinyin_test.go go.mod go.sum
git commit -m "feat: 拼音写时标注服务(词表最长匹配 + 逐字对齐 JSON)"
Task 2: 表迁移:5 张表加 *_pinyin 列
Files:
-
Create:
common/migrate.go -
Modify:
biz/dao/dao_strategy.go、biz/dao/dao_level.go、biz/dao/dao_scene_node.go、biz/dao/dao_node_option.go、biz/dao/dao_element.go -
Step 1: 实现幂等加列
common/migrate.go(SQLite 无 DROP COLUMN,只增不改;失败仅告警不阻断启动)
package common
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
// EnsureColumn 幂等加列:PRAGMA 查列不存在则 ALTER TABLE ADD COLUMN。
// 失败只记警告,不阻断启动(列缺失时后续查询只会取到空值)。
func EnsureColumn(ctx context.Context, table, col, sqlType string) {
cols, err := g.DB().GetAll(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
g.Log().Warningf(ctx, "migrate: %s 表结构读取失败: %v", table, err)
return
}
for _, c := range cols {
if c["name"].String() == col {
return
}
}
if _, err := g.DB().Exec(ctx, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, sqlType)); err != nil {
g.Log().Warningf(ctx, "migrate: %s.%s 加列失败: %v", table, col, err)
return
}
g.Log().Infof(ctx, "migrate: %s.%s 已添加", table, col)
}
- Step 2: 更新
biz/dao/dao_strategy.go——CREATE TABLE 加 4 列,Init 末尾补EnsureColumn
修改 CREATE TABLE(在 meaning TEXT NOT NULL 后加一行、teach_content TEXT 后加一行、summary_q TEXT 后加一行、summary_options TEXT 后加一行):
meaning TEXT NOT NULL, -- 儿童语言释义
meaning_pinyin TEXT, -- 释义拼音(逐字对齐 JSON)
teach_content TEXT, -- 计策学堂:儿童化讲解(名称/释义/使用时机)
teach_content_pinyin TEXT,
summary_q TEXT, -- 智慧总结反思题
summary_q_pinyin TEXT,
summary_options TEXT, -- 反思题选项 JSON(含正确答案)
summary_options_pinyin TEXT,
修改 Init 末尾(return err 前):
common.EnsureColumn(ctx, consts.TableStrategy, "meaning_pinyin", "TEXT")
common.EnsureColumn(ctx, consts.TableStrategy, "teach_content_pinyin", "TEXT")
common.EnsureColumn(ctx, consts.TableStrategy, "summary_q_pinyin", "TEXT")
common.EnsureColumn(ctx, consts.TableStrategy, "summary_options_pinyin", "TEXT")
return err
-
Step 3: 更新
biz/dao/dao_level.go——scene_content TEXT NOT NULL后加scene_content_pinyin TEXT;Init 末尾加common.EnsureColumn(ctx, consts.TableLevel, "scene_content_pinyin", "TEXT") -
Step 4: 更新
biz/dao/dao_scene_node.go——content TEXT NOT NULL后加content_pinyin TEXT;Init 末尾加common.EnsureColumn(ctx, consts.TableSceneNode, "content_pinyin", "TEXT") -
Step 5: 更新
biz/dao/dao_node_option.go——text TEXT NOT NULL后加text_pinyin TEXT、feedback TEXT NOT NULL后加feedback_pinyin TEXT、feedback_pros TEXT(对比点评:好处)、feedback_cons TEXT(对比点评:不足/错过的更好选择);Init 末尾加四个common.EnsureColumn(ctx, consts.TableNodeOption, "text_pinyin", "TEXT")/("feedback_pinyin", "TEXT")/("feedback_pros", "TEXT")/("feedback_cons", "TEXT") -
Step 6: 更新
biz/dao/dao_element.go——name TEXT NOT NULL后加name_pinyin TEXT、description TEXT后加description_pinyin TEXT;Init 末尾加两个EnsureColumn -
Step 7: 编译验证 + 迁移验证(对现有库)
Run: go build ./... && go vet ./...
Run: go run main.go 观察日志出现 migrate: level.scene_content_pinyin 已添加 等 12 行(含 node_option 的 feedback_pros / feedback_cons);sqlite3 data/36wisdom.db "PRAGMA table_info(node_option);" 含 text_pinyin、feedback_pros、feedback_cons。启动后 Ctrl-C 停掉。
- Step 8: 提交
git add common/migrate.go biz/dao/
git commit -m "feat: 内容表 *_pinyin 列迁移(幂等 EnsureColumn)"
Task 3: 拼音入库:种子插入时标注 + 启动补标
Files:
-
Modify:
biz/service/seed/seed.go -
Create:
biz/service/seed/annotate.go -
Step 1: 种子插入时标注(修改
seed.go四处)
insertElement 的 g.Map(第 208-211 行)改为:
res, err := tx.Model(consts.TableElement).Ctx(ctx).Data(g.Map{
"e_type": el.EType, "name": el.Name, "name_pinyin": common.AnnotatePinyin(el.Name),
"image": el.Image, "audio": el.Audio,
"description": el.Description, "description_pinyin": common.AnnotatePinyin(el.Description),
"status": consts.StatusEnabled,
}).Insert()
insertStrategy 的 g.Map(第 236-241 行)改为:
data := g.Map{
"name": s.Name, "pinyin": s.Pinyin, "group_no": s.GroupNo, "group_name": s.GroupName,
"meaning": s.Meaning, "meaning_pinyin": common.AnnotatePinyin(s.Meaning),
"teach_content": s.TeachContent, "teach_content_pinyin": common.AnnotatePinyin(s.TeachContent),
"teach_image": s.TeachImage,
"teach_audio": s.TeachAudio, "summary_q": s.SummaryQ, "summary_q_pinyin": common.AnnotatePinyin(s.SummaryQ),
"summary_options": s.SummaryOptions, "summary_options_pinyin": common.AnnotatePinyin(s.SummaryOptions),
"summary_audio": s.SummaryAudio, "icon": s.Icon, "sort_order": s.SortOrder,
}
insertLevel 的 g.Map(第 278-283 行)改为(补 scene_content_pinyin):
res, err := tx.Model(consts.TableLevel).Ctx(ctx).Data(g.Map{
"strategy_id": strategyId, "title": lv.Title, "scene_id": sceneId,
"scene_content": lv.SceneContent, "scene_content_pinyin": common.AnnotatePinyin(lv.SceneContent),
"scene_image": lv.SceneImage, "scene_audio": lv.SceneAudio,
"age_group": ageGroup, "content_version": 1, "sort_order": sortOrder,
"status": consts.StatusEnabled,
}).Insert()
选项插入(第 336-342 行)改为(补 text_pinyin / feedback_pinyin):
if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{
"node_id": nodeIds[i], "text": opt.Text, "text_pinyin": common.AnnotatePinyin(opt.Text),
"prop_id": propId,
"audio": opt.Audio, "next_node_id": nextNodeId, "feedback": opt.Feedback,
"feedback_pinyin": common.AnnotatePinyin(opt.Feedback),
"feedback_audio": opt.FeedbackAudio, "sort_order": oi + 1,
"status": consts.StatusEnabled,
}).Insert(); err != nil {
return err
}
seed.go 的 import 增加 "36wisdom/common"。
- Step 2: 写补标
biz/service/seed/annotate.go(对既有库与残留空值,幂等)
package seed
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/common"
)
// annotateAll 拼音补标:对 *_pinyin 为空的记录按原文标注。
// 启动维护一次性(约 500 行),单事务内逐行 UPDATE,失败告警不阻断。
func annotateAll(ctx context.Context) {
type target struct{ table, pinyinCol, textCol string }
targets := []target{
{consts.TableStrategy, "meaning_pinyin", "meaning"},
{consts.TableStrategy, "teach_content_pinyin", "teach_content"},
{consts.TableStrategy, "summary_q_pinyin", "summary_q"},
{consts.TableStrategy, "summary_options_pinyin", "summary_options"},
{consts.TableLevel, "scene_content_pinyin", "scene_content"},
{consts.TableSceneNode, "content_pinyin", "content"},
{consts.TableNodeOption, "text_pinyin", "text"},
{consts.TableNodeOption, "feedback_pinyin", "feedback"},
{consts.TableElement, "name_pinyin", "name"},
{consts.TableElement, "description_pinyin", "description"},
}
if err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, t := range targets {
recs, err := tx.Model(t.table).Ctx(ctx).Where(t.pinyinCol, "").Fields("id," + t.textCol).All()
if err != nil {
return err
}
for _, rec := range recs {
if _, err := tx.Model(t.table).Ctx(ctx).WherePri(rec["id"].Int64()).
Data(g.Map{t.pinyinCol: common.AnnotatePinyin(rec[t.textCol].String())}).Update(); err != nil {
return err
}
}
}
return nil
}); err != nil {
g.Log().Warningf(ctx, "seed: 拼音补标失败: %v", err)
return
}
g.Log().Info(ctx, "seed: 拼音补标完成")
}
注意:需要 import "github.com/gogf/gf/v2/database/gdb"。
- Step 3:
EnsureSeeded末尾追加补标与互动节点调用(互动节点 Task 4 实现,先留注释占位)
修改 EnsureSeeded(seed.go 第 118-131 行)为:
func EnsureSeeded(ctx context.Context) {
count, err := dao.Strategy.Model().Ctx(ctx).Count()
if err != nil {
g.Log().Fatal(ctx, err)
}
if count > 0 {
g.Log().Info(ctx, "seed: 数据已存在,跳过种子导入")
} else {
if err := doSeed(ctx); err != nil {
g.Log().Fatal(ctx, err)
}
g.Log().Info(ctx, "seed: 种子数据导入完成")
}
annotateAll(ctx)
// ensureInteractions(ctx) // Task 4 打开
}
- Step 4: 编译 + 验证补标
Run: go build ./...
Run: go run main.go,日志出现 seed: 拼音补标完成;sqlite3 data/36wisdom.db "SELECT content_pinyin FROM scene_node LIMIT 1;" 返回 JSON 数组(如 ["小","明",",","..."])。Ctrl-C 停掉。
- Step 5: 提交
git add biz/service/seed/seed.go biz/service/seed/annotate.go
git commit -m "feat: 种子拼音写时标注 + 启动补标"
Task 4: 互动入口节点程序化生成(触控 5 种)
Files:
- Create:
biz/service/seed/interaction.go - Test:
biz/service/seed/interaction_test.go
模型(技术设计.md 4.7):每关决策树入口前插入互动节点(is_entry=1、interaction_type 2/3/5/7/8 轮换),选项表预置两个出口——成功→原入口节点、失败→本关首个失败终局;原入口 is_entry 置 0。数据全部从该关元素派生:道具=该关选项的 prop 去重、正确答案=能到达最佳终局的第一个选项的道具、人物=该关节点 character 去重。
- Step 1: 写失败单测
biz/service/seed/interaction_test.go(纯函数planInteraction)
package seed
import (
"encoding/json"
"testing"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/consts"
)
// gdb.Record = map[string]gdb.Value = map[string]*gvar.Var(gf v2.10.2),字面量一律 gvar.New
// 构造与 level 1 同构的小树:entry(1) → 决策(2) → best(3)/good(4)/fail(5)
func testTree() (nodes []gdb.Record, options []gdb.Record) {
nodes = []gdb.Record{
{"id": gvar.New(1), "character_id": gvar.New(11), "result_type": gvar.New(0)},
{"id": gvar.New(2), "character_id": gvar.New(12), "result_type": gvar.New(0)},
{"id": gvar.New(3), "character_id": gvar.New(12), "result_type": gvar.New(3)},
{"id": gvar.New(4), "character_id": gvar.New(12), "result_type": gvar.New(2)},
{"id": gvar.New(5), "character_id": gvar.New(11), "result_type": gvar.New(1)},
}
options = []gdb.Record{
{"id": gvar.New(101), "node_id": gvar.New(1), "prop_id": gvar.New(21), "next_node_id": gvar.New(2), "sort_order": gvar.New(1)},
{"id": gvar.New(102), "node_id": gvar.New(1), "prop_id": gvar.New(22), "next_node_id": gvar.New(5), "sort_order": gvar.New(2)},
{"id": gvar.New(103), "node_id": gvar.New(2), "prop_id": gvar.New(21), "next_node_id": gvar.New(3), "sort_order": gvar.New(1)},
{"id": gvar.New(104), "node_id": gvar.New(2), "prop_id": gvar.New(23), "next_node_id": gvar.New(4), "sort_order": gvar.New(2)},
{"id": gvar.New(105), "node_id": gvar.New(2), "prop_id": gvar.New(24), "next_node_id": gvar.New(5), "sort_order": gvar.New(3)},
}
return
}
func elems() map[int64]string {
return map[int64]string{11: "小明", 12: "妈妈", 21: "画", 22: "扫帚", 23: "剪刀", 24: "长杆"}
}
func TestPlanInteraction_BestPropAndFailFinal(t *testing.T) {
nodes, options := testTree()
p := planInteraction(2, 1, nodes, options, elems())
if p == nil {
t.Fatal("应生成互动计划")
}
// 正确答案 = 能到达最佳终局(3)的第一个选项(103)的道具 21
if p.answer != 21 {
t.Fatalf("正确答案应 21(选项103的道具),实际 %d", p.answer)
}
// 失败出口 = 首个失败终局 5
if p.failFinalID != 5 {
t.Fatalf("失败终局应 5,实际 %d", p.failFinalID)
}
// 道具按选项顺序去重:21,22,23,24
if len(p.items) != 4 || p.items[0].ID != 21 || p.items[3].ID != 24 {
t.Fatalf("道具顺序错误: %+v", p.items)
}
}
func TestPlanInteraction_StepSortReversed(t *testing.T) {
nodes, options := testTree()
p := planInteraction(3, 1, nodes, options, elems())
if len(p.answerOrder) != 4 {
t.Fatalf("排序应有 4 项,实际 %d", len(p.answerOrder))
}
// 正确顺序 = 道具顺序逆序
if p.answerOrder[0] != 24 || p.answerOrder[3] != 21 {
t.Fatalf("逆序错误: %v", p.answerOrder)
}
}
func TestPlanInteraction_LinkMatchPairs(t *testing.T) {
nodes, options := testTree()
p := planInteraction(8, 1, nodes, options, elems())
if p == nil {
t.Fatal("应生成连线计划")
}
// 人物:11(小明)、12(妈妈);小明(节点1)首个带道具选项→扫帚22;妈妈(节点2)→画21
found := false
for _, pair := range p.pairs {
if pair[0] == 11 && pair[1] == 22 {
found = true
}
}
if !found {
t.Fatalf("缺少 小明-扫帚 配对: %v", p.pairs)
}
}
func TestPlanInteraction_FallbackToPropPick(t *testing.T) {
// 只有 1 个道具 → step_sort 退化为 prop_pick
nodes, options := testTree()
options = options[:1]
p := planInteraction(3, 1, nodes, options, elems())
if p == nil || p.kind != "prop_pick" {
t.Fatalf("应退化为 prop_pick,实际 %+v", p)
}
}
func TestPlanInteraction_NoFailFinalSkipped(t *testing.T) {
nodes, options := testTree()
for i := range nodes {
if nodes[i]["result_type"].Int() == consts.ResultFail {
nodes[i]["result_type"] = gconv.Int(2)
}
}
if p := planInteraction(2, 1, nodes, options, elems()); p != nil {
t.Fatal("无失败终局应返回 nil(跳过该关)")
}
}
func TestInteractionConfig_JSON(t *testing.T) {
p := &interactionPlan{kind: "prop_pick", items: []configItem{{ID: 21, Name: "画"}}, answer: 21, prompt: "选一选"}
s := interactionConfigJSON(p)
var m map[string]any
if err := json.Unmarshal([]byte(s), &m); err != nil {
t.Fatalf("config 非法 JSON: %v", err)
}
if m["kind"] != "prop_pick" || m["answer"] != float64(21) {
t.Fatalf("config 字段错误: %v", m)
}
}
- Step 2: 跑测试确认失败
Run: go test ./biz/service/seed/ -run TestPlanInteraction -v
Expected: FAIL(planInteraction / interactionPlan / configItem / interactionConfigJSON 未定义)
- Step 3: 实现
biz/service/seed/interaction.go
package seed
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
"36wisdom/common"
)
// interactionKinds 触控互动类型轮换顺序(技术设计.md 4.7:2道具选择 3步骤排序 5拖拽放置 7找线索 8连线配对)
var interactionKinds = []int{2, 7, 5, 3, 8}
// interactionPrompts 各互动类型的节点提示语
var interactionPrompts = map[int]string{
2: "选一选:哪个道具能帮上忙?",
7: "找一找:线索在哪里?点一点",
5: "拖一拖:把道具放到框里",
3: "排一排:按顺序摆好道具",
8: "连一连:谁会用哪个道具?",
}
type configItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type interactionPlan struct {
kind string `json:"kind"` // prop_pick / find_spot / drag_place / step_sort / link_match
Items []configItem `json:"items"`
Answer int64 `json:"answer"`
AnswerOrder []int64 `json:"answer_order,omitempty"`
Persons []configItem `json:"persons,omitempty"`
Pairs [][2]int64 `json:"pairs,omitempty"`
EntryID int64 // 成功出口目标:原入口节点
FailFinalID int64 // 失败出口目标:本关首个失败终局
Prompt string `json:"-"`
}
// ensureInteractions 每关生成互动入口节点(幂等):入口节点 interaction_type>1 视为已有,跳过。
func ensureInteractions(ctx context.Context) {
levels, err := dao.Level.Model().Ctx(ctx).
Where("status", consts.StatusEnabled).Order("id ASC").All()
if err != nil {
g.Log().Warningf(ctx, "seed: 互动节点生成失败(查关卡): %v", err)
return
}
for i, lv := range levels {
if err := ensureLevelInteraction(ctx, lv, interactionKinds[i%len(interactionKinds)]); err != nil {
g.Log().Warningf(ctx, "seed: 互动节点生成失败 level=%d: %v", lv["id"].Int64(), err)
}
}
}
func ensureLevelInteraction(ctx context.Context, lv gdb.Record, kind int) error {
levelId := lv["id"].Int64()
nodes, err := dao.SceneNode.Model().Ctx(ctx).
Where("level_id", levelId).Where("status", consts.StatusEnabled).
Order("sort_order ASC, id ASC").All()
if err != nil {
return err
}
var entry gdb.Record
for _, n := range nodes {
if n["is_entry"].Int() == 1 {
entry = n
break
}
}
if entry.IsEmpty() {
return fmt.Errorf("关卡 %d 无入口节点", levelId)
}
if entry["interaction_type"].Int() > 1 {
return nil // 已有互动入口,幂等跳过
}
options, err := dao.NodeOption.Model().Ctx(ctx).
WhereIn("node_id", nodeIdsOf(nodes)).Where("status", consts.StatusEnabled).
Order("sort_order ASC, id ASC").All()
if err != nil {
return err
}
elementNames := map[int64]string{}
for _, n := range nodes {
if cid := n["character_id"].Int64(); cid > 0 {
elementNames[cid] = ""
}
}
for _, o := range options {
if pid := o["prop_id"].Int64(); pid > 0 {
elementNames[pid] = ""
}
}
if len(elementNames) > 0 {
ids := make([]int64, 0, len(elementNames))
for id := range elementNames {
ids = append(ids, id)
}
if recs, err := dao.Element.Model().Ctx(ctx).
WhereIn("id", uniqueInt64(ids)).Where("status", consts.StatusEnabled).All(); err == nil {
for _, r := range recs {
elementNames[r["id"].Int64()] = r["name"].String()
}
}
}
plan := planInteraction(kind, entry["id"].Int64(), nodes, options, elementNames)
if plan == nil {
return nil // 数据不足(无失败终局等),该关不插互动节点
}
return insertInteractionNode(ctx, levelId, kind, plan)
}
// planInteraction 纯函数:从关卡节点/选项派生互动计划;数据不足返回 nil。
func planInteraction(kind int, entryID int64, nodes, options []gdb.Record, elementNames map[int64]string) *interactionPlan {
// 失败终局:本关第一个 result_type=1 的节点;无则整关跳过
failFinalID := int64(0)
for _, n := range nodes {
if n["result_type"].Int() == consts.ResultFail {
failFinalID = n["id"].Int64()
break
}
}
if failFinalID == 0 {
return nil
}
// 道具:按选项出现顺序去重
items := []configItem{}
seenProp := map[int64]bool{}
for _, o := range options {
pid := o["prop_id"].Int64()
if pid == 0 || seenProp[pid] {
continue
}
seenProp[pid] = true
items = append(items, configItem{ID: pid, Name: elementNames[pid]})
}
// 正确答案:第一个其子树能到达最佳终局(result_type=3)的选项的道具
bestProp := bestPropOf(options, nodes)
if bestProp == 0 || len(items) == 0 {
return nil
}
p := &interactionPlan{
kind: kindOf(kind, items),
Items: items,
Answer: bestProp,
EntryID: entryID,
FailFinalID: failFinalID,
Prompt: interactionPrompts[kind],
}
switch p.kind {
case "step_sort":
for i := len(items) - 1; i >= 0; i-- {
p.AnswerOrder = append(p.AnswerOrder, items[i].ID)
}
case "link_match":
p.Persons, p.Pairs = pairsOf(nodes, options)
if len(p.Pairs) < 2 {
// 配对不足退化为道具选择
p.kind = "prop_pick"
p.Prompt = interactionPrompts[2]
}
}
return p
}
// kindOf 类型降级:步骤排序需 ≥2 道具,连线配对需 ≥2 人物配对,否则退化为道具选择。
func kindOf(kind int, items []configItem) string {
switch kind {
case 3:
if len(items) >= 2 {
return "step_sort"
}
case 8:
return "link_match"
case 7:
return "find_spot"
case 5:
return "drag_place"
}
return "prop_pick"
}
// bestPropOf 第一个(选项顺序)其子树内存在最佳终局的选项的道具 id。
func bestPropOf(options []gdb.Record, nodes []gdb.Record) int64 {
nextOf := map[int64][]int64{} // node_id → 可达下一节点(通过其选项)
for _, o := range options {
nextOf[o["node_id"].Int64()] = append(nextOf[o["node_id"].Int64()], o["next_node_id"].Int64())
}
best := map[int64]bool{}
for _, n := range nodes {
if n["result_type"].Int() == consts.ResultBest {
best[n["id"].Int64()] = true
}
}
canReachBest := func(start int64) bool {
visited := map[int64]bool{}
queue := []int64{start}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if visited[cur] {
continue
}
visited[cur] = true
if best[cur] {
return true
}
queue = append(queue, nextOf[cur]...)
}
return false
}
for _, o := range options {
if canReachBest(o["next_node_id"].Int64()) {
return o["prop_id"].Int64()
}
}
return 0
}
// pairsOf 人物-道具配对:每个去重人物取其首个带道具选项的道具。
func pairsOf(nodes, options []gdb.Record) ([]configItem, [][2]int64) {
persons := []configItem{}
seen := map[int64]bool{}
personOf := map[int64]int64{} // person id → 其所在节点
for _, n := range nodes {
cid := n["character_id"].Int64()
if cid == 0 || seen[cid] {
continue
}
seen[cid] = true
personOf[cid] = n["id"].Int64()
}
propOf := map[int64]int64{} // node_id → 首个带道具选项的道具
for _, o := range options {
pid := o["prop_id"].Int64()
if pid == 0 {
continue
}
nid := o["node_id"].Int64()
if _, ok := propOf[nid]; !ok {
propOf[nid] = pid
}
}
pairs := [][2]int64{}
for _, n := range nodes {
cid := n["character_id"].Int64()
if cid == 0 {
continue
}
if pid, ok := propOf[n["id"].Int64()]; ok {
pairs = append(pairs, [2]int64{cid, pid})
if !seen[cid] {
seen[cid] = true
persons = append(persons, configItem{ID: cid})
}
}
}
return persons, pairs
}
// interactionConfigJSON 互动配置(前端渲染数据)。
func interactionConfigJSON(p *interactionPlan) string {
b, _ := json.Marshal(p)
return string(b)
}
func nodeIdsOf(nodes []gdb.Record) []int64 {
ids := make([]int64, 0, len(nodes))
for _, n := range nodes {
ids = append(ids, n["id"].Int64())
}
return ids
}
// insertInteractionNode 事务:原入口 is_entry=0 → 插互动节点(is_entry=1) → 插成功/失败出口选项。
func insertInteractionNode(ctx context.Context, levelId int64, kind int, plan *interactionPlan) error {
promptPinyin := common.AnnotatePinyin(plan.Prompt)
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := tx.Model(consts.TableSceneNode).Ctx(ctx).
Data(g.Map{"is_entry": 0}).WherePri(plan.EntryID).Update(); err != nil {
return err
}
res, err := tx.Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{
"level_id": levelId, "title": "动动小脑筋",
"content": plan.Prompt, "content_pinyin": promptPinyin,
"node_type": consts.NodeDecision, "interaction_type": kind,
"config": interactionConfigJSON(plan), "result_type": consts.ResultNone,
"is_entry": 1, "sort_order": 1, "status": consts.StatusEnabled,
}).Insert()
if err != nil {
return err
}
nodeId, err := res.LastInsertId()
if err != nil {
return err
}
if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{
"node_id": nodeId, "text": "成功了",
"text_pinyin": common.AnnotatePinyin("成功了"), "next_node_id": plan.EntryID,
"feedback": "真棒!我们看看接下来会发生什么~",
"feedback_pinyin": common.AnnotatePinyin("真棒!我们看看接下来会发生什么~"),
"sort_order": 1, "status": consts.StatusEnabled,
}).Insert(); err != nil {
return err
}
if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{
"node_id": nodeId, "text": "没成功",
"text_pinyin": common.AnnotatePinyin("没成功"), "next_node_id": plan.FailFinalID,
"feedback": "没关系,再仔细观察一下~",
"feedback_pinyin": common.AnnotatePinyin("没关系,再仔细观察一下~"),
"sort_order": 2, "status": consts.StatusEnabled,
}).Insert(); err != nil {
return err
}
return nil
})
}
注意:uniqueInt64 已在 biz/service/strategy.go 定义(同包 service,seed 是不同包)——seed 包内没有 uniqueInt64,用局部实现或改为 biz/service 的(不同包不可见)。在 interaction.go 内自建:
func uniqueInt64(in []int64) []int64 {
seen := make(map[int64]struct{}, len(in))
out := make([]int64, 0, len(in))
for _, v := range in {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
- Step 4: 打开
EnsureSeeded里的ensureInteractions(ctx)调用
修改 seed.go(Task 3 Step 3 留的注释行):
annotateAll(ctx)
ensureInteractions(ctx)
- Step 5: 跑单测确认通过
Run: go test ./biz/service/seed/ -run TestPlanInteraction -v
Expected: PASS(6 个用例全绿)
- Step 6: 编译 + 启动验证(现有库补生成)
Run: go build ./... && go vet ./...
Run: go run main.go;Ctrl-C 停掉后再 sqlite3 data/36wisdom.db 验证:
SELECT COUNT(*) FROM scene_node WHERE interaction_type > 1; -- 应为 36(36 关各 1 个)
SELECT id, interaction_type, is_entry, config FROM scene_node WHERE interaction_type > 1 LIMIT 2;
SELECT text, next_node_id, sort_order FROM node_option WHERE node_id = (SELECT id FROM scene_node WHERE interaction_type > 1 LIMIT 1);
预期:text 为「成功了 / 没成功」两行,sort_order 1/2。
- Step 7: 提交
git add biz/service/seed/interaction.go biz/service/seed/interaction_test.go biz/service/seed/seed.go
git commit -m "feat: 互动入口节点程序化生成(触控5种,成功/失败双出口,choose零改动)"
实现偏差记录(2026-08-13,两轮审查均通过):
- 结构体字段小写化(计划 Step 3 为大写,Step 1 测试用 p.answer/p.items 小写访问)→ 实现按测试为验收规格小写化,并加自定义 MarshalJSON 输出 kind/items/answer 等 JSON 键(encoding/json 忽略未导出字段)
- pairsOf 取"最后一个带道具选项"(计划注释"小明→扫帚22"与"首个"代码自相矛盾,测试断言 22)并修复 persons 恒为空的死代码(seen 预标记 bug);persons 现带 Name
- bestProp==0 时回退 items[0].ID 而非返回 nil(计划测试 FallbackToPropPick 要求 prop_pick 不退关;真实数据无此场景)
- 测试数据 gvar.New 包装:gf v2.10.2 中
gdb.Record = map[string]*gvar.Var(gdb.go:678),gconv.Int64 字面量无法编译 - Step 6 预期修正:互动节点数 = 14 而非 36——种子数据仅 14 关存在带 prop 的选项(其余 22 关零道具),planInteraction 按设计返回 nil 跳过;数据驱动行为,非缺陷。M1.5 收尾时如需 36 关全互动,须先补种子道具数据
- 审查后修复 commit 4744ba9:persons 补名字、seed 缓存一致性注释(启动期无读者不清缓存,同 annotateAll 先例)、元素名查询失败 warn
Task 8 偏差记录(审查后修复 7f7cf49):
- 游戏化 CSS 补全 47 行(进度点/场景横幅/漂浮装饰/连击徽章/角色卡/选项 c1-c4 四色边框/option-icon 定宽高/fb-in 错峰动画/顶部开关)——原计划 style 段缺失这些类导致进度点不可见、素材落地后 image 默认 320×240 撑爆布局
- OptionVO 透出 audio(node_option.audio 列已存在零迁移)——前端"听点评"可播文件,避免静默降级 TTS
Task 9 走查修复(RubyText 布局):
- RubyText 外层 text → view + flex-wrap(原 text>text inline-flex 在 H5 基线错位、小程序 text 组件不支持 flex):拼音不再对齐在汉字正上方。使用处均为独立文本块(scene-text/node-content/option-text/fb-option/learn-*),无行内混排,块级化安全
Task 5: 拼音字段透出(service / dto / controller)
Files:
-
Modify:
biz/service/level.go、biz/service/strategy.go、biz/model/dto/level.go、biz/model/dto/level_play.go、biz/model/dto/strategy.go、biz/controller/level.go、biz/controller/strategy.go -
Step 1: service 层
biz/service/level.go
Element 结构体加字段(第 43-50 行):
type Element struct {
Id int64
EType int
Name string
NamePinyin string
Image string
Audio string
Description string
}
Option 加 TextPinyin string、FeedbackPros string、FeedbackCons string;Node 加 ContentPinyin string;LevelDetail 加 SceneContentPinyin string。
elementsOf 映射处(第 191-198 行)加 NamePinyin: r["name_pinyin"].String()。
buildNode(第 205-227 行)加:
node := &Node{
NodeId: nodeRec["id"].Int64(),
Title: nodeRec["title"].String(),
Content: nodeRec["content"].String(),
ContentPinyin: nodeRec["content_pinyin"].String(),
Image: nodeRec["image"].String(),
...
}
for _, o := range optionRecs {
...
node.Options = append(node.Options, &Option{
OptionId: o["id"].Int64(),
Text: o["text"].String(),
TextPinyin: o["text_pinyin"].String(),
FeedbackPros: o["feedback_pros"].String(),
FeedbackCons: o["feedback_cons"].String(),
Prop: elements[o["prop_id"].Int64()],
})
}
Detail 返回(第 152-163 行)加 SceneContentPinyin: levelRec["scene_content_pinyin"].String()。
- Step 2: service 层
biz/service/strategy.go
StrategyDetail 加 MeaningPinyin string、TeachContentPinyin string;Detail 组装(第 179-190 行)加两个字段映射。
- Step 3: dto 层
biz/model/dto/level.go:ElementVO 加 NamePinyin string \json:"name_pinyin"`;OptionVO加TextPinyin string `json:"text_pinyin"`、FeedbackPros string `json:"feedback_pros"`、FeedbackCons string `json:"feedback_cons"`;NodeVO加ContentPinyin string `json:"content_pinyin"`;LevelDetailRes加SceneContentPinyin string `json:"scene_content_pinyin"``。
biz/model/dto/level_play.go:NodeVO(ChooseRes.Next 用同一类型,在 dto/level.go 定义)——level_play.go 的 ChooseRes 引用 *NodeVO,无需改。FinalSettle 不加拼音。
biz/model/dto/strategy.go:StrategyDetailRes 加 MeaningPinyin string \json:"meaning_pinyin"`、TeachContentPinyin string `json:"teach_content_pinyin"``。
- Step 4: controller 层
biz/controller/level.go:elementVO 加 NamePinyin: e.NamePinyin;nodeVO 加 ContentPinyin: n.ContentPinyin;OptionVO 映射加 TextPinyin: o.TextPinyin、FeedbackPros: o.FeedbackPros、FeedbackCons: o.FeedbackCons;Detail 加 SceneContentPinyin: detail.SceneContentPinyin。
biz/controller/strategy.go:Detail 返回加 MeaningPinyin、TeachContentPinyin 映射。
- Step 5: 编译 + 接口验证
Run: go build ./... && go vet ./...
Run: go run main.go 后:
TOKEN=$(curl -s -X POST localhost:8080/api/parent/login -H 'Content-Type: application/json' -d '{"phone":"13600000000","password":"pass123456"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
curl -s "localhost:8080/api/level/detail?child_id=3&level_id=1" -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys,json
d=json.load(sys.stdin)['data']
print('scene pinyin:', d['scene_content_pinyin'][:60])
n=d['entry']
print('entry type:', n['interaction_type'], '| content pinyin:', n['content_pinyin'][:60])
print('opt1 pinyin:', n['options'][0]['text_pinyin'])
"
预期:三个 pinyin 字段都是 JSON 数组字符串。Ctrl-C 停掉。
- Step 6: 提交
git add biz/service/level.go biz/service/strategy.go biz/model/dto/ biz/controller/level.go biz/controller/strategy.go
git commit -m "feat: 拼音字段透出到内容接口(level/strategy 详情)"
Task 6: 前端基础能力(朗读 / 视觉 / 拼音 / 组件)
Files:
-
Create:
ui-src/src/utils/speech.js、ui-src/src/utils/visual.js、ui-src/src/utils/pinyin.js -
Create:
ui-src/src/components/RubyText.vue、ui-src/src/components/SpeakButton.vue -
Step 1: 朗读
ui-src/src/utils/speech.js(文件优先,无文件浏览器语音降级)
let innerAudio = null
let uttering = false
function playFile(url) {
stopSpeak()
if (!innerAudio) innerAudio = uni.createInnerAudioContext()
innerAudio.stop()
innerAudio.src = url
innerAudio.play()
}
function speakBrowser(text) {
stopSpeak()
// H5 专用:浏览器原生语音,零成本朗读(小程序端后续接 TTS 文件)
if (typeof window === 'undefined' || !window.speechSynthesis) return
const u = new SpeechSynthesisUtterance(text)
u.lang = 'zh-CN'
u.rate = 0.9
u.onend = u.onerror = () => { uttering = false }
window.speechSynthesis.speak(u)
uttering = true
}
// speak 朗读一段文本:有音频文件优先播文件,否则浏览器语音降级
export function speak(text, fileUrl) {
if (!text) return
if (fileUrl) {
playFile(fileUrl)
return
}
speakBrowser(text)
}
export function stopSpeak() {
if (innerAudio) innerAudio.stop()
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.cancel()
}
uttering = false
}
export function isSpeaking() {
return uttering
}
- Step 2: 占位视觉
ui-src/src/utils/visual.js(无美术素材阶段,按名称关键词映射 emoji + 渐变色;后续素材接入只改此表)
const SCENE_MAP = {
幼儿园: { emoji: '🏫', color: '#ffd08a' }, 教室: { emoji: '🏫', color: '#ffd08a' },
公园: { emoji: '🌳', color: '#a8e6a3' }, 花园: { emoji: '🌷', color: '#f4b8d0' },
家里: { emoji: '🏠', color: '#ffb347' }, 小区: { emoji: '🏘️', color: '#c9a7eb' },
操场: { emoji: '⚽', color: '#7ec8e3' }, 球场: { emoji: '⚽', color: '#7ec8e3' },
商店: { emoji: '🏪', color: '#f6d365' }, 超市: { emoji: '🛒', color: '#f6d365' },
游乐场: { emoji: '🎠', color: '#ff9a9e' }, 路上: { emoji: '🛤️', color: '#d5c4ae' },
食堂: { emoji: '🍚', color: '#ffe0ac' }, 图书馆: { emoji: '📚', color: '#b8a6d9' },
海滩: { emoji: '🏖️', color: '#83e3e8' }, 泳池: { emoji: '🏊', color: '#83e3e8' },
外婆家: { emoji: '🏡', color: '#ffd08a' }
}
const PROP_MAP = {
画: '🖼️', 书: '📖', 剪刀: '✂️', 长杆: '🥢', 扫帚: '🧹', 绳子: '🪢',
球: '⚽', 玩具: '🧸', 手机: '📱', 钥匙: '🔑', 雨伞: '☂️', 帽子: '🧢',
凳子: '🪑', 书包: '🎒', 水杯: '🥤', 饼干: '🍪', 铅笔: '✏️', 纸: '📄',
胶水: '🧴', 篮子: '🧺', 喇叭: '📢', 鼓: '🥁', 娃娃: '🎎', 积木: '🧱',
调色盘: '🎨', 蜡笔: '✏️', 电池: '🔋', 瓜子: '🥜', 苹果: '🍎', 小汽车: '🚗'
}
function pick(list, key, fallback) {
for (const k of Object.keys(list)) {
if (key.includes(k)) return list[k]
}
return fallback
}
export function sceneVisual(name) {
const v = pick(SCENE_MAP, name || '', { emoji: '🏞️', color: '#cde8c4' })
return { emoji: v.emoji, color: v.color }
}
export function propVisual(name) {
// PROP_MAP 值是纯字符串 emoji,不能取 .emoji(会得 undefined 恒兜底 🎁)
return { emoji: pick(PROP_MAP, name || '', '🎁'), color: '#fff0e5' }
}
const PERSON_COLORS = ['#ff9a3d', '#ff6b6b', '#4ecdc4', '#6c8cff', '#a58cff', '#ffb347']
export function personVisual(id) {
return { color: PERSON_COLORS[Number(id || 0) % PERSON_COLORS.length] }
}
- Step 3: 拼音解析
ui-src/src/utils/pinyin.js(后端 JSON 数组 + 与原文 zip)
// parsePinyin 后端 *_pinyin 字段(逐字对齐 JSON 数组字符串)→ 数组
export function parsePinyin(raw) {
if (!raw) return []
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr : []
} catch (e) {
return []
}
}
// zipText 原文与拼音逐字 zip:返回 [{c, p}],p 为空串时无注音
export function zipText(text, pinyinArr) {
const chars = Array.from(text || '')
const out = []
for (let i = 0; i < chars.length; i++) {
out.push({ c: chars[i], p: (pinyinArr && pinyinArr[i]) || '' })
}
return out
}
- Step 4: 注音组件
ui-src/src/components/RubyText.vue
<template>
<text class="ruby-wrap" :class="{ dark: dark }">
<text v-for="(ch, i) in pairs" :key="i" class="ruby-unit">
<text v-if="ch.p && show" class="ruby-py">{{ ch.p }}</text>
<text class="ruby-ch">{{ ch.c }}</text>
</text>
</text>
</template>
<script>
import { parsePinyin, zipText } from '../utils/pinyin.js'
export default {
name: 'RubyText',
props: {
text: { type: String, default: '' },
pinyin: { type: String, default: '' }, // 后端原始 JSON 字符串
show: { type: Boolean, default: true }, // 4-6 档显示,6-8 档可关
dark: { type: Boolean, default: false }
},
computed: {
pairs() {
return zipText(this.text, parsePinyin(this.pinyin))
}
}
}
</script>
<style scoped>
.ruby-wrap {
display: inline;
line-height: 1.9;
}
.ruby-unit {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: top;
margin: 0 2rpx;
}
.ruby-py {
font-size: 20rpx;
color: #ff6b35;
line-height: 1.3;
height: 28rpx;
}
.ruby-ch {
font-size: 32rpx;
line-height: 1.5;
}
.dark .ruby-ch {
color: #5b4636;
}
</style>
- Step 5: 朗读按钮
ui-src/src/components/SpeakButton.vue
<template>
<view class="speak-btn" :class="{ on: speaking }" @click="toggle">
<text class="icon">{{ speaking ? '🔊' : '🔈' }}</text>
<text class="label">{{ label }}</text>
</view>
</template>
<script>
import { speak, stopSpeak } from '../utils/speech.js'
export default {
name: 'SpeakButton',
props: {
text: { type: String, default: '' },
file: { type: String, default: '' },
label: { type: String, default: '听一听' }
},
data() {
return { speaking: false, timer: null }
},
methods: {
toggle() {
if (this.speaking) {
clearTimeout(this.timer)
this.timer = null
stopSpeak()
this.speaking = false
return
}
speak(this.text, this.file)
if (this.text || this.file) {
this.speaking = true
this.timer = setTimeout(() => (this.speaking = false), 3000)
}
}
},
beforeUnmount() {
stopSpeak()
}
}
</script>
<style scoped>
.speak-btn {
display: inline-flex;
align-items: center;
gap: 8rpx;
background: #fff0e5;
color: #ff6b35;
border-radius: 40rpx;
padding: 10rpx 28rpx;
font-size: 26rpx;
font-weight: 600;
}
.speak-btn.on {
background: #ffe0c2;
}
.icon {
font-size: 30rpx;
}
</style>
- Step 6: 音效合成
ui-src/src/utils/sound.js(WebAudio 合成短音效,无音频文件依赖;小程序端 WebAudio 受限时静默降级)
// WebAudio 合成音效:点击/正确/失败/完成/金币。小程序端无 window.AudioContext 时全部静默。
let ctx = null
let enabled = true
const stored = uni.getStorageSync('36wisdom_sound')
if (stored !== '') enabled = stored !== 'off'
function ac() {
if (!enabled || typeof window === 'undefined') return null
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext
if (!AC) return null
ctx = new AC()
}
if (ctx.state === 'suspended') ctx.resume()
return ctx
}
function tone(freq, dur, type = 'sine', vol = 0.15, delay = 0) {
const c = ac()
if (!c) return
const t = c.currentTime + delay
const o = c.createOscillator()
const g = c.createGain()
o.type = type
o.frequency.setValueAtTime(freq, t)
g.gain.setValueAtTime(0, t)
g.gain.linearRampToValueAtTime(vol, t + 0.02)
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
o.connect(g).connect(c.destination)
o.start(t)
o.stop(t + dur + 0.05)
}
export function playSound(name) {
switch (name) {
case 'click': tone(520, 0.08, 'sine', 0.1); break
case 'good': tone(660, 0.12); tone(880, 0.18, 'sine', 0.14, 0.09); break
case 'bad': tone(220, 0.18, 'sine', 0.1); tone(180, 0.22, 'sine', 0.08, 0.12); break
case 'coin': tone(1046, 0.07, 'square', 0.06); tone(1568, 0.16, 'square', 0.06, 0.07); break
case 'finish': tone(523, 0.12); tone(659, 0.12, 'sine', 0.14, 0.1); tone(784, 0.2, 'sine', 0.14, 0.2); break
case 'pop': tone(880, 0.06, 'triangle', 0.12); break
}
}
export function setSoundEnabled(on) {
enabled = on
uni.setStorageSync('36wisdom_sound', on ? 'on' : 'off')
}
export function isSoundEnabled() {
return enabled
}
- Step 7: H5 编译检查
Run: cd ui-src && npm run dev:h5(后台),curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/RubyText.vue 返回 200。
- Step 8: 提交
cd .. && git add ui-src/src/utils/ ui-src/src/components/RubyText.vue ui-src/src/components/SpeakButton.vue
git commit -m "feat: 前端朗读/占位视觉/拼音注音/音效合成基础组件"
Task 7: 触控互动组件 5 个
Files:
- Create:
ui-src/src/components/interactions/PropPick.vue、FindSpot.vue、DragPlace.vue、StepSort.vue、LinkMatch.vue
统一约定:props 为 config(后端 scene_node.config JSON 字符串);判定完成后 this.$emit('done', { success });失败给一次重试机会(重试次数内不发射 done,超限发射 success=false)。
- Step 1: 道具选择
PropPick.vue(点选道具,点中 answer 即成功)
<template>
<view class="prop-pick">
<view class="hint">点一点,选一个道具</view>
<view class="items">
<view
v-for="(it, i) in items"
:key="it.id"
class="item"
:class="{ picked: picked === i, win: winIdx === i, wrong: wrongIdx === i }"
@click="pick(it, i)"
>
<view class="item-icon" :style="{ background: '#fff0e5' }">{{ propVisual(it.name).emoji }}</view>
<view class="item-name">{{ it.name }}</view>
</view>
</view>
<view v-if="wrongIdx !== -1" class="wrong-tip">再想想,看看情境里发生了什么~</view>
</view>
</template>
<script>
import { propVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'PropPick',
props: { config: { type: String, default: '' } },
data() {
return { picked: -1, wrongIdx: -1, winIdx: -1, retries: 0, done: false }
},
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'prop_pick', items: [], answer: 0 } }
},
items() { return this.cfg.items || [] }
},
methods: {
propVisual,
pick(it, i) {
if (this.done) return
if (it.id === this.cfg.answer) {
this.picked = i
this.winIdx = i
this.wrongIdx = -1
this.done = true
playSound('good')
this.$emit('done', { success: true })
} else {
this.retries++
this.wrongIdx = i
this.picked = -1
playSound('bad')
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.items { display: flex; gap: 24rpx; flex-wrap: wrap; justify-content: center; }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); transition: transform .12s ease, box-shadow .12s ease; }
.item:active { transform: scale(.92); }
.item.picked { border: 4rpx solid #ff9a3d; }
.item.win { transform: scale(1.08); border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.item.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
.item-icon { width: 88rpx; height: 88rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; font-size: 48rpx; margin-bottom: 12rpx; }
.item-name { font-size: 28rpx; color: #5b4636; font-weight: 600; }
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.wrong-tip { text-align: center; color: #ff6b6b; margin-top: 24rpx; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>
- Step 2: 找线索
FindSpot.vue(道具卡散布在"场景"里,点击寻找,点中 answer 即成功;视觉与 PropPick 同构,排布错落)
<template>
<view class="find-spot">
<view class="scene" :style="{ background: scene.color + '55' }">
<view
v-for="(it, i) in items"
:key="it.id"
class="spot"
:style="pos(i)"
:class="{ win: foundIdx === i }"
@click="tap(it, i)"
>
<view class="spot-icon">{{ propVisual(it.name).emoji }}</view>
</view>
<view class="scene-emoji">{{ scene.emoji }}</view>
</view>
<view class="hint">场景里藏着线索,点一点找到它!</view>
</view>
</template>
<script>
import { propVisual, sceneVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'FindSpot',
props: { config: { type: String, default: '' } },
data() { return { retries: 0, found: false, done: false, foundIdx: -1 } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'find_spot', items: [], answer: 0 } }
},
items() { return this.cfg.items || [] },
scene() { return sceneVisual('场景') }
},
methods: {
propVisual,
pos(i) {
// 5 个固定散布位置(左上/右上/中/左下/右下),按 index 轮转
const spots = [
{ left: '8%', top: '18%' }, { left: '58%', top: '12%' }, { left: '30%', top: '45%' },
{ left: '10%', top: '62%' }, { left: '55%', top: '60%' }
]
return spots[i % spots.length]
},
tap(it, i) {
if (this.found || this.done) return
if (it.id === this.cfg.answer) {
this.found = true
this.done = true
this.foundIdx = i
playSound('good')
this.$emit('done', { success: true })
} else {
this.retries++
playSound('bad')
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.scene { position: relative; height: 420rpx; border-radius: 28rpx; margin-bottom: 24rpx; }
.scene-emoji { position: absolute; right: 20rpx; top: 10rpx; font-size: 120rpx; opacity: .35; }
.spot { position: absolute; width: 100rpx; height: 100rpx; border-radius: 24rpx; background: #fff; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.18); display: flex; align-items: center; justify-content: center; transition: transform .12s ease; }
.spot:active { transform: scale(.92); }
.spot.win { transform: scale(1.08); border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.spot-icon { font-size: 52rpx; }
.hint { text-align: center; color: #a08c74; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
</style>
- Step 3: 拖拽放置
DragPlace.vue(touch 拖道具到目标框:命中且道具=answer 成功,未命中/道具错 重试)
<template>
<view class="drag-place">
<view class="target" :class="{ win: winDrop }" :style="{ background: over ? '#ffe0c2' : '#f8f2e8' }">
<text class="target-text">{{ over ? '放这里!' : '把道具拖到这里' }}</text>
</view>
<view class="items">
<view
v-for="(it, i) in items"
:key="it.id"
class="item"
:class="{ dragging: dragIdx === i }"
@touchstart="onStart($event, i)"
@touchmove.prevent="onMove"
@touchend="onEnd(it, i)"
>
<view class="item-icon">{{ propVisual(it.name).emoji }}</view>
<view class="item-name">{{ it.name }}</view>
</view>
</view>
</view>
</template>
<script>
import { propVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'DragPlace',
props: { config: { type: String, default: '' } },
data() { return { dragIdx: -1, over: false, retries: 0, done: false, winDrop: false, target: null } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'drag_place', items: [], answer: 0 } }
},
items() { return this.cfg.items || [] }
},
methods: {
propVisual,
onStart(e, i) {
if (this.done) return
this.dragIdx = i
// 拿起时量一次目标框实际位置;touchmove 里纯坐标比较,避免逐帧调系统 API
uni.createSelectorQuery().in(this).select('.target').boundingClientRect(rect => {
this.target = rect || null
}).exec()
},
onMove(e) {
if (this.dragIdx === -1) return
const t = e.touches && e.touches[0]
const r = this.target
if (!t || !r) return
this.over = t.clientX >= r.left && t.clientX <= r.right && t.clientY >= r.top && t.clientY <= r.bottom
},
onEnd(it, i) {
if (this.done || this.dragIdx === -1) return
this.dragIdx = -1
const hit = this.over
this.over = false
if (hit && it.id === this.cfg.answer) {
this.done = true
this.winDrop = true
playSound('good')
this.$emit('done', { success: true })
} else if (hit) {
this.retries++
playSound('bad')
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.target { height: 220rpx; border-radius: 28rpx; border: 4rpx dashed #d5c4ae; display: flex; align-items: center; justify-content: center; margin-bottom: 48rpx; transition: transform .12s ease, box-shadow .12s ease; }
.target.win { transform: scale(1.05); border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.target-text { color: #a08c74; font-size: 30rpx; }
.items { display: flex; gap: 24rpx; justify-content: center; }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; transition: transform .12s ease; }
.item:active { transform: scale(.92); }
.item.dragging { opacity: .5; transform: scale(1.05); }
.item-icon { font-size: 52rpx; margin-bottom: 8rpx; }
.item-name { font-size: 26rpx; color: #5b4636; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.05); } }
</style>
- Step 4: 步骤排序
StepSort.vue(touch 拖拽排序:touchstart 拿起、touchmove 交换、touchend 判定全序 == answer_order)
<template>
<!-- touchmove 挂容器:touch 事件的 target 恒为 touchstart 元素,须按手指坐标判定目标槽位 -->
<view class="step-sort" @touchmove.prevent="onMove($event)">
<view class="hint">按住道具拖一拖,按顺序排好</view>
<view
v-for="(it, i) in items"
:key="it.id"
class="slot"
:class="{ over: overIdx === i, dragging: dragIdx === i, win: allWin }"
@touchstart="onStart($event, i)"
@touchend="onEnd"
>
<view class="order-no">{{ i + 1 }}</view>
<view class="slot-icon">{{ propVisual(it.name).emoji }}</view>
<view class="slot-name">{{ it.name }}</view>
</view>
<view v-if="wrong" class="wrong-tip">顺序不对哦,再试试~</view>
</view>
</template>
<script>
import { propVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'StepSort',
props: { config: { type: String, default: '' } },
data() { return { order: [], dragIdx: -1, overIdx: -1, retries: 0, wrong: false, done: false, allWin: false, rects: [] } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'step_sort', items: [], answer_order: [] } }
},
items() { return this.order }
},
created() {
// 初始乱序:拷贝 items 后随机打乱(保证不是答案顺序)
const arr = (this.cfg.items || []).slice()
const ans = this.cfg.answer_order || []
// 洗牌直至与答案不同(至少 2 项时几乎必然不同)
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
if (ans.length > 1 && arr.every((it, i) => it.id === ans[i])) {
;[arr[0], arr[1]] = [arr[1], arr[0]]
}
this.order = arr
},
methods: {
propVisual,
onStart(e, i) {
if (this.done) return
this.dragIdx = i
// 拿起时量一次所有槽位位置,touchmove 按坐标判定
uni.createSelectorQuery().in(this).selectAll('.slot').boundingClientRect(rects => {
this.rects = rects || []
}).exec()
},
onMove(e) {
if (this.dragIdx === -1) return
const t = e.touches && e.touches[0]
if (!t || !this.rects.length) return
let idx = -1
for (let i = 0; i < this.rects.length; i++) {
const r = this.rects[i]
if (t.clientY >= r.top && t.clientY <= r.bottom) { idx = i; break }
}
this.overIdx = idx
},
onEnd() {
if (this.done) return
if (this.dragIdx === -1 || this.overIdx === -1) { this.dragIdx = -1; this.overIdx = -1; return }
const arr = this.order.slice()
;[arr[this.dragIdx], arr[this.overIdx]] = [arr[this.overIdx], arr[this.dragIdx]]
this.order = arr
this.dragIdx = -1
this.overIdx = -1
this.check()
},
check() {
const ans = this.cfg.answer_order || []
if (this.order.length !== ans.length) return
const ok = this.order.every((it, i) => it.id === ans[i])
if (ok) {
this.done = true
this.allWin = true
playSound('good')
this.$emit('done', { success: true })
} else {
this.wrong = true
this.retries++
playSound('bad')
setTimeout(() => (this.wrong = false), 800)
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.slot { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); transition: transform .12s ease; }
.slot:active { transform: scale(.98); }
.slot.over { border: 4rpx solid #ff9a3d; }
.slot.dragging { opacity: .6; }
.slot.win { border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.order-no { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; }
.slot-icon { font-size: 44rpx; margin-right: 20rpx; }
.slot-name { font-size: 30rpx; color: #5b4636; font-weight: 600; }
.wrong-tip { text-align: center; color: #ff6b6b; margin-top: 20rpx; }
@keyframes win-pulse { 0% { transform: scale(.96); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
</style>
- Step 5: 连线配对
LinkMatch.vue(SVG 画线:左列人物、右列道具,先点人物再点道具成对;全部配完且与 pairs 一致成功)
<template>
<view class="link-match">
<view class="hint">先点一个人物,再点它用的道具</view>
<view class="board">
<view class="col persons">
<view
v-for="p in persons"
:key="p.id"
class="node person"
:class="{ selected: selPerson === p.id, win: winPair }"
@click="tapPerson(p)"
>
<view class="node-avatar" :style="{ background: personVisual(p.id).color }">{{ (p.name || '?').slice(0, 1) }}</view>
<view class="node-name">{{ p.name }}</view>
</view>
</view>
<view class="lines">
<svg width="100%" height="100%">
<line
v-for="(m, i) in matches"
:key="i"
:x1="5" :y1="lineY(i)" :x2="95" :y2="lineY(i)"
stroke="#ff9a3d" stroke-width="4" stroke-linecap="round"
/>
</svg>
</view>
<view class="col items">
<view
v-for="(it, i) in items"
:key="it.id"
class="node item"
:class="{ selected: selItem === it.id, win: winPair }"
@click="tapItem(it)"
>
<view class="node-icon">{{ propVisual(it.name).emoji }}</view>
<view class="node-name">{{ it.name }}</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { propVisual, personVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'LinkMatch',
props: { config: { type: String, default: '' } },
data() { return { selPerson: 0, selItem: 0, matches: [], retries: 0, done: false, winPair: false } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'link_match', persons: [], items: [], pairs: [] } }
},
persons() { return this.cfg.persons || [] },
items() { return this.cfg.items || [] }
},
methods: {
propVisual,
personVisual,
lineY(i) {
// 在 12%-88% 区间按索引线性插值,避免固定步长在 i≥1 时越出视口
const n = Math.max(this.persons.length - 1, 1)
return 12 + (i / n) * 76 + '%'
},
tapPerson(p) {
if (this.done) return
this.selPerson = this.selPerson === p.id ? 0 : p.id
this.tryMatch()
},
tapItem(it) {
if (this.done) return
this.selItem = this.selItem === it.id ? 0 : it.id
this.tryMatch()
},
tryMatch() {
if (!this.selPerson || !this.selItem) return
const pair = [this.selPerson, this.selItem]
const ans = this.cfg.pairs || []
// 已配对的组合重复点选不重复计数(否则同对可反复刷到 done)
if (this.matches.some(([p, i]) => p === pair[0] && i === pair[1])) {
this.selPerson = 0
this.selItem = 0
return
}
const correct = ans.some(([p, i]) => p === pair[0] && i === pair[1])
if (correct) {
this.matches.push(pair)
this.selPerson = 0
this.selItem = 0
if (this.matches.length === ans.length) {
this.done = true
this.winPair = true
playSound('good')
this.$emit('done', { success: true })
}
} else {
this.retries++
this.selPerson = 0
this.selItem = 0
playSound('bad')
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.board { display: flex; justify-content: space-between; align-items: stretch; }
.col { display: flex; flex-direction: column; gap: 24rpx; width: 36%; }
.lines { width: 24%; position: relative; min-height: 400rpx; }
.node { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 16rpx 20rpx; transition: transform .12s ease; }
.node:active { transform: scale(.95); }
.node.selected { border: 4rpx solid #ff9a3d; }
.node.win { border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.node-avatar { width: 64rpx; height: 64rpx; border-radius: 50%; color: #fff; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; }
.node-icon { font-size: 44rpx; margin-right: 16rpx; }
.node-name { font-size: 28rpx; color: #5b4636; }
@keyframes win-pulse { 0% { transform: scale(.95); } 50% { transform: scale(1.08); } 100% { transform: scale(1); } }
</style>
- Step 6: H5 编译检查
Run: curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/interactions/PropPick.vue 等 5 个组件返回 200。
- Step 7: 触控皮肤统一升级(游戏化:按压回弹 + 成功脉冲 / 失败 shake + 音效。逐组件改,共 5 处)
每个组件 3 处修改(类名见下表,按各组件实际类名替换):
① 引入音效:script 顶部加 import { playSound } from '../../utils/sound.js';成功判定处(this.$emit('done', { success: true }) 前)加 playSound('good');错误判定处(重试分支)加 playSound('bad')。
② 成功/失败视觉态:成功时对应元素加 .win 类(脉冲放大 + 金色发光);错误时错误元素加 .wrong 类(shake 动画)。
③ style 末尾追加(类名按上表替换,<style scoped> 内):
.item, .spot, .node, .prop-block, .step-item { transition: transform .12s ease, box-shadow .12s ease; }
.item:active, .spot:active, .node:active, .prop-block:active { transform: scale(.92); }
.item.win, .spot.win, .node.win, .prop-block.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.item.wrong, .spot.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
各组件类名对照(组件内已存在的类名,只加 .win / .wrong 态与 :active):
| 组件 | 元素类名 | 成功挂 .win 位置 |
|---|---|---|
| PropPick | .item |
pick() 命中分支:this.picked = i 后给该元素加 win(模板 :class="{ picked: picked === i, win: winIdx === i }",data 加 winIdx: -1) |
| FindSpot | .spot |
tap() 命中分支:data 加 foundIdx: -1,模板 :class="{ win: foundIdx === i }" |
| DragPlace | .prop-block |
命中目标分支:data 加 winDrop: false,目标框 :class="{ win: winDrop }" |
| StepSort | .step-item |
排完正确分支:data 加 allWin: false,每个 step-item :class="{ win: allWin }" |
| LinkMatch | .node |
配对正确分支:data 加 winPair: false,被配对两侧节点 :class="{ win: winPair }" |
(若某组件无对应元素类名,按语义选主交互元素替换;本步骤不改交互逻辑,只加视觉态与音效。)
- Step 8: 提交
git add ui-src/src/components/interactions/
git commit -m "feat: 触控互动组件5种(道具选择/找线索/拖拽放置/步骤排序/连线配对)+ 游戏化皮肤"
Task 8: play.vue 集成(场景 / 拼音 / 朗读 / 互动分发)+ map.vue 学堂
Files:
-
Modify:
ui-src/src/pages/play/play.vue、ui-src/src/pages/map/map.vue、ui-src/src/pages/children/children.vue、ui-src/src/store/user.js -
Step 1: store 记录孩子年龄段(
ui-src/src/store/user.js追加)
const AGE_KEY = '36wisdom_child_age'
export function getChildAge() {
return uni.getStorageSync(AGE_KEY) || '4-6'
}
export function setChildAge(age) {
uni.setStorageSync(AGE_KEY, age)
}
ui-src/src/pages/children/children.vue 的 enter(c) 与 create 成功处追加 setChildAge(c.age_group) / setChildAge(this.form.ageGroup),并 import。
- Step 2: 重写
ui-src/src/pages/play/play.vue
完整替换(场景面板 + 互动分发 + 拼音注音 + 朗读按钮 + 反馈面板):
<template>
<view class="page">
<view class="topbar">
<view class="back" @click="goBack">‹</view>
<view class="top-title">{{ detail ? detail.title : '' }}</view>
<view class="top-actions">
<view class="py-toggle" @click="showPinyin = !showPinyin">{{ showPinyin ? '拼音开' : '拼音关' }}</view>
<view class="sound-toggle" @click="toggleSound">{{ soundOn ? '🔊' : '🔇' }}</view>
</view>
</view>
<view v-if="detail" class="content">
<!-- 进度点:每走一步点亮一个 -->
<view class="progress-dots">
<view v-for="i in 6" :key="i" class="dot" :class="{ on: i <= stepCount }"></view>
</view>
<!-- 场景面板:环境 + 情境描述 + 朗读 + 漂浮装饰 -->
<view class="scene-card card" :style="{ background: scene.color + '44' }">
<image v-if="sceneImg" class="scene-img" :src="sceneImg" mode="aspectFill" />
<view class="scene-float f1">☁️</view>
<view class="scene-float f2">⭐</view>
<view class="scene-head">
<view class="scene-emoji">{{ scene.emoji }}</view>
<view class="scene-name">{{ detail.scene ? detail.scene.name : '' }}</view>
<view v-if="combo > 1" class="combo-badge">🔥 ×{{ combo }}</view>
</view>
<RubyText class="scene-text" :text="detail.scene_content" :pinyin="detail.scene_content_pinyin" :show="showPinyin" />
<SpeakButton class="scene-speak" :text="detail.scene_content" :file="detail.scene_audio" label="读一读情境" />
</view>
<!-- 节点:互动分发 -->
<view v-if="curNode" class="node card">
<view class="node-title">{{ curNode.title }}</view>
<view v-if="curNode.character" class="character">
<view class="char-card">
<image v-if="charImg" class="char-img" :src="charImg" mode="aspectFit" />
<view v-else class="char-avatar" :style="{ background: personColor(curNode.character.id) }">{{ curNode.character.name.slice(0, 1) }}</view>
<view class="char-mood">{{ mood }}</view>
</view>
<view class="char-info">
<view class="char-name">{{ curNode.character.name }}</view>
<SpeakButton :text="curNode.character.name" :file="curNode.character.audio" label="读一读" />
</view>
</view>
<view class="node-content">
<RubyText :text="curNode.content" :pinyin="curNode.content_pinyin" :show="showPinyin" />
</view>
<SpeakButton class="node-speak" :text="curNode.content" :file="curNode.audio" label="读一读" />
<!-- 互动组件(触控 5 种) -->
<PropPick v-if="curNode.interaction_type === 2" :config="curNode.config" @done="interactionDone" />
<FindSpot v-else-if="curNode.interaction_type === 7" :config="curNode.config" @done="interactionDone" />
<DragPlace v-else-if="curNode.interaction_type === 5" :config="curNode.config" @done="interactionDone" />
<StepSort v-else-if="curNode.interaction_type === 3" :config="curNode.config" @done="interactionDone" />
<LinkMatch v-else-if="curNode.interaction_type === 8" :config="curNode.config" @done="interactionDone" />
<!-- 选项选择(type 1) -->
<view v-else-if="curNode.interaction_type === 1" class="options">
<view
v-for="(o, idx) in curNode.options"
:key="o.option_id"
class="option"
:class="['c' + (idx + 1), { disabled: choosing }]"
@click="choose(o, idx)"
>
<view class="option-mark">{{ ['A', 'B', 'C', 'D'][idx] }}</view>
<view v-if="o.prop" class="option-icon">
<image v-if="o.prop.image" class="option-icon-img" :src="o.prop.image" mode="aspectFit" />
<text v-else class="option-icon-emoji">{{ propEmoji(o.prop.name) }}</text>
</view>
<view class="option-text">
<RubyText :text="o.text" :pinyin="o.text_pinyin" :show="showPinyin" />
</view>
</view>
</view>
<view v-else class="soon card">该互动玩法(类型 {{ curNode.interaction_type }})敬请期待</view>
</view>
</view>
<!-- 反馈面板:你的选择 + 好处 + 对比(对比点评三段式) -->
<view v-if="feedback" class="mask">
<view class="feedback card">
<view class="fb-title">你的选择</view>
<view class="fb-option"><RubyText :text="feedback.text" :pinyin="feedback.textPinyin" :show="showPinyin" /></view>
<view v-if="feedback.pros" class="fb-line fb-pros fb-in"><text class="fb-mark">✓</text>{{ feedback.pros }}</view>
<view v-if="feedback.cons" class="fb-line fb-cons fb-in d2"><text class="fb-mark">✗</text>{{ feedback.cons }}</view>
<view v-if="feedback.prop" class="fb-prop">
<view class="fb-prop-name">{{ feedback.prop.name }}</view>
<view v-if="feedback.prop.description" class="fb-prop-desc">{{ feedback.prop.description }}</view>
</view>
<SpeakButton :text="feedback.text" :file="feedback.audio" label="听点评" />
<view class="btn-primary fb-continue" @click="continuePlay">继续 ›</view>
</view>
</view>
</view>
</template>
<script>
import { api } from '../../api/index.js'
import { getChildId, getChildAge } from '../../store/user.js'
import { sceneVisual, personVisual, propVisual } from '../../utils/visual.js'
import { playSound, isSoundEnabled, setSoundEnabled } from '../../utils/sound.js'
import RubyText from '../../components/RubyText.vue'
import SpeakButton from '../../components/SpeakButton.vue'
import PropPick from '../../components/interactions/PropPick.vue'
import FindSpot from '../../components/interactions/FindSpot.vue'
import DragPlace from '../../components/interactions/DragPlace.vue'
import StepSort from '../../components/interactions/StepSort.vue'
import LinkMatch from '../../components/interactions/LinkMatch.vue'
const RESULT_KEY = '36wisdom_result'
export default {
components: { RubyText, SpeakButton, PropPick, FindSpot, DragPlace, StepSort, LinkMatch },
data() {
return {
childId: getChildId(),
levelId: 0,
detail: null,
curNode: null,
feedback: null,
choosing: false,
showPinyin: getChildAge() === '4-6',
routeSteps: [],
mood: '🤔',
combo: 0,
soundOn: isSoundEnabled()
}
},
onLoad(options) {
this.levelId = Number(options.level_id || 0)
this.load()
},
computed: {
scene() {
return sceneVisual(this.detail && this.detail.scene ? this.detail.scene.name : '')
},
sceneImg() {
return this.detail && this.detail.scene && this.detail.scene.image ? this.detail.scene.image : ''
},
charImg() {
return this.curNode && this.curNode.character && this.curNode.character.image ? this.curNode.character.image : ''
},
stepCount() {
return Math.min(this.routeSteps.length + 1, 6)
}
},
methods: {
async load() {
try {
const data = await api.levelDetail(this.childId, this.levelId)
this.detail = data
this.curNode = data.entry
} catch (e) {
setTimeout(() => this.goBack(), 800)
}
},
goBack() {
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/map/map' }) })
},
personColor(id) {
return personVisual(id).color
},
// 触控互动判定完成:提交成功/失败出口选项
interactionDone({ success }) {
this.mood = success ? '😊' : '😢'
const opt = success ? this.curNode.options[0] : this.curNode.options[1]
if (opt) this.submit(opt)
},
propEmoji(name) {
return propVisual(name).emoji
},
toggleSound() {
this.soundOn = !this.soundOn
setSoundEnabled(this.soundOn)
},
choose(o) {
if (this.choosing) return
playSound('click')
this.submit(o)
},
async submit(o) {
this.choosing = true
try {
const data = await api.levelChoose(this.childId, this.levelId, this.curNode.node_id, o.option_id)
this.routeSteps.push({ text: o.text, pros: o.feedback_pros || '' })
if (data.next) {
this.feedback = {
text: o.text,
textPinyin: o.text_pinyin,
prop: o.prop,
audio: o.audio,
pros: o.feedback_pros,
cons: o.feedback_cons,
next: data.next
}
this.mood = o.feedback_cons ? '😢' : '😊'
this.combo = o.feedback_cons ? 0 : Math.min(this.combo + 1, 9)
playSound(o.feedback_cons ? 'bad' : 'good')
} else if (data.final) {
uni.setStorageSync(RESULT_KEY, {
level_id: this.levelId,
title: this.detail.title,
scene_name: this.detail.scene ? this.detail.scene.name : '',
route_steps: this.routeSteps,
final: data.final
})
playSound('finish')
uni.redirectTo({ url: '/pages/result/result' })
}
} catch (e) {
} finally {
this.choosing = false
}
},
continuePlay() {
this.curNode = this.feedback.next
this.feedback = null
this.mood = '🤔'
}
}
}
</script>
<style scoped>
.page { min-height: 100vh; display: flex; flex-direction: column; background: #fdf6ec; }
.topbar { display: flex; align-items: center; padding: 24rpx 32rpx; padding-top: calc(24rpx + env(safe-area-inset-top)); }
.back { font-size: 56rpx; color: #ff6b35; width: 72rpx; height: 72rpx; display: flex; align-items: center; }
.top-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 700; color: #5b4636; }
.top-actions { display: flex; align-items: center; gap: 16rpx; }
.py-toggle { font-size: 24rpx; color: #ff6b35; background: #fff0e5; border-radius: 30rpx; padding: 8rpx 20rpx; }
.sound-toggle { font-size: 32rpx; background: #fff0e5; border-radius: 50%; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; }
.content { flex: 1; padding: 24rpx 32rpx 60rpx; }
.progress-dots { display: flex; justify-content: center; gap: 16rpx; margin-bottom: 24rpx; }
.dot { width: 20rpx; height: 20rpx; border-radius: 50%; background: #e5d9c8; transition: all 0.3s; }
.dot.on { background: #ff9a3d; transform: scale(1.25); box-shadow: 0 0 0 6rpx rgba(255, 154, 61, 0.2); }
.scene-card { padding: 32rpx; margin-bottom: 24rpx; position: relative; overflow: hidden; }
.scene-img { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 24rpx; }
.scene-float { position: absolute; font-size: 44rpx; opacity: 0.85; animation: float-y 3.5s ease-in-out infinite; pointer-events: none; }
.scene-float.f1 { top: 16rpx; right: 28rpx; }
.scene-float.f2 { bottom: 20rpx; left: 24rpx; animation-delay: 1.2s; }
@keyframes float-y { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-14rpx); } }
.scene-head { display: flex; align-items: center; margin-bottom: 16rpx; position: relative; z-index: 1; }
.scene-emoji { font-size: 64rpx; margin-right: 20rpx; }
.scene-name { font-size: 32rpx; font-weight: 800; color: #5b4636; }
.combo-badge { margin-left: auto; background: #ff6b35; color: #fff; font-weight: 800; border-radius: 30rpx; padding: 8rpx 24rpx; font-size: 28rpx; animation: combo-pop 0.4s ease; }
@keyframes combo-pop { 0% { transform: scale(0.4); } 60% { transform: scale(1.2); } 100% { transform: scale(1); } }
.scene-text { margin-bottom: 20rpx; position: relative; z-index: 1; }
.scene-speak { margin-top: 8rpx; position: relative; z-index: 1; }
.node { padding: 48rpx 40rpx; }
.node-title { font-size: 38rpx; font-weight: 800; color: #ff6b35; margin-bottom: 24rpx; }
.character { display: flex; align-items: center; margin-bottom: 24rpx; }
.char-card { position: relative; margin-right: 20rpx; flex-shrink: 0; }
.char-img { width: 96rpx; height: 96rpx; border-radius: 50%; background: #f8f2e8; }
.char-avatar { width: 96rpx; height: 96rpx; border-radius: 50%; color: #fff; font-size: 40rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.char-mood { position: absolute; right: -8rpx; bottom: -8rpx; font-size: 36rpx; background: #fff; border-radius: 50%; width: 48rpx; height: 48rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(91, 70, 54, 0.2); animation: mood-pop 0.3s ease; }
@keyframes mood-pop { 0% { transform: scale(0.4); } 100% { transform: scale(1); } }
.char-info { display: flex; flex-direction: column; gap: 8rpx; }
.char-name { font-size: 30rpx; font-weight: 700; color: #7a6248; }
.node-content { font-size: 32rpx; line-height: 1.9; color: #5b4636; margin-bottom: 24rpx; }
.node-speak { margin-bottom: 32rpx; }
.options { display: flex; flex-direction: column; gap: 20rpx; }
.option { display: flex; align-items: center; background: #f8f2e8; border: 6rpx solid transparent; border-radius: 24rpx; padding: 26rpx 28rpx; }
.option.disabled { opacity: 0.6; }
.option.c1 { border-color: #ff9a3d; }
.option.c2 { border-color: #4ecdc4; }
.option.c3 { border-color: #6c8cff; }
.option.c4 { border-color: #a58cff; }
.option:active { transform: scale(0.98); }
.option-mark { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.option-icon { width: 88rpx; height: 88rpx; background: #fff7ec; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.option-icon-img { width: 64rpx; height: 64rpx; }
.option-icon-emoji { font-size: 48rpx; }
.option-text { font-size: 30rpx; font-weight: 600; color: #5b4636; flex: 1; }
.soon { padding: 40rpx; text-align: center; color: #a08c74; background: #f8f2e8; }
.mask { position: fixed; inset: 0; background: rgba(91, 70, 54, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10; }
.feedback { width: 82%; padding: 48rpx 40rpx; }
.fb-title { font-size: 26rpx; color: #a08c74; margin-bottom: 16rpx; }
.fb-option { font-size: 34rpx; font-weight: 800; color: #ff6b35; margin-bottom: 24rpx; }
.fb-line { font-size: 28rpx; line-height: 1.7; border-radius: 16rpx; padding: 16rpx 24rpx; margin-bottom: 16rpx; }
.fb-in { opacity: 0; animation: fb-in 0.4s ease forwards; }
.fb-in.d2 { animation-delay: 0.2s; }
@keyframes fb-in { from { opacity: 0; transform: translateY(16rpx); } to { opacity: 1; transform: translateY(0); } }
.fb-pros { background: #e8f7f3; color: #1fa98f; }
.fb-cons { background: #fff0e5; color: #e07b39; }
.fb-mark { font-weight: 800; margin-right: 8rpx; }
.fb-prop { background: #fff7ec; border-radius: 20rpx; padding: 24rpx 28rpx; margin-bottom: 32rpx; }
.fb-prop-name { font-size: 30rpx; font-weight: 700; color: #7a6248; margin-bottom: 8rpx; }
.fb-prop-desc { font-size: 26rpx; line-height: 1.7; color: #a08c74; }
.fb-continue { margin-top: 24rpx; font-size: 30rpx; }
</style>
- Step 3: 后端 OptionVO 透出 audio(
node_option.audio列已存在,零迁移;支撑"听点评"文件朗读而非 TTS 降级)
biz/service/level.go:Option 结构体加 Audio string 字段,buildNode 内选项组装处追加 Audio: o["audio"].String():
type Option struct {
OptionId int64
Text string
TextPinyin string
FeedbackPros string
FeedbackCons string
Audio string
Prop *Element
}
buildNode 的 node.Options = append(...) 处改为:
node.Options = append(node.Options, &Option{
OptionId: o["id"].Int64(),
Text: o["text"].String(),
TextPinyin: o["text_pinyin"].String(),
FeedbackPros: o["feedback_pros"].String(),
FeedbackCons: o["feedback_cons"].String(),
Audio: o["audio"].String(),
Prop: elements[o["prop_id"].Int64()],
})
biz/model/dto/level.go:OptionVO 加字段:
type OptionVO struct {
OptionId int64 `json:"option_id"`
Text string `json:"text"`
TextPinyin string `json:"text_pinyin"`
FeedbackPros string `json:"feedback_pros"`
FeedbackCons string `json:"feedback_cons"`
Audio string `json:"audio"`
Prop *ElementVO `json:"prop"`
}
biz/controller/level.go:nodeVO 的选项循环追加映射:
vo.Options = append(vo.Options, dto.OptionVO{
OptionId: o.OptionId,
Text: o.Text,
TextPinyin: o.TextPinyin,
FeedbackPros: o.FeedbackPros,
FeedbackCons: o.FeedbackCons,
Audio: o.Audio,
Prop: elementVO(o.Prop),
})
- Step 4: map.vue 学堂弹层增强(拼音注音 + 朗读)
在 <script> 加 import:RubyText、SpeakButton、getChildAge;<template> 的 learn-meaning 与 learn-content 替换为:
<view class="learn-meaning"><RubyText :text="strategy.meaning" :pinyin="strategy.meaning_pinyin" :show="showPinyin" /></view>
<view class="learn-content"><RubyText :text="strategy.teach_content" :pinyin="strategy.teach_content_pinyin" :show="showPinyin" /></view>
<SpeakButton :text="strategy.teach_content" :file="strategy.teach_audio" label="读一读学堂" />
data 加 showPinyin: getChildAge() === '4-6';components: { RubyText, SpeakButton };原 playAudio 方法删除(SpeakButton 接管)。
- Step 5: H5 编译检查
Run: curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/pages/play/play.vue 返回 200;同样检查 map.vue、children.vue;后端 go build ./... && go vet ./... 通过。
- Step 6: 提交
git add ui-src/src/pages/play/play.vue ui-src/src/pages/map/map.vue ui-src/src/pages/children/children.vue ui-src/src/store/user.js biz/service/level.go biz/model/dto/level.go biz/controller/level.go
git commit -m "feat: 闯关页场景/拼音/朗读/互动分发集成(含游戏化样式),学堂弹层注音朗读,OptionVO 透出 audio"
Task 9: 端到端验证(拼音 / 互动 / 朗读 / 点评)+ 阶段性提交
Files:
-
none(验证)
-
Step 1: 后端全量检查
Run: go build ./... && go vet ./... && go test ./...
Expected: 全部通过(含既有 level_play 10 个单测 + 新增 pinyin 4 个 + interaction 6 个)
- Step 2: 后端接口冒烟(互动节点 + 拼音透出 + 判分不受影响)
Run: go run main.go(后台)后:
TOKEN=$(curl -s -X POST localhost:8080/api/parent/login -H 'Content-Type: application/json' -d '{"phone":"13600000000","password":"pass123456"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
BASE=localhost:8080
# 1) 关卡详情:入口是互动节点,带 config 与拼音
curl -s "$BASE/api/level/detail?child_id=3&level_id=1" -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys,json
d=json.load(sys.stdin)['data']
e=d['entry']
print('entry interaction_type:', e['interaction_type'], '| options:', len(e['options']))
print('config:', e['config'][:120])
print('content_pinyin:', e['content_pinyin'][:50])
print('scene_content_pinyin:', d['scene_content_pinyin'][:50])
"
# 2) 互动成功出口 → 返回原入口决策节点
NID=$(curl -s "$BASE/api/level/detail?child_id=3&level_id=1" -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['entry']['node_id'])")
OID=$(curl -s "$BASE/api/level/detail?child_id=3&level_id=1" -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['entry']['options'][0]['option_id'])")
curl -s -X POST "$BASE/api/level/choose" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d "{\"child_id\":3,\"level_id\":1,\"node_id\":$NID,\"option_id\":$OID}" | python3 -c "
import sys,json
d=json.load(sys.stdin)['data']
print('next node:', d['next']['node_id'] if d.get('next') else None, '| final:', d.get('final'))
"
预期:entry type ∈ {2,7,5,3,8};choose 返回 next(原决策节点),无 final(成功出口不是终局)。
- Step 3: 前端 H5 冒烟
Run: cd ui-src && npm run dev:h5(后台),curl -s http://localhost:5173/ 200;确认 5 个互动组件编译通过(Task 7 Step 6 已查)。浏览器手动走查:地图 → 关卡 1 → 触控互动(拖/点/连)→ 成功进决策树(拼音注音 + 读一读)→ 补分支到完美 → 结算。注意:浏览器交互无法脚本化,请在浏览器完成走查并反馈问题。
- Step 4: 阶段性提交
git add -A
git commit -m "feat: M1.5 拼音/朗读/触控互动/对比点评字段与展示(素材生成见 Task 10/11)"
注意:Task 9 之后追加了 Task 10(离线生成管线)与 Task 11(结算动画/路线回顾/素材验证),提交信息中不要写"收尾"。Task 9 验证时反馈弹层应已展示 feedback_pros/cons(为空属正常——Task 10 生成后才填充)。
Task 10: 离线生成管线 cmd/genasset(oMLX 视觉素材 + 对比点评)
规格依据:技术设计.md 4.8。生成工具只跑在开发机,不进运行链路;产物 SVG→PNG 打包入客户端、点评与 image 路径回填数据库,运行时零 LLM 依赖。前置条件:本机 oMLX 已启动(
cd /Users/zhangbin/Desktop/d盘/work/rag-local && ./scripts/start_models.sh,服务在 127.0.0.1:18080)。
Files:
-
Modify:
config.yml(genasset 段) -
Create:
cmd/genasset/omlx.go— oMLX OpenAI 兼容客户端(关闭 Qwen3.5 思维链) -
Create:
cmd/genasset/prompts.go— 素材/点评 prompt 模板 -
Create:
cmd/genasset/main.go— CLI 编排(清单/生成/PNG 转换/回填) -
Create:
ui-src/scripts/svg2png.mjs— sharp SVG→PNG -
Modify:
ui-src/package.json— devDependencies 加 sharp -
Step 1:
config.yml追加 genasset 段
# 离线素材生成(cmd/genasset,仅开发机运行;本机 oMLX:Qwen3.5-9B-MLX-4bit)
genasset:
endpoint: http://127.0.0.1:18080
api_key: wenwu901 # 本机 oMLX 鉴权 key(rag-local model_config 表)
model: Qwen3.5-9B-MLX-4bit
timeout: 600 # 本地 ~5 tok/s,单张 SVG 1-3 分钟
max_tokens: 24576
retries: 3
- Step 2: 写
cmd/genasset/omlx.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// omlx 调本机 oMLX(OpenAI 兼容 /v1/chat/completions)。
// 注意:Qwen3.5 思考链默认开启且思考文本混入 content,必须传 chat_template_kwargs.enable_thinking=false。
type omlx struct {
endpoint string
apiKey string
model string
timeout int // 秒
maxTokens int
retries int
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func (m *omlx) chat(ctx context.Context, system, user string) (string, error) {
payload := chatRequest{
Model: m.model,
Messages: []chatMessage{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Stream: false,
MaxTokens: m.maxTokens,
ChatTemplateKwargs: map[string]any{"enable_thinking": false},
}
buf, err := json.Marshal(payload)
if err != nil {
return "", err
}
var lastErr error
for i := 0; i <= m.retries; i++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.endpoint+"/v1/chat/completions", bytes.NewReader(buf))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+m.apiKey)
resp, err := (&http.Client{Timeout: time.Duration(m.timeout) * time.Second}).Do(req)
if err != nil {
lastErr = fmt.Errorf("oMLX 请求失败: %w", err)
time.Sleep(2 * time.Second)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("oMLX %d: %s", resp.StatusCode, string(body))
time.Sleep(2 * time.Second)
continue
}
var cr chatResponse
if err := json.Unmarshal(body, &cr); err != nil {
lastErr = fmt.Errorf("oMLX 响应解析失败: %w", err)
continue
}
if len(cr.Choices) == 0 {
lastErr = fmt.Errorf("oMLX 空响应")
continue
}
return cr.Choices[0].Message.Content, nil
}
return "", lastErr
}
- Step 3: 写
cmd/genasset/prompts.go
package main
// styleRule 素材风格约束:简笔卡通、无文字、暖色系、儿童向
const styleRule = `要求:简笔卡通风格,粗线条,暖色调(米黄/橙/青绿),画面中禁止出现任何文字,圆润可爱,适合 4-8 岁儿童。
只输出 JSON,格式:{"svg": "<svg ...></svg>"}。SVG viewBox="0 0 400 300"(道具图标 0 0 200 200),只用基础形状(rect/circle/path/ellipse/line),不使用外部图片与字体,不用 <text>。`
const scenePrompt = `你是儿童绘本插画师。为儿童学习游戏绘制一张场景插画。
场景名称:%s
场景说明:%s
%s`
const charPrompt = `你是儿童绘本插画师。绘制一个 Q 版儿童角色立绘(全身像,头身比 1:2,圆脸大眼)。
角色名称:%s
角色说明:%s
%s`
const propPrompt = `你是儿童绘本插画师。绘制一个道具图标(单一主体居中,底部浅色圆角底衬)。
道具名称:%s
道具说明:%s
%s`
const cardPrompt = `你是儿童绘本插画师。绘制一个圆形徽章图标(占满画布,无文字)。
计策名称:%s
计策释义:%s
%s`
const commentSystem = `你是给 4-8 岁儿童写闯关游戏点评的老师。语言儿童化、口语化、温和,禁止责备,多用"因为/所以"。每条 20-40 字。
只输出 JSON:{"comments": [{"option_id": 1, "pros": "选它的好处", "cons": "它的不足,或错过的更好选择"}, ...]},与输入的选项一一对应。`
const commentUser = `情境:%s
选项:
%s
请为每个选项写「好处 pros」(选它的收获、为什么好)与「坏处 cons」(它的不足;若它是最佳选择可留空或写小小的代价)。`
- Step 4: 写
cmd/genasset/main.go
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"36wisdom/biz/consts"
)
// genasset:离线生成视觉素材(SVG→PNG 打包入客户端)与对比点评(回填数据库)。
// 用法:
//
// go run ./cmd/genasset --list # 打印生成清单
// go run ./cmd/genasset --only=comments # 只生成对比点评
// go run ./cmd/genasset --only=visuals --strategy=1 # 只生成第 1 计素材
// go run ./cmd/genasset --force # 覆盖已有产物
//
// 幂等:产物存在即跳过(--force 覆盖);点评按 feedback_pros 是否为空判定。
var (
ctx = context.Background()
genClient omlx
assetDir = "workspace/genasset" // SVG 源 + 转换缓存
staticDir = "ui-src/static/generated" // 打包入客户端的 PNG
)
type genFlags struct {
listOnly bool
only string // comments | visuals
strategy int // 0 = 全部计策
force bool
}
type visualItem struct {
kind string // scene | char | prop | strategy
key string
id int64
name string
desc string
}
func main() {
fl := parseFlags()
genClient = omlx{
endpoint: g.Cfg().MustGet(ctx, "genasset.endpoint", "http://127.0.0.1:18080").String(),
apiKey: g.Cfg().MustGet(ctx, "genasset.api_key", "wenwu901").String(),
model: g.Cfg().MustGet(ctx, "genasset.model", "Qwen3.5-9B-MLX-4bit").String(),
timeout: g.Cfg().MustGet(ctx, "genasset.timeout", 600).Int(),
maxTokens: g.Cfg().MustGet(ctx, "genasset.max_tokens", 24576).Int(),
retries: g.Cfg().MustGet(ctx, "genasset.retries", 3).Int(),
}
if fl.listOnly {
printInventory(fl)
return
}
if fl.only == "" || fl.only == "comments" {
genComments(fl)
}
if fl.only == "" || fl.only == "visuals" {
genVisuals(fl)
}
fmt.Println("[genasset] 完成")
}
func parseFlags() genFlags {
var fl genFlags
flag.BoolVar(&fl.listOnly, "list", false, "只打印生成清单")
flag.StringVar(&fl.only, "only", "", "comments | visuals,默认都生成")
flag.IntVar(&fl.strategy, "strategy", 0, "只处理指定计策(strategy_id),0=全部")
flag.BoolVar(&fl.force, "force", false, "覆盖已存在产物")
flag.Parse()
return fl
}
// ---------- 清单 ----------
func printInventory(fl genFlags) {
comments := pendingComments(fl)
fmt.Printf("待生成点评选项:%d(按节点分组 %d 个请求)\n", len(comments), distinctCount(comments))
for _, it := range visualInventory(fl) {
fmt.Printf("%-9s %-16s %s\n", it.kind, it.key, it.name)
}
}
// ---------- 对比点评 ----------
func pendingComments(fl genFlags) []gdb.Record {
m := g.DB().Model(consts.TableNodeOption).Where("feedback_pros IS NULL OR feedback_pros = ''")
if fl.strategy > 0 {
_, nodeIDs := strategyScope(fl)
m = m.WhereIn("node_id", nodeIDs)
}
rows, err := m.All(ctx)
if err != nil {
glog.Fatal(ctx, err)
}
return rows
}
func distinctCount(rows []gdb.Record) int {
seen := map[int64]bool{}
for _, r := range rows {
seen[r["node_id"].Int64()] = true
}
return len(seen)
}
// strategyScope 返回某计策涉及的 level_ids 与 node_ids(strategy<=0 时返回 nil)
func strategyScope(fl genFlags) ([]int64, []int64) {
if fl.strategy <= 0 {
return nil, nil
}
var levelIDs []int64
for _, v := range g.DB().Model(consts.TableLevel).Where("strategy_id", fl.strategy).Array("id") {
levelIDs = append(levelIDs, v.Int64())
}
var nodeIDs []int64
for _, v := range g.DB().Model(consts.TableSceneNode).WhereIn("level_id", levelIDs).Array("id") {
nodeIDs = append(nodeIDs, v.Int64())
}
return levelIDs, nodeIDs
}
func genComments(fl genFlags) {
rows := pendingComments(fl)
if len(rows) == 0 {
fmt.Println("[comments] 无待生成选项(全部已回填)")
return
}
byNode := map[int64][]gdb.Record{}
var nodeIDs []int64
for _, r := range rows {
nid := r["node_id"].Int64()
if _, ok := byNode[nid]; !ok {
nodeIDs = append(nodeIDs, nid)
}
byNode[nid] = append(byNode[nid], r)
}
nodeRows, err := g.DB().Model(consts.TableSceneNode).WhereIn("id", nodeIDs).All(ctx)
if err != nil {
glog.Fatal(ctx, err)
}
nodes := map[int64]gdb.Record{}
for _, r := range nodeRows {
nodes[r["id"].Int64()] = r
}
sem := make(chan struct{}, 2) // 本地 9B 模型慢,并发 2 防排队打爆
var wg sync.WaitGroup
for _, nid := range nodeIDs {
wg.Add(1)
sem <- struct{}{}
go func(nid int64) {
defer wg.Done()
defer func() { <-sem }()
if err := genNodeComments(nid, nodes[nid], byNode[nid]); err != nil {
glog.Errorf(ctx, "[comments] node %d 失败: %v", nid, err)
}
}(nid)
}
wg.Wait()
}
func genNodeComments(nid int64, node gdb.Record, opts []gdb.Record) error {
var lines []string
for _, o := range opts {
lines = append(lines, fmt.Sprintf("%d. %s", o["id"].Int64(), o["text"].String()))
}
out, err := genClient.chat(ctx, commentSystem, fmt.Sprintf(commentUser, node["content"].String(), strings.Join(lines, "\n")))
if err != nil {
return err
}
var resp struct {
Comments []struct {
OptionID int64 `json:"option_id"`
Pros string `json:"pros"`
Cons string `json:"cons"`
} `json:"comments"`
}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
return fmt.Errorf("点评 JSON 解析失败: %v(输出: %s)", err, truncate(out, 200))
}
if len(resp.Comments) == 0 {
return fmt.Errorf("点评空结果: %s", truncate(out, 200))
}
for _, c := range resp.Comments {
_, err := g.DB().Model(consts.TableNodeOption).
Data(g.Map{"feedback_pros": c.Pros, "feedback_cons": c.Cons}).
Where("id", c.OptionID).Update(ctx)
if err != nil {
return err
}
}
fmt.Printf("[comments] ok node %d(%d 个选项)\n", nid, len(resp.Comments))
return nil
}
// ---------- 视觉素材 ----------
func visualInventory(fl genFlags) []visualItem {
var items []visualItem
items = append(items, elementItems("scene", sceneIDs(fl))...)
items = append(items, elementItems("char", charIDs(fl))...)
items = append(items, elementItems("prop", propIDs(fl))...)
// 计策卡图标
m := g.DB().Model(consts.TableStrategy)
if fl.strategy > 0 {
m = m.Where("id", fl.strategy)
}
rows, err := m.All(ctx)
if err != nil {
glog.Fatal(ctx, err)
}
for _, r := range rows {
items = append(items, visualItem{
kind: "strategy", key: fmt.Sprintf("strategy_%d", r["id"].Int64()),
id: r["id"].Int64(), name: r["name"].String(), desc: r["meaning"].String(),
})
}
return items
}
func sceneIDs(fl genFlags) []int64 {
m := g.DB().Model(consts.TableLevel).Where("scene_id > 0").Fields("DISTINCT scene_id")
if fl.strategy > 0 {
m = m.Where("strategy_id", fl.strategy)
}
var ids []int64
for _, v := range m.Array("scene_id") {
ids = append(ids, v.Int64())
}
return ids
}
func charIDs(fl genFlags) []int64 {
m := g.DB().Model(consts.TableSceneNode).Where("character_id > 0").Fields("DISTINCT character_id")
if fl.strategy > 0 {
levelIDs, _ := strategyScope(fl)
m = m.WhereIn("level_id", levelIDs)
}
var ids []int64
for _, v := range m.Array("character_id") {
ids = append(ids, v.Int64())
}
return ids
}
func propIDs(fl genFlags) []int64 {
m := g.DB().Model(consts.TableNodeOption).Where("prop_id > 0").Fields("DISTINCT prop_id")
if fl.strategy > 0 {
_, nodeIDs := strategyScope(fl)
m = m.WhereIn("node_id", nodeIDs)
}
var ids []int64
for _, v := range m.Array("prop_id") {
ids = append(ids, v.Int64())
}
return ids
}
func elementItems(kind string, ids []int64) []visualItem {
if len(ids) == 0 {
return nil
}
rows, err := g.DB().Model(consts.TableElement).WhereIn("id", ids).All(ctx)
if err != nil {
glog.Fatal(ctx, err)
}
var items []visualItem
for _, r := range rows {
items = append(items, visualItem{
kind: kind, key: fmt.Sprintf("%s_%d", kind, r["id"].Int64()),
id: r["id"].Int64(), name: r["name"].String(), desc: r["description"].String(),
})
}
return items
}
func genVisuals(fl genFlags) {
items := visualInventory(fl)
if len(items) == 0 {
fmt.Println("[visuals] 清单为空")
return
}
fmt.Printf("[visuals] 共 %d 个素材\n", len(items))
sem := make(chan struct{}, 2)
var wg sync.WaitGroup
for _, it := range items {
wg.Add(1)
sem <- struct{}{}
go func(it visualItem) {
defer wg.Done()
defer func() { <-sem }()
if err := genOneVisual(fl, it); err != nil {
glog.Errorf(ctx, "[visuals] %s %s 失败: %v", it.kind, it.key, err)
}
}(it)
}
wg.Wait()
// 统一转 PNG(每个 kind 一个目录)
for _, kind := range []string{"scene", "char", "prop", "strategy"} {
src := filepath.Join(assetDir, kind)
if _, err := os.Stat(src); err != nil {
continue
}
if err := svg2png(src, filepath.Join(staticDir, kind)); err != nil {
glog.Errorf(ctx, "[visuals] svg2png %s: %v", kind, err)
}
}
// 回填 image 路径(前端按 /static/... 直接引用);PNG 未生成成功的不回填(避免前端 404)
for _, it := range items {
if !fileExists(filepath.Join(staticDir, it.kind, it.key+".png")) {
glog.Errorf(ctx, "[visuals] 回填 %s 跳过:PNG 不存在", it.key)
continue
}
img := pngPath(it)
var err error
if it.kind == "strategy" {
_, err = g.DB().Model(consts.TableStrategy).Data(g.Map{"icon": img}).Where("id", it.id).Update(ctx)
} else {
_, err = g.DB().Model(consts.TableElement).Data(g.Map{"image": img}).Where("id", it.id).Update(ctx)
}
if err != nil {
glog.Errorf(ctx, "[visuals] 回填 %s 失败: %v", it.key, err)
}
}
}
func genOneVisual(fl genFlags, it visualItem) error {
svgPath := filepath.Join(assetDir, it.kind, it.key+".svg")
if !fl.force && fileExists(svgPath) {
return nil // 幂等:已有产物跳过
}
var user string
switch it.kind {
case "scene":
user = fmt.Sprintf("场景名称:%s\n场景说明:%s\n%s", it.name, it.desc, styleRule)
case "char":
user = fmt.Sprintf("角色名称:%s\n角色说明:%s\n%s", it.name, it.desc, styleRule)
case "prop":
user = fmt.Sprintf("道具名称:%s\n道具说明:%s\n%s", it.name, it.desc, styleRule)
case "strategy":
user = fmt.Sprintf("计策名称:%s\n计策释义:%s\n%s", it.name, it.desc, styleRule)
}
out, err := genClient.chat(ctx, "", user)
if err != nil {
return err
}
svg, err := extractSVG(out)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(svgPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(svgPath, []byte(svg), 0o644); err != nil {
return err
}
fmt.Printf("[visuals] ok %s/%s\n", it.kind, it.key)
return nil
}
func extractSVG(out string) (string, error) {
var box struct {
SVG string `json:"svg"`
}
if err := json.Unmarshal([]byte(out), &box); err != nil {
return "", fmt.Errorf("模型输出不是 JSON: %v(输出: %s)", err, truncate(out, 200))
}
if !strings.Contains(box.SVG, "<svg") {
return "", fmt.Errorf("输出缺少 svg 内容: %s", truncate(out, 200))
}
return box.SVG, nil
}
func pngPath(it visualItem) string {
return "/static/generated/" + it.kind + "/" + it.key + ".png"
}
// svg2png 调 ui-src/scripts/svg2png.mjs(sharp)批量转换
func svg2png(src, dst string) error {
cmd := exec.CommandContext(ctx, "node", "ui-src/scripts/svg2png.mjs", src, dst)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %v", strings.TrimSpace(string(out)), err)
}
fmt.Print(string(out))
return nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
- Step 5: 编译验证
Run: go build ./... && go vet ./...
Expected: 通过(cmd/genasset 纳入编译;gf 配置在项目根 config.yml 自动加载)
- Step 6: 预览清单
Run: go run ./cmd/genasset --list
Expected: 打印各计场景/角色/道具/计策卡清单与待生成点评数(36 计全量时场景约 36、角色约 40-70、道具约 100+、计策卡 36;点评按节点数)。确认清单数据来自真实种子内容。
- Step 7: 第 1 计小批量跑通 + 人工验收风格
先确认 oMLX 在跑(curl -s http://127.0.0.1:18080/v1/models 有响应;没起则 cd /Users/zhangbin/Desktop/d盘/work/rag-local && ./scripts/start_models.sh)。
Run: go run ./cmd/genasset --only=comments --strategy=1(第 1 计点评,几个请求,几分钟)
Run: go run ./cmd/genasset --only=visuals --strategy=1(第 1 计场景/角色/道具/计策卡 SVG,每张 1-3 分钟)
验收:
ls workspace/genasset/scene/ workspace/genasset/char/ workspace/genasset/prop/ workspace/genasset/strategy/ 2>/dev/null
sqlite3 data/36wisdom.db "SELECT id, feedback_pros, feedback_cons FROM node_option WHERE feedback_pros != '' LIMIT 3;"
-
打开 1-2 张 SVG 预览(浏览器/编辑器)检查风格:简笔卡通、暖色、无文字;风格不行则调整 prompts.go 的
styleRule措辞后重跑(--force只重生成该计) -
点评抽查:温和、儿童化、有因果、20-40 字;不行调整
commentSystem后重跑(幂等:--force不适用点评,需sqlite3 data/36wisdom.db "UPDATE node_option SET feedback_pros='', feedback_cons='' WHERE node_id IN (第1计节点);"清空后重跑) -
Step 8: PNG 转换脚本
ui-src/scripts/svg2png.mjs+ sharp 安装
// 用法: node svg2png.mjs <srcDir> <outDir> [width]
// 把 srcDir 下所有 .svg 转为 outDir 下同名 .png(默认 800 宽,2x 高清)
import sharp from 'sharp'
import { readdirSync, mkdirSync } from 'fs'
import { join } from 'path'
const [srcDir, outDir, widthArg] = process.argv.slice(2)
const width = Number(widthArg) || 800
mkdirSync(outDir, { recursive: true })
let n = 0
for (const f of readdirSync(srcDir)) {
if (!f.endsWith('.svg')) continue
const out = join(outDir, f.replace(/\.svg$/, '.png'))
await sharp(join(srcDir, f)).resize({ width }).png().toFile(out)
n++
console.log('png:', out)
}
console.log(`done: ${n} files`)
Run: cd ui-src && npm i -D sharp && npm run dev:h5 启动后 curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/static/generated/scene/scene_1.png 返回 200(Task 10 Step 7 生成的 SVG 已转 PNG——先手动跑一次转换:node ui-src/scripts/svg2png.mjs ../workspace/genasset/scene ../ui-src/static/generated/scene,或直接跑第 9 步全量)。
- Step 9: 全量后台生成(幂等,可中断续跑)
go build -o bin/genasset ./cmd/genasset
nohup ./bin/genasset > /tmp/genasset.log 2>&1 &
tail -f /tmp/genasset.log
- 全量 100-200 张 SVG,本地 9B 约 2-4 小时,放后台跑;日志出现
[genasset] 完成即结束 - 中断后可重新执行同命令续跑(幂等跳过已有产物)
- 完成后验证回填:
sqlite3 data/36wisdom.db "SELECT COUNT(*) FROM element WHERE image != '';"
sqlite3 data/36wisdom.db "SELECT COUNT(*) FROM strategy WHERE icon != '';"
sqlite3 data/36wisdom.db "SELECT COUNT(*) FROM node_option WHERE feedback_pros != '';"
ls ui-src/static/generated/scene | wc -l
-
检查日志中的失败行(
[visuals] ... 失败/[comments] node ... 失败),个别失败可重跑续生成;反复失败的素材(如模型输出质量差)在清单里标注后续人工替换 -
Step 10: 提交
git add config.yml cmd/genasset ui-src/scripts ui-src/package.json ui-src/package-lock.json
git commit -m "feat: genasset 离线生成管线(oMLX 素材 SVG→PNG + 对比点评回填)"
注意:
ui-src/static/generated/产物是否提交 git 按部署策略定(README 声明产物随包发布;若仓库体积敏感可将 generated 加入 .gitignore,用发布脚本生成)。
Task 11: 结算动画 / 路线回顾 / 素材效果验证
Files:
-
Modify:
ui-src/src/pages/result/result.vue(彩带动画 + 路线回顾展示) -
Modify:
ui-src/src/pages/play/play.vue(角色表情徽章 + 选项动效 + 素材图接入) -
Modify:
ui-src/src/utils/visual.js(不变——兜底逻辑已存在) -
Step 1: result.vue 加彩带动画 + 路线回顾
在 result.vue 模板的 stars-area 之后插入:
<view class="confetti" aria-hidden="true">
<text v-for="(c, i) in confettis" :key="i" class="confetti-piece" :class="c" :style="{ left: c.left, animationDelay: c.delay }">{{ c.emoji }}</text>
</view>
<view v-if="result.route_steps && result.route_steps.length" class="route card">
<view class="route-title">🗺 你的路线</view>
<view v-for="(s, i) in result.route_steps" :key="i" class="route-step">
<view class="route-idx">{{ i + 1 }}</view>
<view class="route-body">
<view class="route-text">{{ s.text }}</view>
<view v-if="s.pros" class="route-pros">✓ {{ s.pros }}</view>
</view>
</view>
</view>
<script> data 加:
confettis: Array.from({ length: 12 }, (_, i) => ({
emoji: ['🎉', '🎊', '⭐', '✨'][i % 4],
left: (i * 8 + Math.random() * 4) + '%',
delay: (Math.random() * 0.8).toFixed(2) + 's'
}))
<style> 追加:
.confetti { position: relative; height: 0; margin-bottom: 24rpx; }
.confetti-piece {
position: absolute; top: -20rpx; font-size: 44rpx; opacity: 0;
animation: confetti-fall 1.6s ease-out forwards;
}
@keyframes confetti-fall {
0% { opacity: 0; transform: translateY(-30rpx) rotate(0); }
20% { opacity: 1; }
100% { opacity: 0.2; transform: translateY(140rpx) rotate(160deg); }
}
.route { text-align: left; padding: 32rpx 36rpx; margin-bottom: 32rpx; }
.route-title { font-size: 30rpx; font-weight: 800; color: #5b4636; margin-bottom: 20rpx; }
.route-step { display: flex; gap: 20rpx; padding: 14rpx 0; border-bottom: 2rpx dashed #f0e6d8; }
.route-step:last-child { border-bottom: none; }
.route-idx {
width: 44rpx; height: 44rpx; border-radius: 50%; background: #ff9a3d; color: #fff;
font-size: 24rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.route-body { flex: 1; }
.route-text { font-size: 28rpx; font-weight: 700; color: #5b4636; }
.route-pros { font-size: 24rpx; color: #1fa98f; margin-top: 6rpx; }
- Step 2: play.vue 素材图接入 + 角色表情 + 选项动效
场景面板(Task 8 已写 scene-card),改场景头部:有 image 显示素材图,否则 emoji 兜底:
<view class="scene-head">
<image v-if="detail.scene && detail.scene.image" class="scene-img" :src="detail.scene.image" mode="aspectFill" />
<view v-else class="scene-emoji">{{ scene.emoji }}</view>
<view class="scene-name">{{ detail.scene ? detail.scene.name : '' }}</view>
</view>
角色头像改「立绘 + 表情徽章」:
<view v-if="curNode.character" class="character">
<view class="char-wrap">
<image v-if="curNode.character.image" class="char-img" :src="curNode.character.image" mode="aspectFit" />
<view v-else class="char-avatar" :style="{ background: personColor(curNode.character.id) }">{{ curNode.character.name.slice(0, 1) }}</view>
<view v-if="mood" class="char-mood">{{ mood }}</view>
</view>
<view class="char-name">{{ curNode.character.name }}</view>
<SpeakButton class="char-speak" :text="curNode.character.name" :file="curNode.character.audio" label="" />
</view>
选项(type 1)道具图标化——option 内 option-mark 前插道具图:
<view v-if="o.prop && o.prop.image" class="option-icon"><image :src="o.prop.image" mode="aspectFit" /></view>
<view v-else-if="o.prop" class="option-icon">{{ propVisual(o.prop.name).emoji }}</view>
script 调整:
import { sceneVisual, personVisual, propVisual } from '../../utils/visual.js'
// data 加: mood: '', optionFx: null
// interactionDone 加表情反馈
interactionDone({ success }) {
this.mood = success ? '😊' : '😢'
const opt = success ? this.curNode.options[0] : this.curNode.options[1]
if (opt) this.submit(opt)
},
// continuePlay 清除表情与选项动效
continuePlay() {
this.curNode = this.feedback.next
this.feedback = null
this.mood = ''
this.optionFx = null
}
选项动效完整实现(choose 改为先播动画再请求;好选择以"有 pros 无 cons"近似判定,素材生成前字段为空则只弹不跳):
choose(o, idx) {
if (this.choosing) return
this.choosing = true
// feedback_pros 生成前为空 → good=false → 只触发弹跳 class(fx 无 class 区分)
this.optionFx = { idx, good: !!(o.feedback_pros && !o.feedback_cons) }
setTimeout(() => this.submit(o), 350)
}
option 模板绑定动效 class(替换原 :class="{ disabled: choosing }"):
:class="{ disabled: choosing, 'fx-pop': optionFx && optionFx.idx === idx, 'fx-good': optionFx && optionFx.idx === idx && optionFx.good, 'fx-bad': optionFx && optionFx.idx === idx && !optionFx.good }"
style 追加(素材图 + 表情徽章 + 正确弹跳/错误抖动动效):
.scene-img { width: 100%; height: 200rpx; border-radius: 20rpx; margin-bottom: 16rpx; }
.char-wrap { position: relative; margin-right: 20rpx; }
.char-img { width: 110rpx; height: 110rpx; border-radius: 50%; }
.char-mood {
position: absolute; right: -12rpx; bottom: -8rpx; font-size: 40rpx;
animation: mood-pop 0.5s ease;
}
@keyframes mood-pop {
0% { transform: scale(0); } 60% { transform: scale(1.4); } 100% { transform: scale(1); }
}
.option-icon { width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; font-size: 56rpx; }
.option-icon image { width: 100%; height: 100%; }
.option.fx-pop { animation: pop-right 0.35s ease; }
.option.fx-good { border-color: #2fce9a; background: #e8f7f3; animation: pop-right 0.35s ease; }
.option.fx-bad { border-color: #e8c4b0; background: #faf3ec; animation: shake 0.4s ease; }
@keyframes pop-right {
0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); }
}
- Step 3: H5 走查(素材展示 + 结算动画 + 路线回顾)
Run: cd ui-src && npm run dev:h5,浏览器走查:
-
地图 → 关卡 1:场景面板显示生成插画(无素材时 emoji 兜底)、角色立绘 + 表情徽章(互动成功 😊 / 失败 😢)、选项道具图标
-
完成一关:结算页星星弹出 + 彩带 + 路线回顾(每步选择 + 好处)
-
反馈弹层:三段式(你的选择 / ✓ 好处 / ✗ 对比)——需 Task 10 生成点评后才有内容
-
Step 4: 最终提交
git add ui-src/src/pages/result/result.vue ui-src/src/pages/play/play.vue
git commit -m "feat: 结算彩带与路线回顾,角色表情/道具图标/选项动效,素材图接入"
Self-Review
1. Spec 覆盖(对照技术设计.md 4.6/4.7 与用户确认的四项决策):
- 拼音写时标注入库 → Task 1/2/3 ✓;词表(36 计 + 专名)→ Task 1 ✓;逐字对齐 JSON 格式 → Task 1 定义、Task 2 列迁移、Task 5 透出 ✓
- 朗读降级(文件优先 + SpeechSynthesis)→ Task 6 speech.js、Task 8 集成 ✓
- 场景/人物/道具占位视觉 → Task 6 visual.js、Task 8 play.vue 场景面板 + 人物 ✓
- 触控 5 种(2/3/5/7/8)→ Task 4 后端互动入口节点 + Task 7 前端组件 + Task 8 分发 ✓;choose 零改动 ✓(成功/失败出口复用选项表);失败进失败终局正常判分 ✓
- 全量 36 计 → Task 4 按 level 遍历,无内容手写 ✓
2. 占位符扫描: 无 TBD/TODO;interaction.go 的 nodeIdsOf 与 uniqueInt64 在文件内自建(seed 包内无这两个函数,勿引用 service 包的同名函数——不同包不可见)。plan 中所有代码块完整。
3. 类型一致性:
- config JSON:
kind+items[{id,name}]+answer+answer_order+persons[{id,name}]+pairs[[p,i]]—— Task 4 生成(json tag answer_order)、Task 7 StepSort 读cfg.answer_order✓、LinkMatch 读cfg.pairs✓ interactionPlan字段Items/Answer/AnswerOrder/Persons/Pairs(导出,JSON tag 小写)——interactionConfigJSON直接 marshal plan,json:"-"排除 Prompt/EntryID/FailFinalID ✓- DTO 拼音字段名:
NamePinyin/TextPinyin/ContentPinyin/SceneContentPinyin/MeaningPinyin/TeachContentPinyin,json tagname_pinyin/text_pinyin/content_pinyin/scene_content_pinyin/meaning_pinyin/teach_content_pinyin——Task 5 前后端一致 ✓;前端 play.vue 读o.text_pinyin、n.content_pinyin、d.scene_content_pinyin、s.meaning_pinyin、s.teach_content_pinyin✓ choose选项option_id未在 OptionVO?已存在(OptionVO.OptionId → option_id)✓- 互动组件 emit
done({success})——Task 7 各组件与 Task 8interactionDone({ success })一致 ✓
4. 潜在坑(实现时注意):
interaction_test.go用gconv.Int64(1)构造g.Var——gdb.Record是map[string]g.Var,直接字面量{"id": gconv.Int64(1)}可编译(gconv 返回 g.Var)✓ensureInteractions幂等判定:entry.interaction_type > 1即跳过;旧库补生成后再次启动不再重复插入 ✓- 互动节点失败出口指向的失败终局是既有终局(不新增终局),TotalFinals/完美逻辑不变 ✓
bestPropOfBFS 以选项next_node_id为边——选项的 next 为 0(终局无选项)时不入队;visited 防环 ✓- annotateAll 单事务逐行 UPDATE(约 500 行,启动一次性;CLAUDE.md 批处理约束针对热路径,此处为启动维护例外,已注释说明)
pairsOf中 persons 的 name 填充:返回 persons 列表元素只填 ID(Name 由调用方补)——不,pairsOf返回 persons 含 ID;名字需要在 planInteraction 里补——检查:pairsOf(nodes, options)返回 persons 无 name。修正:planInteraction中 link_match 分支后补名字:for i := range p.Persons { p.Persons[i].Name = elementNames[p.Persons[i].ID] }。加入实现代码。
5. Task 10/11 覆盖(对照技术设计.md 4.8):
- oMLX 接入(endpoint/model/关思维链/max_tokens/超时/重试)→ Task 10 Step 2 omlx.go + Step 1 配置 ✓
- 生成清单(场景/角色/道具/计策卡去重、点评按节点分组)→ Task 10 main.go 清单函数 ✓;幂等(SVG 存在跳过 / feedback_pros 非空跳过)✓
- SVG→PNG(sharp,小程序不支持 SVG 统一 PNG)→ Task 10 Step 8 svg2png.mjs ✓
- 回填 element.image / strategy.icon / node_option.feedback_pros-cons → Task 10 genVisuals/genNodeComments ✓
- 风格验收 + 全量后台生成(分批幂等)→ Task 10 Step 7/9 ✓
- 对比点评三段式展示 + 路线回顾 + 结算彩带 + 角色表情/道具图标/选项动效 → Task 8(弹层/累积)+ Task 11 ✓
- 动效动画 CSS 代码实现(不走 LLM)、表情 emoji 徽章叠加 → Task 11 ✓
6. Task 10/11 潜在坑:
WhereIn传空 slice 生成IN ()语法错误:pendingComments/charIDs/propIDs中fl.strategy > 0时先取strategyScope,实现时必须判空:if len(nodeIDs) > 0 { m = m.WhereIn(...) }(全量模式无此问题,但 --strategy 过滤时可能命中空集)- prompts.go 的
scenePrompt/charPrompt/propPrompt/cardPrompt常量在 genOneVisual 中未引用(包级常量未使用不报编译错,属正常,可保留作模板文档或删除) - svg2png 用
exec.CommandContext调 node:需ui-src/node_modules/sharp已安装(Step 8 先npm i -D sharp);若 sharp 安装失败可换rsvg-convert(mac 需 brew)或 Go 侧 tdewolff/canvas,产物路径不变 - 点评生成依赖
node.content字段:互动节点(Task 4 生成)与种子决策节点都有 content ✓;若某节点 content 为空,prompt 情境空,模型仍会输出(可接受,后续人工补) - genVisuals 回填在 PNG 转换后统一执行:若某 SVG 转换失败(文件缺失),仍会回填 image 路径导致前端 404——回填前检查 PNG 文件存在(
fileExists(filepath.Join(staticDir, it.kind, it.key+".png"))) - Task 11
choose动效 350ms 后 submit:choosing 已在 choose 开头置 true,连点被拦截 ✓;interactionDone 路径不经过动效直接 submit ✓ optionFx判定 good 依赖 feedback_pros/cons 非空:素材生成前全部为空 → 所有选项只触发 fx-pop(弹跳)不跳色,行为一致可接受