Files
36Wisdom/docs/superpowers/plans/2026-08-13-m1_5-interactions.md
T

128 KiB
Raw Blame History

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=falseSVG 经 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 + ViteH5)、CSS/SVG touch 交互(无游戏引擎)、本机 oMLXQwen3.5-9B-MLX-4bitOpenAI 兼容)、sharpdevDependencySVG→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(新增)— <ruby> 注音文本组件
  • 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 计成语整体拼音 + 种子内容常用专名;标注时最长匹配优先)

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: FAILAnnotatePinyin 未定义)

  • 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: PASS4 个用例全绿)

  • 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.gobiz/dao/dao_level.gobiz/dao/dao_scene_node.gobiz/dao/dao_node_option.gobiz/dao/dao_element.go

  • Step 1: 实现幂等加列 common/migrate.goSQLite 无 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 TEXTInit 末尾加 common.EnsureColumn(ctx, consts.TableLevel, "scene_content_pinyin", "TEXT")

  • Step 4: 更新 biz/dao/dao_scene_node.go——content TEXT NOT NULL 后加 content_pinyin TEXTInit 末尾加 common.EnsureColumn(ctx, consts.TableSceneNode, "content_pinyin", "TEXT")

  • Step 5: 更新 biz/dao/dao_node_option.go——text TEXT NOT NULL 后加 text_pinyin TEXTfeedback TEXT NOT NULL 后加 feedback_pinyin TEXTfeedback_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 TEXTdescription TEXT 后加 description_pinyin TEXTInit 末尾加两个 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_pinyinfeedback_prosfeedback_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 四处)

insertElementg.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()

insertStrategyg.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,
	}

insertLevelg.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 实现,先留注释占位)

修改 EnsureSeededseed.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.Vargf 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: FAILplanInteraction / 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 定义(同包 serviceseed 是不同包)——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.goTask 3 Step 3 留的注释行):

	annotateAll(ctx)
	ensureInteractions(ctx)
  • Step 5: 跑单测确认通过

Run: go test ./biz/service/seed/ -run TestPlanInteraction -v Expected: PASS6 个用例全绿)

  • Step 6: 编译 + 启动验证(现有库补生成)

Run: go build ./... && go vet ./... Run: go run main.goCtrl-C 停掉后再 sqlite3 data/36wisdom.db 验证:

SELECT COUNT(*) FROM scene_node WHERE interaction_type > 1;  -- 应为 3636 关各 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.Vargdb.go:678),gconv.Int64 字面量无法编译
  • Step 6 预期修正:互动节点数 = 14 而非 36——种子数据仅 14 关存在带 prop 的选项(其余 22 关零道具),planInteraction 按设计返回 nil 跳过;数据驱动行为,非缺陷。M1.5 收尾时如需 36 关全互动,须先补种子道具数据
  • 审查后修复 commit 4744ba9persons 补名字、seed 缓存一致性注释(启动期无读者不清缓存,同 annotateAll 先例)、元素名查询失败 warn

Task 8 偏差记录(审查后修复 7f7cf49)

  • 游戏化 CSS 补全 47 行(进度点/场景横幅/漂浮装饰/连击徽章/角色卡/选项 c1-c4 四色边框/option-icon 定宽高/fb-in 错峰动画/顶部开关)——原计划 style 段缺失这些类导致进度点不可见、素材落地后 image 默认 320×240 撑爆布局
  • OptionVO 透出 audionode_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.gobiz/service/strategy.gobiz/model/dto/level.gobiz/model/dto/level_play.gobiz/model/dto/strategy.gobiz/controller/level.gobiz/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
}

OptionTextPinyin stringFeedbackPros stringFeedbackCons stringNodeContentPinyin stringLevelDetailSceneContentPinyin 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

StrategyDetailMeaningPinyin stringTeachContentPinyin stringDetail 组装(第 179-190 行)加两个字段映射。

  • Step 3: dto 层

biz/model/dto/level.goElementVONamePinyin string \json:"name_pinyin"`OptionVOTextPinyin string `json:"text_pinyin"`FeedbackPros string `json:"feedback_pros"`FeedbackCons string `json:"feedback_cons"`NodeVOContentPinyin string `json:"content_pinyin"`LevelDetailResSceneContentPinyin string `json:"scene_content_pinyin"``。

