Compare commits

...

10 Commits

13 changed files with 1065 additions and 488 deletions

View File

@@ -16,6 +16,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -13,6 +13,8 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@types/node": "^22.10.1",
"typescript": "^5.0.2",
"vite": "^6.0.1"
}
}

50
pnpm-lock.yaml generated
View File

@@ -10,14 +10,20 @@ importers:
dependencies:
vue:
specifier: ^3.5.13
version: 3.5.13
version: 3.5.13(typescript@5.7.2)
devDependencies:
'@types/node':
specifier: ^22.10.1
version: 22.10.1
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@6.0.3)(vue@3.5.13)
version: 5.2.1(vite@6.0.3(@types/node@22.10.1))(vue@3.5.13(typescript@5.7.2))
typescript:
specifier: ^5.0.2
version: 5.7.2
vite:
specifier: ^6.0.1
version: 6.0.3
version: 6.0.3(@types/node@22.10.1)
packages:
@@ -283,6 +289,9 @@ packages:
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz}
'@types/node@22.10.1':
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==, tarball: https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz}
'@vitejs/plugin-vue@5.2.1':
resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==, tarball: https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -363,6 +372,14 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz}
engines: {node: '>=0.10.0'}
typescript@5.7.2:
resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz}
vite@6.0.3:
resolution: {integrity: sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw==, tarball: https://registry.npmjs.org/vite/-/vite-6.0.3.tgz}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -559,10 +576,14 @@ snapshots:
'@types/estree@1.0.6': {}
'@vitejs/plugin-vue@5.2.1(vite@6.0.3)(vue@3.5.13)':
'@types/node@22.10.1':
dependencies:
vite: 6.0.3
vue: 3.5.13
undici-types: 6.20.0
'@vitejs/plugin-vue@5.2.1(vite@6.0.3(@types/node@22.10.1))(vue@3.5.13(typescript@5.7.2))':
dependencies:
vite: 6.0.3(@types/node@22.10.1)
vue: 3.5.13(typescript@5.7.2)
'@vue/compiler-core@3.5.13':
dependencies:
@@ -610,11 +631,11 @@ snapshots:
'@vue/shared': 3.5.13
csstype: 3.1.3
'@vue/server-renderer@3.5.13(vue@3.5.13)':
'@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.7.2))':
dependencies:
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
vue: 3.5.13
vue: 3.5.13(typescript@5.7.2)
'@vue/shared@3.5.13': {}
@@ -695,18 +716,25 @@ snapshots:
source-map-js@1.2.1: {}
vite@6.0.3:
typescript@5.7.2: {}
undici-types@6.20.0: {}
vite@6.0.3(@types/node@22.10.1):
dependencies:
esbuild: 0.24.0
postcss: 8.4.49
rollup: 4.28.1
optionalDependencies:
'@types/node': 22.10.1
fsevents: 2.3.3
vue@3.5.13:
vue@3.5.13(typescript@5.7.2):
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/compiler-sfc': 3.5.13
'@vue/runtime-dom': 3.5.13
'@vue/server-renderer': 3.5.13(vue@3.5.13)
'@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.7.2))
'@vue/shared': 3.5.13
optionalDependencies:
typescript: 5.7.2

View File

@@ -1,217 +1,61 @@
<script setup>
import VirtualList from './components/VirtualList.vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
// 虚拟列表配置
const listConfig = {
itemHeight: 50,
itemPadding: 16,
bufferCount: 10,
scrollDebounceTime: 16,
}
<script setup lang="ts">
import ProjectList from './components/ProjectList.vue'
import type { ListItem, MenuItem } from '@/types'
const samplePic =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAMAAAAOusbgAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JQAAgIMAAPn/AACA6QAAdTAAAOpgAAA6mAAAF2+SX8VGAAACf1BMVEVGQksvbmYyaWM0ZGA3X107WFk9U1VATlNDSU8ucWgyamQ2Yl88VlhDSE8wbWY1ZGAvb2c1ZGA+U1U6WloucWgzZ2I/UFQ0ZmFATVIyaWM/T1MyaWMwbWY7V1gucWg1Yl8wbWUucWg0ZWAvb2cwbWYxa2QzaGI0ZWFBTFE5XFszZmI0ZmE9VFY7WFkxa2RERk5BTVIxamQ2YV4xbGUvcGdATlI8Vlgvb2c6Wlo5XFswbWYucWgzZ2IucWgxa2Q9U1U7WFg5W1pCSlAucWgzZ2IyaWMucWg1Y19DSE43X103X104XlwvcGc8VlcxbGUvb2c3X10wbWU6WVk4XlxATlMyaWMucWg2YV4vbmYxa2RDR041Y18ucWgyaGM2YV4vb2c1Y185W1ovb2cwbGU8VVdCSU82Yl8+UVQxa2Q4XVs0ZWE1Yl8xbGU4XVwyaWM6WlowbWY2Yl8wbGUwbWY4XVs/T1M0ZmEvb2c3X109U1YwbWYwbmYxbGU4Xlw2YV4+UVU5XFs0ZmEvbmY5W1o+UlUwbmY6Wlo/UFQ1Y181Y180ZWEyamQzZ2JBS1Evb2c8Vlc5XFsvb2cyaWM2Yl8vcGcvb2dDSU80ZWE7V1g1Yl9BTFExamRER049VFY6WVk6WlpBS1AwbWU0ZWEyaWM7WFkvb2c0ZmE5W1ozaGIxbGU4XVs1ZGAyamQzaGIxa2RCSVAzaGI7V1g6WVk2YV4zZ2I9VFYxbGU0ZmEucWgwbmYzZmIzaGIxa2QwbWY4Xlw3YF03YF5CSlAxamQvb2cyaWM0ZWA2YV43X11AT1M3X107V1gwbGVFRU1GQktFRExERU1GQ0tFQ0xERk5ERk3///+ARxMXAAAAzHRSTlMAMXyqzeXy+f4UdL7s/jivKrDz4wiX9pz6ffh/RusKuU0CqBdFao+n+9qbofHnaP77cMNfIfrrH+DbQwSSBmTz6Nz9FpN6ErP+y8rQI+1aMM9P5dL6gQ7CNWf+sgyHxBu03SxU7v279mXYprZc1YPhQbxZStf5mR3M8kg8V9HA9dygM9/0PuL3uLWidpb8KOzZJoa9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'
// 生成模拟数据
const listData = Array.from({ length: 100 }, (_, i) => ({
const listData: ListItem[] = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `project-${i + 1}`,
path: `/Users/lanyuanxiaoyao/Project/IdeaProjects/project-${i + 1}`,
// 随机选择一个图标
icon: samplePic,
tags: [
...(i % 2 === 0 ? [{ id: 1, name: 'Vue', color: '#42b883' }] : []),
...(i % 3 === 0 ? [{ id: 2, name: 'TypeScript', color: '#3178c6' }] : []),
...(i % 4 === 0 ? [{ id: 3, name: 'React', color: '#149eca' }] : []),
...(i % 5 === 0 ? [{ id: 4, name: 'Node.js', color: '#539e43' }] : []),
],
}))
// 添加当前选中项的状态
const selectedItem = ref(null)
// 修改选择处理函数
const handleSelect = (item) => {
selectedItem.value = item
}
// 添加点击处理函数
const handleClick = (item) => {
console.log('Clicked:', item)
// 这里可以添加点击特定处理逻辑
}
// 菜单配置
const menuItems = [
{ id: 'refresh', label: '刷新列表' },
{ id: 'clear', label: '清空选择' },
{ id: 'export', label: '导出数据' },
// 自定义菜单项
const menuItems: MenuItem[] = [
{
id: 'refresh',
label: '刷新列表',
action: (item) => {
console.log('当前选中项:', item)
},
},
{
id: 'clear',
label: '清空选择',
action: (item) => {
console.log('清空前选中项:', item)
},
},
{
id: 'export',
label: '导出数据',
action: (item) => {
console.log('当前选中项:', item)
},
},
]
// 控制菜单显示状态
const showMenu = ref(false)
// 添加列表冻结状态
const listFrozen = ref(false)
// 修改菜单显示状态的处理
const showContextMenu = (data) => {
const { item } = data
// 选中该项
handleSelect(item)
// 显示菜单
showMenu.value = true
listFrozen.value = true // 冻结列表
}
// 修改关闭菜单的处理
const closeMenu = () => {
showMenu.value = false
listFrozen.value = false
selectedMenuIndex.value = -1 // 重置菜单选中项
}
// 添加当前选中的菜单项索引
const selectedMenuIndex = ref(-1)
// 修改键盘事件处理函数
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight' && !showMenu.value && selectedItem.value) {
e.preventDefault()
handleMenuTriggerClick(e)
} else if (e.key === 'ArrowLeft' && showMenu.value) {
e.preventDefault()
showMenu.value = false
listFrozen.value = false
selectedMenuIndex.value = -1 // 重置菜单选中项
} else if (e.key === 'Escape') {
// ESC 键只在菜单打开时响应
if (showMenu.value) {
e.preventDefault()
closeMenu()
}
} else if (showMenu.value) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedMenuIndex.value =
selectedMenuIndex.value <= 0
? menuItems.length - 1
: selectedMenuIndex.value - 1
break
case 'ArrowDown':
e.preventDefault()
selectedMenuIndex.value =
selectedMenuIndex.value >= menuItems.length - 1
? 0
: selectedMenuIndex.value + 1
break
case 'Enter':
e.preventDefault()
if (selectedMenuIndex.value >= 0) {
handleMenuClick(menuItems[selectedMenuIndex.value])
selectedMenuIndex.value = -1 // 重置菜单选中项
}
break
}
}
}
onMounted(() => {
// 添加全局点击事件来关闭菜单
document.addEventListener('click', closeMenu)
// 添加键盘事件监听
document.addEventListener('keydown', handleKeyDown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu)
// 移除键盘事件监听
document.removeEventListener('keydown', handleKeyDown)
})
// 修改菜单击处理
const handleMenuClick = (item) => {
console.log('Menu clicked:', item)
showMenu.value = false
listFrozen.value = false // 解冻列表
}
// 修改菜单触发器点击处理
const handleMenuTriggerClick = (e) => {
e.stopPropagation()
// 切换菜单显示状态
if (showMenu.value) {
showMenu.value = false
listFrozen.value = false // 解冻列表
} else {
// 如果有选中项,选中第一个可见项
if (listData.length > 0) {
// 如果已有选中项就保持当前选中,否则选中第一项
const itemToSelect = selectedItem.value || listData[0]
showContextMenu({ item: itemToSelect })
} else {
showMenu.value = true
listFrozen.value = true // 冻结列表
}
}
}
// 添加过渡钩子函数
const onBeforeLeave = (el) => {
// 确保元素在离开过渡开始时可见
el.style.display = 'block'
}
const onAfterLeave = (el) => {
// 过渡结束后重置样式
el.style.display = ''
// 列表项点击事件处理
const handleItemClick = (item: ListItem): void => {
console.log('点击列表项:', item)
}
</script>
<template>
<div class="app-container" @contextmenu.prevent>
<div class="main-content">
<VirtualList
:data="listData"
:config="listConfig"
:frozen="listFrozen"
@select="handleSelect"
@click="handleClick"
@contextmenu="showContextMenu"
/>
</div>
<div class="toolbar">
<div class="toolbar-content">
<div class="total-count"> {{ listData.length }} </div>
<div class="toolbar-spacer"></div>
<div
class="menu-trigger"
:class="{ active: showMenu }"
@click="handleMenuTriggerClick"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="6" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="18" r="2" fill="currentColor" />
</svg>
<!-- 使用 transition 组件包裹菜单 -->
<transition
name="menu"
@before-leave="onBeforeLeave"
@after-leave="onAfterLeave"
>
<div v-if="showMenu" class="popup-menu" @click.stop>
<div v-if="selectedItem" class="selected-item-info">
<div class="selected-item-name">{{ selectedItem.name }}</div>
<div class="selected-item-path">{{ selectedItem.path }}</div>
</div>
<div class="menu-divider"></div>
<div
v-for="(item, index) in menuItems"
:key="item.id"
class="menu-item"
:class="{ 'menu-item-selected': index === selectedMenuIndex }"
@click="handleMenuClick(item)"
>
{{ item.label }}
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
<ProjectList
:data="listData"
:menu-items="menuItems"
@click="handleItemClick"
/>
</template>
<style>
@@ -226,143 +70,3 @@ body {
height: 100%;
}
</style>
<style scoped>
.app-container {
height: 100%;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
min-height: 0; /* 重要:防止内容溢出 */
}
.toolbar {
height: 40px;
border-top: 1px solid #e8e8e8;
background-color: #fff;
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.05);
position: relative;
z-index: 1;
}
.toolbar-content {
height: 100%;
padding: 0 16px;
display: flex;
align-items: center;
}
.total-count {
font-size: 13px;
color: #666;
}
.toolbar-spacer {
flex: 1;
}
.menu-trigger {
position: relative;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #666;
border-radius: 6px;
transition: all 0.2s ease;
}
.menu-trigger:hover {
background-color: #f5f5f5;
color: #1a1a1a;
}
.menu-trigger.active {
background-color: #f5f5f5;
color: #1a1a1a;
}
.popup-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 23px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
min-width: 260px;
padding: 8px 0;
z-index: 1000;
border: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
transform-origin: top right;
}
/* 添加 transition 相关样式 */
.menu-enter-active,
.menu-leave-active {
transition: all 0.2s ease-out;
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: scale(0.95);
}
.menu-enter-to,
.menu-leave-from {
opacity: 1;
transform: scale(1);
}
.menu-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.06);
margin: 6px 0;
}
.menu-item {
padding: 10px 16px;
margin: 0 4px;
font-size: 13px;
color: #333;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 8px;
display: flex;
align-items: center;
font-weight: 500;
}
.menu-item:hover, .menu-item-selected {
background-color: rgba(79, 70, 229, 0.06);
color: #4f46e5;
}
.selected-item-info {
padding: 12px 16px;
margin: 0 4px 4px 4px;
border-radius: 8px;
}
.selected-item-name {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 8px;
}
.selected-item-path {
font-size: 12px;
color: #666;
word-break: break-all;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,557 @@
<script setup lang="ts">
import VirtualList from './VirtualList.vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import type { ListItem, MenuItem } from '@/types'
/**
* 组件属性接口
*/
interface Props {
/** 列表数据 */
data: ListItem[]
/** 菜单项配置 */
menuItems: MenuItem[]
}
const props = withDefaults(defineProps<Props>(), {})
// ===== 工具栏配置常量 =====
/** 是否显示工具栏 */
const SHOW_TOOLBAR = true
/** 工具栏高度(像素) */
const TOOLBAR_HEIGHT = 40
/**
* 组件事件定义
*/
const emit = defineEmits<{
/** 选中项变化事件 */
select: [item: ListItem]
/** 点击项目事件 */
click: [item: ListItem]
/** 右键菜单事件 */
'context-menu': [data: { item: ListItem }]
}>()
// ===== 状态管理 =====
/** 当前选中的列表项 */
const selectedItem = ref<ListItem | null>(null)
/** 菜单显示状态 */
const showMenu = ref<boolean>(false)
/** 列表冻结状态 */
const listFrozen = ref<boolean>(false)
/** 临时禁用鼠标悬停状态 */
const temporaryDisableHover = ref<boolean>(false)
/** 当前选中的菜单项索引 */
const selectedMenuIndex = ref<number>(-1)
/** Toast 显示状态 */
const toastVisible = ref<boolean>(false)
/** Toast 消息内容 */
const toastMessage = ref<string>('')
// ===== 定时器 =====
/** 鼠标悬停禁用定时器 */
let hoverDisableTimer: number | null = null
/** Toast 显示定时器 */
let toastTimer: number | null = null
// ===== 事件处理函数 =====
/**
* 处理列表项选中
* @param item 选中的列表项
*/
const handleSelect = (item: ListItem): void => {
selectedItem.value = item
emit('select', item)
}
/**
* 处理列表项点击
* @param item 点击的列表项
*/
const handleClick = (item: ListItem): void => {
emit('click', item)
}
/**
* 显示右键菜单
* @param data 包含目标列表项的数据对象
*/
const showContextMenu = (data: { item: ListItem }): void => {
const { item } = data
handleSelect(item)
showMenu.value = true
listFrozen.value = true
emit('context-menu', data)
}
/**
* 关闭菜单并处理相关状态
*/
const closeMenu = (): void => {
showMenu.value = false
listFrozen.value = false
selectedMenuIndex.value = -1
// 临时禁用鼠标悬停,防止菜单关闭时立即触发悬停效果
temporaryDisableHover.value = true
if (hoverDisableTimer) clearTimeout(hoverDisableTimer)
hoverDisableTimer = window.setTimeout(() => {
temporaryDisableHover.value = false
}, 300)
}
/**
* 处理菜单项点击
* @param item 被点击的菜单项
*/
const handleMenuClick = (item: MenuItem): void => {
item.action(selectedItem.value)
closeMenu()
}
/**
* 处理菜单触发器点击
* 控制菜单的显示/隐藏状态
* @param e 鼠标事件对象
*/
const handleMenuTriggerClick = (e: MouseEvent): void => {
e.stopPropagation()
if (showMenu.value) {
closeMenu()
} else {
if (props.data.length > 0) {
// 如果有数据,选择当前选中项或第一项
const itemToSelect = selectedItem.value || props.data[0]
showContextMenu({ item: itemToSelect })
} else {
// 如果没有数据,仅显示菜单
showMenu.value = true
listFrozen.value = true
}
}
}
/**
* 处理键盘事件
* - 右方向键:打开菜单
* - 左方向键/ESC关闭菜单
* - 上下方向键:在菜单打开时导航菜单项
* - 回车键:在菜单打开时选择当前菜单项
* @param e 键盘事件对象
*/
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'ArrowRight' && !showMenu.value && selectedItem.value) {
e.preventDefault()
handleMenuTriggerClick(new MouseEvent('click'))
} else if ((e.key === 'ArrowLeft' || e.key === 'Escape') && showMenu.value) {
e.preventDefault()
closeMenu()
} else if (showMenu.value) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
// 向上选择菜单项,到顶部时循环到底部
selectedMenuIndex.value =
selectedMenuIndex.value <= 0
? props.menuItems.length - 1
: selectedMenuIndex.value - 1
break
case 'ArrowDown':
e.preventDefault()
// 向下选择菜单项,到底部时循环到顶部
selectedMenuIndex.value =
selectedMenuIndex.value >= props.menuItems.length - 1
? 0
: selectedMenuIndex.value + 1
break
case 'Enter':
e.preventDefault()
if (selectedMenuIndex.value >= 0) {
handleMenuClick(props.menuItems[selectedMenuIndex.value])
}
break
}
}
}
/**
* 显示 Toast 消息
* @param message 要显示的消息内容
*/
const showToast = (message: string): void => {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = window.setTimeout(() => {
toastVisible.value = false
}, 3000)
}
// ===== 生命周期钩子 =====
/**
* 组件挂载时添加全局事件监听
*/
onMounted(() => {
document.addEventListener('click', closeMenu)
document.addEventListener('keydown', handleKeyDown)
})
/**
* 组件卸载前清理事件监听和定时器
*/
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu)
document.removeEventListener('keydown', handleKeyDown)
if (toastTimer) clearTimeout(toastTimer)
if (hoverDisableTimer) clearTimeout(hoverDisableTimer)
})
// ===== 过渡钩子 =====
/**
* 过渡开始前的处理
* 确保元素在离开过渡开始时可见
* @param el 过渡元素
*/
const onBeforeLeave = (el: Element): void => {
(el as HTMLElement).style.display = 'block'
}
/**
* 过渡结束后的处理
* 重置元素的显示状态
* @param el 过渡元素
*/
const onAfterLeave = (el: Element): void => {
(el as HTMLElement).style.display = ''
}
</script>
<template>
<!-- 主容器阻止默认右键菜单 -->
<div class="project-list-container" @contextmenu.prevent>
<!-- 主要内容区域 -->
<div class="main-content">
<VirtualList
:data="data"
:frozen="listFrozen"
:disable-hover="showMenu || temporaryDisableHover"
@select="handleSelect"
@click="handleClick"
@contextmenu="showContextMenu"
@showToast="showToast"
/>
</div>
<!-- 工具栏 -->
<div v-if="SHOW_TOOLBAR" class="toolbar" :style="{ height: `${TOOLBAR_HEIGHT}px` }">
<div class="toolbar-content">
<div class="total-count"> {{ data.length }} </div>
<div class="toolbar-spacer"></div>
<div
class="menu-trigger"
:class="{ active: showMenu }"
@click="handleMenuTriggerClick"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="6" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="18" r="2" fill="currentColor" />
</svg>
<transition
name="menu"
@before-leave="onBeforeLeave"
@after-leave="onAfterLeave"
>
<div v-if="showMenu" class="popup-menu" @click.stop>
<div v-if="selectedItem" class="selected-item-info">
<div class="selected-item-header">
<div class="selected-item-icon">
<img
v-if="selectedItem.icon"
:src="selectedItem.icon"
:alt="selectedItem.name"
width="24"
height="24"
>
<svg v-else viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="rgba(0, 0, 0, 0.38)" />
</svg>
</div>
<div class="selected-item-name">{{ selectedItem.name }}</div>
</div>
<div class="selected-item-content">
<div class="selected-item-path">{{ selectedItem.path }}</div>
<div class="selected-item-tags" v-if="selectedItem.tags?.length">
<span
v-for="tag in selectedItem.tags"
:key="tag.id"
class="selected-item-tag"
:style="{ backgroundColor: tag.color }"
>
{{ tag.name }}
</span>
</div>
</div>
</div>
<div class="menu-divider"></div>
<div
v-for="(item, index) in menuItems"
:key="item.id"
class="menu-item"
:class="{ 'menu-item-selected': index === selectedMenuIndex }"
@click="handleMenuClick(item)"
>
{{ item.label }}
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- Toast 消息 -->
<transition name="toast">
<div v-if="toastVisible" class="toast">
{{ toastMessage }}
</div>
</transition>
</div>
</template>
<style scoped>
/* 主容器:使用 flex 布局实现垂直方向的布局结构 */
.project-list-container {
height: 100%;
display: flex;
flex-direction: column;
}
/* 主内容区域:占用剩余空间并防止内容溢出 */
.main-content {
flex: 1;
min-height: 0;
}
/* 工具栏:固定高度,顶部边框和阴影效果 */
.toolbar {
height: 40px;
background-color: #fff;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 1;
}
/* 工具栏内容:水平布局,两端对齐 */
.toolbar-content {
height: 100%;
padding: 0 16px;
display: flex;
align-items: center;
}
/* 总数显示:次要文本样式 */
.total-count {
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
/* 弹性间隔:用于推开左右两侧的内容 */
.toolbar-spacer {
flex: 1;
}
/* 菜单触发器:圆形按钮样式 */
.menu-trigger {
position: relative;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: rgba(0, 0, 0, 0.54);
border-radius: 6px;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 菜单触发器悬停和激活状态 */
.menu-trigger:hover,
.menu-trigger.active {
background-color: rgba(0, 0, 0, 0.04);
color: rgba(0, 0, 0, 0.87);
}
/* 弹出菜单:浮动面板样式 */
.popup-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 23px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
min-width: 320px;
padding: 8px 0;
z-index: 1000;
transform-origin: top right;
}
/* 菜单动画相关样式 */
.menu-enter-active,
.menu-leave-active {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: scale(0.95);
}
.menu-enter-to,
.menu-leave-from {
opacity: 1;
transform: scale(1);
}
/* 菜单分割线 */
.menu-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.12);
margin: 6px 0;
}
/* 菜单项:交互式列表项样式 */
.menu-item {
padding: 10px 16px;
margin: 0 4px;
font-size: 13px;
color: rgba(0, 0, 0, 0.87);
cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
display: flex;
align-items: center;
font-weight: 400;
}
/* 菜单项悬停和选中状态 */
.menu-item:hover,
.menu-item-selected {
background-color: rgba(0, 0, 0, 0.04);
color: rgba(0, 0, 0, 0.87);
}
/* 选中项信息区域 */
.selected-item-info {
padding: 12px 16px;
margin: 0 4px 4px 4px;
border-radius: 8px;
}
/* 选中项头部区域 */
.selected-item-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
/* 选中项图标容器 */
.selected-item-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.54);
overflow: hidden;
}
/* 选中项图标图片 */
.selected-item-icon img {
width: 24px;
height: 24px;
object-fit: contain;
}
/* 选中项名称 */
.selected-item-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 选中项内容区域 */
.selected-item-content {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 选中项路径 */
.selected-item-path {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
word-break: break-all;
line-height: 1.5;
}
/* 选中项标签容器 */
.selected-item-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* 选中项标签样式 */
.selected-item-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 12px;
font-weight: 400;
white-space: nowrap;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #fff !important;
opacity: 0.9;
}
/* Toast 消息容器:居中显示的浮动提示 */
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #323232;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
pointer-events: none;
max-width: 80%;
text-align: center;
}
/* Toast 动画相关样式 */
.toast-enter-active,
.toast-leave-active {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translate(-50%, calc(-50% - 20px));
}
</style>

