实现排序功能
Some checks failed
Dart CI / build (push) Failing after 9s

This commit is contained in:
2026-04-05 19:39:37 +08:00
parent 90f7159746
commit 2bb8a83bbc
9 changed files with 193 additions and 80 deletions

View File

@@ -13,19 +13,17 @@ class PhotoRepository {
PhotoRepository(this.basePath);
/// 获取相册中所有照片(不分页,保留兼容)
Future<List<Photo>> getPhotosByAlbumId(int id) async {
final all = await _loadPhotosForAlbum(id);
return all;
}
/// 获取相册中的照片(分页)
Future<PageResult<Photo>> 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<Photo?> getPhotoById(int id) async {
try {
return _map.values.expand((l) => l).firstWhere((p) => p.id == id);

View File

@@ -204,10 +204,16 @@ Future<Response> _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({

View File

@@ -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');
});
});

View File

@@ -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<import('./types').Photo[] | { items: import('./types').Photo[], total: number, page: number, size: number }>}
*/
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}
*/

View File

@@ -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 @@
}
</script>
<a href={resolve(`/photo/${photo.id}`)} class="photo-card" class:photo-card-waterfall={waterfall} class:photo-card-compact-waterfall={borderLess && waterfall} style={`--wf-aspect: ${aspectRatioStyle}; --wf-delay: ${animationDelay};`}>
<a href={photoLink} class="photo-card" class:photo-card-waterfall={waterfall} class:photo-card-compact-waterfall={borderLess && waterfall} style={`--wf-aspect: ${aspectRatioStyle}; --wf-delay: ${animationDelay};`}>
<div class="photo-wrapper" class:photo-borderless={borderLess} class:photo-waterfall={waterfall}>
{#if photo.mimeType?.startsWith('video/')}
<div class="video-indicator">🎬</div>

View File

@@ -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}
<div class="waterfall-column">
{#each col as photo (photo.id)}
<PhotoCard {photo} {onUpgradeQuality} {borderLess} {waterfall} />
<PhotoCard {photo} {onUpgradeQuality} {borderLess} {waterfall} {sortMode} {sortOrder} />
{/each}
</div>
{/each}
@@ -108,7 +112,7 @@
{:else}
<div class="photo-grid" class:photo-grid-borderless={borderLess}>
{#each getVisiblePhotos() as photo, i (photo.id)}
<PhotoCard {photo} {onUpgradeQuality} {borderLess} {waterfall} index={i} />
<PhotoCard {photo} {onUpgradeQuality} {borderLess} {waterfall} index={i} {sortMode} {sortOrder} />
{/each}
</div>
{/if}

View File

@@ -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) {

View File

@@ -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"
/>
<SegmentedControl
value={sortMode}
onchange={(v) => { sortMode = v; applySort(); }}
options={sortOptions}
size="small"
/>
<button
class="sort-order-toggle"
type="button"
onclick={() => { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; applySort(); }}
aria-label={sortOrder === 'asc' ? '降序' : '升序'}
>
<span class="sort-icon">{sortOrder === 'asc' ? '↑' : '↓'}</span>
</button>
</div>
</div>
{/if}
@@ -201,6 +236,8 @@
bind:loadMoreTrigger
borderLess={styleMode === 'borderless'}
waterfall={layoutMode === 'waterfall'}
sortMode={sortMode}
sortOrder={sortOrder}
/>
{/if}
</Container>
@@ -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;
}
</style>

View File

@@ -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));