master
dap 5 months ago
commit 72e1115330

@ -22,7 +22,7 @@ outline: deep
## 基础用法
使用 `useVbenDrawer` 创建最基础的模态框
使用 `useVbenDrawer` 创建最基础的抽屉
<DemoPreview dir="demos/vben-drawer/basic" />
@ -52,7 +52,7 @@ Drawer 内的内容一般业务中,会比较复杂,所以我们可以将 dra
::: info 注意
- `VbenDrawer` 组件对参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- `VbenDrawer` 组件对参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenDrawer`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。
- 使用了`connectedComponent`参数时,可以配置`destroyOnClose`属性来决定当关闭弹窗时,是否要销毁`connectedComponent`组件(重新创建`connectedComponent`组件,这将会把其内部所有的变量、状态、数据等恢复到初始状态。)。
- 如果抽屉的默认行为不符合你的预期,可以在`src\bootstrap.ts`中修改`setDefaultDrawerProps`的参数来设置默认的属性如默认隐藏全屏按钮修改默认ZIndex等。
@ -77,7 +77,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| appendToMain | 是否挂载到内容区域默认挂载到body | `boolean` | `false` |
| connectedComponent | 连接另一个Modal组件 | `Component` | - |
| connectedComponent | 连接另一个Drawer组件 | `Component` | - |
| destroyOnClose | 关闭时销毁 | `boolean` | `false` |
| title | 标题 | `string\|slot` | - |
| titleTooltip | 标题提示信息 | `string\|slot` | - |
@ -96,7 +96,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
| cancelText | 取消按钮文本 | `string\|slot` | `取消` |
| placement | 抽屉弹出位置 | `'left'\|'right'\|'top'\|'bottom'` | `right` |
| showCancelButton | 显示取消按钮 | `boolean` | `true` |
| showConfirmButton | 显示确认按钮文本 | `boolean` | `true` |
| showConfirmButton | 显示确认按钮 | `boolean` | `true` |
| class | modal的class宽度通过这个配置 | `string` | - |
| contentClass | modal内容区域的class | `string` | - |
| footerClass | modal底部区域的class | `string` | - |

@ -324,6 +324,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| submitOnEnter | 按下回车健时提交表单 | `boolean` | false |
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false |
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
::: tip handleValuesChange

@ -15,6 +15,7 @@ const [Form] = useVbenForm({
handleSubmit: onSubmit,
// labelinputvertical
// labelinput
scrollToFirstError: true,
layout: 'horizontal',
schema: [
{

@ -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) {

@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps {
layout: 'horizontal',
resetButtonOptions: {},
schema: [],
scrollToFirstError: false,
showCollapseButton: false,
showDefaultActions: true,
submitButtonOptions: {},
@ -253,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);
@ -377,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();
@ -396,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

@ -287,7 +287,11 @@ defineExpose({
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 && Array.isArray(item.value[childrenField]) && item.value[childrenField].length > 0"
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="

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

@ -158,6 +158,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;

@ -20,6 +20,7 @@ export {
VbenAvatar,
VbenButton,
VbenButtonGroup,
VbenCheckbox,
VbenCheckButtonGroup,
VbenCountToAnimator,
VbenFullScreen,
@ -27,6 +28,7 @@ export {
VbenLoading,
VbenLogo,
VbenPinInput,
VbenSelect,
VbenSpinner,
VbenTree,
} from '@vben-core/shadcn-ui';

@ -85,7 +85,7 @@ useScrollLock();
<transition name="slide-left">
<div v-show="!showUnlockForm" class="size-full">
<div
class="flex-col-center text-foreground/80 hover:text-foreground group my-4 cursor-pointer text-xl font-semibold"
class="flex-col-center text-foreground/80 hover:text-foreground group fixed left-1/2 top-6 z-[2001] -translate-x-1/2 cursor-pointer text-xl font-semibold"
@click="toggleUnlockForm"
>
<LockKeyhole
@ -93,19 +93,23 @@ useScrollLock();
/>
<span>{{ $t('ui.widgets.lockScreen.unlock') }}</span>
</div>
<div class="flex h-full justify-center px-[10%]">
<div
class="bg-accent flex-center relative mb-14 mr-20 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
>
<span class="absolute left-4 top-4 text-xl font-semibold">
{{ meridiem }}
</span>
{{ hour }}
</div>
<div
class="bg-accent flex-center mb-14 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
>
{{ minute }}
<div class="flex h-full w-full items-center justify-center">
<div class="flex w-full justify-center gap-4 px-4 sm:gap-6 md:gap-8">
<div
class="bg-accent relative flex h-[140px] w-[140px] items-center justify-center rounded-xl text-[36px] sm:h-[160px] sm:w-[160px] sm:text-[42px] md:h-[200px] md:w-[200px] md:text-[72px]"
>
<span
class="absolute left-3 top-3 text-xs font-semibold sm:text-sm md:text-xl"
>
{{ meridiem }}
</span>
{{ hour }}
</div>
<div
class="bg-accent flex h-[140px] w-[140px] items-center justify-center rounded-xl text-[36px] sm:h-[160px] sm:w-[160px] sm:text-[42px] md:h-[200px] md:w-[200px] md:text-[72px]"
>
{{ minute }}
</div>
</div>
</div>
</div>
@ -117,9 +121,8 @@ useScrollLock();
class="flex-center size-full"
@keydown.enter.prevent="handleSubmit"
>
<div class="flex-col-center mb-10 w-[300px]">
<div class="flex-col-center mb-10 w-[90%] max-w-[300px] px-4">
<VbenAvatar :src="avatar" class="enter-x mb-6 size-20" />
<div class="enter-x mb-2 w-full items-center">
<Form />
</div>
@ -145,12 +148,13 @@ useScrollLock();
</transition>
<div
class="enter-y absolute bottom-5 w-full text-center xl:text-xl 2xl:text-3xl"
class="enter-y absolute bottom-5 w-full text-center text-xl md:text-2xl xl:text-xl 2xl:text-3xl"
>
<div v-if="showUnlockForm" class="enter-x mb-2 text-3xl">
{{ hour }}:{{ minute }} <span class="text-lg">{{ meridiem }}</span>
<div v-if="showUnlockForm" class="enter-x mb-2 text-2xl md:text-3xl">
{{ hour }}:{{ minute }}
<span class="text-base md:text-lg">{{ meridiem }}</span>
</div>
<div class="text-3xl">{{ date }}</div>
<div class="text-xl md:text-3xl">{{ date }}</div>
</div>
</div>
</template>

@ -303,7 +303,7 @@ async function init() {
if (enableProxyConfig && autoLoad) {
// readonly cloneDeep
props.api.grid.commitProxy?.(
'_init',
'initial',
cloneDeep(formOptions.value)
? (cloneDeep(await formApi.getValues()) ?? {})
: {},

@ -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": "确认选择",

@ -19,6 +19,7 @@
"custom": "Custom Component",
"api": "Api",
"merge": "Merge Form",
"scrollToError": "Scroll to Error Field",
"upload-error": "Partial file upload failed",
"upload-urls": "Urls after file upload",
"file": "file",
@ -41,6 +42,7 @@
"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",

@ -22,6 +22,7 @@
"custom": "自定义组件",
"api": "Api",
"merge": "合并表单",
"scrollToError": "滚动到错误字段",
"upload-error": "部分文件上传失败",
"upload-urls": "文件上传后的网址",
"file": "文件",
@ -44,6 +45,7 @@
"pointSelection": "点选验证",
"sliderCaptcha": "滑块验证",
"sliderRotateCaptcha": "旋转验证",
"sliderTranslateCaptcha": "拼图滑块验证",
"captchaCardTitle": "请完成安全验证",
"pageDescription": "通过点击图片中的特定位置来验证用户身份。",
"pageTitle": "验证码组件示例",

@ -85,6 +85,15 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.form.merge'),
},
},
{
name: 'FormScrollToErrorExample',
path: '/examples/form/scroll-to-error-test',
component: () =>
import('#/views/examples/form/scroll-to-error-test.vue'),
meta: {
title: $t('examples.form.scrollToError'),
},
},
],
},
{
@ -196,6 +205,15 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.captcha.sliderRotateCaptcha'),
},
},
{
name: 'TranslateVerifyExample',
path: '/examples/captcha/slider-translate',
component: () =>
import('#/views/examples/captcha/slider-translate-captcha.vue'),
meta: {
title: $t('examples.captcha.sliderTranslateCaptcha'),
},
},
{
name: 'CaptchaPointSelectionExample',
path: '/examples/captcha/point-selection',

@ -0,0 +1,27 @@
<script setup lang="ts">
import { Page, SliderTranslateCaptcha } from '@vben/common-ui';
import { Card, message } from 'ant-design-vue';
function handleSuccess() {
message.success('success!');
}
</script>
<template>
<Page
description="用于前端简单的拼图滑块水平拖动校验场景"
title="拼图滑块校验"
>
<Card class="mb-5" title="基本示例">
<div class="flex items-center justify-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>

@ -0,0 +1,183 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Switch } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
defineOptions({
name: 'ScrollToErrorTest',
});
const scrollEnabled = ref(true);
const [Form, formApi] = useVbenForm({
scrollToFirstError: scrollEnabled.value,
schema: [
{
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
},
fieldName: 'username',
label: '用户名',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
},
fieldName: 'email',
label: '邮箱',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入手机号',
},
fieldName: 'phone',
label: '手机号',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入地址',
},
fieldName: 'address',
label: '地址',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入备注',
},
fieldName: 'remark',
label: '备注',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入公司名称',
},
fieldName: 'company',
label: '公司名称',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入职位',
},
fieldName: 'position',
label: '职位',
rules: 'required',
},
{
component: 'Select',
componentProps: {
options: [
{ label: '男', value: 'male' },
{ label: '女', value: 'female' },
],
placeholder: '请选择性别',
},
fieldName: 'gender',
label: '性别',
rules: 'selectRequired',
},
],
showDefaultActions: false,
});
// validateAndSubmitForm
async function testValidateAndSubmit() {
await formApi.validateAndSubmitForm();
}
// validate
async function testValidate() {
await formApi.validate();
}
// validateField
async function testValidateField() {
await formApi.validateField('username');
}
//
function toggleScrollToError() {
formApi.setState({ scrollToFirstError: scrollEnabled.value });
}
//
async function fillPartialData() {
await formApi.resetForm();
await formApi.setFieldValue('username', '测试用户');
await formApi.setFieldValue('email', 'test@example.com');
}
</script>
<template>
<Page
description="测试表单验证失败时自动滚动到错误字段的功能"
title="滚动到错误字段测试"
>
<Card title="功能测试">
<template #extra>
<div class="flex items-center gap-2">
<Switch
v-model:checked="scrollEnabled"
@change="toggleScrollToError"
/>
<span>启用滚动到错误字段</span>
</div>
</template>
<div class="space-y-4">
<div class="rounded bg-blue-50 p-4">
<h3 class="mb-2 font-medium">测试说明</h3>
<ul class="list-inside list-disc space-y-1 text-sm">
<li>所有验证方法在验证失败时都会自动滚动到第一个错误字段</li>
<li>可以通过右上角的开关控制是否启用自动滚动功能</li>
</ul>
</div>
<div class="rounded border p-4">
<h4 class="mb-3 font-medium">验证方法测试</h4>
<div class="flex flex-wrap gap-2">
<Button type="primary" @click="testValidateAndSubmit">
测试 validateAndSubmitForm()
</Button>
<Button @click="testValidate"> validate() </Button>
<Button @click="testValidateField"> validateField() </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p> validateAndSubmitForm(): 验证表单并提交</p>
<p> validate(): 手动验证整个表单</p>
<p> validateField(): 验证单个字段这里测试用户名字段</p>
</div>
</div>
<div class="rounded border p-4">
<h4 class="mb-3 font-medium">数据填充测试</h4>
<div class="flex flex-wrap gap-2">
<Button @click="fillPartialData"> </Button>
<Button @click="() => formApi.resetForm()"> 清空表单 </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p> 填充部分数据后验证会滚动到第一个错误字段</p>
</div>
</div>
<Form />
</div>
</Card>
</Page>
</template>

@ -55,7 +55,7 @@ const gridOptions: VxeGridProps<RowType> = {
custom: true,
export: true,
// import: true,
refresh: { code: 'query' },
refresh: true,
zoom: true,
},
};

@ -187,8 +187,8 @@ catalog:
vue-router: ^4.5.1
vue-tippy: ^6.7.1
vue-tsc: 2.2.10
vxe-pc-ui: ^4.6.42
vxe-table: 4.13.53
vxe-pc-ui: ^4.7.12
vxe-table: ^4.14.4
watermark-js-plus: ^1.6.2
zod: ^3.25.67
zod-defaults: ^0.1.3

Loading…
Cancel
Save