diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue
index 43755301..16aea107 100644
--- a/frontend/src/components/common/DataTable.vue
+++ b/frontend/src/components/common/DataTable.vue
@@ -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"
>
boolean
+ select: (id: number) => void
+ deselect: (id: number) => void
+}
+
+export function useSwipeSelect(
+ containerRef: Ref,
+ adapter: SwipeSelectAdapter
+) {
+ const isDragging = ref(false)
+
+ let dragMode: 'select' | 'deselect' = 'select'
+ let startRowIndex = -1
+ let lastEndIndex = -1
+ let startY = 0
+ let initialSelectedSnapshot = new Map()
+ let cachedRows: HTMLElement[] = []
+ let marqueeEl: HTMLDivElement | null = null
+
+ 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
+ }
+
+ // --- Marquee overlay ---
+ function createMarquee() {
+ marqueeEl = document.createElement('div')
+ const isDark = document.documentElement.classList.contains('dark')
+ Object.assign(marqueeEl.style, {
+ position: 'fixed',
+ background: isDark ? 'rgba(96, 165, 250, 0.15)' : 'rgba(59, 130, 246, 0.12)',
+ border: isDark ? '1.5px solid rgba(96, 165, 250, 0.5)' : '1.5px solid rgba(59, 130, 246, 0.4)',
+ borderRadius: '4px',
+ pointerEvents: 'none',
+ zIndex: '9999',
+ transition: 'none'
+ })
+ document.body.appendChild(marqueeEl)
+ }
+
+ function updateMarquee(currentY: number) {
+ if (!marqueeEl || !containerRef.value) return
+ const containerRect = containerRef.value.getBoundingClientRect()
+
+ const top = Math.min(startY, currentY)
+ const bottom = Math.max(startY, currentY)
+
+ // Clamp to container horizontal bounds, extend full width
+ marqueeEl.style.left = containerRect.left + 'px'
+ marqueeEl.style.width = containerRect.width + 'px'
+ marqueeEl.style.top = top + 'px'
+ marqueeEl.style.height = (bottom - top) + 'px'
+ }
+
+ function removeMarquee() {
+ if (marqueeEl) {
+ marqueeEl.remove()
+ marqueeEl = null
+ }
+ }
+
+ // --- Row selection logic ---
+ 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
+
+ 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) {
+ if (dragMode === 'select') {
+ adapter.select(id)
+ } else {
+ adapter.deselect(id)
+ }
+ } else {
+ 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
+ if (target.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]')) return
+ 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
+
+ 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
+ startY = e.clientY
+ dragMode = adapter.isSelected(rowId) ? 'deselect' : 'select'
+
+ applyRange(rowIndex)
+
+ // Create visual marquee
+ createMarquee()
+ updateMarquee(e.clientY)
+
+ e.preventDefault()
+ document.body.style.userSelect = 'none'
+ document.addEventListener('mousemove', onMouseMove)
+ document.addEventListener('mouseup', onMouseUp)
+ }
+
+ function onMouseMove(e: MouseEvent) {
+ if (!isDragging.value) return
+
+ // Update marquee box
+ updateMarquee(e.clientY)
+
+ 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)
+ autoScroll(e)
+ }
+
+ function onMouseUp() {
+ isDragging.value = false
+ startRowIndex = -1
+ lastEndIndex = -1
+ cachedRows = []
+ initialSelectedSnapshot.clear()
+ stopAutoScroll()
+ removeMarquee()
+ document.body.style.userSelect = ''
+
+ document.removeEventListener('mousemove', onMouseMove)
+ document.removeEventListener('mouseup', onMouseUp)
+ }
+
+ // --- Auto-scroll ---
+ let scrollRAF = 0
+ const SCROLL_ZONE = 40
+ const SCROLL_SPEED = 8
+
+ 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()
+ removeMarquee()
+ })
+
+ return { isDragging }
+}
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index 0173ea0a..b608aa97 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -132,6 +132,7 @@
+
+
@@ -285,6 +287,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'
@@ -319,6 +322,12 @@ const authStore = useAuthStore()
const proxies = ref([])
const groups = ref([])
const selIds = ref([])
+const accountTableRef = ref(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(() => {
const platforms = new Set(
accounts.value
diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue
index 147b3205..c26aa233 100644
--- a/frontend/src/views/admin/ProxiesView.vue
+++ b/frontend/src/views/admin/ProxiesView.vue
@@ -88,6 +88,7 @@
+
+
@@ -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>(new Set())
const batchTesting = ref(false)
const batchQualityChecking = ref(false)
const selectedProxyIds = ref>(new Set())
+const proxyTableRef = ref(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(null)
const proxyAccounts = ref([])
const accountsLoading = ref(false)
|