266 lines
12 KiB
Vue
266 lines
12 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="topbar">
|
||
<view class="back" @click="goBack">‹</view>
|
||
<view class="top-title">{{ detail ? detail.title : '' }}</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-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">
|
||
<RubyText :text="curNode.content" :pinyin="curNode.content_pinyin" :show="showPinyin" />
|
||
</view>
|
||
<SpeakButton class="node-speak" :text="curNode.content" :file="curNode.audio" label="读一读" />
|
||
|
||
<!-- 互动组件(触控 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="['c' + (idx + 1), { disabled: choosing }]"
|
||
@click="choose(o, idx)"
|
||
>
|
||
<view class="option-mark">{{ ['A', 'B', 'C', 'D'][idx] }}</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>
|
||
</view>
|
||
|
||
<!-- 反馈面板:你的选择 + 好处 + 对比(对比点评三段式) -->
|
||
<view v-if="feedback" class="mask">
|
||
<view class="feedback card">
|
||
<view class="fb-title">你的选择</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 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>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { api } from '../../api/index.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(),
|
||
levelId: 0,
|
||
detail: null,
|
||
curNode: null,
|
||
feedback: null,
|
||
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 {
|
||
const data = await api.levelDetail(this.childId, this.levelId)
|
||
this.detail = data
|
||
this.curNode = data.entry
|
||
} catch (e) {
|
||
setTimeout(() => this.goBack(), 800)
|
||
}
|
||
},
|
||
goBack() {
|
||
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/map/map' }) })
|
||
},
|
||
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, o.option_id)
|
||
this.routeSteps.push({ text: o.text, pros: o.feedback_pros || '' })
|
||
if (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) {
|
||
} finally {
|
||
this.choosing = false
|
||
}
|
||
},
|
||
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; }
|
||
.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>
|