mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 02:46:51 +00:00
Merge pull request #853 from touwaeriol/pr/swipe-select-admin-tables
feat(frontend): 为后台账号管理和 IP 管理增加拖筐选中能力
This commit is contained in:
@@ -1,5 +1,26 @@
|
||||
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* WeChat-style swipe/drag to select rows in a DataTable,
|
||||
* with a semi-transparent marquee overlay showing the selection area.
|
||||
*
|
||||
* Features:
|
||||
* - Start dragging inside the current table-page layout's non-text area
|
||||
* - Mouse wheel scrolling continues selecting new rows
|
||||
* - Auto-scroll when dragging near viewport edges
|
||||
* - 5px drag threshold to avoid accidental selection on click
|
||||
*
|
||||
* 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
|
||||
@@ -16,9 +37,22 @@ export function useSwipeSelect(
|
||||
let startRowIndex = -1
|
||||
let lastEndIndex = -1
|
||||
let startY = 0
|
||||
let lastMouseY = 0
|
||||
let pendingStartY = 0
|
||||
let initialSelectedSnapshot = new Map<number, boolean>()
|
||||
let cachedRows: HTMLElement[] = []
|
||||
let marqueeEl: HTMLDivElement | null = null
|
||||
let cachedScrollParent: HTMLElement | null = null
|
||||
|
||||
const DRAG_THRESHOLD = 5
|
||||
const SCROLL_ZONE = 60
|
||||
const SCROLL_SPEED = 8
|
||||
|
||||
function getActivationRoot(): HTMLElement | null {
|
||||
const container = containerRef.value
|
||||
if (!container) return null
|
||||
return container.closest('.table-page-layout') as HTMLElement | null || container
|
||||
}
|
||||
|
||||
function getDataRows(): HTMLElement[] {
|
||||
const container = containerRef.value
|
||||
@@ -33,8 +67,40 @@ export function useSwipeSelect(
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
/** Find the row index closest to a viewport Y coordinate (binary search). */
|
||||
function findRowIndexAtY(clientY: number): number {
|
||||
const len = cachedRows.length
|
||||
if (len === 0) return -1
|
||||
|
||||
// Boundary checks
|
||||
const firstRect = cachedRows[0].getBoundingClientRect()
|
||||
if (clientY < firstRect.top) return 0
|
||||
const lastRect = cachedRows[len - 1].getBoundingClientRect()
|
||||
if (clientY > lastRect.bottom) return len - 1
|
||||
|
||||
// Binary search — rows are vertically ordered
|
||||
let lo = 0, hi = len - 1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
const rect = cachedRows[mid].getBoundingClientRect()
|
||||
if (clientY < rect.top) hi = mid - 1
|
||||
else if (clientY > rect.bottom) lo = mid + 1
|
||||
else return mid
|
||||
}
|
||||
// In a gap between rows — pick the closer one
|
||||
if (hi < 0) return 0
|
||||
if (lo >= len) return len - 1
|
||||
const rHi = cachedRows[hi].getBoundingClientRect()
|
||||
const rLo = cachedRows[lo].getBoundingClientRect()
|
||||
return (clientY - rHi.bottom < rLo.top - clientY) ? hi : lo
|
||||
}
|
||||
|
||||
// --- Prevent text selection via selectstart (no body style mutation) ---
|
||||
function onSelectStart(e: Event) { e.preventDefault() }
|
||||
|
||||
// --- Marquee overlay ---
|
||||
function createMarquee() {
|
||||
removeMarquee() // defensive: remove any stale marquee
|
||||
marqueeEl = document.createElement('div')
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
Object.assign(marqueeEl.style, {
|
||||
@@ -44,7 +110,7 @@ export function useSwipeSelect(
|
||||
borderRadius: '4px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '9999',
|
||||
transition: 'none'
|
||||
transition: 'none',
|
||||
})
|
||||
document.body.appendChild(marqueeEl)
|
||||
}
|
||||
@@ -52,11 +118,8 @@ export function useSwipeSelect(
|
||||
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'
|
||||
@@ -64,140 +127,259 @@ export function useSwipeSelect(
|
||||
}
|
||||
|
||||
function removeMarquee() {
|
||||
if (marqueeEl) {
|
||||
marqueeEl.remove()
|
||||
marqueeEl = null
|
||||
}
|
||||
if (marqueeEl) { marqueeEl.remove(); marqueeEl = null }
|
||||
}
|
||||
|
||||
// --- Row selection logic ---
|
||||
function applyRange(endIndex: number) {
|
||||
if (startRowIndex < 0 || endIndex < 0) return
|
||||
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)
|
||||
}
|
||||
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)
|
||||
if (wasSelected) adapter.select(id)
|
||||
else adapter.deselect(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastEndIndex = endIndex
|
||||
}
|
||||
|
||||
// --- Scrollable parent ---
|
||||
function getScrollParent(el: HTMLElement): HTMLElement {
|
||||
let parent = el.parentElement
|
||||
while (parent && parent !== document.documentElement) {
|
||||
const { overflow, overflowY } = getComputedStyle(parent)
|
||||
if (/(auto|scroll)/.test(overflow + overflowY)) return parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return document.documentElement
|
||||
}
|
||||
|
||||
// --- Scrollbar click detection ---
|
||||
/** Check if click lands on a scrollbar of the target element or any ancestor. */
|
||||
function isOnScrollbar(e: MouseEvent): boolean {
|
||||
let el = e.target as HTMLElement | null
|
||||
while (el && el !== document.documentElement) {
|
||||
const hasVScroll = el.scrollHeight > el.clientHeight
|
||||
const hasHScroll = el.scrollWidth > el.clientWidth
|
||||
if (hasVScroll || hasHScroll) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
// clientWidth/clientHeight exclude scrollbar; offsetWidth/offsetHeight include it
|
||||
if (hasVScroll && e.clientX > rect.left + el.clientWidth) return true
|
||||
if (hasHScroll && e.clientY > rect.top + el.clientHeight) return true
|
||||
}
|
||||
el = el.parentElement
|
||||
}
|
||||
// Document-level scrollbar
|
||||
const docEl = document.documentElement
|
||||
if (e.clientX >= docEl.clientWidth || e.clientY >= docEl.clientHeight) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* If the mousedown starts on inner cell content rather than cell padding,
|
||||
* prefer the browser's native text selection so users can copy text normally.
|
||||
*/
|
||||
function shouldPreferNativeTextSelection(target: HTMLElement): boolean {
|
||||
const row = target.closest('tbody tr[data-row-id]')
|
||||
if (!row) return false
|
||||
|
||||
const cell = target.closest('td, th')
|
||||
if (!cell) return false
|
||||
|
||||
return target !== cell && !target.closest('[data-swipe-select-handle]')
|
||||
}
|
||||
|
||||
function hasDirectTextContent(target: HTMLElement): boolean {
|
||||
return Array.from(target.childNodes).some(
|
||||
(node) => node.nodeType === Node.TEXT_NODE && (node.textContent?.trim().length ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
function shouldPreferNativeSelectionOutsideRows(target: HTMLElement): boolean {
|
||||
const activationRoot = getActivationRoot()
|
||||
if (!activationRoot) return false
|
||||
if (!activationRoot.contains(target)) return false
|
||||
if (target.closest('tbody tr[data-row-id]')) return false
|
||||
|
||||
return hasDirectTextContent(target)
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Phase 1: detect drag threshold (5px movement)
|
||||
// =============================================
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (!containerRef.value) 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
|
||||
const activationRoot = getActivationRoot()
|
||||
if (!activationRoot || !activationRoot.contains(target)) return
|
||||
|
||||
// Skip clicks on any scrollbar (inner containers + document)
|
||||
if (isOnScrollbar(e)) return
|
||||
|
||||
if (target.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"], [role="combobox"], [role="dialog"]')) return
|
||||
if (shouldPreferNativeTextSelection(target)) return
|
||||
if (shouldPreferNativeSelectionOutsideRows(target)) 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
|
||||
if (cachedRows.length === 0) return
|
||||
|
||||
const rowId = getRowId(tr)
|
||||
if (rowId === null) return
|
||||
pendingStartY = e.clientY
|
||||
// Prevent text selection as soon as the mouse is down,
|
||||
// before the drag threshold is reached (Phase 1).
|
||||
// Without this, the browser starts selecting text during
|
||||
// the 0–5px threshold movement window.
|
||||
document.addEventListener('selectstart', onSelectStart)
|
||||
document.addEventListener('mousemove', onThresholdMove)
|
||||
document.addEventListener('mouseup', onThresholdUp)
|
||||
}
|
||||
|
||||
function onThresholdMove(e: MouseEvent) {
|
||||
if (Math.abs(e.clientY - pendingStartY) < DRAG_THRESHOLD) return
|
||||
// Threshold exceeded — begin actual drag
|
||||
document.removeEventListener('mousemove', onThresholdMove)
|
||||
document.removeEventListener('mouseup', onThresholdUp)
|
||||
|
||||
beginDrag(pendingStartY)
|
||||
|
||||
// Process the move that crossed the threshold
|
||||
lastMouseY = e.clientY
|
||||
updateMarquee(e.clientY)
|
||||
const rowIdx = findRowIndexAtY(e.clientY)
|
||||
if (rowIdx >= 0) applyRange(rowIdx)
|
||||
autoScroll(e)
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('wheel', onWheel, { passive: true })
|
||||
}
|
||||
|
||||
function onThresholdUp() {
|
||||
document.removeEventListener('mousemove', onThresholdMove)
|
||||
document.removeEventListener('mouseup', onThresholdUp)
|
||||
// Phase 1 ended without crossing threshold — remove selectstart blocker
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
cachedRows = []
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Phase 2: actual drag session
|
||||
// ============================
|
||||
function beginDrag(clientY: number) {
|
||||
startRowIndex = findRowIndexAtY(clientY)
|
||||
const startRowId = startRowIndex >= 0 ? getRowId(cachedRows[startRowIndex]) : null
|
||||
dragMode = (startRowId !== null && adapter.isSelected(startRowId)) ? 'deselect' : 'select'
|
||||
|
||||
initialSelectedSnapshot = new Map()
|
||||
for (const row of cachedRows) {
|
||||
const id = getRowId(row)
|
||||
if (id !== null) {
|
||||
initialSelectedSnapshot.set(id, adapter.isSelected(id))
|
||||
}
|
||||
if (id !== null) initialSelectedSnapshot.set(id, adapter.isSelected(id))
|
||||
}
|
||||
|
||||
isDragging.value = true
|
||||
startRowIndex = rowIndex
|
||||
startY = clientY
|
||||
lastMouseY = clientY
|
||||
lastEndIndex = -1
|
||||
startY = e.clientY
|
||||
dragMode = adapter.isSelected(rowId) ? 'deselect' : 'select'
|
||||
cachedScrollParent = cachedRows.length > 0
|
||||
? getScrollParent(cachedRows[0])
|
||||
: (containerRef.value ? getScrollParent(containerRef.value) : null)
|
||||
|
||||
applyRange(rowIndex)
|
||||
|
||||
// Create visual marquee
|
||||
createMarquee()
|
||||
updateMarquee(e.clientY)
|
||||
|
||||
e.preventDefault()
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
updateMarquee(clientY)
|
||||
applyRange(startRowIndex)
|
||||
// selectstart is already blocked since Phase 1 (onMouseDown).
|
||||
// Clear any text selection that the browser may have started
|
||||
// before our selectstart handler took effect.
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
// Update marquee box
|
||||
lastMouseY = e.clientY
|
||||
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)
|
||||
const rowIdx = findRowIndexAtY(e.clientY)
|
||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
|
||||
autoScroll(e)
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
function onWheel() {
|
||||
if (!isDragging.value) return
|
||||
// After wheel scroll, rows shift in viewport — re-check selection
|
||||
requestAnimationFrame(() => {
|
||||
if (!isDragging.value) return // guard: drag may have ended before this frame
|
||||
const rowIdx = findRowIndexAtY(lastMouseY)
|
||||
if (rowIdx >= 0) applyRange(rowIdx)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupDrag() {
|
||||
isDragging.value = false
|
||||
startRowIndex = -1
|
||||
lastEndIndex = -1
|
||||
cachedRows = []
|
||||
initialSelectedSnapshot.clear()
|
||||
cachedScrollParent = null
|
||||
stopAutoScroll()
|
||||
removeMarquee()
|
||||
document.body.style.userSelect = ''
|
||||
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('wheel', onWheel)
|
||||
}
|
||||
|
||||
// --- Auto-scroll ---
|
||||
function onMouseUp() {
|
||||
cleanupDrag()
|
||||
}
|
||||
|
||||
// Guard: clean up if mouse leaves window or window loses focus during drag
|
||||
function onWindowBlur() {
|
||||
if (isDragging.value) cleanupDrag()
|
||||
// Also clean up threshold phase (Phase 1)
|
||||
document.removeEventListener('mousemove', onThresholdMove)
|
||||
document.removeEventListener('mouseup', onThresholdUp)
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
}
|
||||
|
||||
// --- Auto-scroll logic ---
|
||||
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 scrollEl = cachedScrollParent
|
||||
if (!scrollEl) 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 (scrollEl === document.documentElement) {
|
||||
if (e.clientY < SCROLL_ZONE) dy = -SCROLL_SPEED
|
||||
else if (e.clientY > window.innerHeight - SCROLL_ZONE) dy = SCROLL_SPEED
|
||||
} else {
|
||||
const rect = scrollEl.getBoundingClientRect()
|
||||
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
|
||||
const prevScrollTop = scrollEl.scrollTop
|
||||
scrollEl.scrollTop += dy
|
||||
// Only re-check selection if scroll actually moved
|
||||
if (scrollEl.scrollTop !== prevScrollTop) {
|
||||
const rowIdx = findRowIndexAtY(lastMouseY)
|
||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
|
||||
}
|
||||
scrollRAF = requestAnimationFrame(step)
|
||||
}
|
||||
scrollRAF = requestAnimationFrame(step)
|
||||
@@ -208,16 +390,20 @@ export function useSwipeSelect(
|
||||
cancelAnimationFrame(scrollRAF)
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(() => {
|
||||
containerRef.value?.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
containerRef.value?.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
stopAutoScroll()
|
||||
removeMarquee()
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
// Clean up any in-progress drag state
|
||||
document.removeEventListener('mousemove', onThresholdMove)
|
||||
document.removeEventListener('mouseup', onThresholdUp)
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
cleanupDrag()
|
||||
})
|
||||
|
||||
return { isDragging }
|
||||
|
||||
98
frontend/src/composables/useTableSelection.ts
Normal file
98
frontend/src/composables/useTableSelection.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,37 @@
|
||||
::selection {
|
||||
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
|
||||
}
|
||||
|
||||
/*
|
||||
* 表格滚动容器:始终显示滚动条,不跟随全局悬停策略。
|
||||
*
|
||||
* 浏览器兼容性说明:
|
||||
* - Chrome 121+ 原生支持 scrollbar-color / scrollbar-width。
|
||||
* 一旦元素匹配了这两个标准属性,::-webkit-scrollbar-* 被完全忽略。
|
||||
* 全局 * { scrollbar-width: thin } 使所有元素都走标准属性,
|
||||
* 因此 Chrome 121+ 只看 scrollbar-color。
|
||||
* - Chrome < 121 不认识标准属性,只看 ::-webkit-scrollbar-*,
|
||||
* 所以保留 ::-webkit-scrollbar-thumb 作为回退。
|
||||
* - Firefox 始终只看 scrollbar-color / scrollbar-width。
|
||||
*/
|
||||
.table-wrapper {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.7) transparent;
|
||||
}
|
||||
.dark .table-wrapper {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.7) transparent;
|
||||
}
|
||||
/* 旧版 Chrome (< 121) 兼容回退 */
|
||||
.table-wrapper::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-gray-400/70;
|
||||
}
|
||||
.dark .table-wrapper::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-gray-500/70;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -131,8 +131,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
<div ref="accountTableRef">
|
||||
<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"
|
||||
:data="accounts"
|
||||
@@ -288,6 +288,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'
|
||||
@@ -322,17 +323,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]
|
||||
@@ -340,7 +335,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]
|
||||
@@ -565,6 +560,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
|
||||
}
|
||||
@@ -866,24 +884,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)
|
||||
@@ -956,7 +961,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)
|
||||
})
|
||||
@@ -976,16 +981,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
|
||||
@@ -1031,7 +1037,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
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<div ref="proxyTableRef">
|
||||
<div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<template #header-select>
|
||||
<input
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user