refactor(frontend): unify admin table selection state

This commit is contained in:
erio
2026-03-08 04:36:49 +08:00
parent abe8162292
commit c7d44b419f
4 changed files with 161 additions and 62 deletions
+4 -3
View File
@@ -5,7 +5,7 @@ import { ref, onMounted, onUnmounted, type Ref } from 'vue'
* with a semi-transparent marquee overlay showing the selection area.
*
* Features:
* - Start dragging from anywhere on the page (not just inside the table)
* - Start dragging only inside the table container
* - Mouse wheel scrolling continues selecting new rows
* - Auto-scroll when dragging near viewport edges
* - 5px drag threshold to avoid accidental selection on click
@@ -202,10 +202,12 @@ export function useSwipeSelect(
if (e.button !== 0) return
if (!containerRef.value) return
const target = e.target as HTMLElement
if (!containerRef.value.contains(target)) return
// Skip clicks on any scrollbar (inner containers + document)
if (isOnScrollbar(e)) return
const target = e.target as HTMLElement
if (target.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"], [role="combobox"], [role="dialog"]')) return
if (shouldPreferNativeTextSelection(target)) return
@@ -367,7 +369,6 @@ export function useSwipeSelect(
// --- Lifecycle ---
onMounted(() => {
// Listen on document so drag can start from anywhere on the page
document.addEventListener('mousedown', onMouseDown)
window.addEventListener('blur', onWindowBlur)
})
@@ -0,0 +1,98 @@
import { computed, ref, type Ref } from 'vue'
interface UseTableSelectionOptions<T> {
rows: Ref<T[]>
getId: (row: T) => number
}
export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T>) {
const selectedSet = ref<Set<number>>(new Set())
const selectedIds = computed(() => Array.from(selectedSet.value))
const selectedCount = computed(() => selectedSet.value.size)
const isSelected = (id: number) => selectedSet.value.has(id)
const replaceSelectedSet = (next: Set<number>) => {
selectedSet.value = next
}
const setSelectedIds = (ids: number[]) => {
selectedSet.value = new Set(ids)
}
const select = (id: number) => {
if (selectedSet.value.has(id)) return
const next = new Set(selectedSet.value)
next.add(id)
replaceSelectedSet(next)
}
const deselect = (id: number) => {
if (!selectedSet.value.has(id)) return
const next = new Set(selectedSet.value)
next.delete(id)
replaceSelectedSet(next)
}
const toggle = (id: number) => {
if (selectedSet.value.has(id)) {
deselect(id)
return
}
select(id)
}
const clear = () => {
if (selectedSet.value.size === 0) return
replaceSelectedSet(new Set())
}
const removeMany = (ids: number[]) => {
if (ids.length === 0 || selectedSet.value.size === 0) return
const next = new Set(selectedSet.value)
let changed = false
ids.forEach((id) => {
if (next.delete(id)) changed = true
})
if (changed) replaceSelectedSet(next)
}
const allVisibleSelected = computed(() => {
if (rows.value.length === 0) return false
return rows.value.every((row) => selectedSet.value.has(getId(row)))
})
const toggleVisible = (checked: boolean) => {
const next = new Set(selectedSet.value)
rows.value.forEach((row) => {
const id = getId(row)
if (checked) {
next.add(id)
} else {
next.delete(id)
}
})
replaceSelectedSet(next)
}
const selectVisible = () => {
toggleVisible(true)
}
return {
selectedSet,
selectedIds,
selectedCount,
allVisibleSelected,
isSelected,
setSelectedIds,
select,
deselect,
toggle,
clear,
removeMany,
toggleVisible,
selectVisible
}
}
+35 -29
View File
@@ -131,7 +131,7 @@
</div>
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable
:columns="cols"
@@ -290,6 +290,7 @@ import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import { useTableLoader } from '@/composables/useTableLoader'
import { useSwipeSelect } from '@/composables/useSwipeSelect'
import { useTableSelection } from '@/composables/useTableSelection'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
@@ -323,17 +324,11 @@ const authStore = useAuthStore()
const proxies = ref<Proxy[]>([])
const groups = ref<AdminGroup[]>([])
const selIds = ref<number[]>([])
const accountTableRef = ref<HTMLElement | null>(null)
useSwipeSelect(accountTableRef, {
isSelected: (id) => selIds.value.includes(id),
select: (id) => { if (!selIds.value.includes(id)) selIds.value.push(id) },
deselect: (id) => { selIds.value = selIds.value.filter(x => x !== id) }
})
const selPlatforms = computed<AccountPlatform[]>(() => {
const platforms = new Set(
accounts.value
.filter(a => selIds.value.includes(a.id))
.filter(a => isSelected(a.id))
.map(a => a.platform)
)
return [...platforms]
@@ -341,7 +336,7 @@ const selPlatforms = computed<AccountPlatform[]>(() => {
const selTypes = computed<AccountType[]>(() => {
const types = new Set(
accounts.value
.filter(a => selIds.value.includes(a.id))
.filter(a => isSelected(a.id))
.map(a => a.type)
)
return [...types]
@@ -566,6 +561,29 @@ const {
initialParams: { platform: '', type: '', status: '', group: '', search: '' }
})
const {
selectedIds: selIds,
allVisibleSelected,
isSelected,
setSelectedIds,
select,
deselect,
toggle: toggleSel,
clear: clearSelection,
removeMany: removeSelectedAccounts,
toggleVisible,
selectVisible: selectPage
} = useTableSelection<Account>({
rows: accounts,
getId: (account) => account.id
})
useSwipeSelect(accountTableRef, {
isSelected,
select,
deselect
})
const resetAutoRefreshCache = () => {
autoRefreshETag.value = null
}
@@ -865,24 +883,11 @@ const openMenu = (a: Account, e: MouseEvent) => {
menu.show = true
}
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const allVisibleSelected = computed(() => {
if (accounts.value.length === 0) return false
return accounts.value.every(account => selIds.value.includes(account.id))
})
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.checked) {
const next = new Set(selIds.value)
accounts.value.forEach(account => next.add(account.id))
selIds.value = Array.from(next)
return
}
const visibleIds = new Set(accounts.value.map(account => account.id))
selIds.value = selIds.value.filter(id => !visibleIds.has(id))
toggleVisible(target.checked)
}
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 handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); clearSelection(); reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
if (accountIds.length === 0) return
const idSet = new Set(accountIds)
@@ -955,7 +960,7 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
const { successIds, failedIds, successCount, failedCount, hasIds, hasCounts } = normalizeBulkSchedulableResult(result, accountIds)
if (!hasIds && !hasCounts) {
appStore.showError(t('admin.accounts.bulkSchedulableResultUnknown'))
selIds.value = accountIds
setSelectedIds(accountIds)
load().catch((error) => {
console.error('Failed to refresh accounts:', error)
})
@@ -975,16 +980,17 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
? t('admin.accounts.bulkSchedulablePartial', { success: successCount, failed: failedCount })
: t('admin.accounts.bulkSchedulableResultUnknown')
appStore.showError(message)
selIds.value = failedIds.length > 0 ? failedIds : accountIds
setSelectedIds(failedIds.length > 0 ? failedIds : accountIds)
} else {
selIds.value = hasIds ? [] : accountIds
if (hasIds) clearSelection()
else setSelectedIds(accountIds)
}
} catch (error) {
console.error('Failed to bulk toggle schedulable:', error)
appStore.showError(t('common.error'))
}
}
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
const handleDataImported = () => { showImportData.value = false; reload() }
const accountMatchesCurrentFilters = (account: Account) => {
if (params.platform && account.platform !== params.platform) return false
@@ -1030,7 +1036,7 @@ const patchAccountInList = (updatedAccount: Account) => {
if (!accountMatchesCurrentFilters(mergedAccount)) {
accounts.value = accounts.value.filter(account => account.id !== mergedAccount.id)
syncPaginationAfterLocalRemoval()
selIds.value = selIds.value.filter(id => id !== mergedAccount.id)
removeSelectedAccounts([mergedAccount.id])
if (menu.acc?.id === mergedAccount.id) {
menu.show = false
menu.acc = null
+24 -30
View File
@@ -883,6 +883,7 @@ import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useSwipeSelect } from '@/composables/useSwipeSelect'
import { useTableSelection } from '@/composables/useTableSelection'
const { t } = useI18n()
const appStore = useAppStore()
@@ -961,12 +962,25 @@ const testingProxyIds = ref<Set<number>>(new Set())
const qualityCheckingProxyIds = ref<Set<number>>(new Set())
const batchTesting = ref(false)
const batchQualityChecking = ref(false)
const selectedProxyIds = ref<Set<number>>(new Set())
const proxyTableRef = ref<HTMLElement | null>(null)
const {
selectedSet: selectedProxyIds,
selectedCount,
allVisibleSelected,
isSelected,
select,
deselect,
clear: clearSelectedProxies,
removeMany: removeSelectedProxies,
toggleVisible
} = useTableSelection<Proxy>({
rows: proxies,
getId: (proxy) => proxy.id
})
useSwipeSelect(proxyTableRef, {
isSelected: (id) => selectedProxyIds.value.has(id),
select: (id) => { const next = new Set(selectedProxyIds.value); next.add(id); selectedProxyIds.value = next },
deselect: (id) => { const next = new Set(selectedProxyIds.value); next.delete(id); selectedProxyIds.value = next }
isSelected,
select,
deselect
})
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
@@ -977,12 +991,6 @@ const showQualityReportDialog = ref(false)
const qualityReportProxy = ref<Proxy | null>(null)
const qualityReport = ref<ProxyQualityCheckResult | null>(null)
const selectedCount = computed(() => selectedProxyIds.value.size)
const allVisibleSelected = computed(() => {
if (proxies.value.length === 0) return false
return proxies.value.every((proxy) => selectedProxyIds.value.has(proxy.id))
})
// Batch import state
const createMode = ref<'standard' | 'batch'>('standard')
const batchInput = ref('')
@@ -1029,26 +1037,16 @@ const isAbortError = (error: unknown) => {
const toggleSelectRow = (id: number, event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
if (target.checked) {
next.add(id)
} else {
next.delete(id)
select(id)
return
}
selectedProxyIds.value = next
deselect(id)
}
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
for (const proxy of proxies.value) {
if (target.checked) {
next.add(proxy.id)
} else {
next.delete(proxy.id)
}
}
selectedProxyIds.value = next
toggleVisible(target.checked)
}
const loadProxies = async () => {
@@ -1740,11 +1738,7 @@ const confirmDelete = async () => {
await adminAPI.proxies.delete(deletingProxy.value.id)
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
showDeleteDialog.value = false
if (selectedProxyIds.value.has(deletingProxy.value.id)) {
const next = new Set(selectedProxyIds.value)
next.delete(deletingProxy.value.id)
selectedProxyIds.value = next
}
removeSelectedProxies([deletingProxy.value.id])
deletingProxy.value = null
loadProxies()
} catch (error: any) {
@@ -1771,7 +1765,7 @@ const confirmBatchDelete = async () => {
appStore.showInfo(t('admin.proxies.batchDeleteSkipped', { skipped }))
}
selectedProxyIds.value = new Set()
clearSelectedProxies()
showBatchDeleteDialog.value = false
loadProxies()
} catch (error: any) {