乐趣区

关于前端:使用SolidJSSpringBoot写一个简单的个人博客

首发于 Enaium 的集体博客


前言

前端咱们应用了 SolidJS 来进行开发, 它是一个新出的前端框架, 它的特点是应用 React Hooks 的形式来进行开发, 并且它的 APIReactAPI 十分类似, 所以如果你会 React 的话, 那么你就会SolidJS.

后端咱们应用了 SpringBoot 来进行开发, 数据库咱们应用了 MySQL 来进行开发, 这里我应用的是 MariaDB 来进行开发,ORM 框架应用的是 Jimmer, 这个框架它反对JavaKotlin, 这里为了简略起见就应用 Java 开发, 但理论应用 Koltin 会更不便.

前端环境

首先呢, 须要装置一下 SolidJS 的模板, 这里我应用的是 TypeScriptBootstrap的模板

npx degit solidjs/templates/ts-bootstrap website
cd website
pmpm install # or npm install
pmpm dev # or npm run dev

而后咱们须要装置一下 SolidJS 的路由, 这是一个官网的路由

pnpm install @solidjs/router

而后咱们须要装置一下 SolidJS 的状态治理, 这里我应用的是zustand

pnpm install zustand solid-zustand

而后咱们须要装置一下 Immer 用于批改不可变数据

pnpm install solid-immer

而后咱们须要装置solid-toast, 这是一个用于显示提醒的库

pnpm install solid-toast

最初咱们须要装置 TanStack Query, 这是一个用于进行网络申请的库, 同时反对React,Vue,Svelte 当然也反对SolidJS

pnpm install @tanstack/solid-query

编写前端根底代码

当初咱们开始编写前端代码

首先咱们须要删掉模板中自带的款式, 并且删除 App.tsx 中的内容, 而后咱们须要在 App.tsx 中引入 Router 组件, 并且应用 useRoutes 来进行路由的渲染, 这里咱们须要传入一个路由数组, 这个数组中蕴含了咱们的路由信息, 而后咱们须要在 App.tsx 中应用 Router 组件来进行路由的渲染

import type {Component} from "solid-js"
import {Router, useRoutes} from "@solidjs/router"
import routes from "./router" // 这里是路由数组
import {Toaster} from "solid-toast" // 这个组件用于显示提醒

const App: Component = () => {const Routes = useRoutes(routes)

  return (
    <>
      <Toaster />
      <Router>
        <Routes />
      </Router>
    </>
  )
}

之后新建 views 目录, 在其中新家一个 Login.tsx 文件, 这个文件用于渲染登录页面, 而后咱们须要在路由数组中增加这个路由, 路由文件就是在 src 目录下新建一个 router 目录, 而后在其中新建一个 index.ts 文件, 这个文件用于导出路由数组

import {RouteDefinition} from "@solidjs/router"
import Login from "../views/Login"

const routes: RouteDefinition[] = [
  {
    path: "/login",
    component: () => <Login /> // 这里有个坑, 如果不加上() =>, 那么会报错, 也能够间接应用 Login 比方 component: Login
  }
]

export default routes

好了当初来写用户的 session 状态的治理, 咱们须要在 src 目录下新建一个 store 目录, 而后在其中新建一个 index.ts 文件, 这个文件用于导出 session 状态

import create from "solid-zustand" // 须要留神的是这里应用的是 solid-zustand, 而不是 zustand
import {persist} from "zustand/middleware"

// 这里定义了 session 的类型
interface Session {
  token?: string
  id?: string
  setToken: (token: string) => void
  setId: (id: string) => void
}

// 这里应用 zustand 的 persist 中间件来进行长久化
export const useSessionStore = create(
  persist<Session>((set, get) => ({setToken: (token: string) => set({token}),
      setId: (id: string) => set({id})
    }),
    {
      name: "session-store",
      getStorage: () => localStorage}
  )
)

后端环境

首先关上 IntelliJ IDEA 而后抉择 Spring Initializr, 语言选择Java, 构建工具抉择Gradle, 脚本应用Kotlin, 因为ktsgroovy更好用, 剩下的本人看着改改,JDK应用 17, 因为SpringBoot3 只能应用 JDK17, 而后点击Next, 依赖抉择SpringWeb,MariaDB Driver,Lombok, 下面这些步骤应用start.spring.io 也能够实现, 而后点击 Create, 而后期待Gradle 下载依赖, 下载实现后就能够开始编写代码了

首先引入 Jimmer 的相干依赖

dependencies {implementation("org.babyfish.jimmer:jimmer-spring-boot-starter:0.7.67")// 这个是 Jimmer 的 SpringBoot 的 Starter
annotationProcessor("org.babyfish.jimmer:jimmer-apt:0.7.67")// 这个是 Jimmer 的 APT, 用于生成代码
}

接着引入 SaToken 的依赖

implementation("cn.dev33:sa-token-spring-boot3-starter:1.34.0")

而后咱们须要在 application.properties 中配置一下所须要的配置

# 数据库配置, 这里应用的是 MariaDB, 因为我的数据库在虚拟机中, 所以这里应用的是虚拟机的 IP 地址
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mariadb://192.168.56.101:3306/blog
#Jimmer 配置, 这里配置了生成 Typescript 的门路, 以及是否显示 SQL 语句, 以及是否丑化 SQL 语句
jimmer.client.ts.path=/typescript.zip
jimmer.show-sql=true
jimmer.pretty-sql=true
#SaToken 配置, 这里配置了 token 的名称
sa-token.token-name=token

须要留神一下,IntelliJ IDEA默认对 properties 文件的编码是 ISO-8859-1, 所以咱们须要批改一下编码, 批改办法是在File->Settings->Editor->File Encodings 中批改 Default encoding for properties files 的编码为UTF-8, 要不然中文会乱码

设计数据库

首先一个简略的集体博客包含了文章, 评论, 用户, 分类

create table blog.category
(
    id   uuid        not null
        primary key,
    name varchar(32) not null
);

create table blog.user
(
    id       uuid        not null
        primary key,
    username varchar(18) not null,
    password varchar(18) not null
);

create table blog.post
(
    id          uuid        not null
        primary key,
    user_id     uuid        not null,
    category_id uuid        not null,
    title       varchar(50) not null,
    content     text        not null,
    constraint post_category_id_fk
        foreign key (category_id) references blog.category (id),
    constraint post_user_id_fk
        foreign key (user_id) references blog.user (id)
);

