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