feat: 前端组件准备

This commit is contained in:
2026-05-22 15:02:31 +08:00
parent 397ccdca13
commit 5e05bf4f63
62 changed files with 6903 additions and 878 deletions
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
const emit = defineEmits<{
(e: "change", changed: boolean): void;
}>();
let hideTimer: NodeJS.Timeout | null = null;
/** 是否发生变化 */
const hasChanged = ref(false);
/** 上一次的内容,用于对比变化 */
const previousContent = ref("");
/* 是否初次渲染 */
const isInitialized = ref(false);
const cellRef = useTemplateRef<HTMLElement | null>("cellRef");
const checkChange = async () => {
await nextTick();
if (!cellRef.value) return;
const currentContent = cellRef.value?.textContent?.trim() || "";
if (!isInitialized.value) {
// 首次渲染,记录原始值
previousContent.value = currentContent;
isInitialized.value = true;
hasChanged.value = false;
} else {
// 对比变化
hasChanged.value = currentContent !== previousContent.value;
emit("change", hasChanged.value);
}
};
watch(hasChanged, (newVal) => {
if (newVal) {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
hasChanged.value = false;
emit("change", false);
previousContent.value = cellRef.value?.textContent?.trim() || "";
}, 1000);
}
});
let observer: MutationObserver | null = null;
watch(
cellRef,
(el) => {
observer?.disconnect();
if (el) {
checkChange();
observer = new MutationObserver(checkChange);
observer.observe(el, {
childList: true,
subtree: true,
characterData: true
});
}
},
{ immediate: true }
);
</script>
<template>
<div
ref="cellRef"
class="transition-colors duration-300"
:class="{ 'bg-[#ECF2FF] h-full w-full p-4 -my-4': hasChanged }"
>
<slot />
</div>
</template>
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
import { MoreHorizontal } from "lucide-vue-next";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import type { PaginationProps } from "./type";
const props = withDefaults(defineProps<PaginationProps & { load: boolean }>(), {
total: 0,
pageSize: 5,
currentPage: 1
});
const emits = defineEmits<{
"update:page": [page: number];
}>();
const pageCount = computed(() => Math.ceil(props.total / props.pageSize));
</script>
<template>
<!-- 分页器 -->
<Pagination
v-slot="{ page }"
:page="props.currentPage"
:items-per-page="props.pageSize"
:total="props.total"
:show-edges="true"
:disabled="props.load || pageCount <= 1"
class="select-none"
:class="`justify-${props.align || 'center'}`"
@update:page="(val: number) => emits('update:page', val)"
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious class="w-5.5 h-5.5 text-[#2563EB]!">
<ChevronLeftIcon class="size-4" />
</PaginationPrevious>
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
class="w-fit min-w-5.5 h-5.5 text-xs px-1 text-[#71717A]/80"
:class="{
'text-[#2563EB]! border-[#2563EB]!': item.value === page
}"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis
v-else-if="item.type === 'ellipsis'"
class="text-[#71717A]/80"
>
<Button
variant="outline"
:disabled="props.load"
class="w-5.5 h-5.5 p-0 text-[12px] text-[#71717A]/80 border-transparent bg-white rounded"
>
<MoreHorizontal class="size-4" />
</Button>
</PaginationEllipsis>
</template>
<PaginationNext class="w-5.5 h-5.5 text-[#2563EB]!">
<ChevronRightIcon class="size-4" />
</PaginationNext>
</PaginationContent>
</Pagination>
</template>
+559
View File
@@ -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>
+79
View File
@@ -0,0 +1,79 @@
import type { ClassValue } from "clsx";
import type { HTMLAttributes } from "vue";
type CommonTypes = {
/** 单元格的自定义类名 */
cellClassName?: ClassValue;
/** 表头的自定义类名 */
headerClassName?: ClassValue;
/** 表头对齐方式 */
headerAlign?: "start" | "center" | "end";
/** 单元格对齐方式 */
cellAlign?: "left" | "center" | "right";
};
export type PaginationProps = {
/** 每页显示条目个数,默认值 `5` `该属性为必填属性` */
pageSize: number;
/** 总条目数,默认值 `0` `该属性为必填属性` */
total: number;
/** 当前页数 `该属性为必填属性` */
currentPage: number;
/** 分页器对齐方式 */
align?: "start" | "center" | "end";
};
export type TableProps<T extends Record<string, any>> = CommonTypes & {
class?: HTMLAttributes["class"];
/** 表格数据 */
data: T[];
/** 表格列配置 */
columns?: TableColumn[];
/** 默认排序列 */
defaultSortColumn?: string;
/** 默认排序方向 */
defaultSortDirection?: "asc" | "desc" | "";
/** 数据缓冲,加载新数据时不立即清空旧数据,避免表格闪烁 */
buffer?: boolean;
/** 加载状态 */
load?: boolean;
/** 分页相关配置 */
pagination?: PaginationProps;
/** 是否隐藏溢出阴影 */
hiddenShadow?: boolean;
/** 置顶条件函数,返回 true 的行将被置顶(不受排序影响) */
pinnedCondition?: (row: T) => boolean;
/** 控制按列分发的 row-append 行是否可见(静态控制,优先级低于展开状态) */
rowAppendVisible?: (row: T, index: number) => boolean;
/** row-append 默认是否展开,默认 true */
rowAppendDefaultExpanded?: boolean;
/** 是否启用点击行展开/收起 row-append */
rowAppendExpandable?: boolean;
};
export type TableColumn = CommonTypes & {
/** 显示的标题 */
label?: string;
/** 字段名称,对应列内容的字段名 */
prop?: string | ((index: number) => number);
/** 唯一标识 */
key?: string;
/** 自定义列的内容插槽 */
slot?: string;
/** 对应列的类型,如果设置了 `index` 则显示该行的索引(从 `1` 开始计算) */
type?: "index";
/** 如果设置了 `type=index`,可以通过传递 `index` 属性来自定义索引 */
index?: number | ((index: number) => number | string);
/** 对应列的宽度 */
width?: string | number;
/** 对应列的最小宽度,对应列的最小宽度,与 `width` 的区别是 `width` 是固定的,`min-width` 会把剩余宽度按比例分配给设置了 `min-width` 的列 */
minWidth?: string | number;
/** 对应列是否可以排序 */
sortable?: boolean;
/** 是否隐藏该列 */
hidden?: boolean;
/** 格式化函数 */
formatter?: (value: any, row: any, index: number) => string;
/** 是否将 0 视为 null 值处理(排序时放在末尾) */
zeroAsNull?: boolean;
};