420 lines
11 KiB
Vue
420 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onActivated, onMounted, ref } from 'vue';
|
|
import { userGenderRecord } from '@/constants/business';
|
|
import { fetchGetMyProfileDetail, fetchUpdateMyAvatar } from '@/service/api';
|
|
import { useAuthStore } from '@/store/modules/auth';
|
|
import { useAppStore } from '@/store/modules/app';
|
|
import { $t } from '@/locales';
|
|
import ProfileInfoDialog from './modules/profile-info-dialog.vue';
|
|
import ProfilePasswordDialog from './modules/profile-password-dialog.vue';
|
|
import { formatProfileDateTime, resolveProfileRoleLabels } from './modules/profile-model';
|
|
|
|
defineOptions({ name: 'MyProfile' });
|
|
|
|
const authStore = useAuthStore();
|
|
const appStore = useAppStore();
|
|
|
|
const loading = ref(false);
|
|
const avatarSubmitting = ref(false);
|
|
const profile = ref<Api.Auth.MyProfileDetail | null>(null);
|
|
const profileInfoVisible = ref(false);
|
|
const passwordVisible = ref(false);
|
|
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
|
|
|
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
|
|
|
const descriptionColumns = computed(() => (appStore.isMobile ? 1 : 2));
|
|
const displayName = computed(() => profile.value?.nickname?.trim() || profile.value?.username || '--');
|
|
const displayUsername = computed(() => profile.value?.username?.trim() || '--');
|
|
const companyText = computed(() => profile.value?.company?.trim() || '--');
|
|
const deptText = computed(() => profile.value?.dept?.name?.trim() || profile.value?.deptName?.trim() || '--');
|
|
const positionText = computed(
|
|
() => profile.value?.position?.name?.trim() || profile.value?.positionName?.trim() || '--'
|
|
);
|
|
const mobileText = computed(() => profile.value?.mobile?.trim() || '--');
|
|
const emailText = computed(() => profile.value?.email?.trim() || '--');
|
|
const genderText = computed(() => {
|
|
const value = profile.value?.sex;
|
|
|
|
if (value === null || value === undefined) {
|
|
return '--';
|
|
}
|
|
|
|
return $t(userGenderRecord[value]);
|
|
});
|
|
|
|
const roleLabels = computed(() => {
|
|
const roles = profile.value?.roles ?? [];
|
|
|
|
if (roles.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return resolveProfileRoleLabels(roles);
|
|
});
|
|
|
|
function getAvatarText() {
|
|
const name = displayName.value;
|
|
|
|
return name === '--' ? 'CN' : name.slice(0, 1).toUpperCase();
|
|
}
|
|
|
|
async function loadProfile() {
|
|
const userId = authStore.userInfo.userId;
|
|
|
|
if (!userId) {
|
|
profile.value = null;
|
|
return;
|
|
}
|
|
|
|
const { data, error } = await fetchGetMyProfileDetail({ userId });
|
|
|
|
if (!error) {
|
|
profile.value = data;
|
|
}
|
|
}
|
|
|
|
async function initPage() {
|
|
loading.value = true;
|
|
|
|
await authStore.initUserInfo();
|
|
await loadProfile();
|
|
|
|
loading.value = false;
|
|
}
|
|
|
|
function triggerAvatarSelect() {
|
|
if (!profile.value || avatarSubmitting.value) {
|
|
return;
|
|
}
|
|
|
|
avatarInputRef.value?.click();
|
|
}
|
|
|
|
async function handleAvatarChange(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
input.value = '';
|
|
|
|
if (!file || !profile.value) {
|
|
return;
|
|
}
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
window.$message?.error('请上传图片文件');
|
|
return;
|
|
}
|
|
|
|
if (file.size > MAX_AVATAR_SIZE) {
|
|
window.$message?.error('头像图片大小不能超过 5MB');
|
|
return;
|
|
}
|
|
|
|
avatarSubmitting.value = true;
|
|
|
|
const updateResult = await fetchUpdateMyAvatar(file);
|
|
|
|
avatarSubmitting.value = false;
|
|
|
|
if (updateResult.error) {
|
|
return;
|
|
}
|
|
|
|
window.$message?.success('头像更新成功');
|
|
await Promise.all([loadProfile(), authStore.refreshUserInfo()]);
|
|
}
|
|
|
|
async function handleProfileSubmitted() {
|
|
await Promise.all([loadProfile(), authStore.refreshUserInfo()]);
|
|
}
|
|
|
|
onMounted(() => {
|
|
initPage();
|
|
});
|
|
|
|
onActivated(() => {
|
|
initPage();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div v-loading="loading" class="my-profile-page">
|
|
<template v-if="profile">
|
|
<ElCard class="my-profile-hero-card" shadow="never">
|
|
<div class="my-profile-hero">
|
|
<div class="my-profile-hero__identity">
|
|
<button
|
|
class="my-profile-hero__avatar-button"
|
|
type="button"
|
|
:disabled="avatarSubmitting"
|
|
@click="triggerAvatarSelect"
|
|
>
|
|
<ElAvatar v-if="profile.avatar" :src="profile.avatar" :size="88" class="my-profile-hero__avatar" />
|
|
<div v-else class="my-profile-hero__avatar-fallback">{{ getAvatarText() }}</div>
|
|
<div class="my-profile-hero__avatar-mask">
|
|
<span>{{ avatarSubmitting ? '上传中...' : '更换头像' }}</span>
|
|
</div>
|
|
</button>
|
|
|
|
<input
|
|
ref="avatarInputRef"
|
|
class="my-profile-hero__avatar-input"
|
|
type="file"
|
|
accept="image/*"
|
|
@change="handleAvatarChange"
|
|
/>
|
|
|
|
<div class="my-profile-hero__summary">
|
|
<div class="my-profile-hero__title-row">
|
|
<h1 class="my-profile-hero__title">{{ displayName }}</h1>
|
|
<ElTag type="info" effect="plain">个人中心</ElTag>
|
|
</div>
|
|
<p class="my-profile-hero__subtitle">@{{ displayUsername }}</p>
|
|
<div class="my-profile-hero__meta">
|
|
<ElTag effect="plain">{{ companyText }}</ElTag>
|
|
<ElTag effect="plain">{{ deptText }}</ElTag>
|
|
<ElTag effect="plain">{{ positionText }}</ElTag>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="my-profile-hero__actions">
|
|
<ElButton type="primary" @click="profileInfoVisible = true">编辑基本信息</ElButton>
|
|
<ElButton @click="passwordVisible = true">修改密码</ElButton>
|
|
</div>
|
|
</div>
|
|
</ElCard>
|
|
|
|
<div class="my-profile-content">
|
|
<ElCard shadow="never">
|
|
<template #header>
|
|
<div class="my-profile-card__header">
|
|
<span class="my-profile-card__title">基本资料</span>
|
|
</div>
|
|
</template>
|
|
|
|
<ElDescriptions :column="descriptionColumns" border>
|
|
<ElDescriptionsItem label="用户名">{{ displayUsername }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="名称">{{ displayName }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="手机号">{{ mobileText }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="邮箱">{{ emailText }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="性别">{{ genderText }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="所属公司">{{ companyText }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="所属部门">{{ deptText }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="所属岗位">{{ positionText }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="角色" :span="descriptionColumns">
|
|
<div v-if="roleLabels.length" class="my-profile-role-list">
|
|
<ElTag v-for="roleLabel in roleLabels" :key="roleLabel" type="primary" effect="plain">
|
|
{{ roleLabel }}
|
|
</ElTag>
|
|
</div>
|
|
<span v-else>--</span>
|
|
</ElDescriptionsItem>
|
|
</ElDescriptions>
|
|
</ElCard>
|
|
|
|
<ElCard shadow="never">
|
|
<template #header>
|
|
<div class="my-profile-card__header">
|
|
<span class="my-profile-card__title">登录信息</span>
|
|
</div>
|
|
</template>
|
|
|
|
<ElDescriptions :column="descriptionColumns" border>
|
|
<ElDescriptionsItem label="最近登录 IP">{{ profile.loginIp?.trim() || '--' }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="最近登录时间">{{ formatProfileDateTime(profile.loginDate) }}</ElDescriptionsItem>
|
|
<ElDescriptionsItem label="账号创建时间" :span="descriptionColumns">
|
|
{{ formatProfileDateTime(profile.createTime) }}
|
|
</ElDescriptionsItem>
|
|
</ElDescriptions>
|
|
</ElCard>
|
|
</div>
|
|
</template>
|
|
|
|
<ElEmpty v-else description="未获取到个人信息" />
|
|
|
|
<ProfileInfoDialog v-model:visible="profileInfoVisible" :profile="profile" @submitted="handleProfileSubmitted" />
|
|
<ProfilePasswordDialog v-model:visible="passwordVisible" :username="profile?.username" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.my-profile-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.my-profile-hero-card {
|
|
overflow: hidden;
|
|
border: 1px solid rgb(226 232 240 / 92%);
|
|
background:
|
|
radial-gradient(circle at top left, rgb(14 116 144 / 12%), transparent 32%),
|
|
radial-gradient(circle at bottom right, rgb(16 185 129 / 10%), transparent 26%),
|
|
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
|
|
}
|
|
|
|
.my-profile-hero {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 16px;
|
|
align-items: center;
|
|
}
|
|
|
|
.my-profile-hero__identity {
|
|
display: flex;
|
|
min-width: 0;
|
|
align-items: center;
|
|
gap: 18px;
|
|
}
|
|
|
|
.my-profile-hero__avatar-button {
|
|
position: relative;
|
|
display: inline-flex;
|
|
width: 88px;
|
|
height: 88px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: none;
|
|
border-radius: 999px;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.my-profile-hero__avatar-button:disabled {
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.my-profile-hero__avatar,
|
|
.my-profile-hero__avatar-fallback {
|
|
width: 88px;
|
|
height: 88px;
|
|
}
|
|
|
|
.my-profile-hero__avatar-fallback {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 999px;
|
|
background: linear-gradient(135deg, rgb(14 116 144 / 92%), rgb(15 118 110 / 84%));
|
|
color: #fff;
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.my-profile-hero__avatar-mask {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: rgb(15 23 42 / 52%);
|
|
color: #fff;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
opacity: 0;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.my-profile-hero__avatar-button:hover .my-profile-hero__avatar-mask,
|
|
.my-profile-hero__avatar-button:focus-visible .my-profile-hero__avatar-mask {
|
|
opacity: 1;
|
|
}
|
|
|
|
.my-profile-hero__avatar-input {
|
|
display: none;
|
|
}
|
|
|
|
.my-profile-hero__summary {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.my-profile-hero__title-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.my-profile-hero__title {
|
|
margin: 0;
|
|
color: rgb(15 23 42 / 98%);
|
|
font-size: 28px;
|
|
line-height: 1.15;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.my-profile-hero__subtitle {
|
|
margin: 0;
|
|
color: rgb(100 116 139 / 92%);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.my-profile-hero__meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.my-profile-hero__actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
}
|
|
|
|
.my-profile-content {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
.my-profile-card__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.my-profile-card__title {
|
|
color: rgb(15 23 42 / 98%);
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.my-profile-role-list {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
@media (width <= 960px) {
|
|
.my-profile-hero {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.my-profile-hero__actions {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.my-profile-content {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (width <= 640px) {
|
|
.my-profile-hero__identity {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.my-profile-hero__title {
|
|
font-size: 24px;
|
|
}
|
|
}
|
|
</style>
|