添加图片预览api
This commit is contained in:
@@ -9,3 +9,4 @@ build/
|
|||||||
.packages
|
.packages
|
||||||
|
|
||||||
data
|
data
|
||||||
|
cache
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
|||||||
.dart_tool/
|
.dart_tool/
|
||||||
|
|
||||||
data
|
data
|
||||||
|
cache
|
||||||
@@ -5,9 +5,11 @@ import 'dart:async';
|
|||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
import 'util/vips.dart';
|
||||||
import 'domain/repositories/photo_repository.dart';
|
import 'domain/repositories/photo_repository.dart';
|
||||||
import 'domain/repositories/album_repository.dart';
|
import 'domain/repositories/album_repository.dart';
|
||||||
|
|
||||||
|
final vips = Vips();
|
||||||
final albumRepository = AlbumRepository(basePath: 'data');
|
final albumRepository = AlbumRepository(basePath: 'data');
|
||||||
final photoRepository = PhotoRepository('data');
|
final photoRepository = PhotoRepository('data');
|
||||||
|
|
||||||
@@ -24,9 +26,10 @@ Router createApiV1Router() {
|
|||||||
..get('/', _rootHandler)
|
..get('/', _rootHandler)
|
||||||
..get("/album", _listAlbumHandler)
|
..get("/album", _listAlbumHandler)
|
||||||
..get("/album/<id>", _getAlbumHandler)
|
..get("/album/<id>", _getAlbumHandler)
|
||||||
..get("/album/<id>/photo", _getPhotosOfAlbumHander)
|
..get("/album/<id>/photo", _getPhotosOfAlbumHandler)
|
||||||
..get('/photo/<id>', _getPhotoHandler)
|
..get('/photo/<id>', _getPhotoHandler)
|
||||||
..get('/photo/<id>/file', _getPhotoFileHandler)
|
..get('/photo/<id>/file', _getPhotoFileHandler)
|
||||||
|
..get('/photo/<id>/preview', _getPhotoPreviewHandler)
|
||||||
..get('/echo/<message>', _echoHandler);
|
..get('/echo/<message>', _echoHandler);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
@@ -79,10 +82,7 @@ Future<Response> _getAlbumHandler(Request req) async {
|
|||||||
if (album == null) {
|
if (album == null) {
|
||||||
return Response.notFound('Album not found');
|
return Response.notFound('Album not found');
|
||||||
}
|
}
|
||||||
return jsonResponse({
|
return jsonResponse({'id': album.id, 'name': album.name});
|
||||||
'id': album.id,
|
|
||||||
'name': album.name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _getPhotoHandler(Request req) async {
|
Future<Response> _getPhotoHandler(Request req) async {
|
||||||
@@ -151,7 +151,7 @@ Future<Response> _getPhotoFileHandler(Request req) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _getPhotosOfAlbumHander(Request req) async {
|
Future<Response> _getPhotosOfAlbumHandler(Request req) async {
|
||||||
final idParam = req.params['id'];
|
final idParam = req.params['id'];
|
||||||
if (idParam == null) {
|
if (idParam == null) {
|
||||||
return Response.badRequest(body: 'Missing album id');
|
return Response.badRequest(body: 'Missing album id');
|
||||||
@@ -163,3 +163,51 @@ Future<Response> _getPhotosOfAlbumHander(Request req) async {
|
|||||||
final photos = await photoRepository.getPhotosByAlbumId(albumId);
|
final photos = await photoRepository.getPhotosByAlbumId(albumId);
|
||||||
return jsonResponse(photos);
|
return jsonResponse(photos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Response> _getPhotoPreviewHandler(Request req) async {
|
||||||
|
final idParam = req.params['id'];
|
||||||
|
if (idParam == null) {
|
||||||
|
return Response.badRequest(body: 'Missing photo id');
|
||||||
|
}
|
||||||
|
final id = int.tryParse(idParam);
|
||||||
|
if (id == null) {
|
||||||
|
return Response.badRequest(body: 'Invalid photo id: $idParam');
|
||||||
|
}
|
||||||
|
|
||||||
|
final photo = await photoRepository.getPhotoById(id);
|
||||||
|
if (photo == null) {
|
||||||
|
return Response.notFound('Photo not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = await vips.generatePreview(photo.filePath);
|
||||||
|
|
||||||
|
if (!await file.exists()) {
|
||||||
|
return Response.notFound('File not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 修复:使用 StreamController 包装,确保在客户端断开时关闭文件流
|
||||||
|
final streamController = StreamController<List<int>>();
|
||||||
|
final fileStream = file.openRead();
|
||||||
|
|
||||||
|
// 订阅文件流,并将数据转发到响应流
|
||||||
|
final subscription = fileStream.listen(
|
||||||
|
streamController.add,
|
||||||
|
onError: streamController.addError,
|
||||||
|
onDone: streamController.close,
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当客户端取消订阅时(断开连接),取消文件流订阅并关闭文件
|
||||||
|
streamController.onCancel = () {
|
||||||
|
subscription.cancel();
|
||||||
|
return Future.value();
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.ok(
|
||||||
|
streamController.stream,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': photo.mimeType,
|
||||||
|
'Content-Length': file.statSync().size.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
31
bin/util/vips.dart
Normal file
31
bin/util/vips.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
|
class Vips {
|
||||||
|
String? vipsExecuteFile;
|
||||||
|
|
||||||
|
Vips({this.vipsExecuteFile});
|
||||||
|
|
||||||
|
Future<File> generatePreview(String imgPath) async {
|
||||||
|
final imgOut =
|
||||||
|
"cache/preview/" + basename(imgPath).hashCode.toString() + ".webp";
|
||||||
|
final outFile = File(imgOut);
|
||||||
|
if (outFile.existsSync()) {
|
||||||
|
return outFile;
|
||||||
|
}
|
||||||
|
final result = await Process.run("vips", [
|
||||||
|
"webpsave",
|
||||||
|
imgPath,
|
||||||
|
imgOut,
|
||||||
|
"--Q",
|
||||||
|
"80",
|
||||||
|
"--smart-subsample",
|
||||||
|
"--strip",
|
||||||
|
]);
|
||||||
|
if (result.exitCode == 0) {
|
||||||
|
return outFile;
|
||||||
|
}
|
||||||
|
throw Exception("vips image transcode failed!");
|
||||||
|
}
|
||||||
|
}
|
||||||
10
web/.idea/.gitignore
generated
vendored
Normal file
10
web/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
63
web/.idea/codeStyles/Project.xml
generated
Normal file
63
web/.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<HTMLCodeStyleSettings>
|
||||||
|
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||||
|
</HTMLCodeStyleSettings>
|
||||||
|
<JSCodeStyleSettings version="0">
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</JSCodeStyleSettings>
|
||||||
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</TypeScriptCodeStyleSettings>
|
||||||
|
<VueCodeStyleSettings>
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||||
|
</VueCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="HTML">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="JavaScript">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="TypeScript">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Vue">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
5
web/.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
web/.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
6
web/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
web/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
web/.idea/modules.xml
generated
Normal file
8
web/.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/web.iml" filepath="$PROJECT_DIR$/.idea/web.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
web/.idea/prettier.xml
generated
Normal file
6
web/.idea/prettier.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="PrettierConfiguration">
|
||||||
|
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
web/.idea/vcs.xml
generated
Normal file
6
web/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
web/.idea/web.iml
generated
Normal file
8
web/.idea/web.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
@@ -58,3 +58,12 @@ export async function getPhoto(id) {
|
|||||||
export function getPhotoFileUrl(id) {
|
export function getPhotoFileUrl(id) {
|
||||||
return `${API_BASE}/photo/${id}/file`;
|
return `${API_BASE}/photo/${id}/file`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get photo preview URL
|
||||||
|
* @param {number} id
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getPhotoPreviewUrl(id) {
|
||||||
|
return `${API_BASE}/photo/${id}/preview`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { localizeHref } from '$lib/paraglide/runtime';
|
|
||||||
import { getAlbums } from '$lib/api/client';
|
import { getAlbums } from '$lib/api/client';
|
||||||
import { albums, loading, no_albums } from '$lib/paraglide/messages';
|
import { albums, loading, no_albums } from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { getAlbum, getAlbumPhotos, getPhotoFileUrl } from '$lib/api/client';
|
import { getAlbum, getAlbumPhotos, getPhotoFileUrl, getPhotoPreviewUrl } from '$lib/api/client';
|
||||||
import { albums, loading, no_albums, back, photo_count } from '$lib/paraglide/messages';
|
import { albums, loading, no_albums, back, photo_count } from '$lib/paraglide/messages';
|
||||||
|
|
||||||
/** @type {import('$lib/api/types').Album|null} */
|
/** @type {import('$lib/api/types').Album|null} */
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
src={getPhotoFileUrl(photo.id)}
|
src={getPhotoPreviewUrl(photo.id)}
|
||||||
alt={photo.fileName}
|
alt={photo.fileName}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { getPhoto, getPhotoFileUrl, getAlbumPhotos } from '$lib/api/client';
|
import { getPhoto, getPhotoFileUrl, getAlbumPhotos, getPhotoPreviewUrl } from '$lib/api/client';
|
||||||
import { m, loading } from '$lib/paraglide/messages';
|
import { m, loading } from '$lib/paraglide/messages';
|
||||||
|
|
||||||
/** @type {import('$lib/api/types').Photo|null} */
|
/** @type {import('$lib/api/types').Photo|null} */
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<source src={getPhotoFileUrl(photo.id)} type={photo.mimeType} />
|
<source src={getPhotoFileUrl(photo.id)} type={photo.mimeType} />
|
||||||
</video>
|
</video>
|
||||||
{:else}
|
{:else}
|
||||||
<img src={getPhotoFileUrl(photo.id)} alt={photo.fileName} />
|
<img src={getPhotoPreviewUrl(photo.id)} alt={photo.fileName} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user