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` 写下默认导出的页面,再在这张表里登记路径和布局。

View File

@@ -0,0 +1,68 @@
# 撰写总则
## 目录
- [文档分三类](#文档分三类)
- [每篇文档都写目录](#每篇文档都写目录)
- [图中只用项目词条](#图中只用项目词条)
- [图只画本层](#图只画本层)
- [功能模块何时拆开](#功能模块何时拆开)
- [文件名](#文件名)
- [示例与正式文档](#示例与正式文档)
## 文档分三类
| 文档 | 位置 | 正文 |
| ---- | ---------------------------------- | ---------------------------------------- |
| 项目架构 | `doc/项目架构.md`,全库一篇 | 目录结构(到 `features/[feature]`)、模块定义、功能模块之间的业务数据流 |
| 基础应用 | `doc/基础应用/`,`src/app` 下,一个目录一篇 | 功能定义、使用示例、该应用内部的数据流与时序 |
| 功能模块 | `doc/功能模块/`,`src/features` 下一个目录一组 | 模块内目录、功能定义、核心业务层与核心模型、核心业务时序图 |
`src/app` 下的每个目录是一个基础应用。`src/features` 下的每个目录是一个功能模块。
章节的写法分别见:
- [项目架构文档撰写说明](./项目架构文档撰写说明.md)
- [基础应用文档撰写说明](./基础应用/基础应用文档撰写说明.md)
- [功能模块文档撰写说明](./功能模块/功能模块文档撰写说明.md)
## 每篇文档都写目录
标题下第一段是目录,标题为「目录」。条目与正文标题一致,并链到对应标题。拆出去的业务文档写进父文档目录,链到文件。
撰写说明、示例、正式文档都要有目录。
## 图中只用项目词条
Mermaid 的节点、参与者、箭头文字只用仓库里的目录名、类名、方法名、字段名。中文说明写在图外的正文里。
## 图只画本层
项目架构只画 `home`、`security`、`project`、`editor` 之间的业务数据。不画 `session`、`network`、`db`、`routes` 这些基础应用,也不画时序。
基础应用只画该目录内部的类和函数。不画功能模块的调用链,也不把其他基础应用的内部步骤画进来。调用方可以出现在使用示例的代码里。时序图已经能说明这条行为时,不再画数据流图。
功能模块的时序写在功能模块文档。那里不展开基础应用内部步骤。
## 功能模块何时拆开
一条主业务时,时序图写在该模块主文档里。
同一模块有多条要分开讲述的事件业务时,为每条业务另写一篇,放在该模块目录下。主文档保留目录结构、功能定义、核心业务层与核心模型,并用目录链到各业务文档。编辑器的打开、绘制、撤销与重做按这个方式拆开。
方法定义写在主文档的核心业务层。业务文档写该业务的定义和时序图,并点名用到的方法。
## 文件名
- 基础应用:`中文名(目录名).md`,例如 `会话(session).md`。
- 功能模块主文档:`模块名(目录名).md`,例如 `编辑器模块(editor).md`。
- 拆出的业务文档:放在同一模块目录,用业务名作文件名,例如 `绘制.md`。
- 说明与示例只放在 `doc/文档撰写说明与示例/`。
## 示例与正式文档
示例用来对齐章节和粒度,正式文档另写。类名、方法、目录以当时仓库为准;代码变了就改正式文档。

View File

@@ -0,0 +1,33 @@
# 项目架构文档撰写说明
适用文件:`doc/项目架构.md`。
## 目录
- [1. 项目目录结构](#1-项目目录结构)
- [2. 模块定义](#2-模块定义)
- [3. 模块间数据流](#3-模块间数据流)
示例见 [项目架构(示例)](./项目架构(示例).md)。
## 1. 项目目录结构
用目录树写出 `src`。`src/app` 展开到每个基础应用目录。`src/features` 展开到 `features/[feature]`,不写模块内部。
模块内部目录由功能模块文档写。基础应用内部文件由使用示例和数据流体现。
## 2. 模块定义
先用一段话定义本仓库的模块:`src/app/<目录>` 是基础应用,`src/features/<目录>` 是功能模块。
然后分「基础应用」「功能模块」两张表。每行:名称、目录、这个模块做什么。表中的目录与上一节一致,不多出、不漏掉。
功能模块的定义写 application 层的业务,也就是该模块对外提供的用例。不写页面上的操作,不写 ViewModel。ViewModel 是页面业务,放在功能模块文档。模块没有自己的 application 时,写明它不持有独立用例,以及它调用哪个模块的用例。
## 3. 模块间数据流
只画功能模块之间的业务数据。节点是 `home`、`security`、`project`、`editor`。箭头文字使用在模块之间传递的方法名或字段名,例如 `list`、`projectId`。中文写在图下。
不画基础应用,例如 `session`、`network`、`db`、`routes`。不画时序,不写类内部的调用步骤。基础应用内部的数据流和时序写在基础应用文档。功能模块内部的时序写在功能模块文档。
一条业务一张图。图下用几句话说明业务数据从哪个功能模块到哪个功能模块。决定下游数据能否继续流动的状态必须画出来。例如 `home` 先取得 `security` 的 `authenticated`,才从 `project` 取得 `projectId`,再交给 `editor`。

View File

@@ -0,0 +1,172 @@
# 代码撰写规范
本规范规定脚手架里新代码怎么放、怎么命名、怎么接进容器。业务数据流见 [项目架构](./项目架构.md)。
## 目录
- [环境变量](#环境变量)
- [目录与分层](#目录与分层)
- [文件命名](#文件命名)
- [类与函数命名](#类与函数命名)
- [依赖注入](#依赖注入)
- [Singleton 与 Transient](#singleton-与-transient)
- [功能模块入口](#功能模块入口)
- [import](#import)
- [新增功能模块](#新增功能模块)
## 环境变量
环境文件放在 `src/`。`vite.config.ts` 的 `envDir` 是 `src`。
| 文件 | 脚本 |
| ---------------------- | ----------------------------------- |
| `src/.env.debug` | `npm run debug` |
| `src/.env.development` | `npm run dev`、`npm run build` |
| `src/.env.production` | `npm run prod`、`npm run build:prod` |
只有 `VITE_` 开头的变量会进入浏览器。命名是 `VITE_` 加大写蛇形,例如 `VITE_API_URL`。
这里只放会公开到前端的地址和开关。密钥不放进这些文件。业务代码用 `import.meta.env.VITE_*` 读取,并在使用处给出缺省值。`Http` 的基地址写在 `src/app/network/http.ts`:
```ts
baseURL: import.meta.env.VITE_API_URL || 'https://mybackend.com'
```
## 目录与分层
`src/app` 放全库能力,不放业务用例。`src/features/<name>` 放一个功能模块。
| 层 | 放置 | 可以依赖 |
| -------------- | ----------------- | -------------------------------------- |
| `domain` | 聚合、值对象、领域事件、接口 | 本模块 `domain` |
| `application` | 用例 | 本模块 `domain`,以及 `src/app` 里该用例需要的入口 |
| `infra` | 接口的实现 | 本模块 `domain`,以及 `src/app` 的网络、数据库、依赖注入 |
| `presentation` | 页面、ViewModel、展示组件 | 本模块 `application`,以及标明可使用的其他模块类型 |
`domain` 不引用 React,也不引用 `src/app`。没有内容的层不建空目录。
聚合根是外部唯一可以修改的实体。`Project` 包含 `Document` 和图形,落笔是否成立、撤销和重做都写在 `Project` 上。页面和其他模块不调用 `Document.addShape`,也不替换文档快照。
仓储接口只表达取出和保存聚合,不表达本地或远程。`local` 与 `remote` 留在 `application`,用来选择哪个 `infra` 实现。接口的返回值是领域对象。登录返回 `User`,不返回未标明类型的 `user`。
登录成功先形成领域结果,再由用例调用 `TokenStore.save`。`AUTH_CHANGED` 只通知界面,不是领域事件。
路由只在 `src/app/routes.ts` 里把 URL 指到 `presentation` 下的页面。需要登录的页面放进 `auth` 布局,仅访客可进的页面放进 `guest` 布局。
## 文件命名
目录和文件用小写蛇形。
| 种类 | 形式 | 例子 |
| --------- | ---------------------------------------- | --------------------------------- |
| 页面 | 与所在目录同名 | `home/home.tsx` |
| ViewModel | `*_view_model.ts` | `security_view_model.ts` |
| 样式 | 与组件同名的 `.scss` | `home.scss` |
| 实体 | 聚合或业务名文件 | `project.ts`、`document.ts`、`user.ts` |
| 抽象接口 | `i_` 前缀 | `i_security_repository.ts` |
| 仓储实现 | `*_repository.ts`,放在 `infra/persistence` | `security_repository.ts` |
| 布局 | 点号标出角色 | `auth.layout.tsx` |
| 文案 | 语言码 | `zh_cn.ts`、`en_us.ts` |
| 模块入口 | 与功能目录同名 | `security.ts`、`project.ts` |
## 类与函数命名
类用帕斯卡命名。函数和钩子用小驼峰,钩子以 `use` 开头。
| 种类 | 形式 | 例子 |
| ---- | -------------------------------- | --------------------------- |
| 用例 | `名称Application` | `SecurityApplication` |
| 页面模型 | `名称ViewModel`,继承 `PageViewModel` | `HomeViewModel` |
| 仓储实现 | `名称Repository` | `LocalProjectRepository` |
| 接口 | 抽象类,`I` 前缀 | `ISecurityRepository` |
| 实体 | 业务名,不加 `Entity` 后缀 | `Project`、`User`、`AuthCredentials` |
| 枚举 | `名称Enum` | `AuthEnum` |
实体使用 `User`、`Project` 这样的业务名,类名不带 `Entity` 后缀。
路由页面使用默认导出。`src/app/routes.ts` 按文件加载页面。
## 依赖注入
全库只有 `src/app/di/root_container.ts` 里的 `rootContainer`。容器打开 `autobind`,默认 scope 是 `Singleton`。
| 对象 | 写法 |
| --------------------------------- | ------------------------------------------- |
| 用例、`TokenStore`、`Http`、`Database` | `@injectable('Singleton')` |
| 页面 ViewModel | `@injectable('Transient')` |
| 接口实现 | `@injectable('Singleton')`,并加 `@bindAs(接口)` |
### Singleton 与 Transient
`Singleton` 在整个应用里只创建一次。之后无论哪个页面、哪次 `rootContainer.get`,拿到的都是同一个实例。适合没有页面现场、需要一直留着的对象:用例、仓储、`TokenStore`、`Http`、`Database`。登录后的凭证、数据库连接和用例方法要在首页、编辑器之间共用,所以这些类用 `Singleton`。
`Transient` 每次 `rootContainer.get` 都新建一个实例。`useViewModel` 在页面挂载时取一次 ViewModel,离开页面后这次实例不再使用。适合只属于当前页的状态:表单、`errorMessage`、尚未落笔的草稿。撤销栈属于 `Project`,不放在 ViewModel 里。`SecurityViewModel`、`HomeViewModel`、`EditorViewModel`、`ProjectListViewModel` 都用 `Transient`。
ViewModel 若标成 `Singleton`,再次进入同一页会看到上次留下的表单、错误和草稿。用例若标成 `Transient`,每个页面都会各持有一份,凭证和仓储无法在页面之间共用。
无参的 `@injectable()` 会落到容器默认的单例,效果与 `Singleton` 相同。新代码写出 scope。现有仓储仍是无参写法。
`bindAs` 定义在 `src/app/di/bind_as.ts`。装饰器在类定义时绑定,并且写在 `@injectable` 上面。类装饰器从靠近类的那一个开始执行,`@injectable` 先写入构造参数类型,`bind(port).to(impl)` 才能读到。同一个实现再次执行时,先卸掉上一次的绑定再绑。同一个接口有多个实现时传入名称,例如 `@bindAs(IProjectRepository, ProjectStore.LOCAL)`。用例构造函数用 `@inject(IProjectRepository)` 和 `@named(ProjectStore.LOCAL)` 注入对应实现。
```ts
@bindAs(ISecurityRepository)
@injectable('Singleton')
export class SecurityRepository implements ISecurityRepository {}
```
只在 `infra` 的接口实现上使用 `@bindAs`。用例和 ViewModel 由 `autobind` 按类本身注册,不再包一层接口。
## 功能模块入口
`src/features/<name>/<name>.ts` 只做副作用 import,不导出类,不写用例。它让文案注册和 `@bindAs` 在页面渲染前执行。
`src/features/security/security.ts`:
```ts
import './i18n';
import './infra/persistence/security_repository';
```
第一行执行 `registerMessages`。第二行使 `SecurityRepository` 上的 `@bindAs(ISecurityRepository)` 跑起来。安全页在文件顶部引用它:
```ts
import '../../security';
```
`project.ts` 导入本地仓储和远程仓储。使用项目用例的页面同样要先引用它,例如 `import '@/features/project/project'` 或 `import '../../project'`。
新增仓储或文案包时,把对应文件 import 进这个入口,并保证至少有一个会被路由加载到的页面引用该入口。没有这行 import,容器里没有实现,文案也不会注册。
## import
跨目录使用 `@/`,与 `tsconfig.app.json` 的 `paths` 一致。同一功能模块内部使用相对路径。
```ts
import { useViewModel } from '@/app/lifecycle/use_view_model';
import { ProjectApplication } from '@/features/project/application/project_application';
import { SecurityViewModel } from './security_view_model';
```
副作用 import 放在文件最上方,单独成行,不写绑定。类型使用 `import type`。
其他模块可以引用对方的 application,以及标明可使用的 entity 和展示组件。不引用对方的 `infra`。
## 新增功能模块
1. 在 `src/features/<name>/domain` 写实体和接口。接口是 `I` 前缀的抽象类。
2. 在 `application` 写用例,并标 `@injectable('Singleton')`。
3. 在 `infra` 写实现,标 `@injectable('Singleton')` 和 `@bindAs(接口)`。
4. 把实现 import 进 `src/features/<name>/<name>.ts`。有文案时,同时 import 该模块的 `i18n`。
5. 在 `presentation` 写页面和 ViewModel。ViewModel 继承 `PageViewModel`,标 `@injectable('Transient')`。页面先 import 模块入口,再用 `useViewModel` 取出 ViewModel。页面默认导出。
6. 在 `src/app/routes.ts` 登记路径。需要登录的页面放进 `auth` 布局。

View File

@@ -0,0 +1,75 @@
# 功能模块文档撰写说明
适用文件:`doc/功能模块/<模块名>/<模块名>(目录名).md`。
## 目录
- [主文档写什么](#主文档写什么)
- [一条主业务](#一条主业务)
- [多条事件业务](#多条事件业务)
- [业务文档写什么](#业务文档写什么)
单文档形态见本节「一条主业务」。拆分形态的示例是 [编辑器主文档](./功能模块(示例).md) 和 [绘制](./绘制(示例).md)。
## 主文档写什么
### 1. 模块目录结构
写出该 `src/features/[feature]` 内部的目录和承担业务的文件。空目录按仓库现状保留。
### 2. 功能定义
一段话定义这个模块做什么。下面列出它包含的业务。每条业务一句话。
### 3. 核心业务层与核心模型
分两节,不合并成「核心类」。
核心业务层是 ViewModel。每个 ViewModel 写定义、文件路径和方法。公开方法必写,承担业务的私有方法一并列出。方法写签名和这个方法做什么。页面组件不列入。
核心模型写聚合根、它包含的实体,以及不变条件。每个类型写定义、文件路径和领域方法。包含关系用类图。外部只能调用聚合根的方法。存储位置、接口报文字段不写进实体。
需要画出页面怎样向聚合提交动作时,另用用例图:参与者写 ViewModel,用例写聚合根的方法名。类图和用例图不要重复同一件事。
## 一条主业务
模块只有一条要讲述的主业务时,不另建文件。主文档增加第 4 节「核心业务」,放一张时序图。图中只用类名和方法名,图下用中文按调用顺序写清这条业务。
只有一条主业务的模块,目录是:
```markdown
## 目录
- [模块目录结构](#模块目录结构)
- [功能定义](#功能定义)
- [核心业务层](#核心业务层)
- [核心模型](#核心模型)
- [核心业务](#核心业务)
```
「核心业务」里直接放时序图。例如项目模块的保存:调用方 → `ProjectApplication.save` → `IProjectRepository`。应用层先按 `store` 选定实现,领域端口本身不区分本地和远程。`list`、`get`、`save`、`create` 写在功能定义。落笔、撤销和重做写在聚合根。
## 多条事件业务
同一模块有多条事件业务时,主文档不放时序图。第 4 节改为「业务文档」,作为索引链到同目录下的各篇。
编辑器按事件拆开,例如:
```text
doc/功能模块/编辑器模块/
├── 编辑器模块(editor).md
├── 打开项目.md
├── 绘制.md
└── 撤销与重做.md
```
主文档目录里列出这些文件。
## 业务文档写什么
每篇业务文档包含目录,以及:
1. 业务定义:这个事件做什么,从哪个操作开始。
2. 时序图:参与者使用主文档里的类名,箭头文字使用方法名。图下用中文按顺序说明这次事件。
方法表留在主文档的核心业务层。业务文档点名本事件调用的方法。

View File

@@ -0,0 +1,124 @@
# 安全模块
## 目录
- [模块目录结构](#模块目录结构)
- [功能定义](#功能定义)
- [核心业务层](#核心业务层)
- [SecurityViewModel](#securityviewmodel)
- [核心模型](#核心模型)
- [核心业务](#核心业务)
- [登录](#登录)
## 模块目录结构
```text
src/features/security/
├── application/
│ └── security_application.ts
├── domain/
│ ├── entities/
│ │ ├── auth_credentials.ts
│ │ ├── register_credentials.ts
│ │ ├── login_result.ts
│ │ └── user.ts
│ ├── enum/
│ │ └── auth_enum.ts
│ └── repositories/
│ └── i_security_repository.ts
├── i18n/
├── infra/
│ └── persistence/
│ └── security_repository.ts
├── presentation/
│ ├── profile/
│ └── security/
└── security.ts
```
`security.ts` 导入文案和 `SecurityRepository`。`profile` 页面没有用例。
## 功能定义
安全模块提供身份用例。
- `login(credentials)`:校验身份,得到 `LoginResult`。用例再把其中的访问凭证写入会话。
- `register(credentials)`:注册一个用户。
- `logout()`:清除会话中的凭证。
这些方法在 `SecurityApplication`。端口方法名不带 `remote`。
## 核心业务层
### SecurityViewModel
登录与注册页的状态。文件:`src/features/security/presentation/security/security_view_model.ts`。继承 `PageViewModel`。
状态:`activeTab`、`isLoading`、`errorMessage`、`loginForm`、`registerForm`。
| 方法 | 说明 |
|---|---|
| `switchTab(tab: AuthEnum): void` | 在 `LOGIN` 与 `REGISTER` 之间切换,并清空 `errorMessage` |
| `submit(): Promise<boolean>` | 表单已经由领域对象判定有效后,登录调用 `login`,注册调用 `register` |
| `dispose(): void` | 清空 `errorMessage` |
`submit` 在 `isValid` 不为真时,用 `validationErrorKey` 取出文案并返回 `false`。登录成功返回 `true`。注册成功后切回 `LOGIN` 并返回 `false`。
## 核心模型
| 类型 | 文件 | 定义 |
|---|---|---|
| `AuthCredentials` | `src/features/security/domain/entities/auth_credentials.ts` | 登录凭证。用户名不少于 3 个字符,密码不少于 6 个字符 |
| `RegisterCredentials` | `src/features/security/domain/entities/register_credentials.ts` | 注册凭证。在登录凭证上增加一次确认密码,两次密码必须相同 |
| `User` | `src/features/security/domain/entities/user.ts` | 用户。持有 `id`、`username`、`roles` |
| `LoginResult` | `src/features/security/domain/entities/login_result.ts` | 一次成功的登录。持有 `User` 和 `accessToken` |
| `ISecurityRepository` | `src/features/security/domain/repositories/i_security_repository.ts` | `login` 返回 `LoginResult`,`register` 接收 `RegisterCredentials` |
```mermaid
classDiagram
class AuthCredentials {
username
password
isValid
validationErrorKey
}
class RegisterCredentials {
confirmPassword
isValid
validationErrorKey
}
class User {
id
username
roles
hasPermission(permission)
}
class LoginResult {
user
accessToken
}
AuthCredentials <|-- RegisterCredentials
LoginResult --> User
```
`User.hasPermission` 在角色包含 `admin` 或指定权限时返回真。登录失败不产生 `LoginResult`。
## 核心业务
### 登录
```mermaid
sequenceDiagram
participant SecurityViewModel
participant SecurityApplication
participant ISecurityRepository
participant LoginResult
participant TokenStore
SecurityViewModel->>SecurityApplication: login(credentials)
SecurityApplication->>ISecurityRepository: login(credentials)
ISecurityRepository-->>SecurityApplication: LoginResult
SecurityApplication->>TokenStore: save(accessToken)
```
`SecurityViewModel.submit` 在 `activeTab` 为 `LOGIN` 时调用 `login`。仓储返回的是 `User` 和 `accessToken`,不是未标明类型的用户字段。用例只把 `accessToken` 交给 `TokenStore.save`。`AUTH_CHANGED` 是会话发出的界面通知,不是领域事件。通知怎样到达页面,见 [会话](../../基础应用/会话(session).md)。

View File

@@ -0,0 +1,28 @@
# 打开项目
## 目录
- [业务定义](#业务定义)
- [时序](#时序)
## 业务定义
编辑页根据路由中的 `projectId` 和 `store` 载入 `Project`,并清空尚未落笔的草稿。
本事件使用 `EditorViewModel.open`。参数 `store` 必须是枚举 `ProjectStore` 的取值。
## 时序
```mermaid
sequenceDiagram
participant EditorPage
participant EditorViewModel
participant ProjectApplication
participant Project
EditorPage->>EditorViewModel: open(id, store)
EditorViewModel->>ProjectApplication: get(store, id)
ProjectApplication-->>EditorViewModel: Project
```
`id` 或 `store` 为空时,`errorMessage` 为缺少项目。`store` 不在 `ProjectStore` 的取值里时,`errorMessage` 为项目存储无效。这两种情况都不再请求。读到的聚合写入 `project`,并记下 `store`,同时清空 `draft`。没有这个项目时,`errorMessage` 为找不到项目。

View File

@@ -0,0 +1,31 @@
# 撤销与重做
## 目录
- [业务定义](#业务定义)
- [时序](#时序)
## 业务定义
编辑器请求 `Project` 后退或前进一份文档,并把聚合保存。快照由 `Project` 持有。
本事件使用 `EditorViewModel` 的 `undo`、`redo`。
## 时序
```mermaid
sequenceDiagram
participant EditorPage
participant EditorViewModel
participant Project
participant ProjectApplication
EditorPage->>EditorViewModel: undo()
EditorViewModel->>Project: undo()
EditorViewModel->>ProjectApplication: save(store, project)
EditorPage->>EditorViewModel: redo()
EditorViewModel->>Project: redo()
EditorViewModel->>ProjectApplication: save(store, project)
```
`Project.undo` 没有更早的快照时返回失败,页面不调用 `save`。成功时聚合把当前文档换回上一份快照。`Project.redo` 没有更新的快照时同样不保存。成功时聚合恢复下一份快照。两次成功之后都按 `store` 调用 `ProjectApplication.save`。

View File

@@ -0,0 +1,32 @@
# 绘制
## 目录
- [业务定义](#业务定义)
- [时序](#时序)
## 业务定义
用户在画布上按下、移动、抬起指针。编辑器按当前工具形成草稿,再交给 `Project.addShape`。聚合接受这次落笔后,用例保存整个 `Project`。
本事件使用 `EditorViewModel` 的 `pointerDown`、`pointerMove`、`pointerUp`。是否落笔由 `Project.addShape` 决定。
## 时序
```mermaid
sequenceDiagram
participant EditorPage
participant EditorViewModel
participant Project
participant ProjectApplication
EditorPage->>EditorViewModel: pointerDown(x, y)
EditorViewModel->>EditorViewModel: draft
EditorPage->>EditorViewModel: pointerMove(x, y)
EditorViewModel->>EditorViewModel: draft.x2 / draft.y2
EditorPage->>EditorViewModel: pointerUp()
EditorViewModel->>Project: addShape(shape)
EditorViewModel->>ProjectApplication: save(store, project)
```
`pointerDown` 要求已经打开项目,并用当前 `EditorTool` 创建 `DraftShape`。`pointerMove` 只在存在草稿时调用 `DraftShape.move`。`pointerUp` 先清掉草稿,再按工具生成 `LineShape` 或 `RectShape` 并调用 `addShape`。位移小于 2 时 `addShape` 拒绝,不保存。接受时,聚合自己记下文档快照并写入图形,然后 `save` 按页面上的 `store` 写回仓储。

View File

@@ -0,0 +1,92 @@
# 编辑器模块
## 目录
- [模块目录结构](#模块目录结构)
- [功能定义](#功能定义)
- [核心业务层](#核心业务层)
- [EditorViewModel](#editorviewmodel)
- [核心模型](#核心模型)
- [业务文档](#业务文档)
## 模块目录结构
```text
src/features/editor/
├── domain/
│ ├── entities/
│ │ └── draft_shape.ts
│ └── enum/
│ └── editor_tool.ts
└── presentation/
└── editor/
├── editor.tsx
├── editor.scss
└── editor_view_model.ts
```
编辑器不定义自己的聚合和用例。文档规则在项目模块的 `Project` 上。当前工具和未落笔的草稿是编辑器自己的领域对象。
## 功能定义
编辑器把页面动作提交给已经载入的 `Project`,再交给项目用例保存。
- 按 `projectId` 和 `store` 载入聚合。
- 按当前工具收集指针草稿,落笔时调用 `Project.addShape`。
- 调用 `Project.undo` 或 `Project.redo`,再保存。
## 核心业务层
### EditorViewModel
编辑页的状态和操作。文件:`src/features/editor/presentation/editor/editor_view_model.ts`。继承 `PageViewModel`。
状态:`project`、`store`、`tool`、`draft`、`errorMessage`。撤销栈不在这里。
| 方法 | 说明 |
|---|---|
| `open(id: string, store: string \| null): Promise<void>` | 按 `store` 读取 `Project`,并清空 `draft` |
| `setTool(tool: EditorTool): void` | 切换 `EditorTool.LINE` 或 `EditorTool.RECT` |
| `pointerDown(x: number, y: number): void` | 在已有项目时记下草稿起点 |
| `pointerMove(x: number, y: number): void` | 更新草稿终点 |
| `pointerUp(): void` | 用草稿调用 `Project.addShape`,接受后保存 |
| `undo(): void` | 调用 `Project.undo`,成功后保存 |
| `redo(): void` | 调用 `Project.redo`,成功后保存 |
`store` 只记录这次页面从本地还是远程打开,供 `ProjectApplication.get` 和 `save` 选择仓储。
## 核心模型
编辑器定义当前工具和未落笔的草稿。已提交的图形和撤销规则仍在项目模块的 `Project` 上,见 [项目模块](../项目模块/项目模块(project).md)。
| 类型 | 文件 | 定义 |
|---|---|---|
| `EditorTool` | `src/features/editor/domain/enum/editor_tool.ts` | 当前工具。`LINE` 与 `RECT` |
| `DraftShape` | `src/features/editor/domain/entities/draft_shape.ts` | 一笔尚未提交的草稿。持有工具和起点、终点 |
```mermaid
flowchart LR
EditorViewModel((EditorViewModel))
open([open])
addShape([addShape])
undo([undo])
redo([redo])
Project
EditorViewModel --> open
EditorViewModel --> addShape
EditorViewModel --> undo
EditorViewModel --> redo
open --> Project
addShape --> Project
undo --> Project
redo --> Project
```
`open` 载入 `Project`。`addShape`、`undo`、`redo` 都调用这个聚合根,不调用 `Document`。
## 业务文档
- [打开项目](./打开项目.md)
- [绘制](./绘制.md)
- [撤销与重做](./撤销与重做.md)

View File

@@ -0,0 +1,133 @@
# 项目模块
## 目录
- [模块目录结构](#模块目录结构)
- [功能定义](#功能定义)
- [核心业务层](#核心业务层)
- [ProjectListViewModel](#projectlistviewmodel)
- [核心模型](#核心模型)
- [核心业务](#核心业务)
- [保存项目](#保存项目)
## 模块目录结构
```text
src/features/project/
├── application/
│ └── project_application.ts
├── domain/
│ ├── entities/
│ │ ├── document.ts
│ │ └── project.ts
│ ├── enum/
│ │ └── project_store.ts
│ └── repositories/
│ └── i_project_repository.ts
├── infra/
│ └── persistence/
│ ├── local_project_repository.ts
│ └── remote_project_repository.ts
├── presentation/
│ ├── local_list/
│ ├── project_list/
│ ├── project_row/
│ └── remote_list/
└── project.ts
```
`project.ts` 导入 `LocalProjectRepository` 和 `RemoteProjectRepository`。两者都实现 `IProjectRepository`,由应用层按 `store` 选择。`store` 是领域枚举 `ProjectStore`,取值 `local` 与 `remote`,不是 `Project` 的字段。
## 功能定义
项目模块维护 `Project` 聚合,并按 `store` 选择仓储。
- `list(store)`:列出该存储位置的项目。
- `get(store, id)`:读取一个项目。
- `save(store, project)`:调用 `Project.touch` 后保存整个聚合。
- `create(store, name)`:通过 `Project.create` 新建项目并保存。`local` 写入本地仓储,`remote` 写入远程仓储。
这些方法在 `ProjectApplication`。页面和编辑器不直接选择仓储实现。
## 核心业务层
### ProjectListViewModel
本地列表和远程列表页的状态。文件:`src/features/project/presentation/project_list/project_list_view_model.ts`。继承 `PageViewModel`。
状态:`projects`、`errorMessage`。
| 方法 | 说明 |
|---|---|
| `load(store: ProjectStore): Promise<void>` | 调用 `ProjectApplication.list`,写入 `projects` |
`ProjectStore` 定义在 `domain/enum/project_store.ts`。`ProjectListPage` 在进入页面时调用 `load`。本地页传入 `ProjectStore.LOCAL`,远程页传入 `ProjectStore.REMOTE`。
## 核心模型
`Project` 是聚合根。外部只通过它修改文档和图形。
| 类型 | 文件 | 定义 |
|---|---|---|
| `Project` | `src/features/project/domain/entities/project.ts` | 聚合根。持有 `id`、`name`、`updatedAt`、`document`,以及撤销栈 |
| `ProjectStore` | `src/features/project/domain/enum/project_store.ts` | 存储位置。`LOCAL` 与 `REMOTE` |
| `Document` | `src/features/project/domain/entities/document.ts` | 聚合内部的图形文档,持有 `shapes` |
| `LineShape` | 同上 | 一条线段 |
| `RectShape` | 同上 | 一个矩形 |
| `IProjectRepository` | `src/features/project/domain/repositories/i_project_repository.ts` | `list`、`get`、`save`。不区分存储位置 |
```mermaid
classDiagram
class Project {
id
name
updatedAt
create(name)
touch()
addShape(shape)
undo()
redo()
}
class Document {
shapes
}
class LineShape {
id
x1
y1
x2
y2
}
class RectShape {
id
x
y
width
height
}
Project --> Document
Document --> LineShape
Document --> RectShape
```
`Project.create` 生成 `id`、名称和空文档。`touch` 把 `updatedAt` 写成当前时间。
`addShape` 是唯一写入图形的方法。线段或矩形的位移小于 2 时拒绝,文档保持不变。通过时,先记下当前文档快照,再把图形放入 `Document`。
`undo` 在没有更早的快照时不做。否则把当前文档换回上一份快照。`redo` 按相反方向恢复。快照存在聚合内部,不放在页面 ViewModel 里。
## 核心业务
### 保存项目
```mermaid
sequenceDiagram
participant ProjectApplication
participant Project
participant IProjectRepository
ProjectApplication->>Project: touch()
ProjectApplication->>IProjectRepository: save(project)
```
`ProjectApplication` 按 `store` 把 `IProjectRepository` 指到 `LocalProjectRepository` 或 `RemoteProjectRepository`,再调用 `save`。`list`、`get`、`create` 使用同一次选择。领域端口上看不到 `local` 或 `remote`。

View File

@@ -0,0 +1,87 @@
# 首页
## 目录
- [模块目录结构](#模块目录结构)
- [功能定义](#功能定义)
- [核心业务层](#核心业务层)
- [HomeViewModel](#homeviewmodel)
- [核心模型](#核心模型)
- [核心业务](#核心业务)
- [列出并新建](#列出并新建)
## 模块目录结构
```text
src/features/home/
└── presentation/
└── home/
├── home.tsx
├── home.scss
└── home_view_model.ts
```
首页没有 `application`、`domain`、`infra`。
## 功能定义
首页不持有独立用例。它调用项目模块的 `list` 和 `create`。
- 进入页面时按 `local` 与 `remote` 各列出一份项目。
- 新建时调用 `ProjectApplication.create`。聚合由 `Project.create` 生成,再按 `store` 保存。
已登录状态来自安全模块一侧的 `authenticated`。未登录时,`projectId` 不交给编辑器。
## 核心业务层
### HomeViewModel
首页的项目列表状态。文件:`src/features/home/presentation/home/home_view_model.ts`。继承 `PageViewModel`。
状态:`cloudProjects`、`localProjects`、`errorMessage`。
| 方法 | 说明 |
|---|---|
| `ready(): Promise<void>` | 进入页面时调用 `reload` |
| `reload(): Promise<void>` | 调用 `ProjectApplication.list(ProjectStore.REMOTE)` 和 `list(ProjectStore.LOCAL)` |
| `createLocal(): Promise<Project>` | 调用 `ProjectApplication.create(ProjectStore.LOCAL)` |
`local` 和 `remote` 是传给用例的 `store`,用来选择仓储。它们不是 `Project` 的属性。
## 核心模型
首页不定义实体。它使用项目模块的聚合根 `Project`。
```mermaid
flowchart LR
HomeViewModel((HomeViewModel))
list([list])
create([create])
Project
HomeViewModel --> list
HomeViewModel --> create
list --> Project
create --> Project
```
`list` 的结果写入 `cloudProjects` 或 `localProjects`。`create` 返回新建的 `Project`。
## 核心业务
### 列出并新建
```mermaid
sequenceDiagram
participant HomeViewModel
participant ProjectApplication
participant Project
HomeViewModel->>ProjectApplication: list(remote)
HomeViewModel->>ProjectApplication: list(local)
HomeViewModel->>ProjectApplication: create(local)
ProjectApplication->>Project: create(name)
ProjectApplication-->>HomeViewModel: Project
```
`ready` 触发两次 `list`。其中一次失败时,对应列表为空,错误写入 `errorMessage`。`create` 返回的 `Project.id` 与本次使用的 `store` 一起交给编辑器。

View File

@@ -0,0 +1,27 @@
# 基础应用文档撰写说明
适用文件:`doc/基础应用/<中文名>(目录名).md`。一个 `src/app` 子目录一篇。
## 目录
- [1. 功能定义](#1-功能定义)
- [2. 使用示例](#2-使用示例)
- [3. 数据流与时序](#3-数据流与时序)
示例见 [基础应用(示例)](./基础应用(示例).md)。
## 1. 功能定义
一段话说明这个基础应用做什么。下列出对外入口:类或函数、文件路径、各自负责的事。
## 2. 使用示例
给出调用这个入口的代码,并注明代码所在文件。示例表现功能模块或路由怎样使用它。一个入口一段示例;入口很小且调用方式相同时可以合在一段里。
## 3. 数据流与时序
一条行为优先用时序图。时序图已经能说明调用顺序时,不要再画数据流图。数据流图只在时序图表达不了数据经过哪些对象时再补。图中只用本目录的类名、函数名、方法名。图下用中文复述这条路径。
本节标题按实际采用的图来写。只有时序图时,标题为「时序」。
不画功能模块的调用链,例如 `SecurityViewModel` 到 `SecurityApplication`。不把其他基础应用的内部步骤画进本篇。功能模块何时调用这些入口,由功能模块文档的时序图说明。使用示例里的代码片段可以来自调用方。

View File

@@ -0,0 +1,49 @@
# 主题
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [切换主题](#切换主题)
## 功能定义
主题保存 `light`、`dark` 或 `system`,解析成实际的 `light` 或 `dark`,并交给界面库。
| 入口 | 文件 | 定义 |
|---|---|---|
| `getThemeMode()` | `src/app/theme/theme.ts` | 读取保存的 `ThemeMode`,缺省为 `system` |
| `resolveTheme(mode)` | 同上 | 把 `ThemeMode` 解析为 `ResolvedTheme` |
| `setThemeMode(mode)` | 同上 | 保存模式、涂色,并通知订阅者 |
| `subscribeTheme(listener)` | 同上 | 订阅主题变化 |
| `useThemeMode()` | `src/app/theme/use_theme.ts` | 读取当前 `ThemeMode` |
| `useResolvedTheme()` | 同上 | 读取当前 `ResolvedTheme` |
| `antdTheme(resolved)` | `src/app/theme/antd_theme.ts` | 按解析结果选择界面库算法 |
## 使用示例
应用外壳把解析结果交给界面库。文件:`src/app/routes/app_shell.tsx`。
```ts
const resolved = useResolvedTheme();
<ConfigProvider theme={antdTheme(resolved)} />
```
## 时序
### 切换主题
```mermaid
sequenceDiagram
participant setThemeMode
participant applyTheme
participant resolveTheme
participant subscribeTheme
setThemeMode->>applyTheme: applyTheme(mode)
applyTheme->>resolveTheme: resolveTheme(mode)
setThemeMode->>subscribeTheme: emit()
```
`setThemeMode` 写入本地后调用 `applyTheme`。`resolveTheme` 在模式为 `system` 时读取系统配色,否则直接采用 `light` 或 `dark`。涂色写到 `documentElement.dataset.theme`,然后通知 `subscribeTheme` 的监听者。`useResolvedTheme` 通过这个订阅更新界面。首次涂色在根组件挂载之后执行,水合完成前不改 `<html>` 上的属性。

View File

@@ -0,0 +1,67 @@
# 会话
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [凭证变化](#凭证变化)
## 功能定义
会话负责登录凭证的保存、读取和清除,并在凭证变化后通知界面。`AUTH_CHANGED` 只表示凭证变了,不是领域事件。登录是否成功由安全模块的 `LoginResult` 决定。
| 入口 | 文件 | 定义 |
|---|---|---|
| `TokenStore.save(token)` | `src/app/session/token_store.ts` | 写入凭证并调用 `notifyAuthChanged` |
| `TokenStore.get()` | 同上 | 读出凭证;没有则返回 `null` |
| `TokenStore.remove()` | 同上 | 清除凭证并调用 `notifyAuthChanged` |
| `TokenStore.has()` | 同上 | 当前是否持有凭证 |
| `hasSession()` | `src/app/session/auth_session.ts` | 从根容器取出 `TokenStore` 并调用 `has()` |
| `useAuthenticated()` | `src/app/session/use_authenticated.ts` | 订阅 `AUTH_CHANGED`,得到当前是否已登录 |
| `notifyAuthChanged()` | `src/app/session/auth_event.ts` | 发出 `AUTH_CHANGED` |
| `subscribeAuth(listener)` | 同上 | 登记 `AUTH_CHANGED` 的监听,并返回取消函数 |
## 使用示例
安全模块在登录成功后写入凭证,登出时清除。文件:`src/features/security/application/security_application.ts`。
```ts
async login(credentials: AuthCredentials): Promise<void> {
const result = await this.repository.login(credentials);
this.tokenStore.save(result.accessToken);
}
async logout(): Promise<void> {
this.tokenStore.remove();
}
```
路由用 `hasSession()` 判断能否进入。文件:`src/app/routes/layouts/auth.layout.tsx`。
```ts
const policy = decideRoutePolicy('require-auth', hasSession(), url.pathname, url.search);
```
## 时序
### 凭证变化
```mermaid
sequenceDiagram
participant TokenStore
participant notifyAuthChanged
participant subscribeAuth
participant useAuthenticated
participant hasSession
useAuthenticated->>subscribeAuth: subscribeAuth(sync)
TokenStore->>TokenStore: save(token)
TokenStore->>notifyAuthChanged: notifyAuthChanged()
notifyAuthChanged->>subscribeAuth: AUTH_CHANGED
subscribeAuth->>useAuthenticated: sync()
useAuthenticated->>hasSession: hasSession()
hasSession->>TokenStore: has()
```
`TokenStore.save` 和 `TokenStore.remove` 改完本地凭证后调用 `notifyAuthChanged`。`notifyAuthChanged` 发出 `AUTH_CHANGED`。`useAuthenticated` 事先通过 `subscribeAuth` 登记 `sync`,收到事件后再次调用 `hasSession`。`hasSession` 只调用 `TokenStore.has`。

View File

@@ -0,0 +1,66 @@
# 依赖注入
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [绑定端口](#绑定端口)
## 功能定义
依赖注入提供全库共用的根容器,并把功能模块的端口绑定到具体实现。容器默认单例,且对带 `@injectable` 的类自动绑定。
| 入口 | 文件 | 定义 |
|---|---|---|
| `rootContainer` | `src/app/di/root_container.ts` | 根容器。`autobind` 打开,`defaultScope` 为 `Singleton` |
| `bindAs(port, name?)` | `src/app/di/bind_as.ts` | 把抽象端口绑定到实现类。同一端口有多个实现时传入 `name` |
## 使用示例
安全模块把 `ISecurityRepository` 绑到 `SecurityRepository`。文件:`src/features/security/infra/persistence/security_repository.ts`。
```ts
@bindAs(ISecurityRepository)
@injectable('Singleton')
export class SecurityRepository implements ISecurityRepository {}
```
`@bindAs` 写在 `@injectable` 上面。类装饰器从靠近类的那一个开始执行,`@injectable` 先写入构造参数类型,`bind(port).to(impl)` 才能读到。
项目模块把同一个 `IProjectRepository` 绑两次,用名称区分:
```ts
@bindAs(IProjectRepository, ProjectStore.LOCAL)
@injectable('Singleton')
export class LocalProjectRepository implements IProjectRepository {}
```
`RemoteProjectRepository` 使用 `ProjectStore.REMOTE`。用例按名称注入抽象端口:
```ts
constructor(
@inject(IProjectRepository) @named(ProjectStore.LOCAL) private localRepository: IProjectRepository,
@inject(IProjectRepository) @named(ProjectStore.REMOTE) private remoteRepository: IProjectRepository,
) {}
```
模块入口负责导入这些实现,绑定才会执行。文件:`src/features/security/security.ts`。
```ts
import './infra/persistence/security_repository';
```
## 时序
### 绑定端口
```mermaid
sequenceDiagram
participant bindAs
participant rootContainer
bindAs->>rootContainer: bind(port).to(impl).whenNamed(name)
```
`bindAs` 返回的装饰器在类定义时把端口绑到实现。带名称时用 `whenNamed` 区分同一端口的多个实现。同一个实现再次执行时,先卸掉上一次的绑定再绑。用例构造函数用 `@inject` 指定端口,用 `@named` 指定实现,容器创建用例时传入对应的仓储。

View File

@@ -0,0 +1,54 @@
# 国际化
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [注册文案](#注册文案)
## 功能定义
国际化保存各模块的文案,并按当前语言取出文本。默认回退语言是 `zh`。
| 入口 | 文件 | 定义 |
|---|---|---|
| `registerMessages(feature, locales)` | `src/app/i18n/catalog.ts` | 把一个功能模块的 `zh`、`en` 文案写入目录 |
| `buildResources()` | 同上 | 把已登记的文案交给 i18next |
| `I18n.translate(key)` | `src/app/i18n/i18n.ts` | 按键取出当前语言的文本 |
| `I18n.changeLanguage(lng)` | 同上 | 切换 `zh` 或 `en` |
| `I18n.currentLanguage` | 同上 | 当前语言 |
功能模块自己的键缺失时,`parseMissingKeyHandler` 会改查 `common` 下的同名键。
## 使用示例
安全模块在加载时登记文案。文件:`src/features/security/i18n/index.ts`。
```ts
registerMessages('security', { zh: zhCN, en: enUS });
```
页面 ViewModel 取文本。文件:`src/features/security/presentation/security/security_view_model.ts`。
```ts
this.errorMessage = this.i18n.translate(currentForm.validationErrorKey);
```
## 时序
### 注册文案
```mermaid
sequenceDiagram
participant I18n
participant buildResources
participant registerMessages
participant i18n
I18n->>buildResources: buildResources()
registerMessages->>registerMessages: bundles
registerMessages->>i18n: addResourceBundle
```
`I18n` 构造时用 `buildResources` 初始化。此后 `registerMessages` 写入 `bundles`;i18next 已经初始化时,再调用 `addResourceBundle`。尚未初始化时只写入 `bundles`,等 `I18n` 构造时一并载入。

View File

@@ -0,0 +1,45 @@
# 数据库
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [打开并升级](#打开并升级)
## 功能定义
数据库打开名为 `local_db` 的 IndexedDB,并按 `schemaVersions` 从旧版本逐级升级。
| 入口 | 文件 | 定义 |
|---|---|---|
| `Database.open()` | `src/app/db/database.ts` | 打开数据库。同一时刻复用同一次打开 |
| `Database.reset()` | 同上 | 关闭进行中的打开,并删除 `local_db` |
| `schemaVersions` | `src/app/db/schema/index.ts` | 按版本排列的升级函数。`DB_VERSION` 等于它的长度 |
| `upgradeV1` | `src/app/db/schema/v1.ts` | 第一版。创建 `projects` 对象仓库,`keyPath` 为 `id` |
## 使用示例
本地项目仓储在读写前打开数据库。文件:`src/features/project/infra/persistence/local_project_repository.ts`。
```ts
const db = await this.database.open();
```
对象仓库不存在时,仓储关闭连接并调用 `Database.reset()`,再打开一次。
## 时序
### 打开并升级
```mermaid
sequenceDiagram
participant Database
participant schemaVersions
Database->>Database: open()
Database->>Database: connect()
Database->>schemaVersions: upgrade(oldVersion)
```
`open` 若已有进行中的连接,直接返回该连接。`onupgradeneeded` 时,`upgrade` 从 `oldVersion` 走到 `newVersion`,依次执行 `schemaVersions` 里对应的函数。版本变化导致连接关闭时,下一次 `open` 重新连接。

View File

@@ -0,0 +1,52 @@
# 生命周期
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [页面 ViewModel](#页面-viewmodel)
## 功能定义
生命周期负责创建页面 ViewModel,并在进入、刷新、离开时回调。
| 入口 | 文件 | 定义 |
|---|---|---|
| `PageViewModel` | `src/app/lifecycle/page_view_model.ts` | 页面 ViewModel 的基类。回调为 `created`、`ready`、`beforeRefresh`、`dispose` |
| `useViewModel(identifier)` | `src/app/lifecycle/use_view_model.ts` | 从根容器取出 ViewModel,并接上上述回调 |
## 使用示例
编辑器页面取出 `EditorViewModel`。文件:`src/features/editor/presentation/editor/editor.tsx`。
```ts
const vm = useViewModel(EditorViewModel);
```
ViewModel 继承 `PageViewModel`,需要在容器里可解析。文件:`src/features/editor/presentation/editor/editor_view_model.ts`。
```ts
@injectable('Transient')
export class EditorViewModel extends PageViewModel {}
```
## 时序
### 页面 ViewModel
```mermaid
sequenceDiagram
participant useViewModel
participant rootContainer
participant PageViewModel
useViewModel->>rootContainer: get(identifier)
rootContainer-->>useViewModel: PageViewModel
useViewModel->>PageViewModel: created()
useViewModel->>PageViewModel: ready()
useViewModel->>PageViewModel: beforeRefresh()
useViewModel->>PageViewModel: dispose()
```
`created` 在首次取出实例时调用。`ready` 在效果提交后调用。窗口触发 `pagehide` 时调用 `beforeRefresh`。离开页面时去掉监听并调用 `dispose`。

View File

@@ -0,0 +1,51 @@
# 线程脚本
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [请求与回复](#请求与回复)
## 功能定义
线程脚本管理一个 Worker 的创建、请求和崩溃。当前没有功能模块调用它。
| 入口 | 文件 | 定义 |
|---|---|---|
| `WorkerSession.request(message)` | `src/app/worker/worker_session.ts` | 保证 Worker 存在,发送 `WorkerMessage`,并等待对应 `WorkerReply` |
| `WorkerSession.dispose()` | 同上 | 拒绝尚未完成的请求,并终止 Worker |
| `WorkerRuntime.listen(handlers)` | `src/app/worker/worker_runtime.ts` | 在 Worker 线程按 `type` 分发,并用 `WorkerReply` 回复 |
| `WorkerMessage` | `src/app/worker/entities/worker_message.ts` | 请求体。含 `id`、`type`、`payload` |
| `WorkerReply` | `src/app/worker/entities/worker_reply.ts` | 回复体。按 `id` 交回等待中的请求 |
| `WorkerError` | `src/app/worker/entities/worker_error.ts` | Worker 失败时的错误 |
连续崩溃超过一次后,`WorkerSession` 停止再创建,后续 `request` 直接拒绝。
## 使用示例
调用方传入创建函数,再发送一条消息。
```ts
const worker = new WorkerSession(() => new Worker('worker.js'));
const message = new WorkerMessage('type', payload);
const result = await worker.request(message);
```
## 时序
### 请求与回复
```mermaid
sequenceDiagram
participant WorkerSession
participant WorkerMessage
participant WorkerReply
WorkerSession->>WorkerSession: ensure()
WorkerSession->>WorkerMessage: toJson()
WorkerSession->>WorkerReply: fromJson(data)
WorkerReply-->>WorkerSession: unwrap()
```
`ensure` 没有实例时调用构造函数传入的工厂。`request` 按 `WorkerMessage.id` 登记等待。Worker 线程里的 `WorkerRuntime.listen` 按 `type` 调用处理函数,并用 `WorkerReply` 回复。`onmessage` 用 `WorkerReply.fromJson` 找到同一 `id`,再 `unwrap` 完成该请求。`onerror` 或 `onmessageerror` 时终止当前 Worker,并拒绝尚未完成的请求。

View File

@@ -0,0 +1,42 @@
# 网络
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [本站请求](#本站请求)
## 功能定义
网络提供两个 HTTP 客户端。`Http` 访问本站接口,自动附带凭证,并在 401 时清除凭证。`ExternalHttp` 访问第三方接口,响应体原样返回。
| 入口 | 文件 | 定义 |
|---|---|---|
| `Http.instance` | `src/app/network/http.ts` | 本站 Axios 实例。请求拦截器读取凭证,响应拦截器按 `code` 解包 |
| `ExternalHttp.instance` | `src/app/network/external_http.ts` | 第三方 Axios 实例。成功时返回 `response.data` |
`Http` 在响应 `code` 不是 200 时拒绝。状态码为 401 时调用 `TokenStore.remove`。凭证如何保存见 [会话](./会话(session).md)。
## 使用示例
安全仓储通过 `Http.instance` 提交登录。文件:`src/features/security/infra/persistence/security_repository.ts`。
```ts
return await this.http.instance.post('/auth/login', plainDto);
```
## 时序
### 本站请求
```mermaid
sequenceDiagram
participant Http
Http->>Http: interceptors.request
Http->>Http: Authorization
Http->>Http: interceptors.response
```
请求拦截器读取凭证,有凭证时写入 `Authorization`。响应拦截器在 `code` 为 200 时返回 `data`,否则拒绝。状态码为 401 时清除凭证并离开当前页。`ExternalHttp` 不写入 `Authorization`,也不读取 `code`。

View File

@@ -0,0 +1,54 @@
# 路由
## 目录
- [功能定义](#功能定义)
- [使用示例](#使用示例)
- [时序](#时序)
- [访问策略](#访问策略)
## 功能定义
路由把 URL 指到功能模块页面,并用布局策略决定未登录或已登录时能否进入。
| 入口 | 文件 | 定义 |
|---|---|---|
| 路由表 | `src/app/routes.ts` | 声明页面路径和所属布局 |
| `decideRoutePolicy` | `src/app/routes/layouts/decide_route_policy.ts` | 根据 `require-auth` 或 `require-guest` 返回 `allowed` 或 `denied` |
| `AuthLayout` | `src/app/routes/layouts/auth.layout.tsx` | 已登录区域。未登录时拒绝 |
| `GuestLayout` | `src/app/routes/layouts/guest.layout.tsx` | 访客区域。已登录时拒绝 |
路由表:
| 路径 | 布局 | 页面 |
|---|---|---|
| `/` | `app_shell` | `home` |
| `/security` | `guest` | `security` |
| `/profile` | `auth` | `security` 的资料页 |
| `/projects/remote` | `auth` | `project` |
| `/projects/local` | `auth` | `project` |
| `/editor/:projectId` | `auth` | `editor` |
## 使用示例
已登录布局把 `hasSession()` 交给策略。文件:`src/app/routes/layouts/auth.layout.tsx`。
```ts
const policy = decideRoutePolicy('require-auth', hasSession(), url.pathname, url.search);
if (policy.status === 'denied') throw redirect(policy.redirect.to);
```
## 时序
### 访问策略
```mermaid
sequenceDiagram
participant AuthLayout
participant decideRoutePolicy
AuthLayout->>decideRoutePolicy: decideRoutePolicy(require-auth, authenticated, pathname, search)
decideRoutePolicy-->>AuthLayout: denied
```
`require-auth` 且未登录时,结果是 `denied`,`redirect.to` 指向 `/security`,并带上当前路径。`require-guest` 且已登录时,`redirect.to` 为 `/`。其余情况为 `allowed`。`GuestLayout` 使用 `require-guest`,步骤相同。

View File

@@ -0,0 +1,96 @@
# 项目架构
## 目录
- [项目目录结构](#项目目录结构)
- [模块定义](#模块定义)
- [基础应用](#基础应用)
- [功能模块](#功能模块)
- [模块间数据流](#模块间数据流)
- [项目业务](#项目业务)
## 项目目录结构
本仓库是脚手架。`src/app` 是每个项目保留的基础应用。`src/features` 下的四个目录是示例业务,用来示范功能模块如何接入;新项目可以替换它们。
```text
src/
├── app/
│ ├── db/
│ ├── di/
│ ├── i18n/
│ ├── lifecycle/
│ ├── network/
│ ├── routes/
│ ├── session/
│ ├── theme/
│ ├── worker/
│ ├── entry.client.tsx
│ ├── root.tsx
│ └── routes.ts
├── assets/
└── features/
├── editor/
├── home/
├── project/
└── security/
```
## 模块定义
`src/app` 下每个目录是一个基础应用,供功能模块和路由使用。`src/features` 下每个目录是一个功能模块,承载一块业务。功能模块的定义写它的 application 用例。
### 基础应用
| 模块 | 目录 | 定义 |
| --------------------------------- | ------------------- | ---------------------------- |
| [数据库](./基础应用/数据库(Database).md) | `src/app/db` | 打开本地库并按版本升级表结构 |
| [依赖注入](./基础应用/依赖注入(DI).md) | `src/app/di` | 提供根容器,并把端口绑定到实现 |
| [国际化](./基础应用/国际化(i18n).md) | `src/app/i18n` | 注册文案并按当前语言取出文本 |
| [生命周期](./基础应用/生命周期(lifecycle).md) | `src/app/lifecycle` | 创建页面 ViewModel,并在进入、刷新、离开时回调 |
| [网络](./基础应用/网络(network).md) | `src/app/network` | 提供 HTTP 客户端,附带凭证并处理 401 |
| [路由](./基础应用/路由(routes).md) | `src/app/routes` | 把 URL 指到页面,并按会话决定能否进入 |
| [会话](./基础应用/会话(session).md) | `src/app/session` | 保存、读取、清除登录凭证,并通知登录态变化 |
| [主题](./基础应用/主题(theme).md) | `src/app/theme` | 解析当前主题,并交给界面库 |
| [线程脚本](./基础应用/线程脚本(WebWorker).md) | `src/app/worker` | 封装 Worker 的消息会话 |
### 功能模块
| 模块 | 目录 | 定义 |
| ------------------------------------ | ----------------------- | ---------------------------- |
| [编辑器](./功能模块/编辑器模块/编辑器模块(editor).md) | `src/features/editor` | 把指针和当前工具提交给项目聚合,不持有文档规则 |
| [首页](./功能模块/首页/首页(home).md) | `src/features/home` | 不持有独立用例,调用项目模块按存储位置列出与新建 |
| [项目](./功能模块/项目模块/项目模块(project).md) | `src/features/project` | 维护项目聚合:创建、列出、读取、保存,以及落笔、撤销和重做 |
| [安全](./功能模块/安全模块/安全模块(security).md) | `src/features/security` | 提供身份用例:注册、登录和登出。登录得到 `User` |
## 模块间数据流
### 项目业务
```mermaid
flowchart LR
security
home
project
editor
security -->|authenticated| home
home -->|list / create| project
project -->|projectId / store| home
home -->|projectId / store| editor
editor -->|get / save| project
```
`home` 先读取 `security` 的 `authenticated`。已登录时,`home` 通过 `list` 或 `create` 向 `project` 要项目,拿到 `projectId` 和 `store` 后再交给 `editor`。未登录时,`projectId` 无法流转至 `editor`。
`store` 只表示应用层选择本地仓储还是远程仓储,不是 `Project` 的字段。进入 `editor` 后,`get` 载入聚合。落笔、撤销和重做由 `Project` 执行,再经 `save` 写回。