From e06548fa3cf56e14965a2c4bfcef56d0dc9133c6 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 14:45:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E6=9C=97=E8=AF=BB/?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E8=A7=86=E8=A7=89/=E6=8B=BC=E9=9F=B3?= =?UTF-8?q?=E6=B3=A8=E9=9F=B3/=E9=9F=B3=E6=95=88=E5=90=88=E6=88=90?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-src/src/components/RubyText.vue | 54 +++++++++++++++++++++++++ ui-src/src/components/SpeakButton.vue | 57 +++++++++++++++++++++++++++ ui-src/src/utils/pinyin.js | 20 ++++++++++ ui-src/src/utils/sound.js | 52 ++++++++++++++++++++++++ ui-src/src/utils/speech.js | 43 ++++++++++++++++++++ ui-src/src/utils/visual.js | 38 ++++++++++++++++++ 6 files changed, 264 insertions(+) create mode 100644 ui-src/src/components/RubyText.vue create mode 100644 ui-src/src/components/SpeakButton.vue create mode 100644 ui-src/src/utils/pinyin.js create mode 100644 ui-src/src/utils/sound.js create mode 100644 ui-src/src/utils/speech.js create mode 100644 ui-src/src/utils/visual.js 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 @@ + + + + + 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 @@ + + + + + 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] } +}