Initial commit: 网络验证平台

This commit is contained in:
Admin
2026-04-27 17:22:56 +08:00
commit afe67d704e
780 changed files with 88960 additions and 0 deletions
@@ -0,0 +1,90 @@
<script lang='ts' setup generic="T">
import type { Table as VueTable } from '@tanstack/vue-table'
import { XIcon } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
interface BulkActionsProps<T> {
table: VueTable<T>
entityName?: string
}
const { table, entityName: _entityName } = defineProps<BulkActionsProps<T>>()
const selectedRows = computed(() => table.getSelectedRowModel().rows)
const selectedCount = computed(() => selectedRows.value.length || 0)
function handleClearSelection() {
table.resetRowSelection()
}
</script>
<template>
<TooltipProvider>
<Transition
enter-active-class="transition-all duration-200 ease-out"
leave-active-class="transition-all duration-200 ease-in"
enter-from-class="opacity-0 -translate-y-2"
leave-to-class="opacity-0 -translate-y-2"
>
<div
v-if="selectedCount"
:class="cn(
'mb-4 p-3 rounded-lg border',
'bg-muted/50',
'flex flex-wrap items-center gap-2',
)"
>
<div class="flex items-center gap-2">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-7"
aria-label="Clear selection"
@click="handleClearSelection"
>
<XIcon class="size-4" />
<span class="sr-only">Clear selection</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>取消选择</p>
</TooltipContent>
</Tooltip>
<Separator
class="h-5"
orientation="vertical"
aria-hidden="true"
/>
<div class="flex items-center gap-1 text-sm">
<UiBadge
class="min-w-8 rounded-lg"
:aria-label="`${selectedCount} selected`"
>
{{ selectedCount }}
</UiBadge>
<span>项已选中</span>
</div>
</div>
<Separator
class="h-5 hidden sm:block"
orientation="vertical"
aria-hidden="true"
/>
<div class="flex flex-wrap items-center gap-2">
<slot />
</div>
</div>
</Transition>
</TooltipProvider>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts" generic="T">
import type { Column } from '@tanstack/vue-table'
import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, ChevronsUpDownIcon, EyeOffIcon, PinIcon, PinOffIcon } from 'lucide-vue-next'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface DataTableColumnHeaderProps {
column: Column<T, any>
title: string
}
const props = defineProps<DataTableColumnHeaderProps>()
const canPinned = computed(() => props.column.getCanPin())
const canSorted = computed(() => props.column.getCanSort())
</script>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<template>
<div v-if="canSorted || canPinned" :class="cn('flex items-center space-x-2', $attrs.class ?? '')">
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="ghost"
size="sm"
class="-ml-3 h-8 data-[state=open]:bg-accent"
>
<template v-if="canPinned">
<PinIcon v-if="props.column.getIsPinned()" class="ml-2 size-4 text-primary" />
</template>
<span>{{ title }}</span>
<template v-if="canSorted">
<ArrowDownIcon v-if="props.column.getIsSorted() === 'desc'" class="ml-2 size-4" />
<ArrowUpIcon v-else-if="props.column.getIsSorted() === 'asc'" class="ml-2 size-4" />
<ChevronsUpDownIcon v-else class="ml-2 size-4" />
</template>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start">
<template v-if="canSorted">
<UiDropdownMenuItem @click="props.column.toggleSorting(false)">
<ArrowUpIcon class="mr-2 size-4 text-muted-foreground/70" />
Asc
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.toggleSorting(true)">
<ArrowDownIcon class="mr-2 size-4 text-muted-foreground/70" />
Desc
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.clearSorting()">
<ChevronsUpDownIcon class="mr-2 size-4 text-muted-foreground/70" />
Clear Sorting
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
</template>
<UiDropdownMenuItem @click="props.column.toggleVisibility(false)">
<EyeOffIcon class="mr-2 size-4 text-muted-foreground/70" />
Hide
</UiDropdownMenuItem>
<template v-if="canPinned">
<UiDropdownMenuSeparator />
<UiDropdownMenuItem @click="props.column.pin('left')">
<ArrowLeftIcon class="mr-2 size-4 text-muted-foreground/70" />
Pin Left
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.pin('right')">
<ArrowRightIcon class="mr-2 size-4 text-muted-foreground/70" />
Pin Right
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.pin(false)">
<PinOffIcon class="mr-2 size-4 text-muted-foreground/70" />
Unpin
</UiDropdownMenuItem>
</template>
</UiDropdownMenuContent>
</UiDropdownMenu>
</div>
<div v-else :class="$attrs?.class ?? ''">
{{ title }}
</div>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts" generic="T">
import type { Column, Table as VueTable } from '@tanstack/vue-table'
import type { CSSProperties } from 'vue'
import { FlexRender } from '@tanstack/vue-table'
import { ChevronRight } from 'lucide-vue-next'
import DataTableLoading from '@/components/data-table/table-loading.vue'
import DataTablePagination from '@/components/data-table/table-pagination.vue'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import type { DataTableProps } from './types'
import NoResultFound from '../no-result-found.vue'
const props = defineProps<DataTableProps<T> & {
table: VueTable<T>
}>()
defineEmits<{
refresh: []
}>()
defineExpose({
table: props.table,
})
function getCommonPinningStyles(column: Column<T>): CSSProperties {
const isPinned = column.getIsPinned()
return {
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
position: isPinned ? 'sticky' : 'relative',
width: `${column.getSize()}px`,
zIndex: isPinned ? 1 : 0,
}
}
</script>
<template>
<div class="space-y-4">
<slot name="toolbar" :selected-count="table.getSelectedRowModel().rows.length" />
<slot name="filters" />
<div class="border rounded-md">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:style="getCommonPinningStyles(header.column)"
:class="{ 'bg-background': header.column.getIsPinned() }"
>
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="!loading">
<template v-if="table.getRowModel().rows?.length">
<template v-for="row in table.getRowModel().rows" :key="row.id">
<TableRow
:data-state="row.getIsSelected() && 'selected'"
:class="{ 'bg-muted/50': row.getIsExpanded() }"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="getCommonPinningStyles(cell.column)"
:class="{ 'bg-background': cell.column.getIsPinned() }"
>
<div class="flex items-center">
<template v-if="cell.column.id === 'select'">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
<template v-if="row.getCanExpand()">
<Button
variant="ghost"
size="icon"
class="ml-2 h-6 w-6"
@click="row.toggleExpanded()"
>
<ChevronRight
class="h-4 w-4 transition-transform"
:class="{ 'rotate-90': row.getIsExpanded() }"
/>
</Button>
</template>
<template v-else>
<div class="w-10" />
</template>
</template>
<template v-else>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</template>
</div>
</TableCell>
</TableRow>
</template>
</template>
<TableRow v-else>
<TableCell
:colspan="columns.length"
class="h-24 text-center"
>
<NoResultFound />
</TableCell>
</TableRow>
</TableBody>
</Table>
<DataTableLoading v-if="loading" />
</div>
<DataTablePagination v-if="!loading" :table="table" :server-pagination="serverPagination" />
</div>
</template>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { CalendarIcon } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DateTimePicker from '@/components/ui/date-picker/DateTimePicker.vue'
interface Props {
startModelValue: string
endModelValue: string
title?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '',
})
const emit = defineEmits<{
'update:startModelValue': [value: string]
'update:endModelValue': [value: string]
}>()
const { t } = useI18n()
const displayValue = computed(() => {
if (props.startModelValue && props.endModelValue) {
return `${props.startModelValue.replace('T', ' ')} ~ ${props.endModelValue.replace('T', ' ')}`
}
if (props.startModelValue) {
return `${props.startModelValue.replace('T', ' ')} ~ `
}
if (props.endModelValue) {
return `~ ${props.endModelValue.replace('T', ' ')}`
}
return ''
})
const buttonTitle = computed(() => props.title || t('developer.cards.timeRange'))
function setQuickRange(range: 'today' | 'week' | 'month') {
const now = new Date()
const today = now.toISOString().split('T')[0]
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
switch (range) {
case 'today': {
emit('update:startModelValue', `${today}T00:00`)
emit('update:endModelValue', `${today}T23:59`)
break
}
case 'week': {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
emit('update:startModelValue', `${weekAgo.toISOString().split('T')[0]}T00:00`)
emit('update:endModelValue', `${today}T23:59`)
break
}
case 'month': {
emit('update:startModelValue', `${firstDayOfMonth}T00:00`)
emit('update:endModelValue', `${today}T23:59`)
break
}
}
}
function clearFilter() {
emit('update:startModelValue', '')
emit('update:endModelValue', '')
}
</script>
<template>
<UiPopover>
<UiPopoverTrigger as-child>
<UiButton variant="outline" size="sm" class="h-8 border-dashed w-auto">
<CalendarIcon class="size-4 mr-2" />
{{ buttonTitle }}
<template v-if="displayValue">
<UiSeparator orientation="vertical" class="h-4 mx-2" />
<span class="text-xs">{{ displayValue }}</span>
</template>
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-auto p-4" align="start">
<div class="space-y-3">
<div class="text-sm font-medium">
{{ t('developer.cards.selectTimeRange') }}
</div>
<div class="flex items-center gap-2">
<DateTimePicker
:model-value="startModelValue"
:placeholder="t('developer.cards.startTime')"
class="w-[180px]"
@update:model-value="emit('update:startModelValue', $event)"
/>
<span class="text-muted-foreground text-sm">~</span>
<DateTimePicker
:model-value="endModelValue"
:placeholder="t('developer.cards.endTime')"
class="w-[180px]"
@update:model-value="emit('update:endModelValue', $event)"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="setQuickRange('today')">
{{ t('developer.cards.today') }}
</UiButton>
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="setQuickRange('week')">
{{ t('developer.cards.last7Days') }}
</UiButton>
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="setQuickRange('month')">
{{ t('developer.cards.thisMonth') }}
</UiButton>
</div>
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="clearFilter">
{{ t('developer.cards.reset') }}
</UiButton>
</div>
</div>
</UiPopoverContent>
</UiPopover>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts" generic="T">
import type { Column } from '@tanstack/vue-table'
import { Check, CirclePlus } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import type { FacetedFilterOption } from './types'
interface DataTableFacetedFilter {
column?: Column<T, any>
title?: string
options: FacetedFilterOption[]
}
const props = defineProps<DataTableFacetedFilter>()
const facets = computed(() => props.column?.getFacetedUniqueValues())
const selectedValues = computed(() => new Set(props.column?.getFilterValue() as string[]))
const filterFunction = (list: DataTableFacetedFilter['options'], term: string) => list.filter(i => i.label.toLowerCase()?.includes(term))
</script>
<template>
<UiPopover>
<UiPopoverTrigger as-child>
<UiButton variant="outline" size="sm" class="h-8 border-dashed">
<CirclePlus class="size-4 mr-2" />
{{ title }}
<template v-if="selectedValues.size > 0">
<UiSeparator orientation="vertical" class="h-4 mx-2" />
<UiBadge
variant="secondary"
class="px-1 font-normal rounded-sm lg:hidden"
>
{{ selectedValues.size }}
</UiBadge>
<div class="hidden space-x-1 lg:flex">
<UiBadge
v-if="selectedValues.size > 2"
variant="secondary"
class="px-1 font-normal rounded-sm"
>
{{ selectedValues.size }} selected
</UiBadge>
<template v-else>
<UiBadge
v-for="option in options
.filter((option) => selectedValues.has(option.value))"
:key="option.value"
variant="secondary"
class="px-1 font-normal rounded-sm"
>
{{ option.label }}
</UiBadge>
</template>
</div>
</template>
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-[200px] p-0" align="start">
<UiCommand
:filter-function="filterFunction as unknown as any"
>
<UiCommandInput :placeholder="title" />
<UiCommandList>
<UiCommandEmpty>No results found.</UiCommandEmpty>
<UiCommandGroup>
<UiCommandItem
v-for="option in options"
:key="option.value"
:value="option"
@select="(_e) => {
const isSelected = selectedValues.has(option.value)
if (isSelected) {
selectedValues.delete(option.value)
}
else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined,
)
}"
>
<div
:class="cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedValues.has(option.value)
? 'bg-primary'
: 'opacity-50 [&_svg]:invisible',
)"
>
<Check :class="cn('h-4 w-4', selectedValues.has(option.value) ? 'text-primary-foreground' : '')" />
</div>
<component :is="option.icon" v-if="option.icon" class="size-4 mr-2 text-muted-foreground" />
<span>{{ option.label }}</span>
<span v-if="facets?.get(option.value)" class="flex items-center justify-center size-4 ml-auto font-mono text-xs">
{{ facets.get(option.value) }}
</span>
</UiCommandItem>
</UiCommandGroup>
<template v-if="selectedValues.size > 0">
<UiCommandSeparator />
<UiCommandGroup>
<UiCommandItem
:value="{ label: 'Clear filters' }"
class="justify-center text-center"
@select="column?.setFilterValue(undefined)"
>
Clear filters
</UiCommandItem>
</UiCommandGroup>
</template>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { CircleIcon } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
defineProps<{
checked: boolean
}>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
type="button"
role="radio"
:aria-checked="checked"
:class="
cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'hover:border-ring cursor-pointer',
)
"
@click="emit('click', $event)"
>
<span
v-if="checked"
class="relative flex items-center justify-center"
>
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</span>
</button>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { Check, CirclePlus } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/lib/utils'
interface FilterOption {
value: string
label: string
icon?: any
count?: number
}
interface Props {
title: string
options: FilterOption[]
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const selectedValue = computed(() => props.modelValue)
const filterFunction = (list: FilterOption[], term: string) => list.filter(i => i.label.toLowerCase()?.includes(term))
</script>
<template>
<UiPopover>
<UiPopoverTrigger as-child>
<UiButton variant="outline" size="sm" class="h-8 border-dashed">
<CirclePlus class="size-4 mr-2" />
{{ title }}
<template v-if="selectedValue">
<UiSeparator orientation="vertical" class="h-4 mx-2" />
<UiBadge
variant="secondary"
class="px-1 font-normal rounded-sm"
>
{{ options.find(o => o.value === selectedValue)?.label }}
</UiBadge>
</template>
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-[200px] p-0" align="start">
<UiCommand :filter-function="filterFunction as unknown as any">
<UiCommandInput :placeholder="title" />
<UiCommandList>
<UiCommandEmpty>{{ t('common.noResults') }}</UiCommandEmpty>
<UiCommandGroup>
<UiCommandItem
v-for="option in options"
:key="option.value"
:value="option"
@select="() => {
emit('update:modelValue', option.value === selectedValue ? '' : option.value)
}"
>
<div
:class="cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedValue === option.value
? 'bg-primary'
: 'opacity-50 [&_svg]:invisible',
)"
>
<Check :class="cn('h-4 w-4', selectedValue === option.value ? 'text-primary-foreground' : '')" />
</div>
<component :is="option.icon" v-if="option.icon" class="size-4 mr-2 text-muted-foreground" />
<span>{{ option.label }}</span>
<span v-if="option.count" class="flex items-center justify-center size-4 ml-auto font-mono text-xs">
{{ option.count }}
</span>
</UiCommandItem>
</UiCommandGroup>
<template v-if="selectedValue">
<UiCommandSeparator />
<UiCommandGroup>
<UiCommandItem
:value="{ label: 'Clear filters' }"
class="justify-center text-center"
@select="emit('update:modelValue', '')"
>
{{ t('common.clearFilters') }}
</UiCommandItem>
</UiCommandGroup>
</template>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>
@@ -0,0 +1,49 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import RadioCell from './radio-cell.vue'
const FIXED_WIDTH_COLUMN = {
size: 32,
minSize: 32,
maxSize: 32,
enableResizing: false,
} as const
export const SelectColumn: ColumnDef<any> = {
id: 'select',
...FIXED_WIDTH_COLUMN,
header: ({ table }) => h(Checkbox, {
'modelValue': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
'onUpdate:modelValue': value => table.toggleAllPageRowsSelected(!!value),
'ariaLabel': 'Select all',
}),
cell: ({ row }) => h(Checkbox, {
'modelValue': row.getIsSelected(),
'onUpdate:modelValue': value => row.toggleSelected(!!value),
'ariaLabel': 'Select row',
}),
enableSorting: false,
enableHiding: false,
}
export const RadioSelectColumn: ColumnDef<any> = {
id: 'radio-select',
...FIXED_WIDTH_COLUMN,
header: () => null,
cell: ({ row, table }) => h(RadioCell, {
checked: row.getIsSelected(),
onClick: (event: MouseEvent) => {
event.stopPropagation()
// cancel selection of all rows
table.toggleAllRowsSelected(false)
// select the current row
row.toggleSelected(true)
},
}),
enableSorting: false,
enableHiding: false,
}
@@ -0,0 +1,8 @@
<script lang="ts" setup>
</script>
<template>
<div class="h-120 w-full flex items-center justify-center">
<UiSpinner class="size-10" />
</div>
</template>
@@ -0,0 +1,222 @@
<script setup lang="ts" generic="T">
import type { Table } from '@tanstack/vue-table'
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeft,
ChevronsRight,
} from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { PAGE_SIZES } from '@/constants/pagination'
import type { ServerPagination } from './types'
interface DataTablePaginationProps {
table: Table<T>
serverPagination?: ServerPagination
}
const props = defineProps<DataTablePaginationProps>()
const { t } = useI18n()
const isServerPagination = computed(() => !!props.serverPagination)
const currentPage = computed(() => {
if (isServerPagination.value && props.serverPagination) {
return props.serverPagination.page
}
return props.table.getState().pagination.pageIndex + 1
})
const currentPageSize = computed(() => {
if (isServerPagination.value && props.serverPagination) {
return props.serverPagination.pageSize
}
return props.table.getState().pagination.pageSize
})
const totalPages = computed(() => {
if (isServerPagination.value && props.serverPagination) {
return Math.ceil(props.serverPagination.total / props.serverPagination.pageSize)
}
return props.table.getPageCount()
})
const canPreviousPage = computed(() => {
if (isServerPagination.value) {
return currentPage.value > 1
}
return props.table.getCanPreviousPage()
})
const canNextPage = computed(() => {
if (isServerPagination.value) {
return currentPage.value < totalPages.value
}
return props.table.getCanNextPage()
})
const selectedCount = computed(() => props.table.getSelectedRowModel().rows.length)
const jumpPage = ref<string>('')
const isEditingPage = ref(false)
function handlePageSizeChange(value: any) {
if (!value)
return
const newPageSize = Number(value)
if (isServerPagination.value && props.serverPagination?.onPageSizeChange) {
props.serverPagination.onPageSizeChange(newPageSize)
}
else {
props.table.setPageSize(newPageSize)
}
}
function goToFirstPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(1)
}
else {
props.table.setPageIndex(0)
}
}
function goToPreviousPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(currentPage.value - 1)
}
else {
props.table.previousPage()
}
}
function goToNextPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(currentPage.value + 1)
}
else {
props.table.nextPage()
}
}
function goToLastPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(totalPages.value)
}
else {
props.table.setPageIndex(props.table.getPageCount() - 1)
}
}
function startEditPage() {
jumpPage.value = String(currentPage.value)
isEditingPage.value = true
}
function handleJumpPage() {
const page = Number.parseInt(jumpPage.value, 10)
if (!Number.isNaN(page) && page >= 1 && page <= totalPages.value) {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(page)
}
else {
props.table.setPageIndex(page - 1)
}
}
isEditingPage.value = false
jumpPage.value = ''
}
</script>
<template>
<div class="flex items-center justify-between px-2">
<div class="flex items-center text-sm text-muted-foreground">
<Transition
enter-active-class="transition-all duration-200 ease-out"
leave-active-class="transition-all duration-200 ease-in"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<span v-if="selectedCount">
{{ selectedCount }} {{ t('common.itemsSelected') }}
</span>
</Transition>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">{{ t('common.rowsPerPage') }}</span>
<UiSelect
:model-value="`${currentPageSize}`"
@update:model-value="handlePageSizeChange"
>
<UiSelectTrigger size="sm" class="h-7 w-[65px] px-2">
<UiSelectValue :placeholder="`${currentPageSize}`" />
</UiSelectTrigger>
<UiSelectContent side="top">
<UiSelectItem v-for="pageSize in PAGE_SIZES" :key="pageSize" :value="`${pageSize}`">
{{ pageSize }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="flex items-center gap-1">
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canPreviousPage"
@click="goToFirstPage"
>
<ChevronsLeft class="size-4" />
</UiButton>
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canPreviousPage"
@click="goToPreviousPage"
>
<ChevronLeftIcon class="size-4" />
</UiButton>
<div class="flex items-center px-2">
<template v-if="isEditingPage">
<UiInput
v-model="jumpPage"
inputmode="numeric"
pattern="[0-9]*"
class="h-7 w-14 text-center text-sm [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@blur="handleJumpPage"
@keydown.enter="handleJumpPage"
@keydown.escape="isEditingPage = false"
/>
<span class="ml-1 text-sm">/ {{ totalPages }}</span>
</template>
<button
v-else
class="min-w-[60px] rounded px-2 py-1 text-sm font-medium hover:bg-accent"
@click="startEditPage"
>
{{ currentPage }} / {{ totalPages }}
</button>
</div>
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canNextPage"
@click="goToNextPage"
>
<ChevronRightIcon class="size-4" />
</UiButton>
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canNextPage"
@click="goToLastPage"
>
<ChevronsRight class="size-4" />
</UiButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,22 @@
import type { ColumnDef } from '@tanstack/vue-table'
export interface FacetedFilterOption {
label: string
value: string
icon?: Component
}
export interface ServerPagination {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
}
export interface DataTableProps<T> {
loading?: boolean
columns: ColumnDef<T, any>[]
data: T[]
serverPagination?: ServerPagination
}
@@ -0,0 +1,119 @@
import type { ColumnFiltersState, ColumnPinningState, PaginationState, SortingState, TableOptionsWithReactiveData, VisibilityState } from '@tanstack/vue-table'
import { computed, ref } from 'vue'
import { getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { DEFAULT_PAGE_SIZE } from '@/constants/pagination'
import { valueUpdater } from '@/lib/utils'
import type { DataTableProps } from './types'
interface ExtendedDataTableProps<T> extends DataTableProps<T> {
getSubRows?: (row: T) => T[]
}
export function generateVueTable<T>(props: ExtendedDataTableProps<T>, columns: DataTableProps<T>['columns']) {
const sorting = ref<SortingState>([])
const columnFilters = ref<ColumnFiltersState>([])
const columnVisibility = ref<VisibilityState>({})
const columnPinning = ref<ColumnPinningState>({ left: [], right: [] })
const rowSelection = ref({})
const globalFilter = ref<string>('')
const expanded = ref({})
const pagination = ref<PaginationState>({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE,
})
const useServerPagination = !!props.serverPagination
const pageIndex = computed(() => {
if (useServerPagination && props.serverPagination) {
return props.serverPagination.page - 1
}
return 0
})
const pageSize = computed(() => {
if (useServerPagination && props.serverPagination) {
return props.serverPagination.pageSize
}
return DEFAULT_PAGE_SIZE
})
const pageCount = computed(() => {
if (useServerPagination && props.serverPagination) {
return Math.ceil(props.serverPagination.total / props.serverPagination.pageSize)
}
return -1
})
const tableConfig: TableOptionsWithReactiveData<T> = {
get data() { return props.data },
get columns() { return columns },
state: {
get sorting() { return sorting.value },
get columnFilters() { return columnFilters.value },
get columnVisibility() { return columnVisibility.value },
get columnPinning() { return columnPinning.value },
get rowSelection() { return rowSelection.value },
get globalFilter() { return globalFilter.value },
get expanded() { return expanded.value },
get pagination() {
if (useServerPagination) {
return {
pageIndex: pageIndex.value,
pageSize: pageSize.value,
}
}
return pagination.value
},
},
enableRowSelection: true,
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning),
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
onGlobalFilterChange: updaterOrValue => valueUpdater(updaterOrValue, globalFilter),
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getExpandedRowModel: getExpandedRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const search = String(filterValue).toLowerCase()
const values = Object.values(row.original as Record<string, unknown>)
return values.some((value) => {
if (value == null)
return false
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>).some(
v => v != null && String(v).toLowerCase().includes(search),
)
}
return String(value).toLowerCase().includes(search)
})
},
}
if (props.getSubRows) {
tableConfig.getSubRows = props.getSubRows
}
if (useServerPagination) {
tableConfig.pageCount = pageCount.value
tableConfig.manualPagination = true
}
else {
tableConfig.getPaginationRowModel = getPaginationRowModel()
}
const table = useVueTable<T>(tableConfig)
return table
}
@@ -0,0 +1,72 @@
<script setup lang="ts" generic="T">
import type { Table } from '@tanstack/vue-table'
import { RefreshCcw, Settings2 } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
interface DataTableViewOptionsProps {
table: Table<T>
columnLabels?: Record<string, string>
}
const props = defineProps<DataTableViewOptionsProps>()
const { t } = useI18n()
const columns = computed(() => props.table.getAllColumns()
.filter(
column =>
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
))
function resetColumnVisible() {
columns.value.forEach(column => column.toggleVisibility(true))
}
function getColumnLabel(columnId: string): string {
if (props.columnLabels && props.columnLabels[columnId]) {
try {
return t(props.columnLabels[columnId])
}
catch {
return columnId
}
}
return columnId
}
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="outline"
size="sm"
class="hidden h-8 ml-auto lg:flex"
>
<Settings2 class="size-4 mr-2" />
{{ t('common.columnsView') }}
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end" class="w-[150px]">
<UiDropdownMenuLabel>{{ t('common.toggleColumns') }}</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuCheckboxItem
v-for="column in columns"
:key="column.id"
:model-value="column.getIsVisible()"
@update:model-value="(value:boolean) => column.toggleVisibility(!!value)"
>
{{ getColumnLabel(column.id) }}
</UiDropdownMenuCheckboxItem>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem
@click="resetColumnVisible"
>
<RefreshCcw class="mr-2 h-4 w-4" />
{{ t('common.reset') }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>