antd 可编辑可排序表格

107 阅读2分钟
// ./sort.tsx

import { HolderOutlined } from '@ant-design/icons'
import type { DragEndEvent } from '@dnd-kit/core'
import { DndContext } from '@dnd-kit/core'
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Button } from 'antd'
import React, { createContext, PropsWithChildren, useContext, useMemo } from 'react'

interface RowContextProps {
  setActivatorNodeRef?: (element: HTMLElement | null) => void
  listeners?: SyntheticListenerMap
}

const RowContext = createContext<RowContextProps>({})

export const DragHandle = () => {
  const { setActivatorNodeRef, listeners } = useContext(RowContext)
  return (
    <Button type='text' icon={<HolderOutlined />} style={{ cursor: 'move' }} ref={setActivatorNodeRef} {...listeners} />
  )
}

interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
  'data-row-key': string
}

export const CustomRow = (props: RowProps) => {
  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
    id: props['data-row-key'],
  })

  const style: React.CSSProperties = {
    ...props.style,
    transform: CSS.Translate.toString(transform),
    transition,
    ...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
  }

  const contextValue = useMemo<RowContextProps>(
    () => ({ setActivatorNodeRef, listeners }),
    [setActivatorNodeRef, listeners],
  )

  return (
    <RowContext.Provider value={contextValue}>
      <tr {...props} ref={setNodeRef} style={style} {...attributes} />
    </RowContext.Provider>
  )
}

export function SortWrapper(
  props: PropsWithChildren<{ dataSource: { id: string }[]; onDragEnd: (e: DragEndEvent) => void }>,
) {
  return (
    <DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={props.onDragEnd}>
      <SortableContext items={props.dataSource.map((i) => i.id)} strategy={verticalListSortingStrategy}>
        {props.children}
      </SortableContext>
    </DndContext>
  )
}


// ./utils.ts

export const uid = () => Math.random().toString(36).slice(2, 8)

export const toFixed = (num: number, precision = 2) => Number(num.toFixed(precision))

export const cateOptions = [
  { value: '1', label: '分类1' },
  { value: '2', label: '分类2' },
  { value: '3', label: '分类3' },
]

export const subCateOptions = [
  { value: '1', label: '子分类1' },
  { value: '2', label: '子分类2' },
  { value: '3', label: '子分类3' },
]

export type RowData = {
  id: string
  category?: string
  subCategory?: string
  price?: number // 层级对应的价格
  c
  ount?: number
  hours?: number
  total?: number // 总价 = price * count * hours
}


// ./index.tsx

import { DragEndEvent } from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import type { TableProps } from 'antd'
import { Button, Form, InputNumber, Select, Space, Table } from 'antd'
import { useState } from 'react'
import { CustomRow, DragHandle, SortWrapper } from './sort'
import { cateOptions, RowData, subCateOptions, toFixed, uid } from './utils'

export default function DataTable() {
  const [datatSource, setDataSource] = useState<RowData[]>([])
  const [editingId, setEditingId] = useState<string>()

  const [form] = Form.useForm<RowData>()
  const category = Form.useWatch('category', form)
  const subCategory = Form.useWatch('subCategory', form)
  const price = category && subCategory ? Number(`${category}.${subCategory}`) : 0
  const count = Form.useWatch('count', form)
  const hours = Form.useWatch('hours', form)
  const total = price && count && hours ? toFixed(price * count * hours) : 0

  const onAdd = () => {
    const id = uid()
    setDataSource((prev) => prev.concat({ id }))
    setEditingId(id)
  }

  const onEdit = (record: RowData) => {
    form.setFieldsValue(record)
    setEditingId(record.id)
  }

  const onSave = async () => {
    await form.validateFields()
    const values = form.getFieldsValue()

    // 更新数据项
    // 并且重新计算对应的计算属性
    setDataSource((arr) =>
      arr.map((v) => {
        if (v.id === editingId) {
          const price = values.category && values.subCategory ? Number(`${values.category}.${values.subCategory}`) : 0
          const total = price && values.count && values.hours ? toFixed(price * values.count * values.hours) : 0
          return { ...v, ...values, price, total }
        }
        return v
      }),
    )

    form.resetFields()
    setEditingId(undefined)
  }

  const onCancel = () => {
    form.resetFields()
    setEditingId(undefined)
  }

  const onRemove = (id: string) => {
    form.resetFields()
    setDataSource((prev) => prev.filter((item) => item.id !== id))
  }

  const columns: TableProps<RowData>['columns'] = [
    {
      title: '分类',
      dataIndex: 'category',
      render(value, record) {
        if (editingId === record.id) {
          return (
            <Form.Item name='category' rules={[{ required: true }]}>
              <Select options={cateOptions} />
            </Form.Item>
          )
        } else {
          return value
        }
      },
    },
    {
      title: '子分类',
      dataIndex: 'subCategory',
      render(value, record) {
        if (editingId === record.id) {
          return (
            <Form.Item name='subCategory' rules={[{ required: true }]}>
              <Select options={subCateOptions} />
            </Form.Item>
          )
        } else {
          return value
        }
      },
    },
    {
      title: '价格',
      dataIndex: 'price',
      render(value, record) {
        return ${editingId === record.id ? price : value || ''}`
      },
    },
    {
      title: '数量',
      dataIndex: 'count',
      render(value, record) {
        if (editingId === record.id) {
          return (
            <Form.Item name='count' rules={[{ required: true }]}>
              <InputNumber />
            </Form.Item>
          )
        } else {
          return value
        }
      },
    },
    {
      title: '时长',
      dataIndex: 'hours',
      render(value, record) {
        if (editingId === record.id) {
          return (
            <Form.Item name='hours' rules={[{ required: true }]}>
              <InputNumber />
            </Form.Item>
          )
        } else {
          return value
        }
      },
    },
    {
      title: '总价',
      dataIndex: 'total',
      render(value, record) {
        return ${editingId === record.id ? total : value || ''}`
      },
    },
    {
      title: '操作',
      fixed: 'right',
      render: (record: RowData) => {
        if (editingId === record.id) {
          return (
            <Space>
              <Button onClick={() => onSave()}>保存</Button>
              <Button onClick={() => onCancel()}>取消</Button>
              <Button onClick={() => onRemove(record.id)}>删除</Button>
            </Space>
          )
        } else {
          return (
            <Space>
              <Button onClick={() => onEdit(record)}>编辑</Button>
              <DragHandle />
            </Space>
          )
        }
      },
    },
  ]

  // sort
  const onDragEnd = ({ active, over }: DragEndEvent) => {
    if (active.id !== over?.id) {
      setDataSource((prevState) => {
        const activeIndex = prevState.findIndex((record) => record.id === active?.id)
        const overIndex = prevState.findIndex((record) => record.id === over?.id)
        return arrayMove(prevState, activeIndex, overIndex)
      })
    }
  }

  return (
    <>
      <Space>
        <Button onClick={onAdd}>添加</Button>
      </Space>

      <SortWrapper dataSource={datatSource} onDragEnd={onDragEnd}>
        <Form component={false} form={form}>
          <Table
            rowKey={(row) => row.id}
            components={{ body: { row: CustomRow } }}
            columns={columns}
            dataSource={datatSource}
            pagination={{ pageSize: 1000, hideOnSinglePage: true }}
          />
        </Form>
      </SortWrapper>
    </>
  )
}