feat: 前端朗读/占位视觉/拼音注音/音效合成基础组件
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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 }
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
if (this.speaking) {
|
||||
stopSpeak()
|
||||
this.speaking = false
|
||||
return
|
||||
}
|
||||
speak(this.text, this.file)
|
||||
this.speaking = true
|
||||
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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] }
|
||||
}
|
||||
Reference in New Issue
Block a user