create table blog.reply
(
    id      uuid          not null
        primary key,
    user_id uuid          not null,
    post_id uuid          not null,
    content varchar(1000) not null,
    constraint reply_post_id_fk
        foreign key (post_id) references blog.post (id),
    constraint reply_user_id_fk
        foreign key (user_id) references blog.user (id)
);

后端编写

定义实体类

Jimmer 中定义实体类须要应用接口, 如果你应用的是自增主键, 那么你须要应用@GeneratedValue(strategy = GenerationType.IDENTITY)

import org.babyfish.jimmer.sql.Entity;
import org.babyfish.jimmer.sql.GeneratedValue;
import org.babyfish.jimmer.sql.Id;
import org.babyfish.jimmer.sql.Table;
import org.babyfish.jimmer.sql.meta.UUIDIdGenerator;
import org.jetbrains.annotations.NotNull;

@Entity// 这个注解用于标记这个接口是一个实体类
@Table(name = "user")// 如果表名比拟非凡, 那么能够应用这个注解来指定表名
public interface User {
    @Id// 这个注解用于标记这个属性是主键
    @GeneratedValue(generatorType = UUIDIdGenerator.class)// 这个注解用于标记这个属性的值是主动生成的, 这里应用的是 UUID
    UUID id();


    @NotNull// 这个注解用于标记这个属性的值不可为空, 举荐应用 Jetbrains 的注解
    String username();

    @NotNull
    String password();}

接着定义其余的实体类

@Entity
@Table(name = "category")
public interface Category {
    @Id
    @GeneratedValue(generatorType = UUIDIdGenerator.class)
    UUID id();

    String name();}
@Entity
@Table(name = "post")
public interface Post {
    @Id
    @GeneratedValue(generatorType = UUIDIdGenerator.class)
    UUID id();

    UUID userId();

    UUID categoryId();

    @NotNull
    String title();

    @NotNull
    String content();}
@Entity
@Table(name = "reply")
public interface Reply {
    @Id
    @GeneratedValue(generatorType = UUIDIdGenerator.class)
    UUID id();

    UUID userId();

    UUID postId();

    @NotNull
    String content();}

定义好实体类之后须要运行一下我的项目, 这样 Jimmer 就会生成代码,Draft,Fetcher,Props,TableTableEx 之类的, 找到我的项目下的 build/generated/sources/annotationProcessor 地位, 你就会发现这些生成的代码, 如果应用的是 Kotlin 生成的类会和这些有点不同, 但本篇文章应用的是Java, 所以就不多说了, 因为尽管实质上一样, 但还是有点不同的

如果你应用的是 Linux 环境, 那么你能够像我一样为每个实体类加上一个 Table 注解, 那是因为 Linux 的名称是辨别大小写的, 而 Windows 的名称是不辨别大小写的,Jimmer默认是会将所有的名称转换为大写, 你也能够间接应用配置来批改这个行为

