@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user