Procházet zdrojové kódy

feat: 新增构建模式ssg,为每个路由生成单独的html,同时之前的csr模式(单页面应用)也保留

f-dev před 1 týdnem
rodič
revize
968466393e

+ 3 - 0
.gitignore

@@ -13,6 +13,9 @@ dist-ssr
 *.local
 .pnpm-store
 
+# React Router framework 模式自动生成的类型/临时目录
+.react-router
+
 # Editor directories and files
 !.vscode/extensions.json
 .idea

+ 16 - 42
build/buildInfo.ts

@@ -1,60 +1,34 @@
-import boxen from 'boxen';
-import dayjs from 'dayjs';
-import duration from 'dayjs/plugin/duration';
-import gradient from 'gradient-string';
+import { printBuildComplete, printBuildStart } from './buildReport';
 
-import { getPackageSize } from './utils';
-
-import type { Options as BoxenOptions } from 'boxen';
-import type { Dayjs } from 'dayjs';
 import type { Plugin } from 'vite';
 
-dayjs.extend(duration);
-
-const welcomeMessage = gradient(['cyan', 'magenta']).multiline(`正在打包...`);
-
-const boxenOptions: BoxenOptions = {
-    padding: 0.5,
-    borderColor: 'cyan',
-    borderStyle: 'round',
-};
+// 渲染模式(与 vite.config.ts 同源)。SSG 模式下「恭喜打包完成」改由 react-router.config.ts 的
+// buildEnd 钩子打印(那里晚于 ssr 构建、prerender、清理 server 等全部步骤);此插件的 closeBundle
+// 只对 CSR 有意义——CSR 不走 react-router,vite build 结束即全部结束。
+const isCSR = process.env.APP_RENDER_MODE === 'csr';
 
 export function viteBuildInfo(): Plugin {
-    let config: { command: string };
-    let startTime: Dayjs;
-    let endTime: Dayjs;
+    let isBuild = false;
     let outDir: string;
+    let startTime: number | undefined;
     return {
         name: 'vite:buildInfo',
+        // framework 模式下分 client / ssr 两个环境构建;ssr:false 时 react-router 删除 dist/server,
+        // 扫描已删目录会报 ENOENT。仅在 client 环境运行本插件(打包信息只对最终客户端产物有意义)。
+        applyToEnvironment: (environment) => environment.name === 'client',
         configResolved(resolvedConfig) {
-            config = resolvedConfig;
+            isBuild = resolvedConfig.command === 'build';
             outDir = resolvedConfig.build?.outDir ?? 'dist';
         },
         buildStart() {
-            if (config.command === 'build') {
-                console.log('\n' + boxen(welcomeMessage, boxenOptions));
-                startTime = dayjs(new Date());
+            if (isBuild) {
+                startTime = printBuildStart();
             }
         },
         closeBundle() {
-            if (config.command === 'build') {
-                endTime = dayjs(new Date());
-                getPackageSize({
-                    folder: outDir,
-                    callback: (size: string | number) => {
-                        console.log(
-                            '\n' +
-                                boxen(
-                                    gradient(['cyan', 'magenta']).multiline(
-                                        `🎉 恭喜打包完成\n` +
-                                            `总用时:${dayjs.duration(endTime.diff(startTime)).format('mm分ss秒')}\n` +
-                                            `打包后的大小为:${size}`
-                                    ),
-                                    boxenOptions
-                                )
-                        );
-                    },
-                });
+            // 仅 CSR 在此收尾。SSG 交给 buildEnd,避免「恭喜」早于后续 ssr/prerender 输出。
+            if (isBuild && isCSR) {
+                void printBuildComplete(outDir, startTime);
             }
         },
     };

+ 62 - 0
build/buildReport.ts

@@ -0,0 +1,62 @@
+import boxen from 'boxen';
+import dayjs from 'dayjs';
+import duration from 'dayjs/plugin/duration';
+import gradient from 'gradient-string';
+
+import { getPackageSize } from './utils';
+
+import type { Options as BoxenOptions } from 'boxen';
+
+dayjs.extend(duration);
+
+const boxenOptions: BoxenOptions = {
+    padding: 0.5,
+    borderColor: 'cyan',
+    borderStyle: 'round',
+};
+
+// 起始时间戳经 process.env 传递。SSG 下 buildStart(vite 插件)与 buildEnd(react-router.config)
+// 可能被加载为 buildReport 的不同模块实例(react-router 用独立 loader 加载 config),模块级变量不可靠;
+// 而 process.env 在同一 Node 进程内必然共享,故用它跨钩子传起始时间。
+const START_ENV_KEY = '__BUILD_START_TS__';
+
+/** 打印「正在打包...」并把起始时间戳写入 process.env,供 printBuildComplete 读取。 */
+export function printBuildStart(): number {
+    console.log('\n' + boxen(gradient(['cyan', 'magenta']).multiline('正在打包...'), boxenOptions));
+    const now = Date.now();
+    process.env[START_ENV_KEY] = String(now);
+    return now;
+}
+
+/** 以 Promise 封装回调式的 getPackageSize,便于在 async 钩子(如 RR buildEnd)里 await。 */
+function measurePackageSize(folder: string): Promise<string | number> {
+    return new Promise((resolve) => {
+        getPackageSize({ folder, callback: resolve });
+    });
+}
+
+/**
+ * 打印「🎉 恭喜打包完成」总结框(用时 + 产物体积)。
+ * @param folder 统计体积的产物目录(CSR → dist;SSG → dist/client)。
+ * @param startTime 起始时间戳(毫秒);缺省则回退到 printBuildStart 记录的模块级值。
+ *
+ * SSG 模式必须在 React Router 的 buildEnd 钩子里调用:client/ssr 双环境构建、prerender、
+ * 删除 server 目录都在 vite closeBundle 之后发生,只有 buildEnd 晚于全部步骤,
+ * 在此打印才真正是「最后一行」。
+ */
+export async function printBuildComplete(folder: string, startTime?: number): Promise<void> {
+    const size = await measurePackageSize(folder);
+    const envStart = process.env[START_ENV_KEY] ? Number(process.env[START_ENV_KEY]) : undefined;
+    const start = startTime ?? envStart;
+    const elapsed =
+        start != null ? `总用时:${dayjs.duration(Date.now() - start).format('mm分ss秒')}\n` : '';
+    console.log(
+        '\n' +
+            boxen(
+                gradient(['cyan', 'magenta']).multiline(
+                    `🎉 恭喜打包完成\n${elapsed}打包后的大小为:${size}`
+                ),
+                boxenOptions
+            )
+    );
+}

+ 18 - 4
build/compress.ts

@@ -6,6 +6,14 @@ import type { Plugin } from 'vite';
 export const configCompressPlugin = (compress: BuildCompression): Plugin | Plugin[] | null => {
     if (compress === 'none') return null;
 
+    // framework 模式下 Vite 分 client / ssr 两个环境构建;ssr:false 时 react-router 会删除 dist/server,
+    // 压缩插件若在 server 环境的 closeBundle 阶段扫描已被删的目录会报 ENOENT。故只对 client 环境启用压缩
+    //(server 产物本就不部署,无需压缩)。
+    const onlyClient = (plugin: Plugin): Plugin => ({
+        ...plugin,
+        applyToEnvironment: (environment) => environment.name === 'client',
+    });
+
     const gz = {
         // 生成的压缩包后缀
         ext: '.gz',
@@ -41,19 +49,25 @@ export const configCompressPlugin = (compress: BuildCompression): Plugin | Plugi
                 if (isArray(item.v)) {
                     item.v.forEach((vItem) => {
                         plugins.push(
-                            compressPlugin(Object.assign(vItem, { deleteOriginFile: true }))
+                            onlyClient(
+                                compressPlugin(Object.assign(vItem, { deleteOriginFile: true }))
+                            )
                         );
                     });
                 } else {
-                    plugins.push(compressPlugin(Object.assign(item.v, { deleteOriginFile: true })));
+                    plugins.push(
+                        onlyClient(
+                            compressPlugin(Object.assign(item.v, { deleteOriginFile: true }))
+                        )
+                    );
                 }
             } else {
                 if (isArray(item.v)) {
                     item.v.forEach((vItem) => {
-                        plugins.push(compressPlugin(vItem));
+                        plugins.push(onlyClient(compressPlugin(vItem)));
                     });
                 } else {
-                    plugins.push(compressPlugin(item.v));
+                    plugins.push(onlyClient(compressPlugin(item.v)));
                 }
             }
         }

+ 36 - 0
build/config/build.ts

@@ -0,0 +1,36 @@
+import { VENDOR_CHUNKS } from './chunks';
+
+import type { BuildOptions } from 'vite';
+
+/**
+ * 浏览器支持基线(esbuild target 语法):chrome87/safari13/firefox78/edge88 这条老线。
+ * 仅用于 build.cssTarget(CSS 的压缩/降级目标)——build.target 不设,JS 语法降级交给 legacy() 用 Babel 处理。
+ * 注:legacy() 的 targets 用 browserslist 语法('safari >= 13')表达同一条基线,二者需保持一致。
+ */
+export const CSS_TARGET = ['chrome87', 'safari13', 'firefox78', 'edge88'];
+
+/**
+ * 构建配置。依赖渲染模式(isCSR)与环境(isProd),故以工厂函数导出。
+ * @param isCSR 是否 CSR 模式。CSR 单环境构建,需显式 outDir 与内联 manualChunks;
+ *              SSG 由 react-router.config.ts 接管 outDir,分包经 clientChunksPlugin 注入。
+ * @param isProd 是否生产构建。生产关闭 sourcemap。
+ */
+export function createBuildOptions(isCSR: boolean, isProd: boolean): BuildOptions {
+    return {
+        // CSR:输出到 dist(无 react-router.config 管理,需显式指定)。
+        // SSG:输出目录由 react-router.config.ts 的 buildDirectory 接管(dist/client、dist/server),此处不设。
+        ...(isCSR ? { outDir: 'dist' } : {}),
+        sourcemap: !isProd,
+        // build.target 不设:JS 现代 chunk 走 vite 默认基线,语法降级由 legacy() 用 Babel 处理。
+        // CSS 不经 legacy 处理,故显式指定 cssTarget 到老浏览器基线(与 legacy targets 对齐)。
+        cssTarget: CSS_TARGET,
+        rollupOptions: {
+            output: {
+                // CSR 单环境构建,vendor 分包直接在此声明(复用 VENDOR_CHUNKS);
+                // SSG 此处留空,分包由 clientChunksPlugin 仅注入 client 环境。
+                ...(isCSR ? { manualChunks: VENDOR_CHUNKS } : {}),
+            },
+        },
+        chunkSizeWarningLimit: 1500,
+    };
+}

+ 35 - 0
build/config/chunks.ts

@@ -0,0 +1,35 @@
+import type { Plugin } from 'vite';
+
+/**
+ * vendor 分包策略。CSR 与 SSG 期望的分组一致,但注入方式因构建环境不同而分两处
+ * (SSG 经 clientChunksPlugin 注入 client 环境,CSR 直接写 build.rollupOptions),
+ * 故把分组表抽为单一常量,两处共用,避免改一处漏改另一处。
+ */
+export const VENDOR_CHUNKS = {
+    'react-vendor': ['react', 'react-dom', 'react-router-dom'],
+    'antd-vendor': ['antd', '@ant-design/icons'],
+    'utils-vendor': ['lodash-es', 'ramda'],
+    'i18n-vendor': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
+};
+
+/**
+ * vendor 分包仅注入客户端构建环境。
+ * framework(SSG)模式下 Vite 分 client / ssr 两个环境构建,通过 configEnvironment 按环境名注入 manualChunks,
+ * 避免 server 构建把 external 的 react 纳入分包而报错(EXTERNAL_MODULES_CANNOT_BE_INCLUDED_IN_MANUAL_CHUNKS)。
+ * 仅 SSG 需要此插件;CSR 单环境构建,分包直接写在 build.rollupOptions 里。
+ */
+export const clientChunksPlugin: Plugin = {
+    name: 'client-manual-chunks',
+    configEnvironment(name: string) {
+        if (name !== 'client') return;
+        return {
+            build: {
+                rollupOptions: {
+                    output: {
+                        manualChunks: VENDOR_CHUNKS,
+                    },
+                },
+            },
+        };
+    },
+};

+ 22 - 0
build/config/optimize.ts

@@ -0,0 +1,22 @@
+import type { CSSOptions, DepOptimizationOptions } from 'vite';
+
+/**
+ * 依赖预构建配置。
+ * brotli-wasm 需运行期用 fetch + WebAssembly 实例化(web 入口自带加载逻辑),
+ * 不能被 esbuild 预打包,故排除。
+ */
+export const optimizeDeps: DepOptimizationOptions = {
+    exclude: ['brotli-wasm'],
+};
+
+/** CSS 预处理器配置:less 允许内联 JS;scss 自动注入变量。 */
+export const cssOptions: CSSOptions = {
+    preprocessorOptions: {
+        less: {
+            javascriptEnabled: true,
+        },
+        scss: {
+            additionalData: `@use "@/styles/variables" as *;`,
+        },
+    },
+};

+ 50 - 0
build/config/plugins.ts

@@ -0,0 +1,50 @@
+import { reactRouter } from '@react-router/dev/vite';
+import legacy from '@vitejs/plugin-legacy';
+import react from '@vitejs/plugin-react';
+import removeConsole from 'vite-plugin-remove-console';
+
+// import wasm from 'vite-plugin-wasm'; // 代码中直接 import 'xxx.wasm' 时需要用到此插件
+// import topLevelAwait from 'vite-plugin-top-level-await'; // 使用 vite-plugin-wasm 插件时需要同时引入此插件
+
+import { viteBuildInfo } from '../buildInfo';
+import { configCompressPlugin } from '../compress';
+import svgConvert from '../svgConvert';
+
+import { clientChunksPlugin } from './chunks';
+
+import type { PluginOption } from 'vite';
+
+/**
+ * 组装插件数组。依赖渲染模式(isCSR)、环境(isProd)与 env(压缩方式)。
+ * @param isCSR 是否 CSR 模式。CSR 用官方 plugin-react;SSG 用 reactRouter(接管 React 处理与入口)。二者互斥。
+ * @param isProd 是否生产构建。生产才启用 removeConsole。
+ * @param env 处理后的环境变量(提供 VITE_BUILD_COMPRESSION)。
+ */
+export function createPlugins(isCSR: boolean, isProd: boolean, env: ImportMetaEnv): PluginOption[] {
+    return [
+        // React 处理:CSR 用官方 plugin-react;SSG 用 reactRouter(自带 React 处理并接管入口)。二者互斥。
+        isCSR ? react() : reactRouter(),
+        // clientChunksPlugin 仅 SSG 需要(framework 分 client/ssr 双环境构建);
+        // CSR 单环境构建,vendor 分包直接写在 build.rollupOptions 里。
+        !isCSR && clientChunksPlugin,
+
+        // wasm(),
+        // topLevelAwait(),
+
+        legacy({
+            // 浏览器支持基线(browserslist 语法):chrome87/safari13/firefox78/edge88 这条老线,
+            // 与 build.ts 的 CSS_TARGET(esbuild 语法)表达同一基线,需保持一致。
+            // legacy() 会为该基线生成 nomodule 的 legacy chunk + polyfill,并用 Babel 降级现代 JS 语法,
+            // 使产物可在这些老浏览器上运行(现代浏览器仍走 type=module 的现代 chunk)。
+            targets: ['chrome >= 87', 'safari >= 13', 'firefox >= 78', 'edge >= 88', 'not IE 11'],
+        }),
+        isProd &&
+            removeConsole({
+                includes: ['log', 'warn'],
+                external: ['error'],
+            }),
+        svgConvert(),
+        viteBuildInfo(),
+        configCompressPlugin(env.VITE_BUILD_COMPRESSION!),
+    ].filter(Boolean) as PluginOption[];
+}

+ 45 - 0
build/config/server.ts

@@ -0,0 +1,45 @@
+import type { ServerOptions } from 'vite';
+
+/**
+ * 开发服务器配置(含 API 代理)。依赖运行期 env(端口、代理目标),故以工厂函数导出。
+ * 代理 /dev/api/v1 → VITE_DEV_PROXY_TARGET_API_BASE_URL,并 rewrite 掉前缀。
+ * @param env 处理后的环境变量(wrapperEnv 产物)。
+ */
+export function createServerOptions(env: ImportMetaEnv): ServerOptions {
+    return {
+        port: env.VITE_DEV_PORT,
+        host: '0.0.0.0',
+        open: true,
+        proxy: {
+            '/dev/api/v1': {
+                target: env.VITE_DEV_PROXY_TARGET_API_BASE_URL,
+                changeOrigin: true,
+                rewrite: (path) => path.replace(/^\/dev\/api\/v1/, ''),
+                secure: false,
+                configure: (proxy) => {
+                    proxy.on('error', (err) => {
+                        console.log('proxy error', err);
+                    });
+                    proxy.on('proxyReq', (_, req) => {
+                        console.log(
+                            'Sending Request to the Target:',
+                            req.method,
+                            req.url,
+                            env.VITE_DEV_PROXY_TARGET_API_BASE_URL
+                        );
+                    });
+                    proxy.on('proxyRes', (proxyRes, req) => {
+                        console.log(
+                            'Received Response from the Target:',
+                            proxyRes.statusCode,
+                            req.url
+                        );
+                    });
+                },
+            },
+        },
+        warmup: {
+            clientFiles: ['./index.html', './src/{pages,components}/*'],
+        },
+    };
+}

+ 21 - 16
package.json

@@ -12,12 +12,23 @@
     "ios >= 9"
   ],
   "scripts": {
-    "dev": "vite --mode localdev",
+    "dev": "npm run dev:ssg",
+    "dev:ssg": "cross-env APP_RENDER_MODE=ssg react-router dev --mode localdev",
+    "dev:csr": "cross-env APP_RENDER_MODE=csr vite --mode localdev",
     "build": "npm run build:prod",
-    "build:dev": "tsc && vite build --mode development",
-    "build:prod": "tsc && vite build --mode production",
-    "build:test": "tsc && vite build --mode test",
-    "preview": "vite preview --mode localdev",
+    "build:prod": "npm run build:ssg:prod",
+    "build:dev": "npm run build:ssg:dev",
+    "build:test": "npm run build:ssg:test",
+    "build:ssg:dev": "cross-env APP_RENDER_MODE=ssg react-router build --mode development",
+    "build:ssg:prod": "cross-env APP_RENDER_MODE=ssg react-router build --mode production",
+    "build:ssg:test": "cross-env APP_RENDER_MODE=ssg react-router build --mode test",
+    "build:csr:dev": "cross-env APP_RENDER_MODE=csr tsc && cross-env APP_RENDER_MODE=csr vite build --mode development",
+    "build:csr:prod": "cross-env APP_RENDER_MODE=csr tsc && cross-env APP_RENDER_MODE=csr vite build --mode production",
+    "build:csr:test": "cross-env APP_RENDER_MODE=csr tsc && cross-env APP_RENDER_MODE=csr vite build --mode test",
+    "typecheck": "react-router typegen && tsc",
+    "preview": "npm run preview:ssg",
+    "preview:ssg": "sirv dist/client --single --port 8848",
+    "preview:csr": "sirv dist --single --port 8848",
     "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
     "lint:fix": "eslint . --fix && prettier --write \"src/**/*.{ts,tsx,js,jsx}\"",
     "stylelint": "stylelint \"src/**/*.{css,less,scss,vue}\"",
@@ -33,6 +44,7 @@
     "@iconify-icons/mdi": "^1.2.48",
     "@iconify-icons/ri": "^1.2.10",
     "@iconify/react": "^5.2.1",
+    "@react-router/node": "7.13.0",
     "antd": "^5.29.3",
     "axios": "^1.13.5",
     "brotli-wasm": "3.0.1",
@@ -45,6 +57,7 @@
     "firebase": "^11.10.0",
     "i18next": "^23.16.8",
     "i18next-browser-languagedetector": "^8.2.0",
+    "isbot": "^5",
     "js-cookie": "^3.0.5",
     "lodash-es": "^4.17.23",
     "qrcode.react": "4.2.0",
@@ -54,6 +67,7 @@
     "react-dom": "^18.3.1",
     "react-i18next": "^14.1.3",
     "react-markdown": "10.1.0",
+    "react-router": "7.13.0",
     "react-router-dom": "^7.13.0",
     "remark-gfm": "4.0.1"
   },
@@ -62,6 +76,7 @@
     "@commitlint/config-conventional": "^19.8.0",
     "@eslint/js": "^9.21.0",
     "@iconify/tools": "^4.1.2",
+    "@react-router/dev": "7.13.0",
     "@types/crypto-js": "^4.2.2",
     "@types/file-saver": "^2.0.7",
     "@types/js-cookie": "^3.0.6",
@@ -96,6 +111,7 @@
     "postcss-nesting": "^13.0.1",
     "postcss-scss": "^4.0.9",
     "prettier": "^3.5.3",
+    "sirv-cli": "3.0.1",
     "stylelint": "^15.11.0",
     "stylelint-config-recommended-less": "2.0.0",
     "stylelint-config-standard": "^30.0.0",
@@ -126,16 +142,5 @@
       "stylelint --fix",
       "prettier --write"
     ]
-  },
-  "pnpm": {
-    "onlyBuiltDependencies": [
-      "@firebase/util",
-      "@parcel/watcher",
-      "@swc/core",
-      "core-js",
-      "esbuild",
-      "protobufjs",
-      "unrs-resolver"
-    ]
   }
 }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 482 - 11
