feat: add WeChat-style swipe-to-select for account and proxy tables

Add useSwipeSelect composable that enables drag-to-select rows in
DataTable. Mousedown on a row and drag up/down to select/deselect
a contiguous range. Integrated into AccountsView and ProxiesView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-08 01:29:13 +08:00
co-authored by Claude Opus 4.6
parent 597c19e80b
commit 4fdc977192
4 changed files with 218 additions and 0 deletions
@@ -152,6 +152,7 @@
v-else
v-for="(row, index) in sortedData"
:key="resolveRowKey(row, index)"
:data-row-id="resolveRowKey(row, index)"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td
+199
View File
@@ -0,0 +1,199 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
/**
* WeChat-style swipe/drag to select rows in a DataTable.
*
* Usage:
* const containerRef = ref<HTMLElement | null>(null)
* useSwipeSelect(containerRef, {
* 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) },
* })
*
* Wrap <DataTable> with <div ref="containerRef">...</div>
* DataTable rows must have data-row-id attribute.
*/
export interface SwipeSelectAdapter {
isSelected: (id: number) => boolean
select: (id: number) => void
deselect: (id: number) => void
}
export function useSwipeSelect(
containerRef: Ref<HTMLElement | null>,
adapter: SwipeSelectAdapter
) {
const isDragging = ref(false)
let dragMode: 'select' | 'deselect' = 'select'
let startRowIndex = -1
let lastEndIndex = -1
// Snapshot of which row IDs were selected when drag started
let initialSelectedSnapshot = new Map<number, boolean>()
// Cache of row elements for the current drag operation
let cachedRows: HTMLElement[] = []
function getDataRows(): HTMLElement[] {
const container = containerRef.value
if (!container) return []
return Array.from(container.querySelectorAll('tbody tr[data-row-id]'))
}
function getRowId(el: HTMLElement): number | null {
const raw = el.getAttribute('data-row-id')
if (raw === null) return null
const id = Number(raw)
return Number.isFinite(id) ? id : null
}
function applyRange(endIndex: number) {
const rangeMin = Math.min(startRowIndex, endIndex)
const rangeMax = Math.max(startRowIndex, endIndex)
const prevMin = lastEndIndex >= 0 ? Math.min(startRowIndex, lastEndIndex) : rangeMin
const prevMax = lastEndIndex >= 0 ? Math.max(startRowIndex, lastEndIndex) : rangeMax
// Determine the full affected region (union of old and new range)
const lo = Math.min(rangeMin, prevMin)
const hi = Math.max(rangeMax, prevMax)
for (let i = lo; i <= hi && i < cachedRows.length; i++) {
const id = getRowId(cachedRows[i])
if (id === null) continue
if (i >= rangeMin && i <= rangeMax) {
// In current range → apply drag mode
if (dragMode === 'select') {
adapter.select(id)
} else {
adapter.deselect(id)
}
} else {
// Outside current range → restore to initial state
const wasSelected = initialSelectedSnapshot.get(id) ?? false
if (wasSelected) {
adapter.select(id)
} else {
adapter.deselect(id)
}
}
}
lastEndIndex = endIndex
}
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return
const target = e.target as HTMLElement
// Don't interfere with interactive elements
if (target.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]')) return
// Must be inside tbody
if (!target.closest('tbody')) return
cachedRows = getDataRows()
const tr = target.closest('tr[data-row-id]') as HTMLElement | null
if (!tr) return
const rowIndex = cachedRows.indexOf(tr)
if (rowIndex < 0) return
const rowId = getRowId(tr)
if (rowId === null) return
// Snapshot current selection state for all visible rows
initialSelectedSnapshot = new Map()
for (const row of cachedRows) {
const id = getRowId(row)
if (id !== null) {
initialSelectedSnapshot.set(id, adapter.isSelected(id))
}
}
isDragging.value = true
startRowIndex = rowIndex
lastEndIndex = -1
dragMode = adapter.isSelected(rowId) ? 'deselect' : 'select'
applyRange(rowIndex)
e.preventDefault()
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
if (!el) return
const tr = el.closest('tr[data-row-id]') as HTMLElement | null
if (!tr) return
const rowIndex = cachedRows.indexOf(tr)
if (rowIndex < 0) return
applyRange(rowIndex)
// Auto-scroll when near container edges
autoScroll(e)
}
function onMouseUp() {
isDragging.value = false
startRowIndex = -1
lastEndIndex = -1
cachedRows = []
initialSelectedSnapshot.clear()
stopAutoScroll()
document.body.style.userSelect = ''
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
// --- Auto-scroll logic ---
let scrollRAF = 0
const SCROLL_ZONE = 40 // px from edge
const SCROLL_SPEED = 8 // px per frame
function autoScroll(e: MouseEvent) {
cancelAnimationFrame(scrollRAF)
const container = containerRef.value
if (!container) return
const rect = container.getBoundingClientRect()
let dy = 0
if (e.clientY < rect.top + SCROLL_ZONE) {
dy = -SCROLL_SPEED
} else if (e.clientY > rect.bottom - SCROLL_ZONE) {
dy = SCROLL_SPEED
}
if (dy !== 0) {
const step = () => {
container.scrollTop += dy
scrollRAF = requestAnimationFrame(step)
}
scrollRAF = requestAnimationFrame(step)
}
}
function stopAutoScroll() {
cancelAnimationFrame(scrollRAF)
}
onMounted(() => {
containerRef.value?.addEventListener('mousedown', onMouseDown)
})
onUnmounted(() => {
containerRef.value?.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
stopAutoScroll()
})
return { isDragging }
}
@@ -132,6 +132,7 @@
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<div ref="accountTableRef">
<DataTable
:columns="cols"
:data="accounts"
@@ -254,6 +255,7 @@
</div>
</template>
</DataTable>
</div>
</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>
@@ -287,6 +289,7 @@ import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import { useTableLoader } from '@/composables/useTableLoader'
import { useSwipeSelect } from '@/composables/useSwipeSelect'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
@@ -321,6 +324,12 @@ 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
+9
View File
@@ -88,6 +88,7 @@
</template>
<template #table>
<div ref="proxyTableRef">
<DataTable :columns="columns" :data="proxies" :loading="loading">
<template #header-select>
<input
@@ -325,6 +326,7 @@
/>
</template>
</DataTable>
</div>
</template>
<template #pagination>
@@ -880,6 +882,7 @@ import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useSwipeSelect } from '@/composables/useSwipeSelect'
const { t } = useI18n()
const appStore = useAppStore()
@@ -959,6 +962,12 @@ 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)
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 }
})
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
const accountsLoading = ref(false)