mirror of
https://github.com/vbenjs/vue-vben-admin.git
synced 2025-08-25 08:06:30 +08:00
Merge branch 'main' into form
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/design",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/icons",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/shared",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/typings",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
3
packages/@core/base/typings/src/app.d.ts
vendored
3
packages/@core/base/typings/src/app.d.ts
vendored
@@ -60,8 +60,9 @@ type BreadcrumbStyleType = 'background' | 'normal';
|
||||
* 权限模式
|
||||
* backend 后端权限模式
|
||||
* frontend 前端权限模式
|
||||
* mixed 混合权限模式
|
||||
*/
|
||||
type AccessModeType = 'backend' | 'frontend';
|
||||
type AccessModeType = 'backend' | 'frontend' | 'mixed';
|
||||
|
||||
/**
|
||||
* 导航风格
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/composables",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -61,6 +61,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
|
||||
},
|
||||
"logo": {
|
||||
"enable": true,
|
||||
"fit": "contain",
|
||||
"source": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp",
|
||||
},
|
||||
"navigation": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/preferences",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -62,6 +62,7 @@ const defaultPreferences: Preferences = {
|
||||
|
||||
logo: {
|
||||
enable: true,
|
||||
fit: 'contain',
|
||||
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
|
||||
},
|
||||
navigation: {
|
||||
|
@@ -134,6 +134,8 @@ interface HeaderPreferences {
|
||||
interface LogoPreferences {
|
||||
/** logo是否可见 */
|
||||
enable: boolean;
|
||||
/** logo图片适应方式 */
|
||||
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
/** logo地址 */
|
||||
source: string;
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/form-ui",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -48,13 +48,18 @@ const queryFormStyle = computed(() => {
|
||||
async function handleSubmit(e: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const { valid } = await form.validate();
|
||||
const props = unref(rootProps);
|
||||
if (!props.formApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { valid } = await props.formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = toRaw(await unref(rootProps).formApi?.getValues());
|
||||
await unref(rootProps).handleSubmit?.(values);
|
||||
const values = toRaw(await props.formApi.getValues());
|
||||
await props.handleSubmit?.(values);
|
||||
}
|
||||
|
||||
async function handleReset(e: Event) {
|
||||
|
@@ -11,7 +11,7 @@ import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { FormActions, FormSchema, VbenFormProps } from './types';
|
||||
|
||||
import { toRaw } from 'vue';
|
||||
import { isRef, toRaw } from 'vue';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import {
|
||||
@@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps {
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: {},
|
||||
schema: [],
|
||||
scrollToFirstError: false,
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: {},
|
||||
@@ -100,9 +101,26 @@ export class FormApi {
|
||||
getFieldComponentRef<T = ComponentPublicInstance>(
|
||||
fieldName: string,
|
||||
): T | undefined {
|
||||
return this.componentRefMap.has(fieldName)
|
||||
? (this.componentRefMap.get(fieldName) as T)
|
||||
let target = this.componentRefMap.has(fieldName)
|
||||
? (this.componentRefMap.get(fieldName) as ComponentPublicInstance)
|
||||
: undefined;
|
||||
if (
|
||||
target &&
|
||||
target.$.type.name === 'AsyncComponentWrapper' &&
|
||||
target.$.subTree.ref
|
||||
) {
|
||||
if (Array.isArray(target.$.subTree.ref)) {
|
||||
if (
|
||||
target.$.subTree.ref.length > 0 &&
|
||||
isRef(target.$.subTree.ref[0]?.r)
|
||||
) {
|
||||
target = target.$.subTree.ref[0]?.r.value as ComponentPublicInstance;
|
||||
}
|
||||
} else if (isRef(target.$.subTree.ref.r)) {
|
||||
target = target.$.subTree.ref.r.value as ComponentPublicInstance;
|
||||
}
|
||||
}
|
||||
return target as T;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +254,41 @@ export class FormApi {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到第一个错误字段
|
||||
* @param errors 验证错误对象
|
||||
*/
|
||||
scrollToFirstError(errors: Record<string, any> | string) {
|
||||
// https://github.com/logaretm/vee-validate/discussions/3835
|
||||
const firstErrorFieldName =
|
||||
typeof errors === 'string' ? errors : Object.keys(errors)[0];
|
||||
|
||||
if (!firstErrorFieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
let el = document.querySelector(
|
||||
`[name="${firstErrorFieldName}"]`,
|
||||
) as HTMLElement;
|
||||
|
||||
// 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的
|
||||
if (!el) {
|
||||
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
|
||||
if (componentRef && componentRef.$el instanceof HTMLElement) {
|
||||
el = componentRef.$el;
|
||||
}
|
||||
}
|
||||
|
||||
if (el) {
|
||||
// 滚动到错误字段,添加一些偏移量以确保字段完全可见
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
|
||||
const form = await this.getForm();
|
||||
form.setFieldValue(field, value, shouldValidate);
|
||||
@@ -360,14 +413,21 @@ export class FormApi {
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
|
||||
if (this.state?.scrollToFirstError) {
|
||||
this.scrollToFirstError(validateResult.errors);
|
||||
}
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
async validateAndSubmitForm() {
|
||||
const form = await this.getForm();
|
||||
const { valid } = await form.validate();
|
||||
const { valid, errors } = await form.validate();
|
||||
if (!valid) {
|
||||
if (this.state?.scrollToFirstError) {
|
||||
this.scrollToFirstError(errors);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return await this.submitForm();
|
||||
@@ -379,6 +439,10 @@ export class FormApi {
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
|
||||
if (this.state?.scrollToFirstError) {
|
||||
this.scrollToFirstError(fieldName);
|
||||
}
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
@@ -387,6 +387,12 @@ export interface VbenFormProps<
|
||||
*/
|
||||
resetButtonOptions?: ActionButtonOptions;
|
||||
|
||||
/**
|
||||
* 验证失败时是否自动滚动到第一个错误字段
|
||||
* @default false
|
||||
*/
|
||||
scrollToFirstError?: boolean;
|
||||
|
||||
/**
|
||||
* 是否显示默认操作按钮
|
||||
* @default true
|
||||
|
@@ -10,7 +10,7 @@ import { createContext } from '@vben-core/shadcn-ui';
|
||||
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useForm } from 'vee-validate';
|
||||
import { object } from 'zod';
|
||||
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
|
||||
import { getDefaultsForSchema } from 'zod-defaults';
|
||||
|
||||
type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi };
|
||||
@@ -52,7 +52,12 @@ export function useFormInitial(
|
||||
if (Reflect.has(item, 'defaultValue')) {
|
||||
set(initialValues, item.fieldName, item.defaultValue);
|
||||
} else if (item.rules && !isString(item.rules)) {
|
||||
// 检查规则是否适合提取默认值
|
||||
const customDefaultValue = getCustomDefaultValue(item.rules);
|
||||
zodObject[item.fieldName] = item.rules;
|
||||
if (customDefaultValue !== undefined) {
|
||||
initialValues[item.fieldName] = customDefaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,6 +69,38 @@ export function useFormInitial(
|
||||
}
|
||||
return mergeWithArrayOverride(initialValues, zodDefaults);
|
||||
}
|
||||
// 自定义默认值提取逻辑
|
||||
function getCustomDefaultValue(rule: any): any {
|
||||
if (rule instanceof ZodString) {
|
||||
return ''; // 默认为空字符串
|
||||
} else if (rule instanceof ZodNumber) {
|
||||
return null; // 默认为 null(避免显示 0)
|
||||
} else if (rule instanceof ZodObject) {
|
||||
// 递归提取嵌套对象的默认值
|
||||
const defaultValues: Record<string, any> = {};
|
||||
for (const [key, valueSchema] of Object.entries(rule.shape)) {
|
||||
defaultValues[key] = getCustomDefaultValue(valueSchema);
|
||||
}
|
||||
return defaultValues;
|
||||
} else if (rule instanceof ZodIntersection) {
|
||||
// 对于交集类型,从schema 提取默认值
|
||||
const leftDefaultValue = getCustomDefaultValue(rule._def.left);
|
||||
const rightDefaultValue = getCustomDefaultValue(rule._def.right);
|
||||
|
||||
// 如果左右两边都能提取默认值,合并它们
|
||||
if (
|
||||
typeof leftDefaultValue === 'object' &&
|
||||
typeof rightDefaultValue === 'object'
|
||||
) {
|
||||
return { ...leftDefaultValue, ...rightDefaultValue };
|
||||
}
|
||||
|
||||
// 否则优先使用左边的默认值
|
||||
return leftDefaultValue ?? rightDefaultValue;
|
||||
} else {
|
||||
return undefined; // 其他类型不提供默认值
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
delegatedSlots,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/layout-ui",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/menu-ui",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -34,7 +34,6 @@ const props = withDefaults(defineProps<AlertProps>(), {
|
||||
bordered: true,
|
||||
buttonAlign: 'end',
|
||||
centered: true,
|
||||
containerClass: 'w-[520px]',
|
||||
});
|
||||
const emits = defineEmits(['closed', 'confirm', 'opened']);
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
@@ -148,7 +147,7 @@ async function handleOpenChange(val: boolean) {
|
||||
:class="
|
||||
cn(
|
||||
containerClass,
|
||||
'left-0 right-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:rounded-[var(--radius)] md:w-[520px] md:max-w-[80%]',
|
||||
'left-0 right-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:w-[520px] sm:max-w-[80%] sm:rounded-[var(--radius)]',
|
||||
{
|
||||
'border-border border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
|
@@ -1,7 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerProps, ExtendedDrawerApi } from './drawer';
|
||||
|
||||
import { computed, provide, ref, unref, useId, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onDeactivated,
|
||||
provide,
|
||||
ref,
|
||||
unref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
useIsMobile,
|
||||
@@ -94,6 +102,16 @@ const {
|
||||
// },
|
||||
// );
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
|
||||
if (!appendToMain.value) {
|
||||
props.drawerApi?.close();
|
||||
}
|
||||
});
|
||||
|
||||
function interactOutside(e: Event) {
|
||||
if (!closeOnClickModal.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
|
@@ -9,7 +9,6 @@ import {
|
||||
h,
|
||||
inject,
|
||||
nextTick,
|
||||
onDeactivated,
|
||||
provide,
|
||||
reactive,
|
||||
ref,
|
||||
@@ -72,13 +71,6 @@ export function useVbenDrawer<
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
(extendedApi as ExtendedDrawerApi)?.close?.();
|
||||
});
|
||||
|
||||
return [Drawer, extendedApi as ExtendedDrawerApi] as const;
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ExtendedModalApi, ModalProps } from './modal';
|
||||
|
||||
import { computed, nextTick, provide, ref, unref, useId, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onDeactivated,
|
||||
provide,
|
||||
ref,
|
||||
unref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
useIsMobile,
|
||||
@@ -96,10 +105,17 @@ const shouldDraggable = computed(
|
||||
() => draggable.value && !shouldFullscreen.value && header.value,
|
||||
);
|
||||
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value
|
||||
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const { dragging, transform } = useModalDraggable(
|
||||
dialogRef,
|
||||
headerRef,
|
||||
shouldDraggable,
|
||||
getAppendTo,
|
||||
);
|
||||
|
||||
const firstOpened = ref(false);
|
||||
@@ -135,6 +151,16 @@ watch(
|
||||
// },
|
||||
// );
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
|
||||
if (!appendToMain.value) {
|
||||
props.modalApi?.close();
|
||||
}
|
||||
});
|
||||
|
||||
function handleFullscreen() {
|
||||
props.modalApi?.setState((prev) => {
|
||||
// if (prev.fullscreen) {
|
||||
@@ -179,11 +205,6 @@ function handleFocusOutside(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value
|
||||
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const getForceMount = computed(() => {
|
||||
return !unref(destroyOnClose) && unref(firstOpened);
|
||||
@@ -205,7 +226,8 @@ function handleClosed() {
|
||||
:append-to="getAppendTo"
|
||||
:class="
|
||||
cn(
|
||||
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0 sm:rounded-[var(--radius)]',
|
||||
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0',
|
||||
shouldFullscreen ? 'sm:rounded-none' : 'sm:rounded-[var(--radius)]',
|
||||
modalClass,
|
||||
{
|
||||
'border-border border': bordered,
|
||||
|
@@ -13,6 +13,7 @@ export function useModalDraggable(
|
||||
targetRef: Ref<HTMLElement | undefined>,
|
||||
dragRef: Ref<HTMLElement | undefined>,
|
||||
draggable: ComputedRef<boolean>,
|
||||
containerSelector?: ComputedRef<string | undefined>,
|
||||
) {
|
||||
const transform = reactive({
|
||||
offsetX: 0,
|
||||
@@ -30,20 +31,36 @@ export function useModalDraggable(
|
||||
}
|
||||
|
||||
const targetRect = targetRef.value.getBoundingClientRect();
|
||||
|
||||
const { offsetX, offsetY } = transform;
|
||||
const targetLeft = targetRect.left;
|
||||
const targetTop = targetRect.top;
|
||||
const targetWidth = targetRect.width;
|
||||
const targetHeight = targetRect.height;
|
||||
const docElement = document.documentElement;
|
||||
const clientWidth = docElement.clientWidth;
|
||||
const clientHeight = docElement.clientHeight;
|
||||
|
||||
const minLeft = -targetLeft + offsetX;
|
||||
const minTop = -targetTop + offsetY;
|
||||
const maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
|
||||
const maxTop = clientHeight - targetTop - targetHeight + offsetY;
|
||||
let containerRect: DOMRect | null = null;
|
||||
|
||||
if (containerSelector?.value) {
|
||||
const container = document.querySelector(containerSelector.value);
|
||||
if (container) {
|
||||
containerRect = container.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
|
||||
let maxLeft, maxTop, minLeft, minTop;
|
||||
if (containerRect) {
|
||||
minLeft = containerRect.left - targetLeft + offsetX;
|
||||
maxLeft = containerRect.right - targetLeft - targetWidth + offsetX;
|
||||
minTop = containerRect.top - targetTop + offsetY;
|
||||
maxTop = containerRect.bottom - targetTop - targetHeight + offsetY;
|
||||
} else {
|
||||
const docElement = document.documentElement;
|
||||
const clientWidth = docElement.clientWidth;
|
||||
const clientHeight = docElement.clientHeight;
|
||||
minLeft = -targetLeft + offsetX;
|
||||
minTop = -targetTop + offsetY;
|
||||
maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
|
||||
maxTop = clientHeight - targetTop - targetHeight + offsetY;
|
||||
}
|
||||
|
||||
const onMousemove = (e: MouseEvent) => {
|
||||
let moveX = offsetX + e.clientX - downX;
|
||||
|
@@ -5,7 +5,6 @@ import {
|
||||
h,
|
||||
inject,
|
||||
nextTick,
|
||||
onDeactivated,
|
||||
provide,
|
||||
reactive,
|
||||
ref,
|
||||
@@ -71,13 +70,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
(extendedApi as ExtendedModalApi)?.close?.();
|
||||
});
|
||||
|
||||
return [Modal, extendedApi as ExtendedModalApi] as const;
|
||||
}
|
||||
|
||||
@@ -94,8 +86,9 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
||||
injectData.options?.onOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
const onClosed = mergedOptions.onClosed;
|
||||
mergedOptions.onClosed = () => {
|
||||
options.onClosed?.();
|
||||
onClosed?.();
|
||||
if (mergedOptions.destroyOnClose) {
|
||||
injectData.reCreateModal?.();
|
||||
}
|
||||
@@ -129,6 +122,7 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
|
||||
return [Modal, extendedApi] as const;
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/shadcn-ui",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"#main": "./dist/index.mjs",
|
||||
"#module": "./dist/index.mjs",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
|
@@ -5,6 +5,8 @@ import type {
|
||||
AvatarRootProps,
|
||||
} from 'radix-vue';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
@@ -16,6 +18,7 @@ interface Props extends AvatarFallbackProps, AvatarImageProps, AvatarRootProps {
|
||||
class?: ClassType;
|
||||
dot?: boolean;
|
||||
dotClass?: ClassType;
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
size?: number;
|
||||
}
|
||||
|
||||
@@ -28,6 +31,15 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
dot: false,
|
||||
dotClass: 'bg-green-500',
|
||||
fit: 'cover',
|
||||
});
|
||||
|
||||
const imageStyle = computed<CSSProperties>(() => {
|
||||
const { fit } = props;
|
||||
if (fit) {
|
||||
return { objectFit: fit };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const text = computed(() => {
|
||||
@@ -51,7 +63,7 @@ const rootStyle = computed(() => {
|
||||
class="relative flex flex-shrink-0 items-center"
|
||||
>
|
||||
<Avatar :class="props.class" class="size-full">
|
||||
<AvatarImage :alt="alt" :src="src" />
|
||||
<AvatarImage :alt="alt" :src="src" :style="imageStyle" />
|
||||
<AvatarFallback>{{ text }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
|
@@ -29,14 +29,25 @@ export type ValueType = boolean | number | string;
|
||||
|
||||
export interface VbenButtonGroupProps
|
||||
extends Pick<VbenButtonProps, 'disabled'> {
|
||||
/** 单选模式下允许清除选中 */
|
||||
allowClear?: boolean;
|
||||
/** 值改变前的回调 */
|
||||
beforeChange?: (
|
||||
value: ValueType,
|
||||
isChecked: boolean,
|
||||
) => boolean | PromiseLike<boolean | undefined> | undefined;
|
||||
/** 按钮样式 */
|
||||
btnClass?: any;
|
||||
/** 按钮间隔距离 */
|
||||
gap?: number;
|
||||
/** 多选模式下限制最多选择的数量。0表示不限制 */
|
||||
maxCount?: number;
|
||||
/** 是否允许多选 */
|
||||
multiple?: boolean;
|
||||
/** 选项 */
|
||||
options?: { [key: string]: any; label: CustomRenderType; value: ValueType }[];
|
||||
/** 显示图标 */
|
||||
showIcon?: boolean;
|
||||
/** 尺寸 */
|
||||
size?: 'large' | 'middle' | 'small';
|
||||
}
|
||||
|
@@ -19,6 +19,8 @@ const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
|
||||
multiple: false,
|
||||
showIcon: true,
|
||||
size: 'middle',
|
||||
allowClear: false,
|
||||
maxCount: 0,
|
||||
});
|
||||
const emit = defineEmits(['btnClick']);
|
||||
const btnDefaultProps = computed(() => {
|
||||
@@ -82,12 +84,22 @@ async function onBtnClick(value: ValueType) {
|
||||
if (innerValue.value.includes(value)) {
|
||||
innerValue.value = innerValue.value.filter((item) => item !== value);
|
||||
} else {
|
||||
if (props.maxCount > 0 && innerValue.value.length >= props.maxCount) {
|
||||
innerValue.value = innerValue.value.slice(0, props.maxCount - 1);
|
||||
}
|
||||
innerValue.value.push(value);
|
||||
}
|
||||
modelValue.value = innerValue.value;
|
||||
} else {
|
||||
innerValue.value = [value];
|
||||
modelValue.value = value;
|
||||
if (props.allowClear && innerValue.value.includes(value)) {
|
||||
innerValue.value = [];
|
||||
modelValue.value = undefined;
|
||||
emit('btnClick', undefined);
|
||||
return;
|
||||
} else {
|
||||
innerValue.value = [value];
|
||||
modelValue.value = value;
|
||||
}
|
||||
}
|
||||
emit('btnClick', value);
|
||||
}
|
||||
@@ -110,14 +122,21 @@ async function onBtnClick(value: ValueType) {
|
||||
v-bind="btnDefaultProps"
|
||||
:variant="innerValue.includes(btn.value) ? 'default' : 'outline'"
|
||||
@click="onBtnClick(btn.value)"
|
||||
type="button"
|
||||
>
|
||||
<div class="icon-wrapper" v-if="props.showIcon">
|
||||
<LoaderCircle
|
||||
class="animate-spin"
|
||||
v-if="loadingValues.includes(btn.value)"
|
||||
/>
|
||||
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
|
||||
<Circle v-else />
|
||||
<slot
|
||||
name="icon"
|
||||
:loading="loadingValues.includes(btn.value)"
|
||||
:checked="innerValue.includes(btn.value)"
|
||||
>
|
||||
<LoaderCircle
|
||||
class="animate-spin"
|
||||
v-if="loadingValues.includes(btn.value)"
|
||||
/>
|
||||
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
|
||||
<Circle v-else />
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="option" :label="btn.label" :value="btn.value" :data="btn">
|
||||
<VbenRenderContent :content="btn.label" />
|
||||
|
@@ -6,6 +6,10 @@ interface Props {
|
||||
* @zh_CN 是否收起文本
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* @zh_CN Logo 图片适应方式
|
||||
*/
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
/**
|
||||
* @zh_CN Logo 跳转地址
|
||||
*/
|
||||
@@ -38,6 +42,7 @@ withDefaults(defineProps<Props>(), {
|
||||
logoSize: 32,
|
||||
src: '',
|
||||
theme: 'light',
|
||||
fit: 'cover',
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -53,6 +58,7 @@ withDefaults(defineProps<Props>(), {
|
||||
:alt="text"
|
||||
:src="src"
|
||||
:size="logoSize"
|
||||
:fit="fit"
|
||||
class="relative rounded-none bg-transparent"
|
||||
/>
|
||||
<template v-if="!collapsed">
|
||||
|
@@ -80,7 +80,7 @@ defineExpose({
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'z-popup bg-background w-full p-6 shadow-lg outline-none sm:rounded-xl',
|
||||
'z-popup bg-background p-6 shadow-lg outline-none sm:rounded-xl',
|
||||
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
{
|
||||
|
@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<TreeProps>(), {
|
||||
defaultExpandedKeys: () => [],
|
||||
defaultExpandedLevel: 0,
|
||||
disabled: false,
|
||||
disabledField: 'disabled',
|
||||
expanded: () => [],
|
||||
iconField: 'icon',
|
||||
labelField: 'label',
|
||||
@@ -101,16 +102,37 @@ function updateTreeValue() {
|
||||
if (val === undefined) {
|
||||
treeValue.value = undefined;
|
||||
} else {
|
||||
treeValue.value = Array.isArray(val)
|
||||
? val.map((v) => getItemByValue(v))
|
||||
: getItemByValue(val);
|
||||
if (Array.isArray(val)) {
|
||||
const filteredValues = val.filter((v) => {
|
||||
const item = getItemByValue(v);
|
||||
return item && !get(item, props.disabledField);
|
||||
});
|
||||
treeValue.value = filteredValues.map((v) => getItemByValue(v));
|
||||
|
||||
if (filteredValues.length !== val.length) {
|
||||
modelValue.value = filteredValues;
|
||||
}
|
||||
} else {
|
||||
const item = getItemByValue(val);
|
||||
if (item && !get(item, props.disabledField)) {
|
||||
treeValue.value = item;
|
||||
} else {
|
||||
treeValue.value = undefined;
|
||||
modelValue.value = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateModelValue(val: Arrayable<Recordable<any>>) {
|
||||
modelValue.value = Array.isArray(val)
|
||||
? val.map((v) => get(v, props.valueField))
|
||||
: get(val, props.valueField);
|
||||
if (Array.isArray(val)) {
|
||||
const filteredVal = val.filter((v) => !get(v, props.disabledField));
|
||||
modelValue.value = filteredVal.map((v) => get(v, props.valueField));
|
||||
} else {
|
||||
if (val && !get(val, props.disabledField)) {
|
||||
modelValue.value = get(val, props.valueField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expandToLevel(level: number) {
|
||||
@@ -149,10 +171,18 @@ function collapseAll() {
|
||||
expanded.value = [];
|
||||
}
|
||||
|
||||
function isNodeDisabled(item: FlattenedItem<Recordable<any>>) {
|
||||
return props.disabled || get(item.value, props.disabledField);
|
||||
}
|
||||
|
||||
function onToggle(item: FlattenedItem<Recordable<any>>) {
|
||||
emits('expand', item);
|
||||
}
|
||||
function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
|
||||
if (isNodeDisabled(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!props.checkStrictly &&
|
||||
props.multiple &&
|
||||
@@ -224,34 +254,44 @@ defineExpose({
|
||||
:class="
|
||||
cn('cursor-pointer', getNodeClass?.(item), {
|
||||
'data-[selected]:bg-accent': !multiple,
|
||||
'cursor-not-allowed': disabled,
|
||||
'cursor-not-allowed': isNodeDisabled(item),
|
||||
})
|
||||
"
|
||||
v-bind="
|
||||
Object.assign(item.bind, {
|
||||
onfocus: disabled ? 'this.blur()' : undefined,
|
||||
onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined,
|
||||
disabled: isNodeDisabled(item),
|
||||
})
|
||||
"
|
||||
@select="
|
||||
(event) => {
|
||||
(event: any) => {
|
||||
if (isNodeDisabled(item)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (event.detail.originalEvent.type === 'click') {
|
||||
event.preventDefault();
|
||||
}
|
||||
!disabled && onSelect(item, event.detail.isSelected);
|
||||
onSelect(item, event.detail.isSelected);
|
||||
}
|
||||
"
|
||||
@toggle="
|
||||
(event) => {
|
||||
(event: any) => {
|
||||
if (event.detail.originalEvent.type === 'click') {
|
||||
event.preventDefault();
|
||||
}
|
||||
!disabled && onToggle(item);
|
||||
!isNodeDisabled(item) && onToggle(item);
|
||||
}
|
||||
"
|
||||
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
|
||||
>
|
||||
<ChevronRight
|
||||
v-if="item.hasChildren"
|
||||
v-if="
|
||||
item.hasChildren &&
|
||||
Array.isArray(item.value[childrenField]) &&
|
||||
item.value[childrenField].length > 0
|
||||
"
|
||||
class="size-4 cursor-pointer transition"
|
||||
:class="{ 'rotate-90': isExpanded }"
|
||||
@click.stop="
|
||||
@@ -266,24 +306,32 @@ defineExpose({
|
||||
</div>
|
||||
<Checkbox
|
||||
v-if="multiple"
|
||||
:checked="isSelected"
|
||||
:disabled="disabled"
|
||||
:indeterminate="isIndeterminate"
|
||||
:checked="isSelected && !isNodeDisabled(item)"
|
||||
:disabled="isNodeDisabled(item)"
|
||||
:indeterminate="isIndeterminate && !isNodeDisabled(item)"
|
||||
@click="
|
||||
() => {
|
||||
!disabled && handleSelect();
|
||||
// onSelect(item, !isSelected);
|
||||
(event: MouseEvent) => {
|
||||
if (isNodeDisabled(item)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
handleSelect();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center gap-1 pl-2"
|
||||
@click="
|
||||
(_event) => {
|
||||
// $event.stopPropagation();
|
||||
// $event.preventDefault();
|
||||
!disabled && handleSelect();
|
||||
// onSelect(item, !isSelected);
|
||||
(event: MouseEvent) => {
|
||||
if (isNodeDisabled(item)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
"
|
||||
>
|
||||
|
@@ -22,6 +22,8 @@ export interface TreeProps {
|
||||
defaultValue?: Arrayable<number | string>;
|
||||
/** 禁用 */
|
||||
disabled?: boolean;
|
||||
/** 禁用字段名 */
|
||||
disabledField?: string;
|
||||
/** 自定义节点类名 */
|
||||
getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
|
||||
iconField?: string;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben-core/tabs-ui",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/constants",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/access",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -96,6 +96,15 @@ async function generateRoutes(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'mixed': {
|
||||
const [frontend_resultRoutes, backend_resultRoutes] = await Promise.all([
|
||||
generateRoutesByFrontend(routes, roles || [], forbiddenComponent),
|
||||
generateRoutesByBackend(options),
|
||||
]);
|
||||
|
||||
resultRoutes = [...frontend_resultRoutes, ...backend_resultRoutes];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/common-ui",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
@@ -40,6 +40,7 @@
|
||||
"@vben/types": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"json-bigint": "catalog:",
|
||||
"qrcode": "catalog:",
|
||||
"tippy.js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
|
@@ -3,11 +3,11 @@ import type { Component } from 'vue';
|
||||
|
||||
import type { AnyPromiseFunction } from '@vben/types';
|
||||
|
||||
import { computed, ref, unref, useAttrs, watch } from 'vue';
|
||||
import { computed, nextTick, ref, unref, useAttrs, watch } from 'vue';
|
||||
|
||||
import { LoaderCircle } from '@vben/icons';
|
||||
|
||||
import { get, isEqual, isFunction } from '@vben-core/shared/utils';
|
||||
import { cloneDeep, get, isEqual, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { objectOmit } from '@vueuse/core';
|
||||
|
||||
@@ -104,6 +104,8 @@ const refOptions = ref<OptionsItem[]>([]);
|
||||
const loading = ref(false);
|
||||
// 首次是否加载过了
|
||||
const isFirstLoaded = ref(false);
|
||||
// 标记是否有待处理的请求
|
||||
const hasPendingRequest = ref(false);
|
||||
|
||||
const getOptions = computed(() => {
|
||||
const { labelField, valueField, childrenField, numberToString } = props;
|
||||
@@ -146,18 +148,26 @@ const bindProps = computed(() => {
|
||||
});
|
||||
|
||||
async function fetchApi() {
|
||||
let { api, beforeFetch, afterFetch, params, resultField } = props;
|
||||
const { api, beforeFetch, afterFetch, resultField } = props;
|
||||
|
||||
if (!api || !isFunction(api) || loading.value) {
|
||||
if (!api || !isFunction(api)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在加载,标记有待处理的请求并返回
|
||||
if (loading.value) {
|
||||
hasPendingRequest.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
refOptions.value = [];
|
||||
try {
|
||||
loading.value = true;
|
||||
let finalParams = unref(mergedParams);
|
||||
if (beforeFetch && isFunction(beforeFetch)) {
|
||||
params = (await beforeFetch(params)) || params;
|
||||
finalParams = (await beforeFetch(cloneDeep(finalParams))) || finalParams;
|
||||
}
|
||||
let res = await api(params);
|
||||
let res = await api(finalParams);
|
||||
if (afterFetch && isFunction(afterFetch)) {
|
||||
res = (await afterFetch(res)) || res;
|
||||
}
|
||||
@@ -177,6 +187,13 @@ async function fetchApi() {
|
||||
isFirstLoaded.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// 如果有待处理的请求,立即触发新的请求
|
||||
if (hasPendingRequest.value) {
|
||||
hasPendingRequest.value = false;
|
||||
// 使用 nextTick 确保状态更新完成后再触发新请求
|
||||
await nextTick();
|
||||
fetchApi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +207,7 @@ async function handleFetchForVisible(visible: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const params = computed(() => {
|
||||
const mergedParams = computed(() => {
|
||||
return {
|
||||
...props.params,
|
||||
...unref(innerParams),
|
||||
@@ -198,7 +215,7 @@ const params = computed(() => {
|
||||
});
|
||||
|
||||
watch(
|
||||
params,
|
||||
mergedParams,
|
||||
(value, oldValue) => {
|
||||
if (isEqual(value, oldValue)) {
|
||||
return;
|
||||
|
@@ -3,4 +3,5 @@ export { default as PointSelectionCaptchaCard } from './point-selection-captcha/
|
||||
|
||||
export { default as SliderCaptcha } from './slider-captcha/index.vue';
|
||||
export { default as SliderRotateCaptcha } from './slider-rotate-captcha/index.vue';
|
||||
export { default as SliderTranslateCaptcha } from './slider-translate-captcha/index.vue';
|
||||
export type * from './types';
|
||||
|
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CaptchaVerifyPassingData,
|
||||
SliderCaptchaActionType,
|
||||
SliderRotateVerifyPassingData,
|
||||
SliderTranslateCaptchaProps,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
unref,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SliderCaptcha from '../slider-captcha/index.vue';
|
||||
|
||||
const props = withDefaults(defineProps<SliderTranslateCaptchaProps>(), {
|
||||
defaultTip: '',
|
||||
canvasWidth: 420,
|
||||
canvasHeight: 280,
|
||||
squareLength: 42,
|
||||
circleRadius: 10,
|
||||
src: '',
|
||||
diffDistance: 3,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [CaptchaVerifyPassingData];
|
||||
}>();
|
||||
|
||||
const PI: number = Math.PI;
|
||||
enum CanvasOpr {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Clip = 'clip',
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Fill = 'fill',
|
||||
}
|
||||
|
||||
const modalValue = defineModel<boolean>({ default: false });
|
||||
|
||||
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
|
||||
const puzzleCanvasRef = useTemplateRef<HTMLCanvasElement>('puzzleCanvasRef');
|
||||
const pieceCanvasRef = useTemplateRef<HTMLCanvasElement>('pieceCanvasRef');
|
||||
|
||||
const state = reactive({
|
||||
dragging: false,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
pieceX: 0,
|
||||
pieceY: 0,
|
||||
moveDistance: 0,
|
||||
isPassing: false,
|
||||
showTip: false,
|
||||
});
|
||||
|
||||
const left = ref('0');
|
||||
|
||||
const pieceStyle = computed(() => {
|
||||
return {
|
||||
left: left.value,
|
||||
};
|
||||
});
|
||||
|
||||
function setLeft(val: string) {
|
||||
left.value = val;
|
||||
}
|
||||
|
||||
const verifyTip = computed(() => {
|
||||
return state.isPassing
|
||||
? $t('ui.captcha.sliderTranslateSuccessTip', [
|
||||
((state.endTime - state.startTime) / 1000).toFixed(1),
|
||||
])
|
||||
: $t('ui.captcha.sliderTranslateFailTip');
|
||||
});
|
||||
function handleStart() {
|
||||
state.startTime = Date.now();
|
||||
}
|
||||
|
||||
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||
state.dragging = true;
|
||||
const { moveX } = data;
|
||||
state.moveDistance = moveX;
|
||||
setLeft(`${moveX}px`);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
const { pieceX } = state;
|
||||
const { diffDistance } = props;
|
||||
|
||||
if (Math.abs(pieceX - state.moveDistance) >= (diffDistance || 3)) {
|
||||
setLeft('0');
|
||||
state.moveDistance = 0;
|
||||
} else {
|
||||
checkPass();
|
||||
}
|
||||
state.showTip = true;
|
||||
state.dragging = false;
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
state.isPassing = true;
|
||||
state.endTime = Date.now();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
(isPassing) => {
|
||||
if (isPassing) {
|
||||
const { endTime, startTime } = state;
|
||||
const time = (endTime - startTime) / 1000;
|
||||
emit('success', { isPassing, time: time.toFixed(1) });
|
||||
}
|
||||
modalValue.value = isPassing;
|
||||
},
|
||||
);
|
||||
|
||||
function resetCanvas() {
|
||||
const { canvasWidth, canvasHeight } = props;
|
||||
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||
const pieceCanvas = unref(pieceCanvasRef);
|
||||
if (!puzzleCanvas || !pieceCanvas) return;
|
||||
pieceCanvas.width = canvasWidth;
|
||||
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||
// Canvas2D: Multiple readback operations using getImageData
|
||||
// are faster with the willReadFrequently attribute set to true.
|
||||
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
||||
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||
puzzleCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
pieceCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const { canvasWidth, canvasHeight, squareLength, circleRadius, src } = props;
|
||||
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||
const pieceCanvas = unref(pieceCanvasRef);
|
||||
if (!puzzleCanvas || !pieceCanvas) return;
|
||||
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||
// Canvas2D: Multiple readback operations using getImageData
|
||||
// are faster with the willReadFrequently attribute set to true.
|
||||
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
||||
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||
const img = new Image();
|
||||
// 解决跨域
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.src = src;
|
||||
img.addEventListener('load', () => {
|
||||
draw(puzzleCanvasCtx, pieceCanvasCtx);
|
||||
puzzleCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||
pieceCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||
const pieceLength = squareLength + 2 * circleRadius + 3;
|
||||
const sx = state.pieceX;
|
||||
const sy = state.pieceY - 2 * circleRadius - 1;
|
||||
const imageData = pieceCanvasCtx.getImageData(
|
||||
sx,
|
||||
sy,
|
||||
pieceLength,
|
||||
pieceLength,
|
||||
);
|
||||
pieceCanvas.width = pieceLength;
|
||||
pieceCanvasCtx.putImageData(imageData, 0, sy);
|
||||
setLeft('0');
|
||||
});
|
||||
}
|
||||
|
||||
function getRandomNumberByRange(start: number, end: number) {
|
||||
return Math.round(Math.random() * (end - start) + start);
|
||||
}
|
||||
|
||||
// 绘制拼图
|
||||
function draw(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D) {
|
||||
const { canvasWidth, canvasHeight, squareLength, circleRadius } = props;
|
||||
state.pieceX = getRandomNumberByRange(
|
||||
squareLength + 2 * circleRadius,
|
||||
canvasWidth - (squareLength + 2 * circleRadius),
|
||||
);
|
||||
state.pieceY = getRandomNumberByRange(
|
||||
3 * circleRadius,
|
||||
canvasHeight - (squareLength + 2 * circleRadius),
|
||||
);
|
||||
drawPiece(ctx1, state.pieceX, state.pieceY, CanvasOpr.Fill);
|
||||
drawPiece(ctx2, state.pieceX, state.pieceY, CanvasOpr.Clip);
|
||||
}
|
||||
|
||||
// 绘制拼图切块
|
||||
function drawPiece(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
opr: CanvasOpr,
|
||||
) {
|
||||
const { squareLength, circleRadius } = props;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.arc(
|
||||
x + squareLength / 2,
|
||||
y - circleRadius + 2,
|
||||
circleRadius,
|
||||
0.72 * PI,
|
||||
2.26 * PI,
|
||||
);
|
||||
ctx.lineTo(x + squareLength, y);
|
||||
ctx.arc(
|
||||
x + squareLength + circleRadius - 2,
|
||||
y + squareLength / 2,
|
||||
circleRadius,
|
||||
1.21 * PI,
|
||||
2.78 * PI,
|
||||
);
|
||||
ctx.lineTo(x + squareLength, y + squareLength);
|
||||
ctx.lineTo(x, y + squareLength);
|
||||
ctx.arc(
|
||||
x + circleRadius - 2,
|
||||
y + squareLength / 2,
|
||||
circleRadius + 0.4,
|
||||
2.76 * PI,
|
||||
1.24 * PI,
|
||||
true,
|
||||
);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.stroke();
|
||||
opr === CanvasOpr.Clip ? ctx.clip() : ctx.fill();
|
||||
ctx.globalCompositeOperation = 'destination-over';
|
||||
}
|
||||
|
||||
function resume() {
|
||||
state.showTip = false;
|
||||
const basicEl = unref(slideBarRef);
|
||||
if (!basicEl) {
|
||||
return;
|
||||
}
|
||||
state.dragging = false;
|
||||
state.isPassing = false;
|
||||
state.pieceX = 0;
|
||||
state.pieceY = 0;
|
||||
|
||||
basicEl.resume();
|
||||
resetCanvas();
|
||||
initCanvas();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div
|
||||
class="border-border relative flex cursor-pointer overflow-hidden border shadow-md"
|
||||
>
|
||||
<canvas
|
||||
ref="puzzleCanvasRef"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
@click="resume"
|
||||
></canvas>
|
||||
<canvas
|
||||
ref="pieceCanvasRef"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
:style="pieceStyle"
|
||||
class="absolute"
|
||||
@click="resume"
|
||||
></canvas>
|
||||
<div
|
||||
class="h-15 absolute bottom-3 left-0 z-10 block w-full text-center text-xs leading-[30px] text-white"
|
||||
>
|
||||
<div
|
||||
v-if="state.showTip"
|
||||
:class="{
|
||||
'bg-success/80': state.isPassing,
|
||||
'bg-destructive/80': !state.isPassing,
|
||||
}"
|
||||
>
|
||||
{{ verifyTip }}
|
||||
</div>
|
||||
<div v-if="!state.dragging" class="bg-black/30">
|
||||
{{ defaultTip || $t('ui.captcha.sliderTranslateDefaultTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SliderCaptcha
|
||||
ref="slideBarRef"
|
||||
v-model="modalValue"
|
||||
class="mt-5"
|
||||
is-slot
|
||||
@end="handleDragEnd"
|
||||
@move="handleDragBarMove"
|
||||
@start="handleStart"
|
||||
>
|
||||
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||
<slot :name="key" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</SliderCaptcha>
|
||||
</div>
|
||||
</template>
|
@@ -159,6 +159,42 @@ export interface SliderRotateCaptchaProps {
|
||||
defaultTip?: string;
|
||||
}
|
||||
|
||||
export interface SliderTranslateCaptchaProps {
|
||||
/**
|
||||
* @description 拼图的宽度
|
||||
* @default 420
|
||||
*/
|
||||
canvasWidth?: number;
|
||||
/**
|
||||
* @description 拼图的高度
|
||||
* @default 280
|
||||
*/
|
||||
canvasHeight?: number;
|
||||
/**
|
||||
* @description 切块上正方形的长度
|
||||
* @default 42
|
||||
*/
|
||||
squareLength?: number;
|
||||
/**
|
||||
* @description 切块上圆形的半径
|
||||
* @default 10
|
||||
*/
|
||||
circleRadius?: number;
|
||||
/**
|
||||
* @description 图片的地址
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* @description 允许的最大差距
|
||||
* @default 3
|
||||
*/
|
||||
diffDistance?: number;
|
||||
/**
|
||||
* @description 默认提示文本
|
||||
*/
|
||||
defaultTip?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaVerifyPassingData {
|
||||
isPassing: boolean;
|
||||
time: number | string;
|
||||
|
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, watchEffect } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
|
||||
import { VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
@@ -33,6 +40,16 @@ interface Props {
|
||||
* @default true
|
||||
*/
|
||||
tooltip?: boolean;
|
||||
/**
|
||||
* 是否只在文本被截断时显示提示框
|
||||
* @default false
|
||||
*/
|
||||
tooltipWhenEllipsis?: boolean;
|
||||
/**
|
||||
* 文本截断检测的像素差异阈值,越大则判断越严格
|
||||
* @default 3
|
||||
*/
|
||||
ellipsisThreshold?: number;
|
||||
/**
|
||||
* 提示框背景颜色,优先级高于 overlayStyle
|
||||
*/
|
||||
@@ -62,12 +79,15 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
maxWidth: '100%',
|
||||
placement: 'top',
|
||||
tooltip: true,
|
||||
tooltipWhenEllipsis: false,
|
||||
ellipsisThreshold: 3,
|
||||
tooltipBackgroundColor: '',
|
||||
tooltipColor: '',
|
||||
tooltipFontSize: 14,
|
||||
tooltipMaxWidth: undefined,
|
||||
tooltipOverlayStyle: () => ({ textAlign: 'justify' }),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ expandChange: [boolean] }>();
|
||||
|
||||
const textMaxWidth = computed(() => {
|
||||
@@ -79,9 +99,67 @@ const textMaxWidth = computed(() => {
|
||||
const ellipsis = ref();
|
||||
const isExpand = ref(false);
|
||||
const defaultTooltipMaxWidth = ref();
|
||||
const isEllipsis = ref(false);
|
||||
|
||||
const { width: eleWidth } = useElementSize(ellipsis);
|
||||
|
||||
// 检测文本是否被截断
|
||||
const checkEllipsis = () => {
|
||||
if (!ellipsis.value || !props.tooltipWhenEllipsis) return;
|
||||
|
||||
const element = ellipsis.value;
|
||||
|
||||
const originalText = element.textContent || '';
|
||||
const originalTrimmed = originalText.trim();
|
||||
|
||||
// 对于空文本直接返回 false
|
||||
if (!originalTrimmed) {
|
||||
isEllipsis.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const widthDiff = element.scrollWidth - element.clientWidth;
|
||||
const heightDiff = element.scrollHeight - element.clientHeight;
|
||||
|
||||
// 使用足够大的差异阈值确保只有真正被截断的文本才会显示 tooltip
|
||||
isEllipsis.value =
|
||||
props.line === 1
|
||||
? widthDiff > props.ellipsisThreshold
|
||||
: heightDiff > props.ellipsisThreshold;
|
||||
};
|
||||
|
||||
// 使用 ResizeObserver 监听尺寸变化
|
||||
let resizeObserver: null | ResizeObserver = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver !== 'undefined' && props.tooltipWhenEllipsis) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
checkEllipsis();
|
||||
});
|
||||
|
||||
if (ellipsis.value) {
|
||||
resizeObserver.observe(ellipsis.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始检测
|
||||
checkEllipsis();
|
||||
});
|
||||
|
||||
// 使用onUpdated钩子检测内容变化
|
||||
onUpdated(() => {
|
||||
if (props.tooltipWhenEllipsis) {
|
||||
checkEllipsis();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (props.tooltip && eleWidth.value) {
|
||||
@@ -91,9 +169,13 @@ watchEffect(
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
function onExpand() {
|
||||
isExpand.value = !isExpand.value;
|
||||
emit('expandChange', isExpand.value);
|
||||
if (props.tooltipWhenEllipsis) {
|
||||
checkEllipsis();
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpand() {
|
||||
@@ -110,7 +192,9 @@ function handleExpand() {
|
||||
color: tooltipColor,
|
||||
backgroundColor: tooltipBackgroundColor,
|
||||
}"
|
||||
:disabled="!props.tooltip || isExpand"
|
||||
:disabled="
|
||||
!props.tooltip || isExpand || (props.tooltipWhenEllipsis && !isEllipsis)
|
||||
"
|
||||
:side="placement"
|
||||
>
|
||||
<slot name="tooltip">
|
||||
|
@@ -76,6 +76,12 @@ const keyword = ref('');
|
||||
const keywordDebounce = refDebounced(keyword, 300);
|
||||
const innerIcons = ref<string[]>([]);
|
||||
|
||||
/* 当检索关键词变化时,重置分页 */
|
||||
watch(keywordDebounce, () => {
|
||||
currentPage.value = 1;
|
||||
setCurrentPage(1);
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
() => props.prefix,
|
||||
async (prefix) => {
|
||||
|
@@ -18,6 +18,9 @@ import { $t } from '@vben/locales';
|
||||
|
||||
import { isBoolean } from '@vben-core/shared/utils';
|
||||
|
||||
// @ts-ignore
|
||||
import JsonBigint from 'json-bigint';
|
||||
|
||||
defineOptions({ name: 'JsonViewer' });
|
||||
|
||||
const props = withDefaults(defineProps<JsonViewerProps>(), {
|
||||
@@ -68,6 +71,20 @@ function handleClick(event: MouseEvent) {
|
||||
emit('click', event);
|
||||
}
|
||||
|
||||
// 支持显示 bigint 数据,如较长的订单号
|
||||
const jsonData = computed<Record<string, any>>(() => {
|
||||
if (typeof props.value !== 'string') {
|
||||
return props.value || {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JsonBigint({ storeAsString: true }).parse(props.value);
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const bindProps = computed<Recordable<any>>(() => {
|
||||
const copyable = {
|
||||
copyText: $t('ui.jsonViewer.copy'),
|
||||
@@ -79,6 +96,7 @@ const bindProps = computed<Recordable<any>>(() => {
|
||||
return {
|
||||
...props,
|
||||
...attrs,
|
||||
value: jsonData.value,
|
||||
onCopied: (event: JsonViewerAction) => emit('copied', event),
|
||||
onKeyclick: (key: string) => emit('keyClick', key),
|
||||
onClick: (event: MouseEvent) => handleClick(event),
|
||||
|
@@ -3,6 +3,8 @@ import type { AuthenticationProps } from './types';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
import { Slot, VbenAvatar } from '@vben-core/shadcn-ui';
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/hooks",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -2,48 +2,139 @@ import type { Arrayable, MaybeElementRef } from '@vueuse/core';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { computed, onUnmounted, ref, unref, watch } from 'vue';
|
||||
import { computed, effectScope, onUnmounted, ref, unref, watch } from 'vue';
|
||||
|
||||
import { isFunction } from '@vben/utils';
|
||||
|
||||
import { useElementHover } from '@vueuse/core';
|
||||
|
||||
interface HoverDelayOptions {
|
||||
/** 鼠标进入延迟时间 */
|
||||
enterDelay?: (() => number) | number;
|
||||
/** 鼠标离开延迟时间 */
|
||||
leaveDelay?: (() => number) | number;
|
||||
}
|
||||
|
||||
const DEFAULT_LEAVE_DELAY = 500; // 鼠标离开延迟时间,默认为 500ms
|
||||
const DEFAULT_ENTER_DELAY = 0; // 鼠标进入延迟时间,默认为 0(立即响应)
|
||||
|
||||
/**
|
||||
* 监测鼠标是否在元素内部,如果在元素内部则返回 true,否则返回 false
|
||||
* @param refElement 所有需要检测的元素。如果提供了一个数组,那么鼠标在任何一个元素内部都会返回 true
|
||||
* @param delay 延迟更新状态的时间
|
||||
* @param refElement 所有需要检测的元素。支持单个元素、元素数组或响应式引用的元素数组。如果鼠标在任何一个元素内部都会返回 true
|
||||
* @param delay 延迟更新状态的时间,可以是数字或包含进入/离开延迟的配置对象
|
||||
* @returns 返回一个数组,第一个元素是一个 ref,表示鼠标是否在元素内部,第二个元素是一个控制器,可以通过 enable 和 disable 方法来控制监听器的启用和禁用
|
||||
*/
|
||||
export function useHoverToggle(
|
||||
refElement: Arrayable<MaybeElementRef>,
|
||||
delay: (() => number) | number = 500,
|
||||
refElement: Arrayable<MaybeElementRef> | Ref<HTMLElement[] | null>,
|
||||
delay: (() => number) | HoverDelayOptions | number = DEFAULT_LEAVE_DELAY,
|
||||
) {
|
||||
const isHovers: Array<Ref<boolean>> = [];
|
||||
const value = ref(false);
|
||||
const timer = ref<ReturnType<typeof setTimeout> | undefined>();
|
||||
const refs = Array.isArray(refElement) ? refElement : [refElement];
|
||||
refs.forEach((refEle) => {
|
||||
const eleRef = computed(() => {
|
||||
const ele = unref(refEle);
|
||||
return ele instanceof Element ? ele : (ele?.$el as Element);
|
||||
});
|
||||
const isHover = useElementHover(eleRef);
|
||||
isHovers.push(isHover);
|
||||
});
|
||||
const isOutsideAll = computed(() => isHovers.every((v) => !v.value));
|
||||
// 兼容旧版本API
|
||||
const normalizedOptions: HoverDelayOptions =
|
||||
typeof delay === 'number' || isFunction(delay)
|
||||
? { enterDelay: DEFAULT_ENTER_DELAY, leaveDelay: delay }
|
||||
: {
|
||||
enterDelay: DEFAULT_ENTER_DELAY,
|
||||
leaveDelay: DEFAULT_LEAVE_DELAY,
|
||||
...delay,
|
||||
};
|
||||
|
||||
function setValueDelay(val: boolean) {
|
||||
timer.value && clearTimeout(timer.value);
|
||||
timer.value = setTimeout(
|
||||
() => {
|
||||
value.value = val;
|
||||
timer.value = undefined;
|
||||
},
|
||||
isFunction(delay) ? delay() : delay,
|
||||
);
|
||||
const value = ref(false);
|
||||
const enterTimer = ref<ReturnType<typeof setTimeout> | undefined>();
|
||||
const leaveTimer = ref<ReturnType<typeof setTimeout> | undefined>();
|
||||
const hoverScopes = ref<ReturnType<typeof effectScope>[]>([]);
|
||||
|
||||
// 使用计算属性包装 refElement,使其响应式变化
|
||||
const refs = computed(() => {
|
||||
const raw = unref(refElement);
|
||||
if (raw === null) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
});
|
||||
// 存储所有 hover 状态
|
||||
const isHovers = ref<Array<Ref<boolean>>>([]);
|
||||
|
||||
// 更新 hover 监听的函数
|
||||
function updateHovers() {
|
||||
// 停止并清理之前的作用域
|
||||
hoverScopes.value.forEach((scope) => scope.stop());
|
||||
hoverScopes.value = [];
|
||||
|
||||
isHovers.value = refs.value.map((refEle) => {
|
||||
if (!refEle) {
|
||||
return ref(false);
|
||||
}
|
||||
const eleRef = computed(() => {
|
||||
const ele = unref(refEle);
|
||||
return ele instanceof Element ? ele : (ele?.$el as Element);
|
||||
});
|
||||
|
||||
// 为每个元素创建独立的作用域
|
||||
const scope = effectScope();
|
||||
const hoverRef = scope.run(() => useElementHover(eleRef)) || ref(false);
|
||||
hoverScopes.value.push(scope);
|
||||
|
||||
return hoverRef;
|
||||
});
|
||||
}
|
||||
|
||||
const watcher = watch(
|
||||
// 监听元素数量变化,避免过度执行
|
||||
const elementsCount = computed(() => {
|
||||
const raw = unref(refElement);
|
||||
if (raw === null) return 0;
|
||||
return Array.isArray(raw) ? raw.length : 1;
|
||||
});
|
||||
|
||||
// 初始设置
|
||||
updateHovers();
|
||||
|
||||
// 只在元素数量变化时重新设置监听器
|
||||
const stopWatcher = watch(elementsCount, updateHovers, { deep: false });
|
||||
|
||||
const isOutsideAll = computed(() => isHovers.value.every((v) => !v.value));
|
||||
|
||||
function clearTimers() {
|
||||
if (enterTimer.value) {
|
||||
clearTimeout(enterTimer.value);
|
||||
enterTimer.value = undefined;
|
||||
}
|
||||
if (leaveTimer.value) {
|
||||
clearTimeout(leaveTimer.value);
|
||||
leaveTimer.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function setValueDelay(val: boolean) {
|
||||
clearTimers();
|
||||
|
||||
if (val) {
|
||||
// 鼠标进入
|
||||
const enterDelay = normalizedOptions.enterDelay ?? DEFAULT_ENTER_DELAY;
|
||||
const delayTime = isFunction(enterDelay) ? enterDelay() : enterDelay;
|
||||
|
||||
if (delayTime <= 0) {
|
||||
value.value = true;
|
||||
} else {
|
||||
enterTimer.value = setTimeout(() => {
|
||||
value.value = true;
|
||||
enterTimer.value = undefined;
|
||||
}, delayTime);
|
||||
}
|
||||
} else {
|
||||
// 鼠标离开
|
||||
const leaveDelay = normalizedOptions.leaveDelay ?? DEFAULT_LEAVE_DELAY;
|
||||
const delayTime = isFunction(leaveDelay) ? leaveDelay() : leaveDelay;
|
||||
|
||||
if (delayTime <= 0) {
|
||||
value.value = false;
|
||||
} else {
|
||||
leaveTimer.value = setTimeout(() => {
|
||||
value.value = false;
|
||||
leaveTimer.value = undefined;
|
||||
}, delayTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hoverWatcher = watch(
|
||||
isOutsideAll,
|
||||
(val) => {
|
||||
setValueDelay(!val);
|
||||
@@ -53,15 +144,19 @@ export function useHoverToggle(
|
||||
|
||||
const controller = {
|
||||
enable() {
|
||||
watcher.resume();
|
||||
hoverWatcher.resume();
|
||||
},
|
||||
disable() {
|
||||
watcher.pause();
|
||||
hoverWatcher.pause();
|
||||
},
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
timer.value && clearTimeout(timer.value);
|
||||
clearTimers();
|
||||
// 停止监听器
|
||||
stopWatcher();
|
||||
// 停止所有剩余的作用域
|
||||
hoverScopes.value.forEach((scope) => scope.stop());
|
||||
});
|
||||
|
||||
return [value, controller] as [typeof value, typeof controller];
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/layouts",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -62,21 +62,23 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
</template>
|
||||
</AuthenticationFormView>
|
||||
|
||||
<!-- 头部 Logo 和应用名称 -->
|
||||
<div
|
||||
v-if="logo || appName"
|
||||
class="absolute left-0 top-0 z-10 flex flex-1"
|
||||
@click="clickLogo"
|
||||
>
|
||||
<slot name="logo">
|
||||
<!-- 头部 Logo 和应用名称 -->
|
||||
<div
|
||||
class="text-foreground lg:text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
|
||||
v-if="logo || appName"
|
||||
class="absolute left-0 top-0 z-10 flex flex-1"
|
||||
@click="clickLogo"
|
||||
>
|
||||
<img v-if="logo" :alt="appName" :src="logo" class="mr-2" width="42" />
|
||||
<p v-if="appName" class="m-0 text-xl font-medium">
|
||||
{{ appName }}
|
||||
</p>
|
||||
<div
|
||||
class="text-foreground lg:text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
|
||||
>
|
||||
<img v-if="logo" :alt="appName" :src="logo" class="mr-2" width="42" />
|
||||
<p v-if="appName" class="m-0 text-xl font-medium">
|
||||
{{ appName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 系统介绍 -->
|
||||
<div v-if="!authPanelCenter" class="relative hidden w-0 flex-1 lg:block">
|
||||
|
@@ -234,6 +234,7 @@ const headerSlots = computed(() => {
|
||||
<template #logo>
|
||||
<VbenLogo
|
||||
v-if="preferences.logo.enable"
|
||||
:fit="preferences.logo.fit"
|
||||
:class="logoClass"
|
||||
:collapsed="logoCollapsed"
|
||||
:src="preferences.logo.source"
|
||||
@@ -324,6 +325,7 @@ const headerSlots = computed(() => {
|
||||
<template #side-extra-title>
|
||||
<VbenLogo
|
||||
v-if="preferences.logo.enable"
|
||||
:fit="preferences.logo.fit"
|
||||
:text="preferences.app.name"
|
||||
:theme="theme"
|
||||
>
|
||||
|
@@ -6,7 +6,7 @@ import { $t } from '@vben/locales';
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
|
||||
interface Props {
|
||||
// 轮训时间,分钟
|
||||
// 轮询时间,分钟
|
||||
checkUpdatesInterval?: number;
|
||||
// 检查更新的地址
|
||||
checkUpdateUrl?: string;
|
||||
@@ -46,6 +46,7 @@ async function getVersionTag() {
|
||||
const response = await fetch(props.checkUpdateUrl, {
|
||||
cache: 'no-cache',
|
||||
method: 'HEAD',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
return (
|
||||
|
@@ -46,7 +46,11 @@ interface Props {
|
||||
/**
|
||||
* 菜单数组
|
||||
*/
|
||||
menus?: Array<{ handler: AnyFunction; icon?: Component; text: string }>;
|
||||
menus?: Array<{
|
||||
handler: AnyFunction;
|
||||
icon?: Component | Function | string;
|
||||
text: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 标签文本
|
||||
|
@@ -26,14 +26,14 @@ function getDefaultState(): VxeGridProps {
|
||||
};
|
||||
}
|
||||
|
||||
export class VxeGridApi {
|
||||
export class VxeGridApi<T extends Record<string, any> = any> {
|
||||
public formApi = {} as ExtendedFormApi;
|
||||
|
||||
// private prevState: null | VxeGridProps = null;
|
||||
public grid = {} as VxeGridInstance;
|
||||
public state: null | VxeGridProps = null;
|
||||
public grid = {} as VxeGridInstance<T>;
|
||||
public state: null | VxeGridProps<T> = null;
|
||||
|
||||
public store: Store<VxeGridProps>;
|
||||
public store: Store<VxeGridProps<T>>;
|
||||
|
||||
private isMounted = false;
|
||||
|
||||
@@ -99,8 +99,8 @@ export class VxeGridApi {
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: VxeGridProps) => Partial<VxeGridProps>)
|
||||
| Partial<VxeGridProps>,
|
||||
| ((prev: VxeGridProps<T>) => Partial<VxeGridProps<T>>)
|
||||
| Partial<VxeGridProps<T>>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState((prev) => {
|
||||
|
@@ -3,4 +3,8 @@ export type { VxeTableGridOptions } from './types';
|
||||
export * from './use-vxe-grid';
|
||||
|
||||
export { default as VbenVxeGrid } from './use-vxe-grid.vue';
|
||||
export type { VxeGridListeners, VxeGridProps } from 'vxe-table';
|
||||
export type {
|
||||
VxeGridListeners,
|
||||
VxeGridProps,
|
||||
VxeGridPropTypes,
|
||||
} from 'vxe-table';
|
||||
|
@@ -9,7 +9,7 @@ import type { Ref } from 'vue';
|
||||
|
||||
import type { ClassType, DeepPartial } from '@vben/types';
|
||||
|
||||
import type { VbenFormProps } from '@vben-core/form-ui';
|
||||
import type { BaseFormComponentType, VbenFormProps } from '@vben-core/form-ui';
|
||||
|
||||
import type { VxeGridApi } from './api';
|
||||
|
||||
@@ -35,7 +35,11 @@ export interface SeparatorOptions {
|
||||
show?: boolean;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
export interface VxeGridProps {
|
||||
|
||||
export interface VxeGridProps<
|
||||
T extends Record<string, any> = any,
|
||||
D extends BaseFormComponentType = BaseFormComponentType,
|
||||
> {
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
@@ -55,15 +59,15 @@ export interface VxeGridProps {
|
||||
/**
|
||||
* vxe-grid 配置
|
||||
*/
|
||||
gridOptions?: DeepPartial<VxeTableGridOptions>;
|
||||
gridOptions?: DeepPartial<VxeTableGridOptions<T>>;
|
||||
/**
|
||||
* vxe-grid 事件
|
||||
*/
|
||||
gridEvents?: DeepPartial<VxeGridListeners>;
|
||||
gridEvents?: DeepPartial<VxeGridListeners<T>>;
|
||||
/**
|
||||
* 表单配置
|
||||
*/
|
||||
formOptions?: VbenFormProps;
|
||||
formOptions?: VbenFormProps<D>;
|
||||
/**
|
||||
* 显示搜索表单
|
||||
*/
|
||||
@@ -74,9 +78,12 @@ export interface VxeGridProps {
|
||||
separator?: boolean | SeparatorOptions;
|
||||
}
|
||||
|
||||
export type ExtendedVxeGridApi = VxeGridApi & {
|
||||
useStore: <T = NoInfer<VxeGridProps>>(
|
||||
selector?: (state: NoInfer<VxeGridProps>) => T,
|
||||
export type ExtendedVxeGridApi<
|
||||
D extends Record<string, any> = any,
|
||||
F extends BaseFormComponentType = BaseFormComponentType,
|
||||
> = VxeGridApi<D> & {
|
||||
useStore: <T = NoInfer<VxeGridProps<D, F>>>(
|
||||
selector?: (state: NoInfer<VxeGridProps<any, any>>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
|
@@ -1,3 +1,9 @@
|
||||
import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
|
||||
|
||||
import type { SlotsType } from 'vue';
|
||||
|
||||
import type { BaseFormComponentType } from '@vben-core/form-ui';
|
||||
|
||||
import type { ExtendedVxeGridApi, VxeGridProps } from './types';
|
||||
|
||||
import { defineComponent, h, onBeforeUnmount } from 'vue';
|
||||
@@ -7,16 +13,25 @@ import { useStore } from '@vben-core/shared/store';
|
||||
import { VxeGridApi } from './api';
|
||||
import VxeGrid from './use-vxe-grid.vue';
|
||||
|
||||
export function useVbenVxeGrid(options: VxeGridProps) {
|
||||
type FilteredSlots<T> = {
|
||||
[K in keyof VxeGridSlots<T> as K extends 'form'
|
||||
? never
|
||||
: K]: VxeGridSlots<T>[K];
|
||||
};
|
||||
|
||||
export function useVbenVxeGrid<
|
||||
T extends Record<string, any> = any,
|
||||
D extends BaseFormComponentType = BaseFormComponentType,
|
||||
>(options: VxeGridProps<T, D>) {
|
||||
// const IS_REACTIVE = isReactive(options);
|
||||
const api = new VxeGridApi(options);
|
||||
const extendedApi: ExtendedVxeGridApi = api as ExtendedVxeGridApi;
|
||||
const extendedApi: ExtendedVxeGridApi<T, D> = api as ExtendedVxeGridApi<T, D>;
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useStore(api.store, selector);
|
||||
};
|
||||
|
||||
const Grid = defineComponent(
|
||||
(props: VxeGridProps, { attrs, slots }) => {
|
||||
(props: VxeGridProps<T>, { attrs, slots }) => {
|
||||
onBeforeUnmount(() => {
|
||||
api.unmount();
|
||||
});
|
||||
@@ -26,6 +41,16 @@ export function useVbenVxeGrid(options: VxeGridProps) {
|
||||
{
|
||||
name: 'VbenVxeGrid',
|
||||
inheritAttrs: false,
|
||||
slots: Object as SlotsType<
|
||||
{
|
||||
// 表格标题
|
||||
'table-title': undefined;
|
||||
// 工具栏左侧部分
|
||||
'toolbar-actions': VxeGridSlotTypes.DefaultSlotParams<T>;
|
||||
// 工具栏右侧部分
|
||||
'toolbar-tools': VxeGridSlotTypes.DefaultSlotParams<T>;
|
||||
} & FilteredSlots<T>
|
||||
>,
|
||||
},
|
||||
);
|
||||
// Add reactivity support
|
||||
|
@@ -59,6 +59,7 @@ const FORM_SLOT_PREFIX = 'form-';
|
||||
|
||||
const TOOLBAR_ACTIONS = 'toolbar-actions';
|
||||
const TOOLBAR_TOOLS = 'toolbar-tools';
|
||||
const TABLE_TITLE = 'table-title';
|
||||
|
||||
const gridRef = useTemplateRef<VxeGridInstance>('gridRef');
|
||||
|
||||
@@ -129,7 +130,7 @@ const [Form, formApi] = useTableForm({
|
||||
});
|
||||
|
||||
const showTableTitle = computed(() => {
|
||||
return !!slots.tableTitle?.() || tableTitle.value;
|
||||
return !!slots[TABLE_TITLE]?.() || tableTitle.value;
|
||||
});
|
||||
|
||||
const showToolbar = computed(() => {
|
||||
@@ -277,6 +278,15 @@ const delegatedFormSlots = computed(() => {
|
||||
return resultSlots.map((key) => key.replace(FORM_SLOT_PREFIX, ''));
|
||||
});
|
||||
|
||||
const showDefaultEmpty = computed(() => {
|
||||
// 检查是否有原生的 VXE Table 空状态配置
|
||||
const hasEmptyText = options.value.emptyText !== undefined;
|
||||
const hasEmptyRender = options.value.emptyRender !== undefined;
|
||||
|
||||
// 如果有原生配置,就不显示默认的空状态
|
||||
return !hasEmptyText && !hasEmptyRender;
|
||||
});
|
||||
|
||||
async function init() {
|
||||
await nextTick();
|
||||
const globalGridConfig = VxeUI?.getConfig()?.grid ?? {};
|
||||
@@ -458,7 +468,7 @@ onUnmounted(() => {
|
||||
</slot>
|
||||
</template>
|
||||
<!-- 统一控状态 -->
|
||||
<template #empty>
|
||||
<template v-if="showDefaultEmpty" #empty>
|
||||
<slot name="empty">
|
||||
<EmptyIcon class="mx-auto" />
|
||||
<div class="mt-2">{{ $t('common.noData') }}</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/request",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import type { RequestClient } from '../request-client';
|
||||
import type { RequestClientConfig } from '../types';
|
||||
|
||||
import { isUndefined } from '@vben/utils';
|
||||
|
||||
class FileUploader {
|
||||
private client: RequestClient;
|
||||
|
||||
@@ -18,10 +20,10 @@ class FileUploader {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
formData.append(`${key}[${index}]`, item);
|
||||
!isUndefined(item) && formData.append(`${key}[${index}]`, item);
|
||||
});
|
||||
} else {
|
||||
formData.append(key, value);
|
||||
!isUndefined(value) && formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/icons",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/locales",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -32,8 +32,11 @@
|
||||
"sliderDefaultText": "Slider and drag",
|
||||
"alt": "Supports img tag src attribute value",
|
||||
"sliderRotateDefaultTip": "Click picture to refresh",
|
||||
"sliderTranslateDefaultTip": "Click picture to refresh",
|
||||
"sliderRotateFailTip": "Validation failed",
|
||||
"sliderRotateSuccessTip": "Validation successful, time {0} seconds",
|
||||
"sliderTranslateFailTip": "Validation failed",
|
||||
"sliderTranslateSuccessTip": "Validation successful, time {0} seconds",
|
||||
"refreshAriaLabel": "Refresh captcha",
|
||||
"confirmAriaLabel": "Confirm selection",
|
||||
"confirm": "Confirm",
|
||||
|
@@ -31,8 +31,11 @@
|
||||
"sliderSuccessText": "验证通过",
|
||||
"sliderDefaultText": "请按住滑块拖动",
|
||||
"sliderRotateDefaultTip": "点击图片可刷新",
|
||||
"sliderTranslateDefaultTip": "点击图片可刷新",
|
||||
"sliderRotateFailTip": "验证失败",
|
||||
"sliderRotateSuccessTip": "验证成功,耗时{0}秒",
|
||||
"sliderTranslateFailTip": "验证失败",
|
||||
"sliderTranslateSuccessTip": "验证成功,耗时{0}秒",
|
||||
"alt": "支持img标签src属性值",
|
||||
"refreshAriaLabel": "刷新验证码",
|
||||
"confirmAriaLabel": "确认选择",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/preferences",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/stores",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -208,7 +208,7 @@ export const useTabbarStore = defineStore('core-tabbar', {
|
||||
const keys: string[] = [];
|
||||
|
||||
for (const key of closeKeys) {
|
||||
if (key !== tab.key) {
|
||||
if (key !== getTabKeyFromTab(tab)) {
|
||||
const closeTab = this.tabs.find(
|
||||
(item) => getTabKeyFromTab(item) === key,
|
||||
);
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/styles",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/types",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/utils",
|
||||
"version": "5.5.6",
|
||||
"version": "5.5.7",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
Reference in New Issue
Block a user