From 2bb8a83bbc98bd1e3e9970569340a0c9e7e6d919 Mon Sep 17 00:00:00 2001 From: lzw-723 Date: Sun, 5 Apr 2026 19:39:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=8E=92=E5=BA=8F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/domain/repositories/photo_repository.dart | 55 +++++++++-- bin/router.dart | 6 ++ .../repositories/photo_repository_test.dart | 97 +++++++------------ web/src/lib/api/client.js | 6 +- web/src/lib/components/photo/PhotoCard.svelte | 9 +- web/src/lib/components/photo/PhotoGrid.svelte | 10 +- web/src/routes/album/[id]/+page.server.js | 9 +- web/src/routes/album/[id]/+page.svelte | 66 ++++++++++++- web/src/routes/photo/[id]/+page.server.js | 15 ++- 9 files changed, 193 insertions(+), 80 deletions(-) diff --git a/bin/domain/repositories/photo_repository.dart b/bin/domain/repositories/photo_repository.dart index 1507804..b9c86a8 100644 --- a/bin/domain/repositories/photo_repository.dart +++ b/bin/domain/repositories/photo_repository.dart @@ -13,19 +13,17 @@ class PhotoRepository { PhotoRepository(this.basePath); - /// 获取相册中所有照片(不分页,保留兼容) - Future> getPhotosByAlbumId(int id) async { - final all = await _loadPhotosForAlbum(id); - return all; - } /// 获取相册中的照片(分页) Future> getPhotosByAlbumIdPaged( int id, { int page = 1, int size = 20, + String sortBy = 'fileName', + String order = 'asc', }) async { final all = await _loadPhotosForAlbum(id); + all.sort(_createComparator(sortBy, order)); final total = all.length; final startIndex = (page - 1) * size; final endIndex = startIndex + size; @@ -90,12 +88,55 @@ class PhotoRepository { }); final photos = await Future.wait(photoFutures); - // 按文件名排序,确保跨平台分页结果一致 - photos.sort((a, b) => a.fileName.compareTo(b.fileName)); _map[id] = List.from(photos); return photos; } + /// 创建排序比较器 + int Function(Photo, Photo) _createComparator( + String sortBy, + String order, + ) { + final ascending = order == 'asc'; + + int compare(Photo a, Photo b) { + // Null always sorts last regardless of ascending/descending + int nullAwareCompare(int? valueA, int? valueB) { + if (valueA == null && valueB == null) return 0; + if (valueA == null) return 1; + if (valueB == null) return -1; + return ascending + ? valueA.compareTo(valueB) + : valueB.compareTo(valueA); + } + + switch (sortBy) { + case 'fileName': + return ascending + ? a.fileName.compareTo(b.fileName) + : b.fileName.compareTo(a.fileName); + case 'fileSize': + return ascending + ? a.fileSize.compareTo(b.fileSize) + : b.fileSize.compareTo(a.fileSize); + case 'createdAt': + return ascending + ? a.createdAt.compareTo(b.createdAt) + : b.createdAt.compareTo(a.createdAt); + case 'width': + return nullAwareCompare(a.width, b.width); + case 'height': + return nullAwareCompare(a.height, b.height); + default: + return ascending + ? a.fileName.compareTo(b.fileName) + : b.fileName.compareTo(a.fileName); + } + } + + return compare; + } + Future getPhotoById(int id) async { try { return _map.values.expand((l) => l).firstWhere((p) => p.id == id); diff --git a/bin/router.dart b/bin/router.dart index add0974..d0e51ba 100644 --- a/bin/router.dart +++ b/bin/router.dart @@ -204,10 +204,16 @@ Future _getPhotosOfAlbumHandler( final page = int.tryParse(req.url.queryParameters['page'] ?? '1') ?? 1; final size = int.tryParse(req.url.queryParameters['size'] ?? '20') ?? 20; + // 获取排序参数 + final sortBy = req.url.queryParameters['sort'] ?? 'fileName'; + final order = req.url.queryParameters['order'] ?? 'asc'; + final result = await repo.getPhotosByAlbumIdPaged( albumId, page: page, size: size, + sortBy: sortBy, + order: order, ); return jsonResponse({ diff --git a/test/unit/domain/repositories/photo_repository_test.dart b/test/unit/domain/repositories/photo_repository_test.dart index 2802b05..569698c 100644 --- a/test/unit/domain/repositories/photo_repository_test.dart +++ b/test/unit/domain/repositories/photo_repository_test.dart @@ -269,23 +269,21 @@ void main() { // ===== Tests ===== - group('getPhotosByAlbumId', () { + group('getPhotosByAlbumIdPaged', () { test( 'should return empty list when base directory does not exist', () async { final repo = PhotoRepository('/nonexistent/path'); - final photos = await repo.getPhotosByAlbumId(123); - expect(photos, isEmpty); + final photos = (await repo.getPhotosByAlbumIdPaged(123)).items; expect(photos, isEmpty); }, ); test( 'should return empty list when album directory does not exist', () async { - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'nonexistent').hashCode, - ); - expect(photos, isEmpty); + )).items; expect(photos, isEmpty); }, ); @@ -297,8 +295,7 @@ void main() { _createTestGif(albumDir, 'photo2.gif', width: 150, height: 250); final albumId = p.join(tempDir.path, 'my_album').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 2); expect( photos.map((p) => p.fileName), @@ -313,8 +310,7 @@ void main() { File(p.join(albumDir.path, 'readme.md')).writeAsString('# Readme'); final albumId = p.join(tempDir.path, 'mixed').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 1); expect(photos.first.fileName, 'image.png'); }); @@ -326,8 +322,7 @@ void main() { _createTestPng(albumDir, 'test.png', width: 640, height: 480); final albumId = p.join(tempDir.path, 'png_test').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 1); expect(photos.first.width, 640); expect(photos.first.height, 480); @@ -341,8 +336,7 @@ void main() { _createTestGif(albumDir, 'test.gif', width: 320, height: 240); final albumId = p.join(tempDir.path, 'gif_test').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 1); expect(photos.first.width, 320); expect(photos.first.height, 240); @@ -356,8 +350,7 @@ void main() { _createTestBmp(albumDir, 'test.bmp', width: 800, height: 600); final albumId = p.join(tempDir.path, 'bmp_test').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 1); expect(photos.first.width, 800); expect(photos.first.height, 600); @@ -371,8 +364,7 @@ void main() { _createTestWebpVp8(albumDir, 'test.webp', width: 1920, height: 1080); final albumId = p.join(tempDir.path, 'webp_test').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 1); expect(photos.first.width, 1920); expect(photos.first.height, 1080); @@ -388,8 +380,7 @@ void main() { ).writeAsBytesSync(List.filled(100, 0)); final albumId = p.join(tempDir.path, 'video_test').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 1); expect(photos.first.width, isNull); expect(photos.first.height, isNull); @@ -405,8 +396,7 @@ void main() { ).writeAsBytesSync(List.filled(100, 0)); final albumId = p.join(tempDir.path, 'video_album').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.length, 1); expect(photos.first.fileName, 'movie.mp4'); }); @@ -418,8 +408,7 @@ void main() { _createTestPng(albumDir, 'test.png', width: 100, height: 100); final albumId = p.join(tempDir.path, 'meta_test').hashCode; - final photos = await repository.getPhotosByAlbumId(albumId); - + final photos = (await repository.getPhotosByAlbumIdPaged(albumId)).items; expect(photos.first.albumId, albumId); expect(photos.first.fileName, 'test.png'); expect(photos.first.fileSize, greaterThan(0)); @@ -445,10 +434,9 @@ void main() { ).create(); _createTestPng(albumDir, 'unique.png', width: 50, height: 50); - final allPhotos = await repository.getPhotosByAlbumId( + final allPhotos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'find_album').hashCode, - ); - final targetId = allPhotos.first.id; + )).items; final targetId = allPhotos.first.id; final photo = await repository.getPhotoById(targetId); @@ -464,10 +452,9 @@ void main() { ).create(); _createTestBmp(albumDir, 'dims.bmp', width: 1024, height: 768); - final allPhotos = await repository.getPhotosByAlbumId( + final allPhotos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'dims_album').hashCode, - ); - final targetId = allPhotos.first.id; + )).items; final targetId = allPhotos.first.id; final photo = await repository.getPhotoById(targetId); @@ -485,10 +472,9 @@ void main() { File(p.join(albumDir.path, 'test.jpg')).writeAsBytesSync([0xFF, 0xD8]); File(p.join(albumDir.path, 'test2.jpeg')).writeAsBytesSync([0xFF, 0xD8]); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'mime_jpg').hashCode, - ); - expect(photos.length, 2); + )).items; expect(photos.length, 2); expect(photos.every((p) => p.mimeType == 'image/jpeg'), isTrue); }); @@ -500,10 +486,9 @@ void main() { p.join(albumDir.path, 'test.png'), ).writeAsBytesSync(List.filled(100, 0)); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'mime_png').hashCode, - ); - expect(photos.first.mimeType, 'image/png'); + )).items; expect(photos.first.mimeType, 'image/png'); }); test('should detect webp as image/webp', () async { @@ -514,10 +499,9 @@ void main() { p.join(albumDir.path, 'test.webp'), ).writeAsBytesSync(List.filled(100, 0)); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'mime_webp').hashCode, - ); - expect(photos.first.mimeType, 'image/webp'); + )).items; expect(photos.first.mimeType, 'image/webp'); }); test('should detect mp4 as video/mp4', () async { @@ -528,10 +512,9 @@ void main() { p.join(albumDir.path, 'test.mp4'), ).writeAsBytesSync(List.filled(100, 0)); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'mime_mp4').hashCode, - ); - expect(photos.first.mimeType, 'video/mp4'); + )).items; expect(photos.first.mimeType, 'video/mp4'); }); test('should detect avi as video/avi', () async { @@ -542,10 +525,9 @@ void main() { p.join(albumDir.path, 'test.avi'), ).writeAsBytesSync(List.filled(100, 0)); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'mime_avi').hashCode, - ); - expect(photos.first.mimeType, 'video/avi'); + )).items; expect(photos.first.mimeType, 'video/avi'); }); test('should detect mov as video/quicktime', () async { @@ -556,10 +538,9 @@ void main() { p.join(albumDir.path, 'test.mov'), ).writeAsBytesSync(List.filled(100, 0)); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'mime_mov').hashCode, - ); - expect(photos.first.mimeType, 'video/quicktime'); + )).items; expect(photos.first.mimeType, 'video/quicktime'); }); test('should detect mkv as video/x-matroska', () async { @@ -570,10 +551,9 @@ void main() { p.join(albumDir.path, 'test.mkv'), ).writeAsBytesSync(List.filled(100, 0)); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'mime_mkv').hashCode, - ); - expect(photos.first.mimeType, 'video/x-matroska'); + )).items; expect(photos.first.mimeType, 'video/x-matroska'); }); }); @@ -590,10 +570,9 @@ void main() { ).writeAsBytesSync(List.filled(10, 0)); } - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'ext_test').hashCode, - ); - expect(photos.length, extensions.length); + )).items; expect(photos.length, extensions.length); }); test('should recognize all video extensions', () async { @@ -608,10 +587,9 @@ void main() { ).writeAsBytesSync(List.filled(10, 0)); } - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'ext_video').hashCode, - ); - expect(photos.length, extensions.length); + )).items; expect(photos.length, extensions.length); }); test('should ignore unsupported extensions', () async { @@ -623,10 +601,9 @@ void main() { File(p.join(albumDir.path, 'skip.pdf')).writeAsBytesSync([]); File(p.join(albumDir.path, 'skip.exe')).writeAsBytesSync([]); - final photos = await repository.getPhotosByAlbumId( + final photos =(await repository.getPhotosByAlbumIdPaged( p.join(tempDir.path, 'ext_ignore').hashCode, - ); - expect(photos.length, 1); + )).items; expect(photos.length, 1); expect(photos.first.fileName, 'valid.png'); }); }); diff --git a/web/src/lib/api/client.js b/web/src/lib/api/client.js index 2417f41..78490bf 100644 --- a/web/src/lib/api/client.js +++ b/web/src/lib/api/client.js @@ -40,12 +40,16 @@ export async function getAlbum(id) { * @param {Object} [options] * @param {number} [options.page] - 页码(从 1 开始) * @param {number} [options.size] - 每页数量 + * @param {string} [options.sort] - 排序字段 + * @param {string} [options.order] - 排序方向 (asc/desc) * @returns {Promise} */ export async function getAlbumPhotos(albumId, options = {}) { const params = new URLSearchParams(); if (options.page) params.set('page', String(options.page)); if (options.size) params.set('size', String(options.size)); + if (options.sort) params.set('sort', String(options.sort)); + if (options.order) params.set('order', String(options.order)); const query = params.toString(); const endpoint = query @@ -73,7 +77,7 @@ export function getPhotoFileUrl(id) { } /** - * Get photo preview URL + * and photo preview URL * @param {number} id * @returns {string} */ diff --git a/web/src/lib/components/photo/PhotoCard.svelte b/web/src/lib/components/photo/PhotoCard.svelte index 7d8d0ac..1699582 100644 --- a/web/src/lib/components/photo/PhotoCard.svelte +++ b/web/src/lib/components/photo/PhotoCard.svelte @@ -8,14 +8,19 @@ * @property {boolean} [borderLess] * @property {boolean} [waterfall] * @property {number} [index] + * @property {string} [sortMode] + * @property {string} [sortOrder] */ /** @type {PhotoCardProps} */ - let { photo, onUpgradeQuality, borderLess = false, waterfall = false, index = 0 } = $props(); + let { photo, onUpgradeQuality, borderLess = false, waterfall = false, index = 0, sortMode = 'fileName', sortOrder = 'asc' } = $props(); // 使用 $derived 确保响应式更新 let previewSrc = $derived(`/api/v1/photo/${photo.id}/preview`); + // 构建带排序参数的照片详情页链接 + let photoLink = $derived(resolve(`/photo/${photo.id}?sort=${sortMode}&order=${sortOrder}`)); + // 根据原始宽高比计算瀑布流显示比例,防止图片加载前容器塌陷 let aspectRatioStyle = $derived( waterfall && photo.width && photo.height @@ -39,7 +44,7 @@ } - +
{#if photo.mimeType?.startsWith('video/')}
🎬
diff --git a/web/src/lib/components/photo/PhotoGrid.svelte b/web/src/lib/components/photo/PhotoGrid.svelte index 0b182e3..ac7d58e 100644 --- a/web/src/lib/components/photo/PhotoGrid.svelte +++ b/web/src/lib/components/photo/PhotoGrid.svelte @@ -16,6 +16,8 @@ * @property {HTMLDivElement | null} [loadMoreTrigger] * @property {boolean} [borderLess] * @property {boolean} [waterfall] + * @property {string} [sortMode] + * @property {string} [sortOrder] */ /** @type {PhotoGridProps} */ @@ -29,7 +31,9 @@ scrollContainer = $bindable(null), loadMoreTrigger = $bindable(null), borderLess = false, - waterfall = false + waterfall = false, + sortMode = 'fileName', + sortOrder = 'asc' } = $props(); function getVisiblePhotos() { @@ -100,7 +104,7 @@ {#each splitToColumns(getVisiblePhotos(), columnCount) as col}
{#each col as photo (photo.id)} - + {/each}
{/each} @@ -108,7 +112,7 @@ {:else}
{#each getVisiblePhotos() as photo, i (photo.id)} - + {/each}
{/if} diff --git a/web/src/routes/album/[id]/+page.server.js b/web/src/routes/album/[id]/+page.server.js index 6cf76f2..a3183c1 100644 --- a/web/src/routes/album/[id]/+page.server.js +++ b/web/src/routes/album/[id]/+page.server.js @@ -8,11 +8,18 @@ export async function load({ params, fetch, url }) { const page = parseInt(url.searchParams.get('page') || '1', 10); const pageSize = parseInt(url.searchParams.get('pageSize') || '100', 10); + // 支持排序参数 + const sort = url.searchParams.get('sort') || 'fileName'; + const order = url.searchParams.get('order') || 'asc'; + try { const [album, photos] = await Promise.all([ fetchApi(`/album/${albumId}`, fetch), // 后端使用 size 作为分页参数名 - fetchApi(`/album/${albumId}/photo?page=${page}&size=${pageSize}`, fetch) + fetchApi( + `/album/${albumId}/photo?page=${page}&size=${pageSize}&sort=${sort}&order=${order}`, + fetch + ) ]); if (!album) { diff --git a/web/src/routes/album/[id]/+page.svelte b/web/src/routes/album/[id]/+page.svelte index 1b77c7c..b6d5da6 100644 --- a/web/src/routes/album/[id]/+page.svelte +++ b/web/src/routes/album/[id]/+page.svelte @@ -6,6 +6,7 @@ import { resolve } from '$app/paths'; import { SvelteSet } from 'svelte/reactivity'; import { Container, PageHeader, BackLink, PhotoGrid, Empty, SegmentedControl } from '$lib/components'; + import { goto } from '$app/navigation'; let { data } = $props(); @@ -42,6 +43,11 @@ // 布局模式:砖块 / 瀑布(Grid / Columns) let layoutMode = $state('grid'); + // 排序模式 + let sortMode = $state(page.data.album ? (page.url.searchParams.get('sort') || 'fileName') : 'fileName'); + // 排序方向 + let sortOrder = $state(page.data.album ? (page.url.searchParams.get('order') || 'asc') : 'asc'); + const styleOptions = [ { value: 'card', label: '卡片', icon: '🖼️' }, { value: 'borderless', label: '紧凑', icon: '📐' } @@ -52,6 +58,12 @@ { value: 'waterfall', label: '瀑布', icon: '🌊' } ]; + const sortOptions = [ + { value: 'fileName', label: '名称' }, + { value: 'createdAt', label: '时间' }, + { value: 'fileSize', label: '大小' } + ]; + // 监听数据变化,重置状态 $effect(() => { if (data.photos) { @@ -105,9 +117,9 @@ currentPage++; try { - // 后端使用 size 作为分页参数名 + // 后端使用 size 作为分页参数名,同时传递排序参数 const response = await fetch( - `${page.url.pathname}?page=${currentPage}&pageSize=${PAGE_SIZE}` + `${page.url.pathname}?page=${currentPage}&pageSize=${PAGE_SIZE}&sort=${sortMode}&order=${sortOrder}` ); const newData = await response.json(); @@ -127,6 +139,15 @@ } } + // 处理排序变化 + function applySort() { + // 重置分页状态并导航到新 URL + goto(`${page.url.pathname}?page=1&pageSize=${PAGE_SIZE}&sort=${sortMode}&order=${sortOrder}`, { + replaceState: true, + keepFocus: true + }); + } + /** * 当用户悬停或聚焦图片时,升级图片质量 * @param {number} photoId @@ -183,6 +204,20 @@ options={layoutOptions} size="small" /> + { sortMode = v; applySort(); }} + options={sortOptions} + size="small" + /> +
{/if} @@ -201,6 +236,8 @@ bind:loadMoreTrigger borderLess={styleMode === 'borderless'} waterfall={layoutMode === 'waterfall'} + sortMode={sortMode} + sortOrder={sortOrder} /> {/if} @@ -221,5 +258,30 @@ .controls { display: flex; gap: var(--space-sm); + align-items: center; + } + + .sort-order-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); + cursor: pointer; + border-radius: var(--radius-md); + padding: 0.25rem 0.5rem; + font-size: var(--font-size-sm); + transition: all var(--transition-normal); + } + + .sort-order-toggle:hover { + color: var(--color-text); + background: var(--color-border); + } + + .sort-icon { + font-size: 0.85rem; + line-height: 1; } diff --git a/web/src/routes/photo/[id]/+page.server.js b/web/src/routes/photo/[id]/+page.server.js index a54de60..fca36cd 100644 --- a/web/src/routes/photo/[id]/+page.server.js +++ b/web/src/routes/photo/[id]/+page.server.js @@ -1,9 +1,13 @@ import { fetchApi } from '$lib/api/client'; /** @type {import('./$types').PageServerLoad} */ -export async function load({ params, fetch }) { +export async function load({ params, fetch, url }) { const photoId = params.id; + // 保留排序参数用于 prev/next 导航 + const sort = url.searchParams.get('sort') || 'fileName'; + const order = url.searchParams.get('order') || 'asc'; + try { // 获取当前照片信息 const photo = await fetchApi(`/photo/${photoId}`, fetch); @@ -26,11 +30,14 @@ export async function load({ params, fetch }) { let prevPhotoId = null; let nextPhotoId = null; - // 如果有 albumId,尝试获取相册中的照片列表 + // 如果有 albumId,尝试获取相册中的照片列表(使用相同排序) if (photo.albumId) { try { - const albumPhotos = await fetchApi(`/album/${photo.albumId}/photo`, fetch); - const photos = Array.isArray(albumPhotos) ? albumPhotos : []; + const albumPhotos = await fetchApi( + `/album/${photo.albumId}/photo?sort=${sort}&order=${order}`, + fetch + ); + const photos = Array.isArray(albumPhotos) ? albumPhotos : albumPhotos?.items ?? []; if (photos.length > 0) { const index = photos.findIndex((p) => String(p.id) === String(photoId));