mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 09:20:04 +00:00
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>
738 lines
22 KiB
Vue
738 lines
22 KiB
Vue
<template>
|
||
<div class="md:hidden space-y-3">
|
||
<template v-if="loading">
|
||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||
<div class="space-y-3">
|
||
<div v-for="column in dataColumns" :key="column.key" class="flex justify-between">
|
||
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||
</div>
|
||
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
|
||
<div class="h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="!data || data.length === 0">
|
||
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900">
|
||
<slot name="empty">
|
||
<div class="flex flex-col items-center">
|
||
<Icon
|
||
name="inbox"
|
||
size="xl"
|
||
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
|
||
/>
|
||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||
{{ t('empty.noData') }}
|
||
</p>
|
||
</div>
|
||
</slot>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div
|
||
v-for="(row, index) in sortedData"
|
||
:key="resolveRowKey(row, index)"
|
||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
|
||
>
|
||
<div class="space-y-3">
|
||
<div
|
||
v-for="column in dataColumns"
|
||
:key="column.key"
|
||
class="flex items-start justify-between gap-4"
|
||
>
|
||
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
|
||
{{ column.label }}
|
||
</span>
|
||
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
|
||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||
</slot>
|
||
</div>
|
||
</div>
|
||
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
|
||
<slot name="cell-actions" :row="row" :value="row['actions']" :expanded="actionsExpanded"></slot>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div
|
||
ref="tableWrapperRef"
|
||
class="table-wrapper hidden md:block"
|
||
:class="{
|
||
'actions-expanded': actionsExpanded,
|
||
'is-scrollable': isScrollable
|
||
}"
|
||
>
|
||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||
<thead class="table-header bg-gray-50 dark:bg-dark-800">
|
||
<tr>
|
||
<th
|
||
v-for="(column, index) in columns"
|
||
:key="column.key"
|
||
scope="col"
|
||
:class="[
|
||
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
|
||
getAdaptivePaddingClass(),
|
||
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
|
||
getStickyColumnClass(column, index)
|
||
]"
|
||
@click="column.sortable && handleSort(column.key)"
|
||
>
|
||
<slot
|
||
:name="`header-${column.key}`"
|
||
:column="column"
|
||
:sort-key="sortKey"
|
||
:sort-order="sortOrder"
|
||
>
|
||
<div class="flex items-center space-x-1">
|
||
<span>{{ column.label }}</span>
|
||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||
<svg
|
||
v-if="sortKey === column.key"
|
||
class="h-4 w-4"
|
||
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path
|
||
fill-rule="evenodd"
|
||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||
clip-rule="evenodd"
|
||
/>
|
||
</svg>
|
||
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||
<path
|
||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
</slot>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||
<!-- Loading skeleton -->
|
||
<tr v-if="loading" v-for="i in 5" :key="i">
|
||
<td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]">
|
||
<div class="animate-pulse">
|
||
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Empty state -->
|
||
<tr v-else-if="!data || data.length === 0">
|
||
<td
|
||
:colspan="columns.length"
|
||
:class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]"
|
||
>
|
||
<slot name="empty">
|
||
<div class="flex flex-col items-center">
|
||
<Icon
|
||
name="inbox"
|
||
size="xl"
|
||
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
|
||
/>
|
||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||
{{ t('empty.noData') }}
|
||
</p>
|
||
</div>
|
||
</slot>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Data rows -->
|
||
<tr
|
||
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
|
||
v-for="(column, colIndex) in columns"
|
||
:key="column.key"
|
||
:class="[
|
||
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
|
||
getAdaptivePaddingClass(),
|
||
getStickyColumnClass(column, colIndex)
|
||
]"
|
||
>
|
||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||
</slot>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import type { Column } from './types'
|
||
import Icon from '@/components/icons/Icon.vue'
|
||
|
||
const { t } = useI18n()
|
||
|
||
const emit = defineEmits<{
|
||
sort: [key: string, order: 'asc' | 'desc']
|
||
}>()
|
||
|
||
// 表格容器引用
|
||
const tableWrapperRef = ref<HTMLElement | null>(null)
|
||
const isScrollable = ref(false)
|
||
const actionsColumnNeedsExpanding = ref(false)
|
||
|
||
// 检查是否可滚动
|
||
const checkScrollable = () => {
|
||
if (tableWrapperRef.value) {
|
||
isScrollable.value = tableWrapperRef.value.scrollWidth > tableWrapperRef.value.clientWidth
|
||
}
|
||
}
|
||
|
||
// 检查操作列是否需要展开
|
||
const checkActionsColumnWidth = () => {
|
||
if (!tableWrapperRef.value) return
|
||
|
||
// 查找第一行的操作列单元格
|
||
const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child')
|
||
if (!firstActionCell) return
|
||
|
||
// 查找操作列内容的容器div
|
||
const actionsContainer = firstActionCell.querySelector('div')
|
||
if (!actionsContainer) return
|
||
|
||
// 临时展开以测量完整宽度
|
||
const wasExpanded = actionsExpanded.value
|
||
actionsExpanded.value = true
|
||
|
||
// 等待DOM更新
|
||
nextTick(() => {
|
||
// 测量所有按钮的总宽度
|
||
const actionItems = actionsContainer.querySelectorAll('button, a, [role="button"]')
|
||
if (actionItems.length <= 2) {
|
||
actionsColumnNeedsExpanding.value = false
|
||
actionsExpanded.value = wasExpanded
|
||
return
|
||
}
|
||
|
||
// 计算所有按钮的总宽度(包括gap)
|
||
let totalWidth = 0
|
||
actionItems.forEach((item, index) => {
|
||
totalWidth += (item as HTMLElement).offsetWidth
|
||
if (index < actionItems.length - 1) {
|
||
totalWidth += 4 // gap-1 = 4px
|
||
}
|
||
})
|
||
|
||
// 获取单元格可用宽度(减去padding)
|
||
const cellWidth = (firstActionCell as HTMLElement).clientWidth - 32 // 减去左右padding
|
||
|
||
// 如果总宽度超过可用宽度,需要展开功能
|
||
actionsColumnNeedsExpanding.value = totalWidth > cellWidth
|
||
|
||
// 恢复原来的展开状态
|
||
actionsExpanded.value = wasExpanded
|
||
})
|
||
}
|
||
|
||
// 监听尺寸变化
|
||
let resizeObserver: ResizeObserver | null = null
|
||
let resizeHandler: (() => void) | null = null
|
||
|
||
onMounted(() => {
|
||
checkScrollable()
|
||
checkActionsColumnWidth()
|
||
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
|
||
resizeObserver = new ResizeObserver(() => {
|
||
checkScrollable()
|
||
checkActionsColumnWidth()
|
||
})
|
||
resizeObserver.observe(tableWrapperRef.value)
|
||
} else {
|
||
// 降级方案:不支持 ResizeObserver 时使用 window resize
|
||
resizeHandler = () => {
|
||
checkScrollable()
|
||
checkActionsColumnWidth()
|
||
}
|
||
window.addEventListener('resize', resizeHandler)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
resizeObserver?.disconnect()
|
||
if (resizeHandler) {
|
||
window.removeEventListener('resize', resizeHandler)
|
||
resizeHandler = null
|
||
}
|
||
})
|
||
|
||
interface Props {
|
||
columns: Column[]
|
||
data: any[]
|
||
loading?: boolean
|
||
stickyFirstColumn?: boolean
|
||
stickyActionsColumn?: boolean
|
||
expandableActions?: boolean
|
||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||
rowKey?: string | ((row: any) => string | number)
|
||
/**
|
||
* Default sort configuration (only applied when there is no persisted sort state)
|
||
*/
|
||
defaultSortKey?: string
|
||
defaultSortOrder?: 'asc' | 'desc'
|
||
/**
|
||
* Persist sort state (key + order) to localStorage using this key.
|
||
* If provided, DataTable will load the stored sort state on mount.
|
||
*/
|
||
sortStorageKey?: string
|
||
/**
|
||
* Enable server-side sorting mode. When true, clicking sort headers
|
||
* will emit 'sort' events instead of performing client-side sorting.
|
||
*/
|
||
serverSideSort?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
loading: false,
|
||
stickyFirstColumn: true,
|
||
stickyActionsColumn: true,
|
||
expandableActions: true,
|
||
defaultSortOrder: 'asc',
|
||
serverSideSort: false
|
||
})
|
||
|
||
const sortKey = ref<string>('')
|
||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||
const actionsExpanded = ref(false)
|
||
|
||
type PersistedSortState = {
|
||
key: string
|
||
order: 'asc' | 'desc'
|
||
}
|
||
|
||
const collator = new Intl.Collator(undefined, {
|
||
numeric: true,
|
||
sensitivity: 'base'
|
||
})
|
||
|
||
const getSortableKeys = () => {
|
||
const keys = new Set<string>()
|
||
for (const col of props.columns) {
|
||
if (col.sortable) keys.add(col.key)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
const normalizeSortKey = (candidate: string) => {
|
||
if (!candidate) return ''
|
||
const sortableKeys = getSortableKeys()
|
||
return sortableKeys.has(candidate) ? candidate : ''
|
||
}
|
||
|
||
const normalizeSortOrder = (candidate: any): 'asc' | 'desc' => {
|
||
return candidate === 'desc' ? 'desc' : 'asc'
|
||
}
|
||
|
||
const readPersistedSortState = (): PersistedSortState | null => {
|
||
if (!props.sortStorageKey) return null
|
||
try {
|
||
const raw = localStorage.getItem(props.sortStorageKey)
|
||
if (!raw) return null
|
||
const parsed = JSON.parse(raw) as Partial<PersistedSortState>
|
||
const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '')
|
||
if (!key) return null
|
||
return { key, order: normalizeSortOrder(parsed.order) }
|
||
} catch (e) {
|
||
console.error('[DataTable] Failed to read persisted sort state:', e)
|
||
return null
|
||
}
|
||
}
|
||
|
||
const writePersistedSortState = (state: PersistedSortState) => {
|
||
if (!props.sortStorageKey) return
|
||
try {
|
||
localStorage.setItem(props.sortStorageKey, JSON.stringify(state))
|
||
} catch (e) {
|
||
console.error('[DataTable] Failed to persist sort state:', e)
|
||
}
|
||
}
|
||
|
||
const resolveInitialSortState = (): PersistedSortState | null => {
|
||
const persisted = readPersistedSortState()
|
||
if (persisted) return persisted
|
||
|
||
const key = normalizeSortKey(props.defaultSortKey || '')
|
||
if (!key) return null
|
||
return { key, order: normalizeSortOrder(props.defaultSortOrder) }
|
||
}
|
||
|
||
const applySortState = (state: PersistedSortState | null) => {
|
||
if (!state) return
|
||
sortKey.value = state.key
|
||
sortOrder.value = state.order
|
||
}
|
||
|
||
const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === ''
|
||
|
||
const toFiniteNumberOrNull = (value: any): number | null => {
|
||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||
if (typeof value === 'boolean') return value ? 1 : 0
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim()
|
||
if (!trimmed) return null
|
||
const n = Number(trimmed)
|
||
return Number.isFinite(n) ? n : null
|
||
}
|
||
return null
|
||
}
|
||
|
||
const toSortableString = (value: any): string => {
|
||
if (value === null || value === undefined) return ''
|
||
if (typeof value === 'string') return value
|
||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||
if (value instanceof Date) return value.toISOString()
|
||
try {
|
||
return JSON.stringify(value)
|
||
} catch {
|
||
return String(value)
|
||
}
|
||
}
|
||
|
||
const compareSortValues = (a: any, b: any): number => {
|
||
const aEmpty = isNullishOrEmpty(a)
|
||
const bEmpty = isNullishOrEmpty(b)
|
||
if (aEmpty && bEmpty) return 0
|
||
if (aEmpty) return 1
|
||
if (bEmpty) return -1
|
||
|
||
const aNum = toFiniteNumberOrNull(a)
|
||
const bNum = toFiniteNumberOrNull(b)
|
||
if (aNum !== null && bNum !== null) {
|
||
if (aNum === bNum) return 0
|
||
return aNum < bNum ? -1 : 1
|
||
}
|
||
|
||
const aStr = toSortableString(a)
|
||
const bStr = toSortableString(b)
|
||
const res = collator.compare(aStr, bStr)
|
||
if (res === 0) return 0
|
||
return res < 0 ? -1 : 1
|
||
}
|
||
const resolveRowKey = (row: any, index: number) => {
|
||
if (typeof props.rowKey === 'function') {
|
||
const key = props.rowKey(row)
|
||
return key ?? index
|
||
}
|
||
if (typeof props.rowKey === 'string' && props.rowKey) {
|
||
const key = row?.[props.rowKey]
|
||
return key ?? index
|
||
}
|
||
const key = row?.id
|
||
return key ?? index
|
||
}
|
||
|
||
const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions'))
|
||
const columnsSignature = computed(() =>
|
||
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
|
||
)
|
||
|
||
// 数据/列变化时重新检查滚动状态
|
||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||
watch(
|
||
[() => props.data.length, columnsSignature],
|
||
async () => {
|
||
await nextTick()
|
||
checkScrollable()
|
||
checkActionsColumnWidth()
|
||
},
|
||
{ flush: 'post' }
|
||
)
|
||
|
||
// 单独监听展开状态变化,只更新滚动状态
|
||
watch(actionsExpanded, async () => {
|
||
await nextTick()
|
||
checkScrollable()
|
||
})
|
||
|
||
const handleSort = (key: string) => {
|
||
let newOrder: 'asc' | 'desc' = 'asc'
|
||
if (sortKey.value === key) {
|
||
newOrder = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||
}
|
||
|
||
if (props.serverSideSort) {
|
||
// Server-side sort mode: emit event and update internal state for UI feedback
|
||
sortKey.value = key
|
||
sortOrder.value = newOrder
|
||
emit('sort', key, newOrder)
|
||
} else {
|
||
// Client-side sort mode: just update internal state
|
||
sortKey.value = key
|
||
sortOrder.value = newOrder
|
||
}
|
||
}
|
||
|
||
const sortedData = computed(() => {
|
||
// Server-side sort mode: return data as-is (server handles sorting)
|
||
if (props.serverSideSort || !sortKey.value || !props.data) return props.data
|
||
|
||
const key = sortKey.value
|
||
const order = sortOrder.value
|
||
|
||
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
|
||
return props.data
|
||
.map((row, index) => ({ row, index }))
|
||
.sort((a, b) => {
|
||
const cmp = compareSortValues(a.row?.[key], b.row?.[key])
|
||
if (cmp !== 0) return order === 'asc' ? cmp : -cmp
|
||
return a.index - b.index
|
||
})
|
||
.map(item => item.row)
|
||
})
|
||
|
||
const hasActionsColumn = computed(() => {
|
||
return props.columns.some(column => column.key === 'actions')
|
||
})
|
||
|
||
const hasSelectColumn = computed(() => {
|
||
return props.columns.length > 0 && props.columns[0].key === 'select'
|
||
})
|
||
|
||
// 生成固定列的 CSS 类
|
||
const getStickyColumnClass = (column: Column, index: number) => {
|
||
const classes: string[] = []
|
||
|
||
if (props.stickyFirstColumn) {
|
||
// 如果第一列是勾选列,固定前两列(勾选+名称)
|
||
if (hasSelectColumn.value) {
|
||
if (index === 0) {
|
||
classes.push('sticky-col sticky-col-left-first')
|
||
} else if (index === 1) {
|
||
classes.push('sticky-col sticky-col-left-second')
|
||
}
|
||
} else {
|
||
// 否则只固定第一列
|
||
if (index === 0) {
|
||
classes.push('sticky-col sticky-col-left')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 操作列固定(最后一列)
|
||
if (props.stickyActionsColumn && column.key === 'actions') {
|
||
classes.push('sticky-col sticky-col-right')
|
||
}
|
||
|
||
return classes.join(' ')
|
||
}
|
||
|
||
// 根据列数自适应调整内边距
|
||
const getAdaptivePaddingClass = () => {
|
||
const columnCount = props.columns.length
|
||
|
||
// 列数越多,内边距越小
|
||
if (columnCount >= 10) {
|
||
return 'px-2' // 8px
|
||
} else if (columnCount >= 7) {
|
||
return 'px-3' // 12px
|
||
} else if (columnCount >= 5) {
|
||
return 'px-4' // 16px
|
||
} else {
|
||
return 'px-6' // 24px (原始值)
|
||
}
|
||
}
|
||
|
||
// Init + keep persisted sort state consistent with current columns
|
||
const didInitSort = ref(false)
|
||
|
||
onMounted(() => {
|
||
const initial = resolveInitialSortState()
|
||
applySortState(initial)
|
||
didInitSort.value = true
|
||
})
|
||
|
||
watch(
|
||
columnsSignature,
|
||
() => {
|
||
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||
const normalized = normalizeSortKey(sortKey.value)
|
||
if (!sortKey.value) {
|
||
const initial = resolveInitialSortState()
|
||
applySortState(initial)
|
||
return
|
||
}
|
||
|
||
if (!normalized) {
|
||
const fallback = resolveInitialSortState()
|
||
if (fallback) {
|
||
applySortState(fallback)
|
||
} else {
|
||
sortKey.value = ''
|
||
sortOrder.value = 'asc'
|
||
}
|
||
}
|
||
},
|
||
{ flush: 'post' }
|
||
)
|
||
|
||
watch(
|
||
[sortKey, sortOrder],
|
||
([nextKey, nextOrder]) => {
|
||
if (!didInitSort.value) return
|
||
if (!props.sortStorageKey) return
|
||
const key = normalizeSortKey(nextKey)
|
||
if (!key) return
|
||
writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) })
|
||
},
|
||
{ flush: 'post' }
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 表格横向滚动 */
|
||
.table-wrapper {
|
||
--select-col-width: 52px; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
|
||
position: relative;
|
||
overflow-x: auto;
|
||
isolation: isolate;
|
||
}
|
||
|
||
/* 表头容器,确保在滚动时覆盖表体内容 */
|
||
.table-wrapper .table-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 200;
|
||
background-color: rgb(249 250 251);
|
||
}
|
||
|
||
.dark .table-wrapper .table-header {
|
||
background-color: rgb(31 41 55);
|
||
}
|
||
|
||
/* 表体保持在表头下方 */
|
||
.table-body {
|
||
position: relative;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* 所有表头单元格固定在顶部 */
|
||
.sticky-header-cell {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 210; /* 必须高于所有表体内容 */
|
||
background-color: rgb(249 250 251);
|
||
}
|
||
|
||
.dark .sticky-header-cell {
|
||
background-color: rgb(31 41 55);
|
||
}
|
||
|
||
/* Sticky 列基础样式 */
|
||
.sticky-col {
|
||
position: sticky;
|
||
z-index: 20; /* 表体固定列 */
|
||
}
|
||
|
||
/* 单列固定(无勾选列时) */
|
||
.sticky-col-left {
|
||
left: 0;
|
||
}
|
||
|
||
/* 双列固定(有勾选列时):第一列(勾选) */
|
||
.sticky-col-left-first {
|
||
left: 0;
|
||
}
|
||
|
||
/* 双列固定(有勾选列时):第二列(名称) */
|
||
.sticky-col-left-second {
|
||
left: var(--select-col-width);
|
||
}
|
||
|
||
/* 操作列固定 */
|
||
.sticky-col-right {
|
||
right: 0;
|
||
}
|
||
|
||
/* 表头 sticky 列 - 需要比普通表头单元格更高的 z-index */
|
||
.sticky-header-cell.sticky-col {
|
||
z-index: 220; /* 高于普通表头单元格和表体固定列 */
|
||
}
|
||
|
||
/* 表体 sticky 列背景 */
|
||
tbody .sticky-col {
|
||
background-color: white;
|
||
}
|
||
|
||
.dark tbody .sticky-col {
|
||
background-color: rgb(17 24 39);
|
||
}
|
||
|
||
/* hover 状态保持 */
|
||
tbody tr:hover .sticky-col {
|
||
background-color: rgb(249 250 251);
|
||
}
|
||
|
||
.dark tbody tr:hover .sticky-col {
|
||
background-color: rgb(31 41 55);
|
||
}
|
||
|
||
/* 阴影只在可滚动时显示 */
|
||
/* 单列固定右侧阴影 */
|
||
.is-scrollable .sticky-col-left::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 10px;
|
||
transform: translateX(100%);
|
||
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 双列固定:只在第二列显示阴影 */
|
||
.is-scrollable .sticky-col-left-second::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 10px;
|
||
transform: translateX(100%);
|
||
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 操作列左侧阴影 */
|
||
.is-scrollable .sticky-col-right::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
bottom: 0;
|
||
width: 10px;
|
||
transform: translateX(-100%);
|
||
background: linear-gradient(to left, rgba(0, 0, 0, 0.08), transparent);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 暗色模式阴影 */
|
||
.dark .is-scrollable .sticky-col-left::after,
|
||
.dark .is-scrollable .sticky-col-left-second::after {
|
||
background: linear-gradient(to right, rgba(0, 0, 0, 0.2), transparent);
|
||
}
|
||
|
||
.dark .is-scrollable .sticky-col-right::before {
|
||
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
|
||
}
|
||
</style>
|