You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1077 lines
27 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="profile-container">
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="profile-main" v-loading="loading" element-loading-text="正在加载用户信息...">
<div class="profile-content">
<!-- 个人信息卡片 -->
<div class="profile-header card-light">
<div class="avatar-section">
<div class="avatar-container">
<el-avatar :size="120" :src="userProfile.avatar">
{{ userProfile.nickname?.charAt(0) }}
</el-avatar>
<el-button class="change-avatar-btn" circle @click="showAvatarUpload = true">
<el-icon><Camera /></el-icon>
</el-button>
</div>
<div class="user-basic-info">
<h1 class="user-name">{{ userProfile.nickname || '加载中...' }}</h1>
<p class="user-title">{{ userProfile.department }} · {{ userProfile.major }}</p>
<p class="user-grade">{{ userProfile.grade }}</p>
<div class="user-stats" v-loading="statsLoading" element-loading-text="加载统计中...">
<div class="stat-item">
<span class="stat-number">{{ userStats.postsCount }}</span>
<span class="stat-label">发帖</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ userStats.likesReceived }}</span>
<span class="stat-label">获赞</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ userStats.resourcesCount }}</span>
<span class="stat-label">资源</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ userProfile.points }}</span>
<span class="stat-label">积分</span>
</div>
</div>
</div>
</div>
<div class="profile-actions">
<el-button type="primary" @click="showEditProfile = true">
<el-icon><Edit /></el-icon>
编辑资料
</el-button>
<el-button @click="showChangePassword = true">
<el-icon><Lock /></el-icon>
修改密码
</el-button>
</div>
</div>
<!-- 详细信息和活动 -->
<div class="profile-details">
<!-- 个人信息 -->
<div class="info-section card-light">
<h3 class="section-title">个人信息</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">用户名</span>
<span class="info-value">{{ userProfile.username || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">邮箱</span>
<span class="info-value">{{ userProfile.email || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">学号</span>
<span class="info-value">{{ userProfile.studentId || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">性别</span>
<span class="info-value">{{ getGenderText(userProfile.gender) }}</span>
</div>
<div class="info-item">
<span class="info-label">学院</span>
<span class="info-value">{{ userProfile.department || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">专业</span>
<span class="info-value">{{ userProfile.major || '未设置' }}</span>
</div>
</div>
<div class="bio-section">
<span class="info-label">个人简介</span>
<p class="bio-text">{{ userProfile.bio || '这个人很懒,什么都没有留下...' }}</p>
</div>
</div>
<!-- 最近活动 -->
<div class="activity-section card-light">
<h3 class="section-title">最近活动</h3>
<div class="activity-tabs">
<el-tabs v-model="activeTab">
<el-tab-pane label="我的帖子" name="posts">
<div class="activity-content" v-loading="postsLoading" element-loading-text="加载帖子中...">
<div v-if="recentPosts.length > 0" class="posts-list">
<div
v-for="post in recentPosts"
:key="post.id"
class="post-item"
@click="viewPost(post.id)"
>
<div class="post-info">
<h4 class="post-title">{{ post.title }}</h4>
<p class="post-summary">{{ getPostSummary(post.content) }}</p>
<div class="post-meta">
<span class="post-time">{{ post.createdAt }}</span>
<div class="post-stats">
<span>{{ post.viewCount }} 浏览</span>
<span>{{ post.likeCount }} 点赞</span>
<span>{{ post.commentCount }} 评论</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="!postsLoading" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>还没有发布任何帖子</p>
<el-button type="primary" @click="$router.push('/forum')">去发帖</el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="我的资源" name="resources">
<div class="activity-content" v-loading="resourcesLoading" element-loading-text="加载资源中...">
<div v-if="recentResources.length > 0" class="resources-list">
<div
v-for="resource in recentResources"
:key="resource.id"
class="resource-item"
>
<el-icon class="resource-icon"><Document /></el-icon>
<div class="resource-info">
<h4 class="resource-title">{{ resource.title }}</h4>
<p class="resource-desc">{{ resource.description }}</p>
<div class="resource-meta">
<span class="resource-time">{{ resource.createdAt }}</span>
<span class="resource-downloads">{{ resource.downloadCount }} 下载</span>
</div>
</div>
</div>
</div>
<div v-else-if="!resourcesLoading" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>还没有上传任何资源</p>
<el-button type="primary" @click="$router.push('/resources')">去上传</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
</div>
<!-- 编辑资料对话框 -->
<el-dialog
v-model="showEditProfile"
title="编辑个人资料"
width="600px"
>
<el-form :model="editForm" label-position="top">
<el-form-item label="昵称">
<el-input v-model="editForm.nickname" placeholder="请输入昵称" size="large" />
</el-form-item>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<el-form-item label="学院">
<el-input v-model="editForm.department" placeholder="请输入学院" size="large" />
</el-form-item>
<el-form-item label="专业">
<el-input v-model="editForm.major" placeholder="请输入专业" size="large" />
</el-form-item>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<el-form-item label="年级">
<el-input v-model="editForm.grade" placeholder="请输入年级" size="large" />
</el-form-item>
<el-form-item label="性别">
<el-select v-model="editForm.gender" placeholder="请选择性别" size="large">
<el-option label="保密" :value="0" />
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
</div>
<el-form-item label="个人简介">
<el-input
v-model="editForm.bio"
type="textarea"
:rows="4"
placeholder="介绍一下自己吧..."
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditProfile = false">取消</el-button>
<el-button type="primary" @click="handleUpdateProfile">保存</el-button>
</template>
</el-dialog>
<!-- 修改密码对话框 -->
<el-dialog
v-model="showChangePassword"
title="修改密码"
width="400px"
>
<el-form :model="passwordForm" label-position="top">
<el-form-item label="验证码">
<div style="display: flex; gap: 12px;">
<el-input
v-model="passwordForm.code"
placeholder="请输入验证码"
size="large"
style="flex: 1;"
/>
<el-button @click="sendVerificationCode" :disabled="codeCountdown > 0">
{{ codeCountdown > 0 ? `${codeCountdown}s` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认密码">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
size="large"
show-password
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showChangePassword = false">取消</el-button>
<el-button type="primary" @click="handleChangePassword">确认修改</el-button>
</template>
</el-dialog>
<!-- 头像上传对话框 -->
<el-dialog
v-model="showAvatarUpload"
title="更换头像"
width="400px"
>
<el-upload
class="avatar-uploader"
action="#"
:show-file-list="false"
:auto-upload="false"
accept="image/*"
@change="handleAvatarChange"
>
<div class="upload-area">
<el-icon class="upload-icon"><Plus /></el-icon>
<div class="upload-text">点击选择头像</div>
<div class="upload-tip">支持 JPG、PNG 格式,文件大小不超过 2MB</div>
</div>
</el-upload>
<template #footer>
<el-button @click="showAvatarUpload = false">取消</el-button>
<el-button type="primary" @click="handleUploadAvatar">上传</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Setting,
Edit,
Lock,
Camera,
Document,
Plus
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import {
getUserInfo,
getUserStats,
getUserRecentPosts,
updateUserProfile,
changePassword,
uploadAvatar,
sendEmailCode
} from '@/api/user'
import { getMyResources } from '@/api/resources'
import type { ApiResponse } from '@/types'
import TopNavbar from '@/components/TopNavbar.vue'
const router = useRouter()
const userStore = useUserStore()
//
const showEditProfile = ref(false)
const showChangePassword = ref(false)
const showAvatarUpload = ref(false)
const activeTab = ref('posts')
const codeCountdown = ref(0)
const loading = ref(false)
const statsLoading = ref(false)
const postsLoading = ref(false)
const resourcesLoading = ref(false)
const selectedAvatarFile = ref<File | null>(null)
// 用户资料数据 - 使用空对象待API加载
const userProfile = ref({
id: 0,
username: '',
email: '',
nickname: '',
avatar: '',
bio: '',
gender: 0,
studentId: '',
department: '',
major: '',
grade: '',
points: 0,
role: 0,
isVerified: 0
})
// 用户统计数据 - 使用空对象待API加载
const userStats = ref({
postsCount: 0,
commentsCount: 0,
resourcesCount: 0,
likesReceived: 0,
coursesCount: 0,
schedulesCount: 0
})
// 最近帖子 - 使用空数组待API加载
const recentPosts = ref<any[]>([])
// 最近资源 - 使用空数组待API加载
const recentResources = ref<any[]>([])
// 编辑表单
const editForm = reactive({
nickname: '',
department: '',
major: '',
grade: '',
gender: 0,
bio: ''
})
// 密码表单
const passwordForm = reactive({
code: '',
newPassword: '',
confirmPassword: ''
})
// 加载用户信息
const loadUserProfile = async () => {
try {
loading.value = true
const response = await getUserInfo() as any as ApiResponse<typeof userProfile.value>
if (response.code === 200) {
userProfile.value = response.data
console.log('用户信息加载成功:', userProfile.value)
// 更新编辑表单数据
Object.assign(editForm, {
nickname: userProfile.value.nickname,
department: userProfile.value.department,
major: userProfile.value.major,
grade: userProfile.value.grade,
gender: userProfile.value.gender,
bio: userProfile.value.bio
})
} else {
console.error('获取用户信息失败:', response.message)
ElMessage.error(response.message || '获取用户信息失败')
}
} catch (error) {
console.error('获取用户信息失败:', error)
ElMessage.error('获取用户信息失败')
} finally {
loading.value = false
}
}
// 加载用户统计数据
const loadUserStats = async () => {
try {
statsLoading.value = true
const response = await getUserStats() as any as ApiResponse<{
totalPosts: number
totalLikes: number
totalComments: number
totalViews: number
}>
if (response.code === 200) {
// 映射后端字段到前端期望的字段
userStats.value = {
postsCount: response.data.totalPosts || 0,
commentsCount: response.data.totalComments || 0,
resourcesCount: 0, // 后端暂未返回,使用默认值
likesReceived: response.data.totalLikes || 0,
coursesCount: 0, // 后端暂未返回,使用默认值
schedulesCount: 0 // 后端暂未返回,使用默认值
}
console.log('用户统计数据加载成功:', userStats.value)
} else {
console.error('获取用户统计失败:', response.message)
}
} catch (error) {
console.error('获取用户统计失败:', error)
} finally {
statsLoading.value = false
}
}
// 加载用户最近帖子
const loadRecentPosts = async () => {
try {
postsLoading.value = true
const response = await getUserRecentPosts(5) as any as ApiResponse<{
posts: any[]
totalCount: number
}>
if (response.code === 200) {
recentPosts.value = response.data.posts || []
console.log('最近帖子加载成功:', recentPosts.value)
} else {
console.error('获取最近帖子失败:', response.message)
}
} catch (error) {
console.error('获取最近帖子失败:', error)
} finally {
postsLoading.value = false
}
}
// 加载用户最近资源
const loadRecentResources = async () => {
try {
resourcesLoading.value = true
const response = await getMyResources({ page: 1, size: 5 }) as any as ApiResponse<{
total: number
list: any[]
pages: number
}>
if (response.code === 200) {
recentResources.value = response.data.list
console.log('最近资源加载成功:', recentResources.value)
} else {
console.error('获取最近资源失败:', response.message)
}
} catch (error) {
console.error('获取最近资源失败:', error)
} finally {
resourcesLoading.value = false
}
}
// 方法
const getGenderText = (gender: number) => {
const genderMap = { 0: '保密', 1: '男', 2: '女' }
return genderMap[gender as keyof typeof genderMap] || '保密'
}
const getPostSummary = (content: string) => {
if (!content) return '无内容'
// 移除markdown语法
let summary = content
.replace(/#{1,6}\s+/g, '') // 移除标题
.replace(/\*\*(.*?)\*\*/g, '$1') // 移除粗体
.replace(/\*(.*?)\*/g, '$1') // 移除斜体
.replace(/`(.*?)`/g, '$1') // 移除行内代码
.replace(/```[\s\S]*?```/g, '') // 移除代码块
.replace(/!\[.*?\]\(.*?\)/g, '') // 移除图片
.replace(/\[.*?\]\(.*?\)/g, '') // 移除链接
.replace(/\n+/g, ' ') // 将换行替换为空格
.trim()
// 限制长度
return summary.length > 120 ? summary.substring(0, 120) + '...' : summary
}
const viewPost = (postId: number) => {
router.push(`/forum/post/${postId}`)
}
const handleUpdateProfile = async () => {
try {
const response = await updateUserProfile({
username: editForm.nickname, // 注意API可能需要username字段
bio: editForm.bio,
gender: editForm.gender,
department: editForm.department,
major: editForm.major,
grade: editForm.grade
}) as any as ApiResponse<null>
if (response.code === 200) {
// 更新本地数据
Object.assign(userProfile.value, editForm)
showEditProfile.value = false
ElMessage.success('资料更新成功!')
// 重新加载用户信息
await loadUserProfile()
} else {
ElMessage.error(response.message || '更新失败')
}
} catch (error) {
console.error('更新用户资料失败:', error)
ElMessage.error('更新失败')
}
}
const sendVerificationCode = async () => {
try {
const response = await sendEmailCode(userProfile.value.email) as any as ApiResponse<null>
if (response.code === 200) {
// 开始倒计时
codeCountdown.value = 60
const timer = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
ElMessage.success('验证码已发送到您的邮箱')
} else {
ElMessage.error(response.message || '发送验证码失败')
}
} catch (error) {
console.error('发送验证码失败:', error)
ElMessage.error('发送验证码失败')
}
}
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
try {
const response = await changePassword({
code: passwordForm.code,
newPassword: passwordForm.newPassword
}) as any as ApiResponse<null>
if (response.code === 200) {
showChangePassword.value = false
// 重置表单
passwordForm.code = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
ElMessage.success('密码修改成功!')
} else {
ElMessage.error(response.message || '密码修改失败')
}
} catch (error) {
console.error('修改密码失败:', error)
ElMessage.error('修改密码失败')
}
}
const handleAvatarChange = (file: any) => {
console.log('选择的头像文件:', file)
// 验证文件类型
if (!file.raw.type.startsWith('image/')) {
ElMessage.error('请选择图片文件')
return
}
// 验证文件大小2MB限制
if (file.raw.size > 2 * 1024 * 1024) {
ElMessage.error('文件大小不能超过2MB')
return
}
selectedAvatarFile.value = file.raw
}
const handleUploadAvatar = async () => {
if (!selectedAvatarFile.value) {
ElMessage.error('请先选择头像文件')
return
}
try {
const response = await uploadAvatar(selectedAvatarFile.value) as any as ApiResponse<{ avatarUrl: string }>
if (response.code === 200) {
// 更新本地头像
userProfile.value.avatar = response.data.avatarUrl
showAvatarUpload.value = false
selectedAvatarFile.value = null
ElMessage.success('头像上传成功!')
// 更新用户store中的头像信息
if (userStore.user) {
userStore.user.avatar = response.data.avatarUrl
}
// 重新加载用户信息
await loadUserProfile()
} else {
ElMessage.error(response.message || '头像上传失败')
}
} catch (error) {
console.error('上传头像失败:', error)
ElMessage.error('头像上传失败')
}
}
const handleCommand = (command: string) => {
if (command === 'logout') {
userStore.logout()
router.push('/login')
} else if (command === 'profile') {
// 已经在个人资料页面
}
}
onMounted(async () => {
console.log('个人资料页面加载完成,开始加载数据...')
// 并行加载所有数据
await Promise.all([
loadUserProfile(),
loadUserStats(),
loadRecentPosts(),
loadRecentResources()
])
})
</script>
<style scoped>
.profile-container {
min-height: 100vh;
background: var(--gradient-bg);
}
/* 主要内容区域 */
.profile-main {
padding: 32px 24px;
}
.profile-content {
max-width: 1000px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
/* 个人信息头部 */
.profile-header {
padding: 32px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.avatar-section {
display: flex;
gap: 24px;
align-items: center;
}
.avatar-container {
position: relative;
}
.change-avatar-btn {
position: absolute;
bottom: 0;
right: 0;
background: var(--primary-500);
color: white;
border: 3px solid white;
}
.user-basic-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-name {
font-size: 2rem;
font-weight: 700;
color: var(--gray-800);
margin: 0;
}
.user-title {
color: var(--gray-600);
font-size: 16px;
margin: 0;
}
.user-grade {
color: var(--gray-500);
font-size: 14px;
margin: 0;
}
.user-stats {
display: flex;
gap: 24px;
margin-top: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-number {
font-size: 20px;
font-weight: 700;
color: var(--primary-600);
}
.stat-label {
font-size: 12px;
color: var(--gray-500);
}
.profile-actions {
display: flex;
gap: 12px;
}
/* 详细信息区域 */
.profile-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.section-title {
color: var(--gray-800);
font-size: 18px;
font-weight: 700;
margin: 0 0 20px 0;
}
.info-section {
padding: 24px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 12px;
color: var(--gray-500);
font-weight: 600;
}
.info-value {
color: var(--gray-700);
font-weight: 500;
}
.bio-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.bio-text {
color: var(--gray-700);
line-height: 1.6;
margin: 0;
padding: 12px;
background: var(--gray-50);
border-radius: 8px;
}
/* 活动区域 */
.activity-section {
padding: 24px;
}
.activity-tabs {
height: 100%;
}
.activity-content {
height: 400px;
overflow-y: auto;
padding-right: 8px;
}
.activity-content::-webkit-scrollbar {
width: 6px;
}
.activity-content::-webkit-scrollbar-track {
background: var(--gray-100);
border-radius: 3px;
}
.activity-content::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 3px;
}
.activity-content::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
.posts-list,
.resources-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.post-item {
padding: 16px;
border: 1px solid var(--gray-200);
border-radius: 12px;
cursor: pointer;
transition: var(--transition-base);
}
.post-item:hover {
border-color: var(--primary-300);
transform: translateY(-1px);
}
.post-title {
font-size: 16px;
font-weight: 600;
color: var(--gray-800);
margin: 0 0 8px 0;
}
.post-summary {
color: var(--gray-600);
font-size: 14px;
margin: 0 0 12px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.post-time {
font-size: 12px;
color: var(--gray-500);
}
.post-stats {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--gray-500);
}
.resource-item {
display: flex;
gap: 12px;
padding: 16px;
border: 1px solid var(--gray-200);
border-radius: 12px;
}
.resource-icon {
font-size: 24px;
color: var(--primary-500);
flex-shrink: 0;
}
.resource-info {
flex: 1;
}
.resource-title {
font-size: 14px;
font-weight: 600;
color: var(--gray-800);
margin: 0 0 4px 0;
}
.resource-desc {
font-size: 12px;
color: var(--gray-600);
margin: 0 0 8px 0;
}
.resource-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--gray-500);
}
/* 上传区域 */
.avatar-uploader {
width: 100%;
}
.upload-area {
border: 2px dashed var(--gray-300);
border-radius: 12px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: var(--transition-base);
}
.upload-area:hover {
border-color: var(--primary-400);
background: var(--primary-50);
}
.upload-icon {
font-size: 32px;
color: var(--gray-400);
margin-bottom: 12px;
}
.upload-text {
color: var(--gray-600);
font-weight: 600;
margin-bottom: 8px;
}
.upload-tip {
color: var(--gray-500);
font-size: 12px;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.profile-content {
max-width: 800px;
}
.profile-header {
flex-direction: column;
text-align: center;
}
.avatar-section {
flex-direction: column;
text-align: center;
gap: 20px;
}
.user-stats {
justify-content: center;
}
.profile-details {
flex-direction: column;
}
.info-section,
.activity-section {
width: 100%;
}
}
@media (max-width: 768px) {
.profile-main {
padding: 24px 16px;
}
.user-stats {
flex-wrap: wrap;
gap: 16px;
}
.stat-item {
min-width: calc(50% - 8px);
}
.info-grid {
grid-template-columns: 1fr;
}
.profile-actions {
flex-direction: column;
gap: 12px;
}
}
/* 空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--gray-500);
}
.empty-icon {
font-size: 48px;
color: var(--gray-400);
margin-bottom: 16px;
}
.empty-state p {
font-size: 16px;
margin-bottom: 20px;
color: var(--gray-600);
}
.empty-state .el-button {
border-radius: 8px;
font-weight: 600;
}
</style>