Merge remote-tracking branch 'origin/main'

# Conflicts:
#	packages/effects/plugins/src/vxe-table/use-vxe-grid.vue
This commit is contained in:
雪忆天堂
2026-06-15 17:41:12 +08:00
496 changed files with 14298 additions and 7860 deletions

View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@@ -0,0 +1,7 @@
---
'@vben/styles': patch
'@vben-core/form-ui': patch
'@vben/web-naive': patch
---
feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray

View File

@@ -61,7 +61,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -89,6 +89,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: '/language:${{matrix.language}}'

View File

@@ -19,7 +19,7 @@ jobs:
steps:
# 关闭未活动的 Issues
- name: Close Inactive Issues
uses: actions/stale@v9
uses: actions/stale@v10
with:
days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
stale-issue-label: needs-reproduction # Label that flags an issue as stale.

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: remove enhancement pending
if: github.event.label.name == 'enhancement'
uses: actions-cool/issues-helper@v3
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
with:
actions: 'remove-labels'
token: ${{ secrets.GITHUB_TOKEN }}
@@ -27,7 +27,7 @@ jobs:
- name: remove bug pending
if: github.event.label.name == 'bug'
uses: actions-cool/issues-helper@v3
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
with:
actions: 'remove-labels'
token: ${{ secrets.GITHUB_TOKEN }}
@@ -36,7 +36,7 @@ jobs:
- name: needs reproduction
if: github.event.label.name == 'needs reproduction'
uses: actions-cool/issues-helper@v3
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
with:
actions: 'create-comment, remove-labels'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,7 +14,7 @@ jobs:
if: github.repository == 'vbenjs/vue-vben-admin'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '14'

View File

@@ -3,60 +3,44 @@ name: Create Release Tag
on:
push:
tags:
- 'v*.*.*' # Push events to matching v*, i.e. v1.0, v20.15.10
env:
HUSKY: '0'
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to create (e.g. v1.2.3)'
required: true
type: string
permissions:
pull-requests: write
contents: write
jobs:
build:
release:
name: Create Release
if: github.repository == 'vbenjs/vue-vben-admin'
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
# - name: Checkout code
# uses: actions/checkout@v6
# with:
# fetch-depth: 0
# - name: Install pnpm
# uses: pnpm/action-setup@v4
# - name: Use Node.js ${{ matrix.node-version }}
# uses: actions/setup-node@v4
# with:
# node-version: ${{ matrix.node-version }}
# cache: "pnpm"
# - name: Install dependencies
# run: pnpm install --frozen-lockfile
# - name: Test and Build
# run: |
# pnpm run test
# pnpm run build
- name: version
- name: Extract version
id: version
run: |
tag=${GITHUB_REF/refs\/tags\//}
version=${tag#v}
major=${version%%.*}
echo "tag=${tag}" >> $GITHUB_OUTPUT
echo "version=${version}" >> $GITHUB_OUTPUT
echo "major=${major}" >> $GITHUB_OUTPUT
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
raw_tag="${{ inputs.tag }}"
else
raw_tag="${GITHUB_REF_NAME}"
fi
# Normalize: ensure v prefix
tag="${raw_tag}"
[[ "${tag:0:1}" != "v" ]] && tag="v${tag}"
version="${tag#v}"
major="${version%%.*}"
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
echo "major=${major}" >> "${GITHUB_OUTPUT}"
- uses: release-drafter/release-drafter@v7
with:
@@ -64,17 +48,3 @@ jobs:
publish: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: force update major tag
# run: |
# git tag v${{ steps.version.outputs.major }} ${{ steps.version.outputs.tag }} -f
# git push origin refs/tags/v${{ steps.version.outputs.major }} -f
# - name: Create Release for Tag
# id: release_tag
# uses: ncipollo/release-action@v1
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# generateReleaseNotes: "true"
# body: |
# > Please refer to [CHANGELOG.md](https://github.com/vbenjs/vue-vben-admin/blob/main/CHANGELOG.md) for details.

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Validate PR title
uses: amannn/action-semantic-pull-request@v5
uses: amannn/action-semantic-pull-request@v6
with:
wip: true
subjectPattern: ^(?![A-Z]).+$

View File

@@ -9,7 +9,7 @@ jobs:
if: github.repository == 'vbenjs/vue-vben-admin'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'

3
.gitignore vendored
View File

@@ -23,6 +23,7 @@ package-lock.json
.VSCodeCounter
**/backend-mock/data
.omx
.pnpm-store
# local env files
.env.local
.env.*.local
@@ -57,3 +58,5 @@ vite.config.ts.*
.claude
.codex
skills-lock.json
.atomcode
datalog

View File

@@ -1 +1 @@
22.22.0
24.16.0

12
.npmrc
View File

@@ -1,13 +1 @@
registry=https://registry.npmmirror.com
public-hoist-pattern[]=lefthook
public-hoist-pattern[]=eslint
public-hoist-pattern[]=oxfmt
public-hoist-pattern[]=oxlint
public-hoist-pattern[]=stylelint
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=@commitlint/*
public-hoist-pattern[]=czg
strict-peer-dependencies=false
auto-install-peers=true
dedupe-peer-dependents=true

View File

@@ -38,7 +38,7 @@
// lint && format
"oxc.enable": true,
"oxc.typeAware": true,
"oxc.typeAware": false,
"oxc.configPath": "oxlint.config.ts",
"oxc.fmt.configPath": "oxfmt.config.ts",
"eslint.useFlatConfig": true,

View File

@@ -114,7 +114,7 @@ pnpm build
## ブラウザサポート
ローカル開発には `Chrome 80+` ブラウザを推奨します
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
モダンブラウザをサポートし、IEはサポートしません

View File

@@ -114,7 +114,7 @@ Reference [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.
## Browser Support
The `Chrome 80+` browser is recommended for local development
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
Support modern browsers, not IE

View File

@@ -114,7 +114,7 @@ pnpm build
## 浏览器支持
本地开发推荐使用 `Chrome 80+` 浏览器
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
支持现代浏览器,不支持 IE

View File

@@ -0,0 +1,85 @@
import { faker } from '@faker-js/faker';
import { eventHandler, getQuery } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
const formatterCN = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
function generateMockDataList(count: number) {
const dataList = [];
for (let i = 0; i < count; i++) {
const dataItem: Record<string, any> = {
id: faker.string.uuid(),
name: faker.commerce.product(),
status: faker.helpers.arrayElement([0, 1]),
createTime: formatterCN.format(
faker.date.between({ from: '2022-01-01', to: '2025-01-01' }),
),
deptId: faker.string.uuid(),
remark: faker.lorem.sentence(),
};
dataList.push(dataItem);
}
return dataList;
}
const mockData = generateMockDataList(100);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const {
page = 1,
pageSize = 20,
name,
id,
remark,
startTime,
endTime,
deptId,
status,
} = getQuery(event);
let listData = structuredClone(mockData);
if (name) {
listData = listData.filter((item) =>
item.name.toLowerCase().includes(String(name).toLowerCase()),
);
}
if (id) {
listData = listData.filter((item) =>
item.id.toLowerCase().includes(String(id).toLowerCase()),
);
}
if (remark) {
listData = listData.filter((item) =>
item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()),
);
}
if (startTime) {
listData = listData.filter((item) => item.createTime >= startTime);
}
if (endTime) {
listData = listData.filter((item) => item.createTime <= endTime);
}
if (['0', '1'].includes(status as string)) {
listData = listData.filter((item) => item.status === Number(status));
}
if (deptId) {
listData = listData.filter((item) => item.deptId === deptId);
}
return usePageResponseSuccess(page as string, pageSize as string, listData);
});

View File

@@ -131,8 +131,8 @@ const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@@ -702,7 +702,9 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮

View File

@@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -244,7 +244,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<template #description> 今日晴20 - 32 </template>
</WorkbenchHeader>
<div class="mt-5 flex flex-col lg:flex-row">
<div class="flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
@@ -252,7 +252,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
class="lg:mt-0"
title="快捷导航"
@click="navTo"
/>

View File

@@ -36,8 +36,11 @@ import type { Component, Ref } from 'vue';
import type {
ApiComponentSharedProps,
BaseFormComponentType,
CollapsibleParamsProps,
IconPickerProps,
} from '@vben/common-ui';
import type { Sortable } from '@vben/hooks';
import type { TipTapProps } from '@vben/plugins/tiptap';
import type { Recordable } from '@vben/types';
import {
@@ -45,6 +48,9 @@ import {
defineAsyncComponent,
defineComponent,
h,
nextTick,
onMounted,
onUnmounted,
ref,
render,
unref,
@@ -55,19 +61,25 @@ import {
ApiComponent,
globalShareState,
IconPicker,
VbenCollapsibleParams,
VCropper,
} from '@vben/common-ui';
import { useSortable } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { VbenTiptap } from '@vben/plugins/tiptap';
import { isEmpty } from '@vben/utils';
import { message, Modal, notification } from 'antdv-next';
import { upload_file } from '#/api';
type AdapterUploadProps = UploadProps & {
aspectRatio?: string;
crop?: boolean;
draggable?: boolean;
handleChange?: (event: UploadChangeParam) => void;
maxSize?: number;
onDragSort?: (oldIndex: number, newIndex: number) => void;
onHandleChange?: (event: UploadChangeParam) => void;
};
@@ -80,8 +92,8 @@ const Button = defineAsyncComponent(
const Checkbox = defineAsyncComponent(
() => import('antdv-next/dist/checkbox/index'),
);
const CheckboxGroup = defineAsyncComponent(
() => import('antdv-next/dist/checkbox/Group'),
const CheckboxGroup = defineAsyncComponent(() =>
import('antdv-next/dist/checkbox/index').then((res) => res.CheckboxGroup),
);
const DatePicker = defineAsyncComponent(
() => import('antdv-next/dist/date-picker/index'),
@@ -136,8 +148,8 @@ const PreviewGroup = defineAsyncComponent(() =>
import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@@ -170,260 +182,263 @@ const withDefaultPlaceholder = <T extends Component>(
});
};
const withPreviewUpload = () => {
// 检查是否为图片文件的辅助函数
const isImageFile = (file: UploadFile): boolean => {
const imageExtensions = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'svg',
'webp',
]);
if (file.url) {
try {
const pathname = new URL(file.url, 'http://localhost').pathname;
const ext = pathname.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
} catch {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
const IMAGE_EXTENSIONS = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'svg',
'webp',
]);
/**
* 检查是否为图片文件
*/
function isImageFile(file: UploadFile): boolean {
if (file.url) {
try {
const pathname = new URL(file.url, 'http://localhost').pathname;
const ext = pathname.split('.').pop()?.toLowerCase();
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
} catch {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
return file.type.startsWith('image/');
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
}
return file.type.startsWith('image/');
}
/**
* 创建默认的上传按钮插槽
*/
function createDefaultUploadSlots(listType: string, placeholder: string) {
if (listType === 'picture-card') {
return { default: () => placeholder };
}
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
// 创建默认的上传按钮插槽
const createDefaultSlotsWithUpload = (
listType: string,
placeholder: string,
) => {
switch (listType) {
case 'picture-card': {
return {
default: () => placeholder,
};
}
default: {
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
}
/**
* 获取文件的 Base64
*/
function getBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result as string));
reader.addEventListener('error', reject);
});
}
/**
* 预览图片
*/
async function previewImage(
file: UploadFile,
open: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) {
// 非图片文件直接打开链接
if (!isImageFile(file)) {
const url = file.url || file.preview;
if (url) {
window.open(url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
message.error($t('ui.formRules.previewWarning'));
}
return;
}
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
// 过滤图片文件并生成预览
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = await getBase64(imgFile.originFileObj);
}
}
const container = document.createElement('div');
document.body.append(container);
let isUnmounted = false;
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
class: 'hidden',
preview: {
open: open.value,
current: currentIndex,
onOpenChange: (value: boolean) => {
open.value = value;
if (!value) {
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
() => placeholder,
},
},
() =>
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
};
}
}
);
};
},
};
// 构建预览图片组
const previewImage = async (
file: UploadFile,
visible: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) => {
// 如果当前文件不是图片,直接打开
if (!isImageFile(file)) {
if (file.url) {
window.open(file.url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
message.error($t('ui.formRules.previewWarning'));
}
return;
}
// 对于图片文件,继续使用预览组
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
render(h(PreviewWrapper), container);
}
const getBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
};
// 从fileList中过滤出所有图片文件
const imageFiles = (unref(fileList) || []).filter((element) =>
isImageFile(element),
);
// 为所有没有预览地址的图片生成预览
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string;
}
}
const container: HTMLElement | null = document.createElement('div');
/**
* 图片裁剪操作
*/
function cropImage(file: File, aspectRatio: string | undefined) {
return new Promise<Blob | string | undefined>((resolve, reject) => {
const container = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载
let isUnmounted = false;
let objectUrl: null | string = null;
const PreviewWrapper = {
const open = ref<boolean>(true);
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
function closeModal() {
open.value = false;
setTimeout(() => {
if (!isUnmounted && container) {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
const CropperWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
if (!objectUrl) {
objectUrl = URL.createObjectURL(file);
}
return h(
PreviewGroupComponent,
Modal,
{
class: 'hidden',
preview: {
open: visible.value,
// 设置初始显示的图片索引
current: imageFiles.findIndex((f) => f.uid === file.uid),
onOpenChange: (value: boolean) => {
visible.value = value;
if (!value) {
// 延迟清理,确保动画完成
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
open: open.value,
title: h('div', {}, [
$t('ui.crop.title'),
h(
'span',
{
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
},
$t('ui.crop.titleTip', [aspectRatio]),
),
]),
centered: true,
width: 548,
keyboard: false,
maskClosable: false,
closable: false,
cancelText: $t('common.cancel'),
okText: $t('ui.crop.confirm'),
destroyOnHidden: true,
onOk: async () => {
const cropper = cropperRef.value;
if (!cropper) {
reject(new Error('Cropper not found'));
closeModal();
return;
}
try {
const dataUrl = await cropper.getCropImage();
if (dataUrl) {
resolve(dataUrl);
} else {
reject(new Error($t('ui.crop.errorTip')));
}
},
} catch {
reject(new Error($t('ui.crop.errorTip')));
} finally {
closeModal();
}
},
onCancel() {
resolve('');
closeModal();
},
},
() =>
// 渲染所有图片文件
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
h(VCropper, {
ref: (ref: any) => (cropperRef.value = ref),
img: objectUrl as string,
aspectRatio,
}),
);
};
},
};
render(h(PreviewWrapper), container);
};
// 图片裁剪操作
const cropImage = (file: File, aspectRatio: string | undefined) => {
return new Promise((resolve, reject) => {
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载
let isUnmounted = false;
let objectUrl: null | string = null;
const open = ref<boolean>(true);
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
const closeModal = () => {
open.value = false;
// 延迟清理,确保动画完成
setTimeout(() => {
if (!isUnmounted && container) {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
};
const CropperWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
if (!objectUrl) {
objectUrl = URL.createObjectURL(file);
}
return h(
Modal,
{
open: open.value,
title: h('div', {}, [
$t('ui.crop.title'),
h(
'span',
{
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
},
$t('ui.crop.titleTip', [aspectRatio]),
),
]),
centered: true,
width: 548,
keyboard: false,
maskClosable: false,
closable: false,
cancelText: $t('common.cancel'),
okText: $t('ui.crop.confirm'),
destroyOnHidden: true,
onOk: async () => {
const cropper = cropperRef.value;
if (!cropper) {
reject(new Error('Cropper not found'));
closeModal();
return;
}
try {
const dataUrl = await cropper.getCropImage();
resolve(dataUrl);
} catch {
reject(new Error($t('ui.crop.errorTip')));
} finally {
closeModal();
}
},
onCancel() {
resolve('');
closeModal();
},
},
() =>
h(VCropper, {
ref: (ref: any) => (cropperRef.value = ref),
img: objectUrl as string,
aspectRatio,
}),
);
};
},
};
render(h(CropperWrapper), container);
});
};
render(h(CropperWrapper), container);
});
}
/**
* 带预览功能的上传组件
*/
function withPreviewUpload() {
return defineComponent({
name: 'AUpload',
name: Upload.name,
emits: ['update:modelValue'],
setup: (
setup(
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) => {
) {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`);
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
@@ -433,16 +448,18 @@ const withPreviewUpload = () => {
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
);
const handleBeforeUpload = async (
async function handleBeforeUpload(
file: UploadFile,
originFileList: Array<File>,
) => {
) {
// 文件大小限制
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
file.status = 'removed';
return false;
}
// 多选或者非图片不唤起裁剪框
// 图片裁剪处理
if (
attrs.crop &&
!attrs.multiple &&
@@ -450,27 +467,21 @@ const withPreviewUpload = () => {
isImageFile(file)
) {
file.status = 'removed';
// antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
const blob = await cropImage(originFileList[0], aspectRatio.value);
return new Promise((resolve, reject) => {
if (!blob) {
return reject(new Error($t('ui.crop.errorTip')));
}
resolve(blob);
});
if (!blob) {
throw new Error($t('ui.crop.errorTip'));
}
return blob;
}
return attrs.beforeUpload?.(file) ?? true;
};
}
const handleChange = (event: UploadChangeParam) => {
function handleChange(event: UploadChangeParam) {
try {
// 行内写法 handleChange: (event) => {}
attrs.handleChange?.(event);
// template写法 @handle-change="(event) => {}"
attrs.onHandleChange?.(event);
} catch (error) {
// Avoid breaking internal v-model sync on user handler errors
console.error(error);
}
fileList.value = event.fileList.filter(
@@ -480,28 +491,95 @@ const withPreviewUpload = () => {
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
};
}
const handlePreview = async (file: UploadFile) => {
function handlePreview(file: UploadFile) {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
return previewImage(file, previewVisible, fileList);
}
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
function renderUploadButton() {
if (attrs.disabled) return null;
return isEmpty(slots)
? createDefaultUploadSlots(listType, placeholder)
: slots;
}
// 如果禁用,不渲染上传按钮
if (isDisabled) {
return null;
// 拖拽排序
const draggable = computed(
() => (attrs.draggable ?? false) && !attrs.disabled,
);
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const sortableInstance = ref<null | Sortable>(null);
const styleId = `upload-drag-style-${uploadId}`;
function injectDragStyle() {
if (!document.querySelector(`[id="${styleId}"]`)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
`;
document.head.append(style);
}
}
function removeDragStyle() {
document.querySelector(`[id="${styleId}"]`)?.remove();
}
async function initSortable(retryCount = 0) {
if (!draggable.value) return;
injectDragStyle();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
const container = document.querySelector(
`[data-upload-id="${uploadId}"] .ant-upload-list`,
) as HTMLElement;
if (!container) {
if (retryCount < 5) {
setTimeout(() => initSortable(retryCount + 1), 200);
}
return;
}
// 否则渲染默认上传按钮
return isEmpty(slots)
? createDefaultSlotsWithUpload(listType, placeholder)
: slots;
};
const { initializeSortable } = useSortable(container, {
animation: 300,
delay: 400,
delayOnTouchOnly: true,
filter:
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (
oldIndex === undefined ||
newIndex === undefined ||
oldIndex === newIndex
) {
return;
}
// 可以监听到表单API设置的值
const list = [...(fileList.value || [])];
const [movedItem] = list.splice(oldIndex, 1);
if (movedItem) {
list.splice(newIndex, 0, movedItem);
fileList.value = list;
}
attrs.onDragSort?.(oldIndex, newIndex);
emit('update:modelValue', fileList.value);
},
});
sortableInstance.value = await initializeSortable();
}
// 监听表单值变化
watch(
() => attrs.modelValue,
(res) => {
@@ -509,22 +587,32 @@ const withPreviewUpload = () => {
},
);
onMounted(initSortable);
onUnmounted(() => {
sortableInstance.value?.destroy();
removeDragStyle();
});
return () =>
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
beforeUpload: handleBeforeUpload,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton(),
'div',
{ 'data-upload-id': uploadId, class: 'w-full' },
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
beforeUpload: handleBeforeUpload,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton() as any,
),
);
},
});
};
}
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
@@ -535,6 +623,7 @@ export type ComponentType =
| 'Cascader'
| 'Checkbox'
| 'CheckboxGroup'
| 'CollapsibleParams'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
@@ -548,6 +637,7 @@ export type ComponentType =
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'RichEditor'
| 'Select'
| 'Space'
| 'Switch'
@@ -568,6 +658,7 @@ export interface ComponentPropsMap {
Cascader: CascaderProps;
Checkbox: CheckboxProps;
CheckboxGroup: CheckboxGroupProps;
CollapsibleParams: CollapsibleParamsProps;
DatePicker: DatePickerProps;
DefaultButton: ButtonProps;
Divider: DividerProps;
@@ -581,6 +672,7 @@ export interface ComponentPropsMap {
RadioGroup: RadioGroupProps;
RangePicker: RangePickerProps;
Rate: RateProps;
RichEditor: TipTapProps;
Select: SelectProps;
Space: SpaceProps;
Switch: SwitchProps;
@@ -601,13 +693,13 @@ async function initComponentAdapter() {
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onVisibleChange',
visibleEvent: 'onOpenChange',
}),
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: Select,
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onVisibleChange',
visibleEvent: 'onOpenChange',
}),
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: TreeSelect,
@@ -615,7 +707,7 @@ async function initComponentAdapter() {
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
visibleEvent: 'onOpenChange',
}),
AutoComplete,
Cascader,
@@ -633,7 +725,9 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮
@@ -644,6 +738,27 @@ async function initComponentAdapter() {
RadioGroup,
RangePicker,
Rate,
RichEditor: withDefaultPlaceholder(VbenTiptap, 'input', {
imageUpload: {
upload: (file: any, onProgress: any) => {
return new Promise((resolve, reject) => {
upload_file({
file,
onProgress({ percent }) {
onProgress?.(percent);
},
onSuccess(response) {
// 从响应中提取图片URL
resolve(response?.data?.url ?? response?.url ?? '');
},
onError() {
reject(new Error($t('ui.tiptap.upload.uploadFailed')));
},
});
});
},
},
}),
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
@@ -651,6 +766,7 @@ async function initComponentAdapter() {
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload: withPreviewUpload(),
CollapsibleParams: VbenCollapsibleParams,
};
// 将组件注册到全局共享状态中

View File

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

View File

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

View File

@@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -141,8 +141,8 @@ const ElUpload = defineAsyncComponent(() =>
]).then(([res]) => res.ElUpload),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@@ -284,7 +284,9 @@ async function initComponentAdapter() {
inputComponent: ElInput,
}),
Input: withDefaultPlaceholder(ElInput, 'input'),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input', {
style: { width: '100%' },
}),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {

View File

@@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -26,7 +26,6 @@
"#/*": "./src/*"
},
"dependencies": {
"@vben-core/shadcn-ui": "workspace:*",
"@vben/access": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/constants": "workspace:*",

View File

@@ -84,8 +84,8 @@ const NUpload = defineAsyncComponent(() =>
import('naive-ui/es/upload').then((res) => res.NUpload),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@@ -225,7 +225,9 @@ async function initComponentAdapter() {
inputComponent: NInput,
}),
Input: withDefaultPlaceholder(NInput, 'input'),
InputNumber: withDefaultPlaceholder(NInputNumber, 'input'),
InputNumber: withDefaultPlaceholder(NInputNumber, 'input', {
style: { width: '100%' },
}),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {

View File

@@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -3,6 +3,7 @@
"naive": "Naive UI",
"table": "Table",
"form": "Form",
"arrayForm": "Array Editor",
"vben": {
"title": "Project",
"about": "About",

View File

@@ -3,6 +3,7 @@
"naive": "Naive UI",
"table": "Table",
"form": "表单",
"arrayForm": "数组编辑器",
"vben": {
"title": "项目",
"about": "关于",

View File

@@ -37,6 +37,14 @@ const routes: RouteRecordRaw[] = [
path: '/demos/form',
component: () => import('#/views/demos/form/basic.vue'),
},
{
meta: {
title: $t('demos.arrayForm'),
},
name: 'ArrayForm',
path: '/demos/array-form',
component: () => import('#/views/demos/naive/array-form/index.vue'),
},
],
},
];

View File

@@ -0,0 +1,123 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { NButton, NCard, useMessage } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
const message = useMessage();
const [Form, formApi] = useVbenForm({
layout: 'vertical',
wrapperClass: 'grid-cols-1',
handleSubmit: (values) => {
message.success(`提交成功:${JSON.stringify(values)}`);
},
schema: [
{
component: 'Input',
fieldName: 'projectName',
label: '项目名称',
rules: 'required',
},
{
component: 'VbenFormFieldArray',
fieldName: 'members',
label: '项目成员',
// 初始化为空数组,供内部 useFieldArray 使用
defaultValue: [],
componentProps: {
min: 1,
max: 5,
createRow: () => ({
name: null,
age: null,
role: null,
joinDate: null,
active: true,
}),
// 每一列就是一个子字段,复用 vbenForm 的所有编辑组件
schema: [
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: 'required',
componentProps: { placeholder: '请输入姓名' },
},
{
component: 'InputNumber',
fieldName: 'age',
label: '年龄',
componentProps: { min: 0, max: 150 },
},
{
component: 'Select',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
componentProps: {
placeholder: '请选择',
options: [
{ label: '前端', value: 'fe' },
{ label: '后端', value: 'be' },
{ label: '测试', value: 'qa' },
{ label: '产品', value: 'pm' },
],
},
},
{
component: 'DatePicker',
fieldName: 'joinDate',
label: '入职日期',
},
{
component: 'Switch',
fieldName: 'active',
label: '在职',
},
],
},
},
],
});
function setFormValues() {
formApi.setValues({
projectName: 'Vben Admin',
members: [
{ name: '张三', age: 28, role: 'fe', joinDate: Date.now(), active: true },
{
name: '李四',
age: 32,
role: 'be',
joinDate: Date.now(),
active: false,
},
],
});
}
async function getFormValues() {
const values = await formApi.getValues();
message.info(JSON.stringify(values));
}
</script>
<template>
<Page
description="基于 useVbenForm 的数组编辑器VbenFormFieldArray可增删行每个单元格复用 vbenForm 注册的编辑组件,并享受逐格校验。"
title="数组编辑器表单"
>
<NCard title="数组编辑器">
<template #header-extra>
<NButton class="mr-2" @click="setFormValues">设置表单值</NButton>
<NButton class="mr-2" @click="getFormValues">获取表单值</NButton>
<NButton type="primary" @click="formApi.submitForm()">
提交校验
</NButton>
</template>
<Form />
</NCard>
</Page>
</template>

