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,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` 写回。