A:增加模拟列表
This commit is contained in:
1 parent
3929f9d9e6
commit
34da75c64c
1 file changed
+165
-53
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user