Files
vue3-element-admin/src/views/profile/index.vue

1375 lines
35 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">
<section class="profile-hero">
<div class="profile-hero__body">
<div class="profile-avatar">
<el-avatar :src="displayAvatar" :size="72">
<el-icon><UserFilled /></el-icon>
</el-avatar>
<el-button
type="info"
class="profile-avatar__action"
circle
:icon="Camera"
size="small"
title="更换头像"
@click="triggerFileUpload"
/>
<input
ref="fileInput"
class="profile-avatar__input"
type="file"
accept="image/*"
@change="handleFileChange"
/>
</div>
<div class="profile-hero__info">
<div class="profile-hero__title">
<h2 class="profile-hero__name">{{ displayName }}</h2>
<el-tag type="primary" effect="light" round>{{ primaryRole }}</el-tag>
</div>
<p class="profile-hero__desc">
{{ userProfile.username || "-" }} / {{ userProfile.deptName || "未分配部门" }}
</p>
<div class="profile-hero__meta">
<span class="profile-hero__meta-item">
<el-icon><Calendar /></el-icon>
加入 {{ formatValue(userProfile.createTime) }}
</span>
<span class="profile-hero__meta-item">
<el-icon><Location /></el-icon>
最近登录 {{ recentLoginRecords[0]?.time }}
</span>
</div>
</div>
</div>
<div class="profile-hero__actions">
<el-button :icon="Edit" @click="handleOpenDialog(DialogType.ACCOUNT)">编辑资料</el-button>
<el-button type="primary" :icon="Lock" @click="handleOpenDialog(DialogType.PASSWORD)">
修改密码
</el-button>
</div>
</section>
<div class="profile-page__layout">
<aside class="profile-page__side">
<section class="profile-card">
<header class="profile-card__header">
<h3 class="profile-card__title">个人资料</h3>
<el-tag size="small" effect="plain">{{ genderText }}</el-tag>
</header>
<dl class="profile-info">
<div v-for="item in profileInfoItems" :key="item.label" class="profile-info__item">
<dt class="profile-info__label">
<el-icon><component :is="item.icon" /></el-icon>
{{ item.label }}
</dt>
<dd class="profile-info__value" :class="{ 'is-muted': item.muted }">
{{ item.value }}
</dd>
</div>
</dl>
</section>
<section class="profile-card">
<header class="profile-card__header">
<h3 class="profile-card__title">账号概览</h3>
</header>
<div class="profile-stats">
<div v-for="item in profileStats" :key="item.label" class="profile-stats__item">
<span :class="['profile-icon', 'profile-icon--' + item.tone]">
<el-icon><component :is="item.icon" /></el-icon>
</span>
<div class="profile-stats__body">
<span class="profile-stats__label">{{ item.label }}</span>
<strong class="profile-stats__value">
{{ item.value }}
<em>{{ item.suffix }}</em>
</strong>
</div>
</div>
</div>
</section>
<section class="profile-card">
<header class="profile-card__header">
<h3 class="profile-card__title">角色权限</h3>
<span class="profile-card__extra">{{ permissionCount }} 个权限</span>
</header>
<div class="profile-tags">
<el-tag v-for="role in roleList" :key="role" class="m-0" size="small" effect="light">
{{ role }}
</el-tag>
<span v-if="!roleList.length" class="profile-empty">暂无角色</span>
</div>
</section>
</aside>
<main class="profile-page__main">
<section class="profile-card">
<header class="profile-card__header">
<div>
<h3 class="profile-card__title">安全设置</h3>
<p class="profile-card__desc">维护账号登录凭证与身份验证方式</p>
</div>
<el-tag :type="securityLevel.type" effect="light">
安全等级 {{ securityLevel.label }}
</el-tag>
</header>
<div class="profile-security">
<div v-for="item in securityItems" :key="item.key" class="profile-security__item">
<span :class="['profile-icon', 'profile-icon--large', 'profile-icon--' + item.tone]">
<el-icon><component :is="item.icon" /></el-icon>
</span>
<div class="profile-security__body">
<div class="profile-security__title">
<span>{{ item.title }}</span>
<el-tag size="small" :type="item.statusType" effect="plain">
{{ item.status }}
</el-tag>
</div>
<p class="profile-security__desc">{{ item.description }}</p>
</div>
<div class="profile-security__actions">
<el-button
v-for="action in item.actions"
:key="action.label"
:type="action.type"
link
@click="action.onClick"
>
{{ action.label }}
</el-button>
</div>
</div>
</div>
</section>
<div class="profile-page__grid">
<section class="profile-card">
<header class="profile-card__header">
<h3 class="profile-card__title">近期登录</h3>
<span class="profile-card__extra">最近 3 </span>
</header>
<div class="profile-login">
<div
v-for="record in recentLoginRecords"
:key="record.time"
class="profile-login__item"
>
<span class="profile-icon">
<el-icon><Monitor /></el-icon>
</span>
<div class="profile-login__body">
<strong class="profile-login__device">{{ record.device }}</strong>
<span class="profile-login__meta">{{ record.location }} / {{ record.ip }}</span>
</div>
<time class="profile-login__time">{{ record.time }}</time>
</div>
</div>
</section>
<section class="profile-card">
<header class="profile-card__header">
<h3 class="profile-card__title">账号状态</h3>
<span class="profile-card__extra">完善度 {{ profileCompletion }}%</span>
</header>
<div class="profile-status">
<div
v-for="item in accountStatusItems"
:key="item.label"
class="profile-status__item"
:class="{ 'is-warning': !item.done }"
>
<el-icon class="profile-status__icon">
<CircleCheck v-if="item.done" />
<Warning v-else />
</el-icon>
<div class="profile-status__body">
<strong class="profile-status__title">{{ item.label }}</strong>
<span class="profile-status__desc">{{ item.value }}</span>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<el-dialog v-model="dialogState.visible" :title="dialogState.title" width="520px">
<el-form
v-if="dialogState.type === DialogType.ACCOUNT"
ref="userProfileFormRef"
:model="userProfileForm"
:rules="userProfileRules"
label-width="88px"
class="pr-10px"
>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="userProfileForm.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="性别">
<DictSelect v-model="userProfileForm.gender" code="gender" />
</el-form-item>
</el-form>
<el-form
v-else-if="dialogState.type === DialogType.PASSWORD"
ref="passwordChangeFormRef"
:model="passwordChangeForm"
:rules="passwordChangeRules"
label-width="88px"
class="pr-10px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="passwordChangeForm.oldPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordChangeForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordChangeForm.confirmPassword" type="password" show-password />
</el-form-item>
</el-form>
<el-form
v-else-if="dialogState.type === DialogType.MOBILE"
ref="mobileBindingFormRef"
:model="mobileUpdateForm"
:rules="mobileBindingRules"
label-width="88px"
class="pr-10px"
>
<el-form-item label="手机号码" prop="mobile">
<el-input v-model="mobileUpdateForm.mobile" />
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="mobileUpdateForm.code">
<template #append>
<el-button :disabled="mobileCountdown > 0" @click="handleSendMobileCode">
{{ mobileCountdown > 0 ? mobileCountdown + "s后重新发送" : "发送验证码" }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="当前密码" prop="password">
<el-input v-model="mobileUpdateForm.password" type="password" show-password />
</el-form-item>
</el-form>
<el-form
v-else-if="dialogState.type === DialogType.EMAIL"
ref="emailBindingFormRef"
:model="emailUpdateForm"
:rules="emailBindingRules"
label-width="88px"
class="pr-10px"
>
<el-form-item label="邮箱" prop="email">
<el-input v-model="emailUpdateForm.email" />
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="emailUpdateForm.code">
<template #append>
<el-button :disabled="emailCountdown > 0" @click="handleSendEmailCode">
{{ emailCountdown > 0 ? emailCountdown + "s后重新发送" : "发送验证码" }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="当前密码" prop="password">
<el-input v-model="emailUpdateForm.password" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<span class="inline-flex gap-2">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import UserAPI from "@/api/system/user";
import type {
UserProfileDetail,
PasswordChangeForm,
MobileUpdateForm,
EmailUpdateForm,
UserProfileForm,
} from "@/api/system/user";
import type { Component } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import FileAPI from "@/api/file";
import { useUserStoreHook } from "@/stores";
import { redirectToLogin } from "@/utils/auth";
import {
Calendar,
Camera,
CircleCheck,
DataLine,
Edit,
Female,
Iphone,
Key,
Location,
Lock,
Male,
Message,
Monitor,
OfficeBuilding,
Timer,
User,
UserFilled,
Warning,
} from "@element-plus/icons-vue";
interface ProfileInfoItem {
label: string;
value: string;
icon: Component;
muted?: boolean;
}
interface SecurityAction {
label: string;
type: "primary" | "danger";
onClick: () => void;
}
interface SecurityItem {
key: string;
title: string;
description: string;
status: string;
statusType: "success" | "warning" | "info";
icon: Component;
tone: "primary" | "success" | "warning";
actions: SecurityAction[];
}
const userStore = useUserStoreHook();
const userProfile = ref<UserProfileDetail>({});
const enum DialogType {
ACCOUNT = "account",
PASSWORD = "password",
MOBILE = "mobile",
EMAIL = "email",
}
const dialogState = reactive({
visible: false,
title: "",
type: "" as DialogType,
});
const userProfileFormRef = ref();
const passwordChangeFormRef = ref();
const mobileBindingFormRef = ref();
const emailBindingFormRef = ref();
const userProfileForm = reactive<UserProfileForm>({});
const passwordChangeForm = reactive<PasswordChangeForm>({});
const mobileUpdateForm = reactive<MobileUpdateForm>({});
const emailUpdateForm = reactive<EmailUpdateForm>({});
const mobileCountdown = ref(0);
const mobileTimer = ref();
const emailCountdown = ref(0);
const emailTimer = ref();
const recentLoginRecords = [
{
device: "Chrome / Windows",
location: "上海",
ip: "192.168.1.26",
time: "2026-06-20 09:32",
},
{
device: "Edge / Windows",
location: "杭州",
ip: "192.168.1.18",
time: "2026-06-19 18:46",
},
{
device: "Safari / iOS",
location: "深圳",
ip: "192.168.1.12",
time: "2026-06-18 14:08",
},
];
const userProfileRules = {
nickname: [{ required: true, message: "请输入昵称", trigger: "blur" }],
};
const passwordChangeRules = {
oldPassword: [{ required: true, message: "请输入原密码", trigger: "blur" }],
newPassword: [{ required: true, message: "请输入新密码", trigger: "blur" }],
confirmPassword: [
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (value !== passwordChangeForm.newPassword) {
callback(new Error("两次输入的密码不一致"));
return;
}
callback();
},
trigger: "blur",
},
],
};
// 手机号校验规则
const mobileBindingRules = {
mobile: [
{ required: true, message: "请输入手机号", trigger: "blur" },
{
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
message: "请输入正确的手机号码",
trigger: "blur",
},
],
code: [{ required: true, message: "请输入验证码", trigger: "blur" }],
password: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
};
// 邮箱校验规则
const emailBindingRules = {
email: [
{ required: true, message: "请输入邮箱", trigger: "blur" },
{
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
message: "请输入正确的邮箱地址",
trigger: "blur",
},
],
code: [{ required: true, message: "请输入验证码", trigger: "blur" }],
password: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
};
const displayAvatar = computed(() => userProfile.value.avatar || userStore.userInfo.avatar || "");
const displayName = computed(() => {
return (
userProfile.value.nickname ||
userStore.userInfo.nickname ||
userProfile.value.username ||
userStore.userInfo.username ||
"未命名用户"
);
});
const roleList = computed(() => {
return (userProfile.value.roleNames || "")
.split(/[,]/)
.map((role) => role.trim())
.filter(Boolean);
});
const primaryRole = computed(() => roleList.value[0] || "普通用户");
const permissionCount = computed(() => userStore.userInfo.perms?.length || 0);
const genderText = computed(() => {
if (userProfile.value.gender === 1) return "男";
if (userProfile.value.gender === 2) return "女";
return "未设置";
});
const boundCount = computed(() => {
return [userProfile.value.mobile, userProfile.value.email].filter(Boolean).length;
});
const profileCompletion = computed(() => {
const fields = [
userProfile.value.username,
userProfile.value.nickname,
displayAvatar.value,
userProfile.value.mobile,
userProfile.value.email,
userProfile.value.deptName,
userProfile.value.roleNames,
userProfile.value.createTime,
];
return Math.round((fields.filter(Boolean).length / fields.length) * 100);
});
const securityLevel = computed(() => {
const score = 60 + boundCount.value * 20;
if (score >= 100) {
return { score, label: "高", type: "success" as const };
}
if (score >= 80) {
return { score, label: "中", type: "warning" as const };
}
return { score, label: "低", type: "info" as const };
});
const profileInfoItems = computed<ProfileInfoItem[]>(() => [
{
label: "用户名",
value: userProfile.value.username || "-",
icon: userProfile.value.gender === 2 ? Female : userProfile.value.gender === 1 ? Male : User,
},
{
label: "手机号码",
value: userProfile.value.mobile || "未绑定",
icon: Iphone,
muted: !userProfile.value.mobile,
},
{
label: "邮箱",
value: userProfile.value.email || "未绑定",
icon: Message,
muted: !userProfile.value.email,
},
{
label: "部门",
value: userProfile.value.deptName || "-",
icon: OfficeBuilding,
muted: !userProfile.value.deptName,
},
{
label: "创建时间",
value: formatValue(userProfile.value.createTime),
icon: Timer,
muted: !userProfile.value.createTime,
},
]);
const profileStats = computed(() => [
{
label: "安全评分",
value: securityLevel.value.score,
suffix: "分",
icon: Key,
tone: "primary",
},
{
label: "绑定项目",
value: boundCount.value,
suffix: "/2",
icon: CircleCheck,
tone: "success",
},
{
label: "角色数量",
value: roleList.value.length,
suffix: "个",
icon: UserFilled,
tone: "warning",
},
{
label: "权限标识",
value: permissionCount.value,
suffix: "个",
icon: DataLine,
tone: "info",
},
]);
const accountStatusItems = computed(() => [
{
label: "登录账号",
value: userProfile.value.username || "未获取",
done: !!userProfile.value.username,
},
{
label: "手机号验证",
value: userProfile.value.mobile ? "已绑定" : "未绑定",
done: !!userProfile.value.mobile,
},
{
label: "邮箱验证",
value: userProfile.value.email ? "已绑定" : "未绑定",
done: !!userProfile.value.email,
},
{
label: "资料完善",
value: `${profileCompletion.value}%`,
done: profileCompletion.value >= 80,
},
]);
const securityItems = computed<SecurityItem[]>(() => [
{
key: "password",
title: "账户密码",
description: "定期修改密码有助于保护账户安全",
status: "已设置",
statusType: "success",
icon: Lock,
tone: "primary",
actions: [
{
label: "修改",
type: "primary",
onClick: () => handleOpenDialog(DialogType.PASSWORD),
},
],
},
{
key: "mobile",
title: "手机号",
description: mobileSecurityDesc.value,
status: userProfile.value.mobile ? "已绑定" : "未绑定",
statusType: userProfile.value.mobile ? "success" : "warning",
icon: Iphone,
tone: "success",
actions: userProfile.value.mobile
? [
{
label: "更换",
type: "primary",
onClick: () => handleOpenDialog(DialogType.MOBILE),
},
{
label: "解绑",
type: "danger",
onClick: handleUnbindMobile,
},
]
: [
{
label: "绑定",
type: "primary",
onClick: () => handleOpenDialog(DialogType.MOBILE),
},
],
},
{
key: "email",
title: "邮箱",
description: emailSecurityDesc.value,
status: userProfile.value.email ? "已绑定" : "未绑定",
statusType: userProfile.value.email ? "success" : "warning",
icon: Message,
tone: "warning",
actions: userProfile.value.email
? [
{
label: "更换",
type: "primary",
onClick: () => handleOpenDialog(DialogType.EMAIL),
},
{
label: "解绑",
type: "danger",
onClick: handleUnbindEmail,
},
]
: [
{
label: "绑定",
type: "primary",
onClick: () => handleOpenDialog(DialogType.EMAIL),
},
],
},
]);
function formatValue(value?: Date | string) {
return value ? String(value) : "-";
}
function getPromptValue(result: unknown) {
if (result && typeof result === "object" && "value" in result) {
return String(result.value || "");
}
return "";
}
function maskMobile(mobile?: string) {
if (!mobile) return "";
return mobile.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
}
function maskEmail(email?: string) {
if (!email) return "";
const [name, domain] = email.split("@");
if (!domain) return email;
if (name.length <= 2) return `${name[0] || ""}***@${domain}`;
return `${name.slice(0, 2)}***@${domain}`;
}
const mobileSecurityDesc = computed(() => {
return userProfile.value.mobile
? `已绑定:${maskMobile(userProfile.value.mobile)}`
: "未绑定手机号,建议立即绑定";
});
const emailSecurityDesc = computed(() => {
return userProfile.value.email
? `已绑定:${maskEmail(userProfile.value.email)}`
: "未绑定邮箱,建议立即绑定";
});
const handleOpenDialog = (type: DialogType) => {
dialogState.type = type;
dialogState.visible = true;
switch (type) {
case DialogType.ACCOUNT:
dialogState.title = "编辑资料";
userProfileForm.nickname = userProfile.value.nickname;
userProfileForm.avatar = userProfile.value.avatar;
userProfileForm.gender = userProfile.value.gender;
break;
case DialogType.PASSWORD:
dialogState.title = "修改密码";
break;
case DialogType.MOBILE:
dialogState.title = userProfile.value.mobile ? "更换手机号" : "绑定手机号";
mobileUpdateForm.mobile = "";
mobileUpdateForm.code = "";
mobileUpdateForm.password = "";
break;
case DialogType.EMAIL:
dialogState.title = userProfile.value.email ? "更换邮箱" : "绑定邮箱";
emailUpdateForm.email = "";
emailUpdateForm.code = "";
emailUpdateForm.password = "";
break;
}
};
async function handleUnbindMobile() {
if (!userProfile.value.mobile) return;
try {
const result = await ElMessageBox.prompt("请输入当前密码以解绑手机号", "解绑手机号", {
type: "warning",
confirmButtonText: "确定",
cancelButtonText: "取消",
inputType: "password",
inputPlaceholder: "当前密码",
inputValidator: (val) => !!val || "请输入当前密码",
});
const value = getPromptValue(result);
await UserAPI.unbindMobile({ password: value });
ElMessage.success("手机号解绑成功");
await loadUserProfile();
} catch {
// ignore
}
}
async function handleUnbindEmail() {
if (!userProfile.value.email) return;
try {
const result = await ElMessageBox.prompt("请输入当前密码以解绑邮箱", "解绑邮箱", {
type: "warning",
confirmButtonText: "确定",
cancelButtonText: "取消",
inputType: "password",
inputPlaceholder: "当前密码",
inputValidator: (val) => !!val || "请输入当前密码",
});
const value = getPromptValue(result);
await UserAPI.unbindEmail({ password: value });
ElMessage.success("邮箱解绑成功");
await loadUserProfile();
} catch {
// ignore
}
}
function handleSendMobileCode() {
if (!mobileUpdateForm.mobile) {
ElMessage.error("请输入手机号");
return;
}
const reg = /^1[3-9]\d{9}$/;
if (!reg.test(mobileUpdateForm.mobile)) {
ElMessage.error("手机号格式不正确");
return;
}
UserAPI.sendMobileCode(mobileUpdateForm.mobile).then(() => {
ElMessage.success("验证码发送成功");
mobileCountdown.value = 60;
mobileTimer.value = setInterval(() => {
if (mobileCountdown.value > 0) {
mobileCountdown.value -= 1;
} else {
clearInterval(mobileTimer.value!);
}
}, 1000);
});
}
function handleSendEmailCode() {
if (!emailUpdateForm.email) {
ElMessage.error("请输入邮箱");
return;
}
const reg = /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/;
if (!reg.test(emailUpdateForm.email)) {
ElMessage.error("邮箱格式不正确");
return;
}
UserAPI.sendEmailCode(emailUpdateForm.email).then(() => {
ElMessage.success("验证码发送成功");
emailCountdown.value = 60;
emailTimer.value = setInterval(() => {
if (emailCountdown.value > 0) {
emailCountdown.value -= 1;
} else {
clearInterval(emailTimer.value!);
}
}, 1000);
});
}
const handleSubmit = async () => {
try {
if (dialogState.type === DialogType.ACCOUNT) {
const valid = await userProfileFormRef.value?.validate();
if (!valid) return;
await UserAPI.updateProfile(userProfileForm);
ElMessage.success("账号资料修改成功");
dialogState.visible = false;
if (userProfileForm.nickname) {
userStore.userInfo.nickname = userProfileForm.nickname;
}
await loadUserProfile();
} else if (dialogState.type === DialogType.PASSWORD) {
const valid = await passwordChangeFormRef.value?.validate();
if (!valid) return;
await UserAPI.changePassword(passwordChangeForm);
dialogState.visible = false;
await redirectToLogin("密码已修改,请重新登录");
} else if (dialogState.type === DialogType.MOBILE) {
const valid = await mobileBindingFormRef.value?.validate();
if (!valid) return;
await UserAPI.bindOrChangeMobile(mobileUpdateForm);
ElMessage.success(userProfile.value.mobile ? "手机号更换成功" : "手机号绑定成功");
dialogState.visible = false;
await loadUserProfile();
} else if (dialogState.type === DialogType.EMAIL) {
const valid = await emailBindingFormRef.value?.validate();
if (!valid) return;
await UserAPI.bindOrChangeEmail(emailUpdateForm);
ElMessage.success(userProfile.value.email ? "邮箱更换成功" : "邮箱绑定成功");
dialogState.visible = false;
await loadUserProfile();
}
} catch {
// ignore
}
};
const handleCancel = () => {
dialogState.visible = false;
if (dialogState.type === DialogType.ACCOUNT) {
userProfileFormRef.value?.resetFields();
} else if (dialogState.type === DialogType.PASSWORD) {
passwordChangeFormRef.value?.resetFields();
} else if (dialogState.type === DialogType.MOBILE) {
mobileBindingFormRef.value?.resetFields();
} else if (dialogState.type === DialogType.EMAIL) {
emailBindingFormRef.value?.resetFields();
}
};
const fileInput = ref<HTMLInputElement | null>(null);
const triggerFileUpload = () => {
fileInput.value?.click();
};
const handleFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files ? target.files[0] : null;
if (file) {
const data = await FileAPI.uploadFile(file);
await UserAPI.updateProfile({
avatar: data.url,
});
userProfile.value.avatar = data.url;
userStore.userInfo.avatar = data.url;
ElMessage.success("头像更新成功");
}
target.value = "";
};
const loadUserProfile = async () => {
const data = await UserAPI.getProfile();
userProfile.value = data;
};
onMounted(async () => {
if (mobileTimer.value) {
clearInterval(mobileTimer.value);
}
if (emailTimer.value) {
clearInterval(emailTimer.value);
}
await loadUserProfile();
});
onBeforeUnmount(() => {
if (mobileTimer.value) {
clearInterval(mobileTimer.value);
}
if (emailTimer.value) {
clearInterval(emailTimer.value);
}
});
</script>
<style lang="scss" scoped>
.profile-page {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
padding: 16px;
}
.profile-hero,
.profile-card {
background: var(--content-bg);
border: 1px solid var(--card-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
}
.profile-hero {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
}
.profile-hero__body,
.profile-hero__title,
.profile-hero__meta,
.profile-hero__actions,
.profile-hero__meta-item {
display: flex;
align-items: center;
}
.profile-hero__body {
gap: 16px;
min-width: 0;
}
.profile-hero__info {
min-width: 0;
}
.profile-hero__title {
flex-wrap: wrap;
gap: 10px;
}
.profile-hero__name {
margin: 0;
font-size: 22px;
font-weight: 700;
line-height: 30px;
color: var(--el-text-color-primary);
}
.profile-hero__desc {
margin: 4px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.profile-hero__meta {
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.profile-hero__meta-item {
gap: 4px;
}
.profile-hero__actions {
flex-shrink: 0;
gap: 8px;
}
.profile-avatar {
position: relative;
flex-shrink: 0;
}
.profile-avatar__action {
position: absolute;
right: -2px;
bottom: -2px;
border: 2px solid var(--content-bg);
}
.profile-avatar__input {
display: none;
}
.profile-page__layout {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.profile-page__side,
.profile-page__main {
display: grid;
gap: 16px;
min-width: 0;
}
.profile-page__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);
gap: 16px;
}
.profile-card {
padding: 18px 20px;
}
.profile-card__header {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 14px;
}
.profile-card__title {
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 22px;
color: var(--el-text-color-primary);
}
.profile-card__desc {
margin: 3px 0 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.profile-card__extra,
.profile-empty {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.profile-info {
display: grid;
gap: 10px;
margin: 0;
}
.profile-info__item {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 34px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.profile-info__item:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.profile-info__label {
display: flex;
gap: 6px;
align-items: center;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.profile-info__value {
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.profile-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.profile-stats__item {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
padding: 12px;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-extra-light);
border-radius: 8px;
}
.profile-stats__body {
min-width: 0;
}
.profile-stats__label,
.profile-stats__value {
display: block;
}
.profile-stats__label {
margin-bottom: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.profile-stats__value {
font-size: 20px;
line-height: 24px;
color: var(--el-text-color-primary);
}
.profile-stats__value em {
display: inline;
margin-left: 2px;
font-size: 12px;
font-style: normal;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.profile-icon {
display: flex;
flex: 0 0 36px;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 18px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.profile-icon--large {
flex-basis: 40px;
width: 40px;
height: 40px;
font-size: 20px;
}
.profile-icon--success {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
}
.profile-icon--warning {
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
.profile-icon--info {
color: var(--el-color-info);
background: var(--el-fill-color-light);
}
.profile-security,
.profile-login,
.profile-status {
display: grid;
gap: 12px;
}
.profile-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.profile-security__item {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--el-border-color-extra-light);
border-radius: 8px;
}
.profile-security__body,
.profile-login__body,
.profile-status__body {
min-width: 0;
}
.profile-security__title {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.profile-security__desc {
margin: 4px 0 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.profile-security__actions {
display: flex;
gap: 8px;
align-items: center;
}
.profile-security__actions .el-button + .el-button {
margin-left: 0;
}
.profile-login__item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-height: 44px;
}
.profile-login__device,
.profile-login__meta,
.profile-login__time,
.profile-status__title,
.profile-status__desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-login__device,
.profile-status__title {
font-size: 13px;
color: var(--el-text-color-primary);
}
.profile-login__meta,
.profile-login__time,
.profile-status__desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.profile-status__item {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 38px;
color: var(--el-color-success);
}
.profile-status__item.is-warning {
color: var(--el-color-warning);
}
.profile-status__title {
margin-bottom: 2px;
}
.is-muted {
color: var(--el-text-color-placeholder);
}
@media (width <= 1200px) {
.profile-page__layout,
.profile-page__grid {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.profile-page {
padding: 12px;
}
.profile-hero {
align-items: flex-start;
}
.profile-hero,
.profile-hero__body,
.profile-hero__actions {
flex-direction: column;
}
.profile-hero__actions {
align-items: stretch;
width: 100%;
}
.profile-hero__actions .el-button {
width: 100%;
margin-left: 0;
}
.profile-security__item,
.profile-login__item {
grid-template-columns: 40px minmax(0, 1fr);
}
.profile-security__actions,
.profile-login__time {
grid-column: 2;
justify-self: start;
}
}
@media (width <= 520px) {
.profile-stats,
.profile-info__item {
grid-template-columns: 1fr;
}
.profile-info__item {
gap: 4px;
}
}
</style>