diff --git a/frontend/src/composables/useSwipeSelect.ts b/frontend/src/composables/useSwipeSelect.ts index 377bc96a..21316ba3 100644 --- a/frontend/src/composables/useSwipeSelect.ts +++ b/frontend/src/composables/useSwipeSelect.ts @@ -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(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 with
...
+ * 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() 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 } diff --git a/frontend/src/composables/useTableSelection.ts b/frontend/src/composables/useTableSelection.ts new file mode 100644 index 00000000..a65144a9 --- /dev/null +++ b/frontend/src/composables/useTableSelection.ts @@ -0,0 +1,98 @@ +import { computed, ref, type Ref } from 'vue' + +interface UseTableSelectionOptions { + rows: Ref + getId: (row: T) => number +} + +export function useTableSelection({ rows, getId }: UseTableSelectionOptions) { + const selectedSet = ref>(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) => { + 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 + } +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 25631aaf..e36a3651 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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 { diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 66d9b720..9334cf30 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -131,8 +131,8 @@