From 5e9c7ba3582595bd7c0f265895a6b0f9b08eba94 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com>
Date: Thu, 13 Aug 2026 15:29:45 +0800
Subject: [PATCH] =?UTF-8?q?docs:=20M1.5=20=E6=B8=B8=E6=88=8F=E5=8C=96=20UI?=
=?UTF-8?q?=20=E8=AE=BE=E8=AE=A1=E3=80=81=E4=BB=BB=E5=8A=A1=E5=81=8F?=
=?UTF-8?q?=E5=B7=AE=E8=AE=B0=E5=BD=95=E3=80=81=E6=95=85=E4=BA=8B=E5=89=A7?=
=?UTF-8?q?=E5=9C=BA=E5=8C=96=E8=A7=84=E6=A0=BC=EF=BC=88README/=E6=8A=80?=
=?UTF-8?q?=E6=9C=AF=E8=AE=BE=E8=AE=A1/=E8=AE=A1=E5=88=92=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 7 +-
.../plans/2026-08-13-m1_5-interactions.md | 518 ++++++++++++++----
技术设计.md | 109 +++-
3 files changed, 521 insertions(+), 113 deletions(-)
diff --git a/README.md b/README.md
index b603e6a..f43a4f6 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
## 功能特性
-- **分支情境闯关**:36 计 × 每计 1-3 个现代情境,每个情境是一棵决策树——环境场景、人物、道具构成每个决策的要素,不同选择走向不同分支、产生不同结果,多条路线都能拿到最佳结果;每步即时反馈"智慧点评";**通关 = 到达最佳终局,完美 = 走完所有分支,完美才解锁下一关**;未通关走错路线扣教训分
+- **分支情境闯关**:36 计 × 每计 1-3 个现代情境,每个情境是一棵决策树——环境场景、人物、道具构成每个决策的要素,不同选择走向不同分支、产生不同结果,多条路线都能拿到最佳结果;每步即时反馈"智慧点评",**每个选择都解释好处/坏处(对比点评)**,结算页展示整局选择路线回顾;**通关 = 到达最佳终局,完美 = 走完所有分支,完美才解锁下一关**;未通关走错路线扣教训分
- **多样互动**:闯关不止选项点击——道具选择、步骤排序、动作过关(跳跃/攀爬/躲避/奔跑,一套组件配置驱动)、拖拽放置、接取收集、找线索、连线配对共 8 类互动形态按年龄适配;操作型互动成功/失败同样走向对应决策分支,教育逻辑不受互动形式影响
- **计策学堂**:每章开头先认识计策——卡通图文讲解名称、拼音、释义与"什么时候用它",再进情境践行,构成认知 → 践行 → 反思完整学习闭环
- **智慧总结**:每章闯关完成后一道反思题"哪个说法总结了这条计策",把直觉选择转成语言认知,答对奖励积分
@@ -59,7 +59,7 @@
| 家长中心 | 家长注册/登录(微信 openid 或手机号)、孩子档案管理(含每日学习限额)、学习报告、实物奖品确认、生活任务确认 |
| 计策学堂 | 每章开头的认知环节:图文/动画讲解计策名称、拼音、释义、使用时机 |
| 关卡地图 | 36 计按六套分组展示,星星进度、解锁状态、计策卡图标 |
-| 情境闯关 | 每关一棵决策树:环境场景 + 人物冲突 + 道具行为构成决策要素,入口 → 多样互动(选择/排序/动作过关-跳跃攀爬躲避奔跑/拖拽/找线索,按年龄适配)→ 多路线通往不同终局;通关=到达最佳终局,完美=走完全部分支解锁下一关;未通关走错扣教训分 |
+| 情境闯关 | 每关一棵决策树:环境场景 + 人物冲突 + 道具行为构成决策要素,入口 → 多样互动(选择/排序/动作过关-跳跃攀爬躲避奔跑/拖拽/找线索,按年龄适配)→ 多路线通往不同终局;通关=到达最佳终局,完美=走完全部分支解锁下一关;未通关走错扣教训分。已实现:选项选择 + 触控互动(道具选择/步骤排序/拖拽放置/找线索/连线配对,互动入口节点模型,成功进决策树/失败进失败终局)+ 对比点评(每个选项好处/坏处)+ 结算路线回顾 + 场景插画/角色立绘/道具图标视觉呈现 |
| 章末温故 | 每章完美后自动出现:随机抽 2-3 个已学计谋关卡重温(选项打乱),完成解锁下一章 + 奖励积分 |
| 智慧总结 | 每章完成后的反思题("哪个说法总结了这条计策"),答对 +5 分 |
| 生活践行 | 章完美后下发的真实生活小任务(家长引导完成并确认,得积分) |
@@ -68,7 +68,8 @@
| 积分 | 闯关得分、每日签到,积分流水明细 |
| 徽章 | 成就徽章(集卡、连续学习、通关数、复习、探索等),自动解锁 |
| 奖品 | 积分兑换虚拟/实物奖品,兑换记录与状态 |
-| 朗读拼音 | 拼音随内容一次性标注入库、朗读音频 TTS 生成(写时一次性,读取零计算),低龄档全量开启 |
+| 朗读拼音 | 拼音随内容一次性标注入库(逐字对齐 JSON,`common/pinyin.go` + 多音字词表),朗读音频优先播文件、无文件时浏览器语音降级朗读(H5),低龄档(4-6)全量开启;TTS 文件生成留后续接入 |
+| 视觉素材生成 | 场景插画/角色立绘/道具图标/计策卡图标由 `cmd/genasset/` 离线生成管线调用本机 oMLX(Qwen3.5-9B)生成 SVG 并转 PNG,打包入客户端随五端发布;未生成素材按 emoji 映射兜底 |
### 后台(Web)
diff --git a/docs/superpowers/plans/2026-08-13-m1_5-interactions.md b/docs/superpowers/plans/2026-08-13-m1_5-interactions.md
index a7daea7..b9e8a1d 100644
--- a/docs/superpowers/plans/2026-08-13-m1_5-interactions.md
+++ b/docs/superpowers/plans/2026-08-13-m1_5-interactions.md
@@ -147,7 +147,7 @@ func TestAnnotatePinyin_IdiomOverride(t *testing.T) {
func TestAnnotatePinyin_Alignment(t *testing.T) {
// 逐字对齐:标点/非汉字对应空串
pys := parse(t, AnnotatePinyin("小明,你好!"))
- want := []string{"xiǎo", "míng", "", "", "nǐ", "hǎo", ""}
+ want := []string{"xiǎo", "míng", "", "nǐ", "hǎo", ""}
if len(pys) != len(want) {
t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys)
}
@@ -357,7 +357,7 @@ func EnsureColumn(ctx context.Context, table, col, sqlType string) {
- [ ] **Step 7: 编译验证 + 迁移验证(对现有库)**
Run: `go build ./... && go vet ./...`
-Run: `go run main.go` 观察日志出现 `migrate: level.scene_content_pinyin 已添加` 等 12 行(含 node_option 的 feedback_pros / feedback_cons);`sqlite3 data/36wisdom.db "PRAGMA table_info(node_option);"` 含 `content_pinyin`、`feedback_pros`、`feedback_cons`。启动后 Ctrl-C 停掉。
+Run: `go run main.go` 观察日志出现 `migrate: level.scene_content_pinyin 已添加` 等 12 行(含 node_option 的 feedback_pros / feedback_cons);`sqlite3 data/36wisdom.db "PRAGMA table_info(node_option);"` 含 `text_pinyin`、`feedback_pros`、`feedback_cons`。启动后 Ctrl-C 停掉。
- [ ] **Step 8: 提交**
@@ -529,7 +529,7 @@ git commit -m "feat: 种子拼音写时标注 + 启动补标"
**模型**(技术设计.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`)
+- [x] **Step 1: 写失败单测 `biz/service/seed/interaction_test.go`**(纯函数 `planInteraction`)
```go
package seed
@@ -538,27 +538,28 @@ import (
"encoding/json"
"testing"
+ "github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
- "github.com/gogf/gf/v2/util/gconv"
"36wisdom/biz/consts"
)
+// gdb.Record = map[string]gdb.Value = map[string]*gvar.Var(gf v2.10.2),字面量一律 gvar.New
// 构造与 level 1 同构的小树:entry(1) → 决策(2) → best(3)/good(4)/fail(5)
func testTree() (nodes []gdb.Record, options []gdb.Record) {
nodes = []gdb.Record{
- {"id": gconv.Int64(1), "character_id": gconv.Int64(11), "result_type": gconv.Int(0)},
- {"id": gconv.Int64(2), "character_id": gconv.Int64(12), "result_type": gconv.Int(0)},
- {"id": gconv.Int64(3), "character_id": gconv.Int64(12), "result_type": gconv.Int(3)},
- {"id": gconv.Int64(4), "character_id": gconv.Int64(12), "result_type": gconv.Int(2)},
- {"id": gconv.Int64(5), "character_id": gconv.Int64(11), "result_type": gconv.Int(1)},
+ {"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": gconv.Int64(101), "node_id": gconv.Int64(1), "prop_id": gconv.Int64(21), "next_node_id": gconv.Int64(2), "sort_order": gconv.Int(1)},
- {"id": gconv.Int64(102), "node_id": gconv.Int64(1), "prop_id": gconv.Int64(22), "next_node_id": gconv.Int64(5), "sort_order": gconv.Int(2)},
- {"id": gconv.Int64(103), "node_id": gconv.Int64(2), "prop_id": gconv.Int64(21), "next_node_id": gconv.Int64(3), "sort_order": gconv.Int(1)},
- {"id": gconv.Int64(104), "node_id": gconv.Int64(2), "prop_id": gconv.Int64(23), "next_node_id": gconv.Int64(4), "sort_order": gconv.Int(2)},
- {"id": gconv.Int64(105), "node_id": gconv.Int64(2), "prop_id": gconv.Int64(24), "next_node_id": gconv.Int64(5), "sort_order": gconv.Int(3)},
+ {"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
}
@@ -652,12 +653,12 @@ func TestInteractionConfig_JSON(t *testing.T) {
}
```
-- [ ] **Step 2: 跑测试确认失败**
+- [x] **Step 2: 跑测试确认失败**
Run: `go test ./biz/service/seed/ -run TestPlanInteraction -v`
Expected: FAIL(`planInteraction` / `interactionPlan` / `configItem` / `interactionConfigJSON` 未定义)
-- [ ] **Step 3: 实现 `biz/service/seed/interaction.go`**
+- [x] **Step 3: 实现 `biz/service/seed/interaction.go`**
```go
package seed
@@ -1005,7 +1006,7 @@ func uniqueInt64(in []int64) []int64 {
}
```
-- [ ] **Step 4: 打开 `EnsureSeeded` 里的 `ensureInteractions(ctx)` 调用**
+- [x] **Step 4: 打开 `EnsureSeeded` 里的 `ensureInteractions(ctx)` 调用**
修改 `seed.go`(Task 3 Step 3 留的注释行):
@@ -1014,12 +1015,12 @@ func uniqueInt64(in []int64) []int64 {
ensureInteractions(ctx)
```
-- [ ] **Step 5: 跑单测确认通过**
+- [x] **Step 5: 跑单测确认通过**
Run: `go test ./biz/service/seed/ -run TestPlanInteraction -v`
Expected: PASS(6 个用例全绿)
-- [ ] **Step 6: 编译 + 启动验证(现有库补生成)**
+- [x] **Step 6: 编译 + 启动验证(现有库补生成)**
Run: `go build ./... && go vet ./...`
Run: `go run main.go`;Ctrl-C 停掉后再 `sqlite3 data/36wisdom.db` 验证:
@@ -1032,13 +1033,28 @@ SELECT text, next_node_id, sort_order FROM node_option WHERE node_id = (SELECT i
预期:`text` 为「成功了 / 没成功」两行,`sort_order` 1/2。
-- [ ] **Step 7: 提交**
+- [x] **Step 7: 提交**
```bash
git add biz/service/seed/interaction.go biz/service/seed/interaction_test.go biz/service/seed/seed.go
git commit -m "feat: 互动入口节点程序化生成(触控5种,成功/失败双出口,choose零改动)"
```
+**实现偏差记录(2026-08-13,两轮审查均通过)**:
+- 结构体字段小写化(计划 Step 3 为大写,Step 1 测试用 p.answer/p.items 小写访问)→ 实现按测试为验收规格小写化,并加自定义 MarshalJSON 输出 kind/items/answer 等 JSON 键(encoding/json 忽略未导出字段)
+- pairsOf 取"最后一个带道具选项"(计划注释"小明→扫帚22"与"首个"代码自相矛盾,测试断言 22)并修复 persons 恒为空的死代码(seen 预标记 bug);persons 现带 Name
+- bestProp==0 时回退 items[0].ID 而非返回 nil(计划测试 FallbackToPropPick 要求 prop_pick 不退关;真实数据无此场景)
+- 测试数据 gvar.New 包装:gf v2.10.2 中 `gdb.Record = map[string]*gvar.Var`(gdb.go:678),gconv.Int64 字面量无法编译
+- Step 6 预期修正:互动节点数 = **14 而非 36**——种子数据仅 14 关存在带 prop 的选项(其余 22 关零道具),planInteraction 按设计返回 nil 跳过;数据驱动行为,非缺陷。M1.5 收尾时如需 36 关全互动,须先补种子道具数据
+- 审查后修复 commit 4744ba9:persons 补名字、seed 缓存一致性注释(启动期无读者不清缓存,同 annotateAll 先例)、元素名查询失败 warn
+
+**Task 8 偏差记录(审查后修复 7f7cf49)**:
+- 游戏化 CSS 补全 47 行(进度点/场景横幅/漂浮装饰/连击徽章/角色卡/选项 c1-c4 四色边框/option-icon 定宽高/fb-in 错峰动画/顶部开关)——原计划 style 段缺失这些类导致进度点不可见、素材落地后 image 默认 320×240 撑爆布局
+- OptionVO 透出 audio(node_option.audio 列已存在零迁移)——前端"听点评"可播文件,避免静默降级 TTS
+
+**Task 9 走查修复(RubyText 布局)**:
+- RubyText 外层 text → view + flex-wrap(原 text>text inline-flex 在 H5 基线错位、小程序 text 组件不支持 flex):拼音不再对齐在汉字正上方。使用处均为独立文本块(scene-text/node-content/option-text/fb-option/learn-*),无行内混排,块级化安全
+
---
### Task 5: 拼音字段透出(service / dto / controller)
@@ -1165,6 +1181,7 @@ function speakBrowser(text) {
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
}
@@ -1203,13 +1220,15 @@ const SCENE_MAP = {
商店: { 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: '#83e3e8' }, 泳池: { emoji: '🏊', color: '#83e3e8' },
+ 外婆家: { emoji: '🏡', color: '#ffd08a' }
}
const PROP_MAP = {
画: '🖼️', 书: '📖', 剪刀: '✂️', 长杆: '🥢', 扫帚: '🧹', 绳子: '🪢',
球: '⚽', 玩具: '🧸', 手机: '📱', 钥匙: '🔑', 雨伞: '☂️', 帽子: '🧢',
凳子: '🪑', 书包: '🎒', 水杯: '🥤', 饼干: '🍪', 铅笔: '✏️', 纸: '📄',
- 胶水: '🧴', 篮子: '🧺', 喇叭: '📢', 鼓: '🥁', 娃娃: '🎎', 积木: '🧱'
+ 胶水: '🧴', 篮子: '🧺', 喇叭: '📢', 鼓: '🥁', 娃娃: '🎎', 积木: '🧱',
+ 调色盘: '🎨', 蜡笔: '✏️', 电池: '🔋', 瓜子: '🥜', 苹果: '🍎', 小汽车: '🚗'
}
function pick(list, key, fallback) {
@@ -1225,7 +1244,8 @@ export function sceneVisual(name) {
}
export function propVisual(name) {
- return { emoji: pick(PROP_MAP, name || '', '🎁').emoji || '🎁', color: '#fff0e5' }
+ // PROP_MAP 值是纯字符串 emoji,不能取 .emoji(会得 undefined 恒兜底 🎁)
+ return { emoji: pick(PROP_MAP, name || '', '🎁'), color: '#fff0e5' }
}
const PERSON_COLORS = ['#ff9a3d', '#ff6b6b', '#4ecdc4', '#6c8cff', '#a58cff', '#ffb347']
@@ -1340,18 +1360,22 @@ export default {
label: { type: String, default: '听一听' }
},
data() {
- return { speaking: false }
+ 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)
- this.speaking = true
- setTimeout(() => (this.speaking = false), 3000)
+ if (this.text || this.file) {
+ this.speaking = true
+ this.timer = setTimeout(() => (this.speaking = false), 3000)
+ }
}
},
beforeUnmount() {
@@ -1381,15 +1405,72 @@ export default {
```
-- [ ] **Step 6: H5 编译检查**
+- [ ] **Step 6: 音效合成 `ui-src/src/utils/sound.js`**(WebAudio 合成短音效,无音频文件依赖;小程序端 WebAudio 受限时静默降级)
+
+```js
+// 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 7: 提交**
+- [ ] **Step 8: 提交**
```bash
cd .. && git add ui-src/src/utils/ ui-src/src/components/RubyText.vue ui-src/src/components/SpeakButton.vue
-git commit -m "feat: 前端朗读/占位视觉/拼音注音基础组件"
+git commit -m "feat: 前端朗读/占位视觉/拼音注音/音效合成基础组件"
```
---
@@ -1412,10 +1493,10 @@ git commit -m "feat: 前端朗读/占位视觉/拼音注音基础组件"
v-for="(it, i) in items"
:key="it.id"
class="item"
- :class="{ picked: picked === i }"
+ :class="{ picked: picked === i, win: winIdx === i, wrong: wrongIdx === i }"
@click="pick(it, i)"
>
- {{ visual.emoji }}
+ {{ propVisual(it.name).emoji }}
{{ it.name }}
@@ -1425,31 +1506,40 @@ git commit -m "feat: 前端朗读/占位视觉/拼音注音基础组件"
```
@@ -1627,15 +1739,15 @@ export default {
```vue
-
+
+
按住道具拖一拖,按顺序排好
{{ i + 1 }}
@@ -1648,11 +1760,12 @@ export default {
```
-- [ ] **Step 3: map.vue 学堂弹层增强**(拼音注音 + 朗读)
+- [ ] **Step 3: 后端 OptionVO 透出 audio**(`node_option.audio` 列已存在,零迁移;支撑"听点评"文件朗读而非 TTS 降级)
+
+`biz/service/level.go`:`Option` 结构体加 `Audio string` 字段,`buildNode` 内选项组装处追加 `Audio: o["audio"].String()`:
+
+```go
+type Option struct {
+ OptionId int64
+ Text string
+ TextPinyin string
+ FeedbackPros string
+ FeedbackCons string
+ Audio string
+ Prop *Element
+}
+```
+
+`buildNode` 的 `node.Options = append(...)` 处改为:
+
+```go
+ node.Options = append(node.Options, &Option{
+ OptionId: o["id"].Int64(),
+ Text: o["text"].String(),
+ TextPinyin: o["text_pinyin"].String(),
+ FeedbackPros: o["feedback_pros"].String(),
+ FeedbackCons: o["feedback_cons"].String(),
+ Audio: o["audio"].String(),
+ Prop: elements[o["prop_id"].Int64()],
+ })
+```
+
+`biz/model/dto/level.go`:`OptionVO` 加字段:
+
+```go
+type OptionVO struct {
+ OptionId int64 `json:"option_id"`
+ Text string `json:"text"`
+ TextPinyin string `json:"text_pinyin"`
+ FeedbackPros string `json:"feedback_pros"`
+ FeedbackCons string `json:"feedback_cons"`
+ Audio string `json:"audio"`
+ Prop *ElementVO `json:"prop"`
+}
+```
+
+`biz/controller/level.go`:`nodeVO` 的选项循环追加映射:
+
+```go
+ 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 学堂弹层增强**(拼音注音 + 朗读)
在 `