A:增加模拟列表

This commit is contained in:
jimmy committed 2025-08-20 20:56:14 +08:00
1 parent 3929f9d9e6
commit 34da75c64c
1 file changed
+165 -53
+165 -53
View File
@@ -1,23 +1,35 @@
<template> <template>
<div class="photos-timeline"> <div class="photos-timeline">
<!-- 左侧照片展示区域 --> <!-- 左侧照片展示区域(虚拟滚动) -->
<div class="photos-content" ref="photosContentRef" @scroll="handleScroll"> <div class="photos-content" ref="photosContentRef">
<div <RecycleScroller
v-for="period in photoPeriods" class="virtual-scroller"
:key="period.id" :items="virtualItems"
:ref="(el) => setPeriodRef(period.id, el as HTMLElement)" :item-size="null"
class="photo-period" key-field="id"
size-field="size"
v-slot="{ item }"
@scroll="handleVirtualScroll"
:buffer="400"
:prerender="10"
> >
<!-- 时期标题 --> <!-- 时期标题 -->
<div class="period-header"> <div
<h2 class="period-title">{{ period.displayName }}</h2> v-if="item.type === 'header'"
<span class="photo-count">{{ period.photos.length }} 张照片</span> class="period-header"
:ref="(el) => setPeriodRef(item.periodId, el as HTMLElement)"
>
<h2 class="period-title">{{ item.period?.displayName }}</h2>
<span class="photo-count">{{ item.period?.photos.length }} 张照片</span>
</div> </div>
<!-- 照片网格 --> <!-- 照片行或间距 -->
<div class="photos-grid"> <div
v-else-if="item.type === 'row'"
class="photos-row"
>
<div <div
v-for="photo in period.photos" v-for="photo in item.photos"
:key="photo.id" :key="photo.id"
class="photo-item" class="photo-item"
:style="{ :style="{
@@ -32,15 +44,15 @@
</div> </div>
</div> </div>
</div> </div>
</div> </RecycleScroller>
</div> </div>
<!-- 右侧时间线导航 --> <!-- 右侧时间线导航 -->
<div <div
class="timeline-nav" class="timeline-nav"
:class="{ :class="{
hovered: timelineHovered, hovered: timelineHovered,
scrolling: isScrolling scrolling: isScrolling
}" }"
@mouseenter="timelineHovered = true" @mouseenter="timelineHovered = true"
@mouseleave="timelineHovered = false" @mouseleave="timelineHovered = false"
@@ -48,21 +60,21 @@
<div class="timeline-container"> <div class="timeline-container">
<!-- 时间线背景线 --> <!-- 时间线背景线 -->
<div class="timeline-line"></div> <div class="timeline-line"></div>
<!-- 进度指示器 --> <!-- 进度指示器 -->
<div <div
class="timeline-progress" class="timeline-progress"
:style="{ height: `${scrollProgress * 100}%` }" :style="{ height: `${scrollProgress * 100}%` }"
></div> ></div>
<!-- 当前位置指示器 --> <!-- 当前位置指示器 -->
<div <div
class="timeline-indicator" class="timeline-indicator"
:style="{ top: `${scrollProgress * 100}%` }" :style="{ top: `${scrollProgress * 100}%` }"
></div> ></div>
<!-- 当前时间显示 --> <!-- 当前时间显示 -->
<div <div
class="current-time-display" class="current-time-display"
:class="{ visible: isScrolling || timelineHovered }" :class="{ visible: isScrolling || timelineHovered }"
:style="{ top: `${scrollProgress * 100}%` }" :style="{ top: `${scrollProgress * 100}%` }"
@@ -74,17 +86,17 @@
v-for="(period, index) in photoPeriods" v-for="(period, index) in photoPeriods"
:key="period.id" :key="period.id"
class="timeline-node" class="timeline-node"
:class="{ :class="{
active: activePeriodId === period.id, active: activePeriodId === period.id,
inactive: isScrolling && activePeriodId !== period.id inactive: isScrolling && activePeriodId !== period.id
}" }"
:style="{ :style="{
top: `${(index / Math.max(photoPeriods.length - 1, 1)) * 100}%` top: `${(index / Math.max(photoPeriods.length - 1, 1)) * 100}%`
}" }"
@click.stop="scrollToPeriod(period.id)" @click.stop="scrollToPeriod(period.id)"
> >
<div class="node-dot"></div> <div class="node-dot"></div>
<div class="node-label">{{ period.period.split('-')[1] }}月</div> <div class="node-label">{{ period.period.split('-')[0] }}年{{ period.period.split('-')[1] }}月</div>
</div> </div>
</div> </div>
@@ -97,7 +109,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, reactive } from 'vue' import { ref, onMounted, nextTick, reactive, computed } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Photo { interface Photo {
id: string id: string
@@ -150,7 +164,8 @@ const generatePhotoPeriods = (): PhotoPeriod[] => {
const periods: PhotoPeriod[] = [] const periods: PhotoPeriod[] = []
const now = new Date() const now = new Date()
for (let i = 0; i < 8; i++) { // 生成过去36个月的数据,模拟大量照片,总计约1万张以上
for (let i = 0; i < 36; i++) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1) const date = new Date(now.getFullYear(), now.getMonth() - i, 1)
const year = date.getFullYear() const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
@@ -160,7 +175,7 @@ const generatePhotoPeriods = (): PhotoPeriod[] => {
id: period, id: period,
period, period,
displayName: `${year}年${parseInt(month)}月`, displayName: `${year}年${parseInt(month)}月`,
photos: generatePhotos(Math.floor(Math.random() * 30) + 10), photos: generatePhotos(Math.floor(Math.random() * 400) + 200), // 200-600张照片每月
}) })
} }
@@ -192,6 +207,56 @@ const hoverTime = ref('')
// 当前时间显示 // 当前时间显示
const currentViewTime = ref('') const currentViewTime = ref('')
// 虚拟滚动数据结构:以行为单位,包含时期标题和照片行
interface VirtualItem {
type: 'header' | 'row'
periodId: string
period?: PhotoPeriod
photos?: Photo[] // 当前行的照片
id: string
size: number
}
const virtualItems = computed<VirtualItem[]>(() => {
const items: VirtualItem[] = []
const containerWidth = 1000 // 估算容器宽度
const photosPerRow = Math.max(1, Math.floor(containerWidth / 164))
photoPeriods.value.forEach(period => {
// 添加时期标题
items.push({
type: 'header',
periodId: period.id,
period,
id: `header-${period.id}`,
size: 80 // 标题固定高度
})
// 将照片分割成行
for (let i = 0; i < period.photos.length; i += photosPerRow) {
const rowPhotos = period.photos.slice(i, i + photosPerRow)
items.push({
type: 'row',
periodId: period.id,
photos: rowPhotos,
id: `row-${period.id}-${Math.floor(i / photosPerRow)}`,
size: 212 // 照片行高度200px + 间距12px
})
}
// 添加时期底部间距
items.push({
type: 'row',
periodId: period.id,
photos: [],
id: `spacer-${period.id}`,
size: 48 // 底部间距
})
})
return items
})
// 设置时期元素引用 // 设置时期元素引用
const setPeriodRef = (periodId: string, el: HTMLElement) => { const setPeriodRef = (periodId: string, el: HTMLElement) => {
if (el) { if (el) {
@@ -202,7 +267,7 @@ const setPeriodRef = (periodId: string, el: HTMLElement) => {
// 滚动处理 // 滚动处理
const handleScroll = () => { const handleScroll = () => {
if (!photosContentRef.value) return if (!photosContentRef.value) return
// 滚动状态管理 // 滚动状态管理
isScrolling.value = true isScrolling.value = true
clearTimeout(scrollTimeout) clearTimeout(scrollTimeout)
@@ -214,7 +279,7 @@ const handleScroll = () => {
const containerHeight = photosContentRef.value.clientHeight const containerHeight = photosContentRef.value.clientHeight
const scrollHeight = photosContentRef.value.scrollHeight const scrollHeight = photosContentRef.value.scrollHeight
const viewportCenter = scrollTop + containerHeight / 2 const viewportCenter = scrollTop + containerHeight / 2
// 计算滚动进度 (0-1) // 计算滚动进度 (0-1)
scrollProgress.value = Math.min(scrollTop / (scrollHeight - containerHeight), 1) scrollProgress.value = Math.min(scrollTop / (scrollHeight - containerHeight), 1)
@@ -229,7 +294,7 @@ const handleScroll = () => {
if (viewportCenter >= periodTop && viewportCenter < periodBottom) { if (viewportCenter >= periodTop && viewportCenter < periodBottom) {
activePeriodId.value = period.id activePeriodId.value = period.id
currentPeriod = period currentPeriod = period
// 计算期间内的进度,推算具体日期 // 计算期间内的进度,推算具体日期
const periodProgress = (viewportCenter - periodTop) / periodEl.offsetHeight const periodProgress = (viewportCenter - periodTop) / periodEl.offsetHeight
const dayInMonth = Math.floor(periodProgress * 30) + 1 const dayInMonth = Math.floor(periodProgress * 30) + 1
@@ -239,7 +304,7 @@ const handleScroll = () => {
} }
} }
} }
// 如果没有找到精确匹配,使用第一个或最后一个时期 // 如果没有找到精确匹配,使用第一个或最后一个时期
if (!currentPeriod && photoPeriods.value.length > 0) { if (!currentPeriod && photoPeriods.value.length > 0) {
const firstPeriod = photoPeriods.value[0] const firstPeriod = photoPeriods.value[0]
@@ -254,15 +319,58 @@ const handleScroll = () => {
} }
} }
// 虚拟滚动事件处理
const handleVirtualScroll = (event: Event) => {
const target = event.target as HTMLElement
if (!target) return
// 滚动状态管理
isScrolling.value = true
clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(() => {
isScrolling.value = false
}, 150)
const scrollTop = target.scrollTop
const containerHeight = target.clientHeight
const scrollHeight = target.scrollHeight
// 计算滚动进度 (0-1)
scrollProgress.value = Math.min(scrollTop / (scrollHeight - containerHeight), 1)
// 基于虚拟滚动位置估算当前时期
const totalItems = virtualItems.value.length
const visibleIndex = Math.floor((scrollTop / scrollHeight) * totalItems)
const currentItem = virtualItems.value[visibleIndex]
if (currentItem) {
activePeriodId.value = currentItem.periodId
// 根据当前项找到对应的时期
const period = photoPeriods.value.find(p => p.id === currentItem.periodId)
if (period) {
const [year, month] = period.period.split('-')
const dayInMonth = Math.floor(Math.random() * 28) + 1
currentViewTime.value = `${year}年${parseInt(month)}月${dayInMonth}日`
}
}
}
// 跳转到指定时期 // 跳转到指定时期
const scrollToPeriod = (periodId: string) => { const scrollToPeriod = (periodId: string) => {
const periodEl = periodRefs[periodId] // 在虚拟列表中查找目标时期的标题索引
if (periodEl && photosContentRef.value) { const targetIndex = virtualItems.value.findIndex(item =>
const offsetTop = periodEl.offsetTop - 20 // 留一些边距 item.type === 'header' && item.periodId === periodId
photosContentRef.value.scrollTo({ )
top: offsetTop,
behavior: 'smooth', if (targetIndex >= 0) {
}) const scroller = document.querySelector('.virtual-scroller') as HTMLElement
if (scroller) {
const scrollTop = (targetIndex / virtualItems.value.length) * scroller.scrollHeight
scroller.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}
activePeriodId.value = periodId activePeriodId.value = periodId
} }
} }
@@ -314,7 +422,7 @@ onMounted(() => {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
scroll-behavior: smooth; scroll-behavior: smooth;
/* 隐藏滚动条 */ /* 隐藏滚动条 */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */ -ms-overflow-style: none; /* IE/Edge */
@@ -349,13 +457,18 @@ onMounted(() => {
color: var(--text-color-secondary); color: var(--text-color-secondary);
} }
/* 照片网格 */ /* 虚拟滚动容器 */
.photos-grid { .virtual-scroller {
height: 100%;
width: 100%;
}
/* 照片行 */
.photos-row {
display: flex; display: flex;
flex-wrap: wrap;
gap: 4px; gap: 4px;
margin-bottom: 32px;
align-items: flex-start; align-items: flex-start;
margin-bottom: 4px;
} }
.photo-item { .photo-item {
@@ -396,16 +509,16 @@ onMounted(() => {
/* 右侧时间线导航 */ /* 右侧时间线导航 */
.timeline-nav { .timeline-nav {
width: 80px; width: 50px;
background: transparent; background: transparent;
border-left: none; border-left: none;
position: relative; position: relative;
padding: 20px 8px; padding: 20px 2px;
overflow: visible; overflow: visible;
flex-shrink: 0; flex-shrink: 0;
transition: opacity 0.2s ease, background-color 0.2s ease; transition: opacity 0.2s ease, background-color 0.2s ease;
opacity: 0.3; opacity: 0.3;
cursor: crosshair; cursor: row-resize;
} }
.timeline-nav:hover, .timeline-nav:hover,
@@ -512,7 +625,6 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
padding: 8px 0; padding: 8px 0;
opacity: 0.6; opacity: 0.6;
@@ -617,7 +729,7 @@ onMounted(() => {
/* 响应式适配 */ /* 响应式适配 */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.timeline-nav { .timeline-nav {
width: 60px; width: 40px;
} }
.photos-content { .photos-content {
@@ -673,7 +785,7 @@ onMounted(() => {
padding: 16px; padding: 16px;
height: calc(100% - 60px); height: calc(100% - 60px);
} }
.current-time-display { .current-time-display {
display: none; display: none;
} }