diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml index 23267a6..3c002e5 100644 --- a/.idea/caches/deviceStreaming.xml +++ b/.idea/caches/deviceStreaming.xml @@ -784,6 +784,18 @@ + + + + + + + + + + + + diff --git a/bin/router.dart b/bin/router.dart index 3c8b4d7..2dc5eae 100644 --- a/bin/router.dart +++ b/bin/router.dart @@ -18,7 +18,8 @@ Response jsonResponse(dynamic data) { ); } -Router createRouter() { +/// 创建 API v1 版本的路由 +Router createApiV1Router() { final router = Router() ..get('/', _rootHandler) ..get("/album", _listAlbumHandler) @@ -31,6 +32,16 @@ Router createRouter() { return router; } +/// 创建主路由器,将 API v1 挂载到 /api/v1 路径下 +Router createRouter() { + final router = Router(); + + // 将 API v1 路由挂载到 /api/v1 路径下 + router.mount('/api/v1', createApiV1Router().call); + + return router; +} + Response _rootHandler(Request req) { return Response.ok('Hello, World!\n'); } @@ -113,8 +124,26 @@ Future _getPhotoFileHandler(Request req) async { return Response.notFound('File not found'); } + // ✅ 修复:使用 StreamController 包装,确保在客户端断开时关闭文件流 + final streamController = StreamController>(); + 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( - file.openRead(), + streamController.stream, headers: { 'Content-Type': photo.mimeType, 'Content-Length': photo.fileSize.toString(), @@ -122,8 +151,6 @@ Future _getPhotoFileHandler(Request req) async { ); } - - Future _getPhotosOfAlbumHander(Request req) async { final idParam = req.params['id']; if (idParam == null) { diff --git a/test/server_test.dart b/test/server_test.dart index 741f55b..bbc4210 100644 --- a/test/server_test.dart +++ b/test/server_test.dart @@ -28,30 +28,30 @@ void main() { }); group('Root endpoint', () { - test('GET / returns Hello World', () async { - final response = await http.get(Uri.parse('$host/')); + test('GET /api/v1/ returns Hello World', () async { + final response = await http.get(Uri.parse('$host/api/v1/')); expect(response.statusCode, 200); expect(response.body, 'Hello, World!\n'); }); }); group('Echo endpoint', () { - test('GET /echo/ returns the message', () async { - final response = await http.get(Uri.parse('$host/echo/hello')); + test('GET /api/v1/echo/ returns the message', () async { + final response = await http.get(Uri.parse('$host/api/v1/echo/hello')); expect(response.statusCode, 200); expect(response.body, 'hello\n'); }); - test('GET /echo/ with special characters', () async { - final response = await http.get(Uri.parse('$host/echo/test%20message')); + test('GET /api/v1/echo/ with special characters', () async { + final response = await http.get(Uri.parse('$host/api/v1/echo/test%20message')); expect(response.statusCode, 200); expect(response.body, contains('test')); }); }); group('Album endpoints', () { - test('GET /album returns list of albums', () async { - final response = await http.get(Uri.parse('$host/album')); + test('GET /api/v1/album returns list of albums', () async { + final response = await http.get(Uri.parse('$host/api/v1/album')); expect(response.statusCode, 200); expect(response.headers['content-type'], contains('application/json')); final albums = jsonDecode(response.body) as List; @@ -62,27 +62,27 @@ void main() { } }); - test('GET /album/ with invalid id returns 400', () async { - final response = await http.get(Uri.parse('$host/album/invalid_id')); + test('GET /api/v1/album/ with invalid id returns 400', () async { + final response = await http.get(Uri.parse('$host/api/v1/album/invalid_id')); expect(response.statusCode, 400); expect(response.body, contains('Invalid album id')); }); - test('GET /album/ with non-existent id returns 404', () async { - final response = await http.get(Uri.parse('$host/album/999999')); + test('GET /api/v1/album/ with non-existent id returns 404', () async { + final response = await http.get(Uri.parse('$host/api/v1/album/999999')); expect(response.statusCode, 404); expect(response.body, contains('Album not found')); }); - test('GET /album//photo with invalid id returns 400', () async { - final response = await http.get(Uri.parse('$host/album/invalid_id/photo')); + test('GET /api/v1/album//photo with invalid id returns 400', () async { + final response = await http.get(Uri.parse('$host/api/v1/album/invalid_id/photo')); expect(response.statusCode, 400); expect(response.body, contains('Invalid album id')); }); - test('GET /album//photo with valid id returns photos', () async { + test('GET /api/v1/album//photo with valid id returns photos', () async { // First get the list to find a valid album id - final listResponse = await http.get(Uri.parse('$host/album')); + final listResponse = await http.get(Uri.parse('$host/api/v1/album')); expect(listResponse.statusCode, 200); final albums = jsonDecode(listResponse.body) as List; @@ -94,7 +94,7 @@ void main() { final album = albums.first as Map; final albumId = album['id']; - final response = await http.get(Uri.parse('$host/album/$albumId/photo')); + final response = await http.get(Uri.parse('$host/api/v1/album/$albumId/photo')); expect(response.statusCode, 200); expect(response.headers['content-type'], contains('application/json')); final photos = jsonDecode(response.body) as List; @@ -103,35 +103,35 @@ void main() { }); group('Photo endpoints', () { - test('GET /photo/ with invalid id returns 400', () async { - final response = await http.get(Uri.parse('$host/photo/abc')); + test('GET /api/v1/photo/ with invalid id returns 400', () async { + final response = await http.get(Uri.parse('$host/api/v1/photo/abc')); expect(response.statusCode, 400); expect(response.body, contains('Invalid photo id')); }); - test('GET /photo/ with non-existent id returns 404', () async { + test('GET /api/v1/photo/ with non-existent id returns 404', () async { // Use a hash that won't match any file - final response = await http.get(Uri.parse('$host/photo/12345')); + final response = await http.get(Uri.parse('$host/api/v1/photo/12345')); expect(response.statusCode, 404); expect(response.body, contains('Photo not found')); }); - test('GET /photo//file with invalid id returns 400', () async { - final response = await http.get(Uri.parse('$host/photo/abc/file')); + test('GET /api/v1/photo//file with invalid id returns 400', () async { + final response = await http.get(Uri.parse('$host/api/v1/photo/abc/file')); expect(response.statusCode, 400); expect(response.body, contains('Invalid photo id')); }); - test('GET /photo//file with non-existent id returns 404', () async { + test('GET /api/v1/photo//file with non-existent id returns 404', () async { // Use a hash that won't match any file - final response = await http.get(Uri.parse('$host/photo/12345/file')); + final response = await http.get(Uri.parse('$host/api/v1/photo/12345/file')); expect(response.statusCode, 404); expect(response.body, contains('Photo not found')); }); - test('GET /photo//file with valid id returns file', () async { + test('GET /api/v1/photo//file with valid id returns file', () async { // First get a valid photo id from an album - final albumResponse = await http.get(Uri.parse('$host/album')); + final albumResponse = await http.get(Uri.parse('$host/api/v1/album')); expect(albumResponse.statusCode, 200); final albums = jsonDecode(albumResponse.body) as List; @@ -144,7 +144,7 @@ void main() { final albumId = album['id']; final photosResponse = await http.get( - Uri.parse('$host/album/$albumId/photo'), + Uri.parse('$host/api/v1/album/$albumId/photo'), ); expect(photosResponse.statusCode, 200); final photos = jsonDecode(photosResponse.body) as List; @@ -158,7 +158,7 @@ void main() { final photoId = photo['id']; final response = await http.get( - Uri.parse('$host/photo/$photoId/file'), + Uri.parse('$host/api/v1/photo/$photoId/file'), ); expect(response.statusCode, 200); expect(response.contentLength, greaterThan(0)); @@ -172,7 +172,7 @@ void main() { }); test('Unknown nested route returns 404', () async { - final response = await http.get(Uri.parse('$host/api/v1/unknown')); + final response = await http.get(Uri.parse('$host/api/v2/unknown')); expect(response.statusCode, 404); }); }); diff --git a/web/README.md b/web/README.md index 97dabcd..a6f799f 100644 --- a/web/README.md +++ b/web/README.md @@ -1,6 +1,14 @@ -# sv +# Loongyan Photo Album Web -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +网页相册前端,基于 SvelteKit 构建,支持中英文国际化。 + +## 功能特性 + +- 📁 相册列表展示 +- 🖼️ 照片浏览 +- 🌍 中英文国际化支持 +- 📱 响应式设计 +- ⌨️ 键盘导航支持(左右键切换照片,ESC 关闭) ## Creating a project @@ -18,17 +26,24 @@ To recreate this project with the same configuration: pnpm dlx sv@0.12.6 create --template minimal --types jsdoc --add prettier vitest="usages:unit,component" eslint sveltekit-adapter="adapter:auto" devtools-json better-auth="demo:password" mdsvex paraglide="languageTags:en, zh+demo:yes" drizzle="database:sqlite+sqlite:better-sqlite3" --install pnpm web/ ``` -## Developing +## 开发 -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +安装依赖并启动开发服务器: ```sh +pnpm install npm run dev -# or start the server and open the app in a new browser tab +# 或自动打开浏览器 npm run dev -- --open ``` +## API 配置 + +本项目需要配合后端 API 使用。默认情况下,API 请求会发送到同源服务器。 + +如果需要配置 API 地址,请修改 `src/lib/api/client.js` 中的 `API_BASE` 常量。 + ## Building To create a production version of your app: diff --git a/web/messages/en.json b/web/messages/en.json index 37a9894..499ec22 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1,4 +1,12 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from en!" + "hello_world": "Hello, {name} from en!", + "albums": "Albums", + "loading": "Loading...", + "no_albums": "No albums yet", + "error": "Error", + "back": "Back", + "photo_count": "{count} photos", + "view_photo": "View Photo", + "load_more": "All loaded" } diff --git a/web/messages/zh.json b/web/messages/zh.json index 79ece0c..9a8476e 100644 --- a/web/messages/zh.json +++ b/web/messages/zh.json @@ -1,4 +1,12 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from zh!" + "hello_world": "Hello, {name} from zh!", + "albums": "相册", + "loading": "加载中...", + "no_albums": "暂无相册", + "error": "错误", + "back": "返回", + "photo_count": "{count} 张照片", + "view_photo": "查看照片", + "load_more": "已全部加载" } diff --git a/web/src/lib/api/client.js b/web/src/lib/api/client.js new file mode 100644 index 0000000..b69f688 --- /dev/null +++ b/web/src/lib/api/client.js @@ -0,0 +1,60 @@ +const API_BASE = '/api/v1'; + +/** + * @param {string} endpoint + * @returns {Promise} + */ +async function fetchApi(endpoint) { + const response = await fetch(`${API_BASE}${endpoint}`); + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`API Error: ${response.status}`); + } + return response.json(); +} + +/** + * Get all albums + * @returns {Promise} + */ +export async function getAlbums() { + return fetchApi('/album'); +} + +/** + * Get album by id + * @param {number} id + * @returns {Promise} + */ +export async function getAlbum(id) { + return fetchApi(`/album/${id}`); +} + +/** + * Get photos of an album + * @param {number} albumId + * @returns {Promise} + */ +export async function getAlbumPhotos(albumId) { + return fetchApi(`/album/${albumId}/photo`); +} + +/** + * Get photo by id + * @param {number} id + * @returns {Promise} + */ +export async function getPhoto(id) { + return fetchApi(`/photo/${id}`); +} + +/** + * Get photo file URL + * @param {number} id + * @returns {string} + */ +export function getPhotoFileUrl(id) { + return `${API_BASE}/photo/${id}/file`; +} diff --git a/web/src/lib/api/types.js b/web/src/lib/api/types.js new file mode 100644 index 0000000..b6acbc0 --- /dev/null +++ b/web/src/lib/api/types.js @@ -0,0 +1,22 @@ +/** + * @typedef {Object} Album + * @property {number} id + * @property {string} name + * @property {string} createdAt + * @property {string} updatedAt + */ + +/** + * @typedef {Object} Photo + * @property {number} id + * @property {number} albumId + * @property {string} filePath + * @property {string} fileName + * @property {number} fileSize + * @property {string} mimeType + * @property {number|null} width + * @property {number|null} height + * @property {string} createdAt + */ + +export const types = {}; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index cc88df0..d5436a6 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,2 +1,124 @@ -Welcome to SvelteKit -Visit svelte.dev/docs/kit to read the documentation + + + + {albums()} + + + + + {albums()} + + + {#if loading_state} + {loading()} + {:else if error} + {error} + {:else if albums_list.length === 0} + {no_albums()} + {:else} + + {#each albums_list as album (album.id)} + + 📁 + {album.name} + + {new Date(album.createdAt).toLocaleDateString()} + + + {/each} + + {/if} + + + diff --git a/web/src/routes/album/[id]/+page.svelte b/web/src/routes/album/[id]/+page.svelte new file mode 100644 index 0000000..844e237 --- /dev/null +++ b/web/src/routes/album/[id]/+page.svelte @@ -0,0 +1,302 @@ + + + + {album ? `${album.name} - ${albums()}` : loading()} + + + + + ← {back()} + {#if album} + {album.name} + + {photo_count({ count: photos.length })} + + {/if} + + + {#if loading_state} + {loading()} + {:else if error} + {error} + {:else if photos.length === 0} + {no_albums()} + {:else} + + + {#each getVisiblePhotos() as photo (photo.id)} + + + {#if photo.mimeType.startsWith('video/')} + 🎬 + + {photo.fileName} + + {:else} + + {/if} + + {photo.fileName} + + {/each} + + + {#if displayedCount < photos.length} + + {loading()} + + {:else} + + ✓ 已加载全部 {photos.length} 张照片 + + {/if} + + {/if} + + + diff --git a/web/src/routes/photo/[id]/+page.svelte b/web/src/routes/photo/[id]/+page.svelte new file mode 100644 index 0000000..b8c245e --- /dev/null +++ b/web/src/routes/photo/[id]/+page.svelte @@ -0,0 +1,273 @@ + + + + {photo ? photo.fileName : loading()} + + + + {#if loading_state} + {m.loading()} + {:else if error} + {m.error()} + {:else if photo} + + ← {m.back()} + + {currentIndex + 1} / {albumPhotos.length} + + + + + + ‹ + + + + {#if photo.mimeType.startsWith('video/')} + + + + {:else} + + {/if} + + + + › + + + + + {photo.fileName} + + {Math.round(photo.fileSize / 1024)} KB • + {photo.width && photo.height ? `${photo.width}×${photo.height}` : ''} • + {new Date(photo.createdAt).toLocaleDateString()} + + + {/if} + + + diff --git a/web/vite.config.js b/web/vite.config.js index 20f7de4..9bdb625 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -10,6 +10,11 @@ export default defineConfig({ devtoolsJson(), paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }) ], + server: { + proxy: { + '/api': 'http://localhost:8080' + } + }, test: { expect: { requireAssertions: true }, projects: [
Visit svelte.dev/docs/kit to read the documentation
+ {new Date(album.createdAt).toLocaleDateString()} +
+ {photo_count({ count: photos.length })} +
{photo.fileName}
+ {Math.round(photo.fileSize / 1024)} KB • + {photo.width && photo.height ? `${photo.width}×${photo.height}` : ''} • + {new Date(photo.createdAt).toLocaleDateString()} +