feat: uni-app 前端骨架(登录/孩子/地图/闯关/结算)
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>三十六计小课堂</title>
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') +
|
||||
'" />'
|
||||
)
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6760
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "36wisdom-ui",
|
||||
"version": "0.1.0",
|
||||
"description": "三十六计小课堂 - 儿童闯关学习客户端(uni-app H5 先行)",
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"build:h5": "uni build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-5020420260813001",
|
||||
"@dcloudio/uni-components": "3.0.0-5020420260813001",
|
||||
"@dcloudio/uni-h5": "3.0.0-5020420260813001",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-5020420260813001",
|
||||
"vite": "5.2.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log('36wisdom app launch')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page {
|
||||
background-color: #fdf6ec;
|
||||
font-size: 28rpx;
|
||||
color: #5b4636;
|
||||
}
|
||||
|
||||
/* 全局卡通配色 */
|
||||
.bg-warm {
|
||||
background-color: #fdf6ec;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(91, 70, 54, 0.08);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #ff9a3d, #ff6b35);
|
||||
color: #ffffff;
|
||||
border-radius: 48rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
padding: 20rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
background: #e5d9c8;
|
||||
color: #b3a18d;
|
||||
}
|
||||
|
||||
.title-lg {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #5b4636;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #a08c74;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { get, post } from './request'
|
||||
|
||||
export const api = {
|
||||
register: (data) => post('/parent/register', data),
|
||||
login: (data) => post('/parent/login', data),
|
||||
|
||||
childCreate: (data) => post('/parent/child/create', data),
|
||||
childList: () => get('/parent/child/list'),
|
||||
|
||||
strategyList: (childId) => get('/strategy/list', { child_id: childId }),
|
||||
strategyDetail: (childId, strategyId) =>
|
||||
get('/strategy/detail', { child_id: childId, strategy_id: strategyId }),
|
||||
|
||||
levelDetail: (childId, levelId) =>
|
||||
get('/level/detail', { child_id: childId, level_id: levelId }),
|
||||
levelChoose: (childId, levelId, nodeId, optionId) =>
|
||||
post('/level/choose', { child_id: childId, level_id: levelId, node_id: nodeId, option_id: optionId })
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getToken, clearAuth } from '../store/user'
|
||||
|
||||
const BASE_URL = '/api'
|
||||
|
||||
function request(path, options = {}) {
|
||||
const token = getToken()
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: BASE_URL + path,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
...(options.header || {})
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
clearAuth()
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => uni.reLaunch({ url: '/pages/login/login' }), 600)
|
||||
reject(new Error('unauthorized'))
|
||||
return
|
||||
}
|
||||
const body = res.data
|
||||
if (body && body.code === 0) {
|
||||
resolve(body.data)
|
||||
return
|
||||
}
|
||||
const msg = (body && body.message) || '请求失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
reject(new Error(msg))
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({ title: '网络错误,请重试', icon: 'none' })
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const get = (path, data) => request(path, { method: 'GET', data })
|
||||
export const post = (path, data) => request(path, { method: 'POST', data })
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return { app }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "三十六计小课堂",
|
||||
"appid": "__UNI__36WISDOM",
|
||||
"description": "儿童闯关学习《三十六计》",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"h5": {
|
||||
"title": "三十六计小课堂",
|
||||
"router": {
|
||||
"mode": "hash"
|
||||
}
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "三十六计小课堂",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/children/children",
|
||||
"style": {
|
||||
"navigationBarTitleText": "选择孩子"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/map/map",
|
||||
"style": {
|
||||
"navigationBarTitleText": "三十六计地图"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/play/play",
|
||||
"style": {
|
||||
"navigationBarTitleText": "闯关中",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/result/result",
|
||||
"style": {
|
||||
"navigationBarTitleText": "闯关结果",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "三十六计小课堂",
|
||||
"navigationBarBackgroundColor": "#fdf6ec",
|
||||
"backgroundColor": "#fdf6ec"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<view class="title-lg">选择孩子</view>
|
||||
<view class="text-muted">点选孩子进入闯关地图</view>
|
||||
</view>
|
||||
|
||||
<view v-for="c in children" :key="c.child_id" class="card child-card" @click="enter(c)">
|
||||
<view class="avatar">{{ c.nickname.slice(0, 1) }}</view>
|
||||
<view class="info">
|
||||
<view class="name">{{ c.nickname }} <text class="age-tag">{{ c.age_group }}岁</text></view>
|
||||
<view class="text-muted">
|
||||
{{ c.level_title }} · {{ c.points }} 分 · 完美 {{ c.perfect_count }} 计
|
||||
</view>
|
||||
</view>
|
||||
<view class="arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view v-if="children.length === 0" class="empty card">
|
||||
<view class="empty-icon">🎈</view>
|
||||
<view class="text-muted">还没有孩子档案,先添加一个吧</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showForm" class="card form-card">
|
||||
<view class="form-title">新建孩子档案</view>
|
||||
<input v-model="form.nickname" class="input" maxlength="32" placeholder="孩子昵称" />
|
||||
<view class="age-row">
|
||||
<view
|
||||
v-for="g in ageGroups"
|
||||
:key="g.value"
|
||||
class="age-option"
|
||||
:class="{ active: form.ageGroup === g.value }"
|
||||
@click="form.ageGroup = g.value"
|
||||
>{{ g.label }}</view>
|
||||
</view>
|
||||
<view class="btn-primary submit" @click="create">创建并进入</view>
|
||||
</view>
|
||||
|
||||
<view class="btn-primary add" @click="showForm = !showForm">
|
||||
{{ showForm ? '收起' : '+ 添加孩子' }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from '../../api/index.js'
|
||||
import { setChildId } from '../../store/user.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
children: [],
|
||||
showForm: false,
|
||||
form: { nickname: '', ageGroup: '4-6' },
|
||||
ageGroups: [
|
||||
{ label: '4-6 岁', value: '4-6' },
|
||||
{ label: '6-8 岁', value: '6-8' }
|
||||
]
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
try {
|
||||
const data = await api.childList()
|
||||
this.children = data.list || []
|
||||
} catch (e) {}
|
||||
},
|
||||
enter(c) {
|
||||
setChildId(c.child_id)
|
||||
uni.reLaunch({ url: '/pages/map/map' })
|
||||
},
|
||||
async create() {
|
||||
if (!this.form.nickname.trim()) {
|
||||
uni.showToast({ title: '请输入孩子昵称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.childCreate({
|
||||
nickname: this.form.nickname.trim(),
|
||||
age_group: this.form.ageGroup
|
||||
})
|
||||
setChildId(data.child_id)
|
||||
uni.reLaunch({ url: '/pages/map/map' })
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 32rpx 32rpx 200rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header {
|
||||
padding: 32rpx 16rpx;
|
||||
}
|
||||
.child-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ffd08a, #ff9a3d);
|
||||
color: #ffffff;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 28rpx;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
}
|
||||
.name {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.age-tag {
|
||||
font-size: 22rpx;
|
||||
color: #ff6b35;
|
||||
background: #fff0e5;
|
||||
border-radius: 20rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 48rpx;
|
||||
color: #d5c4ae;
|
||||
}
|
||||
.empty {
|
||||
padding: 80rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 72rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.form-card {
|
||||
padding: 40rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.form-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.input {
|
||||
background: #f8f2e8;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.age-row {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.age-option {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 20rpx;
|
||||
background: #f5ece0;
|
||||
color: #a08c74;
|
||||
font-weight: 600;
|
||||
}
|
||||
.age-option.active {
|
||||
background: #ff9a3d;
|
||||
color: #ffffff;
|
||||
}
|
||||
.submit {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.add {
|
||||
position: fixed;
|
||||
left: 32rpx;
|
||||
right: 32rpx;
|
||||
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="hero">
|
||||
<view class="hero-title">三十六计小课堂</view>
|
||||
<view class="hero-sub">动脑筋 · 闯关卡 · 学计谋</view>
|
||||
</view>
|
||||
|
||||
<view class="card form-card">
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="t in tabs"
|
||||
:key="t.value"
|
||||
class="tab"
|
||||
:class="{ active: mode === t.value }"
|
||||
@click="mode = t.value"
|
||||
>{{ t.label }}</view>
|
||||
</view>
|
||||
|
||||
<input v-model="form.phone" class="input" type="number" maxlength="11" placeholder="手机号" />
|
||||
<input v-model="form.password" class="input" password placeholder="密码(6-32位)" />
|
||||
<input
|
||||
v-if="mode === 'register'"
|
||||
v-model="form.nickname"
|
||||
class="input"
|
||||
maxlength="32"
|
||||
placeholder="家长昵称"
|
||||
/>
|
||||
|
||||
<view class="btn-primary submit" @click="submit">{{ mode === 'login' ? '登 录' : '注册并登录' }}</view>
|
||||
<view class="text-muted tip">首次使用请先注册,家长可管理多个孩子档案</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from '../../api/index.js'
|
||||
import { setAuth } from '../../store/user.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
mode: 'login',
|
||||
tabs: [
|
||||
{ label: '登录', value: 'login' },
|
||||
{ label: '注册', value: 'register' }
|
||||
],
|
||||
form: { phone: '', password: '', nickname: '' }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
const { phone, password, nickname } = this.form
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data =
|
||||
this.mode === 'login'
|
||||
? await api.login({ phone, password })
|
||||
: await api.register({ phone, password, nickname })
|
||||
setAuth(data.token, data.parent_id)
|
||||
uni.reLaunch({ url: '/pages/children/children' })
|
||||
} catch (e) {
|
||||
// 错误提示已在 request 层处理
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hero {
|
||||
padding: 160rpx 0 80rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
color: #ff6b35;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.hero-sub {
|
||||
margin-top: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: #a08c74;
|
||||
}
|
||||
.form-card {
|
||||
padding: 48rpx 40rpx 40rpx;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
margin-bottom: 40rpx;
|
||||
border-radius: 40rpx;
|
||||
background: #f5ece0;
|
||||
padding: 8rpx;
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16rpx 0;
|
||||
border-radius: 32rpx;
|
||||
color: #a08c74;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab.active {
|
||||
background: #ffffff;
|
||||
color: #ff6b35;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
.input {
|
||||
background: #f8f2e8;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.submit {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.tip {
|
||||
margin-top: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-for="group in groups" :key="group.group_no" class="group">
|
||||
<view class="group-header">
|
||||
<text class="group-name">{{ group.group_name }}</text>
|
||||
<text class="group-desc text-muted">{{ groupDesc(group.group_no) }}</text>
|
||||
</view>
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="s in group.items"
|
||||
:key="s.strategy_id"
|
||||
class="strategy-card card"
|
||||
:class="{ locked: !s.unlocked }"
|
||||
@click="openStrategy(s)"
|
||||
>
|
||||
<view class="icon" :style="iconStyle(s)">
|
||||
{{ s.name.slice(0, 1) }}
|
||||
<view v-if="s.perfect_count > 0" class="perfect-badge">⭐</view>
|
||||
</view>
|
||||
<view class="s-name">{{ s.name }}</view>
|
||||
<view class="stars">
|
||||
<text v-for="i in 3" :key="i" class="star" :class="{ on: i <= s.stars }">★</text>
|
||||
</view>
|
||||
<view v-if="!s.unlocked" class="lock">🔒</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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 v-if="strategy.levels && strategy.levels.length" class="level-list">
|
||||
<view
|
||||
v-for="lv in strategy.levels"
|
||||
:key="lv.level_id"
|
||||
class="level-item"
|
||||
:class="{ disabled: !lv.unlocked }"
|
||||
@click="enterLevel(lv)"
|
||||
>
|
||||
<view class="level-info">
|
||||
<view class="level-title">{{ lv.title }}</view>
|
||||
<view class="text-muted">{{ lv.scene_name }} · 完成 {{ lv.stars }}/3 星</view>
|
||||
</view>
|
||||
<view class="level-go">{{ lv.unlocked ? '闯关 ›' : '🔒' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="learn-close btn-primary" @click="closeLearn">关闭</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from '../../api/index.js'
|
||||
import { getChildId } from '../../store/user.js'
|
||||
|
||||
const GROUP_DESC = {
|
||||
1: '胜利要用巧办法',
|
||||
2: '对付敌人有妙计',
|
||||
3: '进攻也要动脑筋',
|
||||
4: '混乱时找机会',
|
||||
5: '一起玩要讲智慧',
|
||||
6: '失败也有好办法'
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
groups: [],
|
||||
showLearn: false,
|
||||
strategy: null,
|
||||
childId: getChildId()
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
if (!this.childId) {
|
||||
uni.reLaunch({ url: '/pages/children/children' })
|
||||
return
|
||||
}
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
groupDesc(no) {
|
||||
return GROUP_DESC[no] || ''
|
||||
},
|
||||
iconStyle(s) {
|
||||
const colors = ['#ff9a3d', '#ff6b6b', '#4ecdc4', '#6c8cff', '#a58cff', '#ffb347']
|
||||
return { background: colors[(s.strategy_id - 1) % colors.length] }
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
const data = await api.strategyList(this.childId)
|
||||
const items = data.list || []
|
||||
const groups = []
|
||||
const map = {}
|
||||
for (const it of items) {
|
||||
if (!map[it.group_no]) {
|
||||
map[it.group_no] = { group_no: it.group_no, group_name: it.group_name, items: [] }
|
||||
groups.push(map[it.group_no])
|
||||
}
|
||||
map[it.group_no].items.push(it)
|
||||
}
|
||||
this.groups = groups
|
||||
} catch (e) {}
|
||||
},
|
||||
async openStrategy(s) {
|
||||
if (!s.unlocked) {
|
||||
uni.showToast({ title: s.unlock_reason || '还未解锁', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const detail = await api.strategyDetail(this.childId, s.strategy_id)
|
||||
this.strategy = detail
|
||||
this.showLearn = true
|
||||
} catch (e) {}
|
||||
},
|
||||
closeLearn() {
|
||||
this.showLearn = false
|
||||
},
|
||||
playAudio(src) {
|
||||
uni.showToast({ title: '音频资源暂未配置', icon: 'none' })
|
||||
},
|
||||
enterLevel(lv) {
|
||||
if (!lv.unlocked) {
|
||||
uni.showToast({ title: '关卡未解锁', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.showLearn = false
|
||||
uni.navigateTo({ url: '/pages/play/play?level_id=' + lv.level_id })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx 32rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.group {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 0 8rpx 20rpx;
|
||||
}
|
||||
.group-name {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #5b4636;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
.group-desc {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.strategy-card {
|
||||
width: calc((100% - 40rpx) / 3);
|
||||
padding: 24rpx 0 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.strategy-card.locked {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 28rpx;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.perfect-badge {
|
||||
position: absolute;
|
||||
top: -12rpx;
|
||||
right: -12rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.s-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #5b4636;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
.stars {
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.star {
|
||||
color: #e5d9c8;
|
||||
}
|
||||
.star.on {
|
||||
color: #ffb347;
|
||||
}
|
||||
.lock {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 16rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(91, 70, 54, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.learn-panel {
|
||||
width: 85%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 48rpx 40rpx;
|
||||
}
|
||||
.learn-title {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #ff6b35;
|
||||
}
|
||||
.learn-pinyin {
|
||||
font-size: 26rpx;
|
||||
color: #a08c74;
|
||||
font-weight: 400;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
.learn-meaning {
|
||||
margin: 20rpx 0;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.6;
|
||||
color: #7a6248;
|
||||
}
|
||||
.audio-btn {
|
||||
display: inline-block;
|
||||
background: #fff0e5;
|
||||
color: #ff6b35;
|
||||
border-radius: 40rpx;
|
||||
padding: 12rpx 32rpx;
|
||||
font-size: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.learn-content {
|
||||
background: #f8f2e8;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.8;
|
||||
color: #5b4636;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.level-list {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.level-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f8f2e8;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.level-item.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.level-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
.level-go {
|
||||
font-size: 30rpx;
|
||||
color: #ff6b35;
|
||||
font-weight: 700;
|
||||
}
|
||||
.learn-close {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="topbar">
|
||||
<view class="back" @click="goBack">‹</view>
|
||||
<view class="top-title">{{ detail ? detail.title : '' }}</view>
|
||||
<view class="top-spacer"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail" class="content">
|
||||
<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>
|
||||
<view class="node-content">{{ curNode.content }}</view>
|
||||
|
||||
<view v-if="curNode.interaction_type === 1" class="options">
|
||||
<view
|
||||
v-for="(o, idx) in curNode.options"
|
||||
:key="o.option_id"
|
||||
class="option"
|
||||
:class="{ disabled: choosing }"
|
||||
@click="choose(o, idx)"
|
||||
>
|
||||
<view class="option-mark">{{ ['A', 'B', 'C', 'D'][idx] }}</view>
|
||||
<view class="option-text">{{ o.text }}</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">{{ feedback.text }}</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>
|
||||
<view class="btn-primary fb-continue" @click="continuePlay">继续 ›</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from '../../api/index.js'
|
||||
import { getChildId } from '../../store/user.js'
|
||||
|
||||
const RESULT_KEY = '36wisdom_result'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
childId: getChildId(),
|
||||
levelId: 0,
|
||||
detail: null,
|
||||
curNode: null,
|
||||
feedback: null,
|
||||
choosing: false
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.levelId = Number(options.level_id || 0)
|
||||
this.load()
|
||||
},
|
||||
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' }) })
|
||||
},
|
||||
async choose(option, idx) {
|
||||
if (this.choosing) return
|
||||
this.choosing = true
|
||||
try {
|
||||
const data = await api.levelChoose(this.childId, this.levelId, this.curNode.node_id, option.option_id)
|
||||
if (data.next) {
|
||||
this.feedback = { text: option.text, prop: option.prop, next: data.next }
|
||||
} 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 : '',
|
||||
final: data.final
|
||||
})
|
||||
uni.redirectTo({ url: '/pages/result/result' })
|
||||
}
|
||||
} catch (e) {
|
||||
} finally {
|
||||
this.choosing = false
|
||||
}
|
||||
},
|
||||
continuePlay() {
|
||||
this.curNode = this.feedback.next
|
||||
this.feedback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="result" class="result">
|
||||
<view class="result-title">闯关完成</view>
|
||||
|
||||
<view class="stars-area">
|
||||
<view
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="big-star"
|
||||
:class="{ on: i <= result.final.stars, pop: i <= result.final.stars }"
|
||||
:style="{ animationDelay: i * 0.35 + 's' }"
|
||||
>★</view>
|
||||
</view>
|
||||
|
||||
<view class="card panel">
|
||||
<view class="panel-row">
|
||||
<text class="label">关卡</text>
|
||||
<text class="value">{{ result.title }}</text>
|
||||
</view>
|
||||
<view class="panel-row">
|
||||
<text class="label">积分</text>
|
||||
<text class="value delta" :class="result.final.score_delta >= 0 ? 'up' : 'down'">
|
||||
{{ result.final.score_delta >= 0 ? '+' : '' }}{{ result.final.score_delta }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="panel-row">
|
||||
<text class="label">当前余额</text>
|
||||
<text class="value">{{ result.final.balance_after }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result.final.perfect" class="badge perfect">✨ 完美通关!</view>
|
||||
<view v-if="result.final.collection_unlocked" class="badge card-collect">🃏 获得计策卡</view>
|
||||
<view v-if="result.final.unlock_next" class="badge next-unlock">🔓 下一计已解锁</view>
|
||||
<view v-if="result.final.new_level > 0" class="badge level-up">🎉 成长等级提升!</view>
|
||||
|
||||
<view class="actions">
|
||||
<view class="btn-primary action" @click="replay">再玩一次</view>
|
||||
<view class="btn-primary action ghost" @click="backMap">返回地图</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const RESULT_KEY = '36wisdom_result'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return { result: null }
|
||||
},
|
||||
onLoad() {
|
||||
const saved = uni.getStorageSync(RESULT_KEY)
|
||||
this.result = saved || null
|
||||
if (!saved) {
|
||||
uni.reLaunch({ url: '/pages/map/map' })
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
replay() {
|
||||
uni.redirectTo({ url: '/pages/play/play?level_id=' + this.result.level_id })
|
||||
},
|
||||
backMap() {
|
||||
uni.reLaunch({ url: '/pages/map/map' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.result {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.result-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #ff6b35;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
.stars-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32rpx;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
.big-star {
|
||||
font-size: 96rpx;
|
||||
color: #e5d9c8;
|
||||
opacity: 0;
|
||||
}
|
||||
.big-star.on {
|
||||
color: #ffb347;
|
||||
}
|
||||
.big-star.pop {
|
||||
animation: pop-in 0.5s ease forwards;
|
||||
}
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3) rotate(-30deg);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2) rotate(10deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
padding: 32rpx 40rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.panel-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
.label {
|
||||
color: #a08c74;
|
||||
}
|
||||
.value {
|
||||
font-weight: 700;
|
||||
color: #5b4636;
|
||||
}
|
||||
.delta.up {
|
||||
color: #ff6b35;
|
||||
}
|
||||
.delta.down {
|
||||
color: #6c8cff;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #fff0e5;
|
||||
color: #ff6b35;
|
||||
border-radius: 40rpx;
|
||||
padding: 14rpx 36rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
margin: 0 12rpx 24rpx;
|
||||
}
|
||||
.badge.card-collect {
|
||||
background: #fff7e0;
|
||||
color: #d9a404;
|
||||
}
|
||||
.badge.next-unlock {
|
||||
background: #e8f7f3;
|
||||
color: #1fa98f;
|
||||
}
|
||||
.badge.level-up {
|
||||
background: #ffeef2;
|
||||
color: #e0557a;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.action {
|
||||
flex: 1;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.action.ghost {
|
||||
background: #f5ece0;
|
||||
color: #7a6248;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const AUTH_KEY = '36wisdom_auth'
|
||||
const CHILD_KEY = '36wisdom_child'
|
||||
|
||||
function loadAuth() {
|
||||
const saved = uni.getStorageSync(AUTH_KEY)
|
||||
return saved || { token: '', parentId: 0 }
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
token: loadAuth().token,
|
||||
parentId: loadAuth().parentId,
|
||||
childId: Number(uni.getStorageSync(CHILD_KEY) || 0)
|
||||
})
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!state.token
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return state.token
|
||||
}
|
||||
|
||||
export function getParentId() {
|
||||
return state.parentId
|
||||
}
|
||||
|
||||
export function getChildId() {
|
||||
return state.childId
|
||||
}
|
||||
|
||||
export function setChildId(childId) {
|
||||
state.childId = childId
|
||||
uni.setStorageSync(CHILD_KEY, childId)
|
||||
}
|
||||
|
||||
export function setAuth(token, parentId) {
|
||||
state.token = token
|
||||
state.parentId = parentId
|
||||
uni.setStorageSync(AUTH_KEY, { token, parentId })
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
state.token = ''
|
||||
state.parentId = 0
|
||||
uni.removeStorageSync(AUTH_KEY)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user