feat: 优化表格组件

master
绝弹 2023-07-18 21:54:58 +08:00
parent e6a83318bc
commit 6ce573fda7
17 changed files with 253 additions and 310 deletions

1
.env
View File

@ -24,7 +24,6 @@ VITE_API_BASE_URL = http://127.0.0.1:3030
VITE_API_PROXY_URL = /api
# API文档地址(开发环境) 备注需为openapi规范的json文件
# VITE_API_DOCS_URL = http://127.0.0.1:3030/openapi-json
VITE_API_DOCS_URL = https://petstore.swagger.io/v2/swagger.json
# 端口号(开发环境)

View File

@ -1,52 +1,8 @@
import { FormItem as BaseFormItem, FieldRule, FormItemInstance, SelectOptionData } from "@arco-design/web-vue";
import { NodeType, NodeUnion, nodeMap } from "./form-node";
import { RuleMap } from "./form-rules";
const defineRuleMap = <T extends Record<string, FieldRule>>(ruleMap: T) => ruleMap;
const ruleMap = defineRuleMap({
required: {
required: true,
message: "该项不能为空",
},
string: {
type: "string",
message: "请输入字符串",
},
number: {
type: "number",
message: "请输入数字",
},
email: {
type: "email",
message: "邮箱格式错误,示例: xx@abc.com",
},
url: {
type: "url",
message: "URL格式错误, 示例: www.abc.com",
},
ip: {
type: "ip",
message: "IP格式错误, 示例: 101.10.10.30",
},
phone: {
match: /^(?:(?:\+|00)86)?1\d{10}$/,
message: "手机格式错误, 示例(11位): 15912345678",
},
idcard: {
match: /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/,
message: "身份证格式错误, 长度为15或18位",
},
alphabet: {
match: /^[a-zA-Z]\w{4,15}$/,
message: "请输入英文字母, 长度为4~15位",
},
password: {
match: /^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Z])(?=\S*[a-z])(?=\S*[!@#$%^&*? ])\S*$/,
message: "至少包含大写字母、小写字母、数字和特殊字符",
},
});
export type FieldStringRule = keyof typeof ruleMap;
export type FieldStringRule = keyof typeof RuleMap;
export type FieldObjectRule = FieldRule & {
disable?: (arg: { item: IFormItem; model: Record<string, any> }) => boolean;
@ -67,11 +23,11 @@ export const FormItem = (props: any, { emit }: any) => {
const rules = computed(() => {
const result = [];
if (item.required) {
result.push(ruleMap.required);
result.push(RuleMap.required);
}
item.rules?.forEach((rule: any) => {
if (typeof rule === "string") {
result.push(ruleMap[rule as FieldStringRule]);
result.push(RuleMap[rule as FieldStringRule]);
return;
}
if (!rule.disable) {
@ -144,9 +100,9 @@ type FormItemBase = {
/**
*
* @description undefinedmodel
* @description model
*/
initialValue?: any;
initial?: any;
/**
*
@ -172,14 +128,16 @@ type FormItemBase = {
* @example
* ```typescript
* rules: [
* 'idcard', // 内置的身份证号校验规则
* // 内置
* 'idcard',
* // 自定义
* {
* match: /\d+/,
* message: '请输入数字',
* },
* ]
*```
* @see https://arco.design/vue/component/form#FieldRule
*```
* @see https://arco.design/vue/component/form#FieldRule
*/
rules?: FieldRuleType[];

View File

@ -17,29 +17,27 @@ import {
const initOptions = ({ item, model }: any) => {
if (Array.isArray(item.options)) {
item.nodeProps.options = item.options;
return;
}
if (typeof item.options !== "function") {
return;
if (typeof item.options === "function") {
item.nodeProps.options = reactive([]);
const fetchData = item.options;
item._updateOptions = async () => {
let data = await fetchData({ item, model });
if (Array.isArray(data?.data)) {
data = data.data.map((i: any) => ({ label: i.name, value: i.id }));
}
if (Array.isArray(data)) {
item.nodeProps.options.splice(0);
item.nodeProps.options.push(...data);
}
};
item._updateOptions();
}
item.nodeProps.options = reactive([]);
const fetchData = item.options;
item._updateOptions = async () => {
let data = await fetchData({ item, model });
if (Array.isArray(data?.data)) {
data = data.data.map((i: any) => ({ label: i.name, value: i.id }));
}
if (Array.isArray(data)) {
item.nodeProps.options.splice(0);
item.nodeProps.options.push(...data);
}
};
item._updateOptions();
};
const defineNodeMap = <T extends { [key: string]: { component: any, nodeProps: any } }>(map: T) => {
return map
}
const defineNodeMap = <T extends { [key: string]: { component: any; nodeProps: any } }>(map: T) => {
return map;
};
/**
*

View File

@ -0,0 +1,46 @@
import { FieldRule } from "@arco-design/web-vue";
const defineRuleMap = <T extends Record<string, FieldRule>>(ruleMap: T) => ruleMap;
export const RuleMap = defineRuleMap({
required: {
required: true,
message: "该项不能为空",
},
string: {
type: "string",
message: "请输入字符串",
},
number: {
type: "number",
message: "请输入数字",
},
email: {
type: "email",
message: "邮箱格式错误,示例: xx@abc.com",
},
url: {
type: "url",
message: "URL格式错误, 示例: www.abc.com",
},
ip: {
type: "ip",
message: "IP格式错误, 示例: 101.10.10.30",
},
phone: {
match: /^(?:(?:\+|00)86)?1\d{10}$/,
message: "手机格式错误, 示例(11位): 15912345678",
},
idcard: {
match: /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/,
message: "身份证格式错误, 长度为15或18位",
},
alphabet: {
match: /^[a-zA-Z]\w{4,15}$/,
message: "请输入英文字母, 长度为4~15位",
},
password: {
match: /^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Z])(?=\S*[a-z])(?=\S*[!@#$%^&*? ])\S*$/,
message: "至少包含大写字母、小写字母、数字和特殊字符",
},
});

View File

@ -4,6 +4,8 @@ import { PropType } from "vue";
import { FormItem, IFormItem } from "./form-item";
import { NodeType, nodeMap } from "./form-node";
type SubmitFn = (arg: { model: Record<string, any>; items: IFormItem[] }) => Promise<any>;
/**
*
*/
@ -28,7 +30,7 @@ export const Form = defineComponent({
*
*/
submit: {
type: Function as PropType<(arg: { model: Record<string, any>; items: IFormItem[] }) => Promise<any>>,
type: Function as PropType<SubmitFn>,
},
/**
* Form
@ -68,6 +70,21 @@ export const Form = defineComponent({
return model;
};
const setModel = (model: Record<string, any>) => {
for (const key of Object.keys(props.model)) {
if (/[^:]+:[^:]+/.test(key)) {
const [key1, key2] = key.split(":");
props.model[key] = [model[key1], model[key2]];
} else {
props.model[key] = model[key];
}
}
};
const resetModel = () => {
assign(props.model, model);
};
const submitForm = async () => {
if (await formRef.value?.validate()) {
return;
@ -82,21 +99,6 @@ export const Form = defineComponent({
}
};
const resetModel = () => {
assign(props.model, model);
};
const setModel = (model: Record<string, any>) => {
for (const key of Object.keys(props.model)) {
if (/.+:.+/.test(key)) {
const [key1, key2] = key.split(":");
props.model[key] = [model[key1], model[key2]];
} else {
props.model[key] = model[key];
}
}
};
return {
formRef,
loading,

View File

@ -34,13 +34,13 @@ export const useForm = (options: Options) => {
}
if (/(.+)\?(.+)/.test(item.field)) {
const [field, condition] = item.field.split("?");
model[field] = item.initialValue ?? model[item.field];
model[field] = item.initial ?? model[item.field];
const params = new URLSearchParams(condition);
for (const [key, value] of params.entries()) {
model[key] = value;
}
}
model[item.field] = model[item.field] ?? item.initialValue;
model[item.field] = model[item.field] ?? item.initial;
const _item = { ...item };
items.push(_item);
});

View File

@ -1,71 +1,62 @@
import { Button } from "@arco-design/web-vue";
import { IconRefresh, IconSearch } from "@arco-design/web-vue/es/icon";
/**
*
*/
export const TABLE_SEARCH_DEFAULTS = {
labelAlign: "left",
autoLabelWidth: true,
model: {},
};
/**
*
*/
export const TABLE_COLUMN_DEFAULTS = {
ellipsis: true,
tooltip: true,
render: ({ record, column }: any) => record[column.dataIndex] || "-",
};
/**
*
*/
export const TABLE_ACTION_DEFAULTS = {
buttonProps: {
type: "primary",
export const config = {
searchFormProps: {
labelAlign: "left",
autoLabelWidth: true,
model: {},
},
};
/**
*
*/
export const TABLE_DELTE_DEFAULTS = {
title: "删除确认",
content: "确认删除当前数据吗?",
modalClass: "text-center",
hideCancel: false,
maskClosable: false,
};
export const TALBE_INDEX_DEFAULTS = {
title: "#",
width: 60,
align: "center",
render: ({ rowIndex }: any) => rowIndex + 1,
};
export const searchItem = {
field: "id",
type: "custom",
itemProps: {
class: "table-search-item col-start-4 !mr-0 grid grid-cols-[0_1fr]",
hideLabel: true,
},
component: () => {
const tableRef = inject<any>("ref:table");
return (
<div class="w-full flex gap-x-2 justify-end">
{(tableRef.search?.items?.length || 0) > 3 && (
<Button disabled={tableRef?.loading.value} onClick={() => tableRef?.reloadData()}>
{{ icon: () => <IconRefresh></IconRefresh>, default: () => "重置" }}
searchItemSubmit: {
field: "id",
type: "custom",
itemProps: {
class: "table-search-item col-start-4 !mr-0 grid grid-cols-[0_1fr]",
hideLabel: true,
},
component: () => {
const tableRef = inject<any>("ref:table");
return (
<div class="w-full flex gap-x-2 justify-end">
{(tableRef.search?.items?.length || 0) > 3 && (
<Button disabled={tableRef?.loading.value} onClick={() => tableRef?.reloadData()}>
{{ icon: () => <IconRefresh></IconRefresh>, default: () => "重置" }}
</Button>
)}
<Button type="primary" loading={tableRef?.loading.value} onClick={() => tableRef?.loadData()}>
{{ icon: () => <IconSearch></IconSearch>, default: () => "查询" }}
</Button>
)}
<Button type="primary" loading={tableRef?.loading.value} onClick={() => tableRef?.loadData()}>
{{ icon: () => <IconSearch></IconSearch>, default: () => "查询" }}
</Button>
</div>
);
</div>
);
},
},
pagination: {
current: 1,
pageSize: 10,
total: 300,
showTotal: true,
},
columnBase: {
ellipsis: true,
tooltip: true,
render: ({ record, column }: any) => record[column.dataIndex] || "-",
},
columnIndex: {
title: "序号",
width: 60,
align: "center",
render: ({ rowIndex }: any) => rowIndex + 1,
},
columnButtonBase: {
buttonProps: {
type: "primary",
},
},
columnButtonDelete: {
title: "删除确认",
content: "确认删除当前数据吗?",
modalClass: "text-center",
hideCancel: false,
maskClosable: false,
},
};

View File

@ -1,11 +1,11 @@
import {
TableColumnData as BaseColumn,
TableData as BaseData,
Table as BaseTable,
Divider,
} from "@arco-design/web-vue";
import { TableColumnData as BaseColumn, TableData as BaseData, Table as BaseTable } from "@arco-design/web-vue";
import { merge } from "lodash-es";
import { PropType, computed, defineComponent, reactive, ref, watch } from "vue";
import { Form, FormInstance, FormModal, FormModalInstance, FormModalProps, FormProps } from "../form";
import { config } from "./table.config";
type DataFn = (search: Record<string, any>, paging: { page: number; size: number }) => Promise<any>;
type Data = BaseData[] | DataFn;
/**
*
@ -18,9 +18,7 @@ export const Table = defineComponent({
*
*/
data: {
type: [Array, Function] as PropType<
BaseData[] | ((search: Record<string, any>, paging: { page: number; size: number }) => Promise<any>)
>,
type: [Array, Function] as PropType<Data>,
},
/**
*
@ -34,7 +32,7 @@ export const Table = defineComponent({
*/
pagination: {
type: Object as PropType<any>,
default: () => reactive({ current: 1, pageSize: 10, total: 300, showTotal: true }),
default: () => reactive(config.pagination),
},
/**
*
@ -73,25 +71,17 @@ export const Table = defineComponent({
const createRef = ref<FormModalInstance>();
const modifyRef = ref<FormModalInstance>();
const renderData = ref<BaseData[]>([]);
const inlineSearch = computed(() => (props.search?.items?.length || 0) < 4);
Object.assign(props.columns, { getInstance: () => getCurrentInstance() });
const getPaging = (pagination: Partial<any>) => {
const { current: page, pageSize: size } = { ...props.pagination, ...pagination } as any;
return { page, size };
};
const inlined = computed(() => (props.search?.items?.length ?? 0) < 4);
const reloadData = () => loadData({ current: 1, pageSize: 10 });
const openModifyModal = (data: any) => modifyRef.value?.open(data.record);
const loadData = async (pagination: Partial<any> = {}) => {
if (!props.data) {
return;
}
const merged = { ...props.pagination, ...pagination };
const paging = { page: merged.current, size: merged.pageSize };
const model = searchRef.value?.getModel() ?? {};
if (Array.isArray(props.data)) {
if (!props.search?.model) {
return;
}
const filters = Object.entries(props.search?.model || {});
const data = props.data?.filter((item) => {
const filters = Object.entries(model);
const data = props.data.filter((item) => {
return filters.every(([key, value]) => {
if (typeof value === "string") {
return item[key].includes(value);
@ -99,59 +89,35 @@ export const Table = defineComponent({
return item[key] === value;
});
});
renderData.value = data || [];
renderData.value = data;
props.pagination.total = renderData.value.length;
props.pagination.current = 1;
return;
}
if (typeof props.data !== "function") {
return;
if (typeof props.data === "function") {
try {
loading.value = true;
const resData = await props.data(model, paging);
const { data = [], meta = {} } = resData || {};
const { page: pageNum, total } = meta;
renderData.value = data;
props.pagination.total = total;
props.pagination.current = pageNum;
} catch (error) {
console.log("table error", error);
} finally {
loading.value = false;
}
}
const model = searchRef.value?.getModel() || {};
const paging = getPaging(pagination);
try {
loading.value = true;
const resData = await props.data(model, paging);
const { data = [], meta = {} } = resData || {};
const { page: pageNum, total } = meta;
renderData.value = data;
Object.assign(props.pagination, { current: pageNum, total });
} catch (error) {
console.log("table error", error);
} finally {
loading.value = false;
}
};
const reloadData = () => {
loadData({ current: 1, pageSize: 10 });
};
const openModifyModal = (data: any) => {
modifyRef.value?.open(data.record);
};
const onPageChange = (current: number) => {
loadData({ current });
};
const onCreateOk = () => {
reloadData();
};
const onModifyOk = () => {
reloadData();
};
watch(
() => props.data,
(data) => {
if (!Array.isArray(data)) {
return;
if (Array.isArray(data)) {
renderData.value = data;
props.pagination.total = data.length;
props.pagination.current = 1;
}
renderData.value = data;
props.pagination.total = data.length;
props.pagination.current = 1;
},
{
immediate: true,
@ -162,19 +128,20 @@ export const Table = defineComponent({
loadData();
});
if (props.search) {
merge(props.search, { formProps: { layout: "inline" } });
}
const state = {
loading,
inlined,
searchRef,
createRef,
modifyRef,
renderData,
inlineSearch,
loadData,
reloadData,
openModifyModal,
onPageChange,
onCreateOk,
onModifyOk,
};
provide("ref:table", { ...state, ...props });
@ -185,52 +152,33 @@ export const Table = defineComponent({
(this.columns as any).instance = this;
return (
<div class="bh-table w-full">
{!this.inlineSearch && (
<div class="">
<Form ref={(el: any) => (this.searchRef = el)} class="grid grid-cols-4 gap-x-4" {...this.search}></Form>
{!this.inlined && (
<div class="pb-5 border-b border-slate-200 mb-5">
<Form ref="searchRef" class="grid grid-cols-4 gap-x-4" {...this.search}></Form>
</div>
)}
{!this.inlineSearch && <Divider class="mt-0 border-gray-200" />}
<div class={`mb-2 flex justify-between ${!this.inlineSearch && "mt-2"}`}>
<div class={`mb-2 flex justify-between ${!this.inlined && "mt-2"}`}>
<div class="flex-1 flex gap-2">
{this.create && (
<FormModal
ref={(el: any) => (this.createRef = el)}
onOk={this.onCreateOk}
{...(this.create as any)}
></FormModal>
)}
{this.create && <FormModal ref="createRef" onOk={this.reloadData} {...(this.create as any)}></FormModal>}
{this.modify && (
<FormModal
ref={(el: any) => (this.modifyRef = el)}
onOk={this.onModifyOk}
trigger={false}
{...(this.modify as any)}
></FormModal>
<FormModal ref="modifyRef" onOk={this.reloadData} trigger={false} {...(this.modify as any)}></FormModal>
)}
{this.$slots.action?.()}
</div>
<div>
{this.inlineSearch && (
<Form
ref={(el: any) => (this.searchRef = el)}
{...{ ...this.search, formProps: { layout: "inline" } }}
></Form>
)}
</div>
</div>
<div>
<BaseTable
row-key="id"
bordered={false}
{...this.tableProps}
loading={this.loading}
pagination={this.pagination}
data={this.renderData}
columns={this.columns}
onPageChange={this.onPageChange}
></BaseTable>
<div>{this.inlined && <Form ref="searchRef" {...this.search}></Form>}</div>
</div>
<BaseTable
row-key="id"
bordered={false}
{...this.tableProps}
loading={this.loading}
pagination={this.pagination}
data={this.renderData}
columns={this.columns}
onPageChange={(current: number) => this.loadData({ current })}
></BaseTable>
</div>
);
},

View File

@ -1,14 +1,8 @@
import { Link, Message, Modal, TableColumnData } from "@arco-design/web-vue";
import { defaultsDeep, isArray, isFunction, mergeWith, omit } from "lodash-es";
import { defaultsDeep, isArray, isFunction, mergeWith } from "lodash-es";
import { reactive } from "vue";
import { TableInstance } from "./table";
import {
TABLE_ACTION_DEFAULTS,
TABLE_COLUMN_DEFAULTS,
TABLE_DELTE_DEFAULTS,
TALBE_INDEX_DEFAULTS,
searchItem,
} from "./table.config";
import { config } from "./table.config";
import { UseTableOptions } from "./use-interface";
const merge = (...args: any[]) => {
@ -34,18 +28,15 @@ export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions))
const getTable = (): TableInstance => (columns as any).instance;
/**
*
*/
options.columns.forEach((column) => {
// 序号
if (column.type === "index") {
defaultsDeep(column, TALBE_INDEX_DEFAULTS);
defaultsDeep(column, config.columnIndex);
}
// 操作
if (column.type === "button" && isArray(column.buttons)) {
if (options.detail) {
column.buttons.unshift({ text: "详情", onClick: (data) => {} });
}
if (options.modify) {
const modifyAction = column.buttons.find((i) => i.type === "modify");
if (modifyAction) {
@ -68,11 +59,10 @@ export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions))
column.buttons = column.buttons?.map((action) => {
let onClick = action?.onClick;
if (action.type === "delete") {
onClick = (data) => {
Modal.warning({
...TABLE_DELTE_DEFAULTS,
...config.columnButtonDelete,
onOk: async () => {
const resData: any = await action?.onClick?.(data);
resData.msg && Message.success(resData?.msg || "");
@ -81,27 +71,26 @@ export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions))
});
};
}
return { ...TABLE_ACTION_DEFAULTS, ...action, onClick } as any;
return { ...config.columnButtonBase, ...action, onClick } as any;
});
column.render = (columnData) =>
column.buttons?.map((action) => {
const onClick = () => action.onClick?.(columnData);
const omitKeys = ["text", "render", "api", "action", "onClick", "disabled"];
const disabled = () => action.disabled?.(columnData);
if (action.visible && !action.visible(columnData)) {
column.render = (columnData) => {
return column.buttons?.map((btn) => {
const onClick = () => btn.onClick?.(columnData);
const disabled = () => btn.disabled?.(columnData);
if (btn.visible && !btn.visible(columnData)) {
return null;
}
return (
<Link onClick={onClick} disabled={disabled()} {...omit(action as any, omitKeys)}>
{action.text}
<Link onClick={onClick} disabled={disabled()} {...btn.buttonProps}>
{btn.text}
</Link>
);
});
};
}
columns.push({ ...TABLE_COLUMN_DEFAULTS, ...column });
columns.push({ ...config.columnBase, ...column });
});
const itemsMap = options.common?.items?.reduce((map, item) => {
@ -114,28 +103,34 @@ export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions))
*/
if (options.search && options.search.items) {
const searchItems: any[] = [];
options.search.items.forEach((item) => {
for (const item of options.search.items) {
if (typeof item === "string") {
if (!itemsMap[item]) {
throw new Error(`search item ${item} not found in common items`);
}
searchItems.push(itemsMap[item]);
return;
continue;
}
if ("extend" in item && item.extend && itemsMap[item.extend]) {
searchItems.push(merge({}, itemsMap[item.extend], item));
return;
continue;
}
searchItems.push(item);
});
searchItems.push(searchItem);
}
searchItems.push(config.searchItemSubmit);
options.search.items = searchItems;
}
/**
*
*/
if (options.create && propTruly(options.create, "extend")) {
options.create = merge(options.common, options.create);
}
/**
*
*/
if (options.modify && propTruly(options.modify, "extend")) {
options.modify = merge(options.common, options.modify);
}

View File

@ -5,12 +5,14 @@
</template>
<script setup lang="tsx">
import { ContentType, api } from "@/api";
import { Table, useTable } from "@/components";
const table = useTable({
data: async (model, paging) => ({ data: [{}], meta: { total: 0 } }),
columns: [
{
type: "index",
},
{
title: "姓名",
dataIndex: "username",
@ -66,7 +68,13 @@ const table = useTable({
{
label: "头像",
field: "avatar?avatarUrl",
type: "input",
type: "select",
},
{
field: "startTime:endTime",
label: "日期范围",
type: "dateRange",
nodeProps: {},
},
],
modalProps: {
@ -89,16 +97,14 @@ const table = useTable({
create: {
title: "新建用户",
submit: ({ model }) => {
return api.user.createUser(model as any, {
type: ContentType.FormData,
});
console.log(model);
},
},
modify: {
extend: true,
title: "修改用户",
submit: ({ model }) => {
return api.user.updateUser(model.id, model);
console.log(model);
},
},
});

View File

@ -1,9 +1,9 @@
import "uno.css";
import { Plugin } from "vue";
import "./arco-design.less";
import "./style.less";
import "./transition.less";
import "./uno.less";
import "./css-arco.less";
import "./css-base.less";
import "./css-transition.less";
import "./css-unocss.less";
export const style: Plugin = {
install(app) {},

View File

@ -39,7 +39,7 @@ export default defineConfig(({ mode }) => {
less: {
javascriptEnabled: true,
modifyVars: {
hack: `true; @import (reference) "${resolve("src/style/arco-design.less")}";`,
hack: `true; @import (reference) "${resolve("src/style/css-arco.less")}";`,
arcoblue: "#66f",
},
},