Merge remote-tracking branch 'vben/main' into vben-dev

# Conflicts:
#	.gitignore
#	.husky/pre-commit
#	.vscode/extensions.json
#	.vscode/settings.json
#	README.ja-JP.md
#	README.md
#	README.zh-CN.md
#	apps/backend-mock/api/auth/codes.ts
#	apps/backend-mock/api/auth/login.post.ts
#	apps/backend-mock/api/auth/logout.post.ts
#	apps/backend-mock/api/auth/refresh.post.ts
#	apps/backend-mock/api/menu/all.ts
#	apps/backend-mock/api/status.ts
#	apps/backend-mock/api/table/list.ts
#	apps/backend-mock/api/test.get.ts
#	apps/backend-mock/api/test.post.ts
#	apps/backend-mock/api/user/info.ts
#	apps/backend-mock/middleware/1.api.ts
#	apps/backend-mock/nitro.config.ts
#	apps/backend-mock/package.json
#	apps/backend-mock/routes/[...].ts
#	apps/backend-mock/tsconfig.json
#	apps/backend-mock/utils/cookie-utils.ts
#	apps/backend-mock/utils/jwt-utils.ts
#	apps/backend-mock/utils/mock-data.ts
#	apps/backend-mock/utils/response.ts
#	apps/web-antd/.env
#	apps/web-antd/src/adapter/component/index.ts
#	apps/web-antd/src/adapter/form.ts
#	apps/web-antd/src/api/request.ts
#	apps/web-antd/src/layouts/auth.vue
#	apps/web-antd/src/layouts/basic.vue
#	apps/web-antd/src/preferences.ts
#	apps/web-antd/src/router/guard.ts
#	apps/web-antd/src/router/routes/modules/demos.ts
#	apps/web-antd/src/store/auth.ts
#	apps/web-antd/src/views/demos/antd/index.vue
#	apps/web-antdv-next/vite.config.ts
#	apps/web-ele/.env
#	apps/web-ele/index.html
#	apps/web-ele/package.json
#	apps/web-ele/src/adapter/component/index.ts
#	apps/web-ele/src/adapter/form.ts
#	apps/web-ele/src/adapter/vxe-table.ts
#	apps/web-ele/src/api/request.ts
#	apps/web-ele/src/bootstrap.ts
#	apps/web-ele/src/layouts/auth.vue
#	apps/web-ele/src/layouts/basic.vue
#	apps/web-ele/src/locales/index.ts
#	apps/web-ele/src/locales/langs/en-US/demos.json
#	apps/web-ele/src/locales/langs/en-US/page.json
#	apps/web-ele/src/locales/langs/zh-CN/demos.json
#	apps/web-ele/src/locales/langs/zh-CN/page.json
#	apps/web-ele/src/preferences.ts
#	apps/web-ele/src/router/guard.ts
#	apps/web-ele/src/router/routes/core.ts
#	apps/web-ele/src/router/routes/modules/dashboard.ts
#	apps/web-ele/src/router/routes/modules/demos.ts
#	apps/web-ele/src/router/routes/modules/vben.ts
#	apps/web-ele/src/store/auth.ts
#	apps/web-ele/src/views/_core/authentication/code-login.vue
#	apps/web-ele/src/views/_core/authentication/forget-password.vue
#	apps/web-ele/src/views/_core/authentication/register.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-trends.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-data.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-sales.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-source.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits.vue
#	apps/web-ele/src/views/dashboard/analytics/index.vue
#	apps/web-ele/src/views/demos/element/index.vue
#	apps/web-ele/src/views/demos/form/basic.vue
#	apps/web-ele/tsconfig.json
#	apps/web-ele/tsconfig.node.json
#	apps/web-naive/.env
#	apps/web-naive/index.html
#	apps/web-naive/package.json
#	apps/web-naive/src/adapter/component/index.ts
#	apps/web-naive/src/adapter/form.ts
#	apps/web-naive/src/adapter/vxe-table.ts
#	apps/web-naive/src/api/request.ts
#	apps/web-naive/src/bootstrap.ts
#	apps/web-naive/src/layouts/auth.vue
#	apps/web-naive/src/layouts/basic.vue
#	apps/web-naive/src/locales/index.ts
#	apps/web-naive/src/locales/langs/en-US/demos.json
#	apps/web-naive/src/locales/langs/en-US/page.json
#	apps/web-naive/src/locales/langs/zh-CN/demos.json
#	apps/web-naive/src/locales/langs/zh-CN/page.json
#	apps/web-naive/src/preferences.ts
#	apps/web-naive/src/router/guard.ts
#	apps/web-naive/src/router/routes/core.ts
#	apps/web-naive/src/router/routes/modules/dashboard.ts
#	apps/web-naive/src/router/routes/modules/demos.ts
#	apps/web-naive/src/router/routes/modules/vben.ts
#	apps/web-naive/src/store/auth.ts
#	apps/web-naive/src/views/_core/authentication/code-login.vue
#	apps/web-naive/src/views/_core/authentication/forget-password.vue
#	apps/web-naive/src/views/_core/authentication/register.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-trends.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-data.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-sales.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-source.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits.vue
#	apps/web-naive/src/views/dashboard/analytics/index.vue
#	apps/web-naive/src/views/demos/form/basic.vue
#	apps/web-naive/src/views/demos/naive/index.vue
#	apps/web-naive/tsconfig.json
#	apps/web-naive/tsconfig.node.json
#	apps/web-naive/vite.config.ts
#	docs/.vitepress/components/demo-preview.vue
#	docs/.vitepress/components/preview-group.vue
#	docs/.vitepress/config/en.mts
#	docs/.vitepress/config/index.mts
#	docs/.vitepress/config/plugins/demo-preview.ts
#	docs/.vitepress/config/shared.mts
#	docs/.vitepress/config/zh.mts
#	docs/.vitepress/theme/components/site-layout.vue
#	docs/.vitepress/theme/components/vben-contributors.vue
#	docs/.vitepress/theme/styles/variables.css
#	docs/package.json
#	docs/src/_env/adapter/component.ts
#	docs/src/_env/adapter/form.ts
#	docs/src/_env/adapter/vxe-table.ts
#	docs/src/commercial/community.md
#	docs/src/components/common-ui/vben-count-to-animator.md
#	docs/src/components/common-ui/vben-drawer.md
#	docs/src/components/common-ui/vben-form.md
#	docs/src/components/common-ui/vben-modal.md
#	docs/src/components/common-ui/vben-vxe-table.md
#	docs/src/components/layout-ui/page.md
#	docs/src/demos/vben-drawer/auto-height/drawer.vue
#	docs/src/demos/vben-drawer/basic/index.vue
#	docs/src/demos/vben-drawer/dynamic/index.vue
#	docs/src/demos/vben-drawer/shared-data/drawer.vue
#	docs/src/demos/vben-drawer/shared-data/index.vue
#	docs/src/demos/vben-form/api/index.vue
#	docs/src/demos/vben-form/basic/index.vue
#	docs/src/demos/vben-form/custom/index.vue
#	docs/src/demos/vben-form/dynamic/index.vue
#	docs/src/demos/vben-form/query/index.vue
#	docs/src/demos/vben-form/rules/index.vue
#	docs/src/demos/vben-modal/auto-height/modal.vue
#	docs/src/demos/vben-modal/basic/index.vue
#	docs/src/demos/vben-modal/dynamic/index.vue
#	docs/src/demos/vben-modal/shared-data/index.vue
#	docs/src/demos/vben-modal/shared-data/modal.vue
#	docs/src/demos/vben-vxe-table/basic/index.vue
#	docs/src/demos/vben-vxe-table/custom-cell/index.vue
#	docs/src/demos/vben-vxe-table/edit-row/index.vue
#	docs/src/demos/vben-vxe-table/fixed/index.vue
#	docs/src/demos/vben-vxe-table/form/index.vue
#	docs/src/demos/vben-vxe-table/remote/index.vue
#	docs/src/demos/vben-vxe-table/tree/index.vue
#	docs/src/demos/vben-vxe-table/virtual/index.vue
#	docs/src/en/guide/essentials/development.md
#	docs/src/en/guide/essentials/external-module.md
#	docs/src/en/guide/essentials/route.md
#	docs/src/en/guide/essentials/server.md
#	docs/src/en/guide/essentials/settings.md
#	docs/src/en/guide/in-depth/access.md
#	docs/src/en/guide/in-depth/loading.md
#	docs/src/en/guide/in-depth/locale.md
#	docs/src/en/guide/in-depth/theme.md
#	docs/src/en/guide/introduction/quick-start.md
#	docs/src/en/guide/introduction/thin.md
#	docs/src/en/guide/introduction/vben.md
#	docs/src/en/guide/other/faq.md
#	docs/src/en/guide/project/dir.md
#	docs/src/en/guide/project/standard.md
#	docs/src/en/guide/project/tailwindcss.md
#	docs/src/en/index.md
#	docs/src/friend-links/index.md
#	docs/src/guide/essentials/build.md
#	docs/src/guide/essentials/development.md
#	docs/src/guide/essentials/external-module.md
#	docs/src/guide/essentials/route.md
#	docs/src/guide/essentials/server.md
#	docs/src/guide/essentials/settings.md
#	docs/src/guide/in-depth/access.md
#	docs/src/guide/in-depth/check-updates.md
#	docs/src/guide/in-depth/loading.md
#	docs/src/guide/in-depth/locale.md
#	docs/src/guide/in-depth/theme.md
#	docs/src/guide/introduction/quick-start.md
#	docs/src/guide/introduction/thin.md
#	docs/src/guide/introduction/vben.md
#	docs/src/guide/introduction/why.md
#	docs/src/guide/other/faq.md
#	docs/src/guide/project/dir.md
#	docs/src/guide/project/standard.md
#	docs/src/guide/project/tailwindcss.md
#	docs/src/index.md
#	docs/tsconfig.json
#	package.json
#	packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
#	packages/constants/src/core.ts
#	packages/effects/common-ui/src/components/index.ts
#	packages/effects/common-ui/src/ui/dashboard/analysis/analysis-overview.vue
#	packages/effects/common-ui/src/ui/dashboard/workbench/workbench-header.vue
#	packages/effects/common-ui/src/ui/dashboard/workbench/workbench-project.vue
#	packages/effects/layouts/src/authentication/authentication.vue
#	packages/effects/layouts/src/basic/layout.vue
#	packages/effects/layouts/src/widgets/notification/notification.vue
#	packages/effects/layouts/src/widgets/notification/types.ts
#	packages/icons/src/iconify/index.ts
#	packages/icons/src/svg/index.ts
#	packages/stores/src/modules/user.ts
#	playground/src/adapter/component/index.ts
#	playground/src/store/auth.ts
#	pnpm-lock.yaml
#	vben-admin.code-workspace
This commit is contained in:
李琦
2026-08-10 16:55:17 +08:00
1278 changed files with 71754 additions and 19298 deletions

View File