biz/model/dto/level_play.goNodeVOChooseRes.Next 用同一类型,在 dto/level.go 定义)——level_play.go 的 ChooseRes 引用 *NodeVO,无需改。FinalSettle 不加拼音。

biz/model/dto/strategy.goStrategyDetailResMeaningPinyin string \json:"meaning_pinyin"`TeachContentPinyin string `json:"teach_content_pinyin"``。

  • Step 4: controller 层

biz/controller/level.goelementVONamePinyin: e.NamePinyinnodeVOContentPinyin: n.ContentPinyinOptionVO 映射加 TextPinyin: o.TextPinyinFeedbackPros: o.FeedbackProsFeedbackCons: o.FeedbackConsDetailSceneContentPinyin: detail.SceneContentPinyin

biz/controller/strategy.goDetail 返回加 MeaningPinyinTeachContentPinyin 映射。

  • 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.jsui-src/src/utils/visual.jsui-src/src/utils/pinyin.js

  • Create: ui-src/src/components/RubyText.vueui-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.vueFindSpot.vueDragPlace.vueStepSort.vueLinkMatch.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.vuetouch 拖拽排序: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.vueui-src/src/pages/map/map.vueui-src/src/pages/children/children.vueui-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.vueenter(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 透出 audionode_option.audio 列已存在,零迁移;支撑"听点评"文件朗读而非 TTS 降级)

biz/service/level.goOption 结构体加 Audio string 字段,buildNode 内选项组装处追加 Audio: o["audio"].String()

type Option struct {
	OptionId     int64
	Text         string
	TextPinyin   string
	FeedbackPros string
	FeedbackCons string
	Audio        string
	Prop         *Element
}

buildNodenode.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.goOptionVO 加字段:

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.gonodeVO 的选项循环追加映射:

		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> 加 importRubyTextSpeakButtongetChildAge<template>learn-meaninglearn-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/genassetoMLX 视觉素材 + 对比点评)

规格依据:技术设计.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.ymlgenasset 段)

  • 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,仅开发机运行;本机 oMLXQwen3.5-9B-MLX-4bit
genasset:
  endpoint: http://127.0.0.1:18080
  api_key: wenwu901       # 本机 oMLX 鉴权 keyrag-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 调本机 oMLXOpenAI 兼容 /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_idsstrategy<=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.mjssharp)批量转换
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 返回 200Task 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)道具图标化——optionoption-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 → 只触发弹跳 classfx 无 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/TODOinteraction.go 的 nodeIdsOfuniqueInt64 在文件内自建(seed 包内无这两个函数,勿引用 service 包的同名函数——不同包不可见)。plan 中所有代码块完整。

3. 类型一致性:

  • config JSONkind + 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 planjson:"-" 排除 Prompt/EntryID/FailFinalID ✓
  • DTO 拼音字段名:NamePinyin/TextPinyin/ContentPinyin/SceneContentPinyin/MeaningPinyin/TeachContentPinyinjson tag name_pinyin/text_pinyin/content_pinyin/scene_content_pinyin/meaning_pinyin/teach_content_pinyin——Task 5 前后端一致 ✓;前端 play.vue 读 o.text_pinyinn.content_pinyind.scene_content_pinyins.meaning_pinyins.teach_content_pinyin
  • choose 选项 option_id 未在 OptionVO?已存在(OptionVO.OptionId → option_id)✓
  • 互动组件 emit done({success})——Task 7 各组件与 Task 8 interactionDone({ success }) 一致 ✓

4. 潜在坑(实现时注意):

  • interaction_test.gogconv.Int64(1) 构造 g.Var——gdb.Recordmap[string]g.Var,直接字面量 {"id": gconv.Int64(1)} 可编译(gconv 返回 g.Var)✓
  • ensureInteractions 幂等判定:entry.interaction_type > 1 即跳过;旧库补生成后再次启动不再重复插入 ✓
  • 互动节点失败出口指向的失败终局是既有终局(不新增终局),TotalFinals/完美逻辑不变 ✓
  • bestPropOf BFS 以选项 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→PNGsharp,小程序不支持 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/propIDsfl.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-convertmac 需 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 后 submitchoosing 已在 choose 开头置 true,连点被拦截 ✓;interactionDone 路径不经过动效直接 submit ✓
  • optionFx 判定 good 依赖 feedback_pros/cons 非空:素材生成前全部为空 → 所有选项只触发 fx-pop(弹跳)不跳色,行为一致可接受