pnpm-lock.yaml


+ 11 - 0
pnpm-workspace.yaml

@@ -0,0 +1,11 @@
+# pnpm 10 起,onlyBuiltDependencies 等设置从 package.json 的 "pnpm" 字段迁移到此文件。
+# 详见 https://pnpm.io/settings
+# 仅允许以下依赖执行安装脚本(build/postinstall),其余默认被阻止,属安全加固。
+onlyBuiltDependencies:
+  - '@firebase/util'
+  - '@parcel/watcher'
+  - '@swc/core'
+  - core-js
+  - esbuild
+  - protobufjs
+  - unrs-resolver

+ 30 - 0
react-router.config.ts

@@ -0,0 +1,30 @@
+import { printBuildComplete } from './build/buildReport';
+
+import type { Config } from '@react-router/dev/config';
+
+/**
+ * React Router v7 framework 模式配置。
+ *
+ * - appDirectory: 源码根目录,routes.ts / root.tsx / entry.client.tsx 均位于此。
+ * - ssr: false —— 纯静态预渲染(SPA + prerender),不部署常驻 Node 服务,对 CF 加速零影响。
+ *   注意:ssr:false 仅关闭「运行时」服务端渲染;构建时仍会在 Node 端预渲染 root 及 prerender 列表中的路由,
+ *   因此路由代码必须 SSR 安全(不能在渲染期直接访问 window 等浏览器 API)。
+ * - prerender: 预渲染的静态路径列表。多语言采用路径前缀(/en/...),当前仅启用 en-US,
+ *   将来启用其它语言时把对应 /xx/... 路径追加进来即可。未列出的路径由 SPA fallback 承载。
+ */
+export default {
+    appDirectory: 'src',
+    // 输出目录改为 dist(默认是 build,会与项目里存放自研 Vite 插件的 build/ 目录冲突并被清空)。
+    // 产物结构:dist/client(静态站点,含预渲染 HTML 与资源)、dist/server(构建期预渲染用,ssr:false 时运行时不部署)。
+    buildDirectory: 'dist',
+    ssr: false,
+    async prerender() {
+        return ['/en/home', '/en/privacy', '/en/terms-of-service'];
+    },
+    // SSG 构建收尾。buildEnd 晚于 client/ssr 双环境构建、prerender、删除 server 目录等全部步骤,
+    // 是整个 react-router build 的最后一个钩子,「🎉 恭喜打包完成」在此打印才真正是最后一行。
+    // 体积统计客户端产物目录 dist/client(此时 dist/server 已因 ssr:false 被删)。
+    async buildEnd({ reactRouterConfig }) {
+        await printBuildComplete(`${reactRouterConfig.buildDirectory}/client`);
+    },
+} satisfies Config;