@Bean
public DatabaseNamingStrategy databaseNamingStrategy() {return DefaultDatabaseNamingStrategy.LOWER_CASE;// 这里应用的是小写}

定义 Repository

JimmerJPA 一样, 同样领有 Repository, 因为Jimmer 同时反对 JavaKotlin, 所以这里的 RepositoryJRepository, 这个接口继承了 JPAJpaRepository, 所以它领有 JPA 的所有性能, 泛型的第一个参数是实体类的类型, 第二个参数是主键的类型, 这里我的主键应用的是UUID, 所以这里的类型是UUID

import cn.enaium.blog.model.entity.User;
import org.babyfish.jimmer.spring.repository.JRepository;
import org.springframework.stereotype.Repository;

import java.util.UUID;

@Repository// 这个注解用于标记这个接口是一个 Repository, 这个注解是 SpringData 提供的, 也能够不必这个注解,SpringBoot 会主动扫描这个接口
public interface UserRepository extends JRepository<User, UUID> {}

接着定义其余的Repository

@Repository
public interface CategoryRepository extends JRepository<Category, UUID> {}
@Repository
public interface PostRepository extends JRepository<Post, UUID> {}
@Repository
public interface ReplyRepository extends JRepository<Reply, UUID> {}

当初最根本的代码就写完了, 还有一部分没有写, 之后一遍写 Controller 时再写, 当初咱们须要编写一下 Controller 的代码

编写 Controller

SessionController

SessionController用来解决用户的登录和和退出登录

@RestController
public class SessionController {@PutMapping("/sessions/")
    public void login() {}

    @DeleteMapping("/sessions/")
    public void logout() {}
}

既然要登录, 那么就须要一个登录的申请体, 这里我应用的是 JSON 来进行传输, 所以咱们须要定义一个 UserInput 类, 这个类用于接管 JSON 数据

@Data
public class UserInput {
    @Nullable
    private final UUID id;
    @Nullable
    private final String username;
    @Nullable
    private final String password;
}

接着将 UserRepository 注入到 SessionController

@AllArgsConstructor// 这个注解用于标记这个类的构造函数是全参构造函数, 这个注解是 Lombok 提供的
public class SessionController {private final UserRepository userRepository;// 这里应用的是构造函数注入, 也能够应用字段注入, 然而不举荐应用字段注入}

首先要在 UserRepository 中编写一个 findByUsername 办法, 这个办法用于依据用户名查问用户

@Repository
public interface UserRepository extends JRepository<User, UUID> {User findByUsername(String username);// 这里的办法名是有规定的, 如果是依据用户名查问, 那么办法名就是 findByUsername, 如果是依据用户名和明码查问, 那么办法名就是 findByUsernameAndPassword
}

而后咱们须要在 SessionController 中编写 login 办法, 简略的登录逻辑, 如果用户不存在, 那么就抛出一个 NullPointerException, 如果明码谬误, 那么就抛出一个IllegalArgumentException, 如果登录胜利, 那么就返回一个LoginResponse 对象, 这个对象蕴含了 tokenid

@PutMapping("/sessions/")
public LoginResponse login(@RequestBody UserInput userInput) {final var user = userRepository.findByUsername(userInput.getUsername());
    if (user == null) {throw new NullPointerException("User not found");
    }
    if (!user.password().equals(userInput.getPassword())) {throw new IllegalArgumentException("Password error");
    }
    return new LoginResponse(StpUtil.createLoginSession(user.id()), user.id());
}
@Data
public class LoginResponse {
    private final String token;
    private final UUID id;
}

而后咱们须要编写 logout 办法, 这个办法用于退出登录, 这个办法很简略, 就是调用 SaTokenlogout办法

@DeleteMapping("/sessions/")
public void logout() {StpUtil.logout();
}

UserController

接着咱们须要编写UserController, 这个类用于解决用户的注册

@RestController
@AllArgsConstructor
public class UserController {

    private final UserRepository userRepository;

    @PutMapping("/users/")
    @ResponseStatus(HttpStatus.OK)// 这个注解用于标记这个办法的响应状态码, 这个注解是 Spring 提供的
    public void register(@RequestBody UserInput userInput) {
        // 如果用户曾经存在, 那么就抛出一个异样
        if (userRepository.findByUsername(userInput.getUsername()) != null) {throw new IllegalArgumentException("User already exists");
        }

        // 插入用户, 这里应用的是 Jimmer 的 UserDraft, 这个类用于创立一个 User 对象, 这个对象是一个 Draft 对象, 这个对象能够批改, 因为在 Jimmer 中实体类是不可变的, 所以须要应用 Draft, 对于应用过 Immer 的人来说, 这个 Draft 就是一个 Immer 的 Draft, 这里应用的是 produce 办法, 这个办法用于创立一个 Draft 对象, 这个办法的参数是一个 Consumer, 这个 Consumer 用于批改 Draft 对象, 这里 Jimmer 借鉴了 Immer
        userRepository.insert(UserDraft.$.produce(draft -> {draft.setUsername(userInput.getUsername());
            draft.setPassword(userInput.getPassword());
        }));
    }
}

CategoryController

而后咱们须要编写CategoryController, 这个类用于解决分类的查问

@RestController
@AllArgsConstructor
public class CategoryController {
    private final CategoryRepository categoryRepository;

    @GetMapping("/categories/")
    public List<Category> findCategories() {return categoryRepository.findAll();
    }
}

PostController

而后咱们须要编写 PostController, 这个类用于解决文章的查问, 这里会应用JimmerFetcher性能, 这个性能用于查问关联表的数据, 这里咱们须要查问文章的分类, 所以须要应用 Fetcher 性能

首先定义 PostInput 类, 这个类用于接管 JSON 数据

@Data
public class PostInput {
    @Nullable
    private final UUID id;
    @Nullable
    private final String title;
    @Nullable
    private final String content;
    @Nullable
    private final UUID categoryId;
}

接着设置表的关联关系, 表的关联在表的实体类中进行设置, 须要留神的是, 关联建设后, 之前生成的代码是不会更新的, 所以须要手动更新一下, 这里我应用的是 IntelliJ IDEARebuild Project性能, 这个性能会从新生成代码, 或者应用 Gradlebuild性能也能够, 这里我应用的是 IntelliJ IDEARebuild Project性能

首先 Post 表和 Category 表是多对一的关系, 所以在 Post 表中设置一个 category 办法, 之后再 Category 实体类中定义一个 posts 办法, 这个办法示意这个分类下的所有文章

@Entity
@Table(name = "post")
public interface Post {
    @Id
    @GeneratedValue(generatorType = UUIDIdGenerator.class)
    UUID id();

    UUID userId();

    UUID categoryId();

    @ManyToOne// 这个注解用于标记这个属性是多对一的关系
    Category category();

    @NotNull
    String title();

    @NotNull
    String content();}
@Entity
@Table(name = "category")
public interface Category {
    @Id
    @GeneratedValue(generatorType = UUIDIdGenerator.class)
    UUID id();

    @NotNull
    String name();

    @OneToMany(mappedBy = "category")// 这个注解用于标记这个属性是一对多的关系, 这里的 mappedBy 示意这个属性在另一个实体类中的名称
    List<Post> posts();}

之后咱们开始写 PostController 的代码, 在编写之前响一下, 都须要写哪些办法, 这里我列出来

  • 查问所有文章 (蕴含分类), 用于渲染首页, 但这里不须要文章的内容, 所以不须要查问文章的内容, 这里须要应用Fetcher 性能
  • 依据文章 ID 查问文章, 用于渲染文章详情页, 这里须要查问文章的内容, 所以须要应用 Fetcher 性能
  • 依据分类 ID 查问文章, 用于渲染分类页, 但这里不须要文章的内容, 这里须要应用 Fetcher 性能

以上就是根本须要的办法, 所以这里咱们须要定义 2 个Fetcher, 一个是不蕴含文章内容的Fetcher, 一个是蕴含文章内容的Fetcher

public class PostController {
    // 这个 Fetcher 用于查问所有文章, 但不蕴含文章内容
    public static final PostFetcher DEFAULT_POST = PostFetcher.$
            .allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段
            .content(false)// 这个办法用于设置是否查问文章内容, 这里设置为 false, 示意不查问文章内容
            .category(// 这个办法用于设置查问文章的分类, 这里应用的是一个 CategoryFetcher, 这个 Fetcher 用于查问分类
                    CategoryFetcher.$
                            .allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段);

    // 这个 Fetcher 用于查问所有文章, 蕴含文章内容
    public static final PostFetcher FULL_POST = PostFetcher.$
            .allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段
            .category(// 这个办法用于设置查问文章的分类, 这里应用的是一个 CategoryFetcher, 这个 Fetcher 用于查问分类
                    CategoryFetcher.$
                            .allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段);
}

接着写接口

@RestController
@AllArgsConstructor
public class PostController {
    private final PostRepository repository;

    // 这个办法用于创立文章
    @PutMapping("/posts/")
    @ResponseStatus(HttpStatus.OK)
    public void createPost(@RequestBody PostInput postInput) {
        repository.insert(PostDraft.$.produce(draft -> {draft.setUserId(UUID.fromString(StpUtil.getLoginIdAsString()));
            draft.setTitle(postInput.getTitle());
            draft.setContent(postInput.getContent());
            draft.setCategoryId(postInput.getCategoryId());
        }));
    }

    // 这个办法用于查问所有文章, 但不蕴含文章内容,FetchBy 注解用于标记这个办法应用的是哪个 Fetcher, 其实就是为了生成 Typescript 代码时应用的, 这个注解是 Jimmer 提供的, 这个注解的参数是一个字符串, 这个字符串就是 Fetcher 的名称, 这个名称就是在哪个类中定义的, 这里是在 PostController 中定义的, 默认是在以后类中定义的, 如果不想在以后类中定义, 那么就须要应用 ownerType 参数来指定
    @GetMapping("/posts/")
    public Page<@FetchBy(value = "FULL_POST", ownerType = PostController.class) Post> findPosts(@RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) {return repository.findAll(PageRequest.of(page, size), DEFAULT_POST);// 这里应用的是 findAll 办法, 这个办法用于查问所有的实体类, 这个办法的第二个参数是一个 Fetcher, 这个 Fetcher 用于查问关联表的数据, 这里应用的是 DEFAULT_POST, 示意查问文章的分类, 但不查问文章的内容, 这个办法是 Jimmer 提供的
    }

    // 这个办法用于查问所有文章, 蕴含文章内容
    @GetMapping("/posts/{id}")
    public @FetchBy("FULL_POST") Post findPost(@PathVariable UUID id) {return repository.findNullable(id, FULL_POST);// 这里应用的是 findNullable 办法, 这个办法用于查问一个实体类, 如果查问不到, 那么就返回 null, 如果查问到了, 那么就返回这个实体类, 这个办法的第二个参数是一个 Fetcher, 这个 Fetcher 用于查问关联表的数据, 这里应用的是 FULL_POST, 示意查问文章的分类, 同时查问文章的内容, 这个办法是 Jimmer 提供的
    }

    // 这个办法用于查问分类下的所有文章, 但不蕴含文章内容
    @GetMapping("/categories/{categoryId}/posts/")
    public Page<@FetchBy("DEFAULT_POST") Post> findPostsByCategory(@PathVariable UUID categoryId, @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) {return repository.findAllByCategoryId(PageRequest.of(page, size), categoryId, DEFAULT_POST);
    }
}

接着看下 PostRepository 中的代码

@Repository
public interface PostRepository extends JRepository<Post, UUID> {
    // 这个办法依据分类 ID 查问文章, 具体的 Fetcher 在办法的参数中指定
    Page<Post> findAllByCategoryId(Pageable pageable, UUID categoryId, Fetcher<Post> fetcher);
}

ReplyController

而后咱们须要编写 ReplyController, 这个类用于解决评论的查问, 这里会应用JimmerFetcher性能, 这个性能用于查问关联表的数据, 这里咱们须要查问评论的用户, 所以须要应用 Fetcher 性能

首先定义 ReplyInput 类, 这个类用于接管 JSON 数据

@Data
public class ReplyInput {
    @Nullable
    private final UUID id;

    @Nullable
    private final UUID userId;

    @Nullable
    private final UUID postId;

    @Nullable
    private final String content;
}

接着设置表的关联关系, 表的关联在表的实体类中进行设置

@Entity
@Table(name = "reply")
public interface Reply {
    @Id
    @GeneratedValue(generatorType = UUIDIdGenerator.class)
    UUID id();

    UUID userId();

    @ManyToOne// 这个注解用于标记这个属性是多对一的关系
    User user();

    UUID postId();

    @NotNull
    String content();}
@Entity
@Table(name = "user")
public interface User {
    @Id
    @GeneratedValue(generatorType = UUIDIdGenerator.class)
    UUID id();

    @NotNull
    String username();

    @NotNull
    String password();

    @OneToMany(mappedBy = "user")// 这个注解用于标记这个属性是一对多的关系, 这里的 mappedBy 示意这个属性在另一个实体类中的名称
    List<Reply> replies();}

接着写接口

@RestController
@AllArgsConstructor
public class ReplyController {

    private final ReplyRepository replyRepository;

    @PutMapping("/replies/")
    public void createReply(@RequestBody ReplyInput replyInput) {
        replyRepository.insert(ReplyDraft.$.produce(draft -> {draft.setUserId(UUID.fromString(StpUtil.getLoginIdAsString()));
            draft.setContent(replyInput.getContent());
            draft.setPostId(replyInput.getPostId());
        }));
    }

    @GetMapping("/posts/{postId}/replies/")
    public Page<@FetchBy("DEFAULT_REPLY") Reply> findRepliesByPost(@PathVariable UUID postId, @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) {return replyRepository.findAllByPostId(PageRequest.of(page, size), postId, DEFAULT_REPLY);
    }

    // 这个办法用于查问所有评论, 蕴含评论的用户, 但不蕴含用户的明码
    public static final ReplyFetcher DEFAULT_REPLY = ReplyFetcher.$.allScalarFields().user(UserFetcher.$.username());
}

ExceptionController

当初编写全局异样解决, 这个异样解决用于解决 NullPointerExceptionIllegalArgumentException等异样

@ControllerAdvice// 这个注解用于标记这个类是一个全局异样解决类
public class ExceptionController {@ExceptionHandler(Exception.class)// 这个注解用于标记这个办法是一个异样解决办法, 这里的参数是异样的类型, 因为要解决所有的异样, 所以这里应用的是 Exception
    public ResponseEntity<String> exception(Exception exception) {if (exception instanceof NullPointerException) {
            // 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 404 状态码和异样信息
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
        } else if (exception instanceof IllegalArgumentException) {
            // 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 400 状态码和异样信息
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exception.getMessage());
        } else if (exception instanceof NotLoginException) {
            // 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 401 状态码和异样信息
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(exception.getMessage());
        }
        // 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 500 状态码和异样信息
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(exception.getMessage());
    }
}

最初咱们要开启后端的 CORS, 这个性能用于解决跨域问题, 这里我应用的是拦截器来实现的, 首先咱们须要编写一个CorsInterceptor 类, 这个类用于拦挡所有的申请, 而后设置响应头

@Component
public class CorsInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws Exception {response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "*");
        response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*");
        response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
        response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
        return HandlerInterceptor.super.preHandle(request, response, handler);
    }
}

