feat: 前端组件准备
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
<div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="size-6">
|
||||
<path class="svg-top" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"
|
||||
d="M8.25 9L12 5.25L15.75 9" />
|
||||
<path class="svg-bottom" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"
|
||||
d="M8.25 15L12 18.75L15.75 15" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1 @@
|
||||
export { default as ChevronUpDownIcon } from "./ChevronUpDownIcon.svg";
|
||||
@@ -0,0 +1,128 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter Variable", sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.527 0.154 150.069);
|
||||
--primary-foreground: oklch(0.982 0.018 155.826);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.871 0.15 154.449);
|
||||
--chart-2: oklch(0.723 0.219 149.579);
|
||||
--chart-3: oklch(0.627 0.194 149.214);
|
||||
--chart-4: oklch(0.527 0.154 150.069);
|
||||
--chart-5: oklch(0.448 0.119 151.328);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.627 0.194 149.214);
|
||||
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.448 0.119 151.328);
|
||||
--primary-foreground: oklch(0.982 0.018 155.826);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.871 0.15 154.449);
|
||||
--chart-2: oklch(0.723 0.219 149.579);
|
||||
--chart-3: oklch(0.627 0.194 149.214);
|
||||
--chart-4: oklch(0.527 0.154 150.069);
|
||||
--chart-5: oklch(0.448 0.119 151.328);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.723 0.219 149.579);
|
||||
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply font-sans;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
outline: 'border-border bg-background dark:bg-transparent hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
'default': 'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
|
||||
'xs': 'h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*=size-])]:size-3',
|
||||
'sm': 'h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'lg': 'h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
|
||||
'icon': 'size-9',
|
||||
'icon-xs': 'size-6 [&_svg:not([class*=size-])]:size-3',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationRootEmits, PaginationRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { PaginationRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<PaginationRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const emits = defineEmits<PaginationRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination"
|
||||
v-bind="forwarded"
|
||||
:class="cn('mx-auto flex w-full justify-center', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { PaginationList } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<PaginationListProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationList
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination-content"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('gap-1 flex items-center', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationList>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationEllipsisProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { MoreHorizontalIcon } from 'lucide-vue-next'
|
||||
import { PaginationEllipsis } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationEllipsis
|
||||
data-slot="pagination-ellipsis"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('size-9 [&_svg:not([class*=size-])]:size-4 flex items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<MoreHorizontalIcon />
|
||||
<span class="sr-only">More pages</span>
|
||||
</slot>
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationFirstProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronsLeftIcon } from 'lucide-vue-next'
|
||||
import { PaginationFirst, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationFirstProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}>(), {
|
||||
size: 'default',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationFirst
|
||||
data-slot="pagination-first"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronsLeftIcon data-icon="inline-start" />
|
||||
<span class="hidden sm:block">First</span>
|
||||
</slot>
|
||||
</PaginationFirst>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationListItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { PaginationListItem } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationListItemProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
isActive?: boolean
|
||||
}>(), {
|
||||
size: 'icon',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size', 'isActive')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationListItem
|
||||
data-slot="pagination-item"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
props.class)"
|
||||
>
|
||||
<slot />
|
||||
</PaginationListItem>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationLastProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronsRightIcon } from 'lucide-vue-next'
|
||||
import { PaginationLast, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationLastProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}>(), {
|
||||
size: 'default',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationLast
|
||||
data-slot="pagination-last"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Last</span>
|
||||
<ChevronsRightIcon data-icon="inline-end" />
|
||||
</slot>
|
||||
</PaginationLast>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
href?: string
|
||||
size?: ButtonVariants['size']
|
||||
isActive?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>(), {
|
||||
size: 'icon',
|
||||
isActive: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="href"
|
||||
data-slot="pagination-link"
|
||||
:data-active="isActive ? '' : undefined"
|
||||
:aria-current="isActive ? 'page' : undefined"
|
||||
:class="cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
'',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationNextProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import { PaginationNext, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationNextProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}>(), {
|
||||
size: 'default',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationNext
|
||||
data-slot="pagination-next"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pr-2!', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon data-icon="inline-end" class="cn-rtl-flip" />
|
||||
</slot>
|
||||
</PaginationNext>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationPrevProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronLeftIcon } from 'lucide-vue-next'
|
||||
import { PaginationPrev, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationPrevProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}>(), {
|
||||
size: 'default',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationPrev
|
||||
data-slot="pagination-previous"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pl-2!', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeftIcon data-icon="inline-start" class="cn-rtl-flip" />
|
||||
<span class="hidden sm:block">Previous</span>
|
||||
</slot>
|
||||
</PaginationPrev>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as Pagination } from './Pagination.vue'
|
||||
export { default as PaginationContent } from './PaginationContent.vue'
|
||||
export { default as PaginationEllipsis } from './PaginationEllipsis.vue'
|
||||
export { default as PaginationFirst } from './PaginationFirst.vue'
|
||||
export { default as PaginationItem } from './PaginationItem.vue'
|
||||
export { default as PaginationLast } from './PaginationLast.vue'
|
||||
export { default as PaginationLink } from './PaginationLink.vue'
|
||||
export { default as PaginationNext } from './PaginationNext.vue'
|
||||
export { default as PaginationPrevious } from './PaginationPrevious.vue'
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:class="cn('p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
import TableCell from './TableCell.vue'
|
||||
import TableRow from './TableRow.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
colspan?: number
|
||||
}>(), {
|
||||
colspan: 1,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
data-slot="table-head"
|
||||
:class="cn('text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
:class="cn('[&_tr]:border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as Table } from './Table.vue'
|
||||
export { default as TableBody } from './TableBody.vue'
|
||||
export { default as TableCaption } from './TableCaption.vue'
|
||||
export { default as TableCell } from './TableCell.vue'
|
||||
export { default as TableEmpty } from './TableEmpty.vue'
|
||||
export { default as TableFooter } from './TableFooter.vue'
|
||||
export { default as TableHead } from './TableHead.vue'
|
||||
export { default as TableHeader } from './TableHeader.vue'
|
||||
export { default as TableRow } from './TableRow.vue'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Updater } from '@tanstack/vue-table'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { isFunction } from '@tanstack/vue-table'
|
||||
|
||||
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||
ref.value = isFunction(updaterOrValue)
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx"
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sortableTable";
|
||||
@@ -0,0 +1,100 @@
|
||||
export interface SortableColumn {
|
||||
key?: string;
|
||||
slot?: string;
|
||||
prop?: string | ((index: number) => number);
|
||||
name?: string; // 列的显示名称
|
||||
sortable?: boolean; // 是否可排序
|
||||
zeroAsNull?: boolean; // 是否将 0 视为 null 值处理
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 可排序表格的函数
|
||||
export const useSortableTable = <T>(
|
||||
dataRef: Ref<T[]>,
|
||||
defaultSortColumn: MaybeRefOrGetter<string> = "", // 默认排序列的key值,可以让某一列默认排序
|
||||
defaultSortDirection: MaybeRefOrGetter<"asc" | "desc" | ""> = "asc", // 为空表示不排序
|
||||
columnsRef?: Ref<SortableColumn[]> // 列配置,用于获取 zeroAsNull 等配置
|
||||
) => {
|
||||
// 排序状态
|
||||
const sortColumn = ref<string>(toValue(defaultSortColumn));
|
||||
const sortDirection = ref<"asc" | "desc" | "">(
|
||||
toValue(defaultSortColumn) ? toValue(defaultSortDirection) : ""
|
||||
);
|
||||
|
||||
// 监听默认排序参数的变化
|
||||
watchEffect(() => {
|
||||
const newColumn = toValue(defaultSortColumn);
|
||||
sortColumn.value = newColumn;
|
||||
sortDirection.value = newColumn ? toValue(defaultSortDirection) : "";
|
||||
});
|
||||
|
||||
// 排序处理函数
|
||||
const handleSort = (column: SortableColumn) => {
|
||||
if (!column.sortable) return;
|
||||
|
||||
const columnKey = column.key || column.slot || column.prop;
|
||||
if (!columnKey || typeof columnKey !== "string") return;
|
||||
|
||||
if (sortColumn.value === columnKey) {
|
||||
// 当前列被点击:升序 -> 降序 -> 取消排序
|
||||
if (sortDirection.value === "asc") {
|
||||
// 如果是升序,改为降序
|
||||
sortDirection.value = "desc";
|
||||
} else if (sortDirection.value === "desc") {
|
||||
// 如果是降序,取消排序
|
||||
sortColumn.value = "";
|
||||
sortDirection.value = "";
|
||||
} else {
|
||||
// 如果是不排序,设置为升序
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
} else {
|
||||
// 如果点击的是新列,设置为升序
|
||||
sortColumn.value = columnKey;
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
};
|
||||
|
||||
// 计算排序后的数据
|
||||
const getSortedData = computed(() => {
|
||||
const data = dataRef.value || [];
|
||||
if (!sortColumn.value || !sortDirection.value) return data;
|
||||
|
||||
// 查找当前排序列的配置
|
||||
const currentColumn = columnsRef?.value?.find(
|
||||
(col) => (col.key || col.slot || col.prop) === sortColumn.value
|
||||
);
|
||||
|
||||
return [...data].sort((a: any, b: any) => {
|
||||
let aValue = a[sortColumn.value];
|
||||
let bValue = b[sortColumn.value];
|
||||
const factor = sortDirection.value === "asc" ? 1 : -1;
|
||||
|
||||
// 如果配置了 zeroAsNull,将 0 视为 null
|
||||
if (currentColumn?.zeroAsNull) {
|
||||
if (aValue === 0) aValue = null;
|
||||
if (bValue === 0) bValue = null;
|
||||
}
|
||||
|
||||
// 处理 null 和 undefined 值
|
||||
if (aValue == null && bValue == null) return 0;
|
||||
if (aValue == null) return 1;
|
||||
if (bValue == null) return -1;
|
||||
|
||||
// 根据数据类型进行排序
|
||||
if (typeof aValue === "number" && typeof bValue === "number") {
|
||||
return (aValue - bValue) * factor;
|
||||
} else {
|
||||
// 字符串或其他类型
|
||||
return String(aValue).localeCompare(String(bValue)) * factor;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
handleSort,
|
||||
sortedData: getSortedData
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user