feat: 个人中心

This commit is contained in:
阿梦 2026-05-03 12:49:02 +09:00
parent aa8779c1d4
commit 7ad9abb5b2
4 changed files with 539 additions and 2 deletions

BIN
src/assets/icon/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

View File

@ -1,6 +1,6 @@
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="header-user"> <button class="header-user" type="button" @click="goToProfile">
<img <img
src="https://placehold.co/52x52/e9eef9/8f9db7?text=%E5%A4%B4" src="https://placehold.co/52x52/e9eef9/8f9db7?text=%E5%A4%B4"
alt="学生头像" alt="学生头像"
@ -10,7 +10,7 @@
<p class="user-name">HI, 张小小</p> <p class="user-name">HI, 张小小</p>
<span class="user-level">Lv1</span> <span class="user-level">Lv1</span>
</div> </div>
</div> </button>
<div class="header-actions"> <div class="header-actions">
<button class="grade-btn"> <button class="grade-btn">
七年级 七年级
@ -42,6 +42,9 @@
import xinxiangIcon from '@/assets/header/xinxiang.png' import xinxiangIcon from '@/assets/header/xinxiang.png'
import huodongIcon from '@/assets/header/huodong.png' import huodongIcon from '@/assets/header/huodong.png'
import setupIcon from '@/assets/header/setup.png' import setupIcon from '@/assets/header/setup.png'
import { useRouter } from 'vue-router'
const router = useRouter()
const actions = [ const actions = [
{ label: '作业', icon: '□', iconSrc: xinxiangIcon, badge: 1 }, { label: '作业', icon: '□', iconSrc: xinxiangIcon, badge: 1 },
@ -49,6 +52,10 @@ const actions = [
{ label: '活动', icon: '▣', iconSrc: huodongIcon, badge: 1 }, { label: '活动', icon: '▣', iconSrc: huodongIcon, badge: 1 },
{ label: '设置', icon: '⚙', iconSrc: setupIcon, badge: 0 }, { label: '设置', icon: '⚙', iconSrc: setupIcon, badge: 0 },
] ]
const goToProfile = () => {
router.push({ name: 'profile' })
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -66,6 +73,11 @@ const actions = [
align-items: center; align-items: center;
gap: 12px; gap: 12px;
height: 88px; height: 88px;
border: none;
background: transparent;
cursor: pointer;
font: inherit;
padding: 0;
} }
.user-avatar { .user-avatar {

View File

@ -9,6 +9,12 @@ const router = createRouter({
name: 'subjects', name: 'subjects',
component: () => import('@/views/Subjects/index.vue'), component: () => import('@/views/Subjects/index.vue'),
}, },
{
path: '/profile',
name: 'profile',
component: () => import('@/views/Profile.vue'),
meta: { hideLayout: true },
},
{ {
path: '/subjects/student-management', path: '/subjects/student-management',
name: 'student-management', name: 'student-management',

519
src/views/Profile.vue Normal file
View File

@ -0,0 +1,519 @@
<template>
<div class="profile-page">
<header class="profile-header">
<button class="back-btn" type="button" aria-label="返回" @click="router.back()">
<svg class="back-icon" viewBox="0 0 28 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21 4L6 23L21 42"
stroke="#111827"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<span class="page-title">个人中心</span>
</header>
<PageFrame section-class="profile-frame">
<section class="profile-card">
<aside class="student-panel">
<div class="avatar-wrap">
<div class="avatar-face">
<span class="hair hair-left"></span>
<span class="hair hair-right"></span>
<span class="face"></span>
<span class="bangs"></span>
<span class="eye eye-left"></span>
<span class="eye eye-right"></span>
<span class="mouth"></span>
<span class="body"></span>
</div>
</div>
<div class="student-name" :class="{ editing: isEditing }">爱学习的张小小</div>
<div class="student-info">
<label v-for="item in profileItems" :key="item.label" class="info-row" :class="{ editing: isEditing }">
<span class="info-label">{{ item.label }}</span>
<textarea
v-if="isEditing"
v-model="item.value"
class="info-input"
:aria-label="item.label"
rows="1"
@input="resizeTextarea"
></textarea>
<span v-else class="info-value">{{ item.value }}</span>
</label>
</div>
<button class="edit-btn" :class="{ saveBtn: isEditing }" type="button" @click="toggleEditing">
<img v-if="!isEditing" class="edit-icon" :src="editIcon" alt="" />
{{ isEditing ? '保存' : '修改资料' }}
</button>
</aside>
<main class="profile-main">
<div class="stats-grid">
<article v-for="stat in stats" :key="stat.label" class="stat-card">
<div class="stat-title">
<span>{{ stat.label }}</span>
<template v-if="stat.level">
<span class="level-badge">Lv1</span>
<span class="level-tip">再32点可升级</span>
</template>
</div>
<strong class="stat-value">{{ stat.value }}</strong>
</article>
</div>
<h2 class="section-title">我的成就</h2>
<div class="achievement-grid">
<article v-for="achievement in achievements" :key="achievement.id" class="achievement-card">
<div class="medal-placeholder"></div>
<h3>{{ achievement.title }}</h3>
<p>
获得途径单词闯关环节中拼写的单词总数达<span>{{ achievement.count }}</span>
</p>
</article>
</div>
</main>
</section>
</PageFrame>
</div>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import PageFrame from '@/components/layout/PageFrame.vue'
import editIcon from '@/assets/icon/edit.png'
const router = useRouter()
const isEditing = ref(false)
const profileItems = reactive([
{ label: '姓名', value: '张小小' },
{ label: '性别', value: '女' },
{ label: '学校', value: '关岭自治县花江领航学校' },
{ label: '班级', value: '五年级2班' },
])
const stats = [
{ label: '成长值', value: 2356, level: true },
{ label: '钻石', value: 56 },
{ label: '乐贝', value: 56 },
]
const achievements = Array.from({ length: 6 }, (_, index) => ({
id: index + 1,
title: '百词入门',
count: 100,
}))
const resizeTextarea = (event: Event) => {
const textarea = event.target as HTMLTextAreaElement
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}
const toggleEditing = async () => {
isEditing.value = !isEditing.value
if (isEditing.value) {
await nextTick()
document.querySelectorAll<HTMLTextAreaElement>('.info-input').forEach((textarea) => {
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
})
}
}
</script>
<style scoped lang="scss">
.profile-page {
display: flex;
min-height: 100%;
flex: 1;
flex-direction: column;
background: #d9e3f8;
}
.profile-header {
display: flex;
align-items: center;
gap: 14px;
min-height: 82px;
padding: 18px 64px 6px;
}
.back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 44px;
border: none;
background: transparent;
cursor: pointer;
padding: 0;
}
.back-icon {
width: 22px;
height: 36px;
}
.page-title {
color: #15171c;
font-size: 24px;
font-weight: 500;
}
:deep(.profile-frame) {
height: calc(100vh - 124px);
overflow: hidden;
border-color: rgba(255, 255, 255, 0.76);
border-radius: 28px;
background: rgba(255, 255, 255, 0.62);
padding: 28px 34px 32px;
}
.profile-card {
display: grid;
grid-template-columns: 456px 1fr;
gap: 28px;
height: 100%;
min-height: 0;
background: #fff;
border-radius: 14px;
}
.student-panel {
width: 456px;
display: flex;
min-height: 0;
flex-direction: column;
align-items: center;
border-radius: 14px;
background: linear-gradient(180deg, #4387f7 0%, #2d76ee 100%);
border: 2px solid #fff;
padding: 56px 32px;
color: #fff;
}
.avatar-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 180px;
height: 180px;
flex: 0 0 auto;
border-radius: 999px;
background: #f3f8ff;
box-shadow: inset 0 0 0 8px rgba(209, 224, 247, 0.92);
}
.avatar-face {
position: relative;
width: 86px;
height: 86px;
overflow: hidden;
border-radius: 50%;
background: #99dff0;
}
.face {
position: absolute;
left: 18px;
top: 23px;
width: 50px;
height: 46px;
border-radius: 44% 44% 50% 50%;
background: #ffd6cf;
}
.hair {
position: absolute;
z-index: 2;
top: 13px;
width: 35px;
height: 48px;
background: #151113;
}
.hair-left {
left: 13px;
border-radius: 24px 10px 18px 20px;
}
.hair-right {
right: 13px;
border-radius: 10px 24px 20px 18px;
}
.bangs {
position: absolute;
z-index: 3;
left: 18px;
top: 17px;
width: 50px;
height: 22px;
border-radius: 18px 18px 8px 8px;
background: #151113;
}
.eye,
.mouth {
position: absolute;
z-index: 4;
}
.eye {
top: 45px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #201819;
}
.eye-left {
left: 33px;
}
.eye-right {
right: 33px;
}
.mouth {
left: 39px;
top: 57px;
width: 8px;
height: 4px;
border-bottom: 2px solid #d45d67;
border-radius: 0 0 8px 8px;
}
.body {
position: absolute;
z-index: 3;
left: 25px;
bottom: -2px;
width: 36px;
height: 24px;
border-radius: 18px 18px 0 0;
background: #21a8bd;
}
.student-name {
width: 100%;
margin-top: 28px;
text-align: center;
font-size: 36px;
font-weight: 700;
line-height: 54px;
padding: 16px;
border-radius: 12px;
&.editing {
// border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
}
.student-info {
display: flex;
width: 100%;
flex-direction: column;
gap: 16px;
margin-top: 64px;
}
.info-row {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: flex-start;
min-height: 40px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.92);
font-size: 28px;
line-height: 1.35;
padding: 16px;
&.editing {
min-height: 52px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
}
}
.info-label {
white-space: nowrap;
}
.info-input {
min-width: 0;
width: 100%;
min-height: 38px;
border: none;
outline: none;
background: transparent;
color: #fff;
font: inherit;
line-height: 1.35;
resize: none;
overflow: hidden;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
.info-value {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
.edit-btn {
min-width: 172px;
// height: 38px;
margin-top: auto;
border: none;
border-radius: 999px;
background: #2d76ee;
// background: ;
color:#fff;
cursor: pointer;
font-size: 24px;
padding: 12px 24px;
border: 1px solid #fff;
}
.saveBtn {
background: #fff;
color: #2d76ee;
}
.edit-icon {
width: 21px;
height: 21px;
margin-right: 5.5px;
object-fit: contain;
vertical-align: -2px;
}
.profile-main {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
padding-top: 32px;
}
.stat-card {
height: 218px;
border: 1px solid #ebf2ff;
border-radius: 16px;
background: #fff;
padding: 22px 24px;
}
.stat-title {
display: flex;
align-items: center;
gap: 16px;
color: #222;
font-size: 32px;
font-weight: 700;
}
.level-badge {
border-radius: 3px;
background: #2f7df3;
padding: 4px 6px;
color: #fff;
font-size: 18px;
// line-height: 1.2;
}
.level-tip {
color: #6f7785;
font-size: 20px;
font-weight: 400;
}
.stat-value {
display: block;
margin-top: 36px;
text-align: center;
color: #050505;
font-size: 44px;
font-weight: 700;
}
.section-title {
margin: 36px 0 32px;
color: #050505;
font-size: 32px;
font-weight: 700;
}
.achievement-grid {
display: grid;
flex: 1;
min-height: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: max-content;
gap: 20px;
overflow-y: auto;
padding-right: 2px;
}
.achievement-card {
width: 381px;
height: 392px;
border: 1px solid rgba(229, 231, 235, 0.92);
border-radius: 12px;
background: #fff;
padding: 48px 24px;
text-align: center;
}
.medal-placeholder {
width: 120px;
height: 120px;
margin: 0 auto 48px;
border: 3px solid #dedede;
background:
linear-gradient(135deg, transparent 0 46%, rgba(235, 235, 235, 0.75) 47% 53%, transparent 54%),
#f9f9f9;
}
.achievement-card h3 {
color: #050505;
font-size: 28px;
font-weight: 700;
}
.achievement-card p {
margin-top: 10px;
color: #333b45;
font-size: 24px;
line-height: 1.55;
text-align: left;
}
.achievement-card span {
color: #2f7df3;
}
</style>