feat(table): 表格排序与搜索改为后端处理
This commit is contained in:
@@ -21,7 +21,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="items" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="email"
|
||||
default-sort-order="asc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-email="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -62,7 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
@@ -98,23 +106,54 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const sortState = reactive({
|
||||
sort_by: 'email',
|
||||
sort_order: 'asc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const items = ref<AnnouncementUserReadStatus[]>([])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'email', label: t('common.email') },
|
||||
{ key: 'username', label: t('admin.users.columns.username') },
|
||||
{ key: 'balance', label: t('common.balance') },
|
||||
{ key: 'email', label: t('common.email'), sortable: true },
|
||||
{ key: 'username', label: t('admin.users.columns.username'), sortable: true },
|
||||
{ key: 'balance', label: t('common.balance'), sortable: true },
|
||||
{ key: 'eligible', label: t('admin.announcements.eligible') },
|
||||
{ key: 'read_at', label: t('admin.announcements.readAt') }
|
||||
])
|
||||
|
||||
let currentController: AbortController | null = null
|
||||
let searchDebounceTimer: number | null = null
|
||||
|
||||
function resetDialogState() {
|
||||
loading.value = false
|
||||
search.value = ''
|
||||
items.value = []
|
||||
pagination.page = 1
|
||||
pagination.total = 0
|
||||
pagination.pages = 0
|
||||
sortState.sort_by = 'email'
|
||||
sortState.sort_order = 'asc'
|
||||
}
|
||||
|
||||
function cancelPendingLoad(resetState = false) {
|
||||
if (searchDebounceTimer) {
|
||||
window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
currentController?.abort()
|
||||
currentController = null
|
||||
if (resetState) {
|
||||
resetDialogState()
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.show || !props.announcementId) return
|
||||
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
currentController?.abort()
|
||||
const requestController = new AbortController()
|
||||
currentController = requestController
|
||||
const { signal } = requestController
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -122,20 +161,37 @@ async function load() {
|
||||
props.announcementId,
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
search.value
|
||||
{
|
||||
search: search.value,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
if (signal.aborted || currentController !== requestController) return
|
||||
|
||||
items.value = res.items
|
||||
pagination.total = res.total
|
||||
pagination.pages = res.pages
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
if (
|
||||
signal.aborted ||
|
||||
currentController !== requestController ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to load read status:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (currentController === requestController) {
|
||||
loading.value = false
|
||||
currentController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
|
||||
load()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
@@ -160,13 +222,17 @@ function handleSearch() {
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
cancelPendingLoad(true)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
if (!v) {
|
||||
cancelPendingLoad(true)
|
||||
return
|
||||
}
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
@@ -181,7 +247,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// noop
|
||||
onUnmounted(() => {
|
||||
cancelPendingLoad()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import AnnouncementReadStatusDialog from '../AnnouncementReadStatusDialog.vue'
|
||||
|
||||
const { getReadStatus, showError } = vi.hoisted(() => ({
|
||||
getReadStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
announcements: {
|
||||
getReadStatus,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/usePersistedPageSize', () => ({
|
||||
getPersistedPageSize: () => 20,
|
||||
}))
|
||||
|
||||
const BaseDialogStub = {
|
||||
props: ['show', 'title', 'width'],
|
||||
emits: ['close'],
|
||||
template: '<div><slot /><slot name="footer" /></div>',
|
||||
}
|
||||
|
||||
describe('AnnouncementReadStatusDialog', () => {
|
||||
beforeEach(() => {
|
||||
getReadStatus.mockReset()
|
||||
showError.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('closes by aborting active requests and clearing debounced reloads', async () => {
|
||||
let activeSignal: AbortSignal | undefined
|
||||
getReadStatus.mockImplementation(async (...args: any[]) => {
|
||||
activeSignal = args[4]?.signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const wrapper = mount(AnnouncementReadStatusDialog, {
|
||||
props: {
|
||||
show: false,
|
||||
announcementId: 1,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
DataTable: true,
|
||||
Pagination: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.setProps({ show: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(getReadStatus).toHaveBeenCalledTimes(1)
|
||||
expect(activeSignal?.aborted).toBe(false)
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.search = 'alice'
|
||||
setupState.handleSearch()
|
||||
|
||||
setupState.handleClose()
|
||||
await flushPromises()
|
||||
|
||||
expect(activeSignal?.aborted).toBe(true)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(350)
|
||||
await flushPromises()
|
||||
|
||||
expect(getReadStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -196,7 +196,6 @@
|
||||
:total="localEntries.length"
|
||||
:page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@update:page="currentPage = $event"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-auto">
|
||||
<DataTable :columns="columns" :data="data" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:server-side-sort="serverSideSort"
|
||||
:default-sort-key="defaultSortKey"
|
||||
:default-sort-order="defaultSortOrder"
|
||||
@sort="(key, order) => $emit('sort', key, order)"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<button
|
||||
@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
defineProps(['data', 'loading', 'columns'])
|
||||
defineEmits(['userClick'])
|
||||
interface Props {
|
||||
data: AdminUsageLog[]
|
||||
loading?: boolean
|
||||
columns: Column[]
|
||||
serverSideSort?: boolean
|
||||
defaultSortKey?: string
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
serverSideSort: false,
|
||||
defaultSortKey: '',
|
||||
defaultSortOrder: 'asc'
|
||||
})
|
||||
defineEmits<{
|
||||
userClick: [userID: number, email?: string]
|
||||
sort: [key: string, order: 'asc' | 'desc']
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Tooltip state - cost
|
||||
|
||||
@@ -52,7 +52,7 @@ Pagination component with page numbers, navigation, and page size selector.
|
||||
- `total: number` - Total number of items
|
||||
- `page: number` - Current page (1-indexed)
|
||||
- `pageSize: number` - Items per page
|
||||
- `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50, 100])
|
||||
- `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50])
|
||||
|
||||
**Events:**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user