feat: add preset avatar picker

This commit is contained in:
Codex
2026-06-30 17:53:53 +08:00
parent c7ad3450fd
commit 727e65b554
14 changed files with 215 additions and 15 deletions

View File

@@ -40,10 +40,10 @@ pages/student/vocabulary/index 单词单元、复习计划、认识/再记、
pages/student/handbook/index 手册科目、章节、知识点阅读
pages/student/scoreline/index 分数线列表
pages/student/assets/index 资料列表、预览签名、下载签名
pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章
pages/student/profile/index 个人中心、男女预设头像、会员、订单、签到、激活码、勋章
```
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善长题干排版、公式图片混排、支付容器体验和小程序兼容。
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、题目反馈、视频解析、交卷报告、错题本、收藏夹、男女预设头像选择、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善长题干排版、公式图片混排、支付容器体验和小程序兼容。学生头像不做上传、裁剪或第三方头像同步,个人中心只保存 `avatarPreset=male/female`
学生端商城链路的安全边界:

View File

@@ -34,7 +34,12 @@ export default {
},
defineConstants: {},
copy: {
patterns: [],
patterns: [
{
from: path.resolve(configDir, '..', 'src', 'assets'),
to: path.resolve(configDir, '..', outputRoot, 'assets'),
},
],
options: {},
},
h5: {

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title desc">
<title id="title">Default female avatar</title>
<desc id="desc">A simple pink preset avatar used for student profiles.</desc>
<rect width="160" height="160" rx="24" fill="#fff1f2"/>
<circle cx="80" cy="61" r="30" fill="#fbcfe8"/>
<path d="M35 133c6-28 23-45 45-45s39 17 45 45" fill="#db2777"/>
<path d="M49 67c3-28 18-43 39-41 17 2 29 16 28 38-11-13-23-20-38-20-13 0-23 8-29 23z" fill="#831843"/>
<path d="M47 71c-8 18-8 33 0 45 6-14 10-27 11-40z" fill="#831843"/>
<path d="M113 72c8 17 8 32 0 44-6-14-9-27-10-40z" fill="#831843"/>
<circle cx="68" cy="65" r="4" fill="#172033"/>
<circle cx="92" cy="65" r="4" fill="#172033"/>
<path d="M69 78c7 6 15 6 22 0" fill="none" stroke="#172033" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 860 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title desc">
<title id="title">Default male avatar</title>
<desc id="desc">A simple blue preset avatar used for student profiles.</desc>
<rect width="160" height="160" rx="24" fill="#eff6ff"/>
<circle cx="80" cy="61" r="30" fill="#bfdbfe"/>
<path d="M36 133c5-28 22-45 44-45s39 17 44 45" fill="#2563eb"/>
<path d="M53 61c6-24 19-36 39-32 13 3 22 14 23 28-14-10-29-13-44-8-7 3-13 7-18 12z" fill="#1e3a8a"/>
<circle cx="68" cy="65" r="4" fill="#172033"/>
<circle cx="92" cy="65" r="4" fill="#172033"/>
<path d="M69 78c7 6 15 6 22 0" fill="none" stroke="#172033" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

View File

@@ -22,6 +22,7 @@ import {
loadProfile,
loadScoreEvents,
redeemExchangeItem,
updateProfile,
updateNotificationStatus,
type PointActivityTask,
type PointExchangeItem,
@@ -87,6 +88,7 @@ function sessionStatusClass(status?: string | null) {
}
type ProfileNotificationStatus = NonNullable<UserNotificationItem['status']>;
type AvatarPreset = NonNullable<StudentProfile['avatarPreset']>;
function taskTypeLabel(type?: string | null) {
if (type === 'daily_check_in') return '每日签到';
@@ -136,6 +138,10 @@ function notificationStatusClass(status?: string | null, severity?: string | nul
return '';
}
function avatarPresetLabel(preset?: string | null) {
return preset === 'female' ? '女生默认' : '男生默认';
}
export default function StudentProfilePage() {
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [plans, setPlans] = useState<SvipPlan[]>([]);
@@ -153,6 +159,7 @@ export default function StudentProfilePage() {
const [notificationFilter, setNotificationFilter] = useState<'' | ProfileNotificationStatus>('unread');
const [notificationBusy, setNotificationBusy] = useState('');
const [pointsBusy, setPointsBusy] = useState('');
const [profileBusy, setProfileBusy] = useState('');
const [activationCode, setActivationCode] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
@@ -233,6 +240,31 @@ export default function StudentProfilePage() {
Taro.redirectTo({ url: '/pages/student/login/index' });
}
async function changeAvatarPreset(nextPreset: AvatarPreset) {
const currentPreset = profile?.avatarPreset || profile?.avatar?.preset || 'male';
if (profileBusy || currentPreset === nextPreset) return;
setProfileBusy(`avatar:${nextPreset}`);
setError('');
try {
await updateProfile({ avatarPreset: nextPreset });
setProfile(prev => prev ? {
...prev,
avatarPreset: nextPreset,
avatar: {
...prev.avatar,
preset: nextPreset,
displayUrl: `/assets/avatars/default-${nextPreset}.svg`,
},
} : prev);
setMessage('头像已更新');
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '头像更新失败');
} finally {
setProfileBusy('');
}
}
async function reloadNotifications(nextFilter = notificationFilter) {
setNotificationBusy('reload');
try {
@@ -307,6 +339,7 @@ export default function StudentProfilePage() {
const favoriteStats = learningStats?.favorites;
const questionTypes = learningStats?.questionTypes || [];
const bestPlan = plans[0];
const avatarPreset = profile?.avatarPreset || profile?.avatar?.preset || 'male';
return (
<View className='student-page'>
@@ -319,6 +352,33 @@ export default function StudentProfilePage() {
<Button className='secondary-button' onClick={handleLogout}>退</Button>
</View>
<View className='profile-card'>
<View className={`profile-avatar ${avatarPreset}`}>
<Text className='profile-avatar-mark'>{avatarPreset === 'female' ? '女' : '男'}</Text>
</View>
<View className='profile-card-main'>
<Text className='row-main'>{profile?.name || profile?.phone || '题库学生'}</Text>
<Text className='row-meta'>{avatarPresetLabel(avatarPreset)} · 使</Text>
<View className='avatar-choice-grid'>
{(['male', 'female'] as AvatarPreset[]).map(preset => (
<View
key={preset}
className={`avatar-choice ${avatarPreset === preset ? 'active' : ''} ${profileBusy === `avatar:${preset}` ? 'busy' : ''}`}
onClick={() => void changeAvatarPreset(preset)}
>
<View className={`avatar-choice-icon ${preset}`}>
<Text className='avatar-choice-mark'>{preset === 'female' ? '女' : '男'}</Text>
</View>
<View className='avatar-choice-copy'>
<Text className='avatar-choice-title'>{avatarPresetLabel(preset)}</Text>
<Text className='avatar-choice-meta'>{profileBusy === `avatar:${preset}` ? '保存中' : avatarPreset === preset ? '当前使用' : '点击切换'}</Text>
</View>
</View>
))}
</View>
</View>
</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 File

@@ -131,6 +131,115 @@
background: #f8fbff;
}
.profile-card {
display: flex;
align-items: center;
gap: 22px;
padding: 24px;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #fff;
}
.profile-avatar {
flex: 0 0 116px;
width: 116px;
height: 116px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: linear-gradient(135deg, #dbeafe, #eff6ff);
color: #1d4ed8;
text-align: center;
}
.profile-avatar.female {
border-color: #fbcfe8;
background: linear-gradient(135deg, #fce7f3, #fff1f2);
color: #be185d;
}
.profile-avatar-mark {
display: block;
font-size: 48px;
font-weight: 850;
line-height: 116px;
}
.profile-card-main {
flex: 1;
min-width: 0;
}
.avatar-choice-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.avatar-choice {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
min-height: 88px;
padding: 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.avatar-choice.active {
border-color: #2563eb;
background: #eff6ff;
}
.avatar-choice.busy {
opacity: 0.72;
}
.avatar-choice-icon {
flex: 0 0 54px;
width: 54px;
height: 54px;
border-radius: 8px;
background: #dbeafe;
color: #1d4ed8;
text-align: center;
}
.avatar-choice-icon.female {
background: #fce7f3;
color: #be185d;
}
.avatar-choice-mark {
display: block;
font-size: 28px;
font-weight: 850;
line-height: 54px;
}
.avatar-choice-copy {
min-width: 0;
}
.avatar-choice-title {
display: block;
color: #0f172a;
font-size: 24px;
font-weight: 780;
line-height: 1.25;
}
.avatar-choice-meta {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 20px;
line-height: 1.25;
}
.list-stack {
display: flex;
flex-direction: column;