From 5aed73523e7b0b9b221af66c18a4588e26fd6efa Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 20 May 2026 15:54:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=B8=B8=E9=87=8F=20APP=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E7=A1=AC=E7=BC=96=E7=A0=81=E6=95=A3=E8=90=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/shared/app.ts,定义应用元信息(name、title、subtitle、description、version) - 后端 3 处硬编码改为引用 APP.name - 前端 3 处硬编码改为引用 APP.title/APP.description - localStorage key 从 my-app.theme.preference 改为 theme.preference - 构建脚本可执行文件名改为引用 APP.name - 更新 README.md 和 DEVELOPMENT.md 文档 - 新增 openspec/specs/app-constants/spec.md 规范文档 --- DEVELOPMENT.md | 4 +- README.md | 31 +++++++------- openspec/specs/app-constants/spec.md | 61 +++++++++++++++++++++++++++ scripts/build.ts | 4 +- src/server/config.ts | 4 +- src/server/helpers.ts | 4 +- src/server/server.ts | 3 +- src/shared/app.ts | 7 +++ src/web/app.tsx | 11 ++++- src/web/hooks/use-theme-preference.ts | 2 +- src/web/index.html | 4 +- tests/web/App.test.tsx | 3 +- 12 files changed, 112 insertions(+), 26 deletions(-) create mode 100644 openspec/specs/app-constants/spec.md create mode 100644 src/shared/app.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 91e547d..b14aeeb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -34,6 +34,7 @@ src/ health.ts GET /health shared/ api.ts 前后端共享 TypeScript 类型 + app.ts 应用全局常量(name、title、subtitle、description、version) web/ React 前端(通过 Vite 构建) index.html HTML 入口 app.tsx 根组件(Layout + Header + Content,/health 联调示例展示) @@ -161,6 +162,7 @@ export function handleHealth(mode: RuntimeMode): Response; ### 1.5 类型定义规范 - **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用 +- **应用常量**以 `src/shared/app.ts` 为唯一源头,定义 `APP` 对象(name、title、subtitle、description、version),前后端及构建脚本共同引用 - 前端不得 `import src/server/` 下的任何文件 - **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string` - API 响应类型(`ApiErrorResponse`、`HealthResponse`)定义在 shared 中 @@ -233,7 +235,7 @@ main.tsx hooks/use-theme-preference.ts(浏览器 UI 偏好) ├── ThemePreference: system / light / dark(RadioGroup 受控值) ├── EffectiveTheme: light / dark(写入 document.documentElement theme-mode) -├── localStorage key: my-app.theme.preference(同一浏览器记忆) +├── localStorage key: theme.preference(同一浏览器记忆) └── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化) ``` diff --git a/README.md b/README.md index d3a8da0..52f12e5 100644 --- a/README.md +++ b/README.md @@ -25,23 +25,23 @@ cd my-project rm -rf .git && git init ``` -### 2. 替换占位符 `my-app` +### 2. 配置应用信息 -在整个项目中全局搜索替换 `my-app` 为你的项目名称(如 `your-project`)。以下为所有出现位置: +编辑 `src/shared/app.ts`,修改应用元信息: -| # | 文件 | 说明 | -| --- | --------------------------------------- | ------------------------------------------ | -| 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` | `` 和 `<meta name="description">` | -| 7 | `src/web/app.tsx` | Header 中的品牌名和欢迎标题 | -| 8 | `src/web/hooks/use-theme-preference.ts` | `localStorage` 键名 | -| 9 | `tests/web/App.test.tsx` | 测试中的品牌名断言 | +```typescript +export const APP = { + name: "your-app", // 机器标识(kebab-case) + title: "Your App", // 人类可读标题 + subtitle: "你的副标题", // 副标题 + description: "应用描述", // SEO meta 描述 + version: "0.1.0", // 版本号 +} as const; +``` -> **提示**:可直接在编辑器中全局搜索 `my-app`,一次性替换。 +同时修改 `package.json` 的 `name` 字段保持一致。 + +> **注意**:localStorage key 已从 `"my-app.theme.preference"` 变更为 `"theme.preference"`。如果从旧版本升级,用户的主题偏好设置将丢失,需重新选择。 ### 3. 清理 OpenSpec 历史 @@ -111,7 +111,8 @@ bun run dev │ │ └── routes/ # API 路由处理器 │ │ └── health.ts # 健康检查端点 │ ├── shared/ -│ │ └── api.ts # 前后端共享 TypeScript 类型定义 +│ │ ├── api.ts # 前后端共享 TypeScript 类型定义 +│ │ └── app.ts # 应用全局常量(name、title、version 等) │ └── web/ # 前端代码 │ ├── index.html # HTML 入口 │ ├── main.tsx # React 入口(QueryClient + ErrorBoundary) diff --git a/openspec/specs/app-constants/spec.md b/openspec/specs/app-constants/spec.md new file mode 100644 index 0000000..6dc8ae7 --- /dev/null +++ b/openspec/specs/app-constants/spec.md @@ -0,0 +1,61 @@ +## Purpose + +定义应用全局常量,作为应用元信息(name、title、subtitle、description、version)的唯一真实来源,供前后端及构建脚本统一引用,消除硬编码散落。 + +## Requirements + +### Requirement: 应用元信息唯一来源 + +系统 SHALL 在 `src/shared/app.ts` 中定义应用全局常量 `APP`,包含以下字段: +- `name`:机器标识(kebab-case 格式) +- `title`:人类可读标题 +- `subtitle`:副标题 +- `description`:应用描述(用于 SEO meta) +- `version`:语义版本号 + +`APP` SHALL 使用 `as const` 声明,保证字面量类型推断。 + +#### Scenario: 后端引用应用名称 + +- **WHEN** 后端代码需要应用名称(如 CLI 帮助文本、health 响应、启动日志) +- **THEN** 系统 SHALL 从 `src/shared/app.ts` 导入 `APP.name` + +#### Scenario: 前端引用应用标题 + +- **WHEN** 前端代码需要应用标题(如 Header logo、欢迎文本) +- **THEN** 系统 SHALL 从 `src/shared/app.ts` 导入 `APP.title` + +#### Scenario: 构建脚本引用应用名称 + +- **WHEN** 构建脚本需要确定可执行文件名 +- **THEN** 系统 SHALL 从 `src/shared/app.ts` 导入 `APP.name` + +### Requirement: 前端 HTML 元信息动态设置 + +系统 SHALL 在 React 应用挂载时动态设置 HTML 元信息: +- `document.title` SHALL 设置为 `APP.title` +- `<meta name="description">` 内容 SHALL 设置为 `APP.description` + +#### Scenario: 页面标题显示应用名称 + +- **WHEN** 用户访问应用 +- **THEN** 浏览器标签页标题 SHALL 显示 `APP.title` + +#### Scenario: meta description 设置 + +- **WHEN** 搜索引擎爬取页面 +- **THEN** meta description SHALL 包含 `APP.description` + +### Requirement: localStorage key 语义化命名 + +主题偏好存储 key SHALL 为 `"theme.preference"`,不包含应用名前缀。 + +#### Scenario: 主题偏好持久化 + +- **WHEN** 用户选择主题偏好(system/light/dark) +- **THEN** 系统 SHALL 将偏好值存储到 localStorage key `"theme.preference"` + +#### Scenario: 主题偏好读取 + +- **WHEN** 应用初始化时读取用户主题偏好 +- **THEN** 系统 SHALL 从 localStorage key `"theme.preference"` 读取 diff --git a/scripts/build.ts b/scripts/build.ts index e08fb8d..df018ba 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -2,10 +2,12 @@ import { readdir, rm, writeFile } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { APP } from "../src/shared/app"; + const projectRoot = fileURLToPath(new URL("..", import.meta.url)); const distWebDir = join(projectRoot, "dist/web"); const buildDir = join(projectRoot, ".build"); -const executablePath = join(projectRoot, "dist/my-app"); +const executablePath = join(projectRoot, `dist/${APP.name}`); async function build() { try { diff --git a/src/server/config.ts b/src/server/config.ts index 19fa58a..6628f7a 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,3 +1,5 @@ +import { APP } from "../shared/app"; + export interface ServerConfig { host: string; port: number; @@ -43,7 +45,7 @@ export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPa if (argv.length === 0) return {}; const firstArg = argv[0]; if (firstArg === "--help" || firstArg === "-h") { - console.log("用法: my-app [config.yaml]"); + console.log(`用法: ${APP.name} [config.yaml]`); console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)"); process.exit(0); } diff --git a/src/server/helpers.ts b/src/server/helpers.ts index c0b2b00..d5d4a67 100644 --- a/src/server/helpers.ts +++ b/src/server/helpers.ts @@ -1,5 +1,7 @@ import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api"; +import { APP } from "../shared/app"; + export function createApiError(error: string, status: number): ApiErrorResponse { return { error, status }; } @@ -18,7 +20,7 @@ export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers { export function createHealthResponse(): HealthResponse { return { ok: true, - service: "my-app", + service: APP.name, timestamp: new Date().toISOString(), }; } diff --git a/src/server/server.ts b/src/server/server.ts index d13bfae..c756c38 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2,6 +2,7 @@ import type { RuntimeMode } from "../shared/api"; import type { ServerConfig } from "./config"; import type { StaticAssets } from "./static"; +import { APP } from "../shared/app"; import { createApiError, jsonResponse } from "./helpers"; import { handleHealth } from "./routes/health"; import { serveStaticAsset } from "./static"; @@ -32,7 +33,7 @@ export function startServer(options: StartServerOptions) { }, }); - console.log(`my-app listening on ${server.url}`); + console.log(`${APP.name} listening on ${server.url}`); return server; } diff --git a/src/shared/app.ts b/src/shared/app.ts new file mode 100644 index 0000000..09b5262 --- /dev/null +++ b/src/shared/app.ts @@ -0,0 +1,7 @@ +export const APP = { + description: "基于 Bun + React + TDesign 的全栈开发框架", + name: "my-app", + subtitle: "Bun 全栈应用", + title: "My App", + version: "0.1.0", +} as const; diff --git a/src/web/app.tsx b/src/web/app.tsx index fe79f8e..ae68998 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -1,8 +1,10 @@ import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; import { Layout, Menu, RadioGroup, Space } from "tdesign-react"; import type { HealthResponse } from "../shared/api"; +import { APP } from "../shared/app"; import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference"; const { Content, Header } = Layout; @@ -21,6 +23,11 @@ export function App() { staleTime: 5000, }); + useEffect(() => { + document.title = APP.title; + document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description); + }, []); + const handleThemeChange = (value: ThemePreference) => { setThemePreference(value); }; @@ -31,7 +38,7 @@ export function App() { <Menu.HeadMenu logo={ <span className="dashboard-brand"> - <span className="dashboard-logo">my-app</span> + <span className="dashboard-logo">{APP.title}</span> </span> } operations={ @@ -50,7 +57,7 @@ export function App() { <Content> <div className="dashboard-content"> <Space direction="vertical" size="large" style={{ width: "100%" }}> - <h2>欢迎使用 my-app</h2> + <h2>欢迎使用 {APP.title}</h2> <p>在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):</p> {health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>} </Space> diff --git a/src/web/hooks/use-theme-preference.ts b/src/web/hooks/use-theme-preference.ts index 07d01d1..9bc709d 100644 --- a/src/web/hooks/use-theme-preference.ts +++ b/src/web/hooks/use-theme-preference.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; export type EffectiveTheme = "dark" | "light"; export type ThemePreference = "dark" | "light" | "system"; -export const THEME_PREFERENCE_STORAGE_KEY = "my-app.theme.preference"; +export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference"; export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)"; export function applyInitialThemePreference() { diff --git a/src/web/index.html b/src/web/index.html index 3e97999..e3306f9 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -3,8 +3,8 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <meta name="description" content="my-app" /> - <title>my-app + + App
diff --git a/tests/web/App.test.tsx b/tests/web/App.test.tsx index 37aa1a4..f95e273 100644 --- a/tests/web/App.test.tsx +++ b/tests/web/App.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, test } from "bun:test"; import { createElement, StrictMode } from "react"; +import { APP } from "../../src/shared/app"; import { App } from "../../src/web/app"; import { ErrorBoundary } from "../../src/web/components/ErrorBoundary"; @@ -46,7 +47,7 @@ describe("App", () => { renderApp(); - expect(screen.getByText("my-app")).not.toBeNull(); + expect(screen.getByText(APP.title)).not.toBeNull(); expect(screen.getByText("系统")).not.toBeNull(); expect(screen.getByText("明亮")).not.toBeNull(); expect(screen.getByText("黑暗")).not.toBeNull();