Files
gongxue-base/apps/taro/src/pages/student/profile/index.tsx
2026-06-30 00:30:38 +08:00

351 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
loadLearningStats,
loadLearningTrend,
loadPracticeHistory,
type LeaderboardItem,
type LearningStats,
type LearningTrendItem,
type PracticeHistoryItem,
} from '@/services/learning';
import { checkIn, loadBadges, loadExamCountdowns, loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
function percent(value?: number | null) {
return `${Math.round((value || 0) * 100)}%`;
}
function shortDate(value?: string | null) {
if (!value) return '';
return value.slice(5, 10);
}
function dateText(value?: string | null) {
if (!value) return '暂无记录';
return value.slice(0, 10);
}
function practiceTitle(item: PracticeHistoryItem) {
return item.blueprintName || item.collectionName || item.contentNodeName || item.entryName || modeLabel(item.mode);
}
function modeLabel(mode?: string | null) {
const normalized = String(mode || '');
if (normalized === 'mock_exam') return '全真模拟';
if (normalized === 'random') return '随机练习';
if (normalized === 'wrong_review') return '错题复习';
if (normalized === 'favorites') return '收藏练习';
return '顺序练习';
}
function maxTrendAnswers(items: LearningTrendItem[]) {
return Math.max(1, ...items.map(item => item.answeredCount || 0));
}
function trendBucket(value: number, maxValue: number) {
if (value <= 0) return 'h-bucket-0';
const ratio = maxValue ? value / maxValue : 0;
const bucket = Math.max(1, Math.min(12, Math.ceil(ratio * 12)));
return `h-bucket-${bucket}`;
}
function progressBucket(value: number) {
const ratio = Math.max(0, Math.min(1, value || 0));
const bucket = Math.max(0, Math.min(10, Math.round(ratio * 10)));
return `w-bucket-${bucket}`;
}
function sessionStatusLabel(status?: string | null) {
if (status === 'active') return '进行中';
if (status === 'expired') return '已过期';
return '已完成';
}
function sessionStatusClass(status?: string | null) {
if (status === 'active') return 'warning';
if (status === 'expired') return 'danger';
return '';
}
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<LeaderboardItem[]>([]);
const [currentRank, setCurrentRank] = useState<LeaderboardItem | null>(null);
const [learningStats, setLearningStats] = useState<LearningStats | null>(null);
const [learningTrend, setLearningTrend] = useState<LearningTrendItem[]>([]);
const [practiceHistory, setPracticeHistory] = useState<PracticeHistoryItem[]>([]);
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: [], currentUser: null })),
loadLearningStats(30).catch(() => ({ item: undefined })),
loadLearningTrend(14).catch(() => ({ items: [] })),
loadPracticeHistory({ limit: 6 }).catch(() => ({ items: [] })),
loadEntitlements().catch(() => ({ summary: {} })),
]).then(([
profilePayload,
planPayload,
orderPayload,
badgePayload,
countdownPayload,
leaderboardPayload,
statsPayload,
trendPayload,
historyPayload,
]) => {
setProfile(profilePayload.item || null);
setPlans(planPayload.items || []);
setOrders(orderPayload.items || []);
setBadges(badgePayload.items || []);
setCountdowns(countdownPayload.items || []);
setLeaderboard(leaderboardPayload.items || []);
setCurrentRank(leaderboardPayload.currentUser || null);
setLearningStats(statsPayload.item || null);
setLearningTrend(trendPayload.items || []);
setPracticeHistory(historyPayload.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' });
}
const maxAnswers = maxTrendAnswers(learningTrend);
const answerStats = learningStats?.answers;
const reportStats = learningStats?.reports;
const wrongBook = learningStats?.wrongBook;
const favoriteStats = learningStats?.favorites;
const questionTypes = learningStats?.questionTypes || [];
const bestPlan = plans[0];
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(answerStats?.totalAnswered ?? (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='report-panel learning-report-panel'>
<View className='grid-two'>
<View className='metric compact-metric'>
<Text className='metric-value'>{percent(answerStats?.accuracy)}</Text>
<Text className='metric-label'></Text>
</View>
<View className='metric compact-metric'>
<Text className='metric-value'>{String(answerStats?.todayAnswered ?? 0)}</Text>
<Text className='metric-label'></Text>
</View>
<View className='metric compact-metric'>
<Text className='metric-value'>{String(wrongBook?.unresolvedWrong ?? 0)}</Text>
<Text className='metric-label'></Text>
</View>
<View className='metric compact-metric'>
<Text className='metric-value'>{String(reportStats?.bestScore ?? 0)}</Text>
<Text className='metric-label'>/</Text>
</View>
</View>
<Text className='row-meta report-summary-line'>
{String(learningStats?.windowDays || 30)} · {dateText(answerStats?.latestAnsweredAt)} · {String(reportStats?.reportCount || 0)}
</Text>
</View>
</View>
<View className='section-block'>
<Text className='section-heading'>14 </Text>
{learningTrend.length ? (
<View className='trend-chart'>
{learningTrend.map(item => {
return (
<View className='trend-column' key={item.date}>
<Text className='trend-value'>{String(item.answeredCount || 0)}</Text>
<View className='trend-bar-track'>
<View className={`trend-bar-fill ${trendBucket(item.answeredCount || 0, maxAnswers)}`} />
</View>
<Text className='trend-label'>{shortDate(item.date)}</Text>
</View>
);
})}
</View>
) : <View className='empty-state'></View>}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
{questionTypes.length ? (
<View className='list-stack'>
{questionTypes.slice(0, 6).map(item => (
<View className='list-row' key={item.questionType}>
<View className='split-row'>
<Text className='row-main'>{item.typeLabel || item.questionType}</Text>
<Text className='status-badge'>{percent(item.accuracy)}</Text>
</View>
<Text className='row-meta'> {String(item.answeredCount)} · {String(item.correctCount)} · {String(item.wrongCount)}</Text>
<View className='progress-track'>
<View className={`progress-fill ${progressBucket(item.accuracy)}`} />
</View>
</View>
))}
</View>
) : <View className='empty-state'></View>}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
{practiceHistory.length ? (
<View className='list-stack'>
{practiceHistory.map(item => (
<View className='list-row' key={item.id}>
<View className='split-row'>
<Text className='row-main'>{practiceTitle(item)}</Text>
<Text className={`status-badge ${sessionStatusClass(item.status)}`}>{sessionStatusLabel(item.status)}</Text>
</View>
<Text className='row-meta'>
{modeLabel(item.mode)} · {String(item.answeredCount || 0)} / {String(item.questionCount || 0)} · {percent(item.accuracy)}
</Text>
<Text className='row-meta'> {dateText(item.startedAt)}{item.submittedAt ? ` · 交卷 ${dateText(item.submittedAt)}` : ''}</Text>
<View className='toolbar compact-toolbar'>
{item.status === 'active' ? (
<Button className='primary-button' onClick={() => Taro.navigateTo({ url: `/pages/student/practice/index?practiceSessionId=${item.id}` })}></Button>
) : null}
{item.reportId ? (
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: `/pages/student/reports/index?practiceSessionId=${item.id}` })}></Button>
) : null}
</View>
</View>
))}
</View>
) : <View className='empty-state'></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 className='toolbar wrap'>
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/region/index' })}></Button>
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/review/index?type=wrong' })}></Button>
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/review/index?type=favorite' })}></Button>
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/reports/index' })}></Button>
</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} onClick={() => Taro.navigateTo({ url: `/pages/student/checkout/index?planId=${encodeURIComponent(item.id)}${profile?.target?.regionId ? `&regionId=${encodeURIComponent(profile.target.regionId)}` : ''}` })}>
<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 className='toolbar wrap'>
<Button className='primary-button' onClick={() => Taro.navigateTo({ url: `/pages/student/checkout/index${bestPlan?.id ? `?planId=${encodeURIComponent(bestPlan.id)}` : profile?.target?.regionId ? `?regionId=${encodeURIComponent(profile.target.regionId)}` : ''}` })}></Button>
</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} onClick={() => Taro.navigateTo({ url: `/pages/student/order-detail/index?orderNo=${encodeURIComponent(item.orderNo)}` })}>
<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'>{currentRank ? `${String(currentRank.rank)}` : String(leaderboard.length)}</Text><Text className='metric-label'>{currentRank ? `7 日答题榜 · ${String(currentRank.value)}` : '排行榜样本'}</Text></View>
<View className='metric'><Text className='metric-value'>{String(favoriteStats?.favoriteQuestions ?? 0)}</Text><Text className='metric-label'></Text></View>
<View className='metric'><Text className='metric-value'>{percent(reportStats?.avgAccuracy)}</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>
);
}