A:完成虚拟列表初始展示

This commit is contained in:
jimmy committed 2025-08-23 15:04:37 +08:00
1 parent fbfd77ec93
commit 03ab075d9d
6 files changed
+544 -98

No files matched your search

@@ -13,7 +13,7 @@ import type { HttpConfig } from '@/types/http.ts'
// 2. 配置管理器 (config/httpConfig.ts) // 2. 配置管理器 (config/httpConfig.ts)
class HttpConfigManager { class HttpConfigManager {
private config: HttpConfig = { private config: HttpConfig = {
baseURL: import.meta.env.VITE_APP_API_URL || 'http://127.0.0.1:9484', baseURL: import.meta.env.VITE_APP_API_URL || 'http://127.0.0.1:9484/api',
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -0,0 +1,180 @@
import { httpClient } from '@/utils/http'
import type {
TimelineParams,
TimelineResponse,
PhotosParams,
PhotosResponse,
Photo,
DayGroup,
MonthGroup, PhotosData
} from '@/types/api'
import type { ApiResponse } from '@/types/http.ts'
/**
* 获取照片时间线统计数据
*/
export async function getTimeline(params?: TimelineParams): Promise<ApiResponse<TimelineResponse>> {
const response = await httpClient.get<TimelineResponse>('/v1/photos/timeline', params)
return response
}
/**
* 获取照片列表数据
*/
export async function getPhotos(params?: PhotosParams): Promise<ApiResponse<PhotosData>> {
const response = await httpClient.get<PhotosData>('/v1/photos', params)
return response
}
/**
* 根据时间线数据获取具体某天的照片
*/
export async function getPhotosByDate(date: string, limit = 50, offset = 0): Promise<Photo[]> {
const response:ApiResponse<PhotosData> = await getPhotos({
start_date: date,
end_date: date,
limit,
offset
})
if (response.code !== 200 || !response.data) {
return []
}
const { hash, isImage, takenAt, ratio } = response.data
// 转换列式存储数据为行式存储
const photos: Photo[] = []
const count = Math.min(hash.length, isImage.length, takenAt.length, ratio.length)
for (let i = 0; i < count; i++) {
// 为justified-layout计算实际尺寸
const photoRatio = ratio[i] || 1
const baseHeight = 200
const width = Math.round(baseHeight * photoRatio)
const height = baseHeight
photos.push({
hash: hash[i],
isImage: isImage[i],
takenAt: takenAt[i],
ratio: photoRatio,
width,
height
})
}
return photos
}
/**
* 获取照片缩略图URL
*/
export function getThumbnailUrl(hash: string, size = '400x400'): string {
const baseUrl = import.meta.env.VITE_APP_API_URL
return `${baseUrl}/v1/photo/${hash}?format=thumbnail&size=${size}`
}
/**
* 获取照片原图URL
*/
export function getOriginalUrl(hash: string): string {
const baseUrl = import.meta.env.VITE_APP_API_URL
return `${baseUrl}/v1/photo/${hash}?format=original`
}
/**
* 将时间线数据转换为月份分组数据
*/
export function groupTimelineByMonth(timelineData: { date: string; count: number }[]): MonthGroup[] {
const monthMap = new Map<string, MonthGroup>()
for (const item of timelineData) {
const date = new Date(item.date)
const year = date.getFullYear()
const month = date.getMonth() + 1
const monthKey = `${year}-${month.toString().padStart(2, '0')}`
if (!monthMap.has(monthKey)) {
monthMap.set(monthKey, {
year,
month,
title: `${year}年${month}月`,
subtitle: '0张照片',
days: [],
totalCount: 0
})
}
const monthGroup = monthMap.get(monthKey)!
monthGroup.totalCount += item.count
// 添加日期组
monthGroup.days.push({
date: item.date,
photos: [], // 这里暂时为空,需要时再加载
count: item.count
})
}
// 更新subtitle并排序
const result = Array.from(monthMap.values())
.map(month => ({
...month,
subtitle: `${month.totalCount}张照片`,
days: month.days.sort((a, b) => b.date.localeCompare(a.date)) // 按日期倒序
}))
.sort((a, b) => {
// 按年月倒序
if (a.year !== b.year) return b.year - a.year
return b.month - a.month
})
return result
}
/**
* 获取完整的时间线数据(包含照片)
*/
export async function getFullTimeline(params?: TimelineParams): Promise<MonthGroup[]> {
console.log('正在获取时间线数据,参数:', params)
try {
const timelineResponse = await getTimeline(params)
console.log('时间线API响应:', timelineResponse)
// 检查响应结构
if (!timelineResponse) {
throw new Error('API响应为空')
}
if (timelineResponse.code !== 200) {
throw new Error(timelineResponse.message || '获取时间线数据失败')
}
if (!Array.isArray(timelineResponse.data)) {
throw new Error('时间线数据格式错误')
}
const monthGroups = groupTimelineByMonth(timelineResponse.data)
console.log('处理后的月份分组:', monthGroups)
// 为每个日期加载照片数据(限制加载数量,避免一次性加载太多)
for (const monthGroup of monthGroups) {
for (const dayGroup of monthGroup.days) {
try {
// 每天最多加载30张照片用于展示
const photos = await getPhotosByDate(dayGroup.date, 30, 0)
dayGroup.photos = photos
} catch (error) {
console.error(`Failed to load photos for ${dayGroup.date}:`, error)
dayGroup.photos = []
}
}
}
return monthGroups
} catch (error) {
console.error('获取时间线数据失败:', error)
throw error
}
}
+78
View File
@@ -0,0 +1,78 @@
// API相关类型定义
// 时间线API响应
export interface TimelineItem {
date: string // YYYY-MM-DD格式
count: number // 该日期的照片数量
}
export interface TimelineResponse {
code: number
message: string
data: TimelineItem[]
}
// 照片列表API响应(列式存储格式)
export interface PhotosData {
hash: string[] // 照片hash数组
isImage: boolean[] // 是否为图片数组
takenAt: string[] // 拍摄时间数组 ISO格式
ratio: number[] // 宽高比数组
}
export interface PhotosResponse {
code: number
message: string
data: PhotosData
}
// 照片详情响应
export interface PhotoDetails {
// 这里需要根据实际API响应结构补充
// 目前从API文档看只有获取照片文件的接口,没有详情结构
}
// API请求参数
export interface TimelineParams {
start_date?: string // YYYY-MM-DD格式
end_date?: string // YYYY-MM-DD格式
}
export interface PhotosParams {
limit?: number // 分页大小,最大1000
offset?: number // 偏移量
start_date?: string // YYYY-MM-DD格式
end_date?: string // YYYY-MM-DD格式
}
export interface PhotoRequestParams {
format?: 'original' | 'thumbnail'
size?: string // 格式:WIDTHxHEIGHT,如 400x400
}
// 内部使用的照片数据结构
export interface Photo {
hash: string
isImage: boolean
takenAt: string
ratio: number
// 为justified-layout计算用的尺寸信息
width: number
height: number
}
// 时间线组织数据结构
export interface DayGroup {
date: string // YYYY-MM-DD
photos: Photo[]
count: number
}
export interface MonthGroup {
year: number
month: number
title: string // 如:"2024年1月"
subtitle: string // 如:"128张照片"
days: DayGroup[]
totalCount: number
}
+29
View File
@@ -0,0 +1,29 @@
declare module 'justified-layout' {
interface GeometryOptions {
containerWidth?: number
targetRowHeight?: number
targetRowHeightTolerance?: number
boxSpacing?: number
containerPadding?: number
}
interface Box {
top: number
left: number
width: number
height: number
}
interface Geometry {
containerHeight: number
boxes: Box[]
}
interface PhotoInput {
width: number
height: number
}
function justifiedLayout(photos: PhotoInput[], options?: GeometryOptions): Geometry
export = justifiedLayout
}
@@ -115,15 +115,13 @@ const removePath = async (item: LibraryPath) => {
// 开始任务 // 开始任务
const startTask = async () => { const startTask = async () => {
const enabledPaths = libraryPaths.value.filter((item) => item.enabled) const enabledPaths = libraryPaths.value.filter((item) => item.enabled)
message.error('创建任务失败')
return
if (enabledPaths.length === 0) { if (enabledPaths.length === 0) {
message.warning('请至少选择一个启用的路径') message.warning('请至少选择一个启用的路径')
return return
} }
try { try {
const response = await httpClient.post('/api/task/create', { const response = await httpClient.post('v1/library/indexed', {
paths: enabledPaths.map((item) => item.path), paths: enabledPaths.map((item) => item.path),
}) })
+255 -94
View File
@@ -1,6 +1,21 @@
<template> <template>
<div class="photos-timeline"> <div class="photos-timeline">
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<div class="loading-text">正在加载照片时间线...</div>
</div>
<!-- 空状态 -->
<div v-else-if="timelineItems.length === 0" class="empty-state">
<div class="empty-icon">📷</div>
<div class="empty-text">暂无照片</div>
<div class="empty-subtitle">开始上传一些照片来创建你的时间线吧</div>
</div>
<!-- 时间线内容 -->
<DynamicScroller <DynamicScroller
v-else
ref="scrollerRef" ref="scrollerRef"
:items="timelineItems" :items="timelineItems"
:min-item-size="100" :min-item-size="100"
@@ -47,11 +62,22 @@
top: photo.box.top + 'px', top: photo.box.top + 'px',
left: photo.box.left + 'px', left: photo.box.left + 'px',
width: photo.box.width + 'px', width: photo.box.width + 'px',
height: photo.box.height + 'px', height: photo.box.height + 'px'
backgroundColor: photo.color
}" }"
@click="handlePhotoClick(photo)"
> >
<div class="photo-placeholder"> <img
:src="photo.thumbnailUrl"
:alt="`照片 ${photo.hash.slice(0, 8)}`"
class="photo-image"
:style="{
backgroundColor: photo.color
}"
@error="handleImageError"
loading="lazy"
/>
<!-- 开发时显示比例信息 -->
<div v-if="false" class="photo-debug-info">
<span class="photo-ratio">{{ photo.ratio.toFixed(2) }}</span> <span class="photo-ratio">{{ photo.ratio.toFixed(2) }}</span>
</div> </div>
</div> </div>
@@ -67,13 +93,13 @@ import { ref, onMounted, nextTick } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller' import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import justifiedLayout from 'justified-layout' import justifiedLayout from 'justified-layout'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { getFullTimeline, getThumbnailUrl } from '@/services/timelineService'
import type { Photo as ApiPhoto, MonthGroup, DayGroup } from '@/types/api'
interface Photo { interface Photo extends ApiPhoto {
id: string id: string
width: number color?: string // 占位颜色,用于加载时显示
height: number thumbnailUrl: string
ratio: number
color: string
box: { box: {
top: number top: number
left: number left: number
@@ -89,12 +115,15 @@ interface TimelineItem {
subtitle?: string subtitle?: string
height?: number height?: number
photos?: Photo[] photos?: Photo[]
loading?: boolean
} }
const scrollerRef = ref() const scrollerRef = ref()
const containerWidth = ref(1200) const containerWidth = ref(1200)
const loading = ref(true)
const error = ref('')
// 生成随机颜色 // 生成随机颜色作为占位符
function generateRandomColor(): string { function generateRandomColor(): string {
const colors = [ const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
@@ -104,28 +133,23 @@ function generateRandomColor(): string {
return colors[Math.floor(Math.random() * colors.length)] return colors[Math.floor(Math.random() * colors.length)]
} }
// 生成随机照片数据 // 转换API照片数据为组件用的照片数据
function generateRandomPhotos(count: number): Photo[] { function convertApiPhotosToPhotos(apiPhotos: ApiPhoto[]): Photo[] {
const photos: Photo[] = [] return apiPhotos.map(apiPhoto => ({
for (let i = 0; i < count; i++) { ...apiPhoto,
const width = Math.floor(Math.random() * 400) + 200 // 200-600px id: apiPhoto.hash, // 使用hash作为id
const height = Math.floor(Math.random() * 400) + 200 // 200-600px color: generateRandomColor(), // 占位颜色
const ratio = width / height thumbnailUrl: getThumbnailUrl(apiPhoto.hash, '400'),
box: { top: 0, left: 0, width: 0, height: 0 } // 稍后计算
photos.push({ }))
id: `photo-${i}`,
width,
height,
ratio,
color: generateRandomColor(),
box: { top: 0, left: 0, width: 0, height: 0 } // 先设置默认值,后面会计算
})
}
return photos
} }
// 使用 justified-layout 计算照片布局 // 使用 justified-layout 计算照片布局
function calculatePhotosLayout(photos: Photo[]): { photos: Photo[], height: number } { function calculatePhotosLayout(photos: Photo[]): { photos: Photo[], height: number } {
if (photos.length === 0) {
return { photos: [], height: 0 }
}
const photoInputs = photos.map(photo => ({ const photoInputs = photos.map(photo => ({
width: photo.width, width: photo.width,
height: photo.height height: photo.height
@@ -141,7 +165,7 @@ function calculatePhotosLayout(photos: Photo[]): { photos: Photo[], height: numb
const layoutPhotos = photos.map((photo, index) => ({ const layoutPhotos = photos.map((photo, index) => ({
...photo, ...photo,
box: geometry.boxes[index] box: geometry.boxes[index] || { top: 0, left: 0, width: 0, height: 0 }
})) }))
return { return {
@@ -150,58 +174,56 @@ function calculatePhotosLayout(photos: Photo[]): { photos: Photo[], height: numb
} }
} }
// 生成时间线数据 // 转换API数据为时间线展示数据
function generateTimelineData(): TimelineItem[] { function convertMonthGroupsToTimelineItems(monthGroups: MonthGroup[]): TimelineItem[] {
const items: 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) => { monthGroups.forEach((monthGroup, monthIndex) => {
// 添加月份标题 // 添加月份标题
items.push({ items.push({
id: `month-${monthIndex}`, id: `month-${monthGroup.year}-${monthGroup.month}`,
type: 'month', type: 'month',
title: month.title, title: monthGroup.title,
subtitle: month.subtitle subtitle: monthGroup.subtitle
}) })
// 添加回忆卡片 (随机) // 添加回忆卡片 (随机,约40%概率出现)
if (Math.random() > 0.6) { if (Math.random() > 0.6) {
items.push({ items.push({
id: `memory-${monthIndex}`, id: `memory-${monthGroup.year}-${monthGroup.month}`,
type: 'memory', type: 'memory',
title: '回忆', title: '回忆',
subtitle: '一年前的今天' subtitle: '一年前的今天'
}) })
} }
// 添加日期和照片组 // 添加每天的照片数据
const daysInMonth = Math.floor(Math.random() * 8) + 3 // 3-10天 monthGroup.days.forEach((dayGroup, dayIndex) => {
for (let day = 1; day <= daysInMonth; day++) { if (dayGroup.photos.length > 0) {
const dayId = `day-${monthIndex}-${day}` const dayId = `day-${monthGroup.year}-${monthGroup.month}-${dayIndex}`
// 添加日期卡片
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 date = new Date(dayGroup.date)
const photos = generateRandomPhotos(photoCount) const dateTitle = `${date.getMonth() + 1}月${date.getDate()}日`
const layout = calculatePhotosLayout(photos)
items.push({ items.push({
id: `photos-${dayId}`, id: `date-${dayId}`,
type: 'photos', type: 'date',
height: layout.height, title: dateTitle
photos: layout.photos })
})
} // 转换并布局照片
const photos = convertApiPhotosToPhotos(dayGroup.photos)
const layout = calculatePhotosLayout(photos)
items.push({
id: `photos-${dayId}`,
type: 'photos',
height: layout.height,
photos: layout.photos
})
}
})
}) })
return items return items
@@ -210,43 +232,98 @@ function generateTimelineData(): TimelineItem[] {
// 响应式数据 // 响应式数据
const timelineItems = ref<TimelineItem[]>([]) const timelineItems = ref<TimelineItem[]>([])
// 加载时间线数据
async function loadTimelineData() {
try {
loading.value = true
error.value = ''
// 获取最近2年的数据
const endDate = new Date().toISOString().split('T')[0]
const startDate = new Date(Date.now() - 2 * 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const monthGroups = await getFullTimeline({
start_date: startDate,
end_date: endDate
})
// 转换为时间线展示数据
timelineItems.value = convertMonthGroupsToTimelineItems(monthGroups)
} catch (err) {
console.error('Failed to load timeline data:', err)
error.value = err instanceof Error ? err.message : '加载时间线数据失败'
// 降级到空状态,不显示错误给用户,在控制台记录即可
timelineItems.value = []
} finally {
loading.value = false
}
}
// 重新计算照片布局
function recalculatePhotosLayout() {
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
})
}
// 处理照片点击事件
function handlePhotoClick(photo: Photo) {
// TODO: 实现照片详情查看或大图预览功能
console.log('Photo clicked:', photo)
}
// 处理图片加载失败
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
// 显示占位颜色背景
img.style.display = 'none'
const parent = img.parentElement
if (parent) {
parent.style.backgroundColor = '#f0f0f0'
parent.innerHTML = `
<div class="photo-error">
<span>📷</span>
<span>加载失败</span>
</div>
`
}
}
// 初始化数据 // 初始化数据
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
// 获取容器宽度 // 获取容器宽度
const container = document.querySelector('.photos-timeline') const container = document.querySelector('.photos-timeline')
if (container) { if (container) {
containerWidth.value = container.clientWidth containerWidth.value = container.clientWidth
} }
// 生成时间线数据 // 加载时间线数据
timelineItems.value = generateTimelineData() await loadTimelineData()
// 监听窗口大小变化 // 监听窗口大小变化
const handleResize = () => { const handleResize = () => {
if (container) { if (container) {
const newWidth = container.clientWidth const newWidth = container.clientWidth
if (Math.abs(newWidth - containerWidth.value) > 50) { // 只有明显变化时才重新计算 if (Math.abs(newWidth - containerWidth.value) > 50) { // 只有明显变化时才重新计算
containerWidth.value = newWidth containerWidth.value = newWidth
// 重新计算所有照片布局 recalculatePhotosLayout()
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) window.addEventListener('resize', handleResize)
// 清理事件监听器 // 清理事件监听器
return () => { return () => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
@@ -265,6 +342,62 @@ onMounted(async () => {
height: 100%; height: 100%;
} }
/* 加载状态样式 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 16px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top: 3px solid var(--primary-color, #007bff);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 16px;
color: var(--text-color-secondary);
}
/* 空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 12px;
}
.empty-icon {
font-size: 64px;
opacity: 0.5;
}
.empty-text {
font-size: 20px;
font-weight: 500;
color: var(--text-color);
}
.empty-subtitle {
font-size: 14px;
color: var(--text-color-secondary);
opacity: 0.7;
}
/* 月份标题样式 */ /* 月份标题样式 */
.month-header { .month-header {
padding: 40px 20px 20px 20px; padding: 40px 20px 20px 20px;
@@ -339,11 +472,9 @@ onMounted(async () => {
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative;
} }
.photo-item:hover { .photo-item:hover {
@@ -352,16 +483,46 @@ onMounted(async () => {
z-index: 1; z-index: 1;
} }
.photo-placeholder { .photo-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.photo-image:not([src]) {
opacity: 0;
}
/* 图片加载失败显示 */
.photo-error {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
color: var(--text-color-secondary);
gap: 4px;
}
.photo-error span:first-child {
font-size: 20px;
opacity: 0.5;
}
/* 开发调试信息 */
.photo-debug-info {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.7);
color: white; color: white;
font-weight: bold; padding: 2px 6px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); border-radius: 4px;
font-size: 10px;
pointer-events: none;
} }
.photo-ratio { .photo-ratio {
@@ -388,21 +549,21 @@ onMounted(async () => {
.month-header { .month-header {
padding: 32px 16px 16px 16px; padding: 32px 16px 16px 16px;
} }
.month-title { .month-title {
font-size: 24px; font-size: 24px;
} }
.memory-card { .memory-card {
margin: 12px 16px; margin: 12px 16px;
} }
.date-card { .date-card {
padding: 12px 16px 8px 16px; padding: 12px 16px 8px 16px;
} }
.photos-grid { .photos-grid {
margin: 0 16px 20px 16px; margin: 0 16px 20px 16px;
} }
} }
</style> </style>