pull/355/head^2
xingyu4j 2026-06-05 19:03:00 +08:00
commit 0483d5cd8b
249 changed files with 7479 additions and 4465 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -196,6 +196,14 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
link: 'common-ui/vben-ellipsis-text',
text: 'EllipsisText 省略文本',
},
{
link: 'common-ui/vben-descriptions',
text: 'Descriptions 描述列表',
},
{
link: 'common-ui/vben-table-action',
text: 'TableAction 表格操作',
},
{
link: 'common-ui/vben-cropper',
text: 'Cropper 图片裁剪',

View File

@ -15,13 +15,13 @@
}
},
"dependencies": {
"@lucide/vue": "catalog:",
"@vben-core/shadcn-ui": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/styles": "workspace:*",
"antdv-next": "catalog:",
"lucide-vue-next": "catalog:",
"medium-zoom": "catalog:",
"reka-ui": "catalog:",
"vitepress-plugin-group-icons": "catalog:"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@
## 浏览器支持
- **本地开发**推荐使用`Chrome 最新版`浏览器,**不支持**`Chrome 80`以下版本
- **本地开发**推荐使用`Chrome 最新版`浏览器,**不支持**` Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
- **生产环境**支持现代浏览器,不支持 IE。

View File

@ -0,0 +1,6 @@
// eslint-disable-next-line n/no-extraneous-import
import type { UserConfig } from '@commitlint/types';
declare const userConfig: UserConfig;
export default userConfig;

View File

@ -18,6 +18,7 @@
"module": "./index.mjs",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.mjs",
"default": "./index.mjs"
}

View File

@ -32,7 +32,7 @@ export async function node(): Promise<Linter.Config[]> {
'error',
{
ignores: [],
version: '>=20.12.0',
version: '>=22.18.0',
},
],
'n/prefer-global/buffer': ['error', 'never'],

View File

@ -13,7 +13,7 @@ interface PluginOptions {
root: string;
}
const GLOBAL_CONFIG_FILE_NAME = '_app.config.js';
const GLOBAL_CONFIG_FILE_NAME = '_app-config';
const VBEN_ADMIN_PRO_APP_CONF = '_VBEN_ADMIN_PRO_APP_CONF_';
/**
@ -27,6 +27,7 @@ async function viteExtraAppConfigPlugin({
}: PluginOptions): Promise<PluginOption | undefined> {
let publicPath: string;
let source: string;
let hash: string;
if (!isBuild) {
return;
@ -38,11 +39,12 @@ async function viteExtraAppConfigPlugin({
async configResolved(config) {
publicPath = ensureTrailingSlash(config.base);
source = await getConfigSource();
hash = generatorContentHash(source, 8);
},
async generateBundle() {
try {
this.emitFile({
fileName: GLOBAL_CONFIG_FILE_NAME,
fileName: `${GLOBAL_CONFIG_FILE_NAME}-${version}-${hash}.js`,
source,
type: 'asset',
});
@ -58,9 +60,7 @@ async function viteExtraAppConfigPlugin({
},
name: 'vite:extra-app-config',
async transformIndexHtml(html) {
const hash = `v=${version}-${generatorContentHash(source, 8)}`;
const appConfigSrc = `${publicPath}${GLOBAL_CONFIG_FILE_NAME}?${hash}`;
const appConfigSrc = `${publicPath}${GLOBAL_CONFIG_FILE_NAME}-${version}-${hash}.js`;
return {
html,

View File

@ -105,5 +105,5 @@
"node": "^22.18.0 || ^24.0.0",
"pnpm": ">=11.0.0"
},
"packageManager": "pnpm@11.2.2"
"packageManager": "pnpm@11.5.0"
}

View File

@ -36,7 +36,7 @@
},
"dependencies": {
"@iconify/vue": "catalog:",
"lucide-vue-next": "catalog:",
"@lucide/vue": "catalog:",
"vue": "catalog:"
}
}

View File

@ -36,7 +36,6 @@ export {
EyeOff,
FoldHorizontal,
Fullscreen,
Github,
Grid,
Grip,
GripVertical,
@ -99,4 +98,4 @@ export {
Upload,
UserRoundPen,
X,
} from 'lucide-vue-next';
} from '@lucide/vue';

View File

@ -28,3 +28,21 @@ it('updateCSSVariables should update CSS variables in :root selector', () => {
updatedStyleContent?.includes('fontSize: 16px;'),
).toBe(true);
});
it('updateCSSVariables should support a custom selector', () => {
document.head.innerHTML = `<style id="tdesign-styles"></style>`;
// 使用自定义选择器(如 TDesign 的 theme-mode 选择器)更新 CSS 变量
updateCSSVariables(
{ '--td-brand-color': 'rgb(0, 82, 217)' },
'tdesign-styles',
":root[theme-mode='dark']",
);
const styleElement = document.querySelector('#tdesign-styles');
const content = styleElement?.textContent ?? '';
// 选择器与变量都应正确写入
expect(content.startsWith(":root[theme-mode='dark'] {")).toBe(true);
expect(content.includes('--td-brand-color: rgb(0, 82, 217);')).toBe(true);
});

View File

@ -1,10 +1,15 @@
/**
* CSS
* @param variables CSS
* @param id id便
* @param selector CSS `:root`
* TDesign `:root[theme-mode='dark']`
*
*/
function updateCSSVariables(
variables: { [key: string]: string },
id = '__vben-styles__',
selector = ':root',
): void {
// 获取或创建内联样式表元素
const styleElement =
@ -13,7 +18,7 @@ function updateCSSVariables(
styleElement.id = id;
// 构建要更新的 CSS 变量的样式文本
let cssText = ':root {';
let cssText = `${selector} {`;
for (const key in variables) {
if (Object.prototype.hasOwnProperty.call(variables, key)) {
cssText += `${key}: ${variables[key]};`;

View File

@ -9,6 +9,7 @@ export const messages: Record<Locale, Record<string, string>> = {
prompt: 'Prompt',
reset: 'Reset',
submit: 'Submit',
confirmTitle: 'Please Confirm',
},
'zh-CN': {
cancel: '取消',
@ -18,6 +19,7 @@ export const messages: Record<Locale, Record<string, string>> = {
prompt: '提示',
reset: '重置',
submit: '提交',
confirmTitle: '请确认',
},
};

View File

@ -0,0 +1,177 @@
<script setup lang="ts">
import type { FormSchema } from '../types';
import { computed } from 'vue';
import { Plus, X } from '@vben-core/icons';
import {
VbenButton,
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { useFieldArray } from 'vee-validate';
import FormField from '../form-render/form-field.vue';
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/**
* 新增一行时生成的默认数据缺省时按 schema fieldName 生成空对象
*/
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/**
* 字段路径由外层 FormField 通过 componentField 透传vee-validate name
*/
name?: string;
/**
* 列定义每一列就是一个子字段复用 FormSchema
*/
schema?: FormSchema[];
/** 是否显示序号列 */
showIndex?: boolean;
}>(),
{
actionText: '操作',
addButtonText: '添加一行',
createRow: undefined,
disabled: false,
emptyText: '暂无数据',
max: Number.POSITIVE_INFINITY,
min: 0,
name: '',
schema: () => [],
showIndex: true,
},
);
const arrayPath = computed(() => props.name);
const { fields, push, remove } = useFieldArray<Record<string, any>>(
() => arrayPath.value,
);
const canAdd = computed(() => fields.value.length < props.max);
const canRemove = computed(() => fields.value.length > props.min);
function buildDefaultRow(): Record<string, any> {
if (props.createRow) {
return props.createRow();
}
return Object.fromEntries(props.schema.map((col) => [col.fieldName, null]));
}
function addRow() {
if (props.disabled || !canAdd.value) {
return;
}
push(buildDefaultRow());
}
function removeRow(index: number) {
if (props.disabled || !canRemove.value) {
return;
}
remove(index);
}
/**
* 把列定义转换为子单元格 FormField 所需的 props
* - fieldName 替换为嵌套路径 `name[index].fieldName`让校验与取值落在数组元素上
* - hideLabel表头已展示列名单元格不重复显示
*/
function cellProps(col: FormSchema, index: number) {
return {
...col,
commonComponentProps: {},
disabled: props.disabled,
fieldName: `${arrayPath.value}[${index}].${col.fieldName}`,
formFieldProps: {},
hideLabel: true,
};
}
</script>
<template>
<div :class="cn('w-full', $attrs.class as string)">
<table class="w-full border-collapse">
<thead>
<tr class="border-border border-b">
<th
v-if="showIndex"
class="text-muted-foreground w-12 px-2 py-2 text-left text-sm font-normal"
>
#
</th>
<th
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</th>
<th
class="text-muted-foreground w-16 px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, index) in fields"
:key="entry.key"
class="border-border/60 border-b align-top"
>
<td v-if="showIndex" class="text-muted-foreground px-2 py-3 text-sm">
{{ index + 1 }}
</td>
<td v-for="col in schema" :key="col.fieldName" class="px-2 py-2">
<FormField v-bind="cellProps(col, index)" />
</td>
<td class="px-2 py-3">
<VbenIconButton
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</td>
</tr>
</tbody>
</table>
<div
v-if="fields.length === 0"
class="text-muted-foreground border-border/60 border-b py-6 text-center text-sm"
>
{{ emptyText }}
</div>
<VbenButton
variant="outline"
size="sm"
:disabled="disabled || !canAdd"
class="mt-3 w-full border-dashed"
@click="addRow"
>
<Plus class="mr-1 size-4" />
{{ addButtonText }}
</VbenButton>
</div>
</template>

View File

@ -20,6 +20,8 @@ import { globalShareState } from '@vben-core/shared/global-state';
import { defineRule } from 'vee-validate';
import VbenFormFieldArray from './components/form-field-array.vue';
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
@ -28,6 +30,7 @@ export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
VbenCheckbox,
VbenFormFieldArray,
VbenInput,
VbenInputPassword,
VbenPinInput,

View File

@ -4,6 +4,7 @@ export type {
BaseFormComponentType,
ExtendedFormApi,
FormLayout,
VbenFormFieldArrayProps,
VbenFormProps,
FormSchema as VbenFormSchema,
} from './types';

View File

@ -14,6 +14,7 @@ export type BaseFormComponentType =
| 'DefaultButton'
| 'PrimaryButton'
| 'VbenCheckbox'
| 'VbenFormFieldArray'
| 'VbenInput'
| 'VbenInputPassword'
| 'VbenPinInput'
@ -309,6 +310,32 @@ export type FormSchema<
P extends Record<string, any> = Record<never, never>,
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
/**
* VbenFormFieldArray
*/
export interface VbenFormFieldArrayProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> {
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 列定义,每一列是一个子字段(复用 FormSchema */
schema: FormSchema<T, P>[];
/** 是否显示序号列 */
showIndex?: boolean;
}
export type HandleSubmitFn = (
values: Record<string, any>,
) => Promise<void> | void;

View File

@ -6,7 +6,7 @@ import type {
import { defineComponent, h, isReactive, onBeforeUnmount, watch } from 'vue';
import { useStore } from '@vben-core/shared/store';
import { useSelector } from '@vben-core/shared/store';
import { FormApi } from './form-api';
import VbenUseForm from './vben-use-form.vue';
@ -19,7 +19,7 @@ export function useVbenForm<
const api = new FormApi(options as unknown as VbenFormProps);
const extendedApi: ExtendedFormApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
return useSelector(api.store, selector);
};
const Form = defineComponent(

View File

@ -37,7 +37,7 @@ const menuIcon = computed(() =>
const isHttp = computed(() => isHttpUrl(item.parentPaths.at(-1)));
const isTopLevelMenuItem = computed(
() => parentMenu.value?.type.name === 'Menu',
() => parentMenu.value?.type.name === 'MenuUI',
);
const collapseShowTitle = computed(

View File

@ -37,7 +37,7 @@ import SubMenu from './sub-menu.vue';
interface Props extends MenuProps {}
defineOptions({ name: 'Menu' });
defineOptions({ name: 'MenuUI' });
const props = withDefaults(defineProps<Props>(), {
accordion: true,

View File

@ -54,7 +54,7 @@ const opened = computed(() => {
return rootMenu?.openedMenus.includes(props.path);
});
const isTopLevelMenuSubmenu = computed(
() => parentMenu.value?.type.name === 'Menu',
() => parentMenu.value?.type.name === 'MenuUI',
);
const mode = computed(() => rootMenu?.props.mode ?? 'vertical');
const rounded = computed(() => rootMenu?.props.rounded);

View File

@ -42,7 +42,7 @@ function useSubMenuContext() {
if (!instance) {
throw new Error('instance is required');
}
const parentMenu = findComponentUpward(instance, ['Menu', 'SubMenu']);
const parentMenu = findComponentUpward(instance, ['MenuUI', 'SubMenu']);
const subMenu = inject(`subMenu:${parentMenu?.uid}`) as SubMenuProvider;
return subMenu;
}

View File

@ -16,7 +16,7 @@ function useMenu() {
const parentPaths = computed(() => {
let parent = instance.parent;
const paths: string[] = [instance.props.path as string];
while (parent?.type.name !== 'Menu') {
while (parent?.type.name !== 'MenuUI') {
if (parent?.props.path) {
paths.unshift(parent.props.path as string);
}
@ -27,7 +27,7 @@ function useMenu() {
});
const parentMenu = computed(() => {
return findComponentUpward(instance, ['Menu', 'SubMenu']);
return findComponentUpward(instance, ['MenuUI', 'SubMenu']);
});
return {

View File

@ -156,7 +156,7 @@ async function handleOpenChange(val: boolean) {
:class="
cn(
containerClass,
'inset-x-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:w-130 sm:max-w-[80%] sm:rounded-(--radius)',
'flex max-h-[80%] flex-col p-0 duration-300 sm:w-130 sm:max-w-[80%] sm:rounded-(--radius)',
{
'border border-border': bordered,
'shadow-3xl': !bordered,
@ -197,7 +197,7 @@ async function handleOpenChange(val: boolean) {
<component
:is="components.DefaultButton || VbenButton"
:disabled="loading"
variant="ghost"
variant="outline"
@click="handleCancel"
>
{{ cancelText || $t('cancel') }}

View File

@ -54,7 +54,8 @@ const components = globalShareState.getComponents();
const id = useId();
provide('DISMISSABLE_DRAWER_ID', id);
// const wrapperRef = ref<HTMLElement>();
// @ts-expect-error unused
const wrapperRef = ref<HTMLElement>();
const { $t } = useSimpleLocale();
const { isMobile } = useIsMobile();
@ -285,8 +286,8 @@ const getForceMount = computed(() => {
<SheetDescription />
</VisuallyHidden>
</template>
<!-- 注释掉的部分 <div ref="wrapperRef" -->
<div
ref="wrapperRef"
:class="
cn('relative flex-1 overflow-y-auto p-3', contentClass, {
'pointer-events-none': showLoading || submitting,

View File

@ -15,7 +15,7 @@ import {
} from 'vue';
import { usePreferences } from '@vben-core/preferences';
import { useStore } from '@vben-core/shared/store';
import { useSelector } from '@vben-core/shared/store';
import { DrawerApi } from './drawer-api';
import VbenDrawer from './drawer.vue';
@ -55,7 +55,7 @@ export function useVbenDrawer<
// 不能用 Object.assign,会丢失 api 的原型函数
Object.setPrototypeOf(extendedApi, api);
},
defaultOptions,
options: defaultOptions,
async reCreateDrawer() {
isDrawerReady.value = false;
await nextTick();
@ -109,7 +109,7 @@ export function useVbenDrawer<
const extendedApi: ExtendedDrawerApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
return useSelector(api.store, selector);
};
const Drawer = defineComponent(

View File

@ -1,22 +1,9 @@
<script lang="ts" setup>
import type { ExtendedModalApi, ModalProps } from './modal';
import {
computed,
nextTick,
onDeactivated,
provide,
ref,
unref,
useId,
watch,
} from 'vue';
import { computed, nextTick, onDeactivated, ref, unref, watch } from 'vue';
import {
useIsMobile,
usePriorityValues,
useSimpleLocale,
} from '@vben-core/composables';
import { usePriorityValues, useSimpleLocale } from '@vben-core/composables';
import { Expand, Shrink } from '@vben-core/icons';
import {
Dialog,
@ -57,12 +44,7 @@ const headerRef = ref();
// @ts-expect-error unused
const footerRef = ref();
const id = useId();
provide('DISMISSABLE_MODAL_ID', id);
const { $t } = useSimpleLocale();
const { isMobile } = useIsMobile();
const state = props.modalApi?.useStore?.();
const {
@ -101,7 +83,7 @@ const {
zIndex,
} = usePriorityValues(props, state);
const shouldFullscreen = computed(() => fullscreen.value || isMobile.value);
const shouldFullscreen = computed(() => fullscreen.value);
const shouldDraggable = computed(
() => draggable.value && !shouldFullscreen.value && header.value,
@ -199,15 +181,8 @@ function handleOpenAutoFocus(e: Event) {
// pointer-down-outside
function pointerDownOutside(e: Event) {
const target = e.target as HTMLElement;
const isDismissableModal = target?.dataset.dismissableModal;
if (
!closeOnClickModal.value ||
isDismissableModal !== id ||
submitting.value
) {
if (!closeOnClickModal.value || submitting.value) {
e.preventDefault();
e.stopPropagation();
}
}
@ -216,6 +191,10 @@ function handleFocusOutside(e: Event) {
e.stopPropagation();
}
function handleCloseAutoFocus(_e: Event) {
// allow reka-ui to return focus to the trigger element on close
}
const getForceMount = computed(() => {
return !unref(destroyOnClose) && unref(firstOpened);
});
@ -233,7 +212,7 @@ function handleClosed() {
</script>
<template>
<Dialog
:modal="false"
:modal="modal"
:open="state?.isOpen"
@update:open="() => (!submitting ? modalApi?.close() : undefined)"
>
@ -242,13 +221,15 @@ function handleClosed() {
:append-to="getAppendTo"
:class="
cn(
'inset-x-0 top-[10vh] mx-auto flex max-h-[80%] w-130 flex-col p-0',
shouldFullscreen ? 'sm:rounded-none' : 'sm:rounded-(--radius)',
'inset-x-0 top-[10vh] mx-auto flex w-130 flex-col p-0',
shouldFullscreen ? 'rounded-none' : 'rounded-(--radius)',
modalClass,
{
'border border-border': bordered,
'shadow-3xl': !bordered,
'top-0 left-0 size-full max-h-full transform-[translate(0,0)]!':
'max-h-[min(80%,calc(100dvh-20px))] max-w-[calc(100vw-20px)]':
!shouldFullscreen,
'top-0 left-0 size-full max-h-full max-w-full transform-[translate(0,0)]!':
shouldFullscreen,
'top-1/2': centered && !shouldFullscreen,
'duration-300': !dragging,
@ -264,7 +245,7 @@ function handleClosed() {
:z-index="zIndex"
:overlay-blur="overlayBlur"
close-class="top-3"
@close-auto-focus="handleFocusOutside"
@close-auto-focus="handleCloseAutoFocus"
@closed="handleClosed"
:close-disabled="submitting"
@escape-key-down="escapeKeyDown"
@ -322,7 +303,7 @@ function handleClosed() {
<VbenLoading v-if="showLoading || submitting" spinning />
<VbenIconButton
v-if="fullscreenButton"
class="absolute top-3 right-10 flex-center hidden size-6 rounded-full px-1 text-lg text-foreground/80 opacity-70 transition-opacity hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus:outline-hidden disabled:pointer-events-none sm:block"
class="absolute top-3 right-10 flex-center size-6 rounded-full px-1 text-lg text-foreground/80 opacity-70 transition-opacity hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus:outline-hidden disabled:pointer-events-none"
@click="handleFullscreen"
>
<Shrink v-if="fullscreen" class="size-3.5" />
@ -347,7 +328,7 @@ function handleClosed() {
<component
:is="components.DefaultButton || VbenButton"
v-if="showCancelButton"
variant="ghost"
variant="outline"
:disabled="submitting"
@click="() => modalApi?.onCancel()"
>

View File

@ -11,7 +11,7 @@ import {
} from 'vue';
import { usePreferences } from '@vben-core/preferences';
import { useStore } from '@vben-core/shared/store';
import { useSelector } from '@vben-core/shared/store';
import { ModalApi } from './modal-api';
import VbenModal from './modal.vue';
@ -51,7 +51,7 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
Object.setPrototypeOf(extendedApi, api);
},
consumed: false,
defaultOptions,
options: defaultOptions,
async reCreateModal() {
isModalReady.value = false;
await nextTick();
@ -116,7 +116,7 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
const extendedApi: ExtendedModalApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
return useSelector(api.store, selector);
};
const Modal = defineComponent(

View File

@ -37,6 +37,7 @@
}
},
"dependencies": {
"@lucide/vue": "catalog:",
"@vben-core/composables": "workspace:*",
"@vben-core/design": "workspace:*",
"@vben-core/icons": "workspace:*",
@ -44,7 +45,6 @@
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"class-variance-authority": "catalog:",
"lucide-vue-next": "catalog:",
"reka-ui": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:"

View File

@ -34,7 +34,7 @@ const { handleClick, visible } = useBackTop(props);
:style="backTopStyle"
class="data z-popup bg-background shadow-float hover:bg-heavy dark:bg-accent dark:hover:bg-heavy fixed bottom-10 size-10 rounded-full duration-500"
size="icon"
variant="icon"
variant="ghost"
@click="handleClick"
>
<ArrowUpToLine class="size-4" />

View File

@ -2,7 +2,7 @@ import type { AsTag } from 'reka-ui';
import type { Component } from 'vue';
import type { ButtonVariants, ButtonVariantSize } from '../../ui';
import type { ButtonVariants } from '../../ui';
export interface VbenButtonProps {
/**
@ -19,8 +19,8 @@ export interface VbenButtonProps {
class?: any;
disabled?: boolean;
loading?: boolean;
size?: ButtonVariantSize;
variant?: ButtonVariants;
size?: ButtonVariants['size'];
variant?: ButtonVariants['variant'];
}
export type CustomRenderType = (() => Component | string) | string;

View File

@ -16,7 +16,7 @@ interface Props extends VbenButtonProps {
tooltip?: string;
tooltipDelayDuration?: number;
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
variant?: ButtonVariants;
variant?: ButtonVariants['variant'];
}
const props = withDefaults(defineProps<Props>(), {
@ -24,7 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
onClick: () => {},
tooltipDelayDuration: 200,
tooltipSide: 'bottom',
variant: 'icon',
variant: 'ghost',
});
const slots = useSlots();

View File

@ -7,7 +7,7 @@ import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useNamespace } from '@vben-core/composables';
import { ChevronsDown } from 'lucide-vue-next';
import { ChevronsDown } from '@lucide/vue';
import {
CollapsibleContent,
CollapsibleRoot,

View File

@ -5,7 +5,7 @@ import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
import { ChevronsDown } from 'lucide-vue-next';
import { ChevronsDown } from '@lucide/vue';
import {
CollapsibleContent,
CollapsibleRoot,

View File

@ -0,0 +1,129 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import type { DescriptionsRenderNode, DescriptionsSize } from './types';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { VbenRenderContent } from '../render-content';
interface Props {
/** 是否边框模式 */
bordered?: boolean;
/** 是否显示冒号(仅非边框模式生效) */
colon?: boolean;
/** 内容 */
content?: DescriptionsRenderNode | null;
/** 内容样式 */
contentStyle?: CSSProperties;
/** 单项自定义类名 */
itemClass?: string;
/** 标签 */
label?: DescriptionsRenderNode | null;
/** 标签样式 */
labelStyle?: CSSProperties;
/** 尺寸 */
size?: DescriptionsSize;
/** 跨列数 */
span?: number;
/** 渲染标签 th 还是 td */
tag: 'td' | 'th';
/** 单元格类型 */
type: 'content' | 'item' | 'label';
}
const props = withDefaults(defineProps<Props>(), {
bordered: false,
colon: true,
content: null,
contentStyle: undefined,
itemClass: undefined,
label: null,
labelStyle: undefined,
size: 'middle',
span: 1,
});
const BORDERED_PADDING: Record<DescriptionsSize, string> = {
large: 'px-6 py-4',
middle: 'px-4 py-2.5',
small: 'px-3 py-2',
};
const PLAIN_PADDING: Record<DescriptionsSize, string> = {
large: 'pb-6',
middle: 'pb-4',
small: 'pb-2',
};
//
const COLON_CLASS = "after:content-[':']";
const hasLabel = computed(
() => props.label !== null && props.label !== undefined,
);
const hasContent = computed(
() => props.content !== null && props.content !== undefined,
);
// 0 VbenRenderContent falsy
// null undefined VbenRenderContent content
const displayLabel = computed(() => {
if (props.label === null || props.label === undefined) return undefined;
return typeof props.label === 'number' ? String(props.label) : props.label;
});
const displayContent = computed(() => {
if (props.content === null || props.content === undefined) return undefined;
return typeof props.content === 'number'
? String(props.content)
: props.content;
});
const cellClass = computed(() => {
if (props.bordered) {
return cn(
'border border-border align-top break-words',
BORDERED_PADDING[props.size],
props.type === 'label'
? 'bg-muted/50 text-start font-normal text-foreground'
: 'text-foreground',
props.itemClass,
);
}
return cn('align-top', PLAIN_PADDING[props.size], props.itemClass);
});
const labelClass = computed(() =>
cn('mr-2 shrink-0 text-muted-foreground', props.colon && COLON_CLASS),
);
</script>
<template>
<component :is="tag" :class="cellClass" :colspan="span">
<!-- 边框模式每个单元格仅承载 label content -->
<template v-if="bordered">
<span v-if="hasLabel" :style="labelStyle">
<VbenRenderContent :content="displayLabel" />
</span>
<span v-if="hasContent" :style="contentStyle">
<VbenRenderContent :content="displayContent" />
</span>
</template>
<!-- 非边框模式label + content 容器 -->
<div v-else class="flex">
<span v-if="hasLabel" :class="labelClass" :style="labelStyle">
<VbenRenderContent :content="displayLabel" />
</span>
<span
v-if="hasContent"
class="break-words text-foreground"
:style="contentStyle"
>
<VbenRenderContent :content="displayContent" />
</span>
</div>
</component>
</template>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import type { PropType } from 'vue';
import type { DescriptionsItemSpan, DescriptionsRenderNode } from './types';
import { defineComponent } from 'vue';
import { DESCRIPTIONS_ITEM_NAME } from './use-descriptions';
/**
* 子节点用法的标记组件本身不渲染任何内容
* props 与默认插槽会被父级 VbenDescriptions 收集为列表项
*/
const VbenDescriptionsItem = defineComponent({
name: DESCRIPTIONS_ITEM_NAME,
props: {
content: {
default: undefined,
type: [
String,
Number,
Function,
Object,
] as PropType<DescriptionsRenderNode>,
},
contentStyle: {
default: undefined,
type: Object,
},
label: {
default: undefined,
type: [
String,
Number,
Function,
Object,
] as PropType<DescriptionsRenderNode>,
},
labelStyle: {
default: undefined,
type: Object,
},
span: {
default: undefined,
type: [Number, String, Object] as PropType<DescriptionsItemSpan>,
},
},
setup() {
return () => null;
},
});
// 便 vnode
(VbenDescriptionsItem as Record<string, any>).__isDescriptionsItem = true;
export default VbenDescriptionsItem;
</script>

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import type { DescriptionsSize, InternalDescriptionsItem } from './types';
import DescriptionsCell from './descriptions-cell.vue';
interface Props {
bordered?: boolean;
colon?: boolean;
contentStyle?: CSSProperties;
labelStyle?: CSSProperties;
row: InternalDescriptionsItem[];
size?: DescriptionsSize;
vertical?: boolean;
}
withDefaults(defineProps<Props>(), {
bordered: false,
colon: true,
contentStyle: undefined,
labelStyle: undefined,
size: 'middle',
vertical: false,
});
function mergeStyle(
base?: CSSProperties,
override?: CSSProperties,
): CSSProperties | undefined {
if (!base && !override) return undefined;
return { ...base, ...override };
}
</script>
<template>
<!-- 垂直布局标签独占一行内容独占一行 -->
<template v-if="vertical">
<tr>
<DescriptionsCell
v-for="(item, index) in row"
:key="`label-${item.key ?? index}`"
tag="th"
type="label"
:span="item.span ?? 1"
:bordered="bordered"
:colon="colon"
:size="size"
:label="item.label ?? null"
:item-class="item.class"
:label-style="mergeStyle(labelStyle, item.labelStyle)"
/>
</tr>
<tr>
<DescriptionsCell
v-for="(item, index) in row"
:key="`content-${item.key ?? index}`"
tag="td"
type="content"
:span="item.span ?? 1"
:bordered="bordered"
:size="size"
:content="item.content ?? null"
:item-class="item.class"
:content-style="mergeStyle(contentStyle, item.contentStyle)"
/>
</tr>
</template>
<!-- 水平 + 边框每项拆分为 label(th) content(td) -->
<tr v-else-if="bordered">
<template v-for="(item, index) in row" :key="item.key ?? index">
<DescriptionsCell
tag="th"
type="label"
:span="1"
:bordered="true"
:size="size"
:label="item.label ?? null"
:item-class="item.class"
:label-style="mergeStyle(labelStyle, item.labelStyle)"
/>
<DescriptionsCell
tag="td"
type="content"
:span="(item.span ?? 1) * 2 - 1"
:bordered="true"
:size="size"
:content="item.content ?? null"
:content-style="mergeStyle(contentStyle, item.contentStyle)"
/>
</template>
</tr>
<!-- 水平 + 非边框每项一个单元格label content 同列 -->
<tr v-else>
<DescriptionsCell
v-for="(item, index) in row"
:key="item.key ?? index"
tag="td"
type="item"
:span="item.span ?? 1"
:colon="colon"
:size="size"
:label="item.label ?? null"
:content="item.content ?? null"
:item-class="item.class"
:label-style="mergeStyle(labelStyle, item.labelStyle)"
:content-style="mergeStyle(contentStyle, item.contentStyle)"
/>
</tr>
</template>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import type { VNode } from 'vue';
import type { DescriptionsItemType, DescriptionsProps } from './types';
import { computed, useSlots } from 'vue';
import { cn } from '@vben-core/shared/utils';
import DescriptionsRow from './descriptions-row.vue';
import {
calcRows,
normalizeItems,
parseItemsFromSlot,
resolveColumn,
useScreens,
} from './use-descriptions';
defineOptions({ name: 'VbenDescriptions' });
const props = withDefaults(defineProps<DescriptionsProps>(), {
bordered: false,
class: undefined,
colon: true,
column: undefined,
contentStyle: undefined,
extra: undefined,
items: undefined,
labelStyle: undefined,
layout: 'horizontal',
size: 'middle',
title: undefined,
});
const slots = useSlots();
const screens = useScreens();
// 使 items VbenDescriptionsItem
const resolvedItems = computed<DescriptionsItemType[]>(() => {
if (props.items && props.items.length > 0) return props.items;
const nodes = (slots.default?.() ?? []) as VNode[];
return parseItemsFromSlot(nodes);
});
const mergedColumn = computed(() => resolveColumn(props.column, screens.value));
const mergedItems = computed(() =>
normalizeItems(resolvedItems.value, screens.value),
);
const rows = computed(() => calcRows(mergedItems.value, mergedColumn.value));
const hasHeader = computed(
() => !!props.title || !!props.extra || !!slots.title || !!slots.extra,
);
const tableClass = computed(() =>
cn(
'w-full table-auto border-collapse text-sm',
//
!props.bordered && '[&>tbody>tr:last-child>td]:pb-0',
),
);
</script>
<template>
<div :class="cn('w-full', props.class)">
<div v-if="hasHeader" class="mb-5 flex items-center justify-between gap-4">
<div class="text-base font-semibold text-foreground">
<slot name="title">{{ title }}</slot>
</div>
<div class="text-foreground">
<slot name="extra">{{ extra }}</slot>
</div>
</div>
<table :class="tableClass">
<tbody>
<DescriptionsRow
v-for="(row, index) in rows"
:key="index"
:row="row"
:vertical="layout === 'vertical'"
:bordered="bordered"
:colon="colon"
:size="size"
:label-style="labelStyle"
:content-style="contentStyle"
/>
</tbody>
</table>
</div>
</template>

View File

@ -0,0 +1,4 @@
export { default as VbenDescriptionsItem } from './descriptions-item.vue';
export { default as VbenDescriptions } from './descriptions.vue';
export * from './types';

View File

@ -0,0 +1,93 @@
import type { Component, CSSProperties } from 'vue';
/** 响应式断点,与 antdv-next 保持一致 */
export type DescriptionsBreakpoint =
| 'lg'
| 'md'
| 'sm'
| 'xl'
| 'xs'
| 'xxl'
| 'xxxl';
/** 当前命中的断点集合 */
export type ScreenMap = Partial<Record<DescriptionsBreakpoint, boolean>>;
export type DescriptionsLayout = 'horizontal' | 'vertical';
export type DescriptionsSize = 'large' | 'middle' | 'small';
/** 列数,可为固定数字或按断点配置 */
export type DescriptionsColumn =
| number
| Partial<Record<DescriptionsBreakpoint, number>>;
/** 单项跨列,支持固定数字、'filled'(占满当前行剩余)或按断点配置 */
export type DescriptionsItemSpan =
| 'filled'
| number
| Partial<Record<DescriptionsBreakpoint, number>>;
/** 可渲染内容:字符串/数字/渲染函数/组件 */
export type DescriptionsRenderNode = (() => any) | Component | number | string;
export interface DescriptionsItemType {
/** 内容 */
content?: DescriptionsRenderNode;
/** 内容样式 */
contentStyle?: CSSProperties;
/** 唯一 key */
key?: number | string;
/** 标签 */
label?: DescriptionsRenderNode;
/** 标签样式 */
labelStyle?: CSSProperties;
/** 跨列 */
span?: DescriptionsItemSpan;
}
export interface DescriptionsProps {
/** 是否展示边框 */
bordered?: boolean;
class?: any;
/** 是否显示冒号(仅非 bordered 的水平布局生效) */
colon?: boolean;
/** 一行的列数 */
column?: DescriptionsColumn;
/** 统一的内容样式 */
contentStyle?: CSSProperties;
/** 操作区域,位于标题右侧 */
extra?: string;
/** 数据驱动的列表项;不传则读取默认插槽中的 VbenDescriptionsItem */
items?: DescriptionsItemType[];
/** 统一的标签样式 */
labelStyle?: CSSProperties;
/** 布局方式 */
layout?: DescriptionsLayout;
/** 尺寸 */
size?: DescriptionsSize;
/** 标题 */
title?: string;
}
export interface DescriptionsItemProps {
content?: DescriptionsRenderNode;
contentStyle?: CSSProperties;
label?: DescriptionsRenderNode;
labelStyle?: CSSProperties;
span?: DescriptionsItemSpan;
}
/** 归一化后的内部项span 已解析为数字 */
export interface InternalDescriptionsItem {
_index?: number;
class?: string;
content?: DescriptionsRenderNode;
contentStyle?: CSSProperties;
filled?: boolean;
key?: number | string;
label?: DescriptionsRenderNode;
labelStyle?: CSSProperties;
span?: number;
style?: CSSProperties;
}

View File

@ -0,0 +1,216 @@
import type { VNode } from 'vue';
import type {
DescriptionsBreakpoint,
DescriptionsColumn,
DescriptionsItemType,
InternalDescriptionsItem,
ScreenMap,
} from './types';
import { Comment, computed, Fragment } from 'vue';
import { useBreakpoints } from '@vueuse/core';
/** 默认列数映射 */
export const DEFAULT_COLUMN_MAP: Record<DescriptionsBreakpoint, number> = {
lg: 3,
md: 3,
sm: 2,
xl: 3,
xs: 1,
xxl: 3,
xxxl: 4,
};
/** 由大到小的断点顺序matchScreen 按此顺序取第一个命中的值 */
const RESPONSIVE_ARRAY: DescriptionsBreakpoint[] = [
'xxxl',
'xxl',
'xl',
'lg',
'md',
'sm',
'xs',
];
/** 断点像素值 */
const BREAKPOINT_PX = {
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1600,
xxxl: 2000,
};
/**
*
*/
export function matchScreen(
screens: ScreenMap,
screenSizes?: Partial<Record<DescriptionsBreakpoint, number>>,
): number | undefined {
if (!screenSizes) return undefined;
for (const breakpoint of RESPONSIVE_ARRAY) {
if (screens[breakpoint] && screenSizes[breakpoint] !== undefined) {
return screenSizes[breakpoint];
}
}
return undefined;
}
/**
*
*/
export function useScreens() {
const breakpoints = useBreakpoints(BREAKPOINT_PX);
return computed<ScreenMap>(() => ({
lg: breakpoints.lg.value,
md: breakpoints.md.value,
sm: breakpoints.sm.value,
xl: breakpoints.xl.value,
xs: !breakpoints.sm.value,
xxl: breakpoints.xxl.value,
xxxl: breakpoints.xxxl.value,
}));
}
/**
*
*/
export function resolveColumn(
column: DescriptionsColumn | undefined,
screens: ScreenMap,
): number {
if (typeof column === 'number') return column;
return matchScreen(screens, { ...DEFAULT_COLUMN_MAP, ...column }) ?? 3;
}
/**
* span 'filled' filled
*/
export function normalizeItems(
items: DescriptionsItemType[],
screens: ScreenMap,
): InternalDescriptionsItem[] {
return items.map((item, index) => {
const { span, ...rest } = item;
if (span === 'filled') {
return { ...rest, _index: index, filled: true };
}
return {
...rest,
_index: index,
span: typeof span === 'number' ? span : matchScreen(screens, span),
};
});
}
/**
* span
* antdv-next useRow
*/
export function calcRows(
items: InternalDescriptionsItem[],
column: number,
): InternalDescriptionsItem[][] {
let rows: InternalDescriptionsItem[][] = [];
let tmpRow: InternalDescriptionsItem[] = [];
let count = 0;
items.filter(Boolean).forEach((item) => {
const { filled, ...rest } = item;
// filled占满当前行剩余并立即换行
if (filled) {
tmpRow.push(rest);
rows.push(tmpRow);
tmpRow = [];
count = 0;
return;
}
const restSpan = column - count;
count += item.span || 1;
if (count >= column) {
// 超出列数时,将当前项 span 收敛为剩余列数,避免溢出
tmpRow.push(count > column ? { ...rest, span: restSpan } : rest);
rows.push(tmpRow);
tmpRow = [];
count = 0;
} else {
tmpRow.push(rest);
}
});
if (tmpRow.length > 0) rows.push(tmpRow);
// 补齐:若一行总 span 不足列数,扩展最后一项
rows = rows.map((row) => {
const total = row.reduce((acc, item) => acc + (item.span || 1), 0);
if (total < column) {
const last = row[row.length - 1];
if (last) {
last.span = column - (total - (last.span || 1));
}
}
return row;
});
return rows;
}
/** 标记组件类型为 DescriptionsItem便于从插槽 vnode 中识别 */
export const DESCRIPTIONS_ITEM_NAME = 'VbenDescriptionsItem';
function isItemVNode(node: VNode): boolean {
const type = node.type as any;
return (
!!type &&
(type.__isDescriptionsItem === true || type.name === DESCRIPTIONS_ITEM_NAME)
);
}
function flattenVNodes(nodes: VNode[]): VNode[] {
const result: VNode[] = [];
for (const node of nodes) {
if (node.type === Fragment && Array.isArray(node.children)) {
result.push(...flattenVNodes(node.children as VNode[]));
} else if (node.type !== Comment) {
result.push(node);
}
}
return result;
}
/**
* vnode
* <VbenDescriptionsItem label="..." :span="2">content</VbenDescriptionsItem>
*/
export function parseItemsFromSlot(nodes: VNode[]): DescriptionsItemType[] {
return flattenVNodes(nodes)
.filter((node) => isItemVNode(node))
.map((node) => {
const props = (node.props ?? {}) as Record<string, any>;
const children = (node.children ?? {}) as Record<string, any>;
const labelSlot =
typeof children.label === 'function' ? children.label : undefined;
const contentDefaultSlot =
typeof children.default === 'function' ? children.default : undefined;
const contentSlot =
typeof children.content === 'function'
? children.content
: contentDefaultSlot;
return {
class: props.class,
content: contentSlot ?? props.content,
contentStyle: props.contentStyle ?? props['content-style'],
key: node.key ?? undefined,
label: labelSlot ?? props.label,
labelStyle: props.labelStyle ?? props['label-style'],
span: props.span,
style: props.style,
} as DescriptionsItemType;
});
}

View File

@ -6,6 +6,7 @@ export * from './checkbox';
export * from './collapsible';
export * from './context-menu';
export * from './count-to-animator';
export * from './descriptions';
export * from './dropdown-menu';
export * from './expandable-arrow';
export * from './full-screen';
@ -21,4 +22,5 @@ export * from './segmented';
export * from './select';
export * from './spine-text';
export * from './spinner';
export * from './table-action';
export * from './tooltip';

View File

@ -3,7 +3,7 @@ import type { PinInputProps } from './types';
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue';
import { PinInput, PinInputGroup, PinInputInput } from '../../ui';
import { PinInput, PinInputGroup, PinInputSlot } from '../../ui';
import { VbenButton } from '../button';
defineOptions({
@ -101,7 +101,7 @@ const pinType = 'text' as const;
>
<div class="relative flex w-full">
<PinInputGroup class="mr-2">
<PinInputInput
<PinInputSlot
v-for="(item, index) in codeLength"
:key="item"
:index="index"

View File

@ -115,7 +115,7 @@ function handleScroll(event: Event) {
></div>
<ScrollBar
v-if="horizontal"
:class="scrollBarClass"
:class="cn(scrollBarClass)"
orientation="horizontal"
/>
</ScrollArea>

View File

@ -45,7 +45,7 @@ function activeClass(tab: string): string[] {
<Tabs v-model="activeTab" :default-value="getDefaultValue">
<TabsList
:style="tabsStyle"
class="bg-accent outline-heavy! relative grid w-full outline! outline-2!"
class="bg-accent outline-heavy! relative grid w-full outline-2!"
>
<TabsIndicator :style="tabsIndicatorStyle" />
<template v-for="tab in tabs" :key="tab.value">

View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import type { ActionItem } from './types';
import { computed, ref } from 'vue';
import { useSimpleLocale } from '@vben-core/composables';
import { cn } from '@vben-core/shared/utils';
import {
DropdownMenuItem,
Popover,
PopoverContent,
PopoverTrigger,
} from '../../ui';
import { VbenButton } from '../button';
import { VbenIcon } from '../icon';
const props = defineProps<{ action: ActionItem }>();
const emit = defineEmits<{ confirm: [] }>();
const { $t } = useSimpleLocale();
const open = ref(false);
const itemClass = computed(() =>
cn(
'cursor-pointer gap-2',
props.action.danger && 'text-destructive focus:text-destructive',
),
);
/**
* 阻止 reka-ui 事件的默认行为用于
* - @select阻止点击菜单项后自动关闭菜单以便弹出气泡确认框
* - @open-auto-focus阻止弹层抢占焦点避免与菜单的焦点陷阱冲突
* - @focus-outside阻止因菜单夺回焦点而被误判为焦点移出从而关闭弹层
*/
function preventDefault(event: Event) {
event.preventDefault();
}
function onClick() {
if (props.action.disabled) return;
props.action.onClick?.();
}
function onConfirm() {
open.value = false;
const pc = props.action.popConfirm;
if (pc?.confirm) {
pc.confirm();
} else {
props.action.onClick?.();
}
//
emit('confirm');
}
function onCancel() {
open.value = false;
}
</script>
<template>
<!--
气泡确认菜单项同时作为 Popover 触发器
通过双重 as-childDropdownMenuItem + PopoverTrigger 均合并到同一个叶子元素
使该元素既是菜单项又是弹层触发器@select 阻止点击后菜单自动关闭
-->
<Popover v-if="action.popConfirm" v-model:open="open">
<PopoverTrigger as-child>
<DropdownMenuItem
as-child
:class="itemClass"
:disabled="action.disabled"
@select="preventDefault"
>
<div>
<VbenIcon v-if="action.icon" :icon="action.icon" class="size-4" />
{{ action.text }}
</div>
</DropdownMenuItem>
</PopoverTrigger>
<PopoverContent
class="z-popup w-60"
side="left"
@focus-outside="preventDefault"
@open-auto-focus="preventDefault"
>
<div class="text-foreground mb-3 text-sm">
{{ action.popConfirm.title ?? $t('confirmTitle') }}
</div>
<div class="flex justify-end gap-2">
<VbenButton size="sm" variant="outline" @click="onCancel">
{{ action.popConfirm.cancelText ?? $t('cancel') }}
</VbenButton>
<VbenButton
:variant="action.danger ? 'destructive' : 'default'"
size="sm"
@click="onConfirm"
>
{{ action.popConfirm.okText ?? $t('confirm') }}
</VbenButton>
</div>
</PopoverContent>
</Popover>
<!-- 普通下拉项 -->
<DropdownMenuItem
v-else
:class="itemClass"
:disabled="action.disabled"
@click="onClick"
>
<VbenIcon v-if="action.icon" :icon="action.icon" class="size-4" />
{{ action.text }}
</DropdownMenuItem>
</template>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import type { ActionItem } from './types';
import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui';
import { VbenButton } from '../button';
import { VbenIcon } from '../icon';
const props = defineProps<{ action: ActionItem }>();
const open = ref(false);
const buttonClass = computed(() =>
cn(
'gap-1',
props.action.danger && 'text-destructive hover:text-destructive',
props.action.class,
),
);
const variant = computed(() => props.action.variant ?? 'link');
const size = computed(() => props.action.size ?? 'default');
function onClick() {
if (props.action.disabled || props.action.loading) return;
props.action.onClick?.();
}
function onConfirm() {
open.value = false;
const pc = props.action.popConfirm;
if (pc?.confirm) {
pc.confirm();
} else {
props.action.onClick?.();
}
}
function onCancel() {
open.value = false;
}
</script>
<template>
<!-- 气泡确认 -->
<Popover v-if="action.popConfirm" v-model:open="open">
<PopoverTrigger as-child>
<VbenButton
:class="buttonClass"
:disabled="action.disabled"
:loading="action.loading"
:size="size"
class="p-2"
:variant="variant"
>
<VbenIcon :icon="action.icon" v-if="action.icon" class="size-4" />
<span v-if="action.text">{{ action.text }}</span>
</VbenButton>
</PopoverTrigger>
<PopoverContent class="z-popup w-60" side="top">
<div class="text-foreground mb-3 text-sm">
{{ action.popConfirm.title ?? 'Are you sure?' }}
</div>
<div class="flex justify-end gap-2">
<VbenButton size="default" variant="outline" @click="onCancel">
{{ action.popConfirm.cancelText ?? 'Cancel' }}
</VbenButton>
<VbenButton
:variant="action.danger ? 'destructive' : 'default'"
size="default"
class="p-2"
@click="onConfirm"
>
{{ action.popConfirm.okText ?? 'OK' }}
</VbenButton>
</div>
</PopoverContent>
</Popover>
<!-- 普通按钮 -->
<VbenButton
v-else
:class="buttonClass"
:disabled="action.disabled"
:loading="action.loading"
:size="size"
class="p-2"
:variant="variant"
@click="onClick"
>
<VbenIcon :icon="action.icon" v-if="action.icon" class="size-4" />
<span v-if="action.text">{{ action.text }}</span>
</VbenButton>
</template>

View File

@ -0,0 +1,3 @@
export { default as VbenTableAction } from './table-action.vue';
export type * from './types';

View File

@ -0,0 +1,216 @@
<script setup lang="ts">
import type { ActionItem, TableActionProps } from './types';
import { computed, ref } from 'vue';
import { Ellipsis } from '@vben-core/icons';
import { cn } from '@vben-core/shared/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Separator,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../../ui';
import { VbenButton } from '../button';
import { VbenIcon } from '../icon';
import ActionDropdownItemComp from './action-dropdown-item.vue';
import ActionItemComp from './action-item.vue';
defineOptions({ name: 'VbenTableAction' });
const props = withDefaults(defineProps<TableActionProps>(), {
actions: () => [],
align: 'end',
class: undefined,
divider: false,
dropdownActions: () => [],
hasPermission: undefined,
moreText: undefined,
});
function checkVisible(item: ActionItem): boolean {
//
if (item.auth && props.hasPermission && !props.hasPermission(item.auth)) {
return false;
}
// ifShow
if (typeof item.ifShow === 'boolean') return item.ifShow;
if (typeof item.ifShow === 'function') return item.ifShow();
return true;
}
const visibleActions = computed(() =>
(props.actions ?? []).filter((item) => checkVisible(item)),
);
const visibleDropdownActions = computed(() =>
(props.dropdownActions ?? []).filter((item) => checkVisible(item)),
);
const alignClass = computed(
() =>
({ center: 'justify-center', end: 'justify-end', start: 'justify-start' })[
props.align
],
);
// cn() tailwind-merge
const wrapperClass = computed(() =>
cn('flex items-center gap-1', alignClass.value, props.class),
);
function tooltipSide(action: ActionItem) {
return typeof action.tooltip === 'object'
? (action.tooltip.side ?? 'top')
: 'top';
}
function tooltipContent(action: ActionItem) {
return typeof action.tooltip === 'object'
? action.tooltip.content
: action.tooltip;
}
/**
* 预计算每个主操作的渲染视图模型
* - 普通按钮在本组件内直接渲染不再为每个操作多包一层子组件
* 表格大量行时可显著减少组件实例数
* - popConfirm 操作仍交由子组件维护独立弹层状态
* - 类名等在此一次性计算并缓存避免模板每次渲染都执行 cn()
*/
const renderedActions = computed(() => {
const list = visibleActions.value;
return list.map((action, index) => {
const hasTooltip = !!action.tooltip && !action.popConfirm;
return {
action,
buttonClass: cn(
'gap-1 p-2',
action.danger && 'text-destructive hover:text-destructive',
action.class,
),
hasTooltip,
isConfirm: !!action.popConfirm,
key: action.key ?? index,
showDivider: props.divider && index < list.length - 1,
size: action.size ?? 'default',
tooltipContent: hasTooltip ? tooltipContent(action) : undefined,
tooltipSide: hasTooltip ? tooltipSide(action) : 'top',
variant: action.variant ?? 'link',
};
});
});
const dropdownOpen = ref(false);
function onActionClick(action: ActionItem) {
if (action.disabled || action.loading) return;
action.onClick?.();
}
/**
* 当与气泡确认Popover交互时避免误关闭整个下拉菜单
* Popover 内容被 Portal 渲染到菜单之外默认会被判定为点击外部而关闭菜单
*/
function onContentInteractOutside(event: Event) {
const target = (event as CustomEvent).detail?.originalEvent?.target as
| HTMLElement
| null
| undefined;
if (target?.closest('[data-slot="popover-content"]')) {
event.preventDefault();
}
}
</script>
<template>
<div :class="wrapperClass">
<!-- 所有主操作共享同一个 TooltipProvider避免每个 tooltip 各建一个 provider -->
<TooltipProvider v-if="renderedActions.length > 0" :delay-duration="0">
<template v-for="item in renderedActions" :key="item.key">
<!-- 气泡确认需独立弹层状态交由子组件维护 -->
<ActionItemComp v-if="item.isConfirm" :action="item.action" />
<!-- 带提示的普通按钮 -->
<Tooltip v-else-if="item.hasTooltip">
<TooltipTrigger as-child tabindex="-1">
<VbenButton
:class="item.buttonClass"
:disabled="item.action.disabled"
:loading="item.action.loading"
:size="item.size"
:variant="item.variant"
@click="onActionClick(item.action)"
>
<VbenIcon
v-if="item.action.icon"
:icon="item.action.icon"
class="size-4"
/>
<span v-if="item.action.text">{{ item.action.text }}</span>
</VbenButton>
</TooltipTrigger>
<TooltipContent
:side="item.tooltipSide"
class="side-content bg-accent text-popover-foreground rounded-md"
>
{{ item.tooltipContent }}
</TooltipContent>
</Tooltip>
<!-- 普通按钮 -->
<VbenButton
v-else
:class="item.buttonClass"
:disabled="item.action.disabled"
:loading="item.action.loading"
:size="item.size"
:variant="item.variant"
@click="onActionClick(item.action)"
>
<VbenIcon
v-if="item.action.icon"
:icon="item.action.icon"
class="size-4"
/>
<span v-if="item.action.text">{{ item.action.text }}</span>
</VbenButton>
<Separator v-if="item.showDivider" orientation="vertical" class="h-4" />
</template>
</TooltipProvider>
<DropdownMenu
v-if="visibleDropdownActions.length > 0"
v-model:open="dropdownOpen"
>
<DropdownMenuTrigger as-child>
<VbenButton class="gap-1 p-2" variant="link">
<Ellipsis class="size-4" />
<span v-if="moreText">{{ moreText }}</span>
</VbenButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
@interact-outside="onContentInteractOutside"
>
<template
v-for="(item, index) in visibleDropdownActions"
:key="item.key ?? index"
>
<ActionDropdownItemComp
:action="item"
@confirm="dropdownOpen = false"
/>
<DropdownMenuSeparator
v-if="divider && index < visibleDropdownActions.length - 1"
/>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>

View File

@ -0,0 +1,75 @@
import type { ButtonVariants } from '../../ui';
import { VbenIcon } from '../icon';
/** 权限码:单个或多个,配合注入的 hasPermission 判断 */
export type TableActionAuth = string | string[];
/** 操作按钮提示 */
export interface TableActionTooltip {
content: string;
side?: 'bottom' | 'left' | 'right' | 'top';
}
/** 气泡确认框配置 */
export interface TableActionPopConfirm {
/** 取消按钮文案 */
cancelText?: string;
/** 确认回调;未提供时回退到 action.onClick */
confirm?: () => void;
/** 确认按钮文案 */
okText?: string;
/** 提示标题 */
title?: string;
}
export interface ActionItem {
/** 权限码,配合注入的 hasPermission 过滤 */
auth?: TableActionAuth;
/** 自定义类名 */
class?: any;
/** 危险操作(红色文字) */
danger?: boolean;
/** 是否禁用 */
disabled?: boolean;
/** 图标组件 */
icon?: typeof VbenIcon.icon;
/** 是否显示:布尔或返回布尔的函数 */
ifShow?: (() => boolean) | boolean;
/** 唯一标识,点击回调可据此区分 */
key?: number | string;
/** 加载状态 */
loading?: boolean;
/** 点击回调 */
onClick?: () => void;
/** 气泡确认框 */
popConfirm?: TableActionPopConfirm;
/** 尺寸 */
size?: ButtonVariants['size'];
/** 文本 */
text?: string;
/** 提示:字符串或配置对象 */
tooltip?: string | TableActionTooltip;
/** 按钮样式变体 */
variant?: ButtonVariants['variant'];
}
export interface TableActionProps {
/** 主操作按钮 */
actions?: ActionItem[];
/** 对齐方式 */
align?: 'center' | 'end' | 'start';
/** 自定义类名 */
class?: any;
/** 按钮之间是否显示分割线 */
divider?: boolean;
/** “更多”下拉中的操作 */
dropdownActions?: ActionItem[];
/**
* false auth
* 使 useAccess().hasAccessByCodes
*/
hasPermission?: (auth?: TableActionAuth) => boolean;
/** “更多”按钮文案(提供时显示在图标右侧) */
moreText?: string;
}

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@vben-core/shared/utils';
import { CircleHelp } from 'lucide-vue-next';
import { CircleHelp } from '@lucide/vue';
import Tooltip from './tooltip.vue';

View File

@ -10,7 +10,7 @@ const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot></slot>
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
<slot v-bind="slotProps"></slot>
</AccordionRoot>
</template>

View File

@ -1,23 +1,23 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'reka-ui';
import { computed } from 'vue';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AccordionContent } from 'reka-ui';
const props = defineProps<AccordionContentProps & { class?: any }>();
const props = defineProps<
AccordionContentProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
>

View File

@ -1,25 +1,29 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'reka-ui';
import { computed } from 'vue';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AccordionItem, useForwardProps } from 'reka-ui';
const props = defineProps<AccordionItemProps & { class?: any }>();
const props = defineProps<
AccordionItemProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
<slot></slot>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('border-b last:border-b-0', props.class)"
>
<slot v-bind="slotProps"></slot>
</AccordionItem>
</template>

View File

@ -1,29 +1,29 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'reka-ui';
import { computed } from 'vue';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import { reactiveOmit } from '@vueuse/core';
import { AccordionHeader, AccordionTrigger } from 'reka-ui';
const props = defineProps<AccordionTriggerProps & { class?: any }>();
const props = defineProps<
AccordionTriggerProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
@ -31,7 +31,7 @@ const delegatedProps = computed(() => {
<slot></slot>
<slot name="icon">
<ChevronDown
class="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200"
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>

View File

@ -10,7 +10,11 @@ const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot></slot>
<AlertDialogRoot
v-slot="slotProps"
data-slot="alert-dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps"></slot>
</AlertDialogRoot>
</template>

View File

@ -3,17 +3,22 @@ import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui';
import type { ClassType } from '@vben-core/typings';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useScrollLock } from '@vben-core/composables';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui';
import AlertDialogOverlay from './AlertDialogOverlay.vue';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<
@ -32,11 +37,9 @@ const emits = defineEmits<
AlertDialogContentEmits & { close: []; closed: []; opened: [] }
>();
const delegatedProps = computed(() => {
const { class: _, modal: _modal, open: _open, ...delegated } = props;
useScrollLock();
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
@ -60,6 +63,8 @@ defineExpose({
<AlertDialogPortal>
<Transition name="fade" appear>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-popup bg-overlay"
v-if="open && modal"
:style="{
...(zIndex ? { zIndex } : {}),
@ -71,15 +76,14 @@ defineExpose({
/>
</Transition>
<AlertDialogContent
data-slot="alert-dialog-content"
ref="contentRef"
:style="{ ...(zIndex ? { zIndex } : {}), position: 'fixed' }"
@animationend="onAnimationEnd"
v-bind="forwarded"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'z-popup bg-background p-6 shadow-lg outline-hidden sm:rounded-xl',
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'z-popup bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
{
'data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%]':
!centered,

View File

@ -1,25 +1,25 @@
<script lang="ts" setup>
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'reka-ui';
import { computed } from 'vue';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AlertDialogDescription, useForwardProps } from 'reka-ui';
const props = defineProps<AlertDialogDescriptionProps & { class?: any }>();
const props = defineProps<
AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)
"
>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot></slot>
</div>
</template>

View File

@ -1,8 +0,0 @@
<script setup lang="ts">
import { useScrollLock } from '@vben-core/composables';
useScrollLock();
</script>
<template>
<div class="z-popup bg-overlay inset-0"></div>
</template>

View File

@ -1,29 +1,25 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'reka-ui';
import { computed } from 'vue';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { AlertDialogTitle, useForwardProps } from 'reka-ui';
import { reactiveOmit } from '@vueuse/core';
import { AlertDialogTitle } from 'reka-ui';
const props = defineProps<AlertDialogTitleProps & { class?: any }>();
const props = defineProps<
AlertDialogTitleProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AlertDialogTitle
v-bind="forwardedProps"
:class="
cn('text-lg leading-none font-semibold tracking-tight', props.class)
"
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot></slot>
</AlertDialogTitle>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from 'reka-ui';
import { AlertDialogTrigger } from 'reka-ui';
const props = defineProps<AlertDialogTriggerProps>();
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot></slot>
</AlertDialogTrigger>
</template>

View File

@ -3,4 +3,7 @@ export { default as AlertDialogAction } from './AlertDialogAction.vue';
export { default as AlertDialogCancel } from './AlertDialogCancel.vue';
export { default as AlertDialogContent } from './AlertDialogContent.vue';
export { default as AlertDialogDescription } from './AlertDialogDescription.vue';
export { default as AlertDialogFooter } from './AlertDialogFooter.vue';
export { default as AlertDialogHeader } from './AlertDialogHeader.vue';
export { default as AlertDialogTitle } from './AlertDialogTitle.vue';
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue';

View File

@ -1,27 +1,25 @@
<script setup lang="ts">
import type { AvatarVariants } from './avatar';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { AvatarRoot } from 'reka-ui';
import { avatarVariant } from './avatar';
const props = withDefaults(
defineProps<{
class?: any;
shape?: AvatarVariants['shape'];
size?: AvatarVariants['size'];
}>(),
{
shape: 'circle',
size: 'sm',
},
);
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<AvatarRoot
data-slot="avatar"
:class="
cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
props.class,
)
"
>
<slot></slot>
</AvatarRoot>
</template>

View File

@ -1,13 +1,31 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AvatarFallback } from 'reka-ui';
const props = defineProps<AvatarFallbackProps>();
const props = defineProps<
AvatarFallbackProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AvatarFallback v-bind="props">
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="
cn(
'bg-muted flex size-full items-center justify-center rounded-full',
props.class,
)
"
>
<slot></slot>
</AvatarFallback>
</template>

View File

@ -7,5 +7,11 @@ const props = defineProps<AvatarImageProps>();
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot></slot>
</AvatarImage>
</template>

View File

@ -1,22 +0,0 @@
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
{
variants: {
shape: {
circle: 'rounded-full',
square: 'rounded-md',
},
size: {
base: 'h-16 w-16 text-2xl',
lg: 'h-32 w-32 text-5xl',
sm: 'h-10 w-10 text-xs',
},
},
},
);
export type AvatarVariants = VariantProps<typeof avatarVariant>;

View File

@ -1,4 +1,3 @@
export * from './avatar';
export { default as Avatar } from './Avatar.vue';
export { default as AvatarFallback } from './AvatarFallback.vue';
export { default as AvatarImage } from './AvatarImage.vue';

View File

@ -1,18 +1,33 @@
<script setup lang="ts">
import type { BadgeVariants } from './badge';
import type { PrimitiveProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import type { BadgeVariants } from '.';
import { cn } from '@vben-core/shared/utils';
import { badgeVariants } from './badge';
import { reactiveOmit } from '@vueuse/core';
import { Primitive } from 'reka-ui';
const props = defineProps<{
class?: any;
variant?: BadgeVariants['variant'];
}>();
import { badgeVariants } from '.';
const props = defineProps<
PrimitiveProps & {
class?: HTMLAttributes['class'];
variant?: BadgeVariants['variant'];
}
>();
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot></slot>
</div>
</Primitive>
</template>

View File

@ -3,23 +3,23 @@ import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const badgeVariants = cva(
'inline-flex items-center rounded-md border border-border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
defaultVariants: {
variant: 'default',
},
variants: {
variant: {
default:
'border-transparent bg-accent hover:bg-accent text-primary-foreground shadow-sm',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover',
outline: 'text-foreground',
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;

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