forked from wangziqi/gongxue-base
feat: add preset avatar picker
This commit is contained in:
@@ -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`。
|
||||
|
||||
学生端商城链路的安全边界:
|
||||
|
||||
|
||||
@@ -34,7 +34,12 @@ export default {
|
||||
},
|
||||
defineConstants: {},
|
||||
copy: {
|
||||
patterns: [],
|
||||
patterns: [
|
||||
{
|
||||
from: path.resolve(configDir, '..', 'src', 'assets'),
|
||||
to: path.resolve(configDir, '..', outputRoot, 'assets'),
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
},
|
||||
h5: {
|
||||
|
||||
13
apps/taro/src/assets/avatars/default-female.svg
Normal file
13
apps/taro/src/assets/avatars/default-female.svg
Normal 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 |
11
apps/taro/src/assets/avatars/default-male.svg
Normal file
11
apps/taro/src/assets/avatars/default-male.svg
Normal 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 |
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user