前言
相信各位小伙伴很多时候都遇到过产品给你提后台增删改查的需求,这些需求往往不难,重复性的工作罢了,但是这些重复性的工作有没有把它整合起来呢,封装成公共的,这样下次产品再来提,就不用每次都手写一大堆,正常交付的同时还能有时间去做自己喜欢的事情,嘿嘿,下面是我的一些思路,能帮到各位小伙伴的话,那就点点赞吧,不要逼我跪下来求你。
背景
后台是基于vue3+ts+pinia+unocss+naiv ui 搭建的后台项目,很多ui库的组件其实都是大差不差的,喜欢啥用啥就好了。然后是根据naive ui提供的组件,封装了一个通用的表格,以及查询的组件,构成了页面最基础的增改查功能。
unocss文档:unocss.dev/
naive ui文档:www.naiveui.com/zh-CN/os-th...
pinia文档:pinia.vuejs.org/zh/core-con...
组件设计
1.表格上面根据需求有对应的按钮,比如:新增、导出、导入等等
2.最上面是基础的查询栏,根据需求选择不同的组件,例如:输入框、下拉选择框、时间选择器
废话不多说,先从查询栏开始
一、首先定义类型
form.ts
import type { InputProps, SelectProps } from 'naive-ui'
export enum componentType {
input = 'n-input',
select = 'n-select',
datePicker = 'date-picker',
}
interface FormItemProps {
// 属性字段名称
field?: string
// 默认值
defaultValue?: any
// 名称
label: string
// 组件参数
componentProps?: InputProps | SelectProps | any
// 组件类型
component: componentType
focus?: boolean
datePickerProps?: {
startField?: string
endField?: string
}
display?: boolean
displayField?: string
displayChoose?: Array<string | number>
displayElse?: Array<string | number>
}
export type FormItemsProps = FormItemProps[]
二、定义组件的hooks
useFormItemsProps.ts
import { computed, ref } from 'vue'
import type { FormItemsProps } from '../types'
import { componentType } from '../types'
import { isUndefined } from '~/src/utils/common'
export function useFormItemsProps(formItems: FormItemsProps) {
const getFormItemsProps = computed(() => {
const props = ref<any[]>([])
formItems.forEach((item) => {
const { componentProps, label } = item
const prop: any = {}
if (isUndefined(componentProps?.placeholder)) {
switch (item.component) {
case componentType.input:
prop.placeholder = `请输入${label}`
break
case componentType.select:
prop.placeholder = `请选择${label}`
// 在传入的选项中没有对应当前值的选项时,这个值应该对应的选项。如果设为 false,不会为找不到对应选项的值生成回退选项也不会显示它,未在选项中的值会被视为不合法,操作过程中会被组件清除掉
prop['fallback-option'] = false
break
case componentType.datePicker:
prop.placeholder = `请选择${label}`
break
}
}
// prop.clearable = true
prop.filterable = true
props.value.push({
...prop,
...componentProps,
})
})
return {
...props.value,
}
})
return {
getFormItemsProps,
}
}
useFormValue.ts
import type { FormItemsProps } from '../types'
import { componentType } from '../types'
import { DATE_DAYS, DATE_FORMAT, END_FIELD, START_FIELD } from '../const'
import { dateSub, formatToDate } from '~/src/utils/common/dateUtil'
export function useFormValue(formModel: any, formItems: FormItemsProps) {
function initFormValue() {
formItems.forEach((item) => {
const { field, defaultValue, component } = item
if (component === componentType.batchSelect) {
formModel[field as string] = []
return
}
if (component === componentType.datePicker) {
if (defaultValue === '') {
formModel[item.datePickerProps?.startField || START_FIELD] = ''
formModel[item.datePickerProps?.endField || END_FIELD] = ''
}
else {
formModel[item.datePickerProps?.startField || START_FIELD] = dateSub(new Date(), defaultValue ? (defaultValue as number) - 1 : DATE_DAYS - 1)
formModel[item.datePickerProps?.endField || END_FIELD] = formatToDate(new Date(), DATE_FORMAT)
}
return
}
formModel[field as string] = defaultValue || ''
})
}
function restFormValue() {
initFormValue()
}
return {
initFormValue,
restFormValue,
}
}
const.ts 文件是定义属性字段的
const.ts
// 日期选择开始时间字段
export const START_FIELD = 'start_time'
// 日期选择结束时间字段
export const END_FIELD = 'end_time'
// 日期选择格式
export const DATE_FORMAT = 'yyyy-MM-dd'
// 日期选择默认天数
export const DATE_DAYS = 7
isUndefined是我在utils定义的typeof类型
typeof.ts
enum EnumDataType {
number = '[object Number]',
string = '[object String]',
boolean = '[object Boolean]',
null = '[object Null]',
undefined = '[object Undefined]',
object = '[object Object]',
array = '[object Array]',
date = '[object Date]',
regexp = '[object RegExp]',
set = '[object Set]',
map = '[object Map]',
file = '[object File]',
}
export function isNumber(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.number
}
export function isString(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.string
}
export function isBoolean(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.boolean
}
export function isNull(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.null
}
export function isUndefined(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.undefined
}
export function isObject(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.object
}
export function isArray(data: unknown): data is Array<any> {
return Object.prototype.toString.call(data) === EnumDataType.array
}
export function isDate(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.date
}
export function isRegExp(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.regexp
}
export function isSet(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.set
}
export function isMap(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.map
}
export function isFile(data: unknown) {
return Object.prototype.toString.call(data) === EnumDataType.file
}
三、QueryForm.vue文件的代码
js
<script setup lang="ts">
import { computed, onMounted, reactive, ref, unref, watch } from 'vue'
import { componentType } from './types'
import type { FormItemsProps } from './types'
import { END_FIELD, START_FIELD } from './const'
import { useFormItemsProps, useFormValue } from './hooks'
interface Props {
formItems: FormItemsProps
// 是否展示重置按钮
showResetButton?: boolean
// 是否展示查询按钮
showQueryButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showResetButton: true,
showQueryButton: true,
})
const emits = defineEmits(['search', 'reset'])
const getProps = computed(() => {
return {
...props,
}
})
const formModel = reactive({})
const focusIndex = ref(-1)
const { initFormValue, restFormValue } = useFormValue(formModel, getProps.value.formItems)
const { getFormItemsProps } = useFormItemsProps(getProps.value.formItems)
onMounted(() => {
//这里是输入完值后除了点击查询按钮会进行查询操作,按回车也能进行查询操作
window.addEventListener('keydown', handkeyCode, true)
initFormValue()
emits('search', unref(formModel))
})
function handkeyCode(e) {
if (e.keyCode === 13)
handleSearch()
}
function handleSearch() {
emits('search', unref(formModel))
}
watch(
formModel,
async (newValue) => {
for (let index = 0; index < props.formItems.length; index++) {
const item = props.formItems[index] as any
if (('display' in item) === false)
item.display = true
if (item.displayField) {
if (item.displayChoose?.length > 0) {
item.displayChoose?.forEach((displayItem) => {
if (formModel[item.displayField] === displayItem)
item.display = true
else
item.display = false
})
}
if (item.displayElse?.length > 0) {
item.displayElse?.forEach((displayItem) => {
if (formModel[item.displayField] === displayItem)
item.display = false
else
item.display = true
})
}
}
}
},
{ immediate: true },
)
function clearOneForm(fieldArr: [string]) {
fieldArr.forEach((item) => {
formModel[item] = ''
})
}
function handleReset() {
restFormValue()
handleSearch()
emits('reset')
}
defineExpose({
handleSearch,
clearOneForm,
})
</script>
<template>
<div class="pt-10px">
<n-form inline label-placement="left" :show-feedback="false" :show-label="false" class="flex" style="flex-wrap: wrap;margin-bottom: -20px;">
<div v-for="(item, index) in formItems" :key="index">
<n-form-item v-if="item.display" :key="index" style="width:300px;margin-right: 20px;margin-bottom: 20px;">
<p class="flex absolute left-6px z-10 text-14px lable-bg pl-2px pr-2px" :class="focusIndex === index ? 'form-lebel__focus' : ''">
{{ item.label }}
</p>
<DatePicker
v-if="item.component === componentType.datePicker"
v-model:startTime="formModel[item.datePickerProps?.startField || START_FIELD]"
v-model:endTime="formModel[item.datePickerProps?.endField || END_FIELD]"
:on-focus="() => { focusIndex = index }"
:on-blur="() => { focusIndex = -1 }"
/>
<component
v-bind="getFormItemsProps[index]"
:is="item.component"
v-else
v-model:value="formModel[item.field as string]"
:on-focus="() => { focusIndex = index }"
:on-blur="() => { focusIndex = -1 }"
/>
</n-form-item>
</div>
<n-form-item style="width:300px;margin-right: 20px;margin-bottom: 20px;">
<n-space>
<n-button v-if="showQueryButton" type="primary" @click="handleSearch">
查询
<template #icon>
<svg-icon icon="ri:search-line" />
</template>
</n-button>
<n-button v-if="showResetButton" @click="handleReset">
重置
</n-button>
<slot name="buttons" />
</n-space>
</n-form-item>
</n-form>
</div>
</template>
<style scoped>
.lable-bg {
background-image: linear-gradient(#fff, #fff);
top: -12px;
line-height: normal !important;
}
.form-lebel__focus {
transition: color .3s cubic-bezier(0.4, 0, 0.2, 1);
color:#40a9ff !important;
}
</style>
DatePicker是根据ui库的日期选择器+产品的需求封装的业务组件,也一并分享出来
DatePicker.vue
<script setup lang="ts">
import { computed } from 'vue'
import { formatToDate, getTimeStamp } from '@/utils/common/dateUtil'
const props = defineProps(['startTime', 'endTime'])
const emit = defineEmits(['update:startTime', 'update:endTime'])
const getValue = computed(() => {
if (props.startTime && props.endTime)
return [getTimeStamp(new Date(props.startTime)), getTimeStamp(new Date(props.endTime))] as [number, number]
else
return null
})
function handleUpdate(value: [number, number] | null) {
if (value) {
emit('update:startTime', formatToDate(value[0]))
emit('update:endTime', formatToDate(value[1]))
}
else {
emit('update:startTime', '')
emit('update:endTime', '')
}
}
</script>
<template>
<n-date-picker :value="getValue" type="daterange" close-on-select clearable @update:value="handleUpdate" />
</template>
dateUtil.ts
import { addDays, format, getTime, subDays } from 'date-fns'
const DATE_TIME_FORMAT = 'yyyy-MM-dd hh:mm:ss'
const DATE_FORMAT = 'yyyy-MM-dd'
export function formatToDateTime(date: number | Date, formatStr = DATE_TIME_FORMAT): string {
return format(date, formatStr)
}
export function formatToDate(date: number | Date, formatStr = DATE_FORMAT): string {
return format(date, formatStr)
}
export function dateAdd(date: number | Date, days: number, formatStr = DATE_FORMAT): string {
return format(addDays(date, days), formatStr)
}
export function dateSub(date: number | Date, days: number, formatStr = DATE_FORMAT): string {
return format(subDays(date, days), formatStr)
}
export function getTimeStamp(date?: number | Date): number {
return getTime(date || new Date())
}
// 获取当天时间段
export function getToDayTime(): { startTime: string; endTime: string } {
return {
startTime: `${format(new Date(), DATE_FORMAT)} 00:00:00`,
endTime: `${format(new Date(), DATE_FORMAT)} 23:59:59`,
}
}
然后是数据表格
一、定义props的类型和button的类型
props.ts
import type { ButtonProps } from 'naive-ui'
import type { TableBaseColumn } from 'naive-ui/es/data-table/src/interface'
interface ActionColumn extends ButtonProps {
name: string
onClick: (arg: any) => void
}
export interface BasicColumnItem extends TableBaseColumn {
// 内容类型
contentType?: 'text' | 'copy' | 'switch' | 'img' | 'actions'
// 图片style
imgStyle?: string
actionColumn?: ActionColumn[]
switchRequest?: (arg: any) => void
switchId?: string
displayKey?: string
switchOptkey?: string[]
type?: any
}
export type RequestFn = (opt?: any) => void
export type BasicColumns = BasicColumnItem[]
buttons.ts
import type { ButtonProps } from 'naive-ui'
interface BasicButtonProps extends ButtonProps {
name: any
icon?: string
}
export type BasicButtonsProps = BasicButtonProps[]
二、接下来是hooks,我分为了三部分,表格分页的hook、表格刷新的hook、表格属性的hook
useColumns.tsx
import type { ComputedRef } from 'vue'
import { computed, ref, unref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { NButton, NEllipsis, NImage, NSpace, NSwitch } from 'naive-ui'
import { useLoanStore } from 'loan-package/config'
import type { BasicColumns } from '../types'
import { copyToClipboard } from '~/src/utils/quick/copyPaste'
export function useColumns(basicColumns: ComputedRef<BasicColumns>) {
const columnsRef = ref(unref(basicColumns))
const { getApplet } = storeToRefs(useLoanStore())
watch(() => unref(basicColumns), (columns) => {
columnsRef.value = columns
})
const getColumns = computed(() => {
return columnsRef.value.map((item) => {
if (item.contentType === 'copy') {
item.render = function (row) {
return <div style='cursor: pointer;max-width:170px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;' onClick={() => {
copyToClipboard(row[item.key] as string)
}}>{row[item.key]}</div>
}
}
if (item.contentType === 'switch') {
item.render = function (row) {
return <NSwitch checked-value="1" unchecked-value="0" value={row[item.key] as any} on-update:value={async (value: boolean) => {
const srId = item.switchId || 'id'
const displayKey = item.displayKey || item.key || 'display'
const options = {}
if (item.switchOptkey?.length !== 0) {
if (row.applet_id) {
item.switchOptkey?.forEach((key) => {
options[key] = row[key]
})
}
else {
row.applet_id = getApplet.value[0]
item.switchOptkey?.forEach((key) => {
options[key] = row[key]
})
}
}
if (item.switchRequest) {
const res = await item.switchRequest({
[srId]: row[srId],
[displayKey]: value,
...options,
})
if (!res.error)
row[item.key] = value
}
} } ></NSwitch>
}
}
if (item.contentType === 'img') {
item.render = function (row) {
const value = row[item.key]
if (Array.isArray(value)) {
return value.map((img) => {
return <NImage style={item.imgStyle} src={img as string} ></NImage>
})
}
return <NImage style={item.imgStyle} src={row[item.key] as string} ></NImage>
}
}
if (item.contentType === 'actions') {
item.render = function (row) {
return <NSpace>{item.actionColumn?.map((action) => {
const btnProps: any = { ...action }
delete btnProps.onClick
return <NButton {...btnProps} onClick={() => {
action.onClick(row)
}}>{action.name}</NButton>
})}</NSpace>
}
}
return {
...item,
ellipsis: {
tooltip: true,
},
ellipsisComponent: 'performant-ellipsis'
}
})
})
return {
getColumns,
}
}
usePagination.ts
import { reactive } from 'vue'
import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from '../const'
export function usePagination() {
const paginationInfo = reactive({
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
itemCount: 0,
showSizePicker: true,
pageSizes: PAGE_SIZES,
onChange: (page: number) => {
paginationInfo.page = page
},
onUpdatePageSize: (pageSize: number) => {
paginationInfo.pageSize = pageSize
paginationInfo.page = 1
},
restPage: () => {
paginationInfo.page = 1
},
})
return { paginationInfo }
}
const.ts
// 当前页的字段名
export const PAGE_FIELD = 'page'
// 每页数量字段名
export const SIZE_FIELD = 'step'
// 接口返回的数据字段名
export const LIST_FIELD = 'list'
// 接口返回总条数字段名
export const TOTAL_FIELD = 'total'
// 默认分页数量
export const DEFAULT_PAGE_SIZE = 10
// 可切换每页数量集合
export const PAGE_SIZES = [10, 20, 30, 100]
useDataSource.ts
import type { Ref } from 'vue'
import type { PaginationProps } from 'naive-ui/es/pagination'
import { LIST_FIELD, PAGE_FIELD, SIZE_FIELD, TOTAL_FIELD } from '../const'
import type { RequestFn } from '../types'
interface UseDataSourceProps {
paginationInfo: PaginationProps
request: RequestFn
resCallBack?: any
loading: Ref<boolean>
}
export function useDataSource(tableData: Ref<any>, { paginationInfo, request, loading, resCallBack }: UseDataSourceProps) {
let options = {}
async function fetchDataSource(opt?: any) {
if (opt) {
(paginationInfo as any).restPage()
options = opt
}
loading.value = true
const { page = 1, pageSize = 10 } = paginationInfo
const res: any = await request({ [PAGE_FIELD]: page, [SIZE_FIELD]: pageSize, ...options })
if (resCallBack)
resCallBack(res)
paginationInfo.itemCount = res.data[TOTAL_FIELD]
tableData.value = res.data[LIST_FIELD] || res.data
loading.value = false
}
return {
fetchDataSource,
}
}
三、最后是BasicTable.vue文件
BasicTable.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { DataTableRowKey } from 'naive-ui'
import type { DataTableProps } from 'naive-ui/lib/data-table'
import type { BasicButtonsProps, BasicColumns, RequestFn } from './types'
import { useColumns, useDataSource, usePagination } from './hooks'
interface Props extends DataTableProps {
rowKey?: (rowData: any) => (number | string)
checkRowKey?: DataTableRowKey[]
basicColumns: BasicColumns
request: RequestFn
resCallBack?: any
buttons?: BasicButtonsProps
scrollX?: number | string
}
const props = withDefaults(defineProps<Props>(), {
})
const emits = defineEmits(['update:checkRowKey', 'sort'])
const getProps = computed(() => {
return {
...props,
}
})
const loading = ref(false)
const tableData = ref()
const { paginationInfo } = usePagination()
const getBasicColumns = computed(() => props.basicColumns)
const { getColumns } = useColumns(getBasicColumns)
const { fetchDataSource } = useDataSource(tableData, { loading, paginationInfo, request: getProps.value.request, resCallBack: getProps.value.resCallBack })
function hanleUpdatePage() {
fetchDataSource()
}
function hanleUpdatePageSize() {
fetchDataSource()
}
function handleCheck(rowKeys: DataTableRowKey[]) {
emits('update:checkRowKey', rowKeys)
}
function handleSorterChange(sorter) {
emits('sort', sorter)
}
const getBindValue = computed(() => {
return {
scrollX: props.scrollX,
}
})
// table的高度
const tableHeight = ref(document.documentElement.clientHeight * 0.7)
onMounted(() => {
window.onresize = () => {
return (() => {
tableHeight.value = document.documentElement.clientHeight * 0.7
})()
}
})
defineExpose({
fetchDataSource,
})
</script>
<template>
<div>
<div class="flex justify-between items-center">
<div class="flex items-center">
<template v-if="buttons">
<n-space>
<n-button v-for="(item, index) in buttons" :key="index" :type="item.type || 'primary'" :size="item.size" :disabled="item.disabled" @click="item.onClick">
{{ item.name }}
<template v-if="item.icon" #icon>
<svg-icon :icon="item.icon" />
</template>
</n-button>
</n-space>
</template>
<slot v-else name="left" />
<slot name="number" />
</div>
<div slotclass="flex items-center">
<slot name="right" />
</div>
</div>
<div class="mt-10px">
<n-data-table
v-bind="getBindValue"
:row-key="rowKey"
:remote="true"
:data="tableData"
:loading="loading"
:columns="getColumns"
:pagination="paginationInfo"
@update-page="hanleUpdatePage"
@update-page-size="hanleUpdatePageSize"
@update:checked-row-keys="handleCheck"
@update:sorter="handleSorterChange"
/>
</div>
</div>
</template>
最就是示例环节啦
代码如下:我由于安装了插件,默认全局注册了组件,直接使用即可,各位小伙伴没有安装插件的,就用import引入
vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { fetchEditLpr, fetchLprList } from 'bank-package/api'
import type { BasicButtonsProps, BasicColumns } from '@/components/business/Table/types'
import type { FormItemsProps } from '@/components/business/QueryForm/types'
import { componentType } from '@/components/business/QueryForm/types'
const basicTableRef = ref()
// 查询栏
const formItems: FormItemsProps = reactive([
{
label: '一年期LPR',
field: 'one_year_lpr',
component: componentType.input,
},
{
label: '是否上架',
field: 'display',
component: componentType.select,
componentProps: {
options: [],
},
},
{
label: '更新时间',
component: componentType.datePicker,
datePickerProps: {
startField: 'start_time',
endField: 'end_time',
},
},
])
// 查询函数
function handleSearch(opt) {
// 表格刷新
basicTableRef.value.fetchDataSource(opt)
}
// 表格
const basicColumns: BasicColumns = reactive([
{ title: '发布时间', key: 'pubdate' },
{ title: '一年期LPR(%)', key: 'one_year_lpr' },
{ title: '五年期LPR(%)', key: 'five_year_lpr' },
{ title: '是否上架', key: 'display', contentType: 'switch', switchRequest: fetchEditLpr, switchId: 'lpr_id' },
{ title: '添加时间', key: 'add_time' },
{
title: '操作',
key: 'action',
contentType: 'actions',
actionColumn: [{
name: '编辑',
onClick: (row) => {
// 写你的表单逻辑
},
}],
}])
const basicButtons: BasicButtonsProps = [{
name: '新增',
icon: 'material-symbols:post-add-rounded',
onClick: () => {
// 写你的表单逻辑
},
}]
// 给下拉选择器的options赋值
function getDictList() {
const data = [
{
label: '全部',
value: '',
},
{
label: '上架',
value: '1',
},
{
label: '下架',
value: '0',
},
]
formItems[formItems.findIndex(item => item.field === 'display')].componentProps.options = data
}
getDictList()
</script>
<template>
<div class="h-full">
<n-card class="shadow-sm rounded-5px">
<QueryForm :form-items="formItems" @search="handleSearch" />
</n-card>
<n-card class="shadow-sm rounded-5px mt-10px">
<BasicTable ref="basicTableRef" :basic-columns="basicColumns" :request="fetchLprList" :buttons="basicButtons" />
</n-card>
</div>
</template>