mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 05:24:08 +00:00
feat: add marquee selection box overlay during drag-to-select
Show a semi-transparent blue rectangle overlay while dragging to select rows, matching the project's primary color theme with dark mode support. The box spans the full table width from drag start to current mouse position. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
224
frontend/src/composables/useSwipeSelect.ts
Normal file
224
frontend/src/composables/useSwipeSelect.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
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
|
||||
let startY = 0
|
||||
let initialSelectedSnapshot = new Map<number, boolean>()
|
||||
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 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -252,6 +253,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>
|
||||
@@ -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<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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user