Initial commit: 网络验证平台
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user