feat: 新增应用全局常量 APP,消除硬编码散落
- 新增 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 规范文档
This commit is contained in:
@@ -34,6 +34,7 @@ src/
|
|||||||
health.ts GET /health
|
health.ts GET /health
|
||||||
shared/
|
shared/
|
||||||
api.ts 前后端共享 TypeScript 类型
|
api.ts 前后端共享 TypeScript 类型
|
||||||
|
app.ts 应用全局常量(name、title、subtitle、description、version)
|
||||||
web/ React 前端(通过 Vite 构建)
|
web/ React 前端(通过 Vite 构建)
|
||||||
index.html HTML 入口
|
index.html HTML 入口
|
||||||
app.tsx 根组件(Layout + Header + Content,/health 联调示例展示)
|
app.tsx 根组件(Layout + Header + Content,/health 联调示例展示)
|
||||||
@@ -161,6 +162,7 @@ export function handleHealth(mode: RuntimeMode): Response;
|
|||||||
### 1.5 类型定义规范
|
### 1.5 类型定义规范
|
||||||
|
|
||||||
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
|
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
|
||||||
|
- **应用常量**以 `src/shared/app.ts` 为唯一源头,定义 `APP` 对象(name、title、subtitle、description、version),前后端及构建脚本共同引用
|
||||||
- 前端不得 `import src/server/` 下的任何文件
|
- 前端不得 `import src/server/` 下的任何文件
|
||||||
- **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string`
|
- **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string`
|
||||||
- API 响应类型(`ApiErrorResponse`、`HealthResponse`)定义在 shared 中
|
- API 响应类型(`ApiErrorResponse`、`HealthResponse`)定义在 shared 中
|
||||||
@@ -233,7 +235,7 @@ main.tsx
|
|||||||
hooks/use-theme-preference.ts(浏览器 UI 偏好)
|
hooks/use-theme-preference.ts(浏览器 UI 偏好)
|
||||||
├── ThemePreference: system / light / dark(RadioGroup 受控值)
|
├── ThemePreference: system / light / dark(RadioGroup 受控值)
|
||||||
├── EffectiveTheme: light / dark(写入 document.documentElement theme-mode)
|
├── EffectiveTheme: light / dark(写入 document.documentElement theme-mode)
|
||||||
├── localStorage key: my-app.theme.preference(同一浏览器记忆)
|
├── localStorage key: theme.preference(同一浏览器记忆)
|
||||||
└── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化)
|
└── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -25,23 +25,23 @@ cd my-project
|
|||||||
rm -rf .git && git init
|
rm -rf .git && git init
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 替换占位符 `my-app`
|
### 2. 配置应用信息
|
||||||
|
|
||||||
在整个项目中全局搜索替换 `my-app` 为你的项目名称(如 `your-project`)。以下为所有出现位置:
|
编辑 `src/shared/app.ts`,修改应用元信息:
|
||||||
|
|
||||||
| # | 文件 | 说明 |
|
```typescript
|
||||||
| --- | --------------------------------------- | ------------------------------------------ |
|
export const APP = {
|
||||||
| 1 | `package.json` | `name` 字段 |
|
name: "your-app", // 机器标识(kebab-case)
|
||||||
| 2 | `scripts/build.ts` | 可执行文件路径 |
|
title: "Your App", // 人类可读标题
|
||||||
| 3 | `src/server/config.ts` | CLI 帮助文本 |
|
subtitle: "你的副标题", // 副标题
|
||||||
| 4 | `src/server/helpers.ts` | `createHealthResponse()` 的 `service` 字段 |
|
description: "应用描述", // SEO meta 描述
|
||||||
| 5 | `src/server/server.ts` | 服务启动日志消息 |
|
version: "0.1.0", // 版本号
|
||||||
| 6 | `src/web/index.html` | `<title>` 和 `<meta name="description">` |
|
} as const;
|
||||||
| 7 | `src/web/app.tsx` | Header 中的品牌名和欢迎标题 |
|
```
|
||||||
| 8 | `src/web/hooks/use-theme-preference.ts` | `localStorage` 键名 |
|
|
||||||
| 9 | `tests/web/App.test.tsx` | 测试中的品牌名断言 |
|
|
||||||
|
|
||||||
> **提示**:可直接在编辑器中全局搜索 `my-app`,一次性替换。
|
同时修改 `package.json` 的 `name` 字段保持一致。
|
||||||
|
|
||||||
|
> **注意**:localStorage key 已从 `"my-app.theme.preference"` 变更为 `"theme.preference"`。如果从旧版本升级,用户的主题偏好设置将丢失,需重新选择。
|
||||||
|
|
||||||
### 3. 清理 OpenSpec 历史
|
### 3. 清理 OpenSpec 历史
|
||||||
|
|
||||||
@@ -111,7 +111,8 @@ bun run dev
|
|||||||
│ │ └── routes/ # API 路由处理器
|
│ │ └── routes/ # API 路由处理器
|
||||||
│ │ └── health.ts # 健康检查端点
|
│ │ └── health.ts # 健康检查端点
|
||||||
│ ├── shared/
|
│ ├── shared/
|
||||||
│ │ └── api.ts # 前后端共享 TypeScript 类型定义
|
│ │ ├── api.ts # 前后端共享 TypeScript 类型定义
|
||||||
|
│ │ └── app.ts # 应用全局常量(name、title、version 等)
|
||||||
│ └── web/ # 前端代码
|
│ └── web/ # 前端代码
|
||||||
│ ├── index.html # HTML 入口
|
│ ├── index.html # HTML 入口
|
||||||
│ ├── main.tsx # React 入口(QueryClient + ErrorBoundary)
|
│ ├── main.tsx # React 入口(QueryClient + ErrorBoundary)
|
||||||
|
|||||||
61
openspec/specs/app-constants/spec.md
Normal file
61
openspec/specs/app-constants/spec.md
Normal file
@@ -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"` 读取
|
||||||
@@ -2,10 +2,12 @@ import { readdir, rm, writeFile } from "node:fs/promises";
|
|||||||
import { join, relative, sep } from "node:path";
|
import { join, relative, sep } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { APP } from "../src/shared/app";
|
||||||
|
|
||||||
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
const distWebDir = join(projectRoot, "dist/web");
|
const distWebDir = join(projectRoot, "dist/web");
|
||||||
const buildDir = join(projectRoot, ".build");
|
const buildDir = join(projectRoot, ".build");
|
||||||
const executablePath = join(projectRoot, "dist/my-app");
|
const executablePath = join(projectRoot, `dist/${APP.name}`);
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { APP } from "../shared/app";
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -43,7 +45,7 @@ export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPa
|
|||||||
if (argv.length === 0) return {};
|
if (argv.length === 0) return {};
|
||||||
const firstArg = argv[0];
|
const firstArg = argv[0];
|
||||||
if (firstArg === "--help" || firstArg === "-h") {
|
if (firstArg === "--help" || firstArg === "-h") {
|
||||||
console.log("用法: my-app [config.yaml]");
|
console.log(`用法: ${APP.name} [config.yaml]`);
|
||||||
console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)");
|
console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api";
|
import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api";
|
||||||
|
|
||||||
|
import { APP } from "../shared/app";
|
||||||
|
|
||||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||||
return { error, status };
|
return { error, status };
|
||||||
}
|
}
|
||||||
@@ -18,7 +20,7 @@ export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
|
|||||||
export function createHealthResponse(): HealthResponse {
|
export function createHealthResponse(): HealthResponse {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: "my-app",
|
service: APP.name,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RuntimeMode } from "../shared/api";
|
|||||||
import type { ServerConfig } from "./config";
|
import type { ServerConfig } from "./config";
|
||||||
import type { StaticAssets } from "./static";
|
import type { StaticAssets } from "./static";
|
||||||
|
|
||||||
|
import { APP } from "../shared/app";
|
||||||
import { createApiError, jsonResponse } from "./helpers";
|
import { createApiError, jsonResponse } from "./helpers";
|
||||||
import { handleHealth } from "./routes/health";
|
import { handleHealth } from "./routes/health";
|
||||||
import { serveStaticAsset } from "./static";
|
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;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/shared/app.ts
Normal file
7
src/shared/app.ts
Normal file
@@ -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;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { Layout, Menu, RadioGroup, Space } from "tdesign-react";
|
import { Layout, Menu, RadioGroup, Space } from "tdesign-react";
|
||||||
|
|
||||||
import type { HealthResponse } from "../shared/api";
|
import type { HealthResponse } from "../shared/api";
|
||||||
|
|
||||||
|
import { APP } from "../shared/app";
|
||||||
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||||
|
|
||||||
const { Content, Header } = Layout;
|
const { Content, Header } = Layout;
|
||||||
@@ -21,6 +23,11 @@ export function App() {
|
|||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = APP.title;
|
||||||
|
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleThemeChange = (value: ThemePreference) => {
|
const handleThemeChange = (value: ThemePreference) => {
|
||||||
setThemePreference(value);
|
setThemePreference(value);
|
||||||
};
|
};
|
||||||
@@ -31,7 +38,7 @@ export function App() {
|
|||||||
<Menu.HeadMenu
|
<Menu.HeadMenu
|
||||||
logo={
|
logo={
|
||||||
<span className="dashboard-brand">
|
<span className="dashboard-brand">
|
||||||
<span className="dashboard-logo">my-app</span>
|
<span className="dashboard-logo">{APP.title}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
operations={
|
operations={
|
||||||
@@ -50,7 +57,7 @@ export function App() {
|
|||||||
<Content>
|
<Content>
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||||
<h2>欢迎使用 my-app</h2>
|
<h2>欢迎使用 {APP.title}</h2>
|
||||||
<p>在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):</p>
|
<p>在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):</p>
|
||||||
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
|
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
|||||||
export type EffectiveTheme = "dark" | "light";
|
export type EffectiveTheme = "dark" | "light";
|
||||||
export type ThemePreference = "dark" | "light" | "system";
|
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 const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
export function applyInitialThemePreference() {
|
export function applyInitialThemePreference() {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="my-app" />
|
<meta name="description" content="" />
|
||||||
<title>my-app</title>
|
<title>App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { render, screen } from "@testing-library/react";
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createElement, StrictMode } from "react";
|
import { createElement, StrictMode } from "react";
|
||||||
|
|
||||||
|
import { APP } from "../../src/shared/app";
|
||||||
import { App } from "../../src/web/app";
|
import { App } from "../../src/web/app";
|
||||||
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
|
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ describe("App", () => {
|
|||||||
|
|
||||||
renderApp();
|
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();
|
expect(screen.getByText("明亮")).not.toBeNull();
|
||||||
expect(screen.getByText("黑暗")).not.toBeNull();
|
expect(screen.getByText("黑暗")).not.toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user