Initial commit

This commit is contained in:
2026-05-20 00:18:07 +08:00
commit e2bf594719
58 changed files with 5885 additions and 0 deletions

32
.gitattributes vendored Normal file
View File

@@ -0,0 +1,32 @@
# 跨平台行尾规范
# 所有文本文件统一用 LFUnix 风格),避免 CRLF/LF 混用
* text=auto eol=lf
# 二进制文件不转换行尾
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.pdf binary
*.zip binary
*.gz binary
*.tar binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.mp4 binary
*.mov binary
*.mp3 binary
# Shell 脚本必须 LF
*.sh text eol=lf
# Windows 批处理必须 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# 锁定文件(如 package-lock.json保持 LF
*.lock text eol=lf

424
.gitignore vendored Normal file
View File

@@ -0,0 +1,424 @@
### Git.gitignore ###
# Created by git for backups. To disable backups in Git:
# $ git config --global mergetool.keepBackup false
*.orig
# Created by git when using merge tools for conflicts
*.BACKUP.*
*.BASE.*
*.LOCAL.*
*.REMOTE.*
*_BACKUP_*.txt
*_BASE_*.txt
*_LOCAL_*.txt
*_REMOTE_*.txt
### Go.gitignore ###
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
### Go.patch ###
/vendor/
/Godeps/
### JetBrains+all.gitignore ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### JetBrains+all.patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
.idea/
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
*.iml
modules.xml
.idea/misc.xml
*.ipr
# Sonarlint plugin
.idea/sonarlint
### Linux.gitignore ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node.gitignore ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Test
playwright-report
test-results
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
### VisualStudioCode.gitignore ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
### VisualStudioCode.patch ###
# Ignore all local history of files
.history
### Windows.gitignore ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### macOS.gitignore ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Python.gitignore ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Environments
.venv/
venv/
ENV/
env/
.python-version
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Pyre
.pyre/
# pytype
.pytype/
# Cython debug symbols
cython_debug/
# Custom
.claude/*
!.claude/settings.json
.opencode
.codex
openspec/changes/archive
temp
.agents
skills-lock.json
.worktrees
data/
!scripts/build/
backend/bin
backend/server
backend/desktop
# Embedfs generated
embedfs/assets/
embedfs/frontend-dist/
backend/cmd/desktop/rsrc_windows_*.syso
# Bun
.build/
*.bun-build

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
bunx commitlint --edit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
bunx lint-staged

4
.lintstagedrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"*.{ts,tsx}": ["eslint --fix"],
"*.{md,json,yaml,yml}": ["prettier --write"]
}

13
.prettierignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
dist/
.build/
*.bun-build
openspec/
bun.lock
.opencode/
.claude/
.codex/
.agents/
skills-lock.json
data/
probe-config.schema.json

11
.prettierrc.json Normal file
View File

@@ -0,0 +1,11 @@
{
"printWidth": 120,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"tabWidth": 2,
"useTabs": false
}

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"files.eol": "\n",
"files.encoding": "utf8",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true
}

1
AGENTS.md Normal file
View File

@@ -0,0 +1 @@
严格遵守openspec/config.yaml中context声明的项目规范

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
严格遵守openspec/config.yaml中context声明的项目规范

665
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,665 @@
{{app-name}} 开发文档
本文档面向 `{{app-name}}` 项目的开发者,介绍项目结构、前后端架构、构建流程、测试、代码规范等内容。
用户使用说明请参阅 [README.md](README.md)。
## 目录
- [项目结构](#项目结构)
- [前后端边界](#前后端边界)
- [一、后端开发指引](#一后端开发指引)
- [二、前端开发指引](#二前端开发指引)
- [三、项目运行、集成与打包](#三项目运行集成与打包)
- [代码质量](#代码质量)
- [测试](#测试)
- [已知限制](#已知限制)
---
## 项目结构
```text
src/
server/
bootstrap.ts 后端统一启动引导loadServerConfig → startServer
config.ts CLI 参数解析与配置文件加载(可选 YAML configPath支持 --help/-h
dev.ts 开发模式启动入口mode: "development"
main.ts 生产模式启动入口mode: "production",安全头启用)
server.ts HTTP server 启动工厂Bun.serve routes 声明式路由 + fetch fallback 静态资源服务)
static.ts 生产模式静态资源服务SPA fallback、Content-Type 映射、immutable 缓存)
helpers.ts 共享响应格式化工具(见下方函数清单)
middleware.ts API 参数校验中间件validateIdParam、validatePagination、validateTimeRange
routes/ API 路由 handler按端点拆分
health.ts GET /health
shared/
api.ts 前后端共享 TypeScript 类型
web/ React 前端(通过 Vite 构建)
index.html HTML 入口
app.tsx 根组件Layout + Header + Content/health 联调示例展示)
main.tsx 入口QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools + TDesign CSS 导入)
styles.css 全局样式与自定义 CSS 变量
css.d.ts CSS 模块类型声明
components/ UI 组件
ErrorBoundary.tsx React 错误边界,捕获渲染异常并展示降级 UI
hooks/ React hooks
use-theme-preference.ts 主题偏好 hooksystem/light/darklocalStorage 记忆 + matchMedia 监听)
utils/ 前端工具函数
time.ts 时间处理formatCountdown、formatDurationUnit、formatRelativeTime、isOlderThan、subtractHours
scripts/
dev.ts 双进程开发服务Bun API server + Vite dev server
build.ts Vite → codegen → Bun compile 三步构建流水线
clean.ts 清理构建产物与临时文件
tests/ Bun test 测试(结构镜像 src 目录)
setup.ts 全局测试配置jsdom、polyfill
helpers.ts 测试辅助工具rmRetry
server/ 后端测试
bootstrap.test.ts
config.test.ts
middleware.test.ts
static.test.ts
web/ 前端测试
App.test.tsx
test-utils.tsx
openspec/ OpenSpec 变更与规格文档
config.example.yaml 配置文件示例
```
---
## 前后端边界
前端只通过 HTTP 调用后端API 路径为 `/api/*``/health`。共享类型放在 `src/shared`,前端不得 `import src/server` 的运行时实现。
---
## 一、后端开发指引
### 1.1 架构概览
```
启动流程:
dev.ts / main.ts → parseRuntimeArgs(cli args)
→ bootstrap({ configPath, mode })
→ loadServerConfig(configPath):可选 YAML 解析 → ServerConfig{ host, port }
→ startServer({ config, mode })Bun.serve routes 声明式路由 + fetch fallback
→ 注册 SIGINT/SIGTERM shutdown
HTTP 请求:
Request → Bun.serve routes 声明式匹配 → routes/*.ts(handler)
→ helpers.ts(响应格式化) → Response
前端: fetch fallback → serveStaticAsset (生产) / Vite proxy (开发)
```
### 1.2 库使用优先级
后端代码开发遵循严格的库选择顺序:
| 优先级 | 来源 | 典型用途 |
| ------ | ------------ | --------------------------------------------------------------------------------------------------- |
| 1 | Bun 内置 API | `Bun.serve``Bun.file``Bun.YAML``Bun.spawn``bun:sqlite`(如需数据存储) |
| 2 | es-toolkit | 类型判断(`isPlainObject`/`isNil`/`isEmptyObject`)、深度比较(`isEqual`)、并发控制(`Semaphore` |
| 3 | 标准 Web API | `Object.fromEntries``Headers``fetch``AbortController``Response` |
| 4 | 主流三方库 | 按需引入,优先社区活跃、类型完善的库 |
| 5 | 自行实现 | 仅在以上都无法满足时(如 `parseDuration``parseSize` 等专项逻辑) |
**原则**:新增依赖前先检查上述每一层级是否已有可用方案。禁止随意引入新依赖。
### 1.3 API 路由开发
路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts``Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象:
```typescript
// server.ts 中的路由注册
routes: {
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
"/health": {
GET: () => handleHealth(mode),
},
}
```
Handler 函数签名:
```typescript
// 无依赖的路由
export function handleHealth(mode: RuntimeMode): Response;
```
**请求处理流程**
1. `Bun.serve``routes` 对象按路径 + HTTP 方法匹配请求
2. 未匹配方法的请求落入 `/api/*` 通配符(返回 404
3. 各 handler 内部通过 `helpers.ts``jsonResponse``createApiError` 等格式化输出
4. 需要参数校验时使用 `middleware.ts` 提供的校验函数,返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过
**新增路由步骤**
1.`src/server/routes/` 下创建 `<name>.ts`
2. 实现 handler 函数并 export
3.`server.ts``routes` 对象中注册路径和 method handler
4.`tests/server/` 中添加对应测试
### 1.4 共享工具
- **`helpers.ts`**:跨路由共用的响应工具函数
- `createApiError(error, status)` — 构造 API 错误体
- `createHeaders(mode, init)` — 创建响应 Headers生产模式附加安全头`X-Content-Type-Options``Referrer-Policy`
- `createHealthResponse()` — 构造健康检查响应 `{ ok: true, service, timestamp }`
- `formatDuration(ms)` — 毫秒转为可读时长字符串
- `jsonResponse(body, options)` — JSON 响应构造
- **`middleware.ts`**API 参数校验函数
- `validateIdParam(idStr, mode)` — 校验 ID 参数格式(字母数字下划线连字符,字母开头),返回 `{ id }``Response`
- `validatePagination(pageParam, pageSizeParam, mode)` — 校验分页参数(默认 page=1, pageSize=20pageSize 上限 200返回 `{ page, pageSize }``Response`
- `validateTimeRange(from, to, mode)` — 校验时间范围参数ISO 格式、from < to返回 `{ from, to }``Response`
- **`static.ts`**:生产模式静态资源服务
- `serveStaticAsset(pathname, assets)` — 静态资源分发(文件扩展名路由 → immutable 缓存,无扩展名 → SPA fallback 返回 index.html
- `hasFileExtension(path)` / `contentTypeFor(path)` / `htmlResponse(html)` — 辅助函数
### 1.5 类型定义规范
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
- 前端不得 `import src/server/` 下的任何文件
- **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string`
- API 响应类型(`ApiErrorResponse``HealthResponse`)定义在 shared 中
### 1.6 配置文件规范
配置加载流程:
```
CLI argv → parseRuntimeArgs → { configPath? }
→ loadServerConfig(configPath)
→ 可选 YAML 文件解析 → env 覆盖 → 默认值
→ ServerConfig{ host, port }
```
`ServerConfig` 包含以下字段:
| 字段 | 来源 | 默认值 |
| ------ | ------------------------------------------------- | ----------- |
| `host` | `process.env["HOST"]` → YAML `server.host` → 默认 | `127.0.0.1` |
| `port` | `process.env["PORT"]` → YAML `server.port` → 默认 | `3000` |
配置文件示例(`config.example.yaml`
```yaml
server:
host: "127.0.0.1"
port: 3000
```
### 1.7 错误模式
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404
- **日志**:非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
---
## 二、前端开发指引
### 2.1 技术栈概览
| 层面 | 技术 | 用途 |
| ------ | --------------------------------------------------- | ------------------------ |
| 框架 | React 19 | UI 组件开发 |
| 构建 | Vite开发+ Bun compile生产 | 开发服务 HMR 与生产构建 |
| 语言 | TypeScript | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动刷新 |
| 路由 | 无(单页面应用) | 不引入 React Router |
**不引入的依赖**React Router单页面场景不需要、状态管理库TanStack Query 即服务端状态层,组件内用 `useState` 足够)
### 2.2 组件树与数据流
```
main.tsx
└── StrictMode
└── ErrorBoundaryReact 错误边界)
└── QueryClientProviderTanStack Query 全局挂载)
├── App根组件Layout + Header + Content 骨架)
│ ├── useThemePreference() ── Header 主题模式 RadioGroup系统/明亮/黑暗localStorage 记忆 + theme-mode 应用)
│ ├── useQuery["health"] ── GET /health30s 轮询,前后端联调示例)
│ └── Content ── 欢迎页 + /health API 响应 JSON 展示
└── ReactQueryDevtools开发工具仅开发环境
```
**Hook 架构**
```
hooks/use-theme-preference.ts浏览器 UI 偏好)
├── ThemePreference: system / light / darkRadioGroup 受控值)
├── EffectiveTheme: light / dark写入 document.documentElement theme-mode
├── localStorage key: {{app-name}}.theme.preference同一浏览器记忆
└── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化)
```
### 2.3 TanStack Query 数据层
#### Query Key 规范
```typescript
// 使用 structured array非字符串以便精确匹配和按 prefix 失效
const queryKeys = {
health: () => ["health"] as const,
};
```
- Key 使用 **structured array**(非字符串),以便精确匹配和按 prefix 失效
- 使用 `as const` 保持字面量类型
#### 查询配置规范
```typescript
// 全局级查询(需要持续刷新)
useQuery({
queryKey: queryKeys.health(),
queryFn: () => fetchJson<HealthResponse>("/health"),
refetchInterval: 30000, // 30s 轮询
refetchIntervalInBackground: false, // 切后台不轮询
staleTime: 5000, // 5s 内视为 fresh
});
```
#### fetch 封装
```typescript
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
}
```
- 统一使用 `fetch`(不引入 axios与后端共享 Web API 生态
- 错误抛异常,由 TanStack Query 的 `error` 状态承接
#### QueryClient 全局配置
```typescript
new QueryClient({
defaultOptions: {
queries: {
retry: 1, // 失败重试 1 次
refetchOnWindowFocus: true, // 窗口聚焦时刷新
staleTime: 5000, // 5s 内视为 fresh避免重复请求
},
},
});
```
### 2.4 组件开发规范
#### 文件命名与导入
- 每个 React 组件一个 `.tsx` 文件,文件名使用 PascalCase`ErrorBoundary.tsx`
- 组件 props 定义为 `interface XxxProps`,紧邻组件函数声明
- 类型从 `../shared/api` 导入,使用 `type` 导入(`import type { ... }`
```typescript
import type { HealthResponse } from "../shared/api";
interface AppProps {
title?: string;
}
export function App({ title }: AppProps) {
// ...
}
```
#### 组件拆分原则
- **展示组件**`components/`):纯渲染逻辑,通过 props 接收数据,通过回调返回事件
- **容器逻辑**放在 hooks 中,组件只做数据消费
- **工具函数**(时间处理等)放在 `utils/`,保持纯函数无副作用
### 2.5 样式开发规范
前端基于 TDesign React 构建 UI样式开发遵循以下优先级从高到低
1. **使用 TDesign 组件**:布局、间距、排版优先使用 TDesign 组件(如 Space、Divider、Typography
2. **使用 TDesign 组件 props**:通过组件的 props 参数控制外观(如 `theme``variant``size`
3. **使用 TDesign CSS tokens**:颜色、间距、字体等使用 `--td-*` CSS 变量(如 `--td-success-color``--td-comp-margin-xxl`
4. **在 styles.css 中定义 CSS 类**:无法通过上述方式满足的样式需求,集中定义在 `styles.css`
5. **自行开发组件**:仅在 TDesign 无法满足需求时自行开发
**红线**
- **严禁在组件中使用 `style` 属性内联调整样式**
- **严禁通过 CSS 覆盖 TDesign 组件内部类名**(如 `.t-tab-panel`),如需定制使用组件的 `className` prop
- **严禁使用 `!important`**
- 颜色统一使用 TDesign CSS tokens`--td-success-color``--td-error-color``--td-warning-color` 等),不使用硬编码色值
**styles.css 组织**
- 自定义 CSS 变量定义在 `:root`
- 布局类(`.dashboard``.dashboard-header-controls`)定义全局页面结构
- 组件修饰类为自定义视觉组件提供样式变体
- 通用工具类(`.full-width``.text-disabled``.tabular-nums`)提供公用排版能力
### 2.6 前端测试规范
- 测试目录:`tests/web/`,结构对应 `src/web/`
- 重点测试 **纯函数**(时间处理、格式化等)和**组件渲染**
- 使用 `bun:test` 框架
- 组件测试使用 `@testing-library/react` 的语义化查询getByText、getByRole而非 CSS 选择器
- 测试用户行为而非实现细节:模拟用户点击、输入等操作,而非直接调用组件方法
- 只 mock 系统边界mock fetch 返回预设响应,使用真实的 QueryClientProvider 包裹组件
---
## 三、项目运行、集成与打包
### 3.1 开发期运行
```bash
bun run dev [config.yaml]
```
`scripts/dev.ts` 同时启动两个进程:
- **Bun API server**(端口 3000后端 API 服务,`--watch` 监听后端文件变更自动重启
- **Vite dev server**(端口 5173前端 SPA + HMR 热更新
开发时访问 `http://127.0.0.1:5173`Vite 自动将 `/api``/health` 请求代理到后端。
也可以单独启动:
```bash
bun run dev:server [config.yaml] # 仅启动后端 API server--watch 模式)
bun run dev:web # 仅启动 Vite dev server
```
### 3.2 前后端集成方式
#### 双进程开发架构
开发模式下前后端分别由 Vite 和 Bun 服务:
- Vite dev server 负责前端 SPA、HMR、模块热替换
- Bun API server 负责后端 API 路由
- Vite 通过 proxy 配置将 `/api/*``/health` 转发到 Bun
#### 生产模式架构
生产模式下前端通过 Vite 构建为静态资源,通过 `import with { type: "file" }` 嵌入 Bun 可执行文件:
```typescript
// server.ts
const server = Bun.serve({
fetch(req) {
// staticAssets 存在时服务嵌入的前端资源
if (staticAssets) {
return serveStaticAsset(new URL(req.url).pathname, staticAssets);
}
return new Response("Frontend is served by Vite dev server on :5173", { status: 404 });
},
routes: {
"/api/*": () => ...,
"/health": { GET: () => handleHealth(mode) },
},
});
```
#### 路由优先级
Bun routes 的匹配规则:具体路径 > 通配符。`/health` 优先于 `/*`
未匹配 method 的请求(如 POST /health会落入 `/api/*` 通配符返回 404若无该通配符会落入 fetch fallback。
非 API 路径由 fetch fallback 处理:有文件扩展名的返回对应静态资源或 404无扩展名的返回 SPA index.html。
### 3.3 构建打包
#### 构建命令
```bash
bun run build
```
#### 构建流程
`scripts/build.ts` 执行三步流水线:
```
1. Vite build → dist/web/ (前端静态资源,含 code splitting)
2. Code generation → .build/static-assets.ts + .build/server-entry.ts
3. Bun compile → dist/dial-server (单可执行文件)
```
- Vite 构建前端资源到 `dist/web/`,自动 code splittingvendor-react、vendor-tdesign、vendor-chart
- Code generation 扫描 `dist/web/` 生成 `import with { type: "file" }` 声明,将资源嵌入 binary
- Bun compile 以 `.build/server-entry.ts` 为入口编译最终可执行文件
- `.build/` 临时目录在构建完成后自动清理
#### 产物
| 产物 | 用途 |
| ------------------ | ---------------------------------------- |
| `dist/dial-server` | 生产可执行文件(含前端资源,单文件部署) |
| `dist/web/` | Vite 构建的前端资源(构建中间产物) |
#### 构建参数
| 环境变量 | 说明 |
| --------------------------- | -------------------------------------- |
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(如 `bun-linux-x64` |
#### 运行可执行文件
```bash
./dist/dial-server [config.yaml]
```
启动后:
- 访问 `http://127.0.0.1:3000/` → 返回前端 SPA
- 访问 `http://127.0.0.1:3000/api/*` → 返回后端 API
- 访问 `http://127.0.0.1:3000/health` → 返回健康检查 JSON
#### 清理
```bash
bun run clean
# 清理 dist/ 构建产物和 .build/ 临时文件
```
### 3.4 开发工作流
#### 日常开发循环
```bash
bun run dev [config.yaml] # 启动双进程开发环境Vite :5173 + API :3000
# 访问 http://127.0.0.1:5173
# 修改前端代码 → Vite HMR 热更新 / 修改后端代码 → --watch 自动重启
bun run check # 提交前运行完整质量检查
```
#### 完整验证流程
```bash
bun run verify
# = bun run check + bun run build
```
`verify` 适合 CI 或正式提交前会完整验证类型检查、lint、格式、单元测试和生产构建。
### 3.5 脚本说明
| 脚本 | 文件 | 说明 |
| -------------------- | ------------------- | ---------------------------------------- |
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务Vite :5173 + API :3000 |
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server--watch 模式) |
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
### 3.6 环境变量
| 变量 | 用途 | 默认值 |
| --------------------------- | ----------------------------------------------- | ----------- |
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
| `HOST` | 服务监听地址 | `127.0.0.1` |
| `PORT` | 服务监听端口 | `3000` |
### 3.7 项目配置文件
| 文件 | 用途 |
| ---------------------- | ------------------------------------------------------------ |
| `package.json` | 项目信息、脚本、依赖声明 |
| `tsconfig.json` | TypeScript 配置ESNext 模块、严格模式) |
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
| `commitlint.config.js` | commitlint 提交信息格式校验 |
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120` |
| `.prettierignore` | Prettier 排除路径 |
| `.lintstagedrc.json` | lint-staged 配置TS/TSX → ESLintMD/JSON/YAML → Prettier |
| `config.example.yaml` | 配置文件示例 |
| `vite.config.ts` | Vite 构建配置React 插件、代码分割、API proxy |
| `bunfig.toml` | Bun 配置(测试 preload、排除规则 |
| `opencode.json` | OpenCode 工具配置 |
### 3.8 依赖管理
- **包管理器**:仅使用 `bun`,禁止使用 npm、pnpm、yarn
- **安装依赖**`bun install`
- **运行工具**:使用 `bunx`,禁止使用 `npx``pnpx`
- **锁文件**`bun.lock`
### 3.9 目录约定
| 目录 | 约定 |
| ------------- | ---------------------------------------------------- |
| `src/server/` | 后端代码,不能 import `src/web/`HTML import 除外) |
| `src/web/` | 前端代码,不能 import `src/server/` |
| `src/shared/` | 前后端共享类型,双向可引用 |
| `scripts/` | 独立运行脚本,可 import 项目源码 |
| `tests/` | 测试目录,结构镜像 src 目录 |
| `dist/` | 构建产物gitignore |
| `openspec/` | OpenSpec 变更管理与规格文档 |
---
## 代码质量
项目使用多层代码质量保障体系ESLint 类型感知规则 + Perfectionist 导入排序 + Prettier 格式化(通过 eslint-plugin-prettier 集成至 ESLint+ TypeScript 严格模式 + Git hooks 自动化。
```bash
bun run lint # ESLint 检查含类型感知规则、导入排序、导入验证、Prettier 格式)
bun run format # Prettier 自动格式化
bun run typecheck # TypeScript 类型检查
bun test # 运行所有测试
bun run check # 一键运行 typecheck + lint + test
bun run verify # 完整验证check + build
```
`check` 是日常开发推荐的质量检查命令。
### ESLint 规则
配置文件:`eslint.config.js`
| 配置来源 | 用途 |
| --------------------------------------------------------------- | -------------------------------------------------- |
| `@eslint/js` recommended | JavaScript 基础规则 |
| `typescript-eslint` recommended-type-checked | TypeScript 类型感知规则no-floating-promises 等) |
| `typescript-eslint` stylistic-type-checked | TypeScript 风格规则(命名规范、语法选择等) |
| `eslint-plugin-perfectionist` recommended-natural | 导入语句和命名导出自动排序 |
| `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 |
| `eslint-plugin-react-hooks` recommended | React Hooks 规则(依赖数组完整性检查等) |
| `eslint-plugin-react-refresh` | React Fast Refresh 兼容性检查 |
| `eslint-plugin-prettier` recommended + `eslint-config-prettier` | 将 Prettier 格式集成为 ESLint 规则,禁用冲突规则 |
**前端导入限制**`src/web/` 下的文件禁止 `import src/server/` 下的运行时实现,通过 `no-restricted-imports` 规则强制执行。
### Prettier 配置
配置文件:`.prettierrc.json`,通过 `eslint-plugin-prettier` 集成为 ESLint 规则(`lint` 命令同时检查格式),也可通过 `format` 命令独立运行。
显式声明所有格式化参数(`printWidth: 120``semi: true``singleQuote: false``trailingComma: "all"``endOfLine: "lf"` 等),确保不同开发环境产出完全一致的格式化结果。
### TypeScript 严格标志
| 标志 | 值 | 说明 |
| ------------------------------------ | ----- | ------------------------------------------------ |
| `strict` | true | 全局严格模式 |
| `noUnusedLocals` | true | 未使用局部变量视为错误 |
| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要) |
| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 |
| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 |
| `noImplicitOverride` | true | 子类覆盖父类方法时必须显式使用 `override` 关键字 |
| `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入,与 Bun 构建兼容 |
### Git Hooks
通过 husky 在 commit 阶段自动执行检查:
| Hook | 行为 |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `pre-commit` | lint-staged 对变更文件运行 `eslint --fix`TS/TSX含 Prettier 格式修复)或 `prettier --write`MD/JSON/YAML |
| `commit-msg` | commitlint 校验提交信息格式 `类型: 简短描述` |
提交类型限定:`feat``fix``refactor``docs``style``test``chore`
`bun install` 时自动初始化 husky hooks无需手动配置。
### 质量检查完整清单
提交代码前建议运行:
```bash
bun run verify
```
CI 或正式提交前执行完整验证(类型检查 + lint + 格式 + 测试 + 构建),确保代码可编译并通过所有检查。
---
## 测试
项目采用两层测试体系:单元测试 + 组件测试。所有测试使用 `bun:test` 运行。
### 测试分层
| 层级 | 覆盖范围 | 位置 | 命令 |
| -------- | ---------------------- | ------------------------------------------------------------------- | --------------------------------------------- |
| 单元测试 | 后端函数、纯函数、常量 | `tests/server/**/*.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` |
### 运行命令
```bash
bun test # 运行所有单元测试和组件测试
bun test tests/server # 只运行后端单元测试
bun test tests/web # 只运行前端测试(单元 + 组件)
bun run check # 日常开发(类型检查 + lint + 测试)
bun run verify # 完整验证check + 构建)
```
### 组件测试环境
组件测试使用 jsdom 模拟浏览器环境,配置位于 `tests/setup.ts`(通过 `bunfig.toml` preload 加载):
- jsdom 提供完整的 DOM 环境
- TDesign 组件所需的 polyfillResizeObserver、IntersectionObserver、MutationObserver、matchMedia、attachEvent
- 全局 `afterEach` 清理 document.body 内容,确保测试隔离
### 编写规范
- **优先使用 `@testing-library/react`** 的语义化查询getByText、getByRole而非 CSS 选择器
- **测试用户行为而非实现细节**:模拟用户点击、输入等操作,而非直接调用组件方法
- **只 mock 系统边界**mock fetch 返回预设响应,使用真实的 QueryClientProvider 包裹组件
- **组件测试文件命名**`tests/web/ComponentName.test.tsx`
- **测试目录镜像源码目录**`tests/server/config.test.ts` 对应 `src/server/config.ts`
---
## 已知限制
- 当前仅为单页面应用,不涉及用户认证和权限控制
- 不支持集群部署,单进程运行
- 配置文件仅支持 YAML 格式,不支持热加载
- 无国际化和多语言支持

184
LICENSE Normal file
View File

@@ -0,0 +1,184 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, "control" means (i) the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Object" form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that is
included in or attached to the work (an example is provided in the Appendix
below).
"Derivative Works" shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
reproduce, prepare Derivative Works of, publicly display, publicly perform,
sublicense, and distribute the Work and such Derivative Works in Source or
Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License,
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section) patent
license to make, have made, use, offer to sell, sell, import, and otherwise
transfer the Work, where such license applies only to those patent claims
licensable by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s) with the Work
to which such Contribution(s) was submitted. If You institute patent litigation
against any entity (including a cross-claim or counterclaim in a lawsuit)
alleging that the Work or a Contribution incorporated within the Work
constitutes direct or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate as of the date
such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and in
Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of
this License; and
(b) You must cause any modified files to carry prominent notices stating that
You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from the
Source form of the Work, excluding those notices that do not pertain to any part
of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then
any Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without any
additional terms or conditions. Notwithstanding the above, nothing herein shall
supersede or modify the terms of any separate license agreement you may have
executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor, except as required
for reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
writing, Licensor provides the Work (and each Contributor provides its
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied, including, without limitation, any warranties
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any risks
associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in
tort (including negligence), contract, or otherwise, unless required by
applicable law (such as deliberate and grossly negligent acts) or agreed to in
writing, shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or consequential damages of any character
arising as a result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill, work stoppage,
computer failure or malfunction, or any and all other commercial damages or
losses), even if such Contributor has been advised of the possibility of such
damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a fee for,
acceptance of support, warranty, indemnity, or other liability obligations
and/or rights consistent with this License. However, in accepting such
obligations, You may act only on Your own behalf and on Your sole
responsibility, not on behalf of any other Contributor, and only if You agree to
indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets "[]" replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

208
README.md Normal file
View File

@@ -0,0 +1,208 @@
# {{app-name}}
(替换为你的项目介绍)
Bun 全栈应用模板,基于 Bun + React + TDesign 的前后端一体化开发框架。
## 快速开始
```bash
git clone <your-repo-url> my-project
cd my-project
bun install
bun run dev
```
访问 http://127.0.0.1:5173 查看应用。
## 使用此模板
### 1. 克隆模板
```bash
git clone <template-repo-url> my-project
cd my-project
rm -rf .git && git init
```
### 2. 替换占位符 `{{app-name}}`
在整个项目中全局搜索替换 `{{app-name}}` 为你的项目名称(如 `my-app`)。以下为所有出现位置:
| # | 文件 | 说明 |
| --- | --------------------------------------- | ------------------------------------------ |
| 1 | `package.json` | `name` 字段 |
| 2 | `scripts/build.ts` | 可执行文件路径 |
| 3 | `src/server/config.ts` | CLI 帮助文本 |
| 4 | `src/server/helpers.ts` | `createHealthResponse()``service` 字段 |
| 5 | `src/server/server.ts` | 服务启动日志消息 |
| 6 | `src/web/index.html` | `<title>``<meta name="description">` |
| 7 | `src/web/app.tsx` | Header 中的品牌名和欢迎标题 |
| 8 | `src/web/hooks/use-theme-preference.ts` | `localStorage` 键名 |
| 9 | `tests/web/App.test.tsx` | 测试中的品牌名断言 |
> **提示**:可直接在编辑器中全局搜索 `{{app-name}}`,一次性替换。
### 3. 清理 OpenSpec 历史
删除模板自带的 OpenSpec 变更历史,保留框架配置:
```bash
rm -rf openspec/specs/*
rm -rf openspec/changes/*
```
> `openspec/config.yaml` 需要保留,其中包含项目开发规范配置。
### 4. 安装依赖
```bash
bun install
```
### 5. 开始开发
```bash
bun run dev
```
## 项目管理
| 命令 | 说明 |
| -------------------- | ---------------------------------------------------------- |
| `bun run dev` | 启动开发模式(并行启动后端 + 前端 Vite 开发服务器) |
| `bun run dev:server` | 仅启动后端开发服务(`--watch` 热重载) |
| `bun run dev:web` | 仅启动前端 Vite 开发服务器 |
| `bun run build` | 生产构建Vite 打包前端 → Bun compile 生成独立可执行文件) |
| `bun test` | 运行全部测试 |
| `bun run lint` | ESLint 代码风格检查 |
| `bun run format` | Prettier 代码格式化 |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun run check` | 完整质量检查typecheck + lint + test |
| `bun run verify` | 验证构建流程check + build |
| `bun run clean` | 清理构建产物和临时文件 |
## 项目结构
```text
.
├── config.example.yaml # 配置文件示例
├── bunfig.toml # Bun 配置(测试预加载等)
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 构建配置(代码分包、代理)
├── eslint.config.js # ESLint 统一配置
├── .prettierrc.json # Prettier 格式化配置
├── commitlint.config.js # Commitlint 提交规范配置
├── .lintstagedrc.json # lint-staged 暂存区检查配置
├── scripts/
│ ├── dev.ts # 开发启动脚本(并行启动 API + Vite
│ ├── build.ts # 生产构建脚本Vite → 代码生成 → Bun compile
│ └── clean.ts # 清理脚本
├── src/
│ ├── server/ # 后端代码
│ │ ├── bootstrap.ts # 统一启动引导(配置加载 → 服务启动 → 优雅关闭)
│ │ ├── config.ts # CLI 参数解析 + YAML 配置加载
│ │ ├── dev.ts # 开发模式入口
│ │ ├── main.ts # 生产模式入口
│ │ ├── server.ts # HTTP 服务器Bun.serve routes 声明式路由)
│ │ ├── helpers.ts # 共享响应工具健康检查、JSON 响应)
│ │ ├── middleware.ts # API 参数校验中间件
│ │ ├── static.ts # 静态资源服务
│ │ └── routes/ # API 路由处理器
│ │ └── health.ts # 健康检查端点
│ ├── shared/
│ │ └── api.ts # 前后端共享 TypeScript 类型定义
│ └── web/ # 前端代码
│ ├── index.html # HTML 入口
│ ├── main.tsx # React 入口QueryClient + ErrorBoundary
│ ├── app.tsx # 根组件Layout + 主题切换 + /health 展示)
│ ├── styles.css # 全局样式
│ ├── css.d.ts # CSS 模块类型声明
│ ├── components/ # UI 组件
│ ├── hooks/ # React Hooks
│ └── utils/ # 前端工具函数
├── tests/ # 测试文件(镜像 src 目录结构)
├── openspec/ # OpenSpec 规格与变更管理
└── docs/ # 项目文档
```
## 配置
项目使用 YAML 配置文件,支持环境变量覆盖。
### 配置文件
复制 `config.example.yaml``config.yaml`(或任意名称),根据需要修改:
```yaml
server:
host: "127.0.0.1"
port: 3000
```
### 环境变量覆盖
| 环境变量 | 对应配置字段 | 默认值 |
| -------- | ------------- | ----------- |
| `HOST` | `server.host` | `127.0.0.1` |
| `PORT` | `server.port` | `3000` |
### 配置优先级
```
环境变量 > YAML 配置文件 > 代码默认值
```
### 使用自定义配置
```bash
bun run dev custom-config.yaml
```
## 技术栈
### 运行时
| 技术 | 说明 |
| --------------------------------------------- | ---------------------------------------------- |
| [Bun](https://bun.sh) | JavaScript/TypeScript 运行时、包管理器、打包器 |
| [TypeScript](https://www.typescriptlang.org/) | 类型安全的 JavaScript 超集 |
### 后端
| 技术 | 说明 |
| -------------------------------------------- | ---------------------------- |
| `Bun.serve` | HTTP 服务器,声明式路由匹配 |
| `Bun.YAML` | YAML 配置文件解析 |
| [es-toolkit](https://es-toolkit.slash.page/) | 高性能工具库(推荐优先使用) |
### 前端
| 技术 | 说明 |
| --------------------------------------------------- | ------------------------ |
| [React 19](https://react.dev/) | UI 组件框架 |
| [TDesign React](https://tdesign.tencent.com/react/) | 企业级 UI 组件库 |
| [@tanstack/react-query](https://tanstack.com/query) | 服务端状态管理与数据获取 |
| [Recharts](https://recharts.org/) | 图表可视化(推荐使用) |
| [Vite](https://vitejs.dev/) | 前端构建工具 |
### 工程化
| 技术 | 说明 |
| ------------------------------------------ | ---------------- |
| [ESLint](https://eslint.org/) | 代码规范检查 |
| [Prettier](https://prettier.io/) | 代码格式化 |
| [Husky](https://typicode.github.io/husky/) | Git hooks 管理 |
| [Commitlint](https://commitlint.js.org/) | Git 提交消息校验 |
### 测试
| 技术 | 说明 |
| ----------------------------------------------------------- | ---------------- |
| [bun:test](https://bun.sh/docs/cli/test) | Bun 内置测试框架 |
| [@testing-library/react](https://testing-library.com/react) | React 组件测试 |
| [jsdom](https://github.com/jsdom/jsdom) | DOM 环境模拟 |
## 开源协议
MIT

1204
bun.lock Normal file

File diff suppressed because it is too large Load Diff

3
bunfig.toml Normal file
View File

@@ -0,0 +1,3 @@
[test]
preload = ["./tests/setup.ts"]
exclude = ["./tests/e2e/**"]

8
commitlint.config.js Normal file
View File

@@ -0,0 +1,8 @@
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"subject-case": [0],
"subject-full-stop": [0],
"type-enum": [2, "always", ["feat", "fix", "refactor", "docs", "style", "test", "chore"]],
},
};

3
config.example.yaml Normal file
View File

@@ -0,0 +1,3 @@
server:
host: "127.0.0.1"
port: 3000

292
docs/prompts/README.md Normal file
View File

@@ -0,0 +1,292 @@
# Prompts
面向 AI 大模型的执行型提示词集合。每份提示词都应能被单独复制使用,并驱动 AI 以一致的方式完成收集、分析、确认、执行和收尾。
## 提示词
| 文件 | 用途 |
| ------------------------------------------------------ | ------------------------------------------------------------------------ |
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
| [prompt-spec-review.md](prompt-spec-review.md) | 审查和整理 `openspec/specs/` 下的稳定规范,提升可检索性和一致性 |
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 proposal/design/tasks/specs 与讨论、代码现状、OpenSpec 规范的一致性 |
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后代码、测试、变更文档的一致性,并补齐遗漏或回写文档 |
## 设计目标
从现有提示词提炼出的共同目标:
- 面向执行,不面向讲解
- 先收集证据,再做判断
- 先计划和确认,再做有副作用的修改
- 对缺失信息、规则冲突、上下文不足有降级路径
- 对删除、重写、提交、回退、推送等高风险动作有明确授权边界
- 执行后必须复核,形成闭环
## 命名规则
文件名格式:`prompt-{action}.md`
- `{action}` 使用明确、可搜索、无歧义的英文短语
- 用连字符连接,不使用缩写、代号或过泛词
- 优先体现动作和对象,如 `smart-merge``spec-review``apply-review`
## 通用骨架
大多数提示词应遵循以下结构;按任务类型增删章节,但顺序尽量保持一致:
```md
一句话描述任务目标
## 约束
## 1. 收集 / 准备
## 2. 分析
## 3. 报告 或 计划(用户确认)
## 4. 执行(用户确认)
## 5. 清理 / 收尾
```
适用方式:
- 审查型:`收集 → 分析 → 报告 → 计划 → 执行 → 收尾`
- 执行型:`准备 → 分析 → 计划 → 执行 → 清理`
- 纯报告型:可省略执行,但仍要保留“信息不足时如何降级”和“结果如何输出”
## 编写原则
### 1. 面向 AI不写背景说明
- 不写业务背景、适用场景、周期性说明、方法论阐释
- 不写“为什么这么做”的长解释,直接写成规则或步骤
- 不写示例输出模板,除非输出格式本身就是约束的一部分
### 2. 证据先于结论
- 明确列出需要读取的文档、代码、测试、配置、命令结果
- 能并行的步骤明确写“并行”
- 默认优先使用当前会话信息、现有文档和仓库规则
- 只有在无法定位对象、范围或规则时,才引导 AI 向用户提问或运行补充命令
- 不要求 AI 无差别全量扫描整个仓库;先建立索引,再做定向读取
### 3. 约束集中声明
- 全局不可违反的规则统一放在 `## 约束`
- 不在后续步骤反复重复同一条规则
- 约束优先写边界、禁令、授权条件、同步要求、非目标
常见约束类型:
- 只允许修改哪些对象,不允许修改哪些对象
- 是否默认按某个 workflow 执行
- 是否以代码、文档、讨论或用户确认为准
- 何时必须使用提问工具确认
- 删除、重写前是否必须备份
- 改动后是否必须同步 README、测试、变更文档
### 4. 计划与执行分离
- 分析阶段只产出问题、风险、候选动作,不直接修改
- 执行前必须先给出计划或批次方案
- 批次计划必须能让用户看懂“改什么、为什么、影响什么、如何验证”
- 用户确认执行,不等于授权所有危险动作;额外高风险动作要单独确认
### 5. 闭环优先
- 执行后必须重新读取受影响对象并复核
- 对代码修改要说明测试或验证方式
- 对文档修改要检查相关文档之间是否同步一致
- 收尾时要列出修改文件、备份文件、验证结果和残留风险
## 各章节写法
### 目标句
第一句只做三件事:
- 说明任务对象
- 说明最终目标
- 说明执行方式或范围
要求:
- 一句话写完
- 不写背景铺垫
- 尽量包含最终产物,如“生成计划”“回写文档”“整理稳定规范”
### 约束
推荐写法:
- 作用域边界:改什么,不改什么
- 真相来源优先级:代码 / README / spec / 讨论 / 用户确认
- 风险动作边界删除、重写、提交、推送、回退、stash、merge 等
- 同步要求测试、README、变更文档、现有 spec 是否要同步
- 降级规则:信息不足时如何处理
避免:
- 在约束里塞步骤顺序
- 同一规则在约束和执行里重复出现多次
### 收集 / 准备
要明确三类内容:
- 读什么
- 是否并行
- 无法确定时如何补充定位
推荐做法:
- 先读仓库规则来源,如 `README.md`、配置、架构文档、近期提交、任务入口
- 先读直接相关 artifacts再扩展到相关代码和测试
- 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里
### 分析
分析部分优先使用表格表达维度、优先级和判定规则。
推荐包含:
- 优先级或维度表
- 差异分类规则
- 风险分级规则
- 是否进入待确认清单的判定条件
常见分析模式:
- `P0 / P1 / P2 / P3` 优先级
- “过时 / 重复 / 冲突 / 错位 / 命名 / 格式” 维度
- “文档要求未实现 / 代码修补未回写 / 双方冲突待确认” 差异分类
### 报告 / 计划
这是现有提示词最稳定的共性区块,建议固定包含:
- 问题总览表
- 逐项分析
- 待确认清单
- 分批执行方案
- 预期结果或目录结构
若进入计划阶段,必须写清:
- 改哪些文件或对象
- 动作类型:删除、重命名、迁移、合并、拆分、补充、回写、修复
- 修改原因
- 预期影响
- 验证方式
### 执行
执行部分要强调可操作性:
- 明确顺序执行还是可并行执行
- 明确每批执行前是否确认
- 明确删除、重写、回退前是否要备份或创建锚点
- 明确执行后最少要复核哪些点
推荐写法:
- “逐批执行”或“逐项执行”
- “每批执行后重新读取受影响文件并复核”
- “若涉及删除或重写,先创建备份文件 `{file}.bak.{timestamp}`
### 清理 / 收尾
收尾要输出结果,不只说“完成”。
建议包含:
- 修改文件清单
- 备份文件清单
- 测试 / 构建 / 验证命令与结果
- 文档同步摘要
- 残留问题、未验证项、待确认事项
## 交互与安全规范
### 必须确认的动作
以下动作默认都要在提示词中要求 AI 使用提问工具确认:
- 删除文件或目录
- 重写文件
- 创建或恢复 stash
- 回退、reset、revert、abort
- 合并提交、推送、删分支、删远端分支、删 tag
- 任何会改变工作区现场且用户未明确授权的操作
### 授权边界要写清
提示词要明确区分:
- “确认当前计划”
- “确认执行当前批次”
- “确认某个具体危险动作”
不要把这些授权混为一谈。
### 回退路径要提前写好
对高风险流程,提示词应提供至少一种回退机制:
- 备份文件
- 安全锚点 tag
- `abort` 路径
- 终止后的现场说明
## 表达与格式
- 优先使用表格表达规则、维度、状态、输出项
- 列表优于段落
- 每句话只写一条指令
-`{占位符}` 表示需要 AI 或用户填入的参数
- 步骤编号使用 `## 1.``## 2.`,不用“第 X 步”
- 信息展示遵循“概览 → 详情 → 原始数据”
## 仓库适配规则
- 若仓库文档已经定义命令、目录、提交格式、包管理器、工作流,提示词必须先遵守仓库文档
- 若仓库未定义,再允许 AI 根据脚本、清单文件、近期提交历史推断
- 不把当前仓库的偶然路径结构、命令名、分支名写死到所有提示词中
- 只有当提示词本身就是为当前仓库的特定流程编写时,才写入仓库专属术语和路径
## 反模式
以下写法应避免:
- 让 AI 一上来就全仓库无差别扫描
- 在未分析前直接要求修改
- 把代码现状直接当成唯一真相
- 把历史变更文档直接当成稳定规范来源
- 用抽象表述代替可执行动作
- 把多个危险动作打包成一次默认授权
- 没有备份、没有锚点、没有终止路径
- 只要求“完成修改”,不要求复核和收尾
## 编写检查清单
编写完一份提示词后,至少自检以下问题:
| 检查项 | 说明 |
| ---------------- | ------------------------------------------------- |
| 目标是否单句明确 | 是否能一眼看出任务对象、目标和范围 |
| 约束是否集中 | 全局规则是否只在 `## 约束` 中声明 |
| 数据源是否具体 | 是否明确读哪些文档、代码、测试、命令结果 |
| 是否先分析再执行 | 是否存在独立的分析和计划阶段 |
| 是否有确认节点 | 高风险动作前是否要求提问工具确认 |
| 是否有降级路径 | change 不明、规则不明、上下文不足时是否有处理方式 |
| 是否可操作 | 是否给出命令、工具、路径或结构化动作 |
| 是否可验证 | 执行后是否定义复核或测试方式 |
| 是否能收尾 | 是否要求输出修改清单、备份、验证结果、残留风险 |
## 维护原则
- 新增提示词前,先判断能否复用现有提示词的结构和术语
- 修改提示词时,优先提炼共性,不为单次场景堆特例
- 若某条规则已在多个提示词中稳定出现,应回收进本 README作为统一编写规范

View File

@@ -0,0 +1,130 @@
审查 OpenSpec apply 完成后以及后续手动修补后的实际实现,判断代码、测试、变更文档是否一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
## 约束
- 先审查再修复;未经用户确认,不修改代码或变更文档
- 默认按 `spec-driven` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
- 优先使用当前会话中的实现说明、测试结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
- 不要因为代码已经存在就自动以代码为准;先判断差异属于"文档要求未实现"、"测试后新增修补"还是"意外偏离/回归"
- 每批代码或文档修改执行前用提问工具获得用户确认
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
- 若修改代码涉及新逻辑、模块结构、API、实体或用户可见行为同步更新测试、相关变更文档和 README
## 1. 收集
并行读取:
- 本次 change 的实际 artifacts`spec-driven` 下通常包括 `proposal.md``design.md``tasks.md``specs/*.md`
- 当前会话中与本次变更相关的实现说明、apply 过程中的偏离、测试失败、手动修补原因、待确认事项
- 与本次变更相关的代码和测试文件;优先依据 `git diff --name-only``git diff --name-only --cached``tasks.md`、Impact、失败测试栈定位若工作区已干净再结合文档和代码模块反推
- 最近一次相关测试命令、测试结果、失败信息和修补后的验证结果
- `openspec/config.yaml`
- 与本次改动相关的 README、架构文档以及现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md``Capabilities` / `Modified Capabilities`、手动修补涉及的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
若当前上下文无法明确 change 或文档路径:
- 先用提问工具让用户确认 change 名称或文档范围
- 仍无法确认时,再执行 `openspec status --change "{name}" --json``openspec instructions apply --change "{name}" --json` 辅助定位
若已明确 change但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
若缺少测试结果或手动修补记录,明确说明本次无法可靠判断部分差异的来源,仅能基于代码与文档现状审查。
## 2. 分析
按以下优先级检查:
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际实现与测试结论 | 当前代码的真实行为是什么apply 后是否有手动改动或测试后修补;测试是否证明这些实现有效;若缺少测试结果,标记相关结论为"未验证";检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | 文档同步性 | 对实际存在的 artifacts 检查已落地的实现、测试后新增修补、边界处理、异常路径、验证结论是否已同步回变更文档若影响模块结构、API、实体或用户可见行为再检查 README 是否同步 |
| P2 | 文档要求覆盖 | 对实际存在的 artifacts 检查文档中承诺的目标、方案、Requirement、Scenario 是否都已实现;在 `spec-driven` 下重点检查 `proposal.md``design.md``specs/*.md``tasks.md` |
| P3 | 实现质量 | 代码结构、复用、命名、复杂度、错误处理、测试质量、与项目现有模式的一致性是否存在明显问题或可优化点 |
分析时区分三类差异:
- 文档要求已明确,但代码未实现或实现不完整 → 需补充代码或测试
- 代码因测试暴露问题、手动修补或合理落地细化而新增/变更 → 需回写文档
- 代码与文档不一致,且无法判断应以哪边为准 → 列入待确认清单
不要把以下情况直接视为合理修补:
- 通过 `skip``only`、弱化断言、绕过错误处理来让测试通过
- 为了贴合现有代码而降低已确认的 Requirement 或行为约束
- 未经过讨论和验证就扩大功能范围
重点识别:
- 文档要求但未落地的功能、场景、异常处理或验证步骤
- apply 完成后新增的代码修补、边界处理、接口调整、行为变化未同步到文档
- `tasks.md` 标记完成,但代码、测试或文档未闭环
- `Modified Capabilities` 应更新但未更新的现有 spec
- 代码存在明显的重复、复杂度过高、命名不清、错误处理薄弱、测试质量不足等问题
输出审查结果:
1. **问题总览表**:问题类型 × 涉及文件数
2. **实际改动与修补清单**:本次实现中已落地的主要功能、后续修补和验证结论;若缺少测试结果,对未验证部分单独标记
3. **未覆盖清单**:文档要求但未在代码中实现或未充分验证的内容
4. **需回写文档清单**:代码和测试中已确认、但文档未体现的实现、修补或约束变化
5. **方向待确认清单**:代码与文档不一致,且无法判断应以哪边为准的事项
6. **任务状态问题清单**:未真正完成、状态错误或需补充的新任务
7. **测试问题清单**:缺失覆盖、掩盖错误、验证不足或修补后未回归验证的测试问题
8. **代码质量/优化清单**:可优化的实现问题和建议
9. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
若所有清单均为空,输出"审查通过,未发现问题",跳至步骤 5。
## 3. 计划(用户确认)
先针对"方向待确认清单"用提问工具逐项向用户确认。
再整理完整修复方案,按类别列出:
- 代码或测试补充:补实现、补异常处理、补回归测试、修复掩盖错误的测试
- 文档回写:同步 `proposal.md``design.md``tasks.md``specs/*.md`、README 中遗漏或过时的内容
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
- 代码质量优化:在不改变目标行为的前提下优化结构、复用、命名或可维护性
对每个拟修改的文件说明:
- 修改内容
- 修改原因
- 预期影响
- 验证方式
- 若存在分支方案,分别说明适用前提
用提问工具展示完整修复方案,获得用户确认后执行。
## 4. 执行
逐项执行已确认的代码、测试和文档修复。
若涉及删除或重写:
- 先创建备份文件 `{file}.bak.{timestamp}`
- 再执行修改
若修改了代码或测试:
- 同步更新相关变更文档若影响模块结构、API、实体或用户可见行为再同步 README
- 运行相关测试;若修补影响范围较大,再补充执行受影响的回归测试
若修改了文档:
- 确认实际存在的变更文档之间保持一致;在 `spec-driven` 下重点检查 `proposal.md``design.md``tasks.md``specs/*.md`
- 若 apply 后新增修补改变了能力边界或行为约束,同步更新 `Capabilities` / `Modified Capabilities`
执行后重新读取所有被修改的代码、测试和文档,并复核:
- "未覆盖清单" 是否已清空或已标注保留原因
- "需回写文档清单" 是否已清空
- "方向待确认清单" 是否已清空或已记录用户决策
- "任务状态问题清单" 和 "测试问题清单" 是否已清空或已标注残留原因
- "代码质量/优化清单" 中哪些已处理,哪些有意延期
## 5. 收尾
列出所有修改的文件、备份文件、测试命令与结果、文档同步摘要和剩余风险。
若本次因缺少测试结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。

View File

@@ -0,0 +1,99 @@
审查本次 OpenSpec 变更文档是否与前序讨论、当前代码现状和 OpenSpec 文档规范一致,识别遗漏、冲突和不合理假设,并给出可执行的补充建议,按以下流程执行。
## 约束
- 仅修改本次变更文档,不修改源码
- 默认按 `spec-driven` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
- 优先使用当前会话中的讨论和已生成的变更文档;仅在无法明确 change、`schemaName` 或文档范围时,再用提问工具或 OpenSpec 命令补充定位
- 每批文档修改建议执行前用提问工具获得用户确认
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
## 1. 收集
并行读取:
- 本次 change 的实际 artifacts`spec-driven` 下通常包括 `proposal.md``design.md``tasks.md``specs/*.md`
- 当前会话中与本次变更相关的讨论、澄清、边界约束、非目标、待确认事项
- 与本次变更直接相关的源码、测试、README、架构文档
- `openspec/config.yaml`
- 现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md``Capabilities` / `Modified Capabilities`、讨论中提到的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
若当前上下文无法明确 change 或文档路径:
- 先用提问工具让用户确认 change 名称或文档范围
- 仍无法确认时,再执行 `openspec status --change "{name}" --json``openspec instructions apply --change "{name}" --json` 辅助定位
若已明确 change但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
若缺少讨论记录,明确说明本次降级为"文档 + 代码现状审查",不做讨论一致性结论。
## 2. 分析
按以下优先级检查:
| 优先级 | 维度 | 检查点 |
| ------ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 讨论一致性 | 仅在存在讨论记录时检查:文档是否完整覆盖已确认的目标、范围、非目标、约束、边界条件、风险、决策点、待办事项;若无讨论记录,标记为"跳过" |
| P1 | 代码现实性 | 文档对当前模块、接口、数据结构、命名、依赖、目录结构、复用路径的描述是否准确;是否把"计划变更"误写成"当前现状";是否遗漏真实受影响的现有能力 |
| P2 | 文档内部一致性 | 对实际存在的 artifacts 检查是否互相支撑;在 `spec-driven` 下重点检查 `proposal.md``design.md``tasks.md``specs/*.md``Capabilities` / `Modified Capabilities` 是否完整;每个 capability 是否有对应 spec`tasks.md` 是否覆盖 `design.md``specs/*.md` |
| P3 | OpenSpec 合规性 | 对实际存在的 artifacts 检查是否遵循 OpenSpec 格式和术语;`specs/*.md` 是否只描述行为与约束、不混入实现细节;`tasks.md` 是否一行一个任务;是否混入 git 操作任务 |
分析时区分两类情况:
- 文档对当前代码现状的描述错误
- 文档描述的是预期变更,本来就应当与当前代码不同
重点识别:
- 讨论中已确定但文档未记录的内容
- 文档基于错误现状做出的设计或任务拆分
- 文档之间相互冲突的目标、方案、约束、任务
- `proposal -> specs -> design -> tasks` 链路中的断点
- `Modified Capabilities` 应更新但未更新的现有 spec
输出审查结果:
1. **问题总览表**:问题类型 × 涉及文档数
2. **讨论遗漏清单**:讨论已确定但文档未体现的内容;若缺少讨论记录,标记为"未审查"
3. **现实性问题清单**:与当前代码现状不符的描述、假设或影响分析
4. **文档冲突清单**proposal、design、tasks、specs 之间的不一致
5. **OpenSpec 规范问题清单**:格式、术语、结构问题
6. **待澄清清单**:仅靠讨论和代码仍无法判断的事项
7. **逐项分析**:每个问题说明位置、问题、影响、建议
8. **补充建议方案**:按文件列出建议补充/修正的内容、原因和可选方案
若所有清单均为空,输出"审查通过,未发现问题",跳至步骤 5。
## 3. 计划(用户确认)
先针对"待澄清清单"用提问工具逐项向用户确认。
再整理完整修复方案,按文件列出:
- 建议修改的文件
- 需要补充或修正的内容
- 修改原因
- 若存在分支方案,分别说明适用前提
用提问工具展示完整修复方案,获得用户确认后执行。
## 4. 执行
逐项修改已确认的变更文档,不修改源码。
若涉及删除或重写:
- 先创建备份文件 `{file}.bak.{timestamp}`
- 再执行修改
执行后重新读取所有被修改的文档,并复核:
- "讨论遗漏清单" 是否已清空或已标注保留原因
- "现实性问题清单" 是否已清空或已标注为预期变更
- "文档冲突清单" 和 "OpenSpec 规范问题清单" 是否已清空
## 5. 收尾
列出所有修改的文件、备份文件和变更摘要。
若本次因缺少讨论记录而降级执行,或有问题因信息不足暂未处理,单独说明。

View File

@@ -0,0 +1,595 @@
将所有 `dev*` 分支按计划合并到目标分支(默认 `main`),按准备→分析→计划→执行→清理执行;先探测当前仓库的规则、模块边界、验证命令和提交风格,再基于探测结果执行,避免写死仓库结构。
## 约束
- 全程仅使用 `git merge` 完成分支集成,禁止 `rebase`
- `git add` 仅允许明确文件路径,禁止 `git add .``git add -A`
- 不直接使用默认 `git pull`;同步目标分支时仅允许显式策略:`git fetch` + `git merge`,或 `git pull --no-rebase`
- `git reset --hard` 仅允许回到已记录的安全锚点 tag且执行前必须再次确认
- `git stash push``git stash apply``git stash drop``git revert``git branch -d``git push {remote} --delete {branch}` 执行前都必须再次确认
- 禁止自动推送代码;远端分支删除仅在步骤 5 获得确认后执行
- 冲突文件禁止 AI 自主决定写入内容
- 用户选择 `--ours``--theirs`、保留删除的一侧、保留重命名的一侧等机械方案后AI 可执行对应写入
- 凡涉及“双保留”“重组逻辑”“补写缺失代码”等内容生成,必须逐文件展示最终结果并再次获得确认后写入
- 步骤 3 的“确认执行”仅授权按计划执行正常 merge不授权自动执行 `stash``reset --hard``revert``drop`、删除分支、远端删除、冲突内容重写
- 合并提交与语义审查修复提交必须分离;语义审查修复允许拆成多条独立提交
- 若仓库文档定义了命令、包管理器、提交格式、目录规范,优先遵守文档;若未定义,再根据清单文件、脚本和近期提交历史推断
- 不把当前仓库的路径结构、技术栈、构建命令写死到流程里;一切按探测结果执行
- 信息展示按“概览 → 详情 → 原始数据”分层输出,避免一次性输出全部 diff
## 记录项
执行中持续维护以下记录,后续所有决策和总结都基于这些记录:
| 记录项 | 内容 |
| ----------------------------------- | --------------------------------------------------------------------------------------- |
| `session_timestamp` | 本次流程唯一时间戳 |
| `target` | 目标分支名 |
| `target_upstream` | 目标分支上游,如 `{remote}/{target}`;无则记为空 |
| `target_remote` | 从 `target_upstream` 解析出的远端名;无上游则优先使用默认远端,否则为空 |
| `repo_rules` | 从 README、CONTRIBUTING、开发文档、脚本、清单文件中识别出的规则 |
| `commit_style` | 仓库现有提交信息风格和默认合并/修复提交模板 |
| `module_map` | 按当前仓库结构推断出的模块边界、公共目录、配置目录、基础设施目录 |
| `validation_commands` | 按模块或作用域归纳出的 lint/build/test 命令 |
| `auto_stashes[]` | 本流程创建的 stash 列表,记录唯一 message、创建时 ref、创建顺序、后续是否恢复/保留 |
| `created_local_tracking_branches[]` | 为远端独有 `dev*` 分支创建的本地跟踪分支 |
| `global_tag` | 全局安全锚点 |
| `branch_tags[]` | 每个待合并分支对应的分支级安全锚点 |
| `analysis[]` | 每个分支的分析快照HEAD hash、提交数、变更文件、依赖、初始冲突预测、风险、语义审查模式 |
| `results[]` | 每个分支的最终状态:已合并、已跳过、已回退、失败原因、修复提交列表、验证结果 |
## 安全锚点
| 锚点 | 创建时机 | 用途 |
| ----------------------------------- | ---------------------------- | -------------------------------------------------------- |
| `pre-merge-backup-{timestamp}` | 步骤 1 完成目标分支准备后 | 全局回退点,恢复到本轮合并开始前的目标分支状态 |
| `merge-before-{branch}-{timestamp}` | 步骤 4 每个分支正式 merge 前 | 分支级回退点,回退当前分支的合并提交和其后的语义修复提交 |
除非用户明确要求“全部回退”或“放弃当前分支并回到分支级锚点”,否则不主动使用 `git reset --hard`
## 1. 准备
### 1.1 仓库规则探测
并行收集当前仓库的规则来源:
- 根目录文档:`README*``CONTRIBUTING*``DEVELOPMENT*``docs/**` 中与开发、构建、测试、提交流程有关的文档
- 常见任务入口:`Makefile``justfile``Taskfile.yml``package.json`、工作区配置、CI 文件、脚本目录
- 常见清单/锁文件:`package-lock.json``pnpm-lock.yaml``yarn.lock``bun.lock*``go.mod``Cargo.toml``pyproject.toml``pom.xml``build.gradle*`
- 近期提交:`git log --oneline -20`
探测目标:
| 项目 | 识别方式 | 记录结果 |
| ----------------- | ------------------------------------------------------------- | ---------------------------- |
| 包管理器/任务入口 | 文档、锁文件、任务文件、脚本 | 记录允许的执行方式和优先级 |
| 模块边界 | 顶层目录、工作区配置、语言清单文件、子项目 README | 记录 `module_map` |
| 公共/基础设施目录 | shared/common/lib/core/config/scripts/ci 等目录和根级配置文件 | 记录到 `module_map` |
| 验证命令 | 文档中的 lint/build/test 命令,或脚本中的标准任务 | 记录到 `validation_commands` |
| 提交风格 | 文档约束优先,否则看近期 `git log` | 记录 `commit_style` |
识别规则:
- 文档和脚本冲突时,以文档为准
- 文档缺失时,以仓库当前可见的任务入口和近期提交习惯为准
- 无法确定时,先在步骤 3 的计划表中展示“待确认规则”,由用户确认
### 1.2 初始化现场
- 执行 `git status --short --branch`,记录当前分支和工作区状态
- 执行 `git remote -v`,记录可用远端
- 用提问工具确认目标分支:`main` / `master` / `develop` / 用户自定义
### 1.3 处理非干净工作区
-`git status --porcelain` 非空,先展示变更文件概览,再用提问工具让用户选择:
- `stash 后继续`
- `终止`
- 用户选择 `stash 后继续` 后,执行 `git stash push --include-untracked -m smart-merge-{timestamp}-precheck-{n}`
- 记录 stash 唯一 message 和创建时 ref 到 `auto_stashes[]`
### 1.4 切换并校验目标分支
- 执行 `git checkout {target}`
- 若失败,使用提问工具让用户选择:
- `重新指定目标分支`
- `终止`
### 1.5 解析上游并同步远端引用
- 执行 `git for-each-ref --format='%(upstream:short)' refs/heads/{target}` 获取 `target_upstream`
- 若存在 `target_upstream`
- 解析 `target_remote`
- 执行 `git fetch {target_remote} --prune`
- 执行 `git rev-list --left-right --count {target}...{target_upstream}` 计算 ahead/behind
- 若不存在 `target_upstream`
- 若仓库存在默认远端,记为 `target_remote`
- 否则 `target_remote` 置空
目标分支同步决策:
| 状态 | 处理 |
| ---------------- | ----------------------------------------------------------------------------------------------------------------- |
| 无 upstream | 记录后继续,不自动同步 |
| 仅落后 upstream | 用提问工具选择:`快进同步` / `保持当前本地 HEAD` / `终止`;若选同步,执行 `git merge --ff-only {target_upstream}` |
| 仅领先 upstream | 记录后继续,不自动 push |
| 与 upstream 分叉 | 用提问工具选择:`保持当前本地 HEAD` / `终止`;不自动把 upstream merge 进 target |
### 1.6 收集候选分支
- 本地分支:`git branch --list 'dev*'`
-`target_remote` 非空,远端分支:`git branch -r --list '{target_remote}/dev*'`
- 过滤掉 `HEAD ->` 这类符号引用
- 计算“远端存在、本地不存在”的分支列表
对远端独有分支,不直接 `checkout`,改为先展示清单,再用提问工具选择:
- `全部创建本地跟踪分支`
- `选择部分创建`
- `仅使用已有本地分支`
- `终止`
创建本地跟踪分支使用:`git branch --track {local_branch} {remote}/{remote_branch}`
若本地已存在同名分支,使用提问工具逐项选择:
- `使用现有本地分支`
- `改名创建跟踪分支`
- `跳过该远端分支`
所有自动创建的本地跟踪分支都记录到 `created_local_tracking_branches[]`
### 1.7 无候选分支时直接结束
- 若最终没有任何 `dev*` 分支,输出概览:目标分支、工作区状态、探测到的仓库规则、是否创建过 stash、是否创建过本地跟踪分支
- 若本流程已创建 stash进入步骤 5 的“工作区恢复”
- 否则直接结束
### 1.8 创建全局安全锚点
- 执行 `git tag pre-merge-backup-{timestamp}`
- 记录为 `global_tag`
## 2. 分析
### 2.1 信息收集(并行)
对每个候选分支并行收集以下信息:
| 维度 | 命令/方式 | 结果 |
| -------- | ------------------------------------------------------------------ | -------------------------------------- |
| 基础 | `git rev-parse {branch}` | 记录分支 HEAD hash |
| 合并状态 | `git merge-base --is-ancestor {branch} {target}` | 判断该分支是否已完全被目标分支包含 |
| 提交范围 | `git log --oneline {target}..{branch}` | 记录独有提交数和提交消息,推断分支意图 |
| 变更范围 | `git diff --name-status {target}...{branch}` | 记录文件列表、状态、变更集中区域 |
| 行数统计 | `git diff --stat {target}...{branch}` | 估算改动体量 |
| 模块归属 | 按 `module_map` 归类;若未命中则按顶层目录或最近的语言清单文件归类 | 识别受影响模块 |
模块归类优先级:
1. 仓库文档明确声明的子项目、包、服务、应用、库
2. 工作区配置或语言清单文件定义的模块边界
3. 顶层目录边界
4. 根级共享文件、配置文件、脚本、CI 文件,统一归为 `shared/config/infra`
常见的 `shared/config/infra` 候选包括但不限于:
- 根目录配置文件、锁文件、工作区文件
- 构建脚本、部署脚本、CI 配置、容器配置、基础设施配置
- 公共库目录、共享类型目录、通用组件目录、公共工具目录
### 2.2 依赖判定
依赖同时从 ancestry 和文件重叠两个维度判断:
| 维度 | 方法 | 判定规则 |
| ------------- | ----------------------------------------------------- | ------------------------------------- |
| ancestry 依赖 | `git merge-base --is-ancestor {a} {b}` | 若 `a``b` 的祖先,记为 `b 依赖 a` |
| 文件重叠 | 比较各分支 `git diff --name-only {target}...{branch}` | 同一文件被多个分支修改,记为重叠 |
| 公共文件依赖 | 关注 `shared/config/infra` 范围和公共抽象 | 即使文件数少,也记为高优先级依赖 |
为每个分支输出:
- `depends_on[]`:它依赖哪些分支
- `blocks[]`:哪些分支依赖它
- `shared_files[]`:与其他分支重叠的文件
- `common_files[]`:被识别为公共/基础设施的文件
### 2.3 初始冲突预测(串行)
仅对“未合并且有实际差异”的分支串行执行:
1. 执行 `git merge --no-commit --no-ff {branch}`
2. 若工作区进入 merge 状态:
- 执行 `git diff --name-only --diff-filter=U` 记录冲突文件
- 若存在 `MERGE_HEAD`,执行 `git merge --abort`
3. 若命令输出为 `Already up to date`、无 `MERGE_HEAD`、无冲突文件,记为“已包含或无差异”,不执行 `git merge --abort`
4.`git merge --abort` 失败:
- 报告错误和当前状态
- 用提问工具让用户选择:
- `回到全局锚点后继续分析`:执行 `git reset --hard {global_tag}`
- `终止`
- `reset --hard` 失败则终止并提示用户手动处理
### 2.4 风险分级与排序
风险评级:
- 低:无预测冲突、无依赖、单一模块、小体量改动
- 中:存在依赖,或存在预测冲突,或跨两个以上模块,或触及公共文件
- 高:预测冲突文件 `>= 3`,或冲突占改动文件比例 `>= 30%`,或存在 ancestry 依赖且触及公共文件 / 根级配置 / 基础设施文件
初始排序规则:
1. 已合并 / 无差异(默认跳过,仅展示)
2. 被其他分支依赖的基础分支
3. 仅改公共/基础设施文件的分支
4. 独立、低风险分支
5. 存在依赖或共享文件的分支
6. 高风险、跨模块分支
### 2.5 验证命令归纳
根据步骤 1 探测结果,为后续语义修复和构建验证归纳命令:
| 作用域 | 优先级 |
| ---------------------- | --------------------------------------- |
| 模块级 lint/build/test | 优先使用模块文档或模块脚本中声明的命令 |
| 仓库级 lint/build/test | 若模块级命令缺失,使用仓库统一命令 |
| 无现成命令 | 记录为“无法自动验证”,步骤 3 显示给用户 |
归纳原则:
- 优先使用仓库文档明确写出的命令
- 其次使用标准任务入口中的现有命令
- 再其次使用就近模块的标准脚本
- 不自行发明新的构建或测试命令
### 2.6 分析结果汇总
为每个分支形成分析快照:
| 字段 | 内容 |
| ---------------------- | ---------------------------------- |
| `status` | 已合并 / 无差异 / 待合并 |
| `head` | 步骤 2 记录的 HEAD hash |
| `modules` | 受影响模块 |
| `files` | 改动文件数 |
| `depends_on` | 依赖分支 |
| `predicted_conflicts` | 初始冲突文件列表 |
| `risk` | 低 / 中 / 高 |
| `semantic_review_mode` | 默认 `仅报告` |
| `validation_scope` | 该分支后续优先使用的验证范围和命令 |
## 3. 计划(用户确认)
输出合并计划表,列至少包含:
| 分支 | 状态 | 模块 | 文件数 | 依赖 | 预测冲突 | 风险 | 语义审查 | 验证命令 |
| ---- | ---- | ---- | ------ | ---- | -------- | ---- | -------- | -------- |
默认规则:
- `已合并``无差异` 分支仅展示,不进入执行队列
- `待合并` 分支按步骤 2.4 的排序进入执行队列
- `语义审查` 默认值为 `仅报告`
- 若某分支缺少可自动执行的验证命令,在计划表中明确标记
同时展示仓库级探测结果摘要:
- 识别出的模块边界
- 公共/基础设施范围
- 识别出的验证命令
- 提交风格摘要
- 无法确定、需要用户补充的规则
用提问工具让用户选择:
- `确认执行`
- `调整顺序`
- `排除分支`
- `调整语义审查模式`
- `补充或修正规则`
- `重新分析全部分支`
- `终止`
语义审查模式仅允许三种:
- `关闭`
- `仅报告`
- `报告并修复`
若用户选择 `调整顺序``排除分支``调整语义审查模式``补充或修正规则``重新分析全部分支`,则更新计划表后再次确认,直到用户选择 `确认执行``终止`
最终确认后,记录最终执行队列。并明确提示:
- 正常 merge 已获授权
- 冲突内容重写、`stash``revert``reset --hard`、删除分支、远端删除仍需单独确认
## 4. 执行(顺序执行,冲突/异常时中断)
对执行队列中的分支依次处理。若队列为空,直接进入步骤 5。
### 4.1 单分支合并前检查
每个分支开始前都执行以下检查:
1. `git status --short --branch`,确认当前仍在 `{target}`
2. 若不在 `{target}`,执行 `git checkout {target}`;失败则中断并询问
3. 若工作区非干净,展示异常文件,并用提问工具让用户选择:
- `stash 后继续`
- `终止`
4. 若用户选择 `stash 后继续`,执行 `git stash push --include-untracked -m smart-merge-{timestamp}-runtime-{n}`,记录到 `auto_stashes[]`
5. 检查分支是否仍存在;不存在则记录为 `已跳过:分支不存在`
6. 执行 `git rev-parse {branch}`,对比步骤 2 记录的 `head`
7. 若 HEAD 已变化,先对该分支重新执行步骤 2 的单分支分析,再更新计划快照
### 4.2 基于当前 HEAD 的动态复核
步骤 2 的分析基于初始目标分支;每次实际 merge 前都必须基于“当前最新 HEAD”重新复核当前分支
1. 执行 `git diff --name-status HEAD...{branch}`,获取当前变更文件列表
2. 串行执行 `git merge --no-commit --no-ff {branch}` 做实时 dry-run
3. 若进入 merge 状态,收集 `git diff --name-only --diff-filter=U` 冲突文件后执行 `git merge --abort`
4. 若无 `MERGE_HEAD`,按实际结果记为“无冲突”或“已包含 / 无差异”
5. 将实时结果与步骤 3 的计划快照对比
视为“计划漂移”的情况:
- 冲突文件集合发生变化
- 改动文件数变化明显(以 `20%` 为阈值)
- 新出现公共文件 / 根级配置 / 基础设施文件
- 原本 `无冲突` 变成 `有冲突`
- 原本模块级验证命令不再覆盖当前改动范围
若发生计划漂移,用提问工具让用户选择:
- `按更新后的当前结果继续`
- `重新生成剩余分支计划`:对“当前分支 + 尚未处理的剩余分支”重新执行步骤 2再回到步骤 3
- `跳过当前分支`
- `终止`
### 4.3 生成提交模板并创建分支级锚点
先根据 `commit_style` 生成本轮使用的提交模板:
- 若仓库文档明确约束了提交格式,严格遵守
- 若文档未约束,但近期提交风格稳定,沿用仓库现有风格
- 若无法识别,合并提交使用 `chore: merge {branch} into {target}`,语义修复提交使用 `refactor: address post-merge issues in {branch}`
然后:
- 执行 `git tag merge-before-{branch}-{timestamp}`
- 记录到 `branch_tags[]`
### 4.4 正式合并
- 执行正式合并:`git merge --no-ff -m "{merge_commit_message}" {branch}`
正式合并结果分三类:
- 无冲突:进入步骤 4.6
- 有冲突:进入步骤 4.5
- 非冲突错误:展示错误信息,用提问工具选择:`跳过当前分支` / `终止`
### 4.5 冲突处理(中断点)
先执行 `git diff --name-only --diff-filter=U` 获取冲突文件清单,再按文件生成冲突决策面板:
| # | 文件 | 冲突类型 | HEAD 改动摘要 | 分支改动摘要 | 可批量机械处理 | 推荐方案 |
| --- | ---- | -------- | ------------- | ------------ | -------------- | -------- |
推荐方案规则:
- 仅当解决方案明确等价于 `--ours``--theirs`、保留删除的一侧、保留重命名的一侧时,才允许给出“可批量机械处理”的推荐
- 只要需要合并两边逻辑、补全缺失分支、重排代码顺序、解决同一函数 / 类 / 配置段双改,就标记为 `需逐文件确认`
- `需逐文件确认` 的文件不能被“全部按推荐方案”一键处理
用提问工具让用户选择:
- `处理全部机械型冲突`
- `审查部分文件`
- `放弃当前合并`
若用户选择 `审查部分文件`,先让用户输入文件编号,再对这些文件逐个展示:
- 上下文 diff
- 原始冲突块 `<<<<<<<` / `=======` / `>>>>>>>`
- 当前建议与风险说明
逐文件可选方案:
- `保留目标 (--ours)`
- `保留分支 (--theirs)`
- `AI 起草双保留结果`
- `用户手动编辑`
执行规则:
- `--ours` / `--theirs`:执行 `git checkout --ours/--theirs {file}`
- `AI 起草双保留结果`
- 先生成完整最终结果或统一 diff
- 说明具体合并逻辑
- 再次用提问工具确认后写入文件
- `用户手动编辑`:告知文件路径,等待用户回复“完成”后再执行 `git add {file}`
- 所有冲突文件都必须逐文件 `git add {file}`
- 执行 `git diff --name-only --diff-filter=U`,确认已无未解决冲突
- 再执行 `git commit --no-edit`
若用户选择 `放弃当前合并`
- 用提问工具确认是否执行 `git merge --abort`
-`abort` 成功,记录当前分支为 `已跳过:用户放弃合并`
-`abort` 失败,再用提问工具确认是否执行 `git reset --hard merge-before-{branch}-{timestamp}` 回到分支级锚点
### 4.6 语义审查
语义审查基于 `git diff merge-before-{branch}-{timestamp}..HEAD`
辅助数据源:
| 数据 | 获取方式 | 用途 |
| ------------- | -------------------------------------------------- | -------------------------------- |
| 本次合入 diff | `git diff merge-before-{branch}-{timestamp}..HEAD` | 查看本次实际引入的改动 |
| 分支意图 | 步骤 2 的 commit 消息 | 推断业务目的 |
| 主干近期趋势 | `git log --oneline {target} -20` 及相关 diff | 识别近期重构、迁移、废弃方向 |
| 公共文件现状 | 步骤 2 标记的 `common_files[]` | 判断是否重复造轮子或遗漏基础设施 |
| 仓库规则 | `repo_rules``module_map``validation_commands` | 判断是否偏离当前仓库约定 |
审查维度:
| 维度 | 检查点 |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| 代码冗余 | 新增函数、组件、类型、工具、配置是否已在仓库公共位置存在等价实现 |
| 过时模式 | 是否继续使用近期已迁移、废弃、替换的 API、脚本、配置项、目录模式 |
| 基础设施遗漏 | 新代码是否绕过既有公共抽象、统一错误处理、统一日志、统一配置、统一数据访问、统一请求封装等既有基础设施 |
| 风格不一致 | 命名、文件组织、测试组织、错误处理、注释风格是否偏离当前主干 |
| 模块边界破坏 | 是否把本应落在公共层、共享层、基础设施层的逻辑塞进业务分支目录 |
输出审查结果表:
| # | 类别 | 严重程度 | 文件 | 描述 | 建议修复方式 |
| --- | ---- | -------- | ---- | ---- | ------------ |
严重程度仅分两类:
- `问题`:有运行时风险、明显架构偏差或维护成本高
- `建议`:功能可用,但与主干一致性较差
按计划中的 `semantic_review_mode` 处理:
- `关闭`:跳过语义审查
- `仅报告`:展示结果后直接进入步骤 4.7
- `报告并修复`:展示结果后,用提问工具选择:`全部修复` / `选择修复` / `跳过`
修复执行规则:
1. 每个问题编号视为一个独立修复单元
2. 一次只处理一个编号,避免回退时互相污染
3. 每个修复单元只修改其必要文件,保持最小变更
4. 修复完成后,按 `validation_commands` 和当前改动作用域执行最小充分检查:
| 作用域 | 检查策略 |
| -------------------------- | -------------------------------------- |
| 仅单模块 | 优先执行该模块的 lint 或等价静态检查 |
| 多模块 | 执行覆盖这些模块的仓库级检查或组合检查 |
| 触及 `shared/config/infra` | 执行受影响范围内最严格的可用检查 |
| 无自动检查命令 | 记录为“未自动验证”,并在结果中明确说明 |
5. 若检查通过,立即提交独立修复 commitmessage 遵循 `commit_style`
6. 若检查失败,只回退当前修复单元的未提交修改:`git restore --staged --worktree --source=HEAD -- {files}`
7. 记录该编号为 `修复失败并已回退`,继续处理下一个编号
### 4.7 构建验证
根据当前分支实际引入的改动范围,从 `validation_commands` 中选择最小充分的构建 / 测试 / 检查命令:
| 改动范围 | 验证策略 |
| -------------------------- | -------------------------------------------- |
| 单模块业务改动 | 优先运行该模块的 build / test / lint 组合 |
| 多模块改动 | 运行覆盖这些模块的聚合命令 |
| 触及共享层、配置、基础设施 | 优先运行仓库级验证或覆盖共享层的更高等级验证 |
| 仓库没有可自动执行命令 | 记录为“无法自动验证”,并向用户说明 |
构建成功:
- 记录当前分支为 `已合并`
- 记录冲突处理方式、语义审查结果、修复提交列表、实际执行的验证命令
- 继续下一个分支
构建失败:
1. 展示失败命令和错误摘要
2. 若当前分支存在语义修复提交,用提问工具让用户选择:
- `依次 revert 当前分支的语义修复提交后重试验证`
- `放弃当前分支并回到分支级锚点`
- `终止`
3. 若用户选择 revert
- 按提交时间倒序执行 `git revert --no-edit {commit}`
- 每 revert 一条后重试验证,直到通过或全部 revert 完成
- 若 revert 冲突,立即中断并询问:`回到分支级锚点` / `保留冲突现场并终止`
4. 若无语义修复提交,或全部 revert 后验证仍失败,用提问工具让用户选择:
- `git reset --hard merge-before-{branch}-{timestamp}` 放弃当前分支
- `终止并保留当前现场`
5. 若用户确认回到分支级锚点,记录当前分支为 `已跳过:验证失败并回退`
## 5. 清理(用户确认)
所有分支处理完成后,输出汇总表:
| 项目 | 内容 |
| ---------- | ------------------------------------------------------------------------------------ |
| 目标分支 | `{target}` |
| 当前 HEAD | 当前提交 hash |
| 合并结果 | 成功 / 跳过 / 已合并 / 无差异 数量 |
| 冲突统计 | 冲突分支数、冲突文件数、机械处理数、逐文件确认数 |
| 语义审查 | 每个分支的问题数、建议数、修复成功数、修复失败数 |
| 实际验证 | 每个分支实际执行了哪些验证命令,哪些未验证 |
| 已创建资源 | `global_tag``branch_tags[]``auto_stashes[]``created_local_tracking_branches[]` |
| 候选删除 | 可删除的本地分支、远端分支、可删除 tag |
用提问工具一次性确认清理策略:
- `确认清理`
- `调整清理范围`
- `跳过清理`
清理项包括:
- 删除已成功合并的本地分支:`git branch -d {branch}`
- 删除已成功合并且用户确认的远端分支:`git push {remote} --delete {branch}`
- 删除用户明确不再保留的安全锚点 tag
- 删除本流程仅为分析/合并而创建、且最终不需要保留的本地跟踪分支
远端分支删除规则:
- 优先删除该分支自己的 upstream remote
- 若分支无 upstream但来自 `target_remote`,则删除 `target_remote/{branch}`
- 删除失败只记录错误,不影响其余清理项
### 工作区恢复
`auto_stashes[]` 非空,用提问工具让用户选择:
- `按顺序恢复全部 stash`
- `查看 stash 列表后选择恢复`
- `暂不恢复,保留 stash`
恢复时按后进先出顺序处理。由于 `stash@{n}` 会随列表变化而漂移,先根据已记录的唯一 message 在 `git stash list` 中定位当前 ref再对该 stash 执行 `git stash apply {ref}`,不直接 `pop`
1. `apply` 成功后,再询问是否 `git stash drop {ref}`
2.`apply` 冲突,展示 stash 冲突决策面板,逐文件选择:
- `保留 stash 内容`
- `保留当前内容`
- `AI 起草双保留结果`
- `用户手动编辑`
- `放弃恢复该 stash`
3. 冲突解决完成后,再询问是否 `drop` 对应 stash
输出最终总结:目标分支、当前 HEAD、探测到的仓库规则摘要、保留的锚点、各分支最终状态、未删除的远端分支、stash 恢复结果。
## 终止处理
任一步骤选择 `终止` 时,输出终止摘要:
| 项目 | 内容 |
| ---------- | ------------------------------------------------------------------------------------ |
| 当前步骤 | 准备 / 分析 / 计划 / 执行 / 清理 |
| 当前 HEAD | 当前提交 hash |
| 已完成分支 | 成功合并的分支 |
| 已跳过分支 | 分支及原因 |
| 未处理分支 | 剩余执行队列 |
| 已创建资源 | `global_tag``branch_tags[]``auto_stashes[]``created_local_tracking_branches[]` |
| 已识别规则 | `repo_rules``module_map``validation_commands` |
若终止时存在活动 merge 状态,再用提问工具让用户选择:
- `执行 git merge --abort 并结束`
- `回到当前分支级锚点并结束`
- `保留当前冲突现场,交给用户手动处理`
若终止时不存在活动 merge 状态,不自动变更现场;仅提示用户:
- 全部回退可使用 `git reset --hard {global_tag}`
- 当前分支级回退可使用 `git reset --hard merge-before-{branch}-{timestamp}`
- 本流程创建的 stash 和本地跟踪分支仍保留,是否恢复/删除由步骤 5 或用户后续手动决定

View File

@@ -0,0 +1,142 @@
请审查并整理 `openspec/specs/` 下的稳定规范,使其成为可搜索、边界清晰、无冗余、与当前业务一致的能力索引,按以下流程执行。
## 约束
- `openspec/specs/` 描述长期稳定的业务能力、规则和外部行为,不记录变更过程、迁移说明、实现路径、内部类型名、组件 props、样式数值、层级分层等实现细节
- 用户可感知或对外暴露的契约可以保留:公开 API 路径、请求/响应字段、协议名、错误码、数据约束、交互结果
- `Requirement``Scenario` 应描述业务能力、外部行为或稳定约束,不以“使用某层/某组件/某库实现”作为标题或核心表述
- 不把当前代码自动视为唯一真相若代码、README、现有 spec 冲突且无法判断应以哪边为准,列入待确认清单,不直接改写规范
- 仅删除内容已被其他规范完整覆盖且无独立检索价值的规范;非冗余内容仅迁移、合并、拆分或重命名
- 每批重构执行前用提问工具获得用户确认;删除或重写前先备份原文件为 `{file}.bak.{timestamp}`
- 命名、Purpose、Requirement 标题都必须保留用户下一次最可能搜索的业务关键词
## 1. 收集
并行读取:
- `openspec/config.yaml`
- `README.md`以及与模块结构、API、架构相关的 README 或文档
- `openspec/specs/*/spec.md`
默认不读取 `openspec/changes/**`、历史 proposal/design/tasks 作为稳定规范整理依据;仅在用户明确要求“连同历史变更一起校对”时再纳入。
先建立索引,不直接开始改写:
| 索引 | 内容 |
| -------------- | ----------------------------------------------------------------------------- |
| `spec_index[]` | 每个 spec 的目录名、Purpose、Requirement 摘要、关键词、外部契约、疑似重叠对象 |
| `domain_map[]` | 从 README、API、模块文档中提炼的核心业务域、横切能力和术语 |
| `term_map[]` | 同义词、旧名、缩写和推荐标准术语 |
| `suspects[]` | 需要进一步对照代码或测试确认的 spec |
仅对 `suspects[]` 做定向读取:
- 读取与该 spec 对应的源码、测试、README 或架构文档
- 不对 `backend/``frontend/` 做无差别逐文件扫描
判定依据优先级:
- 当前稳定 spec 与 README 共同支持的事实,可直接视为高置信度
- 仅代码可见但 README 和 spec 未体现的内容,先判断它是稳定外部行为还是临时实现细节
- 代码、README、现有 spec 互相冲突且无法自动定夺时,进入 `待确认清单`
## 2. 审查
按 spec、Requirement、Scenario 三层检查:
| 维度 | 检查点 |
| --------- | --------------------------------------------------------------------------------- |
| 过时 | 描述的能力、术语、外部契约是否仍成立;是否存在 `TBD``TODO`、占位说明 |
| 冲突 | 不同规范是否对同一行为给出不同约束、命名或边界 |
| 重复/重叠 | 是否在文件级、Requirement 级、Scenario 级重复描述同一能力 |
| 错位 | 内容是否放错能力域;横切规则是否混入实体规范;平台实现是否混入通用能力规范 |
| 粒度 | 是否过大导致难检索,或过碎导致回答一个问题必须同时打开多个 spec |
| 术语 | 同一概念是否混用多个名字;旧名、别名、缩写是否需要归一并保留检索入口 |
| 命名/检索 | 目录名、Purpose、Requirement 标题是否准确;是否能被 README、API、业务术语直接命中 |
| 规范性 | 是否使用 SHALL/WHEN/THEN是否混入变更记录、迁移说明、内部实现或 UI/代码细节 |
| 完整性 | Purpose 是否明确;是否存在空目录、非 spec 噪音文件、无清晰归属的孤立规范 |
重构判定规则:
- 若两个 spec 回答的是同一个核心问题,或其中一个只是另一个的子集,优先合并
- 若一个 spec 混合多个独立检索意图,或同时包含横切规则与业务流程,优先拆分
- 若内容正确但目录名、Purpose 或 Requirement 标题不利于检索,优先重命名或改写标题
- 若多个术语指向同一概念,统一到一个标准术语,并在 Purpose 或 Requirement 中保留必要别名以支持搜索
- 若某段内容只是内部实现细节,且不影响外部行为理解,删除该段而不是为其单独保留 spec
- 若某个具体值同时属于外部契约与内部实现,按“是否对调用方可见、是否影响兼容性”判断是否保留
### 命名约定
命名优先复用仓库已存在的稳定术语,如 `provider``model``stats``protocol``proxy``logging``validation``migration``frontend``desktop``mysql`
| 类型 | 模式 | 示例 |
| ------------ | ---------------------------------------------------------- | -------------------------------------------------- |
| 实体生命周期 | `{entity}-management` | `provider-management``model-management` |
| 横切能力 | `{concern}``{concern}-{qualifier}` | `error-handling``structured-logging` |
| 协议/适配 | `{protocol}-{capability}``protocol-adapter-{protocol}` | `openai-protocol-proxy``protocol-adapter-openai` |
| 运行面/入口 | `{surface}``{surface}-{capability}` | `frontend``desktop-app` |
| 基础设施 | `{resource}-{operation}` | `database-migration``mysql-driver` |
命名原则:
- 1-4 个词,保持单一主题
- 优先使用业务名词,不使用 `basic``general``misc``info``data` 等泛化词
- 不使用 `crud``list``table``display` 等实现模式词,除非它本身就是外部契约的一部分
- 同一主题的命名模式保持一致,不同时混用多套前后缀
## 3. 报告
输出分析结果:
1. **问题总览表**:问题类型 × 涉及规范数
2. **规范关系表**:每个 spec 的主主题、重叠对象、冲突对象、建议动作
3. **术语归一表**:旧术语 / 别名 / 缩写 → 推荐标准术语
4. **逐项分析**:每个有问题的规范说明位置、问题、影响、建议和目标规范
5. **待确认清单**代码、README、现有 spec 冲突且无法自动定夺的事项
6. **重构方案**:按优先级分批
7. **重构后目录结构**:预期的新 `openspec/specs/` 目录树
优先级建议:
- P0删除空目录、非 spec 噪音文件、占位内容
- P1删除完全冗余规范将其内容映射到主规范
- P2合并重复/子集规范;拆分错位或过大规范
- P3重命名目录、改写 Purpose 和 Requirement 标题以提升检索性
- P4修正过时描述清理实现细节、迁移说明和变更记录
若所有问题清单为空,输出“审查通过,未发现问题”,跳至步骤 5。
## 4. 计划(用户确认)
先针对 `待确认清单` 用提问工具逐项向用户确认。
再按批次展示完整重构计划,每批必须包含:
- 操作类型:删除、重命名、迁移、合并、拆分、改写
- 路径变化:源路径 → 目标路径
- 内容映射:源 spec 的 Requirement / Scenario 将迁移到哪里
- 术语处理:哪些旧词保留为检索入口,哪些词统一替换
- 执行原因:为什么这样做更利于检索、去重和边界清晰
- 验证方式:如何确认没有丢失约束或引入新的冲突
用提问工具获得当前批次确认后再执行。
## 5. 执行
按 P0 → P4 逐批执行已确认的重构。
执行要求:
- 合并或拆分时先写目标 spec再删除或重命名源 spec
- 删除前确认其 Requirement 和 Scenario 已被完整保留、迁移或判定为纯冗余
- 每批执行后重新读取受影响的 spec并复核结构和内容
每批执行后至少验证:
- 目录结构完整,`openspec/specs/*/spec.md` 可正常读取
- 不存在未承接的 Requirement 或 Scenario
- Purpose、Requirement 标题、目录名可以直接表达主能力
- 不再包含 `TBD`、变更记录、迁移说明、内部实现细节或噪音文件
- 若本批涉及代码对照项,相关外部契约描述与当前仓库现状一致,或已列入残留待确认
收尾时输出:修改文件清单、备份文件清单、最终目录树、残留待确认事项和整理摘要。

93
eslint.config.js Normal file
View File

@@ -0,0 +1,93 @@
import js from "@eslint/js";
import importPlugin from "eslint-plugin-import";
import perfectionist from "eslint-plugin-perfectionist";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"node_modules/**",
"dist/**",
".build/**",
"*.bun-build",
"openspec/**",
".opencode/**",
".claude/**",
".codex/**",
".agents/**",
"bun.lock",
"data/**",
],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
importPlugin.flatConfigs.recommended,
importPlugin.flatConfigs.typescript,
perfectionist.configs["recommended-natural"],
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
settings: {
"import/resolver": { node: true, typescript: true },
},
},
{
rules: {
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/only-throw-error": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"import/no-unresolved": ["error", { ignore: ["^bun:"] }],
"no-undef": "off",
},
},
{
files: ["eslint.config.js"],
rules: {
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
},
},
{
files: ["src/web/**/*.{ts,tsx}"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
"../server/*",
"../server/**",
"../**/server/*",
"../**/server/**",
"../../server/*",
"../../server/**",
"src/server/*",
"src/server/**",
],
message: "前端不得导入 src/server 后端运行时实现;请改用 src/shared 类型或 HTTP API。",
},
],
},
],
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
eslintPluginPrettierRecommended,
);

10
opencode.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"tdesign-mcp-server": {
"enabled": true,
"type": "local",
"command": ["bunx", "tdesign-mcp-server@latest"]
}
}
}

31
openspec/config.yaml Normal file
View File

@@ -0,0 +1,31 @@
schema: spec-driven
context: |
- 使用中文(注释、文档、交流),面向中文开发者
- openspec文档的关键字按openspec规范使用不要翻译为中文
- **优先阅读README.md和DEVELOPMENT.md**获取项目概览与开发规范所有代码风格、命名、注解、依赖、API等规范以DEVELOPMENT.md为准
- 涉及模块结构、API、实体等变更时同步更新README.md
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- 后端库使用优先级Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行
- 优先使用提问工具对用户进行提问
- 本项目为 Bun 全栈应用模板es-toolkit 为后端首选工具库、recharts 为前端首选图表库
rules:
proposal:
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
design:
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
tasks:
- 一行一个任务,严禁任务内容跨行
- 如果是代码存在更新必须
- 执行完整的测试、代码检查、格式检查等质量保障手段
- 更新 README.md 和/或 DEVELOPMENT.md

55
package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "{{app-name}}",
"type": "module",
"private": true,
"scripts": {
"dev": "bun run scripts/dev.ts",
"dev:server": "bun --watch src/server/dev.ts",
"dev:web": "bunx --bun vite --host",
"build": "bun run scripts/build.ts",
"lint": "eslint .",
"format": "prettier . --write",
"check": "bun run typecheck && bun run lint && bun test",
"verify": "bun run check && bun run build",
"test": "bun test",
"clean": "bun run scripts/clean.ts",
"typecheck": "tsc --noEmit",
"prepare": "husky"
},
"devDependencies": {
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@eslint/js": "^10.0.1",
"@tanstack/react-query-devtools": "^5.100.10",
"@testing-library/react": "^16.3.2",
"@types/bun": "^1.3.14",
"@types/jsdom": "^28.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.4",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13"
},
"dependencies": {
"@tanstack/react-query": "^5.100.10",
"es-toolkit": "^1.46.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9"
}
}

155
scripts/build.ts Normal file
View File

@@ -0,0 +1,155 @@
import { readdir, rm, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const distWebDir = join(projectRoot, "dist/web");
const buildDir = join(projectRoot, ".build");
const executablePath = join(projectRoot, "dist/{{app-name}}");
async function build() {
try {
await viteBuild();
await codeGeneration();
await bunCompile();
await cleanup();
console.log(`Built executable: ${executablePath}`);
} catch (error) {
await cleanup();
console.error("Build failed:", error);
process.exit(1);
}
}
async function bunCompile() {
console.log("Step 3/3: Bun compile...");
await rm(executablePath, { force: true });
const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"];
const result = await Bun.build({
compile: target
? {
autoloadBunfig: true,
autoloadDotenv: true,
outfile: executablePath,
target: target as Bun.Build.CompileTarget,
}
: {
autoloadBunfig: true,
autoloadDotenv: true,
outfile: executablePath,
},
entrypoints: [join(buildDir, "server-entry.ts")],
minify: true,
sourcemap: "linked",
});
if (!result.success) {
console.error("Bun compile failed:", result.logs);
await cleanup();
process.exit(1);
}
}
async function cleanup() {
await rm(buildDir, { force: true, recursive: true });
}
async function codeGeneration() {
console.log("Step 2/3: Code generation...");
await rm(buildDir, { force: true, recursive: true });
await Bun.write(join(buildDir, ".gitkeep"), "");
const allFiles = await scanDir(distWebDir, "/");
const importLines: string[] = [];
const fileEntries: string[] = [];
let indexHtmlVar = "";
for (let i = 0; i < allFiles.length; i++) {
const urlPath = allFiles[i]!;
const varName = `f${i}`;
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
if (urlPath === "/index.html") {
indexHtmlVar = varName;
} else {
fileEntries.push(` "${urlPath}": Bun.file(${varName}),`);
}
}
if (!indexHtmlVar) {
console.error("index.html not found in dist/web/");
process.exit(1);
}
const staticAssetsTs = [
`import type { StaticAssets } from "../src/server/static";`,
"",
...importLines,
"",
`export const staticAssets: StaticAssets = {`,
` files: {`,
...fileEntries,
` },`,
` indexHtml: Bun.file(${indexHtmlVar}),`,
`};`,
"",
].join("\n");
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { parseRuntimeArgs } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`async function main() {`,
` const { configPath } = parseRuntimeArgs();`,
` await bootstrap({ configPath, mode: "production", staticAssets });`,
`}`,
"",
`void main().catch((error) => {`,
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
}
async function scanDir(dir: string, prefix: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const paths: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const urlPath = `${prefix}${entry.name}`;
if (entry.isDirectory()) {
paths.push(...(await scanDir(fullPath, `${urlPath}/`)));
} else {
paths.push(urlPath);
}
}
return paths;
}
function toImportSpecifier(fromDir: string, targetPath: string) {
return relative(fromDir, targetPath).split(sep).join("/");
}
async function viteBuild() {
console.log("Step 1/3: Vite build...");
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
cwd: projectRoot,
stderr: "inherit",
stdout: "inherit",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.error("Vite build failed");
process.exit(1);
}
}
await build();

29
scripts/clean.ts Normal file
View File

@@ -0,0 +1,29 @@
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
const root = resolve(import.meta.dir, "..");
const dirs: Array<{ desc: string; path: string }> = [
{ desc: "构建产物", path: "dist" },
{ desc: "Bun 构建缓存", path: ".build" },
{ desc: "Playwright 测试报告", path: "playwright-report" },
{ desc: "测试结果", path: "test-results" },
];
const filePatterns: Array<{ desc: string; glob: string }> = [{ desc: "Bun 构建临时文件", glob: ".*.bun-build" }];
for (const { desc, path } of dirs) {
const full = resolve(root, path);
await rm(full, { force: true, recursive: true });
console.log(`已清理 ${desc}: ${path}`);
}
for (const { desc, glob } of filePatterns) {
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true }));
if (entries.length === 0) continue;
for (const entry of entries) {
const full = resolve(root, entry);
await rm(full, { force: true, recursive: true });
console.log(`已清理 ${desc}: ${entry}`);
}
}

26
scripts/dev.ts Normal file
View File

@@ -0,0 +1,26 @@
import { fileURLToPath } from "node:url";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const apiServer = Bun.spawn(["bun", "--watch", "src/server/dev.ts", ...process.argv.slice(2)], {
cwd: projectRoot,
stderr: "inherit",
stdout: "inherit",
});
const viteServer = Bun.spawn(["bunx", "--bun", "vite", "--host"], {
cwd: projectRoot,
stderr: "inherit",
stdout: "inherit",
});
function shutdown() {
apiServer.kill();
viteServer.kill();
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
await Promise.race([apiServer.exited, viteServer.exited]);
shutdown();

46
src/server/bootstrap.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { RuntimeMode } from "../shared/api";
import type { ServerConfig } from "./config";
import type { StartServerOptions } from "./server";
import { loadServerConfig } from "./config";
import { startServer } from "./server";
export interface BootstrapDependencies {
loadConfig?: (configPath?: string) => Promise<ServerConfig>;
logError?: (...data: unknown[]) => void;
onSignal?: (signal: "SIGINT" | "SIGTERM", handler: () => void) => void;
startServer?: (options: StartServerOptions) => unknown;
}
export interface BootstrapOptions {
config?: ServerConfig;
configPath?: string;
mode: RuntimeMode;
staticAssets?: StartServerOptions["staticAssets"];
}
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
const load = dependencies.loadConfig ?? loadServerConfig;
const serve = dependencies.startServer ?? startServer;
const onSignal =
dependencies.onSignal ??
((signal: "SIGINT" | "SIGTERM", handler: () => void) => {
process.on(signal, handler);
});
const logError = dependencies.logError ?? console.error;
try {
const config = options.config ?? (await load(options.configPath));
const shutdown = () => {
process.exit(0);
};
onSignal("SIGINT", shutdown);
onSignal("SIGTERM", shutdown);
serve({ config, mode: options.mode, staticAssets: options.staticAssets });
} catch (error) {
logError("启动失败:", error instanceof Error ? error.message : error);
process.exit(1);
}
}

51
src/server/config.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface ServerConfig {
host: string;
port: number;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
interface YAMLConfigFile {
server?: YAMLServerBlock;
}
interface YAMLServerBlock {
host?: string;
port?: number;
}
export async function loadServerConfig(configPath?: string): Promise<ServerConfig> {
const fileConfig: { host?: string; port?: number } = {};
if (configPath) {
const file = Bun.file(configPath);
if (!(await file.exists())) {
throw new Error(`配置文件不存在: ${configPath}`);
}
const content = await file.text();
const parsed = Bun.YAML.parse(content) as YAMLConfigFile;
if (parsed.server) {
if (parsed.server.host !== undefined) fileConfig.host = parsed.server.host;
if (parsed.server.port !== undefined) fileConfig.port = parsed.server.port;
}
}
const envPortNum = parseInt(process.env["PORT"] ?? "", 10);
return {
host: process.env["HOST"] ?? fileConfig.host ?? DEFAULT_HOST,
port: !isNaN(envPortNum) ? envPortNum : (fileConfig.port ?? DEFAULT_PORT),
};
}
export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath?: string } {
if (argv.length === 0) return {};
const firstArg = argv[0];
if (firstArg === "--help" || firstArg === "-h") {
console.log("用法: {{app-name}} [config.yaml]");
console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)");
process.exit(0);
}
return { configPath: firstArg };
}

12
src/server/dev.ts Normal file
View File

@@ -0,0 +1,12 @@
import { bootstrap } from "./bootstrap";
import { parseRuntimeArgs } from "./config";
async function main() {
const { configPath } = parseRuntimeArgs();
await bootstrap({ configPath, mode: "development" });
}
void main().catch((error) => {
console.error("启动失败:", error instanceof Error ? error.message : error);
process.exit(1);
});

45
src/server/helpers.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api";
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
const headers = new Headers(init);
if (mode === "production") {
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
}
return headers;
}
export function createHealthResponse(): HealthResponse {
return {
ok: true,
service: "{{app-name}}",
timestamp: new Date().toISOString(),
};
}
export function formatDuration(ms: number): string {
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
return `${ms}ms`;
}
export function jsonResponse(
body: unknown,
options: { headers?: HeadersInit; mode: RuntimeMode; status?: number },
): Response {
const headers = createHeaders(options.mode, {
"Content-Type": "application/json; charset=utf-8",
...options.headers,
});
return new Response(JSON.stringify(body), {
headers,
status: options.status,
});
}

12
src/server/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { bootstrap } from "./bootstrap";
import { parseRuntimeArgs } from "./config";
async function main() {
const { configPath } = parseRuntimeArgs();
await bootstrap({ configPath, mode: "production" });
}
void main().catch((error) => {
console.error("启动失败:", error instanceof Error ? error.message : error);
process.exit(1);
});

63
src/server/middleware.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { RuntimeMode } from "../shared/api";
import { createApiError, jsonResponse } from "./helpers";
const MAX_PAGE_SIZE = 200;
export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
return jsonResponse(createApiError("Invalid ID parameter", 400), { mode, status: 400 });
}
return { id: idStr };
}
export function validatePagination(
pageParam: null | string,
pageSizeParam: null | string,
mode: RuntimeMode,
): Response | { page: number; pageSize: number } {
let page = 1;
let pageSize = 20;
if (pageParam !== null) {
page = Number(pageParam);
if (!Number.isInteger(page) || page <= 0) {
return jsonResponse(createApiError("Invalid page parameter", 400), { mode, status: 400 });
}
}
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
}
if (pageSize > MAX_PAGE_SIZE) {
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
}
}
return { page, pageSize };
}
export function validateTimeRange(
from: null | string,
to: null | string,
mode: RuntimeMode,
): Response | { from: string; to: string } {
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
}
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
}
if (fromDate.getTime() > toDate.getTime()) {
return jsonResponse(createApiError("from must be earlier than to", 400), { mode, status: 400 });
}
return { from: fromDate.toISOString(), to: toDate.toISOString() };
}

View File

@@ -0,0 +1,7 @@
import type { RuntimeMode } from "../../shared/api";
import { createHealthResponse, jsonResponse } from "../helpers";
export function handleHealth(mode: RuntimeMode): Response {
return jsonResponse(createHealthResponse(), { mode });
}

38
src/server/server.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { RuntimeMode } from "../shared/api";
import type { ServerConfig } from "./config";
import type { StaticAssets } from "./static";
import { createApiError, jsonResponse } from "./helpers";
import { handleHealth } from "./routes/health";
import { serveStaticAsset } from "./static";
export interface StartServerOptions {
config: ServerConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
export function startServer(options: StartServerOptions) {
const { config, mode, staticAssets } = options;
const server = Bun.serve({
fetch(req) {
if (staticAssets) {
return serveStaticAsset(new URL(req.url).pathname, staticAssets);
}
return new Response("Frontend is served by Vite dev server on :5173", { status: 404 });
},
hostname: config.host,
port: config.port,
routes: {
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
"/health": {
GET: () => handleHealth(mode),
},
},
});
console.log(`{{app-name}} listening on ${server.url}`);
return server;
}

60
src/server/static.ts Normal file
View File

@@ -0,0 +1,60 @@
export interface StaticAssets {
files: Record<string, Blob>;
indexHtml: Blob;
}
const CONTENT_TYPES: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".woff": "font/woff",
".woff2": "font/woff2",
};
export function contentTypeFor(path: string): string {
const dot = path.lastIndexOf(".");
if (dot === -1) return "application/octet-stream";
const ext = path.slice(dot);
return CONTENT_TYPES[ext] ?? "application/octet-stream";
}
export function hasFileExtension(path: string): boolean {
const lastSlash = path.lastIndexOf("/");
const segment = lastSlash === -1 ? path : path.slice(lastSlash + 1);
return segment.includes(".");
}
export function htmlResponse(html: Blob): Response {
return new Response(html, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/html; charset=utf-8",
},
});
}
export function serveStaticAsset(pathname: string, assets: StaticAssets): Response {
if (pathname === "/") {
return htmlResponse(assets.indexHtml);
}
const file = assets.files[pathname];
if (file) {
return new Response(file, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": contentTypeFor(pathname),
},
});
}
if (hasFileExtension(pathname)) {
return new Response("Not found", { status: 404 });
}
return htmlResponse(assets.indexHtml);
}

17
src/shared/api.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface ApiErrorResponse {
error: string;
status: number;
}
export interface HealthResponse {
ok: true;
service: string;
timestamp: string;
}
export type RuntimeMode = "development" | "production" | "test";
// ==========================================
// 在此定义你的业务类型
// 前后端共享的类型都放在这个文件中
// ==========================================

67
src/web/app.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { useQuery } from "@tanstack/react-query";
import { Layout, Menu, RadioGroup, Space } from "tdesign-react";
import type { HealthResponse } from "../shared/api";
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
const { Content, Header } = Layout;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
{ label: "黑暗", value: "dark" },
] as const;
export function App() {
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { data: health } = useQuery({
queryFn: fetchHealth,
queryKey: ["health"],
refetchInterval: 30000,
staleTime: 5000,
});
const handleThemeChange = (value: ThemePreference) => {
setThemePreference(value);
};
return (
<Layout className="dashboard">
<Header>
<Menu.HeadMenu
logo={
<span className="dashboard-brand">
<span className="dashboard-logo">{"{{app-name}}"}</span>
</span>
}
operations={
<div className="dashboard-header-controls">
<RadioGroup
onChange={handleThemeChange}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
theme="button"
value={themePreference}
variant="default-filled"
/>
</div>
}
/>
</Header>
<Content>
<div className="dashboard-content">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<h2>使 {"{{app-name}}"}</h2>
<p> /health API </p>
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
</Space>
</div>
</Content>
</Layout>
);
}
async function fetchHealth(): Promise<HealthResponse> {
const response = await fetch("/health");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<HealthResponse>;
}

View File

@@ -0,0 +1,38 @@
import type { ErrorInfo, ReactNode } from "react";
import { Component } from "react";
import { Alert, Button, Space } from "tdesign-react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
override state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("渲染错误:", error, info.componentStack);
}
override render() {
if (this.state.hasError) {
return (
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">
<Alert message="页面渲染出现异常,请刷新重试" theme="error" title="页面出错" />
<Button onClick={() => window.location.reload()} theme="primary">
</Button>
</Space>
);
}
return this.props.children;
}
}

1
src/web/css.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
export type EffectiveTheme = "dark" | "light";
export type ThemePreference = "dark" | "light" | "system";
export const THEME_PREFERENCE_STORAGE_KEY = "{{app-name}}.theme.preference";
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
export function applyInitialThemePreference() {
applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark()));
}
export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) {
root.setAttribute("theme-mode", theme);
}
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
try {
return matchMedia(THEME_MEDIA_QUERY).matches;
} catch {
return false;
}
}
export function parseThemePreference(value: unknown): ThemePreference {
return value === "dark" || value === "light" || value === "system" ? value : "system";
}
export function readThemePreference(storage: Storage = window.localStorage): ThemePreference {
try {
return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY));
} catch {
return "system";
}
}
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
if (preference === "dark" || preference === "light") return preference;
return systemPrefersDark ? "dark" : "light";
}
export function useThemePreference() {
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
useEffect(() => {
applyThemeMode(effectiveTheme);
}, [effectiveTheme]);
useEffect(() => {
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
mediaQueryList.addEventListener("change", handleChange);
return () => mediaQueryList.removeEventListener("change", handleChange);
}, []);
const setPreference = (nextPreference: ThemePreference) => {
setPreferenceState(nextPreference);
writeThemePreference(nextPreference);
};
return { effectiveTheme, preference, setPreference };
}
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
try {
storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
} catch {
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
}
}

13
src/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{app-name}}" />
<title>{{app-name}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

41
src/web/main.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
import "tdesign-react/dist/reset.css";
import "tdesign-react/dist/tdesign.min.css";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
retry: 1,
staleTime: 5000,
},
},
});
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("找不到前端挂载节点 #root");
}
applyInitialThemePreference();
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>,
);

65
src/web/styles.css Normal file
View File

@@ -0,0 +1,65 @@
:root {
--td-brand-color: var(--td-brand-color-7);
}
.dashboard {
min-height: 100vh;
background: var(--td-bg-color-page);
width: 100%;
}
.dashboard-content {
box-sizing: border-box;
max-width: 1400px;
margin: 0 auto;
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
width: 100%;
}
.dashboard-brand {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: var(--td-comp-margin-s);
line-height: 1.2;
}
.dashboard-logo {
margin: 0;
color: var(--td-text-color-primary);
font-size: calc(var(--td-font-size-title-large) + 6px);
font-weight: 700;
}
.dashboard-header-controls {
display: inline-flex;
align-items: center;
gap: var(--td-comp-margin-s);
margin-right: var(--td-comp-margin-xxl);
}
.health-response {
background: var(--td-bg-color-component);
border-radius: var(--td-radius-default);
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
font-size: var(--td-font-size-body-medium);
color: var(--td-text-color-primary);
overflow-x: auto;
}
.error-boundary-fallback {
padding-top: 20vh;
width: 100%;
}
.full-width {
width: 100%;
}
.text-disabled {
color: var(--td-text-color-disabled);
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}

46
src/web/utils/time.ts Normal file
View File

@@ -0,0 +1,46 @@
export function formatCountdown(seconds: number): string {
if (seconds < 60) return `${seconds}`;
return `${Math.floor(seconds / 60)}${seconds % 60}`;
}
export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
if (ms === null) return { suffix: "", value: 0 };
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };
if (ms < 3600000) return { suffix: "分钟", value: roundToOne(ms / 60000) };
return { suffix: "小时", value: roundToOne(ms / 3600000) };
}
export function formatRelativeTime(timestamp: null | string, now = new Date()): string {
if (!timestamp) return "尚无检查数据";
const time = new Date(timestamp).getTime();
if (Number.isNaN(time)) return "尚无检查数据";
const diffSeconds = Math.max(0, Math.floor((now.getTime() - time) / 1000));
if (diffSeconds < 60) return `${diffSeconds}秒前`;
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}小时前`;
return `${Math.floor(diffHours / 24)}天前`;
}
export function isOlderThan(timestamp: null | string, ageMs: number, now = new Date()): boolean {
if (!timestamp) return false;
const time = new Date(timestamp).getTime();
if (Number.isNaN(time)) return false;
return now.getTime() - time > ageMs;
}
export function subtractHours(date: Date, hours: number): Date {
const result = new Date(date);
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
return result;
}
function roundToOne(value: number): number {
return Math.round(value * 10) / 10;
}

13
tests/helpers.ts Normal file
View File

@@ -0,0 +1,13 @@
import { rm } from "node:fs/promises";
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
for (let i = 0; i < retries; i++) {
try {
await rm(dir, { force: true, recursive: true });
return;
} catch (e) {
if (i === retries - 1) throw e;
await new Promise((r) => setTimeout(r, delayMs));
}
}
}

