feat: 优化用户中心和认证页面

- 重构用户中心页面UI和交互
- 新增CenterNew.tsx提供更好的用户体验
- 优化登录注册页面的表单验证
- 集成头像上传和个人资料修改功能
- 改进页面样式和响应式布局
This commit is contained in:
Leo 2025-10-13 21:42:26 +08:00
parent 0a63a30a23
commit bca9345e02
5 changed files with 1354 additions and 207 deletions

View File

@ -1,165 +1,331 @@
/* 用户中心页样式 */
/* 用户中心页样式 - 恢复版本 */
.user-center-page {
min-height: 100vh;
background: #fafaf8;
background: #f8f9fa;
padding-bottom: 60px;
}
.page-header {
background: linear-gradient(135deg, #fafaf8 0%, #f5f0e8 50%, #fafaf8 100%);
padding: 80px 0 40px;
/* ========== 封面背景 ========== */
.profile-cover {
position: relative;
height: 320px;
background: linear-gradient(135deg, #c8363d 0%, #d4a574 50%, #e8b864 100%);
background-size: cover;
background-position: center;
overflow: hidden;
}
/* 装饰性背景圆圈 */
.page-header::before {
.profile-cover::before {
content: '';
position: absolute;
top: -30%;
right: 5%;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(200, 54, 61, 0.06) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320"><path fill="rgba(255,255,255,0.1)" d="M0,96L48,112C96,128,192,160,288,160C384,160,480,128,576,112C672,96,768,96,864,112C960,128,1056,160,1152,160C1248,160,1344,128,1392,112L1440,96L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"></path></svg>') no-repeat;
background-size: cover;
background-position: bottom;
opacity: 0.6;
}
.page-header::after {
content: '';
.cover-overlay {
position: absolute;
bottom: -30%;
left: 5%;
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(212, 165, 116, 0.06) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.3) 100%);
}
.user-profile-card {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 40px;
border: 1px solid rgba(232, 227, 219, 0.6);
/* ========== 个人信息区域 ========== */
.profile-section {
margin-top: -160px;
position: relative;
z-index: 1;
z-index: 10;
}
.user-profile-header {
.profile-main-card {
background: #ffffff;
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08);
padding: 40px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 32px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
/* 左侧:头像和信息 */
.profile-left {
flex: 1;
display: flex;
gap: 24px;
align-items: flex-start;
}
.avatar-section {
position: relative;
flex-shrink: 0;
}
.user-avatar-large {
border: 5px solid #ffffff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
background: linear-gradient(135deg, #c8363d 0%, #d4a574 100%);
}
.avatar-badge {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
}
.avatar-badge .ant-tag {
border-radius: 12px;
font-weight: 600;
font-size: 13px;
padding: 2px 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 用户基本信息 */
.user-basic-info {
flex: 1;
padding-top: 8px;
}
.user-name-row {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding-bottom: 32px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.user-avatar-wrapper {
position: relative;
}
.user-avatar-wrapper .ant-avatar {
border: 4px solid #f5f0e8;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.user-info-content {
flex: 1;
}
.user-info-content h2 {
font-size: 24px;
.user-nickname {
font-size: 32px;
font-weight: 700;
color: #2c2c2c;
margin: 0 0 8px 0;
color: #1a1a1a;
margin: 0;
font-family: 'Noto Serif SC', 'Songti SC', serif;
letter-spacing: -0.5px;
}
.user-email {
.user-tag {
border-radius: 6px;
font-size: 14px;
color: #999999;
margin: 0;
padding: 4px 12px;
font-weight: 500;
}
.user-meta-info {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
}
.user-actions {
.meta-item .anticon,
.meta-item > span:first-child {
color: #999;
font-size: 16px;
}
.user-bio {
font-size: 14px;
color: #666;
line-height: 1.8;
margin: 16px 0 0 0;
padding-left: 20px;
border-left: 3px solid #c8363d;
}
/* 右侧:操作按钮 */
.profile-right {
flex-shrink: 0;
}
.profile-actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-left: auto;
}
.user-actions .ant-btn {
border-radius: 8px;
.profile-actions .ant-btn {
border-radius: 10px;
font-weight: 500;
height: 38px;
padding: 0 20px;
min-width: 140px;
height: 44px;
font-size: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.user-actions .ant-btn-default {
border-color: #d9d9d9;
color: #666666;
.profile-actions .ant-btn-primary {
background: linear-gradient(135deg, #c8363d 0%, #d4a574 100%);
border: none;
}
.user-actions .ant-btn-default:hover {
border-color: #c8363d;
color: #c8363d;
}
.user-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.stat-item {
text-align: center;
padding: 24px;
background: linear-gradient(135deg, #fafaf8 0%, #f5f0e8 100%);
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.stat-item:hover {
.profile-actions .ant-btn-primary:hover {
background: linear-gradient(135deg, #b3303a 0%, #c19666 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: rgba(200, 54, 61, 0.2);
box-shadow: 0 4px 12px rgba(200, 54, 61, 0.4);
}
.stat-icon {
font-size: 32px;
color: #c8363d;
margin-bottom: 12px;
display: block;
/* ========== 数据统计卡片(精致版) ========== */
.stats-cards-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.stat-value {
.stat-card-simple {
position: relative;
background: #ffffff;
border-radius: 16px;
padding: 28px 24px;
display: flex;
align-items: center;
gap: 20px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
/* 左侧装饰色块 - 已移除 */
/* .stat-card-simple::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
transition: width 0.3s ease;
}
.stat-card-simple:hover::before {
width: 6px;
} */
.stat-card-simple:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border-color: rgba(0, 0, 0, 0.08);
}
/* 图标圆形背景 */
.stat-icon-circle {
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
transition: all 0.3s ease;
position: relative;
}
.stat-card-simple:hover .stat-icon-circle {
transform: scale(1.08);
}
/* 统计数据区域 */
.stat-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
/* 数字 */
.stat-number {
font-size: 28px;
font-weight: 700;
color: #2c2c2c;
margin: 0 0 4px 0;
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.2;
background: linear-gradient(135deg, #c8363d 0%, #d4a574 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
/* 文字标签 */
.stat-text {
font-size: 13px;
color: #999999;
margin: 0;
color: #888;
font-weight: 500;
}
/* 页面内容 */
/* 收藏卡片 */
.stat-card-simple:nth-child(1) .stat-icon-circle {
background: linear-gradient(135deg, rgba(200, 54, 61, 0.12) 0%, rgba(200, 54, 61, 0.08) 100%);
color: #c8363d;
}
/* 评论卡片 */
.stat-card-simple:nth-child(2) .stat-icon-circle {
background: linear-gradient(135deg, rgba(212, 165, 116, 0.12) 0%, rgba(212, 165, 116, 0.08) 100%);
color: #d4a574;
}
/* 学习时长卡片 */
.stat-card-simple:nth-child(3) .stat-icon-circle {
background: linear-gradient(135deg, rgba(232, 184, 100, 0.12) 0%, rgba(232, 184, 100, 0.08) 100%);
color: #e8b864;
}
/* 积分卡片 */
.stat-card-simple:nth-child(4) .stat-icon-circle {
background: linear-gradient(135deg, rgba(200, 54, 61, 0.12) 0%, rgba(212, 165, 116, 0.08) 100%);
color: #c8363d;
}
/* 移除不需要的颜色类 */
.heart-bg,
.comment-bg,
.clock-bg,
.trophy-bg {
/* 这些类现在通过 nth-child 处理 */
}
/* ========== 页面内容 ========== */
.page-content {
background: #fafaf8;
background: transparent;
padding-top: 32px;
}
.page-content .ant-tabs {
background: #ffffff;
padding: 24px;
border-radius: 12px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.page-content .ant-tabs-nav {
margin-bottom: 24px;
}
.page-content .ant-tabs-tab {
font-size: 15px;
font-weight: 500;
padding: 12px 24px;
}
/* 卡片封面 */
@ -168,7 +334,8 @@
width: 100%;
padding-top: 66.67%;
overflow: hidden;
background: #f5f0e8;
background: #f5f5f5;
border-radius: 8px;
}
.card-cover img {
@ -178,20 +345,27 @@
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.ant-card:hover .card-cover img {
transform: scale(1.05);
}
/* 成就网格 */
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 24px;
}
.achievement-card {
text-align: center;
padding: 32px 16px;
border-radius: 12px;
padding: 32px 20px;
border-radius: 16px;
transition: all 0.3s ease;
background: #ffffff;
border: 2px solid #f0f0f0;
}
.achievement-card.locked {
@ -200,101 +374,251 @@
}
.achievement-card:not(.locked):hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-6px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
border-color: #c8363d;
}
.achievement-icon {
font-size: 48px;
color: #d4a574;
font-size: 56px;
color: #fee140;
margin-bottom: 16px;
filter: drop-shadow(0 4px 8px rgba(254, 225, 64, 0.3));
}
.achievement-card h3 {
font-size: 16px;
font-weight: 600;
color: #2c2c2c;
font-size: 17px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.achievement-card p {
font-size: 13px;
color: #999999;
color: #999;
margin: 0;
line-height: 1.6;
}
/* 响应式 */
@media (max-width: 992px) {
.user-profile-header {
flex-direction: column;
text-align: center;
}
.user-info-content {
text-align: center;
}
.user-email {
justify-content: center;
}
.user-actions {
margin-left: 0;
justify-content: center;
}
.user-stats {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.user-stats {
/* ========== 响应式设计 ========== */
@media (max-width: 1200px) {
.stats-cards-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 576px) {
.page-header {
padding: 60px 0 30px;
@media (max-width: 992px) {
.profile-cover {
height: 240px;
}
.user-profile-card {
padding: 24px;
.profile-section {
margin-top: -120px;
}
.user-profile-header {
margin-bottom: 24px;
padding-bottom: 24px;
.profile-main-card {
flex-direction: column;
padding: 32px;
}
.user-avatar-wrapper .ant-avatar {
width: 80px !important;
height: 80px !important;
.profile-left {
flex-direction: column;
align-items: center;
text-align: center;
}
.user-info-content h2 {
font-size: 20px;
.user-name-row {
justify-content: center;
}
.user-actions {
.user-meta-info {
justify-content: center;
}
.profile-right {
width: 100%;
}
.user-actions .ant-btn {
.profile-actions {
flex-direction: row;
}
.profile-actions .ant-btn {
flex: 1;
}
.user-stats {
grid-template-columns: 1fr;
.user-bio {
text-align: left;
border-left: none;
padding-left: 0;
border-top: 3px solid #c8363d;
padding-top: 16px;
}
}
@media (max-width: 768px) {
.profile-cover {
height: 200px;
}
.profile-section {
margin-top: -100px;
}
.profile-main-card {
padding: 24px;
}
.user-avatar-large {
width: 100px !important;
height: 100px !important;
}
.user-nickname {
font-size: 26px;
}
.stats-cards-grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.stat-item {
padding: 20px;
.stat-card-simple {
padding: 20px 16px;
gap: 16px;
}
.stat-icon-circle {
width: 48px;
height: 48px;
font-size: 20px;
border-radius: 12px;
}
.stat-number {
font-size: 24px;
}
.stat-text {
font-size: 12px;
}
.achievements-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 576px) {
.profile-cover {
height: 160px;
}
.profile-section {
margin-top: -80px;
}
.profile-main-card {
padding: 20px;
}
.user-avatar-large {
width: 80px !important;
height: 80px !important;
}
.user-nickname {
font-size: 22px;
}
.user-meta-info {
flex-direction: column;
gap: 8px;
}
.profile-actions {
flex-direction: column;
}
.stats-cards-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.stat-card-simple {
padding: 18px 16px;
gap: 14px;
}
.stat-icon-circle {
width: 44px;
height: 44px;
font-size: 18px;
border-radius: 10px;
}
.stat-number {
font-size: 22px;
}
.stat-text {
font-size: 12px;
}
}
/* ========== 头像上传器 ========== */
.avatar-uploader {
flex-shrink: 0;
}
.avatar-uploader .ant-upload {
border: none !important;
background: transparent !important;
width: 100px;
height: 100px;
border-radius: 50%;
cursor: pointer;
position: relative;
}
.avatar-uploader .ant-upload:hover {
border-color: transparent !important;
}
.avatar-uploader .ant-upload:hover .ant-avatar {
opacity: 0.8;
}
/* 上传按钮悬浮效果 */
.avatar-uploader .ant-upload:hover div[style*="position: absolute"] {
transform: scale(1.1);
background: #1677ff !important;
}
/* 上传中的加载动画 */
.avatar-uploader .anticon-loading {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 概览卡片统一样式 */
.profile-overview-card {
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.profile-overview-card .ant-card-body {
padding: 32px;
}
/* ========== 加载状态 ========== */
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}

View File

@ -68,40 +68,109 @@ const UserCenter: React.FC = () => {
return (
<div className="user-center-page">
<div className="page-header">
{/* 封面背景 */}
<div className="profile-cover">
<div className="cover-overlay"></div>
</div>
{/* 个人信息卡片 */}
<div className="profile-section">
<div className="container">
<div className="user-profile-card">
<div className="user-profile-header">
<div className="user-avatar-wrapper">
<Avatar size={100} src={user.avatar} icon={<UserOutlined />} />
<div className="profile-main-card">
{/* 左侧:头像和基本信息 */}
<div className="profile-left">
<div className="avatar-section">
<Avatar
size={120}
src={user.avatar}
icon={<UserOutlined />}
className="user-avatar-large"
/>
<div className="avatar-badge">
<Tag color="gold">Lv.{user.level}</Tag>
</div>
</div>
<div className="user-info-content">
<h2>{user.username}</h2>
<p className="user-email">
<MailOutlined />
{user.email}
</p>
</div>
<div className="user-actions">
<Button icon={<EditOutlined />}></Button>
<div className="user-basic-info">
<div className="user-name-row">
<h1 className="user-nickname">{user.nickname}</h1>
<Tag color="blue" className="user-tag">@{user.username}</Tag>
</div>
<div className="user-meta-info">
<div className="meta-item">
<MailOutlined />
<span>{user.email}</span>
</div>
{user.phone && (
<div className="meta-item">
<span>📱</span>
<span>{user.phone}</span>
</div>
)}
{user.createdAt && (
<div className="meta-item">
<ClockCircleOutlined />
<span> {user.createdAt}</span>
</div>
)}
</div>
{user.bio && (
<p className="user-bio">{user.bio}</p>
)}
</div>
</div>
<div className="user-stats">
<div className="stat-item">
<HeartOutlined className="stat-icon" />
<div className="stat-value">{user.favorites.length}</div>
<div className="stat-label"></div>
{/* 右侧:操作按钮 */}
<div className="profile-right">
<div className="profile-actions">
<Button type="primary" icon={<EditOutlined />} size="large">
</Button>
</div>
<div className="stat-item">
<CommentOutlined className="stat-icon" />
<div className="stat-value">0</div>
<div className="stat-label"></div>
</div>
</div>
{/* 数据统计卡片 */}
<div className="stats-cards-grid">
<div className="stat-card-simple">
<div className="stat-icon-circle">
<HeartOutlined />
</div>
<div className="stat-item">
<ClockCircleOutlined className="stat-icon" />
<div className="stat-value">0</div>
<div className="stat-label"></div>
<div className="stat-info">
<div className="stat-number">{user.favorites.length}</div>
<div className="stat-text"></div>
</div>
</div>
<div className="stat-card-simple">
<div className="stat-icon-circle">
<CommentOutlined />
</div>
<div className="stat-info">
<div className="stat-number">0</div>
<div className="stat-text"></div>
</div>
</div>
<div className="stat-card-simple">
<div className="stat-icon-circle">
<ClockCircleOutlined />
</div>
<div className="stat-info">
<div className="stat-number">0</div>
<div className="stat-text"></div>
</div>
</div>
<div className="stat-card-simple">
<div className="stat-icon-circle">
<TrophyOutlined />
</div>
<div className="stat-info">
<div className="stat-number">{user.points || 0}</div>
<div className="stat-text"></div>
</div>
</div>
</div>

View File

@ -0,0 +1,729 @@
/**
*
*
*/
import React, { useEffect, useState } from 'react'
import {
Tabs, Card, Avatar, Button, Form, Input, Select, DatePicker,
message, Modal, Statistic, Row, Col, List, Empty, Tag, Space, Divider,
Upload
} from 'antd'
import {
UserOutlined,
EditOutlined,
LockOutlined,
EyeOutlined,
LikeOutlined,
StarOutlined,
CommentOutlined,
CalendarOutlined,
HistoryOutlined,
MailOutlined,
PhoneOutlined,
ManOutlined,
WomanOutlined,
QuestionCircleOutlined,
CameraOutlined,
LoadingOutlined,
} from '@ant-design/icons'
import type { UploadChangeParam, UploadFile } from 'antd/es/upload/interface'
import { Link, useNavigate } from 'react-router-dom'
import dayjs from 'dayjs'
import { getToken, removeToken } from '@/utils/request'
import CustomPagination from '@components/CustomPagination'
import { useUserStore } from '@/store/useUserStore'
import {
getUserProfile,
updateUserProfile,
updateUserPassword,
getUserStats,
getViewHistory,
uploadAvatar,
} from '@services/userApi'
import type {
UserProfile,
UserProfileUpdateParams,
UserStats,
ViewHistoryItem,
ViewHistoryTargetType,
} from '@/types'
import './Center.css'
const { TabPane } = Tabs
const { Option } = Select
// 性别选项
const genderOptions = [
{ label: '未知', value: 0, icon: <QuestionCircleOutlined /> },
{ label: '男', value: 1, icon: <ManOutlined /> },
{ label: '女', value: 2, icon: <WomanOutlined /> },
]
// 浏览历史类型映射
const historyTypeLabels: Record<ViewHistoryTargetType, string> = {
heritage: '非遗项目',
inheritor: '传承人',
news: '资讯',
event: '活动',
}
const historyTypeColors: Record<ViewHistoryTargetType, string> = {
heritage: 'red',
inheritor: 'blue',
news: 'green',
event: 'orange',
}
const UserCenterNew: React.FC = () => {
const navigate = useNavigate()
const [profileForm] = Form.useForm()
const [passwordForm] = Form.useForm()
// 获取Zustand store的updateUser方法
const { updateUser } = useUserStore()
// 状态管理
const [activeTab, setActiveTab] = useState('profile')
const [profile, setProfile] = useState<UserProfile | null>(null)
const [stats, setStats] = useState<UserStats | null>(null)
const [viewHistory, setViewHistory] = useState<ViewHistoryItem[]>([])
const [historyTotal, setHistoryTotal] = useState(0)
const [historyPage, setHistoryPage] = useState(1)
const [historyPageSize] = useState(10)
const [historyType, setHistoryType] = useState<ViewHistoryTargetType | ''>('')
const [loading, setLoading] = useState(false)
const [profileEditing, setProfileEditing] = useState(false)
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [passwordSubmitting, setPasswordSubmitting] = useState(false)
const [avatarUploading, setAvatarUploading] = useState(false)
useEffect(() => {
// 检查登录状态
const token = getToken()
if (!token) {
message.warning('请先登录')
navigate('/login')
return
}
fetchUserProfile()
fetchUserStats()
}, [])
useEffect(() => {
if (activeTab === 'history') {
fetchViewHistory()
}
}, [activeTab, historyPage, historyType])
// 获取个人资料
const fetchUserProfile = async () => {
setLoading(true)
try {
const data = await getUserProfile()
setProfile(data)
// 设置表单初始值
profileForm.setFieldsValue({
nickname: data.nickname,
email: data.email,
phone: data.phone,
gender: data.gender,
birthday: data.birthday ? dayjs(data.birthday) : null,
province: data.province,
city: data.city,
})
} catch (error: any) {
message.error(error.message || '获取个人资料失败')
} finally {
setLoading(false)
}
}
// 获取用户统计
const fetchUserStats = async () => {
try {
const data = await getUserStats()
setStats(data)
} catch (error: any) {
console.error('获取统计信息失败:', error)
}
}
// 获取浏览历史
const fetchViewHistory = async () => {
setLoading(true)
try {
const result = await getViewHistory({
pageNum: historyPage,
pageSize: historyPageSize,
targetType: historyType || undefined,
})
setViewHistory(result.records)
setHistoryTotal(result.total)
} catch (error: any) {
message.error(error.message || '获取浏览历史失败')
} finally {
setLoading(false)
}
}
// 修改个人资料
const handleUpdateProfile = async () => {
try {
const values = await profileForm.validateFields()
setLoading(true)
const params: UserProfileUpdateParams = {
nickname: values.nickname,
email: values.email,
phone: values.phone,
gender: values.gender,
birthday: values.birthday ? values.birthday.format('YYYY-MM-DD') : undefined,
province: values.province,
city: values.city,
}
await updateUserProfile(params)
message.success('个人资料修改成功')
setProfileEditing(false)
// 刷新资料
await fetchUserProfile()
// 🔥 关键: 同步更新Zustand store,使Header实时更新
// 特别是昵称的更新
updateUser({
nickname: values.nickname,
email: values.email,
phone: values.phone,
})
} catch (error: any) {
if (error.errorFields) {
// 表单验证错误
return
}
message.error(error.message || '修改失败')
} finally {
setLoading(false)
}
}
// 上传头像前的校验
const beforeUpload = (file: File) => {
// 校验文件类型
const isImage = file.type === 'image/jpeg' || file.type === 'image/png' || file.type === 'image/gif'
if (!isImage) {
message.error('只能上传 JPG/PNG/GIF 格式的图片!')
return false
}
// 校验文件大小 (2MB)
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过 2MB!')
return false
}
return true
}
// 处理头像上传
const handleAvatarChange = async (info: UploadChangeParam<UploadFile>) => {
if (info.file.status === 'uploading') {
setAvatarUploading(true)
return
}
if (info.file.status === 'done') {
// 上传成功,获取返回的头像URL
const avatarUrl = info.file.response
if (avatarUrl && profile) {
// 更新本地状态
setProfile({ ...profile, avatar: avatarUrl })
// 🔥 关键: 同步更新Zustand store,使Header实时更新
updateUser({ avatar: avatarUrl })
message.success('头像上传成功!')
}
setAvatarUploading(false)
} else if (info.file.status === 'error') {
setAvatarUploading(false)
message.error('头像上传失败,请重试')
}
}
// 自定义上传请求
const customUploadRequest = async (options: any) => {
const { file, onSuccess, onError } = options
try {
const avatarUrl = await uploadAvatar(file)
onSuccess(avatarUrl)
} catch (error: any) {
console.error('上传失败:', error)
onError(error)
}
}
// 修改密码
const handleUpdatePassword = async () => {
try {
const values = await passwordForm.validateFields()
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的新密码不一致')
return
}
setPasswordSubmitting(true)
await updateUserPassword({
oldPassword: values.oldPassword,
newPassword: values.newPassword,
confirmPassword: values.confirmPassword,
})
message.success('密码修改成功,请重新登录')
passwordForm.resetFields()
setPasswordModalVisible(false)
// 清除token跳转登录页
removeToken()
setTimeout(() => {
navigate('/login')
}, 1500)
} catch (error: any) {
if (error.errorFields) {
return
}
message.error(error.message || '密码修改失败')
} finally {
setPasswordSubmitting(false)
}
}
// 渲染浏览历史项
const renderHistoryItem = (item: ViewHistoryItem) => {
const getLink = () => {
switch (item.targetType) {
case 'heritage':
return `/heritage/${item.targetId}`
case 'inheritor':
return `/inheritor/${item.targetId}`
case 'news':
return `/news/${item.targetId}`
case 'event':
return `/events/${item.targetId}`
default:
return '#'
}
}
return (
<List.Item
extra={
item.targetCover && (
<img
width={120}
height={80}
alt={item.targetTitle}
src={item.targetCover}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
)
}
>
<List.Item.Meta
title={
<Space>
<Tag color={historyTypeColors[item.targetType]}>
{historyTypeLabels[item.targetType]}
</Tag>
<Link to={getLink()} style={{ fontSize: 16, fontWeight: 500 }}>
{item.targetTitle}
</Link>
</Space>
}
description={
<Space direction="vertical" size={4}>
<div style={{ color: '#666', fontSize: 14 }}>
{item.targetDescription}
</div>
<div style={{ color: '#999', fontSize: 13 }}>
<HistoryOutlined /> {dayjs(item.viewTime).format('YYYY-MM-DD HH:mm')}
</div>
</Space>
}
/>
</List.Item>
)
}
if (!profile) {
return (
<div className="loading-container">
<Empty description="加载中..." />
</div>
)
}
return (
<div className="user-center-page">
{/* 封面背景 */}
<div className="profile-cover">
<div className="cover-overlay"></div>
</div>
{/* 个人信息概览 */}
<div className="profile-section">
<div className="container">
<Card className="profile-overview-card">
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
{/* 头像上传组件 */}
<Upload
name="file"
listType="picture-circle"
className="avatar-uploader"
showUploadList={false}
beforeUpload={beforeUpload}
onChange={handleAvatarChange}
customRequest={customUploadRequest}
>
{avatarUploading ? (
<div style={{
width: 100,
height: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<LoadingOutlined style={{ fontSize: 32, color: '#1890ff' }} />
</div>
) : (
<div style={{ position: 'relative' }}>
<Avatar
size={100}
src={profile.avatar}
icon={<UserOutlined />}
style={{ flexShrink: 0 }}
/>
<div
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: 32,
height: 32,
background: '#1890ff',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<CameraOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
</div>
)}
</Upload>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>
{profile.nickname}
</h1>
<Space size="middle">
<span style={{ color: '#666' }}>
<UserOutlined /> @{profile.username}
</span>
{profile.email && (
<span style={{ color: '#666' }}>
<MailOutlined /> {profile.email}
</span>
)}
{profile.phone && (
<span style={{ color: '#666' }}>
<PhoneOutlined /> {profile.phone}
</span>
)}
</Space>
</div>
<Button
type="primary"
icon={<LockOutlined />}
onClick={() => setPasswordModalVisible(true)}
>
</Button>
</div>
{/* 统计信息 */}
{stats && (
<>
<Divider />
<Row gutter={16}>
<Col span={4}>
<Statistic
title="浏览历史"
value={stats.viewHistoryCount}
prefix={<EyeOutlined />}
/>
</Col>
<Col span={4}>
<Statistic
title="我的评论"
value={stats.commentCount}
prefix={<CommentOutlined />}
/>
</Col>
<Col span={4}>
<Statistic
title="我的点赞"
value={stats.likeCount}
prefix={<LikeOutlined />}
/>
</Col>
<Col span={4}>
<Statistic
title="我的收藏"
value={stats.favoriteCount}
prefix={<StarOutlined />}
/>
</Col>
<Col span={4}>
<Statistic
title="活动报名"
value={stats.eventRegistrationCount}
prefix={<CalendarOutlined />}
/>
</Col>
</Row>
</>
)}
</Card>
</div>
</div>
{/* 详细信息区 */}
<div className="user-content-section section-spacing">
<div className="container">
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
{/* 个人资料 */}
<TabPane tab={<span><UserOutlined /></span>} key="profile">
<Card
title="基本信息"
extra={
<Button
type={profileEditing ? 'default' : 'primary'}
icon={<EditOutlined />}
onClick={() => {
if (profileEditing) {
profileForm.resetFields()
}
setProfileEditing(!profileEditing)
}}
>
{profileEditing ? '取消编辑' : '编辑资料'}
</Button>
}
>
<Form
form={profileForm}
layout="vertical"
disabled={!profileEditing}
>
<Row gutter={24}>
<Col span={12}>
<Form.Item label="昵称" name="nickname">
<Input placeholder="请输入昵称" size="large" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: 'email', message: '请输入正确的邮箱格式' }
]}
>
<Input placeholder="请输入邮箱" size="large" />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label="手机号"
name="phone"
rules={[
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }
]}
>
<Input placeholder="请输入手机号" size="large" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="性别" name="gender">
<Select placeholder="请选择性别" size="large">
{genderOptions.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.icon} {opt.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item label="生日" name="birthday">
<DatePicker
placeholder="请选择生日"
size="large"
style={{ width: '100%' }}
format="YYYY-MM-DD"
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="省份" name="province">
<Input placeholder="请输入省份" size="large" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="城市" name="city">
<Input placeholder="请输入城市" size="large" />
</Form.Item>
</Col>
</Row>
{profileEditing && (
<Form.Item>
<Button
type="primary"
size="large"
loading={loading}
onClick={handleUpdateProfile}
>
</Button>
</Form.Item>
)}
</Form>
<Divider />
<div style={{ color: '#999', fontSize: 13 }}>
{dayjs(profile.createTime).format('YYYY-MM-DD HH:mm')}
</div>
</Card>
</TabPane>
{/* 浏览历史 */}
<TabPane tab={<span><HistoryOutlined /></span>} key="history">
<Card>
<Space style={{ marginBottom: 16 }}>
<span></span>
<Select
value={historyType}
onChange={(value) => {
setHistoryType(value)
setHistoryPage(1)
}}
style={{ width: 150 }}
>
<Option value=""></Option>
<Option value="heritage"></Option>
<Option value="inheritor"></Option>
<Option value="news"></Option>
<Option value="event"></Option>
</Select>
</Space>
<List
loading={loading}
dataSource={viewHistory}
renderItem={renderHistoryItem}
locale={{ emptyText: <Empty description="暂无浏览历史" /> }}
/>
{/* 自定义分页 */}
{viewHistory.length > 0 && (
<CustomPagination
current={historyPage}
pageSize={historyPageSize}
total={historyTotal}
unit="条记录"
onChange={(page) => {
setHistoryPage(page)
}}
/>
)}
</Card>
</TabPane>
</Tabs>
</div>
</div>
{/* 修改密码弹窗 */}
<Modal
title="修改密码"
open={passwordModalVisible}
onOk={handleUpdatePassword}
onCancel={() => {
setPasswordModalVisible(false)
passwordForm.resetFields()
}}
confirmLoading={passwordSubmitting}
okText="确认修改"
cancelText="取消"
width={480}
>
<Form
form={passwordForm}
layout="vertical"
style={{ marginTop: 24 }}
>
<Form.Item
label="旧密码"
name="oldPassword"
rules={[{ required: true, message: '请输入旧密码' }]}
>
<Input.Password placeholder="请输入旧密码" size="large" />
</Form.Item>
<Form.Item
label="新密码"
name="newPassword"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, max: 20, message: '密码长度必须在6-20个字符之间' }
]}
>
<Input.Password placeholder="请输入新密码" size="large" />
</Form.Item>
<Form.Item
label="确认新密码"
name="confirmPassword"
rules={[
{ required: true, message: '请确认新密码' },
]}
>
<Input.Password placeholder="请再次输入新密码" size="large" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default UserCenterNew

View File

@ -14,14 +14,18 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const login = useUserStore((state) => state.login)
const onFinish = async (values: { username: string; password: string }) => {
const onFinish = async (values: { account: string; password: string }) => {
setLoading(true)
try {
await login(values.username, values.password)
message.success('登录成功')
navigate('/')
const success = await login(values.account, values.password)
if (success) {
message.success('登录成功')
navigate('/')
}
} catch (error) {
message.error('登录失败,请检查用户名或密码')
// 提取后端返回的具体错误信息
const errorMessage = error instanceof Error ? error.message : '登录失败,请稍后重试'
message.error(errorMessage)
} finally {
setLoading(false)
}
@ -44,10 +48,10 @@ const Login: React.FC = () => {
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
name="account"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
<Input prefix={<UserOutlined />} placeholder="用户名/邮箱/手机号" />
</Form.Item>
<Form.Item

View File

@ -16,8 +16,11 @@ const Register: React.FC = () => {
const onFinish = async (values: {
username: string
email: string
nickname: string
email?: string
phone?: string
password: string
confirmPassword: string
agree: boolean
}) => {
if (!values.agree) {
@ -27,12 +30,13 @@ const Register: React.FC = () => {
setLoading(true)
try {
await register(values.username, values.email, values.password)
await register(values.username, values.password, values.confirmPassword, values.nickname, values.email, values.phone)
message.success('注册成功,欢迎加入非遗文化传承平台!')
navigate('/')
} catch (error) {
const err = error as Error
message.error(err.message || '注册失败,请稍后重试')
// 提取后端返回的具体错误信息
const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试'
message.error(errorMessage)
} finally {
setLoading(false)
}
@ -58,38 +62,55 @@ const Register: React.FC = () => {
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' },
{ max: 20, message: '用户名最多20个字符' },
{ min: 3, max: 16, message: '用户名长度为 3-16 位' },
{ pattern: /^[A-Za-z0-9_]+$/, message: '用户名格式为数字、字母及下划线' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
<Input prefix={<UserOutlined />} placeholder="用户名3-16位字母/数字/下划线)" />
</Form.Item>
<Form.Item
name="nickname"
rules={[
{ required: true, message: '请输入昵称' },
]}
>
<Input prefix={<UserOutlined />} placeholder="昵称" />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input prefix={<MailOutlined />} placeholder="邮箱" />
<Input prefix={<MailOutlined />} placeholder="邮箱(可选)" />
</Form.Item>
<Form.Item
name="phone"
rules={[
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确' },
]}
>
<Input prefix={<UserOutlined />} placeholder="手机号(可选)" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' },
{ min: 6, max: 20, message: '密码长度为 6-20 位' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
<Input.Password prefix={<LockOutlined />} placeholder="密码6-20位" />
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请确认密码' },
{ required: true, message: '请输入确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {