Merge remote-tracking branch 'origin/main'
# Conflicts: # packages/effects/plugins/src/vxe-table/use-vxe-grid.vue
This commit is contained in:
3
.changeset/.prettierrc.json
Normal file
3
.changeset/.prettierrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
7
.changeset/fancy-ears-walk.md
Normal file
7
.changeset/fancy-ears-walk.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
'@vben/styles': patch
|
||||||
|
'@vben-core/form-ui': patch
|
||||||
|
'@vben/web-naive': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray
|
||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
@@ -89,6 +89,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v4
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
2
.github/workflows/issue-close-require.yml
vendored
2
.github/workflows/issue-close-require.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# 关闭未活动的 Issues
|
# 关闭未活动的 Issues
|
||||||
- name: Close Inactive Issues
|
- name: Close Inactive Issues
|
||||||
uses: actions/stale@v9
|
uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
|
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.
|
stale-issue-label: needs-reproduction # Label that flags an issue as stale.
|
||||||
|
|||||||
6
.github/workflows/issue-labeled.yml
vendored
6
.github/workflows/issue-labeled.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: remove enhancement pending
|
- name: remove enhancement pending
|
||||||
if: github.event.label.name == 'enhancement'
|
if: github.event.label.name == 'enhancement'
|
||||||
uses: actions-cool/issues-helper@v3
|
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
|
||||||
with:
|
with:
|
||||||
actions: 'remove-labels'
|
actions: 'remove-labels'
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
- name: remove bug pending
|
- name: remove bug pending
|
||||||
if: github.event.label.name == 'bug'
|
if: github.event.label.name == 'bug'
|
||||||
uses: actions-cool/issues-helper@v3
|
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
|
||||||
with:
|
with:
|
||||||
actions: 'remove-labels'
|
actions: 'remove-labels'
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: needs reproduction
|
- name: needs reproduction
|
||||||
if: github.event.label.name == 'needs reproduction'
|
if: github.event.label.name == 'needs reproduction'
|
||||||
uses: actions-cool/issues-helper@v3
|
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
|
||||||
with:
|
with:
|
||||||
actions: 'create-comment, remove-labels'
|
actions: 'create-comment, remove-labels'
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v5
|
- uses: dessant/lock-threads@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
issue-inactive-days: '14'
|
issue-inactive-days: '14'
|
||||||
|
|||||||
74
.github/workflows/release-tag.yml
vendored
74
.github/workflows/release-tag.yml
vendored
@@ -3,60 +3,44 @@ name: Create Release Tag
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
env:
|
inputs:
|
||||||
HUSKY: '0'
|
tag:
|
||||||
|
description: 'Tag to create (e.g. v1.2.3)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [22]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# - name: Checkout code
|
- name: Extract version
|
||||||
# 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
|
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
tag=${GITHUB_REF/refs\/tags\//}
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
version=${tag#v}
|
raw_tag="${{ inputs.tag }}"
|
||||||
major=${version%%.*}
|
else
|
||||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
raw_tag="${GITHUB_REF_NAME}"
|
||||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
fi
|
||||||
echo "major=${major}" >> $GITHUB_OUTPUT
|
# 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
|
- uses: release-drafter/release-drafter@v7
|
||||||
with:
|
with:
|
||||||
@@ -64,17 +48,3 @@ jobs:
|
|||||||
publish: true
|
publish: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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.
|
|
||||||
|
|||||||
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Validate PR title
|
- name: Validate PR title
|
||||||
uses: amannn/action-semantic-pull-request@v5
|
uses: amannn/action-semantic-pull-request@v6
|
||||||
with:
|
with:
|
||||||
wip: true
|
wip: true
|
||||||
subjectPattern: ^(?![A-Z]).+$
|
subjectPattern: ^(?![A-Z]).+$
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
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'
|
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
3
.gitignore
vendored
@@ -23,6 +23,7 @@ package-lock.json
|
|||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
**/backend-mock/data
|
**/backend-mock/data
|
||||||
.omx
|
.omx
|
||||||
|
.pnpm-store
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
@@ -57,3 +58,5 @@ vite.config.ts.*
|
|||||||
.claude
|
.claude
|
||||||
.codex
|
.codex
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
.atomcode
|
||||||
|
datalog
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
22.22.0
|
24.16.0
|
||||||
|
|||||||
12
.npmrc
12
.npmrc
@@ -1,13 +1 @@
|
|||||||
registry=https://registry.npmmirror.com
|
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
|
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
// lint && format
|
// lint && format
|
||||||
"oxc.enable": true,
|
"oxc.enable": true,
|
||||||
"oxc.typeAware": true,
|
"oxc.typeAware": false,
|
||||||
"oxc.configPath": "oxlint.config.ts",
|
"oxc.configPath": "oxlint.config.ts",
|
||||||
"oxc.fmt.configPath": "oxfmt.config.ts",
|
"oxc.fmt.configPath": "oxfmt.config.ts",
|
||||||
"eslint.useFlatConfig": true,
|
"eslint.useFlatConfig": true,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ pnpm build
|
|||||||
|
|
||||||
## ブラウザサポート
|
## ブラウザサポート
|
||||||
|
|
||||||
ローカル開発には `Chrome 80+` ブラウザを推奨します
|
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
|
||||||
|
|
||||||
モダンブラウザをサポートし、IEはサポートしません
|
モダンブラウザをサポートし、IEはサポートしません
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ Reference [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.
|
|||||||
|
|
||||||
## Browser Support
|
## 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
|
Support modern browsers, not IE
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ pnpm build
|
|||||||
|
|
||||||
## 浏览器支持
|
## 浏览器支持
|
||||||
|
|
||||||
本地开发推荐使用 `Chrome 80+` 浏览器
|
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
|
||||||
|
|
||||||
支持现代浏览器,不支持 IE
|
支持现代浏览器,不支持 IE
|
||||||
|
|
||||||
|
|||||||
85
apps/backend-mock/api/system/user/list.ts
Normal file
85
apps/backend-mock/api/system/user/list.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -131,8 +131,8 @@ const PreviewGroup = defineAsyncComponent(() =>
|
|||||||
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
|
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
|
||||||
);
|
);
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = (
|
||||||
component: T,
|
component: Component,
|
||||||
type: 'input' | 'select',
|
type: 'input' | 'select',
|
||||||
componentProps: Recordable<any> = {},
|
componentProps: Recordable<any> = {},
|
||||||
) => {
|
) => {
|
||||||
@@ -702,7 +702,9 @@ async function initComponentAdapter() {
|
|||||||
modelValueProp: 'value',
|
modelValueProp: 'value',
|
||||||
}),
|
}),
|
||||||
Input: withDefaultPlaceholder(Input, 'input'),
|
Input: withDefaultPlaceholder(Input, 'input'),
|
||||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
|
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||||
|
style: { width: '100%' },
|
||||||
|
}),
|
||||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||||
// 自定义主要按钮
|
// 自定义主要按钮
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ watch(
|
|||||||
description="ann.vben@gmail.com"
|
description="ann.vben@gmail.com"
|
||||||
tag-text="Pro"
|
tag-text="Pro"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #notification>
|
<template #notification>
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
|||||||
<template #description> 今日晴,20℃ - 32℃! </template>
|
<template #description> 今日晴,20℃ - 32℃! </template>
|
||||||
</WorkbenchHeader>
|
</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">
|
<div class="mr-4 w-full lg:w-3/5">
|
||||||
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
|
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
|
||||||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
|
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
|
||||||
@@ -252,7 +252,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
|||||||
<div class="w-full lg:w-2/5">
|
<div class="w-full lg:w-2/5">
|
||||||
<WorkbenchQuickNav
|
<WorkbenchQuickNav
|
||||||
:items="quickNavItems"
|
:items="quickNavItems"
|
||||||
class="mt-5 lg:mt-0"
|
class="lg:mt-0"
|
||||||
title="快捷导航"
|
title="快捷导航"
|
||||||
@click="navTo"
|
@click="navTo"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,8 +36,11 @@ import type { Component, Ref } from 'vue';
|
|||||||
import type {
|
import type {
|
||||||
ApiComponentSharedProps,
|
ApiComponentSharedProps,
|
||||||
BaseFormComponentType,
|
BaseFormComponentType,
|
||||||
|
CollapsibleParamsProps,
|
||||||
IconPickerProps,
|
IconPickerProps,
|
||||||
} from '@vben/common-ui';
|
} from '@vben/common-ui';
|
||||||
|
import type { Sortable } from '@vben/hooks';
|
||||||
|
import type { TipTapProps } from '@vben/plugins/tiptap';
|
||||||
import type { Recordable } from '@vben/types';
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +48,9 @@ import {
|
|||||||
defineAsyncComponent,
|
defineAsyncComponent,
|
||||||
defineComponent,
|
defineComponent,
|
||||||
h,
|
h,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
render,
|
render,
|
||||||
unref,
|
unref,
|
||||||
@@ -55,19 +61,25 @@ import {
|
|||||||
ApiComponent,
|
ApiComponent,
|
||||||
globalShareState,
|
globalShareState,
|
||||||
IconPicker,
|
IconPicker,
|
||||||
|
VbenCollapsibleParams,
|
||||||
VCropper,
|
VCropper,
|
||||||
} from '@vben/common-ui';
|
} from '@vben/common-ui';
|
||||||
|
import { useSortable } from '@vben/hooks';
|
||||||
import { IconifyIcon } from '@vben/icons';
|
import { IconifyIcon } from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
import { VbenTiptap } from '@vben/plugins/tiptap';
|
||||||
import { isEmpty } from '@vben/utils';
|
import { isEmpty } from '@vben/utils';
|
||||||
|
|
||||||
import { message, Modal, notification } from 'antdv-next';
|
import { message, Modal, notification } from 'antdv-next';
|
||||||
|
|
||||||
|
import { upload_file } from '#/api';
|
||||||
type AdapterUploadProps = UploadProps & {
|
type AdapterUploadProps = UploadProps & {
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
crop?: boolean;
|
crop?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
handleChange?: (event: UploadChangeParam) => void;
|
handleChange?: (event: UploadChangeParam) => void;
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
|
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||||
onHandleChange?: (event: UploadChangeParam) => void;
|
onHandleChange?: (event: UploadChangeParam) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,8 +92,8 @@ const Button = defineAsyncComponent(
|
|||||||
const Checkbox = defineAsyncComponent(
|
const Checkbox = defineAsyncComponent(
|
||||||
() => import('antdv-next/dist/checkbox/index'),
|
() => import('antdv-next/dist/checkbox/index'),
|
||||||
);
|
);
|
||||||
const CheckboxGroup = defineAsyncComponent(
|
const CheckboxGroup = defineAsyncComponent(() =>
|
||||||
() => import('antdv-next/dist/checkbox/Group'),
|
import('antdv-next/dist/checkbox/index').then((res) => res.CheckboxGroup),
|
||||||
);
|
);
|
||||||
const DatePicker = defineAsyncComponent(
|
const DatePicker = defineAsyncComponent(
|
||||||
() => import('antdv-next/dist/date-picker/index'),
|
() => import('antdv-next/dist/date-picker/index'),
|
||||||
@@ -136,8 +148,8 @@ const PreviewGroup = defineAsyncComponent(() =>
|
|||||||
import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup),
|
import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup),
|
||||||
);
|
);
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = (
|
||||||
component: T,
|
component: Component,
|
||||||
type: 'input' | 'select',
|
type: 'input' | 'select',
|
||||||
componentProps: Recordable<any> = {},
|
componentProps: Recordable<any> = {},
|
||||||
) => {
|
) => {
|
||||||
@@ -170,260 +182,263 @@ const withDefaultPlaceholder = <T extends Component>(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const withPreviewUpload = () => {
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
// 检查是否为图片文件的辅助函数
|
'bmp',
|
||||||
const isImageFile = (file: UploadFile): boolean => {
|
'gif',
|
||||||
const imageExtensions = new Set([
|
'jpeg',
|
||||||
'bmp',
|
'jpg',
|
||||||
'gif',
|
'png',
|
||||||
'jpeg',
|
'svg',
|
||||||
'jpg',
|
'webp',
|
||||||
'png',
|
]);
|
||||||
'svg',
|
|
||||||
'webp',
|
/**
|
||||||
]);
|
* 检查是否为图片文件
|
||||||
if (file.url) {
|
*/
|
||||||
try {
|
function isImageFile(file: UploadFile): boolean {
|
||||||
const pathname = new URL(file.url, 'http://localhost').pathname;
|
if (file.url) {
|
||||||
const ext = pathname.split('.').pop()?.toLowerCase();
|
try {
|
||||||
return ext ? imageExtensions.has(ext) : false;
|
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||||
} catch {
|
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||||
const ext = file.url?.split('.').pop()?.toLowerCase();
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
return ext ? imageExtensions.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();
|
if (!file.type) {
|
||||||
return ext ? imageExtensions.has(ext) : false;
|
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||||
}
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
return file.type.startsWith('image/');
|
}
|
||||||
|
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,
|
* 获取文件的 Base64
|
||||||
) => {
|
*/
|
||||||
switch (listType) {
|
function getBase64(file: File): Promise<string> {
|
||||||
case 'picture-card': {
|
return new Promise((resolve, reject) => {
|
||||||
return {
|
const reader = new FileReader();
|
||||||
default: () => placeholder,
|
reader.readAsDataURL(file);
|
||||||
};
|
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||||
}
|
reader.addEventListener('error', reject);
|
||||||
default: {
|
});
|
||||||
return {
|
}
|
||||||
default: () =>
|
|
||||||
h(
|
/**
|
||||||
Button,
|
* 预览图片
|
||||||
{
|
*/
|
||||||
icon: h(IconifyIcon, {
|
async function previewImage(
|
||||||
icon: 'ant-design:upload-outlined',
|
file: UploadFile,
|
||||||
class: 'mb-1 size-4',
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对于图片文件,继续使用预览组
|
render(h(PreviewWrapper), container);
|
||||||
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
}
|
||||||
Image,
|
|
||||||
PreviewGroup,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getBase64 = (file: File) => {
|
/**
|
||||||
return new Promise((resolve, reject) => {
|
* 图片裁剪操作
|
||||||
const reader = new FileReader();
|
*/
|
||||||
reader.readAsDataURL(file);
|
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||||
reader.addEventListener('load', () => resolve(reader.result));
|
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||||
reader.addEventListener('error', (error) => reject(error));
|
const container = document.createElement('div');
|
||||||
});
|
|
||||||
};
|
|
||||||
// 从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');
|
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
|
|
||||||
// 用于追踪组件是否已卸载
|
|
||||||
let isUnmounted = false;
|
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() {
|
setup() {
|
||||||
return () => {
|
return () => {
|
||||||
if (isUnmounted) return null;
|
if (isUnmounted) return null;
|
||||||
|
if (!objectUrl) {
|
||||||
|
objectUrl = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
return h(
|
return h(
|
||||||
PreviewGroupComponent,
|
Modal,
|
||||||
{
|
{
|
||||||
class: 'hidden',
|
open: open.value,
|
||||||
preview: {
|
title: h('div', {}, [
|
||||||
open: visible.value,
|
$t('ui.crop.title'),
|
||||||
// 设置初始显示的图片索引
|
h(
|
||||||
current: imageFiles.findIndex((f) => f.uid === file.uid),
|
'span',
|
||||||
onOpenChange: (value: boolean) => {
|
{
|
||||||
visible.value = value;
|
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||||
if (!value) {
|
},
|
||||||
// 延迟清理,确保动画完成
|
$t('ui.crop.titleTip', [aspectRatio]),
|
||||||
setTimeout(() => {
|
),
|
||||||
if (!isUnmounted && container) {
|
]),
|
||||||
isUnmounted = true;
|
centered: true,
|
||||||
render(null, container);
|
width: 548,
|
||||||
container.remove();
|
keyboard: false,
|
||||||
}
|
maskClosable: false,
|
||||||
}, 300);
|
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();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
// 渲染所有图片文件
|
h(VCropper, {
|
||||||
imageFiles.map((imgFile) =>
|
ref: (ref: any) => (cropperRef.value = ref),
|
||||||
h(ImageComponent, {
|
img: objectUrl as string,
|
||||||
key: imgFile.uid,
|
aspectRatio,
|
||||||
src: imgFile.url || imgFile.preview,
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
render(h(PreviewWrapper), container);
|
render(h(CropperWrapper), 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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带预览功能的上传组件
|
||||||
|
*/
|
||||||
|
function withPreviewUpload() {
|
||||||
return defineComponent({
|
return defineComponent({
|
||||||
name: 'AUpload',
|
name: Upload.name,
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
setup: (
|
setup(
|
||||||
props: any,
|
props: any,
|
||||||
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
||||||
) => {
|
) {
|
||||||
const previewVisible = ref<boolean>(false);
|
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 listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
||||||
|
|
||||||
const fileList = ref<UploadProps['fileList']>(
|
const fileList = ref<UploadProps['fileList']>(
|
||||||
attrs?.fileList || attrs?.['file-list'] || [],
|
attrs?.fileList || attrs?.['file-list'] || [],
|
||||||
);
|
);
|
||||||
@@ -433,16 +448,18 @@ const withPreviewUpload = () => {
|
|||||||
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBeforeUpload = async (
|
async function handleBeforeUpload(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
originFileList: Array<File>,
|
originFileList: Array<File>,
|
||||||
) => {
|
) {
|
||||||
|
// 文件大小限制
|
||||||
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
||||||
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
||||||
file.status = 'removed';
|
file.status = 'removed';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 多选或者非图片不唤起裁剪框
|
|
||||||
|
// 图片裁剪处理
|
||||||
if (
|
if (
|
||||||
attrs.crop &&
|
attrs.crop &&
|
||||||
!attrs.multiple &&
|
!attrs.multiple &&
|
||||||
@@ -450,27 +467,21 @@ const withPreviewUpload = () => {
|
|||||||
isImageFile(file)
|
isImageFile(file)
|
||||||
) {
|
) {
|
||||||
file.status = 'removed';
|
file.status = 'removed';
|
||||||
// antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
|
|
||||||
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
||||||
return new Promise((resolve, reject) => {
|
if (!blob) {
|
||||||
if (!blob) {
|
throw new Error($t('ui.crop.errorTip'));
|
||||||
return reject(new Error($t('ui.crop.errorTip')));
|
}
|
||||||
}
|
return blob;
|
||||||
resolve(blob);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return attrs.beforeUpload?.(file) ?? true;
|
return attrs.beforeUpload?.(file) ?? true;
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleChange = (event: UploadChangeParam) => {
|
function handleChange(event: UploadChangeParam) {
|
||||||
try {
|
try {
|
||||||
// 行内写法 handleChange: (event) => {}
|
|
||||||
attrs.handleChange?.(event);
|
attrs.handleChange?.(event);
|
||||||
// template写法 @handle-change="(event) => {}"
|
|
||||||
attrs.onHandleChange?.(event);
|
attrs.onHandleChange?.(event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Avoid breaking internal v-model sync on user handler errors
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
fileList.value = event.fileList.filter(
|
fileList.value = event.fileList.filter(
|
||||||
@@ -480,28 +491,95 @@ const withPreviewUpload = () => {
|
|||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
event.fileList?.length ? fileList.value : undefined,
|
event.fileList?.length ? fileList.value : undefined,
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const handlePreview = async (file: UploadFile) => {
|
function handlePreview(file: UploadFile) {
|
||||||
previewVisible.value = true;
|
previewVisible.value = true;
|
||||||
await previewImage(file, previewVisible, fileList);
|
return previewImage(file, previewVisible, fileList);
|
||||||
};
|
}
|
||||||
|
|
||||||
const renderUploadButton = (): any => {
|
function renderUploadButton() {
|
||||||
const isDisabled = attrs.disabled;
|
if (attrs.disabled) return null;
|
||||||
|
return isEmpty(slots)
|
||||||
|
? createDefaultUploadSlots(listType, placeholder)
|
||||||
|
: slots;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果禁用,不渲染上传按钮
|
// 拖拽排序
|
||||||
if (isDisabled) {
|
const draggable = computed(
|
||||||
return null;
|
() => (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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 否则渲染默认上传按钮
|
const { initializeSortable } = useSortable(container, {
|
||||||
return isEmpty(slots)
|
animation: 300,
|
||||||
? createDefaultSlotsWithUpload(listType, placeholder)
|
delay: 400,
|
||||||
: slots;
|
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(
|
watch(
|
||||||
() => attrs.modelValue,
|
() => attrs.modelValue,
|
||||||
(res) => {
|
(res) => {
|
||||||
@@ -509,22 +587,32 @@ const withPreviewUpload = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(initSortable);
|
||||||
|
onUnmounted(() => {
|
||||||
|
sortableInstance.value?.destroy();
|
||||||
|
removeDragStyle();
|
||||||
|
});
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
h(
|
h(
|
||||||
Upload,
|
'div',
|
||||||
{
|
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||||
...props,
|
h(
|
||||||
...attrs,
|
Upload,
|
||||||
fileList: fileList.value,
|
{
|
||||||
beforeUpload: handleBeforeUpload,
|
...props,
|
||||||
onChange: handleChange,
|
...attrs,
|
||||||
onPreview: handlePreview,
|
fileList: fileList.value,
|
||||||
},
|
beforeUpload: handleBeforeUpload,
|
||||||
renderUploadButton(),
|
onChange: handleChange,
|
||||||
|
onPreview: handlePreview,
|
||||||
|
},
|
||||||
|
renderUploadButton() as any,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||||
export type ComponentType =
|
export type ComponentType =
|
||||||
@@ -535,6 +623,7 @@ export type ComponentType =
|
|||||||
| 'Cascader'
|
| 'Cascader'
|
||||||
| 'Checkbox'
|
| 'Checkbox'
|
||||||
| 'CheckboxGroup'
|
| 'CheckboxGroup'
|
||||||
|
| 'CollapsibleParams'
|
||||||
| 'DatePicker'
|
| 'DatePicker'
|
||||||
| 'DefaultButton'
|
| 'DefaultButton'
|
||||||
| 'Divider'
|
| 'Divider'
|
||||||
@@ -548,6 +637,7 @@ export type ComponentType =
|
|||||||
| 'RadioGroup'
|
| 'RadioGroup'
|
||||||
| 'RangePicker'
|
| 'RangePicker'
|
||||||
| 'Rate'
|
| 'Rate'
|
||||||
|
| 'RichEditor'
|
||||||
| 'Select'
|
| 'Select'
|
||||||
| 'Space'
|
| 'Space'
|
||||||
| 'Switch'
|
| 'Switch'
|
||||||
@@ -568,6 +658,7 @@ export interface ComponentPropsMap {
|
|||||||
Cascader: CascaderProps;
|
Cascader: CascaderProps;
|
||||||
Checkbox: CheckboxProps;
|
Checkbox: CheckboxProps;
|
||||||
CheckboxGroup: CheckboxGroupProps;
|
CheckboxGroup: CheckboxGroupProps;
|
||||||
|
CollapsibleParams: CollapsibleParamsProps;
|
||||||
DatePicker: DatePickerProps;
|
DatePicker: DatePickerProps;
|
||||||
DefaultButton: ButtonProps;
|
DefaultButton: ButtonProps;
|
||||||
Divider: DividerProps;
|
Divider: DividerProps;
|
||||||
@@ -581,6 +672,7 @@ export interface ComponentPropsMap {
|
|||||||
RadioGroup: RadioGroupProps;
|
RadioGroup: RadioGroupProps;
|
||||||
RangePicker: RangePickerProps;
|
RangePicker: RangePickerProps;
|
||||||
Rate: RateProps;
|
Rate: RateProps;
|
||||||
|
RichEditor: TipTapProps;
|
||||||
Select: SelectProps;
|
Select: SelectProps;
|
||||||
Space: SpaceProps;
|
Space: SpaceProps;
|
||||||
Switch: SwitchProps;
|
Switch: SwitchProps;
|
||||||
@@ -601,13 +693,13 @@ async function initComponentAdapter() {
|
|||||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
visibleEvent: 'onVisibleChange',
|
visibleEvent: 'onOpenChange',
|
||||||
}),
|
}),
|
||||||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
component: Select,
|
component: Select,
|
||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
visibleEvent: 'onVisibleChange',
|
visibleEvent: 'onOpenChange',
|
||||||
}),
|
}),
|
||||||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
component: TreeSelect,
|
component: TreeSelect,
|
||||||
@@ -615,7 +707,7 @@ async function initComponentAdapter() {
|
|||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
optionsPropName: 'treeData',
|
optionsPropName: 'treeData',
|
||||||
visibleEvent: 'onVisibleChange',
|
visibleEvent: 'onOpenChange',
|
||||||
}),
|
}),
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
Cascader,
|
Cascader,
|
||||||
@@ -633,7 +725,9 @@ async function initComponentAdapter() {
|
|||||||
modelValueProp: 'value',
|
modelValueProp: 'value',
|
||||||
}),
|
}),
|
||||||
Input: withDefaultPlaceholder(Input, 'input'),
|
Input: withDefaultPlaceholder(Input, 'input'),
|
||||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
|
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||||
|
style: { width: '100%' },
|
||||||
|
}),
|
||||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||||
// 自定义主要按钮
|
// 自定义主要按钮
|
||||||
@@ -644,6 +738,27 @@ async function initComponentAdapter() {
|
|||||||
RadioGroup,
|
RadioGroup,
|
||||||
RangePicker,
|
RangePicker,
|
||||||
Rate,
|
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'),
|
Select: withDefaultPlaceholder(Select, 'select'),
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -651,6 +766,7 @@ async function initComponentAdapter() {
|
|||||||
TimePicker,
|
TimePicker,
|
||||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||||
Upload: withPreviewUpload(),
|
Upload: withPreviewUpload(),
|
||||||
|
CollapsibleParams: VbenCollapsibleParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将组件注册到全局共享状态中
|
// 将组件注册到全局共享状态中
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './menu';
|
export * from './menu';
|
||||||
|
export * from './upload';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
|||||||
25
apps/web-antdv-next/src/api/core/upload.ts
Normal file
25
apps/web-antdv-next/src/api/core/upload.ts
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,7 @@ watch(
|
|||||||
description="ann.vben@gmail.com"
|
description="ann.vben@gmail.com"
|
||||||
tag-text="Pro"
|
tag-text="Pro"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #notification>
|
<template #notification>
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ const ElUpload = defineAsyncComponent(() =>
|
|||||||
]).then(([res]) => res.ElUpload),
|
]).then(([res]) => res.ElUpload),
|
||||||
);
|
);
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = (
|
||||||
component: T,
|
component: Component,
|
||||||
type: 'input' | 'select',
|
type: 'input' | 'select',
|
||||||
componentProps: Recordable<any> = {},
|
componentProps: Recordable<any> = {},
|
||||||
) => {
|
) => {
|
||||||
@@ -284,7 +284,9 @@ async function initComponentAdapter() {
|
|||||||
inputComponent: ElInput,
|
inputComponent: ElInput,
|
||||||
}),
|
}),
|
||||||
Input: withDefaultPlaceholder(ElInput, 'input'),
|
Input: withDefaultPlaceholder(ElInput, 'input'),
|
||||||
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
|
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input', {
|
||||||
|
style: { width: '100%' },
|
||||||
|
}),
|
||||||
RadioGroup: (props, { attrs, slots }) => {
|
RadioGroup: (props, { attrs, slots }) => {
|
||||||
let defaultSlot;
|
let defaultSlot;
|
||||||
if (Reflect.has(slots, 'default')) {
|
if (Reflect.has(slots, 'default')) {
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ watch(
|
|||||||
description="ann.vben@gmail.com"
|
description="ann.vben@gmail.com"
|
||||||
tag-text="Pro"
|
tag-text="Pro"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #notification>
|
<template #notification>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
"#/*": "./src/*"
|
"#/*": "./src/*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vben-core/shadcn-ui": "workspace:*",
|
|
||||||
"@vben/access": "workspace:*",
|
"@vben/access": "workspace:*",
|
||||||
"@vben/common-ui": "workspace:*",
|
"@vben/common-ui": "workspace:*",
|
||||||
"@vben/constants": "workspace:*",
|
"@vben/constants": "workspace:*",
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ const NUpload = defineAsyncComponent(() =>
|
|||||||
import('naive-ui/es/upload').then((res) => res.NUpload),
|
import('naive-ui/es/upload').then((res) => res.NUpload),
|
||||||
);
|
);
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = (
|
||||||
component: T,
|
component: Component,
|
||||||
type: 'input' | 'select',
|
type: 'input' | 'select',
|
||||||
componentProps: Recordable<any> = {},
|
componentProps: Recordable<any> = {},
|
||||||
) => {
|
) => {
|
||||||
@@ -225,7 +225,9 @@ async function initComponentAdapter() {
|
|||||||
inputComponent: NInput,
|
inputComponent: NInput,
|
||||||
}),
|
}),
|
||||||
Input: withDefaultPlaceholder(NInput, 'input'),
|
Input: withDefaultPlaceholder(NInput, 'input'),
|
||||||
InputNumber: withDefaultPlaceholder(NInputNumber, 'input'),
|
InputNumber: withDefaultPlaceholder(NInputNumber, 'input', {
|
||||||
|
style: { width: '100%' },
|
||||||
|
}),
|
||||||
RadioGroup: (props, { attrs, slots }) => {
|
RadioGroup: (props, { attrs, slots }) => {
|
||||||
let defaultSlot;
|
let defaultSlot;
|
||||||
if (Reflect.has(slots, 'default')) {
|
if (Reflect.has(slots, 'default')) {
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ watch(
|
|||||||
description="ann.vben@gmail.com"
|
description="ann.vben@gmail.com"
|
||||||
tag-text="Pro"
|
tag-text="Pro"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #notification>
|
<template #notification>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"naive": "Naive UI",
|
"naive": "Naive UI",
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
"form": "Form",
|
"form": "Form",
|
||||||
|
"arrayForm": "Array Editor",
|
||||||
"vben": {
|
"vben": {
|
||||||
"title": "Project",
|
"title": "Project",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"naive": "Naive UI",
|
"naive": "Naive UI",
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
"form": "表单",
|
"form": "表单",
|
||||||
|
"arrayForm": "数组编辑器",
|
||||||
"vben": {
|
"vben": {
|
||||||
"title": "项目",
|
"title": "项目",
|
||||||
"about": "关于",
|
"about": "关于",
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/demos/form',
|
path: '/demos/form',
|
||||||
component: () => import('#/views/demos/form/basic.vue'),
|
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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
123
apps/web-naive/src/views/demos/naive/array-form/index.vue
Normal file
123
apps/web-naive/src/views/demos/naive/array-form/index.vue
Normal 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>
|
||||||
@@ -89,8 +89,8 @@ const TreeSelect = defineAsyncComponent(
|
|||||||
);
|
);
|
||||||
const Upload = defineAsyncComponent(() => import('tdesign-vue-next/es/upload'));
|
const Upload = defineAsyncComponent(() => import('tdesign-vue-next/es/upload'));
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = (
|
||||||
component: T,
|
component: Component,
|
||||||
type: 'input' | 'select',
|
type: 'input' | 'select',
|
||||||
componentProps: Recordable<any> = {},
|
componentProps: Recordable<any> = {},
|
||||||
) => {
|
) => {
|
||||||
@@ -239,7 +239,9 @@ async function initComponentAdapter() {
|
|||||||
modelValueProp: 'value',
|
modelValueProp: 'value',
|
||||||
}),
|
}),
|
||||||
Input: withDefaultPlaceholder(Input, 'input'),
|
Input: withDefaultPlaceholder(Input, 'input'),
|
||||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
|
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||||
|
style: { width: '100%' },
|
||||||
|
}),
|
||||||
// InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
// InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||||
// Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
// Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||||
// 自定义主要按钮
|
// 自定义主要按钮
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { GlobalConfigProvider } from 'tdesign-vue-next';
|
|||||||
|
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
|
|
||||||
|
import { useTDesignDesignTokens } from '@vben/hooks';
|
||||||
import { usePreferences } from '@vben/preferences';
|
import { usePreferences } from '@vben/preferences';
|
||||||
|
|
||||||
import { merge } from 'es-toolkit/compat';
|
import { merge } from 'es-toolkit/compat';
|
||||||
@@ -12,10 +13,16 @@ import zhConfig from 'tdesign-vue-next/es/locale/zh_CN';
|
|||||||
defineOptions({ name: 'App' });
|
defineOptions({ name: 'App' });
|
||||||
const { isDark } = usePreferences();
|
const { isDark } = usePreferences();
|
||||||
|
|
||||||
|
// 将 Vben 设计系统的 CSS 变量适配到 TDesign 的设计变量上
|
||||||
|
useTDesignDesignTokens();
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => isDark.value,
|
() => isDark.value,
|
||||||
(dark) => {
|
(dark) => {
|
||||||
document.documentElement.setAttribute('theme-mode', dark ? 'dark' : '');
|
document.documentElement.setAttribute(
|
||||||
|
'theme-mode',
|
||||||
|
dark ? 'dark' : 'light',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
|||||||
import { preferences } from '@vben/preferences';
|
import { preferences } from '@vben/preferences';
|
||||||
import { initStores } from '@vben/stores';
|
import { initStores } from '@vben/stores';
|
||||||
import '@vben/styles';
|
import '@vben/styles';
|
||||||
// import '@vben/styles/antd';
|
|
||||||
// 引入组件库的少量全局样式变量
|
|
||||||
|
|
||||||
import { useTitle } from '@vueuse/core';
|
import { useTitle } from '@vueuse/core';
|
||||||
|
|
||||||
@@ -17,6 +15,7 @@ import { initSetupVbenForm } from './adapter/form';
|
|||||||
import App from './app.vue';
|
import App from './app.vue';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
|
|
||||||
|
// 引入组件库的少量全局样式变量
|
||||||
import 'tdesign-vue-next/es/style/index.css';
|
import 'tdesign-vue-next/es/style/index.css';
|
||||||
|
|
||||||
async function bootstrap(namespace: string) {
|
async function bootstrap(namespace: string) {
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ watch(
|
|||||||
description="ann.vben@gmail.com"
|
description="ann.vben@gmail.com"
|
||||||
tag-text="Pro"
|
tag-text="Pro"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #notification>
|
<template #notification>
|
||||||
|
|||||||
2
docs/.vitepress/build/dayjs-loader.mjs
Normal file
2
docs/.vitepress/build/dayjs-loader.mjs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { register } from 'node:module';
|
||||||
|
register('./dayjs-resolve-hook.mjs', import.meta.url);
|
||||||
10
docs/.vitepress/build/dayjs-resolve-hook.mjs
Normal file
10
docs/.vitepress/build/dayjs-resolve-hook.mjs
Normal 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);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { computed, ref, useSlots } from 'vue';
|
|||||||
|
|
||||||
import { VbenTooltip } from '@vben-core/shadcn-ui';
|
import { VbenTooltip } from '@vben-core/shadcn-ui';
|
||||||
|
|
||||||
import { Code } from 'lucide-vue-next';
|
import { Code } from '@lucide/vue';
|
||||||
import {
|
import {
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsIndicator,
|
TabsIndicator,
|
||||||
|
|||||||
@@ -198,6 +198,18 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
|
|||||||
link: 'common-ui/vben-ellipsis-text',
|
link: 'common-ui/vben-ellipsis-text',
|
||||||
text: 'EllipsisText',
|
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',
|
link: 'common-ui/vben-tiptap',
|
||||||
text: 'Tiptap RichTextEditor',
|
text: 'Tiptap RichTextEditor',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { withPwa } from '@vite-pwa/vitepress';
|
import { withPwa } from '@vite-pwa/vitepress';
|
||||||
import { defineConfigWithTheme } from 'vitepress';
|
import { defineConfig } from 'vitepress';
|
||||||
|
|
||||||
import { en } from './en.mts';
|
import { en } from './en.mts';
|
||||||
import { shared } from './shared.mts';
|
import { shared } from './shared.mts';
|
||||||
import { zh } from './zh.mts';
|
import { zh } from './zh.mts';
|
||||||
|
|
||||||
export default withPwa(
|
export default withPwa(
|
||||||
defineConfigWithTheme({
|
defineConfig({
|
||||||
...shared,
|
...shared,
|
||||||
locales: {
|
locales: {
|
||||||
en: {
|
en: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
viteArchiverPlugin,
|
viteArchiverPlugin,
|
||||||
|
viteDayjsPlugin,
|
||||||
viteVxeTableImportsPlugin,
|
viteVxeTableImportsPlugin,
|
||||||
} from '@vben/vite-config';
|
} from '@vben/vite-config';
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ export const shared = defineConfig({
|
|||||||
},
|
},
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
scss: {
|
scss: {
|
||||||
api: 'modern',
|
// api: 'modern',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -69,6 +70,7 @@ export const shared = defineConfig({
|
|||||||
stringify: true,
|
stringify: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
viteDayjsPlugin(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
GitChangelog({
|
GitChangelog({
|
||||||
mapAuthors: [
|
mapAuthors: [
|
||||||
@@ -85,9 +87,23 @@ export const shared = defineConfig({
|
|||||||
name: 'Li Kui',
|
name: 'Li Kui',
|
||||||
username: 'likui628',
|
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',
|
repoURL: () => 'https://github.com/vbenjs/vue-vben-admin',
|
||||||
}),
|
}) as any,
|
||||||
GitChangelogMarkdownSection(),
|
GitChangelogMarkdownSection(),
|
||||||
viteArchiverPlugin({ outputDir: '.vitepress' }),
|
viteArchiverPlugin({ outputDir: '.vitepress' }),
|
||||||
groupIconVitePlugin(),
|
groupIconVitePlugin(),
|
||||||
@@ -103,6 +119,7 @@ export const shared = defineConfig({
|
|||||||
|
|
||||||
ssr: {
|
ssr: {
|
||||||
external: ['@vue/repl'],
|
external: ['@vue/repl'],
|
||||||
|
noExternal: ['@v-c/picker'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -113,7 +130,7 @@ function head(): HeadConfig[] {
|
|||||||
[
|
[
|
||||||
'meta',
|
'meta',
|
||||||
{
|
{
|
||||||
content: 'vben, vitejs, vite, shacdn-ui, vue',
|
content: 'vben, vitejs, vite, shadcn-ui, vue',
|
||||||
name: 'keywords',
|
name: 'keywords',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -196,6 +196,18 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
|
|||||||
link: 'common-ui/vben-ellipsis-text',
|
link: 'common-ui/vben-ellipsis-text',
|
||||||
text: 'EllipsisText 省略文本',
|
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',
|
link: 'common-ui/vben-tiptap',
|
||||||
text: 'Tiptap 富文本编辑器',
|
text: 'Tiptap 富文本编辑器',
|
||||||
@@ -333,8 +345,8 @@ export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = {
|
|||||||
footer: {
|
footer: {
|
||||||
closeText: '关闭',
|
closeText: '关闭',
|
||||||
navigateText: '切换',
|
navigateText: '切换',
|
||||||
searchByText: '搜索提供者',
|
|
||||||
selectText: '选择',
|
selectText: '选择',
|
||||||
|
poweredByText: '搜索提供者',
|
||||||
},
|
},
|
||||||
noResultsScreen: {
|
noResultsScreen: {
|
||||||
noResultsText: '无法找到相关结果',
|
noResultsText: '无法找到相关结果',
|
||||||
@@ -343,10 +355,10 @@ export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = {
|
|||||||
suggestedQueryText: '你可以尝试查询',
|
suggestedQueryText: '你可以尝试查询',
|
||||||
},
|
},
|
||||||
searchBox: {
|
searchBox: {
|
||||||
cancelButtonAriaLabel: '取消',
|
closeButtonAriaLabel: '取消',
|
||||||
cancelButtonText: '取消',
|
closeButtonText: '取消',
|
||||||
resetButtonAriaLabel: '清除查询条件',
|
clearButtonAriaLabel: '清除查询条件',
|
||||||
resetButtonTitle: '清除查询条件',
|
clearButtonTitle: '清除查询条件',
|
||||||
},
|
},
|
||||||
startScreen: {
|
startScreen: {
|
||||||
favoriteSearchesTitle: '收藏',
|
favoriteSearchesTitle: '收藏',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
|
|
||||||
// import { useAntdDesignTokens } from '@vben/hooks';
|
// import { useAntdDesignTokens } from '@vben/hooks';
|
||||||
// import { initPreferences } from '@vben/preferences';
|
// import { initPreferences } from '@vben/preferences';
|
||||||
import { ConfigProvider, theme } from 'ant-design-vue';
|
import { ConfigProvider, theme } from 'antdv-next';
|
||||||
import mediumZoom from 'medium-zoom';
|
import mediumZoom from 'medium-zoom';
|
||||||
import { useRoute } from 'vitepress';
|
import { useRoute } from 'vitepress';
|
||||||
import DefaultTheme from 'vitepress/theme';
|
import DefaultTheme from 'vitepress/theme';
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
"name": "@vben/docs",
|
"name": "@vben/docs",
|
||||||
"version": "5.7.0",
|
"version": "5.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vitepress build",
|
"build": "cross-env NODE_OPTIONS=\"--import ./.vitepress/build/dayjs-loader.mjs\" vitepress build",
|
||||||
"dev": "vitepress dev",
|
"dev": "vitepress dev",
|
||||||
"docs:preview": "vitepress preview"
|
"docs:preview": "vitepress preview"
|
||||||
},
|
},
|
||||||
@@ -14,13 +15,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lucide/vue": "catalog:",
|
||||||
"@vben-core/shadcn-ui": "workspace:*",
|
"@vben-core/shadcn-ui": "workspace:*",
|
||||||
"@vben/common-ui": "workspace:*",
|
"@vben/common-ui": "workspace:*",
|
||||||
"@vben/locales": "workspace:*",
|
"@vben/locales": "workspace:*",
|
||||||
"@vben/plugins": "workspace:*",
|
"@vben/plugins": "workspace:*",
|
||||||
"@vben/styles": "workspace:*",
|
"@vben/styles": "workspace:*",
|
||||||
"ant-design-vue": "catalog:",
|
"antdv-next": "catalog:",
|
||||||
"lucide-vue-next": "catalog:",
|
|
||||||
"medium-zoom": "catalog:",
|
"medium-zoom": "catalog:",
|
||||||
"reka-ui": "catalog:",
|
"reka-ui": "catalog:",
|
||||||
"vitepress-plugin-group-icons": "catalog:"
|
"vitepress-plugin-group-icons": "catalog:"
|
||||||
|
|||||||
@@ -26,16 +26,15 @@ import {
|
|||||||
notification,
|
notification,
|
||||||
Radio,
|
Radio,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
RangePicker,
|
|
||||||
Rate,
|
Rate,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Textarea,
|
TextArea,
|
||||||
TimePicker,
|
TimePicker,
|
||||||
TreeSelect,
|
TreeSelect,
|
||||||
Upload,
|
Upload,
|
||||||
} from 'ant-design-vue';
|
} from 'antdv-next';
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = <T extends Component>(
|
||||||
component: T,
|
component: T,
|
||||||
@@ -98,12 +97,12 @@ async function initComponentAdapter() {
|
|||||||
},
|
},
|
||||||
Radio,
|
Radio,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
RangePicker,
|
RangePicker: withDefaultPlaceholder(DatePicker.RangePicker, 'select'),
|
||||||
Rate,
|
Rate,
|
||||||
Select: withDefaultPlaceholder(Select, 'select'),
|
Select: withDefaultPlaceholder(Select, 'select'),
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
Textarea: withDefaultPlaceholder(TextArea, 'input'),
|
||||||
TimePicker,
|
TimePicker,
|
||||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||||
Upload,
|
Upload,
|
||||||
@@ -118,7 +117,7 @@ async function initComponentAdapter() {
|
|||||||
copyPreferencesSuccess: (title, content) => {
|
copyPreferencesSuccess: (title, content) => {
|
||||||
notification.success({
|
notification.success({
|
||||||
description: content,
|
description: content,
|
||||||
message: title,
|
title,
|
||||||
placement: 'bottomRight',
|
placement: 'bottomRight',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { h } from 'vue';
|
|||||||
|
|
||||||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
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';
|
import { useVbenForm } from './form';
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ outline: deep
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ApiComponent } from '@vben/common-ui';
|
import { ApiComponent } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Cascader } from 'ant-design-vue';
|
import { Cascader } from 'antdv-next';
|
||||||
|
|
||||||
const treeData: Record<string, any> = [
|
const treeData: Record<string, any> = [
|
||||||
{
|
{
|
||||||
|
|||||||
172
docs/src/components/common-ui/vben-cropper.md
Normal file
172
docs/src/components/common-ui/vben-cropper.md
Normal 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>
|
||||||
|
```
|
||||||
102
docs/src/components/common-ui/vben-descriptions.md
Normal file
102
docs/src/components/common-ui/vben-descriptions.md
Normal 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)。:::
|
||||||
@@ -117,7 +117,7 @@ import {
|
|||||||
TimePicker,
|
TimePicker,
|
||||||
TreeSelect,
|
TreeSelect,
|
||||||
Upload,
|
Upload,
|
||||||
} from 'ant-design-vue';
|
} from 'antdv-next';
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = <T extends Component>(
|
||||||
component: T,
|
component: T,
|
||||||
|
|||||||
165
docs/src/components/common-ui/vben-table-action.md
Normal file
165
docs/src/components/common-ui/vben-table-action.md
Normal 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` | - |
|
||||||
@@ -25,7 +25,7 @@ import { h } from 'vue';
|
|||||||
|
|
||||||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
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';
|
import { useVbenForm } from './form';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { h } from 'vue';
|
|||||||
|
|
||||||
import { alert, VbenButton } from '@vben/common-ui';
|
import { alert, VbenButton } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Result } from 'ant-design-vue';
|
import { Result } from 'antdv-next';
|
||||||
|
|
||||||
function showAlert() {
|
function showAlert() {
|
||||||
alert('This is an alert message');
|
alert('This is an alert message');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { h, ref } from 'vue';
|
|||||||
|
|
||||||
import { alert, confirm, VbenButton } from '@vben/common-ui';
|
import { alert, confirm, VbenButton } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Checkbox, message } from 'ant-design-vue';
|
import { Checkbox, message } from 'antdv-next';
|
||||||
|
|
||||||
function showConfirm() {
|
function showConfirm() {
|
||||||
confirm('This is an alert message')
|
confirm('This is an alert message')
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { h } from 'vue';
|
|||||||
|
|
||||||
import { alert, prompt, useAlertContext, VbenButton } from '@vben/common-ui';
|
import { alert, prompt, useAlertContext, VbenButton } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Input, RadioGroup, Select } from 'ant-design-vue';
|
import { BadgeJapaneseYen } from '@lucide/vue';
|
||||||
import { BadgeJapaneseYen } from 'lucide-vue-next';
|
import { Input, RadioGroup, Select } from 'antdv-next';
|
||||||
|
|
||||||
function showPrompt() {
|
function showPrompt() {
|
||||||
prompt({
|
prompt({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ApiComponent } from '@vben/common-ui';
|
import { ApiComponent } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Cascader } from 'ant-design-vue';
|
import { Cascader } from 'antdv-next';
|
||||||
|
|
||||||
const treeData: Record<string, any> = [
|
const treeData: Record<string, any> = [
|
||||||
{
|
{
|
||||||
|
|||||||
102
docs/src/demos/vben-cropper/aspect-ratio/index.vue
Normal file
102
docs/src/demos/vben-cropper/aspect-ratio/index.vue
Normal 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>
|
||||||
70
docs/src/demos/vben-cropper/basic/index.vue
Normal file
70
docs/src/demos/vben-cropper/basic/index.vue
Normal 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>
|
||||||
18
docs/src/demos/vben-descriptions/basic/index.vue
Normal file
18
docs/src/demos/vben-descriptions/basic/index.vue
Normal 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>
|
||||||
22
docs/src/demos/vben-descriptions/bordered/index.vue
Normal file
22
docs/src/demos/vben-descriptions/bordered/index.vue
Normal 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>
|
||||||
17
docs/src/demos/vben-descriptions/custom/index.vue
Normal file
17
docs/src/demos/vben-descriptions/custom/index.vue
Normal 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>
|
||||||
35
docs/src/demos/vben-descriptions/size/index.vue
Normal file
35
docs/src/demos/vben-descriptions/size/index.vue
Normal 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>
|
||||||
15
docs/src/demos/vben-descriptions/span/index.vue
Normal file
15
docs/src/demos/vben-descriptions/span/index.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { VbenDescriptions } from '@vben/common-ui';
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ content: '1', label: 'A' },
|
||||||
|
{ content: '2(span: 2)', label: 'B', span: 2 },
|
||||||
|
{ content: '3', label: 'C' },
|
||||||
|
{ content: '占满当前行剩余空间', label: 'D(span: 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>
|
||||||
13
docs/src/demos/vben-descriptions/vertical/index.vue
Normal file
13
docs/src/demos/vben-descriptions/vertical/index.vue
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Button, message, Space } from 'ant-design-vue';
|
import { Button, message, Space } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
import { Input, message } from 'ant-design-vue';
|
import { Input, message } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenForm, z } from '#/adapter/form';
|
import { useVbenForm, z } from '#/adapter/form';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
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';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
|||||||
28
docs/src/demos/vben-table-action/basic/index.vue
Normal file
28
docs/src/demos/vben-table-action/basic/index.vue
Normal 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>
|
||||||
44
docs/src/demos/vben-table-action/dropdown/index.vue
Normal file
44
docs/src/demos/vben-table-action/dropdown/index.vue
Normal 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>
|
||||||
28
docs/src/demos/vben-table-action/permission/index.vue
Normal file
28
docs/src/demos/vben-table-action/permission/index.vue
Normal 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>
|
||||||
32
docs/src/demos/vben-table-action/popconfirm/index.vue
Normal file
32
docs/src/demos/vben-table-action/popconfirm/index.vue
Normal 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>
|
||||||
17
docs/src/demos/vben-table-action/tooltip/index.vue
Normal file
17
docs/src/demos/vben-table-action/tooltip/index.vue
Normal 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>
|
||||||
@@ -11,10 +11,9 @@ const content = ref('<p>开始编辑你的内容...</p>');
|
|||||||
<VbenTiptap v-model="content" />
|
<VbenTiptap v-model="content" />
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="text-sm text-gray-500">当前内容:</p>
|
<p class="text-sm text-gray-500">当前内容:</p>
|
||||||
<pre
|
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto max-h-40">
|
||||||
class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto max-h-40"
|
{{ content }}
|
||||||
>{{ content }}</pre
|
</pre>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { VxeGridListeners, VxeGridProps } from '#/adapter/vxe-table';
|
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';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
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';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
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';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
import { Button } from 'ant-design-vue';
|
import { Button } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { VbenFormProps } from '#/adapter/form';
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { DemoTableApi } from '../mock-api';
|
|||||||
|
|
||||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
import { Button } from 'ant-design-vue';
|
import { Button } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
import { Button } from 'ant-design-vue';
|
import { Button } from 'antdv-next';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ The current wrapper flow is:
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ApiComponent } from '@vben/common-ui';
|
import { ApiComponent } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Cascader } from 'ant-design-vue';
|
import { Cascader } from 'antdv-next';
|
||||||
|
|
||||||
function fetchApi() {
|
function fetchApi() {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
|
|||||||
159
docs/src/en/components/common-ui/vben-cropper.md
Normal file
159
docs/src/en/components/common-ui/vben-cropper.md
Normal 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>
|
||||||
|
```
|
||||||
102
docs/src/en/components/common-ui/vben-descriptions.md
Normal file
102
docs/src/en/components/common-ui/vben-descriptions.md
Normal 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). :::
|
||||||
@@ -103,7 +103,7 @@ import {
|
|||||||
TimePicker,
|
TimePicker,
|
||||||
TreeSelect,
|
TreeSelect,
|
||||||
Upload,
|
Upload,
|
||||||
} from 'ant-design-vue';
|
} from 'antdv-next';
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = <T extends Component>(
|
||||||
component: T,
|
component: T,
|
||||||
|
|||||||
165
docs/src/en/components/common-ui/vben-table-action.md
Normal file
165
docs/src/en/components/common-ui/vben-table-action.md
Normal 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` | - |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# External Modules
|
# 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
|
## Installing Dependencies
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ In addition to the external modules that are included by default in the project,
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# cd /path/to/your/package
|
# cd /path/to/your/package
|
||||||
pnpm add ant-design-vue
|
pnpm add antdv-next
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -22,9 +22,9 @@ pnpm add ant-design-vue
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import Antd from 'ant-design-vue';
|
import Antd from 'antdv-next';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import 'ant-design-vue/dist/reset.css';
|
import 'antdv-next/dist/reset.css';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ app.use(Antd).mount('#app');
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from 'ant-design-vue';
|
import { Button } from 'antdv-next';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
|
|||||||
|
|
||||||
::: tip How to Dynamically Modify API Endpoint in Production
|
::: 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';
|
} from '@vben/request';
|
||||||
import { useAccessStore } from '@vben/stores';
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
import { useAuthStore } from '#/store';
|
import { useAuthStore } from '#/store';
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The rules are consistent with [Vite Env Variables and Modes](https://vitejs.dev/
|
|||||||
console.log(import.meta.env.VITE_PROT);
|
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
|
## 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
|
```ts
|
||||||
window._VBEN_ADMIN_PRO_APP_CONF_ = {
|
window._VBEN_ADMIN_PRO_APP_CONF_ = {
|
||||||
@@ -104,11 +104,11 @@ Object.defineProperty(window, '_VBEN_ADMIN_PRO_APP_CONF_', {
|
|||||||
|
|
||||||
### Purpose
|
### 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
|
### 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
|
```ts
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ outline: deep
|
|||||||
|
|
||||||
Before starting the project, ensure that your environment meets the following requirements:
|
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.
|
- [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:
|
To verify if your environment meets the above requirements, you can check the versions using the following commands:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
## Browser Support
|
## 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.
|
- **Production environment** supports modern browsers, IE is not supported.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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
|
```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
|
## Console Route Warning Issue
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 外部模块
|
# 外部模块
|
||||||
|
|
||||||
除了项目默认引入的外部模块,有时我们还需要引入其他外部模块。我们以 [ant-design-vue](https://antdv.com/components/overview) 为例:
|
除了项目默认引入的外部模块,有时我们还需要引入其他外部模块。我们以 [antdv-next](https://antdv.com/components/overview) 为例:
|
||||||
|
|
||||||
## 安装依赖
|
## 安装依赖
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# cd /path/to/your/package
|
# cd /path/to/your/package
|
||||||
pnpm add ant-design-vue
|
pnpm add antdv-next
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
@@ -22,9 +22,9 @@ pnpm add ant-design-vue
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import Antd from 'ant-design-vue';
|
import Antd from 'antdv-next';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import 'ant-design-vue/dist/reset.css';
|
import 'antdv-next/dist/reset.css';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ app.use(Antd).mount('#app');
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from 'ant-design-vue';
|
import { Button } from 'antdv-next';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
|
|||||||
|
|
||||||
::: tip 打包如何动态修改接口地址
|
::: 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';
|
} from '@vben/request';
|
||||||
import { useAccessStore } from '@vben/stores';
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
import { useAuthStore } from '#/store';
|
import { useAuthStore } from '#/store';
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
console.log(import.meta.env.VITE_PROT);
|
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
|
```ts
|
||||||
window._VBEN_ADMIN_PRO_APP_CONF_ = {
|
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
|
```ts
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
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
Reference in New Issue
Block a user