View File

@@ -1,69 +1,83 @@
<script setup>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { ListItem } from '@/types'
const props = defineProps({
// 列表数据
data: {
type: Array,
required: true
},
// 配置参数
config: {
type: Object,
default: () => ({
itemHeight: 50, // 列表项基础高度
itemPadding: 16, // 列表项上下padding总和
bufferCount: 2, // 上下缓冲区域的项目数量
scrollDebounceTime: 16 // 滚动防抖时间
})
},
// 容器高度
height: {
type: [String, Number],
default: '100%'
},
frozen: {
type: Boolean,
default: false
}
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', 'click', 'contextmenu'])
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 = 100
/** 滚动防抖时间(毫秒) */
const SCROLL_DEBOUNCE_TIME = 16
/** 虚拟滚动阈值,少于此数量则直接渲染全部 */
const VIRTUALIZATION_THRESHOLD = 500
// 计算列表项实际总高度
const totalItemHeight = computed(() =>
props.config.itemHeight + props.config.itemPadding
ITEM_HEIGHT + ITEM_PADDING
)
/**
* 响应式状态管理
*/
const containerHeight = ref(0) // 容器高度
const startIndex = ref(0) // 可视区域起始索引
const selectedIndex = ref(0) // 当前选中项索引
const isKeyboardNavigating = ref(false) // 键盘导航状态
let keyboardTimer = null // 键盘导航定时器
// 响应式状态管理
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(null)
const listRef = ref(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(() => {
const visibleStart = Math.max(0, startIndex.value - props.config.bufferCount)
// 数据量小于阈值时,直接返回全部数据
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 + props.config.bufferCount
startIndex.value + visibleCount.value + BUFFER_COUNT
)
return props.data.slice(visibleStart, visibleEnd).map((item, index) => ({
@@ -74,68 +88,94 @@ const visibleData = computed(() => {
// 计算列表偏移量
const offsetY = computed(() =>
Math.max(0, (startIndex.value - props.config.bufferCount) * totalItemHeight.value)
needVirtualization.value
? Math.max(0, (startIndex.value - BUFFER_COUNT) * totalItemHeight.value)
: 0
)
// 计算虚拟列表总高度
const phantomHeight = computed(() =>
props.data.length * totalItemHeight.value
needVirtualization.value
? props.data.length * totalItemHeight.value
: 'auto'
)
// ... 其他方法保持不变,只需将 CONFIG 替换为 props.config ...
// 选择处理
const handleSelect = (item) => {
if (props.frozen) return // 如果列表被冻结,不响应选择
selectedIndex.value = item.index
const handleSelect = (item: ListItem): void => {
if (props.frozen) return
selectedIndex.value = item.index ?? 0
emit('select', item)
}
// 点击处理
const handleClick = (item) => {
if (props.frozen) return // 如果列表被冻结,不响应点击
handleSelect(item) // 点击时同时触发选中
/**
* 处理列表项点击
* @param item 点击的列表项
*/
const handleClick = (item: ListItem): void => {
if (props.frozen) return
handleSelect(item)
emit('click', item)
}
// 修改更新容器高度的方法
function updateContainerHeight() {
/**
* 更新容器高度
* 从父元素或窗口获取实际高度
*/
function updateContainerHeight(): void {
if (containerRef.value) {
const parentHeight = containerRef.value.parentElement?.clientHeight
containerHeight.value = parentHeight || window.innerHeight
}
}
// 添加 ResizeObserver 监听容器尺寸变化
let resizeObserver = null
/** ResizeObserver 实例,用于监听容器尺寸变化 */
let resizeObserver: ResizeObserver | null = null
// 添加防抖函数
function debounce(fn, delay) {
let timer = null
return function (...args) {
/**
* 防抖函数
* 在指定延迟后执行函数,如果在延迟期间再次调用则重置延迟
* @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 = setTimeout(() => {
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
}
}, props.config.scrollDebounceTime)
}, SCROLL_DEBOUNCE_TIME)
// 鼠标移入处理
function handleMouseEnter(index) {
if (props.frozen) return // 如果列表被冻结,不响应鼠标移入
/** 鼠标悬停禁用状态 */
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 })
@@ -143,11 +183,24 @@ function handleMouseEnter(index) {
}
}
// 键盘事件处理
function handleKeyDown(e) {
if (props.frozen) return // 如果列表被冻结,不响应键盘事件
/**
* 记录到达列表边界的时间
* 用于实现边界循环导航的延迟判断
*/
const lastReachBoundaryTime = ref<{ top: number; bottom: number }>({
top: 0,
bottom: 0
})
/**
* 处理<E5A484><E79086>盘事件
* 实现键盘导航、边界循环和项目选择功能
* @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 })
@@ -157,26 +210,54 @@ function handleKeyDown(e) {
const isFirstItem = selectedIndex.value === 0
const isLastItem = selectedIndex.value === props.data.length - 1
const now = Date.now()
// 如果已经在第一项还按上键,或在最后一项还按下键,则不处理
if ((e.key === 'ArrowUp' && isFirstItem) ||
(e.key === 'ArrowDown' && isLastItem)) {
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)
const containerTop = containerRef.value?.scrollTop || 0
const containerHeight = containerRef.value?.clientHeight || 0
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 < containerTop && containerRef.value) {
if (itemTop < (containerRef.value?.scrollTop ?? 0) && containerRef.value) {
containerRef.value.scrollTop = itemTop
}
} else {
@@ -185,51 +266,57 @@ function handleKeyDown(e) {
emit('select', { ...props.data[nextIndex], index: nextIndex })
const itemBottom = (nextIndex + 1) * totalItemHeight.value
const scrollBottom = containerTop + containerHeight
const scrollBottom = (containerRef.value?.scrollTop ?? 0) + containerHeight.value
if (itemBottom > scrollBottom && containerRef.value) {
containerRef.value.scrollTop = itemBottom - containerHeight
containerRef.value.scrollTop = itemBottom - containerHeight.value
}
}
keyboardTimer = setTimeout(() => {
keyboardTimer = window.setTimeout(() => {
isKeyboardNavigating.value = false
}, 1000)
}
}
// 确保选中项可见
function ensureSelectedItemVisible() {
/**
* 保持选中项在可视区域内
* 如果选中项不可见,则滚动到合适位置
*/
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
}
}
// 修改右键点击处理函数
const handleContextMenu = (e, item) => {
if (props.frozen) return // 如果列表被冻结,不响应右键点击
e.preventDefault() // 阻止默认右键菜单
emit('contextmenu', { item }) // 不需要传递事件对象
/**
* 处理右键点击事件
* @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
resizeObserver = new ResizeObserver(() => {
updateContainerHeight()
})
@@ -239,31 +326,32 @@ onMounted(() => {
}
}
// 初始化容器高度
updateContainerHeight()
// 添加全局事件监听
window.addEventListener('resize', updateContainerHeight)
window.addEventListener('keydown', handleKeyDown)
})
onBeforeUnmount(() => {
// 清理所有事件监听和定时器
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll)
}
// 清理 ResizeObserver
if (resizeObserver) {
resizeObserver.disconnect()
}
window.removeEventListener('resize', updateContainerHeight)
window.removeEventListener('keydown', handleKeyDown)
if (keyboardTimer) {
clearTimeout(keyboardTimer)
}
if (keyboardTimer) clearTimeout(keyboardTimer)
if (hoverTimer) clearTimeout(hoverTimer)
})
</script>
<template>
<!-- 虚拟列表容器 -->
<div
ref="containerRef"
class="virtual-list-container"
@@ -271,21 +359,24 @@ onBeforeUnmount(() => {
:style="{ height: '100%' }"
tabindex="0"
>
<!-- 虚拟高度占位元素 -->
<div
ref="listRef"
class="virtual-list-phantom"
:style="{ height: `${phantomHeight}px` }"
:style="{ height: needVirtualization ? `${phantomHeight}px` : 'auto' }"
>
<!-- 实际渲染的列表内容 -->
<div
class="virtual-list"
:style="{ transform: `translateY(${offsetY}px)` }"
: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: `${config.itemHeight}px` }"
:style="{ height: `${ITEM_HEIGHT}px` }"
@mouseenter="handleMouseEnter(item.index)"
@click="handleClick(item)"
@contextmenu="handleContextMenu($event, item)"
@@ -293,14 +384,40 @@ onBeforeUnmount(() => {
<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" />
<img
v-if="item.icon"
:src="item.icon"
:alt="item.name"
width="32"
height="32"
>
<svg v-else viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="rgba(0, 0, 0, 0.38)" />
</svg>
</div>
<!-- 项目内容 -->
<div class="item-content">
<div class="item-name">{{ item.name }}</div>
<div class="item-path">{{ item.path }}</div>
<!-- 标题行名称和标签 -->
<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 }"
>
{{ tag.name }}
</span>
</div>
</div>
<!-- 路径信息 -->
<div class="item-info">
<div class="item-path">{{ item.path }}</div>
</div>
</div>
</div>
</slot>
@@ -311,20 +428,23 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* 虚拟列表容器:可滚动区域 */
.virtual-list-container {
overflow-y: auto;
border: 1px solid #ccc;
position: relative;
background-color: #f4f4f4;
background-color: #fafafa;
width: 100%;
height: 100%;
}
/* 拟高度容器:用于保持滚动条比例 */
.virtual-list-phantom {
position: relative;
width: 100%;
}
/* 实际列表内容容器:通过 transform 实现位置偏移 */
.virtual-list {
position: absolute;
top: 0;
@@ -332,21 +452,24 @@ onBeforeUnmount(() => {
width: 100%;
}
/* 列表项基础样式 */
.list-item {
padding: 8px 16px;
padding: 8px 12px 8px 8px;
display: flex;
align-items: center;
transition: background-color 0.2s ease, opacity 0.2s ease;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
background-color: white;
border-radius: 4px;
margin: 0 4px;
}
/* 简单的选中高亮效果 */
/* 选中状态样式 */
.list-item.selected {
background-color: rgba(64, 169, 255, 0.15);
background-color: rgba(0, 0, 0, 0.04);
}
/* 默认列表项样式 */
/* 列表项布局容器 */
.default-item {
display: flex;
align-items: center;
@@ -354,15 +477,26 @@ onBeforeUnmount(() => {
width: 100%;
}
/* 图标容器 */
.item-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.54);
overflow: hidden;
}
/* 图标图片样式 */
.item-icon img {
width: 32px;
height: 32px;
object-fit: contain;
}
/* 内容区域布局 */
.item-content {
flex: 1;
min-width: 0;
@@ -371,24 +505,70 @@ onBeforeUnmount(() => {
gap: 4px;
}
.item-name {
font-size: 14px;
color: #1a1a1a;
font-weight: 500;
/* 标题行布局 */
.item-header {
display: flex;
align-items: center;
min-width: 0;
}
.item-path {
font-size: 12px;
color: #666;
/* 项目名称样式 */
.item-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.87);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
margin-right: auto;
}
/* 添加冻结状态的样式 */
/* 信息行布局 */
.item-info {
display: flex;
gap: 4px;
}
/* 路径文本样式 */
.item-path {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* 标签容器样式 */
.item-tags {
display: flex;
gap: 6px;
flex-wrap: nowrap;
flex-shrink: 0;
overflow: hidden;
margin-left: 12px;
}
/* 单个标签样式 */
.item-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 12px;
font-weight: 400;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #fff !important;
opacity: 0.9;
}
/* 列表冻结状态样式 */
.list-frozen {
pointer-events: none; /* 禁用鼠标事件 */
opacity: 0.7; /* 降低透明度表示冻结状态 */
user-select: none; /* 禁用文本选择 */
pointer-events: none;
opacity: 0.38;
user-select: none;
}
</style>

7
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -1,4 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

6
src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import type { App as AppType } from 'vue'
import App from './App.vue'
const app: AppType = createApp(App)
app.mount('#app')

41
src/types/index.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* 标签信息接口
*/
export interface Tag {
/** 标签唯一标识 */
id: number
/** 标签名称 */
name: string
/** 标签颜色(十六进制颜色值) */
color: string
}
/**
* 列表项数据接口
*/
export interface ListItem {
/** 项目唯一标识 */
id: number
/** 项目名称 */
name: string
/** 项目路径 */
path: string
/** 项目图标 URL */
icon?: string
/** 项目标签列表 */
tags?: Tag[]
/** 在虚拟列表中的索引位置 */
index?: number
}
/**
* 菜单项接口
*/
export interface MenuItem {
/** 菜单项唯一标识 */
id: string
/** 菜单项显示文本 */
label: string
/** 菜单项点击处理函数,接收当前选中的列表项作为参数 */
action: (item: ListItem | null) => void
}

37
tsconfig.json Normal file
View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": [
"ESNext",
"DOM"
],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

14
tsconfig.node.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"lib": [
"ES2020"
]
},
"include": [
"vite.config.ts"
]
}

View File

@@ -1,7 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})