This commit is contained in:
dap
2025-11-18 16:24:31 +08:00
53 changed files with 1091 additions and 76 deletions

View File

@@ -7,6 +7,7 @@
"document": "Document",
"antdv": "Ant Design Vue Version",
"naive-ui": "Naive UI Version",
"element-plus": "Element Plus Version"
"element-plus": "Element Plus Version",
"tdesign": "TDesign Vue Version"
}
}

View File

@@ -5,7 +5,8 @@
"codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password",
"oauthLogin": "Oauth Login"
"oauthLogin": "Oauth Login",
"profile": "Profile"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -7,6 +7,7 @@
"document": "文档",
"antdv": "Ant Design Vue 版本",
"naive-ui": "Naive UI 版本",
"element-plus": "Element Plus 版本"
"element-plus": "Element Plus 版本",
"tdesign": "TDesign Vue 版本"
}
}

View File

@@ -5,7 +5,7 @@
"codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码",
"oauthLogin": "第三方登录"
"profile": "个人中心"
},
"dashboard": {
"title": "概览",

View File

@@ -85,6 +85,16 @@ const routes: RouteRecordRaw[] = [
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;

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,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

@@ -45,6 +45,7 @@
"Qqchat",
"qrcode",
"ruoyi",
"reka",
"shadcn",
"sonner",
"sortablejs",

View File

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

View File

@@ -60,6 +60,8 @@ The execution command is: `pnpm run [script]` or `npm run [script]`.
"build:ele": "pnpm run build --filter=@vben/web-ele",
// Build the web-naive application separately
"build:naive": "pnpm run build --filter=@vben/naive",
// Build the web-tdesign application separately
"build:tdesign": "pnpm run build --filter=@vben/web-tdesign",
// Build the playground application separately
"build:play": "pnpm run build --filter=@vben/playground",
// Changeset version management

View File

@@ -56,6 +56,7 @@ After slimming down, you may need to adjust commands according to your project.
"build:docs": "pnpm run build --filter=@vben/docs",
"build:ele": "pnpm run build --filter=@vben/web-ele",
"build:naive": "pnpm run build --filter=@vben/web-naive",
"build:tdesign": "pnpm run build --filter=@vben/web-tdesign",
"build:play": "pnpm run build --filter=@vben/playground",
"dev:antd": "pnpm -F @vben/web-antd run dev",
"dev:docs": "pnpm -F @vben/docs run dev",

View File

@@ -60,6 +60,8 @@ npm 脚本是项目常见的配置,用于执行一些常见的任务,比如
"build:ele": "pnpm run build --filter=@vben/web-ele",
// 单独构建 web-naive 应用
"build:naive": "pnpm run build --filter=@vben/naive",
// 单独构建 web-tdesign 应用
"build:tdesign": "pnpm run build --filter=@vben/web-tdesign",
// 单独构建 playground 应用
"build:play": "pnpm run build --filter=@vben/playground",
// changeset 版本管理

View File

@@ -60,6 +60,7 @@ pnpm install
"build:docs": "pnpm run build --filter=@vben/docs",
"build:ele": "pnpm run build --filter=@vben/web-ele",
"build:naive": "pnpm run build --filter=@vben/web-naive",
"build:tdesign": "pnpm run build --filter=@vben/web-tdesign",
"build:play": "pnpm run build --filter=@vben/playground",
"dev:antd": "pnpm -F @vben/web-antd run dev",
"dev:docs": "pnpm -F @vben/docs run dev",

View File

@@ -176,18 +176,18 @@ export default {
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
to: { height: 'var(--reka-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
from: { height: 'var(--reka-accordion-content-height)' },
to: { height: '0' },
},
'collapsible-down': {
from: { height: '0' },
to: { height: 'var(--radix-collapsible-content-height)' },
to: { height: 'var(--reka-collapsible-content-height)' },
},
'collapsible-up': {
from: { height: 'var(--radix-collapsible-content-height)' },
from: { height: 'var(--reka-collapsible-content-height)' },
to: { height: '0' },
},
float: {

View File

@@ -88,7 +88,7 @@
"unbuild": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vue": "catalog:",
"vue": "^3.5.24",
"vue-tsc": "catalog:"
},
"engines": {
@@ -108,7 +108,8 @@
"clsx": "catalog:",
"esbuild": "0.25.3",
"pinia": "catalog:",
"vue": "catalog:"
"vue": "catalog:",
"jiti": "^2.6.1"
},
"neverBuiltDependencies": [
"canvas",

View File

@@ -85,10 +85,8 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"defu": "catalog:",
"es-toolkit": "catalog:",
"lodash.clonedeep": "catalog:",
"lodash.get": "catalog:",
"lodash.isequal": "catalog:",
"lodash.set": "catalog:",
"nprogress": "catalog:",
"tailwind-merge": "catalog:",
"theme-colors": "catalog:"

View File

@@ -24,3 +24,5 @@ export const VBEN_ELE_PREVIEW_URL = 'https://ele.vben.pro';
export const VBEN_NAIVE_PREVIEW_URL = 'https://naive.vben.pro';
export const VBEN_ANT_PREVIEW_URL = 'https://ant.vben.pro';
export const VBEN_TD_PREVIEW_URL = 'https://tdesign.vben.pro';

View File

@@ -7,7 +7,19 @@ dayjs.extend(timezone);
type FormatDate = Date | dayjs.Dayjs | number | string;
export function formatDate(time: FormatDate, format = 'YYYY-MM-DD') {
type Format =
| 'HH'
| 'HH:mm'
| 'HH:mm:ss'
| 'YYYY'
| 'YYYY-MM'
| 'YYYY-MM-DD'
| 'YYYY-MM-DD HH'
| 'YYYY-MM-DD HH:mm'
| 'YYYY-MM-DD HH:mm:ss'
| (string & {});
export function formatDate(time?: FormatDate, format: Format = 'YYYY-MM-DD') {
try {
const date = dayjs.isDayjs(time) ? time : dayjs(time);
if (!date.isValid()) {
@@ -16,11 +28,11 @@ export function formatDate(time: FormatDate, format = 'YYYY-MM-DD') {
return date.tz().format(format);
} catch (error) {
console.error(`Error formatting date: ${error}`);
return String(time);
return String(time ?? '');
}
}
export function formatDateTime(time: FormatDate) {
export function formatDateTime(time?: FormatDate) {
return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
}

View File

@@ -15,7 +15,6 @@ export * from './unique';
export * from './update-css-variables';
export * from './util';
export * from './window';
export { get, isEqual, set } from 'es-toolkit/compat';
// export { cloneDeep } from 'es-toolkit/object';
export { default as cloneDeep } from 'lodash.clonedeep';
export { default as get } from 'lodash.get';
export { default as isEqual } from 'lodash.isequal';
export { default as set } from 'lodash.set';

View File

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

View File

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

View File

@@ -379,6 +379,10 @@ export interface VbenFormProps<
* 表单字段映射
*/
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 { getDefaultsForSchema } from 'zod-defaults';
type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi };
type ExtendFormProps = VbenFormProps & { formApi?: ExtendedFormApi };
export const [injectFormProps, provideFormProps] =
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(

View File

@@ -40,7 +40,9 @@ const { delegatedSlots, form } = useFormInitial(props);
provideFormProps([props, form]);
const handleUpdateCollapsed = (value: boolean) => {
currentCollapsed.value = !!value;
currentCollapsed.value = value;
// 触发收起展开状态变化回调
props.handleCollapsedChange?.(value);
};
watchEffect(() => {

View File

@@ -25,7 +25,7 @@ import {
} from './use-form-context';
// 通过 extends 会导致热更新卡死,所以重复写了一遍
interface Props extends VbenFormProps {
formApi: ExtendedFormApi;
formApi?: ExtendedFormApi;
}
const props = defineProps<Props>();
@@ -44,11 +44,13 @@ provideComponentRefMap(componentRefMap);
props.formApi?.mount?.(form, componentRefMap);
const handleUpdateCollapsed = (value: boolean) => {
props.formApi?.setState({ collapsed: !!value });
props.formApi?.setState({ collapsed: value });
// 触发收起展开状态变化回调
forward.value.handleCollapsedChange?.(value);
};
function handleKeyDownEnter(event: KeyboardEvent) {
if (!state.value.submitOnEnter || !forward.value.formApi?.isMounted) {
if (!state?.value.submitOnEnter || !forward.value.formApi?.isMounted) {
return;
}
// 如果是 textarea 不阻止默认行为,否则会导致无法换行。
@@ -58,11 +60,11 @@ function handleKeyDownEnter(event: KeyboardEvent) {
}
event.preventDefault();
forward.value.formApi.validateAndSubmitForm();
forward.value.formApi?.validateAndSubmitForm();
}
const handleValuesChangeDebounced = useDebounceFn(async () => {
state.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
}, 300);
const valuesCache: Recordable<any> = {};
@@ -74,7 +76,7 @@ onMounted(async () => {
() => form.values,
async (newVal) => {
if (forward.value.handleValuesChange) {
const fields = state.value.schema?.map((item) => {
const fields = state?.value.schema?.map((item) => {
return item.fieldName;
});
@@ -91,8 +93,9 @@ onMounted(async () => {
if (changedFields.length > 0) {
// 调用handleValuesChange回调传入所有表单值的深拷贝和变更的字段列表
const values = await forward.value.formApi?.getValues();
forward.value.handleValuesChange(
cloneDeep(await forward.value.formApi.getValues()),
cloneDeep(values ?? {}) as Record<string, any>,
changedFields,
);
}
@@ -109,7 +112,7 @@ onMounted(async () => {
<Form
@keydown.enter="handleKeyDownEnter"
v-bind="forward"
:collapsed="state.collapsed"
:collapsed="state?.collapsed"
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
:component-map="COMPONENT_MAP"
:form="form"
@@ -126,7 +129,7 @@ onMounted(async () => {
<slot v-bind="slotProps">
<FormActions
v-if="forward.showDefaultActions"
:model-value="state.collapsed"
:model-value="state?.collapsed"
@update:model-value="handleUpdateCollapsed"
>
<template #reset-before="resetSlotProps">

View File

@@ -13,7 +13,7 @@ export interface VbenButtonProps {
/**
* Change the default rendered element for the one passed as a child, merging their props and behavior.
*
* Read our [Composition](https://www.radix-vue.com/guides/composition.html) guide for more details.
* Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details.
*/
asChild?: boolean;
class?: any;

View File

@@ -2,3 +2,4 @@ export * from './about';
export * from './authentication';
export * from './dashboard';
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

@@ -1,4 +1,5 @@
interface NotificationItem {
id: number | string;
avatar: string;
date: string;
isRead?: boolean;

View File

@@ -30,6 +30,7 @@ describe('fileDownloader', () => {
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
method: 'GET',
responseType: 'blob',
responseReturn: 'body',
});
@@ -51,6 +52,7 @@ describe('fileDownloader', () => {
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
...customConfig,
method: 'GET',
responseType: 'blob',
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> {
const finalConfig: DownloadRequestConfig = {
responseReturn: 'body',
method: 'GET',
...config,
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

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<rect id="b1" x="0" y="0" width="32" height="32" style="fill:none;"/>
<g id="b11" serif:id="b1">
<g>
<g transform="matrix(1.155236,0,0,1.133743,3.49525,-0.501586)">
<path d="M7.972,26.181L3.28,26.181C3.203,26.181 3.126,26.165 3.055,26.132C2.984,26.1 2.921,26.053 2.87,25.994C2.819,25.936 2.782,25.867 2.76,25.792C2.737,25.718 2.732,25.639 2.742,25.562L3.656,20.403L9.441,20.403L8.498,25.748C8.473,25.869 8.407,25.978 8.311,26.057C8.216,26.136 8.096,26.18 7.972,26.181Z" style="fill:rgb(0,155,255);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.155236,0,0,1.133743,3.49525,-0.501586)">
<clipPath id="_clip1">
<path d="M21.178,8.787L5.698,8.787L6.716,3.002L21.988,3.002C22.071,2.994 22.156,3.006 22.234,3.037C22.312,3.067 22.382,3.115 22.439,3.178C22.495,3.24 22.536,3.315 22.558,3.396C22.58,3.477 22.583,3.562 22.566,3.645L21.71,8.353C21.687,8.477 21.621,8.588 21.523,8.667C21.426,8.747 21.303,8.789 21.178,8.787Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(0.865624,-0,-0,0.882034,-3.025571,0.442416)">
<use xlink:href="#_Image2" x="10.078" y="2.9" width="20px" height="7px"/>
</g>
</g>
</g>
<g transform="matrix(1.155236,0,0,1.133743,3.49525,-0.501586)">
<path d="M5.698,8.787L0.55,8.787C0.471,8.788 0.393,8.772 0.321,8.739C0.249,8.707 0.185,8.66 0.134,8.6C0.082,8.541 0.044,8.471 0.022,8.395C-0,8.32 -0.006,8.24 0.006,8.162L0.845,3.448C0.868,3.323 0.933,3.211 1.031,3.129C1.128,3.048 1.25,3.003 1.377,3.002L6.716,3.002L5.698,8.787Z" style="fill:rgb(0,100,255);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.155236,0,0,1.133743,3.49525,-0.501586)">
<clipPath id="_clip3">
<path d="M9.447,20.385L3.65,20.385L5.698,8.799L11.494,8.799L9.447,20.385Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip3)">
<g transform="matrix(0.865624,-0,-0,0.882034,-3.025571,0.442416)">
<use xlink:href="#_Image4" x="7.712" y="9.474" width="10px" height="14px"/>
</g>
</g>
</g>
</g>
</g>
<defs>
<image id="_Image2" width="20px" height="7px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAHCAYAAAAIy204AAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAsUlEQVQokW2R267CMAwEB/7/RxGoXFrKoSS7PMQJrnQSrZKn8dg+nGwDLJq468zsC7MmFk8svrLqxtN3Vj14eeHPM2+vvL2y8eLjjeINUTHiCGBMpSAK1RURcX6FaW/7i+bS084RaKCIelwCrKhef6AB7Zc9sLpZZLvK3i7DesBhybBswJ1dAqcCRsjdK9s5hpZbdkkzLAMyYO6w+O9w+t9Qzgspv1bTQka7jnYDlqf4BQuSDbEWXdA2AAAAAElFTkSuQmCC"/>
<image id="_Image4" width="10px" height="14px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAOCAYAAAAWo42rAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAR0lEQVQokZ2PQQrAIAwEx5L/3/ys2qbXglRGc1sYZjeFmom4oBkMgqaEO8ZuwfFJixVBtxuPqhf1M/hrfASVENzW6J7mchi89l8Tj8VznSAAAAAASUVORK5CYII="/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -29,11 +29,15 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
baseURL,
transformResponse: (data: any, header: AxiosResponseHeaders) => {
// storeAsString指示将BigInt存储为字符串设为false则会存储为内置的BigInt类型
return header.getContentType()?.toString().includes('application/json')
? cloneDeep(
JSONBigInt({ storeAsString: true, strict: true }).parse(data),
)
: data;
if (
header.getContentType()?.toString().includes('application/json') &&
typeof data === 'string'
) {
return cloneDeep(
JSONBigInt({ storeAsString: true, strict: true }).parse(data),
);
}
return data;
},
});

View File

@@ -2,6 +2,7 @@
import type { NotificationItem } from '@vben/layouts';
import { computed, onBeforeMount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@@ -36,6 +37,7 @@ setMenuList([
const notifications = ref<NotificationItem[]>([
{
id: 1,
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
@@ -43,6 +45,7 @@ const notifications = ref<NotificationItem[]>([
title: '收到了 14 份新周报',
},
{
id: 2,
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
@@ -50,6 +53,7 @@ const notifications = ref<NotificationItem[]>([
title: '朱偏右 回复了你',
},
{
id: 3,
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
@@ -57,14 +61,34 @@ const notifications = ref<NotificationItem[]>([
title: '曲丽丽 评论了你',
},
{
id: 4,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
{
id: 5,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转Workspace示例',
link: '/workspace',
},
{
id: 6,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转外部链接示例',
link: 'https://doc.vben.pro',
},
]);
const router = useRouter();
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
@@ -74,6 +98,13 @@ const showDot = computed(() =>
);
const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{
handler: () => {
openWindow(VBEN_DOC_URL, {
@@ -115,6 +146,17 @@ function handleNoticeClear() {
notifications.value = [];
}
function markRead(id: number | string) {
const item = notifications.value.find((item) => item.id === id);
if (item) {
item.isRead = true;
}
}
function remove(id: number | string) {
notifications.value = notifications.value.filter((item) => item.id !== id);
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
}
@@ -170,6 +212,8 @@ onBeforeMount(() => {
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll"
/>
</template>

View File

@@ -65,6 +65,7 @@
"document": "Document",
"antdv": "Ant Design Vue Version",
"naive-ui": "Naive UI Version",
"element-plus": "Element Plus Version"
"element-plus": "Element Plus Version",
"tdesign": "TDesign Vue Version"
}
}

View File

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

View File

@@ -66,6 +66,7 @@
"document": "文档",
"antdv": "Ant Design Vue 版本",
"naive-ui": "Naive UI 版本",
"element-plus": "Element Plus 版本"
"element-plus": "Element Plus 版本",
"tdesign": "TDesign Vue 版本"
}
}

View File

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

View File

@@ -7,8 +7,9 @@ import {
VBEN_GITHUB_URL,
VBEN_LOGO_URL,
VBEN_NAIVE_PREVIEW_URL,
VBEN_TD_PREVIEW_URL,
} from '@vben/constants';
import { SvgAntdvLogoIcon } from '@vben/icons';
import { SvgAntdvLogoIcon, SvgTDesignIcon } from '@vben/icons';
import { IFrameView } from '#/layouts';
import { $t } from '#/locales';
@@ -77,6 +78,17 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.vben.element-plus'),
},
},
{
name: 'VbenTDesign',
path: '/vben-admin/tdesign',
component: IFrameView,
meta: {
badgeType: 'dot',
icon: SvgTDesignIcon,
link: VBEN_TD_PREVIEW_URL,
title: $t('demos.vben.tdesign'),
},
},
],
},
{
@@ -89,6 +101,16 @@ const routes: RouteRecordRaw[] = [
name: 'VbenAbout',
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;

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

@@ -14,17 +14,17 @@ packages:
- playground
catalog:
'@ast-grep/napi': ^0.37.0
'@ast-grep/napi': ^0.39.9
'@changesets/changelog-github': ^0.5.1
'@changesets/cli': ^2.29.5
'@changesets/cli': ^2.29.7
'@changesets/git': ^3.0.4
'@clack/prompts': ^0.10.1
'@clack/prompts': ^0.11.0
'@commitlint/cli': ^19.8.1
'@commitlint/config-conventional': ^19.8.1
'@ctrl/tinycolor': ^4.1.0
'@eslint/js': ^9.30.1
'@faker-js/faker': ^9.9.0
'@iconify/json': ^2.2.354
'@iconify/json': ^2.2.406
'@iconify/tailwind': ^1.2.0
'@iconify/vue': ^5.0.0
'@intlify/core-base': ^11.1.7
@@ -32,13 +32,13 @@ catalog:
'@jspm/generator': ^2.6.2
'@manypkg/get-packages': ^3.0.0
'@nolebase/vitepress-plugin-git-changelog': ^2.18.0
'@playwright/test': ^1.53.2
'@pnpm/workspace.read-manifest': ^1000.2.0
'@playwright/test': ^1.56.1
'@pnpm/workspace.read-manifest': ^1000.2.6
'@stylistic/stylelint-plugin': ^3.1.3
'@tailwindcss/nesting': 0.0.0-insiders.565cd3e
'@tailwindcss/typography': ^0.5.16
'@tanstack/vue-query': ^5.81.5
'@tanstack/vue-store': ^0.7.1
'@tanstack/vue-query': ^5.91.0
'@tanstack/vue-store': ^0.8.0
'@types/archiver': ^6.0.3
'@types/eslint': ^9.6.1
'@types/html-minifier-terser': ^7.0.2
@@ -54,21 +54,21 @@ catalog:
'@types/qrcode': ^1.5.5
'@types/qs': ^6.14.0
'@types/sortablejs': ^1.15.8
'@typescript-eslint/eslint-plugin': ^8.35.1
'@typescript-eslint/parser': ^8.35.1
'@typescript-eslint/eslint-plugin': ^8.46.4
'@typescript-eslint/parser': ^8.46.4
'@vee-validate/zod': ^4.15.1
'@vite-pwa/vitepress': ^1.0.0
'@vitejs/plugin-vue': ^6.0.1
'@vitejs/plugin-vue-jsx': ^5.0.1
'@vitejs/plugin-vue-jsx': ^5.1.1
'@vue/reactivity': ^3.5.17
'@vue/shared': ^3.5.17
'@vue/shared': ^3.5.24
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^13.4.0
'@vueuse/integrations': ^14.0.0
'@vueuse/motion': ^3.0.3
ant-design-vue: ^4.2.6
archiver: ^7.0.1
autoprefixer: ^10.4.21
autoprefixer: ^10.4.22
axios: ^1.10.0
axios-mock-adapter: ^2.1.0
cac: ^6.7.14
@@ -77,7 +77,7 @@ catalog:
circular-dependency-scanner: ^2.3.0
class-variance-authority: ^0.7.1
clsx: ^2.1.1
commitlint-plugin-function-rules: ^4.0.2
commitlint-plugin-function-rules: ^4.1.1
consola: ^3.4.2
cross-env: ^7.0.3
cspell: ^8.19.4
@@ -88,10 +88,10 @@ catalog:
defu: ^6.1.4
depcheck: ^1.4.7
dotenv: ^16.6.1
echarts: ^5.6.0
echarts: ^6.0.0
element-plus: ^2.10.2
eslint: ^9.30.1
eslint-config-turbo: ^2.5.4
eslint-config-turbo: ^2.6.1
eslint-plugin-command: ^3.3.1
eslint-plugin-eslint-comments: ^3.2.0
eslint-plugin-import-x: ^4.16.1
@@ -122,7 +122,7 @@ catalog:
lodash.get: ^4.4.2
lodash.isequal: ^4.5.0
lodash.set: ^4.3.2
lucide-vue-next: ^0.507.0
lucide-vue-next: ^0.553.0
medium-zoom: ^1.1.0
naive-ui: ^2.42.0
nitropack: ^2.11.13
@@ -131,7 +131,7 @@ catalog:
pinia: ^3.0.3
pinia-plugin-persistedstate: ^4.4.1
pkg-types: ^2.2.0
playwright: ^1.53.2
playwright: ^1.56.1
postcss: ^8.5.6
postcss-antd-fixes: ^0.2.0
postcss-html: ^1.8.0
@@ -139,21 +139,21 @@ catalog:
postcss-preset-env: ^10.2.4
postcss-scss: ^4.0.9
prettier: ^3.6.2
prettier-plugin-tailwindcss: ^0.6.13
prettier-plugin-tailwindcss: ^0.7.1
publint: ^0.3.12
qrcode: ^1.5.4
qs: ^6.14.0
reka-ui: ^2.6.0
resolve.exports: ^2.0.3
rimraf: ^6.0.1
rimraf: ^6.1.0
rollup: ^4.44.1
rollup-plugin-visualizer: ^5.14.0
sass: ^1.89.2
sass: ^1.94.0
secure-ls: ^2.0.0
sortablejs: ^1.15.6
stylelint: ^16.21.0
stylelint-config-recess-order: ^6.1.0
stylelint-config-recommended: ^16.0.0
stylelint-config-recommended: ^17.0.0
stylelint-config-recommended-scss: ^14.1.0
stylelint-config-recommended-vue: ^1.6.1
stylelint-config-standard: ^38.0.0
@@ -161,16 +161,16 @@ catalog:
stylelint-prettier: ^5.0.3
stylelint-scss: ^6.12.1
tailwind-merge: ^2.6.0
tailwindcss: ^3.4.17
tailwindcss: ^3.4.18
tailwindcss-animate: ^1.0.7
theme-colors: ^0.1.0
tippy.js: ^6.3.7
turbo: ^2.5.4
typescript: ^5.8.3
turbo: ^2.6.1
typescript: ^5.9.3
unbuild: ^3.6.1
unplugin-element-plus: ^0.10.0
unplugin-element-plus: ^0.11.1
vee-validate: ^4.15.1
vite: ^7.1.2
vite: ^7.2.2
vite-plugin-compression: ^0.5.1
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
@@ -180,15 +180,16 @@ catalog:
vitepress: ^1.6.3
vitepress-plugin-group-icons: ^1.6.1
vitest: ^3.2.4
vue: ^3.5.17
vue: ^3.5.24
vue-eslint-parser: ^10.2.0
vue-i18n: ^11.1.7
vue-json-viewer: ^3.0.4
vue-router: ^4.5.1
vue-tippy: ^6.7.1
vue-tsc: 2.2.10
vxe-pc-ui: ^4.9.29
vxe-table: ^4.16.11
vxe-pc-ui: ^4.10.22
vxe-table: ^4.17.14
watermark-js-plus: ^1.6.2
zod: ^3.25.67
zod-defaults: ^0.1.3
zod-defaults: 0.1.3
es-toolkit: ^1.41.0