committed by
GitHub
34 changed files with 5171 additions and 3758 deletions
@ -0,0 +1,71 @@ |
|||
<script lang="ts" setup> |
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
|
|||
import { useVbenForm } from '#/adapter/form'; |
|||
|
|||
defineOptions({ |
|||
name: 'FormModelDemo', |
|||
}); |
|||
|
|||
const [Form, formApi] = useVbenForm({ |
|||
schema: [ |
|||
{ |
|||
component: 'Input', |
|||
componentProps: { |
|||
placeholder: '请输入', |
|||
}, |
|||
fieldName: 'field1', |
|||
label: '字段1', |
|||
rules: 'required', |
|||
}, |
|||
{ |
|||
component: 'Input', |
|||
componentProps: { |
|||
placeholder: '请输入', |
|||
}, |
|||
fieldName: 'field2', |
|||
label: '字段2', |
|||
rules: 'required', |
|||
}, |
|||
{ |
|||
component: 'Select', |
|||
componentProps: { |
|||
options: [ |
|||
{ label: '选项1', value: '1' }, |
|||
{ label: '选项2', value: '2' }, |
|||
], |
|||
placeholder: '请输入', |
|||
}, |
|||
fieldName: 'field3', |
|||
label: '字段3', |
|||
rules: 'required', |
|||
}, |
|||
], |
|||
showDefaultActions: false, |
|||
}); |
|||
|
|||
const [Modal, modalApi] = useVbenModal({ |
|||
fullscreenButton: false, |
|||
onCancel() { |
|||
modalApi.close(); |
|||
}, |
|||
onConfirm: async () => { |
|||
await formApi.validateAndSubmitForm(); |
|||
// modalApi.close(); |
|||
}, |
|||
onOpenChange(isOpen: boolean) { |
|||
if (isOpen) { |
|||
const { values } = modalApi.getData<Record<string, any>>(); |
|||
if (values) { |
|||
formApi.setValues(values); |
|||
} |
|||
} |
|||
}, |
|||
title: '内嵌表单示例', |
|||
}); |
|||
</script> |
|||
<template> |
|||
<Modal> |
|||
<Form /> |
|||
</Modal> |
|||
</template> |
|||
@ -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> |
|||
@ -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> |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue