mirror of
https://github.com/labring/FastGPT.git
synced 2025-12-25 20:02:47 +00:00
Some checks are pending
Document deploy / sync-images (push) Waiting to run
Document deploy / generate-timestamp (push) Blocked by required conditions
Document deploy / build-images (map[domain:https://fastgpt.cn suffix:cn]) (push) Blocked by required conditions
Document deploy / build-images (map[domain:https://fastgpt.io suffix:io]) (push) Blocked by required conditions
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.cn kube_config:KUBE_CONFIG_CN suffix:cn]) (push) Blocked by required conditions
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.io kube_config:KUBE_CONFIG_IO suffix:io]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / get-vars (push) Waiting to run
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:amd64 runs-on:ubuntu-24.04]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:arm64 runs-on:ubuntu-24.04-arm]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / release-fastgpt-images (push) Blocked by required conditions
* add manual create http toolset (#5743) * add manual create http toolset * optimize code * optimize * fix * fix * rename filename * feat: integrate ts-rest (#5741) * feat: integrate ts-rest * chore: classify core contract and pro contract * chore: update lockfile * chore: tweak dir structure * chore: tweak dir structure * update tsrest code (#5755) * doc * update tsrest code * fix http toolset (#5753) * fix http toolset * fix * perf: http toolset * fix: toolresponse result (#5760) * doc * fix: toolresponse result * fix: mongo watch * remove log * feat: integrated to minio (#5748) * feat: migrate to minio * feat: migrate apps' and dataset's avatar to minio * feat: migrate more avatars to minio * fix: lock file * feat: migrate copyright settings' logo to minio * feat: integrate minio * chore: improve code * chore: rename variables * refactor: s3 class * fix: s3 and mongo operations * chore: add session for avatar source * fix: init s3 buckets * fix: bugbot issues * expired time code * perf: avatar code * union type * export favouriteContract * empty bucket check --------- Co-authored-by: archer <545436317@qq.com> * refactor: zod schema to generate OpenAPI instead (#5771) * doc * fix: text split code (#5773) * fix: toolresponse result * remove log * stream remove * fix: text split code * fix: workflow (#5779) * fix: toolresponse result * remove log * fix: value check * fix: workflow * openapi doc * perf: bucket delete cron * doc * feat: apikey health * feat: export variables * api code move * perf: workflow performance (#5783) * perf: reactflow context * perf: workflow context split * perf: nodeList computed map * perf: nodes dependen * perf: workflow performance * workflow performance * removel og * lock * version * loop drag * reactflow size * reactflow size * fix: s3init (#5784) * doc * fix: s3init * perf: dynamic import * remove moongose dep * worker build * worker code * perf: worker build * fix: error throw * doc * doc * fix: build * fix: dockerfile * nextjs config * fix: worker * fix: build (#5791) * fix: build * vector cache code * fix: app info modal avatar upload method replace (#5787) * fix: app info modal avatar upload method replace * chore: replace all useSelectFile with useUploadAvatar * remove invalid code * add size * Update projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: heheer <heheer@sealos.io> Co-authored-by: 伍闲犬 <whoeverimf5@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1316 lines
29 KiB
Markdown
1316 lines
29 KiB
Markdown
# FastGPT i18n 客户端化优化实施计划
|
||
|
||
## 项目背景
|
||
|
||
当前 FastGPT 所有页面都使用 `getServerSideProps` + `serviceSideProps` 来加载国际化翻译,导致每次路由切换都需要服务端处理,严重影响性能。由于大部分页面不需要 SEO,可以将 i18n 迁移到客户端,消除服务端阻塞。
|
||
|
||
**预期改善**: 路由切换时间减少 40-50% (从 1560ms 降至 900ms 左右)
|
||
|
||
---
|
||
|
||
## 目标
|
||
|
||
1. ✅ 移除大部分页面的 `getServerSideProps`,改为纯客户端渲染
|
||
2. ✅ 实现客户端 i18n 按需加载和预加载策略
|
||
3. ✅ 保留需要 SEO 的页面的服务端渲染
|
||
4. ✅ 确保翻译功能完整性,无闪烁和加载失败
|
||
|
||
---
|
||
|
||
## 技术方案
|
||
|
||
### 架构对比
|
||
|
||
```
|
||
旧架构 (服务端 i18n):
|
||
用户点击路由
|
||
→ 服务端 getServerSideProps
|
||
→ 加载 i18n 文件 (阻塞)
|
||
→ 服务端渲染 HTML
|
||
→ 返回客户端水合
|
||
= 780ms+
|
||
|
||
新架构 (客户端 i18n):
|
||
用户点击路由
|
||
→ 立即显示页面骨架
|
||
→ 并行加载: bundle + i18n + 数据
|
||
→ React 渲染完成
|
||
= 400-500ms
|
||
```
|
||
|
||
### 核心技术栈
|
||
|
||
- **i18next**: 核心 i18n 库
|
||
- **react-i18next**: React 集成
|
||
- **i18next-http-backend**: HTTP 后端加载翻译文件
|
||
- **i18next-browser-languagedetector**: 浏览器语言检测
|
||
|
||
---
|
||
|
||
## 实施步骤
|
||
|
||
### Phase 1: 基础设施搭建 (第 1 周)
|
||
|
||
#### 1.1 安装依赖
|
||
|
||
```bash
|
||
cd projects/app
|
||
pnpm add i18next-browser-languagedetector i18next-http-backend
|
||
```
|
||
|
||
#### 1.2 创建客户端 i18n 配置
|
||
|
||
**文件**: `projects/app/src/web/i18n/client.ts`
|
||
|
||
```typescript
|
||
import i18n from 'i18next';
|
||
import { initReactI18next } from 'react-i18next';
|
||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||
import Backend from 'i18next-http-backend';
|
||
|
||
i18n
|
||
.use(Backend)
|
||
.use(LanguageDetector)
|
||
.use(initReactI18next)
|
||
.init({
|
||
// 默认语言
|
||
fallbackLng: 'zh',
|
||
lng: 'zh',
|
||
|
||
// 预加载核心命名空间
|
||
ns: ['common'],
|
||
defaultNS: 'common',
|
||
|
||
// 后端加载配置
|
||
backend: {
|
||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||
// 请求超时
|
||
requestOptions: {
|
||
cache: 'no-cache',
|
||
},
|
||
},
|
||
|
||
// 语言检测配置
|
||
detection: {
|
||
order: ['cookie', 'localStorage', 'navigator'],
|
||
caches: ['cookie', 'localStorage'],
|
||
cookieName: 'NEXT_LOCALE',
|
||
lookupCookie: 'NEXT_LOCALE',
|
||
lookupLocalStorage: 'NEXT_LOCALE',
|
||
},
|
||
|
||
// React 配置
|
||
react: {
|
||
useSuspense: true,
|
||
},
|
||
|
||
// 开发配置
|
||
debug: process.env.NODE_ENV === 'development',
|
||
|
||
// 性能配置
|
||
load: 'currentOnly', // 只加载当前语言
|
||
preload: ['zh'], // 预加载中文
|
||
|
||
interpolation: {
|
||
escapeValue: false,
|
||
},
|
||
});
|
||
|
||
export default i18n;
|
||
```
|
||
|
||
**估计时间**: 2 小时
|
||
|
||
#### 1.3 创建页面命名空间映射
|
||
|
||
**文件**: `projects/app/src/web/i18n/namespaceMap.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* 页面路径到 i18n 命名空间的映射
|
||
* 用于自动加载页面所需的翻译文件
|
||
*/
|
||
export const pageNamespaces: Record<string, string[]> = {
|
||
// 应用相关
|
||
'/app/detail': ['app', 'chat', 'workflow', 'publish', 'file'],
|
||
'/dashboard/apps': ['app'],
|
||
|
||
// 数据集相关
|
||
'/dataset/list': ['dataset'],
|
||
'/dataset/detail': ['dataset'],
|
||
|
||
// 账户相关
|
||
'/account/setting': ['user'],
|
||
'/account/apikey': ['user'],
|
||
'/account/bill': ['user'],
|
||
'/account/usage': ['user'],
|
||
'/account/promotion': ['user'],
|
||
'/account/inform': ['user'],
|
||
'/account/team': ['user'],
|
||
'/account/model': ['user'],
|
||
'/account/info': ['user'],
|
||
'/account/thirdParty': ['user'],
|
||
|
||
// 仪表板相关
|
||
'/dashboard/evaluation': ['app'],
|
||
'/dashboard/templateMarket': ['app'],
|
||
'/dashboard/mcpServer': ['app'],
|
||
|
||
// 其他页面
|
||
'/more': ['common'],
|
||
'/price': ['common'],
|
||
|
||
// 需要 SEO 的页面保持服务端渲染
|
||
// '/chat/share': 使用 getServerSideProps
|
||
// '/login': 使用 getServerSideProps
|
||
};
|
||
|
||
/**
|
||
* 判断页面是否需要服务端渲染
|
||
*/
|
||
export const needsSSR = (pathname: string): boolean => {
|
||
const ssrPages = [
|
||
'/chat/share', // 分享页面需要 SEO
|
||
'/price', // 定价页面需要 SEO (可选)
|
||
'/login', // 登录页首屏体验
|
||
'/login/provider',
|
||
'/login/fastlogin',
|
||
];
|
||
|
||
return ssrPages.some(page => pathname.startsWith(page));
|
||
};
|
||
|
||
/**
|
||
* 获取页面需要的命名空间
|
||
*/
|
||
export const getPageNamespaces = (pathname: string): string[] => {
|
||
// 精确匹配
|
||
if (pageNamespaces[pathname]) {
|
||
return pageNamespaces[pathname];
|
||
}
|
||
|
||
// 模糊匹配 (例如 /dashboard/[pluginGroupId] 匹配 /dashboard/*)
|
||
for (const [path, namespaces] of Object.entries(pageNamespaces)) {
|
||
if (pathname.startsWith(path)) {
|
||
return namespaces;
|
||
}
|
||
}
|
||
|
||
// 默认只加载 common
|
||
return [];
|
||
};
|
||
```
|
||
|
||
**估计时间**: 1 小时
|
||
|
||
#### 1.4 创建页面级 i18n Hook
|
||
|
||
**文件**: `projects/app/src/web/i18n/usePageI18n.ts`
|
||
|
||
```typescript
|
||
import { useEffect, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { useRouter } from 'next/router';
|
||
import i18n from './client';
|
||
import { getPageNamespaces } from './namespaceMap';
|
||
|
||
/**
|
||
* 页面级 i18n Hook
|
||
* 自动加载当前页面需要的命名空间
|
||
*/
|
||
export function usePageI18n(pathname?: string) {
|
||
const router = useRouter();
|
||
const { i18n: i18nInstance } = useTranslation();
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isReady, setIsReady] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const currentPath = pathname || router.pathname;
|
||
const namespaces = getPageNamespaces(currentPath);
|
||
|
||
if (namespaces.length === 0) {
|
||
setIsReady(true);
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
|
||
// 加载所有需要的命名空间
|
||
Promise.all(
|
||
namespaces.map(ns => {
|
||
// 检查是否已加载
|
||
if (i18nInstance.hasResourceBundle(i18nInstance.language, ns)) {
|
||
return Promise.resolve();
|
||
}
|
||
return i18nInstance.loadNamespaces(ns);
|
||
})
|
||
)
|
||
.then(() => {
|
||
setIsReady(true);
|
||
})
|
||
.catch(error => {
|
||
console.error('Failed to load i18n namespaces:', error);
|
||
setIsReady(true); // 即使失败也继续渲染
|
||
})
|
||
.finally(() => {
|
||
setIsLoading(false);
|
||
});
|
||
}, [pathname, router.pathname, i18nInstance]);
|
||
|
||
return { isLoading, isReady };
|
||
}
|
||
```
|
||
|
||
**估计时间**: 1 小时
|
||
|
||
#### 1.5 创建预加载 Hook
|
||
|
||
**文件**: `projects/app/src/web/i18n/useI18nPreload.ts`
|
||
|
||
```typescript
|
||
import { useEffect } from 'react';
|
||
import { useRouter } from 'next/router';
|
||
import i18n from './client';
|
||
import { getPageNamespaces } from './namespaceMap';
|
||
|
||
/**
|
||
* i18n 预加载 Hook
|
||
* 在链接悬停时预加载目标页面的翻译
|
||
*/
|
||
export function useI18nPreload() {
|
||
const router = useRouter();
|
||
|
||
useEffect(() => {
|
||
// 预加载当前路由的命名空间
|
||
const currentNamespaces = getPageNamespaces(router.pathname);
|
||
currentNamespaces.forEach(ns => {
|
||
if (!i18n.hasResourceBundle(i18n.language, ns)) {
|
||
i18n.loadNamespaces(ns);
|
||
}
|
||
});
|
||
|
||
// 监听链接悬停事件
|
||
const handleMouseEnter = (e: MouseEvent) => {
|
||
const target = e.target as HTMLElement;
|
||
const link = target.closest('a[href^="/"]');
|
||
|
||
if (link) {
|
||
const href = link.getAttribute('href');
|
||
if (href) {
|
||
const namespaces = getPageNamespaces(href);
|
||
namespaces.forEach(ns => {
|
||
if (!i18n.hasResourceBundle(i18n.language, ns)) {
|
||
// 预加载但不阻塞
|
||
i18n.loadNamespaces(ns).catch(() => {
|
||
// 静默失败
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// 使用捕获阶段监听所有链接
|
||
document.addEventListener('mouseenter', handleMouseEnter, true);
|
||
|
||
return () => {
|
||
document.removeEventListener('mouseenter', handleMouseEnter, true);
|
||
};
|
||
}, [router.pathname]);
|
||
}
|
||
```
|
||
|
||
**估计时间**: 1 小时
|
||
|
||
#### 1.6 创建 Loading 组件
|
||
|
||
**文件**: `projects/app/src/components/i18n/I18nLoading.tsx`
|
||
|
||
```typescript
|
||
import React from 'react';
|
||
import { Box, Spinner, Center } from '@chakra-ui/react';
|
||
|
||
/**
|
||
* i18n 加载状态组件
|
||
* 在翻译文件加载时显示
|
||
*/
|
||
export const I18nLoading: React.FC<{ message?: string }> = ({
|
||
message = '加载中...'
|
||
}) => {
|
||
return (
|
||
<Center h="100vh" w="100%">
|
||
<Box textAlign="center">
|
||
<Spinner size="lg" color="primary.500" mb={4} />
|
||
<Box color="myGray.600" fontSize="sm">
|
||
{message}
|
||
</Box>
|
||
</Box>
|
||
</Center>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 轻量级 Loading (用于页面内部)
|
||
*/
|
||
export const I18nInlineLoading: React.FC = () => {
|
||
return (
|
||
<Box display="inline-flex" alignItems="center" ml={2}>
|
||
<Spinner size="sm" color="primary.500" />
|
||
</Box>
|
||
);
|
||
};
|
||
```
|
||
|
||
**估计时间**: 0.5 小时
|
||
|
||
#### 1.7 确保翻译文件可访问
|
||
|
||
检查翻译文件位置,确保客户端可以访问:
|
||
|
||
```bash
|
||
# 检查现有翻译文件位置
|
||
ls -la projects/app/public/locales/
|
||
# 或
|
||
ls -la packages/web/i18n/
|
||
```
|
||
|
||
如果翻译文件不在 `public/locales/`,需要:
|
||
|
||
**选项 A**: 复制到 public 目录
|
||
|
||
```bash
|
||
# 创建目录
|
||
mkdir -p projects/app/public/locales
|
||
|
||
# 复制翻译文件
|
||
cp -r packages/web/i18n/* projects/app/public/locales/
|
||
```
|
||
|
||
**选项 B**: 配置 next.config.js 重写
|
||
|
||
```javascript
|
||
// projects/app/next.config.js
|
||
const nextConfig = {
|
||
// ... 现有配置
|
||
|
||
async rewrites() {
|
||
return [
|
||
{
|
||
source: '/locales/:lng/:ns.json',
|
||
destination: '/api/locales/:lng/:ns',
|
||
},
|
||
];
|
||
},
|
||
};
|
||
```
|
||
|
||
然后创建 API 路由:
|
||
|
||
**文件**: `projects/app/src/pages/api/locales/[lng]/[ns].ts`
|
||
|
||
```typescript
|
||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
|
||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||
const { lng, ns } = req.query;
|
||
|
||
if (typeof lng !== 'string' || typeof ns !== 'string') {
|
||
return res.status(400).json({ error: 'Invalid parameters' });
|
||
}
|
||
|
||
// 翻译文件路径
|
||
const filePath = path.join(
|
||
process.cwd(),
|
||
'../../packages/web/i18n',
|
||
lng,
|
||
`${ns}.json`
|
||
);
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
return res.status(404).json({ error: 'Translation file not found' });
|
||
}
|
||
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
|
||
// 设置缓存头
|
||
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
|
||
res.setHeader('Content-Type', 'application/json');
|
||
|
||
return res.status(200).send(content);
|
||
}
|
||
```
|
||
|
||
**估计时间**: 1 小时
|
||
|
||
**Phase 1 总计**: 7.5 小时 (约 1 个工作日)
|
||
|
||
---
|
||
|
||
### Phase 2: 修改 _app.tsx (第 2 周 - Day 1)
|
||
|
||
#### 2.1 修改 _app.tsx
|
||
|
||
**文件**: `projects/app/src/pages/_app.tsx`
|
||
|
||
```typescript
|
||
import '@scalar/api-reference-react/style.css';
|
||
|
||
import type { AppProps } from 'next/app';
|
||
import Script from 'next/script';
|
||
import Layout from '@/components/Layout';
|
||
import { I18nextProvider } from 'react-i18next';
|
||
import i18n from '@/web/i18n/client';
|
||
import { useI18nPreload } from '@/web/i18n/useI18nPreload';
|
||
|
||
import QueryClientContext from '@/web/context/QueryClient';
|
||
import ChakraUIContext from '@/web/context/ChakraUI';
|
||
import { useInitApp } from '@/web/context/useInitApp';
|
||
import '@/web/styles/reset.scss';
|
||
import NextHead from '@/components/common/NextHead';
|
||
import { type ReactElement, useEffect, Suspense } from 'react';
|
||
import { type NextPage } from 'next';
|
||
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
|
||
import SystemStoreContextProvider from '@fastgpt/web/context/useSystem';
|
||
import { useRouter } from 'next/router';
|
||
import { I18nLoading } from '@/components/i18n/I18nLoading';
|
||
|
||
type NextPageWithLayout = NextPage & {
|
||
setLayout?: (page: ReactElement) => JSX.Element;
|
||
};
|
||
type AppPropsWithLayout = AppProps & {
|
||
Component: NextPageWithLayout;
|
||
};
|
||
|
||
const routesWithCustomHead = ['/chat', '/chat/share', '/app/detail/', '/dataset/detail'];
|
||
const routesWithoutLayout = ['/openapi'];
|
||
|
||
function AppContent({ Component, pageProps }: AppPropsWithLayout) {
|
||
const { feConfigs, scripts, title } = useInitApp();
|
||
|
||
// 启用预加载
|
||
useI18nPreload();
|
||
|
||
useEffect(() => {
|
||
document.addEventListener(
|
||
'wheel',
|
||
function (e) {
|
||
if (e.ctrlKey && Math.abs(e.deltaY) !== 0) {
|
||
e.preventDefault();
|
||
}
|
||
},
|
||
{ passive: false }
|
||
);
|
||
}, []);
|
||
|
||
const setLayout = Component.setLayout || ((page) => <>{page}</>);
|
||
const router = useRouter();
|
||
const showHead = !router?.pathname || !routesWithCustomHead.includes(router.pathname);
|
||
const shouldUseLayout = !router?.pathname || !routesWithoutLayout.includes(router.pathname);
|
||
|
||
if (router.pathname === '/openapi') {
|
||
return (
|
||
<>
|
||
{showHead && (
|
||
<NextHead
|
||
title={title}
|
||
desc={process.env.SYSTEM_DESCRIPTION}
|
||
icon={getWebReqUrl(feConfigs?.favicon || process.env.SYSTEM_FAVICON)}
|
||
/>
|
||
)}
|
||
{setLayout(<Component {...pageProps} />)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{showHead && (
|
||
<NextHead
|
||
title={title}
|
||
desc={process.env.SYSTEM_DESCRIPTION}
|
||
icon={getWebReqUrl(feConfigs?.favicon || process.env.SYSTEM_FAVICON)}
|
||
/>
|
||
)}
|
||
|
||
{scripts?.map((item, i) => <Script key={i} strategy="lazyOnload" {...item}></Script>)}
|
||
|
||
<QueryClientContext>
|
||
<SystemStoreContextProvider device={pageProps.deviceSize}>
|
||
<ChakraUIContext>
|
||
{shouldUseLayout ? (
|
||
<Layout>{setLayout(<Component {...pageProps} />)}</Layout>
|
||
) : (
|
||
setLayout(<Component {...pageProps} />)
|
||
)}
|
||
</ChakraUIContext>
|
||
</SystemStoreContextProvider>
|
||
</QueryClientContext>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function App(props: AppPropsWithLayout) {
|
||
return (
|
||
<I18nextProvider i18n={i18n}>
|
||
<Suspense fallback={<I18nLoading message="正在加载翻译..." />}>
|
||
<AppContent {...props} />
|
||
</Suspense>
|
||
</I18nextProvider>
|
||
);
|
||
}
|
||
|
||
// ❌ 移除 appWithTranslation
|
||
// export default appWithTranslation(App);
|
||
|
||
// ✅ 直接导出
|
||
export default App;
|
||
```
|
||
|
||
**估计时间**: 1 小时
|
||
|
||
**Phase 2 总计**: 1 小时
|
||
|
||
---
|
||
|
||
### Phase 3: 逐页迁移 (第 2-3 周)
|
||
|
||
#### 3.1 优先级页面列表
|
||
|
||
```yaml
|
||
P0 - 高频访问页面 (优先迁移):
|
||
- /app/detail (应用编辑页)
|
||
- /dataset/list (数据集列表)
|
||
- /dataset/detail (数据集详情)
|
||
- /dashboard/apps (应用列表)
|
||
|
||
P1 - 中频访问页面:
|
||
- /account/setting (账户设置)
|
||
- /account/team (团队管理)
|
||
- /account/apikey (API 密钥)
|
||
- /account/usage (用量统计)
|
||
- /dashboard/evaluation (评测)
|
||
- /dashboard/templateMarket (模板市场)
|
||
|
||
P2 - 低频访问页面:
|
||
- /account/bill (账单)
|
||
- /account/promotion (推广)
|
||
- /account/inform (通知)
|
||
- /account/model (模型)
|
||
- /account/info (个人信息)
|
||
- /account/thirdParty (第三方登录)
|
||
- /dashboard/mcpServer (MCP 服务器)
|
||
- /more (更多)
|
||
|
||
保持 SSR (需要 SEO):
|
||
- /chat/share (分享页面)
|
||
- /login (登录页)
|
||
- /login/provider (第三方登录)
|
||
- /login/fastlogin (快速登录)
|
||
- /price (定价页 - 可选)
|
||
```
|
||
|
||
#### 3.2 页面迁移模板
|
||
|
||
对于每个页面,执行以下步骤:
|
||
|
||
**步骤 1**: 删除 `getServerSideProps`
|
||
|
||
```typescript
|
||
// ❌ 删除这段代码
|
||
export async function getServerSideProps(context: any) {
|
||
return {
|
||
props: {
|
||
...(await serviceSideProps(context, ['app', 'chat', 'user']))
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
**步骤 2**: 添加 `usePageI18n` hook
|
||
|
||
```typescript
|
||
import { usePageI18n } from '@/web/i18n/usePageI18n';
|
||
|
||
function PageComponent() {
|
||
// 添加这行
|
||
const { isReady } = usePageI18n();
|
||
|
||
// 可选:显示加载状态
|
||
// if (!isReady) {
|
||
// return <Loading />;
|
||
// }
|
||
|
||
// 原有代码...
|
||
}
|
||
```
|
||
|
||
**步骤 3**: 测试翻译
|
||
|
||
```bash
|
||
# 启动开发服务器
|
||
pnpm dev
|
||
|
||
# 访问页面,检查:
|
||
# 1. 翻译是否正常显示
|
||
# 2. 控制台是否有错误
|
||
# 3. 切换语言是否生效
|
||
```
|
||
|
||
#### 3.3 迁移示例:/app/detail
|
||
|
||
**文件**: `projects/app/src/pages/app/detail/index.tsx`
|
||
|
||
**修改前**:
|
||
```typescript
|
||
export async function getServerSideProps(context: any) {
|
||
return {
|
||
props: {
|
||
...(await serviceSideProps(context, ['app', 'chat', 'user', 'file', 'publish', 'workflow']))
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
**修改后**:
|
||
```typescript
|
||
import { usePageI18n } from '@/web/i18n/usePageI18n';
|
||
|
||
const AppDetail = () => {
|
||
const { setAppId, setSource } = useChatStore();
|
||
const appDetail = useContextSelector(AppContext, (e) => e.appDetail);
|
||
const route2Tab = useContextSelector(AppContext, (e) => e.route2Tab);
|
||
|
||
// ✅ 添加 i18n 加载
|
||
usePageI18n('/app/detail');
|
||
|
||
useEffect(() => {
|
||
setSource('test');
|
||
if (appDetail._id) {
|
||
setAppId(appDetail._id);
|
||
if (!appDetail.permission.hasWritePer) {
|
||
route2Tab(TabEnum.logs);
|
||
}
|
||
}
|
||
}, [appDetail._id]);
|
||
|
||
// 其余代码不变...
|
||
};
|
||
|
||
// ❌ 删除 getServerSideProps
|
||
```
|
||
|
||
**估计时间**: 每个页面 15-30 分钟
|
||
|
||
**Phase 3 总计**:
|
||
- P0 页面 (4 个): 2 小时
|
||
- P1 页面 (6 个): 3 小时
|
||
- P2 页面 (7 个): 3.5 小时
|
||
- **总计**: 8.5 小时 (约 1-1.5 个工作日)
|
||
|
||
---
|
||
|
||
### Phase 4: 保留 SSR 页面处理 (第 3 周 - Day 1)
|
||
|
||
对于需要 SEO 的页面,保持使用 `getServerSideProps`,但需要确保它们仍然使用 `appWithTranslation`:
|
||
|
||
#### 4.1 创建混合模式支持
|
||
|
||
**文件**: `projects/app/src/web/i18n/withSSRI18n.tsx`
|
||
|
||
```typescript
|
||
import { appWithTranslation } from 'next-i18next';
|
||
import type { AppProps } from 'next/app';
|
||
|
||
/**
|
||
* 为需要 SSR 的页面提供 i18n 支持
|
||
* 这些页面需要保留 getServerSideProps
|
||
*/
|
||
export const withSSRI18n = (App: any) => {
|
||
return appWithTranslation(App);
|
||
};
|
||
```
|
||
|
||
#### 4.2 检查 SSR 页面
|
||
|
||
确保以下页面保留 `getServerSideProps`:
|
||
|
||
**文件**: `projects/app/src/pages/chat/share.tsx`
|
||
```typescript
|
||
// ✅ 保留 getServerSideProps
|
||
export async function getServerSideProps(context: any) {
|
||
return {
|
||
props: {
|
||
...(await serviceSideProps(context, ['chat', 'common']))
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
**文件**: `projects/app/src/pages/login/index.tsx`
|
||
```typescript
|
||
// ✅ 保留 getServerSideProps
|
||
export async function getServerSideProps(context: any) {
|
||
return {
|
||
props: {
|
||
...(await serviceSideProps(context, ['common']))
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
**估计时间**: 1 小时
|
||
|
||
**Phase 4 总计**: 1 小时
|
||
|
||
---
|
||
|
||
### Phase 5: 测试与优化 (第 3 周 - Day 2-3)
|
||
|
||
#### 5.1 功能测试清单
|
||
|
||
```yaml
|
||
基础功能:
|
||
- [ ] 首次访问页面,翻译正常加载
|
||
- [ ] 路由切换,翻译不丢失
|
||
- [ ] 语言切换功能正常 (中文/英文/日文)
|
||
- [ ] 刷新页面,语言设置保持
|
||
|
||
性能测试:
|
||
- [ ] 路由切换时间测量 (目标 < 500ms)
|
||
- [ ] i18n 文件加载时间 (目标 < 100ms)
|
||
- [ ] 首屏加载时间 (目标 < 2s)
|
||
|
||
边缘情况:
|
||
- [ ] 网络断开时的降级处理
|
||
- [ ] 翻译文件加载失败的提示
|
||
- [ ] 不存在的命名空间处理
|
||
- [ ] 并发路由切换
|
||
|
||
浏览器兼容性:
|
||
- [ ] Chrome 最新版
|
||
- [ ] Firefox 最新版
|
||
- [ ] Safari 最新版
|
||
- [ ] Edge 最新版
|
||
- [ ] 移动端浏览器
|
||
|
||
SSR 页面:
|
||
- [ ] /chat/share 正常渲染和 SEO
|
||
- [ ] /login 首屏体验
|
||
- [ ] 爬虫可索引内容
|
||
```
|
||
|
||
#### 5.2 性能测试脚本
|
||
|
||
**文件**: `projects/app/test/i18n-performance.test.ts`
|
||
|
||
```typescript
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('i18n Performance', () => {
|
||
test('should load translations within 200ms', async ({ page }) => {
|
||
await page.goto('http://localhost:3000/dashboard/apps');
|
||
|
||
const startTime = Date.now();
|
||
|
||
// 等待翻译加载完成
|
||
await page.waitForFunction(() => {
|
||
return window.i18next && window.i18next.isInitialized;
|
||
});
|
||
|
||
const loadTime = Date.now() - startTime;
|
||
console.log(`i18n loaded in ${loadTime}ms`);
|
||
|
||
expect(loadTime).toBeLessThan(200);
|
||
});
|
||
|
||
test('should switch routes without translation flash', async ({ page }) => {
|
||
await page.goto('http://localhost:3000/dashboard/apps');
|
||
await page.waitForLoadState('networkidle');
|
||
|
||
// 点击链接切换路由
|
||
const startTime = Date.now();
|
||
await page.click('a[href="/app/detail?appId=test"]');
|
||
|
||
// 等待新页面加载
|
||
await page.waitForSelector('[data-testid="app-detail"]');
|
||
|
||
const switchTime = Date.now() - startTime;
|
||
console.log(`Route switched in ${switchTime}ms`);
|
||
|
||
expect(switchTime).toBeLessThan(500);
|
||
|
||
// 检查翻译是否正常
|
||
const hasTranslation = await page.evaluate(() => {
|
||
return document.body.textContent?.includes('应用') ||
|
||
document.body.textContent?.includes('App');
|
||
});
|
||
|
||
expect(hasTranslation).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
#### 5.3 性能优化
|
||
|
||
**优化 1**: 添加翻译文件缓存
|
||
|
||
```typescript
|
||
// projects/app/src/web/i18n/client.ts
|
||
i18n
|
||
.use(Backend)
|
||
.use(LanguageDetector)
|
||
.use(initReactI18next)
|
||
.init({
|
||
// ... 其他配置
|
||
|
||
backend: {
|
||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||
// ✅ 添加缓存配置
|
||
requestOptions: {
|
||
cache: 'default', // 使用浏览器缓存
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
**优化 2**: Service Worker 缓存翻译文件
|
||
|
||
**文件**: `projects/app/public/sw.js`
|
||
|
||
```javascript
|
||
const CACHE_NAME = 'i18n-cache-v1';
|
||
const I18N_URLS = [
|
||
'/locales/zh/common.json',
|
||
'/locales/en/common.json',
|
||
'/locales/ja/common.json',
|
||
];
|
||
|
||
self.addEventListener('install', (event) => {
|
||
event.waitUntil(
|
||
caches.open(CACHE_NAME).then((cache) => {
|
||
return cache.addAll(I18N_URLS);
|
||
})
|
||
);
|
||
});
|
||
|
||
self.addEventListener('fetch', (event) => {
|
||
if (event.request.url.includes('/locales/')) {
|
||
event.respondWith(
|
||
caches.match(event.request).then((response) => {
|
||
return response || fetch(event.request);
|
||
})
|
||
);
|
||
}
|
||
});
|
||
```
|
||
|
||
**优化 3**: 预加载关键翻译
|
||
|
||
```typescript
|
||
// projects/app/src/web/i18n/client.ts
|
||
i18n.init({
|
||
// ... 其他配置
|
||
|
||
// ✅ 预加载关键命名空间
|
||
preload: ['zh', 'en'], // 预加载中英文
|
||
ns: ['common'], // 预加载 common 命名空间
|
||
});
|
||
```
|
||
|
||
**估计时间**:
|
||
- 功能测试: 4 小时
|
||
- 性能测试: 2 小时
|
||
- 优化实施: 2 小时
|
||
- **总计**: 8 小时 (1 个工作日)
|
||
|
||
**Phase 5 总计**: 8 小时
|
||
|
||
---
|
||
|
||
### Phase 6: 监控与文档 (第 3 周 - Day 4)
|
||
|
||
#### 6.1 添加性能监控
|
||
|
||
**文件**: `projects/app/src/web/i18n/monitoring.ts`
|
||
|
||
```typescript
|
||
import i18n from './client';
|
||
|
||
/**
|
||
* i18n 性能监控
|
||
*/
|
||
export const setupI18nMonitoring = () => {
|
||
// 监听语言切换
|
||
i18n.on('languageChanged', (lng) => {
|
||
console.log(`[i18n] Language changed to: ${lng}`);
|
||
|
||
// 发送到分析服务
|
||
if (typeof window !== 'undefined' && window.umami) {
|
||
window.umami.track('i18n-language-change', { language: lng });
|
||
}
|
||
});
|
||
|
||
// 监听命名空间加载
|
||
i18n.on('loaded', (loaded) => {
|
||
const namespaces = Object.keys(loaded);
|
||
console.log(`[i18n] Loaded namespaces:`, namespaces);
|
||
});
|
||
|
||
// 监听加载失败
|
||
i18n.on('failedLoading', (lng, ns, msg) => {
|
||
console.error(`[i18n] Failed to load ${lng}/${ns}:`, msg);
|
||
|
||
// 发送错误到监控服务
|
||
if (typeof window !== 'undefined' && window.umami) {
|
||
window.umami.track('i18n-load-error', {
|
||
language: lng,
|
||
namespace: ns,
|
||
error: msg,
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
// 在 _app.tsx 中调用
|
||
// setupI18nMonitoring();
|
||
```
|
||
|
||
#### 6.2 更新文档
|
||
|
||
**文件**: `projects/app/docs/i18n-migration.md`
|
||
|
||
```markdown
|
||
# i18n 客户端化迁移指南
|
||
|
||
## 架构变更
|
||
|
||
从服务端 i18n (`next-i18next` + `getServerSideProps`) 迁移到客户端 i18n (`i18next` + HTTP backend)。
|
||
|
||
## 开发指南
|
||
|
||
### 添加新页面
|
||
|
||
1. 在 `src/web/i18n/namespaceMap.ts` 中添加页面映射:
|
||
\`\`\`typescript
|
||
export const pageNamespaces: Record<string, string[]> = {
|
||
'/your/new/page': ['common', 'your-namespace'],
|
||
};
|
||
\`\`\`
|
||
|
||
2. 在页面组件中使用 `usePageI18n`:
|
||
\`\`\`typescript
|
||
import { usePageI18n } from '@/web/i18n/usePageI18n';
|
||
|
||
function YourPage() {
|
||
usePageI18n('/your/new/page');
|
||
|
||
// 其余代码...
|
||
}
|
||
\`\`\`
|
||
|
||
3. 不需要 `getServerSideProps`
|
||
|
||
### 添加新翻译
|
||
|
||
翻译文件位置: `projects/app/public/locales/[lang]/[namespace].json`
|
||
|
||
### 切换语言
|
||
|
||
\`\`\`typescript
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
function LanguageSwitcher() {
|
||
const { i18n } = useTranslation();
|
||
|
||
return (
|
||
<button onClick={() => i18n.changeLanguage('en')}>
|
||
Switch to English
|
||
</button>
|
||
);
|
||
}
|
||
\`\`\`
|
||
|
||
## 性能特性
|
||
|
||
- ✅ 路由切换时间减少 40-50%
|
||
- ✅ 按需加载翻译文件
|
||
- ✅ 自动预加载链接目标的翻译
|
||
- ✅ 浏览器缓存支持
|
||
|
||
## 注意事项
|
||
|
||
- 需要 SEO 的页面仍使用 `getServerSideProps`
|
||
- 首次访问略慢 (多 100-200ms)
|
||
- 配合 Service Worker 可离线使用
|
||
\`\`\`
|
||
|
||
#### 6.3 团队培训文档
|
||
|
||
**文件**: `projects/app/docs/i18n-team-guide.md`
|
||
|
||
```markdown
|
||
# i18n 客户端化 - 团队指南
|
||
|
||
## 背景
|
||
|
||
我们将 i18n 从服务端渲染迁移到客户端,以提升路由切换性能。
|
||
|
||
## 主要变化
|
||
|
||
### 之前
|
||
\`\`\`typescript
|
||
// 每个页面都需要
|
||
export async function getServerSideProps(context: any) {
|
||
return {
|
||
props: {
|
||
...(await serviceSideProps(context, ['app', 'chat']))
|
||
}
|
||
};
|
||
}
|
||
\`\`\`
|
||
|
||
### 之后
|
||
\`\`\`typescript
|
||
// 大部分页面不需要 getServerSideProps
|
||
import { usePageI18n } from '@/web/i18n/usePageI18n';
|
||
|
||
function MyPage() {
|
||
usePageI18n(); // 自动加载所需翻译
|
||
// ...
|
||
}
|
||
\`\`\`
|
||
|
||
## 常见问题
|
||
|
||
### Q: 为什么翻译显示为 key?
|
||
A: 翻译文件正在加载中,稍等片刻或检查网络请求。
|
||
|
||
### Q: 如何添加新的翻译命名空间?
|
||
A: 在 `namespaceMap.ts` 中添加页面映射。
|
||
|
||
### Q: 性能提升了多少?
|
||
A: 路由切换快了约 40-50%,从 1.5s 降至 0.9s。
|
||
|
||
### Q: 哪些页面保留了 SSR?
|
||
A: `/chat/share`, `/login` 等需要 SEO 的页面。
|
||
|
||
## 支持
|
||
|
||
遇到问题请联系前端团队或查看完整文档。
|
||
\`\`\`
|
||
|
||
**估计时间**: 4 小时
|
||
|
||
**Phase 6 总计**: 4 小时
|
||
|
||
---
|
||
|
||
## 总体时间估算
|
||
|
||
| 阶段 | 任务 | 估计时间 |
|
||
|------|------|---------|
|
||
| Phase 1 | 基础设施搭建 | 7.5 小时 |
|
||
| Phase 2 | 修改 _app.tsx | 1 小时 |
|
||
| Phase 3 | 逐页迁移 | 8.5 小时 |
|
||
| Phase 4 | SSR 页面处理 | 1 小时 |
|
||
| Phase 5 | 测试与优化 | 8 小时 |
|
||
| Phase 6 | 监控与文档 | 4 小时 |
|
||
| **总计** | | **30 小时** |
|
||
|
||
**实施周期**: 3 周 (按每周 10 小时计算)
|
||
|
||
---
|
||
|
||
## 风险与缓解
|
||
|
||
### 风险 1: 翻译闪烁 (用户看到 key)
|
||
|
||
**风险等级**: 🟡 中
|
||
|
||
**缓解措施**:
|
||
- 使用 `Suspense` 显示 loading 状态
|
||
- 预加载 `common` 命名空间
|
||
- 实施预加载策略
|
||
|
||
### 风险 2: 翻译加载失败
|
||
|
||
**风险等级**: 🟡 中
|
||
|
||
**缓解措施**:
|
||
- 添加错误边界处理
|
||
- 提供降级显示 (显示 key)
|
||
- 监控加载失败率
|
||
- 实施重试机制
|
||
|
||
### 风险 3: SEO 影响
|
||
|
||
**风险等级**: 🟢 低
|
||
|
||
**缓解措施**:
|
||
- 保留需要 SEO 的页面的 SSR
|
||
- 测试爬虫可访问性
|
||
- 监控搜索引擎收录
|
||
|
||
### 风险 4: 首屏变慢
|
||
|
||
**风险等级**: 🟡 中
|
||
|
||
**缓解措施**:
|
||
- 预加载关键命名空间
|
||
- 使用 Service Worker 缓存
|
||
- 显示友好的 loading 状态
|
||
|
||
---
|
||
|
||
## 成功标准
|
||
|
||
### 性能指标
|
||
|
||
```yaml
|
||
路由切换时间:
|
||
当前: 1560ms (开发环境)
|
||
目标: < 900ms (开发环境)
|
||
改善: 40-50%
|
||
|
||
i18n 加载时间:
|
||
当前: 150-300ms (服务端阻塞)
|
||
目标: < 100ms (客户端异步)
|
||
改善: 50-70%
|
||
|
||
首屏时间:
|
||
当前: 无变化
|
||
目标: 无退化 (可接受 +100ms)
|
||
```
|
||
|
||
### 功能指标
|
||
|
||
```yaml
|
||
翻译完整性: 100% (所有翻译正常显示)
|
||
语言切换: 正常 (中/英/日三语)
|
||
浏览器兼容: 主流浏览器全支持
|
||
SEO 页面: 无影响
|
||
```
|
||
|
||
### 质量指标
|
||
|
||
```yaml
|
||
翻译闪烁率: < 1%
|
||
加载失败率: < 0.1%
|
||
用户投诉: 0
|
||
开发效率: 提升 (无需 getServerSideProps)
|
||
```
|
||
|
||
---
|
||
|
||
## 验收标准
|
||
|
||
### 最终验收清单
|
||
|
||
- [ ] 所有 P0/P1/P2 页面完成迁移
|
||
- [ ] SSR 页面功能正常
|
||
- [ ] 路由切换时间达标 (< 900ms)
|
||
- [ ] 所有功能测试通过
|
||
- [ ] 所有性能测试通过
|
||
- [ ] 文档完整并同步到团队
|
||
- [ ] 团队培训完成
|
||
- [ ] 监控系统运行正常
|
||
- [ ] 生产环境灰度发布成功
|
||
- [ ] 用户反馈收集和处理
|
||
|
||
---
|
||
|
||
## 回滚计划
|
||
|
||
如果遇到严重问题需要回滚:
|
||
|
||
### 快速回滚步骤
|
||
|
||
1. **恢复 _app.tsx**
|
||
```bash
|
||
git checkout origin/main -- projects/app/src/pages/_app.tsx
|
||
```
|
||
|
||
2. **恢复页面的 getServerSideProps**
|
||
```bash
|
||
# 批量恢复
|
||
git checkout origin/main -- projects/app/src/pages/**/*.tsx
|
||
```
|
||
|
||
3. **删除客户端 i18n 代码**
|
||
```bash
|
||
rm -rf projects/app/src/web/i18n/
|
||
```
|
||
|
||
4. **重启开发服务器**
|
||
```bash
|
||
pnpm dev
|
||
```
|
||
|
||
### 回滚决策标准
|
||
|
||
触发回滚的条件:
|
||
- 翻译显示异常 > 10%
|
||
- 加载失败率 > 5%
|
||
- 用户投诉 > 5 个/天
|
||
- 性能退化 > 20%
|
||
- 关键功能不可用
|
||
|
||
---
|
||
|
||
## 后续优化
|
||
|
||
完成基础迁移后的改进方向:
|
||
|
||
### 短期优化 (1 个月内)
|
||
|
||
- 实施 Service Worker 缓存
|
||
- 优化预加载策略
|
||
- 添加更详细的监控
|
||
- 收集用户反馈并改进
|
||
|
||
### 中期优化 (3 个月内)
|
||
|
||
- 翻译文件 CDN 加速
|
||
- 智能预加载 (基于用户行为)
|
||
- 翻译文件分包优化
|
||
- 离线翻译支持
|
||
|
||
### 长期优化 (6 个月内)
|
||
|
||
- 自动翻译更新系统
|
||
- A/B 测试不同加载策略
|
||
- 多地域翻译优化
|
||
- 翻译质量监控系统
|
||
|
||
---
|
||
|
||
## 附录
|
||
|
||
### A. 相关资源
|
||
|
||
- [i18next 官方文档](https://www.i18next.com/)
|
||
- [react-i18next 文档](https://react.i18next.com/)
|
||
- [Next.js i18n 最佳实践](https://nextjs.org/docs/advanced-features/i18n-routing)
|
||
|
||
### B. 团队联系方式
|
||
|
||
- **负责人**: [填写]
|
||
- **前端团队**: [填写]
|
||
- **测试团队**: [填写]
|
||
- **紧急联系**: [填写]
|
||
|
||
### C. 变更记录
|
||
|
||
| 日期 | 版本 | 变更内容 | 作者 |
|
||
|------|------|---------|------|
|
||
| 2025-10-18 | 1.0 | 初始版本 | Claude |
|
||
|
||
---
|
||
|
||
**文档生成时间**: 2025-10-18
|
||
**预计开始日期**: [填写]
|
||
**预计完成日期**: [填写]
|
||
**实际完成日期**: [填写]
|