+ 23 - 0
src/components/AppLink/index.tsx

@@ -0,0 +1,23 @@
+import { forwardRef } from 'react';
+
+import { Link, useParams, type LinkProps } from 'react-router-dom';
+
+import { withLangPrefix } from '@/hooks/useAppNavigate';
+
+/**
+ * 语言前缀感知的 <Link>,屏蔽 CSR / SSG 两种渲染模式的路由差异(配套 useAppNavigate)。
+ *
+ * 用法与 react-router 的 <Link> 一致,但 to 只传**不含语言前缀的逻辑路径**(如 'home'、'/privacy'):
+ * - SSG 模式:自动补当前语言前缀 → /<lang>/home。
+ * - CSR 模式:原样 → /home。
+ *
+ * 仅用于内部路由。外链请直接用 <a href>。to 目前仅支持字符串形式。
+ */
+export const AppLink = forwardRef<HTMLAnchorElement, LinkProps & { to: string }>(
+    ({ to, ...rest }, ref) => {
+        const { lang } = useParams();
+        return <Link ref={ref} to={withLangPrefix(to, lang)} {...rest} />;
+    }
+);
+
+AppLink.displayName = 'AppLink';

+ 16 - 13
src/components/Footerbar/index.tsx

@@ -2,20 +2,24 @@ import { memo } from 'react';
 
 // import { Icon } from '@iconify/react';
 import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
 
+import flashLinkLogoIcon from '@/assets/images/home/flash-link-logo-icon.png';
+import { useAppNavigate } from '@/hooks/useAppNavigate';
 // import logoUnion from '@/assets/iconify/multi-color/logo-union.svg';
