feat: 新增 ele infra 个人中心

This commit is contained in:
puhui999
2025-05-14 17:11:08 +08:00
parent 4e1d842e7f
commit 141a90a53b
10 changed files with 1393 additions and 4 deletions

View File

@@ -1,7 +1,65 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
import { getUserProfile } from '#/api/system/user/profile';
import { useAuthStore } from '#/store';
import BaseInfo from './modules/base-info.vue';
import ProfileUser from './modules/profile-user.vue';
import ResetPwd from './modules/reset-pwd.vue';
import UserSocial from './modules/user-social.vue';
const authStore = useAuthStore();
const activeName = ref('basicInfo');
/** 加载个人信息 */
const profile = ref<SystemUserProfileApi.UserProfileRespVO>();
async function loadProfile() {
profile.value = await getUserProfile();
}
/** 刷新个人信息 */
async function refreshProfile() {
// 加载个人信息
await loadProfile();
// 更新 store
await authStore.fetchUserInfo();
}
/** 初始化 */
onMounted(loadProfile);
</script>
<template>
<div></div>
</template>
<Page auto-content-height>
<div class="flex">
<!-- 左侧 个人信息 -->
<ElCard class="w-2/5" title="个人信息">
<ProfileUser :profile="profile" @success="refreshProfile" />
</ElCard>
<style scoped lang="scss"></style>
<!-- 右侧 标签页 -->
<ElCard class="ml-3 w-3/5">
<ElTabs v-model="activeName" class="-mt-4">
<ElTabPane name="basicInfo" label="基本设置">
<BaseInfo :profile="profile" @success="refreshProfile" />
</ElTabPane>
<ElTabPane name="resetPwd" label="密码设置">
<ResetPwd />
</ElTabPane>
<ElTabPane name="userSocial" label="社交绑定" force-render>
<UserSocial @update:active-name="activeName = $event" />
</ElTabPane>
<!-- TODO @芋艿在线设备 -->
</ElTabs>
</ElCard>
</div>
</Page>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { watch } from 'vue';
import { $t } from '@vben/locales';
import { ElMessage } from 'element-plus';
import { useVbenForm, z } from '#/adapter/form';
import { updateUserProfile } from '#/api/system/user/profile';
import { DICT_TYPE, getDictOptions } from '#/utils';
const props = defineProps<{
profile?: SystemUserProfileApi.UserProfileRespVO;
}>();
const emit = defineEmits<{
(e: 'success'): void;
}>();
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 70,
},
schema: [
{
label: '用户昵称',
fieldName: 'nickname',
component: 'Input',
componentProps: {
placeholder: '请输入用户昵称',
},
rules: 'required',
},
{
label: '用户手机',
fieldName: 'mobile',
component: 'Input',
componentProps: {
placeholder: '请输入用户手机',
},
rules: z.string(),
},
{
label: '用户邮箱',
fieldName: 'email',
component: 'Input',
componentProps: {
placeholder: '请输入用户邮箱',
},
rules: z.string().email('请输入正确的邮箱'),
},
{
label: '用户性别',
fieldName: 'sex',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number(),
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
content: '更新信息',
},
handleSubmit,
});
async function handleSubmit(values: Recordable<any>) {
try {
formApi.setLoading(true);
// 提交表单
await updateUserProfile(values as SystemUserProfileApi.UpdateProfileReqVO);
// 关闭并提示
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
formApi.setLoading(false);
}
}
/** 监听 profile 变化 */
watch(
() => props.profile,
(newProfile) => {
if (newProfile) {
formApi.setValues(newProfile);
}
},
{ immediate: true },
);
</script>
<template>
<div class="mt-16px md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils';
import { ElDescriptions, ElDescriptionsItem, ElTooltip } from 'element-plus';
import { updateUserProfile } from '#/api/system/user/profile';
import { CropperAvatar } from '#/components/cropper';
import { useUpload } from '#/components/upload/use-upload';
const props = defineProps<{
profile?: SystemUserProfileApi.UserProfileRespVO;
}>();
const emit = defineEmits<{
(e: 'success'): void;
}>();
const avatar = computed(
() => props.profile?.avatar || preferences.app.defaultAvatar,
);
async function handelUpload({
file,
filename,
}: {
file: Blob;
filename: string;
}) {
// 1. 上传头像,获取 URL
const { httpRequest } = useUpload();
// 将 Blob 转换为 File
const fileObj = new File([file], filename, { type: file.type });
const avatar = await httpRequest(fileObj);
// 2. 更新用户头像
await updateUserProfile({ avatar });
}
</script>
<template>
<div v-if="profile">
<div class="flex flex-col items-center">
<ElTooltip content="点击上传头像">
<CropperAvatar
:show-btn="false"
:upload-api="handelUpload"
:value="avatar"
:width="120"
@change="emit('success')"
/>
</ElTooltip>
</div>
<div class="mt-8">
<ElDescriptions :column="2">
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
用户账号
</div>
</template>
{{ profile.username }}
</ElDescriptionsItem>
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:user-switch-outlined"
class="mr-1"
/>
所属角色
</div>
</template>
{{ profile.roles.map((role) => role.name).join(',') }}
</ElDescriptionsItem>
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:phone-outlined" class="mr-1" />
手机号码
</div>
</template>
{{ profile.mobile }}
</ElDescriptionsItem>
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:mail-outlined" class="mr-1" />
用户邮箱
</div>
</template>
{{ profile.email }}
</ElDescriptionsItem>
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:team-outlined" class="mr-1" />
所属部门
</div>
</template>
{{ profile.dept?.name }}
</ElDescriptionsItem>
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:usergroup-add-outlined"
class="mr-1"
/>
所属岗位
</div>
</template>
{{ profile.posts.map((post) => post.name).join(',') }}
</ElDescriptionsItem>
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:clock-circle-outlined"
class="mr-1"
/>
创建时间
</div>
</template>
{{ formatDateTime(profile.createTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:login-outlined" class="mr-1" />
登录时间
</div>
</template>
{{ formatDateTime(profile.loginDate) }}
</ElDescriptionsItem>
</ElDescriptions>
</div>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { $t } from '@vben/locales';
import { ElMessage } from 'element-plus';
import { useVbenForm, z } from '#/adapter/form';
import { updateUserPassword } from '#/api/system/user/profile';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 70,
},
schema: [
{
component: 'InputPassword',
fieldName: 'oldPassword',
label: '旧密码',
rules: z
.string({ message: '请输入密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符'),
},
{
component: 'InputPassword',
dependencies: {
rules(values) {
return z
.string({ message: '请输入新密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value !== values.oldPassword,
'新旧密码不能相同',
);
},
triggerFields: ['newPassword', 'oldPassword'],
},
fieldName: 'newPassword',
label: '新密码',
rules: 'required',
},
{
component: 'InputPassword',
dependencies: {
rules(values) {
return z
.string({ message: '请输入确认密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value === values.newPassword,
'新密码和确认密码不一致',
);
},
triggerFields: ['newPassword', 'confirmPassword'],
},
fieldName: 'confirmPassword',
label: '确认密码',
rules: 'required',
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
content: '修改密码',
},
handleSubmit,
});
async function handleSubmit(values: Recordable<any>) {
try {
formApi.setLoading(true);
// 提交表单
await updateUserPassword({
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
formApi.setLoading(false);
}
}
</script>
<template>
<div class="mt-[16px] md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@@ -0,0 +1,214 @@
<script setup lang="tsx">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSocialUserApi } from '#/api/system/social/user';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { confirm } from '@vben/common-ui';
import { ElButton, ElCard, ElImage, ElMessage } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { socialAuthRedirect } from '#/api/core/auth';
import {
getBindSocialUserList,
socialBind,
socialUnbind,
} from '#/api/system/social/user';
import { $t } from '#/locales';
import { DICT_TYPE, getDictLabel, SystemUserSocialTypeEnum } from '#/utils';
const emit = defineEmits<{
(e: 'update:activeName', v: string): void;
}>();
const route = useRoute();
/** 已经绑定的平台 */
const bindList = ref<SystemSocialUserApi.SocialUser[]>([]);
const allBindList = computed<any[]>(() => {
return Object.values(SystemUserSocialTypeEnum).map((social) => {
const socialUser = bindList.value.find((item) => item.type === social.type);
return {
...social,
socialUser,
};
});
});
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'type',
title: '绑定平台',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE },
},
},
{
field: 'openid',
title: '标识',
minWidth: 180,
},
{
field: 'nickname',
title: '昵称',
minWidth: 180,
},
{
field: 'operation',
title: '操作',
minWidth: 80,
align: 'center',
fixed: 'right',
slots: {
default: ({ row }: { row: SystemSocialUserApi.SocialUser }) => {
return (
<ElButton onClick={() => onUnbind(row)} type="text">
解绑
</ElButton>
);
},
},
},
];
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
minHeight: 0,
keepSource: true,
proxyConfig: {
ajax: {
query: async () => {
bindList.value = await getBindSocialUserList();
return bindList.value;
},
},
},
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
});
/** 解绑账号 */
function onUnbind(row: SystemSocialUserApi.SocialUser) {
confirm({
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`,
}).then(async () => {
await socialUnbind({ type: row.type, openid: row.openid });
// 提示成功
ElMessage.success($t('ui.actionMessage.operationSuccess'));
await gridApi.reload();
});
}
/** 绑定账号(跳转授权页面) */
async function onBind(bind: any) {
const type = bind.type;
if (type <= 0) {
return;
}
try {
// 计算 redirectUri
// tricky: type 需要先 encode 一次,否则钉钉回调会丢失。配合 getUrlValue() 使用
const redirectUri = `${location.origin}/profile?${encodeURIComponent(`type=${type}`)}`;
// 进行跳转
window.location.href = await socialAuthRedirect(type, redirectUri);
} catch (error) {
console.error('社交绑定处理失败:', error);
}
}
/** 监听路由变化,处理社交绑定回调 */
async function bindSocial() {
// 社交绑定
const type = Number(getUrlValue('type'));
const code = route.query.code as string;
const state = route.query.state as string;
if (!code) {
return;
}
await socialBind({ type, code, state });
// 提示成功
ElMessage.success('绑定成功');
emit('update:activeName', 'userSocial');
await gridApi.reload();
// 清理 URL 参数,避免刷新重复触发
window.history.replaceState({}, '', location.pathname);
}
// TODO @芋艿:后续搞到 util 里;
// 双层 encode 需要在回调后进行 decode
function getUrlValue(key: string): string {
const url = new URL(decodeURIComponent(location.href));
return url.searchParams.get(key) ?? '';
}
/** 初始化 */
onMounted(() => {
bindSocial();
});
</script>
<template>
<div class="flex flex-col">
<Grid />
<div class="pb-3">
<div
class="grid grid-cols-1 gap-2 px-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3"
>
<ElCard v-for="item in allBindList" :key="item.type" class="!mb-2">
<div class="flex w-full items-center gap-4">
<ElImage
:src="item.img"
:width="40"
:height="40"
:alt="item.title"
:preview="false"
/>
<div class="flex flex-1 items-center justify-between">
<div class="flex flex-col">
<h4
class="mb-[4px] text-[14px] text-black/85 dark:text-white/85"
>
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
</h4>
<span class="text-black/45 dark:text-white/45">
<template v-if="item.socialUser">
{{ item.socialUser?.nickname || item.socialUser?.openid }}
</template>
<template v-else>
绑定{{
getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type)
}}账号
</template>
</span>
</div>
<ElButton
:disabled="!!item.socialUser"
size="small"
type="text"
@click="onBind(item)"
>
{{ item.socialUser ? '已绑定' : '绑定' }}
</ElButton>
</div>
</div>
</ElCard>
</div>
</div>
</div>
</template>