feat: 表格行增加下拉菜单功能

master
luoer 2023-08-08 17:32:41 +08:00
parent 8a2b29ef01
commit 0c9e19dc10
12 changed files with 243 additions and 125 deletions

View File

@ -1,5 +1,5 @@
<template>
<div class="mt-4 mx-5">
<div class="mt-3 mx-5">
<a-breadcrumb>
<a-breadcrumb-item>
<i class="icon-park-outline-all-application text-gray-400"></i>

View File

@ -2,7 +2,7 @@
<div>
<BreadCrumb></BreadCrumb>
<slot name="content">
<div class="mx-4 mt-4 p-4 bg-white">
<div class="mx-4 mt-3 p-4 bg-white">
<slot></slot>
</div>
</slot>

View File

@ -48,19 +48,19 @@ const form = useForm({
| formProps | 传递给`AForm`组件的参数(可选),具体可参考`Arco-Design`的`Form`组件,部分参数不可用,如`model`等。 | `FormInstance['$props']` |
### 表单数据
`model`表示当前表单的数据,当使用`useForm`时,将从`items`中每一项的`field`和`initialValue`生成。如果`model`中的属性与`field`值同名,且`initialValue`值不为空,则原`model`中的同名属性值将被覆盖。
`model`表示当前表单的数据,可为空。当使用`useForm`时,将从`items`中每一项的`field`和`initialValue`生成。如果`model`中的属性与`field`值同名,且`initialValue`值不为空,则原`model`中的同名属性值将被覆盖。
对于日期范围框、级联选择器等值为数组的组件,提供有一份便捷的语法,请看如下示例:
```typescript
const form = useForm({
items: [
{
field: `startDate:endDate`,
field: `[startDate, endDate]`,
label: '日期范围',
type: 'dateRange',
},
{
field: 'provice:city:town',
field: '[provice: number, city: number, town: number]',
label: '省市区',
type: 'cascader',
options: []
@ -68,14 +68,14 @@ const form = useForm({
]
})
```
以上,`field`可通过`:`分隔的语法,指定提交表单时,将数组值划分到指定的属性上,最终提交的数据如下
以上,`field` 使用的是类似Typescript元组的写法类型目前支持 number 和 boolean在提交时将得到如下数据
```typescript
{
startDate: '',
endDate: '',
province: '',
city: '',
town: ''
startDate: '2023',
endDate: '2024',
province: 1,
city: 2,
town: 3
}
```
@ -229,9 +229,9 @@ const form = useForm({
### 常见问题
- Q为什么不是模板形式
- A配置式更易于描述逻辑模板介入和引入的组件比较多且对于做typescript类型提示不是很方便。
- A状态驱动,配置式更易于描述逻辑模板介入和引入的组件比较多且对于做typescript类型提示不是很方便。
- Q为什么不是JSON形式
- A对于自定义组件支持、联动等不是非常友好尽管可以通过解析字符串执行等方式实现对typescript提示不是很友好。
- A对于自定义组件支持、联动等不是非常友好尽管可以通过解析字符串执行等方式实现对typescript提示不是很友好。
### 最后
尽管看起来是低代码,但其实我更倾向于是业务组件。

View File

@ -79,18 +79,17 @@ export const FormItem = (props: any, { emit }: any) => {
type FormItemBase = {
/**
*
*
* @example
* ```typescript
* // 1. 以:分隔的字段名,将用作数组值解构。例如:
* {
* field: 'v1:v2',
* field: '[v1,v2]',
* type: 'dateRange',
* }
* // 将得到
* {
* v1: '2021-01-01',
* v2: '2021-01-02',
* v1: '2021',
* v2: '2021',
* }
* ```
*/

View File

@ -14,9 +14,7 @@ const table = useTable({
username: '用户A'
}
],
meta: {
total: 30
}
};
},
columns: [
@ -32,13 +30,12 @@ const table = useTable({
search: {
items: [
{
field: "username",
label: "用户名称",
type: "input",
extend: "username",
},
],
},
common: {
create: {
title: "新建用户",
items: [
{
field: "username",
@ -46,15 +43,13 @@ const table = useTable({
type: "input",
},
],
},
create: {
title: "新建用户",
submit: async ({ model }) => {
return api.xx(model);
},
},
modify: {
title: "修改用户",
extend: true,
submit: async ({ model }) => {
return api.xx(model);
},
@ -62,6 +57,7 @@ const table = useTable({
});
</script>
```
以上,就是一个 CRUD 表格的简单用法。参数描述:
| 参数 | 说明 | 类型 |
| :--- | :--- | :--- |
@ -75,81 +71,91 @@ const table = useTable({
| tableProps | 传递给`Table`组件的参数,参见 [Table](https://arco.design/vue/component/table) 文档,其中`columns`参数不可用。| TableProps |
### 表格数据
`data`定义表格数据,可以是数组或函数。
- 当是数组时,直接用作数据源。
- 当是函数时,传入查询参数和分页参数,可返回数组或对象,返回数组作用同上,返回对象时需遵循`{ data: [], meta: { total: number } }`格式,用于分页处理。
- 当是函数时,传入查询参数和分页参数,可返回数组或对象,返回数组作用同上,返回对象时需遵循`{ data: [], total: number }`格式,用于分页处理。
用法示例:
```typescript
const table = useTable({
data: async (search, paging) {
const { page, size: pageSize } = paging
const res = await api.xx({ ...search, page, pageSize });
const res = await api.xx({ ...search, ...paging });
return {
data: res.data,
meta: {
total: res.total
}
}
}
})
```
### 表格列
`columns`定义表格列,并在原本基础上增加默认值并扩展部分属性。增加和扩展的属性如下:
| 参数 | 说明 | 类型 |
| :--- | :--- | :--- |
| :------ | :--------------------------------------------------------------------------------------------------- | :------- | -------- |
| type | 特殊类型, 目前支持`index`(表示行数)、`button`(行操作按钮) | 'index' | 'button' |
| buttons | 当`type`为`button`时的按钮数组,如果子项是对象则为`Button`组件的参数,如果为函数则为自定义渲染函数。 | Button[]
| buttons | 当`type`为`button`时的按钮数组,如果子项是对象则为`Button`组件的参数,如果为函数则为自定义渲染函数。 | Button[] |
### 表格分页
`pagination`定义分页行为,具体参数可参考 [Pagination](https://arco.design/vue/component/pagination) 文档。当`data`为数组时,将作为数据源进行分页;当`data`为函数且返回值为对象时,则根据`total`值进行分页。
### 搜索表单
参阅
### 公共参数
参数为`FormModal`的参数,主要作为新增和修改的公共参数。在大多数情况,新增和修改的配置大多是相似的,没必要写两份,把相同的参数写在这里即可,不同的参数在`create`和`modify`中单独配置。
注意,这里的`items`也可以被搜索表单复用,搜索表单可通过`extends: <field>`继承`common.items`中对应的字段配置。使用示例如下:
```typescript
const table = useTable({
common: {
items: [
{
field: 'username',
label: '用户名称',
type: 'input',
field: "username",
label: "用户名称",
type: "input",
required: true,
}
]
},
],
},
search: {
items: [
{
extend: 'usernam',
extend: "usernam",
required: false,
}
]
}
})
},
],
},
});
```
### 新增弹窗
`create`为新增表单弹窗的参数,即`useFormModal`对应的参数。参阅。当指定该参数时,会在表格左上添加新建按钮,如需自定义按钮样式或自定义渲染,可通过`create.trigger`参数配置。
### 修改弹窗
`modify`为新增表单弹窗的参数,即`useFormModal`对应的参数。参阅。当指定该参数时,会在表格行添加修改按钮。
### 表格参数
`tableProps`为传递给`Table`组件的额外参数,其中部分参数不可用,如`data`和`columns`等。此外,部分参数有默认值,具体参数可查看`src/components/table/table.config.ts`文件。
### 插槽
- `Table`组件的插槽可正常使用
- `action`插槽用作表格左上方的操作区。
## 问题
- 问题:日期范围框值为数组,处理不方便
- 解决:字段名使用`v1:v2`格式,提交时会生成`{ v1: '00:00:01', v2: '00:00:02' }`数据
- 问题:搜索表单、新增表单和修改表单通常用到同一表单项,如何避免重复定义

View File

@ -2,6 +2,7 @@ import { Button } from "@arco-design/web-vue";
import { IconRefresh, IconSearch } from "@arco-design/web-vue/es/icon";
export const config = {
searchInlineCount: 3,
searchFormProps: {
labelAlign: "left",
autoLabelWidth: true,
@ -18,7 +19,7 @@ export const config = {
const tableRef = inject<any>("ref:table");
return (
<div class="w-full flex gap-x-2 justify-end">
{(tableRef.search?.items?.length || 0) > 3 && (
{(tableRef.search?.items?.length || 0) > config.searchInlineCount && (
<Button disabled={tableRef?.loading.value} onClick={() => tableRef?.reloadData()}>
{{ icon: () => <IconRefresh></IconRefresh>, default: () => "重置" }}
</Button>
@ -55,7 +56,7 @@ export const config = {
columnButtonBase: {
buttonProps: {
// type: "text",
size: "mini",
// size: "mini",
},
},
columnButtonDelete: {
@ -65,6 +66,10 @@ export const config = {
hideCancel: false,
maskClosable: false,
},
columnDropdownModify: {
text: "修改",
icon: "icon-park-outline-edit",
},
getApiErrorMessage(error: any): string {
const message = error?.response?.data?.message || error?.message || "请求失败";
return message;

View File

@ -76,9 +76,9 @@ export const Table = defineComponent({
const createRef = ref<FormModalInstance>();
const modifyRef = ref<FormModalInstance>();
const renderData = ref<BaseData[]>([]);
const inlined = computed(() => (props.search?.items?.length ?? 0) < 4);
const inlined = computed(() => (props.search?.items?.length ?? 0) <= config.searchInlineCount);
const reloadData = () => loadData({ current: 1, pageSize: 10 });
const openModifyModal = (data: any) => modifyRef.value?.open(data.record);
const openModifyModal = (data: any) => modifyRef.value?.open(data);
const loadData = async (pagination: Partial<any> = {}) => {
const merged = { ...props.pagination, ...pagination };
@ -185,7 +185,7 @@ export const Table = defineComponent({
<BaseTable
row-key="id"
bordered={false}
bordered={true}
{...this.tableProps}
loading={this.loading}
pagination={this.pagination}

View File

@ -1,7 +1,8 @@
import { Link, TableColumnData, TableData } from "@arco-design/web-vue";
import { Doption, Link, TableColumnData, TableData } from "@arco-design/web-vue";
import { FormModalProps, FormProps } from "../form";
import { IFormItem } from "../form/form-item";
import { TableProps } from "./table";
import { RenderFunction } from "vue";
interface UseColumnRenderOptions {
/**
@ -46,15 +47,50 @@ export interface TableColumnButton {
buttonProps?: Partial<Omit<InstanceType<typeof Link>["$props"], "onClick" | "disabled">>;
}
interface TableColumnDropdown {
/**
*
*/
type?: "modify" | "delete";
/**
*
*/
text?: string;
/**
*
*/
icon?: string | RenderFunction;
/**
*
*/
disabled?: (data: UseColumnRenderOptions) => boolean;
/**
*
*/
visibled?: (data: UseColumnRenderOptions) => boolean;
/**
*
*/
onClick?: (data: UseColumnRenderOptions) => any;
/**
*
*/
doptionProps?: Partial<InstanceType<typeof Doption> & Record<string, any>>;
}
export interface UseTableColumn extends TableColumnData {
/**
*
*/
type?: "index" | "button";
type?: "index" | "button" | "dropdown";
/**
*
*/
buttons?: TableColumnButton[];
/**
*
*/
dropdowns?: TableColumnDropdown[];
}
type ExtendedFormItem = Partial<IFormItem> & {

View File

@ -1,5 +1,5 @@
import { Link, Message, TableColumnData } from "@arco-design/web-vue";
import { defaultsDeep, isArray, merge } from "lodash-es";
import { Doption, Dropdown, Link, Message, TableColumnData } from "@arco-design/web-vue";
import { isArray, merge } from "lodash-es";
import { reactive } from "vue";
import { useFormModal } from "../form";
import { TableInstance } from "./table";
@ -7,6 +7,32 @@ import { config } from "./table.config";
import { UseTableOptions } from "./use-interface";
import { modal } from "@/utils/modal";
const onClick = async (item: any, columnData: any, getTable: any) => {
if (item.type === "modify") {
const data = (await item.onClick?.(columnData)) ?? columnData.record;
getTable()?.openModifyModal(data);
return;
}
if (item.type === "delete") {
await modal.delConfirm();
try {
const resData: any = await item?.onClick?.(columnData);
const message = resData?.data?.message;
if (message) {
Message.success(`提示:${message}`);
}
getTable()?.loadData();
} catch (error: any) {
const message = error.response?.data?.message;
if (message) {
Message.warning(`提示:${message}`);
}
}
return;
}
item.onClick?.(columnData);
};
/**
* hook
* @see `src/components/table/use-table.tsx`
@ -19,60 +45,50 @@ export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions))
/**
*
*/
for (const column of options.columns) {
for (let column of options.columns) {
/**
*
*/
if (column.type === "index") {
defaultsDeep(column, config.columnIndex);
column = merge({}, config.columnIndex, column);
}
/**
*
*/
if (column.type === "button" && isArray(column.buttons)) {
if (options.modify) {
const modifyAction = column.buttons.find((i) => i.type === "modify");
if (modifyAction) {
const { onClick } = modifyAction;
modifyAction.onClick = async (columnData) => {
const result = (await onClick?.(columnData)) || columnData;
getTable()?.openModifyModal(result);
};
} else {
column.buttons.unshift({
text: "修改",
onClick: (data) => getTable()?.openModifyModal(data),
});
const buttons = column.buttons;
let hasModify = false;
let hasDelete = false;
for (let i = 0; i < buttons.length; i++) {
let btn = merge({}, config.columnButtonBase);
if (buttons[i].type === "modify") {
btn = merge(btn, buttons[i]);
hasModify = true;
}
if (buttons[i].type === "delete") {
btn = merge(btn, buttons[i]);
hasDelete = true;
}
column.buttons = column.buttons?.map((action) => {
let onClick = action?.onClick;
if (action.type === "delete") {
onClick = async (data) => {
await modal.delConfirm();
try {
const resData: any = await action?.onClick?.(data);
const message = resData?.data?.message;
if (message) {
Message.success(`提示:${message}`);
buttons[i] = merge(btn, buttons[i]);
}
getTable()?.loadData();
} catch (error: any) {
const message = error.response?.data?.message;
if (message) {
Message.warning(`提示:${message}`);
if (!hasModify) {
buttons.push(merge({}, config.columnButtonBase));
}
if (!hasDelete) {
buttons.push(merge({}, config.columnButtonBase));
}
};
}
return { ...config.columnButtonBase, ...action, onClick } as any;
});
column.render = (columnData) => {
return column.buttons?.map((btn) => {
const onClick = () => btn.onClick?.(columnData);
const disabled = () => btn.disabled?.(columnData);
if (btn.visible && !btn.visible(columnData)) {
if (btn.visible?.(columnData) === false) {
return null;
}
return (
<Link onClick={onClick} disabled={disabled()} {...btn.buttonProps}>
<Link
{...btn.buttonProps}
onClick={() => onClick(btn, columnData, getTable)}
disabled={btn.disabled?.(columnData)}
>
{btn.text}
</Link>
);
@ -80,6 +96,53 @@ export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions))
};
}
/**
*
*/
if (column.type === "dropdown" && Array.isArray(column.dropdowns)) {
if (options.modify) {
const index = column.dropdowns?.findIndex((i) => i.type === "modify");
if (index !== undefined) {
column.dropdowns[index] = merge({}, config.columnDropdownModify, column.dropdowns[index]);
} else {
column.dropdowns?.unshift(merge({}, config.columnDropdownModify));
}
}
column.render = (columnData) => {
const content = column.dropdowns?.map((dropdown) => {
const { text, icon, disabled, visibled, doptionProps } = dropdown;
if (visibled?.(columnData) === false) {
return null;
}
return (
<Doption
{...doptionProps}
onClick={() => onClick(dropdown, columnData, getTable)}
disabled={disabled?.(columnData)}
>
{{
icon: typeof icon === "function" ? icon() : () => <i class={icon} />,
default: text,
}}
</Doption>
);
});
const trigger = () => (
<span class="inline-flex p-1 hover:bg-slate-200 rounded cursor-pointer">
<i class="icon-park-outline-more"></i>
</span>
);
return (
<Dropdown position="br">
{{
default: trigger,
content: content,
}}
</Dropdown>
);
};
}
columns.push({ ...config.columnBase, ...column });
}

View File

@ -10,7 +10,7 @@
</div>
<div class="flex items-center gap-4 text-gray-500">
<ADropdown>
<span class="cursor-pointer">
<span class="cursor-pointer hover:text-gray-900">
上传者
<i class="icon-park-outline-down"></i>
</span>
@ -31,7 +31,7 @@
</template>
</ADropdown>
<ADropdown>
<span class="cursor-pointer">
<span class="cursor-pointer hover:text-gray-900">
排序默认
<i class="icon-park-outline-down"></i>
</span>
@ -67,17 +67,17 @@
</ADropdown>
<div class="space-x-1">
<span
class="inline-flex p-1 hover:bg-slate-100 rounded cursor-pointer text-gray-400 hover:text-gray-700"
class="inline-flex p-1 hover:bg-slate-200 rounded cursor-pointer text-gray-400 hover:text-gray-700 bg-slate-200 text-slate-700"
>
<i class="icon-park-outline-list"></i>
</span>
<span
class="inline-flex p-1 hover:bg-slate-100 rounded cursor-pointer text-gray-400 hover:text-gray-700"
class="inline-flex p-1 hover:bg-slate-200 rounded cursor-pointer text-gray-400 hover:text-gray-700"
>
<i class="icon-park-outline-insert-table"></i>
</span>
<span
class="inline-flex p-1 hover:bg-slate-100 rounded cursor-pointer text-gray-400 hover:text-gray-700"
class="inline-flex p-1 hover:bg-slate-200 rounded cursor-pointer text-gray-400 hover:text-gray-700"
>
<i class="icon-park-outline-refresh"></i>
</span>

View File

@ -34,19 +34,25 @@ const table = useTable({
},
{
title: "操作",
type: "button",
width: 136,
buttons: [
type: "dropdown",
width: 60,
align: "center",
dropdowns: [
{
type: "modify",
text: "修改",
icon: "icon-park-outline-edit",
},
{
type: "delete",
text: "删除",
icon: "icon-park-outline-delete",
onClick: ({ record }) => {
return api.post.delPost(record.id);
},
doptionProps: {
class: "!text-red-500 !hover-bg-red-50",
},
},
],
},

View File

@ -22,8 +22,7 @@ const table = useTable({
title: "用户昵称",
dataIndex: "username",
width: 200,
render: ({ record }) => {
return (
render: ({ record }) => (
<div class="flex items-center">
<Avatar size={32}>
<img src={record.avatar} alt="" />
@ -33,8 +32,7 @@ const table = useTable({
<span class="text-gray-400 text-xs truncate">账号{record.username}</span>
</span>
</div>
);
},
),
},
{
title: "用户描述",
@ -63,7 +61,7 @@ const table = useTable({
type: "delete",
text: "删除",
onClick: async ({ record }) => {
return api.user.delUser(record.id);
return api.user.delUser(record.id, { toast: true });
},
},
],
@ -72,7 +70,7 @@ const table = useTable({
search: {
items: [
{
extend: "username",
extend: "nickname",
required: false,
},
],
@ -151,6 +149,11 @@ const table = useTable({
"sort": 10301,
"title": "用户管理",
"icon": "icon-park-outline-user"
},
"parentMeta": {
"title": "系统管理",
"icon": "icon-park-outline-setting",
"sort": 20000
}
}
</route>