Files
template-nuxt4/app/pages/profile/index.vue
2026-04-29 01:33:33 +08:00

613 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="profile-page">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<!-- 需要登录 -->
<div v-if="!isAuthed" class="not-authed">
<a-result
status="403"
sub-title="登录后可查看和编辑个人信息"
title="请先登录"
>
<template #extra>
<a-button size="large" type="primary" @click="navigateTo('/login')">去登录</a-button>
</template>
</a-result>
</div>
<div v-else>
<a-row :gutter="[32, 24]">
<!-- 左侧用户信息卡片 -->
<a-col :lg="7" :xs="24">
<div class="profile-card">
<div class="avatar-section">
<a-upload
:before-upload="beforeUpload"
:show-upload-list="false"
class="avatar-uploader"
list-type="picture-circle"
name="avatar"
@change="handleAvatarChange"
>
<div class="avatar-wrap">
<img v-if="userInfo.avatar" :src="userInfo.avatar" alt="avatar" class="avatar-img" />
<div v-else class="avatar-placeholder">{{ userInfo.nickname?.charAt(0) || '用' }}</div>
<div class="avatar-overlay">
<span>更换头像</span>
</div>
</div>
</a-upload>
</div>
<h2 class="user-name">{{ userInfo.nickname || userInfo.username || '用户' }}</h2>
<div class="user-role">
<a-tag :color="userInfo.isAdmin ? 'red' : 'blue'">
{{ userInfo.isAdmin ? '管理员' : '普通用户' }}
</a-tag>
</div>
<div class="user-stats">
<div class="stat-item">
<div class="stat-num">{{ stats.suggestions }}</div>
<div class="stat-label">建言</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ stats.favorites }}</div>
<div class="stat-label">收藏</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ stats.views }}</div>
<div class="stat-label">浏览</div>
</div>
</div>
<div class="side-menu">
<div
v-for="item in sideMenuItems"
:key="item.key"
:class="{ active: activeTab === item.key }"
class="side-menu-item"
@click="activeTab = item.key"
>
<span class="menu-icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</div>
</div>
</div>
</a-col>
<!-- 右侧内容区 -->
<a-col :lg="17" :xs="24">
<!-- 基本信息 -->
<div v-show="activeTab === 'info'" class="content-panel">
<div class="panel-header">
<h3>基本信息</h3>
<a-button v-if="!editing" type="primary" @click="editing = true">编辑资料</a-button>
<a-space v-else>
<a-button @click="editing = false">取消</a-button>
<a-button :loading="saving" type="primary" @click="saveInfo">保存</a-button>
</a-space>
</div>
<a-form :model="editForm" class="info-form" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="昵称">
<a-input v-model:value="editForm.nickname" :disabled="!editing" placeholder="请输入昵称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="手机号">
<a-input v-model:value="editForm.phone" :disabled="!editing" placeholder="请输入手机号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="电子邮箱">
<a-input v-model:value="editForm.email" :disabled="!editing" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="工作单位">
<a-input v-model:value="editForm.organization" :disabled="!editing" placeholder="请输入工作单位" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="个人简介">
<a-textarea v-model:value="editForm.bio" :disabled="!editing" :rows="3" placeholder="请输入个人简介" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<!-- 账号安全 -->
<div class="security-section">
<h4>账号安全</h4>
<div class="security-items">
<div class="security-item">
<div class="security-info">
<span class="security-icon">🔒</span>
<div>
<div class="security-name">登录密码</div>
<div class="security-desc">建议定期修改密码保护账户安全</div>
</div>
</div>
<a-button size="small" @click="showChangePwd = true">修改</a-button>
</div>
<div class="security-item">
<div class="security-info">
<span class="security-icon">📱</span>
<div>
<div class="security-name">绑定手机</div>
<div class="security-desc">{{ editForm.phone ? `已绑定 ${editForm.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}` : '未绑定手机号' }}</div>
</div>
</div>
<a-button size="small">{{ editForm.phone ? '修改' : '绑定' }}</a-button>
</div>
</div>
</div>
</div>
<!-- 我的建言 -->
<div v-show="activeTab === 'suggestions'" class="content-panel">
<div class="panel-header">
<h3>我的建言</h3>
<a-button type="primary" @click="navigateTo('/suggestions')">提交新建言</a-button>
</div>
<div v-if="mySuggestions.length === 0" class="empty-state">
<a-empty description="暂无建言记录">
<template #extra>
<a-button type="primary" @click="navigateTo('/suggestions')">立即建言</a-button>
</template>
</a-empty>
</div>
<div class="suggestion-list">
<div v-for="item in mySuggestions" :key="item.id" class="suggestion-item">
<div class="suggestion-header">
<span class="suggestion-title">{{ item.title }}</span>
<a-tag :color="getStatusColor(item.status)">{{ getStatusText(item.status) }}</a-tag>
</div>
<p class="suggestion-content">{{ item.content }}</p>
<div class="suggestion-meta">
<span>{{ item.createTime }}</span>
<span v-if="item.reply" class="has-reply">已回复</span>
</div>
<div v-if="item.reply" class="suggestion-reply">
<span class="reply-label">官方回复</span>
<span>{{ item.reply }}</span>
</div>
</div>
</div>
</div>
<!-- 收藏记录 -->
<div v-show="activeTab === 'favorites'" class="content-panel">
<div class="panel-header">
<h3>我的收藏</h3>
</div>
<a-empty description="暂无收藏内容" style="padding: 60px 0" />
</div>
<!-- 浏览历史 -->
<div v-show="activeTab === 'history'" class="content-panel">
<div class="panel-header">
<h3>浏览历史</h3>
<a-button @click="clearHistory">清空历史</a-button>
</div>
<a-empty description="暂无浏览记录" style="padding: 60px 0" />
</div>
</a-col>
</a-row>
</div>
</div>
<!-- 修改密码弹窗 -->
<a-modal v-model:open="showChangePwd" :confirm-loading="saving" title="修改密码" @ok="handleChangePwd">
<a-form :model="pwdForm" layout="vertical">
<a-form-item label="当前密码">
<a-input-password v-model:value="pwdForm.oldPwd" placeholder="请输入当前密码" />
</a-form-item>
<a-form-item label="新密码">
<a-input-password v-model:value="pwdForm.newPwd" placeholder="请输入新密码至少6位" />
</a-form-item>
<a-form-item label="确认新密码">
<a-input-password v-model:value="pwdForm.confirmPwd" placeholder="请再次输入新密码" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { getToken } from '@/utils/token-util'
useHead({ title: '个人中心 - 决策咨询网' })
const isAuthed = computed(() => !!getToken())
const activeTab = ref('info')
const editing = ref(false)
const saving = ref(false)
const showChangePwd = ref(false)
const sideMenuItems = [
{ key: 'info', label: '基本信息', icon: '👤' },
{ key: 'suggestions', label: '我的建言', icon: '💬' },
{ key: 'favorites', label: '我的收藏', icon: '⭐' },
{ key: 'history', label: '浏览历史', icon: '📖' },
]
const userInfo = reactive<any>({
nickname: '用户',
username: '',
avatar: '',
isAdmin: false,
phone: '',
email: '',
organization: '',
bio: '',
})
const editForm = reactive({
nickname: '',
phone: '',
email: '',
organization: '',
bio: '',
})
const pwdForm = reactive({
oldPwd: '',
newPwd: '',
confirmPwd: '',
})
const stats = reactive({
suggestions: 0,
favorites: 0,
views: 0,
})
const mySuggestions = ref<any[]>([])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', processing: 'blue', done: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待处理', processing: '处理中', done: '已处理', rejected: '已关闭' }
return map[status] || status
}
function beforeUpload() {
return false
}
function handleAvatarChange() {
// TODO: 上传头像
}
async function saveInfo() {
saving.value = true
try {
// TODO: 调用API保存
message.success('保存成功')
editing.value = false
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleChangePwd() {
if (pwdForm.newPwd !== pwdForm.confirmPwd) {
message.error('两次密码不一致')
return
}
saving.value = true
try {
// TODO: 调用API修改密码
message.success('密码修改成功,请重新登录')
showChangePwd.value = false
} catch (e: any) {
message.error(e?.message || '修改失败')
} finally {
saving.value = false
}
}
function clearHistory() {
message.info('已清空浏览历史')
}
onMounted(async () => {
if (!isAuthed.value) return
// TODO: 加载用户信息和统计数据
})
</script>
<style scoped>
.profile-page {
background: #f5f7fa;
min-height: 60vh;
}
.not-authed {
background: #fff;
border-radius: 16px;
padding: 60px;
margin: 20px 0;
text-align: center;
}
.profile-card {
background: #fff;
border-radius: 16px;
padding: 28px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
position: sticky;
top: 80px;
}
.avatar-section {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.avatar-wrap {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1e3a5f, #3498db);
color: #fff;
font-size: 32px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.4);
color: #fff;
font-size: 12px;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.avatar-wrap:hover .avatar-overlay {
opacity: 1;
}
.user-name {
text-align: center;
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px;
}
.user-role {
text-align: center;
margin-bottom: 16px;
}
.user-stats {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 16px;
}
.stat-item {
text-align: center;
}
.stat-num {
font-size: 22px;
font-weight: 700;
color: #1e3a5f;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.side-menu {
display: flex;
flex-direction: column;
gap: 2px;
}
.side-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #374151;
transition: all 0.2s;
}
.side-menu-item:hover {
background: #f3f4f6;
}
.side-menu-item.active {
background: #eff6ff;
color: #1e3a5f;
font-weight: 600;
}
.menu-icon {
font-size: 16px;
}
.content-panel {
background: #fff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.panel-header h3 {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.info-form {
max-width: 100%;
}
.security-section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
.security-section h4 {
font-size: 15px;
font-weight: 600;
color: #374151;
margin: 0 0 16px;
}
.security-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.security-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: #f9fafb;
border-radius: 10px;
}
.security-info {
display: flex;
align-items: center;
gap: 12px;
}
.security-icon {
font-size: 20px;
}
.security-name {
font-size: 14px;
font-weight: 500;
color: #374151;
}
.security-desc {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.suggestion-item {
padding: 16px;
background: #f9fafb;
border-radius: 10px;
border: 1px solid #f0f0f0;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.suggestion-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.suggestion-content {
font-size: 14px;
color: #6b7280;
margin: 0 0 8px;
line-height: 1.5;
}
.suggestion-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #9ca3af;
}
.has-reply {
color: #059669;
font-weight: 500;
}
.suggestion-reply {
margin-top: 10px;
padding: 10px 12px;
background: #eff6ff;
border-radius: 6px;
font-size: 13px;
color: #374151;
}
.reply-label {
font-weight: 600;
color: #1e3a5f;
}
.empty-state {
padding: 40px 0;
}
</style>