A:添加虚拟列表类型文件

U:调整时间线组件列表展示
This commit is contained in:
jimmy committed 2025-08-23 13:58:19 +08:00
1 parent 96049909a3
commit fbfd77ec93
9 files changed
+655 -7

No files matched your search

+1 -1
View File
@@ -1,2 +1,2 @@
VITE_MODE='development'
VITE_APP_API_URL='http://localhost:8080/api'
VITE_APP_API_URL='http://127.0.0.1:9484/api'
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"axios": "^1.9.0",
"justified-layout": "^4.1.0",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
+8
View File
@@ -14,6 +14,9 @@ importers:
axios:
specifier: ^1.9.0
version: 1.9.0
justified-layout:
specifier: ^4.1.0
version: 4.1.0
pinia:
specifier: ^3.0.1
version: 3.0.3(typescript@5.8.3)(vue@3.5.16(typescript@5.8.3))
@@ -1640,6 +1643,9 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==, tarball: https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz}
justified-layout@4.1.0:
resolution: {integrity: sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==, tarball: https://registry.npmmirror.com/justified-layout/-/justified-layout-4.1.0.tgz}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, tarball: https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz}
@@ -3983,6 +3989,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
justified-layout@4.1.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -13,7 +13,7 @@ import type { HttpConfig } from '@/types/http.ts'
// 2. 配置管理器 (config/httpConfig.ts)
class HttpConfigManager {
private config: HttpConfig = {
baseURL: import.meta.env.VITE_APP_API_URL || 'http://localhost:8726',
baseURL: import.meta.env.VITE_APP_API_URL || 'http://127.0.0.1:9484',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
+1 -1
View File
@@ -10,7 +10,7 @@ const router = createRouter({
{
path: '/photos',
name: 'photos',
component: () => import('@/views/PhotosView.vue'),
component: () => import('@/views/PhotosView2.vue'),
meta: { title: '照片' }
},
{
@@ -0,0 +1,53 @@
declare module 'vue-virtual-scroller' {
import { DefineComponent } from 'vue'
export const RecycleScroller: DefineComponent<{
items: any[]
itemSize: number | string
keyField?: string
direction?: 'vertical' | 'horizontal'
listTag?: string
itemTag?: string
listClass?: string | object | any[]
itemClass?: string | object | any[]
gridItems?: number
sizeField?: string
typeField?: string
buffer?: number
pageMode?: boolean
prerender?: number
emitUpdate?: boolean
}>
export const DynamicScroller: DefineComponent<{
items: any[]
minItemSize: number | string
keyField?: string
direction?: 'vertical' | 'horizontal'
listTag?: string
itemTag?: string
listClass?: string | object | any[]
itemClass?: string | object | any[]
buffer?: number
pageMode?: boolean
prerender?: number
emitUpdate?: boolean
}>
export const DynamicScrollerItem: DefineComponent<{
item: any
index: number
active: boolean
sizeDependencies?: any[]
watchData?: boolean
tag?: string
emitResize?: boolean
onResize?: () => void
}>
const plugin: {
install: (app: any, options?: any) => void
}
export default plugin
}
@@ -550,10 +550,6 @@ onUnmounted(() => {
display: none; /* Webkit browsers */
}
.photo-period {
margin-bottom: 48px;
}
.period-header {
display: flex;
align-items: baseline;
@@ -0,0 +1,182 @@
<template>
<div class="timeline-container">
<RecycleScroller
:items="blocks"
:key-field="'id'"
:min-item-size="100"
class="scroller"
v-slot="{ item }"
>
<!-- 日期标题 -->
<div v-if="item.type === 'header'" class="header">
{{ item.label }}
</div>
<!-- 照片行 -->
<div v-else-if="item.type === 'photoRow'" class="photo-row" :style="{ height: item.rowHeight + 'px' }">
<div
v-for="photo in item.photos"
:key="photo.hash"
class="photo"
:style="{
backgroundColor: photo.color,
width: photo.displayWidth + 'px',
height: item.rowHeight + 'px'
}"
></div>
</div>
<!-- 未来可能出现的卡片 -->
<div v-else-if="item.type === 'card'" class="card">
{{ item.text }}
</div>
</RecycleScroller>
</div>
</template>
<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
// 模拟接口返回的数据结构
const photoApiData = {
hash: [
"9df97ccc44529d1d377b36f50f79cd5ab9c0920dd58352630412e4b3f8eceaf5",
"70886b5e8bf59eeb3cc5fbb1f714c3da5c371e1a0c83ac5ad9c20621c703ff4f"
],
isImage: [true, true],
takenAt: ["2025-08-02T14:16:36", "2025-08-02T14:16:36"],
ratio: [1, 0.46153846]
}
const timelineApiData = [
{ date: "2024-10-05", count: 1202 },
{ date: "2024-11-01", count: 1 },
{ date: "2024-11-04", count: 4 },
{ date: "2025-08-02", count: 3 }
]
// 随机颜色生成
function randomColor() {
const colors = ['#f87171', '#fbbf24', '#34d399', '#60a5fa', '#a78bfa', '#f472b6']
return colors[Math.floor(Math.random() * colors.length)]
}
// 生成模拟照片
function generatePhotos(count) {
const photos = []
for (let i = 0; i < count; i++) {
const ratio = (Math.random() * 1.5 + 0.5).toFixed(2) // 比例 0.5 ~ 2.0
photos.push({
hash: Math.random().toString(36).substring(2, 15),
color: randomColor(),
ratio: parseFloat(ratio)
})
}
return photos
}
// 将照片按行布局,每行总宽度接近 containerWidth
function buildPhotoRows(photos, containerWidth, rowHeight) {
const rows = []
let currentRow = []
let currentRatioSum = 0
photos.forEach(photo => {
currentRow.push(photo)
currentRatioSum += photo.ratio
// 如果行里照片太多,就换行
if (currentRatioSum >= containerWidth / rowHeight) {
const scale = containerWidth / (currentRatioSum * rowHeight)
rows.push({ photos: currentRow, scale })
currentRow = []
currentRatioSum = 0
}
})
if (currentRow.length > 0) {
const scale = containerWidth / (currentRatioSum * rowHeight)
rows.push({ photos: currentRow, scale })
}
return rows
}
// 生成完整 blocks
function generateBlocks() {
const blocks = []
let idCounter = 0
const containerWidth = 800 // 假设容器宽度 800px
const rowHeight = 200 // 每行高度 200px
timelineApiData.forEach(entry => {
// 日期标题
blocks.push({ id: idCounter++, type: 'header', label: entry.date })
// 生成该日期对应的照片
const photos = generatePhotos(entry.count)
const rows = buildPhotoRows(photos, containerWidth, rowHeight)
rows.forEach(row => {
const scaledHeight = rowHeight * row.scale
const scaledPhotos = row.photos.map(p => ({
...p,
displayWidth: p.ratio * scaledHeight
}))
blocks.push({
id: idCounter++,
type: 'photoRow',
rowHeight: scaledHeight,
photos: scaledPhotos
})
})
})
return blocks
}
const blocks = generateBlocks()
</script>
<style>
@import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
.timeline-container {
padding: 16px;
}
.scroller {
height: 100vh;
overflow-y: auto;
display: block;
}
.header {
font-size: 18px;
font-weight: bold;
padding: 12px 0;
background: #f5f5f5;
position: sticky;
top: 0;
z-index: 10;
}
.photo-row {
display: flex;
gap: 4px;
margin: 8px 0;
}
.photo {
border-radius: 6px;
flex-shrink: 0;
}
.card {
padding: 12px;
margin: 8px 0;
border-radius: 12px;
background: #ddd;
}
</style>
@@ -0,0 +1,408 @@
<template>
<div class="photos-timeline">
<DynamicScroller
ref="scrollerRef"
:items="timelineItems"
:min-item-size="100"
key-field="id"
class="scroller"
:buffer="200"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:index="index"
:active="active"
:size-dependencies="[containerWidth]"
:key="item.id"
>
<!-- 月份标题 -->
<div v-if="item.type === 'month'" class="month-header">
<h2 class="month-title">{{ item.title }}</h2>
<div class="month-subtitle">{{ item.subtitle }}</div>
</div>
<!-- 回忆卡片 -->
<div v-else-if="item.type === 'memory'" class="memory-card">
<div class="memory-icon">📸</div>
<div class="memory-content">
<div class="memory-title">{{ item.title }}</div>
<div class="memory-subtitle">{{ item.subtitle }}</div>
</div>
</div>
<!-- 时间卡片 -->
<div v-else-if="item.type === 'date'" class="date-card">
<div class="date-label">{{ item.title }}</div>
</div>
<!-- 照片网格 -->
<div v-else-if="item.type === 'photos'" class="photos-grid" :style="{ height: item.height + 'px' }">
<div
v-for="photo in item.photos"
:key="photo.id"
class="photo-item"
:style="{
position: 'absolute',
top: photo.box.top + 'px',
left: photo.box.left + 'px',
width: photo.box.width + 'px',
height: photo.box.height + 'px',
backgroundColor: photo.color
}"
>
<div class="photo-placeholder">
<span class="photo-ratio">{{ photo.ratio.toFixed(2) }}</span>
</div>
</div>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import justifiedLayout from 'justified-layout'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Photo {
id: string
width: number
height: number
ratio: number
color: string
box: {
top: number
left: number
width: number
height: number
}
}
interface TimelineItem {
id: string
type: 'month' | 'memory' | 'date' | 'photos'
title?: string
subtitle?: string
height?: number
photos?: Photo[]
}
const scrollerRef = ref()
const containerWidth = ref(1200)
// 生成随机颜色
function generateRandomColor(): string {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F8C471', '#82E0AA', '#F1948A', '#85C1E9', '#D7BDE2'
]
return colors[Math.floor(Math.random() * colors.length)]
}
// 生成随机照片数据
function generateRandomPhotos(count: number): Photo[] {
const photos: Photo[] = []
for (let i = 0; i < count; i++) {
const width = Math.floor(Math.random() * 400) + 200 // 200-600px
const height = Math.floor(Math.random() * 400) + 200 // 200-600px
const ratio = width / height
photos.push({
id: `photo-${i}`,
width,
height,
ratio,
color: generateRandomColor(),
box: { top: 0, left: 0, width: 0, height: 0 } // 先设置默认值,后面会计算
})
}
return photos
}
// 使用 justified-layout 计算照片布局
function calculatePhotosLayout(photos: Photo[]): { photos: Photo[], height: number } {
const photoInputs = photos.map(photo => ({
width: photo.width,
height: photo.height
}))
const geometry = justifiedLayout(photoInputs, {
containerWidth: containerWidth.value - 40, // 减去左右边距
targetRowHeight: 220,
targetRowHeightTolerance: 0.25,
boxSpacing: 4,
containerPadding: 0
})
const layoutPhotos = photos.map((photo, index) => ({
...photo,
box: geometry.boxes[index]
}))
return {
photos: layoutPhotos,
height: geometry.containerHeight
}
}
// 生成时间线数据
function generateTimelineData(): TimelineItem[] {
const items: TimelineItem[] = []
const months = [
{ title: '2024年1月', subtitle: '128张照片' },
{ title: '2023年12月', subtitle: '95张照片' },
{ title: '2023年11月', subtitle: '156张照片' }
]
months.forEach((month, monthIndex) => {
// 添加月份标题
items.push({
id: `month-${monthIndex}`,
type: 'month',
title: month.title,
subtitle: month.subtitle
})
// 添加回忆卡片 (随机)
if (Math.random() > 0.6) {
items.push({
id: `memory-${monthIndex}`,
type: 'memory',
title: '回忆',
subtitle: '一年前的今天'
})
}
// 添加日期和照片组
const daysInMonth = Math.floor(Math.random() * 8) + 3 // 3-10天
for (let day = 1; day <= daysInMonth; day++) {
const dayId = `day-${monthIndex}-${day}`
// 添加日期卡片
items.push({
id: `date-${dayId}`,
type: 'date',
title: `${month.title.slice(0, 7)}${day}日`
})
// 添加照片组
const photoCount = Math.floor(Math.random() * 20) + 5 // 5-24张照片
const photos = generateRandomPhotos(photoCount)
const layout = calculatePhotosLayout(photos)
items.push({
id: `photos-${dayId}`,
type: 'photos',
height: layout.height,
photos: layout.photos
})
}
})
return items
}
// 响应式数据
const timelineItems = ref<TimelineItem[]>([])
// 初始化数据
onMounted(async () => {
await nextTick()
// 获取容器宽度
const container = document.querySelector('.photos-timeline')
if (container) {
containerWidth.value = container.clientWidth
}
// 生成时间线数据
timelineItems.value = generateTimelineData()
// 监听窗口大小变化
const handleResize = () => {
if (container) {
const newWidth = container.clientWidth
if (Math.abs(newWidth - containerWidth.value) > 50) { // 只有明显变化时才重新计算
containerWidth.value = newWidth
// 重新计算所有照片布局
timelineItems.value = timelineItems.value.map(item => {
if (item.type === 'photos' && item.photos) {
const layout = calculatePhotosLayout(item.photos)
return {
...item,
height: layout.height,
photos: layout.photos
}
}
return item
})
}
}
}
window.addEventListener('resize', handleResize)
// 清理事件监听器
return () => {
window.removeEventListener('resize', handleResize)
}
})
</script>
<style scoped>
.photos-timeline {
height: 100vh;
width: 100%;
background: var(--bg-color);
}
.scroller {
height: 100%;
}
/* 月份标题样式 */
.month-header {
padding: 40px 20px 20px 20px;
background: var(--bg-color);
}
.month-title {
font-size: 28px;
font-weight: 600;
color: var(--text-color);
margin: 0 0 4px 0;
}
.month-subtitle {
font-size: 14px;
color: var(--text-color-secondary);
opacity: 0.7;
}
/* 回忆卡片样式 */
.memory-card {
margin: 16px 20px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.memory-icon {
font-size: 24px;
}
.memory-content {
flex: 1;
}
.memory-title {
font-size: 16px;
font-weight: 600;
color: white;
margin-bottom: 4px;
}
.memory-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
}
/* 日期卡片样式 */
.date-card {
padding: 16px 20px 12px 20px;
background: var(--bg-color);
}
.date-label {
font-size: 16px;
font-weight: 500;
color: var(--text-color);
}
/* 照片网格样式 */
.photos-grid {
position: relative;
margin: 0 20px 24px 20px;
background: var(--bg-color);
}
.photo-item {
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.photo-item:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 1;
}
.photo-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: white;
font-weight: bold;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.photo-ratio {
background: rgba(0, 0, 0, 0.3);
padding: 2px 6px;
border-radius: 4px;
}
/* 深色主题适配 */
.dark .month-header {
background: var(--bg-color);
}
.dark .date-card {
background: var(--bg-color);
}
.dark .photos-grid {
background: var(--bg-color);
}
/* 响应式设计 */
@media (max-width: 768px) {
.month-header {
padding: 32px 16px 16px 16px;
}
.month-title {
font-size: 24px;
}
.memory-card {
margin: 12px 16px;
}
.date-card {
padding: 12px 16px 8px 16px;
}
.photos-grid {
margin: 0 16px 20px 16px;
}
}
</style>