feat: add taro student learning flow

This commit is contained in:
Codex
2026-06-29 12:04:59 +08:00
parent c090008f52
commit b79c4259c0
28 changed files with 1567 additions and 12 deletions

View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import { logout } from '@/services/auth';
import { checkActivationCode, loadEntitlements, loadOrders, loadSvipPlans, redeemActivationCode, type OrderItem, type SvipPlan } from '@/services/commerce';
import { loadLeaderboard } from '@/services/learning';
import { checkIn, loadBadges, loadExamCountdowns, loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
export default function StudentProfilePage() {
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [plans, setPlans] = useState<SvipPlan[]>([]);
const [orders, setOrders] = useState<OrderItem[]>([]);
const [badges, setBadges] = useState<Record<string, unknown>[]>([]);
const [countdowns, setCountdowns] = useState<Record<string, unknown>[]>([]);
const [leaderboard, setLeaderboard] = useState<Record<string, unknown>[]>([]);
const [activationCode, setActivationCode] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
function reload() {
Promise.all([
loadProfile().catch(() => ({ item: null })),
loadSvipPlans().catch(() => ({ items: [] })),
loadOrders().catch(() => ({ items: [] })),
loadBadges().catch(() => ({ items: [] })),
loadExamCountdowns().catch(() => ({ items: [] })),
loadLeaderboard('questions').catch(() => ({ items: [] })),
loadEntitlements().catch(() => ({ summary: {} })),
]).then(([profilePayload, planPayload, orderPayload, badgePayload, countdownPayload, leaderboardPayload]) => {
setProfile(profilePayload.item || null);
setPlans(planPayload.items || []);
setOrders(orderPayload.items || []);
setBadges(badgePayload.items || []);
setCountdowns(countdownPayload.items || []);
setLeaderboard(leaderboardPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '个人中心加载失败'));
}
useEffect(() => {
reload();
}, []);
async function handleCheckIn() {
try {
const payload = await checkIn();
setMessage(`签到成功,当前积分 ${String(payload.item?.score ?? '')}`);
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '签到失败');
}
}
async function handleRedeem() {
try {
await checkActivationCode(activationCode);
await redeemActivationCode(activationCode);
setMessage('激活码兑换成功');
setActivationCode('');
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '激活码兑换失败');
}
}
async function handleLogout() {
await logout();
Taro.redirectTo({ url: '/pages/student/login/index' });
}
return (
<View className='student-page'>
<View className='student-topbar'>
<View className='student-title-block'>
<Text className='student-kicker'>Profile</Text>
<Text className='student-title'>{profile?.name || profile?.phone || '个人中心'}</Text>
<Text className='student-subtitle'>{profile?.membership?.isSvip ? 'SVIP 生效中' : '普通用户'} · {profile?.score ?? 0}</Text>
</View>
<Button className='secondary-button' onClick={handleLogout}>退</Button>
</View>
<View className='grid-two'>
<View className='metric'><Text className='metric-value'>{String((profile?.stats?.answers as Record<string, unknown> | undefined)?.totalAnswered ?? 0)}</Text><Text className='metric-label'></Text></View>
<View className='metric'><Text className='metric-value'>{String((profile?.stats?.vocabulary as Record<string, unknown> | undefined)?.masteredWords ?? 0)}</Text><Text className='metric-label'></Text></View>
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
<View className='list-row'>
<Text className='row-main'>{profile?.target?.regionName || '未选择地区'}{profile?.target?.schoolName ? ` · ${profile.target.schoolName}` : ''}</Text>
<Text className='row-meta'>{profile?.target?.majorName || '暂无目标专业'}</Text>
</View>
{countdowns.slice(0, 3).map(item => (
<View className='list-row' key={String(item.id || item.examName)}>
<Text className='row-main'>{String(item.examName || '考试')}</Text>
<Text className='row-meta'> {String(item.daysLeft ?? '-')} </Text>
</View>
))}
</View>
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{plans.slice(0, 4).map(item => (
<View className='list-row' key={item.id}>
<Text className='row-main'>{item.name} · ¥{String(item.price ?? 0)}</Text>
<Text className='row-meta'>{item.days === -1 ? '永久' : `${item.days || 0}`} {item.badge ? ` · ${item.badge}` : ''}</Text>
</View>
))}
</View>
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='toolbar'>
<Input className='input' placeholder='输入激活码' value={activationCode} onInput={event => setActivationCode(String(event.detail.value || ''))} />
<Button className='primary-button' onClick={handleRedeem}></Button>
</View>
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{orders.slice(0, 5).map(item => (
<View className='list-row' key={item.id}>
<Text className='row-main'>{item.productName || item.orderNo}</Text>
<Text className='row-meta'>{item.status} · ¥{((item.amountCents || 0) / 100).toFixed(2)}</Text>
</View>
))}
</View>
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='toolbar'>
<Button className='primary-button' onClick={handleCheckIn}></Button>
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/catalog/index' })}></Button>
</View>
<View className='grid-two'>
<View className='metric'><Text className='metric-value'>{String(badges.length)}</Text><Text className='metric-label'></Text></View>
<View className='metric'><Text className='metric-value'>{String(leaderboard.length)}</Text><Text className='metric-label'></Text></View>
</View>
</View>
{message ? <Text className='success-text'>{message}</Text> : null}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
);
}