Files
2026-07-15 16:13:27 +08:00

303 lines
11 KiB
Vue
Raw Permalink 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="personal-page">
<div class="profile-grid">
<!-- 头像 + 基本信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><User /></el-icon>
<span>基本信息</span>
</div>
<div class="card-body">
<!-- 头像上传 -->
<div class="avatar-section">
<div class="avatar-wrapper" @click="triggerUpload">
<el-avatar :size="80" :src="form.avatar" class="profile-avatar">
{{ (form.realName || form.username || 'U').charAt(0) }}
</el-avatar>
<div class="avatar-overlay">
<el-icon :size="20"><Camera /></el-icon>
</div>
</div>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="handleAvatarUpload" />
<div class="avatar-info">
<div class="avatar-name">{{ form.realName || form.username }}</div>
<div class="avatar-role">{{ form.post || '普通用户' }}</div>
<el-button size="small" text type="primary" @click="triggerUpload">
<el-icon><Upload /></el-icon>更换头像
</el-button>
</div>
</div>
<el-form :model="form" label-width="80px" label-position="left" class="info-form">
<el-form-item label="用户名">
<el-input :model-value="form.username" disabled />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="岗位">
<el-input v-model="form.post" placeholder="请输入岗位" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveProfile">
<el-icon><Check /></el-icon>保存信息
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 修改密码 + 账户信息 -->
<div class="right-cards">
<!-- 修改密码 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><Lock /></el-icon>
<span>修改密码</span>
</div>
<div class="card-body">
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="80px" label-position="left">
<el-form-item label="原密码" prop="oldPwd">
<el-input v-model="pwdForm.oldPwd" type="password" placeholder="请输入原密码" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPwd">
<el-input v-model="pwdForm.newPwd" type="password" placeholder="至少6位" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPwd">
<el-input v-model="pwdForm.confirmPwd" type="password" placeholder="再次输入" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="changingPwd" @click="changePassword">
<el-icon><Key /></el-icon>修改密码
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 账户信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><InfoFilled /></el-icon>
<span>账户信息</span>
</div>
<div class="card-body">
<div class="meta-list">
<div class="meta-row">
<span class="meta-label">用户ID</span>
<code class="meta-value mono">{{ form.userId?.substring(0, 12) || '-' }}...</code>
</div>
<div class="meta-row">
<span class="meta-label">用户名</span>
<span class="meta-value">{{ form.username || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">注册时间</span>
<span class="meta-value">{{ fmtDate(form.createdAt) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">最后登录</span>
<span class="meta-value">{{ fmtDate(form.lastLoginTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Lock, Key, Check, InfoFilled, Camera, Upload } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const fileInputRef = ref<HTMLInputElement>()
const form = reactive({
userId: '' as string | null,
username: '',
realName: '',
email: '',
phone: '',
avatar: '',
post: '',
createdAt: '',
lastLoginTime: '',
})
const saving = ref(false)
function fmtDate(s: string | null | undefined): string {
if (!s) return '-'
try {
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return s }
}
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/auth/user/info')
const d = res.data
Object.assign(form, {
userId: d.userId ?? '',
username: d.username ?? '',
realName: d.realName ?? '',
email: d.email ?? '',
phone: d.phone ?? '',
avatar: d.avatar ?? '',
post: d.post ?? '',
createdAt: d.createdAt ?? '',
lastLoginTime: d.lastLoginTime ?? '',
})
setTimeout(() => pwdFormRef.value?.clearValidate(), 0)
} catch { /* ignore */ }
})
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleAvatarUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const fd = new FormData()
fd.append('file', file)
fd.append('fileType', 'avatar')
fd.append('userId', form.userId || '')
try {
const res = await request.post('/zgapi/v1/admin/file/upload', fd)
// 文件存入数据库后用文件ID构建访问路径
const fileId = res.data?.id
if (!fileId) { ElMessage.error('上传返回异常'); return }
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
const avatarUrl = `${gatewayUrl}/zgapi/v1/admin/file/${fileId}/view`
form.avatar = avatarUrl
// 更新用户头像字段
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
avatar: avatarUrl,
})
// 同步刷新 store 中的用户信息(也会更新 Redis TokenInfo
await authStore.fetchUserInfo()
ElMessage.success('头像已更新')
} catch { ElMessage.error('上传失败') }
}
async function saveProfile() {
saving.value = true
try {
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
username: form.username,
realName: form.realName,
email: form.email,
phone: form.phone,
post: form.post,
})
await authStore.fetchUserInfo()
ElMessage.success('个人信息已更新')
} catch { /* ignore */ } finally {
saving.value = false
}
}
// 密码
const pwdFormRef = ref<FormInstance>()
const changingPwd = ref(false)
const pwdForm = reactive({ oldPwd: '', newPwd: '', confirmPwd: '' })
const pwdRules: FormRules = {
oldPwd: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPwd: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPwd: [
{ required: true, message: '请再次输入', trigger: 'blur' },
{ validator: (_r: any, v: string, cb: any) => v !== pwdForm.newPwd ? cb(new Error('两次密码不一致')) : cb(), trigger: 'blur' },
],
}
async function changePassword() {
const valid = await pwdFormRef.value?.validate().catch(() => false)
if (!valid) return
changingPwd.value = true
try {
await request.put('/zgapi/v1/auth/password/update', null, {
params: { oldPwd: pwdForm.oldPwd, newPwd: pwdForm.newPwd },
})
ElMessage.success('密码修改成功,即将重新登录')
pwdForm.oldPwd = ''; pwdForm.newPwd = ''; pwdForm.confirmPwd = ''
setTimeout(() => {
authStore.logout()
window.location.href = '/login'
}, 1500)
} catch { /* ignore */ } finally {
changingPwd.value = false
}
}
</script>
<style scoped>
.personal-page { max-width: 960px; }
.profile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.right-cards { display: flex; flex-direction: column; gap: 20px; }
.profile-card {
background: var(--bg-surface); border-radius: var(--radius-lg);
border: 1px solid var(--border-color); box-shadow: var(--shadow-xs); overflow: hidden;
}
.card-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
font-weight: 600; font-size: 14px; color: var(--text-primary);
}
.card-body { padding: 24px 20px; }
/* 头像 */
.avatar-section { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border-light); }
.avatar-wrapper { position: relative; cursor: pointer; flex-shrink: 0; }
.profile-avatar { box-shadow: 0 4px 12px rgba(99,102,241,0.2); }
.avatar-overlay {
position: absolute; inset: 0; border-radius: 50%;
background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity var(--transition-fast); color: #fff;
}
.avatar-wrapper:hover .avatar-overlay { opacity: 1; }
.avatar-name { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.avatar-role { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
/* 表单 */
.info-form :deep(.el-form-item) { margin-bottom: 18px; }
.info-form :deep(.el-form-item:last-child) { margin-bottom: 0; padding-top: 4px; }
/* 元信息 */
.meta-list { display: flex; flex-direction: column; }
.meta-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--border-light); }
.meta-row:last-child { border-bottom: none; }
.meta-label { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.meta-value { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
.meta-value.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; }
@media (max-width: 768px) {
.profile-grid { grid-template-columns: 1fr; gap: 12px; }
.personal-page { padding: 0; }
.avatar-section { flex-direction: column; text-align: center; }
.avatar-info { align-items: center; }
.card-body { padding: 16px; }
}
</style>