Merge branch 'main' into main

This commit is contained in:
Jin Mao 2025-11-12 04:27:57 +08:00 committed by GitHub
commit f59e33682c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 1809 additions and 25 deletions

View File

@ -2,6 +2,7 @@
import type { NotificationItem } from '@vben/layouts'; import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@ -74,6 +75,7 @@ const notifications = ref<NotificationItem[]>([
}, },
]); ]);
const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -83,6 +85,13 @@ const showDot = computed(() =>
); );
const menus = computed(() => [ const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{ {
handler: () => { handler: () => {
openWindow(VBEN_DOC_URL, { openWindow(VBEN_DOC_URL, {

View File

@ -4,7 +4,8 @@
"register": "Register", "register": "Register",
"codeLogin": "Code Login", "codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login", "qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password" "forgetPassword": "Forget Password",
"profile": "Profile"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",

View File

@ -4,7 +4,8 @@
"register": "注册", "register": "注册",
"codeLogin": "验证码登录", "codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录", "qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码" "forgetPassword": "忘记密码",
"profile": "个人中心"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "概览",

View File

@ -89,6 +89,16 @@ const routes: RouteRecordRaw[] = [
order: 9999, order: 9999,
}, },
}, },
{
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'lucide:user',
hideInMenu: true,
title: $t('page.auth.profile'),
},
},
]; ];
export default routes; export default routes;

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from 'ant-design-vue';
const profilePasswordSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>

View File

@ -2,6 +2,7 @@
import type { NotificationItem } from '@vben/layouts'; import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@ -74,6 +75,7 @@ const notifications = ref<NotificationItem[]>([
}, },
]); ]);
const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -83,6 +85,13 @@ const showDot = computed(() =>
); );
const menus = computed(() => [ const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{ {
handler: () => { handler: () => {
openWindow(VBEN_DOC_URL, { openWindow(VBEN_DOC_URL, {

View File

@ -4,7 +4,8 @@
"register": "Register", "register": "Register",
"codeLogin": "Code Login", "codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login", "qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password" "forgetPassword": "Forget Password",
"profile": "Profile"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",

View File

@ -4,7 +4,8 @@
"register": "注册", "register": "注册",
"codeLogin": "验证码登录", "codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录", "qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码" "forgetPassword": "忘记密码",
"profile": "个人中心"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "概览",

View File

@ -89,6 +89,16 @@ const routes: RouteRecordRaw[] = [
order: 9999, order: 9999,
}, },
}, },
{
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'lucide:user',
hideInMenu: true,
title: $t('page.auth.profile'),
},
},
]; ];
export default routes; export default routes;

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
const profilePasswordSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
ElMessage.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>

View File

@ -2,6 +2,7 @@
import type { NotificationItem } from '@vben/layouts'; import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@ -74,6 +75,7 @@ const notifications = ref<NotificationItem[]>([
}, },
]); ]);
const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -83,6 +85,13 @@ const showDot = computed(() =>
); );
const menus = computed(() => [ const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{ {
handler: () => { handler: () => {
openWindow(VBEN_DOC_URL, { openWindow(VBEN_DOC_URL, {

View File

@ -4,7 +4,8 @@
"register": "Register", "register": "Register",
"codeLogin": "Code Login", "codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login", "qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password" "forgetPassword": "Forget Password",
"profile": "Profile"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",

View File

@ -4,7 +4,8 @@
"register": "注册", "register": "注册",
"codeLogin": "验证码登录", "codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录", "qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码" "forgetPassword": "忘记密码",
"profile": "个人中心"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "概览",

View File

@ -89,6 +89,16 @@ const routes: RouteRecordRaw[] = [
order: 9999, order: 9999,
}, },
}, },
{
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'lucide:user',
hideInMenu: true,
title: $t('page.auth.profile'),
},
},
]; ];
export default routes; export default routes;

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from '#/adapter/naive';
const profilePasswordSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>

View File

@ -2,6 +2,7 @@
import type { NotificationItem } from '@vben/layouts'; import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@ -74,6 +75,7 @@ const notifications = ref<NotificationItem[]>([
}, },
]); ]);
const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -83,6 +85,13 @@ const showDot = computed(() =>
); );
const menus = computed(() => [ const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{ {
handler: () => { handler: () => {
openWindow(VBEN_DOC_URL, { openWindow(VBEN_DOC_URL, {

View File

@ -4,7 +4,8 @@
"register": "Register", "register": "Register",
"codeLogin": "Code Login", "codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login", "qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password" "forgetPassword": "Forget Password",
"profile": "Profile"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",

View File

@ -4,7 +4,8 @@
"register": "注册", "register": "注册",
"codeLogin": "验证码登录", "codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录", "qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码" "forgetPassword": "忘记密码",
"profile": "个人中心"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "概览",

View File

@ -89,6 +89,16 @@ const routes: RouteRecordRaw[] = [
order: 9999, order: 9999,
}, },
}, },
{
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'lucide:user',
hideInMenu: true,
title: $t('page.auth.profile'),
},
},
]; ];
export default routes; export default routes;

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from '#/adapter/tdesign';
const profilePasswordSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>

View File

@ -335,6 +335,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - | | handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleSubmit | 表单提交回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - | | handleSubmit | 表单提交回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(values: Record<string, any>, fieldsChanged: string[]) => void` | - | | handleValuesChange | 表单值变化回调 | `(values: Record<string, any>, fieldsChanged: string[]) => void` | - |
| handleCollapsedChange | 表单收起展开状态变化回调 | `(collapsed: boolean) => void` | - |
| actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` | | actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` |
| resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - | | resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - |
| submitButtonOptions | 提交按钮组件参数 | `ActionButtonOptions` | - | | submitButtonOptions | 提交按钮组件参数 | `ActionButtonOptions` | - |

View File

@ -47,7 +47,7 @@ async function handleSubmit(e: Event) {
return; return;
} }
const values = toRaw(await props.formApi.getValues()); const values = toRaw(await props.formApi.getValues()) ?? {};
await props.handleSubmit?.(values); await props.handleSubmit?.(values);
} }
@ -56,7 +56,7 @@ async function handleReset(e: Event) {
e?.stopPropagation(); e?.stopPropagation();
const props = unref(rootProps); const props = unref(rootProps);
const values = toRaw(await props.formApi?.getValues()); const values = toRaw(await props.formApi?.getValues()) ?? {};
if (isFunction(props.handleReset)) { if (isFunction(props.handleReset)) {
await props.handleReset?.(values); await props.handleReset?.(values);

View File

@ -36,6 +36,7 @@ function getDefaultState(): VbenFormProps {
handleReset: undefined, handleReset: undefined,
handleSubmit: undefined, handleSubmit: undefined,
handleValuesChange: undefined, handleValuesChange: undefined,
handleCollapsedChange: undefined,
layout: 'horizontal', layout: 'horizontal',
resetButtonOptions: {}, resetButtonOptions: {},
schema: [], schema: [],

View File

@ -379,6 +379,10 @@ export interface VbenFormProps<
* *
*/ */
fieldMappingTime?: FieldMappingTime; fieldMappingTime?: FieldMappingTime;
/**
*
*/
handleCollapsedChange?: (collapsed: boolean) => void;
/** /**
* *
*/ */

View File

@ -13,7 +13,7 @@ import { useForm } from 'vee-validate';
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod'; import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults'; import { getDefaultsForSchema } from 'zod-defaults';
type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi }; type ExtendFormProps = VbenFormProps & { formApi?: ExtendedFormApi };
export const [injectFormProps, provideFormProps] = export const [injectFormProps, provideFormProps] =
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>( createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(

View File

@ -40,7 +40,9 @@ const { delegatedSlots, form } = useFormInitial(props);
provideFormProps([props, form]); provideFormProps([props, form]);
const handleUpdateCollapsed = (value: boolean) => { const handleUpdateCollapsed = (value: boolean) => {
currentCollapsed.value = !!value; currentCollapsed.value = value;
//
props.handleCollapsedChange?.(value);
}; };
watchEffect(() => { watchEffect(() => {

View File

@ -25,7 +25,7 @@ import {
} from './use-form-context'; } from './use-form-context';
// extends // extends
interface Props extends VbenFormProps { interface Props extends VbenFormProps {
formApi: ExtendedFormApi; formApi?: ExtendedFormApi;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@ -44,11 +44,13 @@ provideComponentRefMap(componentRefMap);
props.formApi?.mount?.(form, componentRefMap); props.formApi?.mount?.(form, componentRefMap);
const handleUpdateCollapsed = (value: boolean) => { const handleUpdateCollapsed = (value: boolean) => {
props.formApi?.setState({ collapsed: !!value }); props.formApi?.setState({ collapsed: value });
//
forward.value.handleCollapsedChange?.(value);
}; };
function handleKeyDownEnter(event: KeyboardEvent) { function handleKeyDownEnter(event: KeyboardEvent) {
if (!state.value.submitOnEnter || !forward.value.formApi?.isMounted) { if (!state?.value.submitOnEnter || !forward.value.formApi?.isMounted) {
return; return;
} }
// textarea // textarea
@ -58,11 +60,11 @@ function handleKeyDownEnter(event: KeyboardEvent) {
} }
event.preventDefault(); event.preventDefault();
forward.value.formApi.validateAndSubmitForm(); forward.value.formApi?.validateAndSubmitForm();
} }
const handleValuesChangeDebounced = useDebounceFn(async () => { const handleValuesChangeDebounced = useDebounceFn(async () => {
state.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm(); state?.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
}, 300); }, 300);
const valuesCache: Recordable<any> = {}; const valuesCache: Recordable<any> = {};
@ -74,7 +76,7 @@ onMounted(async () => {
() => form.values, () => form.values,
async (newVal) => { async (newVal) => {
if (forward.value.handleValuesChange) { if (forward.value.handleValuesChange) {
const fields = state.value.schema?.map((item) => { const fields = state?.value.schema?.map((item) => {
return item.fieldName; return item.fieldName;
}); });
@ -91,8 +93,9 @@ onMounted(async () => {
if (changedFields.length > 0) { if (changedFields.length > 0) {
// handleValuesChange // handleValuesChange
const values = await forward.value.formApi?.getValues();
forward.value.handleValuesChange( forward.value.handleValuesChange(
cloneDeep(await forward.value.formApi.getValues()), cloneDeep(values ?? {}) as Record<string, any>,
changedFields, changedFields,
); );
} }
@ -109,7 +112,7 @@ onMounted(async () => {
<Form <Form
@keydown.enter="handleKeyDownEnter" @keydown.enter="handleKeyDownEnter"
v-bind="forward" v-bind="forward"
:collapsed="state.collapsed" :collapsed="state?.collapsed"
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP" :component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
:component-map="COMPONENT_MAP" :component-map="COMPONENT_MAP"
:form="form" :form="form"
@ -126,7 +129,7 @@ onMounted(async () => {
<slot v-bind="slotProps"> <slot v-bind="slotProps">
<FormActions <FormActions
v-if="forward.showDefaultActions" v-if="forward.showDefaultActions"
:model-value="state.collapsed" :model-value="state?.collapsed"
@update:model-value="handleUpdateCollapsed" @update:model-value="handleUpdateCollapsed"
> >
<template #reset-before="resetSlotProps"> <template #reset-before="resetSlotProps">

View File

@ -2,3 +2,4 @@ export * from './about';
export * from './authentication'; export * from './authentication';
export * from './dashboard'; export * from './dashboard';
export * from './fallback'; export * from './fallback';
export * from './profile';

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '@vben-core/form-ui';
import { computed, reactive } from 'vue';
import { useVbenForm } from '@vben-core/form-ui';
import { VbenButton } from '@vben-core/shadcn-ui';
interface Props {
formSchema?: VbenFormSchema[];
}
const props = withDefaults(defineProps<Props>(), {
formSchema: () => [],
});
const emit = defineEmits<{
submit: [Recordable<any>];
}>();
const [Form, formApi] = useVbenForm(
reactive({
commonConfig: {
//
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: computed(() => props.formSchema),
showDefaultActions: false,
}),
);
async function handleSubmit() {
const { valid } = await formApi.validate();
const values = await formApi.getValues();
if (valid) {
emit('submit', values);
}
}
defineExpose({
getFormApi: () => formApi,
});
</script>
<template>
<div @keydown.enter.prevent="handleSubmit">
<Form />
<VbenButton type="submit" class="mt-4" @click="handleSubmit">
更新基本信息
</VbenButton>
</div>
</template>

View File

@ -0,0 +1,6 @@
export { default as ProfileBaseSetting } from './base-setting.vue';
export { default as ProfileNotificationSetting } from './notification-setting.vue';
export { default as ProfilePasswordSetting } from './password-setting.vue';
export { default as Profile } from './profile.vue';
export { default as ProfileSecuritySetting } from './security-setting.vue';
export type * from './types';

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Switch,
} from '@vben-core/shadcn-ui';
withDefaults(defineProps<SettingProps>(), {
formSchema: () => [],
});
const emit = defineEmits<{
change: [Recordable<any>];
}>();
function handleChange(fieldName: string, value: boolean) {
emit('change', { fieldName, value });
}
</script>
<template>
<Form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
<FormItem
class="flex flex-row items-center justify-between rounded-lg border p-4"
>
<div class="space-y-0.5">
<FormLabel class="text-base"> {{ item.label }} </FormLabel>
<FormDescription>
{{ item.description }}
</FormDescription>
</div>
<FormControl>
<Switch
:model-value="item.value"
@update:model-value="handleChange(item.fieldName, $event)"
/>
</FormControl>
</FormItem>
</FormField>
</template>
</div>
</Form>
</template>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '@vben-core/form-ui';
import { computed, reactive } from 'vue';
import { useVbenForm } from '@vben-core/form-ui';
import { VbenButton } from '@vben-core/shadcn-ui';
interface Props {
formSchema?: VbenFormSchema[];
}
const props = withDefaults(defineProps<Props>(), {
formSchema: () => [],
});
const emit = defineEmits<{
submit: [Recordable<any>];
}>();
const [Form, formApi] = useVbenForm(
reactive({
commonConfig: {
//
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: computed(() => props.formSchema),
showDefaultActions: false,
}),
);
async function handleSubmit() {
const { valid } = await formApi.validate();
const values = await formApi.getValues();
if (valid) {
emit('submit', values);
}
}
defineExpose({
getFormApi: () => formApi,
});
</script>
<template>
<div>
<Form />
<VbenButton type="submit" class="mt-4" @click="handleSubmit">
更新密码
</VbenButton>
</div>
</template>

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import type { Props } from './types';
import { preferences } from '@vben-core/preferences';
import {
Card,
Separator,
Tabs,
TabsList,
TabsTrigger,
VbenAvatar,
} from '@vben-core/shadcn-ui';
import { Page } from '../../components';
defineOptions({
name: 'ProfileUI',
});
withDefaults(defineProps<Props>(), {
title: '关于项目',
tabs: () => [],
});
const tabsValue = defineModel<string>('modelValue');
</script>
<template>
<Page auto-content-height>
<div class="flex h-full w-full">
<Card class="w-1/6 flex-none">
<div class="mt-4 flex h-40 flex-col items-center justify-center gap-4">
<VbenAvatar
:src="userInfo?.avatar ?? preferences.app.defaultAvatar"
class="size-20"
/>
<span class="text-lg font-semibold">
{{ userInfo?.realName ?? '' }}
</span>
<span class="text-foreground/80 text-sm">
{{ userInfo?.username ?? '' }}
</span>
</div>
<Separator class="my-4" />
<Tabs v-model="tabsValue" orientation="vertical" class="m-4">
<TabsList class="bg-card grid w-full grid-cols-1">
<TabsTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
class="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground h-12 justify-start"
>
{{ tab.label }}
</TabsTrigger>
</TabsList>
</Tabs>
</Card>
<Card class="ml-4 w-5/6 flex-auto p-8">
<slot name="content"></slot>
</Card>
</div>
</Page>
</template>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Switch,
} from '@vben-core/shadcn-ui';
withDefaults(defineProps<SettingProps>(), {
formSchema: () => [],
});
const emit = defineEmits<{
change: [Recordable<any>];
}>();
function handleChange(fieldName: string, value: boolean) {
emit('change', { fieldName, value });
}
</script>
<template>
<Form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
<FormItem
class="flex flex-row items-center justify-between rounded-lg border p-4"
>
<div class="space-y-0.5">
<FormLabel class="text-base"> {{ item.label }} </FormLabel>
<FormDescription>
{{ item.description }}
</FormDescription>
</div>
<FormControl>
<Switch
:model-value="item.value"
@update:model-value="handleChange(item.fieldName, $event)"
/>
</FormControl>
</FormItem>
</FormField>
</template>
</div>
</Form>
</template>

View File

@ -0,0 +1,21 @@
import type { BasicUserInfo } from '@vben/types';
export interface Props {
title?: string;
userInfo: BasicUserInfo | null;
tabs: {
label: string;
value: string;
}[];
}
export interface FormSchemaItem {
description: string;
fieldName: string;
label: string;
value: boolean;
}
export interface SettingProps {
formSchema: FormSchemaItem[];
}

View File

@ -30,6 +30,7 @@ describe('fileDownloader', () => {
expect(result).toBeInstanceOf(Blob); expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob); expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, { expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
method: 'GET',
responseType: 'blob', responseType: 'blob',
responseReturn: 'body', responseReturn: 'body',
}); });
@ -51,6 +52,7 @@ describe('fileDownloader', () => {
expect(result).toEqual(mockBlob); expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, { expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
...customConfig, ...customConfig,
method: 'GET',
responseType: 'blob', responseType: 'blob',
responseReturn: 'body', responseReturn: 'body',
}); });
@ -84,3 +86,72 @@ describe('fileDownloader', () => {
); );
}); });
}); });
describe('fileDownloader use other method', () => {
let fileDownloader: FileDownloader;
it('should call request using get', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
const mockAxiosInstance = {
request: vi.fn(),
} as any;
fileDownloader = new FileDownloader(mockAxiosInstance);
mockAxiosInstance.request.mockResolvedValueOnce(mockResponse);
const result = await fileDownloader.download(url);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.request).toHaveBeenCalledWith(url, {
method: 'GET',
responseType: 'blob',
responseReturn: 'body',
});
});
it('should call post', async () => {
const url = 'https://example.com/file';
const mockAxiosInstance = {
post: vi.fn(),
} as any;
fileDownloader = new FileDownloader(mockAxiosInstance);
const customConfig: AxiosRequestConfig = {
method: 'POST',
data: { name: 'aa' },
};
await fileDownloader.download(url, customConfig);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
{ name: 'aa' },
{
method: 'POST',
responseType: 'blob',
responseReturn: 'body',
},
);
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/file';
const mockAxiosInstance = {
post: vi.fn(),
} as any;
fileDownloader = new FileDownloader(mockAxiosInstance);
await expect(() =>
fileDownloader.download(url, { method: 'postt' }),
).rejects.toThrow(
'RequestClient does not support method "POSTT". Please ensure the method is properly implemented in your RequestClient instance.',
);
});
});

View File

@ -28,13 +28,32 @@ class FileDownloader {
): Promise<T> { ): Promise<T> {
const finalConfig: DownloadRequestConfig = { const finalConfig: DownloadRequestConfig = {
responseReturn: 'body', responseReturn: 'body',
method: 'GET',
...config, ...config,
responseType: 'blob', responseType: 'blob',
}; };
const response = await this.client.get<T>(url, finalConfig); // Prefer a generic request if available; otherwise, dispatch to method-specific calls.
const method = (finalConfig.method || 'GET').toUpperCase();
const clientAny = this.client as any;
return response; if (typeof clientAny.request === 'function') {
return await clientAny.request(url, finalConfig);
}
const lower = method.toLowerCase();
if (typeof clientAny[lower] === 'function') {
if (['POST', 'PUT'].includes(method)) {
const { data, ...rest } = finalConfig as Record<string, any>;
return await clientAny[lower](url, data, rest);
}
return await clientAny[lower](url, finalConfig);
}
throw new Error(
`RequestClient does not support method "${method}". Please ensure the method is properly implemented in your RequestClient instance.`,
);
} }
} }

View File

@ -2,6 +2,7 @@
import type { NotificationItem } from '@vben/layouts'; import type { NotificationItem } from '@vben/layouts';
import { computed, onBeforeMount, ref, watch } from 'vue'; import { computed, onBeforeMount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@ -87,6 +88,7 @@ const notifications = ref<NotificationItem[]>([
}, },
]); ]);
const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -96,6 +98,13 @@ const showDot = computed(() =>
); );
const menus = computed(() => [ const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{ {
handler: () => { handler: () => {
openWindow(VBEN_DOC_URL, { openWindow(VBEN_DOC_URL, {

View File

@ -6,7 +6,8 @@
"qrcodeLogin": "Qr Code Login", "qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password", "forgetPassword": "Forget Password",
"sendingCode": "SMS Code is sending...", "sendingCode": "SMS Code is sending...",
"codeSentTo": "Code has been sent to {0}" "codeSentTo": "Code has been sent to {0}",
"profile": "Profile"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",

View File

@ -6,7 +6,8 @@
"qrcodeLogin": "二维码登录", "qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码", "forgetPassword": "忘记密码",
"sendingCode": "正在发送验证码", "sendingCode": "正在发送验证码",
"codeSentTo": "验证码已发送至{0}" "codeSentTo": "验证码已发送至{0}",
"profile": "个人中心"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "概览",

View File

@ -101,6 +101,16 @@ const routes: RouteRecordRaw[] = [
name: 'VbenAbout', name: 'VbenAbout',
path: '/vben-admin/about', path: '/vben-admin/about',
}, },
{
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'lucide:user',
hideInMenu: true,
title: $t('page.auth.profile'),
},
},
]; ];
export default routes; export default routes;

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from 'ant-design-vue';
const profilePasswordSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>