Add a scaffold script that replaces the bq prefix with the new project name.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bql
2026-10-27 22:46:06 +08:00
commit bc84c95a89
128 changed files with 9700 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# DDD是什么
这里的 DDD 指领域规则写在领域对象上,再用分层规定代码放在哪一层。本仓库用它来规定功能模块的写法。
## 目录
- [四层](#四层)
- [聚合](#聚合)
- [端口](#端口)
- [本仓库里怎么落](#本仓库里怎么落)
## 四层
一个功能模块在 `src/features/<name>/` 下按层分目录。
| 层 | 放什么 | 不放什么 |
|---|---|---|
| `domain` | 聚合根、值对象、领域方法、端口 | React、`src/app`、本地或远程的说法 |
| `application` | 用例。一次业务动作,例如登录、按 `store` 保存项目 | 页面状态、axios、IndexedDB |
| `infra` | 端口的实现。访问网络或本地库,并把接口数据收成领域对象 | 页面和 ViewModel |
| `presentation` | 页面和 ViewModel | 领域规则,例如笔画长短、撤销栈 |
没有内容的层不建空目录。首页只有 `presentation`,因为它不持有自己的用例,列表和新建都调用 `ProjectApplication`。
用例是模块的业务。`SecurityApplication.login` 负责登录,并把 `LoginResult` 里的访问凭证写入会话。ViewModel 是页面的业务,只保存当前页的表单、错误和加载状态,再调用用例。
## 聚合
`Project` 是项目聚合的根。文件:`src/features/project/domain/entities/project.ts`。外部通过它的方法改文档:
| 方法 | 作用 |
|---|---|
| `Project.create(name)` | 新建一个项目 |
| `addShape(shape)` | 位移小于 2 时拒绝;接受后把当前 `Document` 快照记进聚合,再加入图形 |
| `undo()` / `redo()` | 在聚合内部的 `past`、`future` 之间恢复 `Document` |
| `touch()` | 更新 `updatedAt`。保存前由用例调用 |
`Document.addShape` 只由 `Project` 调用。`past` 和 `future` 是聚合上的普通字段,不是 `tProp`,所以 `getSnapshot(project)` 不会把撤销栈写进本地库或接口。
安全模块的领域对象是 `AuthCredentials`、`RegisterCredentials`、`User` 和 `LoginResult`。用户名不少于 3 个字符,密码不少于 6 个字符,注册时两次密码相同。`User.hasPermission` 判断角色。`LoginResult` 持有 `User` 和 `accessToken`。
`AUTH_CHANGED` 由 `src/app/session` 在凭证写入或清除后通知界面。它属于会话,不属于领域对象。
## 端口
`domain` 里的端口是抽象类。方法名只表达业务,不写 `local` 或 `remote`。
`ISecurityRepository.login` 返回 `LoginResult`,`register` 接收 `RegisterCredentials`。`SecurityRepository` 用 `Http` 访问接口,再把响应收成 `User` 和 `LoginResult`。
`IProjectRepository` 声明 `list`、`get`、`save`。`LocalProjectRepository` 和 `RemoteProjectRepository` 都实现这一个端口。存在哪一侧由领域枚举 `ProjectStore` 决定,`Project` 上没有这个字段。
## 本仓库里怎么落
登录:
```text
presentation/security_view_model.ts 校验表单,调用 login
application/security_application.ts 调用端口,把 accessToken 交给 TokenStore
domain/i_security_repository.ts 声明 login,返回 LoginResult
infra/security_repository.ts 用 Http 访问 /auth/login,收成 LoginResult
```
绘制:
```text
presentation/editor_view_model.ts 记录草稿,调用 Project.addShape
domain/entities/project.ts 判定笔画,维护撤销栈
application/project_application.ts 按 store 调用 save
infra/..._project_repository.ts 把项目快照写入 IndexedDB 或 Http
```
`TokenStore`、`Http`、`Database` 属于 `src/app`,是全库能力,不是功能模块的领域规则。

View File

@@ -0,0 +1,81 @@
# DI是什么
依赖注入是指类不自己 `new` 依赖,而是由容器按构造函数的参数创建并传入。本仓库的容器是 Inversify 的 `rootContainer`。端口是依赖倒置里的抽象,分层见 [DDD是什么](./DDD是什么.md)。
## 目录
- [原则和做法](#原则和做法)
- [容器](#容器)
- [Singleton 与 Transient](#singleton-与-transient)
- [端口怎么绑到实现](#端口怎么绑到实现)
- [同一个端口的多个实现](#同一个端口的多个实现)
- [装饰器能生效的条件](#装饰器能生效的条件)
## 原则和做法
依赖倒置、依赖注入、控制反转是同一条链上的三件事。
依赖倒置:用例和仓储实现都只对着端口。`SecurityApplication` 调用 `ISecurityRepository.login`,不引用 `Http`。`SecurityRepository` 负责请求。登录成功后,用例把 `accessToken` 写入会话。
依赖注入:仓储和 `TokenStore` 从构造函数进来。`SecurityApplication` 只声明参数类型。
控制反转:创建哪一个实现、创建几次,由 `rootContainer` 决定。页面用 `useViewModel` 取出 ViewModel,容器顺着构造函数继续创建用例和仓储。
## 容器
`src/app/di/root_container.ts` 创建唯一容器。`autobind` 打开后,标了 `@injectable` 的具体类可以按类本身取出。`defaultScope` 是 `Singleton`。
页面通过 `useViewModel(SecurityViewModel)` 取出 ViewModel。`useViewModel` 内部调用 `rootContainer.get`。ViewModel 的构造函数需要 `SecurityApplication` 时,容器继续创建或取出用例,再把仓储注入用例。
## Singleton 与 Transient
`Singleton` 在整个应用里只有一个实例。用例、仓储、`TokenStore`、`Http`、`Database` 使用它,这样凭证和连接可以在页面之间共用。
`Transient` 每次 `get` 都新建一个实例。页面 ViewModel 使用它。`useViewModel` 在页面挂载时取一次,离开页面后不再使用这个实例,表单和草稿不会留到下一次进入。
无参的 `@injectable()` 等于 `Singleton`。新代码要写出 `'Singleton'` 或 `'Transient'`。
## 端口怎么绑到实现
用例依赖的是抽象类 `ISecurityRepository`,容器默认不知道用哪个实现。实现类同时标上:
```ts
@bindAs(ISecurityRepository)
@injectable('Singleton')
export class SecurityRepository implements ISecurityRepository {}
```
`@bindAs` 写在 `@injectable` 上面。类装饰器从靠近类的那一个开始执行,`@injectable` 先记下构造函数参数的类型,`@bindAs` 再把端口绑到这个实现。这个类文件必须被执行到,绑定才会发生。执行方式见 [代码实践](./代码实践.md) 里的模块入口。
只在 `infra` 的端口实现上使用 `@bindAs`。用例和 ViewModel 按类本身注册。
## 同一个端口的多个实现
`IProjectRepository` 有本地和远程两个实现。绑定时带上名称,容器里才会同时留下两份:
```ts
@bindAs(IProjectRepository, ProjectStore.LOCAL)
@injectable('Singleton')
export class LocalProjectRepository implements IProjectRepository {}
```
`RemoteProjectRepository` 使用 `ProjectStore.REMOTE`。两个实现留在同一个端口上,用名称区分。
用例的构造函数依赖抽象端口。两个参数类型都是 `IProjectRepository`,类型元数据区分不了本地和远程,所以用 `@inject` 指定端口,用 `@named` 指定实现:
```ts
constructor(
@inject(IProjectRepository) @named(ProjectStore.LOCAL) private localRepository: IProjectRepository,
@inject(IProjectRepository) @named(ProjectStore.REMOTE) private remoteRepository: IProjectRepository,
) {}
```
页面只传入 `ProjectStore`,不自己决定用 IndexedDB 还是 `Http`。
## 装饰器能生效的条件
JavaScript 运行时不保留构造函数参数的类型。`tsconfig.app.json` 打开 `experimentalDecorators` 和 `emitDecoratorMetadata` 后,编译器把参数类型写成 `design:paramtypes`。容器读这个数组,按类型创建依赖。
`SecurityApplication` 的参数类型分别是 `ISecurityRepository` 和 `TokenStore`,数组里就能分开,构造函数只声明类型。`ProjectApplication` 的两个参数类型都是 `IProjectRepository`。上一节用 `@inject` 指定端口,用 `@named(ProjectStore.LOCAL)` 和 `@named(ProjectStore.REMOTE)` 指定实现,容器按名称选择。
跨目录引用写 `@/app/...` 或 `@/features/...`。只表示类型的导入写成 `import type`,避免把类型当成运行时代码。

View File

@@ -0,0 +1,53 @@
# mobx是什么
MobX 负责页面上会变化的状态。mobx-keystone 负责可以保存、比较和恢复的实体。页面组件只负责渲染,不存放业务状态。
## 目录
- [页面状态](#页面状态)
- [ViewModel 的生命周期](#viewmodel-的生命周期)
- [实体与快照](#实体与快照)
## 页面状态
ViewModel 里需要触发重渲染的字段标成 `observable`,修改这些字段的方法标成 `action`。组件用 `mobx-react-lite` 的 `observer` 包起来,字段变化后才会重画。
`SecurityViewModel` 的 `errorMessage`、`isLoading` 就是页面状态。它们跟着这一次打开的登录页,所以这个类标 `@injectable('Transient')`。
普通字段不用 observable。不会显示在界面上的局部变量也不放进 ViewModel。
## ViewModel 的生命周期
ViewModel 继承 `PageViewModel`。页面不自己 `new`,而调用 `useViewModel`。
```ts
export function useViewModel<T extends PageViewModel>(identifier: new (...args: any[]) => T): T {
const [viewModel] = useState(() => {
const vm = rootContainer.get(identifier);
vm.created();
return vm;
});
useEffect(() => {
viewModel.ready();
const onPageHide = () => viewModel.beforeRefresh();
window.addEventListener('pagehide', onPageHide);
return () => {
window.removeEventListener('pagehide', onPageHide);
viewModel.dispose();
};
}, [viewModel]);
return viewModel;
}
```
`created` 在实例取出时调用。`ready` 在页面挂上后调用,适合开始加载数据,例如 `HomeViewModel.ready` 去拉项目列表。`beforeRefresh` 在页面被浏览器收起时调用。离开页面时调用 `dispose`。
## 实体与快照
`Project`、`Document`、`LineShape` 使用 mobx-keystone 的 `@model`。要保存的字段用 `tProp` 声明,修改实体的方法标 `@modelAction`。对外改文档的方法在聚合根上:`Project.addShape`、`Project.undo`、`Project.redo`、`Project.touch`。
快照是实体在某一刻的普通对象。`getSnapshot` 取出它,`applySnapshot` 把实体恢复成那一份。`Project.addShape` 接受图形前,把 `Document` 的快照放进聚合自己的 `past`,`undo` 和 `redo` 再用 `applySnapshot` 换回来。`past` 和 `future` 在 `onInit` 里标成 `observable`,页面才能根据 `canUndo`、`canRedo` 重绘。它们不是 `tProp`,`getSnapshot(project)` 不包含撤销栈。
保存到本地库或提交给接口之前,仓储取出项目快照再写入。实体类仍然留在内存里给界面使用。编辑器 ViewModel 只持有当前的 `project`、`store`、工具和草稿。

View File

@@ -0,0 +1,50 @@
# 代码实践
沿登录走一遍。目的是看见页面、入口文件、ViewModel、用例、端口和网络怎样接在一起。分层见 [DDD是什么](./DDD是什么.md),容器见 [DI是什么](./DI是什么.md)。
## 目录
- [入口文件先执行](#入口文件先执行)
- [页面取出 ViewModel](#页面取出-viewmodel)
- [用例只依赖端口](#用例只依赖端口)
- [实现负责请求](#实现负责请求)
- [少了入口 import 会怎样](#少了入口-import-会怎样)
## 入口文件先执行
`src/features/security/security.ts` 没有导出,只有两行 import:
```ts
import './i18n';
import './infra/persistence/security_repository';
```
第一行执行 `registerMessages`,登录页的错误文案才能按键取到。第二行执行 `SecurityRepository` 上的 `@bindAs(ISecurityRepository)`,容器才知道端口用哪个类。
登录页第一行是 `import '../../security'`。这行不使用任何导出,作用是让上面两个文件运行。
## 页面取出 ViewModel
`security.tsx` 是默认导出的页面,由 `src/app/routes.ts` 挂在 `/security`,并放在 `guest` 布局里。已登录用户进不了这个路径。
页面是 `observer`,用 `useViewModel(SecurityViewModel)` 取出 ViewModel。用户提交时调用 `submit`。ViewModel 是 `Transient`,这次填写的账号和错误只留在这一次打开的页面里。
## 用例只依赖端口
`submit` 在登录页校验 `AuthCredentials`。通过后调用:
```ts
await this.application.login(this.loginForm);
```
`SecurityApplication.login` 不写 axios。它调用 `ISecurityRepository.login`,拿到 `LoginResult` 后执行 `tokenStore.save(result.accessToken)`。凭证怎样通知界面,由 `src/app/session` 负责。
## 实现负责请求
`SecurityRepository.login` 把实体收成快照,再调用 `this.http.instance.post('/auth/login', plainDto)`。响应收成 `User` 和 `LoginResult` 后返回给用例。`Http` 会在请求上附带已有凭证,并处理 401。这些步骤不写进用例。
## 少了入口 import 会怎样
页面如果没有 `import '../../security'`,`security.ts` 不会执行。文案没有注册,`ISecurityRepository` 也没有绑定。`useViewModel` 创建 `SecurityViewModel` 时,容器无法提供 `SecurityApplication` 所依赖的仓储。
项目模块同样依赖 `src/features/project/project.ts`。它导入本地和远程两个仓储,两者分别用 `@bindAs(IProjectRepository, ProjectStore.LOCAL)` 和 `ProjectStore.REMOTE` 绑到同一个端口。用例用 `@inject` 和 `@named` 注入对应实现。首页和编辑器在文件顶部写 `import '@/features/project/project'`,本地列表和远程列表写 `import '../../project'`。新增仓储时,把实现文件加进对应的入口,并保证被路由加载到的页面引用了这个入口。

View File

@@ -0,0 +1,33 @@
# 常见设计模式及使用
本篇只写这套脚手架里会碰到的三种写法。名称用来对照代码,不展开其他模式。
## 目录
- [依赖注入](#依赖注入)
- [仓储](#仓储)
- [观察者](#观察者)
## 依赖注入
调用方不创建依赖,容器按构造函数传入。`SecurityApplication` 需要仓储和 `TokenStore` 时,只声明参数类型,不写 `new SecurityRepository()`。
依赖倒置、注入方式和 `Singleton`、`Transient`、`@bindAs` 的用法见 [DI是什么](./DI是什么.md)。
## 仓储
仓储把「业务要的数据」和「数据放在哪」分开。
`ISecurityRepository` 声明 `login`,返回 `LoginResult`。用例只调用这个方法,再把 `accessToken` 写入 `TokenStore`。`SecurityRepository` 才知道要访问 `/auth/login`,并把响应收成 `User`。
项目模块只有一个端口 `IProjectRepository`,声明 `list`、`get`、`save`。本地实现走 IndexedDB,远程实现走 `Http`,都实现这个端口。`ProjectApplication` 按 `store` 选择其中一个。
因此 `domain` 不出现 axios 和 IndexedDB,`infra` 不决定登录成功后要做什么。
## 观察者
状态变化后,订阅者自己更新。本仓库有两处。
MobX 的 `observable` 字段变化时,包了 `observer` 的组件重绘。这是页面状态到界面的观察者。
`TokenStore.save` 和 `TokenStore.remove` 调用 `notifyAuthChanged`。`useAuthenticated` 通过 `subscribeAuth` 收到 `AUTH_CHANGED` 后再读一次 `hasSession`。登录态变化时,首页不必自己轮询。

View File

@@ -0,0 +1,64 @@
# 当前框架技术栈说明
本目录讲上手这套脚手架需要的概念。本仓库怎么分层、模块之间数据怎么走,见 [项目架构](../项目文档/项目架构.md)。新代码怎么命名、怎么接进容器,见 [代码撰写规范](../项目文档/代码撰写规范.md)。
## 目录
- [阅读顺序](#阅读顺序)
- [技术栈](#技术栈)
- [样式](#样式)
- [路由怎么挂页面](#路由怎么挂页面)
## 阅读顺序
1. 本文。
2. [DDD是什么](./DDD是什么.md)。知道四层、聚合根和端口各自放什么。
3. [DI是什么](./DI是什么.md)。知道类怎样进容器,页面怎样拿到它们。
4. [mobx是什么](./mobx是什么.md)。知道页面状态、实体和快照。
5. [常见设计模式及使用](./常见设计模式及使用.md)。只看本仓库用到的几种。
6. [代码实践](./代码实践.md)。沿登录走一遍。
国际化、数据库、Worker 的用法在 `doc/项目文档/基础应用/`。界面组件使用 Ant Design,不在本目录展开。
## 技术栈
| 作用 | 库 |
|---|---|
| 界面 | React |
| 路由 | React Router。路由表在 `src/app/routes.ts` |
| 构建 | Vite。环境文件在 `src/`,只暴露 `VITE_` 开头的变量 |
| 语言 | TypeScript。装饰器需要 `experimentalDecorators` 和 `emitDecoratorMetadata` |
| 依赖注入 | Inversify。容器在 `src/app/di/root_container.ts` |
| 页面状态 | MobX,配合 `mobx-react-lite` 的 `observer` |
| 实体 | mobx-keystone 的 `@model` |
| 请求 | axios。封装在 `src/app/network` |
| 文案 | i18next |
| 样式 | Sass |
路径别名 `@/` 指向 `src/`。跨目录用它,同一功能模块内部用相对路径。
## 样式
组件样式用同名 `.scss`,在组件文件里 import。主题色由 `src/app/theme` 解析后交给 Ant Design,不在每个组件里各写一套亮色和暗色。
## 路由怎么挂页面
页面不自己注册路由。`src/app/routes.ts` 把 URL 指到 `presentation` 下的页面文件,页面使用默认导出。
```ts
export default [
layout('routes/app_shell.tsx', [
index('../features/home/presentation/home/home.tsx'),
layout('routes/layouts/guest.layout.tsx', [
route('security', '../features/security/presentation/security/security.tsx'),
]),
layout('routes/layouts/auth.layout.tsx', [
route('editor/:projectId', '../features/editor/presentation/editor/editor.tsx'),
]),
]),
] satisfies RouteConfig;
```
`app_shell` 提供文案和主题。`guest` 只允许未登录进入,例如 `/security`。`auth` 只允许已登录进入,例如 `/editor/:projectId`。判断使用 `hasSession()`,规则在 `decideRoutePolicy`。
新增页面时:在功能模块的 `presentation` 写下默认导出的页面,再在这张表里登记路径和布局。