feat: 新增个人中心页面

This commit is contained in:
xlsea
2025-04-28 23:52:26 +08:00
parent d751c14f7b
commit 423a8c031f
22 changed files with 920 additions and 20 deletions

View File

@ -157,12 +157,11 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
<div class="mx-6px flex-y-center justify-between">
<NCheckbox v-model:checked="remberMe">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
<NSpace :size="1">
<ButtonIcon class="color-#44b549" icon="ic:outline-wechat" @click="handleSocialLogin('wechat_enterprise')" />
<ButtonIcon class="color-#44b549" icon="ic:outline-wechat" @click="handleSocialLogin('wechat_open')" />
<ButtonIcon local-icon="topiam" @click="handleSocialLogin('topiam')" />
<ButtonIcon local-icon="maxkey" @click="handleSocialLogin('maxkey')" />
<ButtonIcon class="color-#c71d23" icon="simple-icons:gitee" @click="handleSocialLogin('gitee')" />
<!-- <ButtonIcon class="color-#010409" icon="mdi:github" @click="handleSocialLogin('github')" /> -->
<ButtonIcon icon="material-icon-theme:gitlab" @click="handleSocialLogin('gitlab')" />
<ButtonIcon class="color-#010409" icon="mdi:github" @click="handleSocialLogin('github')" />
</NSpace>
</div>
<NButton type="primary" size="large" block :loading="authStore.loginLoading" @click="handleSubmit">

View File

