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,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`,步骤相同。