feat: 新增版本管理系统,重构 /health → /api/meta
This commit is contained in:
129
DEVELOPMENT.md
129
DEVELOPMENT.md
@@ -30,11 +30,12 @@ src/
|
|||||||
static.ts 生产模式静态资源服务(SPA fallback、Content-Type 映射、immutable 缓存)
|
static.ts 生产模式静态资源服务(SPA fallback、Content-Type 映射、immutable 缓存)
|
||||||
helpers.ts 共享响应格式化工具(见下方函数清单)
|
helpers.ts 共享响应格式化工具(见下方函数清单)
|
||||||
middleware.ts API 参数校验中间件(validateIdParam、validatePagination、validateTimeRange)
|
middleware.ts API 参数校验中间件(validateIdParam、validatePagination、validateTimeRange)
|
||||||
routes/ API 路由 handler(按端点拆分)
|
routes/ API 路由 handler(按端点拆分)
|
||||||
health.ts GET /health
|
meta.ts GET /api/meta
|
||||||
shared/
|
version.ts 运行时版本号读取(从 package.json 读取并验证)
|
||||||
api.ts 前后端共享 TypeScript 类型
|
shared/
|
||||||
app.ts 应用全局常量(name、title、subtitle、description、version)
|
api.ts 前后端共享 TypeScript 类型
|
||||||
|
app.ts 应用全局常量(name、title、subtitle、description)
|
||||||
web/ React 前端(通过 Vite 构建)
|
web/ React 前端(通过 Vite 构建)
|
||||||
index.html HTML 入口
|
index.html HTML 入口
|
||||||
app.tsx 根组件(Admin 布局:Header + Sidebar + Content)
|
app.tsx 根组件(Admin 布局:Header + Sidebar + Content)
|
||||||
@@ -43,8 +44,8 @@ src/
|
|||||||
styles.css 全局样式与自定义 CSS 变量
|
styles.css 全局样式与自定义 CSS 变量
|
||||||
css.d.ts CSS 模块类型声明
|
css.d.ts CSS 模块类型声明
|
||||||
pages/ 页面组件
|
pages/ 页面组件
|
||||||
dashboard/
|
dashboard/
|
||||||
index.tsx 仪表盘页(欢迎语 + /health 联调示例)
|
index.tsx 仪表盘页(欢迎语 + /api/meta 联调示例)
|
||||||
users/
|
users/
|
||||||
index.tsx 用户管理页(占位)
|
index.tsx 用户管理页(占位)
|
||||||
settings/
|
settings/
|
||||||
@@ -62,10 +63,12 @@ src/
|
|||||||
time.ts 时间处理(formatCountdown、formatDurationUnit、formatRelativeTime、isOlderThan、subtractHours)
|
time.ts 时间处理(formatCountdown、formatDurationUnit、formatRelativeTime、isOlderThan、subtractHours)
|
||||||
menu.tsx 菜单配置(路由与菜单项统一数据源)
|
menu.tsx 菜单配置(路由与菜单项统一数据源)
|
||||||
routes.tsx 路由配置(定义所有页面路由)
|
routes.tsx 路由配置(定义所有页面路由)
|
||||||
scripts/
|
scripts/
|
||||||
dev.ts 双进程开发服务(Bun API server + Vite dev server)
|
dev.ts 双进程开发服务(Bun API server + Vite dev server)
|
||||||
build.ts Vite → codegen → Bun compile 三步构建流水线
|
build.ts Vite → codegen → Bun compile 三步构建流水线(含版本号注入)
|
||||||
clean.ts 清理构建产物与临时文件
|
bump-version-logic.ts 纯版本管理逻辑(parse、validate、bump、format)
|
||||||
|
bump-version.ts 版本升迁 CLI 脚本
|
||||||
|
clean.ts 清理构建产物与临时文件
|
||||||
tests/ Bun test 测试(结构镜像 src 目录)
|
tests/ Bun test 测试(结构镜像 src 目录)
|
||||||
setup.ts 全局测试配置(jsdom、polyfill)
|
setup.ts 全局测试配置(jsdom、polyfill)
|
||||||
helpers.ts 测试辅助工具(rmRetry)
|
helpers.ts 测试辅助工具(rmRetry)
|
||||||
@@ -85,7 +88,7 @@ config.example.yaml 配置文件示例
|
|||||||
|
|
||||||
## 前后端边界
|
## 前后端边界
|
||||||
|
|
||||||
前端只通过 HTTP 调用后端,API 路径为 `/api/*` 和 `/health`。共享类型放在 `src/shared`,前端不得 `import src/server` 的运行时实现。
|
前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 `import src/server` 的运行时实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,8 +132,8 @@ HTTP 请求:
|
|||||||
// server.ts 中的路由注册
|
// server.ts 中的路由注册
|
||||||
routes: {
|
routes: {
|
||||||
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
||||||
"/health": {
|
"/api/meta": {
|
||||||
GET: () => handleHealth(mode),
|
GET: async () => handleMeta(mode, await resolveVersion()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -138,8 +141,8 @@ routes: {
|
|||||||
Handler 函数签名:
|
Handler 函数签名:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 无依赖的路由
|
// 带版本号参数的路由
|
||||||
export function handleHealth(mode: RuntimeMode): Response;
|
export function handleMeta(mode: RuntimeMode, version: string): Response;
|
||||||
```
|
```
|
||||||
|
|
||||||
**请求处理流程**:
|
**请求处理流程**:
|
||||||
@@ -161,7 +164,7 @@ export function handleHealth(mode: RuntimeMode): Response;
|
|||||||
- **`helpers.ts`**:跨路由共用的响应工具函数
|
- **`helpers.ts`**:跨路由共用的响应工具函数
|
||||||
- `createApiError(error, status)` — 构造 API 错误体
|
- `createApiError(error, status)` — 构造 API 错误体
|
||||||
- `createHeaders(mode, init)` — 创建响应 Headers(生产模式附加安全头:`X-Content-Type-Options`、`Referrer-Policy`)
|
- `createHeaders(mode, init)` — 创建响应 Headers(生产模式附加安全头:`X-Content-Type-Options`、`Referrer-Policy`)
|
||||||
- `createHealthResponse()` — 构造健康检查响应 `{ ok: true, service, timestamp }`
|
- `createMetaResponse(version)` — 构造应用元信息响应 `{ ok: true, service, timestamp, version }`
|
||||||
- `formatDuration(ms)` — 毫秒转为可读时长字符串
|
- `formatDuration(ms)` — 毫秒转为可读时长字符串
|
||||||
- `jsonResponse(body, options)` — JSON 响应构造
|
- `jsonResponse(body, options)` — JSON 响应构造
|
||||||
|
|
||||||
@@ -177,10 +180,11 @@ export function handleHealth(mode: RuntimeMode): Response;
|
|||||||
### 1.5 类型定义规范
|
### 1.5 类型定义规范
|
||||||
|
|
||||||
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
|
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
|
||||||
- **应用常量**以 `src/shared/app.ts` 为唯一源头,定义 `APP` 对象(name、title、subtitle、description、version),前后端及构建脚本共同引用
|
- **应用常量**以 `src/shared/app.ts` 为唯一源头,定义 `APP` 对象(name、title、subtitle、description),前后端及构建脚本共同引用
|
||||||
|
- **版本号**以 `package.json.version` 为唯一源头,通过 `src/server/version.ts` 运行时读取或构建时注入字面量
|
||||||
- 前端不得 `import src/server/` 下的任何文件
|
- 前端不得 `import src/server/` 下的任何文件
|
||||||
- **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string`
|
- **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string`
|
||||||
- API 响应类型(`ApiErrorResponse`、`HealthResponse`)定义在 shared 中
|
- API 响应类型(`ApiErrorResponse`、`MetaResponse`)定义在 shared 中
|
||||||
|
|
||||||
### 1.6 配置文件规范
|
### 1.6 配置文件规范
|
||||||
|
|
||||||
@@ -208,7 +212,29 @@ server:
|
|||||||
port: 3000
|
port: 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.7 错误模式
|
### 1.7 版本管理
|
||||||
|
|
||||||
|
项目使用 `package.json.version` 作为版本号唯一来源,严格 `MAJOR.MINOR.PATCH` 格式。
|
||||||
|
|
||||||
|
**版本获取方式**:
|
||||||
|
|
||||||
|
- 开发模式:`src/server/version.ts` 运行时从 `package.json` 读取版本号
|
||||||
|
- 生产模式:`scripts/build.ts` 在构建时将版本号烘焙为 `APP_VERSION` 字面量注入 `server-entry.ts`
|
||||||
|
|
||||||
|
**版本升迁命令**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run version:patch # 升迁 patch 版本(0.1.0 → 0.1.1)
|
||||||
|
bun run version:minor # 升迁 minor 版本(0.1.0 → 0.2.0)
|
||||||
|
bun run version:major # 升迁 major 版本(0.1.0 → 1.0.0)
|
||||||
|
bun run version:set 2.0.0 # 显式设置版本号
|
||||||
|
```
|
||||||
|
|
||||||
|
版本升迁仅更新 `package.json.version`,不自动创建 git commit、tag 或 changelog。
|
||||||
|
|
||||||
|
**API 暴露**:`GET /api/meta` 返回 `{ ok, service, timestamp, version }`,前端通过此接口获取并展示版本号。
|
||||||
|
|
||||||
|
### 1.8 错误模式
|
||||||
|
|
||||||
- **API 错误**:`{ error: "描述", status: <code> }`,状态码 400/404
|
- **API 错误**:`{ error: "描述", status: <code> }`,状态码 400/404
|
||||||
- **日志**:非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
|
- **日志**:非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
|
||||||
@@ -248,7 +274,7 @@ main.tsx
|
|||||||
│ │ │ └── Sidebar(TDesign Menu,菜单项点击导航)
|
│ │ │ └── Sidebar(TDesign Menu,菜单项点击导航)
|
||||||
│ │ └── Content
|
│ │ └── Content
|
||||||
│ │ └── AppRoutes(路由配置)
|
│ │ └── AppRoutes(路由配置)
|
||||||
│ │ ├── / → DashboardPage(欢迎语 + /health 联调)
|
│ │ ├── / → DashboardPage(欢迎语 + /api/meta 联调)
|
||||||
│ │ ├── /users → UsersPage(占位)
|
│ │ ├── /users → UsersPage(占位)
|
||||||
│ │ ├── /settings → SettingsPage(占位)
|
│ │ ├── /settings → SettingsPage(占位)
|
||||||
│ │ └── * → NotFoundPage(404)
|
│ │ └── * → NotFoundPage(404)
|
||||||
@@ -288,7 +314,7 @@ utils/menu-config.ts(路由与菜单统一数据源)
|
|||||||
```typescript
|
```typescript
|
||||||
// 使用 structured array(非字符串),以便精确匹配和按 prefix 失效
|
// 使用 structured array(非字符串),以便精确匹配和按 prefix 失效
|
||||||
const queryKeys = {
|
const queryKeys = {
|
||||||
health: () => ["health"] as const,
|
meta: () => ["meta"] as const,
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -300,8 +326,8 @@ const queryKeys = {
|
|||||||
```typescript
|
```typescript
|
||||||
// 全局级查询(需要持续刷新)
|
// 全局级查询(需要持续刷新)
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: queryKeys.health(),
|
queryKey: queryKeys.meta(),
|
||||||
queryFn: () => fetchJson<HealthResponse>("/health"),
|
queryFn: () => fetchJson<MetaResponse>("/api/meta"),
|
||||||
refetchInterval: 30000, // 30s 轮询
|
refetchInterval: 30000, // 30s 轮询
|
||||||
refetchIntervalInBackground: false, // 切后台不轮询
|
refetchIntervalInBackground: false, // 切后台不轮询
|
||||||
staleTime: 5000, // 5s 内视为 fresh
|
staleTime: 5000, // 5s 内视为 fresh
|
||||||
@@ -344,7 +370,7 @@ new QueryClient({
|
|||||||
- 类型从 `../shared/api` 导入,使用 `type` 导入(`import type { ... }`)
|
- 类型从 `../shared/api` 导入,使用 `type` 导入(`import type { ... }`)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { HealthResponse } from "../shared/api";
|
import type { MetaResponse } from "../shared/api";
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -409,7 +435,7 @@ bun run dev [config.yaml]
|
|||||||
- **Bun API server**(端口 3000):后端 API 服务,`--watch` 监听后端文件变更自动重启
|
- **Bun API server**(端口 3000):后端 API 服务,`--watch` 监听后端文件变更自动重启
|
||||||
- **Vite dev server**(端口 5173):前端 SPA + HMR 热更新
|
- **Vite dev server**(端口 5173):前端 SPA + HMR 热更新
|
||||||
|
|
||||||
开发时访问 `http://127.0.0.1:5173`,Vite 自动将 `/api` 和 `/health` 请求代理到后端。
|
开发时访问 `http://127.0.0.1:5173`,Vite 自动将 `/api` 请求代理到后端。
|
||||||
|
|
||||||
也可以单独启动:
|
也可以单独启动:
|
||||||
|
|
||||||
@@ -426,7 +452,7 @@ bun run dev:web # 仅启动 Vite dev server
|
|||||||
|
|
||||||
- Vite dev server 负责前端 SPA、HMR、模块热替换
|
- Vite dev server 负责前端 SPA、HMR、模块热替换
|
||||||
- Bun API server 负责后端 API 路由
|
- Bun API server 负责后端 API 路由
|
||||||
- Vite 通过 proxy 配置将 `/api/*` 和 `/health` 转发到 Bun
|
- Vite 通过 proxy 配置将 `/api/*` 转发到 Bun
|
||||||
|
|
||||||
#### 生产模式架构
|
#### 生产模式架构
|
||||||
|
|
||||||
@@ -444,16 +470,16 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
routes: {
|
routes: {
|
||||||
"/api/*": () => ...,
|
"/api/*": () => ...,
|
||||||
"/health": { GET: () => handleHealth(mode) },
|
"/api/meta": { GET: async () => handleMeta(mode, await resolveVersion()) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 路由优先级
|
#### 路由优先级
|
||||||
|
|
||||||
Bun routes 的匹配规则:具体路径 > 通配符。`/health` 优先于 `/*`。
|
Bun routes 的匹配规则:具体路径 > 通配符。`/api/meta` 优先于 `/api/*`。
|
||||||
|
|
||||||
未匹配 method 的请求(如 POST /health)会落入 `/api/*` 通配符返回 404;若无该通配符会落入 fetch fallback。
|
未匹配 method 的请求(如 POST /api/meta)会落入 `/api/*` 通配符返回 404;若无该通配符会落入 fetch fallback。
|
||||||
|
|
||||||
非 API 路径由 fetch fallback 处理:有文件扩展名的返回对应静态资源或 404,无扩展名的返回 SPA index.html。
|
非 API 路径由 fetch fallback 处理:有文件扩展名的返回对应静态资源或 404,无扩展名的返回 SPA index.html。
|
||||||
|
|
||||||
@@ -471,8 +497,8 @@ bun run build
|
|||||||
|
|
||||||
```
|
```
|
||||||
1. Vite build → dist/web/ (前端静态资源,含 code splitting)
|
1. Vite build → dist/web/ (前端静态资源,含 code splitting)
|
||||||
2. Code generation → .build/static-assets.ts + .build/server-entry.ts
|
2. Code generation → .build/static-assets.ts + .build/server-entry.ts(含版本号字面量注入)
|
||||||
3. Bun compile → dist/dial-server (单可执行文件)
|
3. Bun compile → dist/my-app (单可执行文件)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Vite 构建前端资源到 `dist/web/`,自动 code splitting(vendor-react、vendor-tdesign、vendor-chart)
|
- Vite 构建前端资源到 `dist/web/`,自动 code splitting(vendor-react、vendor-tdesign、vendor-chart)
|
||||||
@@ -482,10 +508,10 @@ bun run build
|
|||||||
|
|
||||||
#### 产物
|
#### 产物
|
||||||
|
|
||||||
| 产物 | 用途 |
|
| 产物 | 用途 |
|
||||||
| ------------------ | ---------------------------------------- |
|
| ------------- | ---------------------------------------- |
|
||||||
| `dist/dial-server` | 生产可执行文件(含前端资源,单文件部署) |
|
| `dist/my-app` | 生产可执行文件(含前端资源,单文件部署) |
|
||||||
| `dist/web/` | Vite 构建的前端资源(构建中间产物) |
|
| `dist/web/` | Vite 构建的前端资源(构建中间产物) |
|
||||||
|
|
||||||
#### 构建参数
|
#### 构建参数
|
||||||
|
|
||||||
@@ -496,14 +522,13 @@ bun run build
|
|||||||
#### 运行可执行文件
|
#### 运行可执行文件
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dist/dial-server [config.yaml]
|
./dist/my-app [config.yaml]
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后:
|
启动后:
|
||||||
|
|
||||||
- 访问 `http://127.0.0.1:3000/` → 返回前端 SPA
|
- 访问 `http://127.0.0.1:3000/` → 返回前端 SPA
|
||||||
- 访问 `http://127.0.0.1:3000/api/*` → 返回后端 API
|
- 访问 `http://127.0.0.1:3000/api/meta` → 返回应用元信息 JSON(含版本号)
|
||||||
- 访问 `http://127.0.0.1:3000/health` → 返回健康检查 JSON
|
|
||||||
|
|
||||||
#### 清理
|
#### 清理
|
||||||
|
|
||||||
@@ -534,13 +559,17 @@ bun run verify
|
|||||||
|
|
||||||
### 3.5 脚本说明
|
### 3.5 脚本说明
|
||||||
|
|
||||||
| 脚本 | 文件 | 说明 |
|
| 脚本 | 文件 | 说明 |
|
||||||
| -------------------- | ------------------- | ---------------------------------------- |
|
| ----------------------- | ------------------------- | ---------------------------------------- |
|
||||||
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务(Vite :5173 + API :3000) |
|
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务(Vite :5173 + API :3000) |
|
||||||
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server(--watch 模式) |
|
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server(--watch 模式) |
|
||||||
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
|
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
|
||||||
| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 |
|
| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 |
|
||||||
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
|
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
|
||||||
|
| `bun run version:patch` | `scripts/bump-version.ts` | 升迁 patch 版本(x.y.Z) |
|
||||||
|
| `bun run version:minor` | `scripts/bump-version.ts` | 升迁 minor 版本(x.Y.0) |
|
||||||
|
| `bun run version:major` | `scripts/bump-version.ts` | 升迁 major 版本(X.0.0) |
|
||||||
|
| `bun run version:set` | `scripts/bump-version.ts` | 显式设置版本号 |
|
||||||
|
|
||||||
### 3.6 环境变量
|
### 3.6 环境变量
|
||||||
|
|
||||||
@@ -668,10 +697,10 @@ CI 或正式提交前执行完整验证(类型检查 + lint + 格式 + 测试
|
|||||||
|
|
||||||
### 测试分层
|
### 测试分层
|
||||||
|
|
||||||
| 层级 | 覆盖范围 | 位置 | 命令 |
|
| 层级 | 覆盖范围 | 位置 | 命令 |
|
||||||
| -------- | ---------------------- | ------------------------------------------------------------------- | --------------------------------------------- |
|
| -------- | ---------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||||
| 单元测试 | 后端函数、纯函数、常量 | `tests/server/**/*.test.ts`、`tests/web/{utils,hooks}/**/*.test.ts` | `bun test tests/server`、`bun test tests/web` |
|
| 单元测试 | 后端函数、纯函数、常量 | `tests/server/**/*.test.ts`、`tests/scripts/**/*.test.ts`、`tests/web/{utils,hooks}/**/*.test.ts` | `bun test tests/server`、`bun test tests/web` |
|
||||||
| 组件测试 | React 组件渲染和交互 | `tests/web/components/**/*.test.tsx` | `bun test tests/web` |
|
| 组件测试 | React 组件渲染和交互 | `tests/web/components/**/*.test.tsx` | `bun test tests/web` |
|
||||||
|
|
||||||
### 运行命令
|
### 运行命令
|
||||||
|
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -35,11 +35,10 @@ export const APP = {
|
|||||||
title: "Your App", // 人类可读标题
|
title: "Your App", // 人类可读标题
|
||||||
subtitle: "你的副标题", // 副标题
|
subtitle: "你的副标题", // 副标题
|
||||||
description: "应用描述", // SEO meta 描述
|
description: "应用描述", // SEO meta 描述
|
||||||
version: "0.1.0", // 版本号
|
|
||||||
} as const;
|
} as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
同时修改 `package.json` 的 `name` 字段保持一致。
|
同时修改 `package.json` 的 `name` 字段保持一致,`version` 字段管理应用版本号。
|
||||||
|
|
||||||
> **注意**:localStorage key 已从 `"my-app.theme.preference"` 变更为 `"theme.preference"`。如果从旧版本升级,用户的主题偏好设置将丢失,需重新选择。
|
> **注意**:localStorage key 已从 `"my-app.theme.preference"` 变更为 `"theme.preference"`。如果从旧版本升级,用户的主题偏好设置将丢失,需重新选择。
|
||||||
|
|
||||||
@@ -68,19 +67,23 @@ bun run dev
|
|||||||
|
|
||||||
## 项目管理
|
## 项目管理
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| -------------------- | ---------------------------------------------------------- |
|
| ----------------------- | ---------------------------------------------------------- |
|
||||||
| `bun run dev` | 启动开发模式(并行启动后端 + 前端 Vite 开发服务器) |
|
| `bun run dev` | 启动开发模式(并行启动后端 + 前端 Vite 开发服务器) |
|
||||||
| `bun run dev:server` | 仅启动后端开发服务(`--watch` 热重载) |
|
| `bun run dev:server` | 仅启动后端开发服务(`--watch` 热重载) |
|
||||||
| `bun run dev:web` | 仅启动前端 Vite 开发服务器 |
|
| `bun run dev:web` | 仅启动前端 Vite 开发服务器 |
|
||||||
| `bun run build` | 生产构建(Vite 打包前端 → Bun compile 生成独立可执行文件) |
|
| `bun run build` | 生产构建(Vite 打包前端 → Bun compile 生成独立可执行文件) |
|
||||||
| `bun test` | 运行全部测试 |
|
| `bun test` | 运行全部测试 |
|
||||||
| `bun run lint` | ESLint 代码风格检查 |
|
| `bun run lint` | ESLint 代码风格检查 |
|
||||||
| `bun run format` | Prettier 代码格式化 |
|
| `bun run format` | Prettier 代码格式化 |
|
||||||
| `bun run typecheck` | TypeScript 类型检查 |
|
| `bun run typecheck` | TypeScript 类型检查 |
|
||||||
| `bun run check` | 完整质量检查:typecheck + lint + test |
|
| `bun run check` | 完整质量检查:typecheck + lint + test |
|
||||||
| `bun run verify` | 验证构建流程:check + build |
|
| `bun run verify` | 验证构建流程:check + build |
|
||||||
| `bun run clean` | 清理构建产物和临时文件 |
|
| `bun run clean` | 清理构建产物和临时文件 |
|
||||||
|
| `bun run version:patch` | 升迁 patch 版本(x.y.Z) |
|
||||||
|
| `bun run version:minor` | 升迁 minor 版本(x.Y.0) |
|
||||||
|
| `bun run version:major` | 升迁 major 版本(X.0.0) |
|
||||||
|
| `bun run version:set` | 显式设置版本号 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -96,7 +99,9 @@ bun run dev
|
|||||||
├── .lintstagedrc.json # lint-staged 暂存区检查配置
|
├── .lintstagedrc.json # lint-staged 暂存区检查配置
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── dev.ts # 开发启动脚本(并行启动 API + Vite)
|
│ ├── dev.ts # 开发启动脚本(并行启动 API + Vite)
|
||||||
│ ├── build.ts # 生产构建脚本(Vite → 代码生成 → Bun compile)
|
│ ├── build.ts # 生产构建脚本(Vite → 代码生成 → Bun compile,含版本号注入)
|
||||||
|
│ ├── bump-version-logic.ts # 纯版本管理逻辑(parse、validate、bump、format)
|
||||||
|
│ ├── bump-version.ts # 版本升迁 CLI 脚本
|
||||||
│ └── clean.ts # 清理脚本
|
│ └── clean.ts # 清理脚本
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── server/ # 后端代码
|
│ ├── server/ # 后端代码
|
||||||
@@ -108,15 +113,16 @@ bun run dev
|
|||||||
│ │ ├── helpers.ts # 共享响应工具(健康检查、JSON 响应)
|
│ │ ├── helpers.ts # 共享响应工具(健康检查、JSON 响应)
|
||||||
│ │ ├── middleware.ts # API 参数校验中间件
|
│ │ ├── middleware.ts # API 参数校验中间件
|
||||||
│ │ ├── static.ts # 静态资源服务
|
│ │ ├── static.ts # 静态资源服务
|
||||||
│ │ └── routes/ # API 路由处理器
|
│ │ └── routes/ # API 路由处理器
|
||||||
│ │ └── health.ts # 健康检查端点
|
│ │ └── meta.ts # 应用元信息端点(GET /api/meta)
|
||||||
|
│ │ version.ts # 版本号读取
|
||||||
│ ├── shared/
|
│ ├── shared/
|
||||||
│ │ ├── api.ts # 前后端共享 TypeScript 类型定义
|
│ │ ├── api.ts # 前后端共享 TypeScript 类型定义
|
||||||
│ │ └── app.ts # 应用全局常量(name、title、version 等)
|
│ │ └── app.ts # 应用全局常量(name、title、subtitle、description)
|
||||||
│ └── web/ # 前端代码
|
│ └── web/ # 前端代码
|
||||||
│ ├── index.html # HTML 入口
|
│ ├── index.html # HTML 入口
|
||||||
│ ├── main.tsx # React 入口(BrowserRouter + QueryClient + ErrorBoundary)
|
│ ├── main.tsx # React 入口(BrowserRouter + QueryClient + ErrorBoundary)
|
||||||
│ ├── app.tsx # 根组件(Admin 布局:Header + Sidebar + Content)
|
│ ├── app.tsx # 根组件(Admin 布局:Header + Sidebar + Content + 版本号展示)
|
||||||
│ ├── routes.tsx # 路由配置
|
│ ├── routes.tsx # 路由配置
|
||||||
│ ├── styles.css # 全局样式
|
│ ├── styles.css # 全局样式
|
||||||
│ ├── css.d.ts # CSS 模块类型声明
|
│ ├── css.d.ts # CSS 模块类型声明
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-app",
|
"name": "my-app",
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,7 +15,11 @@
|
|||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"clean": "bun run scripts/clean.ts",
|
"clean": "bun run scripts/clean.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"prepare": "husky"
|
"prepare": "husky",
|
||||||
|
"version:patch": "bun run scripts/bump-version.ts patch",
|
||||||
|
"version:minor": "bun run scripts/bump-version.ts minor",
|
||||||
|
"version:major": "bun run scripts/bump-version.ts major",
|
||||||
|
"version:set": "bun run scripts/bump-version.ts set"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^21.0.1",
|
"@commitlint/cli": "^21.0.1",
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { join, relative, sep } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { APP } from "../src/shared/app";
|
import { APP } from "../src/shared/app";
|
||||||
|
import { validateVersion } from "./bump-version-logic";
|
||||||
|
|
||||||
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
const distWebDir = join(projectRoot, "dist/web");
|
const distWebDir = join(projectRoot, "dist/web");
|
||||||
const buildDir = join(projectRoot, ".build");
|
const buildDir = join(projectRoot, ".build");
|
||||||
const executablePath = join(projectRoot, `dist/${APP.name}`);
|
const executablePath = join(projectRoot, `dist/${APP.name}`);
|
||||||
|
const packageJsonPath = join(projectRoot, "package.json");
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
try {
|
try {
|
||||||
@@ -62,6 +64,14 @@ async function codeGeneration() {
|
|||||||
await rm(buildDir, { force: true, recursive: true });
|
await rm(buildDir, { force: true, recursive: true });
|
||||||
await Bun.write(join(buildDir, ".gitkeep"), "");
|
await Bun.write(join(buildDir, ".gitkeep"), "");
|
||||||
|
|
||||||
|
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
|
||||||
|
const version = packageJson.version;
|
||||||
|
if (typeof version !== "string") {
|
||||||
|
console.error("package.json does not have a valid version field");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
validateVersion(version);
|
||||||
|
|
||||||
const allFiles = await scanDir(distWebDir, "/");
|
const allFiles = await scanDir(distWebDir, "/");
|
||||||
const importLines: string[] = [];
|
const importLines: string[] = [];
|
||||||
const fileEntries: string[] = [];
|
const fileEntries: string[] = [];
|
||||||
@@ -106,9 +116,11 @@ async function codeGeneration() {
|
|||||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||||
`import { staticAssets } from "./static-assets";`,
|
`import { staticAssets } from "./static-assets";`,
|
||||||
"",
|
"",
|
||||||
|
`const APP_VERSION = "${version}" as const;`,
|
||||||
|
"",
|
||||||
`async function main() {`,
|
`async function main() {`,
|
||||||
` const { configPath } = parseRuntimeArgs();`,
|
` const { configPath } = parseRuntimeArgs();`,
|
||||||
` await bootstrap({ configPath, mode: "production", staticAssets });`,
|
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||||
`}`,
|
`}`,
|
||||||
"",
|
"",
|
||||||
`void main().catch((error) => {`,
|
`void main().catch((error) => {`,
|
||||||
|
|||||||
40
scripts/bump-version-logic.ts
Normal file
40
scripts/bump-version-logic.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const VERSION_REGEX = /^\d+\.\d+\.\d+$/;
|
||||||
|
|
||||||
|
export function bumpVersion(current: string, command: "major" | "minor" | "patch" | "set", target?: string): string {
|
||||||
|
validateVersion(current);
|
||||||
|
const [major, minor, patch] = parseVersion(current);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "major":
|
||||||
|
return formatVersion(major + 1, 0, 0);
|
||||||
|
case "minor":
|
||||||
|
return formatVersion(major, minor + 1, 0);
|
||||||
|
case "patch":
|
||||||
|
return formatVersion(major, minor, patch + 1);
|
||||||
|
case "set": {
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("set command requires a target version");
|
||||||
|
}
|
||||||
|
validateVersion(target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVersion(major: number, minor: number, patch: number): string {
|
||||||
|
return `${major}.${minor}.${patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVersion(version: string): [number, number, number] {
|
||||||
|
const parts = version.split(".").map((p) => parseInt(p, 10));
|
||||||
|
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||||
|
throw new Error(`Invalid version format: ${version}`);
|
||||||
|
}
|
||||||
|
return [parts[0]!, parts[1]!, parts[2]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateVersion(version: string): void {
|
||||||
|
if (!VERSION_REGEX.test(version)) {
|
||||||
|
throw new Error(`Invalid version format: ${version}. Expected MAJOR.MINOR.PATCH (e.g., 0.1.0)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
scripts/bump-version.ts
Normal file
45
scripts/bump-version.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { bumpVersion, validateVersion } from "./bump-version-logic";
|
||||||
|
|
||||||
|
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "package.json");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error("Usage: bun run bump-version.ts <patch|minor|major|set> [version]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = args[0];
|
||||||
|
if (command !== "patch" && command !== "minor" && command !== "major" && command !== "set") {
|
||||||
|
console.error(`Unknown command: ${command}. Expected patch, minor, major, or set`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "set" && args.length < 2) {
|
||||||
|
console.error("Usage: bun run bump-version.ts set <version>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
if (typeof currentVersion !== "string") {
|
||||||
|
console.error("package.json does not have a valid version field");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateVersion(currentVersion);
|
||||||
|
|
||||||
|
const targetVersion = command === "set" ? args[1] : undefined;
|
||||||
|
const nextVersion = bumpVersion(currentVersion, command, targetVersion);
|
||||||
|
|
||||||
|
packageJson.version = nextVersion;
|
||||||
|
writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageJson, null, 2) + "\n");
|
||||||
|
|
||||||
|
console.log(`${currentVersion} -> ${nextVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -17,6 +17,7 @@ export interface BootstrapOptions {
|
|||||||
configPath?: string;
|
configPath?: string;
|
||||||
mode: RuntimeMode;
|
mode: RuntimeMode;
|
||||||
staticAssets?: StartServerOptions["staticAssets"];
|
staticAssets?: StartServerOptions["staticAssets"];
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
|
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
|
||||||
@@ -38,7 +39,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
|||||||
onSignal("SIGINT", shutdown);
|
onSignal("SIGINT", shutdown);
|
||||||
onSignal("SIGTERM", shutdown);
|
onSignal("SIGTERM", shutdown);
|
||||||
|
|
||||||
serve({ config, mode: options.mode, staticAssets: options.staticAssets });
|
serve({ config, mode: options.mode, staticAssets: options.staticAssets, version: options.version });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError("启动失败:", error instanceof Error ? error.message : error);
|
logError("启动失败:", error instanceof Error ? error.message : error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api";
|
import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../shared/api";
|
||||||
|
|
||||||
import { APP } from "../shared/app";
|
import { APP } from "../shared/app";
|
||||||
|
|
||||||
@@ -17,11 +17,12 @@ export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHealthResponse(): HealthResponse {
|
export function createMetaResponse(version: string): MetaResponse {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: APP.name,
|
service: APP.name,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { RuntimeMode } from "../../shared/api";
|
|
||||||
|
|
||||||
import { createHealthResponse, jsonResponse } from "../helpers";
|
|
||||||
|
|
||||||
export function handleHealth(mode: RuntimeMode): Response {
|
|
||||||
return jsonResponse(createHealthResponse(), { mode });
|
|
||||||
}
|
|
||||||
7
src/server/routes/meta.ts
Normal file
7
src/server/routes/meta.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { RuntimeMode } from "../../shared/api";
|
||||||
|
|
||||||
|
import { createMetaResponse, jsonResponse } from "../helpers";
|
||||||
|
|
||||||
|
export function handleMeta(mode: RuntimeMode, version: string): Response {
|
||||||
|
return jsonResponse(createMetaResponse(version), { mode });
|
||||||
|
}
|
||||||
@@ -4,17 +4,24 @@ import type { StaticAssets } from "./static";
|
|||||||
|
|
||||||
import { APP } from "../shared/app";
|
import { APP } from "../shared/app";
|
||||||
import { createApiError, jsonResponse } from "./helpers";
|
import { createApiError, jsonResponse } from "./helpers";
|
||||||
import { handleHealth } from "./routes/health";
|
import { handleMeta } from "./routes/meta";
|
||||||
import { serveStaticAsset } from "./static";
|
import { serveStaticAsset } from "./static";
|
||||||
|
import { readAppVersion } from "./version";
|
||||||
|
|
||||||
export interface StartServerOptions {
|
export interface StartServerOptions {
|
||||||
config: ServerConfig;
|
config: ServerConfig;
|
||||||
mode: RuntimeMode;
|
mode: RuntimeMode;
|
||||||
staticAssets?: StaticAssets;
|
staticAssets?: StaticAssets;
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startServer(options: StartServerOptions) {
|
export function startServer(options: StartServerOptions) {
|
||||||
const { config, mode, staticAssets } = options;
|
const { config, mode, staticAssets, version } = options;
|
||||||
|
|
||||||
|
const resolveVersion = (): Promise<string> => {
|
||||||
|
if (version) return Promise.resolve(version);
|
||||||
|
return readAppVersion();
|
||||||
|
};
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
fetch(req) {
|
fetch(req) {
|
||||||
@@ -27,8 +34,11 @@ export function startServer(options: StartServerOptions) {
|
|||||||
port: config.port,
|
port: config.port,
|
||||||
routes: {
|
routes: {
|
||||||
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
||||||
"/health": {
|
"/api/meta": {
|
||||||
GET: () => handleHealth(mode),
|
GET: async () => {
|
||||||
|
const resolvedVersion = await resolveVersion();
|
||||||
|
return handleMeta(mode, resolvedVersion);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
17
src/server/version.ts
Normal file
17
src/server/version.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { validateVersion } from "../../scripts/bump-version-logic";
|
||||||
|
|
||||||
|
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "..", "package.json");
|
||||||
|
|
||||||
|
export async function readAppVersion(): Promise<string> {
|
||||||
|
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
|
||||||
|
const version = packageJson.version;
|
||||||
|
|
||||||
|
if (typeof version !== "string") {
|
||||||
|
throw new Error("package.json does not have a valid version field");
|
||||||
|
}
|
||||||
|
|
||||||
|
validateVersion(version);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ export interface ApiErrorResponse {
|
|||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthResponse {
|
export interface MetaResponse {
|
||||||
ok: true;
|
ok: true;
|
||||||
service: string;
|
service: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeMode = "development" | "production" | "test";
|
export type RuntimeMode = "development" | "production" | "test";
|
||||||
|
|||||||
@@ -3,5 +3,4 @@ export const APP = {
|
|||||||
name: "my-app",
|
name: "my-app",
|
||||||
subtitle: "Bun 全栈应用",
|
subtitle: "Bun 全栈应用",
|
||||||
title: "My App",
|
title: "My App",
|
||||||
version: "0.1.0",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
||||||
import { Button, Layout, RadioGroup } from "tdesign-react";
|
import { Button, Layout, RadioGroup } from "tdesign-react";
|
||||||
|
|
||||||
|
import type { MetaResponse } from "../shared/api";
|
||||||
|
|
||||||
import { APP } from "../shared/app";
|
import { APP } from "../shared/app";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||||
@@ -22,6 +25,12 @@ export function App() {
|
|||||||
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||||
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
|
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { data: meta } = useQuery({
|
||||||
|
queryFn: fetchMeta,
|
||||||
|
queryKey: ["meta"],
|
||||||
|
refetchInterval: 30000,
|
||||||
|
staleTime: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = APP.title;
|
document.title = APP.title;
|
||||||
@@ -35,6 +44,7 @@ export function App() {
|
|||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||||
const pageTitle = currentItem?.label ?? APP.title;
|
const pageTitle = currentItem?.label ?? APP.title;
|
||||||
|
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="app-layout">
|
<Layout className="app-layout">
|
||||||
@@ -44,6 +54,7 @@ export function App() {
|
|||||||
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="app-brand">{APP.title}</span>
|
<span className="app-brand">{APP.title}</span>
|
||||||
|
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||||
<span className="app-page-title">{pageTitle}</span>
|
<span className="app-page-title">{pageTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-header-right">
|
<div className="app-header-right">
|
||||||
@@ -69,3 +80,9 @@ export function App() {
|
|||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchMeta(): Promise<MetaResponse> {
|
||||||
|
const response = await fetch("/api/meta");
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<MetaResponse>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Space } from "tdesign-react";
|
import { Space } from "tdesign-react";
|
||||||
|
|
||||||
import type { HealthResponse } from "../../../shared/api";
|
import type { MetaResponse } from "../../../shared/api";
|
||||||
|
|
||||||
import { APP } from "../../../shared/app";
|
import { APP } from "../../../shared/app";
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { data: health } = useQuery({
|
const { data: meta } = useQuery({
|
||||||
queryFn: fetchHealth,
|
queryFn: fetchMeta,
|
||||||
queryKey: ["health"],
|
queryKey: ["meta"],
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
});
|
});
|
||||||
@@ -16,14 +16,14 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<Space className="full-width-space" direction="vertical" size="large">
|
<Space className="full-width-space" direction="vertical" size="large">
|
||||||
<h2>欢迎使用 {APP.title}</h2>
|
<h2>欢迎使用 {APP.title}</h2>
|
||||||
<p>在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):</p>
|
<p>在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):</p>
|
||||||
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
|
{meta && <pre className="meta-response">{JSON.stringify(meta, null, 2)}</pre>}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHealth(): Promise<HealthResponse> {
|
async function fetchMeta(): Promise<MetaResponse> {
|
||||||
const response = await fetch("/health");
|
const response = await fetch("/api/meta");
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<HealthResponse>;
|
return response.json() as Promise<MetaResponse>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
color: var(--td-text-color-primary);
|
color: var(--td-text-color-primary);
|
||||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
font-size: var(--td-font-size-body-small);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-page-title {
|
.app-page-title {
|
||||||
@@ -64,7 +72,7 @@
|
|||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100vh - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-response {
|
.meta-response {
|
||||||
background: var(--td-bg-color-component);
|
background: var(--td-bg-color-component);
|
||||||
border-radius: var(--td-radius-default);
|
border-radius: var(--td-radius-default);
|
||||||
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
|
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
|
||||||
|
|||||||
48
tests/scripts/build.test.ts
Normal file
48
tests/scripts/build.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { validateVersion } from "../../scripts/bump-version-logic";
|
||||||
|
|
||||||
|
describe("build 版本注入", () => {
|
||||||
|
test("validateVersion 接受有效版本", () => {
|
||||||
|
expect(() => validateVersion("0.1.0")).not.toThrow();
|
||||||
|
expect(() => validateVersion("1.2.3")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateVersion 拒绝无效版本", () => {
|
||||||
|
expect(() => validateVersion("invalid")).toThrow();
|
||||||
|
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("生成的 server-entry 包含版本字面量", () => {
|
||||||
|
const version = "0.1.0";
|
||||||
|
const serverEntryTs = [
|
||||||
|
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||||
|
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||||
|
`import { staticAssets } from "./static-assets";`,
|
||||||
|
"",
|
||||||
|
`const APP_VERSION = "${version}" as const;`,
|
||||||
|
"",
|
||||||
|
`async function main() {`,
|
||||||
|
` const { configPath } = parseRuntimeArgs();`,
|
||||||
|
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||||
|
`}`,
|
||||||
|
"",
|
||||||
|
`void main().catch((error) => {`,
|
||||||
|
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
|
||||||
|
` process.exit(1);`,
|
||||||
|
`});`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(serverEntryTs).toContain(`const APP_VERSION = "${version}"`);
|
||||||
|
expect(serverEntryTs).toContain("version: APP_VERSION");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("版本字面量不依赖外部 package.json", () => {
|
||||||
|
const serverEntryTs = [`const APP_VERSION = "0.1.0" as const;`].join("\n");
|
||||||
|
|
||||||
|
expect(serverEntryTs).not.toContain("package.json");
|
||||||
|
expect(serverEntryTs).not.toContain("Bun.file");
|
||||||
|
expect(serverEntryTs).toContain('"0.1.0"');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/scripts/bump-version.test.ts
Normal file
73
tests/scripts/bump-version.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { bumpVersion, formatVersion, parseVersion, validateVersion } from "../../scripts/bump-version-logic";
|
||||||
|
|
||||||
|
describe("版本解析与校验", () => {
|
||||||
|
test("parseVersion 解析有效版本", () => {
|
||||||
|
expect(parseVersion("0.1.0")).toEqual([0, 1, 0]);
|
||||||
|
expect(parseVersion("1.2.3")).toEqual([1, 2, 3]);
|
||||||
|
expect(parseVersion("10.20.30")).toEqual([10, 20, 30]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseVersion 拒绝无效版本", () => {
|
||||||
|
expect(() => parseVersion("invalid")).toThrow();
|
||||||
|
expect(() => parseVersion("1.2")).toThrow();
|
||||||
|
expect(() => parseVersion("1.2.3.4")).toThrow();
|
||||||
|
expect(() => parseVersion("1.2.a")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateVersion 接受有效版本", () => {
|
||||||
|
expect(() => validateVersion("0.1.0")).not.toThrow();
|
||||||
|
expect(() => validateVersion("1.2.3")).not.toThrow();
|
||||||
|
expect(() => validateVersion("10.20.30")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateVersion 拒绝无效版本", () => {
|
||||||
|
expect(() => validateVersion("")).toThrow();
|
||||||
|
expect(() => validateVersion("invalid")).toThrow();
|
||||||
|
expect(() => validateVersion("1.2")).toThrow();
|
||||||
|
expect(() => validateVersion("1.2.3.4")).toThrow();
|
||||||
|
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
|
||||||
|
expect(() => validateVersion("v1.0.0")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatVersion 格式化版本", () => {
|
||||||
|
expect(formatVersion(0, 1, 0)).toBe("0.1.0");
|
||||||
|
expect(formatVersion(1, 2, 3)).toBe("1.2.3");
|
||||||
|
expect(formatVersion(10, 20, 30)).toBe("10.20.30");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("版本升迁逻辑", () => {
|
||||||
|
test("bumpVersion patch 升迁", () => {
|
||||||
|
expect(bumpVersion("1.2.3", "patch")).toBe("1.2.4");
|
||||||
|
expect(bumpVersion("0.1.0", "patch")).toBe("0.1.1");
|
||||||
|
expect(bumpVersion("0.0.1", "patch")).toBe("0.0.2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bumpVersion minor 升迁", () => {
|
||||||
|
expect(bumpVersion("1.2.3", "minor")).toBe("1.3.0");
|
||||||
|
expect(bumpVersion("0.1.0", "minor")).toBe("0.2.0");
|
||||||
|
expect(bumpVersion("0.0.1", "minor")).toBe("0.1.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bumpVersion major 升迁", () => {
|
||||||
|
expect(bumpVersion("1.2.3", "major")).toBe("2.0.0");
|
||||||
|
expect(bumpVersion("0.1.0", "major")).toBe("1.0.0");
|
||||||
|
expect(bumpVersion("0.0.1", "major")).toBe("1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bumpVersion set 设置版本", () => {
|
||||||
|
expect(bumpVersion("1.2.3", "set", "2.0.0")).toBe("2.0.0");
|
||||||
|
expect(bumpVersion("0.1.0", "set", "0.2.0")).toBe("0.2.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bumpVersion set 拒绝无效版本", () => {
|
||||||
|
expect(() => bumpVersion("1.2.3", "set", "invalid")).toThrow();
|
||||||
|
expect(() => bumpVersion("1.2.3", "set", "1.0.0-beta.1")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bumpVersion set 缺少目标版本报错", () => {
|
||||||
|
expect(() => bumpVersion("1.2.3", "set")).toThrow("set command requires a target version");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ describe("bootstrap", () => {
|
|||||||
signalRegistered = true;
|
signalRegistered = true;
|
||||||
};
|
};
|
||||||
const mockStartServer = (_options: StartServerOptions) => {
|
const mockStartServer = (_options: StartServerOptions) => {
|
||||||
|
expect(_options.version).toBeUndefined();
|
||||||
started = true;
|
started = true;
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
@@ -38,6 +39,24 @@ describe("bootstrap", () => {
|
|||||||
expect(signalRegistered).toBe(true);
|
expect(signalRegistered).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("传递 version 给 startServer", async () => {
|
||||||
|
let receivedVersion: string | undefined;
|
||||||
|
|
||||||
|
const deps: BootstrapDependencies = {
|
||||||
|
loadConfig: async () => ({ host: "127.0.0.1", port: 0 }),
|
||||||
|
logError: () => {},
|
||||||
|
onSignal: () => {},
|
||||||
|
startServer: (options: StartServerOptions) => {
|
||||||
|
receivedVersion = options.version;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await bootstrap({ mode: "production", version: "1.2.3" }, deps);
|
||||||
|
|
||||||
|
expect(receivedVersion).toBe("1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
test("启动失败时调用 logError", async () => {
|
test("启动失败时调用 logError", async () => {
|
||||||
let errorLogged = false;
|
let errorLogged = false;
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import { renderWithProviders } from "./test-utils";
|
|||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
test("渲染 Layout 骨架和品牌名", () => {
|
test("渲染 Layout 骨架和品牌名", () => {
|
||||||
window.fetch = (async () => {
|
window.fetch = (async () => {
|
||||||
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
|
return new Response(
|
||||||
headers: { "Content-Type": "application/json" },
|
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||||
status: 200,
|
{
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
renderWithProviders(createElement(App));
|
renderWithProviders(createElement(App));
|
||||||
@@ -26,10 +29,13 @@ describe("App", () => {
|
|||||||
|
|
||||||
test("渲染侧边栏菜单项", () => {
|
test("渲染侧边栏菜单项", () => {
|
||||||
window.fetch = (async () => {
|
window.fetch = (async () => {
|
||||||
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
|
return new Response(
|
||||||
headers: { "Content-Type": "application/json" },
|
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||||
status: 200,
|
{
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
renderWithProviders(createElement(App));
|
renderWithProviders(createElement(App));
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import { renderWithProviders } from "../test-utils";
|
|||||||
describe("DashboardPage", () => {
|
describe("DashboardPage", () => {
|
||||||
test("渲染欢迎信息", () => {
|
test("渲染欢迎信息", () => {
|
||||||
window.fetch = (async () => {
|
window.fetch = (async () => {
|
||||||
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
|
return new Response(
|
||||||
headers: { "Content-Type": "application/json" },
|
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||||
status: 200,
|
{
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
renderWithProviders(createElement(DashboardPage));
|
renderWithProviders(createElement(DashboardPage));
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://127.0.0.1:3000",
|
"/api": "http://127.0.0.1:3000",
|
||||||
"/health": "http://127.0.0.1:3000",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user