-import flashLinkLogoIcon from '@/assets/images/home/flash-link-logo-icon.png'
-
 
 const Footerbar = memo(() => {
     const { t } = useTranslation();
-    const navigate = useNavigate();
+    // 语言前缀由 useAppNavigate 按渲染模式自动补齐,此处只传逻辑路径。
+    const navigate = useAppNavigate();
 
-    const navLinks: { path: string; labelKey: string; href?: string }[] = [
-        { path: '/privacy', labelKey: 'components.footerbar.privacyPolicy' },
-        { path: '/terms-of-service', labelKey: 'components.footerbar.termsOfService' },
-        { path: '/contact', labelKey: 'components.footerbar.contact', href: 'mailto:support@flashlink.me' },
+    const navLinks: { segment: string; labelKey: string; href?: string }[] = [
+        { segment: 'privacy', labelKey: 'components.footerbar.privacyPolicy' },
+        { segment: 'terms-of-service', labelKey: 'components.footerbar.termsOfService' },
+        {
+            segment: 'contact',
+            labelKey: 'components.footerbar.contact',
+            href: 'mailto:support@flashlink.me',
+        },
     ];
 
     return (
@@ -39,10 +43,10 @@ const Footerbar = memo(() => {
 
                     {/* Navigation Links */}
                     <nav className="flex items-center justify-center gap-8">
-                        {navLinks.map(({ path, labelKey, href }) =>
+                        {navLinks.map(({ segment, labelKey, href }) =>
                             href ? (
                                 <a
-                                    key={path}
+                                    key={segment}
                                     href={href}
                                     className="text-sm font-normal leading-[1.43] text-white/60 hover:text-white transition-colors"
                                 >
@@ -50,8 +54,8 @@ const Footerbar = memo(() => {
                                 </a>
                             ) : (
                                 <button
-                                    key={path}
-                                    onClick={() => navigate(path)}
+                                    key={segment}
+                                    onClick={() => navigate(segment)}
                                     className="text-sm font-normal leading-[1.43] text-white/60 hover:text-white transition-colors border-none bg-transparent whitespace-nowrap"
                                 >
                                     {t(labelKey)}
@@ -68,4 +72,3 @@ const Footerbar = memo(() => {
 Footerbar.displayName = 'Footerbar';
 
 export default Footerbar;
-

+ 6 - 2
src/components/Topbar/useService.ts

@@ -1,7 +1,7 @@
 import { useCallback, useMemo } from 'react';
 
 import { useTranslation } from 'react-i18next';
-import { matchPath, useLocation } from 'react-router-dom';
+import { matchPath, useLocation, useParams } from 'react-router-dom';
 
 import { getNavMenuItems, type NavMenuItem } from '@/utils/navUtils';
 
@@ -12,8 +12,12 @@ import { getNavMenuItems, type NavMenuItem } from '@/utils/navUtils';
 export function useService() {
     const { t } = useTranslation();
     const location = useLocation();
+    // SSG 模式路由含 :lang 段 → lang 有值,菜单生成 /<lang>/xxx;
+    // CSR 模式路由无 :lang 段 → lang 为 undefined,菜单生成无前缀的 /xxx。
+    // 两种模式共用本组件,getNavMenuItems 依据 lang 是否存在自动适配。
+    const { lang } = useParams();
 
-    const menuItems = useMemo(() => getNavMenuItems(), []);
+    const menuItems = useMemo(() => getNavMenuItems(lang), [lang]);
 
     const isActive = useCallback(
         (path: string): boolean => {

+ 37 - 0
src/defines/appRoutes.ts

@@ -0,0 +1,37 @@
+/**
+ * CSR 业务路由的「纯数据」单一数据源(SSOT)。
+ *
+ * 关键约束:本文件**不 import 任何组件**,只声明路由元信息。
+ * 这样 router/routes.tsx(拼装路由+组件)、utils/navUtils.ts(CSR 菜单反推)、
+ * router/titles.ts(path→locale)都能消费它,而不必反向 import routes.tsx,
+ * 从根本上避免 navUtils → routes.tsx → BasicLayout → Topbar → useService → navUtils 的循环依赖。
+ *
+ * 覆盖范围:仅 BasicLayout 下、既进菜单又需 path→locale 的业务页。
+ * error(403/500/404)、to、index 重定向等不进菜单、结构特殊,仍在 routes.tsx 硬编码,不纳入此清单。
+ *
+ * 字段:
+ * - name: 路由唯一标识,同时作为 routes.tsx 里 name→组件 映射的 key。
+ * - path: 逻辑路径段(不含前导斜杠、不含语言前缀),如 'home' / 'terms-of-service'。
+ * - locale: i18n key(菜单标签、文档标题共用)。
+ * - hideInMenu: 是否在导航菜单中隐藏,默认 false。
+ *
+ * 新增业务页:在此追加一项,并到 routes.tsx 的组件映射里补对应组件即可。
+ */
+export interface AppRouteMeta {
+    name: string;
+    path: string;
+    locale: string;
+    hideInMenu?: boolean;
+}
+
+export const APP_ROUTE_METAS: AppRouteMeta[] = [
+    { name: 'home', path: 'home', locale: 'menus.home' },
+    { name: 'pricing', path: 'pricing', locale: 'menus.pricing' },
+    { name: 'privacyPolicy', path: 'privacy', locale: 'menus.privacyPolicy', hideInMenu: true },
+    {
+        name: 'termsOfService',
+        path: 'terms-of-service',
+        locale: 'menus.termsOfService',
+        hideInMenu: true,
+    },
+];

+ 21 - 0
src/defines/navMenu.ts

@@ -0,0 +1,21 @@
+/**
+ * 顶部导航菜单项定义。
+ *
+ * framework 模式迁移前,菜单项是从旧的 library 路由配置(src/router/routes.tsx)反推的
+ * (取 hideInMenu !== true 的路由)。迁移后路由配置改为 src/routes.ts(不同格式),
+ * 且采用路径前缀多语言(/:lang/xxx),菜单不再适合从路由配置反推,故独立定义于此。
+ *
+ * - segment: 相对路径段(不含语言前缀),由 Topbar 结合当前 :lang 拼成完整路径 /<lang>/<segment>。
+ * - locale: i18n key,缺省回退到 name。
+ * 新增可在菜单显示的页面时,在此追加即可。
+ */
+export interface NavMenuDef {
+    name: string;
+    segment: string;
+    locale: string;
+}
+
+export const NAV_MENU_ITEMS: NavMenuDef[] = [
+    { name: 'home', segment: 'home', locale: 'menus.home' },
+    { name: 'pricing', segment: 'pricing', locale: 'menus.pricing' },
+];

+ 17 - 0
src/entry.client.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+import ReactDOM from 'react-dom/client';
+import { HydratedRouter } from 'react-router/dom';
+
+/**
+ * 客户端入口(取代原 main.tsx)。framework 模式下保持最小化:
+ * 仅把预渲染出的 HTML 文档 hydrate 为可交互的 React 应用。
+ * 所有 Provider、样式、i18n 初始化均在 root.tsx 完成;
+ * 语言随路由 /:lang 段切换的逻辑在路由层处理。
+ */
+ReactDOM.hydrateRoot(
+    document,
+    <React.StrictMode>
+        <HydratedRouter />
+    </React.StrictMode>
+);

+ 92 - 0
src/entry.server.tsx

@@ -0,0 +1,92 @@
+/**
+ * 服务端渲染入口(构建期预渲染使用)。
+ *
+ * 为什么显式提供而非用 @react-router/dev 的默认入口:
+ * 默认 entry.server.node.tsx 位于 @react-router/dev 包的嵌套 node_modules 中,pnpm 严格隔离下
+ * 它无法从自身位置解析到 `react-router`(报 "Cannot find module 'react-router'")。把同样的入口
+ * 放到项目 src 下,其 import 从项目根 node_modules 正常解析,问题消除。
+ *
+ * 注意:ssr:false 时运行时无服务端渲染,但构建期仍用本入口把 root 与 prerender 路由渲染成静态 HTML。
+ * 内容与官方默认模板一致,仅位置不同。
+ */
+import { PassThrough } from 'node:stream';
+
+import { createReadableStreamFromReadable } from '@react-router/node';
+import { isbot } from 'isbot';
+import { renderToPipeableStream } from 'react-dom/server';
+import { ServerRouter } from 'react-router';
+
+import type { RenderToPipeableStreamOptions } from 'react-dom/server';
+import type { AppLoadContext, EntryContext } from 'react-router';
+
+export const streamTimeout = 5_000;
+
+export default function handleRequest(
+    request: Request,
+    responseStatusCode: number,
+    responseHeaders: Headers,
+    routerContext: EntryContext,
+
+    _loadContext: AppLoadContext
+) {
+    // https://httpwg.org/specs/rfc9110.html#HEAD
+    if (request.method.toUpperCase() === 'HEAD') {
+        return new Response(null, {
+            status: responseStatusCode,
+            headers: responseHeaders,
+        });
+    }
+
+    return new Promise((resolve, reject) => {
+        let shellRendered = false;
+        const userAgent = request.headers.get('user-agent');
+
+        // 爬虫请求与 SPA/静态生成需等全部内容就绪后再响应
+        const readyOption: keyof RenderToPipeableStreamOptions =
+            (userAgent && isbot(userAgent)) || routerContext.isSpaMode
+                ? 'onAllReady'
+                : 'onShellReady';
+
+        let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
+            () => abort(),
+            streamTimeout + 1000
+        );
+
+        const { pipe, abort } = renderToPipeableStream(
+            <ServerRouter context={routerContext} url={request.url} />,
+            {
+                [readyOption]() {
+                    shellRendered = true;
+                    const body = new PassThrough({
+                        final(callback) {
+                            clearTimeout(timeoutId);
+                            timeoutId = undefined;
+                            callback();
+                        },
+                    });
+                    const stream = createReadableStreamFromReadable(body);
+
+                    responseHeaders.set('Content-Type', 'text/html');
+
+                    pipe(body);
+
+                    resolve(
+                        new Response(stream, {
+                            headers: responseHeaders,
+                            status: responseStatusCode,
+                        })
+                    );
+                },
+                onShellError(error: unknown) {
+                    reject(error);
+                },
+                onError(error: unknown) {
+                    responseStatusCode = 500;
+                    if (shellRendered) {
+                        console.error(error);
+                    }
+                },
+            }
+        );
+    });
+}

+ 62 - 0
src/hooks/useAppNavigate.ts

@@ -0,0 +1,62 @@
+import { useCallback } from 'react';
+
+import { useNavigate, useParams, type NavigateOptions } from 'react-router-dom';
+
+import { DEFAULT_LANG } from '@/i18n/langConfig';
+
+/**
+ * 统一的"语言前缀感知"导航封装,屏蔽 CSR / SSG 两种渲染模式的路由差异。
+ *
+ * 背景:项目支持两种渲染模式(见 docs/SSG.md),路由结构不同:
+ * - SSG(framework)模式:路由为 /:lang/xxx(如 /en/home),内部跳转需带语言前缀。
+ * - CSR 模式:路由为 /xxx(如 /home),无语言前缀。
+ *
+ * 渲染模式在**构建期**由 vite.config 的 define 注入为编译期常量 import.meta.env.VITE_RENDER_MODE,
+ * 因此这里直接按模式区分,而非靠运行时 useParams().lang 是否有值来"猜"模式
+ * (SSG 下若当前不在 :lang 路由,useParams().lang 也会是 undefined,那种判断会误判)。
+ *
+ * 调用方一律传**不含语言前缀的逻辑路径**(如 'home'、'/privacy'),前缀由本封装按模式补齐。
+ */
+
+/** 默认语言短码(如 en-US → en),SSG 下当前无 :lang 段时的回退前缀。 */
+const DEFAULT_LANG_SEGMENT = DEFAULT_LANG.split('-')[0];
+
+const isSSG = import.meta.env.VITE_RENDER_MODE !== 'csr';
+
+/** 把逻辑路径归一为以单个 '/' 开头、无重复斜杠的形式('home' / '/home' → '/home')。 */
+function normalizePath(to: string): string {
+    return `/${to.replace(/^\/+/, '')}`;
+}
+
+/**
+ * 给逻辑路径补语言前缀。
+ * - SSG 模式:/<lang><path>(lang 缺省用默认语言短码)。
+ * - CSR 模式:原样返回(无前缀)。
+ * @param to 不含语言前缀的逻辑路径,如 'home' 或 '/privacy'。
+ * @param lang 当前语言短码;缺省回退默认语言短码。CSR 模式下忽略此参数。
+ */
+export function withLangPrefix(to: string, lang?: string): string {
+    const path = normalizePath(to);
+    if (!isSSG) return path;
+    const seg = lang || DEFAULT_LANG_SEGMENT;
+    return `/${seg}${path}`;
+}
+
+/**
+ * 语言前缀感知的 navigate。用法与 react-router 的 navigate 一致,但 to 只传逻辑路径(不带语言前缀)。
+ * SSG 下自动补当前语言前缀,CSR 下原样跳转。
+ *
+ * 注意:仅处理**内部 SPA 路由**跳转。外链(http/mailto)、整页跳转请直接用 <a> / window.location。
+ */
+export function useAppNavigate() {
+    const navigate = useNavigate();
+    // SSG 下取当前 URL 的语言段以保持同语言导航;CSR 下 useParams 无 lang,忽略。
+    const { lang } = useParams();
+
+    return useCallback(
+        (to: string, options?: NavigateOptions) => {
+            navigate(withLangPrefix(to, lang), options);
+        },
+        [navigate, lang]
+    );
+}

+ 90 - 0
src/i18n/frameworkI18n.ts

@@ -0,0 +1,90 @@
+/**
+ * React Router framework 模式(SSG / 路径前缀多语言)专用的 i18n 配置。
+ *
+ * 与旧的 ./index.ts 的区别,以及为什么另起一份:
+ *   - 旧配置面向纯 CSR 单页应用:用 LanguageDetector 从 querystring/navigator 探测语言,
+ *     且初始语言包异步加载完成后(i18nReady.then)才挂载 React。这套「异步就绪再渲染」的模型
+ *     与 SSG 的同步渲染冲突,且 Node 端没有 querystring/navigator。
+ *   - 本配置面向 framework 模式:语言由路由 /:lang 段决定(见 routes),因此
+ *       1) 初始语言固定为默认语言(en-US),资源同步内联,i18n.init 同步完成 → SSG 端可直接渲染;
+ *       2) 不使用 LanguageDetector;
+ *       3) hydrate 后由路由层调用 loadAndChangeLanguage() 切到 URL 指定的语言(其它语言懒加载)。
+ *
+ * 旧的 ./index.ts 保留不动,将来若再做纯 CSR 单页应用可继续复用。
+ */
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import enUS from '@/locales/en-US';
+
+import { DEFAULT_LANG, ENABLED_LANGS } from './langConfig';
+import { getLangTarget } from './langMap';
+
+/**
+ * 非默认语言的懒加载入口。默认语言(en-US)已同步内联,不在此列。
+ * import.meta.glob 不 eager,保证这些语言包按需加载、不进首屏包。
+ */
+const localeLoaders = import.meta.glob([
+    '@/locales/*.ts',
+    '!@/locales/dirMap.ts',
+    '!@/locales/en-US.ts', // 默认语言已在上方静态内联,排除以避免「既静态又动态 import」告警
+]);
+
+const languageFiles: Record<string, () => Promise<unknown>> = {};
+for (const [path, loader] of Object.entries(localeLoaders)) {
+    const lang = path.split('/').pop()?.replace('.ts', '') || '';
+    if (lang === DEFAULT_LANG) continue; // 默认语言已内联
+    if (!ENABLED_LANGS.includes(lang as (typeof ENABLED_LANGS)[number])) continue;
+    languageFiles[lang] = loader;
+}
+
+// 同步初始化:resources 里已内联默认语言,i18next 在此情形下 init 是同步完成的,
+// 因此本模块被 import 后 i18n 立即可用于 SSG 端渲染,无需等待任何 Promise。
+i18n.use(initReactI18next).init({
+    lng: DEFAULT_LANG,
+    fallbackLng: DEFAULT_LANG,
+    supportedLngs: [...ENABLED_LANGS],
+    resources: {
+        [DEFAULT_LANG]: { translation: enUS },
+    },
+    interpolation: {
+        escapeValue: false,
+        prefix: '{',
+        suffix: '}',
+    },
+    // 关闭异步初始化路径,确保同步就绪
+    initImmediate: false,
+});
+
+/**
+ * 加载指定语言的翻译包(若未加载)并切换到该语言。
+ * 供路由层在客户端 hydrate 后,根据 URL 的 /:lang 段调用。
+ * 仅在浏览器端执行按需加载;SSG 端不会走到这里。
+ * @param rawLang URL 中的语言段(如 'en'、'zh'),内部经 getLangTarget 归一化为完整 locale。
+ */
+export async function loadAndChangeLanguage(rawLang: string): Promise<void> {
+    const lang = getLangTarget(rawLang);
+
+    if (!i18n.hasResourceBundle(lang, 'translation') && languageFiles[lang]) {
+        try {
+            const mod = (await languageFiles[lang]()) as { default: Record<string, unknown> };
+            i18n.addResourceBundle(lang, 'translation', mod.default, true, true);
+        } catch (error) {
+            console.error(`i18n: failed to load language "${lang}"`, error);
+            return;
+        }
+    }
+
+    if (i18n.language !== lang) {
+        await i18n.changeLanguage(lang);
+    }
+}
+
+/** 把 URL 语言段归一化为受支持的 locale;不支持时回退默认语言。 */
+export function resolveLang(rawLang: string | undefined): string {
+    if (!rawLang) return DEFAULT_LANG;
+    const target = getLangTarget(rawLang);
+    return (ENABLED_LANGS as readonly string[]).includes(target) ? target : DEFAULT_LANG;
+}
+
+export default i18n;

+ 18 - 0
src/i18n/langConfig.ts

@@ -0,0 +1,18 @@
+/**
+ * 语言相关的纯常量配置。
+ *
+ * 独立成文件的关键原因:这些常量被跨渲染模式的共享代码引用(如 hooks/useAppNavigate),
+ * 而 frameworkI18n.ts 顶层静态 import 了默认语言包('@/locales/en-US')并含 i18n.init 副作用。
+ * 若共享代码为拿常量而 import frameworkI18n,会把 en-US 的静态 import 拖进 CSR 构建图,
+ * 与 i18n/index.ts 对 en-US 的动态 import 冲突,触发 rollup 的「既静态又动态 import」告警。
+ * 本文件零 import、零副作用,供任意模式安全引用。
+ */
+
+/** 默认语言:SSG 预渲染与首屏 hydrate 的基准语言,其资源同步内联保证同步 init。 */
+export const DEFAULT_LANG = 'en-US';
+
+/**
+ * 当前启用的语言。临时只启用 en-US;恢复多语言时把对应语言加进来,
+ * 并确保 languageFiles 里有对应的懒加载入口即可。
+ */
+export const ENABLED_LANGS = ['en-US'] as const;

+ 4 - 3
src/pages/error/403.tsx

@@ -1,9 +1,10 @@
 import { Button, Result } from 'antd';
 import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
+
+import { useAppNavigate } from '@/hooks/useAppNavigate';
 
 const Forbidden = () => {
-    const navigate = useNavigate();
+    const navigate = useAppNavigate();
     const { t } = useTranslation();
 
     return (
@@ -12,7 +13,7 @@ const Forbidden = () => {
             title={t('pages.error.403.title')}
             subTitle={t('pages.error.403.subtitle')}
             extra={
-                <Button type="primary" onClick={() => navigate('/')}>
+                <Button type="primary" onClick={() => navigate('home')}>
                     {t('pages.error.403.backHome')}
                 </Button>
             }

+ 4 - 3
src/pages/error/404.tsx

@@ -1,9 +1,10 @@
 import { Button, Result } from 'antd';
 import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
+
+import { useAppNavigate } from '@/hooks/useAppNavigate';
 
 const NotFound = () => {
-    const navigate = useNavigate();
+    const navigate = useAppNavigate();
     const { t } = useTranslation();
 
     return (
@@ -12,7 +13,7 @@ const NotFound = () => {
             title={t('pages.error.404.title')}
             subTitle={t('pages.error.404.subtitle')}
             extra={
-                <Button type="primary" onClick={() => navigate('/')}>
+                <Button type="primary" onClick={() => navigate('home')}>
                     {t('pages.error.404.backHome')}
                 </Button>
             }

+ 4 - 3
src/pages/error/500.tsx

@@ -1,9 +1,10 @@
 import { Button, Result } from 'antd';
 import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
+
+import { useAppNavigate } from '@/hooks/useAppNavigate';
 
 const ServerError = () => {
-    const navigate = useNavigate();
+    const navigate = useAppNavigate();
     const { t } = useTranslation();
 
     return (
@@ -12,7 +13,7 @@ const ServerError = () => {
             title={t('pages.error.500.title')}
             subTitle={t('pages.error.500.subtitle')}
             extra={
-                <Button type="primary" onClick={() => navigate('/')}>
+                <Button type="primary" onClick={() => navigate('home')}>
                     {t('pages.error.500.backHome')}
                 </Button>
             }

+ 9 - 6
src/pages/redirect/index.tsx

@@ -1,7 +1,8 @@
 import React, { useEffect } from 'react';
 
-import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useSearchParams } from 'react-router-dom';
 
+import { useAppNavigate } from '@/hooks/useAppNavigate';
 import { userConfigModel } from '@/models/userConfigModel';
 import { removeToken, setToken } from '@/utils/authUtils';
 import { decryptUrlParams } from '@/utils/requestCrypto';
@@ -20,7 +21,9 @@ const decryptRedirectParams = async (
 };
 
 const Redirect: React.FC = () => {
-    const navigate = useNavigate();
+    // 语言前缀由 useAppNavigate 按渲染模式自动补齐;'/to' 不在 :lang 段下,
+    // SSG 模式会回退默认语言短码(跳 /en/home),CSR 模式无前缀(跳 /home)。
+    const navigate = useAppNavigate();
     const [searchParams] = useSearchParams();
 
     const { setUserConfig } = userConfigModel.useModel();
@@ -31,7 +34,7 @@ const Redirect: React.FC = () => {
 
         // 如果没有重定向参数,默认跳转到 home 页
         if (!redirectParam) {
-            navigate('/home', { replace: true });
+            navigate('home', { replace: true });
             return;
         }
 
@@ -43,7 +46,7 @@ const Redirect: React.FC = () => {
                 console.log('🚀 ~ handleRedirect ~ decryptedData:', decryptedData);
                 if (!decryptedData) {
                     // 解密失败,跳转到 home 页
-                    navigate('/home', { replace: true });
+                    navigate('home', { replace: true });
                     return;
                 }
 
@@ -60,12 +63,12 @@ const Redirect: React.FC = () => {
                 if (redirectPath?.startsWith('http')) {
                     window.location.replace(redirectPath);
                 } else {
-                    navigate(redirectPath || '/home', { replace: true });
+                    navigate(redirectPath || 'home', { replace: true });
                 }
             } catch (error) {
                 console.error('处理重定向参数失败:', error);
                 // 出错时跳转到 home 页
-                navigate('/home', { replace: true });
+                navigate('home', { replace: true });
             }
         };
 

+ 91 - 0
src/root.tsx

@@ -0,0 +1,91 @@
+import { App as AntdApp, ConfigProvider, theme } from 'antd';
+import enUS from 'antd/locale/en_US';
+import faIR from 'antd/locale/fa_IR';
+import zhCN from 'antd/locale/zh_CN';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import { I18nextProvider, useTranslation } from 'react-i18next';
+import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
+
+import { DialogContainer } from '@/components/Dialog/DialogContainer';
+import i18n from '@/i18n/frameworkI18n';
+import { AntdAppInstanceCapture } from '@/utils/antdAppInstance';
+import models from '@/utils/model/autoImportModels';
+
+import './styles/antd.scss';
+import './styles/global.scss';
+import './styles/tailwind.css';
+
+// 配置 dayjs(模块级,浏览器与 SSG 端均安全)
+dayjs.extend(utc);
+
+const antdLocaleMap = {
+    'en-US': enUS,
+    'fa-IR': faIR,
+    'zh-CN': zhCN,
+};
+
+/**
+ * HTML 外壳。framework 模式由 root 的 Layout 渲染整个文档结构(取代原 index.html)。
+ * <Meta/> <Links/> 注入路由声明的 head 内容与样式/资源;<Scripts/> 注入客户端入口脚本;
+ * <ScrollRestoration/> 处理导航时的滚动位置。
+ */
+export function Layout({ children }: { children: React.ReactNode }) {
+    return (
+        <html lang="en">
+            <head>
+                <meta charSet="UTF-8" />
+                <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
+                <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+                <meta
+                    name="description"
+                    content="FlashLink VPN — lightning-fast, free-to-use VPN with high-speed low-latency connection and best-in-class privacy protection."
+                />
+                <title>FlashLink VPN</title>
+                <Meta />
+                <Links />
+            </head>
+            <body>
+                {children}
+                <ScrollRestoration />
+                <Scripts />
+            </body>
+        </html>
+    );
+}
+
+/**
+ * 应用 Provider 层(原 App.tsx 中 RouterProvider 之上的部分整体迁入)。
+ * models 通过自研 import.meta.glob 自动导入后逐层包裹,其内部状态依赖的 storage 已在底层做 SSR 守卫。
+ */
+const ModelProviders = ({ children }: { children: React.ReactNode }) =>
+    models.reduce((acc, model) => <model.Provider>{acc}</model.Provider>, children);
+
+function Providers({ children }: { children: React.ReactNode }) {
+    const { i18n: i18nInstance } = useTranslation();
+    const locale =
+        antdLocaleMap[i18nInstance.language as keyof typeof antdLocaleMap] ||
+        antdLocaleMap['en-US'];
+
+    return (
+        <ConfigProvider locale={locale} theme={{ algorithm: theme.darkAlgorithm }}>
+            <AntdApp className="h-full">
+                <AntdAppInstanceCapture />
+                <ModelProviders>
+                    {children}
+                    <DialogContainer />
+                </ModelProviders>
+            </AntdApp>
+        </ConfigProvider>
+    );
+}
+
+export default function Root() {
+    return (
+        <I18nextProvider i18n={i18n}>
+            <Providers>
+                <Outlet />
+            </Providers>
+        </I18nextProvider>
+    );
+}

+ 24 - 52
src/router/routes.tsx

@@ -1,19 +1,39 @@
 import { Navigate } from 'react-router-dom';
 
+import { APP_ROUTE_METAS } from '@/defines/appRoutes';
 import Layout from '@/layouts/BasicLayout';
 import Forbidden from '@/pages/error/403';
 import NotFound from '@/pages/error/404';
 import ServerError from '@/pages/error/500';
 import Home from '@/pages/home';
-// import RouteDemo from '@/pages/routeDemo';
-// import FeatureDemo from '@/pages/featureDemo';
-// import Pricing from '@/pages/pricing';
+import Pricing from '@/pages/pricing';
 import PrivacyPolicy from '@/pages/privacyPolicy';
 import Redirect from '@/pages/redirect';
 import TermsOfService from '@/pages/termsOfService';
 
 import type { AppRouteObject } from './types';
 
+/**
+ * name → 页面组件 的映射。key 与 defines/appRoutes.ts 的 APP_ROUTE_METAS[].name 对应。
+ * 组件在此显式 import(不走 glob 魔法),路径/菜单可见性/locale 等元信息统一在 APP_ROUTE_METAS 声明,
+ * 二者以 name 关联。新增业务页:在 APP_ROUTE_METAS 追加一项,并在此补对应组件即可。
+ */
+const PAGE_COMPONENTS: Record<string, React.ReactNode> = {
+    home: <Home />,
+    pricing: <Pricing />,
+    privacyPolicy: <PrivacyPolicy />,
+    termsOfService: <TermsOfService />,
+};
+
+// 由「纯数据清单 + 组件映射」拼出 BasicLayout 下的业务子路由,与导航菜单同源(见 utils/navUtils.ts)。
+const businessRoutes: AppRouteObject[] = APP_ROUTE_METAS.map((meta) => ({
+    name: meta.name,
+    path: `/${meta.path}`,
+    locale: meta.locale,
+    hideInMenu: meta.hideInMenu,
+    element: PAGE_COMPONENTS[meta.name],
+}));
+
 const routes: AppRouteObject[] = [
     {
         path: '/',
@@ -23,55 +43,7 @@ const routes: AppRouteObject[] = [
                 index: true,
                 element: <Navigate to="/home" replace />,
             },
-            {
-                name: 'home',
-                path: '/home',
-                element: <Home />,
-            },
-            // {
-            //     name: 'pricing',
-            //     path: '/pricing',
-            //     element: <Pricing />,
-            // },
-            {
-                name: 'privacyPolicy',
-                path: '/privacy',
-                element: <PrivacyPolicy />,
-                hideInMenu: true,
-            },
-            {
-                name: 'termsOfService',
-                path: '/terms-of-service',
-                element: <TermsOfService />,
-                hideInMenu: true,
-            },
-            // {
-            //     name: 'featureDemo',
-            //     path: '/feature-demo',
-            //     element: <FeatureDemo />,
-            // },
-            // {
-            //     name: 'routeDemo',
-            //     path: '/route-demo/:id?',
-            //     element: <RouteDemo />,
-            // },
-            // {
-            //     name: 'test',
-            //     path: '/test',
-            //     hideInMenu: true,
-            //     children: [
-            //         {
-            //             name: 'test1',
-            //             path: '/test/test1',
-            //             element: <div className="text-white">test1</div>,
-            //         },
-            //         {
-            //             name: 'test2',
-            //             path: '/test/test2',
-            //             element: <div className="text-white">test2</div>,
-            //         },
-            //     ],
-            // },
+            ...businessRoutes,
         ],
     },
     {

+ 15 - 48
src/router/titles.ts

@@ -1,44 +1,19 @@
 import i18next from 'i18next';
 import { matchPath } from 'react-router-dom';
 
-import routerConfig from './routes';
-
-import type { AppRouteObject } from './types';
+import { APP_ROUTE_METAS } from '@/defines/appRoutes';
 
 /**
- * 扁平化路由配置,提取所有有 name 的路由
+ * path → locale 映射数据源为 defines/appRoutes.ts 的 APP_ROUTE_METAS(纯数据,不 import 组件),
+ * 而非旧的 router/routes.tsx。这样 titles.ts 不再牵出
+ * routes.tsx → BasicLayout → Topbar → useService 的循环依赖。
+ *
+ * APP_ROUTE_METAS 的 path 不含前导斜杠,这里归一为以 '/' 开头,与 pathname 匹配。
  */
-function getAllRoutes(
-    routes: AppRouteObject[],
-    parentNames: string[] = []
-): Array<{
-    path: string;
-    locale: string;
-}> {
-    const result: Array<{ path: string; locale: string }> = [];
-
-    for (const route of routes) {
-        if (route.name && route.path && !route.index) {
-            const locale = route.locale || `menus.${[...parentNames, route.name].join('.')}`;
-            result.push({ path: route.path, locale });
-        }
-
-        if (route.children) {
-            const currentNames = route.name ? [...parentNames, route.name] : parentNames;
-            result.push(...getAllRoutes(route.children, currentNames));
-        }
-    }
-
-    return result;
-}
-
-let allRoutesCache: Array<{ path: string; locale: string }> | null = null;
-function getAllRoutesCache(): Array<{ path: string; locale: string }> {
-    if (!allRoutesCache) {
-        allRoutesCache = getAllRoutes(routerConfig);
-    }
-    return allRoutesCache;
-}
+const routeLocaleList: Array<{ path: string; locale: string }> = APP_ROUTE_METAS.map((meta) => ({
+    path: `/${meta.path}`,
+    locale: meta.locale,
+}));
 
 /**
  * 根据路径获取 locale key
@@ -46,24 +21,16 @@ function getAllRoutesCache(): Array<{ path: string; locale: string }> {
  * @returns locale key
  */
 export const getLocaleByPath = (pathname: string): string => {
-    const allRoutes = getAllRoutesCache();
-    // 按路径长度降序排序,优先匹配更具体的路由
-    const matchedRoute = allRoutes
-        .filter((route) => {
-            if (route.path === '*') return true;
-            return matchPath({ path: route.path, end: false }, pathname) !== null;
-        })
-        .sort((a, b) => {
-            if (a.path === '*') return 1;
-            if (b.path === '*') return -1;
-            return b.path.length - a.path.length;
-        })[0];
+    // 按路径长度降序,优先匹配更具体的路由
+    const matchedRoute = routeLocaleList
+        .filter((route) => matchPath({ path: route.path, end: false }, pathname) !== null)
+        .sort((a, b) => b.path.length - a.path.length)[0];
 
     if (matchedRoute) {
         return matchedRoute.locale;
     }
 
-    // 如果找不到对应路由,将路径转换为 i18n key
+    // 找不到对应路由时,将路径转换为 i18n key
     return `menus.${pathname.slice(1).replace(/\//g, '.')}`;
 };
 

+ 35 - 0
src/routes.ts

@@ -0,0 +1,35 @@
+import { type RouteConfig, index, layout, prefix, route } from '@react-router/dev/routes';
+
+/**
+ * React Router framework 模式路由配置。
+ *
+ * 结构:
+ *   /                      → 客户端语言重定向到 /<lang>/home(rootRedirect)
+ *   /:lang                 → 语言布局(langLayout,内含 BasicLayout + 语言切换)
+ *     /:lang/home          → 首页
+ *     /:lang/privacy       → 隐私政策
+ *     /:lang/terms-of-service → 服务条款
+ *   /to                    → 登录态重定向处理(redirect 页)
+ *   /403 /500              → 错误页
+ *   *                      → 404
+ *
+ * 多语言采用路径前缀(/en/...),prerender 列表见 react-router.config.ts。
+ * 现有页面组件默认导出 React 组件、无 loader,直接作为 route module 的 default 引用即可。
+ */
+export default [
+    index('routes/rootRedirect.tsx'),
+
+    ...prefix(':lang', [
+        layout('routes/langLayout.tsx', [
+            route('home', 'pages/home/index.tsx'),
+            route('pricing', 'pages/pricing/index.tsx'),
+            route('privacy', 'pages/privacyPolicy/index.tsx'),
+            route('terms-of-service', 'pages/termsOfService/index.tsx'),
+        ]),
+    ]),
+
+    route('to', 'pages/redirect/index.tsx'),
+    route('403', 'pages/error/403.tsx'),
+    route('500', 'pages/error/500.tsx'),
+    route('*', 'pages/error/404.tsx'),
+] satisfies RouteConfig;

+ 27 - 0
src/routes/langLayout.tsx

@@ -0,0 +1,27 @@
+import { useEffect } from 'react';
+
+import { useParams } from 'react-router';
+
+import { loadAndChangeLanguage, resolveLang } from '@/i18n/frameworkI18n';
+import BasicLayout from '@/layouts/BasicLayout';
+
+/**
+ * 语言布局路由(/:lang 段)。
+ *
+ * SSG / 首屏 hydrate 时,i18n 固定为默认语言(en-US,见 frameworkI18n),保证首屏 HTML 与
+ * 客户端首次渲染一致,避免 hydration mismatch。hydrate 完成后,此处的 effect 依据 URL 的
+ * :lang 段按需加载并切换到目标语言(其它语言懒加载)。
+ *
+ * 因此非默认语言用户会经历“默认语言首屏 → 切换到本地语言”的短暂过程,这是纯静态预渲染 +
+ * 路径前缀方案下 hydration 一致性的必然取舍;当前仅启用 en-US,实际不会发生切换。
+ */
+export default function LangLayout() {
+    const { lang } = useParams();
+
+    useEffect(() => {
+        const target = resolveLang(lang);
+        loadAndChangeLanguage(target);
+    }, [lang]);
+
+    return <BasicLayout />;
+}

+ 29 - 0
src/routes/rootRedirect.tsx

@@ -0,0 +1,29 @@
+import { useEffect } from 'react';
+
+import { useNavigate } from 'react-router';
+
+import { DEFAULT_LANG, ENABLED_LANGS } from '@/i18n/langConfig';
+import { getLangTarget } from '@/i18n/langMap';
+
+/**
+ * 根路径 `/` 的语言入口重定向(客户端执行)。
+ *
+ * 采用客户端重定向而非服务端/边缘重定向:nginx 无需任何语言逻辑,对 CF 加速零影响。
+ * SSG 端此组件仅渲染出空 HTML(root 的 SPA fallback 承载),hydrate 后读取 navigator.language,
+ * 归一化为受支持的语言并 replace 到 /<lang>;不支持时回退默认语言。
+ */
+export default function RootRedirect() {
+    const navigate = useNavigate();
+
+    useEffect(() => {
+        const detected = getLangTarget(navigator.language || DEFAULT_LANG);
+        const lang = (ENABLED_LANGS as readonly string[]).includes(detected)
+            ? detected
+            : DEFAULT_LANG;
+        // 用短码作为 URL 段(/en 而非 /en-US),与 prerender 列表保持一致
+        const seg = lang.split('-')[0];
+        navigate(`/${seg}/home`, { replace: true });
+    }, [navigate]);
+
+    return null;
+}

+ 8 - 4
src/utils/compress.ts

@@ -1,4 +1,3 @@
-import brotliWasm from 'brotli-wasm';
 import { gzipSync, gunzipSync } from 'fflate';
 
 export enum CompressFormat {
@@ -6,11 +5,16 @@ export enum CompressFormat {
     BROTLI = 'brotli',
 }
 
-let brotliModule: Awaited<typeof brotliWasm> | null = null;
+// brotli-wasm 在模块顶层用 top-level await + fetch 加载 .wasm 二进制。若在此文件顶层静态 import,
+// 预渲染(SSG,Node 端无 fetch)时会在模块求值阶段崩溃("fetch failed / not implemented")。
+// 改为在真正需要压缩时(仅浏览器端)动态 import,SSG 期间完全不触碰 brotli-wasm。
+type BrotliModule = Awaited<(typeof import('brotli-wasm'))['default']>;
+let brotliModule: BrotliModule | null = null;
 
-async function getBrotli() {
+async function getBrotli(): Promise<BrotliModule> {
     if (brotliModule) return brotliModule;
-    brotliModule = await brotliWasm;
+    const mod = await import('brotli-wasm');
+    brotliModule = await mod.default;
     return brotliModule;
 }
 

+ 3 - 5
src/utils/mdLoader.ts

@@ -1,11 +1,11 @@
+import { DEFAULT_LANG } from '@/i18n/langConfig';
+import { localeDir } from '@/locales/dirMap';
+
 /**
  * 按页面类型与语言动态加载 Markdown 文案(如服务条款、隐私政策)
  */
-
 type MdPageType = 'termsOfService' | 'privacyPolicy';
 
-const DEFAULT_LANG = 'en-US';
-
 // 匹配 @/assets/md/{pageName}_{lang}.md,如 termsOfService_en-US.md、privacyPolicy_zh-CN.md
 const rawMdModules = import.meta.glob<string>('@/assets/md/*_*.md', {
     query: '?raw',
@@ -22,8 +22,6 @@ Object.keys(rawMdModules).forEach((key) => {
     }
 });
 
-import { localeDir } from '@/locales/dirMap';
-
 const dirByLang: Record<string, 'ltr' | 'rtl'> = { ...localeDir };
 
 export interface LoadMdByLangOptions {

+ 41 - 56
src/utils/navUtils.ts

@@ -1,70 +1,55 @@
-import routerConfig from '@/router/routes';
-import { getLocaleByPath } from '@/router/titles';
-import type { AppRouteObject } from '@/router/types';
+import { APP_ROUTE_METAS } from '@/defines/appRoutes';
+import { NAV_MENU_ITEMS } from '@/defines/navMenu';
+import { withLangPrefix } from '@/hooks/useAppNavigate';
 
 export interface NavMenuItem {
     name: string;
+    /** 完整路径(含语言前缀,按渲染模式而定):SSG → /en/home;CSR → /home。 */
     path: string;
     locale?: string;
 }
 
-/**
- * 处理单个路由项,如果符合条件则添加到菜单项数组
- * @param route 路由对象
- * @param items 菜单项数组
- */
-function processRouteItem(route: AppRouteObject, items: NavMenuItem[]): void {
-    // 跳过 index 路由
-    if (route.index) {
-        return;
-    }
-
-    // 检查是否应该在菜单中显示
-    if (route.hideInMenu === true) {
-        return;
-    }
-
-    // 只有有 name 和 path 的路由才添加到菜单
-    if (route.name && route.path) {
-        const currentPath = route.path.startsWith('/') ? route.path : `/${route.path}`;
-        const locale = getLocaleByPath(currentPath);
-
-        items.push({
-            name: route.name,
-            path: currentPath,
-            locale,
-        });
-    }
+/** 渲染模式编译期常量(vite.config 的 define 注入),用于两套实现的编译期隔离。 */
+const isCSR = import.meta.env.VITE_RENDER_MODE === 'csr';
+
+// ————————————————————————————————————————————————————————————————
+// CSR 模式:从路由「纯数据」清单 APP_ROUTE_METAS 生成菜单(取 hideInMenu !== true 的项)。
+// 该清单不 import 任何组件,故不会牵出 routes.tsx→BasicLayout→Topbar 的循环依赖;
+// routes.tsx 与本文件共同消费这份清单,实现「路由与菜单单一数据源」。
+// ————————————————————————————————————————————————————————————————
+
+function getCsrMenuItems(): NavMenuItem[] {
+    return APP_ROUTE_METAS.filter((meta) => meta.hideInMenu !== true).map((meta) => ({
+        name: meta.name,
+        path: `/${meta.path}`,
+        locale: meta.locale,
+    }));
 }
 
-/**
- * 从路由配置中提取菜单项
- * 只包含 hideInMenu !== true 的路由,且只提取顶层路由(不包含子路由)
- */
-function extractMenuItems(routes: AppRouteObject[]): NavMenuItem[] {
-    const items: NavMenuItem[] = [];
-
-    for (const route of routes) {
-        // 如果路由有 children,只处理第一层 children,不递归处理更深层的子路由
-        if (route.children) {
-            for (const childRoute of route.children) {
-                processRouteItem(childRoute, items);
-            }
-            // 处理完 children 后继续下一个路由,不处理当前路由本身
-            continue;
-        }
-
-        // 如果没有 children,处理当前路由
-        processRouteItem(route, items);
-    }
-
-    return items;
+// ————————————————————————————————————————————————————————————————
+// SSG 模式:读静态定义 src/defines/navMenu.ts,路径前缀由 withLangPrefix 按当前语言补齐。
+// framework 模式的路由源(src/routes.ts)是 route()/prefix() DSL,无 name/hideInMenu 可反推,
+// 故菜单独立声明于 navMenu.ts。
+// ————————————————————————————————————————————————————————————————
+
+function getSsgMenuItems(lang?: string): NavMenuItem[] {
+    return NAV_MENU_ITEMS.map((item) => ({
+        name: item.name,
+        path: withLangPrefix(item.segment, lang),
+        locale: item.locale,
+    }));
 }
 
 /**
- * 获取导航菜单项
- * @returns 菜单项数组
+ * 获取导航菜单项。两种渲染模式各走各的来源,按编译期常量 VITE_RENDER_MODE 隔离:
+ * - CSR:从 defines/appRoutes.ts 的路由清单派生(与 router/routes.tsx 同源)。
+ * - SSG:读 defines/navMenu.ts 静态定义,withLangPrefix 补 /<lang> 前缀。
+ *
+ * 构建时非当前模式的分支会被 dead-code 消除。两套数据源都是纯数据、无组件依赖,不构成循环依赖。
+ *
+ * @param lang 当前语言短码(来自 useParams().lang)。仅 SSG 使用;CSR 忽略。
  */
-export function getNavMenuItems(): NavMenuItem[] {
-    return extractMenuItems(routerConfig);
+export function getNavMenuItems(lang?: string): NavMenuItem[] {
+    if (isCSR) return getCsrMenuItems();
+    return getSsgMenuItems(lang);
 }

+ 3 - 1
tsconfig.json

@@ -30,6 +30,8 @@
   "include": [
     "src",
     "types",
-    "vite.config.ts"
+    "vite.config.ts",
+    "react-router.config.ts",
+    "build"
   ]
 }

+ 6 - 0
types/vite-env.d.ts

@@ -56,6 +56,12 @@ interface ImportMetaEnv {
     VITE_APP_VERSION?: string;
     /**路由模式 */
     VITE_ROUTER_MODE?: 'hash' | 'history';
+    /**
+     * 渲染模式(构建期由 vite.config 的 define 注入,非 .env 变量)。
+     * 'ssg' = React Router framework 静态预渲染(路由 /:lang/xxx);'csr' = 传统单页应用(路由 /xxx)。
+     * 供 useAppNavigate 等按模式区分是否加语言前缀。
+     */
+    VITE_RENDER_MODE?: 'csr' | 'ssg';
     /**存储命名空间 */
     VITE_STORAGE_NAME_SPACE?: string;
 

+ 25 - 126
vite.config.ts

@@ -1,140 +1,39 @@
-import legacy from '@vitejs/plugin-legacy';
-import react from '@vitejs/plugin-react';
 import { defineConfig, loadEnv } from 'vite';
-import removeConsole from 'vite-plugin-remove-console';
-import topLevelAwait from 'vite-plugin-top-level-await';
-import wasm from 'vite-plugin-wasm';
 
-import { viteBuildInfo } from './build/buildInfo';
-import { configCompressPlugin } from './build/compress';
-import svgConvert from './build/svgConvert';
+import { createBuildOptions } from './build/config/build';
+import { cssOptions, optimizeDeps } from './build/config/optimize';
+import { createPlugins } from './build/config/plugins';
+import { createServerOptions } from './build/config/server';
 import { root, alias, wrapperEnv } from './build/utils';
 
+/**
+ * 渲染模式,与 --mode(环境:localdev/development/test/production)正交。
+ * 由 package.json 的 dev:csr / dev:ssg / build:csr / build:ssg 等脚本通过环境变量 APP_RENDER_MODE 传入。
+ * - 'ssg'(默认):React Router v7 framework 模式 + 静态预渲染,入口 root.tsx/entry.client.tsx,走 reactRouter() 插件。
+ * - 'csr':传统单页应用,入口 index.html/main.tsx,走 @vitejs/plugin-react。
+ * 两套插件互斥(reactRouter 会接管 React 处理),只能二选一,故按模式条件化。
+ */
+const RENDER_MODE = process.env.APP_RENDER_MODE === 'csr' ? 'csr' : 'ssg';
+const isCSR = RENDER_MODE === 'csr';
+
+// 本文件只负责组装:各配置块(plugins / server / build / optimize / css)拆到 build/config/ 下,
+// 依赖运行期变量(env / isCSR / isProd)的以工厂函数导出,此处按模式与环境注入后拼装。
 // https://vitejs.dev/config/
 export default defineConfig(({ mode }) => {
     const env = wrapperEnv(loadEnv(mode, root));
     const isProd = mode === 'production';
 
-    // @vitejs/plugin-legacy 在 config 阶段会无条件把 build.target 覆盖成含具体浏览器版本(如 safari13)的
-    // esbuild target。而 vite-plugin-top-level-await(brotli-wasm 依赖顶层 await)在转换 TLA 产物时会读取
-    // 这个 target,esbuild 0.28 无法把 TLA 生成的解构赋值降级到任何具体 safari 目标(safari13/14 均报
-    // "Transforming destructuring ... not supported yet",仅纯 ES 版本如 es2022 可通过)。
-    //
-    // 此内联插件把现代 chunk 的 build.target 钉为 es2022:
-    //   - enforce: 'post' 且排在 topLevelAwait() 之前 → 两个 post 插件间按数组顺序执行,
-    //     故本插件的 config hook 先于 TLA 运行,TLA 备份到的即 es2022,转换得以通过。
-    //   - 现代浏览器(safari13+/chrome87+)原生支持解构,es2022 产物运行无碍;
-    //     真正的旧浏览器仍由 plugin-legacy 的 legacy chunk(babel 全量转译)兜底。
-    const pinModernTargetPlugin = {
-        name: 'pin-modern-target-es2022',
-        enforce: 'post' as const,
-        config(config: { build?: { target?: unknown } }) {
-            config.build = config.build || {};
-            config.build.target = 'es2022';
-        },
-    };
-
     return {
         base: env.VITE_BUILD_PUBLIC_PATH,
-        plugins: [
-            react(),
-            wasm(),
-            pinModernTargetPlugin,
-            topLevelAwait(),
-            legacy({
-                targets: [
-                    'chrome >= 87',
-                    'safari >= 13',
-                    'firefox >= 78',
-                    'edge >= 88',
-                    'not IE 11',
-                ],
-            }),
-            isProd &&
-                removeConsole({
-                    includes: ['log', 'warn'],
-                    external: ['error'],
-                }),
-            svgConvert(),
-            viteBuildInfo(),
-            configCompressPlugin(env.VITE_BUILD_COMPRESSION!),
-        ].filter(Boolean),
-        resolve: { alias },
-        optimizeDeps: {
-            exclude: ['brotli-wasm'],
-        },
-        css: {
-            preprocessorOptions: {
-                less: {
-                    javascriptEnabled: true,
-                },
-                scss: {
-                    additionalData: `@use "@/styles/variables" as *;`,
-                },
-            },
-        },
-        server: {
-            port: env.VITE_DEV_PORT,
-            host: '0.0.0.0',
-            open: true,
-            proxy: {
-                '/dev/api/v1': {
-                    target: env.VITE_DEV_PROXY_TARGET_API_BASE_URL,
-                    changeOrigin: true,
-                    rewrite: (path) => path.replace(/^\/dev\/api\/v1/, ''),
-                    secure: false,
-                    configure: (proxy) => {
-                        proxy.on('error', (err) => {
-                            console.log('proxy error', err);
-                        });
-                        proxy.on('proxyReq', (_, req) => {
-                            console.log(
-                                'Sending Request to the Target:',
-                                req.method,
-                                req.url,
-                                env.VITE_DEV_PROXY_TARGET_API_BASE_URL
-                            );
-                        });
-                        proxy.on('proxyRes', (proxyRes, req) => {
-                            console.log(
-                                'Received Response from the Target:',
-                                proxyRes.statusCode,
-                                req.url
-                            );
-                        });
-                    },
-                },
-            },
-            warmup: {
-                clientFiles: ['./index.html', './src/{pages,components}/*'],
-            },
-        },
-        build: {
-            outDir: 'dist',
-            sourcemap: !isProd,
-            // build.target 由上方 pinModernTargetPlugin 在 config 阶段统一钉为 'es2022'(详见该插件注释),
-            // 此处不再设置,避免与之冲突。
-            // 与 plugin-legacy targets 对齐,使 CSS 压缩/转换兼容同一批浏览器(plugin-legacy 不处理 CSS)
-            cssTarget: ['chrome87', 'safari13', 'firefox78', 'edge88'],
-            rollupOptions: {
-                output: {
-                    // 产物文件名:入口/代码块用 [name]-[hash:8].js,静态资源用 [name]-[hash:8][extname]
-                    entryFileNames: 'assets/[name]-[hash].js',
-                    chunkFileNames: 'assets/[name]-[hash].js',
-                    assetFileNames: 'assets/[name]-[hash][extname]',
-                    manualChunks: {
-                        'react-vendor': ['react', 'react-dom', 'react-router-dom'],
-                        'antd-vendor': ['antd', '@ant-design/icons'],
-                        'utils-vendor': ['lodash-es', 'ramda'],
-                        'i18n-vendor': [
-                            'i18next',
-                            'react-i18next',
-                            'i18next-browser-languagedetector',
-                        ],
-                    },
-                },
-            },
-            chunkSizeWarningLimit: 1500,
+        plugins: createPlugins(isCSR, isProd, env),
+        // 把渲染模式作为编译期常量注入客户端代码,供导航封装等按模式区分处理。
+        define: {
+            'import.meta.env.VITE_RENDER_MODE': JSON.stringify(RENDER_MODE),
         },
+        resolve: { alias },
+        optimizeDeps,
+        css: cssOptions,
+        server: createServerOptions(env),
+        build: createBuildOptions(isCSR, isProd),
     };
 });

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů