diff --git a/ui-src/src/components/RubyText.vue b/ui-src/src/components/RubyText.vue
new file mode 100644
index 0000000..48ef1ae
--- /dev/null
+++ b/ui-src/src/components/RubyText.vue
@@ -0,0 +1,54 @@
+
+
+
+ {{ ch.p }}
+ {{ ch.c }}
+
+
+
+
+
+
+
diff --git a/ui-src/src/components/SpeakButton.vue b/ui-src/src/components/SpeakButton.vue
new file mode 100644
index 0000000..2f73945
--- /dev/null
+++ b/ui-src/src/components/SpeakButton.vue
@@ -0,0 +1,57 @@
+
+
+ {{ speaking ? '🔊' : '🔈' }}
+ {{ label }}
+
+
+
+
+
+
diff --git a/ui-src/src/utils/pinyin.js b/ui-src/src/utils/pinyin.js
new file mode 100644
index 0000000..be4f852
--- /dev/null
+++ b/ui-src/src/utils/pinyin.js
@@ -0,0 +1,20 @@
+// 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
+}
diff --git a/ui-src/src/utils/sound.js b/ui-src/src/utils/sound.js
new file mode 100644
index 0000000..5fe1aad
--- /dev/null
+++ b/ui-src/src/utils/sound.js
@@ -0,0 +1,52 @@
+// 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
+}
diff --git a/ui-src/src/utils/speech.js b/ui-src/src/utils/speech.js
new file mode 100644
index 0000000..6efa699
--- /dev/null
+++ b/ui-src/src/utils/speech.js
@@ -0,0 +1,43 @@
+let innerAudio = null
+let uttering = false
+
+function playFile(url) {
+ stopSpeak()
+ if (!innerAudio) innerAudio = uni.createInnerAudioContext()
+ innerAudio.stop()
+ innerAudio.src = url
+ innerAudio.play()
+}
+
+function speakBrowser(text) {
+ stopSpeak()
+ // H5 专用:浏览器原生语音,零成本朗读(小程序端后续接 TTS 文件)
+ if (typeof window === 'undefined' || !window.speechSynthesis) return
+ const u = new SpeechSynthesisUtterance(text)
+ u.lang = 'zh-CN'
+ u.rate = 0.9
+ window.speechSynthesis.speak(u)
+ uttering = true
+}
+
+// speak 朗读一段文本:有音频文件优先播文件,否则浏览器语音降级
+export function speak(text, fileUrl) {
+ if (!text) return
+ if (fileUrl) {
+ playFile(fileUrl)
+ return
+ }
+ speakBrowser(text)
+}
+
+export function stopSpeak() {
+ if (innerAudio) innerAudio.stop()
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
+ window.speechSynthesis.cancel()
+ }
+ uttering = false
+}
+
+export function isSpeaking() {
+ return uttering
+}
diff --git a/ui-src/src/utils/visual.js b/ui-src/src/utils/visual.js
new file mode 100644
index 0000000..ac78768
--- /dev/null
+++ b/ui-src/src/utils/visual.js
@@ -0,0 +1,38 @@
+const SCENE_MAP = {
+ 幼儿园: { emoji: '🏫', color: '#ffd08a' }, 教室: { emoji: '🏫', color: '#ffd08a' },
+ 公园: { emoji: '🌳', color: '#a8e6a3' }, 花园: { emoji: '🌷', color: '#f4b8d0' },
+ 家里: { emoji: '🏠', color: '#ffb347' }, 小区: { emoji: '🏘️', color: '#c9a7eb' },
+ 操场: { emoji: '⚽', color: '#7ec8e3' }, 球场: { emoji: '⚽', color: '#7ec8e3' },
+ 商店: { emoji: '🏪', color: '#f6d365' }, 超市: { emoji: '🛒', color: '#f6d365' },
+ 游乐场: { emoji: '🎠', color: '#ff9a9e' }, 路上: { emoji: '🛤️', color: '#d5c4ae' },
+ 食堂: { emoji: '🍚', color: '#ffe0ac' }, 图书馆: { emoji: '📚', color: '#b8a6d9' },
+ 海滩: { emoji: '🏖️', color: '#83e3e8' }, 泳池: { emoji: '🏊', color: '#83e3e8' }
+}
+const PROP_MAP = {
+ 画: '🖼️', 书: '📖', 剪刀: '✂️', 长杆: '🥢', 扫帚: '🧹', 绳子: '🪢',
+ 球: '⚽', 玩具: '🧸', 手机: '📱', 钥匙: '🔑', 雨伞: '☂️', 帽子: '🧢',
+ 凳子: '🪑', 书包: '🎒', 水杯: '🥤', 饼干: '🍪', 铅笔: '✏️', 纸: '📄',
+ 胶水: '🧴', 篮子: '🧺', 喇叭: '📢', 鼓: '🥁', 娃娃: '🎎', 积木: '🧱'
+}
+
+function pick(list, key, fallback) {
+ for (const k of Object.keys(list)) {
+ if (key.includes(k)) return list[k]
+ }
+ return fallback
+}
+
+export function sceneVisual(name) {
+ const v = pick(SCENE_MAP, name || '', { emoji: '🏞️', color: '#cde8c4' })
+ return { emoji: v.emoji, color: v.color }
+}
+
+export function propVisual(name) {
+ return { emoji: pick(PROP_MAP, name || '', '🎁').emoji || '🎁', color: '#fff0e5' }
+}
+
+const PERSON_COLORS = ['#ff9a3d', '#ff6b6b', '#4ecdc4', '#6c8cff', '#a58cff', '#ffb347']
+
+export function personVisual(id) {
+ return { color: PERSON_COLORS[Number(id || 0) % PERSON_COLORS.length] }
+}