而后咱们须要在 WebMvcConfigurer 中注册这个拦截器

@Configuration
@AllArgsConstructor
public class WebMvcConfiguration implements WebMvcConfigurer {
    private final CorsInterceptor corsInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(corsInterceptor);
    }
}

前端编写

生成前端的代码

咱们在后端定义了那么多的实体类, 那么咱们须要将这些实体类生成 TypeScript 的代码, 这里咱们应用的是 Jimmer 自带的性能, 在之前的配置中咱们配置了 jimmer.client.ts.path=/typescript.zip, 这个配置用于指定生成的TypeScript 代码的门路, 咱们能够手动拜访这个地址下载一个压缩包, 而后解压这个压缩包, 而后将其中的 typescript 文件夹复制到 src 目录下, 也能够写一个脚本来主动实现这些工作, 这里我应用了 PowerShell 来实现这些工作, 在和 src 的同级目录下新建一个 scripts 目录, 而后在其中新建一个 generated.ps1 文件, 这个文件用于生成 TypeScript 代码

$sourceUrl = "http://localhost:8080/typescript.zip"
$tempDir = Join-Path $(Get-Item -Path $env:TEMP).FullName (New-Guid).ToString()
$generatedPath = Join-Path (Split-Path $PSScriptRoot) "src/__generated"

