551 lines
14 KiB
Vue
551 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||
import type { ListItem } from '@/types'
|
||
|
||
interface Props {
|
||
data: ListItem[]
|
||
height?: string | number
|
||
frozen?: boolean
|
||
disableHover?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
height: '100%',
|
||
frozen: false,
|
||
disableHover: false
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
select: [item: ListItem]
|
||
click: [item: ListItem]
|
||
contextmenu: [data: { item: ListItem }]
|
||
showToast: [message: string]
|
||
}>()
|
||
|
||
// ===== 列表配置常量 =====
|
||
/** 列表项基础高度(像素) */
|
||
const ITEM_HEIGHT = 50
|
||
/** 列表项内边距总和(像素) */
|
||
const ITEM_PADDING = 16
|
||
/** 上下缓冲区域的项目数量 */
|
||
const BUFFER_COUNT = 10
|
||
/** 滚动防抖时间(毫秒) */
|
||
const SCROLL_DEBOUNCE_TIME = 16
|
||
/** 虚拟滚动阈值,少于此数量则直接渲染全部 */
|
||
const VIRTUALIZATION_THRESHOLD = 500
|
||
|
||
// 计算列表项实际总高度
|
||
const totalItemHeight = computed(() =>
|
||
ITEM_HEIGHT + ITEM_PADDING
|
||
)
|
||
|
||
// 响应式状态管理
|
||
const containerHeight = ref<number>(0)
|
||
const startIndex = ref<number>(0)
|
||
const selectedIndex = ref<number>(0)
|
||
const isKeyboardNavigating = ref<boolean>(false)
|
||
let keyboardTimer: number | null = null
|
||
|
||
// DOM 引用
|
||
const containerRef = ref<HTMLElement | null>(null)
|
||
const listRef = ref<HTMLElement | null>(null)
|
||
|
||
// 计算可视区域能显示的列表项数量
|
||
const visibleCount = computed(() =>
|
||
Math.floor(containerHeight.value / totalItemHeight.value)
|
||
)
|
||
|
||
/**
|
||
* 是否需要虚拟滚动
|
||
* 当数据量小于阈值时直接渲染全部
|
||
*/
|
||
const needVirtualization = computed<boolean>(() =>
|
||
props.data.length > VIRTUALIZATION_THRESHOLD
|
||
)
|
||
|
||
// 计算当前需要渲染的数据
|
||
const visibleData = computed(() => {
|
||
// 数据量小于阈值时,直接返回全部数据
|
||
if (!needVirtualization.value) {
|
||
return props.data.map((item, index) => ({
|
||
...item,
|
||
index
|
||
}))
|
||
}
|
||
|
||
// 否则使用虚拟滚动
|
||
const visibleStart = Math.max(0, startIndex.value - BUFFER_COUNT)
|
||
const visibleEnd = Math.min(
|
||
props.data.length,
|
||
startIndex.value + visibleCount.value + BUFFER_COUNT
|
||
)
|
||
|
||
return props.data.slice(visibleStart, visibleEnd).map((item, index) => ({
|
||
...item,
|
||
index: visibleStart + index
|
||
}))
|
||
})
|
||
|
||
// 计算列表偏移量
|
||
const offsetY = computed(() =>
|
||
needVirtualization.value
|
||
? Math.max(0, (startIndex.value - BUFFER_COUNT) * totalItemHeight.value)
|
||
: 0
|
||
)
|
||
|
||
// 计算虚拟列表总高度
|
||
const phantomHeight = computed(() =>
|
||
needVirtualization.value
|
||
? props.data.length * totalItemHeight.value
|
||
: 'auto'
|
||
)
|
||
|
||
// 选择处理
|
||
const handleSelect = (item: ListItem): void => {
|
||
if (props.frozen) return
|
||
selectedIndex.value = item.index ?? 0
|
||
emit('select', item)
|
||
}
|
||
|
||
/**
|
||
* 处理列表项点击
|
||
* @param item 点击的列表项
|
||
*/
|
||
const handleClick = (item: ListItem): void => {
|
||
if (props.frozen) return
|
||
handleSelect(item)
|
||
emit('click', item)
|
||
}
|
||
|
||
/**
|
||
* 更新容器高度
|
||
* 从父元素或窗口获取实际高度
|
||
*/
|
||
function updateContainerHeight(): void {
|
||
if (containerRef.value) {
|
||
const parentHeight = containerRef.value.parentElement?.clientHeight
|
||
containerHeight.value = parentHeight || window.innerHeight
|
||
}
|
||
}
|
||
|
||
/** ResizeObserver 实例,用于监听容器尺寸变化 */
|
||
let resizeObserver: ResizeObserver | null = null
|
||
|
||
/**
|
||
* 防抖函数
|
||
* 在指定延迟后执行函数,如果在延迟期间再次调用则重置延迟
|
||
* @param fn 需要防抖的函数
|
||
* @param delay 延迟时间(毫秒)
|
||
*/
|
||
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): (...args: Parameters<T>) => void {
|
||
let timer: number | null = null
|
||
return function (this: any, ...args: Parameters<T>) {
|
||
if (timer) clearTimeout(timer)
|
||
timer = window.setTimeout(() => {
|
||
fn.apply(this, args)
|
||
}, delay)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理滚动事件(已防抖)
|
||
* 根据滚动位置更新可视区的起始索引
|
||
*/
|
||
const handleScroll = debounce(() => {
|
||
// 不需要虚拟滚动时直接返回
|
||
if (!needVirtualization.value) return
|
||
|
||
if (!containerRef.value) return
|
||
const scrollTop = containerRef.value.scrollTop
|
||
const newStartIndex = Math.floor(scrollTop / totalItemHeight.value)
|
||
|
||
if (newStartIndex !== startIndex.value) {
|
||
startIndex.value = newStartIndex
|
||
}
|
||
}, SCROLL_DEBOUNCE_TIME)
|
||
|
||
/** 鼠标悬停禁用状态 */
|
||
const disableHover = ref<boolean>(false)
|
||
/** 鼠标悬停禁用定时器 */
|
||
let hoverTimer: number | null = null
|
||
|
||
/**
|
||
* 处理鼠标移入事件
|
||
* 在非键盘导航状态下更新选中项
|
||
* @param index 目标项的索引
|
||
*/
|
||
function handleMouseEnter(index: number): void {
|
||
if (props.frozen || disableHover.value || props.disableHover) return
|
||
if (!isKeyboardNavigating.value) {
|
||
selectedIndex.value = index
|
||
emit('select', { ...props.data[index], index })
|
||
ensureSelectedItemVisible()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 记录到达列表边界的时间
|
||
* 用于实现边界循环导航的延迟判断
|
||
*/
|
||
const lastReachBoundaryTime = ref<{ top: number; bottom: number }>({
|
||
top: 0,
|
||
bottom: 0
|
||
})
|
||
|
||
/**
|
||
* 处理键盘事件
|
||
* 实现键盘导航、边界循环和项目选择功能
|
||
* @param e 键盘事件对象
|
||
*/
|
||
function handleKeyDown(e: KeyboardEvent): void {
|
||
if (props.frozen) return
|
||
|
||
if (e.key === 'Enter') {
|
||
if (selectedIndex.value >= 0 && selectedIndex.value < props.data.length) {
|
||
const item = props.data[selectedIndex.value]
|
||
emit('click', { ...item, index: selectedIndex.value })
|
||
}
|
||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||
e.preventDefault()
|
||
|
||
const isFirstItem = selectedIndex.value === 0
|
||
const isLastItem = selectedIndex.value === props.data.length - 1
|
||
const now = Date.now()
|
||
|
||
if (e.key === 'ArrowUp' && isFirstItem) {
|
||
const timeSinceLastTop = now - lastReachBoundaryTime.value.top
|
||
if (timeSinceLastTop < 5000) {
|
||
disableHover.value = true
|
||
selectedIndex.value = props.data.length - 1
|
||
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
|
||
ensureSelectedItemVisible()
|
||
if (hoverTimer) clearTimeout(hoverTimer)
|
||
hoverTimer = window.setTimeout(() => {
|
||
disableHover.value = false
|
||
}, 1000)
|
||
} else {
|
||
lastReachBoundaryTime.value.top = now
|
||
emit('showToast', '已经到列表顶端,再次点击将回到列表底端')
|
||
}
|
||
return
|
||
}
|
||
|
||
if (e.key === 'ArrowDown' && isLastItem) {
|
||
const timeSinceLastBottom = now - lastReachBoundaryTime.value.bottom
|
||
if (timeSinceLastBottom < 5000) {
|
||
disableHover.value = true
|
||
selectedIndex.value = 0
|
||
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
|
||
ensureSelectedItemVisible()
|
||
if (hoverTimer) clearTimeout(hoverTimer)
|
||
hoverTimer = window.setTimeout(() => {
|
||
disableHover.value = false
|
||
}, 1000)
|
||
} else {
|
||
lastReachBoundaryTime.value.bottom = now
|
||
emit('showToast', '已经到列表底端,再次点击将回到列表顶端')
|
||
}
|
||
return
|
||
}
|
||
|
||
isKeyboardNavigating.value = true
|
||
if (keyboardTimer) clearTimeout(keyboardTimer)
|
||
|
||
if (e.key === 'ArrowUp') {
|
||
const nextIndex = Math.max(0, selectedIndex.value - 1)
|
||
selectedIndex.value = nextIndex
|
||
emit('select', { ...props.data[nextIndex], index: nextIndex })
|
||
|
||
const itemTop = nextIndex * totalItemHeight.value
|
||
if (itemTop < (containerRef.value?.scrollTop ?? 0) && containerRef.value) {
|
||
containerRef.value.scrollTop = itemTop
|
||
}
|
||
} else {
|
||
const nextIndex = Math.min(props.data.length - 1, selectedIndex.value + 1)
|
||
selectedIndex.value = nextIndex
|
||
emit('select', { ...props.data[nextIndex], index: nextIndex })
|
||
|
||
const itemBottom = (nextIndex + 1) * totalItemHeight.value
|
||
const scrollBottom = (containerRef.value?.scrollTop ?? 0) + containerHeight.value
|
||
if (itemBottom > scrollBottom && containerRef.value) {
|
||
containerRef.value.scrollTop = itemBottom - containerHeight.value
|
||
}
|
||
}
|
||
|
||
keyboardTimer = window.setTimeout(() => {
|
||
isKeyboardNavigating.value = false
|
||
}, 1000)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保持选中项在可视区域内
|
||
* 如果选中项不可见,则滚动到合适位置
|
||
*/
|
||
function ensureSelectedItemVisible(): void {
|
||
if (!containerRef.value) return
|
||
|
||
const containerTop = containerRef.value.scrollTop
|
||
const containerHeight = containerRef.value.clientHeight
|
||
|
||
const itemTop = selectedIndex.value * totalItemHeight.value
|
||
const itemBottom = (selectedIndex.value + 1) * totalItemHeight.value
|
||
const scrollBottom = containerTop + containerHeight
|
||
|
||
if (itemTop < containerTop) {
|
||
containerRef.value.scrollTop = itemTop
|
||
} else if (itemBottom > scrollBottom) {
|
||
containerRef.value.scrollTop = itemBottom - containerHeight
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理右键点击事件
|
||
* @param e 鼠标事件对象
|
||
* @param item 目标列表项
|
||
*/
|
||
const handleContextMenu = (e: MouseEvent, item: ListItem): void => {
|
||
if (props.frozen) return
|
||
e.preventDefault()
|
||
emit('contextmenu', { item })
|
||
}
|
||
|
||
// ===== 生命周期钩子 =====
|
||
onMounted(() => {
|
||
if (containerRef.value) {
|
||
// 添加滚动事件监听
|
||
containerRef.value.addEventListener('scroll', handleScroll)
|
||
|
||
// 创建并启用 ResizeObserver
|
||
resizeObserver = new ResizeObserver(() => {
|
||
updateContainerHeight()
|
||
})
|
||
|
||
if (containerRef.value.parentElement) {
|
||
resizeObserver.observe(containerRef.value.parentElement)
|
||
}
|
||
}
|
||
|
||
// 初始化容器高度
|
||
updateContainerHeight()
|
||
// 添加全局事件监听
|
||
window.addEventListener('resize', updateContainerHeight)
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
// 清理所有事件监听和定时器
|
||
if (containerRef.value) {
|
||
containerRef.value.removeEventListener('scroll', handleScroll)
|
||
}
|
||
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect()
|
||
}
|
||
|
||
window.removeEventListener('resize', updateContainerHeight)
|
||
window.removeEventListener('keydown', handleKeyDown)
|
||
if (keyboardTimer) clearTimeout(keyboardTimer)
|
||
if (hoverTimer) clearTimeout(hoverTimer)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 虚拟列表容器 -->
|
||
<div
|
||
ref="containerRef"
|
||
class="virtual-list-container"
|
||
:class="{ 'list-frozen': frozen }"
|
||
:style="{ height: '100%' }"
|
||
tabindex="0"
|
||
>
|
||
<!-- 虚拟高度占位元素 -->
|
||
<div
|
||
ref="listRef"
|
||
class="virtual-list-phantom"
|
||
:style="{ height: needVirtualization ? `${phantomHeight}px` : 'auto' }"
|
||
>
|
||
<!-- 实际渲染的列表内容 -->
|
||
<div
|
||
class="virtual-list"
|
||
:style="needVirtualization ? { transform: `translateY(${offsetY}px)` } : {}"
|
||
>
|
||
<!-- 列表项 -->
|
||
<div
|
||
v-for="item in visibleData"
|
||
:key="item.index"
|
||
class="list-item"
|
||
:class="{ 'selected': selectedIndex === item.index }"
|
||
:style="{ height: `${ITEM_HEIGHT}px` }"
|
||
@mouseenter="handleMouseEnter(item.index)"
|
||
@click="handleClick(item)"
|
||
@contextmenu="handleContextMenu($event, item)"
|
||
>
|
||
<slot name="item" :item="item">
|
||
<!-- 默认列表项样式 -->
|
||
<div class="default-item">
|
||
<!-- 项目图标 -->
|
||
<div class="item-icon">
|
||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||
<circle cx="12" cy="12" r="10" fill="#1a1a1a" />
|
||
</svg>
|
||
</div>
|
||
<!-- 项目内容 -->
|
||
<div class="item-content">
|
||
<!-- 标题行:名称和标签 -->
|
||
<div class="item-header">
|
||
<div class="item-name">{{ item.name }}</div>
|
||
<!-- 标签列表 -->
|
||
<div class="item-tags" v-if="item.tags?.length">
|
||
<span
|
||
v-for="tag in item.tags"
|
||
:key="tag.id"
|
||
class="item-tag"
|
||
:style="{ backgroundColor: `${tag.color}15`, color: tag.color }"
|
||
>
|
||
{{ tag.name }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- 路径信息 -->
|
||
<div class="item-info">
|
||
<div class="item-path">{{ item.path }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</slot>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 虚拟列表容器:可滚动区域 */
|
||
.virtual-list-container {
|
||
overflow-y: auto;
|
||
border: 1px solid #ccc;
|
||
position: relative;
|
||
background-color: #f4f4f4;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 虚拟高度容器:用于保持滚动条比例 */
|
||
.virtual-list-phantom {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 实际列表内容容器:通过 transform 实现位置偏移 */
|
||
.virtual-list {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 列表项基础样式 */
|
||
.list-item {
|
||
padding: 8px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||
cursor: pointer;
|
||
background-color: white;
|
||
}
|
||
|
||
/* 选中状态样式 */
|
||
.list-item.selected {
|
||
background-color: rgba(64, 169, 255, 0.15);
|
||
}
|
||
|
||
/* 列表项布局容器 */
|
||
.default-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 图标容器 */
|
||
.item-icon {
|
||
flex-shrink: 0;
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 内容区域布局 */
|
||
.item-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* 标题行布局 */
|
||
.item-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* 项目名称样式 */
|
||
.item-name {
|
||
font-size: 14px;
|
||
color: #1a1a1a;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex-shrink: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* 信息行布局 */
|
||
.item-info {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* 路径文本样式 */
|
||
.item-path {
|
||
font-size: 12px;
|
||
color: #666;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex: 1;
|
||
}
|
||
|
||
/* 标签容器样式 */
|
||
.item-tags {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: nowrap;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 单个标签样式 */
|
||
.item-tag {
|
||
font-size: 11px;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 列表冻结状态样式 */
|
||
.list-frozen {
|
||
pointer-events: none; /* 禁用鼠标事件 */
|
||
opacity: 0.7; /* 降低透明度 */
|
||
user-select: none; /* 禁用文本选择 */
|
||
}
|
||
</style> |