@ -0,0 +1,242 @@
<script setup lang="ts">
import { reactive } from 'vue';
import { NButton } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchUpdateUserPassword, fetchUpdateUserProfile } from '@/service/api/system';
import { useAuthStore } from '@/store/modules/auth';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import OnlineTable from './modules/online-table.vue';
import SocialCard from './modules/social-card.vue';
import UserAvatar from './modules/user-avatar.vue';
defineOptions({
name: 'UserCenter'
});
const authStore = useAuthStore();
const { userInfo } = authStore;
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
const {
formRef: profileFormRef,
validate: profileValidate,
restoreValidation: profileRestoreValidation
} = useNaiveForm();
const {
formRef: passwordFormRef,
validate: passwordValidate,
restoreValidation: passwordRestoreValidation
} = useNaiveForm();
const { createRequiredRule, patternRules } = useFormRules();
type ProfileModel = Api.System.UserProfileOperateParams;
type PasswordModel = Api.System.UserPasswordOperateParams & { confirmPassword: string };
const profileModel: ProfileModel = reactive(createDefaultProfileModel());
const passwordModel: PasswordModel = reactive(createDefaultPasswordModel());
function createDefaultProfileModel(): ProfileModel {
return {
nickName: userInfo.user?.nickName || '',
email: userInfo.user?.email || '',
phonenumber: userInfo.user?.phonenumber || '',
sex: userInfo.user?.sex || '0'
};
}
function createDefaultPasswordModel(): PasswordModel {
return {
password: '',
confirmPassword: '',
newPassword: ''
};
}
type ProfileRuleKey = Extract<keyof ProfileModel, 'nickName' | 'email' | 'phonenumber' | 'sex'>;
type PasswordRuleKey = Extract<keyof PasswordModel, 'password' | 'confirmPassword' | 'newPassword'>;
const profileRules: Record<ProfileRuleKey, App.Global.FormRule> = {
nickName: createRequiredRule('昵称不能为空'),
email: { ...patternRules.email, required: true },
phonenumber: { ...patternRules.phone, required: true },
sex: createRequiredRule('性别不能为空')
};
const passwordRules: Record<PasswordRuleKey, App.Global.FormRule> = {
password: createRequiredRule('密码不能为空'),
confirmPassword: createRequiredRule('确认密码不能为空'),
newPassword: createRequiredRule('新密码不能为空')
};
async function updateProfile() {
await profileValidate();
startBtnLoading();
const { error } = await fetchUpdateUserProfile(profileModel);
if (!error) {
window.$message?.success('更新成功');
// 更新本地用户信息
if (userInfo.user) {
Object.assign(userInfo.user, profileModel);
profileRestoreValidation();
}
}
endBtnLoading();
}
async function updatePassword() {
await passwordValidate();
if (passwordModel.newPassword !== passwordModel.confirmPassword) {
window.$message?.error('两次输入的密码不一致');
return;
}
startBtnLoading();
const { error } = await fetchUpdateUserPassword(passwordModel);
if (!error) {
window.$message?.success('密码修改成功');
// 清空表单
Object.assign(passwordModel, createDefaultPasswordModel());
passwordRestoreValidation();
}
endBtnLoading();
}
</script>
<template>
<div class="flex gap-16px">
<!-- 个人信息卡片 -->
<NCard title="个人信息" class="w-360px shadow-sm">
<div class="flex-x-center flex-wrap gap-24px">
<div class="flex-center flex-col gap-16px">
<div class="relative">
<UserAvatar />
</div>
<div class="text-18px font-medium">{{ userInfo.user?.nickName }}</div>
<div class="text-14px text-gray-500">{{ userInfo.user?.userName }}</div>
</div>
<NDescriptions :column="1" label-placement="left" label-width="120px">
<NDescriptionsItem label="手机号码">
<div class="text-14px">{{ userInfo.user?.phonenumber }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="用户邮箱">
<div class="text-14px">{{ userInfo.user?.email }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="所属部门">
<div class="text-14px">{{ userInfo.user?.deptName }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="所属角色">
<NSpace>
<NTag v-for="role in userInfo.user?.roles" :key="role.roleId" type="primary" size="small">
{{ role.roleName }}
</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem label="创建日期">
<div class="text-14px">{{ userInfo.user?.createTime }}</div>
</NDescriptionsItem>
</NDescriptions>
</div>
</NCard>
<!-- 基本资料卡片 -->
<NCard title="基本资料" class="shadow-sm">
<NTabs type="line" animated class="h-full" s>
<NTabPane name="userInfo" tab="基本资料">
<NForm
ref="profileFormRef"
:model="profileModel"
:rules="profileRules"
label-placement="left"
label-width="100px"
class="mt-16px max-w-520px"
>
<NFormItem label="昵称" path="nickName">
<NInput v-model:value="profileModel.nickName" placeholder="请输入昵称" />
</NFormItem>
<NFormItem label="邮箱" path="email">
<NInput v-model:value="profileModel.email" placeholder="请输入邮箱" />
</NFormItem>
<NFormItem label="手机号" path="phonenumber">
<NInput v-model:value="profileModel.phonenumber" placeholder="请输入手机号" />
</NFormItem>
<NFormItem label="性别" path="sex">
<NRadioGroup v-model:value="profileModel.sex">
<NRadio value="0"></NRadio>
<NRadio value="1"></NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem class="flex items-center justify-end">
<NButton class="ml-20px w-80px" type="primary" :loading="btnLoading" @click="updateProfile">
<template #icon>
<SvgIcon icon="ic:outline-save" class="size-24px" />
</template>
保存
</NButton>
</NFormItem>
</NForm>
</NTabPane>
<NTabPane name="updatePwd" tab="修改密码">
<NForm
ref="passwordFormRef"
:model="passwordModel"
:rules="passwordRules"
label-placement="left"
label-width="100px"
class="mt-16px max-w-520px"
>
<NFormItem label="旧密码" path="password">
<NInput
v-model:value="passwordModel.password"
type="password"
placeholder="请输入旧密码"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="新密码" path="newPassword">
<NInput
v-model:value="passwordModel.newPassword"
type="password"
placeholder="请输入新密码"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="确认密码" path="confirmPassword">
<NInput
v-model:value="passwordModel.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password-on="click"
/>
</NFormItem>
<NFormItem class="flex items-center justify-end">
<NButton class="ml-20px w-120px" type="primary" :loading="btnLoading" @click="updatePassword">
<template #icon>
<SvgIcon icon="ic:outline-key" class="size-24px" />
</template>
修改密码
</NButton>
</NFormItem>
</NForm>
</NTabPane>
<NTabPane name="social" tab="第三方应用">
<SocialCard />
</NTabPane>
<NTabPane name="online" tab="在线设备">
<div class="h-full">
<OnlineTable />
</div>
</NTabPane>
</NTabs>
</NCard>
</div>
</template>
<style scoped>
.shadow-sm {
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
:deep(.n-tabs-pane-wrapper),
:deep(.n-tab-pane) {
height: 100% !important;
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="tsx">
import { NTime } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchForceLogout, fetchGetOnlineDeviceList } from '@/service/api/monitor';
import { useAppStore } from '@/store/modules/app';
import { useTable } from '@/hooks/common/table';
import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue';
defineOptions({
name: 'OnlineTable'
});
const appStore = useAppStore();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading(false);
const { columns, data, loading, mobilePagination, getData } = useTable({
apiFn: fetchGetOnlineDeviceList,
apiParams: {
pageNum: 1,
pageSize: 10
},
columns: () => [
{ title: '用户名', key: 'userName', align: 'center', minWidth: 120 },
{ title: 'IP地址', key: 'ipaddr', align: 'center', minWidth: 120 },
{ title: '登录地点', key: 'loginLocation', align: 'center', minWidth: 120 },
{ title: '浏览器', key: 'browser', align: 'center', minWidth: 120 },
{ title: '操作系统', key: 'os', align: 'center', minWidth: 120 },
{
title: '登录时间',
key: 'loginTime',
align: 'center',
minWidth: 180,
render: row => <NTime time={row.loginTime} format="yyyy-MM-dd HH:mm:ss" />
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
minWidth: 80,
render: row => {
return (
<div class="flex-center gap-8px">
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
loading={btnLoading.value}
class="text-18px"
tooltipContent="强制下线"
popconfirmContent="确定强制下线吗?"
onPositiveClick={() => forceLogout(row.tokenId)}
/>
</div>
);
}
}
]
});
/** 强制下线 */
async function forceLogout(tokenId: string) {
startBtnLoading();
const { error } = await fetchForceLogout(tokenId);
if (!error) {
window.$message?.success('强制下线成功');
await getData();
}
endBtnLoading();
}
</script>
<template>
<NDataTable
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"
remote
:row-key="row => row.noticeId"
:pagination="mobilePagination"
class="h-full"
/>
</template>
<style scoped></style>

View File

@ -0,0 +1,117 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useLoading } from '@sa/hooks';
import { fetchSocialAuthBinding, fetchSocialAuthUnbinding, fetchSocialList } from '@/service/api/system';
import { useAuthStore } from '@/store/modules/auth';
defineOptions({
name: 'SocialCard'
});
const authStore = useAuthStore();
const { userInfo } = authStore;
const socialList = ref<Api.System.Social[]>([]);
const { loading, startLoading, endLoading } = useLoading();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
/** 获取SSO账户列表 */
async function getSsoUserList() {
startLoading();
const { data, error } = await fetchSocialList();
if (!error) {
socialList.value = data || [];
}
endLoading();
}
/** 绑定SSO账户 */
async function bindSsoAccount(type: Api.System.SocialSource) {
const { data, error } = await fetchSocialAuthBinding(type, userInfo.user?.tenantId);
if (!error) {
window.location.href = data;
}
}
/** 解绑SSO账户 */
async function unbindSsoAccount(socialId: string) {
startBtnLoading();
const { error } = await fetchSocialAuthUnbinding(socialId);
if (!error) {
window.$message?.success('账户解绑成功');
await getSsoUserList();
}
endBtnLoading();
}
const socialSources = [
{ key: 'wechat_open', icon: 'ic:outline-wechat', color: '#44b549', name: '微信' },
{ key: 'topiam', localIcon: 'topiam', color: '', name: 'TopIAM' },
{ key: 'maxkey', localIcon: 'maxkey', color: '', name: 'MaxKey' },
{ key: 'gitee', icon: 'simple-icons:gitee', color: '#c71d23', name: 'Gitee' },
{ key: 'github', icon: 'mdi:github', color: '#010409', name: 'GitHub' }
];
getSsoUserList();
</script>
<template>
<NSpin :show="loading" class="mt-16px">
<div class="grid grid-cols-1 gap-16px 2xl:grid-cols-3 xl:grid-cols-2">
<div v-for="source in socialSources" :key="source.key" class="relative">
<NCard
class="h-full transition-all duration-300 hover:shadow-md"
:class="[socialList.some(s => s.source === source.key) ? 'border-primary' : 'border-transparent']"
:bordered="true"
>
<template v-if="socialList.some(s => s.source === source.key)">
<div class="flex flex-col items-center gap-16px">
<NAvatar
round
size="large"
:src="socialList.find(s => s.source === source.key)?.avatar"
class="size-80px"
/>
<div class="text-center">
<div class="text-16px font-medium">
{{ socialList.find(s => s.source === source.key)?.nickName }}
</div>
<div class="mt-4px text-12px text-gray-500">
绑定时间{{ socialList.find(s => s.source === source.key)?.createTime }}
</div>
</div>
<NButton
type="error"
size="small"
:loading="btnLoading"
@click="unbindSsoAccount(socialList.find(s => s.source === source.key)?.authId || '')"
>
解绑
</NButton>
</div>
</template>
<template v-else>
<div class="h-full flex flex-col items-center justify-center gap-16px">
<SvgIcon
:local-icon="source.localIcon"
:icon="source.icon"
class="size-48px"
:style="{ color: source.color }"
/>
<div class="text-16px font-medium">{{ source.name }}</div>
<NButton type="primary" size="small" @click="bindSsoAccount(source.key as Api.System.SocialSource)">
绑定
</NButton>
</div>
</template>
</NCard>
</div>
</div>
</NSpin>
</template>
<style scoped>
.border-primary {
border-color: var(--primary-color);
}
</style>

View File

@ -0,0 +1,195 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { NButton, NModal, NUpload } from 'naive-ui';
import { Cropper } from 'vue-advanced-cropper';
import { fetchUpdateUserAvatar } from '@/service/api/system';
import { useAuthStore } from '@/store/modules/auth';
import defaultAvatar from '@/assets/imgs/soybean.jpg';
import 'vue-advanced-cropper/dist/style.css';
interface CropperOptions {
img: string;
fileName: string;
stencilProps: {
aspectRatio: number;
};
}
interface CropperRef {
getResult: () => {
canvas: HTMLCanvasElement;
};
}
const authStore = useAuthStore();
const showModal = ref(false);
const imageUrl = ref(authStore.userInfo.user?.avatar || defaultAvatar);
const cropperRef = ref<CropperRef | null>(null);
// 图片裁剪数据
const options = reactive<CropperOptions>({
img: imageUrl.value,
fileName: '',
stencilProps: {
aspectRatio: 1
}
});
/** 编辑头像 */
function handleEdit() {
showModal.value = true;
}
/** 处理文件选择 */
async function handleFileSelect(data: { file: UploadFileInfo }) {
const file = data.file.file;
if (!file) return false;
if (!file.type.includes('image/')) {
window.$message?.error('请上传图片类型文件JPG、PNG等');
return false;
}
const reader = new FileReader();
reader.onload = () => {
options.img = reader.result as string;
options.fileName = file.name;
};
reader.readAsDataURL(file);
return false;
}
/** 处理裁剪 */
async function handleCrop() {
if (!cropperRef.value) return;
const { canvas } = cropperRef.value.getResult();
// 将 canvas 转换为 blob
canvas.toBlob(async (blob: Blob | null) => {
if (!blob) return;
const formData = new FormData();
formData.append('avatarfile', blob, options.fileName || 'avatar.png');
const { error } = await fetchUpdateUserAvatar(formData);
if (!error) {
window.$message?.success('头像更新成功!');
imageUrl.value = URL.createObjectURL(blob);
handleClose();
}
}, 'image/png');
}
/** 关闭对话框 */
function handleClose() {
showModal.value = false;
options.img = imageUrl.value;
}
</script>
<template>
<div class="avatar-wrapper" @click="handleEdit">
<div class="avatar-container">
<img :src="imageUrl" alt="user-avatar" class="avatar-image" />
<div class="avatar-overlay">
<SvgIcon icon="ep:plus" class="text-24px" />
</div>
</div>
<NModal v-model:show="showModal" preset="card" title="修改头像" class="w-400px" @close="handleClose">
<div class="upload-container">
<div v-if="options.img !== imageUrl" class="cropper-container">
<Cropper ref="cropperRef" class="cropper" :src="options.img" :stencil-props="options.stencilProps" />
</div>
<img v-else :src="imageUrl" alt="user-avatar" class="preview-image" />
<div class="button-group">
<NUpload accept=".jpg,.jpeg,.png,.gif" :max="1" :show-file-list="false" @before-upload="handleFileSelect">
<NButton class="upload-button">选择图片</NButton>
</NUpload>
<NButton v-if="options.img !== imageUrl" type="primary" class="upload-button" @click="handleCrop">
确认裁剪
</NButton>
</div>
</div>
</NModal>
</div>
</template>
<style lang="scss" scoped>
.avatar-wrapper {
display: inline-block;
cursor: pointer;
}
.avatar-container {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
&:hover .avatar-overlay {
opacity: 1;
}
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s ease;
color: #fff;
}
.upload-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 20px 0;
}
.cropper-container {
width: 100%;
height: 300px;
}
.cropper {
height: 100%;
background: #f8f8f8;
}
.preview-image {
width: 200px;
height: 200px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #eee;
}
.button-group {
display: flex;
gap: 12px;
}
.upload-button {
min-width: 100px;
}
</style>