Excel 导出(原先 4 份各自拼装 workbook) - 新增 src/shared/xlsx.ts 作为唯一实现:文件名统一 .xlsx、sheet 名截断到 31 字符, 对外提供 buildAoaSheet / buildJsonSheet / writeWorkbook / exportAoaSheet / exportJsonSheet。 只收敛"组装与写出"这一层,各调用方仍自行决定列宽、冻结与数字格式,导出样式不变。 - 迁移 assets(内联 json_to_sheet + writeFile)、mileage/xlsx-export、 hydrogen 的两份 helper(prototype-download.ts 与 download-xls.js,均已删除)。 高德地图(原先 2 份近乎逐行复制) - 新增 src/shared/amap.ts:SDK 版本、插件列表、安全码注入、底图参数、 控件位置与热力图色带只在此处定义;两个画布只保留各自的半径/透明度。 - 图例渐变条原先把同一组色值又写了一遍,改为复用 shared 的常量,图例与地图不会漂移。 数值格式化 - 两个下钻视图各自复制了同样的 formatNumber/format(共 42 处调用), 统一到 hydrogen/model/display-format.ts 的 formatFixed;默认路径与既有行为逐字一致。 - 原 display-format.ts 里的 finiteNumber/formatNumber/formatScaled 无任何生产调用方, 只被自己的测试引用;改为 formatFixed + blankForMissing 显式选项, 既保留了"真零 vs 不可用"的区分能力,也不再留无人使用的导出。测试同步重写。 失效的演示能力 - Blur / DemoModeProvider 恒为 enabled=false,等于永久 no-op: 移除 13 个文件里 50 处 <Blur> 包裹(渲染结果不变)、删除 components/Blur.tsx 与 Shell 中的 Provider,调用方直接渲染原表达式。 未做(刻意) - src/lib/cn.ts 不引入 tailwind-merge:全仓只有 2 处调用,不值得新增传递依赖。已在文档说明。 架构守护新增 2 条:只有 shared/amap.ts 可加载高德 SDK; modules/** 不得再出现 book_new / book_append_sheet / writeFile(。 lint / test(137) / build 全绿,可达性仍为 0 未引用文件。
139 lines
6.7 KiB
Markdown
139 lines
6.7 KiB
Markdown
# 架构与分层约定
|
||
|
||
本文是这个仓库的结构契约。**6 条硬规则由 `src/architecture.test.ts` 断言**,破坏即测试失败。
|
||
|
||
## 1. 分层与依赖方向
|
||
|
||
```
|
||
app/ 应用外壳(路由、导航注册)
|
||
↓
|
||
modules/ 前端业务域 —— 只依赖 shared/
|
||
↓
|
||
shared/ 叶子层:角色常量、跨端 DTO、纯工具(不得依赖 modules/ 或 server/)
|
||
|
||
server/ 后端 —— 只依赖 shared/,不得依赖 modules/
|
||
```
|
||
|
||
**依赖只能向下**。前端通过 HTTP 调用后端,不通过 import。
|
||
|
||
### 6 条硬规则
|
||
|
||
| # | 规则 | 违反后果 |
|
||
| --- | --- | --- |
|
||
| 1 | `server/**` 不得 import `modules/**` | 服务端被前端类型/组件绑架,无法独立部署与测试 |
|
||
| 2 | `modules/**`、`components/**`、`auth/**`、`app/**` 不得 import `server/**` | 打包会拖入服务端代码与密钥 |
|
||
| 3 | `shared/**` 不得 import `modules/**` / `server/**` / `components/**` | 叶子层反向依赖会让依赖图成环 |
|
||
| 4 | 不得出现 `vendor/` 引用 | 生产代码必须位于业务域目录,不能藏在"第三方/参考"目录里 |
|
||
| 5 | `model.ts` / `model.tsx` 不得 import `react` | 纯模型必须能在 Node 里直接单测,不依赖 DOM |
|
||
| 6 | `@ts-nocheck` 只允许出现在已登记的原型快照文件 | 类型门禁对最大文件失效 |
|
||
|
||
### 关于第 6 条的豁免清单
|
||
|
||
这三个文件是**逐字节保留**的 8113 验收原型(UI / CSS 冻结,改动需重新做桌面与移动端截图验收):
|
||
|
||
```
|
||
modules/energy/hydrogen/board/EnergyBiBoardApp.tsx
|
||
modules/energy/hydrogen/station-daily/StationDailyApp.tsx
|
||
modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx
|
||
```
|
||
|
||
豁免清单是**只减不增**的:新增 `@ts-nocheck` 会被测试拦下,请改为修正类型。
|
||
|
||
## 2. 前端业务域的形状
|
||
|
||
每个域尽量保持同一形状,便于"进一个目录就知道从哪读起":
|
||
|
||
```
|
||
modules/<domain>/
|
||
index.tsx 域入口组件(导出给 app/modules.ts 注册)
|
||
api.ts 只做 HTTP,不做业务判断
|
||
types.ts DTO
|
||
model.ts 纯函数:筛选 / 排序 / 聚合 / 口径(可被 node:test 直接测)
|
||
components/ 无状态展示组件
|
||
*.css 域内样式
|
||
```
|
||
|
||
复杂域可以再分子域,例如氢能:
|
||
|
||
```
|
||
modules/energy/hydrogen/
|
||
index.tsx 导航入口
|
||
api.ts types.ts 数据访问与 DTO
|
||
model/ 纯逻辑(格式化、承担方标签、按日汇总格式)
|
||
board/ 经营看板 UI
|
||
station-daily/ 单站日报 UI
|
||
drill/ 下钻弹层 UI
|
||
common/ 域内共享工具与组件
|
||
fonts/ 看板专用字体
|
||
```
|
||
|
||
判定标准很简单:**能在 Node 里直接测的放 `model/`;需要浏览器/DOM 的放 UI 子目录。**
|
||
|
||
## 3. 后端结构
|
||
|
||
```
|
||
server/
|
||
config.ts 环境变量集中读取 + assertRuntimeConfig() 启动期校验
|
||
app.ts createApp():装配中间件、挂载路由、静态托管(无副作用,可测试)
|
||
index.ts 进程启动:config 校验 → app → bootstrap → listen
|
||
local.ts 只读预览启动(不建表、不定时任务)
|
||
bootstrap.ts 启动副作用总入口
|
||
db/ mysql / hydrogen / heatmap 连接
|
||
middleware/ auth(JWT → 注入 user)、read-only
|
||
auth/ 登录换票、固定密码、权限过滤与脱敏
|
||
routes/ 按业务域的 HTTP 路由
|
||
```
|
||
|
||
### 中间件顺序(在 `app.ts` 中显式体现)
|
||
|
||
```
|
||
cors → read-only → /api/auth(公开) → authMiddleware → 各业务域路由
|
||
→ /api/health → /api/* 未匹配返回 JSON 404 → 静态托管 + SPA 回退
|
||
```
|
||
|
||
两个已经踩过的坑,改动时不要退回去:
|
||
|
||
- **未匹配的 `/api/*` 必须返回 JSON 404**。若落到 SPA 回退,会返回 `200 text/html`,
|
||
前端 `res.ok` 为真后在 `res.json()` 抛解析错误,把"路由不存在"伪装成数据问题。
|
||
- **模块守卫必须 fail-closed**,写法是 `if (!canAccessX(user?.roles))`,
|
||
不能写成 `if (user && !canAccessX(user.roles))`——后者在 `user` 缺失时静默放行。
|
||
|
||
### 角色与数据权限
|
||
|
||
- 角色常量唯一真源:`src/shared/auth/roles.ts`(前后端共用)。
|
||
- 数据范围过滤与客户名脱敏:`src/server/auth/permissions.ts`。
|
||
- `permissionLevel` 只表示**数据范围**,不等于模块权限;能源/调度/反馈各自另有白名单。
|
||
|
||
## 4. 新增一个业务模块的步骤
|
||
|
||
1. `src/modules/<domain>/`:建 `api.ts`(HTTP)、`model.ts`(纯逻辑)、`index.tsx`(入口)、`components/`。
|
||
2. `src/app/modules.ts`:注册导航项,必要时用 `roles.ts` 的判断控制可见性。
|
||
3. `src/server/routes/<domain>.ts`(或 `routes/<domain>/`):实现接口;需要模块级权限就加 **fail-closed** 守卫。
|
||
4. 数据权限:读取后调用 `filterByPermission` + `maskCustomerNames`,或写等价的 SQL 过滤。
|
||
5. 测试:纯逻辑进 `model.test.ts`;接口口径用**固定夹具**锁 SQL 与参数(参考 `server/routes/energy/routes.test.ts`)。
|
||
6. 跑 `npm run lint && npm test && npm run build`。
|
||
|
||
## 5. 已知未尽事项
|
||
|
||
诚实记录,避免后来者以为已经做完:
|
||
|
||
- **后端路由尚未全部拆成 routes / service / repository**。`routes/vehicles/` 已有
|
||
`repository.ts` + `model.ts` 的雏形,其余域仍是单文件路由;拆分时不要改变 SQL 与参数顺序。
|
||
- **运行时建表仍分散在业务代码里**:`ele`、`feedback`、`mileage` 在首次调用时执行
|
||
`CREATE TABLE`,而 `read-only` 中间件只按 HTTP 方法拦截,所以只读预览下 GET 仍可能触发建表。
|
||
应收敛到显式的迁移函数并只由 `bootstrap.ts` 调用。
|
||
- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
|
||
传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。
|
||
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 50 kB),来自原型渲染方式(大量内联样式)。
|
||
|
||
### 已收敛的重复实现(不要退回多份)
|
||
|
||
| 能力 | 唯一实现 | 由测试守护 |
|
||
| --- | --- | --- |
|
||
| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` |
|
||
| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` |
|
||
| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts`、`modules/energy/daily-range/model.ts` | `date-range` 无独立测试;改动请补 |
|
||
| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` |
|
||
| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` |
|
||
|