From acd2dcc3c84884a13b1f5ac27b531dff5092e011 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Wed, 23 Jul 2025 19:33:39 +0800 Subject: [PATCH] BLOG-44 feat: implement post management features with API integration and UI components --- frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 8 +++ frontend/src/app.css | 4 +- .../common/adapter/presenter/asyncState.ts | 26 ++++++++ frontend/src/lib/environment.ts | 5 ++ .../src/lib/home/framework/ui/HomePage.svelte | 11 ++++ .../src/lib/home/framework/ui/Motto.svelte | 4 +- .../src/lib/home/framework/ui/Terminal.svelte | 2 +- .../lib/home/framework/ui/TitleScreen.svelte | 4 +- .../post/adapter/gateway/labelResponseDto.ts | 37 ++++++++++++ .../post/adapter/gateway/postApiService.ts | 5 ++ .../adapter/gateway/postInfoResponseDto.ts | 60 +++++++++++++++++++ .../adapter/gateway/postRepositoryImpl.ts | 12 ++++ .../post/adapter/presenter/labelViewModel.ts | 19 ++++++ .../adapter/presenter/postInfoViewModel.ts | 38 ++++++++++++ .../post/adapter/presenter/postListBloc.ts | 44 ++++++++++++++ .../application/repository/postRepository.ts | 5 ++ .../application/useCase/getAllPostsUseCase.ts | 10 ++++ frontend/src/lib/post/domain/entity/label.ts | 11 ++++ .../src/lib/post/domain/entity/postInfo.ts | 26 ++++++++ .../post/framework/api/postApiServiceImpl.ts | 19 ++++++ .../lib/post/framework/ui/PostListPage.svelte | 23 +++++++ frontend/src/routes/+page.svelte | 10 +--- frontend/src/routes/post/+page.svelte | 17 ++++++ frontend/vite.config.ts | 9 +++ 25 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 frontend/src/lib/common/adapter/presenter/asyncState.ts create mode 100644 frontend/src/lib/environment.ts create mode 100644 frontend/src/lib/home/framework/ui/HomePage.svelte create mode 100644 frontend/src/lib/post/adapter/gateway/labelResponseDto.ts create mode 100644 frontend/src/lib/post/adapter/gateway/postApiService.ts create mode 100644 frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts create mode 100644 frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts create mode 100644 frontend/src/lib/post/adapter/presenter/labelViewModel.ts create mode 100644 frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts create mode 100644 frontend/src/lib/post/adapter/presenter/postListBloc.ts create mode 100644 frontend/src/lib/post/application/repository/postRepository.ts create mode 100644 frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts create mode 100644 frontend/src/lib/post/domain/entity/label.ts create mode 100644 frontend/src/lib/post/domain/entity/postInfo.ts create mode 100644 frontend/src/lib/post/framework/api/postApiServiceImpl.ts create mode 100644 frontend/src/lib/post/framework/ui/PostListPage.svelte create mode 100644 frontend/src/routes/post/+page.svelte diff --git a/frontend/package.json b/frontend/package.json index 223121c..84a9605 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,8 @@ "tailwindcss": "^4.0.0", "typescript": "^5.0.0", "typescript-eslint": "^8.20.0", - "vite": "^7.0.4" + "vite": "^7.0.4", + "zod": "^4.0.5" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 46b6140..519b4f5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: vite: specifier: ^7.0.4 version: 7.0.5(jiti@2.4.2)(lightningcss@1.30.1) + zod: + specifier: ^4.0.5 + version: 4.0.5 packages: @@ -1527,6 +1530,9 @@ packages: zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + zod@4.0.5: + resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} + snapshots: '@ampproject/remapping@2.3.0': @@ -2760,3 +2766,5 @@ snapshots: yocto-queue@0.1.0: {} zimmerframe@1.1.2: {} + + zod@4.0.5: {} diff --git a/frontend/src/app.css b/frontend/src/app.css index 8637657..bbb74a3 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -35,6 +35,6 @@ samp { @apply font-mono; } -.toolbar { - @apply h-[--tool-bar-height]; +.container { + @apply mx-auto max-w-screen-xl px-4 md:px-6; } diff --git a/frontend/src/lib/common/adapter/presenter/asyncState.ts b/frontend/src/lib/common/adapter/presenter/asyncState.ts new file mode 100644 index 0000000..b80e9af --- /dev/null +++ b/frontend/src/lib/common/adapter/presenter/asyncState.ts @@ -0,0 +1,26 @@ +export enum StatusType { + Idle, + Loading, + Success, + Error +} + +export interface IdleState { + status: StatusType.Idle; +} + +export interface LoadingState { + status: StatusType.Loading; +} + +export interface SuccessState { + status: StatusType.Success; + data: T; +} + +export interface ErrorState { + status: StatusType.Error; + error: Error; +} + +export type AsyncState = IdleState | LoadingState | SuccessState | ErrorState; diff --git a/frontend/src/lib/environment.ts b/frontend/src/lib/environment.ts new file mode 100644 index 0000000..4f1791d --- /dev/null +++ b/frontend/src/lib/environment.ts @@ -0,0 +1,5 @@ +import { env } from '$env/dynamic/public'; + +export abstract class Environment { + static readonly API_BASE_URL = env.PUBLIC_BACKEND_URL ?? 'http://localhost:5173/api'; +} diff --git a/frontend/src/lib/home/framework/ui/HomePage.svelte b/frontend/src/lib/home/framework/ui/HomePage.svelte new file mode 100644 index 0000000..25d6e7d --- /dev/null +++ b/frontend/src/lib/home/framework/ui/HomePage.svelte @@ -0,0 +1,11 @@ + + +
+ + + +
diff --git a/frontend/src/lib/home/framework/ui/Motto.svelte b/frontend/src/lib/home/framework/ui/Motto.svelte index 40fbec0..ae8ce39 100644 --- a/frontend/src/lib/home/framework/ui/Motto.svelte +++ b/frontend/src/lib/home/framework/ui/Motto.svelte @@ -2,9 +2,7 @@ import MottoAnimatedMark from './MottoAnimatedMark.svelte'; -
+
diff --git a/frontend/src/lib/home/framework/ui/Terminal.svelte b/frontend/src/lib/home/framework/ui/Terminal.svelte index d0f5b38..73db390 100644 --- a/frontend/src/lib/home/framework/ui/Terminal.svelte +++ b/frontend/src/lib/home/framework/ui/Terminal.svelte @@ -53,7 +53,7 @@
-
+

Hello 大家好!

我是 diff --git a/frontend/src/lib/post/adapter/gateway/labelResponseDto.ts b/frontend/src/lib/post/adapter/gateway/labelResponseDto.ts new file mode 100644 index 0000000..0c46add --- /dev/null +++ b/frontend/src/lib/post/adapter/gateway/labelResponseDto.ts @@ -0,0 +1,37 @@ +import { Label } from '$lib/post/domain/entity/label'; +import { z } from 'zod'; + +export const LabelResponseSchema = z.object({ + id: z.int32(), + name: z.string(), + color: z.string().startsWith('#').length(9) +}); + +export class LabelResponseDto { + readonly id: number; + readonly name: string; + readonly color: string; + + private constructor(props: { id: number; name: string; color: string }) { + this.id = props.id; + this.name = props.name; + this.color = props.color; + } + + static fromJson(json: unknown): LabelResponseDto { + const parsedJson = LabelResponseSchema.parse(json); + return new LabelResponseDto({ + id: parsedJson.id, + name: parsedJson.name, + color: parsedJson.color + }); + } + + toEntity(): Label { + return new Label({ + id: this.id, + name: this.name, + color: this.color + }); + } +} diff --git a/frontend/src/lib/post/adapter/gateway/postApiService.ts b/frontend/src/lib/post/adapter/gateway/postApiService.ts new file mode 100644 index 0000000..123480e --- /dev/null +++ b/frontend/src/lib/post/adapter/gateway/postApiService.ts @@ -0,0 +1,5 @@ +import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; + +export interface PostApiService { + getAllPosts(): Promise; +} diff --git a/frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts b/frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts new file mode 100644 index 0000000..fbaa9f5 --- /dev/null +++ b/frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts @@ -0,0 +1,60 @@ +import { LabelResponseDto, LabelResponseSchema } from '$lib/post/adapter/gateway/labelResponseDto'; +import { PostInfo } from '$lib/post/domain/entity/postInfo'; +import z from 'zod'; + +export const PostInfoResponseSchema = z.object({ + id: z.int32(), + title: z.string(), + description: z.string(), + preview_image_url: z.url(), + labels: z.array(LabelResponseSchema), + published_time: z.number().int(), +}); + +export class PostInfoResponseDto { + readonly id: number; + readonly title: string; + readonly description: string; + readonly previewImageUrl: URL; + readonly labels: readonly LabelResponseDto[]; + readonly publishedTime: Date; + + private constructor(props: { + id: number; + title: string; + description: string; + previewImageUrl: URL; + labels: LabelResponseDto[]; + publishedTime: Date; + }) { + this.id = props.id; + this.title = props.title; + this.description = props.description; + this.previewImageUrl = props.previewImageUrl; + this.labels = props.labels; + this.publishedTime = props.publishedTime; + } + + static fromJson(json: unknown): PostInfoResponseDto { + const parsedJson = PostInfoResponseSchema.parse(json); + return new PostInfoResponseDto({ + id: parsedJson.id, + title: parsedJson.title, + description: parsedJson.description, + previewImageUrl: new URL(parsedJson.preview_image_url), + labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(JSON.stringify(label))), + publishedTime: new Date(parsedJson.published_time / 1000), + }); + } + + toEntity(): PostInfo { + return new PostInfo({ + id: this.id, + title: this.title, + description: this.description, + previewImageUrl: this.previewImageUrl, + labels: this.labels.map((label) => label.toEntity()), + publishedTime: this.publishedTime, + }); + } +} diff --git a/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts b/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts new file mode 100644 index 0000000..560f82b --- /dev/null +++ b/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts @@ -0,0 +1,12 @@ +import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; +import type { PostRepository } from '$lib/post/application/repository/postRepository'; +import type { PostInfo } from '$lib/post/domain/entity/postInfo'; + +export class PostRepositoryImpl implements PostRepository { + constructor(private readonly postApiService: PostApiService) {} + + async getAllPosts(): Promise { + const dtos = await this.postApiService.getAllPosts(); + return dtos.map((dto) => dto.toEntity()); + } +} diff --git a/frontend/src/lib/post/adapter/presenter/labelViewModel.ts b/frontend/src/lib/post/adapter/presenter/labelViewModel.ts new file mode 100644 index 0000000..3ff7d46 --- /dev/null +++ b/frontend/src/lib/post/adapter/presenter/labelViewModel.ts @@ -0,0 +1,19 @@ +export class LabelViewModel { + readonly id: number; + readonly name: string; + readonly color: string; + + private constructor(props: { id: number; name: string; color: string }) { + this.id = props.id; + this.name = props.name; + this.color = props.color; + } + + static fromEntity(label: { id: number; name: string; color: string }): LabelViewModel { + return new LabelViewModel({ + id: label.id, + name: label.name, + color: label.color + }); + } +} diff --git a/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts b/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts new file mode 100644 index 0000000..ae6ed7d --- /dev/null +++ b/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts @@ -0,0 +1,38 @@ +import { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel'; +import type { PostInfo } from '$lib/post/domain/entity/postInfo'; + +export class PostInfoViewModel { + readonly id: number; + readonly title: string; + readonly description: string; + readonly previewImageUrl: URL; + readonly labels: readonly LabelViewModel[]; + readonly publishedTime: Date; + + private constructor(props: { + id: number; + title: string; + description: string; + previewImageUrl: URL; + labels: readonly LabelViewModel[]; + publishedTime: Date; + }) { + this.id = props.id; + this.title = props.title; + this.description = props.description; + this.previewImageUrl = props.previewImageUrl; + this.labels = props.labels; + this.publishedTime = props.publishedTime; + } + + static fromEntity(postInfo: PostInfo): PostInfoViewModel { + return new PostInfoViewModel({ + id: postInfo.id, + title: postInfo.title, + description: postInfo.description, + previewImageUrl: postInfo.previewImageUrl, + labels: postInfo.labels.map((label) => LabelViewModel.fromEntity(label)), + publishedTime: postInfo.publishedTime + }); + } +} diff --git a/frontend/src/lib/post/adapter/presenter/postListBloc.ts b/frontend/src/lib/post/adapter/presenter/postListBloc.ts new file mode 100644 index 0000000..3bccf0d --- /dev/null +++ b/frontend/src/lib/post/adapter/presenter/postListBloc.ts @@ -0,0 +1,44 @@ +import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState'; +import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel'; +import type { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase'; +import { writable } from 'svelte/store'; + +export class PostListBloc { + constructor(private readonly getAllPostsUseCase: GetAllPostUseCase) {} + + private readonly state = writable>({ + status: StatusType.Idle + }); + + get subscribe() { + return this.state.subscribe; + } + + dispatch(event: PostListEvent) { + switch (event.event) { + case PostListEventType.PostListLoadedEvent: + this.loadPosts(); + break; + } + } + + private async loadPosts() { + this.state.set({ status: StatusType.Loading }); + const posts = await this.getAllPostsUseCase.execute(); + const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post)); + this.state.set({ + status: StatusType.Success, + data: postViewModels + }); + } +} + +export enum PostListEventType { + PostListLoadedEvent +} + +export interface PostListLoadedEvent { + event: PostListEventType.PostListLoadedEvent; +} + +export type PostListEvent = PostListLoadedEvent; diff --git a/frontend/src/lib/post/application/repository/postRepository.ts b/frontend/src/lib/post/application/repository/postRepository.ts new file mode 100644 index 0000000..7afe74a --- /dev/null +++ b/frontend/src/lib/post/application/repository/postRepository.ts @@ -0,0 +1,5 @@ +import type { PostInfo } from '$lib/post/domain/entity/postInfo'; + +export interface PostRepository { + getAllPosts(): Promise; +} diff --git a/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts b/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts new file mode 100644 index 0000000..d2eb96e --- /dev/null +++ b/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts @@ -0,0 +1,10 @@ +import type { PostRepository } from '$lib/post/application/repository/postRepository'; +import type { PostInfo } from '$lib/post/domain/entity/postInfo'; + +export class GetAllPostUseCase { + constructor(private readonly postRepository: PostRepository) {} + + execute(): Promise { + return this.postRepository.getAllPosts(); + } +} diff --git a/frontend/src/lib/post/domain/entity/label.ts b/frontend/src/lib/post/domain/entity/label.ts new file mode 100644 index 0000000..a13de0c --- /dev/null +++ b/frontend/src/lib/post/domain/entity/label.ts @@ -0,0 +1,11 @@ +export class Label { + readonly id: number; + readonly name: string; + readonly color: string; + + constructor(props: { id: number; name: string; color: string }) { + this.id = props.id; + this.name = props.name; + this.color = props.color; + } +} diff --git a/frontend/src/lib/post/domain/entity/postInfo.ts b/frontend/src/lib/post/domain/entity/postInfo.ts new file mode 100644 index 0000000..129ffe2 --- /dev/null +++ b/frontend/src/lib/post/domain/entity/postInfo.ts @@ -0,0 +1,26 @@ +import type { Label } from '$lib/post/domain/entity/label'; + +export class PostInfo { + readonly id: number; + readonly title: string; + readonly description: string; + readonly previewImageUrl: URL; + readonly labels: readonly Label[]; + readonly publishedTime: Date; + + constructor(props: { + id: number; + title: string; + description: string; + previewImageUrl: URL; + labels: readonly Label[]; + publishedTime: Date; + }) { + this.id = props.id; + this.title = props.title; + this.description = props.description; + this.previewImageUrl = props.previewImageUrl; + this.labels = props.labels; + this.publishedTime = props.publishedTime; + } +} diff --git a/frontend/src/lib/post/framework/api/postApiServiceImpl.ts b/frontend/src/lib/post/framework/api/postApiServiceImpl.ts new file mode 100644 index 0000000..c0e8c26 --- /dev/null +++ b/frontend/src/lib/post/framework/api/postApiServiceImpl.ts @@ -0,0 +1,19 @@ +import { Environment } from '$lib/environment'; +import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; +import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; + +export class PostApiServiceImpl implements PostApiService { + async getAllPosts(): Promise { + const url = new URL(Environment.API_BASE_URL); + url.pathname += '/post/all'; + + const response = await fetch(url.href); + + if (!response.ok) { + return []; + } + + const json = await response.json(); + return json.map(PostInfoResponseDto.fromJson); + } +} diff --git a/frontend/src/lib/post/framework/ui/PostListPage.svelte b/frontend/src/lib/post/framework/ui/PostListPage.svelte new file mode 100644 index 0000000..5b2d787 --- /dev/null +++ b/frontend/src/lib/post/framework/ui/PostListPage.svelte @@ -0,0 +1,23 @@ + + +
+
文章
+ {#if state.status === StatusType.Loading || state.status === StatusType.Idle} +
Loading
+ {:else if state.status === StatusType.Success} +
{JSON.stringify(state.data)}
+ {:else} +
Error loading posts
+ {/if} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 61552d8..3dfb295 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,11 +1,5 @@ -
- - - -
+ diff --git a/frontend/src/routes/post/+page.svelte b/frontend/src/routes/post/+page.svelte new file mode 100644 index 0000000..370e0e7 --- /dev/null +++ b/frontend/src/routes/post/+page.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1333a23..dc01ee8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,5 +8,14 @@ export default defineConfig({ plugins: [tailwindcss(), sveltekit()], define: { 'App.__VERSION__': JSON.stringify(version) + }, + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } } });