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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
interface AutoDrawerData {
message: string;
method: '自动推导';
}
const data = ref<AutoDrawerData>();
const [Drawer, drawerApi] = useVbenDrawer<AutoDrawerData>({
onOpenChange(isOpen) {
data.value = isOpen ? drawerApi.getData() : undefined;
},
});
defineExpose({ drawerApi });
</script>
<template>
<Drawer title="自动推导数据类型">
<div class="space-y-2 px-2">
<p>类型来源:{{ data?.method }}</p>
<p>接收内容:{{ data?.message }}</p>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,13 @@
import { createVbenDrawer } from '@vben/common-ui';
export interface ExplicitDrawerData {
message: string;
method: '显式泛型';
}
export interface FactoryDrawerData {
message: string;
method: '契约工厂';
}
export const useFactoryDrawer = createVbenDrawer<FactoryDrawerData>();

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ExplicitDrawerData } from './typed-data-contract';
import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
const data = ref<ExplicitDrawerData>();
const [Drawer, drawerApi] = useVbenDrawer<ExplicitDrawerData>({
onOpenChange(isOpen) {
data.value = isOpen ? drawerApi.getData() : undefined;
},
});
</script>
<template>
<Drawer title="显式泛型数据类型">
<div class="space-y-2 px-2">
<p>类型来源:{{ data?.method }}</p>
<p>接收内容:{{ data?.message }}</p>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { FactoryDrawerData } from './typed-data-contract';
import { ref } from 'vue';
import { useFactoryDrawer } from './typed-data-contract';
const data = ref<FactoryDrawerData>();
const [Drawer, drawerApi] = useFactoryDrawer({
onOpenChange(isOpen) {
data.value = isOpen ? drawerApi.getData() : undefined;
},
});
</script>
<template>
<Drawer title="契约工厂数据类型">
<div class="space-y-2 px-2">
<p>类型来源:{{ data?.method }}</p>
<p>接收内容:{{ data?.message }}</p>
</div>
</Drawer>
</template>

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue';
import { EllipsisText, Page } from '@vben/common-ui';
import { Card } from 'ant-design-vue';
import { Card } from 'antdv-next';
import DocButton from '../doc-button.vue';

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space } from 'ant-design-vue';
import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
@@ -82,6 +82,7 @@ function handleClick(
action:
| 'batchAddSchema'
| 'batchDeleteSchema'
| 'componentRef'
| 'disabled'
| 'hiddenAction'
| 'hiddenResetButton'
@@ -129,6 +130,13 @@ function handleClick(
});
break;
}
case 'componentRef': {
// 获取下拉组件的实例,并调用它的focus方法
formApi
.getFieldComponentRef<{ focus?: () => void }>('fieldOptions')
?.focus?.();
break;
}
case 'disabled': {
formApi.setState({ commonConfig: { disabled: true } });
break;
@@ -182,6 +190,7 @@ function handleClick(
formApi.setState({ submitButtonOptions: { show: true } });
break;
}
case 'updateActionAlign': {
formApi.setState({
// 可以自行调整class
@@ -189,7 +198,6 @@ function handleClick(
});
break;
}
case 'updateResetButton': {
formApi.setState({
resetButtonOptions: { disabled: true },
@@ -257,6 +265,7 @@ function handleClick(
<Button @click="handleClick('batchDeleteSchema')">
批量删除表单项
</Button>
<Button @click="handleClick('componentRef')">下拉组件获取焦点</Button>
</Space>
<Card title="操作示例">
<BaseForm />

View File

@@ -1,19 +1,74 @@
<script lang="ts" setup>
import { ref } from 'vue';
import type { UploadFile } from 'antdv-next';
import type { Dayjs } from 'dayjs';
import { h, ref, toRaw } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, TabPane, Tabs } from 'ant-design-vue';
import { useDebounceFn } from '@vueuse/core';
import { Button, Card, message, Spin, Tag } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import { useVbenForm, z } from '#/adapter/form';
import { getAllMenusApi } from '#/api';
import { upload_file } from '#/api/examples/upload';
import { $t } from '#/locales';
import DocButton from '../doc-button.vue';
const activeTab = ref('basic');
const keyword = ref('');
const fetching = ref(false);
interface BasicFormValues extends Record<string, any> {
cropImage?: UploadFile[];
files?: UploadFile[];
rangePicker?: [Dayjs, Dayjs];
}
function encodeBasicFormValues(values: Readonly<BasicFormValues>) {
const { rangePicker, ...formValues } = values;
return {
...formValues,
endTime: rangePicker?.[1]?.format('YYYY-MM-DD'),
startTime: rangePicker?.[0]?.format('YYYY-MM-DD'),
};
}
type BasicSubmitValues = ReturnType<typeof encodeBasicFormValues>;
function decodeBasicFormValues(
values: Readonly<BasicSubmitValues>,
): BasicFormValues {
const { endTime, startTime, ...formValues } = values;
return {
...formValues,
...(startTime && endTime
? { rangePicker: [dayjs(startTime), dayjs(endTime)] }
: {}),
};
}
// 模拟远程获取数据
function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) {
fetching.value = true;
return new Promise((resolve) => {
setTimeout(() => {
const options = Array.from({ length: 10 }).map((_, index) => ({
label: `${keyword}-${index}`,
value: `${keyword}-${index}`,
}));
resolve(options);
fetching.value = false;
}, 1000);
});
}
const [BaseForm, baseFormApi] = useVbenForm({
codec: {
decode: decodeBasicFormValues,
encode: encodeBasicFormValues,
},
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 在label后显示一个冒号
@@ -23,9 +78,11 @@ const [BaseForm, baseFormApi] = useVbenForm({
class: 'w-full',
},
},
fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']],
// 提交函数
handleSubmit: onSubmit,
handleValuesChange(_values, fieldsChanged) {
message.info(`表单以下字段发生变化:${fieldsChanged.join(',')}`);
},
// 垂直布局,label和input在不同行,值为vertical
// 水平布局,label和input在同一行
@@ -44,6 +101,13 @@ const [BaseForm, baseFormApi] = useVbenForm({
label: '字符串',
rules: 'required',
},
{
component: 'Input',
fieldName: 'desc',
// 界面显示的description
description: '这是表单描述',
label: '字符串(带描述)',
},
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'ApiSelect',
@@ -58,22 +122,60 @@ const [BaseForm, baseFormApi] = useVbenForm({
},
// 菜单接口
api: getAllMenusApi,
autoSelect: 'first',
},
// 字段名
fieldName: 'api',
// 界面显示的label
label: 'ApiSelect',
},
{
component: 'ApiSelect',
// 对应组件的参数
componentProps: () => {
return {
api: fetchRemoteOptions,
// 禁止本地过滤
filterOption: false,
// 如果正在获取数据,使用插槽显示一个loading
notFoundContent: fetching.value ? undefined : null,
// 搜索词变化时记录下来, 使用useDebounceFn防抖。
onSearch: useDebounceFn((value: string) => {
keyword.value = value;
}, 300),
// 远程搜索参数。当搜索词变化时,params也会更新
params: {
keyword: keyword.value || undefined,
},
// 远程搜索判断。当为true时,才允许调用api
shouldFetch: (params: any) => {
return !!params?.keyword;
},
showSearch: true,
};
},
// 字段名
fieldName: 'remoteSearch',
// 界面显示的label
label: '远程搜索',
help: '远程查询,仅有输入时方进行查询',
renderComponentContent: () => {
return {
notFoundContent: fetching.value ? h(Spin) : undefined,
};
},
rules: 'selectRequired',
},
{
component: 'ApiTreeSelect',
// 对应组件的参数
componentProps: {
// 菜单接口
api: getAllMenusApi,
childrenField: 'children',
// 菜单接口转options格式
labelField: 'name',
valueField: 'path',
childrenField: 'children',
},
// 字段名
fieldName: 'apiTree',
@@ -103,6 +205,7 @@ const [BaseForm, baseFormApi] = useVbenForm({
label: '图标',
},
{
colon: false,
component: 'Select',
componentProps: {
allowClear: true,
@@ -121,7 +224,7 @@ const [BaseForm, baseFormApi] = useVbenForm({
showSearch: true,
},
fieldName: 'options',
label: '下拉选',
label: () => h(Tag, { color: 'warning' }, () => '😎自定义:'),
},
{
component: 'RadioGroup',
@@ -177,6 +280,9 @@ const [BaseForm, baseFormApi] = useVbenForm({
default: () => ['我已阅读并同意'],
};
},
rules: z
.boolean()
.refine((v) => v, { message: '为什么不同意?勾上它!' }),
},
{
component: 'Mentions',
@@ -207,10 +313,21 @@ const [BaseForm, baseFormApi] = useVbenForm({
class: 'w-auto',
},
fieldName: 'switch',
help: () =>
['这是一个多行帮助信息', '第二行', '第三行'].map((v) => h('p', v)),
label: '开关',
},
{
component: 'DatePicker',
dependencies: {
resolve: ({ values }) => ({
help: () =>
[`这是一个可输出其他字段值的帮助信息${values.rate}`].map((value) =>
h('p', value),
),
}),
triggerFields: ['rate'],
},
fieldName: 'datePicker',
label: '日期选择框',
},
@@ -271,81 +388,117 @@ const [BaseForm, baseFormApi] = useVbenForm({
fieldName: 'treeSelect',
label: '树选择',
},
{
component: 'Upload',
componentProps: {
// 更多属性见:https://ant.design/components/upload-cn
accept: '.png,.jpg,.jpeg',
// 自动携带认证信息
customRequest: upload_file,
disabled: false,
maxCount: 3,
// 单位:MB
maxSize: 2,
multiple: false,
showUploadList: true,
// 上传列表的内建样式,支持四种基本样式 text, picture, picture-card 和 picture-circle
listType: 'picture-card',
draggable: true, // 启用拖拽排序
// onChange事件已被重写,如需自定义请在此基础上扩展
handleChange: ({ file }: { file: UploadFile }) => {
const { name, status } = file;
if (status === 'done') {
message.success(`${name} ${$t('examples.form.upload-success')}`);
} else if (status === 'error') {
message.error(`${name} ${$t('examples.form.upload-fail')}`);
}
},
onDragSort: (oldIndex: number, newIndex: number) => {
console.warn(`图片从 ${oldIndex} 移动到 ${newIndex}`);
},
},
fieldName: 'files',
label: $t('examples.form.file'),
renderComponentContent: () => {
return {
default: () => $t('examples.form.upload-image'),
};
},
rules: 'selectRequired',
},
{
component: 'Upload',
componentProps: {
accept: '.png,.jpg,.jpeg',
customRequest: upload_file,
maxCount: 1,
maxSize: 2,
listType: 'picture-card',
// 是否启用图片裁剪(多选或者非图片不唤起裁剪框)
crop: true,
// 裁剪比例
aspectRatio: '1:1',
},
fieldName: 'cropImage',
label: $t('examples.form.crop-image'),
renderComponentContent: () => {
return {
default: () => $t('examples.form.upload-image'),
};
},
rules: 'selectRequired',
},
{
component: 'RichEditor',
fieldName: 'richEditor',
label: '富文本',
formItemClass: 'col-span-3 items-baseline',
},
],
// 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
const [CustomLayoutForm] = useVbenForm({
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Select',
fieldName: 'field1',
label: '字符串',
},
{
component: 'TreeSelect',
fieldName: 'field2',
label: '字符串',
},
{
component: 'Mentions',
fieldName: 'field3',
label: '字符串',
},
{
component: 'Input',
fieldName: 'field4',
label: '字符串',
},
{
component: 'InputNumber',
fieldName: 'field5',
// 从第三列开始 相当于中间空了一列
formItemClass: 'col-start-3',
label: '前面空了一列',
},
{
component: 'Textarea',
fieldName: 'field6',
// 占满三列空间 基线对齐
formItemClass: 'col-span-3 items-baseline',
label: '占满三列',
},
{
component: 'Input',
fieldName: 'field7',
// 占满2列空间 从第二列开始 相当于前面空了一列
formItemClass: 'col-span-2 col-start-2',
label: '占满2列',
},
{
component: 'Input',
fieldName: 'field8',
// 左右留空
formItemClass: 'col-start-2',
label: '左右留空',
},
{
component: 'InputPassword',
fieldName: 'field9',
formItemClass: 'col-start-1',
label: '字符串',
},
],
// 一共三列
wrapperClass: 'grid-cols-3',
});
function onSubmit(values: BasicSubmitValues) {
const files = (toRaw(values.files) ?? []) as UploadFile[];
const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[];
const doneFiles = files.filter((file) => file.status === 'done');
const failedFiles = files.filter((file) => file.status !== 'done');
const doneCrop = cropImage.filter((file) => file.status === 'done');
const failedCrop = cropImage.filter((file) => file.status !== 'done');
function onSubmit(values: Record<string, any>) {
const msg = [
...doneFiles.map((file) => file.response?.url || file.url),
...failedFiles.map((file) => file.name),
].join(', ');
const msgCrop = [
...doneCrop.map((file) => file.response?.url || file.url),
...failedCrop.map((file) => file.name),
].join(', ');
if (failedFiles.length === 0) {
message.success({
content: `${$t('examples.form.upload-urls')}: ${msg}`,
});
} else {
message.error({
content: `${$t('examples.form.upload-error')}: ${msg}`,
});
return;
}
if (doneCrop.length > 0 && failedCrop.length === 0) {
message.success({
content: `${$t('examples.form.upload-urls')}: ${msgCrop}`,
});
} else if (failedCrop.length > 0) {
message.error({
content: `${$t('examples.form.upload-error')}: ${msgCrop}`,
});
return;
}
// 如果需要可提交前替换为需要的urls
values.files = doneFiles.map((file) => file.response?.url || file.url);
values.cropImage = doneCrop.map((file) => file.response?.url || file.url);
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
@@ -358,6 +511,14 @@ function handleSetFormValue() {
baseFormApi.setValues({
checkboxGroup: ['1'],
datePicker: dayjs('2022-01-01'),
files: [
{
name: 'example.png',
status: 'done',
uid: '-1',
url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
},
],
mentions: '@afc163',
number: 3,
options: '1',
@@ -369,6 +530,12 @@ function handleSetFormValue() {
timePicker: dayjs('2022-01-01 12:00:00'),
treeSelect: 'leaf1',
username: '1',
richEditor: `
<h1>Vben Tiptap</h1>
<p>这个编辑器已经被封装在 <code>packages/effects/plugins/src/tiptap</code> 中。</p>
<p>你可以直接在各个 app 里通过 <code>@vben/plugins/tiptap</code> 引入。</p>
<blockquote>默认内置 StarterKit、Underline、TextAlign、Placeholder。</blockquote>
`,
});
// 设置单个表单值
@@ -380,7 +547,6 @@ function handleSetFormValue() {
<Page
content-class="flex flex-col gap-4"
description="表单组件基础示例,请注意,该页面用到的参数代码会添加一些简单注释,方便理解,请仔细查看。"
header-class="pb-0"
title="表单组件"
>
<template #description>
@@ -389,22 +555,15 @@ function handleSetFormValue() {
表单组件基础示例,请注意,该页面用到的参数代码会添加一些简单注释,方便理解,请仔细查看。
</p>
</div>
<Tabs v-model:active-key="activeTab" :tab-bar-style="{ marginBottom: 0 }">
<TabPane key="basic" tab="基础示例" />
<TabPane key="layout" tab="自定义布局" />
</Tabs>
</template>
<template #extra>
<DocButton class="mb-2" path="/components/common-ui/vben-form" />
</template>
<Card v-show="activeTab === 'basic'" title="基础示例">
<Card title="基础示例">
<template #extra>
<Button type="primary" @click="handleSetFormValue">设置表单值</Button>
</template>
<BaseForm />
</Card>
<Card v-show="activeTab === 'layout'" title="使用tailwind自定义布局">
<CustomLayoutForm />
</Card>
</Page>
</template>

View File

@@ -0,0 +1,311 @@
<script lang="ts" setup>
import type { RadioGroupProps } from 'antdv-next';
import type { CollapsibleParamSchema, FormLayout } from '@vben/common-ui';
import { h, ref } from 'vue';
import { Page, VbenCollapsibleParams } from '@vben/common-ui';
import { Button, Card, message, RadioGroup } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
import DocButton from '../doc-button.vue';
const layouts: RadioGroupProps['options'] = [
{ label: 'Vertical', value: 'vertical' },
{ label: 'Horizontal', value: 'horizontal' },
];
const layout = ref<FormLayout>('vertical');
function getNumberValidator(key: string, limit?: [number?, number?]) {
let validator = z.number({
error: (issue) =>
issue.input === undefined ? `${key} 值不能为空` : `${key} 值只能为数字`,
});
if (limit) {
if (limit[0] !== undefined) {
validator = validator.min(limit[0], {
message: `${key} 值不能小于${limit[0]}`,
});
}
if (limit[1] !== undefined) {
validator = validator.max(limit[1], {
message: `${key} 值不能大于${limit[1]}`,
});
}
}
return validator.optional();
}
const paramsSchema: CollapsibleParamSchema[] = [
{
key: 'micro_batch_size',
description: `批次大小,代表模型训练过程中,模型更新模型参数的数据步长,可理解为模型每看多少数据即更新一次模型参数,
一般建议的批次大小为16/32,表示模型每看16或32条数据即更新一次参数`,
option: {
min: 8,
max: 1024,
step: 8,
},
},
{
key: 'learning_rate',
description:
'学习率,代表每次更新数据的增量参数权重,学习率数值越大参数变化越大,对模型影响越大',
option: {
step: 1e-4,
type: 'exponential',
min: 0,
max: 1,
},
},
{
key: 'eval_steps',
description:
'验证步数,训练阶段针模型的验证间隔步长,用于阶段性评估模型训练准确率、训练损失',
option: {
min: 1,
max: 2_147_483_647,
},
},
{
key: 'num_train_epochs',
description:
'循环次数,代表模型训练过程中模型学习数据集的次数,可理解为看几遍数据,一般建议的范围是1-3遍即可,可依据需求进行调整',
option: {
min: 1,
max: 200,
},
},
{
key: 'max_length',
description: `序列长度,单个训练数据样本的最大长度,超出配置长度将丢弃`,
option: {
min: 500,
max: 131_072,
},
},
{
key: 'warmup_ratio',
description: '学习率预热比例,学习率预热阶段占总训练步数的比例',
option: {
min: 0,
max: 1,
precision: 2,
step: 0.01,
},
},
{
key: 'save_steps',
description: 'Checkpoint保存间隔',
option: {
min: 1,
max: 2_147_483_647,
},
},
];
const paramsValidator = z
.object({
micro_batch_size: getNumberValidator('micro_batch_size', [8, 1024]),
learning_rate: getNumberValidator('learning_rate'),
eval_steps: getNumberValidator('eval_steps', [1, 2_147_483_647]),
num_train_epochs: getNumberValidator('num_train_epochs', [1, 200]),
max_length: getNumberValidator('max_length', [500, 131_072]),
warmup_ratio: getNumberValidator('warmup_ratio', [0, 1]),
save_steps: getNumberValidator('save_steps', [1, 2_147_483_647]),
})
.required();
const [BaseForm, baseFormApi] = useVbenForm({
showDefaultActions: false,
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
colon: true,
componentProps: {
class: 'w-full',
},
},
handleSubmit: onSubmit,
layout: 'vertical',
schema: [
{
component: 'Switch',
fieldName: 'qat',
componentProps: {
checkedChildren: '开',
unCheckedChildren: '关',
class: 'w-auto',
},
label: 'QAT',
formItemClass: 'col-span-2',
defaultValue: false,
},
{
// component:'CollapsibleParams',
component: h(VbenCollapsibleParams),
componentProps: {
params: paramsSchema,
// maxHeight: 200, //限制最大高度,展开后可滚动
// defaultOpen: true, // 默认false折叠
// visibleCount: 0 // 默认3,最小可见为1,小于1取1
},
modelPropName: 'value',
fieldName: 'params',
label: '参数配置',
formItemClass: 'col-span-8 items-baseline col-start-1',
dependencies: {
triggerFields: ['qat'],
componentProps(values) {
return {
params: values.qat
? [
{
key: 'calib_steps',
description: `校准步数;校准的数据集大小 = 校准步数 * 训练的batch_size`,
option: {
min: 1,
},
},
...paramsSchema,
]
: paramsSchema,
};
},
trigger(values, __, controller) {
// 访问 form 内 VbenCollapsibleParams 的实例
const paramsRef =
controller.getFieldComponentRef<typeof VbenCollapsibleParams>(
'params',
);
if (values.qat) {
paramsRef?.updateValues?.({
calib_steps: 10,
micro_batch_size: 32,
learning_rate: 4e-5,
eval_steps: 80,
num_train_epochs: 3,
max_length: 32_768,
warmup_ratio: 0.1,
save_steps: 80,
});
} else {
paramsRef?.updateValues?.({
calib_steps: null,
});
}
},
rules(values) {
if (values.qat) {
return paramsValidator.extend({
calib_steps: getNumberValidator('calib_steps', [1]),
});
}
return paramsValidator;
},
},
rules: paramsValidator,
defaultValue: {
micro_batch_size: 8,
learning_rate: 1e-5,
eval_steps: 50,
num_train_epochs: 3,
max_length: 32_768,
warmup_ratio: 0.05,
save_steps: 50,
},
},
{
component: 'RichEditor',
fieldName: 'richEditor',
label: '富文本',
formItemClass: 'col-span-12 items-baseline',
collapsible: true,
defaultCollapsed: false, // 默认false
},
],
wrapperClass: 'grid-cols-12',
});
function onSubmit(values: Record<string, any>) {
message.info({
content: `form values: ${JSON.stringify(values)}`,
});
}
function onLayoutChange() {
baseFormApi.setState({
layout: layout.value,
});
}
function handleSetFormValue() {
baseFormApi.setFieldValue('params', {
micro_batch_size: 1024,
learning_rate: 1e-5,
eval_steps: 150,
num_train_epochs: 13,
max_length: 131_072,
warmup_ratio: 0.05,
save_steps: 150,
});
}
function handleResetFormValue() {
baseFormApi.reset(undefined, { force: true });
}
async function handleSubmitFormValue() {
const { valid } = await baseFormApi.validate();
if (valid) {
baseFormApi.submit();
}
}
</script>
<template>
<Page
auto-content-height
content-class="flex flex-col gap-4"
title="可折叠表单项"
>
<template #description>
<div class="text-muted-foreground">
<p>可折叠表单项、以及可折叠参数配置组件示例</p>
</div>
</template>
<template #extra>
<DocButton class="mb-2" path="/components/common-ui/vben-form" />
</template>
<Card title="基础示例">
<template #extra>
<div class="inline-flex items-center gap-4!">
<RadioGroup
:options="layouts"
option-type="button"
v-model:value="layout"
@change="onLayoutChange"
/>
<Button type="primary" @click="handleSetFormValue">
设置表单值
</Button>
<Button type="primary" @click="handleSubmitFormValue">
提交表单
</Button>
<Button type="primary" @click="handleResetFormValue">
重置表单
</Button>
</div>
</template>
<div class="w-full overflow-hidden">
<BaseForm />
</div>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { h } from 'vue';
import { Page } from '@vben/common-ui';
import { Card } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import DocButton from '../doc-button.vue';
const [CustomLayoutForm] = useVbenForm({
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Select',
fieldName: 'field1',
label: '字符串',
},
{
component: 'TreeSelect',
fieldName: 'field2',
label: '字符串',
},
{
component: 'Mentions',
fieldName: 'field3',
label: '字符串',
},
{
component: 'Input',
fieldName: 'field4',
label: '字符串',
},
{
component: 'InputNumber',
fieldName: 'field5',
// 从第三列开始 相当于中间空了一列
formItemClass: 'col-start-3',
label: '前面空了一列',
},
{
component: 'Divider',
fieldName: '_divider',
formItemClass: 'col-span-3',
hideLabel: true,
renderComponentContent: () => {
return {
default: () => h('div', '分割线'),
};
},
},
{
component: 'Textarea',
fieldName: 'field6',
// 占满三列空间 基线对齐
formItemClass: 'col-span-3 items-baseline',
label: '占满三列',
},
{
component: 'Input',
fieldName: 'field7',
// 占满2列空间 从第二列开始 相当于前面空了一列
formItemClass: 'col-span-2 col-start-2',
label: '占满2列',
},
{
component: 'Input',
fieldName: 'field8',
// 左右留空
formItemClass: 'col-start-2',
label: '左右留空',
},
{
component: 'InputPassword',
fieldName: 'field9',
formItemClass: 'col-start-1',
label: '字符串',
},
],
// 一共三列
wrapperClass: 'grid-cols-3',
});
</script>
<template>
<Page
content-class="flex flex-col gap-4"
description="使用tailwind自定义表单项的布局"
title="表单自定义布局"
>
<template #description>
<div class="text-muted-foreground">
<p>使用tailwind自定义表单项的布局,使用Divider分割表单。</p>
</div>
</template>
<template #extra>
<DocButton class="mb-2" path="/components/common-ui/vben-form" />
</template>
<Card title="使用tailwind自定义布局">
<CustomLayoutForm />
</Card>
</Page>
</template>

View File

@@ -1,13 +1,51 @@
<script lang="ts" setup>
import { h } from 'vue';
import { h, markRaw, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, Input, message } from 'ant-design-vue';
import { Button, Card, Input, message, Select } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { useVbenForm, z } from '#/adapter/form';
const [Form] = useVbenForm({
import TwoFields from './modules/two-fields.vue';
interface CustomFormValues extends Record<string, unknown> {
field?: string;
field1?: string;
field2?: string;
field3?: string;
field4?: [string | undefined, string | undefined];
field5?: string;
}
function encodeCustomFormValues(values: Readonly<CustomFormValues>) {
const { field4, ...formValues } = values;
return {
...formValues,
phoneNumber: field4?.[1],
phoneType: field4?.[0],
};
}
type CustomSubmitValues = ReturnType<typeof encodeCustomFormValues>;
const dynamicComponentType = ref<'input' | 'select'>('input');
function decodeCustomFormValues(
values: Readonly<CustomSubmitValues>,
): CustomFormValues {
const { phoneNumber, phoneType, ...formValues } = values;
return {
...formValues,
field4: [phoneType, phoneNumber ?? ''],
};
}
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeCustomFormValues,
encode: encodeCustomFormValues,
},
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
@@ -39,9 +77,10 @@ const [Form] = useVbenForm({
}),
},
{
component: h(Input, { placeholder: '请输入' }),
component: h(Input, { placeholder: '请输入Field2' }),
fieldName: 'field2',
label: '自定义组件',
modelPropName: 'value',
rules: 'required',
},
{
@@ -50,12 +89,76 @@ const [Form] = useVbenForm({
label: '自定义组件(slot)',
rules: 'required',
},
{
component: markRaw(TwoFields),
defaultValue: [undefined, ''],
fieldName: 'field4',
formItemClass: 'col-span-1',
label: '组合字段',
rules: z
.array(z.string().optional())
.length(2, '请选择类型并输入手机号码')
.refine((v) => !!v[0], {
message: '请选择类型',
})
.refine((v) => !!v[1] && v[1] !== '', {
message: '       输入手机号码',
})
.refine((v) => v[1]?.match(/^1[3-9]\d{9}$/), {
// 使用全角空格占位,将错误提示文字挤到手机号码输入框的下面
message: '       号码格式不正确',
}),
},
{
component: markRaw(Input),
componentProps: {
placeholder: '请输入动态组件值',
},
fieldName: 'field5',
label: '动态组件',
modelPropName: 'value',
},
],
// 中屏一行显示2个,小屏一行显示1个
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
function onSubmit(values: Record<string, any>) {
function handleToggleDynamicComponent() {
const nextType = dynamicComponentType.value === 'input' ? 'select' : 'input';
dynamicComponentType.value = nextType;
if (nextType === 'select') {
formApi.updateSchema([
{
component: markRaw(Select),
componentProps: {
allowClear: true,
options: [
{ label: '选项一', value: 'option-1' },
{ label: '选项二', value: 'option-2' },
],
placeholder: '请选择动态组件值',
},
fieldName: 'field5',
modelPropName: 'value',
},
]);
return;
}
formApi.updateSchema([
{
component: markRaw(Input),
componentProps: {
placeholder: '请输入动态组件值',
},
fieldName: 'field5',
modelPropName: 'value',
},
]);
}
function onSubmit(values: CustomSubmitValues) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
@@ -65,9 +168,16 @@ function onSubmit(values: Record<string, any>) {
<template>
<Page description="表单组件自定义示例" title="表单组件">
<Card title="基础示例">
<template #extra>
<Button @click="handleToggleDynamicComponent">
{{
dynamicComponentType === 'input' ? '切换为下拉框' : '切换为输入框'
}}
</Button>
</template>
<Form>
<template #field3="slotProps">
<Input placeholder="请输入" v-bind="slotProps" />
<Input placeholder="请输入" v-bind="slotProps.componentProps" />
</template>
</Form>
</Card>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button, Card, message } from 'ant-design-vue';
import { Button, Card, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
import type { StepItem } from 'antdv-next';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Step, Steps, Switch } from 'ant-design-vue';
import { Button, Card, message, Steps, Switch } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
@@ -75,6 +77,7 @@ const [SecondForm, secondFormApi] = useVbenForm({
],
wrapperClass: 'grid-cols-1 md:grid-cols-1 lg:grid-cols-1',
});
const stepsItems: StepItem[] = [{ title: '表单1' }, { title: '表单2' }];
const needMerge = ref(true);
async function handleMergeSubmit() {
const values = await firstFormApi
@@ -102,10 +105,7 @@ async function handleMergeSubmit() {
<Button type="primary" @click="handleMergeSubmit">合并提交</Button>
</template>
<div class="mx-auto max-w-lg">
<Steps :current="currentTab" class="steps">
<Step title="表单1" />
<Step title="表单2" />
</Steps>
<Steps :current="currentTab" :items="stepsItems" class="steps" />
<div class="p-20">
<FirstForm v-show="currentTab === 0" />
<SecondForm v-show="currentTab === 1" />

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import type { SelectValue } from 'antdv-next';
import { Input, Select } from 'antdv-next';
const emit = defineEmits(['blur']);
const modelValue = defineModel<[string | undefined, string | undefined]>({
default: () => [undefined, undefined],
});
function handlePhoneChange(value: string | undefined) {
modelValue.value = [modelValue.value[0], value];
}
function handleTypeChange(value: SelectValue) {
modelValue.value = [
typeof value === 'string' ? value : undefined,
modelValue.value[1],
];
}
</script>
<template>
<div class="flex w-full gap-1">
<Select
:value="modelValue[0]"
class="w-20"
placeholder="类型"
allow-clear
:class="{ 'valid-success': !!modelValue[0] }"
:options="[
{ label: '个人', value: 'personal' },
{ label: '工作', value: 'work' },
{ label: '私密', value: 'private' },
]"
@blur="emit('blur')"
@update:value="handleTypeChange"
/>
<Input
placeholder="请输入11位手机号码"
class="flex-1"
allow-clear
:class="{ 'valid-success': modelValue[1]?.match(/^1[3-9]\d{9}$/) }"
:value="modelValue[1]"
:maxlength="11"
type="tel"
@blur="emit('blur')"
@update:value="handlePhoneChange"
/>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Card, message } from 'ant-design-vue';
import { Card, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
@@ -86,6 +86,62 @@ const [QueryForm] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
const [InlineForm] = useVbenForm({
layout: 'inline',
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入用户名',
},
// 字段名
fieldName: 'username',
// 界面显示的label
label: '字符串',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
fieldName: 'password',
label: '密码',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'number',
label: '数字(带后缀)',
suffix: () => '¥',
},
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
fieldName: 'options',
label: '下拉选',
},
],
});
const [QueryForm1] = useVbenForm({
// 默认展开
collapsed: true,
@@ -125,6 +181,70 @@ const [QueryForm1] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
const [QueryForm2] = useVbenForm({
// 操作按钮组 newLine: 在新行显示。rowEnd: 在行内显示,靠右对齐(默认)。inline: 使用grid默认样式
actionLayout: 'newLine',
actionPosition: 'left', // 操作按钮组在左侧显示
// 默认折叠
collapsed: true,
collapsedRows: 3,
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// 提交函数
handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical
// 水平布局,label和input在同一行
layout: 'vertical',
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入用户名',
},
// 字段名
fieldName: 'username',
// 界面显示的label
label: '字符串',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
fieldName: 'password',
label: '密码',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'number',
label: '数字(带后缀)',
suffix: () => '¥',
},
{
component: 'DatePicker',
fieldName: 'datePicker',
label: '日期选择框',
},
],
// 是否可展开
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
// 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
@@ -140,6 +260,15 @@ function onSubmit(values: Record<string, any>) {
<Card class="mb-5" title="查询表单,默认展开">
<QueryForm />
</Card>
<Card class="mb-5" title="查询表单,单行表单">
<InlineForm />
</Card>
<Card class="mb-5" title="查询表单,默认展开,垂直布局">
<QueryForm2 />
</Card>
<Card title="查询表单,默认折叠,折叠时保留2行">
<QueryForm1 />
</Card>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button, Card, message } from 'ant-design-vue';
import { Button, Card, message } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
@@ -70,7 +70,7 @@ const [Form, formApi] = useVbenForm({
fieldName: 'field4',
// 界面显示的label
label: '邮箱',
rules: z.string().email('请输入正确的邮箱'),
rules: z.email('请输入正确的邮箱'),
},
{
component: 'InputNumber',
@@ -150,7 +150,9 @@ const [Form, formApi] = useVbenForm({
default: () => ['我已阅读并同意'],
};
},
rules: 'selectRequired',
rules: z.boolean().refine((value) => value, {
message: '请勾选',
}),
},
{
component: 'DatePicker',
@@ -182,8 +184,7 @@ const [Form, formApi] = useVbenForm({
},
fieldName: 'input-blur',
formFieldProps: {
validateOnChange: false,
validateOnModelUpdate: false,
validateOn: ['blur'],
},
help: 'blur时才会触发校验',
label: 'blur触发',
@@ -233,7 +234,7 @@ function onSubmit(values: Record<string, any>) {
<Card title="基础组件校验示例">
<template #extra>
<Button @click="() => formApi.validate()">校验表单</Button>
<Button class="mx-2" @click="() => formApi.resetValidate()">
<Button class="mx-2" @click="() => formApi.clearValidation()">
清空校验信息
</Button>
</template>

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Switch } from 'antdv-next';
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.validateAndSubmit();
}
// 测试 validate(手动验证整个表单)
async function testValidate() {
await formApi.validate();
}
// 测试 validateField(验证单个字段)
async function testValidateField() {
await formApi.validateField('username');
}
// 切换滚动功能
function toggleScrollToError(checked: boolean) {
formApi.setState({ scrollToFirstError: checked });
}
// 填充部分数据测试
async function fillPartialData() {
await formApi.reset();
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-sm bg-blue-50 dark:bg-blue-900 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-sm border p-4">
<h4 class="mb-3 font-medium">验证方法测试:</h4>
<div class="flex flex-wrap gap-2">
<Button type="primary" @click="testValidateAndSubmit">
测试 validateAndSubmit()
</Button>
<Button @click="testValidate"> 测试 validate() </Button>
<Button @click="testValidateField"> 测试 validateField() </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p>• validateAndSubmit(): 验证表单并提交</p>
<p>• validate(): 手动验证整个表单</p>
<p>• validateField(): 验证单个字段(这里测试用户名字段)</p>
</div>
</div>
<div class="rounded-sm border p-4">
<h4 class="mb-3 font-medium">数据填充测试:</h4>
<div class="flex flex-wrap gap-2">
<Button @click="fillPartialData"> 填充部分数据 </Button>
<Button @click="() => formApi.reset()"> 清空表单 </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p>• 填充部分数据后验证,会滚动到第一个错误字段</p>
</div>
</div>
<Form />
</div>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,195 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { computed, nextTick, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space, Tag } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import DocButton from '../doc-button.vue';
interface ValueFormatFormValues {
deadline?: Dayjs;
keyword?: string;
reportRange?: [Dayjs, Dayjs];
}
function encodeValueFormatValues(values: Readonly<ValueFormatFormValues>) {
return {
deadline: values.deadline?.valueOf(),
endTime: values.reportRange?.[1].valueOf(),
keyword: values.keyword,
startTime: values.reportRange?.[0].valueOf(),
};
}
type ValueFormatSubmitValues = ReturnType<typeof encodeValueFormatValues>;
function decodeValueFormatValues(
values: Readonly<ValueFormatSubmitValues>,
): ValueFormatFormValues {
let reportRange: [Dayjs, Dayjs] | undefined;
if (values.startTime !== undefined && values.endTime !== undefined) {
reportRange = [dayjs(values.startTime), dayjs(values.endTime)];
}
return {
deadline:
values.deadline === undefined ? undefined : dayjs(values.deadline),
keyword: values.keyword,
reportRange,
};
}
const transformedValues = ref<Partial<ValueFormatSubmitValues>>({});
const liveValues = ref<Partial<ValueFormatFormValues>>({});
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeValueFormatValues,
encode: encodeValueFormatValues,
},
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
handleValuesChange,
schema: [
{
component: 'RangePicker',
fieldName: 'reportRange',
help: '由表单 codec 拆分为 startTime / endTime',
label: '统计时间范围',
},
{
component: 'DatePicker',
fieldName: 'deadline',
help: '由表单 codec 编码为时间戳',
label: '截止时间',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入关键字',
},
fieldName: 'keyword',
label: '关键字',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
const liveValuesPreview = computed(() => formatJsonPreview(liveValues.value));
const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value);
});
function formatJsonPreview(value: unknown) {
return JSON.stringify(
value,
(_key, currentValue) => {
return dayjs.isDayjs(currentValue)
? currentValue.format('YYYY-MM-DD HH:mm:ss')
: currentValue;
},
2,
);
}
async function handleInspectValues() {
await syncPreviewValues();
message.success('已刷新 getValues 输出');
}
function handleSetExampleValue() {
formApi.setValues({
deadline: dayjs('2026-04-12 18:30:00'),
keyword: 'invoice',
reportRange: [dayjs('2026-04-01 00:00:00'), dayjs('2026-04-12 23:59:59')],
});
}
function handleSubmit(values: ValueFormatSubmitValues) {
transformedValues.value = values;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
function handleValuesChange(
values: Readonly<ValueFormatFormValues>,
_fieldsChanged: string[],
getFormattedValues: () => ValueFormatSubmitValues,
) {
liveValues.value = { ...values };
transformedValues.value = getFormattedValues();
}
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
const rawValues = values ?? (await formApi.getRawValues());
liveValues.value = { ...rawValues };
transformedValues.value = await formApi.getValues();
}
onMounted(async () => {
await nextTick();
await syncPreviewValues();
});
</script>
<template>
<Page
content-class="flex flex-col gap-4"
description="演示表单级 codec 如何双向转换组件值和提交 payload。"
title="表单 Codec"
>
<template #description>
<div class="text-muted-foreground space-y-2">
<p>
<code>getRawValues()</code> 返回组件原始值,<code>getValues()</code> /
提交时会按 <code>codec.encode</code> 输出 payload,回填时通过
<code>codec.decode</code> 恢复组件值。
</p>
<div class="flex flex-wrap gap-2">
<Tag color="processing">encode:生成完整提交值</Tag>
<Tag color="success">decode:恢复完整表单值</Tag>
<Tag color="warning">多字段转换原子执行</Tag>
</div>
</div>
</template>
<template #extra>
<DocButton path="/components/common-ui/vben-form" />
</template>
<Card title="Codec 示例">
<template #extra>
<Space wrap>
<Button @click="handleSetExampleValue">填充示例数据</Button>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
</Space>
</template>
<Form />
</Card>
<div class="grid gap-4 lg:grid-cols-2">
<Card title="getRawValues() 输出(组件值)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出(codec.encode 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>
</Card>
</div>
</Page>
</template>

View File

@@ -0,0 +1,66 @@
export const json1 = {
additionalInfo: {
author: 'Your Name',
debug: true,
version: '1.3.10',
versionCode: 132,
},
additionalNotes: 'This JSON is used for demonstration purposes',
tools: [
{
description: 'Description of Tool 1',
name: 'Tool 1',
},
{
description: 'Description of Tool 2',
name: 'Tool 2',
},
{
description: 'Description of Tool 3',
name: 'Tool 3',
},
{
description: 'Description of Tool 4',
name: 'Tool 4',
},
],
};
export const json2 = JSON.parse(`
{
"id": "chatgpt-123",
"object": "chat.completion",
"created": 1677652288,
"model": "gpt-3.5-turbo-0613",
"system_fingerprint": "fp_44709d6fcb",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello there, how may I assist you today?"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 12,
"total_tokens": 21,
"debug_mode": true
},
"debug": {
"startAt": "2021-08-01T00:00:00Z",
"logs": [
{
"timestamp": "2021-08-01T00:00:00Z",
"message": "This is a debug message",
"extra":[ "extra1", "extra2" ]
},
{
"timestamp": "2021-08-01T00:00:01Z",
"message": "This is another debug message",
"extra":[ "extra3", "extra4" ]
}
]
}
}
`);

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import type { JsonViewerAction, JsonViewerValue } from '@vben/common-ui';
import { JsonViewer, Page } from '@vben/common-ui';
import { Card, message } from 'antdv-next';
import { json1, json2 } from './data';
function handleKeyClick(key: string) {
message.info(`点击了Key ${key}`);
}
function handleValueClick(value: JsonViewerValue) {
message.info(`点击了Value ${JSON.stringify(value)}`);
}
function handleCopied(_event: JsonViewerAction) {
message.success('已复制JSON');
}
</script>
<template>
<Page
title="Json Viewer"
description="一个渲染 JSON 结构数据的组件,支持复制、展开等,简单易用"
>
<Card title="默认配置">
<JsonViewer :value="json1" />
</Card>
<Card title="可复制、默认展开3层、显示边框、事件处理" class="mt-4">
<JsonViewer
:value="json2"
:expand-depth="3"
copyable
:sort="false"
@key-click="handleKeyClick"
@value-click="handleValueClick"
@copied="handleCopied"
boxed
/>
</Card>
<Card title="预览模式" class="mt-4">
<JsonViewer
:value="json2"
copyable
preview-mode
:show-array-index="false"
/>
</Card>
</Page>
</template>

View File

@@ -12,7 +12,7 @@ import {
Slider,
Tag,
Tooltip,
} from 'ant-design-vue';
} from 'antdv-next';
const props = reactive({
leftCollapsedWidth: 5,
@@ -22,8 +22,8 @@ const props = reactive({
leftWidth: 30,
resizable: true,
rightWidth: 70,
splitHandle: false,
splitLine: false,
splitHandle: true,
splitLine: true,
});
const leftMinWidth = ref(props.leftMinWidth || 1);
const leftMaxWidth = ref(props.leftMaxWidth || 100);
@@ -42,7 +42,7 @@ const leftMaxWidth = ref(props.leftMaxWidth || 100);
<template #left="{ isCollapsed, expand }">
<div v-if="isCollapsed" @click="expand">
<Tooltip title="点击展开左侧">
<Button shape="circle" type="primary">
<Button shape="circle" type="primary" class="flex-center">
<template #icon>
<IconifyIcon class="text-2xl" icon="bi:arrow-right" />
</template>
@@ -52,7 +52,7 @@ const leftMaxWidth = ref(props.leftMaxWidth || 100);
<div
v-else
:style="{ minWidth: '200px' }"
class="border-border bg-card mr-2 rounded-[var(--radius)] border p-2"
class="mr-2 rounded-(--radius) border border-border bg-card p-2"
>
<p>这里是左侧内容</p>
<p>这里是左侧内容</p>
@@ -77,7 +77,7 @@ const leftMaxWidth = ref(props.leftMaxWidth || 100);
v-model:value="leftMinWidth"
:max="props.leftMaxWidth - 1"
:min="1"
style="width: 100px"
class="w-25"
@after-change="(value) => (props.leftMinWidth = value as number)"
/>
<span>左侧最大宽度百分比:</span>
@@ -85,11 +85,11 @@ const leftMaxWidth = ref(props.leftMaxWidth || 100);
v-model:value="props.leftMaxWidth"
:max="100"
:min="leftMaxWidth + 1"
style="width: 100px"
class="w-25"
@after-change="(value) => (props.leftMaxWidth = value as number)"
/>
</div>
<Alert message="实验性的组件" show-icon type="warning">
<Alert title="实验性的组件" show-icon type="warning">
<template #description>
<p>
双列布局组件是一个在Page组件上扩展的相对基础的布局组件,支持左侧折叠(当拖拽导致左侧宽度比最小宽度还要小时,还可以进入折叠状态)、拖拽调整宽度等功能。

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { Loading, Page, Spinner } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { refAutoReset } from '@vueuse/core';
import { Button, Card, Spin } from 'antdv-next';
const spinning = refAutoReset(false, 3000);
const loading = refAutoReset(false, 3000);
const spinningV = refAutoReset(false, 3000);
const loadingV = refAutoReset(false, 3000);
</script>
<template>
<Page
title="Vben Loading"
description="加载中状态组件。这个组件可以为其它作为容器的组件添加一个加载中的遮罩层。使用它们时,容器需要relative定位。"
>
<Card title="Antd Spin">
<template #actions>这是Antd 组件库自带的Spin组件演示</template>
<Spin :spinning="spinning" description="加载中...">
<Button type="primary" @click="spinning = true">显示Spin</Button>
</Spin>
</Card>
<Card title="Vben Loading" v-loading="loadingV" class="mt-4">
<template #extra>
<Button type="primary" @click="loadingV = true">
v-loading 指令
</Button>
</template>
<template #actions>
Loading组件可以设置文字,并且也提供了icon插槽用于替换加载图标。
</template>
<div class="flex gap-4">
<div class="size-40">
<Loading
:spinning="loading"
text="正在加载..."
class="flex-center size-full"
>
<Button type="primary" @click="loading = true">默认动画</Button>
</Loading>
</div>
<div class="size-40">
<Loading :spinning="loading" class="flex-center size-full">
<Button type="primary" @click="loading = true">自定义动画1</Button>
<template #icon>
<IconifyIcon
icon="svg-spinners:ring-resize"
class="size-10 text-primary"
/>
</template>
</Loading>
</div>
<div class="size-40">
<Loading :spinning="loading" class="flex-center size-full">
<Button type="primary" @click="loading = true">自定义动画2</Button>
<template #icon>
<IconifyIcon
icon="svg-spinners:bars-scale"
class="size-10 text-primary"
/>
</template>
</Loading>
</div>
</div>
</Card>
<Card
title="Vben Spinner"
v-spinning="spinningV"
class="mt-4 overflow-hidden"
:styles="{
body: {
position: 'relative',
overflow: 'hidden',
},
}"
>
<template #extra>
<Button type="primary" @click="spinningV = true">
v-spinning 指令
</Button>
</template>
<template #actions>
Spinner组件是Loading组件的一个特例,只有一个固定的统一样式。
</template>
<Spinner :spinning="spinning" class="flex-center size-40">
<Button type="primary" @click="spinning = true">显示Spinner</Button>
</Spinner>
</Card>
</Page>
</template>

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
const list = ref<number[]>([]);
@@ -16,15 +16,18 @@ const [Modal, modalApi] = useVbenModal({
},
onOpenChange(isOpen) {
if (isOpen) {
handleUpdate(10);
handleUpdate();
}
},
});
function handleUpdate(len: number) {
function handleUpdate(len?: number) {
modalApi.setState({ confirmDisabled: true, loading: true });
setTimeout(() => {
list.value = Array.from({ length: len }, (_v, k) => k + 1);
list.value = Array.from(
{ length: len ?? Math.floor(Math.random() * 10) + 1 },
(_v, k) => k + 1,
);
modalApi.setState({ confirmDisabled: false, loading: false });
}, 2000);
}
@@ -35,12 +38,12 @@ function handleUpdate(len: number) {
<div
v-for="item in list"
:key="item"
class="even:bg-heavy bg-muted flex-center h-[220px] w-full"
class="flex-center h-55 w-full bg-muted even:bg-heavy"
>
{{ item }}
</div>
<template #prepend-footer>
<Button type="link" @click="handleUpdate(6)">点击更新数据</Button>
<Button type="link" @click="handleUpdate()">点击更新数据</Button>
</template>
</Modal>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
const [Modal, modalApi] = useVbenModal({
onCancel() {
@@ -18,9 +18,17 @@ const [Modal, modalApi] = useVbenModal({
message.info('onOpened:打开动画结束');
},
});
function lockModal() {
modalApi.lock();
setTimeout(() => {
modalApi.unlock();
}, 3000);
}
</script>
<template>
<Modal class="w-[600px]" title="基础弹窗示例" title-tooltip="标题提示内容">
<Modal class="w-150" title="基础弹窗示例" title-tooltip="标题提示内容">
base demo
<Button type="primary" @click="lockModal">锁定弹窗</Button>
</Modal>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Slider } from 'antdv-next';
const blur = ref(5);
const [Modal, modalApi] = useVbenModal({
overlayBlur: blur.value,
});
watch(blur, (val) => {
modalApi.setState({
overlayBlur: val,
});
});
</script>
<template>
<Modal title="遮罩层模糊">
<p>调整滑块来改变遮罩层模糊程度:{{ blur }}</p>
<Slider v-model:value="blur" :max="30" :min="0" />
</Modal>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
const [Modal, modalApi] = useVbenModal({
draggable: true,

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
const [Modal, modalApi] = useVbenModal({
draggable: true,

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
function onSubmit(values: Record<string, any>) {
message.info(JSON.stringify(values)); // 只会执行一次
interface FormModalData {
values?: Record<string, unknown>;
}
const [Form, formApi] = useVbenForm({
@@ -35,6 +35,7 @@ const [Form, formApi] = useVbenForm({
{
component: 'Select',
componentProps: {
class: 'w-full',
options: [
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
@@ -49,25 +50,44 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
const [Modal, modalApi] = useVbenModal<FormModalData>({
fullscreenButton: false,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
// modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values } = modalApi.getData<Record<string, any>>();
if (values) {
formApi.setValues(values);
const data = modalApi.getData();
if (data?.values) {
formApi.setValues(data.values);
}
}
},
title: '内嵌表单示例',
});
defineExpose({ modalApi });
function onSubmit(values: Record<string, any>) {
message.loading({
content: '正在提交中...',
duration: 0,
key: 'is-form-submitting',
});
modalApi.lock();
setTimeout(() => {
modalApi.close();
message.success({
content: `提交成功:${JSON.stringify(values)}`,
duration: 2,
key: 'is-form-submitting',
});
}, 3000);
}
</script>
<template>
<Modal>

View File

@@ -1,9 +1,12 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Input, message } from 'antdv-next';
const [Modal, modalApi] = useVbenModal({
destroyOnClose: false,
onCancel() {
modalApi.close();
},
@@ -12,14 +15,16 @@ const [Modal, modalApi] = useVbenModal({
// modalApi.close();
},
});
const value = ref();
</script>
<template>
<Modal
append-to-main
class="w-[600px]"
class="w-150"
title="基础弹窗示例"
title-tooltip="标题提示内容"
>
此弹窗指定在内容区域打开
此弹窗指定在内容区域打开,并且在关闭之后弹窗内容不会被销毁
<Input v-model:value="value" placeholder="KeepAlive测试" />
</Modal>
</template>

View File

@@ -1,16 +1,35 @@
<script lang="ts" setup>
import { Page, useVbenModal } from '@vben/common-ui';
import type { ExplicitModalData } from './typed-data-contract';
import { Button, Card } from 'ant-design-vue';
import { onBeforeUnmount } from 'vue';
import {
alert,
clearAllAlerts,
confirm,
Page,
prompt,
useVbenModal,
} from '@vben/common-ui';
import { Button, Card, Flex, message } from 'antdv-next';
import DocButton from '../doc-button.vue';
import AutoHeightDemo from './auto-height-demo.vue';
import BaseDemo from './base-demo.vue';
import BlurDemo from './blur-demo.vue';
import DragDemo from './drag-demo.vue';
import DynamicDemo from './dynamic-demo.vue';
import FormModalDemo from './form-modal-demo.vue';
import InContentModalDemo from './in-content-demo.vue';
import NestedDemo from './nested-demo.vue';
import SharedDataDemo from './shared-data-demo.vue';
import TypedDataAutoDemo from './typed-data-auto-demo.vue';
import { useFactoryModal } from './typed-data-contract';
import TypedDataExplicitDemo from './typed-data-explicit-demo.vue';
import TypedDataFactoryDemo from './typed-data-factory-demo.vue';
defineOptions({ name: 'ModalExample' });
const [BaseModal, baseModalApi] = useVbenModal({
// 连接抽离的组件
@@ -42,6 +61,27 @@ const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
});
const [TypedDataAutoModal, typedDataAutoModalApi] = useVbenModal({
connectedComponent: TypedDataAutoDemo,
});
const [TypedDataExplicitModal, typedDataExplicitModalApi] =
useVbenModal<ExplicitModalData>({
connectedComponent: TypedDataExplicitDemo,
});
const [TypedDataFactoryModal, typedDataFactoryModalApi] = useFactoryModal({
connectedComponent: TypedDataFactoryDemo,
});
const [NestedModal, nestedModalApi] = useVbenModal({
connectedComponent: NestedDemo,
});
const [BlurModal, blurModalApi] = useVbenModal({
connectedComponent: BlurDemo,
});
function openBaseModal() {
baseModalApi.open();
}
@@ -63,24 +103,118 @@ function openDynamicModal() {
}
function openSharedModal() {
sharedModalApi.setData({
content: '外部传递的数据 content',
payload: '外部传递的数据 payload',
});
sharedModalApi.open();
sharedModalApi
.setData({
content: '外部传递的数据 content',
payload: '外部传递的数据 payload',
})
.open();
}
function openNestedModal() {
nestedModalApi.open();
}
function openBlurModal() {
blurModalApi.open();
}
function handleUpdateTitle() {
dynamicModalApi.setState({ title: '外部动态标题' });
dynamicModalApi.open();
dynamicModalApi.setState({ title: '外部动态标题' }).open();
}
function openFormModal() {
formModalApi.setData({
// 表单值
values: { field1: 'abc' },
formModalApi
.setData({
// 表单值
values: { field1: 'abc', field2: '123', field3: '1' },
})
.open();
}
function openTypedDataAutoModal() {
typedDataAutoModalApi
.setData({
message: '外部无需声明泛型,由 connected component 自动推导。',
method: '自动推导',
})
.open();
}
function openTypedDataExplicitModal() {
typedDataExplicitModalApi
.setData({
message: '父子组件显式引用同一个数据类型。',
method: '显式泛型',
})
.open();
}
function openTypedDataFactoryModal() {
typedDataFactoryModalApi
.setData({
message: '父子组件复用预绑定的 typed composable。',
method: '契约工厂',
})
.open();
}
function openAlert() {
alert({
content: '这是一个弹窗',
icon: 'success',
}).then(() => {
message.info('用户关闭了弹窗');
});
formModalApi.open();
}
onBeforeUnmount(() => {
// 清除所有弹窗
clearAllAlerts();
});
function openConfirm() {
confirm({
beforeClose({ isConfirm }) {
if (!isConfirm) return;
// 这里可以做一些异步操作
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
},
centered: false,
content: '这是一个确认弹窗',
icon: 'question',
})
.then(() => {
message.success('用户确认了操作');
})
.catch(() => {
message.error('用户取消了操作');
});
}
async function openPrompt() {
prompt<string>({
async beforeClose({ isConfirm, value }) {
if (isConfirm && value === '芝士') {
message.error('不能吃芝士');
return false;
}
},
componentProps: { placeholder: '不能吃芝士...' },
content: '中午吃了什么?',
icon: 'question',
overlayBlur: 3,
})
.then((res) => {
message.success(`用户输入了:${res}`);
})
.catch(() => {
message.error('用户取消了输入');
});
}
</script>
@@ -100,44 +234,121 @@ function openFormModal() {
<DynamicModal />
<SharedDataModal />
<FormModal />
<Card class="mb-4" title="基本使用">
<p class="mb-3">一个基础的弹窗示例</p>
<Button type="primary" @click="openBaseModal">打开弹窗</Button>
</Card>
<TypedDataAutoModal />
<TypedDataExplicitModal />
<TypedDataFactoryModal />
<NestedModal />
<BlurModal />
<Flex wrap="wrap" class="w-full" gap="10">
<Card class="w-75" title="基本使用">
<p>一个基础的弹窗示例</p>
<template #actions>
<Button type="primary" @click="openBaseModal">打开弹窗</Button>
</template>
</Card>
<Card class="mb-4" title="指定容器">
<p class="mb-3">在内容区域打开弹窗的示例</p>
<Button type="primary" @click="openInContentModal">打开弹窗</Button>
</Card>
<Card class="w-75" title="指定容器+关闭后不销毁">
<p>在内容区域打开弹窗的示例</p>
<template #actions>
<Button type="primary" @click="openInContentModal">打开弹窗</Button>
</template>
</Card>
<Card class="mb-4" title="内容高度自适应">
<p class="mb-3">可根据内容并自动调整高度</p>
<Button type="primary" @click="openAutoHeightModal">打开弹窗</Button>
</Card>
<Card class="w-75" title="内容高度自适应">
<p>可根据内容并自动调整高度</p>
<template #actions>
<Button type="primary" @click="openAutoHeightModal">
打开弹窗
</Button>
</template>
</Card>
<Card class="mb-4" title="可拖拽示例">
<p class="mb-3">配置 draggable 可开启拖拽功能</p>
<Button type="primary" @click="openDragModal">打开弹窗</Button>
</Card>
<Card class="w-75" title="可拖拽示例">
<p>配置 draggable 可开启拖拽功能</p>
<template #actions>
<Button type="primary" @click="openDragModal"> 打开弹窗 </Button>
</template>
</Card>
<Card class="mb-4" title="动态配置示例">
<p class="mb-3">通过 setState 动态调整弹窗数据</p>
<Button type="primary" @click="openDynamicModal">打开弹窗</Button>
<Button class="ml-2" type="primary" @click="handleUpdateTitle">
从外部修改标题并打开
</Button>
</Card>
<Card class="w-75" title="动态配置示例">
<p>通过 setState 动态调整弹窗数据</p>
<template #extra>
<Button type="link" @click="openDynamicModal">打开弹窗</Button>
</template>
<template #actions>
<Button type="primary" @click="handleUpdateTitle">
外部修改标题并打开
</Button>
</template>
</Card>
<Card class="mb-4" title="内外数据共享示例">
<p class="mb-3">通过共享 sharedData 来进行数据交互</p>
<Button type="primary" @click="openSharedModal">
打开弹窗并传递数据
</Button>
</Card>
<Card class="w-75" title="内外数据共享示例">
<p>通过共享 sharedData 来进行数据交互</p>
<template #actions>
<Button type="primary" @click="openSharedModal">
打开弹窗并传递数据
</Button>
</template>
</Card>
<Card class="mb-4" title="表单弹窗示例">
<p class="mb-3">弹窗与表单结合</p>
<Button type="primary" @click="openFormModal"> 打开弹窗 </Button>
</Card>
<Card class="w-75" title="表单弹窗示例">
<p>弹窗与表单结合</p>
<template #actions>
<Button type="primary" @click="openFormModal"> 打开表单弹窗 </Button>
</template>
</Card>
<Card class="w-75" title="共享数据:自动推导">
<p>子组件 expose API,父组件从 connected component 推导类型</p>
<template #actions>
<Button type="primary" @click="openTypedDataAutoModal">
打开自动推导示例
</Button>
</template>
</Card>
<Card class="w-75" title="共享数据:显式泛型">
<p>无法自动推导时,父子组件显式引用同一个数据类型</p>
<template #actions>
<Button type="primary" @click="openTypedDataExplicitModal">
打开显式泛型示例
</Button>
</template>
</Card>
<Card class="w-75" title="共享数据:契约工厂">
<p>通过 createVbenModal 预绑定类型并复用 typed composable</p>
<template #actions>
<Button type="primary" @click="openTypedDataFactoryModal">
打开契约工厂示例
</Button>
</template>
</Card>
<Card class="w-75" title="嵌套弹窗示例">
<p>在已经打开的弹窗中再次打开弹窗</p>
<template #actions>
<Button type="primary" @click="openNestedModal">打开嵌套弹窗</Button>
</template>
</Card>
<Card class="w-75" title="遮罩模糊示例">
<p>遮罩层应用类似毛玻璃的模糊效果</p>
<template #actions>
<Button type="primary" @click="openBlurModal">打开弹窗</Button>
</template>
</Card>
<Card class="w-75" title="轻量提示弹窗">
<template #extra>
<DocButton path="/components/common-ui/vben-alert" />
</template>
<p>通过快捷方法创建动态提示弹窗,适合一些轻量的提示和确认、输入等</p>
<template #actions>
<Button type="primary" @click="openAlert">Alert</Button>
<Button type="primary" @click="openConfirm">Confirm</Button>
<Button type="primary" @click="openPrompt">Prompt</Button>
</template>
</Card>
</Flex>
</Page>
</template>

View File

@@ -0,0 +1,24 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { Button } from 'antdv-next';
import DragDemo from './drag-demo.vue';
const [Modal] = useVbenModal({
destroyOnClose: true,
});
const [BaseModal, baseModalApi] = useVbenModal({
connectedComponent: DragDemo,
});
function openNestedModal() {
baseModalApi.open();
}
</script>
<template>
<Modal title="嵌套弹窗示例">
<Button @click="openNestedModal" type="primary">打开子弹窗</Button>
<BaseModal />
</Modal>
</template>

View File

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

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
interface AutoModalData {
message: string;
method: '自动推导';
}
const data = ref<AutoModalData>();
const [Modal, modalApi] = useVbenModal<AutoModalData>({
onOpenChange(isOpen) {
data.value = isOpen ? modalApi.getData() : undefined;
},
});
defineExpose({ modalApi });
</script>
<template>
<Modal title="自动推导数据类型">
<div class="space-y-2 px-2">
<p>类型来源:{{ data?.method }}</p>
<p>接收内容:{{ data?.message }}</p>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,13 @@
import { createVbenModal } from '@vben/common-ui';
export interface ExplicitModalData {
message: string;
method: '显式泛型';
}
export interface FactoryModalData {
message: string;
method: '契约工厂';
}
export const useFactoryModal = createVbenModal<FactoryModalData>();

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ExplicitModalData } from './typed-data-contract';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
const data = ref<ExplicitModalData>();
const [Modal, modalApi] = useVbenModal<ExplicitModalData>({
onOpenChange(isOpen) {
data.value = isOpen ? modalApi.getData() : undefined;
},
});
</script>
<template>
<Modal title="显式泛型数据类型">
<div class="space-y-2 px-2">
<p>类型来源:{{ data?.method }}</p>
<p>接收内容:{{ data?.message }}</p>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { FactoryModalData } from './typed-data-contract';
import { ref } from 'vue';
import { useFactoryModal } from './typed-data-contract';
const data = ref<FactoryModalData>();
const [Modal, modalApi] = useFactoryModal({
onOpenChange(isOpen) {
data.value = isOpen ? modalApi.getData() : undefined;
},
});
</script>
<template>
<Modal title="契约工厂数据类型">
<div class="space-y-2 px-2">
<p>类型来源:{{ data?.method }}</p>
<p>接收内容:{{ data?.message }}</p>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
import { reactive } from 'vue';
import { Page } from '@vben/common-ui';
import { Motion, MotionGroup, MotionPresets } from '@vben/plugins/motion';
import { refAutoReset, watchDebounced } from '@vueuse/core';
import {
Button,
Card,
Col,
Form,
FormItem,
InputNumber,
Row,
Select,
} from 'antdv-next';
// 本例子用不到visible类型的动画。带有VisibleOnce和Visible的类型会在组件进入视口被显示时执行动画,
const presets = MotionPresets.filter((v) => !v.includes('Visible'));
const showCard1 = refAutoReset(true, 100);
const showCard2 = refAutoReset(true, 100);
const showCard3 = refAutoReset(true, 100);
const motionProps = reactive({
delay: 0,
duration: 300,
enter: { scale: 1 },
hovered: { scale: 1.1, transition: { delay: 0, duration: 50 } },
preset: 'fade',
tapped: { scale: 0.9, transition: { delay: 0, duration: 50 } },
});
const motionGroupProps = reactive({
delay: 0,
duration: 300,
enter: { scale: 1 },
hovered: { scale: 1.1, transition: { delay: 0, duration: 50 } },
preset: 'fade',
tapped: { scale: 0.9, transition: { delay: 0, duration: 50 } },
});
watchDebounced(
motionProps,
() => {
showCard2.value = false;
},
{ debounce: 200, deep: true },
);
watchDebounced(
motionGroupProps,
() => {
showCard3.value = false;
},
{ debounce: 200, deep: true },
);
function openDocPage() {
window.open('https://motion.vueuse.org/', '_blank');
}
</script>
<template>
<Page title="Motion">
<template #description>
<span>一个易于使用的为其它组件赋予动画效果的组件。</span>
<Button type="link" @click="openDocPage">查看文档</Button>
</template>
<Card title="使用指令" :styles="{ body: { minHeight: '5rem' } }">
<template #extra>
<Button type="primary" @click="showCard1 = false">重载</Button>
</template>
<div>
<div class="relative flex gap-2 overflow-hidden" v-if="showCard1">
<Button v-motion-fade-visible>fade</Button>
<Button v-motion-pop-visible :duration="500">pop</Button>
<Button v-motion-slide-left>slide-left</Button>
<Button v-motion-slide-right>slide-right</Button>
<Button v-motion-slide-bottom>slide-bottom</Button>
<Button v-motion-slide-top>slide-top</Button>
</div>
</div>
</Card>
<Card
class="mt-2"
title="使用组件(将内部作为一个整体添加动画)"
:styles="{ body: { padding: 0 } }"
>
<div class="relative flex-center min-h-32 gap-2 overflow-hidden">
<Motion
v-bind="motionProps"
v-if="showCard2"
class="flex items-center gap-2"
>
<Button size="large">这个按钮在显示时会有动画效果</Button>
<span>附属组件,会作为整体处理动画</span>
</Motion>
</div>
<div class="relative flex-center min-h-32 gap-2 overflow-hidden">
<div v-if="showCard2" class="flex items-center gap-2">
<span>顺序延迟</span>
<Motion
v-bind="{
...motionProps,
delay: motionProps.delay + 100 * i,
}"
v-for="i in 5"
:key="i"
>
<Button size="large">按钮{{ i }}</Button>
</Motion>
</div>
</div>
<div>
<Form :model="motionProps" :label-col="{ span: 10 }">
<Row>
<Col :span="8">
<FormItem prop="preset" label="动画效果">
<Select
v-model:value="motionProps.preset"
:options="presets.map((p) => ({ label: p, value: p }))"
/>
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="duration" label="持续时间">
<InputNumber v-model:value="motionProps.duration" />
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="delay" label="延迟动画">
<InputNumber v-model:value="motionProps.delay" />
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="hovered.scale" label="Hover缩放">
<InputNumber v-model:value="motionProps.hovered.scale" />
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="hovered.tapped" label="按下时缩放">
<InputNumber v-model:value="motionProps.tapped.scale" />
</FormItem>
</Col>
</Row>
</Form>
</div>
</Card>
<Card
class="mt-2"
title="分组动画(每个子元素都会应用相同的独立动画)"
:styles="{ body: { padding: 0 } }"
>
<div class="relative flex-center min-h-32 gap-2 overflow-hidden">
<MotionGroup v-bind="motionGroupProps" v-if="showCard3">
<Button size="large">按钮1</Button>
<Button size="large">按钮2</Button>
<Button size="large">按钮3</Button>
<Button size="large">按钮4</Button>
<Button size="large">按钮5</Button>
</MotionGroup>
</div>
<div>
<Form :model="motionGroupProps" :label-col="{ span: 10 }">
<Row>
<Col :span="8">
<FormItem prop="preset" label="动画效果">
<Select
v-model:value="motionGroupProps.preset"
:options="presets.map((p) => ({ label: p, value: p }))"
/>
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="duration" label="持续时间">
<InputNumber v-model:value="motionGroupProps.duration" />
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="delay" label="延迟动画">
<InputNumber v-model:value="motionGroupProps.delay" />
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="hovered.scale" label="Hover缩放">
<InputNumber v-model:value="motionGroupProps.hovered.scale" />
</FormItem>
</Col>
<Col :span="8">
<FormItem prop="hovered.tapped" label="按下时缩放">
<InputNumber v-model:value="motionGroupProps.tapped.scale" />
</FormItem>
</Col>
</Row>
</Form>
</div>
</Card>
</Page>
</template>

View File

@@ -50,7 +50,7 @@ const resize = (size?: TSize, rect?: TSize) => {
>
<div
:style="{ backgroundColor: colorMap[idx] }"
class="h-full w-full"
class="size-full"
></div>
</VResize>
</template>

View File

@@ -0,0 +1,303 @@
<script lang="ts" setup>
import type { TippyProps } from '@vben/common-ui';
import { reactive } from 'vue';
import { Page, Tippy } from '@vben/common-ui';
import { Button, Card, Flex } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const tippyProps = reactive<TippyProps>({
animation: 'shift-away',
arrow: true,
content: '这是一个提示',
delay: [200, 200],
duration: 200,
followCursor: false,
hideOnClick: false,
inertia: true,
maxWidth: 'none',
placement: 'top',
theme: 'dark',
trigger: 'mouseenter focusin',
});
function parseBoolean(value: string) {
switch (value) {
case 'false': {
return false;
}
case 'true': {
return true;
}
default: {
return value;
}
}
}
const [Form] = useVbenForm({
handleValuesChange(values) {
Object.assign(tippyProps, {
...values,
delay: [values.delay1, values.delay2],
followCursor: parseBoolean(values.followCursor),
hideOnClick: parseBoolean(values.hideOnClick),
trigger: values.trigger.join(' '),
});
},
schema: [
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
class: 'w-full',
options: [
{ label: '自动', value: 'auto' },
{ label: '暗色', value: 'dark' },
{ label: '亮色', value: 'light' },
],
optionType: 'button',
},
defaultValue: tippyProps.theme,
fieldName: 'theme',
label: '主题',
},
{
component: 'Select',
componentProps: {
class: 'w-full',
options: [
{ label: '向上滑入', value: 'shift-away' },
{ label: '向下滑入', value: 'shift-toward' },
{ label: '缩放', value: 'scale' },
{ label: '透视', value: 'perspective' },
{ label: '淡入', value: 'fade' },
],
},
defaultValue: tippyProps.animation,
fieldName: 'animation',
label: '动画类型',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: '是', value: true },
{ label: '否', value: false },
],
optionType: 'button',
},
defaultValue: tippyProps.inertia,
fieldName: 'inertia',
label: '动画惯性',
},
{
component: 'Select',
componentProps: {
class: 'w-full',
options: [
{ label: '顶部', value: 'top' },
{ label: '顶左', value: 'top-start' },
{ label: '顶右', value: 'top-end' },
{ label: '底部', value: 'bottom' },
{ label: '底左', value: 'bottom-start' },
{ label: '底右', value: 'bottom-end' },
{ label: '左侧', value: 'left' },
{ label: '左上', value: 'left-start' },
{ label: '左下', value: 'left-end' },
{ label: '右侧', value: 'right' },
{ label: '右上', value: 'right-start' },
{ label: '右下', value: 'right-end' },
],
},
defaultValue: tippyProps.placement,
fieldName: 'placement',
label: '位置',
},
{
component: 'InputNumber',
componentProps: {
addonAfter: '毫秒',
},
defaultValue: tippyProps.duration,
fieldName: 'duration',
label: '动画时长',
},
{
component: 'InputNumber',
componentProps: {
addonAfter: '毫秒',
},
defaultValue: 100,
fieldName: 'delay1',
label: '显示延时',
},
{
component: 'InputNumber',
componentProps: {
addonAfter: '毫秒',
},
defaultValue: 100,
fieldName: 'delay2',
label: '隐藏延时',
},
{
component: 'Input',
defaultValue: tippyProps.content,
fieldName: 'content',
label: '内容',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: '是', value: true },
{ label: '否', value: false },
],
optionType: 'button',
},
defaultValue: tippyProps.arrow,
fieldName: 'arrow',
label: '指示箭头',
},
{
component: 'Select',
componentProps: {
class: 'w-full',
options: [
{ label: '不跟随', value: 'false' },
{ label: '完全跟随', value: 'true' },
{ label: '仅横向', value: 'horizontal' },
{ label: '仅纵向', value: 'vertical' },
{ label: '仅初始', value: 'initial' },
],
},
defaultValue: tippyProps.followCursor?.toString(),
fieldName: 'followCursor',
label: '跟随指针',
},
{
component: 'Select',
componentProps: {
class: 'w-full',
mode: 'multiple',
options: [
{ label: '鼠标移入', value: 'mouseenter' },
{ label: '被点击', value: 'click' },
{ label: '获得焦点', value: 'focusin' },
{ label: '无触发,仅手动', value: 'manual' },
],
},
defaultValue: tippyProps.trigger?.split(' '),
fieldName: 'trigger',
label: '触发方式',
},
{
component: 'Select',
componentProps: {
class: 'w-full',
options: [
{ label: '否', value: 'false' },
{ label: '是', value: 'true' },
{ label: '仅内部', value: 'toggle' },
],
},
defaultValue: tippyProps.hideOnClick?.toString(),
dependencies: {
componentProps(_, formAction) {
return {
disabled: !formAction.values.trigger.includes('click'),
};
},
triggerFields: ['trigger'],
},
fieldName: 'hideOnClick',
help: '只有在触发方式为`click`时才有效',
label: '点击后隐藏',
},
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: 'none、200px',
},
defaultValue: tippyProps.maxWidth,
fieldName: 'maxWidth',
label: '最大宽度',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
function goDoc() {
window.open('https://atomiks.github.io/tippyjs/v6/all-props/');
}
</script>
<template>
<Page title="Tippy">
<template #description>
<div class="flex items-center">
<p>
Tippy
是一个轻量级的提示工具库,它可以用来创建各种交互式提示,如工具提示、引导提示等。
</p>
<Button type="link" size="small" @click="goDoc">查看文档</Button>
</div>
</template>
<Card title="指令形式使用">
<p class="mb-4">
指令形式使用比较简洁,直接在需要展示tooltip的组件上用v-tippy传递配置,适用于固定内容的工具提示。
</p>
<Flex warp="warp" gap="20" align="center">
<Button v-tippy="'这是一个提示,使用了默认的配置'">默认配置</Button>
<Button
v-tippy="{ theme: 'light', content: '这是一个提示,总是light主题' }"
>
指定主题
</Button>
<Button
v-tippy="{
theme: 'light',
content: '这个提示将在点燃组件100毫秒后激活',
delay: 100,
}"
>
指定延时
</Button>
<Button
v-tippy="{
content: '本提示的动画为`scale`',
animation: 'scale',
}"
>
指定动画
</Button>
</Flex>
</Card>
<Card title="组件形式使用" class="mt-4">
<div class="flex w-full justify-center">
<Tippy v-bind="tippyProps">
<Button>鼠标移到这个组件上来体验效果</Button>
</Tippy>
</div>
<Form class="mt-4" />
<template #actions>
<p
class="cursor-default text-secondary-foreground hover:text-secondary-foreground"
>
更多配置请
<Button type="link" size="small" @click="goDoc">查看文档</Button>
,这里只列出了一些常用的配置
</p>
</template>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { ImageUploadOptions } from '@vben/plugins/tiptap';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { VbenTiptap, VbenTiptapPreview } from '@vben/plugins/tiptap';
import { Card, Switch } from 'antdv-next';
const content = ref(`
<h1>Vben Tiptap</h1>
<p>这个编辑器已经被封装在 <code>packages/effects/plugins/src/tiptap</code> 中。</p>
<p>你可以直接在各个 app 里通过 <code>@vben/plugins/tiptap</code> 引入。</p>
<blockquote>默认内置 StarterKit、Underline、TextAlign、Placeholder。</blockquote>
`);
const previewContent = computed(() => content.value);
const enableUpload = ref(true);
// Mock upload: 模拟上传延迟,支持进度回调
const imageUpload: ImageUploadOptions = {
accept: 'image/*',
maxSize: 5 * 1024 * 1024, // 5MB
upload: (file, onProgress) =>
new Promise((resolve) => {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 30;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
}
onProgress?.(Math.round(progress));
if (progress >= 100) {
// 上传完成后返回 mock URL
resolve(
`https://picsum.photos/seed/${Date.now()}/640/${Math.round((640 * ((file.size % 3) + 2)) / 4)}`,
);
}
}, 300);
}),
onUploadError: (error) => {
console.error('Image upload failed:', error);
},
};
</script>
<template>
<Page title="Tiptap 富文本">
<template #description>
<div class="mt-2 text-foreground/80">
统一封装后的富文本编辑器,适合在各个 app 中直接复用。
</div>
</template>
<Card class="mb-5" title="编辑器">
<div class="mb-3 flex items-center gap-3">
<span class="text-sm">启用图片上传:</span>
<Switch v-model:checked="enableUpload" />
</div>
<VbenTiptap
v-model="content"
:image-upload="enableUpload ? imageUpload : undefined"
/>
</Card>
<Card class="mb-5" title="富文本预览">
<VbenTiptapPreview :content="previewContent" />
</Card>
<Card title="HTML 输出">
<pre class="overflow-auto rounded-xl border border-border bg-muted p-4">
{{ previewContent }}
</pre>
</Card>
</Page>
</template>

View File

@@ -3,7 +3,7 @@ import type { VxeGridListeners, VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
@@ -43,7 +43,22 @@ const gridEvents: VxeGridListeners<RowType> = {
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridEvents, gridOptions });
const [Grid, gridApi] = useVbenVxeGrid<RowType>({
// 放开注释查看表单组件的类型
// formOptions: {
// schema: [
// {
// component: 'Switch',
// fieldName: 'name',
// },
// ],
// },
gridEvents,
gridOptions,
});
// 放开注释查看当前表格实例的类型
// gridApi.grid
const showBorder = gridApi.useStore((state) => state.gridOptions?.border);
const showStripe = gridApi.useStore((state) => state.gridOptions?.stripe);

View File

@@ -3,7 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button, Image, Switch, Tag } from 'ant-design-vue';
import { Button, Image, Switch, Tag } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
@@ -95,7 +95,7 @@ const [Grid] = useVbenVxeGrid({ gridOptions });
<Image :src="row.imageUrl" height="30" width="30" />
</template>
<template #open="{ row }">
<Switch v-model:checked="row.open" />
<Switch v-model="row.open" />
</template>
<template #status="{ row }">
<Tag :color="row.color">{{ row.status }}</Tag>

View File

@@ -3,7 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';

View File

@@ -3,7 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';

View File

@@ -1,13 +1,17 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { VbenFormProps } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
import { createDateRangeCodec } from '#/utils/date-range-codec';
interface RowType {
category: string;
@@ -18,7 +22,24 @@ interface RowType {
releaseDate: string;
}
const formOptions: VbenFormProps = {
interface SearchFormValues extends Record<string, unknown> {
category?: string;
color?: string;
date?: [Dayjs, Dayjs];
price?: string;
productName?: string;
}
const searchCodec = createDateRangeCodec<SearchFormValues>()({
endField: 'end',
rangeField: 'date',
startField: 'start',
});
type SearchSubmitValues = ReturnType<typeof searchCodec.encode>;
const formOptions: VbenFormProps<SearchFormValues, SearchSubmitValues> = {
codec: searchCodec,
// 默认展开
collapsed: false,
schema: [
@@ -58,8 +79,9 @@ const formOptions: VbenFormProps = {
label: 'Color',
},
{
component: 'DatePicker',
fieldName: 'datePicker',
component: 'RangePicker',
defaultValue: [dayjs().subtract(7, 'days'), dayjs()],
fieldName: 'date',
label: 'Date',
},
],
@@ -91,7 +113,7 @@ const gridOptions: VxeTableGridOptions<RowType> = {
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
query: async ({ page }, formValues: SearchSubmitValues) => {
message.success(`Query params: ${JSON.stringify(formValues)}`);
return await getExampleTableApi({
page: page.currentPage,

View File

@@ -3,7 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
@@ -55,7 +55,7 @@ const gridOptions: VxeGridProps<RowType> = {
custom: true,
export: true,
// import: true,
refresh: { code: 'query' },
refresh: true,
zoom: true,
},
};

View File

@@ -3,7 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -0,0 +1,209 @@
<script lang="ts" setup>
import type { OnActionClickParams, VxeGridProps } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
interface RowType {
category: string;
color: string;
id: string;
price: string;
productName: string;
releaseDate: string;
}
const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: 'category',
},
columns: [
{ title: '序号', type: 'seq', width: 50 },
{ field: 'category', sortable: true, title: 'Category' },
{ field: 'color', sortable: true, title: 'Color' },
{ field: 'productName', sortable: true, title: 'Product Name' },
{ field: 'price', sortable: true, title: 'Price' },
{ field: 'releaseDate', formatter: 'formatDateTime', title: 'DateTime' },
{
align: 'center',
cellRender: {
attrs: {
nameField: 'category',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'view',
text: '查看',
},
'edit',
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.menu.operation'),
width: 200,
},
],
exportConfig: {},
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page, sort }) => {
return await getExampleTableApi({
page: page.currentPage,
pageSize: page.pageSize,
sortBy: sort.field,
sortOrder: sort.order,
});
},
},
sort: true,
},
sortConfig: {
defaultSort: { field: 'category', order: 'desc' },
remote: true,
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
refresh: true,
zoom: true,
},
};
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
viewedRowOptions: {
// 触发已读的操作码
actionCodes: ['view'],
// 行数据中的唯一标识字段
keyField: 'id',
// 持久化配置(简写模式,使用内置 localStorage)
// persist: 'viewed_rows',
persist: {
key: 'viewed-rows',
type: 'indexedDB',
ttl: 7 * 24 * 60 * 60 * 1000, // 7天过期
maxSize: 200,
},
},
});
function onActionClick({ code, row }: OnActionClickParams<RowType>) {
switch (code) {
case 'edit': {
onEdit(row);
break;
}
case 'view': {
onView(row);
break;
}
default: {
break;
}
}
}
const editRow = ref<RowType>();
const [Modal, modalApi] = useVbenModal({
draggable: true,
onConfirm: () => {
modalApi.setState({ loading: true });
editRow.value && gridApi.markRowAsViewed(editRow.value);
modalApi.setState({ loading: false });
modalApi.close();
},
});
function onEdit(row: RowType) {
editRow.value = row;
modalApi.open();
}
function onView(row: RowType) {
message.success({
content: `查看${row.category}`,
key: 'action_process_msg_id',
});
}
const isStyle = ref(false);
function onStyleSet() {
isStyle.value = !isStyle.value;
gridApi.setState({
viewedRowOptions: {
rowStyle: () => {
return isStyle.value ? { backgroundColor: 'gray' } : '';
},
},
});
}
const isClassName = ref(false);
function onClassNameSet() {
isClassName.value = !isClassName.value;
gridApi.setState({
viewedRowOptions: {
rowClassName: () => {
return isClassName.value
? 'bg-red-100 vxe-row--viewed'
: 'vxe-row--viewed';
},
},
});
}
function onCustomSet() {
const tableData = gridApi.grid.getData();
const keys = tableData.slice(0, 2).map((row) => row.id);
gridApi.markKeysAsViewed(keys);
}
function onClearViewed() {
gridApi.clearViewedRows();
}
</script>
<template>
<Page
auto-content-height
description="表格行标记支持存储类型 custom | indexedDB | localStorage | memory | sessionStorage 。
默认使用memory存储,当设置custom时需要自己实现getKeys()/setKeys()/removeKeys()。
具体属性查看packages/effects/plugins/src/vxe-table/types.ts。可通过gridApi调用
clearViewedRows()/getViewedKeys()/isRowViewed()/markKeysAsViewed()/markRowAsViewed()/removeViewedKeys()"
title="表格行标记示例"
>
<Modal class="w-150" title="数据修改"> 数据修改完成后设置行标记</Modal>
<Grid table-title="已查看行标记" table-title-help="提示">
<template #toolbar-tools>
<Button class="mr-2" type="primary" @click="onCustomSet">
手动标记
</Button>
<Button class="mr-2" type="primary" @click="onStyleSet">
设置Style
</Button>
<Button class="mr-2" type="primary" @click="onClassNameSet">
设置ClassName
</Button>
<Button type="primary" @click="onClearViewed"> 清空缓存</Button>
</template>
</Grid>
</Page>
</template>