diff --git a/biz/consts/pinyin.go b/biz/consts/pinyin.go index 048d7a5..67df4b4 100644 --- a/biz/consts/pinyin.go +++ b/biz/consts/pinyin.go @@ -33,16 +33,16 @@ var PinyinOverrides = map[string]string{ "上屋抽梯": "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ì", + "美人计": "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ǒ", + "小明": "xiǎo míng", + "小虎": "xiǎo hǔ", + "朵朵": "duǒ duǒ", "幼儿园": "yòu ér yuán", "足球场": "zú qiú chǎng", "游乐场": "yóu lè chǎng", diff --git a/common/pinyin.go b/common/pinyin.go index f97ff72..f80297f 100644 --- a/common/pinyin.go +++ b/common/pinyin.go @@ -49,19 +49,17 @@ func AnnotatePinyin(text string) string { } } - // 未命中汉字逐字转拼音;pi 与 pinyin.Pinyin 的汉字输出顺序同步推进 - args := pinyin.NewArgs() - args.Style = pinyin.Tone - all := pinyin.Pinyin(string(runes), args) - pi := 0 - for idx, r := range runes { - if !unicode.Is(unicode.Han, r) { - continue + // 未命中汉字逐字转拼音:词典缺失字(生僻字等)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] + } } - if out[idx] == "" && pi < len(all) && len(all[pi]) > 0 { - out[idx] = all[pi][0] - } - pi++ + i++ } b, _ := json.Marshal(out) diff --git a/common/pinyin_test.go b/common/pinyin_test.go index 6c0f990..5ad6f21 100644 --- a/common/pinyin_test.go +++ b/common/pinyin_test.go @@ -59,3 +59,17 @@ func TestAnnotatePinyin_Empty(t *testing.T) { 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]) + } + } +} diff --git a/docs/superpowers/plans/2026-08-13-m1_5-interactions.md b/docs/superpowers/plans/2026-08-13-m1_5-interactions.md new file mode 100644 index 0000000..a7daea7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-m1_5-interactions.md @@ -0,0 +1,3052 @@ +# 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 组件,文本用 `` 注音,无音频文件时用浏览器 `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` + `ensureInteractions` +- `biz/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_pinyin +- `biz/model/dto/level.go` / `dto/level_play.go` / `dto/strategy.go`(修改)— VO 加拼音字段 + OptionVO 加 feedback_pros/cons +- `biz/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),否则浏览器 SpeechSynthesis +- `src/utils/visual.js`(新增)— 场景/道具/人物占位视觉(emoji + 渐变色) +- `src/utils/pinyin.js`(新增)— pinyin JSON 解析 + 与原文 zip +- `src/components/RubyText.vue`(新增)— `` 注音文本组件 +- `src/components/SpeakButton.vue`(新增)— 朗读按钮 +- `src/components/interactions/PropPick.vue` / `FindSpot.vue` / `DragPlace.vue` / `StepSort.vue` / `LinkMatch.vue`(新增)— 5 个触控组件,统一 emit `done({success})` +- `src/pages/play/play.vue`(修改)— 场景面板 + 互动分发 + 拼音/朗读 + 对比点评弹层 + 路线回顾累积 + 表情/道具图/动效 +- `src/pages/map/map.vue`(修改)— 学堂弹层拼音 + 朗读 +- `src/pages/children/children.vue`(修改)— 选择孩子时记录年龄段 +- `src/store/user.js`(修改)— getChildAge/setChildAge +- `src/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 计成语整体拼音 + 种子内容常用专名;标注时最长匹配优先) + +```go +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`** + +```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`** + +```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: 提交** + +```bash +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,只增不改;失败仅告警不阻断启动) + +```go +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` 后加一行): + +```sql + 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` 前): + +```go + 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);"` 含 `content_pinyin`、`feedback_pros`、`feedback_cons`。启动后 Ctrl-C 停掉。 + +- [ ] **Step 8: 提交** + +```bash +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 行)改为: + +```go + 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 行)改为: + +```go + 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`): + +```go + 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`): + +```go + 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`**(对既有库与残留空值,幂等) + +```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 行)为: + +```go +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: 提交** + +```bash +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`) + +```go +package seed + +import ( + "encoding/json" + "testing" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/util/gconv" + + "36wisdom/biz/consts" +) + +// 构造与 level 1 同构的小树:entry(1) → 决策(2) → best(3)/good(4)/fail(5) +func testTree() (nodes []gdb.Record, options []gdb.Record) { + nodes = []gdb.Record{ + {"id": gconv.Int64(1), "character_id": gconv.Int64(11), "result_type": gconv.Int(0)}, + {"id": gconv.Int64(2), "character_id": gconv.Int64(12), "result_type": gconv.Int(0)}, + {"id": gconv.Int64(3), "character_id": gconv.Int64(12), "result_type": gconv.Int(3)}, + {"id": gconv.Int64(4), "character_id": gconv.Int64(12), "result_type": gconv.Int(2)}, + {"id": gconv.Int64(5), "character_id": gconv.Int64(11), "result_type": gconv.Int(1)}, + } + options = []gdb.Record{ + {"id": gconv.Int64(101), "node_id": gconv.Int64(1), "prop_id": gconv.Int64(21), "next_node_id": gconv.Int64(2), "sort_order": gconv.Int(1)}, + {"id": gconv.Int64(102), "node_id": gconv.Int64(1), "prop_id": gconv.Int64(22), "next_node_id": gconv.Int64(5), "sort_order": gconv.Int(2)}, + {"id": gconv.Int64(103), "node_id": gconv.Int64(2), "prop_id": gconv.Int64(21), "next_node_id": gconv.Int64(3), "sort_order": gconv.Int(1)}, + {"id": gconv.Int64(104), "node_id": gconv.Int64(2), "prop_id": gconv.Int64(23), "next_node_id": gconv.Int64(4), "sort_order": gconv.Int(2)}, + {"id": gconv.Int64(105), "node_id": gconv.Int64(2), "prop_id": gconv.Int64(24), "next_node_id": gconv.Int64(5), "sort_order": gconv.Int(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`** + +```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 内自建: + +```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 留的注释行): + +```go + 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` 验证: + +```sql +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: 提交** + +```bash +git add biz/service/seed/interaction.go biz/service/seed/interaction_test.go biz/service/seed/seed.go +git commit -m "feat: 互动入口节点程序化生成(触控5种,成功/失败双出口,choose零改动)" +``` + +--- + +### 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 行): + +```go +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 行)加: + +```go + 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` 后: + +```bash +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: 提交** + +```bash +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`**(文件优先,无文件浏览器语音降级) + +```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 + 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 + 渐变色;后续素材接入只改此表) + +```js +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' } +} +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) { + return { emoji: pick(PROP_MAP, name || '', '🎁').emoji || '🎁', 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) + +```js +// 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`** + +```vue + + + + + +``` + +- [ ] **Step 5: 朗读按钮 `ui-src/src/components/SpeakButton.vue`** + +```vue + + + + + +``` + +- [ ] **Step 6: 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 7: 提交** + +```bash +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 即成功) + +```vue + + + + + +``` + +- [ ] **Step 2: 找线索 `FindSpot.vue`**(道具卡散布在"场景"里,点击寻找,点中 answer 即成功;视觉与 PropPick 同构,排布错落) + +```vue + + + + + +``` + +- [ ] **Step 3: 拖拽放置 `DragPlace.vue`**(touch 拖道具到目标框:命中且道具=answer 成功,未命中/道具错 重试) + +```vue + + + + + +``` + +- [ ] **Step 4: 步骤排序 `StepSort.vue`**(touch 拖拽排序:touchstart 拿起、touchmove 交换、touchend 判定全序 == answer_order) + +```vue + + + + + +``` + +- [ ] **Step 5: 连线配对 `LinkMatch.vue`**(SVG 画线:左列人物、右列道具,先点人物再点道具成对;全部配完且与 pairs 一致成功) + +```vue + + + + + +``` + +- [ ] **Step 6: H5 编译检查** + +Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/interactions/PropPick.vue` 等 5 个组件返回 200。 + +- [ ] **Step 7: 提交** + +```bash +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` 追加)** + +```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`** + +完整替换(场景面板 + 互动分发 + 拼音注音 + 朗读按钮 + 反馈面板): + +```vue + + + + + +``` + +- [ ] **Step 3: map.vue 学堂弹层增强**(拼音注音 + 朗读) + +在 `