feat: 前端组件准备
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
<!-- TableCom.vue -->
|
||||
<script lang="ts" setup generic="T extends Record<string, any>">
|
||||
import { PlayIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
import { ChevronUpDownIcon } from "@/assets/Icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSortableTable } from "@/utils";
|
||||
|
||||
import type { TableColumn, TableProps } from "./type";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableEmpty,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const props = defineProps<TableProps<T>>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
"sort:change": [{ sortColumn: string; sortDirection: "asc" | "desc" | "" }];
|
||||
"page:change": [page: number];
|
||||
"row-expand:change": [{ row: T; index: number; expanded: boolean }];
|
||||
}>();
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 640px)");
|
||||
const $slots = useSlots();
|
||||
const tableEl = useTemplateRef<HTMLDivElement>("tableEl");
|
||||
const { x, arrivedState, measure } = useScroll(tableEl);
|
||||
const { width } = useElementSize(tableEl);
|
||||
|
||||
// 显示的数据,避免加载时表格闪烁
|
||||
const displayData = ref<T[]>([]);
|
||||
|
||||
// 监听数据加载完成,更新显示数据
|
||||
watch(
|
||||
[() => props.data, () => props.load],
|
||||
([newData, loading], [_, oldLoading]) => {
|
||||
// 如果不启用数据缓冲,直接更新显示数据
|
||||
if (!props.buffer) {
|
||||
displayData.value = newData;
|
||||
}
|
||||
// 只有当加载状态从 true 变为 false 时才更新显示数据
|
||||
if (oldLoading === true && loading === false) {
|
||||
displayData.value = newData;
|
||||
}
|
||||
// 首次加载完成
|
||||
if (oldLoading === undefined && loading === false && newData.length > 0) {
|
||||
displayData.value = newData;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const defaultSortColumn = computed(() => props.defaultSortColumn || "");
|
||||
const defaultSortDirection = computed(() => props.defaultSortDirection || "");
|
||||
const columnsConfig = computed(() => props.columns || []);
|
||||
|
||||
// 表格排序
|
||||
const { sortColumn, sortDirection, handleSort, sortedData } = useSortableTable(
|
||||
displayData,
|
||||
defaultSortColumn,
|
||||
defaultSortDirection,
|
||||
columnsConfig
|
||||
);
|
||||
|
||||
// 监听排序变化,通知父组件
|
||||
watchEffect(() => {
|
||||
emits("sort:change", {
|
||||
sortColumn: sortColumn.value,
|
||||
sortDirection: sortDirection.value
|
||||
});
|
||||
});
|
||||
|
||||
// 最终数据
|
||||
const finalData = computed(() => {
|
||||
if (!props.pinnedCondition) return sortedData.value;
|
||||
|
||||
const pinned: T[] = [];
|
||||
const unpinned: T[] = [];
|
||||
|
||||
sortedData.value.forEach((row) => {
|
||||
if (props.pinnedCondition!(row as T)) {
|
||||
pinned.push(row as T);
|
||||
} else {
|
||||
unpinned.push(row as T);
|
||||
}
|
||||
});
|
||||
|
||||
return [...pinned, ...unpinned];
|
||||
});
|
||||
|
||||
// 获取索引值
|
||||
const getIndexValue = (column: TableColumn, rowIndex: number) => {
|
||||
if (typeof column.index === "function") return column.index(rowIndex);
|
||||
return typeof column.index === "number" ? column.index + rowIndex : rowIndex;
|
||||
};
|
||||
|
||||
// 获取单元格内容
|
||||
const getCellValue = (row: any, column: TableColumn, rowIndex: number) => {
|
||||
// 如果是索引列
|
||||
if (column.type === "index") return getIndexValue(column, rowIndex + 1);
|
||||
|
||||
// 优先使用 prop
|
||||
if (column.prop && typeof column.prop === "string") return row[column.prop];
|
||||
|
||||
// 其次使用 slot 对应的字段
|
||||
if (column.slot && typeof column.slot === "string") return row[column.slot];
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 获取列的字段名
|
||||
const getColumnKey = (column: TableColumn, index?: number) =>
|
||||
column.key ||
|
||||
(column.prop as string) ||
|
||||
column.slot ||
|
||||
column.label ||
|
||||
(index ?? "");
|
||||
|
||||
// 处理表头点击排序
|
||||
const onHeaderClick = (column: TableColumn) => {
|
||||
if (!column.sortable) return;
|
||||
|
||||
const columnKey = getColumnKey(column);
|
||||
if (!columnKey) return;
|
||||
|
||||
handleSort({
|
||||
key: String(columnKey),
|
||||
sortable: column.sortable,
|
||||
name: column.label || ""
|
||||
});
|
||||
};
|
||||
|
||||
/** 过滤隐藏列 */
|
||||
const visibleColumns = computed(() =>
|
||||
(props.columns || []).filter((column) => !column.hidden)
|
||||
);
|
||||
|
||||
/** 获取列的样式 */
|
||||
const getColumnStyle = (column: TableColumn) => {
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
if (column.width) {
|
||||
style.width =
|
||||
typeof column.width === "number" ? `${column.width}px` : column.width;
|
||||
style.maxWidth =
|
||||
typeof column.width === "number" ? `${column.width}px` : column.width;
|
||||
}
|
||||
|
||||
if (column.minWidth) {
|
||||
style.minWidth =
|
||||
typeof column.minWidth === "number"
|
||||
? `${column.minWidth}px`
|
||||
: column.minWidth;
|
||||
}
|
||||
|
||||
return style;
|
||||
};
|
||||
|
||||
const reMeasure = () => setTimeout(() => measure(), 0);
|
||||
// 尺寸变化重新计算滚动状态
|
||||
useResizeObserver(tableEl, () => reMeasure());
|
||||
|
||||
// 显示滚动阴影
|
||||
const showScrollShadow = (direction: "left" | "right") => {
|
||||
if (props.hiddenShadow) return false;
|
||||
const canScroll = !(arrivedState.left && arrivedState.right);
|
||||
return canScroll && !arrivedState[direction] && !props.load;
|
||||
};
|
||||
|
||||
// ---- 行折叠相关 ----
|
||||
/** 存储每行的展开状态,key 为行索引或行 id */
|
||||
const expandedRows = ref<Set<string | number>>(new Set());
|
||||
|
||||
/** 获取行的唯一标识 */
|
||||
const getRowKey = (row: T, index: number): string | number => row.id ?? index;
|
||||
|
||||
/** 查是否有 row-append-{slot} 插槽 */
|
||||
const hasSlot = computed(() =>
|
||||
visibleColumns.value.some(
|
||||
(col) => col.slot && $slots[`row-append-${col.slot}`]
|
||||
)
|
||||
);
|
||||
|
||||
/** 判断表格是否存在任何可展开的行 */
|
||||
const hasAnyRowAppend = computed(() => {
|
||||
if (!hasSlot.value) return false;
|
||||
// 再检查是否有任意一行满足 rowAppendVisible 条件
|
||||
return finalData.value.some((row, index) => {
|
||||
if (props.rowAppendVisible) {
|
||||
return props.rowAppendVisible(row as T, index);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
/** 判断某行是否有 row-append 内容 */
|
||||
const hasRowAppend = (row: T, index: number): boolean => {
|
||||
if (!hasSlot.value) return false;
|
||||
|
||||
// 再检查 rowAppendVisible 条件
|
||||
if (props.rowAppendVisible) {
|
||||
return props.rowAppendVisible(row as T, index);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 判断某行是否展开 */
|
||||
const isRowExpanded = (row: T, index: number): boolean => {
|
||||
const key = getRowKey(row, index);
|
||||
return expandedRows.value.has(key);
|
||||
};
|
||||
|
||||
/** 切换行的展开状态 */
|
||||
const toggleRowExpand = (row: T, index: number) => {
|
||||
if (!props.rowAppendExpandable) return;
|
||||
if (!hasRowAppend(row, index)) return;
|
||||
|
||||
const key = getRowKey(row, index);
|
||||
const newExpanded = !expandedRows.value.has(key);
|
||||
|
||||
if (newExpanded) {
|
||||
expandedRows.value.add(key);
|
||||
} else {
|
||||
expandedRows.value.delete(key);
|
||||
}
|
||||
|
||||
emits("row-expand:change", { row, index, expanded: newExpanded });
|
||||
};
|
||||
|
||||
/** 初始化展开状态 */
|
||||
const initExpandedRows = () => {
|
||||
expandedRows.value.clear();
|
||||
|
||||
// 如果默认展开,则把所有有 row-append 的行加入展开集合
|
||||
if (props.rowAppendDefaultExpanded !== false) {
|
||||
finalData.value.forEach((row, index: number) => {
|
||||
if (hasRowAppend(row as T, index)) {
|
||||
expandedRows.value.add(getRowKey(row as T, index));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 监听数据变化,重新初始化展开状态
|
||||
watch(
|
||||
() => finalData.value,
|
||||
() => {
|
||||
initExpandedRows();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// ---- 行折叠相关 ----
|
||||
|
||||
// 初始化显示数据
|
||||
onMounted(() => {
|
||||
if (!props.load && props.data.length > 0) {
|
||||
displayData.value = props.data;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative [&>*]:scrollbar-hide"
|
||||
:class="[
|
||||
cn(
|
||||
'before:absolute before:top-0 before:bottom-0 before:left-0 before:w-2',
|
||||
'before:bg-linear-to-r before:from-blue-600/5 before:to-transparent',
|
||||
'before:pointer-events-none before:z-10 before:transition-opacity',
|
||||
'before:shadow-[inset_5px_0_4px_-3px_rgba(37,99,235,0.05)]',
|
||||
|
||||
'after:absolute after:top-0 after:bottom-0 after:right-0 after:w-2',
|
||||
'after:bg-linear-to-l after:from-blue-600/5 after:to-transparent',
|
||||
'after:pointer-events-none after:z-10 after:transition-opacity',
|
||||
'after:shadow-[inset_-5px_0_4px_-3px_rgba(37,99,235,0.05)]',
|
||||
{
|
||||
'before:opacity-100': showScrollShadow('left'),
|
||||
'before:opacity-0': !showScrollShadow('left'),
|
||||
'after:opacity-100': showScrollShadow('right'),
|
||||
'after:opacity-0': !showScrollShadow('right'),
|
||||
'before:bottom-13 after:bottom-13':
|
||||
props.pagination?.currentPage && props.pagination?.pageSize
|
||||
}
|
||||
)
|
||||
]"
|
||||
>
|
||||
<Table
|
||||
ref="tableEl"
|
||||
:class="[
|
||||
cn(
|
||||
'relative bg-[#FCFDFF] text-xs 2xl:text-sm rounded-lg overflow-hidden',
|
||||
props.class
|
||||
)
|
||||
]"
|
||||
>
|
||||
<TableHeader class="bg-[#F2F9FE] select-none">
|
||||
<TableRow class="text-[#181818] font-medium">
|
||||
<TableHead
|
||||
v-for="(column, index) in visibleColumns"
|
||||
:key="getColumnKey(column, index)"
|
||||
:style="getColumnStyle(column)"
|
||||
:class="{
|
||||
'cursor-pointer': column.sortable,
|
||||
'pl-8': hasAnyRowAppend && index === 0
|
||||
}"
|
||||
@click="onHeaderClick(column)"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center text-nowrap',
|
||||
column.headerAlign || props.headerAlign
|
||||
? `justify-${column.headerAlign || props.headerAlign}`
|
||||
: '',
|
||||
props.headerClassName,
|
||||
column.headerClassName
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="column.slot"
|
||||
:name="`header-${column.slot}`"
|
||||
:label="column.label"
|
||||
:column="column"
|
||||
:is-sorted="sortColumn === getColumnKey(column)"
|
||||
>
|
||||
<slot
|
||||
name="header"
|
||||
:label="column.label"
|
||||
:column="column"
|
||||
:is-sorted="sortColumn === getColumnKey(column)"
|
||||
>
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</slot>
|
||||
<template v-else>
|
||||
<slot
|
||||
name="header"
|
||||
:label="column.label"
|
||||
:column="column"
|
||||
:is-sorted="sortColumn === getColumnKey(column)"
|
||||
>
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</template>
|
||||
<!-- 排序指示器 icon -->
|
||||
<span v-if="column.sortable" class="flex items-center ml-2">
|
||||
<ChevronUpDownIcon
|
||||
class="h-6! w-6! [&>.svg-top]:text-[#71717A4D] [&>.svg-bottom]:text-[#71717A4D]"
|
||||
:class="{
|
||||
'[&>.svg-top]:text-[#2563EB]! [&>.svg-bottom]:text-[#71717A4D]!':
|
||||
sortColumn === getColumnKey(column) &&
|
||||
sortDirection === 'asc',
|
||||
'[&>.svg-bottom]:text-[#2563EB]! [&>.svg-top]:text-[#71717A4D]!':
|
||||
sortColumn === getColumnKey(column) &&
|
||||
sortDirection === 'desc'
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody class="[&_tr:last-child]:border-0! text-[#71717A]">
|
||||
<template
|
||||
v-for="(row, rowIndex) in finalData"
|
||||
:key="row.id || rowIndex"
|
||||
>
|
||||
<TableRow
|
||||
class="relative group border-[#0088FE1a] hover:bg-[#ECF2FF] text-inherit"
|
||||
:class="{
|
||||
'cursor-pointer':
|
||||
rowAppendExpandable && hasRowAppend(row as T, rowIndex)
|
||||
}"
|
||||
@click="toggleRowExpand(row as T, rowIndex)"
|
||||
>
|
||||
<TableCell
|
||||
v-for="(column, colIndex) in visibleColumns"
|
||||
:key="getColumnKey(column, colIndex)"
|
||||
:style="getColumnStyle(column)"
|
||||
:class="
|
||||
cn(
|
||||
column.cellAlign || props.cellAlign
|
||||
? `text-${column.cellAlign || props.cellAlign}`
|
||||
: '',
|
||||
props.cellClassName,
|
||||
column.cellClassName,
|
||||
colIndex === 0 &&
|
||||
rowAppendExpandable &&
|
||||
hasAnyRowAppend &&
|
||||
!hasRowAppend(row as T, rowIndex)
|
||||
? 'pl-8'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="contents"
|
||||
:class="[
|
||||
colIndex === 0 &&
|
||||
rowAppendExpandable &&
|
||||
hasRowAppend(row as T, rowIndex)
|
||||
? 'flex! h-full items-center gap-1.5'
|
||||
: ''
|
||||
]"
|
||||
>
|
||||
<!-- 展开/收起指示器 -->
|
||||
<span
|
||||
v-if="
|
||||
colIndex === 0 &&
|
||||
rowAppendExpandable &&
|
||||
hasRowAppend(row as T, rowIndex)
|
||||
"
|
||||
class="flex items-center justify-center transition-transform duration-250"
|
||||
:class="{ 'rotate-90': isRowExpanded(row as T, rowIndex) }"
|
||||
>
|
||||
<PlayIcon class="size-2.5! text-blue-[71717a]" />
|
||||
</span>
|
||||
|
||||
<slot
|
||||
:name="column.slot"
|
||||
:row="row"
|
||||
:column="column"
|
||||
:index="rowIndex"
|
||||
:value="getCellValue(row, column, rowIndex)"
|
||||
:is-sorted="sortColumn === getColumnKey(column)"
|
||||
>
|
||||
{{
|
||||
column.formatter
|
||||
? (column.formatter(
|
||||
getCellValue(row, column, rowIndex),
|
||||
row,
|
||||
rowIndex
|
||||
) ?? "-")
|
||||
: (getCellValue(row, column, rowIndex) ?? "-")
|
||||
}}
|
||||
</slot>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<!-- 按列分发的行追加内容 -->
|
||||
<TableRow
|
||||
v-if="
|
||||
visibleColumns.some(
|
||||
(col) => col.slot && $slots[`row-append-${col.slot}`]
|
||||
) &&
|
||||
(rowAppendVisible?.(row as T, rowIndex) ?? true)
|
||||
"
|
||||
class="border-[#0088FE1a] overflow-hidden bg-muted! hover:bg-gray-200/50! transition-colors duration-200"
|
||||
:class="{
|
||||
'visible [&>td>div]:max-h-0 [&>td>div]:opacity-0 border-transparent ':
|
||||
!isRowExpanded(row as T, rowIndex)
|
||||
}"
|
||||
>
|
||||
<TableCell
|
||||
v-for="(column, colIndex) in visibleColumns"
|
||||
:key="'append-' + getColumnKey(column, colIndex)"
|
||||
:style="getColumnStyle(column)"
|
||||
:class="
|
||||
cn(
|
||||
'py-0 transition-all duration-300',
|
||||
column.cellAlign || props.cellAlign
|
||||
? `text-${column.cellAlign || props.cellAlign}`
|
||||
: '',
|
||||
props.cellClassName,
|
||||
column.cellClassName
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="transition-all duration-300 overflow-hidden"
|
||||
:class="{
|
||||
'max-h-96 opacity-100': isRowExpanded(row as T, rowIndex),
|
||||
'max-h-0 opacity-0': !isRowExpanded(row as T, rowIndex)
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
v-if="column.slot"
|
||||
:name="`row-append-${column.slot}`"
|
||||
:row="row"
|
||||
:index="rowIndex"
|
||||
:column="column"
|
||||
:expanded="isRowExpanded(row as T, rowIndex)"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<TableEmpty
|
||||
v-if="finalData.length === 0 && !props.load"
|
||||
:colspan="visibleColumns?.length"
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="max-sm:absolute max-sm:flex items-center justify-center"
|
||||
:style="{
|
||||
left: `${x}px`,
|
||||
width: isMobile ? `${width}px` : 'auto'
|
||||
}"
|
||||
>
|
||||
<slot name="empty" :colspan="visibleColumns?.length">
|
||||
<span class="text-gray-400">暂无数据</span>
|
||||
</slot>
|
||||
</div>
|
||||
</TableEmpty>
|
||||
|
||||
<!-- 加载骨架 -->
|
||||
<template v-if="props.load && displayData.length === 0">
|
||||
<TableRow v-for="i in 5" :key="i">
|
||||
<TableCell v-for="j in visibleColumns?.length" :key="j" class="p-2">
|
||||
<Skeleton class="h-10 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<template v-if="props.load && displayData.length !== 0">
|
||||
<div class="absolute inset-0 bg-white/40 z-10" />
|
||||
</template>
|
||||
|
||||
<TableFooter :colspan="visibleColumns?.length">
|
||||
<slot
|
||||
name="footer"
|
||||
:data="finalData"
|
||||
:columns="visibleColumns"
|
||||
:total="finalData.length"
|
||||
/>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
|
||||
<!-- 分页器 只有当 pagination 配置了 currentPage 和 pageSize 时才显示 -->
|
||||
<div
|
||||
v-if="props.pagination?.currentPage && props.pagination?.pageSize"
|
||||
class="w-full mt-4 h-9"
|
||||
:colspan="visibleColumns?.length"
|
||||
>
|
||||
<NewTablePagination
|
||||
:total="props.pagination.total"
|
||||
:page-size="props.pagination.pageSize"
|
||||
:current-page="props.pagination.currentPage"
|
||||
:load="props.load"
|
||||
:align="props.pagination.align"
|
||||
class="h-full"
|
||||
@update:page="(page: number) => emits('page:change', page)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user