This commit is contained in:
zlwu committed 2026-06-04 12:57:59 +08:00
1 parent afd517ce10
commit eec61cb5dd
82 files changed
+16585 -9026

No files matched your search

@@ -1,6 +1,7 @@
<!-- 用户菜单 -->
<template>
<ElPopover
<div class="user-menu-root">
<ElPopover
ref="userMenuPopover"
placement="bottom-end"
:width="240"
@@ -13,16 +14,21 @@
>
<template #reference>
<div class="user-avatar-wrapper">
<el-avatar :size="34" :src="avatarDisplayUrl">
{{ userInfo.realName?.charAt(0) || userInfo.username?.charAt(0) || '管' }}
<el-avatar :size="34" :src="avatarDisplayUrl || undefined" :key="avatarDisplayUrl">
<span v-if="showAvatarInitial">{{ avatarInitialText }}</span>
</el-avatar>
</div>
</template>
<template #default>
<div class="pt-3">
<div class="flex-c pb-1 px-0">
<el-avatar :size="40" :src="avatarDisplayUrl" style="margin-right: 12px">
{{ userInfo.realName?.charAt(0) || userInfo.username?.charAt(0) || '管' }}
<el-avatar
:size="40"
:src="avatarDisplayUrl || undefined"
:key="avatarDisplayUrl"
style="margin-right: 12px"
>
<span v-if="showAvatarInitial">{{ avatarInitialText }}</span>
</el-avatar>
<div class="w-[calc(100%-60px)] h-full">
<span class="block text-sm font-medium text-g-800 truncate">
@@ -43,6 +49,15 @@
<span>{{ userInfo.regionName }}</span>
</li>
<div class="w-full h-px my-2 bg-g-300/80"></div>
<li class="btn-item" @click="openProfileDialog">
<ArtSvgIcon icon="edit" />
<span>个人信息</span>
</li>
<li class="btn-item" @click="openPasswordDialog">
<ArtSvgIcon icon="ri:lock-password-line" />
<span>修改密码</span>
</li>
<div class="w-full h-px my-2 bg-g-300/80"></div>
<div class="log-out c-p" @click="loginOut">
{{ $t('topBar.user.logout') }}
</div>
@@ -50,13 +65,23 @@
</div>
</template>
</ElPopover>
<UserProfileDialog v-model:visible="profileDialogVisible" />
<UserPasswordDialog v-model:visible="passwordDialogVisible" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { resolveUserAvatarUrl } from '@/utils/user-avatar'
import {
resolveUserAvatarFromInfo,
pickUserAvatarRaw,
hasCustomUserAvatar
} from '@/utils/user-avatar'
import UserProfileDialog from './UserProfileDialog.vue'
import UserPasswordDialog from './UserPasswordDialog.vue'
defineOptions({ name: 'ArtUserMenu' })
@@ -64,11 +89,31 @@
const { getUserInfo: userInfo } = storeToRefs(userStore)
const avatarDisplayUrl = computed(() =>
resolveUserAvatarUrl(userInfo.value.avatarUrl, userInfo.value.role)
const avatarDisplayUrl = computed(() => resolveUserAvatarFromInfo(userInfo.value))
const showAvatarInitial = computed(() => !hasCustomUserAvatar(pickUserAvatarRaw(userInfo.value)))
const avatarInitialText = computed(
() => userInfo.value.realName?.charAt(0) || userInfo.value.username?.charAt(0) || '管'
)
const userMenuPopover = ref()
const profileDialogVisible = ref(false)
const passwordDialogVisible = ref(false)
function openProfileDialog() {
closeUserMenu()
setTimeout(() => {
profileDialogVisible.value = true
}, 150)
}
function openPasswordDialog() {
closeUserMenu()
setTimeout(() => {
passwordDialogVisible.value = true
}, 150)
}
/**
* 获取角色名称
@@ -0,0 +1,149 @@
<!-- 当前用户修改密码 -->
<template>
<el-dialog
v-model="dialogVisible"
title="修改密码"
width="480px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="原密码" prop="oldPassword" class="form-col-span-2">
<el-input
v-model="formData.oldPassword"
type="password"
show-password
placeholder="请输入原密码"
autocomplete="current-password"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword" class="form-col-span-2">
<el-input
v-model="formData.newPassword"
type="password"
show-password
placeholder="至少 6 位"
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword" class="form-col-span-2">
<el-input
v-model="formData.confirmPassword"
type="password"
show-password
placeholder="请再次输入新密码"
autocomplete="new-password"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Close, Check } from '@element-plus/icons-vue'
import { fetchChangePassword } from '@/api/auth'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const formData = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const formRules: FormRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码长度不能少于 6 位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value !== formData.newPassword) {
callback(new Error('两次输入的新密码不一致'))
return
}
callback()
},
trigger: 'blur'
}
]
}
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
function resetForm() {
formData.oldPassword = ''
formData.newPassword = ''
formData.confirmPassword = ''
formRef.value?.clearValidate?.()
}
function handleClose() {
dialogVisible.value = false
}
async function handleSubmit() {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
await fetchChangePassword({
oldPassword: formData.oldPassword,
newPassword: formData.newPassword
})
dialogVisible.value = false
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitting.value = false
}
}
watch(
() => props.visible,
(val) => {
if (val) {
resetForm()
}
}
)
</script>
@@ -0,0 +1,326 @@
<!-- 当前用户个人信息编辑 -->
<template>
<el-dialog
v-model="dialogVisible"
title="个人信息"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="头像" class="form-col-span-2">
<div class="profile-avatar-row">
<el-avatar
:size="72"
:src="avatarPreviewUrl || undefined"
:key="avatarPreviewKey"
>
<span v-if="showAvatarInitial">{{ avatarInitial }}</span>
</el-avatar>
<div class="profile-avatar-actions">
<div class="profile-avatar-btns">
<el-upload
ref="avatarUploadRef"
accept="image/*"
:show-file-list="false"
:auto-upload="false"
:disabled="avatarUploading"
@change="handleAvatarChange"
>
<el-button class="btn-ghost" :loading="avatarUploading">
<el-icon><Upload /></el-icon>
上传头像
</el-button>
</el-upload>
<el-button
v-if="formData.avatarUrl"
class="btn-ghost"
:disabled="avatarUploading"
@click="handleClearAvatar"
>
<el-icon><Delete /></el-icon>
恢复默认
</el-button>
</div>
<p class="profile-avatar-tip">支持 JPG、PNG 等图片,大小不超过 2MB</p>
</div>
</div>
</el-form-item>
<el-form-item label="手机号" prop="phoneNo">
<el-input v-model="formData.phoneNo" placeholder="选填" maxlength="11" clearable />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="选填" maxlength="50" clearable />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-select v-model="formData.sex" placeholder="请选择" style="width: 100%">
<el-option label="男" value="0" />
<el-option label="女" value="1" />
<el-option label="未知" value="2" />
</el-select>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="选填"
maxlength="512"
show-word-limit
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { Close, Check, Upload, Delete } from '@element-plus/icons-vue'
import { fetchGetUserInfo, fetchUpdateProfile, fetchUploadProfileAvatar } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import {
pickUserAvatarRaw,
pickUploadAvatarUrl,
hasCustomUserAvatar,
resolveUserAvatarUrl
} from '@/utils/user-avatar'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const avatarUploadRef = ref<UploadInstance>()
const submitting = ref(false)
const avatarUploading = ref(false)
const avatarLocalPreview = ref('')
const displayRole = ref('')
const formData = reactive({
phoneNo: '',
email: '',
sex: '0',
remark: '',
avatarUrl: ''
})
const formRules: FormRules = {
phoneNo: [
{
validator: (_rule, value, callback) => {
const v = (value || '').trim()
if (v && !/^1\d{10}$/.test(v)) {
callback(new Error('请输入正确的手机号'))
return
}
callback()
},
trigger: 'blur'
}
],
email: [
{
validator: (_rule, value, callback) => {
const v = (value || '').trim()
if (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
callback(new Error('请输入正确的邮箱'))
return
}
callback()
},
trigger: 'blur'
}
]
}
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const avatarPreviewUrl = computed(() => {
if (avatarLocalPreview.value) return avatarLocalPreview.value
return resolveUserAvatarUrl(formData.avatarUrl, displayRole.value)
})
const avatarPreviewKey = computed(
() => avatarLocalPreview.value || formData.avatarUrl || 'default-avatar'
)
const showAvatarInitial = computed(
() => !avatarLocalPreview.value && !hasCustomUserAvatar(formData.avatarUrl)
)
const avatarInitial = computed(() => {
const info = userStore.getUserInfo
return info.realName?.charAt(0) || info.username?.charAt(0) || '管'
})
function revokeAvatarLocalPreview() {
if (avatarLocalPreview.value) {
URL.revokeObjectURL(avatarLocalPreview.value)
avatarLocalPreview.value = ''
}
}
function handleClose() {
dialogVisible.value = false
}
function handleClearAvatar() {
formData.avatarUrl = ''
revokeAvatarLocalPreview()
avatarUploadRef.value?.clearFiles()
}
async function loadProfile() {
const data = await fetchGetUserInfo()
formData.phoneNo = data.phone || ''
formData.email = data.email || ''
formData.sex = data.sex || '0'
formData.remark = data.remark || ''
formData.avatarUrl = pickUserAvatarRaw(data) || pickUserAvatarRaw(userStore.getUserInfo) || ''
displayRole.value = data.role || userStore.getUserInfo.role || ''
revokeAvatarLocalPreview()
avatarUploadRef.value?.clearFiles()
}
async function handleAvatarChange(uploadFile: UploadFile) {
const file = uploadFile.raw
if (!file) return
if (uploadFile.status && uploadFile.status !== 'ready') return
if (!file.type.startsWith('image/')) {
ElMessage.error('仅支持上传图片文件')
avatarUploadRef.value?.clearFiles()
return
}
if (file.size > 2 * 1024 * 1024) {
ElMessage.error('头像大小不能超过 2MB')
avatarUploadRef.value?.clearFiles()
return
}
avatarUploading.value = true
revokeAvatarLocalPreview()
avatarLocalPreview.value = URL.createObjectURL(file)
try {
const fd = new FormData()
fd.append('file', file)
const res = await fetchUploadProfileAvatar(fd)
const stored = pickUploadAvatarUrl(res)
if (!stored) {
ElMessage.error('上传成功但未返回图片地址')
handleClearAvatar()
return
}
formData.avatarUrl = stored
ElMessage.success('头像上传成功')
} catch {
handleClearAvatar()
} finally {
avatarUploading.value = false
avatarUploadRef.value?.clearFiles()
}
}
async function handleSubmit() {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
const updated = await fetchUpdateProfile({
phoneNo: formData.phoneNo.trim() || undefined,
email: formData.email.trim() || undefined,
sex: formData.sex,
remark: formData.remark.trim() || undefined,
avatarUrl: formData.avatarUrl || ''
})
userStore.setUserInfo({
phone: updated.phone,
email: updated.email,
sex: updated.sex,
remark: updated.remark,
avatarUrl: pickUserAvatarRaw(updated)
})
dialogVisible.value = false
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitting.value = false
}
}
watch(
() => props.visible,
(val) => {
if (val) {
loadProfile().catch(() => {})
formRef.value?.clearValidate?.()
} else {
revokeAvatarLocalPreview()
}
}
)
onBeforeUnmount(() => {
revokeAvatarLocalPreview()
})
</script>
<style scoped lang="scss">
.profile-avatar-row {
display: flex;
gap: 16px;
align-items: center;
}
.profile-avatar-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.profile-avatar-btns {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.profile-avatar-tip {
margin: 0;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.5;
}
</style>