A:完成虚拟列表初始展示
This commit is contained in:
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user