feat: 闯关页场景/拼音/朗读/互动分发集成,学堂弹层注音朗读

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:02:22 +08:00
co-authored by Claude Opus 4.7
parent 5c4335f5e5
commit 55463a9cc9
4 changed files with 189 additions and 185 deletions
+3 -1
View File
@@ -44,7 +44,7 @@
<script>
import { api } from '../../api/index.js'
import { setChildId } from '../../store/user.js'
import { setChildId, setChildAge } from '../../store/user.js'
export default {
data() {
@@ -70,6 +70,7 @@ export default {
},
enter(c) {
setChildId(c.child_id)
setChildAge(c.age_group)
uni.reLaunch({ url: '/pages/map/map' })
},
async create() {
@@ -83,6 +84,7 @@ export default {
age_group: this.form.ageGroup
})
setChildId(data.child_id)
setChildAge(this.form.ageGroup)
uni.reLaunch({ url: '/pages/map/map' })
} catch (e) {}
}
+9 -8
View File
@@ -29,9 +29,9 @@
<view v-if="showLearn && strategy" class="mask" @click="closeLearn">
<view class="learn-panel card" @click.stop>
<view class="learn-title">{{ strategy.name }} <text class="learn-pinyin">{{ strategy.pinyin }}</text></view>
<view class="learn-meaning">{{ strategy.meaning }}</view>
<view v-if="strategy.teach_audio" class="audio-btn" @click="playAudio(strategy.teach_audio)">🔊 听一听</view>
<view class="learn-content">{{ strategy.teach_content }}</view>
<view class="learn-meaning"><RubyText :text="strategy.meaning" :pinyin="strategy.meaning_pinyin" :show="showPinyin" /></view>
<view class="learn-content"><RubyText :text="strategy.teach_content" :pinyin="strategy.teach_content_pinyin" :show="showPinyin" /></view>
<SpeakButton :text="strategy.teach_content" :file="strategy.teach_audio" label="读一读学堂" />
<view v-if="strategy.levels && strategy.levels.length" class="level-list">
<view
@@ -56,7 +56,9 @@
<script>
import { api } from '../../api/index.js'
import { getChildId } from '../../store/user.js'
import { getChildId, getChildAge } from '../../store/user.js'
import RubyText from '../../components/RubyText.vue'
import SpeakButton from '../../components/SpeakButton.vue'
const GROUP_DESC = {
1: '胜利要用巧办法',
@@ -68,12 +70,14 @@ const GROUP_DESC = {
}
export default {
components: { RubyText, SpeakButton },
data() {
return {
groups: [],
showLearn: false,
strategy: null,
childId: getChildId()
childId: getChildId(),
showPinyin: getChildAge() === '4-6'
}
},
onShow() {
@@ -121,9 +125,6 @@ export default {
closeLearn() {
this.showLearn = false
},
playAudio(src) {
uni.showToast({ title: '音频资源暂未配置', icon: 'none' })
},
enterLevel(lv) {
if (!lv.unlocked) {
uni.showToast({ title: '关卡未解锁', icon: 'none' })
+168 -176
View File
@@ -3,44 +3,93 @@
<view class="topbar">
<view class="back" @click="goBack"></view>
<view class="top-title">{{ detail ? detail.title : '' }}</view>
<view class="top-spacer"></view>
<view class="top-actions">
<view class="py-toggle" @click="showPinyin = !showPinyin">{{ showPinyin ? '拼音开' : '拼音关' }}</view>
<view class="sound-toggle" @click="toggleSound">{{ soundOn ? '🔊' : '🔇' }}</view>
</view>
</view>
<view v-if="detail" class="content">
<!-- 进度点每走一步点亮一个 -->
<view class="progress-dots">
<view v-for="i in 6" :key="i" class="dot" :class="{ on: i <= stepCount }"></view>
</view>
<!-- 场景面板环境 + 情境描述 + 朗读 + 漂浮装饰 -->
<view class="scene-card card" :style="{ background: scene.color + '44' }">
<image v-if="sceneImg" class="scene-img" :src="sceneImg" mode="aspectFill" />
<view class="scene-float f1"></view>
<view class="scene-float f2"></view>
<view class="scene-head">
<view class="scene-emoji">{{ scene.emoji }}</view>
<view class="scene-name">{{ detail.scene ? detail.scene.name : '' }}</view>
<view v-if="combo > 1" class="combo-badge">🔥 ×{{ combo }}</view>
</view>
<RubyText class="scene-text" :text="detail.scene_content" :pinyin="detail.scene_content_pinyin" :show="showPinyin" />
<SpeakButton class="scene-speak" :text="detail.scene_content" :file="detail.scene_audio" label="读一读情境" />
</view>
<!-- 节点互动分发 -->
<view v-if="curNode" class="node card">
<view class="node-title">{{ curNode.title }}</view>
<view v-if="curNode.character" class="character">
<view class="char-avatar">{{ curNode.character.name.slice(0, 1) }}</view>
<view class="char-name">{{ curNode.character.name }}</view>
<view class="char-card">
<image v-if="charImg" class="char-img" :src="charImg" mode="aspectFit" />
<view v-else class="char-avatar" :style="{ background: personColor(curNode.character.id) }">{{ curNode.character.name.slice(0, 1) }}</view>
<view class="char-mood">{{ mood }}</view>
</view>
<view class="char-info">
<view class="char-name">{{ curNode.character.name }}</view>
<SpeakButton :text="curNode.character.name" :file="curNode.character.audio" label="读一读" />
</view>
</view>
<view class="node-content">{{ curNode.content }}</view>
<view class="node-content">
<RubyText :text="curNode.content" :pinyin="curNode.content_pinyin" :show="showPinyin" />
</view>
<SpeakButton class="node-speak" :text="curNode.content" :file="curNode.audio" label="读一读" />
<view v-if="curNode.interaction_type === 1" class="options">
<!-- 互动组件触控 5 -->
<PropPick v-if="curNode.interaction_type === 2" :config="curNode.config" @done="interactionDone" />
<FindSpot v-else-if="curNode.interaction_type === 7" :config="curNode.config" @done="interactionDone" />
<DragPlace v-else-if="curNode.interaction_type === 5" :config="curNode.config" @done="interactionDone" />
<StepSort v-else-if="curNode.interaction_type === 3" :config="curNode.config" @done="interactionDone" />
<LinkMatch v-else-if="curNode.interaction_type === 8" :config="curNode.config" @done="interactionDone" />
<!-- 选项选择type 1 -->
<view v-else-if="curNode.interaction_type === 1" class="options">
<view
v-for="(o, idx) in curNode.options"
:key="o.option_id"
class="option"
:class="{ disabled: choosing }"
:class="['c' + (idx + 1), { disabled: choosing }]"
@click="choose(o, idx)"
>
<view class="option-mark">{{ ['A', 'B', 'C', 'D'][idx] }}</view>
<view class="option-text">{{ o.text }}</view>
<view v-if="o.prop" class="option-icon">
<image v-if="o.prop.image" class="option-icon-img" :src="o.prop.image" mode="aspectFit" />
<text v-else class="option-icon-emoji">{{ propEmoji(o.prop.name) }}</text>
</view>
<view class="option-text">
<RubyText :text="o.text" :pinyin="o.text_pinyin" :show="showPinyin" />
</view>
</view>
</view>
<view v-else class="soon card">
该互动玩法类型 {{ curNode.interaction_type }}敬请期待
</view>
<view v-else class="soon card">该互动玩法类型 {{ curNode.interaction_type }}敬请期待</view>
</view>
</view>
<!-- 反馈面板你的选择 + 好处 + 对比对比点评三段式 -->
<view v-if="feedback" class="mask">
<view class="feedback card">
<view class="fb-title">你的选择</view>
<view class="fb-option">{{ feedback.text }}</view>
<view class="fb-option"><RubyText :text="feedback.text" :pinyin="feedback.textPinyin" :show="showPinyin" /></view>
<view v-if="feedback.pros" class="fb-line fb-pros fb-in"><text class="fb-mark"></text>{{ feedback.pros }}</view>
<view v-if="feedback.cons" class="fb-line fb-cons fb-in d2"><text class="fb-mark"></text>{{ feedback.cons }}</view>
<view v-if="feedback.prop" class="fb-prop">
<view class="fb-prop-name">{{ feedback.prop.name }}</view>
<view class="fb-prop-desc">{{ feedback.prop.description }}</view>
<view v-if="feedback.prop.description" class="fb-prop-desc">{{ feedback.prop.description }}</view>
</view>
<SpeakButton :text="feedback.text" :file="feedback.audio" label="听点评" />
<view class="btn-primary fb-continue" @click="continuePlay">继续 </view>
</view>
</view>
@@ -49,11 +98,21 @@
<script>
import { api } from '../../api/index.js'
import { getChildId } from '../../store/user.js'
import { getChildId, getChildAge } from '../../store/user.js'
import { sceneVisual, personVisual, propVisual } from '../../utils/visual.js'
import { playSound, isSoundEnabled, setSoundEnabled } from '../../utils/sound.js'
import RubyText from '../../components/RubyText.vue'
import SpeakButton from '../../components/SpeakButton.vue'
import PropPick from '../../components/interactions/PropPick.vue'
import FindSpot from '../../components/interactions/FindSpot.vue'
import DragPlace from '../../components/interactions/DragPlace.vue'
import StepSort from '../../components/interactions/StepSort.vue'
import LinkMatch from '../../components/interactions/LinkMatch.vue'
const RESULT_KEY = '36wisdom_result'
export default {
components: { RubyText, SpeakButton, PropPick, FindSpot, DragPlace, StepSort, LinkMatch },
data() {
return {
childId: getChildId(),
@@ -61,13 +120,32 @@ export default {
detail: null,
curNode: null,
feedback: null,
choosing: false
choosing: false,
showPinyin: getChildAge() === '4-6',
routeSteps: [],
mood: '🤔',
combo: 0,
soundOn: isSoundEnabled()
}
},
onLoad(options) {
this.levelId = Number(options.level_id || 0)
this.load()
},
computed: {
scene() {
return sceneVisual(this.detail && this.detail.scene ? this.detail.scene.name : '')
},
sceneImg() {
return this.detail && this.detail.scene && this.detail.scene.image ? this.detail.scene.image : ''
},
charImg() {
return this.curNode && this.curNode.character && this.curNode.character.image ? this.curNode.character.image : ''
},
stepCount() {
return Math.min(this.routeSteps.length + 1, 6)
}
},
methods: {
async load() {
try {
@@ -81,20 +159,54 @@ export default {
goBack() {
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/map/map' }) })
},
async choose(option, idx) {
personColor(id) {
return personVisual(id).color
},
// 触控互动判定完成:提交成功/失败出口选项
interactionDone({ success }) {
this.mood = success ? '😊' : '😢'
const opt = success ? this.curNode.options[0] : this.curNode.options[1]
if (opt) this.submit(opt)
},
propEmoji(name) {
return propVisual(name).emoji
},
toggleSound() {
this.soundOn = !this.soundOn
setSoundEnabled(this.soundOn)
},
choose(o) {
if (this.choosing) return
playSound('click')
this.submit(o)
},
async submit(o) {
this.choosing = true
try {
const data = await api.levelChoose(this.childId, this.levelId, this.curNode.node_id, option.option_id)
const data = await api.levelChoose(this.childId, this.levelId, this.curNode.node_id, o.option_id)
this.routeSteps.push({ text: o.text, pros: o.feedback_pros || '' })
if (data.next) {
this.feedback = { text: option.text, prop: option.prop, next: data.next }
this.feedback = {
text: o.text,
textPinyin: o.text_pinyin,
prop: o.prop,
audio: o.audio,
pros: o.feedback_pros,
cons: o.feedback_cons,
next: data.next
}
this.mood = o.feedback_cons ? '😢' : '😊'
this.combo = o.feedback_cons ? 0 : Math.min(this.combo + 1, 9)
playSound(o.feedback_cons ? 'bad' : 'good')
} else if (data.final) {
uni.setStorageSync(RESULT_KEY, {
level_id: this.levelId,
title: this.detail.title,
scene_name: this.detail.scene ? this.detail.scene.name : '',
route_steps: this.routeSteps,
final: data.final
})
playSound('finish')
uni.redirectTo({ url: '/pages/result/result' })
}
} catch (e) {
@@ -105,169 +217,49 @@ export default {
continuePlay() {
this.curNode = this.feedback.next
this.feedback = null
this.mood = '🤔'
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
padding-top: calc(24rpx + env(safe-area-inset-top));
}
.back {
font-size: 56rpx;
color: #ff6b35;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
}
.top-title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #5b4636;
}
.top-spacer {
width: 72rpx;
}
.content {
flex: 1;
padding: 24rpx 32rpx 60rpx;
}
.node {
padding: 48rpx 40rpx;
}
.node-title {
font-size: 38rpx;
font-weight: 800;
color: #ff6b35;
margin-bottom: 24rpx;
}
.character {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.char-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, #4ecdc4, #6c8cff);
color: #fff;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.char-name {
font-size: 28rpx;
font-weight: 700;
color: #7a6248;
}
.node-content {
font-size: 32rpx;
line-height: 1.9;
color: #5b4636;
margin-bottom: 40rpx;
}
.options {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.option {
display: flex;
align-items: center;
background: #f8f2e8;
border: 4rpx solid transparent;
border-radius: 24rpx;
padding: 26rpx 28rpx;
}
.option.disabled {
opacity: 0.6;
}
.option:active {
border-color: #ff9a3d;
}
.option-mark {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #ff9a3d;
color: #fff;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.option-text {
font-size: 30rpx;
font-weight: 600;
color: #5b4636;
}
.soon {
padding: 40rpx;
text-align: center;
color: #a08c74;
background: #f8f2e8;
}
.mask {
position: fixed;
inset: 0;
background: rgba(91, 70, 54, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.feedback {
width: 82%;
padding: 48rpx 40rpx;
}
.fb-title {
font-size: 26rpx;
color: #a08c74;
margin-bottom: 16rpx;
}
.fb-option {
font-size: 34rpx;
font-weight: 800;
color: #ff6b35;
margin-bottom: 24rpx;
}
.fb-prop {
background: #fff7ec;
border-radius: 20rpx;
padding: 24rpx 28rpx;
margin-bottom: 32rpx;
}
.fb-prop-name {
font-size: 30rpx;
font-weight: 700;
color: #7a6248;
margin-bottom: 8rpx;
}
.fb-prop-desc {
font-size: 26rpx;
line-height: 1.7;
color: #a08c74;
}
.fb-continue {
font-size: 30rpx;
}
.page { min-height: 100vh; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; padding: 24rpx 32rpx; padding-top: calc(24rpx + env(safe-area-inset-top)); }
.back { font-size: 56rpx; color: #ff6b35; width: 72rpx; height: 72rpx; display: flex; align-items: center; }
.top-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 700; color: #5b4636; }
.py-toggle { font-size: 24rpx; color: #ff6b35; background: #fff0e5; border-radius: 30rpx; padding: 8rpx 20rpx; }
.content { flex: 1; padding: 24rpx 32rpx 60rpx; }
.scene-card { padding: 32rpx; margin-bottom: 24rpx; }
.scene-head { display: flex; align-items: center; margin-bottom: 16rpx; }
.scene-emoji { font-size: 64rpx; margin-right: 20rpx; }
.scene-name { font-size: 32rpx; font-weight: 800; color: #5b4636; }
.scene-text { margin-bottom: 20rpx; }
.scene-speak { margin-top: 8rpx; }
.node { padding: 48rpx 40rpx; }
.node-title { font-size: 38rpx; font-weight: 800; color: #ff6b35; margin-bottom: 24rpx; }
.character { display: flex; align-items: center; margin-bottom: 24rpx; }
.char-avatar { width: 72rpx; height: 72rpx; border-radius: 50%; color: #fff; font-size: 32rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-right: 20rpx; }
.char-name { font-size: 28rpx; font-weight: 700; color: #7a6248; margin-right: 20rpx; }
.node-content { font-size: 32rpx; line-height: 1.9; color: #5b4636; margin-bottom: 24rpx; }
.node-speak { margin-bottom: 32rpx; }
.options { display: flex; flex-direction: column; gap: 20rpx; }
.option { display: flex; align-items: center; background: #f8f2e8; border: 4rpx solid transparent; border-radius: 24rpx; padding: 26rpx 28rpx; }
.option.disabled { opacity: 0.6; }
.option:active { border-color: #ff9a3d; }
.option-mark { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.option-text { font-size: 30rpx; font-weight: 600; color: #5b4636; }
.soon { padding: 40rpx; text-align: center; color: #a08c74; background: #f8f2e8; }
.mask { position: fixed; inset: 0; background: rgba(91, 70, 54, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10; }
.feedback { width: 82%; padding: 48rpx 40rpx; }
.fb-title { font-size: 26rpx; color: #a08c74; margin-bottom: 16rpx; }
.fb-option { font-size: 34rpx; font-weight: 800; color: #ff6b35; margin-bottom: 24rpx; }
.fb-line { font-size: 28rpx; line-height: 1.7; border-radius: 16rpx; padding: 16rpx 24rpx; margin-bottom: 16rpx; }
.fb-pros { background: #e8f7f3; color: #1fa98f; }
.fb-cons { background: #fff0e5; color: #e07b39; }
.fb-mark { font-weight: 800; margin-right: 8rpx; }
.fb-prop { background: #fff7ec; border-radius: 20rpx; padding: 24rpx 28rpx; margin-bottom: 32rpx; }
.fb-prop-name { font-size: 30rpx; font-weight: 700; color: #7a6248; margin-bottom: 8rpx; }
.fb-prop-desc { font-size: 26rpx; line-height: 1.7; color: #a08c74; }
.fb-continue { margin-top: 24rpx; font-size: 30rpx; }
</style>
+9
View File
@@ -2,6 +2,7 @@ import { reactive } from 'vue'
const AUTH_KEY = '36wisdom_auth'
const CHILD_KEY = '36wisdom_child'
const AGE_KEY = '36wisdom_child_age'
function loadAuth() {
const saved = uni.getStorageSync(AUTH_KEY)
@@ -46,3 +47,11 @@ export function clearAuth() {
state.parentId = 0
uni.removeStorageSync(AUTH_KEY)
}
export function getChildAge() {
return uni.getStorageSync(AGE_KEY) || '4-6'
}
export function setChildAge(age) {
uni.setStorageSync(AGE_KEY, age)
}