diff --git a/packages/devui-vue/devui-cli/templates/vue-devui.js b/packages/devui-vue/devui-cli/templates/vue-devui.js index 402a9da792..d0bf04a6fe 100644 --- a/packages/devui-vue/devui-cli/templates/vue-devui.js +++ b/packages/devui-vue/devui-cli/templates/vue-devui.js @@ -25,6 +25,7 @@ import type { App } from 'vue'; ${imports.join('\n')} import './style/devui.scss'; +import './style/index.scss'; const installs = [ ${installs.join(',\n ')} diff --git a/packages/devui-vue/devui/data-grid/index.ts b/packages/devui-vue/devui/data-grid/index.ts new file mode 100644 index 0000000000..bd2f12beaf --- /dev/null +++ b/packages/devui-vue/devui/data-grid/index.ts @@ -0,0 +1,14 @@ +import type { App } from "vue"; +import DataGrid from './src/data-grid'; + +export * from './src/data-grid-types'; +export { DataGrid } + +export default { + title: 'DataGrid 数据表格', + category: '数据展示', + status: '100%', + install(app: App): void { + app.component(DataGrid.name, DataGrid); + } +}; diff --git a/packages/devui-vue/devui/data-grid/src/components/fix-head-grid.tsx b/packages/devui-vue/devui/data-grid/src/components/fix-head-grid.tsx new file mode 100644 index 0000000000..f4b6eff6e6 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/fix-head-grid.tsx @@ -0,0 +1,88 @@ +import { defineComponent, inject, ref, watch, onMounted, onBeforeMount } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import GridHead from './grid-head'; +import GridBody from './grid-body'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { DataGridContext } from '../data-grid-types'; +import { useDataGridLazy } from '../composables/use-data-grid-scroll'; + +export default defineComponent({ + name: 'FixHeadGrid', + setup() { + const ns = useNamespace('data-grid'); + const { + scrollRef, + headBoxRef, + showHeader, + bodyContentWidth, + bodyContentHeight, + renderColumnData, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderRowData, + translateX, + translateY, + bodyScrollLeft, + rootCtx, + } = inject(DataGridInjectionKey) as DataGridContext; + const hasScrollbar = ref(false); + let resizeObserver: ResizeObserver; + useDataGridLazy(scrollRef); + + const isHaveScrollbar = () => { + if (scrollRef.value) { + hasScrollbar.value = scrollRef.value.scrollHeight > scrollRef.value.clientHeight; + } + }; + + watch(bodyContentHeight, isHaveScrollbar, { immediate: true }); + + onMounted(() => { + if (scrollRef.value) { + resizeObserver = new ResizeObserver(isHaveScrollbar); + resizeObserver.observe(scrollRef.value); + } + }); + + onBeforeMount(() => { + resizeObserver?.disconnect(); + }); + + return () => ( +
+ {showHeader.value && ( +
+
+ +
+ )} +
+
+
+ + {Boolean(renderRowData.value.length) ? ( + + ) : ( +
+ {rootCtx.slots.empty?.()} +
+ )} +
+
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-body.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-body.tsx new file mode 100644 index 0000000000..e1b8afe1cf --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-body.tsx @@ -0,0 +1,135 @@ +import { defineComponent, toRefs, inject, ref, Teleport } from 'vue'; +import { FlexibleOverlay } from '../../../overlay'; +import GridTd from './grid-td'; +import { gridBodyProps, DataGridInjectionKey } from '../data-grid-types'; +import type { GridBodyProps, DataGridContext, InnerRowData } from '../data-grid-types'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { useOverflowTooltip } from '../composables/use-overflow-tooltip'; +import { ToggleTreeIcon, DataGridCheckboxClass } from '../const'; + +export default defineComponent({ + name: 'GridBody', + props: gridBodyProps, + setup(props: GridBodyProps) { + const ns = useNamespace('data-grid'); + const { rowClass, rootCtx } = inject(DataGridInjectionKey) as DataGridContext; + const { rowData, columnData, leftColumnData, rightColumnData, translateX, translateY, bodyScrollLeft } = toRefs(props); + const currentRowIndex = ref(); + const { + showTooltip, + originRef, + tooltipContent, + tooltipPosition, + tooltipClassName, + onCellMouseenter, + onCellMouseleave, + onOverlayMouseenter, + onOverlayMouseleave + } = useOverflowTooltip(); + const trClasses = (rowData: InnerRowData, rowIndex: number) => { + const realRowClass = typeof rowClass.value === 'string' ? rowClass.value : rowClass.value(rowData, rowIndex); + return { + [ns.e('tr')]: true, + [realRowClass]: true, + 'hover-tr': currentRowIndex.value === rowIndex, + }; + }; + const onRowClick = (e: Event, rowData: InnerRowData, rowIndex: number) => { + const composedPath = e.composedPath() as HTMLElement[]; + if (composedPath.some((item) => item.classList?.contains(ToggleTreeIcon) || item.classList?.contains(DataGridCheckboxClass))) { + return; + } + rootCtx.emit('rowClick', { row: { ...rowData }, renderRowIndex: rowIndex, flattenRowIndex: rowData.$rowIndex }); + }; + const onTrMouseenterOrLeave = (rowIndex: number | undefined) => { + currentRowIndex.value = rowIndex; + }; + + return () => ( + <> + {Boolean(leftColumnData.value.length) && ( +
+ {rowData.value.map((itemRow, rowIndex) => ( +
onRowClick(e, itemRow, rowIndex)} + onMouseenter={() => onTrMouseenterOrLeave(rowIndex)} + onMouseleave={() => onTrMouseenterOrLeave(undefined)}> + {leftColumnData.value.map((cellData, cellIndex) => ( + + ))} +
+ ))} +
+ )} + + {Boolean(rightColumnData.value.length) && ( +
+ {rowData.value.map((itemRow, rowIndex) => ( +
onRowClick(e, itemRow, rowIndex)} + onMouseenter={() => onTrMouseenterOrLeave(rowIndex)} + onMouseleave={() => onTrMouseenterOrLeave(undefined)}> + {rightColumnData.value.map((cellData, cellIndex) => ( + + ))} +
+ ))} +
+ )} + +
+ {rowData.value.map((itemRow, rowIndex) => ( +
onRowClick(e, itemRow, rowIndex)} + onMouseenter={() => onTrMouseenterOrLeave(rowIndex)} + onMouseleave={() => onTrMouseenterOrLeave(undefined)}> + {columnData.value.map((cellData) => ( + + ))} +
+ ))} +
+ + + {tooltipContent.value} + + + + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-head.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-head.tsx new file mode 100644 index 0000000000..fa486dc0ad --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-head.tsx @@ -0,0 +1,77 @@ +import { defineComponent, toRefs, Teleport } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { FlexibleOverlay } from '../../../overlay'; +import GridTh from './grid-th'; +import { gridHeadProps } from '../data-grid-types'; +import type { GridHeadProps } from '../data-grid-types'; +import { useOverflowTooltip } from '../composables/use-overflow-tooltip'; + +export default defineComponent({ + name: 'GridHead', + props: gridHeadProps, + setup(props: GridHeadProps) { + const ns = useNamespace('data-grid'); + const { columnData, leftColumnData, rightColumnData, translateX, bodyScrollLeft } = toRefs(props); + const { + showTooltip, + originRef, + tooltipContent, + tooltipPosition, + tooltipClassName, + onCellMouseenter, + onCellMouseleave, + onOverlayMouseenter, + onOverlayMouseleave + } = useOverflowTooltip(); + + return () => ( + <> + {Boolean(leftColumnData.value.length) && ( +
+ {leftColumnData.value.map((item, index) => ( + + ))} +
+ )} + + {Boolean(rightColumnData.value.length) && ( +
+ {rightColumnData.value.map((item, index) => ( + + ))} +
+ )} + +
+ {columnData.value.map((item) => ( + + ))} +
+ + + + {tooltipContent.value} + + + + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-icons.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-icons.tsx new file mode 100644 index 0000000000..01d628725b --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-icons.tsx @@ -0,0 +1,56 @@ +export function SortIcon(): JSX.Element { + return ( + + + + + + + + + + + + + + + + ); +} + +export function FilterIcon(): JSX.Element { + return ( + + + + + + + + ); +} + +export function ExpandIcon(): JSX.Element { + return ( + + + + + + + ); +} + +export function FoldIcon(): JSX.Element { + return ( + + + + + + + ); +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-td.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-td.tsx new file mode 100644 index 0000000000..2e86f1ed22 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-td.tsx @@ -0,0 +1,101 @@ +import { defineComponent, toRefs, inject } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { Checkbox } from '../../../checkbox'; +import { ExpandIcon, FoldIcon } from './grid-icons'; +import { gridTdProps, DataGridInjectionKey } from '../data-grid-types'; +import type { GridTdProps, DataGridContext } from '../data-grid-types'; +import { ToggleTreeIcon, DataGridCheckboxClass } from '../const'; + +export default defineComponent({ + name: 'GridTd', + props: gridTdProps, + setup(props: GridTdProps) { + const ns = useNamespace('data-grid'); + const { indent, size, cellClass, rootCtx, isTreeGrid, toggleRowExpansion, toggleRowChecked } = inject( + DataGridInjectionKey + ) as DataGridContext; + const { rowData, cellData, rowIndex, mouseenterCb, mouseleaveCb } = toRefs(props); + + const getColumnIndex = () => Number(cellData.value.$columnId.split('-')[1]); + + const tdClasses = () => { + const realTdClass = + typeof cellClass.value === 'string' + ? cellClass.value + : cellClass.value(rowData.value, rowIndex.value, cellData.value, getColumnIndex()); + return { + [ns.e('td')]: true, + [ns.m(cellData.value.align)]: true, + [ns.em('td', cellData.value.type)]: true, + [ns.em('td', size.value)]: true, + [realTdClass]: true, + } + }; + + const onCellClick = (e: Event) => { + const composedPath = e.composedPath() as HTMLElement[]; + if (composedPath.some((item) => item.classList?.contains(ToggleTreeIcon) || item.classList?.contains(DataGridCheckboxClass))) { + return; + } + rootCtx.emit('cellClick', { + row: { ...rowData.value }, + renderRowIndex: rowIndex.value, + flattenRowIndex: rowData.value.$rowIndex, + column: { ...cellData.value }, + columnIndex: getColumnIndex(), + }); + }; + + const toggleExpand = () => { + toggleRowExpansion(rowData.value); + }; + + const onCheckedChange = () => { + toggleRowChecked(rowData.value); + }; + + const cellTypeMap = { + checkable: () => ( + + ), + index: () => rowData.value.$rowIndex! + 1, + default: () => rowData.value[cellData.value.field], + }; + + return () => ( +
mouseenterCb.value(e, cellData.value.showOverflowTooltip)} + onMouseleave={(e) => mouseleaveCb.value(e, cellData.value.showOverflowTooltip)}> + {isTreeGrid.value && cellData.value.$showExpandTreeIcon && ( + <> + + {rowData.value.$expand ? ( + + ) : ( + + )} + + )} + {cellData.value.cellRender?.(rowData.value, rowData.value.$rowIndex!, rowData.value[cellData.value.field], getColumnIndex()) ?? + cellTypeMap[cellData.value.type || 'default']()} +
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th-filter.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th-filter.tsx new file mode 100644 index 0000000000..68b4c28a11 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th-filter.tsx @@ -0,0 +1,75 @@ +import { defineComponent, ref, onMounted, onUnmounted } from 'vue'; +import type { SetupContext } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { Dropdown } from '../../../dropdown'; +import { FilterIcon } from './grid-icons'; +import { gridThFilterProps } from '../data-grid-types'; +import type { FilterListItem, GridThFilterProps } from '../data-grid-types'; +import GridThMultipleFilter from './grid-th-multiple-filter'; +import GridThSingleFilter from './grid-th-single-filter'; + +export default defineComponent({ + name: 'GridThFilter', + props: gridThFilterProps, + emits: ['filterChange'], + setup(props: GridThFilterProps, ctx: SetupContext) { + const ns = useNamespace('data-grid'); + const filterIconRef = ref(); + const showMenu = ref(false); + + const toggleFilterMenu = (status?: boolean) => { + if (typeof status === 'boolean') { + showMenu.value = status; + } else { + showMenu.value = !showMenu.value; + } + }; + + const onConfirm = (e: FilterListItem | FilterListItem[]) => { + toggleFilterMenu(false); + ctx.emit('filterChange', e); + }; + + const onScroll = (e: Event) => { + const scrollElement = e.target as HTMLElement; + if (filterIconRef.value && scrollElement?.contains(filterIconRef.value)) { + toggleFilterMenu(false); + } + }; + + onMounted(() => { + window.addEventListener('scroll', onScroll, true); + }); + + onUnmounted(() => { + window.removeEventListener('scroll', onScroll, true); + }); + + return () => ( + (showMenu.value = val)}> + {{ + default: () => ( + + ), + menu: () => + props.filterMenu?.({ toggleFilterMenu, setFilterStatus: props.setFilterStatus }) ?? + (props.multiple ? ( + + ) : ( + + )), + }} + + ); + }, +}); diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th-multiple-filter.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th-multiple-filter.tsx new file mode 100644 index 0000000000..cbebec9b58 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th-multiple-filter.tsx @@ -0,0 +1,48 @@ +import { defineComponent, withModifiers, getCurrentInstance } from 'vue'; +import type { SetupContext } from 'vue'; +import { createI18nTranslate } from '@devui/shared/components/locale/create'; +import { Button } from '../../../button'; +import { Checkbox } from '../../../checkbox'; +import { gridThFilterProps } from '../data-grid-types'; +import type { GridThFilterProps } from '../data-grid-types'; +import { useGridThMultipleFilter } from '../composables/use-grid-th'; + +export default defineComponent({ + name: 'GridThMultipleFilter', + props: gridThFilterProps, + emits: ['confirm'], + setup(props: GridThFilterProps, ctx: SetupContext) { + const app = getCurrentInstance(); + const t = createI18nTranslate('DDataGrid', app); + const { _checkList, _checkAll, _halfChecked, onCheckAllClick, onItemClick, updateCheckAll, onConfirm } = useGridThMultipleFilter( + props, + ctx + ); + + return () => ( + <> +
+
+ +
+
+
+ {_checkList.value.map((item) => ( +
{ + onItemClick(item); + }, ['self'])}> + +
+ ))} +
+
+ +
+ + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th-single-filter.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th-single-filter.tsx new file mode 100644 index 0000000000..1ca682a181 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th-single-filter.tsx @@ -0,0 +1,30 @@ +import { defineComponent, ref } from 'vue'; +import type { SetupContext } from 'vue'; +import { gridThFilterProps } from '../data-grid-types'; +import type { GridThFilterProps, FilterListItem } from '../data-grid-types'; + +export default defineComponent({ + props: gridThFilterProps, + emits: ['select'], + setup(props: GridThFilterProps, ctx: SetupContext) { + const selectedItem = ref(); + const handleSelect = (e: FilterListItem) => { + selectedItem.value = e; + ctx.emit('select', e); + }; + + return () => ( +
+ {props.filterList?.map((item) => ( +
{ + handleSelect(item); + }}> + {item.name} +
+ ))} +
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th.tsx new file mode 100644 index 0000000000..39d9ec9b3a --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th.tsx @@ -0,0 +1,78 @@ +import { defineComponent, toRefs, inject, computed } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { Checkbox } from '../../../checkbox'; +import { SortIcon } from './grid-icons'; +import GridThFilter from './grid-th-filter'; +import { gridThProps, DataGridInjectionKey } from '../data-grid-types'; +import type { GridThProps, DataGridContext } from '../data-grid-types'; +import { useGridThSort, useGridThFilter, useGridThDrag } from '../composables/use-grid-th'; + +export default defineComponent({ + name: 'GridTh', + props: gridThProps, + setup(props: GridThProps) { + const ns = useNamespace('data-grid'); + const { size, allChecked, halfAllChecked, virtualScroll, resizable, addGridThContextToMap, toggleAllRowChecked } = inject( + DataGridInjectionKey + ) as DataGridContext; + const { columnConfig, mouseenterCb, mouseleaveCb } = toRefs(props); + const { direction, doSort, onSortClick, doClearSort } = useGridThSort(columnConfig); + const { filterActive, setFilterStatus, onFilterChange } = useGridThFilter(columnConfig); + const classes = computed(() => ({ + [ns.e('th')]: true, + [ns.m(columnConfig.value.align)]: true, + [ns.e('sticky-th')]: true, + [ns.em('th', size.value)]: true, + [ns.em('th', 'filter-active')]: filterActive.value, + [ns.em('th', 'sort-active')]: Boolean(direction.value), + [ns.em('th', 'operable')]: + columnConfig.value.filterable || + columnConfig.value.sortable || + (!virtualScroll.value && (columnConfig.value.resizable ?? resizable.value)), + })); + const { thRef, onMousedown } = useGridThDrag(columnConfig); + + if (columnConfig.value.sortable) { + addGridThContextToMap(columnConfig.value.field, { doSort, doClearSort }); + } + + const cellTypeMap = { + checkable: () => , + index: () => #, + default: () => {columnConfig.value.header}, + }; + + return () => ( +
mouseenterCb.value(e, columnConfig.value.showHeadOverflowTooltip)} + onMouseleave={(e) => mouseleaveCb.value(e, columnConfig.value.showHeadOverflowTooltip)}> + {columnConfig.value.headRender ? ( + {columnConfig.value.headRender(columnConfig.value)} + ) : ( + cellTypeMap[columnConfig.value.type || 'default']() + )} + {columnConfig.value.sortable && ( + + )} + {columnConfig.value.filterable && ( + + )} + {!virtualScroll.value && (columnConfig.value.resizable ?? resizable.value) && } +
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/normal-head-grid.tsx b/packages/devui-vue/devui/data-grid/src/components/normal-head-grid.tsx new file mode 100644 index 0000000000..dde48042cb --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/normal-head-grid.tsx @@ -0,0 +1,60 @@ +import { defineComponent, inject, } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import GridHead from './grid-head'; +import GridBody from './grid-body'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { DataGridContext } from '../data-grid-types'; +import { useDataGridLazy } from '../composables/use-data-grid-scroll'; + +export default defineComponent({ + name: 'NormalHeadGrid', + setup() { + const ns = useNamespace('data-grid'); + const { + scrollRef, + showHeader, + bodyContentWidth, + bodyContentHeight, + renderColumnData, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderRowData, + translateX, + translateY, + bodyScrollLeft, + rootCtx + } = inject(DataGridInjectionKey) as DataGridContext; + useDataGridLazy(scrollRef); + + return () => ( +
+
+
+ {showHeader.value && ( + + )} + {Boolean(renderRowData.value.length) ? ( + + ) : ( +
+ {rootCtx.slots.empty?.()} +
+ )} +
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-column-sort.ts b/packages/devui-vue/devui/data-grid/src/composables/use-column-sort.ts new file mode 100644 index 0000000000..e98e615550 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-column-sort.ts @@ -0,0 +1,39 @@ +import type { ColumnConfig, SortDirection, SortMethod, ScrollYParams, GridThContext } from "../data-grid-types"; + +export function useColumnSort(scrollYParams: ScrollYParams, afterSort: () => void) { + const gridThListMap = new Map(); + + // 执行sortMethod对数据源排序 + const execSortMethod = (direction: SortDirection, sortMethod?: SortMethod) => { + const temp = [...scrollYParams.defaultSortRowData]; + if (direction === 'asc') { + scrollYParams.originRowData = temp + .sort((a, b) => (sortMethod ? (sortMethod(a, b) ? 1 : -1) : 0)) + .map((item, index) => ({ ...item, offsetTop: index * 40 })); + } else if (direction === 'desc') { + scrollYParams.originRowData = temp + .sort((a, b) => (sortMethod ? (sortMethod(a, b) ? -1 : 1) : 0)) + .map((item, index) => ({ ...item, offsetTop: index * 40 })); + } else { + scrollYParams.originRowData = temp; + } + afterSort() + }; + + const addGridThContextToMap = (key: ColumnConfig['field'], thCtx: GridThContext) => { + gridThListMap.set(key, thCtx); + } + + const clearAllSortState = () => { + gridThListMap.forEach((item) => { + item.doClearSort(); + }) + } + + // 对外expose,业务手动对某列数据按指定顺序进行排序 + const sort = (key: ColumnConfig['field'], direction: SortDirection) => { + gridThListMap.get(key)?.doSort(direction); + }; + + return { sort, execSortMethod, addGridThContextToMap, clearAllSortState }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-drag.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-drag.ts new file mode 100644 index 0000000000..4cf4ee34b7 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-drag.ts @@ -0,0 +1,44 @@ +import type { Ref } from 'vue'; +import type { InnerColumnConfig } from '../data-grid-types'; + +export function useDataGridColumnDrag( + bodyContentWidth: Ref, + scrollRef: Ref, + renderFixedLeftColumnData: Ref, + renderFixedRightColumnData: Ref, + renderColumnData: Ref +) { + const afterColumnDragend = (columnId: InnerColumnConfig['$columnId'], offset: number) => { + const columnLength = renderColumnData.value.length; + const lastColumn = renderColumnData.value[columnLength - 1]; + const scrollDistance = scrollRef.value!.scrollWidth - scrollRef.value!.clientWidth; + + // 拖动结束,最后一列对宽度做补偿 + if (offset > 0 && scrollDistance > 0 && offset > scrollDistance) { + offset = offset - scrollDistance; + lastColumn.width = Math.min( + lastColumn.maxWidth as number, + Math.max(lastColumn.minWidth as number, (lastColumn.width as number) + offset) + ); + } else if (scrollDistance <= 0 && lastColumn.$columnId !== columnId) { + lastColumn.width = Math.min( + lastColumn.maxWidth as number, + Math.max(lastColumn.minWidth as number, (lastColumn.width as number) + offset) + ); + } + // 重新计算总宽度 + let bodyTotalWidth = 0; + for (let i = 0; i < renderFixedLeftColumnData.value.length; i++) { + bodyTotalWidth += renderFixedLeftColumnData.value[i].width as number; + } + for (let i = 0; i < renderFixedRightColumnData.value.length; i++) { + bodyTotalWidth += renderFixedRightColumnData.value[i].width as number; + } + for (let i = 0; i < renderColumnData.value.length; i++) { + bodyTotalWidth += renderColumnData.value[i].width as number; + } + bodyContentWidth.value = bodyTotalWidth; + }; + + return { afterColumnDragend } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-scroll.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-scroll.ts new file mode 100644 index 0000000000..7d1c3588b8 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-scroll.ts @@ -0,0 +1,95 @@ +import { ref, onMounted, inject } from 'vue'; +import type { Ref } from 'vue'; +import { debounce } from 'lodash'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { InnerColumnConfig, InnerRowData, DataGridContext, ScrollYParams, ScrollXParams } from '../data-grid-types'; +import { getXStartOrEndIndex, getYStartIndex } from '../utils'; + +// 虚拟滚动 +export function useDataGridScroll() { + const virtualRowData = ref([]); + const virtualColumnData = ref([]); + const translateX = ref(0); + const translateY = ref(0); + + const calcVirtualColumnData = (scrollXParams: ScrollXParams, scrolling = true) => { + if (scrolling && scrollXParams.distance > scrollXParams.scrollScaleX[0] && scrollXParams.distance < scrollXParams.scrollScaleX[1]) { + return; + } + const startIndex = getXStartOrEndIndex(scrollXParams.originColumnData, scrollXParams.distance); + const endIndex = getXStartOrEndIndex(scrollXParams.originColumnData, scrollXParams.distance + scrollXParams.scrollViewWidth); + let upperStartIndex = Math.ceil(startIndex - scrollXParams.bufferSize); + upperStartIndex = upperStartIndex < 0 ? 0 : upperStartIndex; + translateX.value = scrollXParams.originColumnData[upperStartIndex].offsetLeft; + const upperList = scrollXParams.originColumnData.slice(upperStartIndex, startIndex); + const midList = scrollXParams.originColumnData.slice(startIndex, endIndex); + let downStartIndex = endIndex; + downStartIndex = downStartIndex > scrollXParams.totalColumn - 1 ? scrollXParams.totalColumn : downStartIndex; + scrollXParams.scrollScaleX = [ + scrollXParams.originColumnData[Math.floor(upperStartIndex + scrollXParams.bufferSize / 2)]?.offsetLeft || 0, + scrollXParams.originColumnData[Math.ceil(startIndex + scrollXParams.bufferSize / 2)]?.offsetLeft || 0 + ]; + const downList = scrollXParams.originColumnData.slice(downStartIndex, downStartIndex + scrollXParams.bufferSize); + virtualColumnData.value = [...upperList, ...midList, ...downList]; + let trTotalWidth = 0; + virtualColumnData.value.forEach((item) => { + trTotalWidth += item.width as number; + }) + }; + + const calcVirtualRowData = (scrollYParams: ScrollYParams) => { + if (scrollYParams.distance > scrollYParams.scrollScaleY[0] && scrollYParams.distance < scrollYParams.scrollScaleY[1]) { + return; + } + const startIndex = getYStartIndex(scrollYParams.originRowData, scrollYParams.distance); + let upperStartIndex = Math.ceil(startIndex - scrollYParams.renderCountPerScreen); + upperStartIndex = upperStartIndex < 0 ? 0 : upperStartIndex; + translateY.value = scrollYParams.originRowData[upperStartIndex].offsetTop!; + const upperList = scrollYParams.originRowData.slice(upperStartIndex, startIndex); + const midList = scrollYParams.originRowData.slice(startIndex, startIndex + scrollYParams.renderCountPerScreen); + let downStartIndex = Math.floor(startIndex + scrollYParams.renderCountPerScreen); + downStartIndex = downStartIndex > scrollYParams.originRowData.length - 1 ? scrollYParams.originRowData.length : downStartIndex; + scrollYParams.scrollScaleY = [ + scrollYParams.originRowData[Math.floor(upperStartIndex + scrollYParams.renderCountPerScreen / 2)]?.offsetTop! ?? 0, + scrollYParams.originRowData[Math.ceil(startIndex + scrollYParams.renderCountPerScreen / 2)]?.offsetTop! ?? 0 + ]; + const downList = scrollYParams.originRowData.slice(downStartIndex, downStartIndex + scrollYParams.renderCountPerScreen); + virtualRowData.value = [...upperList, ...midList, ...downList]; + } + + const resetVirtualRowData = () => { + virtualRowData.value = [] + } + + return { + translateX, + translateY, + virtualColumnData, + virtualRowData, + calcVirtualRowData, + calcVirtualColumnData, + resetVirtualRowData + } +} + +// 懒加载 +export function useDataGridLazy(scrollRef: Ref) { + const { lazy, rootCtx } = inject(DataGridInjectionKey) as DataGridContext; + const emitLazyThreshold = 40; + + const onScroll = debounce((e: Event) => { + const targetEl = e.target as HTMLElement; + const clientHeight = targetEl.clientHeight; + const scrollTop = targetEl.scrollTop; + const scrollHeight = targetEl.scrollHeight; + if (scrollHeight - scrollTop - clientHeight <= emitLazyThreshold) { + rootCtx.emit('loadMore') + } + }, 300); + + onMounted(() => { + if (lazy.value && scrollRef.value) { + scrollRef.value.addEventListener('scroll', onScroll); + } + }) +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-tree.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-tree.ts new file mode 100644 index 0000000000..bdd124fa88 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-tree.ts @@ -0,0 +1,281 @@ +import { ref, toRefs } from 'vue'; +import type { SetupContext } from 'vue'; +import { isFunction } from 'lodash'; +import type { DataGridProps, InnerRowData, RowData, IExpandLoadMoreResult } from '../data-grid-types'; +import { generateInnerData } from '../utils'; + +export function useDataGridTree(props: DataGridProps, ctx: SetupContext, afterToggleExpandTree: () => void) { + const { data, checkableRelation, rowKey, reserveCheck } = toRefs(props); + const innerRowsData = ref([]); + const rowIndex = ref(0); + const allChecked = ref(false); + const halfAllChecked = ref(false); + const checkedRowsId = new Map>(); + const rowDataMap: Record = {}; + + const getInnerRowData = (node: InnerRowData | RowData) => { + let innerNode: InnerRowData | null = null; + if (node.$rowId) { + innerNode = node; + } else if (rowKey && rowKey.value) { + let key: string; + if (typeof rowKey.value === 'string') { + key = node[rowKey.value]; + } else if (isFunction(rowKey.value)) { + key = rowKey.value(node); + } else { + return null; + } + innerNode = rowDataMap[key]; + } + return innerNode; + }; + + const updateInnerRowsData = () => { + let allTrue = true; + let allFalse = true; + rowIndex.value = 0; + innerRowsData.value = generateInnerData(data.value, rowIndex, rowKey); + for (let i = 0; i < innerRowsData.value.length; i++) { + const item = innerRowsData.value[i]; + rowDataMap[item.$rowId!] = item; + if (reserveCheck.value && !Reflect.has(item, 'checked')) { + item.checked = checkedRowsId.get(item.$rowId)?.checked; + item.halfChecked = checkedRowsId.get(item.$rowId)?.halfChecked; + } + allTrue &&= Boolean(item.checked); + allFalse &&= Boolean(!item.checked); + } + allChecked.value = allTrue; + halfAllChecked.value = !(allTrue || allFalse); + }; + + const getShowRowsData = () => { + const result: InnerRowData[] = []; + for (let i = 0; i < innerRowsData.value.length; i++) { + if (innerRowsData.value[i].showNode) { + result.push(innerRowsData.value[i]); + } + } + return result; + }; + + // 切换子节点的展开收起状态 + const toggleChildNodeVisible = (node: InnerRowData) => { + if (!node.childList?.length) { + return; + } + const nodeList = [...node.childList]; + while (nodeList.length) { + const item = nodeList.shift(); + if (item) { + item.showNode = node.$expand; + if ((node.$expand && item.$expand) || (!node.$expand && item.childList?.length)) { + const temp = item.childList || []; + nodeList.push(...temp); + } + } + } + } + + // 树表格异步加载子节点后,更新其他行数据$rowIndex + const updateAfterRowIndex = (startIndex: number, addedLength: number) => { + for (let i = startIndex; i < innerRowsData.value.length; i++) { + innerRowsData.value[i].$rowIndex! += addedLength; + } + }; + + // 树表格展开懒加载回调 + const dealChildNodes = (result: IExpandLoadMoreResult) => { + const { node, rowItems } = result; + const tempRowIndex = ref(node.$rowIndex! + 1); + const childList = generateInnerData(rowItems, tempRowIndex, rowKey, node.$level, node); + updateAfterRowIndex(node.$rowIndex! + 1, childList.length); + innerRowsData.value.splice(node.$rowIndex! + 1, 0, ...childList); + // 更新childList + for (let i = 0; i < childList.length; i++) { + if (childList[i].$parentId === node.$rowId) { + node.childList?.push(childList[i]); + } + } + // 更新子节点状态 + toggleChildNodeVisible(node); + afterToggleExpandTree(); + }; + + // 切换树表格单行展开收起状态,指定status可设置展开收起状态 + const toggleRowExpansion = (node: InnerRowData, status?: boolean) => { + // 为了兼容业务通过原始行数据调用此方法,所以需要通过getInnerRowData方法获取内部处理后的行数据 + const innerNode: InnerRowData | null = getInnerRowData(node); + if (!innerNode) { + return; + } + if (typeof status === 'boolean') { + innerNode.$expand = status; + } else { + innerNode.$expand = !innerNode.$expand; + } + if (!innerNode.isLeaf && !innerNode.childList?.length) { + ctx.emit('expandLoadMore', innerNode, dealChildNodes); + } else { + toggleChildNodeVisible(innerNode); + afterToggleExpandTree(); + } + ctx.emit('expandChange', innerNode.$expand, innerNode); + }; + + // 切换树表格所有行展开收起状态,指定status可设置展开收起状态 + const toggleAllRowExpansion = (status?: boolean) => { + for (let i = 0; i < innerRowsData.value.length; i++) { + const item = innerRowsData.value[i]; + if (typeof status === 'boolean') { + item.$expand = status; + } else { + item.$expand = !item.$expand; + } + if (item.$level !== 1) { + item.showNode = item.$expand; + } + } + afterToggleExpandTree(); + ctx.emit('expandAllChange', innerRowsData.value[0].$expand); + }; + + // 保留勾选状态时,更新已勾选节点 + const updateCheckedRowsId = (node: InnerRowData) => { + if (reserveCheck.value) { + if (node.checked) { + checkedRowsId.set(node.$rowId, { checked: node.checked, halfChecked: Boolean(node.halfChecked) }); + } else { + checkedRowsId.delete(node.$rowId); + } + } + }; + + // 父子勾选联动,改变子节点勾选状态 + const toggleChildNodeChecked = (node: InnerRowData) => { + if (!node.childList?.length) { + return; + } + node.halfChecked = false; + const nodeList = [...node.childList]; + while (nodeList.length) { + const item = nodeList.shift(); + if (item) { + if (!item.disableCheck) { + item.checked = node.checked; + item.halfChecked = false; + updateCheckedRowsId(item); + } + const temp = item.childList || []; + nodeList.push(...temp); + } + } + }; + + // 父子勾选联动,改变父节点勾选状态 + const toggleParentNodeChecked = (node: InnerRowData) => { + if (!node.$parentId) { + return; + } + const parentNode = rowDataMap[node.$parentId]; + if (!parentNode || parentNode.disableCheck) { + return; + } + // 父节点勾选状态和半选状态需要根据是否有其他后代节点仍被选中而确定 + const descendantCheckedNodes = parentNode.descendantList?.filter((item) => item.checked) || []; + // 子节点选中,父节点也选中 + if (node.checked) { + parentNode.checked = true; + } else { + // 子节点全部取消选中 + if (descendantCheckedNodes.length === 0) { + parentNode.checked = false; + } else { + parentNode.checked = true; + } + } + parentNode.halfChecked = descendantCheckedNodes.length !== 0 && parentNode.descendantList?.length !== descendantCheckedNodes.length; + updateCheckedRowsId(parentNode); + if (parentNode.$parentId) { + toggleParentNodeChecked(parentNode); + } + }; + + // 行勾选状态变更时,更新表头全选的勾选状态 + const updateCheckAll = () => { + let allTrue = true; + let allFalse = true; + + for (let i = 0; i < innerRowsData.value.length; i++) { + allTrue &&= Boolean(innerRowsData.value[i].checked); + allFalse &&= Boolean(!innerRowsData.value[i].checked); + } + + allChecked.value = allTrue; + halfAllChecked.value = !(allTrue || allFalse); + }; + + // 切换行的勾选状态,指定status可设置勾选状态 + const toggleRowChecked = (node: InnerRowData | RowData, status?: boolean) => { + const innerNode: InnerRowData | null = getInnerRowData(node); + if (!innerNode || innerNode.disableCheck) { + return; + } + if (typeof status === 'boolean') { + innerNode.checked = status; + } else { + innerNode.checked = !innerNode.checked; + } + updateCheckedRowsId(innerNode); + if (['downward', 'both'].includes(checkableRelation.value)) { + toggleChildNodeChecked(innerNode); + } + if (['upward', 'both'].includes(checkableRelation.value)) { + toggleParentNodeChecked(innerNode); + } + updateCheckAll(); + ctx.emit('checkChange', innerNode.checked, innerNode); + }; + + // 切换全选的勾选状态,指定status可设置勾选状态 + const toggleAllRowChecked = (status?: boolean) => { + if (typeof status === 'boolean') { + allChecked.value = status; + } else { + allChecked.value = !allChecked.value; + } + halfAllChecked.value = false; + for (let i = 0; i < innerRowsData.value.length; i++) { + if (!innerRowsData.value[i].disableCheck) { + innerRowsData.value[i].checked = allChecked.value; + innerRowsData.value[i].halfChecked = false; + updateCheckedRowsId(innerRowsData.value[i]); + } + } + ctx.emit('checkAllChange', allChecked.value); + }; + + // 获取当前选中的行数据 + const getCheckedRows = () => { + const result: InnerRowData[] = []; + for (let i = 0; i < innerRowsData.value.length; i++) { + if (innerRowsData.value[i].checked) { + result.push({ ...innerRowsData.value[i] }) + } + } + return result; + }; + + return { + allChecked, + halfAllChecked, + updateInnerRowsData, + getShowRowsData, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + getCheckedRows, + }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid.ts new file mode 100644 index 0000000000..3b40bb0c03 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid.ts @@ -0,0 +1,350 @@ +import { toRefs, computed, watch, ref, nextTick, onMounted, onBeforeMount } from 'vue'; +import type { SetupContext, Ref } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { useDataGridScroll } from './use-data-grid-scroll'; +import { useColumnSort } from './use-column-sort'; +import { useDataGridTree } from './use-data-grid-tree'; +import { useDataGridColumnDrag } from './use-data-grid-drag'; +import type { DataGridProps, InnerRowData, InnerColumnConfig, ScrollYParams, ScrollXParams } from '../data-grid-types'; +import { ColumnType, RowHeightMap } from '../const'; +import { calcEachColumnWidth } from '../utils'; + +export function useDataGrid(props: DataGridProps, ctx: SetupContext) { + const { data, columns, size, virtualScroll } = toRefs(props); + const scrollRef = ref(); + const headBoxRef = ref(); + const bodyContentWidth = ref(0); + const bodyContentHeight = ref(0); + const bodyScrollLeft = ref(0); + const isTreeGrid = ref(false); + const renderFixedLeftColumnData = ref([]); + const renderFixedRightColumnData = ref([]); + const renderRowData = ref([]); + const renderColumnData = ref([]); + const sliceData = computed(() => data.value.slice()); + const sliceColumns = computed(() => columns.value.slice()); + const rowHeight = RowHeightMap[size.value]; + let tick = false; + let resizeObserver: ResizeObserver; + const scrollYParams: ScrollYParams = { + distance: 0, + renderCountPerScreen: 0, + scrollScaleY: [0, 0], + originRowData: [], + defaultSortRowData: [], + }; + const scrollXParams: ScrollXParams = { + distance: 0, + totalColumn: 0, + bufferSize: 5, + scrollViewWidth: 0, + scrollScaleX: [0, 0], + originColumnData: [] + }; + const { translateX, translateY, virtualColumnData, virtualRowData, calcVirtualRowData, calcVirtualColumnData, resetVirtualRowData } = + useDataGridScroll(); + const { sort, execSortMethod, addGridThContextToMap, clearAllSortState } = useColumnSort(scrollYParams, afterSort); + const { + allChecked, + halfAllChecked, + updateInnerRowsData, + getShowRowsData, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + getCheckedRows, + } = useDataGridTree(props, ctx, afterToggleExpandTree); + const { afterColumnDragend } = useDataGridColumnDrag( + bodyContentWidth, + scrollRef, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderColumnData + ); + + const initOriginRowData = () => { + let bodyTotalHeight = 0; + const rowsData = getShowRowsData(); + scrollYParams.originRowData = []; + for (let i = 0; i < rowsData.length; i++) { + const itemRow = rowsData[i]; + itemRow.height = rowHeight; + itemRow.offsetTop = bodyTotalHeight; + scrollYParams.originRowData.push(itemRow); + bodyTotalHeight += rowHeight; + if (!isTreeGrid.value) { + isTreeGrid.value = !itemRow.isLeaf; + } + } + if (!virtualScroll.value) { + renderRowData.value = scrollYParams.originRowData; + } + scrollYParams.defaultSortRowData = scrollYParams.originRowData; + bodyContentHeight.value = bodyTotalHeight; + }; + const initVirtualRowData = (distance = 0) => { + scrollYParams.distance = distance; + scrollYParams.renderCountPerScreen = Math.ceil(scrollRef.value!.clientHeight / rowHeight); + scrollYParams.scrollScaleY = [0, scrollYParams.renderCountPerScreen * rowHeight]; + calcVirtualRowData(scrollYParams); + }; + + const initOriginColumnData = () => { + let bodyTotalWidth = 0; + let columnId = 0; + const scrollViewWidth = scrollRef.value?.clientWidth || 0; + scrollXParams.totalColumn = columns.value.length; + renderFixedLeftColumnData.value = []; + renderFixedRightColumnData.value = []; + scrollXParams.originColumnData = []; + const columnsWithRealWidth = calcEachColumnWidth(columns.value, scrollViewWidth); + for (let i = 0; i < scrollXParams.totalColumn; i++) { + const itemColumn: InnerColumnConfig = { + ...columnsWithRealWidth[i], + offsetLeft: bodyTotalWidth, + $columnId: `columnId-${columnId++}`, + }; + const prevColumn = i > 0 ? columnsWithRealWidth[i - 1] : null; + if (prevColumn) { + if (prevColumn.type && ColumnType.includes(prevColumn.type) && !itemColumn.type) { + itemColumn.$showExpandTreeIcon = true; + } + } else { + if (!itemColumn.type) { + itemColumn.$showExpandTreeIcon = true; + } + } + + if (itemColumn.fixed === 'left') { + renderFixedLeftColumnData.value.push(itemColumn); + } else if (itemColumn.fixed === 'right') { + renderFixedRightColumnData.value.push(itemColumn); + } else { + scrollXParams.originColumnData.push(itemColumn); + } + bodyTotalWidth += itemColumn.width as number; + } + if (!virtualScroll.value) { + renderColumnData.value = scrollXParams.originColumnData; + translateX.value = renderColumnData.value[0]?.offsetLeft ?? 0; + } + bodyContentWidth.value = bodyTotalWidth; + }; + const initVirtualColumnData = (distance = 0, scrollViewWidth: number) => { + scrollXParams.distance = distance; + scrollXParams.scrollViewWidth = scrollViewWidth; + scrollXParams.scrollScaleX = [0, scrollRef.value!.clientWidth]; + calcVirtualColumnData(scrollXParams, false); + } + + function afterSort() { + scrollYParams.scrollScaleY = [0, 0]; + if (!virtualScroll.value) { + renderRowData.value = scrollYParams.originRowData; + } else { + calcVirtualRowData(scrollYParams); + } + } + + function afterToggleExpandTree() { + initOriginRowData(); + scrollYParams.scrollScaleY = [0, 0]; + virtualScroll.value && calcVirtualRowData(scrollYParams); + } + + function refreshRowsData() { + let distance = 0; + updateInnerRowsData(); + initOriginRowData(); + nextTick(() => { + if (virtualScroll.value && scrollRef.value && scrollYParams.originRowData.length) { + const scrollTop = scrollRef.value.scrollTop; + distance = scrollTop > scrollYParams.originRowData[scrollYParams.originRowData.length - 1].offsetTop! ? 0 : scrollTop; + initVirtualRowData(distance); + } else { + resetVirtualRowData(); + } + }); + } + + watch( + sliceData, + () => { + refreshRowsData(); + }, + { immediate: true } + ); + watch( + sliceColumns, + () => { + if (!sliceColumns.value.length) { + renderColumnData.value = []; + return; + } + let distance = 0; + nextTick(() => { + initOriginColumnData(); + if (virtualScroll.value && scrollRef.value) { + distance = scrollRef.value.scrollLeft; + initVirtualColumnData(distance, scrollRef.value.clientWidth); + } + }); + }, + { immediate: true } + ); + + watch( + virtualRowData, + (val: InnerRowData[]) => { + if (virtualScroll.value) { + renderRowData.value = val; + } + }, + { immediate: true } + ); + + watch( + virtualColumnData, + (val: InnerColumnConfig[]) => { + if (virtualScroll.value) { + renderColumnData.value = val; + } + }, + { immediate: true } + ); + + const onScroll = (e: Event) => { + if (tick) { + return; + } + tick = true; + requestAnimationFrame(() => { + tick = false; + }); + const scrollLeft = (e.target as HTMLElement).scrollLeft; + const scrollTop = (e.target as HTMLElement).scrollTop; + if (scrollLeft !== scrollXParams.distance) { + headBoxRef.value && (headBoxRef.value.scrollLeft = scrollLeft); + bodyScrollLeft.value = scrollLeft; + if (scrollXParams.originColumnData.length === 0) { + return; + } + scrollXParams.distance = scrollLeft; + virtualScroll.value && calcVirtualColumnData(scrollXParams); + } else if (scrollTop !== scrollYParams.distance) { + if (scrollYParams.originRowData.length === 0) { + return; + } + scrollYParams.distance = scrollTop; + virtualScroll.value && calcVirtualRowData(scrollYParams); + } + }; + + onMounted(() => { + scrollRef.value?.addEventListener('scroll', onScroll); + if (typeof window !== 'undefined' && scrollRef.value) { + resizeObserver = new ResizeObserver(() => { + if (scrollRef.value) { + let distance = 0; + initOriginColumnData(); + distance = scrollRef.value!.scrollLeft; + virtualScroll.value && initVirtualColumnData(distance, scrollRef.value!.clientWidth); + } + }); + resizeObserver.observe(scrollRef.value); + } + }); + + onBeforeMount(() => { + resizeObserver?.disconnect(); + }); + + return { + scrollRef, + headBoxRef, + bodyScrollLeft, + bodyContentHeight, + bodyContentWidth, + translateX, + translateY, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderColumnData, + renderRowData, + isTreeGrid, + allChecked, + halfAllChecked, + sort, + getCheckedRows, + execSortMethod, + addGridThContextToMap, + clearAllSortState, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + afterColumnDragend, + refreshRowsData, + } +} + +export function useDataGridStyle(props: DataGridProps, scrollRef: Ref) { + const ns = useNamespace('data-grid'); + const { striped, rowHoveredHighlight, fixHeader, headerBg, borderType, shadowType, virtualScroll } = toRefs(props); + const scrollPosition = ref('left'); + const sliceColumns = computed(() => props.columns.slice()); + + const gridClasses = computed(() => ({ + [ns.b()]: true, + [ns.m('fix-header')]: fixHeader.value, + [ns.m('striped')]: striped.value, + [ns.m('row-hover-highlight')]: rowHoveredHighlight.value, + [ns.m('header-bg')]: headerBg.value, + [ns.m(borderType.value)]: Boolean(borderType.value), + [ns.m(shadowType.value)]: Boolean(shadowType.value), + [ns.m('is-virtual')]: Boolean(virtualScroll.value), + [ns.m(`scroll-${scrollPosition.value}`)]: Boolean(scrollPosition.value), + })); + + const onScroll = (e: Event) => { + const target = e.target as HTMLElement; + const scrollLeft = target.scrollLeft; + if (scrollLeft === 0) { + if (target.clientWidth === target.scrollWidth) { + scrollPosition.value = ''; + } else { + scrollPosition.value = 'left'; + } + } else if (scrollLeft + target.clientWidth === target.scrollWidth) { + scrollPosition.value = 'right'; + } else { + scrollPosition.value = 'middle'; + } + }; + + const initScrollPosition = () => { + scrollPosition.value = scrollRef.value!.clientWidth === scrollRef.value!.clientWidth ? '' : 'left'; + }; + + watch( + sliceColumns, + () => { + if (scrollRef.value) { + // 等待列渲染完成再判断是否有滚动条 + setTimeout(initScrollPosition); + } + }, + { flush: 'post' } + ); + + onMounted(() => { + if (scrollRef.value) { + scrollRef.value.addEventListener('scroll', onScroll); + // 等待列渲染完成再判断是否有滚动条 + setTimeout(initScrollPosition); + } + }); + + return { gridClasses }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-grid-th.ts b/packages/devui-vue/devui/data-grid/src/composables/use-grid-th.ts new file mode 100644 index 0000000000..bf90f25e8b --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-grid-th.ts @@ -0,0 +1,168 @@ +import { ref, inject, computed, watch } from 'vue'; +import type { Ref, SetupContext } from 'vue'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { InnerColumnConfig, SortDirection, DataGridContext, FilterListItem, GridThFilterProps } from '../data-grid-types'; + +export function useGridThSort(columnConfig: Ref) { + const { rootCtx, execSortMethod, clearAllSortState } = inject(DataGridInjectionKey) as DataGridContext; + const directionMap: Record<'asc' | 'desc' | 'default', SortDirection> = { + asc: 'desc', + desc: '', + default: 'asc' + }; + const direction = ref(''); + + const doSort = (directionVal: SortDirection) => { + if (direction.value === directionVal) { + return; + } + clearAllSortState(); + direction.value = directionVal; + execSortMethod(direction.value, columnConfig.value.sortMethod); + rootCtx.emit('sortChange', { field: columnConfig.value.field, direction: direction.value }); + }; + + const onSortClick = () => { + doSort(directionMap[direction.value || 'default']); + }; + + const doClearSort = () => { + direction.value = ''; + }; + + return { direction, doSort, onSortClick, doClearSort }; +} + +export function useGridThFilter(columnConfig: Ref) { + const filterActive = ref(false); + const onFilterChange = (e: FilterListItem | FilterListItem[]) => { + filterActive.value = Array.isArray(e) ? Boolean(e.length) : Boolean(e); + columnConfig.value.filterChange?.(e); + }; + const setFilterStatus = (status: boolean) => { + filterActive.value = status; + }; + + return { filterActive, setFilterStatus, onFilterChange }; +} + +export function useGridThMultipleFilter(props: GridThFilterProps, ctx: SetupContext) { + const _checkList = ref([]); + const _checkAllRecord = ref(false); + const _halfChecked = ref(false); + const filterListTemp = computed(() => props.filterList?.slice()); + const _checkAll = computed({ + get: () => _checkAllRecord.value, + set: (val: boolean) => { + _checkAllRecord.value = val; + for (let i = 0; i < _checkList.value.length; i++) { + _checkList.value[i].checked = val; + } + } + }); + + watch( + filterListTemp, + () => { + props.filterList?.forEach((item) => { + _checkList.value.push({ checked: false, ...item }); + }); + }, + { immediate: true } + ); + + const updateCheckAll = () => { + if (!_checkList.value.length) { + return; + } + + let allTrue = true; + let allFalse = true; + + for (let i = 0; i < _checkList.value.length; i++) { + allTrue &&= Boolean(_checkList.value[i].checked); + allFalse &&= Boolean(!_checkList.value[i].checked); + } + + _checkAllRecord.value = allTrue; + _halfChecked.value = !(allFalse || allTrue); + }; + + const getCheckedItems = () => _checkList.value.filter((item) => item.checked); + + const onCheckAllClick = () => { + _checkAll.value = !_checkAll.value; + }; + + const onItemClick = (item: FilterListItem) => { + item.checked = !item.checked; + updateCheckAll(); + }; + + const onConfirm = () => { + ctx.emit('confirm', getCheckedItems()); + }; + + return { _checkList, _checkAll, _halfChecked, onCheckAllClick, onItemClick, updateCheckAll, onConfirm }; +} + +export function useGridThDrag(columnConfig: Ref) { + const { fixHeader, rootRef, rootCtx, scrollRef, afterColumnDragend } = inject(DataGridInjectionKey) as DataGridContext; + const resizing = ref(false); + const thRef = ref(); + let initialWidth = 0; + let mouseDownScreenX = 0; + let resizeBarElement: HTMLElement; + + const onMousemove = (e: MouseEvent) => { + const movementX = e.clientX - mouseDownScreenX; + const newWidth = initialWidth + movementX; + const finalWidth = Math.min(columnConfig.value.maxWidth as number, Math.max(columnConfig.value.minWidth as number, newWidth)); + if (resizeBarElement && scrollRef.value) { + resizeBarElement.style.left = `${finalWidth + thRef.value.offsetLeft - (fixHeader.value ? scrollRef.value.scrollLeft : 0)}px`; + } + rootCtx.emit('resizing', { field: columnConfig.value.field, width: finalWidth }); + }; + + const onMouseup = (e: MouseEvent) => { + const movementX = e.clientX - mouseDownScreenX; + const newWidth = initialWidth + movementX; + const finalWidth = Math.min(columnConfig.value.maxWidth as number, Math.max(columnConfig.value.minWidth as number, newWidth)); + columnConfig.value.width = finalWidth; + resizing.value = false; + rootRef.value?.children[0].classList.remove('data-grid-selector'); + rootRef.value?.children[0].removeChild(resizeBarElement); + afterColumnDragend(columnConfig.value.$columnId, initialWidth - finalWidth); + rootCtx.emit('resizeEnd', { field: columnConfig.value.field, width: finalWidth, beforeWidth: initialWidth }); + document.removeEventListener('mouseup', onMouseup); + document.removeEventListener('mousemove', onMousemove); + }; + + const onMousedown = (e: MouseEvent) => { + const isHandle = (e.target as HTMLElement).classList.contains('resize-handle'); + if (isHandle && rootRef.value && scrollRef.value) { + rootCtx.emit('resizeStart', columnConfig.value.field); + const initialOffset = thRef.value.offsetLeft; + initialWidth = thRef.value.offsetWidth as number; + mouseDownScreenX = e.clientX; + e.stopPropagation(); + resizing.value = true; + + rootRef.value.children[0].classList.add('data-grid-selector'); + + resizeBarElement = document.createElement('div'); + resizeBarElement.classList.add('resize-bar'); + if (rootRef.value.children[0]) { + resizeBarElement.style.display = 'block'; + resizeBarElement.style.left = initialOffset + initialWidth - (fixHeader.value ? scrollRef.value.scrollLeft + 2 : 2) + 'px'; + rootRef.value.children[0].appendChild(resizeBarElement); + } + + document.addEventListener('mouseup', onMouseup); + + document.addEventListener('mousemove', onMousemove); + } + }; + + return { thRef, resizing, onMousedown }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-overflow-tooltip.ts b/packages/devui-vue/devui/data-grid/src/composables/use-overflow-tooltip.ts new file mode 100644 index 0000000000..b61198f057 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-overflow-tooltip.ts @@ -0,0 +1,90 @@ +import { ref } from 'vue'; +import { debounce } from 'lodash'; +import type { Placement } from '../../../overlay'; +import type { InnerColumnConfig } from '../data-grid-types'; + +export function useOverflowTooltip() { + let tdElement: HTMLElement; + const originRef = ref(); + const showTooltip = ref(false); + const tooltipContent = ref(''); + const tooltipPosition = ref(['top', 'right', 'bottom', 'left']); + const tooltipClassName = ref(''); + let mouseEnterDelay = 150; + let mouseLeaveDelay = 100; + let enterable = true; + let isEnterOverlay = false; + let tooltipConfigContent: string | undefined; + + function shouldShowTooltip() { + const range = document.createRange(); + range.setStart(tdElement, 0); + range.setEnd(tdElement, tdElement.childNodes.length); + const rangeWidth = range.getBoundingClientRect().width; + const padding = + parseInt(window.getComputedStyle(tdElement)['paddingLeft'], 10) + parseInt(window.getComputedStyle(tdElement)['paddingRight'], 10); + return Boolean(rangeWidth + padding > tdElement.offsetWidth); + } + + const enter = debounce((tdElement: HTMLElement) => { + if (!isEnterOverlay && shouldShowTooltip() && tdElement.classList.contains('mouse-enter')) { + showTooltip.value = true; + originRef.value = tdElement; + tooltipContent.value = tooltipConfigContent ?? (tdElement?.innerText || tdElement?.textContent || ''); + } + }, mouseEnterDelay); + + const leave = debounce(() => { + if (!isEnterOverlay) { + showTooltip.value = false; + tooltipContent.value = ''; + originRef.value = undefined; + } + }, mouseLeaveDelay); + + const onCellMouseenter = (e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => { + tdElement = e.currentTarget as HTMLElement; + if (tooltipConfig && tdElement) { + tdElement.classList.add('mouse-enter'); + if (typeof tooltipConfig !== 'boolean') { + tooltipConfigContent = tooltipConfig.content; + tooltipConfig.position && (tooltipPosition.value = tooltipConfig.position); + tooltipConfig.class && (tooltipClassName.value = tooltipConfig.class); + mouseEnterDelay = tooltipConfig.mouseEnterDelay ?? mouseEnterDelay; + enterable = tooltipConfig.enterable ?? enterable; + } + enter(tdElement); + } + }; + + const onCellMouseleave = (e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => { + tdElement = e.currentTarget as HTMLElement; + if (tooltipConfig && tdElement) { + tdElement.classList.remove('mouse-enter'); + leave(); + } + }; + + const onOverlayMouseenter = () => { + if (enterable) { + isEnterOverlay = true; + } + }; + + const onOverlayMouseleave = () => { + isEnterOverlay = false; + leave(); + }; + + return { + showTooltip, + originRef, + tooltipContent, + tooltipPosition, + tooltipClassName, + onCellMouseenter, + onCellMouseleave, + onOverlayMouseenter, + onOverlayMouseleave + }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/const.ts b/packages/devui-vue/devui/data-grid/src/const.ts new file mode 100644 index 0000000000..3479a6f8cd --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/const.ts @@ -0,0 +1,13 @@ +import type { Size } from "./data-grid-types"; + +export const ToggleTreeIcon = 'toggle-tree-icon'; +export const DataGridCheckboxClass = 'data-grid-checkbox'; +export const ColumnType = ['checkable', 'index']; +export const ColumnMinWidth = 80; +export const RowHeightMap: Record = { + mini: 24, + xs: 30, + sm: 42, + md: 46, + lg: 54 +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/data-grid-types.ts b/packages/devui-vue/devui/data-grid/src/data-grid-types.ts new file mode 100644 index 0000000000..482f5678cc --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/data-grid-types.ts @@ -0,0 +1,333 @@ +import type { PropType, ExtractPropTypes, VNode, InjectionKey, Ref, SetupContext } from 'vue'; +import type { Placement } from '../../overlay'; + +export interface RowData { + checked?: boolean; + disableCheck?: boolean; + children?: RowData[]; + isLeaf?: boolean; + [k: string]: any; +} +export type ColumnType = 'checkable' | 'index' | ''; +export type ColumnAlign = 'left' | 'center' | 'right'; +export type BorderType = '' | 'bordered' | 'borderless'; +export type Size = 'mini' | 'xs' | 'sm' | 'md' | 'lg'; +export type ShadowType = '' | 'shadowed'; +export type FixedDirection = 'left' | 'right'; +export type SortDirection = 'asc' | 'desc' | ''; +export type CheckableRelation = 'upward' | 'downward' | 'both' | 'none'; +export type SortMethod = (a: T, b: T) => boolean; +export type RowClass = string | ((row: RowData, rowIndex: number) => string); +export type CellClass = string | ((row: RowData, rowIndex: number, column: ColumnConfig, columnIndex: number) => string); +export type RowKey = string | ((row: RowData) => string); +export interface TooltipConfig { + content?: string; + position?: Placement[]; + mouseEnterDelay?: number; + enterable?: boolean; + class?: string; +} +export interface FilterListItem { + name: string; + value: any; + checked?: boolean; +} +export interface ColumnConfig { + header: string; + field: string; + width?: number | string; + minWidth?: number | string; + maxWidth?: number | string; + type?: ColumnType; + resizable?: boolean; + sortable?: boolean; + showSortIcon?: boolean; + sortMethod?: SortMethod; + filterable?: boolean; + showFilterIcon?: boolean; + filterMultiple?: boolean; + filterList?: FilterListItem[]; + filterChange?: (val: FilterListItem | FilterListItem[]) => void; + filterMenu?: (scope: { toggleFilterMenu: (status?: boolean) => void; setFilterStatus: (status: boolean) => void }) => VNode; + fixed?: FixedDirection; + align?: ColumnAlign; + showOverflowTooltip?: boolean | TooltipConfig; + showHeadOverflowTooltip?: boolean | TooltipConfig; + headRender?: (columnConfig: ColumnConfig) => VNode; + cellRender?: (rowData: RowData, rowIndex: number, cellData: string, cellIndex: number) => VNode; +} +export interface InnerColumnConfig extends ColumnConfig { + $columnId: string; + offsetLeft: number; + $showExpandTreeIcon?: boolean; +} +export interface InnerRowData extends RowData { + $rowId?: string; + $parentId?: string; + $rowIndex?: number; + $level?: number; + height?: number; + offsetTop?: number; + showNode?: boolean; // 树表格时,控制是否展示子节点 + $expand?: boolean; + halfChecked?: boolean; + childList?: InnerRowData[]; + descendantList?: InnerRowData[]; +} +export interface ScrollYParams { + distance: number; + renderCountPerScreen: number; + scrollScaleY: number[]; + originRowData: InnerRowData[]; + defaultSortRowData: InnerRowData[]; +} +export interface ScrollXParams { + distance: number; + totalColumn: number; + bufferSize: number; + scrollViewWidth: number; + scrollScaleX: number[]; + originColumnData: InnerColumnConfig[]; +} +export interface IExpandLoadMoreResult { + node: InnerRowData; + rowItems: RowData[]; +} + +export const dataGridProps = { + columns: { + type: Array as PropType, + default: () => [] + }, + data: { + type: Array as PropType, + default: () => [] + }, + indent: { + type: Number, + default: 16 + }, + striped: { + type: Boolean, + default: false + }, + fixHeader: { + type: Boolean, + default: false + }, + rowHoveredHighlight: { + type: Boolean, + default: true + }, + headerBg: { + type: Boolean, + default: false + }, + showHeader: { + type: Boolean, + default: true + }, + lazy: { + type: Boolean, + default: false + }, + virtualScroll: { + type: Boolean, + default: false + }, + reserveCheck: { + type: Boolean, + default: false + }, + resizable: { + type: Boolean, + }, + rowClass: { + type: [String, Function] as PropType, + default: '' + }, + rowKey: { + type: [String, Function] as PropType, + }, + cellClass: { + type: [String, Function] as PropType, + default: '' + }, + size: { + type: String as PropType, + default: 'sm' + }, + borderType: { + type: String as PropType, + default: '' + }, + shadowType: { + type: String as PropType, + default: '' + }, + checkableRelation: { + type: String as PropType, + default: 'both' + } +} +export type DataGridProps = ExtractPropTypes; + +export interface DataGridContext { + showHeader: Ref; + fixHeader: Ref; + lazy: Ref; + virtualScroll: Ref; + resizable: Ref; + indent: Ref; + bodyContentWidth: Ref; + bodyContentHeight: Ref; + translateX: Ref; + translateY: Ref; + bodyScrollLeft: Ref; + rowClass: Ref; + cellClass: Ref; + size: Ref; + rootRef: Ref; + scrollRef: Ref; + headBoxRef: Ref; + renderColumnData: Ref; + renderFixedLeftColumnData: Ref; + renderFixedRightColumnData: Ref; + renderRowData: Ref; + rootCtx: SetupContext; + allChecked: Ref; + halfAllChecked: Ref; + isTreeGrid: Ref; + execSortMethod: (direction: SortDirection, sortMethod?: SortMethod) => void; + addGridThContextToMap: (key: ColumnConfig['field'], thCtx: GridThContext) => void; + clearAllSortState: () => void; + toggleRowExpansion: (node: InnerRowData, status?: boolean) => void; + toggleRowChecked: (node: InnerRowData, status?: boolean) => void; + toggleAllRowChecked: (status?: boolean) => void; + afterColumnDragend: (columnId: InnerColumnConfig['$columnId'], offset: number) => void; +} +export const DataGridInjectionKey: InjectionKey = Symbol('d-data-grid'); + +export interface GridThContext { + doSort: (direction: SortDirection) => void; + doClearSort: () => void; +} + +export const gridHeadProps = { + columnData: { + type: Array as PropType, + default: () => [] + }, + leftColumnData: { + type: Array as PropType, + default: () => [] + }, + rightColumnData: { + type: Array as PropType, + default: () => [] + }, + translateX: { + type: Number, + default: 0 + }, + bodyScrollLeft: { + type: Number, + default: 0 + } +} +export type GridHeadProps = ExtractPropTypes; + +export const gridBodyProps = { + rowData: { + type: Array as PropType, + default: () => [] + }, + columnData: { + type: Array as PropType, + default: () => [] + }, + leftColumnData: { + type: Array as PropType, + default: () => [] + }, + rightColumnData: { + type: Array as PropType, + default: () => [] + }, + translateX: { + type: Number, + default: 0 + }, + translateY: { + type: Number, + default: 0 + }, + bodyScrollLeft: { + type: Number, + default: 0 + } +} +export type GridBodyProps = ExtractPropTypes; + +export const gridThProps = { + columnConfig: { + type: Object as PropType, + default: () => ({}) + }, + mouseenterCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + }, + mouseleaveCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + } +} +export type GridThProps = ExtractPropTypes; + +export const gridThFilterProps = { + filterList: { + type: Array as PropType, + default: () => [] + }, + multiple: { + type: Boolean, + default: true, + }, + showFilterIcon: { + type: Boolean, + default: false + }, + filterMenu: { + type: Function as PropType + }, + setFilterStatus: { + type: Function as PropType<(status: boolean) => void>, + default() { } + } +} +export type GridThFilterProps = ExtractPropTypes; + +export const gridTdProps = { + rowData: { + type: Object as PropType, + default: () => ({}) + }, + cellData: { + type: Object as PropType, + default: () => ({}) + }, + rowIndex: { + type: Number, + default: 0 + }, + mouseenterCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + }, + mouseleaveCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + } +} +export type GridTdProps = ExtractPropTypes; \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/data-grid.scss b/packages/devui-vue/devui/data-grid/src/data-grid.scss new file mode 100644 index 0000000000..f7b217bfae --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/data-grid.scss @@ -0,0 +1,655 @@ +@import '@devui/theme/styles-var/devui-var.scss'; + +$devui-table-inset-shadow-left: var(--devui-table-inset-shadow-left, 8px 0 8px -4px); +$devui-table-inset-shadow-right: var(--devui-table-inset-shadow-right, -8px 0 8px -4px); + +@mixin overflow-ellipsis { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@mixin resize-handle-arrow { + content: ""; + position: absolute; + top: 50%; + display: block; + width: 0; + height: 0; + border: 5px solid transparent; + transform: translateY(-50%); + pointer-events: none; +} + +.#{$devui-prefix}-data-grid { + + &, + & * { + box-sizing: border-box; + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 8px; + background-color: transparent; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: $devui-placeholder; + } + + &::-webkit-scrollbar-corner { + background-color: transparent; + } + } +} + +.#{$devui-prefix}-data-grid { + position: relative; + width: 100%; + height: 100%; + max-height: inherit; + + &__x-space, + &__y-space { + position: absolute; + inset: 0; + z-index: -1; + } + + &__empty { + position: absolute; + top: 47px; + z-index: 5; + width: 100%; + } + + &__head-wrapper { + position: relative; + flex: none; + overflow-x: hidden; + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + } + + &__head { + display: flex; + background-color: $devui-base-bg; + width: fit-content; + } + + &__th { + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; + flex-grow: 0; + font-size: $devui-font-size-sm; + font-weight: 700; + padding: 0 16px; + border-bottom: 1px solid $devui-dividing-line; + color: $devui-text; + + .th-title { + @include overflow-ellipsis(); + } + + svg.th-sort-icon { + margin-left: 8px; + visibility: hidden; + cursor: pointer; + + g { + use { + fill: $devui-shape-icon-fill; + } + + polygon { + fill: $devui-icon-bg; + } + } + + &:hover { + g use { + fill: $devui-shape-icon-fill-active; + } + } + + &.asc { + visibility: visible; + + g { + use { + fill: $devui-brand; + } + + polygon:last-of-type { + opacity: 0.3; + } + } + } + + &.desc { + visibility: visible; + + g { + use { + fill: $devui-brand; + } + + polygon:last-of-type { + opacity: 0.3; + } + } + } + + &.th-sort-default-visible { + visibility: visible; + } + } + + svg.th-filter-icon { + display: block; + height: 16px; + margin-left: 8px; + text-align: right; + visibility: hidden; + cursor: pointer; + + g { + fill: $devui-shape-icon-fill; + } + + &:hover { + g { + fill: $devui-shape-icon-fill-active; + } + } + + &.th-filter-default-visible { + visibility: visible; + } + } + + &:hover { + border-radius: $devui-border-radius 0 0 $devui-border-radius; + + .resize-handle { + border-right: 2px solid $devui-line; + + &::before { + @include resize-handle-arrow(); + + left: -8px; + border-right-color: $devui-line; + } + + &::after { + @include resize-handle-arrow(); + + left: 6px; + border-left-color: $devui-line; + } + } + + .th-sort-icon, + .th-filter-icon { + visibility: visible; + } + } + + &:last-child:hover { + .resize-handle { + &::after { + display: none; + } + } + } + + .resize-handle { + display: inline-block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 5px; + cursor: col-resize; + + &:hover { + border-right: 2px solid $devui-form-control-line-active; + + &::before, + &::after { + display: none; + } + } + } + } + + &__th--mini { + height: 24px; + line-height: 24px; + } + + &__th--xs { + height: 32px; + line-height: 32px; + } + + &__th--sm, + &__th--md, + &__th--lg { + height: 42px; + line-height: 42px; + } + + &__th--operable:hover { + background-color: $devui-list-item-hover-bg; + } + + &__th--sort-active { + background-color: $devui-list-item-hover-bg; + border-radius: $devui-border-radius 0 0 $devui-border-radius; + } + + &__th--filter-active { + background-color: $devui-list-item-hover-bg; + border-radius: $devui-border-radius 0 0 $devui-border-radius; + + svg.th-filter-icon { + visibility: visible; + + g { + fill: $devui-brand; + } + + &:hover { + g { + fill: $devui-brand; + } + } + } + } + + &__body-wrapper { + position: relative; + width: 100%; + flex: 1; + + .#{$devui-prefix}-data-grid__empty { + top: 0; + } + } + + &__body { + width: fit-content; + } + + &__tr { + display: flex; + width: fit-content; + background-color: $devui-base-bg; + } + + &__td { + flex-shrink: 0; + flex-grow: 0; + font-size: $devui-font-size; + padding: 0 16px; + border-bottom: 1px solid $devui-dividing-line; + @include overflow-ellipsis(); + + .tree-indent-placeholder { + display: inline-block; + } + + svg.toggle-tree-icon { + padding-right: 8px; + margin-top: -2px; + vertical-align: middle; + box-sizing: content-box; + cursor: pointer; + } + + svg.expand-icon { + rect { + stroke: $devui-disabled-text; + + &:last-child { + stroke: none; + fill: $devui-disabled-text; + } + } + + &:hover { + rect { + stroke: $devui-icon-fill-active; + + &:last-child { + stroke: none; + fill: $devui-icon-fill-active; + } + } + } + } + + svg.fold-icon { + rect { + stroke: $devui-disabled-text; + } + + path { + fill: $devui-disabled-text; + } + + &:hover { + rect { + stroke: $devui-icon-fill-active; + } + + path { + fill: $devui-icon-fill-active; + } + } + } + } + + &__td--checkable { + display: flex; + align-items: center; + } + + &__td--mini { + height: 24px; + line-height: 24px; + } + + &__td--xs { + height: 30px; + line-height: 30px; + } + + &__td--sm { + height: 42px; + line-height: 42px; + } + + &__td--md { + height: 46px; + line-height: 46px; + } + + &__td--lg { + height: 54px; + line-height: 54px; + } + + &__last-sticky-left-cell { + border-right-color: transparent !important; + } + + &__first-sticky-right-cell { + border-left-color: transparent !important; + } + + &__sticky-left-head, + &__sticky-right-head, + &__sticky-left-body, + &__sticky-right-body { + position: absolute; + z-index: 10; + } + + &--scroll-middle, + &--scroll-right { + .#{$devui-prefix}-data-grid__last-sticky-left-cell { + position: relative; + border-right-color: transparent !important; + background-color: linear-gradient(to left, transparent, $devui-base-bg 10px); + + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 10px; + pointer-events: none; + box-shadow: inset $devui-table-inset-shadow-left $devui-light-shadow; + } + } + } + + &--scroll-middle, + &--scroll-left { + .#{$devui-prefix}-data-grid__first-sticky-right-cell { + position: relative; + border-left-color: transparent !important; + background-color: linear-gradient(to right, transparent, $devui-base-bg 10px); + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 10px; + pointer-events: none; + box-shadow: inset $devui-table-inset-shadow-right $devui-light-shadow; + } + } + } + + &__tooltip.#{$devui-prefix}-flexible-overlay { + max-width: 200px; + min-height: 26px; + padding: 0 16px; + font-size: $devui-font-size; + color: $devui-feedback-overlay-text; + letter-spacing: 0; + line-height: 1.5; + background: $devui-feedback-overlay-bg; + box-shadow: none; + overflow-wrap: break-word; + word-break: break-word; + word-wrap: break-word; + text-align: start; + border-radius: $devui-border-radius-feedback; + line-break: auto; + text-decoration: none; + text-shadow: none; + text-transform: none; + word-spacing: normal; + white-space: normal; + opacity: 1; + z-index: $devui-z-index-pop-up; + + span { + display: block; + max-width: 100%; + max-height: inherit; + padding: 4px 0; + overflow: auto; + } + } + + &__filter-wrapper { + font-size: $devui-font-size; + + & * { + box-sizing: border-box; + } + + .filter-all-check { + width: 200px; + padding: 0 8px 4px; + border-bottom: 1px solid $devui-dividing-line; + } + + .filter-multiple-menu { + width: 200px; + padding: 4px 8px; + border-bottom: 1px solid $devui-dividing-line; + } + + .filter-single-menu { + width: 200px; + + .filter-item { + padding: 0 8px; + color: $devui-text; + border-radius: $devui-border-radius; + transition: color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth, + background-color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth; + + &:hover { + color: $devui-list-item-hover-text; + background-color: $devui-list-item-hover-bg; + } + } + + .filter-item-active { + color: $devui-list-item-active-bg; + background-color: $devui-list-item-active-text; + } + } + + .filter-operation { + display: flex; + justify-content: center; + align-items: center; + padding: 0 8px; + height: 26px; + } + + .filter-item { + display: flex; + align-items: center; + height: 30px; + cursor: pointer; + @include overflow-ellipsis(); + } + } + + &--fix-header { + display: flex; + flex-flow: column nowrap; + overflow: unset; + } + + &--striped { + .#{$devui-prefix}-data-grid__tr:nth-of-type(even) { + background-color: $devui-list-item-strip-bg; + } + } + + &--row-hover-highlight { + .#{$devui-prefix}-data-grid__tr.hover-tr { + background-color: $devui-list-item-hover-bg; + + .#{$devui-prefix}-data-grid__last-sticky-left-cell { + background-color: linear-gradient(to left, transparent, $devui-list-item-hover-bg 10px); + } + + .#{$devui-prefix}-data-grid__first-sticky-right-cell { + background-color: linear-gradient(to right, transparent, $devui-list-item-hover-bg 10px); + } + } + } + + &--header-bg { + .#{$devui-prefix}-data-grid__head { + background-color: $devui-list-item-strip-bg; + } + } + + &--bordered { + .#{$devui-prefix}-data-grid__th { + border-top: 1px solid $devui-dividing-line; + border-right: 1px solid $devui-dividing-line; + + &:first-child { + border-left: 1px solid $devui-dividing-line; + } + } + + .#{$devui-prefix}-data-grid__td { + border-right: 1px solid $devui-dividing-line; + + &:first-child { + border-left: 1px solid $devui-dividing-line; + } + } + } + + &--borderless { + .#{$devui-prefix}-data-grid__th { + border: none; + } + + .#{$devui-prefix}-data-grid__td { + border: none; + } + } + + &--shadowed { + border-radius: $devui-border-radius-card; + box-shadow: $devui-shadow-length-base $devui-light-shadow; + } + + &--left { + text-align: left; + + &.#{$devui-prefix}-data-grid__th, + &.#{$devui-prefix}-data-grid__td { + justify-content: flex-start; + } + } + + &--center { + text-align: center; + + &.#{$devui-prefix}-data-grid__th, + &.#{$devui-prefix}-data-grid__td { + justify-content: center; + } + } + + &--right { + text-align: right; + + &.#{$devui-prefix}-data-grid__th, + &.#{$devui-prefix}-data-grid__td { + justify-content: flex-end; + } + } + + &--is-virtual { + max-height: unset; + } + + .resize-bar { + display: none; + position: absolute; + top: 0; + bottom: 0; + z-index: 9999; + width: 2px; + background: $devui-form-control-line-active; + cursor: col-resize; + } +} + +.data-grid-selector { + user-select: none; + cursor: col-resize; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/data-grid.tsx b/packages/devui-vue/devui/data-grid/src/data-grid.tsx new file mode 100644 index 0000000000..38cd7a5e91 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/data-grid.tsx @@ -0,0 +1,106 @@ +import { defineComponent, provide, toRefs, ref } from "vue"; +import type { SetupContext } from 'vue'; +import FixHeadGrid from './components/fix-head-grid'; +import NormalHeadGrid from './components/normal-head-grid'; +import { dataGridProps, DataGridInjectionKey } from "./data-grid-types"; +import type { DataGridProps } from "./data-grid-types"; +import { useDataGrid, useDataGridStyle } from "./composables/use-data-grid"; +import './data-grid.scss'; + +export default defineComponent({ + name: 'DDataGrid', + props: dataGridProps, + emits: [ + 'loadMore', + 'sortChange', + 'checkChange', + 'checkAllChange', + 'expandChange', + 'expandAllChange', + 'rowClick', + 'cellClick', + 'resizeStart', + 'resizing', + 'resizeEnd', + 'expandLoadMore', + ], + setup(props: DataGridProps, ctx: SetupContext) { + const { fixHeader, showHeader, lazy, rowClass, cellClass, size, indent, virtualScroll, resizable } = toRefs(props); + const rootRef = ref(); + const { + scrollRef, + headBoxRef, + bodyScrollLeft, + bodyContentHeight, + bodyContentWidth, + translateX, + translateY, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderColumnData, + renderRowData, + isTreeGrid, + allChecked, + halfAllChecked, + sort, + getCheckedRows, + execSortMethod, + addGridThContextToMap, + clearAllSortState, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + afterColumnDragend, + refreshRowsData, + } = useDataGrid(props, ctx); + const { gridClasses } = useDataGridStyle(props, scrollRef); + + provide(DataGridInjectionKey, { + rowClass, + cellClass, + size, + fixHeader, + showHeader, + lazy, + indent, + virtualScroll, + resizable, + bodyContentWidth, + bodyContentHeight, + translateX, + translateY, + bodyScrollLeft, + renderColumnData, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderRowData, + rootRef, + scrollRef, + headBoxRef, + rootCtx: ctx, + isTreeGrid, + allChecked, + halfAllChecked, + execSortMethod, + addGridThContextToMap, + clearAllSortState, + toggleRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + afterColumnDragend + }); + + ctx.expose({ sort, toggleRowChecked, toggleAllRowChecked, getCheckedRows, toggleRowExpansion, toggleAllRowExpansion, refreshRowsData }); + + return () => ( +
+ {fixHeader.value ? ( + + ) : ( + + )} +
+ ); + } +}); diff --git a/packages/devui-vue/devui/data-grid/src/utils.ts b/packages/devui-vue/devui/data-grid/src/utils.ts new file mode 100644 index 0000000000..e7947f7f77 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/utils.ts @@ -0,0 +1,148 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { Ref } from 'vue'; +import { isFunction } from 'lodash'; +import type { RowData, InnerRowData, RowKey, InnerColumnConfig, ColumnConfig } from './data-grid-types'; +import { ColumnMinWidth } from './const'; + +export function getRealWidth(width: string | number | undefined, totalWidth: number) { + if (width === undefined) { + return width; + } + if (typeof width === 'string') { + if (width.endsWith('%')) { + return totalWidth * (parseInt(width) / 100); + } else { + return parseInt(width); + } + } else { + return width; + } +} + +export function calcEachColumnWidth(columns: ColumnConfig[], containerWidth: number): ColumnConfig[] { + const flexColumnIndex: number[] = []; + const result: ColumnConfig[] = []; + let totalMinWidth = 0; + + // 根据配置的width参数计算每列宽度 + for (let i = 0; i < columns.length; i++) { + const item = { ...columns[i] }; + item.minWidth = getRealWidth(item.minWidth, containerWidth) ?? ColumnMinWidth; + item.maxWidth = getRealWidth(item.maxWidth, containerWidth) ?? Infinity; + if (item.width) { + item.width = getRealWidth(item.width, containerWidth) as number; + totalMinWidth += item.width; + } else { + // 记录没有配置width参数的列索引 + flexColumnIndex.push(i); + } + result.push(item); + } + + if (flexColumnIndex.length) { + const remainWidth = containerWidth - totalMinWidth; + + if (remainWidth > 0) { + const flexColumnItemWidth = remainWidth / flexColumnIndex.length; + flexColumnIndex.forEach((item) => { + result[item].width = Math.min(result[item].maxWidth as number, Math.max(result[item].minWidth as number, flexColumnItemWidth)); + }); + } else { + flexColumnIndex.forEach((item) => { + result[item].width = result[item].minWidth; + }) + } + } + + return result; +} + +export function getXStartOrEndIndex(list: InnerColumnConfig[], distance: number) { + let start = 0; + let end = list.length - 1; + while (start < end) { + const mid = Math.floor((start + end) / 2); + const { width, offsetLeft } = list[mid]; + if (distance >= offsetLeft && distance < (width as number) + offsetLeft) { + start = mid; + break; + } else if (distance >= (width as number) + offsetLeft) { + start = mid + 1; + } else if (distance < offsetLeft) { + end = mid - 1; + } + } + return start; +} + +export function getYStartIndex(list: RowData[], distance: number) { + let start = 0; + let end = list.length - 1; + + while (start < end) { + const mid = Math.floor((start + end) / 2); + const { height, offsetTop } = list[mid]; + if (distance >= offsetTop && distance < height + offsetTop) { + start = mid; + break; + } else if (distance >= height + offsetTop) { + start = mid + 1; + } else if (distance < offsetTop) { + end = mid - 1; + } + } + + return start; +} + +export function generateInnerData( + rowDataList: RowData[], + rowIndex: Ref, + rowKey: Ref | undefined, + level = 0, + parentNode: InnerRowData = {} +) { + level++; + const result: InnerRowData[] = []; + + for (let i = 0; i < rowDataList.length; i++) { + const newItem: InnerRowData = rowDataList[i]; + newItem.$rowIndex = rowIndex.value; + newItem.$level = level; + newItem.showNode = level === 1; + newItem.$expand = false; + newItem.isLeaf = newItem.isLeaf ?? !newItem.children?.length; + rowIndex.value++; + + if (rowKey && rowKey.value) { + if (typeof rowKey.value === 'string') { + newItem.$rowId = newItem[rowKey.value]; + } else if (isFunction(rowKey.value)) { + newItem.$rowId = rowKey.value(rowDataList[i]); + } else { + newItem.$rowId = uuidv4(); + } + } else { + newItem.$rowId = uuidv4(); + } + + if (parentNode.$rowId) { + newItem.$parentId = parentNode.$rowId; + } + + if (!(newItem.children && newItem.children.length)) { + newItem.childList = []; + result.push(newItem); + } else { + const childrenNodes = generateInnerData(newItem.children, rowIndex, rowKey, level, newItem); + Reflect.deleteProperty(newItem, 'children'); + newItem.childList = childrenNodes.filter((child) => child.$parentId === newItem.$rowId); + newItem.descendantList = childrenNodes; + const childCheckedNodes = childrenNodes.filter((child) => child.checked); + newItem.halfChecked = childCheckedNodes.length !== 0 && childrenNodes.length !== childCheckedNodes.length; + result.push(newItem, ...childrenNodes); + } + } + + return result; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/locale/lang/en-us.ts b/packages/devui-vue/devui/locale/lang/en-us.ts index 5a19c92ae9..f099c90a59 100644 --- a/packages/devui-vue/devui/locale/lang/en-us.ts +++ b/packages/devui-vue/devui/locale/lang/en-us.ts @@ -65,6 +65,10 @@ export default { selectAll: 'Select all', ok: 'OK', }, + dataGrid: { + selectAll: 'Select all', + ok: 'OK', + }, timePopup: { ok: 'OK', }, diff --git a/packages/devui-vue/devui/locale/lang/zh-cn.ts b/packages/devui-vue/devui/locale/lang/zh-cn.ts index 7a5489c96b..b9cb78ee95 100644 --- a/packages/devui-vue/devui/locale/lang/zh-cn.ts +++ b/packages/devui-vue/devui/locale/lang/zh-cn.ts @@ -66,6 +66,10 @@ export default { selectAll: '全选', ok: '确定', }, + dataGrid: { + selectAll: '全选', + ok: '确定', + }, timePopup: { ok: '确定', }, diff --git a/packages/devui-vue/devui/style/global.scss b/packages/devui-vue/devui/style/global.scss new file mode 100644 index 0000000000..79db82c818 --- /dev/null +++ b/packages/devui-vue/devui/style/global.scss @@ -0,0 +1,37 @@ +@import '@devui/theme/styles-var/devui-var.scss'; + +.devui-scroll-overlay { + overflow: auto; + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + + &:hover { + &::-webkit-scrollbar-thumb { + background-color: $devui-line; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: $devui-placeholder; + } + } +} + +@-moz-document url-prefix() { + body * { + scrollbar-width: thin; + + .#{$devui-prefix}-data-grid__head-wrapper { + scrollbar-color: transparent transparent; + } + + .devui-scroll-overlay { + scrollbar-color: transparent transparent; + + &:hover { + scrollbar-color: $devui-line transparent; + } + } + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/style/index.scss b/packages/devui-vue/devui/style/index.scss new file mode 100644 index 0000000000..4ee764d585 --- /dev/null +++ b/packages/devui-vue/devui/style/index.scss @@ -0,0 +1 @@ +@import './global.scss'; \ No newline at end of file diff --git a/packages/devui-vue/docs/components/data-grid/index.md b/packages/devui-vue/docs/components/data-grid/index.md new file mode 100644 index 0000000000..54d8ffa0fb --- /dev/null +++ b/packages/devui-vue/docs/components/data-grid/index.md @@ -0,0 +1,1999 @@ +# DataGrid 表格 + +展示行列数据。 + +### 基本用法 + +:::demo `data`参数传入要展示的数据,`columns`参数传入列数据;列数据中的`field`参数为对应列内容的字段名,`header`参数为对应列的标题。 + +```vue + + + +``` + +::: + +### 表格样式 + +:::demo `striped`参数设置是否显示斑马纹;`header-bg`参数设置是否显示表头背景色;`border-type`参数设置边框类型;`shadow-type`参数设置阴影类型;`show-header`参数设置是否显示表头;列配置中的`align`参数设置对齐方式。 + +```vue + + + + + +``` + +::: + +### 动态数据 + +:::demo loading 由业务自行添加。 + +```vue + + + + + +``` + +::: + +### 动态列 + +:::demo + +```vue + + + +``` + +::: + +### 懒加载 + +:::demo `lazy`参数设置为`true`,即可启用懒加载,在`load-more`事件回调中可动态添加数据。 + +```vue + + + +``` + +::: + +### 自定义行样式 + +:::demo 通过`row-class`参数自定义行样式,可传入字符串,来自定义每一行的样式;也可传入函数,来自定义某一行或某几行的样式,函数参数为行数据和行索引。 + +```vue + + + + + +``` + +::: + +### 自定义单元格样式 + +:::demo 通过`cell-class`参数自定义行样式,可传入字符串,来自定义每一行的样式;也可传入函数,来自定义某一行或某几行的样式,函数参数为行数据、行索引、列数据、列索引。 + +```vue + + + + + +``` + +::: + +### 自定义列宽 + +:::demo 通过`width`参数配置列宽,参数类型为`string | number | undefined`,`number`类型为固定宽度;`string`类型可配置为像素或者百分比,当为百分比时,基于表格所在容器总宽计算该列实际宽度;当为`undefined`即不配置列宽参数时,会与其他不配置列宽的列平分剩余宽度(即容器总宽减去已知列宽)。
通过`minWidth`参数配置最小列宽,参数类型同`width`,当该列未配置`width`参数时,若给该列分配的宽度小于`minWidth`,则按照`minWidth`设置列宽,未配置`minWidth`参数时,为保证该列能够显示,会默认设置`80px`宽度。
通过`maxWidth`参数配置最大列宽,参数类型同`width`,当该列未配置`width`参数时,若给该列分配的宽度大于`maxWidth`,则按照`maxWidth`设置列宽。 + +```vue + + + +``` + +::: + +### 自定义单元格内容 + +:::demo 通过在列数据的`cellRender`参数来自定义单元格内容,函数参数依次为行数据、行索引、列数据、列索引。`cellRender`函数可返回由[h 函数](https://cn.vuejs.org/api/render-function.html#h)创建的虚拟 DOM 节点。 + +```vue + + + +``` + +::: + +### 自定义表头 + +:::demo 通过在列数据的`headRender`参数来自定义表头,函数参数为当前列数据。`headRender`函数可返回由[h 函数](https://cn.vuejs.org/api/render-function.html#h)创建的虚拟 DOM 节点。 + +```vue + + + +``` + +::: + +### 自定义提示内容 + +:::demo `showOverflowTooltip`参数可设置内容超出后,鼠标悬浮是否显示提示内容,可设置为`true`值来开启此功能,也可通过`TooltipConfig`类型的参数来对提示内容做一些配置。
`showHeadOverflowTooltip`用来设置表头,作用及参数值与`showOverflowTooltip`一致。 + +```vue + + + +``` + +::: + +### 空数据模板 + +:::demo 通过`empty`插槽可自定义数据为空时显示的内容。 + +```vue + + + + + +``` + +::: + +### 固定表头 + +:::demo `fix-header`参数设置为`true`即可固定表头。 + +```vue + + + +``` + +::: + +### 固定列 + +:::demo 通过列数据的`fixed`参数可将该列固定,参数值为`left`和`right`,可分别固定在左侧和右侧。 + +```vue + + + +``` + +::: + +### 排序 + +:::demo 列数据中的`sortable`参数设置为`true`可启用排序功能,`sortMethod`参数自定义排序方法,排序后触发`sort-change`事件,事件抛出产生排序的列字段以及当前排序方式。 + +```vue + + + +``` + +::: + +### 过滤 + +:::demo 列数据中的`filterable`参数设置为`true`可启用过滤功能,内置过滤器默认为多选,通过`filterMultiple: false`可设置单选过滤器;`filterList`参数设置过滤器列表;`filterChange`参数为过滤条件变更后的回调;`filterMenu`参数可以自定义过滤器,函数可返回由[h 函数](https://cn.vuejs.org/api/render-function.html#h)创建的虚拟 DOM 节点。。 + +```vue + + + +``` + +::: + +### 树表格 + +行数据中包含`children`字段,则默认展示为树表格;若想在展开某一行时异步加载数据,可将展开行的`isLeaf`设置为`false`,当展开该行时会触发`expand-load-more`事件,事件抛出当前展开行的数据和回调函数,加载完成后执行回调函数将数据回填进表格中;`toggleRowExpansion`可切换某一行的展开状态,第一个参数为行数据,第二个参数可选,可设置展开状态。 + +:::demo + +```vue + + + +``` + +::: + +### 列宽拖拽 + +:::demo 列数据配置`resizable`为`true`,使该列可拖拽,拖拽后,会通过最后一列做宽度补偿。**虚拟滚动暂时不支持列宽拖拽**。 + +```vue + + + +``` + +::: + +### 可选择 + +:::demo 列数据中`type`参数设置为`checkable`可启用勾选功能;行数据中`checked`参数可设置默认勾选状态,`disableCheck`参数可设置禁用勾选;行勾选状态变更时触发`check-change`事件,事件参数为当前行的勾选状态和行数据;表头勾选状态变更时触发`check-all-change`事件,事件参数为当前勾选状态。 + +```vue + + + +``` + +::: + +### 父子联动 + +:::demo 在搭配树形表格使用时,通过`checkable-relation`参数可以控制父子联动方式,默认为`both`,即勾选状态改变会同时影响父和子;其他可选参数为`downward`、`upward`、`none`,具体表现参考 demo 。 + +```vue + + + +``` + +::: + +### 操作方法 + +:::demo `toggleRowChecked`方法切换行的勾选状态,第一个参数为行数据,第二个参数可选,可设置勾选状态;`toggleAllRowChecked`方法切换全选状态,参数可选,可设置勾选状态;`getCheckedRows`方法获取当前已勾选数据。 + +```vue + + + +``` + +::: + +### 大数据 + +:::demo + +```vue + + + +``` + +::: + +### DataGrid 参数 + +| 参数名 | 类型 | 默认值 | 说明 | +| :-------------------- | :-------------------------------------- | :----- | :-------------------------------------------------------------------- | +| data | [RowData[]](#rowdata) | [] | 表格数据 | +| columns | [ColumnConfig[]](#columnconfig) | [] | 列配置数据 | +| indent | `number` | 16 | 树形表格缩进量,单位`px` | +| striped | `boolean` | false | 是否显示斑马纹 | +| fix-header | `boolean` | false | 是否固定表头 | +| row-hovered-highlight | `boolean` | true | 鼠标悬浮是否高亮行 | +| header-bg | `boolean` | false | 是否显示表头背景色 | +| show-header | `boolean` | true | 是否显示表头 | +| lazy | `boolean` | false | 是否懒加载 | +| virtual-scroll | `boolean` | false | 是否启用虚拟滚动 | +| reserve-check | `boolean` | false | 是否保留勾选状态 | +| resizable | `boolean` | -- | 可选,是否所有列支持拖拽调整列宽,列的 resizable 参数优先级高于此参数 | +| row-class | [RowClass](#rowclass) | '' | 自定义行样式,可设置为函数,不同行设置不同样式 | +| row-key | [RowKey](#rowkey) | -- | 勾选行、树表格等场景为必填,需要根据此字段定义的唯一 key 查找数据 | +| cell-class | [CellClass](#cellclass) | '' | 自定义单元格样式,可设置为函数,不同单元格设置不同样式 | +| border-type | [BorderType](#bordertype) | '' | 边框类型 | +| shadow-type | [ShadowType](#shadowtype) | '' | 阴影类型 | +| size | [Size](#size) | 'sm' | 表格大小,反应在行高的不同上 | +| checkable-relation | [CheckableRelation](#checkablerelation) | 'both' | 行勾选和树形表格组合使用时,用来定义父子联动关系 | + +### DataGrid 事件 + +| 事件名 | 回调参数 | 说明 | +| :--------------- | :--------------------------------------------------------------------------- | :------------------------------------------------------------- | +| row-click | `Function(e: RowClickArg)` | 行点击时触发的事件 | +| cell-click | `Function(e: CellClickArg)` | 单元格点击时触发的事件 | +| check-change | `Function(status: boolean, rowData: RowData)` | 行勾选状态变化时触发的事件,参数为当前勾选状态和行数据 | +| check-all-change | `Function(status: boolean)` | 表头勾选状态变化时触发的事件,参数为当前勾选状态 | +| expand-change | `Function(status: boolean, rowData: RowData)` | 树表格,行展开状态变化时触发的事件,参数为当前展开状态和行数据 | +| sort-change | `Function(e: SortChangeArg)` | 排序变化时触发的事件 | +| load-more | `Function()` | 懒加载触发的事件 | +| expand-load-more | `Function(node: RowData, callback: (result: IExpandLoadMoreResult) => void)` | 树表格展开时触发的懒加载事件 | + +### DataGrid 方法 + +| 方法名 | 类型 | 说明 | +| :-------------------- | :--------------------------------------------------------------- | :--------------------------------------------------- | +| sort | `(key: ColumnConfig['field'], direction: SortDirection) => void` | 对某列按指定方式进行排序 | +| toggleRowChecked | `(node: RowData, status?: boolean) => void` | 切换指定行勾选状态,可通过`status`参数指定勾选状态 | +| toggleAllRowChecked | `(status?: boolean) => void` | 切换表头勾选状态,可通过`status`参数指定勾选状态 | +| getCheckedRows | `() => RowData[]` | 获取已勾选的行数据 | +| toggleRowExpansion | `(node: RowData, status?: boolean) => void` | 切换指定行的展开状态,可通过`status`参数指定展开状态 | +| toggleAllRowExpansion | `(status?: boolean) => void` | 切换所有行的展开状态,可通过`status`参数指定展开状态 | +| refreshRowsData | `() => void` | 根据当前传入的`data`,重新计算需要显示的行数据 | + +### 类型定义 + +#### RowData + +```ts +interface RowData { + checked?: boolean; // 是否勾选 + disableCheck?: boolean; // 是否禁用勾选 + children?: RowData[]; // 当存在此字段时,默认展示树表格 + isLeaf?: boolean; // 是否为叶子节点,当树表格中需要异步加载子节点时,需要将此参数设置为false + [k: string]: any; // 业务其他数据 +} +``` + +#### ColumnConfig + +```ts +interface ColumnConfig { + header: string; // 列的头部展示内容 + field: string; // 列字段,用于从 RowData 取数据展示在单元格 + width?: number | string; // 列宽度;可设置百分比,会根据容器总宽度计算单元格实际所占宽度,未设置时,会自动分配宽度,分配规则参考【自定义样式-自定义列宽】示例;启用虚拟滚动此字段必填 + minWidth?: number | string; // 最小列宽,可设置百分比,会根据容器总宽度计算实际最小列宽 + maxWidth?: number | string; // 最大列宽,可设置百分比,会根据容器总宽度计算实际最大列宽 + type?: ColumnType; // 列类型,复选框、索引等 + resizable?: boolean; // 是否支持拖拽调整列宽 + sortable?: boolean; // 是否启用排序 + showSortIcon?: boolean; // 是否显示排序未激活图标,默认不显示 + sortMethod?: SortMethod; // 自定义排序方法 + filterable?: boolean; // 是否启用过滤 + showFilterIcon?: boolean; // 是否显示筛选未激活图标,默认不显示 + filterMultiple?: boolean; // 组件内置多选和单选两种过滤器,默认为多选,配置为 false 可使用单选过滤器 + filterList?: FilterListItem[]; // 过滤器列表 + filterChange?: (val: FilterListItem | FilterListItem[]) => void; // 过滤内容变更时触发 + filterMenu?: (scope: { toggleFilterMenu: (status?: boolean) => void; setFilterStatus: (status: boolean) => void }) => VNode; // 自定义过滤器;toggleFilterMenu: 展开收起筛选菜单;setFilterStatus: 设置表头是否高亮 + fixed?: FixedDirection; // 固定列的方向,固定在左侧 or 右侧 + align?: ColumnAlign; // 列内容对齐方式 + showOverflowTooltip?: boolean | TooltipConfig; // 单元格内容超长是否通过 Tooltip 显示全量内容,可对 Tooltip 进行配置,支持的配置项参考 TooltipConfig + showHeadOverflowTooltip?: boolean | TooltipConfig; // 表头内容超长是否通过 Tooltip 显示全量内容,可对 Tooltip 进行配置,支持的配置项参考 TooltipConfig + headRender?: (columnConfig: ColumnConfig) => VNode; // 自定义表头 + cellRender?: (rowData: RowData, rowIndex: number, cellData: string, cellIndex: number) => VNode; // 自定义单元格 +} +``` + +#### ColumnType + +列类型 + +```ts +type ColumnType = 'checkable' | 'index' | ''; +``` + +#### FilterListItem + +过滤器列表项 + +```ts +interface FilterListItem { + name: string; // 显示的内容 + value: any; // 对应的值 +} +``` + +#### FixedDirection + +固定列的方向 + +```ts +type FixedDirection = 'left' | 'right'; +``` + +#### ColumnAlign + +列内容对齐方式 + +```ts +type ColumnAlign = 'left' | 'center' | 'right'; +``` + +#### TooltipConfig + +超长显示 Tooltip 的配置项 + +```ts +interface TooltipConfig { + content?: string; // 提示内容,默认为单元格内容 + position?: Placement[]; // 展开方向,默认展开顺序为上右下左 + mouseEnterDelay?: number; // 鼠标悬浮后延时多久提示,默认150ms + enterable?: boolean; // 鼠标是否可移入提示框,默认 true +} +``` + +#### RowClass + +自定义行样式的类名,配置为函数,可不同行设置不同样式 + +```ts +type RowClass = string | ((row: RowData, rowIndex: number) => string); +``` + +#### RowKey + +勾选行、树表格等场景需要通过方法操作时根据此字段定义的唯一 key 查找数据 + +```ts +type RowKey = string | ((row: RowData) => string); +``` + +#### CellClass + +自定义单元格样式,可设置为函数,不同单元格设置不同样式 + +```ts +type CellClass = string | ((row: RowData, rowIndex: number, column: ColumnConfig, columnIndex: number) => string); +``` + +#### BorderType + +```ts +type BorderType = '' | 'bordered' | 'borderless'; +``` + +#### ShadowType + +```ts +type ShadowType = '' | 'shadowed'; +``` + +#### Size + +```ts +type Size = 'mini' | 'xs' | 'sm' | 'md' | 'lg'; +``` + +#### CheckableRelation + +```ts +type CheckableRelation = 'upward' | 'downward' | 'both' | 'none'; +``` + +#### RowClickArg + +行点击事件的回调参数 + +```ts +interface RowClickArg { + row: RowData; // 行数据 + renderRowIndex: number; // 当前行在已渲染列表中的索引 + flattenRowIndex: number; // 当前行在所有数据中的索引,大数据时和 renderRowIndex 不一致 +} +``` + +#### CellClickArg + +单元格点击事件的回调参数 + +```ts +interface CellClickArg { + row: RowData; // 行数据 + renderRowIndex: number; // 当前行在已渲染列表中的索引 + flattenRowIndex: number; // 当前行在所有数据中的索引,大数据时和 renderRowIndex 不一致 + column: ColumnConfig; // 列配置数据 + columnIndex: number; // 列在所有数据中的索引 +} +``` + +#### SortDirection + +排序方式 + +```ts +type SortDirection = 'asc' | 'desc' | ''; +``` + +#### SortChangeArg + +排序事件回调参数 + +```ts +interface SortChangeArg { + field: ColumnConfig['field']; // 列字段 + direction: SortDirection; // 当前排序方式 +} +``` + +#### IExpandLoadMoreResult + +```ts +interface IExpandLoadMoreResult { + node: RowData; + rowItems: RowData[]; +} +``` diff --git a/packages/devui-vue/package.json b/packages/devui-vue/package.json index ee44664d29..f588bb4631 100644 --- a/packages/devui-vue/package.json +++ b/packages/devui-vue/package.json @@ -71,6 +71,7 @@ "mermaid": "9.1.1", "mitt": "^3.0.0", "monaco-editor": "0.34.0", + "uuid": "^9.0.1", "vue": "^3.2.37", "vue-router": "^4.0.3", "xss": "^1.0.14"