View File

@@ -89,8 +89,8 @@ const TreeSelect = defineAsyncComponent(
);
const Upload = defineAsyncComponent(() => import('tdesign-vue-next/es/upload'));
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@@ -239,7 +239,9 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
// InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
// Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮

View File

@@ -3,6 +3,7 @@ import type { GlobalConfigProvider } from 'tdesign-vue-next';
import { watch } from 'vue';
import { useTDesignDesignTokens } from '@vben/hooks';
import { usePreferences } from '@vben/preferences';
import { merge } from 'es-toolkit/compat';
@@ -12,10 +13,16 @@ import zhConfig from 'tdesign-vue-next/es/locale/zh_CN';
defineOptions({ name: 'App' });
const { isDark } = usePreferences();
// 将 Vben 设计系统的 CSS 变量适配到 TDesign 的设计变量上
useTDesignDesignTokens();
watch(
() => isDark.value,
(dark) => {
document.documentElement.setAttribute('theme-mode', dark ? 'dark' : '');
document.documentElement.setAttribute(
'theme-mode',
dark ? 'dark' : 'light',
);
},
{ immediate: true },
);

View File

@@ -5,8 +5,6 @@ import { registerLoadingDirective } from '@vben/common-ui/es/loading';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
// import '@vben/styles/antd';
// 引入组件库的少量全局样式变量
import { useTitle } from '@vueuse/core';
@@ -17,6 +15,7 @@ import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
// 引入组件库的少量全局样式变量
import 'tdesign-vue-next/es/style/index.css';
async function bootstrap(namespace: string) {

View File

@@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@@ -0,0 +1,2 @@
import { register } from 'node:module';
register('./dayjs-resolve-hook.mjs', import.meta.url);

View File

@@ -0,0 +1,10 @@
const DAYJS_SUBPATH_RE = /^dayjs\/(plugin|locale)\/([^./]+)$/;
/** @type {import('node:module').ResolveHook} */
export async function resolve(specifier, context, nextResolve) {
const match = specifier.match(DAYJS_SUBPATH_RE);
if (match) {
return nextResolve(`${specifier}.js`, context);
}
return nextResolve(specifier, context);
}

View File

@@ -5,7 +5,7 @@ import { computed, ref, useSlots } from 'vue';
import { VbenTooltip } from '@vben-core/shadcn-ui';
import { Code } from 'lucide-vue-next';
import { Code } from '@lucide/vue';
import {
TabsContent,
TabsIndicator,

View File

@@ -198,6 +198,18 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
link: 'common-ui/vben-ellipsis-text',
text: 'EllipsisText',
},
{
link: 'common-ui/vben-descriptions',
text: 'Descriptions',
},
{
link: 'common-ui/vben-table-action',
text: 'TableAction',
},
{
link: 'common-ui/vben-cropper',
text: 'Cropper',
},
{
link: 'common-ui/vben-tiptap',
text: 'Tiptap RichTextEditor',

View File

@@ -1,12 +1,12 @@
import { withPwa } from '@vite-pwa/vitepress';
import { defineConfigWithTheme } from 'vitepress';
import { defineConfig } from 'vitepress';
import { en } from './en.mts';
import { shared } from './shared.mts';
import { zh } from './zh.mts';
export default withPwa(
defineConfigWithTheme({
defineConfig({
...shared,
locales: {
en: {

View File

@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
import {
viteArchiverPlugin,
viteDayjsPlugin,
viteVxeTableImportsPlugin,
} from '@vben/vite-config';
@@ -61,7 +62,7 @@ export const shared = defineConfig({
},
preprocessorOptions: {
scss: {
api: 'modern',
// api: 'modern',
},
},
},
@@ -69,6 +70,7 @@ export const shared = defineConfig({
stringify: true,
},
plugins: [
viteDayjsPlugin(),
tailwindcss(),
GitChangelog({
mapAuthors: [
@@ -85,9 +87,23 @@ export const shared = defineConfig({
name: 'Li Kui',
username: 'likui628',
},
{
mapByNameAliases: ['Jin Mao', 'jinmao'],
name: 'Jin Mao',
username: 'jinmao88',
},
{
name: 'Netfan',
username: 'mynetfan',
},
{
mapByNameAliases: ['xingyu4j', 'xingyu'],
name: 'xingyu4j',
username: 'xingyu4j',
},
],
repoURL: () => 'https://github.com/vbenjs/vue-vben-admin',
}),
}) as any,
GitChangelogMarkdownSection(),
viteArchiverPlugin({ outputDir: '.vitepress' }),
groupIconVitePlugin(),
@@ -103,6 +119,7 @@ export const shared = defineConfig({
ssr: {
external: ['@vue/repl'],
noExternal: ['@v-c/picker'],
},
},
});
@@ -113,7 +130,7 @@ function head(): HeadConfig[] {
[
'meta',
{
content: 'vben, vitejs, vite, shacdn-ui, vue',
content: 'vben, vitejs, vite, shadcn-ui, vue',
name: 'keywords',
},
],

View File

@@ -196,6 +196,18 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
link: 'common-ui/vben-ellipsis-text',
text: 'EllipsisText 省略文本',
},
{
link: 'common-ui/vben-descriptions',
text: 'Descriptions 描述列表',
},
{
link: 'common-ui/vben-table-action',
text: 'TableAction 表格操作',
},
{
link: 'common-ui/vben-cropper',
text: 'Cropper 图片裁剪',
},
{
link: 'common-ui/vben-tiptap',
text: 'Tiptap 富文本编辑器',
@@ -333,8 +345,8 @@ export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = {
footer: {
closeText: '关闭',
navigateText: '切换',
searchByText: '搜索提供者',
selectText: '选择',
poweredByText: '搜索提供者',
},
noResultsScreen: {
noResultsText: '无法找到相关结果',
@@ -343,10 +355,10 @@ export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = {
suggestedQueryText: '你可以尝试查询',
},
searchBox: {
cancelButtonAriaLabel: '取消',
cancelButtonText: '取消',
resetButtonAriaLabel: '清除查询条件',
resetButtonTitle: '清除查询条件',
closeButtonAriaLabel: '取消',
closeButtonText: '取消',
clearButtonAriaLabel: '清除查询条件',
clearButtonTitle: '清除查询条件',
},
startScreen: {
favoriteSearchesTitle: '收藏',

View File

@@ -10,7 +10,7 @@ import {
// import { useAntdDesignTokens } from '@vben/hooks';
// import { initPreferences } from '@vben/preferences';
import { ConfigProvider, theme } from 'ant-design-vue';
import { ConfigProvider, theme } from 'antdv-next';
import mediumZoom from 'medium-zoom';
import { useRoute } from 'vitepress';
import DefaultTheme from 'vitepress/theme';

View File

@@ -2,8 +2,9 @@
"name": "@vben/docs",
"version": "5.7.0",
"private": true,
"type": "module",
"scripts": {
"build": "vitepress build",
"build": "cross-env NODE_OPTIONS=\"--import ./.vitepress/build/dayjs-loader.mjs\" vitepress build",
"dev": "vitepress dev",
"docs:preview": "vitepress preview"
},
@@ -14,13 +15,13 @@
}
},
"dependencies": {
"@lucide/vue": "catalog:",
"@vben-core/shadcn-ui": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/styles": "workspace:*",
"ant-design-vue": "catalog:",
"lucide-vue-next": "catalog:",
"antdv-next": "catalog:",
"medium-zoom": "catalog:",
"reka-ui": "catalog:",
"vitepress-plugin-group-icons": "catalog:"

View File

@@ -26,16 +26,15 @@ import {
notification,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TextArea,
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,
@@ -98,12 +97,12 @@ async function initComponentAdapter() {
},
Radio,
RadioGroup,
RangePicker,
RangePicker: withDefaultPlaceholder(DatePicker.RangePicker, 'select'),
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
Textarea: withDefaultPlaceholder(TextArea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
@@ -118,7 +117,7 @@ async function initComponentAdapter() {
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
title,
placement: 'bottomRight',
});
},

View File

@@ -2,7 +2,7 @@ import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { Button, Image } from 'antdv-next';
import { useVbenForm } from './form';

View File

@@ -22,7 +22,7 @@ outline: deep
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
import { Cascader } from 'antdv-next';
const treeData: Record<string, any> = [
{

View File

@@ -0,0 +1,172 @@
---
outline: deep
---
# Vben Cropper 图片裁剪
`VCropper` 是一个纯原生实现的图片裁剪组件,支持自由比例和固定比例裁剪,可通过方法调用获取裁剪后的图片。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
::: info 写在前面
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
:::
## 基础用法
最基本的图片裁剪,支持自由比例调整。
<DemoPreview dir="demos/vben-cropper/basic" />
## 固定比例裁剪
通过 `aspectRatio` 属性设置裁剪比例,格式为 `"宽:高"`,如 `"1:1"``"16:9"``"3:4"` 等。
<DemoPreview dir="demos/vben-cropper/aspect-ratio" />
## API
### Props
| 属性名 | 描述 | 类型 | 默认值 |
| ------------- | ------------------------------------- | -------- | ------ |
| `img` | 图片地址(必填) | `string` | - |
| `width` | 容器宽度 | `number` | `500` |
| `height` | 容器高度 | `number` | `400` |
| `aspectRatio` | 裁剪比例,格式如 `"1:1"``"16:9"` 等 | `string` | - |
### Methods
通过 `ref` 调用组件方法:
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const handleCrop = async () => {
const result = await cropperRef.value?.getCropImage();
// result 为 Blob 或 base64 字符串
};
</script>
```
#### getCropImage
裁剪并获取图片。
```ts
interface GetCropImageOptions {
/** 输出图片格式 */
format?: 'image/jpeg' | 'image/png';
/** 压缩质量0-1仅对 jpeg 格式有效 */
quality?: number;
/** 输出类型 */
outputType?: 'base64' | 'blob';
/** 目标宽度(可选,不传则为原始裁剪宽度) */
targetWidth?: number;
/** 目标高度(可选,不传则为原始裁剪高度) */
targetHeight?: number;
}
getCropImage(
format?: 'image/jpeg' | 'image/png',
quality?: number,
outputType?: 'base64' | 'blob',
targetWidth?: number,
targetHeight?: number,
): Promise<Blob | string | undefined>
```
**参数说明:**
| 参数 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `format` | `'image/jpeg' \| 'image/png'` | `'image/png'` | 输出图片格式 |
| `quality` | `number` | `0.92` | 压缩质量0-1仅 jpeg 有效 |
| `outputType` | `'base64' \| 'blob'` | `'blob'` | 输出类型base64 字符串或 Blob 对象 |
| `targetWidth` | `number` | - | 目标宽度,不传则使用原始裁剪宽度 |
| `targetHeight` | `number` | - | 目标高度,不传则使用原始裁剪高度 |
## 功能特性
### 裁剪操作
- **拖拽移动** - 拖拽裁剪框中心区域移动裁剪位置
- **边角调整** - 拖拽四角调整裁剪框大小
- **边缘调整** - 拖拽四边中点调整单边
### 比例控制
- **自由比例** - 不设置 `aspectRatio` 时,可自由调整任意比例
- **固定比例** - 设置 `aspectRatio` 后,裁剪框始终保持设定比例
### 高清屏适配
组件自动适配 Retina 等高清屏幕,保证输出图片清晰无模糊。
### 图片适配
- 图片自动等比缩放以完整显示在容器内
- 支持本地图片和网络图片
- 网络图片需目标服务端支持 CORS 才能导出裁剪结果
## 使用示例
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const imageUrl = ref('https://example.com/image.jpg');
const croppedImage = ref('');
// 获取裁剪后的 Blob 对象
const handleCropBlob = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// 上传到服务器或创建预览URL
const url = URL.createObjectURL(blob);
croppedImage.value = url;
}
};
// 获取裁剪后的 base64 字符串
const handleCropBase64 = async () => {
const base64 = await cropperRef.value?.getCropImage('image/png', 1, 'base64');
if (typeof base64 === 'string') {
croppedImage.value = base64;
}
};
// 导出指定尺寸
const handleCropWithSize = async () => {
const blob = await cropperRef.value?.getCropImage(
'image/jpeg',
0.9,
'blob',
200, // 目标宽度
200, // 目标高度
);
};
</script>
<template>
<div>
<VCropper
ref="cropperRef"
:img="imageUrl"
:width="500"
:height="400"
aspect-ratio="1:1"
/>
<button @click="handleCropBlob">裁剪</button>
<img v-if="croppedImage" :src="croppedImage" />
</div>
</template>
```

View File

@@ -0,0 +1,102 @@
---
outline: deep
---
# Vben Descriptions 描述列表
`Descriptions` 用于成组展示只读的字段信息,常用于详情页、信息预览等场景。组件基于 shadcn-ui 构建API 参考 Ant Design Vue 的 Descriptions支持响应式列数、跨列、边框、垂直布局等能力。
> 如果文档内没有覆盖到你需要的细节,可以结合在线示例一起查看。
::: info 写在前面
组件提供两种使用方式:通过 `items` 数据驱动(推荐),或通过子组件 `VbenDescriptionsItem` 声明列表项。两者可按需选择,`items` 优先级更高。:::
## 基础用法
通过 `items` 传入字段数组,每项包含 `label``content`。默认按断点自适应列数(`xs` 1 列、`sm` 2 列、`md` 及以上 3 列)。
<DemoPreview dir="demos/vben-descriptions/basic" />
## 带边框
设置 `bordered` 展示边框样式,配合 `title` 标题与 `#extra` 插槽(位于标题右侧的操作区域)。
<DemoPreview dir="demos/vben-descriptions/bordered" />
## 垂直布局
通过 `layout="vertical"` 让标签位于内容上方。
<DemoPreview dir="demos/vben-descriptions/vertical" />
## 不同尺寸
通过 `size` 设置 `small``middle``large` 三种尺寸。
<DemoPreview dir="demos/vben-descriptions/size" />
## 跨列与响应式
单项通过 `span` 设置跨列数,`'filled'` 表示占满当前行剩余空间;`column` 支持传入按断点配置的对象实现响应式列数。
<DemoPreview dir="demos/vben-descriptions/span" />
## 子组件用法
不传 `items` 时,可在默认插槽中使用 `VbenDescriptionsItem` 声明列表项,内容支持默认插槽或 `#content` 插槽自定义。
<DemoPreview dir="demos/vben-descriptions/custom" />
## API
### Descriptions Props
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| items | 数据驱动的列表项;不传则读取默认插槽 | `DescriptionsItemType[]` | - |
| bordered | 是否展示边框 | `boolean` | `false` |
| column | 一行的列数,支持按断点配置 | `number \| Partial<Record<Breakpoint, number>>` | `{ xs: 1, sm: 2, md: 3, xxxl: 4 }` |
| layout | 布局方式 | `'horizontal' \| 'vertical'` | `'horizontal'` |
| size | 尺寸 | `'small' \| 'middle' \| 'large'` | `'middle'` |
| colon | 是否显示冒号(仅非边框的水平布局生效) | `boolean` | `true` |
| title | 标题 | `string` | - |
| extra | 标题右侧的操作区域 | `string` | - |
| labelStyle | 统一的标签样式 | `CSSProperties` | - |
| contentStyle | 统一的内容样式 | `CSSProperties` | - |
| class | 根节点自定义类名 | `string` | - |
### Descriptions Slots
| 插槽名 | 描述 |
| ------- | ---------------------------------- |
| title | 自定义标题 |
| extra | 自定义标题右侧操作区域 |
| default | 放置 `VbenDescriptionsItem` 子组件 |
### DescriptionsItem
`items` 数组中的每一项,或子组件 `VbenDescriptionsItem` 的属性。
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| label | 标签 | `string \| number \| (() => VNode) \| Component` | - |
| content | 内容 | `string \| number \| (() => VNode) \| Component` | - |
| span | 跨列数,`'filled'` 占满当前行剩余 | `number \| 'filled' \| Partial<Record<Breakpoint, number>>` | `1` |
| labelStyle | 标签样式 | `CSSProperties` | - |
| contentStyle | 内容样式 | `CSSProperties` | - |
| key | 唯一标识 | `string \| number` | - |
### DescriptionsItem Slots
仅子组件用法可用。
| 插槽名 | 描述 |
| ------- | ------------------------ |
| default | 内容(等价于 `content` |
| content | 自定义内容 |
| label | 自定义标签 |
::: tip Breakpoint
响应式断点 `Breakpoint` 取值为 `'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'`,断点像素与 Ant Design 一致(`sm` 576、`md` 768、`lg` 992、`xl` 1200、`xxl` 1600、`xxxl` 2000。:::

View File

@@ -117,7 +117,7 @@ import {
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,

View File

@@ -0,0 +1,165 @@
---
outline: deep
---
# Vben TableAction 表格操作
`TableAction` 用于在表格操作列中渲染一组操作按钮,参考 vben2 的 TableAction 设计。基于 shadcn-ui 构建,支持权限控制、气泡确认、提示、下拉「更多」、分割线等能力,可在表格内外任意场景复用。
> 如果文档内没有覆盖到你需要的细节,可以结合在线示例一起查看。
::: info 写在前面
组件本身不依赖任何业务逻辑(不直接读取权限 store权限通过注入 `hasPermission` 实现,从而保持核心层零耦合、可跨框架复用。在 vxe-table 中推荐通过列插槽(`slots: { default: 'action' }`)在页面里渲染,不改变表格原有的渲染机制。:::
## 基础用法
通过 `actions` 传入操作项数组,每项包含 `text``onClick` 等;`danger` 标记危险操作,`divider` 显示按钮间分割线。
<DemoPreview dir="demos/vben-table-action/basic" />
## 提示
通过 `tooltip` 为操作项添加提示,支持字符串或 `{ content, side }` 配置。
<DemoPreview dir="demos/vben-table-action/tooltip" />
## 气泡确认
通过 `popConfirm` 开启点击前的气泡确认,常用于删除等危险操作。
<DemoPreview dir="demos/vben-table-action/popconfirm" />
## 更多下拉
通过 `dropdownActions` 将次要操作收纳到「更多」下拉中,`moreText` 可自定义按钮文案。
<DemoPreview dir="demos/vben-table-action/dropdown" />
## 权限控制
为操作项设置 `auth` 权限码,并注入 `hasPermission` 判断函数,无权限的操作会被隐藏。
<DemoPreview dir="demos/vben-table-action/permission" />
## 在 vxe-table 中使用
不改变 vxe-table 原有渲染方式,推荐在列配置中声明插槽,在页面通过插槽渲染。
::: tip 推荐:使用适配器封装的版本项目的 `#/adapter/vxe-table` 已对 `VbenTableAction` 做了二次封装,内部统一注入了 `hasPermission`(基于 `useAccess().hasAccessByCodes`)。因此从适配器引入时**无需再传入 `:has-permission`**,只需通过操作项的 `auth` 字段声明权限码即可。:::
```ts
// data.ts —— 列配置声明插槽
{
align: 'center',
field: 'operation',
fixed: 'right',
slots: { default: 'action' },
title: $t('system.user.operation'),
width: 180,
}
```
```vue
<!-- list.vue —— 从适配器引入权限自动注入无需传入 has-permission -->
<script setup lang="ts">
import { VbenTableAction } from '#/adapter/vxe-table';
</script>
<template>
<Grid>
<template #action="{ row }">
<template #action="{ row }">
<VbenTableAction
:actions="[
{
text: $t('common.detail'),
icon: 'lucide:eye',
onClick: () => onDetail(row),
},
{
text: $t('common.edit'),
icon: 'lucide:edit',
onClick: () => onEdit(row),
},
]"
:dropdown-actions="[
{
text: $t('common.delete'),
icon: 'lucide:trash-2',
danger: true,
onClick: () => onDelete(row),
auth: ['AC_100100'],
},
]"
align="center"
/>
</template>
</template>
</Grid>
</template>
```
若直接从 `@vben/common-ui` 引入核心组件(不经过适配器),组件不依赖任何业务逻辑,需自行注入 `hasPermission`
```vue
<script setup lang="ts">
import { useAccess } from '@vben/access';
import { VbenTableAction } from '@vben/common-ui';
const { hasAccessByCodes } = useAccess();
function hasPermission(auth?: string | string[]) {
if (!auth) return true;
return hasAccessByCodes(Array.isArray(auth) ? auth : [auth]);
}
</script>
<template>
<VbenTableAction
v-bind="useActions(row, onActionClick)"
:has-permission="hasPermission"
align="center"
/>
</template>
```
## API
### TableAction Props
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| actions | 主操作按钮 | `ActionItem[]` | `[]` |
| dropdownActions | 「更多」下拉中的操作 | `ActionItem[]` | `[]` |
| align | 对齐方式 | `'start' \| 'center' \| 'end'` | `'end'` |
| divider | 按钮之间是否显示分割线 | `boolean` | `false` |
| moreText | 「更多」按钮文案(提供时显示在图标右侧) | `string` | - |
| hasPermission | 权限判断函数,返回 `false` 则隐藏对应 `auth` 的操作(从 `#/adapter/vxe-table` 引入时已自动注入,无需手动传入) | `(auth?: string \| string[]) => boolean` | - |
| class | 根节点自定义类名 | `string` | - |
### ActionItem
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| text | 按钮文本 | `string` | - |
| icon | 图标组件 | `string`\| `VbenIcon` | - |
| onClick | 点击回调 | `() => void` | - |
| auth | 权限码,配合 `hasPermission` 过滤 | `string \| string[]` | - |
| ifShow | 是否显示 | `boolean \| (() => boolean)` | `true` |
| disabled | 是否禁用 | `boolean` | `false` |
| loading | 加载状态 | `boolean` | `false` |
| danger | 危险操作(红色文字) | `boolean` | `false` |
| tooltip | 提示 | `string \| { content: string; side?: 'top' \| 'bottom' \| 'left' \| 'right' }` | - |
| popConfirm | 气泡确认 | `TableActionPopConfirm` | - |
| variant | 按钮样式变体 | `ButtonVariants['variant']` | `'link'` |
| size | 按钮尺寸 | `ButtonVariants['size']` | `'sm'` |
| key | 唯一标识 | `string \| number` | - |
### TableActionPopConfirm
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| title | 提示标题 | `string` | `'Are you sure?'` |
| okText | 确认按钮文案 | `string` | `'OK'` |
| cancelText | 取消按钮文案 | `string` | `'Cancel'` |
| confirm | 确认回调;未提供时回退到 `action.onClick` | `() => void` | - |

View File

@@ -25,7 +25,7 @@ import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { Button, Image } from 'antdv-next';
import { useVbenForm } from './form';

View File

@@ -3,7 +3,7 @@ import { h } from 'vue';
import { alert, VbenButton } from '@vben/common-ui';
import { Result } from 'ant-design-vue';
import { Result } from 'antdv-next';
function showAlert() {
alert('This is an alert message');

View File

@@ -3,7 +3,7 @@ import { h, ref } from 'vue';
import { alert, confirm, VbenButton } from '@vben/common-ui';
import { Checkbox, message } from 'ant-design-vue';
import { Checkbox, message } from 'antdv-next';
function showConfirm() {
confirm('This is an alert message')

View File

@@ -3,8 +3,8 @@ import { h } from 'vue';
import { alert, prompt, useAlertContext, VbenButton } from '@vben/common-ui';
import { Input, RadioGroup, Select } from 'ant-design-vue';
import { BadgeJapaneseYen } from 'lucide-vue-next';
import { BadgeJapaneseYen } from '@lucide/vue';
import { Input, RadioGroup, Select } from 'antdv-next';
function showPrompt() {
prompt({

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
import { Cascader } from 'antdv-next';
const treeData: Record<string, any> = [
{

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import { onBeforeUnmount, ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const aspectRatio = ref('1:1');
const imageUrl = ref('https://picsum.photos/seed/cropper-ratio/800/600');
const croppedImage = ref('');
const aspectOptions = [
{ label: '1:1 (正方形)', value: '1:1' },
{ label: '16:9 (宽屏)', value: '16:9' },
{ label: '4:3 (标准)', value: '4:3' },
{ label: '3:4 (竖版)', value: '3:4' },
{ label: '3:2 (照片)', value: '3:2' },
];
// 释放旧的 object URL 以避免内存泄漏
const revokeCroppedImage = () => {
if (croppedImage.value?.startsWith('blob:')) {
URL.revokeObjectURL(croppedImage.value);
}
};
const handleCrop = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// 释放旧的 URL
revokeCroppedImage();
croppedImage.value = URL.createObjectURL(blob);
}
};
const handleReset = () => {
// 释放 URL
revokeCroppedImage();
croppedImage.value = '';
imageUrl.value = `https://picsum.photos/seed/cropper-${Date.now()}/800/600`;
};
// 组件卸载时清理
onBeforeUnmount(() => {
revokeCroppedImage();
});
</script>
<template>
<div>
<div class="mb-4">
<label class="text-sm text-gray-500 mr-2">选择比例:</label>
<select v-model="aspectRatio" class="px-3 py-1 border rounded text-sm">
<option
v-for="option in aspectOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<VCropper
ref="cropperRef"
:img="imageUrl"
:width="500"
:height="300"
:aspect-ratio="aspectRatio"
/>
<div class="mt-4 flex gap-2">
<button
class="px-4 py-2 bg-blue-500 rounded hover:bg-blue-600"
@click="handleCrop"
>
裁剪图片
</button>
<button
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600"
@click="handleReset"
>
重置
</button>
</div>
<div v-if="croppedImage" class="mt-4">
<p class="text-sm text-gray-500 mb-2">
裁剪结果 (比例: {{ aspectRatio }}):
</p>
<img :src="croppedImage" class="max-w-full rounded border" />
</div>
<div class="mt-4">
<p class="text-sm text-gray-500">提示:</p>
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4">
<li>设置固定比例后裁剪框始终维持该比例</li>
<li>切换比例会自动重新计算裁剪框大小</li>
<li>比例格式为 "宽:高" "16:9"</li>
</ul>
</div>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script lang="ts" setup>
import { onBeforeUnmount, ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const imageUrl = ref('https://picsum.photos/seed/cropper-demo/800/600');
const croppedImage = ref('');
// 释放旧的 object URL 以避免内存泄漏
const revokeCroppedImage = () => {
if (croppedImage.value?.startsWith('blob:')) {
URL.revokeObjectURL(croppedImage.value);
}
};
const handleCrop = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// 释放旧的 URL
revokeCroppedImage();
croppedImage.value = URL.createObjectURL(blob);
}
};
const handleReset = () => {
// 释放 URL
revokeCroppedImage();
croppedImage.value = '';
// 重新加载图片以重置裁剪框
imageUrl.value = `https://picsum.photos/seed/cropper-${Date.now()}/800/600`;
};
// 组件卸载时清理
onBeforeUnmount(() => {
revokeCroppedImage();
});
</script>
<template>
<div>
<VCropper ref="cropperRef" :img="imageUrl" :width="500" :height="300" />
<div class="mt-4 flex gap-2">
<button
class="px-4 py-2 bg-blue-500 rounded hover:bg-blue-600"
@click="handleCrop"
>
裁剪图片
</button>
<button
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600"
@click="handleReset"
>
重置
</button>
</div>
<div v-if="croppedImage" class="mt-4">
<p class="text-sm text-gray-500 mb-2">裁剪结果:</p>
<img :src="croppedImage" class="max-w-full rounded border" />
</div>
<div class="mt-4">
<p class="text-sm text-gray-500">提示:</p>
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4">
<li>拖拽裁剪框中心区域可移动裁剪位置</li>
<li>拖拽四角或四边可调整裁剪框大小</li>
<li>默认为自由比例可调整为任意比例</li>
</ul>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import { VbenDescriptions } from '@vben/common-ui';
const items = [
{ content: 'Vben', label: '用户名' },
{ content: '13800138000', label: '手机号' },
{ content: '中国 · 杭州', label: '居住地' },
{ content: '前端工程师', label: '职位' },
{
content: '这是一段较长的备注信息,用于演示跨列展示。',
label: '备注',
span: 3,
},
];
</script>
<template>
<VbenDescriptions :items="items" />
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { VbenDescriptions } from '@vben/common-ui';
const items = [
{ content: 'Vben', label: '用户名' },
{ content: '13800138000', label: '手机号' },
{ content: '正常', label: '状态' },
{ content: '中国 · 杭州', label: '居住地' },
{
content: '浙江省杭州市西湖区某某街道某某小区 1 幢 2 单元',
label: '地址',
span: 3,
},
];
</script>
<template>
<VbenDescriptions bordered title="用户信息" :items="items">
<template #extra>
<span style="color: #1677ff; cursor: pointer">编辑</span>
</template>
</VbenDescriptions>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { VbenDescriptions, VbenDescriptionsItem } from '@vben/common-ui';
</script>
<template>
<!-- 通过子组件 VbenDescriptionsItem 声明列表项 -->
<VbenDescriptions bordered :column="2">
<VbenDescriptionsItem label="用户名">Vben</VbenDescriptionsItem>
<VbenDescriptionsItem label="状态">
<span style="color: #52c41a"> 正常</span>
</VbenDescriptionsItem>
<VbenDescriptionsItem label="备注" :span="2">
<template #content>
<span style="color: #888">通过 #content 插槽自定义内容</span>
</template>
</VbenDescriptionsItem>
</VbenDescriptions>
</template>

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import { VbenDescriptions } from '@vben/common-ui';
const items = [
{ content: 'Vben', label: '用户名' },
{ content: '13800138000', label: '手机号' },
{ content: '中国 · 杭州', label: '居住地' },
{ content: '前端工程师', label: '职位' },
];
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 16px">
<VbenDescriptions
size="small"
bordered
title="Small"
:column="2"
:items="items"
/>
<VbenDescriptions
size="middle"
bordered
title="Middle"
:column="2"
:items="items"
/>
<VbenDescriptions
size="large"
bordered
title="Large"
:column="2"
:items="items"
/>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { VbenDescriptions } from '@vben/common-ui';
const items = [
{ content: '1', label: 'A' },
{ content: '2span: 2', label: 'B', span: 2 },
{ content: '3', label: 'C' },
{ content: '占满当前行剩余空间', label: 'Dspan: filled', span: 'filled' },
{ content: '5', label: 'E' },
];
</script>
<template>
<!-- 列数随断点变化xs 1 sm 2 md 及以上 3 -->
<VbenDescriptions bordered :column="{ md: 3, sm: 2, xs: 1 }" :items="items" />
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
import { VbenDescriptions } from '@vben/common-ui';
const items = [
{ content: 'Vben', label: '用户名' },
{ content: '13800138000', label: '手机号' },
{ content: '中国 · 杭州', label: '居住地' },
{ content: '这是一段较长的备注信息。', label: '备注', span: 3 },
];
</script>
<template>
<VbenDescriptions bordered layout="vertical" :items="items" />
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Button, message, Space } from 'ant-design-vue';
import { Button, message, Space } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { h } from 'vue';
import { Input, message } from 'ant-design-vue';
import { Input, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Button, Card, message, Space, Tag } from 'ant-design-vue';
import { Button, Card, message, Space, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { ActionItem } from '@vben/common-ui';
import { ref } from 'vue';
import { VbenTableAction } from '@vben/common-ui';
const last = ref('无');
const actions: ActionItem[] = [
{ key: 'edit', onClick: () => (last.value = '编辑'), text: '编辑' },
{ key: 'detail', onClick: () => (last.value = '详情'), text: '详情' },
{
danger: true,
key: 'delete',
onClick: () => (last.value = '删除'),
text: '删除',
},
];
</script>
<template>
<div>
<VbenTableAction :actions="actions" align="start" divider />
<p style="margin-top: 8px; font-size: 13px; opacity: 0.7">
最近点击{{ last }}
</p>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
import type { ActionItem } from '@vben/common-ui';
import { ref } from 'vue';
import { VbenTableAction } from '@vben/common-ui';
const last = ref('无');
const actions: ActionItem[] = [
{ key: 'edit', onClick: () => (last.value = '编辑'), text: '编辑' },
];
const dropdownActions: ActionItem[] = [
{ key: 'copy', onClick: () => (last.value = '复制'), text: '复制' },
{ key: 'export', onClick: () => (last.value = '导出'), text: '导出' },
{
danger: true,
key: 'remove',
// 下拉项同样支持气泡确认
popConfirm: {
cancelText: '取消',
confirm: () => (last.value = '已移除'),
okText: '确认',
title: '确定移除吗?',
},
text: '移除',
},
];
</script>
<template>
<div>
<VbenTableAction
:actions="actions"
:dropdown-actions="dropdownActions"
align="start"
divider
more-text="更多"
/>
<p style="margin-top: 8px; font-size: 13px; opacity: 0.7">
最近点击{{ last }}
</p>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { ActionItem } from '@vben/common-ui';
import { VbenTableAction } from '@vben/common-ui';
// 模拟当前用户拥有的权限码
const allow = new Set(['user:detail', 'user:edit']);
function hasPermission(auth?: string | string[]) {
if (!auth) return true;
const codes = Array.isArray(auth) ? auth : [auth];
return codes.some((code) => allow.has(code));
}
const actions: ActionItem[] = [
{ auth: 'user:edit', key: 'edit', text: '编辑' },
{ auth: 'user:detail', key: 'detail', text: '详情' },
// 无 user:delete 权限,按钮被隐藏
{ auth: 'user:delete', danger: true, key: 'delete', text: '删除(无权限)' },
];
</script>
<template>
<VbenTableAction
:actions="actions"
:has-permission="hasPermission"
align="start"
/>
</template>

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import type { ActionItem } from '@vben/common-ui';
import { ref } from 'vue';
import { VbenTableAction } from '@vben/common-ui';
const last = ref('无');
const actions: ActionItem[] = [
{ key: 'edit', onClick: () => (last.value = '编辑'), text: '编辑' },
{
danger: true,
key: 'delete',
popConfirm: {
cancelText: '取消',
confirm: () => (last.value = '已删除'),
okText: '确认',
title: '确定删除这一行吗?',
},
text: '删除',
},
];
</script>
<template>
<div>
<VbenTableAction :actions="actions" align="start" />
<p style="margin-top: 8px; font-size: 13px; opacity: 0.7">
最近操作{{ last }}
</p>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { ActionItem } from '@vben/common-ui';
import { VbenTableAction } from '@vben/common-ui';
const actions: ActionItem[] = [
{ key: 'edit', text: '编辑', tooltip: '编辑这一行' },
{
key: 'detail',
text: '详情',
tooltip: { content: '查看详情', side: 'top' },
},
];
</script>
<template>
<VbenTableAction :actions="actions" align="start" />
</template>

View File

@@ -11,10 +11,9 @@ const content = ref('<p>开始编辑你的内容...</p>');
<VbenTiptap v-model="content" />
<div class="mt-4">
<p class="text-sm text-gray-500">当前内容:</p>
<pre
class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto max-h-40"
>{{ content }}</pre
>
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto max-h-40">
{{ content }}
</pre>
</div>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridListeners, VxeGridProps } from '#/adapter/vxe-table';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button, Image, Switch, Tag } from 'ant-design-vue';
import { Button, Image, Switch, Tag } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -2,7 +2,7 @@
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -3,7 +3,7 @@ import type { DemoTableApi } from '../mock-api';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

View File

@@ -20,7 +20,7 @@ The current wrapper flow is:
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
import { Cascader } from 'antdv-next';
function fetchApi() {
return Promise.resolve([

View File

@@ -0,0 +1,159 @@
---
outline: deep
---
# Vben Cropper Image Cropping
`VCropper` is a pure native image cropping component that supports both free and fixed aspect ratio cropping, with method-based access to cropped results.
> If some details are not obvious from the docs, check the live demos as well.
::: info Note
If you feel the current component implementation doesn't meet your needs, you can use native components directly or create your own component. The components provided by the framework are not constraints - use them at your discretion.
:::
## Basic Usage
Basic image cropping with free aspect ratio adjustment.
<DemoPreview dir="demos/vben-cropper/basic" />
## Fixed Aspect Ratio
Set the cropping ratio via the `aspectRatio` prop. The format is `"width:height"`, e.g. `"1:1"`, `"16:9"`, `"3:4"`.
<DemoPreview dir="demos/vben-cropper/aspect-ratio" />
## API
### Props
| Property | Description | Type | Default |
| ------------- | ---------------------------------- | -------- | ------- |
| `img` | Image URL (required) | `string` | - |
| `width` | Container width | `number` | `500` |
| `height` | Container height | `number` | `400` |
| `aspectRatio` | Crop ratio, e.g. `"1:1"`, `"16:9"` | `string` | - |
### Methods
Call component methods via `ref`:
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const handleCrop = async () => {
const result = await cropperRef.value?.getCropImage();
// result is a Blob or base64 string
};
</script>
```
#### getCropImage
Crop and retrieve the image.
```ts
getCropImage(
format?: 'image/jpeg' | 'image/png',
quality?: number,
outputType?: 'base64' | 'blob',
targetWidth?: number,
targetHeight?: number,
): Promise<Blob | string | undefined>
```
**Parameters:**
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `format` | `'image/jpeg' \| 'image/png'` | `'image/png'` | Output image format |
| `quality` | `number` | `0.92` | Compression quality (0-1), only effective for jpeg |
| `outputType` | `'base64' \| 'blob'` | `'blob'` | Output type, base64 string or Blob object |
| `targetWidth` | `number` | - | Target width, defaults to original crop width if omitted |
| `targetHeight` | `number` | - | Target height, defaults to original crop height if omitted |
## Features
### Cropping Operations
- **Drag to Move** - Drag the center area of the crop box to move its position
- **Corner Resize** - Drag the four corners to resize the crop box
- **Edge Resize** - Drag the midpoints of edges to adjust a single side
### Aspect Ratio Control
- **Free Ratio** - Without `aspectRatio`, adjust the crop box to any ratio
- **Fixed Ratio** - With `aspectRatio` set, the crop box maintains the specified ratio
### HiDPI Support
The component automatically adapts to Retina and other high-DPI screens, ensuring crisp output images.
### Image Fitting
- Images are automatically scaled to fit within the container
- Supports both local and remote images
- Remote images require CORS support from the server to export cropped results
## Usage Example
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const imageUrl = ref('https://example.com/image.jpg');
const croppedImage = ref('');
// Get cropped Blob
const handleCropBlob = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// Upload to server or create preview URL
const url = URL.createObjectURL(blob);
croppedImage.value = url;
}
};
// Get cropped base64 string
const handleCropBase64 = async () => {
const base64 = await cropperRef.value?.getCropImage('image/png', 1, 'base64');
if (typeof base64 === 'string') {
croppedImage.value = base64;
}
};
// Export with specific dimensions
const handleCropWithSize = async () => {
const blob = await cropperRef.value?.getCropImage(
'image/jpeg',
0.9,
'blob',
200, // target width
200, // target height
);
};
</script>
<template>
<div>
<VCropper
ref="cropperRef"
:img="imageUrl"
:width="500"
:height="400"
aspect-ratio="1:1"
/>
<button @click="handleCropBlob">Crop</button>
<img v-if="croppedImage" :src="croppedImage" />
</div>
</template>
```

View File

@@ -0,0 +1,102 @@
---
outline: deep
---
# Vben Descriptions
`Descriptions` displays a group of read-only fields, commonly used on detail pages and information previews. It is built on shadcn-ui with an API modeled after Ant Design Vue's Descriptions, supporting responsive columns, column spanning, borders, and vertical layout.
> If the documentation does not cover the details you need, please refer to the online examples.
::: info Before you start
The component supports two usages: data-driven via `items` (recommended), or declaring entries with the `VbenDescriptionsItem` child component. `items` takes precedence when both are provided. :::
## Basic Usage
Pass an array of fields via `items`, each with a `label` and `content`. Columns adapt to breakpoints by default (1 column on `xs`, 2 on `sm`, 3 on `md` and above).
<DemoPreview dir="demos/vben-descriptions/basic" />
## Bordered
Set `bordered` for a bordered style, combined with the `title` prop and the `#extra` slot (an action area on the right of the title).
<DemoPreview dir="demos/vben-descriptions/bordered" />
## Vertical Layout
Use `layout="vertical"` to place labels above their content.
<DemoPreview dir="demos/vben-descriptions/vertical" />
## Sizes
Use `size` to switch between `small`, `middle`, and `large`.
<DemoPreview dir="demos/vben-descriptions/size" />
## Span & Responsive
Set `span` on an item to span multiple columns; `'filled'` fills the remaining space of the current row. `column` accepts a breakpoint-keyed object for responsive columns.
<DemoPreview dir="demos/vben-descriptions/span" />
## Child Component Usage
When `items` is omitted, declare entries with `VbenDescriptionsItem` in the default slot. Content can be customized via the default slot or the `#content` slot.
<DemoPreview dir="demos/vben-descriptions/custom" />
## API
### Descriptions Props
| Prop | Description | Type | Default |
| --- | --- | --- | --- |
| items | Data-driven entries; reads the default slot when omitted | `DescriptionsItemType[]` | - |
| bordered | Whether to show borders | `boolean` | `false` |
| column | Columns per row, supports breakpoint config | `number \| Partial<Record<Breakpoint, number>>` | `{ xs: 1, sm: 2, md: 3, xxxl: 4 }` |
| layout | Layout direction | `'horizontal' \| 'vertical'` | `'horizontal'` |
| size | Size | `'small' \| 'middle' \| 'large'` | `'middle'` |
| colon | Show colon (only for non-bordered horizontal layout) | `boolean` | `true` |
| title | Title | `string` | - |
| extra | Action area on the right of the title | `string` | - |
| labelStyle | Shared label style | `CSSProperties` | - |
| contentStyle | Shared content style | `CSSProperties` | - |
| class | Custom class for the root node | `string` | - |
### Descriptions Slots
| Slot | Description |
| ------- | ------------------------------------- |
| title | Custom title |
| extra | Custom action area beside the title |
| default | Place `VbenDescriptionsItem` children |
### DescriptionsItem
Each entry in `items`, or the props of the `VbenDescriptionsItem` child component.
| Prop | Description | Type | Default |
| --- | --- | --- | --- |
| label | Label | `string \| number \| (() => VNode) \| Component` | - |
| content | Content | `string \| number \| (() => VNode) \| Component` | - |
| span | Columns to span, `'filled'` fills the rest of the row | `number \| 'filled' \| Partial<Record<Breakpoint, number>>` | `1` |
| labelStyle | Label style | `CSSProperties` | - |
| contentStyle | Content style | `CSSProperties` | - |
| key | Unique key | `string \| number` | - |
### DescriptionsItem Slots
Available only for the child component usage.
| Slot | Description |
| ------- | --------------------------------- |
| default | Content (equivalent to `content`) |
| content | Custom content |
| label | Custom label |
::: tip Breakpoint
The responsive `Breakpoint` is one of `'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'`, with pixel values aligned with Ant Design (`sm` 576, `md` 768, `lg` 992, `xl` 1200, `xxl` 1600, `xxxl` 2000). :::

View File

@@ -103,7 +103,7 @@ import {
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,

View File

@@ -0,0 +1,165 @@
---
outline: deep
---
# Vben TableAction
`TableAction` renders a group of action buttons for table operation columns, inspired by the TableAction component from vben2. Built on shadcn-ui, it supports permission control, popconfirm, tooltips, a "more" dropdown, and dividers, and can be reused inside or outside tables.
> If the documentation does not cover the details you need, please refer to the online examples.
::: info Before you start
The component carries no business logic (it does not read the permission store directly); permissions are handled by injecting `hasPermission`, keeping the core layer decoupled and reusable across frameworks. Inside vxe-table, the recommended approach is to render it via a column slot (`slots: { default: 'action' }`) on the page, without changing the table's original rendering mechanism. :::
## Basic Usage
Pass an array of action items via `actions`, each with `text`, `onClick`, etc. `danger` marks destructive actions, and `divider` shows separators between buttons.
<DemoPreview dir="demos/vben-table-action/basic" />
## Tooltip
Add a tooltip to an action via `tooltip`, accepting a string or a `{ content, side }` object.
<DemoPreview dir="demos/vben-table-action/tooltip" />
## PopConfirm
Use `popConfirm` to require confirmation before the action runs, commonly used for destructive actions like delete.
<DemoPreview dir="demos/vben-table-action/popconfirm" />
## More Dropdown
Use `dropdownActions` to collapse secondary actions into a "more" dropdown. `moreText` customizes the button label.
<DemoPreview dir="demos/vben-table-action/dropdown" />
## Permission Control
Set an `auth` code on an action and inject a `hasPermission` resolver; actions without permission are hidden.
<DemoPreview dir="demos/vben-table-action/permission" />
## Usage with vxe-table
Without changing vxe-table's rendering mechanism, declare a slot in the column config and render it on the page.
::: tip Recommended: use the adapter-wrapped version The project's `#/adapter/vxe-table` re-wraps `VbenTableAction` and injects `hasPermission` internally (based on `useAccess().hasAccessByCodes`). So when you import it from the adapter, **you no longer need to pass `:has-permission`** — just declare permission codes via the `auth` field of each action. :::
```ts
// data.ts — declare a slot in the column config
{
align: 'center',
field: 'operation',
fixed: 'right',
slots: { default: 'action' },
title: $t('system.user.operation'),
width: 180,
}
```
```vue
<!-- list.vue import from the adapter; permission is auto-injected, no has-permission needed -->
<script setup lang="ts">
import { VbenTableAction } from '#/adapter/vxe-table';
</script>
<template>
<Grid>
<template #action="{ row }">
<template #action="{ row }">
<VbenTableAction
:actions="[
{
text: $t('common.detail'),
icon: 'lucide:eye',
onClick: () => onDetail(row),
},
{
text: $t('common.edit'),
icon: 'lucide:edit',
onClick: () => onEdit(row),
},
]"
:dropdown-actions="[
{
text: $t('common.delete'),
icon: 'lucide:trash-2',
danger: true,
onClick: () => onDelete(row),
auth: ['AC_100100'],
},
]"
align="center"
/>
</template>
</template>
</Grid>
</template>
```
If you import the core component directly from `@vben/common-ui` (without going through the adapter), the component carries no business logic and you need to inject `hasPermission` yourself:
```vue
<script setup lang="ts">
import { useAccess } from '@vben/access';
import { VbenTableAction } from '@vben/common-ui';
const { hasAccessByCodes } = useAccess();
function hasPermission(auth?: string | string[]) {
if (!auth) return true;
return hasAccessByCodes(Array.isArray(auth) ? auth : [auth]);
}
</script>
<template>
<VbenTableAction
v-bind="useActions(row, onActionClick)"
:has-permission="hasPermission"
align="center"
/>
</template>
```
## API
### TableAction Props
| Prop | Description | Type | Default |
| --- | --- | --- | --- |
| actions | Main action buttons | `ActionItem[]` | `[]` |
| dropdownActions | Actions inside the "more" dropdown | `ActionItem[]` | `[]` |
| align | Alignment | `'start' \| 'center' \| 'end'` | `'end'` |
| divider | Whether to show separators between buttons | `boolean` | `false` |
| moreText | Label for the "more" button (shown beside the icon) | `string` | - |
| hasPermission | Permission resolver; returning `false` hides the action with that `auth` (auto-injected when imported from `#/adapter/vxe-table`, no need to pass manually) | `(auth?: string \| string[]) => boolean` | - |
| class | Custom class for the root node | `string` | - |
### ActionItem
| Prop | Description | Type | Default |
| --- | --- | --- | --- |
| text | Button text | `string` | - |
| icon | Icon component | `string` \| `VbenIcon` | - |
| onClick | Click callback | `() => void` | - |
| auth | Permission code, filtered by `hasPermission` | `string \| string[]` | - |
| ifShow | Whether to show | `boolean \| (() => boolean)` | `true` |
| disabled | Whether disabled | `boolean` | `false` |
| loading | Loading state | `boolean` | `false` |
| danger | Destructive action (red text) | `boolean` | `false` |
| tooltip | Tooltip | `string \| { content: string; side?: 'top' \| 'bottom' \| 'left' \| 'right' }` | - |
| popConfirm | PopConfirm | `TableActionPopConfirm` | - |
| variant | Button variant | `ButtonVariants['variant']` | `'link'` |
| size | Button size | `ButtonVariants['size']` | `'sm'` |
| key | Unique key | `string \| number` | - |
### TableActionPopConfirm
| Prop | Description | Type | Default |
| --- | --- | --- | --- |
| title | Confirm title | `string` | `'Are you sure?'` |
| okText | Confirm button text | `string` | `'OK'` |
| cancelText | Cancel button text | `string` | `'Cancel'` |
| confirm | Confirm callback; falls back to `action.onClick` if omitted | `() => void` | - |

View File

@@ -1,6 +1,6 @@
# External Modules
In addition to the external modules that are included by default in the project, sometimes we need to import other external modules. Let's take [ant-design-vue](https://antdv.com/components/overview) as an example:
In addition to the external modules that are included by default in the project, sometimes we need to import other external modules. Let's take [antdv-next](https://antdv.com/components/overview) as an example:
## Installing Dependencies
@@ -13,7 +13,7 @@ In addition to the external modules that are included by default in the project,
```bash
# cd /path/to/your/package
pnpm add ant-design-vue
pnpm add antdv-next
```
## Usage
@@ -22,9 +22,9 @@ pnpm add ant-design-vue
```ts
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import Antd from 'antdv-next';
import App from './App';
import 'ant-design-vue/dist/reset.css';
import 'antdv-next/dist/reset.css';
const app = createApp(App);
@@ -43,7 +43,7 @@ app.use(Antd).mount('#app');
```vue
<script setup lang="ts">
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
</script>
<template>

View File

@@ -98,7 +98,7 @@ VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
::: tip How to Dynamically Modify API Endpoint in Production
Variables starting with `VITE_GLOB_*` in the `.env` file are injected into the `_app.config.js` file during packaging. After packaging, you can modify the corresponding API addresses in `dist/_app.config.js` and refresh the page to apply the changes. This eliminates the need to package multiple times for different environments, allowing a single package to be deployed across multiple API environments.
Variables starting with `VITE_GLOB_*` in the `.env` file are injected into the `_app-config-{version}-{hash}.js` file during packaging. After packaging, you can modify the corresponding API addresses in `dist/_app-config-{version}-{hash}.js` and refresh the page to apply the changes. This eliminates the need to package multiple times for different environments, allowing a single package to be deployed across multiple API environments.
:::
@@ -174,7 +174,7 @@ import {
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useAuthStore } from '#/store';

View File

@@ -21,7 +21,7 @@ The rules are consistent with [Vite Env Variables and Modes](https://vitejs.dev/
console.log(import.meta.env.VITE_PROT);
```
- Variables starting with `VITE_GLOB_*` will be added to the `_app.config.js` configuration file during packaging.
- Variables starting with `VITE_GLOB_*` will be added to the `_app-config-{version}-{hash}.js` configuration file during packaging.
:::
@@ -87,9 +87,9 @@ VITE_ARCHIVER=true
## Dynamic Configuration in Production Environment
When executing `pnpm build` in the root directory of the monorepo, a `dist/_app.config.js` file will be automatically generated in the corresponding application and inserted into `index.html`.
When executing `pnpm build` in the root directory of the monorepo, a `dist/_app-config-{version}-{hash}.js` file will be automatically generated in the corresponding application and inserted into `index.html`.
`_app.config.js` is a dynamic configuration file that allows for modifications to the configuration dynamically based on different environments after the project has been built. The content is as follows:
`_app-config-{version}-{hash}.js` is a dynamic configuration file that allows for modifications to the configuration dynamically based on different environments after the project has been built. The content is as follows:
```ts
window._VBEN_ADMIN_PRO_APP_CONF_ = {
@@ -104,11 +104,11 @@ Object.defineProperty(window, '_VBEN_ADMIN_PRO_APP_CONF_', {
### Purpose
`_app.config.js` is used for projects that need to dynamically modify configurations after packaging, such as API endpoints. There's no need to repackage; you can simply modify the variables in `/dist/_app.config.js` after packaging, and refresh to update the variables in the code. A `js` file is used to ensure that the configuration file is loaded early in the order.
`_app-config-{version}-{hash}.js` is used for projects that need to dynamically modify configurations after packaging, such as API endpoints. There's no need to repackage; you can simply modify the variables in `/dist/_app-config-{version}-{hash}.js` after packaging, and refresh to update the variables in the code. A `js` file is used to ensure that the configuration file is loaded early in the order.
### Usage
To access the variables inside `_app.config.js`, you need to use the `useAppConfig` method provided by `@vben/hooks`.
To access the variables inside `_app-config-{version}-{hash}.js`, you need to use the `useAppConfig` method provided by `@vben/hooks`.
```ts
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);

View File

@@ -10,7 +10,7 @@ outline: deep
Before starting the project, ensure that your environment meets the following requirements:
- [Node.js](https://nodejs.org/en) version 20.15.0 or above. It is recommended to use [fnm](https://github.com/Schniz/fnm), [nvm](https://github.com/nvm-sh/nvm), or directly use [pnpm](https://pnpm.io/cli/env) for version management.
- [Node.js](https://nodejs.org/en) version 22.18.0 or above. It is recommended to use [fnm](https://github.com/Schniz/fnm), [nvm](https://github.com/nvm-sh/nvm), or directly use [pnpm](https://pnpm.io/cli/env) for version management.
- [Git](https://git-scm.com/) any version.
To verify if your environment meets the above requirements, you can check the versions using the following commands:

View File

@@ -26,7 +26,7 @@
## Browser Support
- **Local development** is recommended using the **latest version of Chrome**. **Versions below Chrome 80 are not supported**.
- **Local development** is recommended using the **latest version of Chrome**. **Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+**.
- **Production environment** supports modern browsers, IE is not supported.

View File

@@ -110,7 +110,7 @@ registry = https://registry.npmmirror.com/
If you encounter errors similar to the following, please check that the full project path (including all parent paths) does not contain Chinese, Japanese, or Korean characters. Otherwise, you will encounter a 404 error for the path, leading to the following issue:
```ts
[vite] Failed to resolve module import "ant-design-vue/dist/antd.css-vben-adminode_modulesant-design-vuedistantd.css". (imported by /@/setup/ant-design-vue/index.ts)
[vite] Failed to resolve module import "antdv-next/dist/antd.css-vben-adminode_modulesantdv-nextdistantd.css". (imported by /@/setup/antdv-next/index.ts)
```
## Console Route Warning Issue

View File

@@ -1,6 +1,6 @@
# 外部模块
除了项目默认引入的外部模块,有时我们还需要引入其他外部模块。我们以 [ant-design-vue](https://antdv.com/components/overview) 为例:
除了项目默认引入的外部模块,有时我们还需要引入其他外部模块。我们以 [antdv-next](https://antdv.com/components/overview) 为例:
## 安装依赖
@@ -13,7 +13,7 @@
```bash
# cd /path/to/your/package
pnpm add ant-design-vue
pnpm add antdv-next
```
## 使用
@@ -22,9 +22,9 @@ pnpm add ant-design-vue
```ts
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import Antd from 'antdv-next';
import App from './App';
import 'ant-design-vue/dist/reset.css';
import 'antdv-next/dist/reset.css';
const app = createApp(App);
@@ -43,7 +43,7 @@ app.use(Antd).mount('#app');
```vue
<script setup lang="ts">
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
</script>
<template>

View File

@@ -98,7 +98,7 @@ VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
::: tip 打包如何动态修改接口地址
`.env` 文件内的 `VITE_GLOB_*` 开头的变量会在打包的时候注入 `_app.config.js` 文件内。在 `dist/_app.config.js` 修改相应的接口地址后刷新页面即可,不需要在根据不同环境打包多次,一次打包可以用于多个不同接口环境的部署。
`.env` 文件内的 `VITE_GLOB_*` 开头的变量会在打包的时候注入 `_app-config-{version}-{hash}.js` 文件内。在 `dist/_app-config-{version}-{hash}.js` 修改相应的接口地址后刷新页面即可,不需要在根据不同环境打包多次,一次打包可以用于多个不同接口环境的部署。
:::
@@ -204,7 +204,7 @@ import {
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useAuthStore } from '#/store';

View File

@@ -21,7 +21,7 @@
console.log(import.meta.env.VITE_PROT);
```
- 以 `VITE_GLOB_*` 开头的的变量,在打包的时候,会被加入 `_app.config.js`配置文件当中.
- 以 `VITE_GLOB_*` 开头的的变量,在打包的时候,会被加入 `_app-config-{version}-{hash}.js`配置文件当中.
:::
@@ -86,9 +86,9 @@ VITE_ARCHIVER=true
## 生产环境动态配置
当在大仓根目录下,执行 `pnpm build`构建项目之后,会自动在对应的应用下生成 `dist/_app.config.js`文件并插入 `index.html`。
当在大仓根目录下,执行 `pnpm build`构建项目之后,会自动在对应的应用下生成 `dist/_app-config-{version}-{hash}.js`文件并插入 `index.html`。
`_app.config.js` 是一个动态配置文件,可以在项目构建之后,根据不同的环境动态修改配置。内容如下:
`_app-config-{version}-{hash}.js` 是一个动态配置文件,可以在项目构建之后,根据不同的环境动态修改配置。内容如下:
```ts
window._VBEN_ADMIN_PRO_APP_CONF_ = {
@@ -103,11 +103,11 @@ Object.defineProperty(window, '_VBEN_ADMIN_PRO_APP_CONF_', {
### 作用
`_app.config.js` 用于项目在打包后,需要动态修改配置的需求,如接口地址。不用重新进行打包,可在打包后修改 /`dist/_app.config.js` 内的变量,刷新即可更新代码内的局部变量。这里使用`js`文件,是为了确保配置文件加载顺序保持在前面。
`_app-config-{version}-{hash}.js` 用于项目在打包后,需要动态修改配置的需求,如接口地址。不用重新进行打包,可在打包后修改 /`dist/_app-config-{version}-{hash}.js` 内的变量,刷新即可更新代码内的局部变量。这里使用`js`文件,是为了确保配置文件加载顺序保持在前面。
### 使用
想要获取 `_app.config.js` 内的变量,需要使用`@vben/hooks`提供的 `useAppConfig`方法。
想要获取 `_app-config-{version}-{hash}.js` 内的变量,需要使用`@vben/hooks`提供的 `useAppConfig`方法。
```ts
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);

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