Files
jczxw-pc/app/pages/profile/index.vue
赵忠林 56aea4ad86 feat(about): 重构“关于我们”页面并丰富内容展示
- 采用左右分栏布局,左侧新增图标导航
- 全新设计顶部 Banner,提升视觉效果
- 添加学会简介数据亮点和主要职能展示
- 新增组织机构图、主要领导及专家委员会成员展示
- 引入学会章程章节分明条目展示
- 丰富咨询服务内容,新增服务项目卡片和联系方式
- “加入我们”板块支持企业与个人会员申请详情说明
- 支持资料下载并优化排版与交互体验
- 增强响应式支持,保证移动端体验一致
- 页面样式大幅调整,提升整体美观与可读性
2026-04-26 01:44:07 +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"
title="请先登录"
sub-title="登录后可查看和编辑个人信息"
>
<template #extra>
<a-button type="primary" size="large" @click="navigateTo('/login')">去登录</a-button>
</template>
</a-result>
</div>
<div v-else>
<a-row :gutter="[32, 24]">
<!-- 左侧用户信息卡片 -->
<a-col :xs="24" :lg="7">
<div class="profile-card">
<div class="avatar-section">
<a-upload
name="avatar"
list-type="picture-circle"
class="avatar-uploader"
:show-upload-list="false"
:before-upload="beforeUpload"
@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="side-menu-item"
:class="{ active: activeTab === item.key }"
@click="activeTab = item.key"
>
<span class="menu-icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</div>
</div>
</div>
</a-col>
<!-- 右侧内容区 -->
<a-col :xs="24" :lg="17">
<!-- 基本信息 -->
<div v-show="activeTab === 'info'" class="content-panel">
<div class="panel-header">
<h3>基本信息</h3>
<a-button type="primary" v-if="!editing" @click="editing = true">编辑资料</a-button>
<a-space v-else>
<a-button @click="editing = false">取消</a-button>
<a-button type="primary" :loading="saving" @click="saveInfo">保存</a-button>
</a-space>
</div>
<a-form :model="editForm" layout="vertical" class="info-form">
<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" title="修改密码" @ok="handleChangePwd" :confirm-loading="saving">
<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 setup lang="ts">
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>