303 lines
11 KiB
Vue
303 lines
11 KiB
Vue
<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>
|