# 下载源码并解压
if (-not (Test-Path $tempDir)) {New-Item -ItemType Directory -Path $tempDir | Out-Null}

Write-Host "Downloading $sourceUrl to $tempDir..."
Invoke-WebRequest -Uri $sourceUrl -OutFile "$tempDir/source.zip"
Write-Host "Extracting files from $tempDir/source.zip..."
Expand-Archive -Path "$tempDir/source.zip" -DestinationPath $tempDir

# 删除已有的生成门路
if (Test-Path $generatedPath) {
    Write-Host "Deleting existing generated path $generatedPath..."
    Remove-Item -Path $generatedPath -Recurse -Force
}

# 挪动文件
Write-Host "Moving files from $tempDir to $generatedPath..."
Move-Item -Path $tempDir -Destination $generatedPath

# 删除源码
Remove-Item -Path "$generatedPath/source.zip"

Write-Host "API code is refreshed successfully."

如果是对 Typescript 代码比拟相熟的话, 看这些生成的代码应该没什么问题, 但这里工夫无限, 所以我就不具体解说了

编写前端代码

在生成了代码之后, 咱们还须要为 fetch 创立一个拦截器, 这个拦截器用于在每次申请时增加 token, 在common 目录中新建一个 ApiInstance.ts 文件, 这个文件用于创立 fetch 的实例

import {Api} from "../__generated"
import {useSessionStore} from "../store"

// 创立一个 Api 实例
export const api = new Api(async ({ uri, method, body}) => {
  // 获取 token
  const token = useSessionStore().token as string | undefined
  // 发送申请
  const response = await fetch(`http://localhost:8080${uri}`, {
    method,
    body: body !== undefined ? JSON.stringify(body) : undefined,
    headers: {
      "content-type": "application/json;charset=UTF-8",
      ...(token !== undefined && token !== "" ? { token} : {})
    }
  })
  // 如果响应状态码不是 200, 那么就抛出一个谬误
  if (response.status !== 200) {throw await response.text()
  }
  // 获取响应体
  const text = await response.text()
  // 如果响应体为空, 那么就返回 null
  if (text.length === 0) {return null}
  // 返回响应体
  return JSON.parse(text)
})

编写注册页面

views 目录下新建一个 Register.tsx 文件, 这个文件用于渲染注册页面

import {createImmerSignal} from "solid-immer"
import {UserInput} from "../__generated/model/static"
import {api} from "../common/ApiInstance"
import toast from "solid-toast"
import {Link, Route, useNavigate} from "@solidjs/router"