View File

@@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/require-await, @typescript-eslint/unbound-method */
import { describe, expect, test } from "bun:test";
import type { StartServerOptions } from "../../src/server/server";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
const origExit = process.exit;
describe("bootstrap", () => {
test("使用默认依赖启动", async () => {
let started = false;
let signalRegistered = false;
const mockLoadConfig = (async () => ({
host: "127.0.0.1",
port: 0,
})) as unknown as BootstrapDependencies["loadConfig"];
const mockLogError = () => {};
const mockOnSignal = (_signal: string, _handler: () => void) => {
signalRegistered = true;
};
const mockStartServer = (_options: StartServerOptions) => {
started = true;
return {};
};
const deps: BootstrapDependencies = {
loadConfig: mockLoadConfig,
logError: mockLogError,
onSignal: mockOnSignal,
startServer: mockStartServer,
};
await bootstrap({ mode: "production" }, deps);
expect(started).toBe(true);
expect(signalRegistered).toBe(true);
});
test("启动失败时调用 logError", async () => {
let errorLogged = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
process.exit = ((code?: number) => {
throw new Error("process.exit called");
}) as unknown as typeof process.exit;
const deps: BootstrapDependencies = {
loadConfig: async () => {
throw new Error("test config error");
},
logError: () => {
errorLogged = true;
},
startServer: () => {
throw new Error("should not reach");
},
};
try {
await bootstrap({ mode: "production" }, deps);
} catch {
// process.exit throws to interrupt flow
}
process.exit = origExit;
expect(errorLogged).toBe(true);
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import { rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadServerConfig, parseRuntimeArgs } from "../../src/server/config";
describe("parseRuntimeArgs", () => {
test("无参数返回空对象", () => {
const result = parseRuntimeArgs([]);
expect(result).toEqual({});
});
test("有参数返回 configPath", () => {
const result = parseRuntimeArgs(["config.yaml"]);
expect(result).toEqual({ configPath: "config.yaml" });
});
});
describe("loadServerConfig", () => {
test("无 configPath 使用默认值", async () => {
const config = await loadServerConfig();
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
});
test("环境变量 HOST 覆盖默认值", async () => {
const prev = process.env["HOST"];
process.env["HOST"] = "0.0.0.0";
try {
const config = await loadServerConfig();
expect(config.host).toBe("0.0.0.0");
} finally {
if (prev === undefined) {
delete process.env["HOST"];
} else {
process.env["HOST"] = prev;
}
}
});
test("环境变量 PORT 覆盖默认值", async () => {
const prev = process.env["PORT"];
process.env["PORT"] = "8080";
try {
const config = await loadServerConfig();
expect(config.port).toBe(8080);
} finally {
if (prev === undefined) {
delete process.env["PORT"];
} else {
process.env["PORT"] = prev;
}
}
});
test("YAML 配置文件不存在时报错", async () => {
try {
await loadServerConfig("/nonexistent/path/config.yaml");
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("配置文件不存在");
}
});
test("YAML 配置文件加载 server 配置", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "test-config.yaml");
const yamlContent = 'server:\n host: "0.0.0.0"\n port: 9999\n';
await writeFile(yamlPath, yamlContent);
try {
const config = await loadServerConfig(yamlPath);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(9999);
} finally {
await rm(yamlPath, { force: true });
}
});
test("YAML 缺少 server 字段时使用默认值", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "test-empty.yaml");
const yamlContent = "runtime:\n debug: true\n";
await writeFile(yamlPath, yamlContent);
try {
const config = await loadServerConfig(yamlPath);
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
} finally {
await rm(yamlPath, { force: true });
}
});
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { validateIdParam, validatePagination, validateTimeRange } from "../../src/server/middleware";
describe("validateIdParam", () => {
test("有效的 ID 返回字符串", () => {
const result = validateIdParam("api-health_01", "production");
expect(result).not.toHaveProperty("status");
expect((result as { id: string }).id).toBe("api-health_01");
});
test("无效的 ID 返回 400", () => {
const invalid = ["-1", "_abc", "has space", "1.5", ""];
for (const id of invalid) {
const result = validateIdParam(id, "production");
expect(result).toHaveProperty("status", 400);
}
});
});
describe("validateTimeRange", () => {
test("有效的 from/to 返回 ISO 字符串", () => {
const result = validateTimeRange("2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z", "production");
expect(result).not.toHaveProperty("status");
expect((result as { from: string; to: string }).from).toBe("2024-01-01T00:00:00.000Z");
expect((result as { from: string; to: string }).to).toBe("2024-01-02T00:00:00.000Z");
});
test("缺失 from 或 to 返回 400", () => {
const missingFrom = validateTimeRange(null, "2024-01-02T00:00:00.000Z", "production");
const missingTo = validateTimeRange("2024-01-01T00:00:00.000Z", null, "production");
const missingBoth = validateTimeRange(null, null, "production");
expect(missingFrom).toHaveProperty("status", 400);
expect(missingTo).toHaveProperty("status", 400);
expect(missingBoth).toHaveProperty("status", 400);
});
test("空字符串 from 或 to 返回 400", () => {
const emptyFrom = validateTimeRange("", "2024-01-02T00:00:00.000Z", "production");
const emptyTo = validateTimeRange("2024-01-01T00:00:00.000Z", "", "production");
expect(emptyFrom).toHaveProperty("status", 400);
expect(emptyTo).toHaveProperty("status", 400);
});
test("无效的日期格式返回 400", () => {
const result = validateTimeRange("invalid-date", "2024-01-02T00:00:00.000Z", "production");
expect(result).toHaveProperty("status", 400);
});
test("from 晚于 to 返回 400", () => {
const result = validateTimeRange("2024-01-02T00:00:00.000Z", "2024-01-01T00:00:00.000Z", "production");
expect(result).toHaveProperty("status", 400);
});
});
describe("validatePagination", () => {
test("默认值page=1, pageSize=20", () => {
const result = validatePagination(null, null, "production");
expect(result).toEqual({ page: 1, pageSize: 20 });
});
test("有效的 page 和 pageSize 参数", () => {
const result = validatePagination("2", "50", "production");
expect(result).toEqual({ page: 2, pageSize: 50 });
});
test("无效的 page 参数返回 400", () => {
const invalidPage = ["0", "-1", "abc", "1.5"];
for (const page of invalidPage) {
const result = validatePagination(page, "20", "production");
expect(result).toHaveProperty("status", 400);
}
});
test("无效的 pageSize 参数返回 400", () => {
const invalidPageSize = ["0", "-1", "abc", "1.5"];
for (const pageSize of invalidPageSize) {
const result = validatePagination("1", pageSize, "production");
expect(result).toHaveProperty("status", 400);
}
});
test("pageSize 超过上限返回 400", () => {
const result = validatePagination("1", "201", "production");
expect(result).toHaveProperty("status", 400);
});
test("pageSize 等于上限 200 返回成功", () => {
const result = validatePagination("1", "200", "production");
expect(result).toEqual({ page: 1, pageSize: 200 });
});
});

127
tests/server/static.test.ts Normal file
View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import {
contentTypeFor,
hasFileExtension,
htmlResponse,
serveStaticAsset,
type StaticAssets,
} from "../../src/server/static";
function createTestAssets(): StaticAssets {
return {
files: {
"/assets/index-a1b2c3.css": new Blob([".app{}"], { type: "text/css" }),
"/assets/index-a1b2c3.js": new Blob(["console.log(1)"], { type: "text/javascript" }),
"/assets/vendor-react-x9y8z7.js": new Blob(["react"], { type: "text/javascript" }),
"/favicon.svg": new Blob(["<svg/>"], { type: "image/svg+xml" }),
},
indexHtml: new Blob(["<!doctype html><html></html>"], { type: "text/html" }),
};
}
describe("contentTypeFor", () => {
test("JavaScript 文件", () => {
expect(contentTypeFor("/assets/index-a1b2c3.js")).toBe("text/javascript; charset=utf-8");
});
test("mjs 文件", () => {
expect(contentTypeFor("/assets/chunk.mjs")).toBe("text/javascript; charset=utf-8");
});
test("CSS 文件", () => {
expect(contentTypeFor("/assets/style.css")).toBe("text/css; charset=utf-8");
});
test("SVG 文件", () => {
expect(contentTypeFor("/icon.svg")).toBe("image/svg+xml");
});
test("未知扩展名返回 octet-stream", () => {
expect(contentTypeFor("/file.xyz")).toBe("application/octet-stream");
});
test("无扩展名返回 octet-stream", () => {
expect(contentTypeFor("/noext")).toBe("application/octet-stream");
});
});
describe("hasFileExtension", () => {
test("有扩展名", () => {
expect(hasFileExtension("/assets/index.js")).toBe(true);
expect(hasFileExtension("/favicon.svg")).toBe(true);
});
test("无扩展名", () => {
expect(hasFileExtension("/dashboard")).toBe(false);
expect(hasFileExtension("/")).toBe(false);
expect(hasFileExtension("/api/targets")).toBe(false);
});
});
describe("htmlResponse", () => {
test("返回 HTML 响应带正确 headers", async () => {
const blob = new Blob(["<html></html>"]);
const response = htmlResponse(blob);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<html></html>");
});
});
describe("serveStaticAsset", () => {
test("根路径返回 indexHtml", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<!doctype html><html></html>");
});
test("已知资源返回对应文件和 immutable 缓存", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/index-a1b2c3.js", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(await response.text()).toBe("console.log(1)");
});
test("未知带扩展名路径返回 404", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/missing.js", assets);
expect(response.status).toBe(404);
});
test("SPA fallback — 无扩展名路径返回 indexHtml", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/dashboard", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<!doctype html><html></html>");
});
test("SVG 资源返回正确 Content-Type", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/favicon.svg", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/svg+xml");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
});
test("CSS 资源返回正确 Content-Type", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/index-a1b2c3.css", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/css; charset=utf-8");
});
});

106
tests/setup.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* 全局测试配置
* 主要为后端测试提供基础环境
* 组件测试使用各自的 test-utils.tsx
*/
/* eslint-disable @typescript-eslint/no-empty-function */
// Set up jsdom for ALL tests (both backend and frontend)
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost",
});
globalThis.document = dom.window.document;
globalThis.window = dom.window as unknown as typeof globalThis & Window;
globalThis.navigator = dom.window.navigator;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.getComputedStyle = dom.window.getComputedStyle;
// Ensure document.body exists
if (!globalThis.document.body) {
const body = globalThis.document.createElement("body");
globalThis.document.documentElement.appendChild(body);
}
// CRITICAL: Set up polyfills BEFORE any other imports
// This ensures @testing-library/react sees these when it loads
// IE-style event handling polyfill (React fallback)
const nodeProto = dom.window.Node.prototype;
const elementProto = dom.window.Element.prototype;
const htmlElementProto = dom.window.HTMLElement.prototype;
const attachEventFn = () => {};
const detachEventFn = () => {};
Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(nodeProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
Object.defineProperty(elementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
// Other polyfills
globalThis.ResizeObserver = class {
disconnect() {}
observe() {}
unobserve() {}
};
globalThis.MutationObserver = class {
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
globalThis.IntersectionObserver = class {
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
} as unknown as typeof IntersectionObserver;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16);
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
Object.defineProperty(dom.window, "matchMedia", {
value: (query: string) => ({
addEventListener: () => {},
addListener: () => {},
dispatchEvent: () => true,
matches: false,
media: query,
onchange: null,
removeEventListener: () => {},
removeListener: () => {},
}),
writable: true,
});
dom.window.Element.prototype.scrollTo = () => {};
dom.window.Element.prototype.scrollIntoView = () => {};
Object.defineProperty(dom.window, "customElements", {
value: {
define: () => {},
get: () => undefined,
},
writable: true,
});
globalThis.customElements = dom.window.customElements;
import { afterEach } from "bun:test";
afterEach(() => {
document.body.innerHTML = "";
});

54
tests/web/App.test.tsx Normal file
View File

@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/require-await */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement, StrictMode } from "react";
import { App } from "../../src/web/app";
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
});
}
function renderApp() {
const queryClient = createTestQueryClient();
return render(
createElement(
StrictMode,
null,
createElement(
ErrorBoundary,
null,
createElement(QueryClientProvider, { client: queryClient }, createElement(App)),
),
),
);
}
describe("App", () => {
test("渲染 Layout 骨架和品牌名", () => {
// mock /health fetch 避免网络错误
window.fetch = (async () => {
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
}) as unknown as typeof fetch;
renderApp();
expect(screen.getByText("{{app-name}}")).not.toBeNull();
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
});
});

51
tests/web/test-utils.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { mock } from "bun:test";
// Note: jsdom and polyfills are now set up in tests/setup.ts
// This file only contains component-specific mocks
// Mock recharts BEFORE any component imports
void mock.module("recharts", () => ({
Area: () => null,
CartesianGrid: () => null,
Line: () => null,
LineChart: ({ children }: { children: unknown }) => children,
ResponsiveContainer: ({ children }: { children: unknown }) => children,
Tooltip: () => null,
XAxis: () => null,
YAxis: () => null,
}));
// Custom test helpers (替代 jest-dom matchers)
export const testHelpers = {
toBeInTheDocument: (element: Element | null) => {
const pass = element !== null && document.contains(element);
return {
message: () => (pass ? "Expected element not to be in document" : "Expected element to be in document"),
pass,
};
},
toHaveAttribute: (element: Element | null, attr: string, value?: string) => {
const pass = value === undefined ? (element?.hasAttribute(attr) ?? false) : element?.getAttribute(attr) === value;
return {
message: () =>
pass ? `Expected element not to have attribute "${attr}"` : `Expected element to have attribute "${attr}"`,
pass,
};
},
toHaveClass: (element: Element | null, className: string) => {
const pass = element?.classList.contains(className) ?? false;
return {
message: () =>
pass ? `Expected element not to have class "${className}"` : `Expected element to have class "${className}"`,
pass,
};
},
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
const content = element?.textContent ?? "";
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,
};
},
};

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import {
formatCountdown,
formatDurationUnit,
formatRelativeTime,
isOlderThan,
subtractHours,
} from "../../../src/web/utils/time";
describe("subtractHours", () => {
test("正常扣减小时", () => {
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 3);
expect(result.toISOString()).toBe("2025-01-15T09:00:00.000Z");
});
test("跨天扣减", () => {
const result = subtractHours(new Date("2025-01-15T02:00:00.000Z"), 6);
expect(result.toISOString()).toBe("2025-01-14T20:00:00.000Z");
});
test("跨月扣减", () => {
const result = subtractHours(new Date("2025-03-01T01:00:00.000Z"), 2);
expect(result.toISOString()).toBe("2025-02-28T23:00:00.000Z");
});
test("扣减 0 小时返回相同时间", () => {
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 0);
expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z");
});
});
describe("formatRelativeTime", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
test("格式化秒和分钟", () => {
expect(formatRelativeTime("2025-01-01T00:01:45.000Z", now)).toBe("15秒前");
expect(formatRelativeTime("2025-01-01T00:00:00.000Z", now)).toBe("2分钟前");
});
test("无时间返回占位", () => {
expect(formatRelativeTime(null, now)).toBe("尚无检查数据");
expect(formatRelativeTime("invalid", now)).toBe("尚无检查数据");
});
});
describe("formatDurationUnit", () => {
test("按秒、分钟、小时动态格式化", () => {
expect(formatDurationUnit(1500)).toEqual({ suffix: "秒", value: 1.5 });
expect(formatDurationUnit(120000)).toEqual({ suffix: "分钟", value: 2 });
expect(formatDurationUnit(5400000)).toEqual({ suffix: "小时", value: 1.5 });
});
test("空时长返回占位", () => {
expect(formatDurationUnit(null)).toEqual({ suffix: "", value: 0 });
});
});
describe("formatCountdown", () => {
test("格式化秒级和分钟级倒计时", () => {
expect(formatCountdown(0)).toBe("0秒");
expect(formatCountdown(59)).toBe("59秒");
expect(formatCountdown(60)).toBe("1分0秒");
expect(formatCountdown(299)).toBe("4分59秒");
});
});
describe("isOlderThan", () => {
test("判断时间是否超过阈值", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
expect(isOlderThan("2025-01-01T00:00:59.000Z", 60000, now)).toBe(true);
expect(isOlderThan("2025-01-01T00:01:30.000Z", 60000, now)).toBe(false);
});
});

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": true
}
}

37
vite.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
build: {
emptyOutDir: true,
outDir: "../../dist/web",
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: "vendor-react",
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
},
{
name: "vendor-tdesign",
test: /[\\/]node_modules[\\/](tdesign-react|tdesign-icons-react)[\\/]/,
},
{
name: "vendor-chart",
test: /[\\/]node_modules[\\/](recharts|d3-.*)[\\/]/,
},
],
},
},
},
},
plugins: [react()],
root: "src/web",
server: {
proxy: {
"/api": "http://127.0.0.1:3000",
"/health": "http://127.0.0.1:3000",
},
},
});