Merge branch 'fix/table-pagination-and-features'

This commit is contained in:
shaw
2026-01-09 20:42:05 +08:00
26 changed files with 602 additions and 49 deletions

View File

@@ -16,7 +16,7 @@ import type {
* List all groups with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (platform, status, is_exclusive)
* @param filters - Optional filters (platform, status, is_exclusive, search)
* @returns Paginated list of groups
*/
export async function list(
@@ -26,6 +26,7 @@ export async function list(
platform?: GroupPlatform
status?: 'active' | 'inactive'
is_exclusive?: boolean
search?: string
},
options?: {
signal?: AbortSignal

View File

@@ -1,8 +1,27 @@
<template>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg">
<span class="text-sm font-medium">{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}</span>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg dark:bg-primary-900/20">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}
</span>
<button
@click="$emit('select-page')"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
</button>
<span class="text-gray-300 dark:text-primary-800"></span>
<button
@click="$emit('clear')"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.clear') }}
</button>
</div>
<div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
</div>
</div>
@@ -10,5 +29,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit']); const { t } = useI18n()
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable']); const { t } = useI18n()
</script>

View File

@@ -43,7 +43,8 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
const currentController = new AbortController()
abortController = currentController
loading.value = true
try {
@@ -51,9 +52,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
pagination.page,
pagination.page_size,
toRaw(params) as P,
{ signal: abortController.signal }
{ signal: currentController.signal }
)
items.value = response.items || []
pagination.total = response.total || 0
pagination.pages = response.pages || 0
@@ -63,7 +64,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
throw error
}
} finally {
if (abortController && !abortController.signal.aborted) {
if (abortController === currentController) {
loading.value = false
}
}
@@ -77,7 +78,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const debouncedReload = useDebounceFn(reload, debounceMs)
const handlePageChange = (page: number) => {
pagination.page = page
// 确保页码在有效范围内
const validPage = Math.max(1, Math.min(page, pagination.pages || 1))
pagination.page = validPage
load()
}

View File

@@ -1085,12 +1085,16 @@ export default {
tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared successfully',
bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)',
bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)',
bulkActions: {
selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page',
clear: 'Clear selection',
edit: 'Bulk Edit',
delete: 'Bulk Delete'
delete: 'Bulk Delete',
enableScheduling: 'Enable Scheduling',
disableScheduling: 'Disable Scheduling'
},
bulkEdit: {
title: 'Bulk Edit Accounts',

View File

@@ -1221,12 +1221,16 @@ export default {
accountCreatedSuccess: '账号添加成功',
accountUpdatedSuccess: '账号更新成功',
accountDeletedSuccess: '账号删除成功',
bulkSchedulableEnabled: '成功启用 {count} 个账号的调度',
bulkSchedulableDisabled: '成功停止 {count} 个账号的调度',
bulkActions: {
selected: '已选择 {count} 个账号',
selectCurrentPage: '本页全选',
clear: '清除选择',
edit: '批量编辑账号',
delete: '批量删除'
delete: '批量删除',
enableScheduling: '批量启用调度',
disableScheduling: '批量停止调度'
},
bulkEdit: {
title: '批量编辑账号',

View File

@@ -7,7 +7,7 @@
v-model:searchQuery="params.search"
:filters="params"
@update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="reload"
@change="debouncedReload"
@update:searchQuery="debouncedReload"
/>
<AccountTableActions
@@ -19,7 +19,7 @@
</div>
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" />
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<DataTable :columns="cols" :data="accounts" :loading="loading">
<template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
@@ -107,7 +107,7 @@
</template>
</DataTable>
</template>
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" /></template>
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /></template>
</TablePageLayout>
<CreateAccountModal :show="showCreate" :proxies="proxies" :groups="groups" @close="showCreate = false" @created="reload" />
<EditAccountModal :show="showEdit" :account="edAcc" :proxies="proxies" :groups="groups" @close="showEdit = false" @updated="load" />
@@ -175,7 +175,7 @@ const statsAcc = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange } = useTableLoader<Account, any>({
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', search: '' }
})
@@ -209,6 +209,21 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const handleBulkToggleSchedulable = async (schedulable: boolean) => {
const count = selIds.value.length
try {
const result = await adminAPI.accounts.bulkUpdate(selIds.value, { schedulable });
const message = schedulable
? t('admin.accounts.bulkSchedulableEnabled', { count: result.success || count })
: t('admin.accounts.bulkSchedulableDisabled', { count: result.success || count });
appStore.showSuccess(message);
selIds.value = [];
reload()
} catch (error) {
console.error('Failed to bulk toggle schedulable:', error);
appStore.showError(t('common.error'))
}
}
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }

View File

@@ -16,6 +16,7 @@
type="text"
:placeholder="t('admin.groups.searchGroups')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<Select
@@ -64,7 +65,7 @@
</template>
<template #table>
<DataTable :columns="columns" :data="displayedGroups" :loading="loading">
<DataTable :columns="columns" :data="groups" :loading="loading">
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
@@ -932,16 +933,6 @@ const pagination = reactive({
let abortController: AbortController | null = null
const displayedGroups = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return groups.value
return groups.value.filter((group) => {
const name = group.name?.toLowerCase?.() ?? ''
const description = group.description?.toLowerCase?.() ?? ''
return name.includes(q) || description.includes(q)
})
})
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
@@ -1011,7 +1002,8 @@ const loadGroups = async () => {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined,
search: searchQuery.value.trim() || undefined
}, { signal })
if (signal.aborted) return
groups.value = response.items
@@ -1030,6 +1022,15 @@ const loadGroups = async () => {
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadGroups()
}, 300)
}
const handlePageChange = (page: number) => {
pagination.page = page
loadGroups()

View File

@@ -519,7 +519,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
@@ -942,4 +942,9 @@ const confirmDelete = async () => {
onMounted(() => {
loadProxies()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>

View File

@@ -364,7 +364,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
@@ -693,4 +693,9 @@ onMounted(() => {
loadCodes()
loadSubscriptionGroups()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>

View File

@@ -893,12 +893,13 @@ const loadUsers = async () => {
}
}
}
} catch (error) {
} catch (error: any) {
const errorInfo = error as { name?: string; code?: string }
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.users.failedToLoad'))
const message = error.response?.data?.detail || error.message || t('admin.users.failedToLoad')
appStore.showError(message)
console.error('Error loading users:', error)
} finally {
if (abortController === currentAbortController) {
@@ -917,7 +918,9 @@ const handleSearch = () => {
}
const handlePageChange = (page: number) => {
pagination.page = page
// 确保页码在有效范围内
const validPage = Math.max(1, Math.min(page, pagination.pages || 1))
pagination.page = validPage
loadUsers()
}
@@ -943,6 +946,7 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters.add(key)
}
saveFiltersToStorage()
pagination.page = 1
loadUsers()
}
@@ -957,6 +961,7 @@ const toggleAttributeFilter = (attr: UserAttributeDefinition) => {
activeAttributeFilters[attr.id] = ''
}
saveFiltersToStorage()
pagination.page = 1
loadUsers()
}
@@ -1059,5 +1064,7 @@ onMounted(async () => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>