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

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;
}