const Register = () => {const navigate = useNavigate() // 这个函数用于跳转路由

  let formRef // 这个援用用于获取表单元素

  const [form, setForm] = createImmerSignal<UserInput>({}) // 这个函数用于创立一个信号, 这个信号用于存储表单数据

  const submit = (e) => {
    // 阻止默认事件
    e.preventDefault()
    e.stopPropagation()

    // 如果表单验证通过, 那么就发送申请
    if (formRef.checkValidity()) {
      api.userController
        .register({body: form() })
        .then(() => {
          // 如果注册胜利, 那么就跳转到登录页面
          toast.success("Registered successfully")
          navigate("/login")
        })
        .catch((err) => {
          // 如果注册失败, 那么就显示错误信息
          toast.error(err)
        })
    }

    // 这里须要手动调用表单的验证办法, 因为咱们应用的是原生的表单验证, 而不是应用第三方库
    formRef.classList.add("was-validated")
  }

  return (
    <div class="vh-100 d-flex justify-content-center align-items-center">
      <div class="card p-5">
        <form ref={formRef} class="needs-validation" style={{width: "18rem", height: "14rem"}} novalidate>
          <div class="d-flex flex-column justify-content-between h-100">
            <div>
              <label for="validationCustom01" class="form-label">
                Username
              </label>
              <input
                type="text"
                class="form-control"
                id="validationCustom01"
                value={form().username ?? ""}
                required
                onInput={(e) => setForm((draft) => (draft.username = e.currentTarget.value))}
              />
              <div class="valid-feedback">Looks good!</div>
              <div class="invalid-feedback">Please enter your username.</div>
            </div>
            <div>
              <label for="validationCustom02" class="form-label">
                Password
              </label>
              <input
                type="text"
                class="form-control"
                id="validationCustom02"
                required
                value={form().password ?? ""}
                onInput={(e) => setForm((draft) => (draft.password = e.currentTarget.value))}
              />
              <div class="valid-feedback">Looks good!</div>
              <div class="invalid-feedback">Please enter your password.</div>
            </div>
            <Link href="/login">Login</Link>
            {/* 这里应用了 Link 组件, 这个组件用于跳转路由 */}
            <button class="btn btn-primary" type="submit" onClick={submit}>
              Register
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}

export default Register

编写登录页面

在之前曾经创立好的 Login.tsx 中编写登录页面

import {createImmerSignal} from "solid-immer"
import {useSessionStore} from "../store"
import {UserInput} from "../__generated/model/static"
import {api} from "../common/ApiInstance"
import toast from "solid-toast"
import {Link, Route, useNavigate} from "@solidjs/router"

const Login = () => {const navigate = useNavigate()

  const session = useSessionStore() // 这个函数用于获取 session 状态

  let formRef

  const [form, setForm] = createImmerSignal<UserInput>({})

  const submit = (e) => {e.preventDefault()
    e.stopPropagation()

    if (formRef.checkValidity()) {
      api.sessionController
        .login({body: form() })
        .then((data) => {
          // 如果登录胜利, 那么就将 token 和 id 存储到 session 中, 而后跳转到首页
          session.setToken(data.token)
          session.setId(data.id)
          navigate("/")
        })
        .catch((err) => {toast.error(err)
        })
    }

    formRef.classList.add("was-validated")
  }

  return (
    <div class="vh-100 d-flex justify-content-center align-items-center">
      <div class="card p-5">
        <form ref={formRef} class="needs-validation" style={{width: "18rem", height: "14rem"}} novalidate>
          <div class="d-flex flex-column justify-content-between h-100">
            <div>
              <label for="validationCustom01" class="form-label">
                Username
              </label>
              <input
                type="text"
                class="form-control"
                id="validationCustom01"
                value={form().username ?? ""}
                required
                onInput={(e) => setForm((draft) => (draft.username = e.currentTarget.value))}
              />
              <div class="valid-feedback">Looks good!</div>
              <div class="invalid-feedback">Please enter your username.</div>
            </div>
            <div>
              <label for="validationCustom02" class="form-label">
                Password
              </label>
              <input
                type="text"
                class="form-control"
                id="validationCustom02"
                required
                value={form().password ?? ""}
                onInput={(e) => setForm((draft) => (draft.password = e.currentTarget.value))}
              />
              <div class="valid-feedback">Looks good!</div>
              <div class="invalid-feedback">Please enter your password.</div>
            </div>
            <Link href="/register">Register</Link>
            <button class="btn btn-primary" type="submit" onClick={submit}>
              Login
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}

export default Login

编写首页布局

src 目录下新建一个 layouts 目录, 之后在外面创立一个 HomeLayout.tsx 文件, 这就是首页的布局, 这个布局蕴含了一个导航栏

import {Outlet} from "@solidjs/router"
import Nav from "../components/Nav"

const HomeLayout = () => {
  return (
    <div>
      <div class="container">
        <Nav />
      </div>
      <div class="container">
        {/* 这里应用了 Outlet 组件, 这个组件用于渲染子路由 */}
        <Outlet />
      </div>
    </div>
  )
}

export default HomeLayout

而后在 src 目录下新建一个 components 目录, 在其中新建一个 Nav.tsx 文件, 这个文件用于渲染导航栏,Logo能够从 SolidJS 的官网下载, 而后放到 assets 目录下, 间接把官网上的扒下来就行

import {Link} from "@solidjs/router"
import Logo from "../assets/solid.svg"

const Nav = () => {
  return (
    <div>
      <nav class="navbar navbar-expand-lg navbar-light">
        <img src={Logo} alt=""width="30"height="24"class="d-inline-block align-text-top" />
        <span class="navbar-brand mb-0 h1">Solid</span>
        <ul class="navbar-nav me-auto mb-2 mb-lg-0">
          <li class="nav-item">
            <Link class="nav-link" href="/">
              Posts
            </Link>
          </li>
          <li class="nav-item">
            <Link class="nav-link" href="/category">
              Category
            </Link>
          </li>
          <li class="nav-item">
            <Link class="nav-link" href="/write">
              Write
            </Link>
          </li>
        </ul>
      </nav>
    </div>
  )
}

export default Nav

之后别离在 views 目录中新建 Posts.tsx,Category.tsx,Write.tsx 文件, 这些文件用于渲染文章列表, 分类列表, 写文章页面

const Posts = () => {return <div>Post</div>}
export default Posts
const Category = () => {return <div>Category</div>}

export default Category
const Write = () => {return () => <div>Write</div>
}

export default Write

而后咱们须要在 router 目录中编写路由数组

const routes: RouteDefinition[] = [
  {
    path: "/login",
    component: () => <Login />},
  {
    path: "/register",
    component: () => <Register />},
  {
    path: "/",
    component: () => <HomeLayout />,
    children: [
      {
        path: "/",
        component: () => <Posts />},
      {
        path: "/category",
        component: () => <Category />},
      {
        path: "/write",
        component: () => <Write />}
    ]
  }
]

编写公布文章页面

在编写页面之前, 须要将 TanStack QueryQueryClientProvider组件放到 App.tsx

import type {Component} from "solid-js"
import {Router, useRoutes} from "@solidjs/router"
import routes from "./router"
import {Toaster} from "solid-toast"
import {QueryClient, QueryClientProvider} from "@tanstack/solid-query"

const App: Component = () => {const queryClient = new QueryClient()
  const Routes = useRoutes(routes)

  return (
    <>
      <Toaster />
      {/* 这里应用了 QueryClientProvider 组件, 这个组件用于提供 QueryClient, 这个组件必须放在 Router 组件的里面 */}
      <QueryClientProvider client={queryClient}>
        <Router>
          <Routes />
        </Router>
      </QueryClientProvider>
    </>
  )
}

export default App

首先须要明确一点,TanStack QuerySolidJS 中的响应并不反对解构

不反对这样

const {isLoading, error, data} = useQuery(...)

只能这样

const query = createQuery(...)

而后在 Write.tsx 中编写公布文章页面

在开始之前, 须要向 Category 中插入 3 条数据

insert into category(id, name)
values (uuid(), 'Category 1');
insert into category(id, name)
values (uuid(), 'Category 2');
insert into category(id, name)
values (uuid(), 'Category 3');

发帖页面的代码如下

import {createQuery} from "@tanstack/solid-query"
import {api} from "../common/ApiInstance"
import {PostInput} from "../__generated/model/static"
import {createImmerSignal} from "solid-immer"
import {For, Match, Switch} from "solid-js"
import {useNavigate} from "@solidjs/router"
import toast from "solid-toast"

const Write = () => {const navigate = useNavigate()

  const [form, setForm] = createImmerSignal<PostInput>({}) // 这个函数用于创立一个信号, 这个信号用于存储表单数据

  let formRef

  const categories = createQuery({queryKey: () => ["category"],
    queryFn: () => api.categoryController.findCategories()
  })

  const submit = (e) => {e.preventDefault()
    e.stopPropagation()

    if (formRef.checkValidity()) {
      api.postController
        .createPost({body: form() })
        .then(() => {toast.success("Publish success")
          navigate("/")
        })
        .catch((err) => {toast.error(err)
        })
    }

    formRef.classList.add("was-validated")
  }

  return () => (
    <div>
      <form ref={formRef} class="needs-validation" novalidate>
        <div>
          <label class="form-label">Title</label>
          <input
            type="text"
            class="form-control"
            required
            onInput={(e) => setForm((draft) => (draft.title = e.currentTarget.value))}
          />
          <div class="valid-feedback">Looks good!</div>
          <div class="invalid-feedback">Please enter title.</div>
        </div>
        <div>
          <label class="form-label">Content</label>
          <textarea
            class="form-control"
            style={{height: "16rem"}}
            required
            onInput={(e) => setForm((draft) => (draft.content = e.currentTarget.value))}
          />
          <div class="valid-feedback">Looks good!</div>
          <div class="invalid-feedback">Please enter content.</div>
        </div>
        <div>
          <label class="form-label">Category</label>
          {/* 这里应用了 Switch 组件, 这个组件用于依据条件渲染不同的内容 */}
          <Switch>
            {/* 这里应用了 Match 组件, 这个组件用于匹配条件, 如果条件为 true, 那么就渲染加载 */}
            <Match when={categories.isLoading}>
              <div class="spinner-border" role="status">
                <span class="visually-hidden">Loading...</span>
              </div>
            </Match>
            <Match when={categories.isError}>Error</Match>
            <Match when={categories.isSuccess}>
              <select
                class="form-select"
                value={form().categoryId}
                onInput={(e) =>
                  setForm((draft) => {draft.categoryId = e.currentTarget.value})
                }
                required
              >
                <For each={categories.data}>{(item) => <option value={item.id}>{item.name}</option>}</For>
              </select>
            </Match>
          </Switch>

          <div class="valid-feedback">Looks good!</div>
          <div class="invalid-feedback">Please select category.</div>
        </div>
        <button class="btn btn-primary" type="submit" onClick={submit}>
          Publish
        </button>
      </form>
    </div>
  )
}

export default Write

编写文章详情页面

views 目录下创立 Post.tsx 文件

import {useParams} from "@solidjs/router"
import {createQuery} from "@tanstack/solid-query"
import {api} from "../common/ApiInstance"
import {Match, Switch} from "solid-js"

const Post = () => {
  // 这里应用了 useParams 函数, 这个函数用于获取路由参数
  const params = useParams()

  const post = createQuery({queryKey: () => ["post", params.id],
    queryFn: () => api.postController.findPost({ id: params.id})
  })

  return (
    <div>
      <Switch>
        <Match when={post.isLoading}>
          <div class="spinner-border" role="status">
            <span class="visually-hidden">Loading...</span>
          </div>
        </Match>
        <Match when={post.isError}>Error</Match>
        <Match when={post.isSuccess}>
          <div>{post.data.title}</div>
          <div>{post.data.category.name}</div>
          <div>{post.data.content}</div>
        </Match>
      </Switch>
    </div>
  )
}

export default Post

之后在 router 目录中增加一个路由, 布局是HomeLayout, 门路是/post/:id, 组件是Post

{
  path: "/post/:id",
  component: () => <Post />}

编写文章列表页面

Posts.tsx 中编写文章列表页面, 这里应用了 RequestOf, 这个类型用于获取申请的参数类型, 这个类型是Jimmer 主动生成的, 比方 RequestOf<typeof api.postController.findPosts> 的类型就是{page?: number; size?: number;}

import {createImmerSignal} from "solid-immer"
import {RequestOf} from "../__generated"
import {api} from "../common/ApiInstance"
import {createQuery} from "@tanstack/solid-query"
import {For, Match, Switch} from "solid-js"
import {Link} from "@solidjs/router"

const Posts = () => {const [options, setOptions] = createImmerSignal<RequestOf<typeof api.postController.findPosts>>({})

  const posts = createQuery({queryKey: () => ["posts", options()],
    queryFn: () => api.postController.findPosts(options())
  })

  return (
    <>
      <Switch>
        <Match when={posts.isLoading}>
          <div class="spinner-border" role="status">
            <span class="visually-hidden">Loading...</span>
          </div>
        </Match>
        <Match when={posts.isError}>Error</Match>
        <Match when={posts.isSuccess}>
          <ul class="list-group">
            <For each={posts.data.content}>
              {(post) => (
                <li class="list-group-item">
                  {/* 这里应用了 Link 组件, 这个组件用于跳转路由, 这里应用了 post.id 作为参数 */}
                  <Link href={`/post/${post.id}`}>{post.title}</Link>
                </li>
              )}
            </For>
          </ul>
          {/* 分页 */}
          <nav aria-label="Page navigation example">
            <ul class="pagination">
              <li class="page-item">
                <div
                  class="page-link"
                  onClick={() => {if (options().page ?? 0 > 0)
                      setOptions((draft) => {draft.page = (options().page ?? 0) - 1
                      })
                  }}
                >
                  Previous
                </div>
              </li>
              {/* 这里应用了 For 组件, 一共有几页就渲染几页, 这里应用了 Array.from 办法, 这个办法用于将一个数字转换为数组, 比方 Array.from({length: 3})的后果就是[1,2,3] */}
              <For each={Array.from({ length: posts.data.totalPages}, (_, i) => i + 1)}>
                {(page, index) => (
                  <li class="page-item">
                    <div
                      class="page-link"
                      classList={{active: (options().page ?? 0) === index()}}
                      onClick={() =>
                        setOptions((draft) => {draft.page = index()
                        })
                      }
                    >
                      {page}
                    </div>
                  </li>
                )}
              </For>
              <li class="page-item">
                <div
                  class="page-link"
                  onClick={() => {if ((options().page ?? 0) < posts.data.totalPages - 1)
                      setOptions((draft) => {draft.page = (options().page ?? 0) + 1
                      })
                  }}
                >
                  Next
                </div>
              </li>
            </ul>
          </nav>
        </Match>
      </Switch>
    </>
  )
}
export default Posts

编写分类列表页面

Category.tsx 中编写分类列表页面, 这里为了不便, 我间接将 Posts.tsx 中的代码复制过去了, 而后批改了一下申请参数

import {createImmerSignal} from "solid-immer"
import {RequestOf} from "../__generated"
import {createQuery} from "@tanstack/solid-query"
import {api} from "../common/ApiInstance"
import {Link, useParams} from "@solidjs/router"
import {Switch, Match, For} from "solid-js"

const CategoryPosts = () => {const params = useParams()

  const [options, setOptions] = createImmerSignal<RequestOf<typeof api.postController.findPostsByCategory>>({categoryId: params.categoryId})

  const posts = createQuery({queryKey: () => ["posts", options()],
    queryFn: () => api.postController.findPostsByCategory(options())
  })

  return (
    <Switch>
      <Match when={posts.isLoading}>
        <div class="spinner-border" role="status">
          <span class="visually-hidden">Loading...</span>
        </div>
      </Match>
      <Match when={posts.isError}>Error</Match>
      <Match when={posts.isSuccess}>
        <ul class="list-group">
          <For each={posts.data.content}>
            {(post) => (
              <li class="list-group-item">
                <Link href={`/post/${post.id}`}>{post.title}</Link>
              </li>
            )}
          </For>
        </ul>
        <nav aria-label="Page navigation example">
          <ul class="pagination">
            <li class="page-item">
              <div
                class="page-link"
                onClick={() => {if (options().page ?? 0 > 0)
                    setOptions((draft) => {draft.page = (options().page ?? 0) - 1
                    })
                }}
              >
                Previous
              </div>
            </li>
            <For each={Array.from({ length: posts.data.totalPages}, (_, i) => i + 1)}>
              {(page, index) => (
                <li class="page-item">
                  <div
                    class="page-link"
                    classList={{active: (options().page ?? 0) === index()}}
                    onClick={() =>
                      setOptions((draft) => {draft.page = index()
                      })
                    }
                  >
                    {page}
                  </div>
                </li>
              )}
            </For>
            <li class="page-item">
              <div
                class="page-link"
                onClick={() => {if ((options().page ?? 0) < posts.data.totalPages - 1)
                    setOptions((draft) => {draft.page = (options().page ?? 0) + 1
                    })
                }}
              >
                Next
              </div>
            </li>
          </ul>
        </nav>
      </Match>
    </Switch>
  )
}

export default CategoryPosts

之后在 router 目录中增加一个路由, 布局是HomeLayout, 门路是/category/:categoryId, 组件是CategoryPosts

{
  path: "/category/:categoryId",
  component: () => <CategoryPosts />}

编写评论列表组件

components 目录下新建一个 Replys.tsx 文件, 这个文件用于渲染评论列表

import {createQuery} from "@tanstack/solid-query"
import {createImmerSignal} from "solid-immer"
import {Component, For, Match, Switch} from "solid-js"
import {RequestOf} from "../__generated"
import {api} from "../common/ApiInstance"
import {ReplyInput} from "../__generated/model/static"
import toast from "solid-toast"

const Replys: Component<{postId: string}> = ({postId}) => {const [options, setOptions] = createImmerSignal<RequestOf<typeof api.replyController.findRepliesByPost>>({postId})

  const replies = createQuery({queryKey: () => ["replies", options()],
    queryFn: () => api.replyController.findRepliesByPost(options())
  })

  let formRef

  const [form, setForm] = createImmerSignal<ReplyInput>({postId})

  const submit = (e) => {e.preventDefault()
    e.stopPropagation()

    if (formRef.checkValidity()) {
      api.replyController
        .createReply({body: form()
        })
        .then(() => {toast.success("Reply created")
        })
        .catch(() => {toast.error("Reply creation failed")
        })
    }

    formRef.classList.add("was-validated")
  }

  return (
    <div>
      <div class="card">
        <form ref={formRef} class="needs-validation">
          <div>
            <label class="form-label">Content</label>
            <textarea
              class="form-control"
              value={form().content ?? ""}
              required
              onInput={(e) => setForm((draft) => (draft.content = e.currentTarget.value))}
            />
            <div class="valid-feedback">Looks good!</div>
            <div class="invalid-feedback">Please enter your reply.</div>
          </div>
          <button class="btn btn-primary" type="submit" onClick={submit}>
            Reply
          </button>
        </form>
      </div>
      <Switch>
        <Match when={replies.isLoading}>
          <div class="spinner-border" role="status">
            <span class="visually-hidden">Loading...</span>
          </div>
        </Match>
        <Match when={replies.isError}>Error</Match>
        <Match when={replies.isSuccess}>
          <ul class="list-group">
            <For each={replies.data.content}>
              {(reply) => (
                <li class="list-group-item">
                  <div class="d-flex justify-content-between">
                    <div>User:{reply.user.username}</div>
                    <div>{reply.content}</div>
                  </div>
                </li>
              )}
            </For>
          </ul>
          <nav aria-label="Page navigation example">
            <ul class="pagination">
              <li class="page-item">
                <div
                  class="page-link"
                  onClick={() => {if (options().page ?? 0 > 0)
                      setOptions((draft) => {draft.page = (options().page ?? 0) - 1
                      })
                  }}
                >
                  Previous
                </div>
              </li>
              <For each={Array.from({ length: replies.data.totalPages}, (_, i) => i + 1)}>
                {(page, index) => (
                  <li class="page-item">
                    <div
                      class="page-link"
                      classList={{active: (options().page ?? 0) === index()}}
                      onClick={() =>
                        setOptions((draft) => {draft.page = index()
                        })
                      }
                    >
                      {page}
                    </div>
                  </li>
                )}
              </For>
              <li class="page-item">
                <div
                  class="page-link"
                  onClick={() => {if ((options().page ?? 0) < replies.data.totalPages - 1)
                      setOptions((draft) => {draft.page = (options().page ?? 0) + 1
                      })
                  }}
                >
                  Next
                </div>
              </li>
            </ul>
          </nav>
        </Match>
      </Switch>
    </div>
  )
}

export default Replys

最初将这个组件放到 Post.tsx

<Replys postId={params.id} />

好了, 到这里咱们的博客就实现了, 如果你想要看残缺的代码, 能够去 GitHub 上看

退出移动版