@@ -3,13 +3,184 @@
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { BaseFormComponentType } from '@vben/common-ui';
/* eslint-disable vue/one-component-per-file */
import type { Component, SetupContext } from 'vue';
import { h } from 'vue';
import type {
AutoCompleteProps,
ButtonProps,
CascaderProps,
CheckboxGroupProps,
CheckboxProps,
DatePickerProps,
DividerProps,
InputNumberProps,
InputProps,
MentionsProps,
RadioGroupProps,
RadioProps,
RangePickerProps,
RateProps,
SelectProps,
SpaceProps,
SwitchProps,
TextAreaProps,
TimePickerProps,
TreeSelectProps,
UploadChangeParam,
UploadFile,
UploadProps,
} from 'antdv-next';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import type { Component, Ref } from 'vue';
import type {
ApiComponentSharedProps,
BaseFormComponentType,
CollapsibleParamsProps,
IconPickerProps,
} from '@vben/common-ui';
import type { Sortable } from '@vben/hooks';
import type { TipTapProps } from '@vben/plugins/tiptap';
import type { Recordable } from '@vben/types';
import {
computed,
defineAsyncComponent,
defineComponent,
h,
nextTick,
onMounted,
onUnmounted,
ref,
render,
unref,
watch,
} from 'vue';
import {
ApiComponent,
globalShareState,
IconPicker,
VbenCollapsibleParams,
VCropper,
} from '@vben/common-ui';
import { useSortable } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { VbenTiptap } from '@vben/plugins/tiptap';
import { isEmpty } from '@vben/utils';
import { message, Modal, notification } from 'antdv-next';
import { upload_file } from '#/api/examples/upload';
type AdapterUploadProps = UploadProps & {
aspectRatio?: string;
crop?: boolean;
draggable?: boolean;
handleChange?: (event: UploadChangeParam) => void;
maxSize?: number;
onDragSort?: (oldIndex: number, newIndex: number) => void;
onHandleChange?: (event: UploadChangeParam) => void;
};
const AutoComplete = defineAsyncComponent(
() => import('antdv-next/dist/auto-complete/index'),
);
const Button = defineAsyncComponent(
() => import('antdv-next/dist/button/index'),
);
const Checkbox = defineAsyncComponent(
() => import('antdv-next/dist/checkbox/index'),
);
const CheckboxGroup = defineAsyncComponent(() =>
import('antdv-next/dist/checkbox/index').then((res) => res.CheckboxGroup),
);
const DatePicker = defineAsyncComponent(
() => import('antdv-next/dist/date-picker/index'),
);
const Divider = defineAsyncComponent(
() => import('antdv-next/dist/divider/index'),
);
const Input = defineAsyncComponent(() => import('antdv-next/dist/input/index'));
const InputNumber = defineAsyncComponent(
() => import('antdv-next/dist/input-number/index'),
);
const InputPassword = defineAsyncComponent(() =>
import('antdv-next/dist/input/index').then((res) => res.InputPassword),
);
const Mentions = defineAsyncComponent(
() => import('antdv-next/dist/mentions/index'),
);
const Radio = defineAsyncComponent(() => import('antdv-next/dist/radio/index'));
const RadioGroup = defineAsyncComponent(() =>
import('antdv-next/dist/radio/index').then((res) => res.RadioGroup),
);
const RangePicker = defineAsyncComponent(() =>
import('antdv-next/dist/date-picker/index').then(
(res) => res.DateRangePicker,
),
);
const Rate = defineAsyncComponent(() => import('antdv-next/dist/rate/index'));
const Select = defineAsyncComponent(
() => import('antdv-next/dist/select/index'),
);
const Space = defineAsyncComponent(() => import('antdv-next/dist/space/index'));
const Switch = defineAsyncComponent(
() => import('antdv-next/dist/switch/index'),
);
const Textarea = defineAsyncComponent(
() => import('antdv-next/dist/input/TextArea'),
);
const TimePicker = defineAsyncComponent(
() => import('antdv-next/dist/time-picker/index'),
);
const TreeSelect = defineAsyncComponent(
() => import('antdv-next/dist/tree-select/index'),
);
const Cascader = defineAsyncComponent(
() => import('antdv-next/dist/cascader/index'),
);
const Upload = defineAsyncComponent(
() => import('antdv-next/dist/upload/index'),
);
const Image = defineAsyncComponent(() => import('antdv-next/dist/image/index'));
const PreviewGroup = defineAsyncComponent(() =>
import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
name: component.name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
},
});
};
import {
AutoComplete,
@@ -46,15 +217,449 @@ const withDefaultPlaceholder = <T extends Component>(
return h(component, { ...props, ...attrs, placeholder }, slots);
};
};
const IMAGE_EXTENSIONS = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'svg',
'webp',
]);
/**
* 检查是否为图片文件
*/
function isImageFile(file: UploadFile): boolean {
if (file.url) {
try {
const pathname = new URL(file.url, 'http://localhost').pathname;
const ext = pathname.split('.').pop()?.toLowerCase();
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
} catch {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
}
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
}
return file.type.startsWith('image/');
}
/**
* 创建默认的上传按钮插槽
*/
function createDefaultUploadSlots(listType: string, placeholder: string) {
if (listType === 'picture-card') {
return { default: () => placeholder };
}
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
}
/**
* 获取文件的 Base64
*/
function getBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result as string));
reader.addEventListener('error', reject);
});
}
/**
* 预览图片
*/
async function previewImage(
file: UploadFile,
open: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) {
// 非图片文件直接打开链接
if (!isImageFile(file)) {
const url = file.url || file.preview;
if (url) {
window.open(url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
message.error($t('ui.formRules.previewWarning'));
}
return;
}
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
// 过滤图片文件并生成预览
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = await getBase64(imgFile.originFileObj);
}
}
const container = document.createElement('div');
document.body.append(container);
let isUnmounted = false;
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
classes: { popup: { root: '!z-2000' } },
preview: {
open: open.value,
current: currentIndex,
onOpenChange: (value: boolean) => {
open.value = value;
if (!value) {
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
},
},
() =>
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
);
};
},
};
render(h(PreviewWrapper), container);
}
/**
* 图片裁剪操作
*/
function cropImage(file: File, aspectRatio: string | undefined) {
return new Promise<Blob | string | undefined>((resolve, reject) => {
const container = document.createElement('div');
document.body.append(container);
let isUnmounted = false;
let objectUrl: null | string = null;
const open = ref<boolean>(true);
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
function closeModal() {
open.value = false;
setTimeout(() => {
if (!isUnmounted && container) {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
const CropperWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
if (!objectUrl) {
objectUrl = URL.createObjectURL(file);
}
return h(
Modal,
{
open: open.value,
title: h('div', {}, [
$t('ui.crop.title'),
h(
'span',
{
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
},
$t('ui.crop.titleTip', [aspectRatio]),
),
]),
centered: true,
width: 548,
zIndex: 9999,
keyboard: false,
maskClosable: false,
closable: false,
cancelText: $t('common.cancel'),
okText: $t('ui.crop.confirm'),
destroyOnHidden: true,
onOk: async () => {
const cropper = cropperRef.value;
if (!cropper) {
reject(new Error('Cropper not found'));
closeModal();
return;
}
try {
const dataUrl = await cropper.getCropImage();
if (dataUrl) {
resolve(dataUrl);
} else {
reject(new Error($t('ui.crop.errorTip')));
}
} catch {
reject(new Error($t('ui.crop.errorTip')));
} finally {
closeModal();
}
},
onCancel() {
resolve('');
closeModal();
},
},
() =>
h(VCropper, {
ref: (ref: any) => (cropperRef.value = ref),
img: objectUrl as string,
aspectRatio,
}),
);
};
},
};
render(h(CropperWrapper), container);
});
}
/**
* 带预览功能的上传组件
*/
function withPreviewUpload() {
return defineComponent({
name: Upload.name,
emits: ['update:modelValue'],
setup(
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
const aspectRatio = computed(
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
);
async function handleBeforeUpload(
file: UploadFile,
originFileList: Array<File>,
) {
// 文件大小限制
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
file.status = 'removed';
return false;
}
// 图片裁剪处理
if (
attrs.crop &&
!attrs.multiple &&
originFileList[0] &&
isImageFile(file)
) {
file.status = 'removed';
const blob = await cropImage(originFileList[0], aspectRatio.value);
if (!blob) {
throw new Error($t('ui.crop.errorTip'));
}
return blob;
}
return attrs.beforeUpload?.(file) ?? true;
}
function handleChange(event: UploadChangeParam) {
try {
attrs.handleChange?.(event);
attrs.onHandleChange?.(event);
} catch (error) {
console.error(error);
}
fileList.value = event.fileList.filter(
(file) => file.status !== 'removed',
);
emit(
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
}
function handlePreview(file: UploadFile) {
previewVisible.value = true;
return previewImage(file, previewVisible, fileList);
}
function renderUploadButton() {
if (attrs.disabled) return null;
return isEmpty(slots)
? createDefaultUploadSlots(listType, placeholder)
: slots;
}
// 拖拽排序
const draggable = computed(
() => (attrs.draggable ?? false) && !attrs.disabled,
);
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const sortableInstance = ref<null | Sortable>(null);
const styleId = `upload-drag-style-${uploadId}`;
function injectDragStyle() {
if (!document.querySelector(`[id="${styleId}"]`)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
`;
document.head.append(style);
}
}
function removeDragStyle() {
document.querySelector(`[id="${styleId}"]`)?.remove();
}
async function initSortable(retryCount = 0) {
if (!draggable.value) return;
injectDragStyle();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
const container = document.querySelector(
`[data-upload-id="${uploadId}"] .ant-upload-list`,
) as HTMLElement;
if (!container) {
if (retryCount < 5) {
setTimeout(() => initSortable(retryCount + 1), 200);
}
return;
}
const { initializeSortable } = useSortable(container, {
animation: 300,
delay: 400,
delayOnTouchOnly: true,
filter:
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (
oldIndex === undefined ||
newIndex === undefined ||
oldIndex === newIndex
) {
return;
}
const list = [...(fileList.value || [])];
const [movedItem] = list.splice(oldIndex, 1);
if (movedItem) {
list.splice(newIndex, 0, movedItem);
fileList.value = list;
}
attrs.onDragSort?.(oldIndex, newIndex);
emit('update:modelValue', fileList.value);
},
});
sortableInstance.value = await initializeSortable();
}
// 监听表单值变化
watch(
() => attrs.modelValue,
(res) => {
fileList.value = res;
},
);
onMounted(initSortable);
onUnmounted(() => {
sortableInstance.value?.destroy();
removeDragStyle();
});
return () =>
h(
'div',
{ 'data-upload-id': uploadId, class: 'w-full' },
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
beforeUpload: handleBeforeUpload,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton() as any,
),
);
},
});
}
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiCascader'
| 'ApiSelect'
| 'ApiTreeSelect'
| 'AutoComplete'
| 'Cascader'
| 'Checkbox'
| 'CheckboxGroup'
| 'CollapsibleParams'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
@@ -68,6 +673,7 @@ export type ComponentType =
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'RichEditor'
| 'Select'
| 'Space'
| 'Switch'
@@ -77,44 +683,68 @@ export type ComponentType =
| 'Upload'
| BaseFormComponentType;
/**
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示
*/
export interface ComponentPropsMap {
ApiCascader: ApiComponentSharedProps & CascaderProps;
ApiSelect: ApiComponentSharedProps & SelectProps;
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
AutoComplete: AutoCompleteProps;
Cascader: CascaderProps;
Checkbox: CheckboxProps;
CheckboxGroup: CheckboxGroupProps;
CollapsibleParams: CollapsibleParamsProps;
DatePicker: DatePickerProps;
DefaultButton: ButtonProps;
Divider: DividerProps;
IconPicker: IconPickerProps;
Input: InputProps;
InputNumber: InputNumberProps;
InputPassword: InputProps;
Mentions: MentionsProps;
PrimaryButton: ButtonProps;
Radio: RadioProps;
RadioGroup: RadioGroupProps;
RangePicker: RangePickerProps;
Rate: RateProps;
RichEditor: TipTapProps;
Select: SelectProps;
Space: SpaceProps;
Switch: SwitchProps;
Textarea: TextAreaProps;
TimePicker: TimePickerProps;
TreeSelect: TreeSelectProps;
Upload: AdapterUploadProps;
}
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiSelect: (props, { attrs, slots }) => {
return h(
ApiComponent,
{
placeholder: $t('ui.placeholder.select'),
...props,
...attrs,
component: Select,
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onVisibleChange',
},
slots,
);
},
ApiTreeSelect: (props, { attrs, slots }) => {
return h(
ApiComponent,
{
placeholder: $t('ui.placeholder.select'),
...props,
...attrs,
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
slots,
);
},
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
component: Cascader,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onOpenChange',
}),
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: Select,
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onOpenChange',
}),
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onOpenChange',
}),
AutoComplete,
Cascader,
Checkbox,
@@ -125,13 +755,11 @@ async function initComponentAdapter() {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
IconPicker: (props, { attrs, slots }) => {
return h(
IconPicker,
{ iconSlot: 'addonAfter', inputComponent: Input, ...props, ...attrs },
slots,
);
},
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
@@ -144,13 +772,35 @@ async function initComponentAdapter() {
RadioGroup,
RangePicker,
Rate,
RichEditor: withDefaultPlaceholder(VbenTiptap, 'input', {
imageUpload: {
upload: (file: any, onProgress: any) => {
return new Promise((resolve, reject) => {
upload_file({
file,
onProgress({ percent }) {
onProgress?.(percent);
},
onSuccess(response) {
// 从响应中提取图片URL
resolve(response?.data?.url ?? response?.url ?? '');
},
onError() {
reject(new Error($t('ui.tiptap.upload.uploadFailed')));
},
});
});
},
},
}),
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
Upload: withPreviewUpload(),
CollapsibleParams: VbenCollapsibleParams,
};
// 将组件注册到全局共享状态中
@@ -162,7 +812,7 @@ async function initComponentAdapter() {
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
title,
placement: 'bottomRight',
});
},

View File

@@ -1,45 +1,67 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
FormValues,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import type { ComponentPropsMap, ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
// antdv-next 组件库默认都是 v-model:value
baseModelPropName: 'value',
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
},
},
},
defineRules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
});
}
const useVbenForm = useForm<ComponentType>;
function useVbenForm<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) {
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
}
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;

View File

@@ -1,10 +1,25 @@
import { h } from 'vue';
import type { FormValues, TableActionProps } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
import { Button, Image } from 'ant-design-vue';
import { defineComponent, h } from 'vue';
import { useVbenForm } from './form';
import { useAccess } from '@vben/access';
import { VbenTableAction as VbenTableActionCore } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import {
setupVbenVxeTable,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import { get, isFunction, isString } from '@vben/utils';
import { objectOmit } from '@vueuse/core';
import { Button, Image, Popconfirm, Switch, Tag } from 'antdv-next';
import { $t } from '#/locales';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
@@ -26,7 +41,7 @@ setupVbenVxeTable({
response: {
result: 'items',
total: 'total',
list: 'items',
list: '',
},
showActiveMsg: true,
showResponseMsg: false,
@@ -34,14 +49,24 @@ setupVbenVxeTable({
round: true,
showOverflow: true,
size: 'small',
},
} as VxeTableGridOptions,
});
/**
* 解决vxeTable在热更新时可能会出错的问题
*/
vxeUI.renderer.forEach((_item, key) => {
if (key.startsWith('Cell')) {
vxeUI.renderer.delete(key);
}
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
return h(Image, { src: row[column.field] });
return h(Image, { src: row[column.field], ...props });
},
});
@@ -57,12 +82,258 @@ setupVbenVxeTable({
},
});
// 单元格渲染: Tag
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = get(row, column.field);
const tagOptions = options ?? [
{ color: 'success', label: $t('common.enabled'), value: 1 },
{ color: 'error', label: $t('common.disabled'), value: 0 },
];
const tagItem = tagOptions.find((item) => item.value === value);
return h(
Tag,
{
...props,
...objectOmit(tagItem ?? {}, ['label']),
},
{ default: () => tagItem?.label ?? value },
);
},
});
vxeUI.renderer.add('CellSwitch', {
renderTableDefault({ attrs, props }, { column, row }) {
const loadingKey = `__loading_${column.field}`;
const finallyProps = {
checkedChildren: $t('common.enabled'),
checkedValue: 1,
unCheckedChildren: $t('common.disabled'),
unCheckedValue: 0,
...props,
checked: row[column.field],
loading: row[loadingKey] ?? false,
'onUpdate:checked': onChange,
};
async function onChange(newVal: any) {
row[loadingKey] = true;
try {
const result = await attrs?.beforeChange?.(newVal, row);
if (result !== false) {
row[column.field] = newVal;
}
} finally {
row[loadingKey] = false;
}
}
return h(Switch, finallyProps);
},
});
/**
* 注册表格的操作按钮渲染器
*/
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { size: 'small', type: 'link', ...props };
let align: string;
switch (column.align) {
case 'center': {
align = 'center';
break;
}
case 'left': {
align = 'start';
break;
}
default: {
align = 'end';
break;
}
}
const presets: Recordable<Recordable<any>> = {
delete: {
danger: true,
text: $t('common.delete'),
},
edit: {
text: $t('common.edit'),
},
detail: {
text: $t('common.detail'),
},
};
const operations: Array<Recordable<any>> = (
options || ['edit', 'detail', 'delete']
)
.map((opt) => {
if (isString(opt)) {
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
...defaultProps,
};
} else {
return { ...defaultProps, ...presets[opt.code], ...opt };
}
})
.map((opt) => {
const optBtn: Recordable<any> = {};
Object.keys(opt).forEach((key) => {
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
});
return optBtn;
})
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
return h(
Button,
{
...props,
...opt,
icon: undefined,
onClick: listen
? () =>
attrs?.onClick?.({
code: opt.code,
row,
})
: undefined,
},
{
default: () => {
const content = [];
if (opt.icon) {
content.push(
h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
);
}
content.push(opt.text);
return content;
},
},
);
}
function renderConfirm(opt: Recordable<any>) {
let viewportWrapper: HTMLElement | null = null;
return h(
Popconfirm,
{
/**
* 当popconfirm用在固定列中时将固定列作为弹窗的容器时可能会因为固定列较窄而无法容纳弹窗
* 将表格主体区域作为弹窗容器时又会因为固定列的层级较高而遮挡弹窗
* 将body或者表格视口区域作为弹窗容器时又会导致弹窗无法跟随表格滚动。
* 鉴于以上各种情况,一种折中的解决方案是弹出层展示时,禁止操作表格的滚动条。
* 这样既解决了弹窗的遮挡问题,又不至于让弹窗随着表格的滚动而跑出视口区域。
*/
getPopupContainer(el) {
viewportWrapper = el.closest('.vxe-table--viewport-wrapper');
return document.body;
},
placement: 'topLeft',
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
...props,
...opt,
icon: undefined,
onOpenChange: (open: boolean) => {
// 当弹窗打开时,禁止表格的滚动
if (open) {
viewportWrapper?.style.setProperty('pointer-events', 'none');
} else {
viewportWrapper?.style.removeProperty('pointer-events');
}
},
onConfirm: () => {
attrs?.onClick?.({
code: opt.code,
row,
});
},
},
{
default: () => renderBtn({ ...opt }, false),
description: () =>
h(
'div',
{ class: 'truncate' },
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
),
},
);
}
const btns = operations.map((opt) =>
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
);
return h(
'div',
{
class: 'flex table-operations',
style: { justifyContent: align },
},
btns,
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
export { useVbenVxeGrid };
export const useVbenVxeGrid = <
T extends Record<string, any>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
/**
* 表格操作按钮组件
*
* 在适配器内部统一注入权限判断hasPermission使用方无需再传入 `:has-permission`。
* 通过 action 的 `auth` 字段声明权限码,结合 `useAccess().hasAccessByCodes` 判断是否展示。
* 如需自定义权限逻辑,仍可显式传入 `:has-permission` 覆盖默认行为。
*/
export const VbenTableAction = defineComponent(
(props: TableActionProps, { attrs, slots }) => {
const { hasAccessByCodes } = useAccess();
function hasPermission(auth?: string | string[]) {
if (!auth) return true;
return hasAccessByCodes(Array.isArray(auth) ? auth : [auth]);
}
return () =>
h(VbenTableActionCore, { hasPermission, ...props, ...attrs }, slots);
},
{
name: 'VbenTableAction',
inheritAttrs: false,
},
);
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export type * from '@vben/plugins/vxe-table';

View File

@@ -22,23 +22,29 @@ export namespace AuthApi {
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
return requestClient.post<AuthApi.LoginResult>('/auth/login', data, {
withCredentials: true,
});
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
null,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', null, {
withCredentials: true,
});
}

View File

@@ -1,3 +1,4 @@
export * from './auth';
export * from './menu';
export * from './timezone';
export * from './user';

View File

@@ -0,0 +1,26 @@
import { requestClient } from '#/api/request';
/**
* 获取系统支持的时区列表
*/
export async function getTimezoneOptionsApi() {
return await requestClient.get<
{
label: string;
value: string;
}[]
>('/timezone/getTimezoneOptions');
}
/**
* 获取用户时区
*/
export async function getTimezoneApi(): Promise<null | string | undefined> {
return requestClient.get<null | string | undefined>('/timezone/getTimezone');
}
/**
* 设置用户时区
* @param timezone 时区
*/
export async function setTimezoneApi(timezone: string): Promise<void> {
return requestClient.post('/timezone/setTimezone', { timezone });
}

View File

@@ -0,0 +1,28 @@
import type { RequestResponse } from '@vben/request';
import { requestClient } from '../request';
/**
* 下载文件获取Blob
* @returns Blob
*/
async function downloadFile1() {
return requestClient.download<Blob>(
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
);
}
/**
* 下载文件获取完整的Response
* @returns RequestResponse<Blob>
*/
async function downloadFile2() {
return requestClient.download<RequestResponse<Blob>>(
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
{
responseReturn: 'raw',
},
);
}
export { downloadFile1, downloadFile2 };

View File

@@ -0,0 +1,10 @@
import { requestClient } from '#/api/request';
/**
* 发起请求
*/
async function getBigIntData() {
return requestClient.get('/demo/bigint');
}
export { getBigIntData };

View File

@@ -0,0 +1,19 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 发起数组请求
*/
async function getParamsData(
params: Recordable<any>,
type: 'brackets' | 'comma' | 'indices' | 'repeat',
) {
return requestClient.get('/status', {
params,
paramsSerializer: type,
responseReturn: 'raw',
});
}
export { getParamsData };

View File

@@ -0,0 +1,25 @@
import { requestClient } from '#/api/request';
interface UploadFileParams {
file: File;
onError?: (error: Error) => void;
onProgress?: (progress: { percent: number }) => void;
onSuccess?: (data: any, file: File) => void;
}
export async function upload_file({
file,
onError,
onProgress,
onSuccess,
}: UploadFileParams) {
try {
onProgress?.({ percent: 0 });
const data = await requestClient.upload('/upload', { file });
onProgress?.({ percent: 100 });
onSuccess?.(data, file);
} catch (error) {
onError?.(error instanceof Error ? error : new Error(String(error)));
}
}

View File

@@ -1,2 +1,3 @@
export * from './core';
export * from './examples';
export * from './system';

View File

@@ -1,18 +1,21 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { HttpResponse } from '@vben/request';
import type { AxiosResponseHeaders, RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { cloneDeep } from '@vben/utils';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import JSONBigInt from 'json-bigint';
import { useAuthStore } from '#/store';
@@ -20,9 +23,22 @@ import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createRequestClient(baseURL: string) {
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
transformResponse: (data: any, header: AxiosResponseHeaders) => {
// storeAsString指示将BigInt存储为字符串设为false则会存储为内置的BigInt类型
if (
header.getContentType()?.toString().includes('application/json') &&
typeof data === 'string'
) {
return cloneDeep(
JSONBigInt({ storeAsString: true, strict: true }).parse(data),
);
}
return data;
},
});
/**
@@ -69,19 +85,14 @@ function createRequestClient(baseURL: string) {
},
});
// response数据解构
client.addResponseInterceptor<HttpResponse>({
fulfilled: (response) => {
const { data: responseData, status } = response;
const { code, data } = responseData;
if (status >= 200 && status < 400 && code === 0) {
return data;
}
throw Object.assign({}, response, { response });
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: 0,
}),
);
// token过期的处理
client.addResponseInterceptor(
@@ -109,6 +120,14 @@ function createRequestClient(baseURL: string) {
return client;
}
export const requestClient = createRequestClient(apiURL);
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
export interface PageFetchParams {
[key: string]: any;
pageNo?: number;
pageSize?: number;
}

View File

@@ -0,0 +1,54 @@
import { requestClient } from '#/api/request';
export namespace SystemDeptApi {
export interface SystemDept {
[key: string]: any;
children?: SystemDept[];
id: string;
name: string;
remark?: string;
status: 0 | 1;
}
}
/**
* 获取部门列表数据
*/
async function getDeptList() {
return requestClient.get<Array<SystemDeptApi.SystemDept>>(
'/system/dept/list',
);
}
/**
* 创建部门
* @param data 部门数据
*/
async function createDept(
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.post('/system/dept', data);
}
/**
* 更新部门
*
* @param id 部门 ID
* @param data 部门数据
*/
async function updateDept(
id: string,
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.put(`/system/dept/${id}`, data);
}
/**
* 删除部门
* @param id 部门 ID
*/
async function deleteDept(id: string) {
return requestClient.delete(`/system/dept/${id}`);
}
export { createDept, deleteDept, getDeptList, updateDept };

View File

@@ -0,0 +1,4 @@
export * from './dept';
export * from './menu';
export * from './role';
export * from './user';

View File

@@ -0,0 +1,158 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemMenuApi {
/** 徽标颜色集合 */
export const BadgeVariants = [
'default',
'destructive',
'primary',
'success',
'warning',
] as const;
/** 徽标类型集合 */
export const BadgeTypes = ['dot', 'normal'] as const;
/** 菜单类型集合 */
export const MenuTypes = [
'catalog',
'menu',
'embedded',
'link',
'button',
] as const;
/** 系统菜单 */
export interface SystemMenu {
[key: string]: any;
/** 后端权限标识 */
authCode: string;
/** 子级 */
children?: SystemMenu[];
/** 组件 */
component?: string;
/** 菜单ID */
id: string;
/** 菜单元数据 */
meta?: {
/** 激活时显示的图标 */
activeIcon?: string;
/** 作为路由时需要激活的菜单的Path */
activePath?: string;
/** 固定在标签栏 */
affixTab?: boolean;
/** 在标签栏固定的顺序 */
affixTabOrder?: number;
/** 徽标内容(当徽标类型为normal时有效) */
badge?: string;
/** 徽标类型 */
badgeType?: (typeof BadgeTypes)[number];
/** 徽标颜色 */
badgeVariants?: (typeof BadgeVariants)[number];
/** 在菜单中隐藏下级 */
hideChildrenInMenu?: boolean;
/** 在面包屑中隐藏 */
hideInBreadcrumb?: boolean;
/** 在菜单中隐藏 */
hideInMenu?: boolean;
/** 在标签栏中隐藏 */
hideInTab?: boolean;
/** 菜单图标 */
icon?: string;
/** 内嵌Iframe的URL */
iframeSrc?: string;
/** 是否缓存页面 */
keepAlive?: boolean;
/** 外链页面的URL */
link?: string;
/** 同一个路由最大打开的标签数 */
maxNumOfOpenTab?: number;
/** 无需基础布局 */
noBasicLayout?: boolean;
/** 是否在新窗口打开 */
openInNewWindow?: boolean;
/** 菜单排序 */
order?: number;
/** 额外的路由参数 */
query?: Recordable<any>;
/** 菜单标题 */
title?: string;
};
/** 菜单名称 */
name: string;
/** 路由路径 */
path: string;
/** 父级ID */
pid: string;
/** 重定向 */
redirect?: string;
/** 菜单类型 */
type: (typeof MenuTypes)[number];
}
}
/**
* 获取菜单数据列表
*/
async function getMenuList() {
return requestClient.get<Array<SystemMenuApi.SystemMenu>>(
'/system/menu/list',
);
}
async function isMenuNameExists(
name: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/name-exists', {
params: { id, name },
});
}
async function isMenuPathExists(
path: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/path-exists', {
params: { id, path },
});
}
/**
* 创建菜单
* @param data 菜单数据
*/
async function createMenu(
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.post('/system/menu', data);
}
/**
* 更新菜单
*
* @param id 菜单 ID
* @param data 菜单数据
*/
async function updateMenu(
id: string,
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.put(`/system/menu/${id}`, data);
}
/**
* 删除菜单
* @param id 菜单 ID
*/
async function deleteMenu(id: string) {
return requestClient.delete(`/system/menu/${id}`);
}
export {
createMenu,
deleteMenu,
getMenuList,
isMenuNameExists,
isMenuPathExists,
updateMenu,
};

View File

@@ -0,0 +1,55 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemRoleApi {
export interface SystemRole {
[key: string]: any;
id: string;
name: string;
permissions: string[];
remark?: string;
status: 0 | 1;
}
}
/**
* 获取角色列表数据
*/
async function getRoleList(params: Recordable<any>) {
return requestClient.get<Array<SystemRoleApi.SystemRole>>(
'/system/role/list',
{ params },
);
}
/**
* 创建角色
* @param data 角色数据
*/
async function createRole(data: Omit<SystemRoleApi.SystemRole, 'id'>) {
return requestClient.post('/system/role', data);
}
/**
* 更新角色
*
* @param id 角色 ID
* @param data 角色数据
*/
async function updateRole(
id: string,
data: Omit<SystemRoleApi.SystemRole, 'id'>,
) {
return requestClient.put(`/system/role/${id}`, data);
}
/**
* 删除角色
* @param id 角色 ID
*/
async function deleteRole(id: string) {
return requestClient.delete(`/system/role/${id}`);
}
export { createRole, deleteRole, getRoleList, updateRole };

View File

@@ -0,0 +1,55 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemUserApi {
export interface SystemUser {
[key: string]: any;
id: string;
name: string;
permissions: string[];
remark?: string;
status: 0 | 1;
}
}
/**
* 获取用户列表数据
*/
async function getUserList(params: Recordable<any>) {
return requestClient.get<Array<SystemUserApi.SystemUser>>(
'/system/user/list',
{ params },
);
}
/**
* 创建用户
* @param data 用户数据
*/
async function createUser(data: Omit<SystemUserApi.SystemUser, 'id'>) {
return requestClient.post('/system/user', data);
}
/**
* 更新用户
*
* @param id 用户 ID
* @param data 用户数据
*/
async function updateUser(
id: string,
data: Omit<SystemUserApi.SystemUser, 'id'>,
) {
return requestClient.put(`/system/user/${id}`, data);
}
/**
* 删除用户
* @param id 用户 ID
*/
async function deleteUser(id: string) {
return requestClient.delete(`/system/user/${id}`);
}
export { createUser, deleteUser, getUserList, updateUser };

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
import { App, ConfigProvider, theme } from 'ant-design-vue';
import { App, ConfigProvider, StyleProvider, theme } from 'antdv-next';
import { antdLocale } from '#/locales';
@@ -28,12 +28,23 @@ const tokenTheme = computed(() => {
token: tokens,
};
});
watch(
tokenTheme,
(themeConfig) => {
ConfigProvider.config({ theme: themeConfig });
},
{ immediate: true },
);
</script>
<template>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<App>
<RouterView />
</App>
</ConfigProvider>
<!-- layer: antd 组件样式注入 @layer antd Tailwind 工具类可以覆盖组件样式 -->
<StyleProvider layer>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<App>
<RouterView />
</App>
</ConfigProvider>
</StyleProvider>
</template>

View File

@@ -1,41 +1,79 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui';
import { providePluginsOptions } from '@vben/plugins';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import '@vben/styles/antdv-next';
import { VueQueryPlugin } from '@tanstack/vue-query';
import { useTitle } from '@vueuse/core';
import { $t, setupI18n } from '#/locales';
import { router } from '#/router';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm, useVbenForm } from './adapter/form';
import App from './app.vue';
import { initTimezone } from './timezone-init';
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// 注入插件全局配置
providePluginsOptions({
form: { useVbenForm },
});
// 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
// });
// 设置抽屉的默认配置
// setDefaultDrawerProps({
// zIndex: 1020,
// });
const app = createApp(App);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);
// 配置 pinia-tore
await initStores(app, { namespace });
// 初始化时区HANDLER
initTimezone();
// 安装权限指令
registerAccessDirective(app);
// 初始化 tippy
const { initTippy } = await import('@vben/common-ui/es/tippy');
initTippy(app);
// 配置路由及路由守卫
app.use(router);
// 配置@tanstack/vue-query
const { VueQueryPlugin } = await import('@tanstack/vue-query');
app.use(VueQueryPlugin);
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
app.use(MotionPlugin);
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {

View File

@@ -8,6 +8,7 @@ import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
const clickLogo = () => {};
</script>
<template>
@@ -16,6 +17,7 @@ const logo = computed(() => preferences.logo.source);
:logo="logo"
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
:click-logo="clickLogo"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->

View File

@@ -1,28 +1,43 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue';
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';
import { useWatermark } from '@vben/hooks';
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useTabbarStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
const { setMenuList } = useTabbarStore();
setMenuList([
'close',
'affix',
'maximize',
'reload',
'open-in-new-window',
'close-left',
'close-right',
'close-other',
'close-all',
]);
const notifications = ref<NotificationItem[]>([
{
id: 1,
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
@@ -30,6 +45,7 @@ const notifications = ref<NotificationItem[]>([
title: '收到了 14 份新周报',
},
{
id: 2,
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
@@ -37,6 +53,7 @@ const notifications = ref<NotificationItem[]>([
title: '朱偏右 回复了你',
},
{
id: 3,
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
@@ -44,23 +61,51 @@ 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();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{
handler: () => {
openWindow(VBEN_DOC_URL, {
@@ -76,7 +121,7 @@ const menus = computed(() => [
target: '_blank',
});
},
icon: MdiGithub,
icon: SvgGithubIcon,
text: 'GitHub',
},
{
@@ -102,16 +147,79 @@ 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));
}
function handleClickLogo() {}
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch(
() => preferences.app.watermark,
async (enable) => {
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content, isDark: isDarkValue }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
content: `${userStore.userInfo?.username}`,
advancedStyle: {
colorStops: [
{
color: watermarkColor,
offset: 0,
},
{
color: watermarkColor,
offset: 1,
},
],
type: 'linear',
},
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
});
} else {
destroyWatermark();
@@ -121,10 +229,22 @@ watch(
immediate: true,
},
);
onBeforeMount(() => {
if (preferences.app.watermark) {
destroyWatermark();
}
});
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@click-logo="handleClickLogo"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@@ -134,6 +254,7 @@ watch(
tag-text="Pro"
trigger="both"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>
@@ -141,7 +262,11 @@ watch(
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/>
</template>
<template #extra>

View File

@@ -1,7 +1,9 @@
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
import type { Locale } from 'ant-design-vue/es/locale';
import type { Locale } from 'antdv-next/dist/locale/index';
import type { App } from 'vue';
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
import { ref } from 'vue';
import {
@@ -11,8 +13,8 @@ import {
} from '@vben/locales';
import { preferences } from '@vben/preferences';
import antdEnLocale from 'ant-design-vue/es/locale/en_US';
import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN';
import antdEnLocale from 'antdv-next/dist/locale/en_US';
import antdDefaultLocale from 'antdv-next/dist/locale/zh_CN';
import dayjs from 'dayjs';
const antdLocale = ref<Locale>(antdDefaultLocale);

View File

@@ -44,13 +44,72 @@
"loginExpired": "Login Expired",
"icons": "Icons",
"watermark": "Watermark",
"preferencesExtension": "Preferences Extension",
"tabs": "Tabs",
"tabDetail": "Tab Detail Page",
"fullScreen": "FullScreen",
"clipboard": "Clipboard",
"menuWithQuery": "Menu With Query",
"openInNewWindow": "Open in New Window",
"fileDownload": "File Download"
"fileDownload": "File Download",
"requestParamsSerializer": "Request Params Serializer"
},
"preferencesExtensionDemo": {
"description": "This page directly uses extension preferences defined in the playground app. You can modify them in “Preferences → Playground Extension” at the top right, or click the preset buttons below to see the page update in real time.",
"currentConfig": "Current Extension Preferences",
"currentTitle": "Current Title: {title}",
"currentDescription": "Displaying {count} tasks by default; quick actions are {quickActionText}; the current highlight tone is “{tone}”.",
"showQuickActions": "shown",
"hideQuickActions": "hidden",
"boardDescription": "This is a real usage example: the page title, action area, visible row count, and highlight styles are all driven by extension preferences.",
"quickActionsEnabled": "Quick actions are currently disabled, so the page only keeps the read-only information view.",
"quickActions": {
"create": "Create Task",
"export": "Batch Export",
"refresh": "Refresh Data"
},
"presetTitle": "Quick Presets (demonstrates updateCustomPreferences)",
"presetButtons": {
"default": "Reset Default",
"compact": "Switch to Compact",
"review": "Switch to Review"
},
"presetTitles": {
"compact": "Compact Dashboard",
"default": "Weekly Operations Overview",
"review": "Review Dashboard"
},
"tones": {
"default": "Default",
"success": "Success",
"warning": "Warning"
},
"owner": "Owner"
},
"preferencesExtensionConfig": {
"tabLabel": "Extension",
"title": "Playground Business Preferences",
"fields": {
"reportTitle": {
"label": "Report Title",
"placeholder": "Please enter the report title"
},
"defaultVisibleRows": {
"label": "Default Visible Rows",
"tip": "Controls how many task items are rendered by default on the demo page."
},
"enableQuickActions": {
"label": "Show Quick Actions"
},
"highlightTone": {
"label": "Highlight Tone",
"options": {
"default": "Default",
"success": "Success",
"warning": "Warning"
}
}
}
},
"breadcrumb": {
"navigation": "Breadcrumb Navigation",
@@ -64,7 +123,9 @@
"about": "About",
"document": "Document",
"antdv": "Ant Design Vue Version",
"antdv-next": "Antdv Next Version",
"naive-ui": "Naive UI Version",
"element-plus": "Element Plus Version"
"element-plus": "Element Plus Version",
"tdesign": "TDesign Vue Version"
}
}

View File

@@ -12,12 +12,21 @@
"form": {
"title": "Form",
"basic": "Basic Form",
"layout": "Custom Layout",
"query": "Query Form",
"valueFormat": "Value Format",
"rules": "Form Rules",
"dynamic": "Dynamic Form",
"custom": "Custom Component",
"api": "Api",
"merge": "Merge Form"
"merge": "Merge Form",
"scrollToError": "Scroll to Error Field",
"upload-error": "Partial file upload failed",
"upload-urls": "Urls after file upload",
"file": "file",
"crop-image": "Crop image",
"upload-image": "Click to upload image",
"collapsible": "Collapsible Form Field"
},
"vxeTable": {
"title": "Vxe Table",
@@ -29,13 +38,15 @@
"editCell": "Edit Cell",
"editRow": "Edit Row",
"custom-cell": "Custom Cell",
"form": "Form Table"
"form": "Form Table",
"viewed": "Row Marker"
},
"captcha": {
"title": "Captcha",
"pointSelection": "Point Selection Captcha",
"sliderCaptcha": "Slider Captcha",
"sliderRotateCaptcha": "Rotate Captcha",
"sliderTranslateCaptcha": "Translate Captcha",
"captchaCardTitle": "Please complete the security verification",
"pageDescription": "Verify user identity by clicking on specific locations in the image.",
"pageTitle": "Captcha Component Example",
@@ -62,5 +73,17 @@
},
"layout": {
"col-page": "ColPage Layout"
},
"button-group": {
"title": "Button Group"
},
"function": {
"contentMenu": "Content Menu"
},
"cropper": {
"title": "Cropper"
},
"tiptap": {
"title": "Rich Text Editor"
}
}

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

@@ -0,0 +1,78 @@
{
"title": "System Management",
"user": {
"title": "User Management",
"list": "User List",
"name": "User",
"userName": "User Name",
"id": "User ID",
"dept": "Department",
"status": "Status",
"remark": "Remark",
"createTime": "Creation Time",
"operation": "Operation",
"placeholder": "Search Department..."
},
"dept": {
"name": "Department",
"title": "Department Management",
"deptName": "Department Name",
"status": "Status",
"createTime": "Create Time",
"remark": "Remark",
"operation": "Operation",
"parentDept": "Parent Department"
},
"menu": {
"title": "Menu Management",
"parent": "Parent Menu",
"menuTitle": "Title",
"menuName": "Menu Name",
"name": "Menu",
"type": "Type",
"typeCatalog": "Catalog",
"typeMenu": "Menu",
"typeButton": "Button",
"typeLink": "Link",
"typeEmbedded": "Embedded",
"icon": "Icon",
"activeIcon": "Active Icon",
"activePath": "Active Path",
"path": "Route Path",
"component": "Component",
"status": "Status",
"authCode": "Auth Code",
"badge": "Badge",
"operation": "Operation",
"linkSrc": "Link Address",
"affixTab": "Affix In Tabs",
"keepAlive": "Keep Alive",
"hideInMenu": "Hide In Menu",
"hideInTab": "Hide In Tabbar",
"hideChildrenInMenu": "Hide Children In Menu",
"hideInBreadcrumb": "Hide In Breadcrumb",
"advancedSettings": "Other Settings",
"activePathMustExist": "The path could not find a valid menu",
"activePathHelp": "When jumping to the current route, \nthe menu path that needs to be activated must be specified when it does not display in the navigation menu.",
"badgeType": {
"title": "Badge Type",
"dot": "Dot",
"normal": "Text",
"none": "None"
},
"badgeVariants": "Badge Style"
},
"role": {
"title": "Role Management",
"list": "Role List",
"name": "Role",
"roleName": "Role Name",
"id": "Role ID",
"status": "Status",
"remark": "Remark",
"createTime": "Creation Time",
"operation": "Operation",
"permissions": "Permissions",
"setPermissions": "Permissions"
}
}

View File

@@ -44,13 +44,72 @@
"loginExpired": "登录过期",
"icons": "图标",
"watermark": "水印",
"preferencesExtension": "偏好扩展示例",
"tabs": "标签页",
"tabDetail": "标签详情页",
"fullScreen": "全屏",
"clipboard": "剪贴板",
"menuWithQuery": "带参菜单",
"openInNewWindow": "新窗口打开",
"fileDownload": "文件下载"
"fileDownload": "文件下载",
"requestParamsSerializer": "参数序列化"
},
"preferencesExtensionDemo": {
"description": "这个页面直接读取 playground 子项目定义的拓展偏好。你可以在右上角的“偏好设置 → Playground 拓展”中修改字段,也可以点击下方预设按钮,页面会实时联动。",
"currentConfig": "当前拓展配置",
"currentTitle": "当前标题:{title}",
"currentDescription": "默认展示 {count} 条任务;{quickActionText} 快捷操作;当前高亮风格为“{tone}”。",
"showQuickActions": "显示",
"hideQuickActions": "隐藏",
"boardDescription": "这是一个“真实使用”的示例:页面标题、操作区、列表条数和高亮样式都由拓展偏好驱动。",
"quickActionsEnabled": "当前已关闭快捷操作,页面只保留只读信息展示。",
"quickActions": {
"create": "新建任务",
"export": "批量导出",
"refresh": "刷新数据"
},
"presetTitle": "快捷预设(演示 updateCustomPreferences 用法)",
"presetButtons": {
"default": "恢复默认",
"compact": "切换紧凑模式",
"review": "切换评审模式"
},
"presetTitles": {
"compact": "紧凑模式看板",
"default": "本周运营概览",
"review": "评审态工作看板"
},
"tones": {
"default": "默认",
"success": "成功",
"warning": "警告"
},
"owner": "负责人"
},
"preferencesExtensionConfig": {
"tabLabel": "拓展",
"title": "Playground 业务偏好",
"fields": {
"reportTitle": {
"label": "看板标题",
"placeholder": "请输入看板标题"
},
"defaultVisibleRows": {
"label": "默认展示条数",
"tip": "用于控制示例页中任务列表默认渲染多少条数据。"
},
"enableQuickActions": {
"label": "显示快捷操作"
},
"highlightTone": {
"label": "高亮风格",
"options": {
"default": "默认",
"success": "成功",
"warning": "警告"
}
}
}
},
"breadcrumb": {
"navigation": "面包屑导航",
@@ -64,7 +123,9 @@
"about": "关于",
"document": "文档",
"antdv": "Ant Design Vue 版本",
"antdv-next": "Antdv Next 版本",
"naive-ui": "Naive UI 版本",
"element-plus": "Element Plus 版本"
"element-plus": "Element Plus 版本",
"tdesign": "TDesign Vue 版本"
}
}

View File

@@ -15,12 +15,21 @@
"form": {
"title": "表单",
"basic": "基础表单",
"layout": "自定义布局",
"query": "查询表单",
"valueFormat": "值格式化",
"rules": "表单校验",
"dynamic": "动态表单",
"custom": "自定义组件",
"api": "Api",
"merge": "合并表单"
"merge": "合并表单",
"scrollToError": "滚动到错误字段",
"upload-error": "部分文件上传失败",
"upload-urls": "文件上传后的网址",
"file": "文件",
"crop-image": "裁剪图片",
"upload-image": "点击上传图片",
"collapsible": "单项表单折叠"
},
"vxeTable": {
"title": "Vxe 表格",
@@ -32,13 +41,15 @@
"editCell": "单元格编辑",
"editRow": "行编辑",
"custom-cell": "自定义单元格",
"form": "搜索表单"
"form": "搜索表单",
"viewed": "行标记"
},
"captcha": {
"title": "验证码",
"pointSelection": "点选验证",
"sliderCaptcha": "滑块验证",
"sliderRotateCaptcha": "旋转验证",
"sliderTranslateCaptcha": "拼图滑块验证",
"captchaCardTitle": "请完成安全验证",
"pageDescription": "通过点击图片中的特定位置来验证用户身份。",
"pageTitle": "验证码组件示例",
@@ -62,5 +73,17 @@
},
"layout": {
"col-page": "双列布局"
},
"button-group": {
"title": "按钮组"
},
"function": {
"contentMenu": "上下文菜单"
},
"cropper": {
"title": "图片裁剪"
},
"tiptap": {
"title": "富文本编辑器"
}
}

View File

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

View File

@@ -0,0 +1,80 @@
{
"title": "系统管理",
"user": {
"title": "用户管理",
"list": "用户列表",
"name": "用户名",
"userName": "用户名称",
"id": "用户ID",
"dept": "部门",
"status": "状态",
"remark": "备注",
"createTime": "创建时间",
"operation": "操作",
"placeholder": "搜索部门..."
},
"dept": {
"list": "部门列表",
"createTime": "创建时间",
"deptName": "部门名称",
"name": "部门",
"operation": "操作",
"parentDept": "上级部门",
"remark": "备注",
"status": "状态",
"title": "部门管理"
},
"menu": {
"list": "菜单列表",
"activeIcon": "激活图标",
"activePath": "激活路径",
"activePathHelp": "跳转到当前路由时,需要激活的菜单路径。\n当不在导航菜单中显示时需要指定激活路径",
"activePathMustExist": "该路径未能找到有效的菜单",
"advancedSettings": "其它设置",
"affixTab": "固定在标签",
"authCode": "权限标识",
"badge": "徽章内容",
"badgeVariants": "徽标样式",
"badgeType": {
"dot": "点",
"none": "无",
"normal": "文字",
"title": "徽标类型"
},
"component": "页面组件",
"hideChildrenInMenu": "隐藏子菜单",
"hideInBreadcrumb": "在面包屑中隐藏",
"hideInMenu": "隐藏菜单",
"hideInTab": "在标签栏中隐藏",
"icon": "图标",
"keepAlive": "缓存标签页",
"linkSrc": "链接地址",
"menuName": "菜单名称",
"menuTitle": "标题",
"name": "菜单",
"operation": "操作",
"parent": "上级菜单",
"path": "路由地址",
"status": "状态",
"title": "菜单管理",
"type": "类型",
"typeButton": "按钮",
"typeCatalog": "目录",
"typeEmbedded": "内嵌",
"typeLink": "外链",
"typeMenu": "菜单"
},
"role": {
"title": "角色管理",
"list": "角色列表",
"name": "角色",
"roleName": "角色名称",
"id": "角色ID",
"status": "状态",
"remark": "备注",
"createTime": "创建时间",
"operation": "操作",
"permissions": "权限",
"setPermissions": "授权"
}
}

View File

@@ -1,7 +1,7 @@
import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
import { overridesPreferences, preferencesExtension } from './preferences';
/**
* 应用初始化完成之后再进行页面加载渲染
@@ -15,6 +15,7 @@ async function initApplication() {
// app偏好设置初始化
await initPreferences({
extension: preferencesExtension,
namespace,
overrides: overridesPreferences,
});
@@ -28,4 +29,4 @@ async function initApplication() {
unmountGlobalLoading();
}
initApplication();
void initApplication();

View File

@@ -1,4 +1,15 @@
import { defineOverridesPreferences } from '@vben/preferences';
import {
appCopyrightPreferences,
defineOverridesPreferences,
definePreferencesExtension,
} from '@vben/preferences';
interface PlaygroundPreferencesExtension {
defaultVisibleRows: number;
enableQuickActions: boolean;
highlightTone: 'default' | 'success' | 'warning';
reportTitle: string;
}
/**
* @description 项目配置文件
@@ -10,4 +21,66 @@ export const overridesPreferences = defineOverridesPreferences({
app: {
name: import.meta.env.VITE_APP_TITLE,
},
copyright: appCopyrightPreferences,
});
export type { PlaygroundPreferencesExtension };
export const preferencesExtension =
definePreferencesExtension<PlaygroundPreferencesExtension>({
tabLabel: 'demos.preferencesExtensionConfig.tabLabel',
title: 'demos.preferencesExtensionConfig.title',
fields: [
{
component: 'input',
defaultValue: '',
key: 'reportTitle',
label: 'demos.preferencesExtensionConfig.fields.reportTitle.label',
placeholder:
'demos.preferencesExtensionConfig.fields.reportTitle.placeholder',
},
{
component: 'number',
componentProps: {
max: 8,
min: 1,
step: 1,
},
defaultValue: 4,
key: 'defaultVisibleRows',
label:
'demos.preferencesExtensionConfig.fields.defaultVisibleRows.label',
tip: 'demos.preferencesExtensionConfig.fields.defaultVisibleRows.tip',
},
{
component: 'switch',
defaultValue: true,
key: 'enableQuickActions',
label:
'demos.preferencesExtensionConfig.fields.enableQuickActions.label',
},
{
component: 'select',
defaultValue: 'default',
key: 'highlightTone',
label: 'demos.preferencesExtensionConfig.fields.highlightTone.label',
options: [
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.default',
value: 'default',
},
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.success',
value: 'success',
},
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.warning',
value: 'warning',
},
],
},
],
});

View File

@@ -6,7 +6,7 @@ import type {
import { generateAccessible } from '@vben/access';
import { preferences } from '@vben/preferences';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { getAllMenusApi } from '#/api';
import { BasicLayout, IFrameView } from '#/layouts';

View File

@@ -1,6 +1,6 @@
import type { Router } from 'vue-router';
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { startProgress, stopProgress } from '@vben/utils';
@@ -18,7 +18,7 @@ function setupCommonGuard(router: Router) {
// 记录已经加载的页面
const loadedPaths = new Set<string>();
router.beforeEach(async (to) => {
router.beforeEach((to) => {
to.meta.loaded = loadedPaths.has(to.path);
// 页面加载进度条
@@ -54,7 +54,7 @@ function setupAccessGuard(router: Router) {
return decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
DEFAULT_HOME_PATH,
preferences.app.defaultHomePath,
);
}
return true;
@@ -73,7 +73,7 @@ function setupAccessGuard(router: Router) {
path: LOGIN_PATH,
// 如不需要,直接删除 query
query:
to.fullPath === DEFAULT_HOME_PATH
to.fullPath === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(to.fullPath) },
// 携带当前跳转的页面,登录后重新跳转该页面
@@ -105,11 +105,16 @@ function setupAccessGuard(router: Router) {
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
const redirectPath = (from.query.redirect ??
(to.path === DEFAULT_HOME_PATH
? userInfo.homePath || DEFAULT_HOME_PATH
: to.fullPath)) as string;
let redirectPath: string;
if (from.query.redirect) {
redirectPath = from.query.redirect as string;
} else if (to.fullPath === preferences.app.defaultHomePath) {
redirectPath = preferences.app.defaultHomePath;
} else if (userInfo.homePath && to.fullPath === userInfo.homePath) {
redirectPath = userInfo.homePath;
} else {
redirectPath = to.fullPath;
}
return {
...router.resolve(decodeURIComponent(redirectPath)),
replace: true,

View File

@@ -1,11 +1,12 @@
import type { RouteRecordRaw } from 'vue-router';
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { AuthPageLayout } from '#/layouts';
import { $t } from '#/locales';
import Login from '#/views/_core/authentication/login.vue';
const BasicLayout = () => import('#/layouts/basic.vue');
const AuthPageLayout = () => import('#/layouts/auth.vue');
/** 全局404页面 */
const fallbackNotFoundRoute: RouteRecordRaw = {
component: () => import('#/views/_core/fallback/not-found.vue'),
@@ -21,13 +22,21 @@ const fallbackNotFoundRoute: RouteRecordRaw = {
/** 基本路由,这些路由是必须存在的 */
const coreRoutes: RouteRecordRaw[] = [
/**
* 根路由
* 使用基础布局作为所有页面的父级容器子级就不必配置BasicLayout。
* 此路由必须存在,且不应修改
*/
{
component: BasicLayout,
meta: {
hideInBreadcrumb: true,
title: 'Root',
},
name: 'Root',
path: '/',
redirect: DEFAULT_HOME_PATH,
redirect: preferences.app.defaultHomePath,
children: [],
},
{
component: AuthPageLayout,
@@ -42,7 +51,7 @@ const coreRoutes: RouteRecordRaw[] = [
{
name: 'Login',
path: 'login',
component: Login,
component: () => import('#/views/_core/authentication/login.vue'),
meta: {
title: $t('page.auth.login'),
},

View File

@@ -34,4 +34,14 @@ const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
export { accessRoutes, coreRouteNames, routes };
const componentKeys: string[] = Object.keys(
import.meta.glob('../../views/**/*.vue'),
)
.filter((item) => !item.includes('/modules/'))
.map((v) => {
const path = v.replace('../../views/', '/');
return path.endsWith('.vue') ? path.slice(0, -4) : path;
});
export { accessRoutes, componentKeys, coreRouteNames, routes };

View File

@@ -1,32 +1,31 @@
import type { RouteRecordRaw } from 'vue-router';
import { BasicLayout } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
component: BasicLayout,
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
},
name: 'Dashboard',
path: '/',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
keepAlive: true,
},
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',

View File

@@ -1,11 +1,10 @@
import type { RouteRecordRaw } from 'vue-router';
import { BasicLayout, IFrameView } from '#/layouts';
import { IFrameView } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
component: BasicLayout,
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
@@ -22,20 +21,21 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.access.frontendPermissions'),
},
name: 'AccessDemos',
path: '/demos/access',
path: 'access',
children: [
{
name: 'AccessPageControlDemo',
path: '/demos/access/page-control',
path: 'page-control',
component: () => import('#/views/demos/access/index.vue'),
meta: {
icon: 'mdi:page-previous-outline',
title: $t('demos.access.pageAccess'),
domCached: true,
},
},
{
name: 'AccessButtonControlDemo',
path: '/demos/access/button-control',
path: 'button-control',
component: () => import('#/views/demos/access/button-control.vue'),
meta: {
icon: 'mdi:button-cursor',
@@ -44,7 +44,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessMenuVisible403Demo',
path: '/demos/access/menu-visible-403',
path: 'menu-visible-403',
component: () =>
import('#/views/demos/access/menu-visible-403.vue'),
meta: {
@@ -56,7 +56,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessSuperVisibleDemo',
path: '/demos/access/super-visible',
path: 'super-visible',
component: () => import('#/views/demos/access/super-visible.vue'),
meta: {
authority: ['super'],
@@ -66,7 +66,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessAdminVisibleDemo',
path: '/demos/access/admin-visible',
path: 'admin-visible',
component: () => import('#/views/demos/access/admin-visible.vue'),
meta: {
authority: ['admin'],
@@ -76,7 +76,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessUserVisibleDemo',
path: '/demos/access/user-visible',
path: 'user-visible',
component: () => import('#/views/demos/access/user-visible.vue'),
meta: {
authority: ['user'],
@@ -93,11 +93,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.features.title'),
},
name: 'FeaturesDemos',
path: '/demos/features',
path: 'features',
children: [
{
name: 'LoginExpiredDemo',
path: '/demos/features/login-expired',
path: 'login-expired',
component: () =>
import('#/views/demos/features/login-expired/index.vue'),
meta: {
@@ -107,7 +107,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'IconsDemo',
path: '/demos/features/icons',
path: 'icons',
component: () => import('#/views/demos/features/icons/index.vue'),
meta: {
icon: 'lucide:annoyed',
@@ -116,7 +116,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'WatermarkDemo',
path: '/demos/features/watermark',
path: 'watermark',
component: () =>
import('#/views/demos/features/watermark/index.vue'),
meta: {
@@ -124,9 +124,19 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.features.watermark'),
},
},
{
name: 'PreferencesExtensionDemo',
path: 'preferences-extension',
component: () =>
import('#/views/demos/features/preferences-extension/index.vue'),
meta: {
icon: 'lucide:sliders-horizontal',
title: $t('demos.features.preferencesExtension'),
},
},
{
name: 'FeatureTabsDemo',
path: '/demos/features/tabs',
path: 'tabs',
component: () => import('#/views/demos/features/tabs/index.vue'),
meta: {
icon: 'lucide:app-window',
@@ -135,7 +145,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FeatureTabDetailDemo',
path: '/demos/features/tabs/detail/:id',
path: 'detail/:id',
component: () =>
import('#/views/demos/features/tabs/tab-detail.vue'),
meta: {
@@ -147,9 +157,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'HideChildrenInMenuParentDemo',
path: '/demos/features/hide-menu-children',
component: () =>
import('#/views/demos/features/hide-menu-children/parent.vue'),
path: 'hide-menu-children',
meta: {
hideChildrenInMenu: true,
icon: 'ic:round-menu',
@@ -160,28 +168,27 @@ const routes: RouteRecordRaw[] = [
name: 'HideChildrenInMenuDemo',
path: '',
component: () =>
import(
'#/views/demos/features/hide-menu-children/children.vue'
),
import('#/views/demos/features/hide-menu-children/parent.vue'),
meta: {
hideInMenu: true,
// hideInMenu: true,
title: $t('demos.features.hideChildrenInMenu'),
},
},
{
name: 'HideChildrenInMenuChildrenDemo',
path: '/demos/features/hide-menu-children/children',
path: 'children',
component: () =>
import(
'#/views/demos/features/hide-menu-children/children.vue'
),
meta: { title: $t('demos.features.hideChildrenInMenu') },
import('#/views/demos/features/hide-menu-children/children.vue'),
meta: {
activePath: '/demos/features/hide-menu-children',
title: $t('demos.features.hideChildrenInMenu'),
},
},
],
},
{
name: 'FullScreenDemo',
path: '/demos/features/full-screen',
path: 'full-screen',
component: () =>
import('#/views/demos/features/full-screen/index.vue'),
meta: {
@@ -191,7 +198,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FileDownloadDemo',
path: '/demos/features/file-download',
path: 'file-download',
component: () =>
import('#/views/demos/features/file-download/index.vue'),
meta: {
@@ -201,7 +208,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ClipboardDemo',
path: '/demos/features/clipboard',
path: 'clipboard',
component: () =>
import('#/views/demos/features/clipboard/index.vue'),
meta: {
@@ -211,7 +218,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'MenuQueryDemo',
path: '/demos/menu-query',
path: 'menu-query',
component: () =>
import('#/views/demos/features/menu-query/index.vue'),
meta: {
@@ -224,7 +231,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'NewWindowDemo',
path: '/demos/new-window',
path: 'new-window',
component: () =>
import('#/views/demos/features/new-window/index.vue'),
meta: {
@@ -235,7 +242,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VueQueryDemo',
path: '/demos/features/vue-query',
path: 'vue-query',
component: () =>
import('#/views/demos/features/vue-query/index.vue'),
meta: {
@@ -243,12 +250,32 @@ const routes: RouteRecordRaw[] = [
title: 'Tanstack Query',
},
},
{
name: 'RequestParamsSerializerDemo',
path: 'request-params-serializer',
component: () =>
import('#/views/demos/features/request-params-serializer/index.vue'),
meta: {
icon: 'lucide:git-pull-request-arrow',
title: $t('demos.features.requestParamsSerializer'),
},
},
{
name: 'BigIntDemo',
path: 'json-bigint',
component: () =>
import('#/views/demos/features/json-bigint/index.vue'),
meta: {
icon: 'lucide:grape',
title: 'JSON BigInt',
},
},
],
},
// 面包屑导航
{
name: 'BreadcrumbDemos',
path: '/demos/breadcrumb',
path: 'breadcrumb',
meta: {
icon: 'lucide:navigation',
title: $t('demos.breadcrumb.navigation'),
@@ -256,7 +283,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'BreadcrumbLateralDemo',
path: '/demos/breadcrumb/lateral',
path: 'lateral',
component: () => import('#/views/demos/breadcrumb/lateral.vue'),
meta: {
icon: 'lucide:navigation',
@@ -265,7 +292,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'BreadcrumbLateralDetailDemo',
path: '/demos/breadcrumb/lateral-detail',
path: 'lateral-detail',
component: () =>
import('#/views/demos/breadcrumb/lateral-detail.vue'),
meta: {
@@ -276,7 +303,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'BreadcrumbLevelDemo',
path: '/demos/breadcrumb/level',
path: 'level',
meta: {
icon: 'lucide:navigation',
title: $t('demos.breadcrumb.level'),
@@ -284,7 +311,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'BreadcrumbLevelDetailDemo',
path: '/demos/breadcrumb/level/detail',
path: 'detail',
component: () =>
import('#/views/demos/breadcrumb/level-detail.vue'),
meta: {
@@ -302,11 +329,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.fallback.title'),
},
name: 'FallbackDemos',
path: '/demos/fallback',
path: 'fallback',
children: [
{
name: 'Fallback403Demo',
path: '/demos/fallback/403',
path: '403',
component: () => import('#/views/_core/fallback/forbidden.vue'),
meta: {
icon: 'mdi:do-not-disturb-alt',
@@ -315,7 +342,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Fallback404Demo',
path: '/demos/fallback/404',
path: '404',
component: () => import('#/views/_core/fallback/not-found.vue'),
meta: {
icon: 'mdi:table-off',
@@ -324,7 +351,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Fallback500Demo',
path: '/demos/fallback/500',
path: '500',
component: () =>
import('#/views/_core/fallback/internal-error.vue'),
meta: {
@@ -334,7 +361,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FallbackOfflineDemo',
path: '/demos/fallback/offline',
path: 'offline',
component: () => import('#/views/_core/fallback/offline.vue'),
meta: {
icon: 'mdi:offline',
@@ -352,12 +379,12 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.badge.title'),
},
name: 'BadgeDemos',
path: '/demos/badge',
path: 'badge',
children: [
{
name: 'BadgeDotDemo',
component: () => import('#/views/demos/badge/index.vue'),
path: '/demos/badge/dot',
path: 'dot',
meta: {
badgeType: 'dot',
icon: 'lucide:square-dot',
@@ -367,7 +394,7 @@ const routes: RouteRecordRaw[] = [
{
name: 'BadgeTextDemo',
component: () => import('#/views/demos/badge/index.vue'),
path: '/demos/badge/text',
path: 'text',
meta: {
badge: '10',
icon: 'lucide:square-dot',
@@ -377,7 +404,7 @@ const routes: RouteRecordRaw[] = [
{
name: 'BadgeColorDemo',
component: () => import('#/views/demos/badge/index.vue'),
path: '/demos/badge/color',
path: 'color',
meta: {
badge: 'Hot',
badgeVariants: 'destructive',
@@ -395,12 +422,12 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.activeIcon.title'),
},
name: 'ActiveIconDemos',
path: '/demos/active-icon',
path: 'active-icon',
children: [
{
name: 'ActiveIconDemo',
component: () => import('#/views/demos/active-icon/index.vue'),
path: '/demos/active-icon/children',
path: 'children',
meta: {
activeIcon: 'fluent-emoji:radioactive',
icon: 'bi:radioactive',
@@ -416,11 +443,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.outside.title'),
},
name: 'OutsideDemos',
path: '/demos/outside',
path: 'outside',
children: [
{
name: 'IframeDemos',
path: '/demos/outside/iframe',
path: 'iframe',
meta: {
icon: 'mdi:newspaper-variant-outline',
title: $t('demos.outside.embedded'),
@@ -428,7 +455,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VueDocumentDemo',
path: '/demos/outside/iframe/vue-document',
path: 'vue-document',
component: IFrameView,
meta: {
icon: 'logos:vue',
@@ -439,7 +466,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'TailwindcssDemo',
path: '/demos/outside/iframe/tailwindcss',
path: 'tailwindcss',
component: IFrameView,
meta: {
icon: 'devicon:tailwindcss',
@@ -452,7 +479,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ExternalLinkDemos',
path: '/demos/outside/external-link',
path: 'external-link',
meta: {
icon: 'mdi:newspaper-variant-multiple-outline',
title: $t('demos.outside.externalLink'),
@@ -460,7 +487,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'ViteDemo',
path: '/demos/outside/external-link/vite',
path: 'vite',
component: IFrameView,
meta: {
icon: 'logos:vitejs',
@@ -470,7 +497,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VueUseDemo',
path: '/demos/outside/external-link/vue-use',
path: 'vue-use',
component: IFrameView,
meta: {
icon: 'logos:vueuse',
@@ -489,11 +516,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.nested.title'),
},
name: 'NestedDemos',
path: '/demos/nested',
path: 'nested',
children: [
{
name: 'Menu1Demo',
path: '/demos/nested/menu1',
path: 'menu1',
component: () => import('#/views/demos/nested/menu-1.vue'),
meta: {
icon: 'ic:round-menu',
@@ -503,7 +530,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu2Demo',
path: '/demos/nested/menu2',
path: 'menu2',
meta: {
icon: 'ic:round-menu',
keepAlive: true,
@@ -512,7 +539,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Menu21Demo',
path: '/demos/nested/menu2/menu2-1',
path: 'menu2-1',
component: () => import('#/views/demos/nested/menu-2-1.vue'),
meta: {
icon: 'ic:round-menu',
@@ -524,7 +551,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu3Demo',
path: '/demos/nested/menu3',
path: 'menu3',
meta: {
icon: 'ic:round-menu',
title: $t('demos.nested.menu3'),
@@ -550,7 +577,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Menu321Demo',
path: '/demos/nested/menu3/menu3-2/menu3-2-1',
path: 'menu3-2-1',
component: () =>
import('#/views/demos/nested/menu-3-2-1.vue'),
meta: {

View File

@@ -1,11 +1,9 @@
import type { RouteRecordRaw } from 'vue-router';
import { BasicLayout } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
component: BasicLayout,
meta: {
icon: 'ion:layers-outline',
keepAlive: true,
@@ -17,7 +15,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'FormExample',
path: '/examples/form',
path: 'form',
meta: {
icon: 'mdi:form-select',
title: $t('examples.form.title'),
@@ -25,7 +23,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'FormBasicExample',
path: '/examples/form/basic',
path: 'basic',
component: () => import('#/views/examples/form/basic.vue'),
meta: {
title: $t('examples.form.basic'),
@@ -33,15 +31,23 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormQueryExample',
path: '/examples/form/query',
path: 'query',
component: () => import('#/views/examples/form/query.vue'),
meta: {
title: $t('examples.form.query'),
},
},
{
name: 'FormValueFormatExample',
path: 'value-format',
component: () => import('#/views/examples/form/value-format.vue'),
meta: {
title: $t('examples.form.valueFormat'),
},
},
{
name: 'FormRulesExample',
path: '/examples/form/rules',
path: 'rules',
component: () => import('#/views/examples/form/rules.vue'),
meta: {
title: $t('examples.form.rules'),
@@ -49,15 +55,23 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormDynamicExample',
path: '/examples/form/dynamic',
path: 'dynamic',
component: () => import('#/views/examples/form/dynamic.vue'),
meta: {
title: $t('examples.form.dynamic'),
},
},
{
name: 'FormLayoutExample',
path: 'custom-layout',
component: () => import('#/views/examples/form/custom-layout.vue'),
meta: {
title: $t('examples.form.layout'),
},
},
{
name: 'FormCustomExample',
path: '/examples/form/custom',
path: 'custom',
component: () => import('#/views/examples/form/custom.vue'),
meta: {
title: $t('examples.form.custom'),
@@ -65,7 +79,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormApiExample',
path: '/examples/form/api',
path: 'api',
component: () => import('#/views/examples/form/api.vue'),
meta: {
title: $t('examples.form.api'),
@@ -73,17 +87,43 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormMergeExample',
path: '/examples/form/merge',
path: 'merge',
component: () => import('#/views/examples/form/merge.vue'),
meta: {
title: $t('examples.form.merge'),
},
},
{
name: 'FormScrollToErrorExample',
path: 'scroll-to-error-test',
component: () =>
import('#/views/examples/form/scroll-to-error-test.vue'),
meta: {
title: $t('examples.form.scrollToError'),
},
},
{
name: 'FormCollapsibleExample',
path: 'collapsible-test',
component: () => import('#/views/examples/form/collapsible.vue'),
meta: {
title: $t('examples.form.collapsible'),
},
},
{
name: 'FormArrayDemo',
path: '/form-array-demo',
component: () => import('#/views/demos/form-array/index.vue'),
meta: {
icon: 'lucide:list-plus',
title: '表单数组 Demo',
},
},
],
},
{
name: 'VxeTableExample',
path: '/examples/vxe-table',
path: 'vxe-table',
meta: {
icon: 'lucide:table',
title: $t('examples.vxeTable.title'),
@@ -91,7 +131,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VxeTableBasicExample',
path: '/examples/vxe-table/basic',
path: 'basic',
component: () => import('#/views/examples/vxe-table/basic.vue'),
meta: {
title: $t('examples.vxeTable.basic'),
@@ -99,7 +139,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableRemoteExample',
path: '/examples/vxe-table/remote',
path: 'remote',
component: () => import('#/views/examples/vxe-table/remote.vue'),
meta: {
title: $t('examples.vxeTable.remote'),
@@ -107,7 +147,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableTreeExample',
path: '/examples/vxe-table/tree',
path: 'tree',
component: () => import('#/views/examples/vxe-table/tree.vue'),
meta: {
title: $t('examples.vxeTable.tree'),
@@ -115,7 +155,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableFixedExample',
path: '/examples/vxe-table/fixed',
path: 'fixed',
component: () => import('#/views/examples/vxe-table/fixed.vue'),
meta: {
title: $t('examples.vxeTable.fixed'),
@@ -123,7 +163,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableCustomCellExample',
path: '/examples/vxe-table/custom-cell',
path: 'custom-cell',
component: () =>
import('#/views/examples/vxe-table/custom-cell.vue'),
meta: {
@@ -132,7 +172,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableFormExample',
path: '/examples/vxe-table/form',
path: 'form',
component: () => import('#/views/examples/vxe-table/form.vue'),
meta: {
title: $t('examples.vxeTable.form'),
@@ -140,7 +180,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableEditCellExample',
path: '/examples/vxe-table/edit-cell',
path: 'edit-cell',
component: () => import('#/views/examples/vxe-table/edit-cell.vue'),
meta: {
title: $t('examples.vxeTable.editCell'),
@@ -148,7 +188,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableEditRowExample',
path: '/examples/vxe-table/edit-row',
path: 'edit-row',
component: () => import('#/views/examples/vxe-table/edit-row.vue'),
meta: {
title: $t('examples.vxeTable.editRow'),
@@ -156,17 +196,25 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableVirtualExample',
path: '/examples/vxe-table/virtual',
path: 'virtual',
component: () => import('#/views/examples/vxe-table/virtual.vue'),
meta: {
title: $t('examples.vxeTable.virtual'),
},
},
{
name: 'VxeTableViewedExample',
path: 'viewed',
component: () => import('#/views/examples/vxe-table/viewed.vue'),
meta: {
title: $t('examples.vxeTable.viewed'),
},
},
],
},
{
name: 'CaptchaExample',
path: '/examples/captcha',
path: 'captcha',
meta: {
icon: 'logos:recaptcha',
title: $t('examples.captcha.title'),
@@ -174,7 +222,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'DragVerifyExample',
path: '/examples/captcha/slider',
path: 'slider',
component: () =>
import('#/views/examples/captcha/slider-captcha.vue'),
meta: {
@@ -183,16 +231,25 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'RotateVerifyExample',
path: '/examples/captcha/slider-rotate',
path: 'slider-rotate',
component: () =>
import('#/views/examples/captcha/slider-rotate-captcha.vue'),
meta: {
title: $t('examples.captcha.sliderRotateCaptcha'),
},
},
{
name: 'TranslateVerifyExample',
path: 'slider-translate',
component: () =>
import('#/views/examples/captcha/slider-translate-captcha.vue'),
meta: {
title: $t('examples.captcha.sliderTranslateCaptcha'),
},
},
{
name: 'CaptchaPointSelectionExample',
path: '/examples/captcha/point-selection',
path: 'point-selection',
component: () =>
import('#/views/examples/captcha/point-selection-captcha.vue'),
meta: {
@@ -203,25 +260,27 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ModalExample',
path: '/examples/modal',
path: 'modal',
component: () => import('#/views/examples/modal/index.vue'),
meta: {
icon: 'system-uicons:window-content',
keepAlive: true,
title: $t('examples.modal.title'),
},
},
{
name: 'DrawerExample',
path: '/examples/drawer',
path: 'drawer',
component: () => import('#/views/examples/drawer/index.vue'),
meta: {
icon: 'iconoir:drawer',
keepAlive: true,
title: $t('examples.drawer.title'),
},
},
{
name: 'EllipsisExample',
path: '/examples/ellipsis',
path: 'ellipsis',
component: () => import('#/views/examples/ellipsis/index.vue'),
meta: {
icon: 'ion:ellipsis-horizontal',
@@ -230,7 +289,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VueResizeDemo',
path: '/demos/resize/basic',
path: 'resize/basic',
component: () => import('#/views/examples/resize/basic.vue'),
meta: {
icon: 'material-symbols:resize',
@@ -239,7 +298,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ColPageDemo',
path: '/examples/layout/col-page',
path: 'layout/col-page',
component: () => import('#/views/examples/layout/col-page.vue'),
meta: {
badge: 'Alpha',
@@ -248,6 +307,87 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.layout.col-page'),
},
},
{
name: 'TippyDemo',
path: 'tippy',
component: () => import('#/views/examples/tippy/index.vue'),
meta: {
icon: 'mdi:message-settings-outline',
title: 'Tippy',
},
},
{
name: 'JsonViewer',
path: 'json-viewer',
component: () => import('#/views/examples/json-viewer/index.vue'),
meta: {
icon: 'tabler:json',
title: 'JsonViewer',
},
},
{
name: 'Motion',
path: 'motion',
component: () => import('#/views/examples/motion/index.vue'),
meta: {
icon: 'mdi:animation-play',
title: 'Motion',
},
},
{
name: 'CountTo',
path: 'count-to',
component: () => import('#/views/examples/count-to/index.vue'),
meta: {
icon: 'mdi:animation-play',
title: 'CountTo',
},
},
{
name: 'Loading',
path: 'loading',
component: () => import('#/views/examples/loading/index.vue'),
meta: {
icon: 'mdi:circle-double',
title: 'Loading',
},
},
{
name: 'ButtonGroup',
path: 'button-group',
component: () => import('#/views/examples/button-group/index.vue'),
meta: {
icon: 'mdi:check-circle',
title: $t('examples.button-group.title'),
},
},
{
name: 'ContextMenu',
path: 'context-menu',
component: () => import('#/views/examples/context-menu/index.vue'),
meta: {
icon: 'mdi:menu',
title: $t('examples.function.contentMenu'),
},
},
{
name: 'CropperDemo',
path: 'cropper',
component: () => import('#/views/examples/cropper/index.vue'),
meta: {
icon: 'mdi:crop',
title: $t('examples.cropper.title'),
},
},
{
name: 'TiptapExample',
path: 'tiptap',
component: () => import('#/views/examples/tiptap/index.vue'),
meta: {
icon: 'lucide:square-pen',
title: $t('examples.tiptap.title'),
},
},
],
},
];

View File

@@ -0,0 +1,55 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ion:settings-outline',
order: 9997,
title: $t('system.title'),
},
name: 'System',
path: '/system',
children: [
{
path: 'user',
name: 'SystemUser',
meta: {
icon: 'mdi:user',
title: $t('system.user.title'),
},
component: () => import('#/views/system/user/list.vue'),
},
{
path: 'role',
name: 'SystemRole',
meta: {
icon: 'mdi:account-group',
title: $t('system.role.title'),
},
component: () => import('#/views/system/role/list.vue'),
},
{
path: 'menu',
name: 'SystemMenu',
meta: {
icon: 'mdi:menu',
title: $t('system.menu.title'),
},
component: () => import('#/views/system/menu/list.vue'),
},
{
path: 'dept',
name: 'SystemDept',
meta: {
icon: 'charm:organisation',
title: $t('system.dept.title'),
},
component: () => import('#/views/system/dept/list.vue'),
},
],
},
];
export default routes;

View File

@@ -2,41 +2,37 @@ import type { RouteRecordRaw } from 'vue-router';
import {
VBEN_ANT_PREVIEW_URL,
VBEN_ANTDV_NEXT_PREVIEW_URL,
VBEN_DOC_URL,
VBEN_ELE_PREVIEW_URL,
VBEN_GITHUB_URL,
VBEN_LOGO_URL,
VBEN_NAIVE_PREVIEW_URL,
VBEN_TD_PREVIEW_URL,
} from '@vben/constants';
import { SvgAntdvLogoIcon } from '@vben/icons';
import {
SvgAntdvLogoIcon,
SvgAntdvNextLogoIcon,
SvgTDesignIcon,
} from '@vben/icons';
import { BasicLayout, IFrameView } from '#/layouts';
import { IFrameView } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
component: BasicLayout,
meta: {
badgeType: 'dot',
icon: VBEN_LOGO_URL,
order: 9999,
order: 9998,
title: $t('demos.vben.title'),
},
name: 'VbenProject',
path: '/vben-admin',
children: [
{
name: 'VbenAbout',
path: '/vben-admin/about',
component: () => import('#/views/_core/about/index.vue'),
meta: {
icon: 'lucide:copyright',
title: $t('demos.vben.about'),
},
},
{
name: 'VbenDocument',
path: '/vben-admin/document',
path: 'document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
@@ -46,7 +42,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
path: 'github',
component: IFrameView,
meta: {
icon: 'mdi:github',
@@ -56,7 +52,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdv',
path: '/vben-admin/antdv',
path: 'antdv',
component: IFrameView,
meta: {
badgeType: 'dot',
@@ -65,9 +61,21 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.vben.antdv'),
},
},
{
name: 'VbenAntdVNext',
path: 'antdv-next',
component: IFrameView,
meta: {
badgeType: 'dot',
icon: SvgAntdvNextLogoIcon,
link: VBEN_ANTDV_NEXT_PREVIEW_URL,
title: $t('demos.vben.antdv-next'),
},
},
{
name: 'VbenNaive',
path: '/vben-admin/naive',
path: 'naive',
component: IFrameView,
meta: {
badgeType: 'dot',
@@ -78,7 +86,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenElementPlus',
path: '/vben-admin/ele',
path: 'ele',
component: IFrameView,
meta: {
badgeType: 'dot',
@@ -87,8 +95,39 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.vben.element-plus'),
},
},
{
name: 'VbenTDesign',
path: 'tdesign',
component: IFrameView,
meta: {
badgeType: 'dot',
icon: SvgTDesignIcon,
link: VBEN_TD_PREVIEW_URL,
title: $t('demos.vben.tdesign'),
},
},
],
},
{
component: () => import('#/views/_core/about/index.vue'),
meta: {
icon: 'lucide:copyright',
order: 9999,
title: $t('demos.vben.about'),
},
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

@@ -4,9 +4,10 @@ import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
import { notification } from 'antdv-next';
import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
@@ -62,7 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nick_name}`,
duration: 3,
message: $t('authentication.loginSuccess'),
title: $t('authentication.loginSuccess'),
});
}
}
@@ -75,15 +76,22 @@ export const useAuthStore = defineStore('auth', () => {
};
}
const isLoggingOut = ref(false); // 正在 logout 标识, 防止 /logout 死循环.
async function logout(redirect: boolean = true) {
if (isLoggingOut.value) return; // 正在登出中, 说明已进入循环, 直接返回.
isLoggingOut.value = true; // 设置 标识
try {
await logoutApi();
} catch {
// 不做任何处理
}
} finally {
isLoggingOut.value = false; // 重置 标识
resetAllStores();
accessStore.setLoginExpired(false);
resetAllStores();
accessStore.setLoginExpired(false);
}
// 回登录页带上当前路由地址
await router.replace({
@@ -97,8 +105,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function fetchUserInfo() {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
const userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
return userInfo;
}

View File

@@ -0,0 +1,20 @@
import { setTimezoneHandler } from '@vben/stores';
import { getTimezoneApi, getTimezoneOptionsApi, setTimezoneApi } from '#/api';
/**
* 初始化时区处理通过API保存时区设置
*/
export function initTimezone() {
setTimezoneHandler({
getTimezone() {
return getTimezoneApi();
},
setTimezone(timezone: string) {
return setTimezoneApi(timezone);
},
getTimezoneOptions() {
return getTimezoneOptionsApi();
},
});
}

View File

@@ -0,0 +1,81 @@
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
type DateRange = [Dayjs, Dayjs];
type OptionalDateRangeField<
TFormValues extends Record<string, unknown>,
TRangeField extends keyof TFormValues,
> =
Record<never, never> extends Pick<TFormValues, TRangeField>
? [Exclude<TFormValues[TRangeField], undefined>] extends [never]
? never
: Exclude<TFormValues[TRangeField], undefined> extends DateRange
? TRangeField
: never
: never;
type DateRangeSubmitValues<
TFormValues extends Record<string, unknown>,
TRangeField extends keyof TFormValues,
TStartField extends string,
TEndField extends string,
> = Omit<TFormValues, TRangeField> &
Record<TEndField | TStartField, string | undefined>;
interface DateRangeCodecOptions<
TFormValues extends Record<string, unknown>,
TRangeField extends keyof TFormValues & string,
TStartField extends string,
TEndField extends string,
> {
endField: TEndField;
rangeField: OptionalDateRangeField<TFormValues, TRangeField> & TRangeField;
startField: TStartField;
}
export function createDateRangeCodec<
TFormValues extends Record<string, unknown>,
>() {
return function createCodec<
TRangeField extends keyof TFormValues & string,
TStartField extends string,
TEndField extends string,
>(
options: DateRangeCodecOptions<
TFormValues,
TRangeField,
TStartField,
TEndField
>,
) {
const { endField, startField } = options;
const rangeField = options.rangeField as TRangeField;
type SubmitValues = DateRangeSubmitValues<
TFormValues,
TRangeField,
TStartField,
TEndField
>;
return {
decode(values: Readonly<SubmitValues>): TFormValues {
const { [endField]: end, [startField]: start, ...formValues } = values;
return {
...formValues,
...(start && end ? { [rangeField]: [dayjs(start), dayjs(end)] } : {}),
} as TFormValues;
},
encode(values: Readonly<TFormValues>): SubmitValues {
const { [rangeField]: value, ...formValues } = values;
const range = value as DateRange | undefined;
return {
...formValues,
[endField]: range?.[1]?.format('YYYY-MM-DD'),
[startField]: range?.[0]?.format('YYYY-MM-DD'),
} as SubmitValues;
},
};
};
}

View File

@@ -7,7 +7,7 @@ import { computed, ref, useTemplateRef } from 'vue';
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
defineOptions({ name: 'CodeLogin' });
@@ -94,8 +94,7 @@ const formSchema = computed((): VbenFormSchema[] => {
* @param values 登录表单数据
*/
async function handleLogin(values: Recordable<any>) {
// eslint-disable-next-line no-console
console.log(values);
void values;
}
</script>

View File

@@ -28,8 +28,7 @@ const formSchema = computed((): VbenFormSchema[] => {
});
function handleSubmit(value: Record<string, any>) {
// eslint-disable-next-line no-console
console.log('reset email:', value);
void value;
}
</script>

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types';
import type { BasicOption, Recordable } from '@vben/types';
import { computed, markRaw } from 'vue';
import { computed, markRaw, useTemplateRef } from 'vue';
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
@@ -104,12 +104,29 @@ const formSchema = computed((): VbenFormSchema[] => {
},
];
});
const loginRef =
useTemplateRef<InstanceType<typeof AuthenticationLogin>>('loginRef');
async function onSubmit(params: Recordable<any>) {
authStore.authLogin(params).catch(() => {
// 登陆失败,刷新验证码的演示
const formApi = loginRef.value?.getFormApi();
// 重置验证码组件的值
formApi?.setFieldValue('captcha', false, false);
// 使用表单API获取验证码组件实例并调用其resume方法来重置验证码
formApi
?.getFieldComponentRef<InstanceType<typeof SliderCaptcha>>('captcha')
?.resume();
});
}
</script>
<template>
<AuthenticationLogin
ref="loginRef"
:form-schema="formSchema"
:loading="authStore.loginLoading"
@submit="authStore.authLogin"
@submit="onSubmit"
/>
</template>

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),
@@ -82,8 +82,7 @@ const formSchema = computed((): VbenFormSchema[] => {
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('register submit:', value);
void value;
}
</script>

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,63 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from 'antdv-next';
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({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
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

@@ -1,11 +1,9 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);

View File

@@ -1,11 +1,9 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);

View File

@@ -1,11 +1,9 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
@@ -26,7 +24,7 @@ onMounted(() => {
{ name: '定制', value: 310 },
{ name: '技术支持', value: 274 },
{ name: '远程', value: 400 },
].sort((a, b) => {
].toSorted((a, b) => {
return a.value - b.value;
}),
name: '商业占比',

View File

@@ -1,11 +1,9 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);

View File

@@ -1,11 +1,9 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);

View File

@@ -15,10 +15,10 @@ import {
} from '@vben/icons';
import AnalyticsTrends from './analytics-trends.vue';
import AnalyticsVisits from './analytics-visits.vue';
import AnalyticsVisitsData from './analytics-visits-data.vue';
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
import AnalyticsVisitsSource from './analytics-visits-source.vue';
import AnalyticsVisits from './analytics-visits.vue';
const overviewItems: AnalysisOverviewItem[] = [
{
@@ -76,10 +76,10 @@ const chartTabs: TabOption[] = [
</AnalysisChartsTabs>
<div class="mt-5 w-full md:flex">
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量">
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问数量">
<AnalyticsVisitsData />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问来源">
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问来源">
<AnalyticsVisitsSource />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">

View File

@@ -252,7 +252,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
class="lg:mt-0"
title="快捷导航"
@click="navTo"
/>

View File

@@ -7,7 +7,7 @@ import { AccessControl, useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { resetAllStores, useUserStore } from '@vben/stores';
import { Button, Card } from 'ant-design-vue';
import { Button, Card } from 'antdv-next';
import { useAuthStore } from '#/store';
@@ -58,7 +58,7 @@ async function changeAccount(role: string) {
<Card class="mb-5">
<template #title>
<span class="font-semibold">当前角色:</span>
<span class="text-primary mx-4 text-lg">
<span class="mx-4 text-lg text-primary">
{{ userStore.userRoles?.[0] }}
</span>
</template>

View File

@@ -7,7 +7,7 @@ import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { resetAllStores, useUserStore } from '@vben/stores';
import { Button, Card } from 'ant-design-vue';
import { Button, Card } from 'antdv-next';
import { useAuthStore } from '#/store';
@@ -71,7 +71,7 @@ async function handleToggleAccessMode() {
>
<Card class="mb-5" title="权限模式">
<span class="font-semibold">当前权限模式:</span>
<span class="text-primary mx-4">{{
<span class="mx-4 text-primary">{{
accessMode === 'frontend' ? '前端权限控制' : '后端权限控制'
}}</span>
<Button type="primary" @click="handleToggleAccessMode">

View File

@@ -4,9 +4,10 @@ import { useRoute } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { MenuBadge } from '@vben-core/menu-ui';
import { Button, Card, Radio, RadioGroup } from 'ant-design-vue';
import { Button, Card, Radio, RadioGroup } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
@@ -90,7 +91,7 @@ function updateMenuBadge() {
<Card title="徽标更新">
<Form>
<template #badgeVariants="slotProps">
<RadioGroup v-bind="slotProps">
<RadioGroup v-bind="slotProps.componentProps">
<Radio
v-for="color in colors"
:key="color.value"
@@ -98,7 +99,7 @@ function updateMenuBadge() {
>
<div
:title="color.label"
class="flex h-[14px] w-[50px] items-center justify-start"
class="flex h-3.5 w-12.5 items-center justify-start"
>
<MenuBadge
v-bind="{ ...badgeProps, badgeVariants: color.value }"

View File

@@ -3,7 +3,7 @@ import { useRouter } from 'vue-router';
import { Fallback } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
const router = useRouter();
</script>

View File

@@ -3,7 +3,7 @@ import { useRouter } from 'vue-router';
import { Fallback } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
const router = useRouter();

View File

@@ -4,7 +4,7 @@ import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useClipboard } from '@vueuse/core';
import { Button, Card, Input } from 'ant-design-vue';
import { Button, Card, Input } from 'antdv-next';
const source = ref('Hello');
const { copy, text } = useClipboard({ legacy: true, source });
@@ -17,7 +17,7 @@ const { copy, text } = useClipboard({ legacy: true, source });
Current copied: <code>{{ text || 'none' }}</code>
</p>
<div class="flex">
<Input v-model:value="source" class="mr-3 flex w-[200px]" />
<Input v-model:value="source" class="mr-3 flex w-50" />
<Button type="primary" @click="copy(source)"> Copy </Button>
</div>
</Card>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
downloadFileFromBase64,
@@ -7,9 +9,25 @@ import {
downloadFileFromUrl,
} from '@vben/utils';
import { Button, Card } from 'ant-design-vue';
import { Button, Card } from 'antdv-next';
import { downloadFile1, downloadFile2 } from '#/api/examples/download';
import imageBase64 from './base64';
const downloadResult = ref('');
function getBlob() {
downloadFile1().then((res) => {
downloadResult.value = `获取Blob成功长度${res.size}`;
});
}
function getResponse() {
downloadFile2().then((res) => {
downloadResult.value = `获取Response成功headers${JSON.stringify(res.headers)},长度:${res.data.size}`;
});
}
</script>
<template>
@@ -70,5 +88,13 @@ import imageBase64 from './base64';
Download TxT
</Button>
</Card>
<Card class="my-5" title="Request download">
<Button type="primary" @click="getBlob"> 获取Blob </Button>
<Button type="primary" class="ml-4" @click="getResponse">
获取Response
</Button>
<div class="mt-4">{{ downloadResult }}</div>
</Card>
</Page>
</template>

View File

@@ -4,7 +4,7 @@ import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useFullscreen } from '@vueuse/core';
import { Button, Card } from 'ant-design-vue';
import { Button, Card } from 'antdv-next';
const domRef = ref<HTMLElement>();
@@ -37,7 +37,7 @@ const { isFullscreen: isDomFullscreen, toggle: toggleDom } =
<div
ref="domRef"
class="mx-auto mt-10 flex h-64 w-1/2 items-center justify-center rounded-md bg-yellow-400"
class="mx-auto mt-10 flex-center h-64 w-1/2 rounded-md bg-yellow-400"
>
<Button class="mr-2" type="primary" @click="toggleDom">
{{ isDomFullscreen ? 'Exit Dom Full Screen' : 'Enter Dom Full Screen' }}

View File

@@ -1,3 +1,23 @@
<script lang="ts" setup>
import { Fallback, VbenButton } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { X } from '@vben/icons';
const { closeCurrentTab } = useTabs();
</script>
<template>
<div>children</div>
<Fallback
description="当前路由在菜单中不可见"
status="coming-soon"
title="被隐藏的子菜单"
show-back
>
<template #action>
<VbenButton size="lg" @click="closeCurrentTab()">
<X class="mr-2 size-4" />
关闭当前标签页
</VbenButton>
</template>
</Fallback>
</template>

View File

@@ -4,8 +4,14 @@ import { Fallback } from '@vben/common-ui';
<template>
<Fallback
description="当前菜单的子菜单不可见"
:description="`当前路由:${String($route.name)}子菜单不可见`"
status="coming-soon"
title="隐藏子菜单"
/>
>
<template #action>
<RouterLink to="/demos/features/hide-menu-children/children">
打开子路由
</RouterLink>
</template>
</Fallback>
</template>

View File

@@ -3,11 +3,7 @@ import { h, ref } from 'vue';
import { IconPicker, Page } from '@vben/common-ui';
import {
MdiGithub,
MdiGoogle,
MdiKeyboardEsc,
MdiQqchat,
MdiWechat,
SvgAvatar1Icon,
SvgAvatar2Icon,
SvgAvatar3Icon,
@@ -16,11 +12,18 @@ import {
SvgCakeIcon,
SvgCardIcon,
SvgDownloadIcon,
SvgGithubIcon,
SvgGoogleIcon,
SvgQQChatIcon,
SvgWeChatIcon,
} from '@vben/icons';
import { Card, Input } from 'ant-design-vue';
import { Card, Input } from 'antdv-next';
const iconValue = ref('ant-design:trademark-outlined');
const iconValue1 = ref('ant-design:trademark-outlined');
const iconValue2 = ref('svg:avatar-1');
const iconValue3 = ref('mdi:alien-outline');
const iconValue4 = ref('mdi-light:book-multiple');
const inputComponent = h(Input);
</script>
@@ -28,7 +31,7 @@ const inputComponent = h(Input);
<template>
<Page title="图标">
<template #description>
<div class="text-foreground/80 mt-2">
<div class="mt-2 text-foreground/80">
图标可在
<a
class="text-primary"
@@ -43,10 +46,10 @@ const inputComponent = h(Input);
<Card class="mb-5" title="Iconify">
<div class="flex items-center gap-5">
<MdiGithub class="size-8" />
<MdiGoogle class="size-8 text-red-500" />
<MdiQqchat class="size-8 text-green-500" />
<MdiWechat class="size-8" />
<SvgGithubIcon class="size-8" />
<SvgGoogleIcon class="size-8" />
<SvgQQChatIcon class="size-8" />
<SvgWeChatIcon class="size-8" />
<MdiKeyboardEsc class="size-8" />
</div>
</Card>
@@ -78,26 +81,32 @@ const inputComponent = h(Input);
<Card class="mb-5" title="图标选择器">
<div class="mb-5 flex items-center gap-5">
<span>原始样式(Iconify):</span>
<IconPicker class="w-[200px]" />
<IconPicker v-model="iconValue1" class="w-50" />
</div>
<div class="mb-5 flex items-center gap-5">
<span>原始样式(svg):</span>
<IconPicker class="w-[200px]" prefix="svg" />
<IconPicker v-model="iconValue2" class="w-50" prefix="svg" />
</div>
<div class="mb-5 flex items-center gap-5">
<span>使用Input:</span>
<IconPicker :input-component="inputComponent" icon-slot="addonAfter" />
<span>自定义Input:</span>
<IconPicker
:input-component="inputComponent"
v-model="iconValue3"
icon-slot="addonAfter"
model-value-prop="value"
prefix="mdi"
/>
</div>
<div class="flex items-center gap-5">
<span>可手动输入只能点击图标打开弹窗:</span>
<span>显示为一个Icon:</span>
<Input
v-model:value="iconValue"
v-model:value="iconValue4"
allow-clear
placeholder="点击这里选择图标"
style="width: 300px"
class="w-75"
>
<template #addonAfter>
<IconPicker v-model="iconValue" class="w-[200px]" />
<IconPicker v-model="iconValue4" prefix="mdi-light" type="icon" />
</template>
</Input>
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Card } from 'antdv-next';
import { getBigIntData } from '#/api/examples/json-bigint';
const response = ref('');
function fetchData() {
getBigIntData().then((res) => {
response.value = res;
});
}
</script>
<template>
<Page
title="JSON BigInt Support"
description="解析后端返回的长整数long/bigInt。代码位置playground/src/api/request.ts中的transformResponse"
>
<Card>
<Alert>
<template #title>
有些后端接口返回的ID是长整数但javascript原生的JSON解析是不支持超过2^53-1的长整数的
这种情况可以建议后端返回数据前将长整数转换为字符串类型如果后端不接受我们的建议😡
<br />
下面的按钮点击后会发起请求接口返回的JSON数据中的id字段是超出整数范围的数字已自动将其解析为字符串
</template>
</Alert>
<Button class="mt-4" type="primary" @click="fetchData">发起请求</Button>
<div>
<pre>
{{ response }}
</pre>
</div>
</Card>
</Page>
</template>

View File

@@ -4,7 +4,7 @@ import type { LoginExpiredModeType } from '@vben/types';
import { Page } from '@vben/common-ui';
import { preferences, updatePreferences } from '@vben/preferences';
import { Button, Card } from 'ant-design-vue';
import { Button, Card } from 'antdv-next';
import { getMockStatusApi } from '#/api';
@@ -20,7 +20,7 @@ async function handleClick(type: LoginExpiredModeType) {
<template>
<Page title="登录过期演示">
<template #description>
<div class="text-foreground/80 mt-2">
<div class="mt-2 text-foreground/80">
接口请求遇到401状态码时需要重新登录有两种方式
<p>1.转到登录页登录成功后跳转回原页面</p>
<p>

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
import type { PlaygroundPreferencesExtension } from '#/preferences';
import { computed } from 'vue';
import { Page } from '@vben/common-ui';
import {
getCustomPreferences,
updateCustomPreferences,
} from '@vben/preferences';
import { Alert, Button, Card, Space, Tag } from 'antdv-next';
import { $t } from '#/locales';
interface DemoTaskItem {
id: number;
owner: string;
priority: 'P0' | 'P1' | 'P2';
title: string;
}
type HighlightTone = PlaygroundPreferencesExtension['highlightTone'];
const playgroundPreferences =
getCustomPreferences<PlaygroundPreferencesExtension>();
const demoTasks: DemoTaskItem[] = [
{ id: 1, owner: 'Luna', priority: 'P0', title: '同步租户配置到缓存' },
{ id: 2, owner: 'Aiden', priority: 'P1', title: '补充角色权限回归用例' },
{ id: 3, owner: 'Mia', priority: 'P0', title: '修复看板接口超时重试' },
{ id: 4, owner: 'Noah', priority: 'P2', title: '整理本周运营周报模板' },
{ id: 5, owner: 'Ethan', priority: 'P1', title: '验证暗黑主题下图表对比度' },
{ id: 6, owner: 'Sophia', priority: 'P1', title: '更新埋点字段映射文档' },
{ id: 7, owner: 'Lucas', priority: 'P2', title: '检查消息中心未读状态同步' },
{ id: 8, owner: 'Emma', priority: 'P0', title: '补齐导出任务失败告警' },
];
const toneMap = {
default: {
alertType: 'info',
cardClass: 'border-primary/20 bg-primary/5',
label: $t('demos.preferencesExtensionDemo.tones.default'),
tagColor: 'processing',
},
success: {
alertType: 'success',
cardClass: 'border-emerald-500/20 bg-emerald-500/5',
label: $t('demos.preferencesExtensionDemo.tones.success'),
tagColor: 'success',
},
warning: {
alertType: 'warning',
cardClass: 'border-amber-500/20 bg-amber-500/5',
label: $t('demos.preferencesExtensionDemo.tones.warning'),
tagColor: 'warning',
},
} as const satisfies Record<
HighlightTone,
{
alertType: 'info' | 'success' | 'warning';
cardClass: string;
label: string;
tagColor: string;
}
>;
const visibleTasks = computed(() => {
return demoTasks.slice(0, playgroundPreferences.defaultVisibleRows);
});
const toneConfig = computed(() => {
return toneMap[playgroundPreferences.highlightTone];
});
const formattedPlaygroundPreferences = computed(() => {
return JSON.stringify(playgroundPreferences, null, 2);
});
const preClasses =
'mt-4 overflow-auto rounded-lg border border-border bg-muted p-4 text-sm';
function applyPreset(type: 'compact' | 'focus' | 'review') {
const presetMap: Record<
typeof type,
Partial<PlaygroundPreferencesExtension>
> = {
compact: {
defaultVisibleRows: 3,
enableQuickActions: false,
highlightTone: 'warning',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.compact'),
},
focus: {
defaultVisibleRows: 4,
enableQuickActions: true,
highlightTone: 'default',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.default'),
},
review: {
defaultVisibleRows: 6,
enableQuickActions: true,
highlightTone: 'success',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.review'),
},
};
updateCustomPreferences(presetMap[type]);
}
function getPriorityColor(priority: DemoTaskItem['priority']) {
switch (priority) {
case 'P0': {
return 'error';
}
case 'P1': {
return 'warning';
}
default: {
return 'default';
}
}
}
</script>
<template>
<Page :title="$t('demos.features.preferencesExtension')">
<template #description>
<div class="mt-2 text-foreground/80">
{{ $t('demos.preferencesExtensionDemo.description') }}
</div>
</template>
<Card
class="mb-5"
:title="$t('demos.preferencesExtensionDemo.currentConfig')"
>
<Alert :type="toneConfig.alertType" show-icon>
<template #title>
{{
$t('demos.preferencesExtensionDemo.currentTitle', {
title: playgroundPreferences.reportTitle,
})
}}
</template>
<template #description>
{{
$t('demos.preferencesExtensionDemo.currentDescription', {
count: playgroundPreferences.defaultVisibleRows,
quickActionText: playgroundPreferences.enableQuickActions
? $t('demos.preferencesExtensionDemo.showQuickActions')
: $t('demos.preferencesExtensionDemo.hideQuickActions'),
tone: toneConfig.label,
})
}}
</template>
</Alert>
<div class="mt-4 rounded-xl border p-4" :class="toneConfig.cardClass">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-lg font-semibold">
{{ playgroundPreferences.reportTitle }}
</div>
<div class="text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.boardDescription') }}
</div>
</div>
<Tag :color="toneConfig.tagColor">
{{ toneConfig.label }}
</Tag>
</div>
<Space
v-if="playgroundPreferences.enableQuickActions"
wrap
class="mb-4"
>
<Button type="primary">
{{ $t('demos.preferencesExtensionDemo.quickActions.create') }}
</Button>
<Button>
{{ $t('demos.preferencesExtensionDemo.quickActions.export') }}
</Button>
<Button>
{{ $t('demos.preferencesExtensionDemo.quickActions.refresh') }}
</Button>
</Space>
<div v-else class="mb-4 text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.quickActionsEnabled') }}
</div>
<div class="space-y-2">
<div
v-for="task in visibleTasks"
:key="task.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-background px-4 py-3"
>
<div>
<div class="font-medium">{{ task.title }}</div>
<div class="text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.owner') }}{{
task.owner
}}
</div>
</div>
<Tag :color="getPriorityColor(task.priority)">
{{ task.priority }}
</Tag>
</div>
</div>
</div>
</Card>
<Card :title="$t('demos.preferencesExtensionDemo.presetTitle')">
<Space wrap>
<Button @click="applyPreset('focus')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.default') }}
</Button>
<Button @click="applyPreset('compact')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.compact') }}
</Button>
<Button type="primary" @click="applyPreset('review')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.review') }}
</Button>
</Space>
<pre :class="preClasses">{{ formattedPlaygroundPreferences }}</pre>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import { computed, ref, watchEffect } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, Radio, RadioGroup } from 'antdv-next';
import { getParamsData } from '#/api/examples/params';
const params = { ids: [2512, 3241, 4255] };
const paramsSerializer = ref<'brackets' | 'comma' | 'indices' | 'repeat'>(
'brackets',
);
const response = ref('');
const paramsStr = computed(() => {
// 写一段代码从完整的URL中提取参数部分
const url = response.value;
return new URL(url).searchParams.toString();
});
watchEffect(() => {
getParamsData(params, paramsSerializer.value).then((res) => {
response.value = res.request.responseURL;
});
});
</script>
<template>
<Page
title="请求参数序列化"
description="不同的后台接口可能对数组类型的GET参数的解析方式不同我们预置了几种数组序列化方式通过配置 paramsSerializer 来实现不同的序列化方式"
>
<Card>
<RadioGroup v-model:value="paramsSerializer" name="paramsSerializer">
<Radio value="brackets">brackets</Radio>
<Radio value="comma">comma</Radio>
<Radio value="indices">indices</Radio>
<Radio value="repeat">repeat</Radio>
</RadioGroup>
<div class="mt-4 flex flex-col gap-4">
<div>
<h3>需要提交的参数</h3>
<div>{{ JSON.stringify(params, null, 2) }}</div>
</div>
<template v-if="response">
<div>
<h3>访问地址</h3>
<pre>{{ response }}</pre>
</div>
<div>
<h3>参数字符串</h3>
<pre>{{ paramsStr }}</pre>
</div>
<div>
<h3>参数解码</h3>
<pre>{{ decodeURIComponent(paramsStr) }}</pre>
</div>
</template>
</div>
</Card>
</Page>
</template>

View File

@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { Button, Card, Input } from 'ant-design-vue';
import { Button, Card, Input } from 'antdv-next';
const router = useRouter();
const newTabTitle = ref('');
@@ -41,7 +41,7 @@ function reset() {
<template>
<Page description="用于需要操作标签页的场景" title="标签页">
<Card class="mb-5" title="打开/关闭标签页">
<div class="text-foreground/80 mb-3">
<div class="mb-3 text-foreground/80">
如果标签页存在直接跳转切换如果标签页不存在则打开新的标签页
</div>
<div class="flex flex-wrap gap-3">
@@ -53,7 +53,7 @@ function reset() {
</Card>
<Card class="mb-5" title="标签页操作">
<div class="text-foreground/80 mb-3">用于动态控制标签页的各种操作</div>
<div class="mb-3 text-foreground/80">用于动态控制标签页的各种操作</div>
<div class="flex flex-wrap gap-3">
<Button type="primary" @click="closeCurrentTab()">
关闭当前标签页
@@ -73,7 +73,7 @@ function reset() {
</Card>
<Card class="mb-5" title="动态标题">
<div class="text-foreground/80 mb-3">
<div class="mb-3 text-foreground/80">
该操作不会影响页面标题仅修改Tab标题
</div>
<div class="flex flex-wrap items-center gap-3">
@@ -90,7 +90,7 @@ function reset() {
</Card>
<Card class="mb-5" title="最大打开数量">
<div class="text-foreground/80 mb-3">
<div class="mb-3 text-foreground/80">
限制带参数的tab打开的最大数量 `route.meta.maxNumOfOpenTab` 控制
</div>
<div class="flex flex-wrap items-center gap-3">

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import { useQuery } from '@tanstack/vue-query';
import { useVbenForm } from '#/adapter/form';
import { getMenuList } from '#/api';
const queryKey = ['demo', 'api', 'options'];
const count = 4;
const { dataUpdatedAt, promise: fetchDataFn } = useQuery({
// 在组件渲染期间预取数据
experimental_prefetchInRender: true,
// 获取接口数据的函数
queryFn: getMenuList,
queryKey,
// 每次组件挂载时都重新获取数据。如果不需要每次都重新获取就不要设置为always
refetchOnMount: 'always',
// 缓存时间
staleTime: 1000 * 60 * 5,
});
async function fetchOptions() {
return await fetchDataFn.value;
}
const schema = [];
for (let i = 0; i < count; i++) {
schema.push({
component: 'ApiSelect',
componentProps: {
api: fetchOptions,
class: 'w-full',
filterOption: (input: string, option: Recordable<any>) => {
return option.label.toLowerCase().includes(input.toLowerCase());
},
labelField: 'name',
showSearch: true,
valueField: 'id',
},
fieldName: `field${i}`,
label: `Select ${i}`,
});
}
const [Form] = useVbenForm({
schema,
showDefaultActions: false,
});
</script>
<template>
<div>
<div class="mb-2 flex gap-2">
<div>以下{{ count }}个组件共用一个数据源</div>
<div>缓存更新时间{{ new Date(dataUpdatedAt).toLocaleString() }}</div>
</div>
<Form />
</div>
</template>

View File

@@ -1,11 +1,15 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { Card } from 'ant-design-vue';
import { refAutoReset } from '@vueuse/core';
import { Button, Card, Empty } from 'antdv-next';
import ConcurrencyCaching from './concurrency-caching.vue';
import InfiniteQueries from './infinite-queries.vue';
import PaginatedQueries from './paginated-queries.vue';
import QueryRetries from './query-retries.vue';
const showCaching = refAutoReset(true, 1000);
</script>
<template>
@@ -15,11 +19,22 @@ import QueryRetries from './query-retries.vue';
<PaginatedQueries />
</Card>
<Card title="无限滚动">
<InfiniteQueries class="h-[300px] overflow-auto" />
<InfiniteQueries class="h-75 overflow-auto" />
</Card>
<Card title="错误重试">
<QueryRetries />
</Card>
<Card
title="并发和缓存"
v-spinning="!showCaching"
:styles="{ body: { minHeight: '330px' } }"
>
<template #extra>
<Button @click="showCaching = false">重新加载</Button>
</template>
<ConcurrencyCaching v-if="showCaching" />
<Empty v-else description="正在加载..." />
</Card>
</div>
</Page>
</template>

View File

@@ -2,7 +2,7 @@
import type { IProducts } from './typing';
import { useInfiniteQuery } from '@tanstack/vue-query';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
const LIMIT = 10;
const fetchProducts = async ({ pageParam = 0 }): Promise<IProducts> => {

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { IProducts } from './typing';
import { type Ref, ref } from 'vue';
import { ref } from 'vue';
import { keepPreviousData, useQuery } from '@tanstack/vue-query';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
const LIMIT = 10;
const fetcher = async (page: Ref<number>): Promise<IProducts> => {

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
const count = ref(-1);
async function fetchApi() {

View File

@@ -2,13 +2,13 @@
import { Page } from '@vben/common-ui';
import { useWatermark } from '@vben/hooks';
import { Button, Card } from 'ant-design-vue';
import { Button, Card } from 'antdv-next';
const { destroyWatermark, updateWatermark, watermark } = useWatermark();
async function recreateWaterMark() {
destroyWatermark();
await updateWatermark({});
await createWaterMark();
}
async function createWaterMark() {
@@ -48,7 +48,7 @@ async function createWaterMark() {
<template>
<Page title="水印">
<template #description>
<div class="text-foreground/80 mt-2">
<div class="mt-2 text-foreground/80">
水印使用了
<a
class="text-primary"

View File

@@ -0,0 +1,324 @@
# Form Array 数组字段用法
这个 demo 展示 `form-ui` 的数组字段 schema 写法。业务侧推荐使用 `type: 'array' + children` 描述数组结构,不需要直接指定 `component: 'VbenFormFieldArray'`
示例页面:
- `/vue-vben-admin/playground/src/views/demos/form-array/index.vue`
核心实现:
- `/packages/@core/ui-kit/form-ui/src/form-render/schema.ts`
- `/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue`
- `/packages/@core/ui-kit/form-ui/src/form-api.ts`
## 快速开始
```ts
const schema: VbenFormSchema[] = [
{
type: 'array',
fieldName: 'contacts',
label: '联系人',
formItemClass: 'col-span-1 md:col-span-2',
defaultValue: [
{
enabled: true,
name: '张三',
phone: '10086',
role: 'owner',
},
],
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
arrayProps: {
addButtonText: '添加联系人',
min: 1,
max: 5,
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
},
children: [
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
},
{
component: 'Select',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
],
},
},
],
},
];
```
## 字段职责
### `type: 'array'`
声明这是一个数组字段。渲染前会被内部转换为 `VbenFormFieldArray`,所以业务 schema 不需要写具体组件名。
### `fieldName`
数组字段名。假设为 `contacts`,第 1 行子字段 `name` 会被转换成:
```ts
contacts[0].name;
```
### `children`
数组每一行的子字段定义。每个 child 都是完整的 `FormSchema`,可以继续使用:
- `component`
- `componentProps`
- `rules`
- `dependencies`
- `defaultValue`
- `help`
- `suffix`
- `renderComponentContent`
- `formFieldProps`
- `disabled`
- `hide`
- `labelClass`
- `controlClass`
### `arrayProps`
传给数组编辑器的配置:
| 字段 | 说明 |
| --------------- | ------------------------ |
| `addButtonText` | 新增按钮文案 |
| `actionText` | 操作列表头文案 |
| `emptyText` | 空数据文案 |
| `min` | 最少行数,达到后禁用删除 |
| `max` | 最多行数,达到后禁用新增 |
| `showIndex` | 是否显示序号 |
| `createRow` | 新增行时生成默认数据 |
## 校验建议
数组父级 `rules` 建议只写数组级规则,例如至少一行:
```ts
rules: z.array(z.any()).min(1, '请至少添加一个联系人');
```
每个子字段的必填、长度、格式校验写在 children 自己的 `rules`
```ts
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
}
```
不要在父级数组规则里重复写 `z.object({ name: ... })`,否则某一行子字段失败时,父级数组也会失败,容易出现重复错误提示。
## dependencies 用法
children 里的 `dependencies.triggerFields` 默认是“当前行相对路径”。例如:
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
dependencies: {
triggerFields: ['role'],
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
},
}
```
在第 1 行中,`triggerFields: ['role']` 会被转换成:
```ts
contacts[0].role;
```
回调多了一个可选 `ctx` 参数:
| 字段 | 说明 |
| ----------------------- | ---------------------------------------- |
| `ctx.row` | 当前行数据 |
| `ctx.rowIndex` | 当前行索引 |
| `ctx.rowPath` | 当前行路径,例如 `contacts[0]` |
| `ctx.arrayField` | 数组字段名,例如 `contacts` |
| `ctx.fieldName` | 当前真实字段名,例如 `contacts[0].phone` |
| `ctx.originalFieldName` | 原始 child 字段名,例如 `phone` |
| `ctx.rootValues` | 表单完整值 |
如果 child 需要依赖表单根字段,可以使用 `$root.` 前缀:
```ts
dependencies: {
triggerFields: ['$root.planName'],
componentProps: (values) => ({
disabled: !values.planName,
}),
}
```
如果想显式写当前行字段,也可以使用 `$row.` 前缀:
```ts
triggerFields: ['$row.role'];
```
## 提交值转换
数组字段的提交转换使用表单级 `codec`,一次处理完整表单值:
```ts
function encodeArrayFormValues(values: Readonly<ArrayFormValues>) {
return {
...values,
contacts: values.contacts.map((contact) => ({
...contact,
name: contact.name.trim(),
phone: contact.phone?.trim() || undefined,
})),
};
}
const [Form] = useVbenForm({
codec: {
decode: decodeArrayFormValues,
encode: encodeArrayFormValues,
},
schema,
});
```
如果要写根字段,用 `$root.`
```ts
setValue('$root.firstContactPhone', value);
```
如果要显式写当前行字段,用 `$row.`
```ts
setValue('$row.phone', value);
```
## updateSchema 用法
可以用父级路径更新 child schema
```ts
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
如果传入带索引路径,也会更新对应 child 定义:
```ts
formApi.updateSchema([
{
fieldName: 'contacts[0].phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
注意:`updateSchema` 更新的是 schema 定义,不是单行实例。因此 `contacts[0].phone` 这种写法目前会解析到 child `phone`,实际影响所有行的该列。
## 新增行默认值
优先使用 `arrayProps.createRow`
```ts
arrayProps: {
createRow: () => ({
name: '',
role: 'member',
phone: '',
enabled: true,
}),
}
```
没有 `createRow` 时,会根据 children 的 `defaultValue` 生成行数据;没有 defaultValue 的字段会给 `null`
## 内部流程
数组字段从 schema 到渲染大致是这条链路:
```mermaid
flowchart TD
A["业务 schema: type='array' + children"] --> B["form.vue computedSchema"]
B --> C["createFormFieldSchema"]
C --> D["识别数组 schema"]
D --> E["转换为 component='VbenFormFieldArray'"]
E --> F["children 放入 componentProps.schema"]
F --> G["form-field.vue 渲染外层 FormField"]
G --> H["form-field-array.vue useFieldArray 管理行"]
H --> I["createArrayChildSchema"]
I --> J["child.fieldName 转为 contacts[index].xxx"]
I --> K["scope dependencies triggerFields"]
I --> L["包装 componentProps/help/render ctx"]
J --> M["继续复用 FormField 渲染 child"]
```
几个关键点:
- 外层 `type: 'array'` 只是语义声明。
- 具体展示仍复用内部 `VbenFormFieldArray`
- child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。
- `dependencies` 不改核心调用链,而是在 `createArrayChildSchema` 里做路径和 ctx 适配。
- `updateSchema``FormApi` 里递归处理 children提交转换由表单级 `codec` 统一完成。
## 小屏幕展示
`form-field-array` 在大屏下按表格式 grid 展示,在小屏下每行转为纵向堆叠,并显示每个 child 的 label。业务侧通常不需要额外处理移动端布局。
## 兼容旧写法
旧写法仍可用:
```ts
{
component: 'VbenFormFieldArray',
fieldName: 'contacts',
componentProps: {
schema: [...],
},
}
```
新代码推荐:
```ts
{
type: 'array',
fieldName: 'contacts',
children: [...],
}
```

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
interface ContactFormValues {
enabled: boolean;
name: string;
phone?: string;
role: 'member' | 'owner' | 'viewer';
}
interface ArrayFormValues extends Record<string, unknown> {
contacts: ContactFormValues[];
description?: string;
planName: string;
}
function encodeArrayFormValues(values: Readonly<ArrayFormValues>) {
return {
...values,
contacts: values.contacts.map(({ phone, ...contact }) => {
const trimmedPhone = phone?.trim();
return {
...contact,
name: contact.name.trim(),
...(trimmedPhone ? { phone: trimmedPhone } : {}),
};
}),
};
}
type ArraySubmitValues = ReturnType<typeof encodeArrayFormValues>;
function decodeArrayFormValues(
values: Readonly<ArraySubmitValues>,
): ArrayFormValues {
return {
...values,
contacts: values.contacts.map((contact) => ({
...contact,
phone: contact.phone ?? '',
})),
};
}
const submitValues = ref<Partial<ArraySubmitValues>>({});
const formattedSubmitValues = computed(() =>
JSON.stringify(submitValues.value, null, 2),
);
const outputClass = [
'bg-muted',
'text-muted-foreground',
'max-h-105',
'overflow-auto',
'rounded-md',
'p-3',
'text-xs',
];
const schema: VbenFormSchema<ArrayFormValues>[] = [
{
component: 'Input',
componentProps: {
placeholder: '请输入方案名称',
},
defaultValue: '值班联络人配置',
fieldName: 'planName',
label: '方案名称',
rules: z.string().min(1, '请输入方案名称'),
},
{
component: 'Textarea',
dependencies: {
componentProps: (values) => {
const planName = values.planName;
return {
disabled: !planName,
placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称',
rows: 2,
};
},
required: (values) => {
return String(values.planName ?? '').includes('值班');
},
rules: (values) => {
return String(values.planName ?? '').includes('值班')
? z.string().min(2, '请输入至少 2 个字')
: z.string().optional();
},
triggerFields: ['planName'],
},
fieldName: 'description',
formItemClass: 'col-span-1 md:col-span-2',
label: '方案说明',
},
{
arrayProps: {
addButtonText: '添加联系人',
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
max: 5,
min: 1,
},
children: [
{
component: 'Input',
componentProps: (ctx) => ({
placeholder: `${(ctx.rowIndex ?? 0) + 1} 行姓名`,
}),
defaultValue: '',
fieldName: 'name',
label: '姓名',
rules: z.string().trim().min(1, '请输入姓名'),
},
{
component: 'Select',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
{ label: '观察员', value: 'viewer' },
],
},
defaultValue: 'member',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
},
{
component: 'Input',
dependencies: {
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
triggerFields: ['role'],
},
fieldName: 'phone',
label: '电话',
rules: z.string().optional(),
},
{
component: 'Switch',
componentProps: {
checkedChildren: '启用',
unCheckedChildren: '停用',
},
defaultValue: true,
fieldName: 'enabled',
label: '状态',
},
],
defaultValue: [
{
enabled: true,
name: '张三',
phone: ' 10086 ',
role: 'owner',
},
],
fieldName: 'contacts',
formItemClass: 'col-span-1 md:col-span-2',
label: '联系人',
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
type: 'array',
},
];
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeArrayFormValues,
encode: encodeArrayFormValues,
},
commonConfig: {
labelWidth: 90,
},
handleSubmit: (values) => {
submitValues.value = values;
message.success('已通过校验');
},
schema,
showDefaultActions: false,
wrapperClass: 'grid-cols-1 gap-x-4 md:grid-cols-2',
});
async function handleSubmit() {
await formApi.validateAndSubmit();
}
async function handleGetValues() {
submitValues.value = await formApi.getValues();
}
function handlePatchChildRule() {
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
message.success('已动态更新子字段规则');
}
</script>
<template>
<Page title="Form Array Demo">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card title="数组字段">
<Form />
<Space class="mt-4 flex flex-wrap">
<Button type="primary" @click="handleSubmit">提交</Button>
<Button @click="handleGetValues">获取值</Button>
<Button @click="handlePatchChildRule">更新电话规则</Button>
</Space>
</Card>
<Card title="输出">
<pre :class="outputClass" v-text="formattedSubmitValues"></pre>
</Card>
</div>
</Page>
</template>

View File

@@ -0,0 +1,229 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import { reactive, ref } from 'vue';
import {
Page,
VbenButton,
VbenButtonGroup,
VbenCheckButtonGroup,
} from '@vben/common-ui';
import { LoaderCircle, Square, SquareCheckBig } from '@vben/icons';
import { Button, Card, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const radioValue = ref<string | undefined>('a');
const checkValue = ref(['a', 'b']);
const options = [
{ label: '选项1', value: 'a' },
{ label: '选项2', value: 'b', num: 999 },
{ label: '选项3', value: 'c' },
{ label: '选项4', value: 'd' },
{ label: '选项5', value: 'e' },
{ label: '选项6', value: 'f' },
];
function resetValues() {
radioValue.value = undefined;
checkValue.value = [];
}
function beforeChange(v: any, isChecked: boolean) {
return new Promise((resolve) => {
message.loading({
content: `正在设置${v}${isChecked ? '选中' : '未选中'}...`,
duration: 0,
key: 'beforeChange',
});
setTimeout(() => {
message.success({ content: `${v} 已设置成功`, key: 'beforeChange' });
resolve(true);
}, 2000);
});
}
const compProps = reactive({
beforeChange: undefined,
disabled: false,
gap: 0,
showIcon: true,
size: 'middle',
allowClear: false,
} as Recordable<any>);
const [Form] = useVbenForm({
handleValuesChange(values) {
Object.keys(values).forEach((k) => {
if (k === 'beforeChange') {
compProps[k] = values[k] ? beforeChange : undefined;
} else {
compProps[k] = values[k];
}
});
},
commonConfig: {
labelWidth: 150,
},
schema: [
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '大', value: 'large' },
{ label: '中', value: 'middle' },
{ label: '小', value: 'small' },
],
},
defaultValue: compProps.size,
fieldName: 'size',
label: '尺寸',
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '无', value: 0 },
{ label: '小', value: 5 },
{ label: '中', value: 15 },
{ label: '大', value: 30 },
],
},
defaultValue: compProps.gap,
fieldName: 'gap',
label: '间距',
},
{
component: 'Switch',
defaultValue: compProps.showIcon,
fieldName: 'showIcon',
label: '显示图标',
},
{
component: 'Switch',
defaultValue: compProps.disabled,
fieldName: 'disabled',
label: '禁用',
},
{
component: 'Switch',
defaultValue: false,
fieldName: 'beforeChange',
label: '前置回调',
},
{
component: 'Switch',
defaultValue: false,
fieldName: 'allowClear',
label: '允许清除',
help: '单选时是否允许取消选中值为undefined',
},
{
component: 'InputNumber',
defaultValue: 0,
fieldName: 'maxCount',
label: '最大选中数量',
help: '多选时有效0表示不限制',
},
],
showDefaultActions: false,
submitOnChange: true,
});
function onBtnClick(value: any) {
const opt = options.find((o) => o.value === value);
if (opt) {
message.success(`点击了按钮${opt.label}value = ${value}`);
}
}
</script>
<template>
<Page
title="VbenButtonGroup 按钮组"
description="VbenButtonGroup是一个按钮容器用于包裹一组按钮协调整体样式。VbenCheckButtonGroup则可以作为一个表单组件提供单选或多选功能"
>
<Card title="基本用法">
<template #extra>
<Button type="primary" @click="resetValues">清空值</Button>
</template>
<p class="mt-4">按钮组</p>
<div class="mt-2 flex flex-col gap-2">
<VbenButtonGroup v-bind="compProps" border>
<VbenButton
v-for="btn in options"
:key="btn.value"
variant="link"
@click="onBtnClick(btn.value)"
>
{{ btn.label }}
</VbenButton>
</VbenButtonGroup>
<VbenButtonGroup v-bind="compProps" border>
<VbenButton
v-for="btn in options"
:key="btn.value"
variant="outline"
@click="onBtnClick(btn.value)"
>
{{ btn.label }}
</VbenButton>
</VbenButtonGroup>
</div>
<p class="mt-4">单选{{ radioValue }}</p>
<div class="mt-2 flex flex-col gap-2">
<VbenCheckButtonGroup
v-model="radioValue"
:options="options"
v-bind="compProps"
/>
</div>
<p class="mt-4">单选插槽{{ radioValue }}</p>
<div class="mt-2 flex flex-col gap-2">
<VbenCheckButtonGroup
v-model="radioValue"
:options="options"
v-bind="compProps"
>
<template #option="{ label, value, data }">
<div class="flex items-center">
<span>{{ label }}</span>
<span class="ml-2 text-gray-400">{{ value }}</span>
<span v-if="data.num" class="white ml-2">{{ data.num }}</span>
</div>
</template>
</VbenCheckButtonGroup>
</div>
<p class="mt-4">多选{{ checkValue }}</p>
<div class="mt-2 flex flex-col gap-2">
<VbenCheckButtonGroup
v-model="checkValue"
multiple
:options="options"
v-bind="compProps"
/>
</div>
<p class="mt-4">自定义图标{{ checkValue }}</p>
<div class="mt-2 flex flex-col gap-2">
<VbenCheckButtonGroup
v-model="checkValue"
multiple
:options="options"
v-bind="compProps"
>
<template #icon="{ loading, checked }">
<LoaderCircle class="animate-spin" v-if="loading" />
<SquareCheckBig v-else-if="checked" />
<Square v-else />
</template>
</VbenCheckButtonGroup>
</div>
</Card>
<Card title="设置" class="mt-4">
<Form />
</Card>
</Page>
</template>

View File

@@ -5,7 +5,7 @@ import { reactive, ref } from 'vue';
import { Page, PointSelectionCaptcha } from '@vben/common-ui';
import { Card, Input, InputNumber, message, Switch } from 'ant-design-vue';
import { Card, Input, InputNumber, message, Switch } from 'antdv-next';
import { $t } from '#/locales';
@@ -64,7 +64,7 @@ const handleClick = (point: CaptchaPoint) => {
/>
<div class="ml-8 flex w-96 items-center">
<Switch
v-model:checked="params.showHintImage"
v-model="params.showHintImage"
:checked-children="$t('examples.captcha.hintImage')"
:un-checked-children="$t('examples.captcha.hintText')"
class="mr-4 w-40"
@@ -82,7 +82,7 @@ const handleClick = (point: CaptchaPoint) => {
</div>
<Switch
v-model:checked="params.showConfirm"
v-model="params.showConfirm"
:checked-children="$t('examples.captcha.showConfirm')"
:un-checked-children="$t('examples.captcha.hideConfirm')"
class="ml-8 w-28"

View File

@@ -9,7 +9,7 @@ import { ref } from 'vue';
import { Page, SliderCaptcha } from '@vben/common-ui';
import { Bell, Sun } from '@vben/icons';
import { Button, Card, message } from 'ant-design-vue';
import { Button, Card, message } from 'antdv-next';
function handleSuccess(data: CaptchaVerifyPassingData) {
const { time } = data;
@@ -33,7 +33,7 @@ const el6 = ref<SliderCaptchaActionType>();
<template>
<Page description="用于前端简单的拖动校验场景" title="滑块校验">
<Card class="mb-5" title="基础示例">
<div class="flex items-center justify-center p-4 px-[30%]">
<div class="flex-center p-4 px-[30%]">
<SliderCaptcha ref="el1" @success="handleSuccess" />
<Button class="ml-2" type="primary" @click="handleBtnClick(el1)">
还原
@@ -41,7 +41,7 @@ const el6 = ref<SliderCaptchaActionType>();
</div>
</Card>
<Card class="mb-5" title="自定义圆角">
<div class="flex items-center justify-center p-4 px-[30%]">
<div class="flex-center p-4 px-[30%]">
<SliderCaptcha
ref="el2"
class="rounded-full"
@@ -53,7 +53,7 @@ const el6 = ref<SliderCaptchaActionType>();
</div>
</Card>
<Card class="mb-5" title="自定义背景色">
<div class="flex items-center justify-center p-4 px-[30%]">
<div class="flex-center p-4 px-[30%]">
<SliderCaptcha
ref="el3"
:bar-style="{
@@ -69,7 +69,7 @@ const el6 = ref<SliderCaptchaActionType>();
</div>
</Card>
<Card class="mb-5" title="自定义拖拽图标">
<div class="flex items-center justify-center p-4 px-[30%]">
<div class="flex-center p-4 px-[30%]">
<SliderCaptcha ref="el4" @success="handleSuccess">
<template #actionIcon="{ isPassing }">
<Bell v-if="isPassing" />
@@ -82,7 +82,7 @@ const el6 = ref<SliderCaptchaActionType>();
</div>
</Card>
<Card class="mb-5" title="自定义文本">
<div class="flex items-center justify-center p-4 px-[30%]">
<div class="flex-center p-4 px-[30%]">
<SliderCaptcha
ref="el5"
success-text="成功"
@@ -95,7 +95,7 @@ const el6 = ref<SliderCaptchaActionType>();
</div>
</Card>
<Card class="mb-5" title="自定义内容(slot)">
<div class="flex items-center justify-center p-4 px-[30%]">
<div class="flex-center p-4 px-[30%]">
<SliderCaptcha ref="el6" @success="handleSuccess">
<template #text="{ isPassing }">
<template v-if="isPassing">

View File

@@ -5,7 +5,7 @@ import { Page, SliderRotateCaptcha } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { Card, message } from 'ant-design-vue';
import { Card, message } from 'antdv-next';
const userStore = useUserStore();
function handleSuccess() {
@@ -20,7 +20,7 @@ const avatar = computed(() => {
<template>
<Page description="用于前端简单的拖动校验场景" title="滑块旋转校验">
<Card class="mb-5" title="基本示例">
<div class="flex items-center justify-center p-4">
<div class="flex-center p-4">
<SliderRotateCaptcha :src="avatar" @success="handleSuccess" />
</div>
</Card>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { Page, SliderTranslateCaptcha } from '@vben/common-ui';
import { Card, message } from 'antdv-next';
function handleSuccess() {
message.success('success!');
}
</script>
<template>
<Page
description="用于前端简单的拼图滑块水平拖动校验场景"
title="拼图滑块校验"
>
<Card class="mb-5" title="基本示例">
<div class="flex-center p-4">
<SliderTranslateCaptcha
src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/pro-avatar.webp"
:canvas-width="420"
:canvas-height="420"
@success="handleSuccess"
/>
</div>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { Page, VbenContextMenu } from '@vben/common-ui';
import { Button, Card, message } from 'antdv-next';
const needHidden = (role: string) => {
return role === 'user';
};
const contextMenus = () => {
return [
{
text: '刷新',
key: 'refresh',
handler: (data: any) => {
message.success('刷新成功', data);
},
hidden: needHidden('admin'),
},
{
text: '关闭当前',
key: 'close-current',
handler: (data: any) => {
message.success('关闭当前', data);
},
hidden: needHidden('user'),
},
{
text: '关闭其他',
key: 'close-other',
handler: (data: any) => {
message.success('关闭其他', data);
},
},
{
text: '关闭所有',
key: 'close-all',
handler: (data: any) => {
message.success('关闭所有', data);
},
},
];
};
</script>
<template>
<Page title="Context Menu 上下文菜单">
<Card title="基本使用">
<div>一共四个菜单刷新关闭当前关闭其他关闭所有</div>
<br />
<br />
<VbenContextMenu :menus="contextMenus" :modal="true" item-class="pr-6">
<Button> 右键点击我打开上下文菜单(有隐藏项) </Button>
</VbenContextMenu>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,175 @@
<script lang="ts" setup>
import type { CountToProps, TransitionPresets } from '@vben/common-ui';
import { reactive } from 'vue';
import { CountTo, Page, TransitionPresetsKeys } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Col,
Form,
FormItem,
Input,
InputNumber,
message,
Row,
Select,
Switch,
} from 'antdv-next';
const props = reactive<CountToProps & { transition: TransitionPresets }>({
decimal: '.',
decimals: 2,
decimalStyle: {
fontSize: 'small',
fontStyle: 'italic',
},
delay: 0,
disabled: false,
duration: 2000,
endVal: 100_000,
mainStyle: {
color: 'hsl(var(--primary))',
fontSize: 'xx-large',
fontWeight: 'bold',
},
prefix: '¥',
prefixStyle: {
paddingRight: '0.5rem',
},
separator: ',',
startVal: 0,
suffix: '元',
suffixStyle: {
paddingLeft: '0.5rem',
},
transition: 'easeOutQuart',
});
function changeNumber() {
props.endVal =
Math.floor(Math.random() * 100_000_000) / 10 ** (props.decimals || 0);
}
function openDocumentation() {
window.open('https://vueuse.org/core/useTransition/', '_blank');
}
function onStarted() {
message.loading({
content: '动画已开始',
duration: 0,
key: 'animator-info',
});
}
function onFinished() {
message.success({
content: '动画已结束',
duration: 2,
key: 'animator-info',
});
}
</script>
<template>
<Page title="CountTo" description="数字滚动动画组件。使用">
<template #description>
<span>
使用useTransition封装的数字滚动动画组件每次改变当前值都会产生过渡动画
</span>
<Button type="link" @click="openDocumentation">
查看useTransition文档
</Button>
</template>
<Card title="基本用法">
<div class="flex-center w-full pb-4">
<CountTo v-bind="props" @started="onStarted" @finished="onFinished" />
</div>
<Form :model="props">
<Row :gutter="20">
<Col :span="8">
<FormItem label="初始值" name="startVal">
<InputNumber v-model:value="props.startVal" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="当前值" name="endVal">
<InputNumber
v-model:value="props.endVal"
class="w-full"
:precision="props.decimals"
>
<template #addonAfter>
<IconifyIcon
v-tippy="`设置一个随机值`"
class="size-5 cursor-pointer outline-hidden"
icon="ix:random-filled"
@click="changeNumber"
/>
</template>
</InputNumber>
</FormItem>
</Col>
<Col :span="8">
<FormItem label="禁用动画" name="disabled">
<Switch v-model="props.disabled" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="延迟动画" name="delay">
<InputNumber v-model:value="props.delay" :min="0" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="持续时间" name="duration">
<InputNumber v-model:value="props.duration" :min="0" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="小数位数" name="decimals">
<InputNumber
v-model:value="props.decimals"
:min="0"
:precision="0"
/>
</FormItem>
</Col>
<Col :span="8">
<FormItem label="分隔符" name="separator">
<Input v-model:value="props.separator" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="小数点" name="decimal">
<Input v-model:value="props.decimal" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="动画" name="transition">
<Select
v-model:value="props.transition"
:options="
TransitionPresetsKeys.map((p) => ({ label: p, value: p }))
"
/>
</FormItem>
</Col>
<Col :span="8">
<FormItem label="前缀" name="prefix">
<Input v-model:value="props.prefix" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="后缀" name="suffix">
<Input v-model:value="props.suffix" />
</FormItem>
</Col>
</Row>
</Form>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,144 @@
<script lang="ts" setup>
import type { UploadChangeParam } from 'antdv-next';
import { ref } from 'vue';
import { Page, VCropper } from '@vben/common-ui';
import { Button, Card, Select, Upload } from 'antdv-next';
const options = [
{ label: '1:1', value: '1:1' },
{ label: '16:9', value: '16:9' },
{ label: '不限制', value: '' },
];
const cropperRef = ref<InstanceType<typeof VCropper>>();
const cropLoading = ref(false);
const validAspectRatio = ref<string | undefined>('1:1');
const imgUrl = ref('');
const cropperImg = ref();
const selectImgFile = (event: UploadChangeParam) => {
const file = event.fileList[0]?.originFileObj;
if (!file) return;
if (!file.type.startsWith('image/')) {
console.error('请上传图片文件');
return;
}
const reader = new FileReader();
reader.addEventListener('load', (e) => {
imgUrl.value = e.target?.result as string;
});
reader.addEventListener('error', () => {
console.error('Failed to read file');
});
reader.readAsDataURL(file);
};
const cropImage = async () => {
if (!cropperRef.value) return;
cropLoading.value = true;
try {
cropperImg.value = await cropperRef.value.getCropImage(
'image/jpeg',
0.92,
'base64',
);
} catch (error) {
console.error('图片裁剪失败:', error);
} finally {
cropLoading.value = false;
}
};
/**
* 下载图片
*/
const downloadImage = () => {
if (!cropperImg.value) return;
const link = document.createElement('a');
link.download = `cropped-image-${Date.now()}.png`;
link.href = cropperImg.value;
link.click();
};
</script>
<template>
<Page
title="VCropper 图片裁剪"
description="VCropper是一个图片裁剪组件提供基础的图片裁剪功能。"
>
<Card>
<div class="image-cropper-container">
<div class="cropper-ratio-display">
<label class="ratio-label">当前裁剪比例</label>
<Select
class="w-24"
v-model:value="validAspectRatio"
:options="options"
/>
<Upload
:max-count="1"
:show-upload-list="false"
:before-upload="() => false"
@change="selectImgFile"
>
<Button>上传图片</Button>
</Upload>
</div>
<div v-if="imgUrl" class="cropper-main-wrapper">
<VCropper
ref="cropperRef"
:img="imgUrl"
:aspect-ratio="validAspectRatio"
:width="600"
:height="600"
/>
<!-- 操作按钮组 -->
<div class="cropper-btn-group">
<Button :loading="cropLoading" @click="cropImage" type="primary">
裁剪
</Button>
<Button v-if="cropperImg" @click="downloadImage" danger>
下载图片
</Button>
</div>
<!-- 裁剪预览 -->
<img
v-if="cropperImg"
class="h-full w-80"
:src="cropperImg"
alt="裁剪预览"
/>
</div>
</div>
</Card>
</Page>
</template>
<style scoped>
/* 比例展示区域 */
.cropper-ratio-display {
@apply my-2.5 flex items-center justify-start gap-4;
}
.ratio-label {
@apply text-sm font-medium;
}
/* 主裁剪区域 */
.cropper-main-wrapper {
@apply flex items-center gap-4;
}
.cropper-btn-group {
@apply flex flex-col gap-2;
}
</style>

View File

@@ -2,7 +2,7 @@
import { VBEN_DOC_URL } from '@vben/constants';
import { openWindow } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
const props = defineProps<{ path: string }>();

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
const list = ref<number[]>([]);
@@ -35,7 +35,7 @@ function handleUpdate(len: number) {
<div
v-for="item in list"
:key="item"
class="even:bg-heavy bg-muted flex-center h-[220px] w-full"
class="flex-center h-55 w-full bg-muted even:bg-heavy"
>
{{ item }}
</div>

View File

@@ -1,24 +1,35 @@
<script lang="ts" setup>
import { useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
const [Drawer, drawerApi] = useVbenDrawer({
onCancel() {
drawerApi.close();
},
onClosed() {
drawerApi.setState({ overlayBlur: 0, placement: 'right' });
},
onConfirm() {
message.info('onConfirm');
// drawerApi.close();
},
});
function lockDrawer() {
drawerApi.lock();
setTimeout(() => {
drawerApi.unlock();
}, 3000);
}
</script>
<template>
<Drawer title="基础抽屉示例" title-tooltip="标题提示内容">
<template #extra> extra </template>
base demo
<Button type="primary" @click="lockDrawer">锁定抽屉状态</Button>
<!-- <template #prepend-footer> slot </template> -->
<!-- <template #append-footer> prepend slot </template> -->
<!-- <template #center-footer> center slot </template> -->
</Drawer>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useVbenDrawer } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
const [Drawer, drawerApi] = useVbenDrawer({
onCancel() {

View File

@@ -7,6 +7,10 @@ defineOptions({
name: 'FormDrawerDemo',
});
interface FormDrawerData {
values?: Record<string, unknown>;
}
const [Form, formApi] = useVbenForm({
schema: [
{
@@ -30,24 +34,28 @@ const [Form, formApi] = useVbenForm({
],
showDefaultActions: false,
});
const [Drawer, drawerApi] = useVbenDrawer({
const [Drawer, drawerApi] = useVbenDrawer<FormDrawerData>({
onCancel() {
drawerApi.close();
},
onConfirm: async () => {
await formApi.submitForm();
await formApi.submit();
drawerApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values } = drawerApi.getData<Record<string, any>>();
if (values) {
formApi.setValues(values);
const data = drawerApi.getData();
if (data?.values) {
formApi.setValues(data.values);
} else {
formApi.reset();
}
}
},
title: '内嵌表单示例',
});
defineExpose({ drawerApi });
</script>
<template>
<Drawer>

View File

@@ -1,9 +1,31 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Input, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const value = ref('');
const [Form] = useVbenForm({
schema: [
{
component: 'Input',
componentProps: {
placeholder: 'KeepAlive测试内部组件',
},
fieldName: 'field1',
hideLabel: true,
label: '字段1',
},
],
showDefaultActions: false,
});
const [Drawer, drawerApi] = useVbenDrawer({
destroyOnClose: false,
onCancel() {
drawerApi.close();
},
@@ -16,6 +38,11 @@ const [Drawer, drawerApi] = useVbenDrawer({
<template>
<Drawer append-to-main title="基础抽屉示例" title-tooltip="标题提示内容">
<template #extra> extra </template>
本抽屉指定在内容区域打开
此弹窗指定在内容区域打开并且在关闭之后弹窗内容不会被销毁
<Input
v-model:value="value"
placeholder="KeepAlive测试:connectedComponent"
/>
<Form />
</Drawer>
</template>

View File

@@ -1,7 +1,11 @@
<script lang="ts" setup>
import { type DrawerPlacement, Page, useVbenDrawer } from '@vben/common-ui';
import type { DrawerPlacement, DrawerState } from '@vben/common-ui';
import { Button, Card } from 'ant-design-vue';
import type { ExplicitDrawerData } from './typed-data-contract';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Button, Card } from 'antdv-next';
import DocButton from '../doc-button.vue';
import AutoHeightDemo from './auto-height-demo.vue';
@@ -10,7 +14,12 @@ import DynamicDemo from './dynamic-demo.vue';
import FormDrawerDemo from './form-drawer-demo.vue';
import inContentDemo from './in-content-demo.vue';
import SharedDataDemo from './shared-data-demo.vue';
import TypedDataAutoDemo from './typed-data-auto-demo.vue';
import { useFactoryDrawer } from './typed-data-contract';
import TypedDataExplicitDemo from './typed-data-explicit-demo.vue';
import TypedDataFactoryDemo from './typed-data-factory-demo.vue';
defineOptions({ name: 'DrawerExample' });
const [BaseDrawer, baseDrawerApi] = useVbenDrawer({
// 连接抽离的组件
connectedComponent: BaseDemo,
@@ -39,26 +48,39 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: FormDrawerDemo,
});
const [TypedDataAutoDrawer, typedDataAutoDrawerApi] = useVbenDrawer({
connectedComponent: TypedDataAutoDemo,
});
const [TypedDataExplicitDrawer, typedDataExplicitDrawerApi] =
useVbenDrawer<ExplicitDrawerData>({
connectedComponent: TypedDataExplicitDemo,
});
const [TypedDataFactoryDrawer, typedDataFactoryDrawerApi] = useFactoryDrawer({
connectedComponent: TypedDataFactoryDemo,
});
function openBaseDrawer(placement: DrawerPlacement = 'right') {
baseDrawerApi.setState({ placement });
baseDrawerApi.open();
baseDrawerApi.setState({ placement }).open();
}
function openBlurDrawer() {
baseDrawerApi.setState({ overlayBlur: 5 }).open();
}
function openInContentDrawer(placement: DrawerPlacement = 'right') {
inContentDrawerApi.setState({ class: '', placement });
const state: Partial<DrawerState> = { class: '', placement };
if (placement === 'top') {
// 页面顶部区域的层级只有200所以设置一个低于200的值抽屉从顶部滑出来的时候才比较合适
inContentDrawerApi.setState({ zIndex: 199 });
} else {
inContentDrawerApi.setState({ zIndex: undefined });
state.zIndex = 199;
}
inContentDrawerApi.open();
inContentDrawerApi.setState(state).open();
}
function openMaxContentDrawer() {
// 这里只是用来演示方便。实际上自己使用的时候可以直接将这些配置卸载Drawer的属性里
inContentDrawerApi.setState({ class: 'w-full', placement: 'right' });
inContentDrawerApi.open();
// 这里只是用来演示方便。实际上自己使用的时候可以直接将这些配置写在Drawer的属性里
inContentDrawerApi.setState({ class: 'w-full', placement: 'right' }).open();
}
function openAutoHeightDrawer() {
@@ -70,24 +92,52 @@ function openDynamicDrawer() {
}
function handleUpdateTitle() {
dynamicDrawerApi.setState({ title: '外部动态标题' });
dynamicDrawerApi.open();
dynamicDrawerApi.setState({ title: '外部动态标题' }).open();
}
function openSharedDrawer() {
sharedDrawerApi.setData({
content: '外部传递的数据 content',
payload: '外部传递的数据 payload',
});
sharedDrawerApi.open();
sharedDrawerApi
.setData({
content: '外部传递的数据 content',
payload: '外部传递的数据 payload',
})
.open();
}
function openFormDrawer() {
formDrawerApi.setData({
// 表单值
values: { field1: 'abc', field2: '123' },
});
formDrawerApi.open();
formDrawerApi
.setData({
// 表单值
values: { field1: 'abc', field2: '123' },
})
.open();
}
function openTypedDataAutoDrawer() {
typedDataAutoDrawerApi
.setData({
message: '外部无需声明泛型,由 connected component 自动推导。',
method: '自动推导',
})
.open();
}
function openTypedDataExplicitDrawer() {
typedDataExplicitDrawerApi
.setData({
message: '父子组件显式引用同一个数据类型。',
method: '显式泛型',
})
.open();
}
function openTypedDataFactoryDrawer() {
typedDataFactoryDrawerApi
.setData({
message: '父子组件复用预绑定的 typed composable。',
method: '契约工厂',
})
.open();
}
</script>
@@ -106,6 +156,9 @@ function openFormDrawer() {
<DynamicDrawer />
<SharedDataDrawer />
<FormDrawer />
<TypedDataAutoDrawer />
<TypedDataExplicitDrawer />
<TypedDataFactoryDrawer />
<Card class="mb-4" title="基本使用">
<p class="mb-3">一个基础的抽屉示例</p>
@@ -125,6 +178,9 @@ function openFormDrawer() {
<Button class="mb-2 ml-2" type="primary" @click="openBaseDrawer('top')">
顶部打开
</Button>
<Button class="mb-2 ml-2" type="primary" @click="openBlurDrawer">
遮罩层模糊效果
</Button>
</Card>
<Card class="mb-4" title="在内容区域打开">
@@ -184,5 +240,30 @@ function openFormDrawer() {
打开抽屉并设置表单schema以及数据
</Button>
</Card>
<Card class="mb-4" title="共享数据:自动推导">
<p class="mb-3">
子组件 expose API父组件从 connected component 推导类型
</p>
<Button type="primary" @click="openTypedDataAutoDrawer">
打开自动推导示例
</Button>
</Card>
<Card class="mb-4" title="共享数据:显式泛型">
<p class="mb-3">无法自动推导时父子组件显式引用同一个数据类型</p>
<Button type="primary" @click="openTypedDataExplicitDrawer">
打开显式泛型示例
</Button>
</Card>
<Card class="mb-4" title="共享数据:契约工厂">
<p class="mb-3">
通过 createVbenDrawer 预绑定类型并复用 typed composable
</p>
<Button type="primary" @click="openTypedDataFactoryDrawer">
打开契约工厂示例
</Button>
</Card>
</Page>
</template>

View File

@@ -3,11 +3,16 @@ import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
const data = ref();
interface SharedData {
content: string;
payload: string;
}
const [Drawer, drawerApi] = useVbenDrawer({
const data = ref<SharedData>();
const [Drawer, drawerApi] = useVbenDrawer<SharedData>({
onCancel() {
drawerApi.close();
},
@@ -17,10 +22,12 @@ const [Drawer, drawerApi] = useVbenDrawer({
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
data.value = drawerApi.getData<Record<string, any>>();
data.value = drawerApi.getData();
}
},
});
defineExpose({ drawerApi });
</script>
<template>
<Drawer title="数据共享示例">

Some files were not shown because too many files have changed in this diff Show More