From 66e16e0d07b81ce1ab7a6f6825a84b69e324cc0b Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Thu, 8 Oct 2026 22:54:05 +0800 Subject: [PATCH 1/2] fix(web): centralize CSR route readiness and preserve initialization behavior --- .../common/client-i18n-csr-migration.md | 111 +++++--------- packages/web/hooks/useSafeTranslation.ts | 11 +- .../web/test/i18n/useSafeTranslation.test.ts | 140 ++++++++++++----- .../Layout/ForceMemberNameModal.tsx | 2 +- .../account/team/MemberNameForm/index.tsx | 2 +- .../team/MemberNameForm/useMemberNameForm.ts | 2 +- .../dashboard/agent/context.tsx | 8 +- .../dashboard/skill/context.tsx | 8 +- .../pageComponents/dataset/list/context.tsx | 8 +- .../app/src/pages/dashboard/create/index.tsx | 13 +- projects/app/src/web/context/AppShell.tsx | 7 +- .../src/web/context/ClientRouteReadyGate.tsx | 29 ++++ projects/app/src/web/context/useInitApp.ts | 3 +- .../app/test/web/context/AppShell.test.ts | 144 ++++++++++++++++++ .../web/context/ClientRouteReadyGate.test.ts | 137 +++++++++++++++++ .../app/test/web/context/useInitApp.test.ts | 24 +++ .../test/web/support/marketing/utils.test.ts | 8 + 17 files changed, 509 insertions(+), 148 deletions(-) create mode 100644 projects/app/src/web/context/ClientRouteReadyGate.tsx create mode 100644 projects/app/test/web/context/AppShell.test.ts create mode 100644 projects/app/test/web/context/ClientRouteReadyGate.test.ts diff --git a/.agents/design/common/client-i18n-csr-migration.md b/.agents/design/common/client-i18n-csr-migration.md index bf07df6ddcdd..448c6ef8a8b7 100644 --- a/.agents/design/common/client-i18n-csr-migration.md +++ b/.agents/design/common/client-i18n-csr-migration.md @@ -156,53 +156,25 @@ client-only 门禁加载语言包后,通过 `addResourceBundle` 一次性注 `projects/app/public/locales`,并以版本化 URL 通过 HTTP backend 加载。试点期间不同时实现两套 资源加载机制。 -### 3.3 组件继续显式声明 namespace +### 3.3 CSR 完整语言包与 SSR 组件按需加载 -namespace 的正确性来源改为实际使用翻译的组件,而不是中央路由配置。迁移组件时,将无参调用改为 -显式声明: +CSR 应用在挂载业务子树前注册完整语言包,因此业务组件统一调用 `useSafeTranslation()`, +不再逐组件复制 namespace 加载声明。翻译调用仍使用可静态识别的 `t('namespace:literal_key')`。 -```tsx -const { t } = useTranslation(['apikey'] as const, { - useSuspense: false -}); -``` - -这样依赖仍与组件一起维护,类型参数继续受 `I18nNsType` 约束。它不再决定资源何时加载,完整语言包 -已经在 client-only 应用挂载前注册完毕。 - -规则: - -1. 每个已迁移组件必须声明自己直接调用 `t`、`Trans` 所使用的全部 namespace;不能依赖祖先组件 - “碰巧已经加载”。 -2. 动态弹窗、抽屉和懒加载组件仍声明自己的 namespace,但挂载时资源已经存在,不需要额外骨架。 -3. 完整语言包由 CSR 初始化门禁先加载,保证 `NextHead`、`Layout` 和公共组件不会展示 key; - 已迁移组件统一使用 `useSafeTranslation(namespace)`,hook 内置 `common` 并关闭 Suspense。仅使用 - `common` 的组件调用 `useSafeTranslation()`。 - `Layout` 根部声明 `price`,而 `serviceSideProps` 统一预加载 `price`,因此 SSR 页面无需再逐页声明该 namespace。 -4. 原 `serviceSideProps(context, namespaces)` 数组只能作为迁移扫描起点,必须检查页面实际组件树中的 - `useTranslation`、`Trans` 和带 namespace 前缀的 key。 -5. 不允许页面自行 `fetch` 或 `import` 翻译文件,所有资源统一由语言门禁加载。 - -页面根组件的首屏预加载声明为: +`/chat` 和 `/chat/share` 保留 SSR,`serviceSideProps` 只注入页面声明的 namespace,不能假设它们 +也经过 CSR 完整语言包门禁。跨两种渲染模式复用的组件如果需要额外资源,应通过同一个 hook +显式声明,由已有的 i18next backend 按需补齐: ```tsx -useSafeTranslation('apikey'); +const { t } = useSafeTranslation('account_team'); ``` -API key 专属文案统一收敛到 `apikey`,`AccountContainer`、`ApiKeyTable`、`TagMultiSelect` 和 -`TagManageModal` 等试点可达组件分别显式声明自己的直接依赖。 +强制成员名弹窗、成员名表单及表单校验 hook 显式声明 `account_team`,保证直接访问 `/chat` +且语言未发生切换时,也能加载标题、按钮和校验文案。重复声明由 i18next 缓存复用,不重复请求。 +`common` 是默认 namespace;调用方也可传 namespace 数组。所有资源加载继续使用现有 backend, +组件不自行 `fetch` 或 `import` 翻译文件。 -CSR 路由集合与翻译依赖解耦,只负责渲染模式。当前 app 的集合为: - -```ts -const clientOnlyRoutes = new Set([ - '/account/apikey', '/account/inform', '/account/setting', '/account/thirdParty', - '/account/customDomain', '/account/bill', '/account/team', '/account/info', - '/account/usage', '/account/model', '/price' -] as const); -``` - -页面只有在组件 namespace 改造和验收完成后才加入该集合,但集合本身不再复制 namespace 数据。 +CSR/SSR 路由判定仅决定渲染模式,翻译依赖由完整包门禁或 SSR 组件声明分别保障。 ### 3.4 缓存与并发去重 @@ -332,41 +304,30 @@ Cookie/localStorage/内存,并发送独立的 `x-fastgpt-share-language` 请 ### 3.7 CSR 边界 删除 `getServerSideProps` 只会使 Pages Router 页面变成自动静态优化,并不等于禁用页面 HTML -预渲染。为了验证“除分享页外只在客户端渲染”,应用入口需要设置明确的 client-only boundary: - -- `/chat/share` 走原有同步 SSR 渲染路径。 -- 已迁移页面走 `{ ssr: false }` 的客户端应用壳。 -- 未迁移页面在过渡期继续走现有 SSR 路径。 - -因此过渡期不能用简单的“pathname 不是 `/chat/share` 就 CSR”开关,否则会一次性改变所有页面。 -已迁移路由由独立的 `clientOnlyRoutes` 判定: - -```ts -const isClientOnlyRoute = (pathname: string) => clientOnlyRoutes.has(pathname); -``` - -当前 `_app` 的结构是:全局 Provider 和 `AppShell` 始终挂载,已迁移路由只把页面内容交给 -`ssr: false` 的 `ClientOnlyPage`;未迁移页面和 `/chat/share` 继续由同一个 `AppShell` 渲染。 - -```tsx -const ClientOnlyPage = dynamic(() => import('@/web/context/ClientOnlyPage'), { - ssr: false -}); - -function AppRouter(props: AppPropsWithLayout) { - const isClientOnlyRoute = clientOnlyRoutes.has(props.router.pathname); - return : undefined} />; -} - -export default appWithTranslation(AppRouter, clientI18nConfig); -``` - -`AppShell` 内部只在 `clientOnly` 路由包裹 `ClientI18nGate`、`ClientI18nBoundary` 和 -`SystemStoreContextProvider.waitForReady`。完整语言包和设备信息 ready 后才挂载页面。因此已迁移路由 -不输出服务端页面业务 HTML,且不会经历页面级 namespace 门禁。 - -当全部非分享页面迁移完成后,再切换为“除 `/chat/share` 外默认 client-only”,并删除过渡兼容分支。 +预渲染。当前 `isClientOnlyRoute` 采用 SSR 例外名单:`/chat`、`/chat/share` 保留服务端 +应用配置及分享信息读取,其余页面默认 client-only。 + +`_app` 保持全局 Provider 和 `AppShell` 挂载。CSR 页面的业务内容交给以 `ssr: false` +加载的 `ClientOnlyPage`,应用壳通过设备、语言包和路由初始化门禁后才挂载业务子树。 +SSR 聊天页面直接渲染,不等待 CSR 门禁,因此仍输出服务端页面和 Head 信息。 + +`ClientRouteReadyGate` 位于 `AppShell` 的稳定位置,同时覆盖 `AppContent` 内的 +`useInitApp`、Layout 和页面。门禁由 effect 在 `router.isReady` 首次为 true 时开放, +保证 SSR/客户端首次输出一致,且首次业务请求和表单初值来自已恢复的 query。 + +首次开放后不再关闭,不监听 `asPath` 来重置状态,也不在 route change 时重新挂载门禁。 +同一 CSR 区域内的目录、筛选和页面导航不因路由门禁新增白屏;业务请求仅随 ID 或筛选条件刷新。 +已有语言包门禁和 CSR/SSR 分支切换的行为独立保留。 + +因此 App/Skill/知识库列表可移除 `disabled: !router.isReady`、`ready: router.isReady` +以及 refreshDeps 中的 `router.isReady`,创建应用页也无需额外的就绪 wrapper。 +必填参数、权限、业务配置等约束仍在所属业务边界校验;跨 SSR/CSR 共用的初始化 effect +可以保留路由检查。营销来源初始化即使未传 `sourceDomain` 也必须调用 helper,以保留 +`document.referrer` 回落和首次来源锁定语义。 + +验证要求:未就绪时 AppContent 和 Layout 的 effect 不执行;就绪后首次请求携带正确 query; +hydration 不报 mismatch;后续 query 更新保留页面编辑状态;SSR 聊天页仍输出业务内容; +直接访问 SSR 页面时缺失的组件 namespace 能按需补齐;营销归因无参数回落正常。 ### 3.8 `deviceSize` 处理 diff --git a/packages/web/hooks/useSafeTranslation.ts b/packages/web/hooks/useSafeTranslation.ts index 9828ffe98cb4..3501ead29e9f 100644 --- a/packages/web/hooks/useSafeTranslation.ts +++ b/packages/web/hooks/useSafeTranslation.ts @@ -2,6 +2,7 @@ import { useTranslation as useNextTranslation } from 'next-i18next'; import type { TFunction } from 'i18next'; import { useMemo } from 'react'; import { I18N_NAMESPACES_MAP } from '../i18n/constants'; +import type { I18nNsType } from '../i18n/i18next'; export type SafeTranslation = T & ((key: string, ...args: any[]) => string); @@ -27,8 +28,14 @@ export const createSafeTranslation = (originalT: T): SafeTr return t as SafeTranslation; }; -export function useSafeTranslation() { - const { t: originalT, ...rest } = useNextTranslation(); +/** + * CSR 页面直接使用启动阶段已加载的完整语言包;SSR 页面复用的组件可显式声明业务 + * namespace,由 i18next 按需补齐资源,避免默认 common 已就绪但业务文案仍裸显 key。 + */ +export function useSafeTranslation< + Ns extends I18nNsType[number] | I18nNsType | undefined = undefined +>(namespace?: Ns) { + const { t: originalT, ...rest } = useNextTranslation(namespace); const t = useMemo(() => createSafeTranslation(originalT), [originalT]); return { diff --git a/packages/web/test/i18n/useSafeTranslation.test.ts b/packages/web/test/i18n/useSafeTranslation.test.ts index 745fc7836912..c363d27e38b4 100644 --- a/packages/web/test/i18n/useSafeTranslation.test.ts +++ b/packages/web/test/i18n/useSafeTranslation.test.ts @@ -1,66 +1,136 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createElement } from 'react'; +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; -import { useTranslation } from 'next-i18next'; +import { createRequire } from 'node:module'; +import { createInstance, type ReadCallback } from 'i18next'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; +import type { I18nNsType } from '@fastgpt/web/i18n/i18next'; +import accountTeamResource from '../../i18n/en/account_team.json'; -vi.mock('next-i18next', () => ({ - useTranslation: vi.fn() -})); +// next-i18next 使用 CJS 版 react-i18next,测试 Provider 也使用同一模块,避免 ESM/CJS 上下文分离。 +const require = createRequire(import.meta.url); +const { I18nextProvider } = createRequire(require.resolve('next-i18next'))( + 'react-i18next' +) as typeof import('react-i18next'); describe('useSafeTranslation', () => { - beforeEach(() => { - vi.mocked(useTranslation).mockReturnValue({ - t: ((key: string) => key) as never, - i18n: { language: 'en' } as never, - ready: true + let i18n: ReturnType; + + beforeEach(async () => { + i18n = createInstance(); + await i18n.init({ + lng: 'en', + fallbackLng: false, + defaultNS: 'common', + ns: ['common', 'account'], + resources: { + en: { + common: {}, + account: { personal_information: 'Personal information' } + } + }, + react: { useSuspense: false } }); }); - it('调用 useTranslation 获取上下文', () => { + it('读取现有 i18next 上下文', () => { function TestComponent() { - useSafeTranslation(); + const { i18n: contextI18n } = useSafeTranslation(); + expect(contextI18n).toBe(i18n); return null; } - renderToStaticMarkup(createElement(TestComponent)); - - expect(useTranslation).toHaveBeenCalled(); + renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(TestComponent))); }); it('完整语言包就绪后正常翻译', () => { - vi.mocked(useTranslation).mockReturnValue({ - t: ((key: string) => `translated:${key}`) as never, - i18n: { language: 'en' } as never, - ready: true - }); - function TestComponent() { const { t } = useSafeTranslation(); return createElement('span', null, t('account:personal_information')); } - expect(renderToStaticMarkup(createElement(TestComponent))).toContain( - 'translated:account:personal_information' - ); + expect( + renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(TestComponent))) + ).toContain('Personal information'); }); it('支持动态翻译 key,并对未知 namespace 或普通字符串原样返回', () => { - vi.mocked(useTranslation).mockReturnValue({ - t: ((key: string) => `translated:${key}`) as never, - i18n: { language: 'en' } as never, - ready: true - }); - function TestComponent() { const { t } = useSafeTranslation(); const dynamicKey: string = 'account:personal_information'; - return createElement('span', null, `${t(dynamicKey)}|${t('09:30')}`); } - expect(renderToStaticMarkup(createElement(TestComponent))).toContain( - 'translated:account:personal_information|09:30' - ); + expect( + renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(TestComponent))) + ).toContain('Personal information|09:30'); }); }); + +describe('useSafeTranslation namespace loading on SSR pages', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it.each<{ namespace: I18nNsType[number] | I18nNsType }>([ + { namespace: 'account_team' }, + { namespace: ['common', 'account_team'] } + ])( + 'loads the declared namespace $namespace when it was absent from SSR resources', + async ({ namespace }) => { + const i18n = createInstance(); + const read = vi.fn((_language: string, ns: string, callback: ReadCallback) => { + callback(null, ns === 'account_team' ? accountTeamResource : {}); + }); + i18n.use({ type: 'backend', init() {}, read }); + await i18n.init({ + lng: 'en', + fallbackLng: false, + defaultNS: 'common', + ns: ['common', 'price', 'file', 'app', 'chat', 'workflow', 'login', 'user'], + partialBundledLanguages: true, + resources: { + en: Object.fromEntries( + ['common', 'price', 'file', 'app', 'chat', 'workflow', 'login', 'user'].map((ns) => [ + ns, + {} + ]) + ) + }, + react: { useSuspense: false } + }); + const MemberNameTitle = () => { + const { t } = useSafeTranslation(namespace); + return createElement('span', null, t('account_team:set_member_name_title')); + }; + + await act(async () => { + root.render(createElement(I18nextProvider, { i18n }, createElement(MemberNameTitle))); + }); + + expect(read).toHaveBeenCalledExactlyOnceWith('en', 'account_team', expect.any(Function)); + expect(container.textContent).toBe(accountTeamResource.set_member_name_title); + expect(i18n.hasResourceBundle('en', 'account_team')).toBe(true); + + await act(async () => { + root.render(createElement(I18nextProvider, { i18n }, createElement(MemberNameTitle))); + }); + expect(read).toHaveBeenCalledOnce(); + } + ); +}); diff --git a/projects/app/src/components/Layout/ForceMemberNameModal.tsx b/projects/app/src/components/Layout/ForceMemberNameModal.tsx index 3741dcfbb3c9..30800e782d29 100644 --- a/projects/app/src/components/Layout/ForceMemberNameModal.tsx +++ b/projects/app/src/components/Layout/ForceMemberNameModal.tsx @@ -7,7 +7,7 @@ import { useUserStore } from '@/web/support/user/useUserStore'; * 弹窗不可关闭;提交后刷新用户信息仍处于待补齐状态时保留弹窗,允许用户重试。 */ const ForceMemberNameModal = ({ onSuccess }: { onSuccess: () => void }) => { - const { t } = useSafeTranslation(); + const { t } = useSafeTranslation('account_team'); const { userInfo } = useUserStore(); return ( diff --git a/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx b/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx index 592cbc1437ac..3396762b88ef 100644 --- a/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx +++ b/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx @@ -31,7 +31,7 @@ const MemberNameFormModal = ({ onSubmitted, onClose }: MemberNameFormModalProps) => { - const { t } = useSafeTranslation(); + const { t } = useSafeTranslation('account_team'); const { initUserInfo } = useUserStore(); const { memberName, nameError, showNameError, markInteracted, onNameChange, parseMemberName } = useMemberNameForm({ defaultName }); diff --git a/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts b/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts index 6aa8c3627a2b..7d7613275a9a 100644 --- a/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts +++ b/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts @@ -18,7 +18,7 @@ export const getMemberNameError = ({ value, t }: { value: string; t: TFunction } * 保证提示时机一致:只有用户交互过之后才展示错误,避免弹窗一打开就标红。 */ export const useMemberNameForm = ({ defaultName }: { defaultName: string }) => { - const { t } = useSafeTranslation(); + const { t } = useSafeTranslation('account_team'); const [memberName, setMemberName] = useState(defaultName); const [hasInteracted, setHasInteracted] = useState(false); diff --git a/projects/app/src/pageComponents/dashboard/agent/context.tsx b/projects/app/src/pageComponents/dashboard/agent/context.tsx index 9116057a8eac..86f8d4a1daff 100644 --- a/projects/app/src/pageComponents/dashboard/agent/context.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/context.tsx @@ -228,9 +228,7 @@ const AppListContextProvider = ({ return fetchApps(persistedTmbIds); }, { - disabled: !router.isReady, refreshDeps: [ - router.isReady, searchKey, parentId, appType, @@ -252,8 +250,7 @@ const AppListContextProvider = ({ () => getAppFolderPath({ sourceId: parentId, type: 'current' }), { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); @@ -264,8 +261,7 @@ const AppListContextProvider = ({ }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); diff --git a/projects/app/src/pageComponents/dashboard/skill/context.tsx b/projects/app/src/pageComponents/dashboard/skill/context.tsx index 6c34c13a98bb..bb281c43daba 100644 --- a/projects/app/src/pageComponents/dashboard/skill/context.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/context.tsx @@ -184,9 +184,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { total: res.total })), { - disabled: !router.isReady, refreshDeps: [ - router.isReady, searchKey, parentId, applyToolbarFilters ? listFilters.sort : '', @@ -210,8 +208,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); @@ -224,8 +221,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); diff --git a/projects/app/src/pageComponents/dataset/list/context.tsx b/projects/app/src/pageComponents/dataset/list/context.tsx index 09e51785a813..4cbe90cbbee8 100644 --- a/projects/app/src/pageComponents/dataset/list/context.tsx +++ b/projects/app/src/pageComponents/dataset/list/context.tsx @@ -180,9 +180,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { ...(tmbIds !== undefined ? { tmbIds } : {}) }), { - disabled: !router.isReady, refreshDeps: [ - router.isReady, parentId, searchKey, listType?.join(',') ?? 'all', @@ -203,8 +201,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { () => (parentId ? getDatasetById(parentId) : Promise.resolve(undefined)), { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); @@ -215,8 +212,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); diff --git a/projects/app/src/pages/dashboard/create/index.tsx b/projects/app/src/pages/dashboard/create/index.tsx index 1de35ec7aee5..368015d09764 100644 --- a/projects/app/src/pages/dashboard/create/index.tsx +++ b/projects/app/src/pages/dashboard/create/index.tsx @@ -21,7 +21,6 @@ import { postCreateApp } from '@/web/core/app/api'; import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar'; import { getUploadAvatarPresignedUrl } from '@/web/common/file/api'; import { useRouter } from 'next/router'; -import Loading from '@fastgpt/web/components/common/MyLoading'; import { getEmptyAppsTemplate } from '@/web/core/app/templates'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import Avatar from '@fastgpt/web/components/common/Avatar'; @@ -70,7 +69,7 @@ export type CreateAppType = | AppTypeEnum.mcpToolSet | AppTypeEnum.httpToolSet; -const CreateAppsContent = () => { +const CreateAppsPage = () => { const { t } = useSafeTranslation(); const router = useRouter(); const { isPc } = useSystem(); @@ -621,14 +620,4 @@ const CreateAppsContent = () => { ); }; -const CreateAppsPage = () => { - const router = useRouter(); - - if (!router.isReady) { - return ; - } - - return ; -}; - export default CreateAppsPage; diff --git a/projects/app/src/web/context/AppShell.tsx b/projects/app/src/web/context/AppShell.tsx index 96d2fdad7a08..b4f8d1d634b1 100644 --- a/projects/app/src/web/context/AppShell.tsx +++ b/projects/app/src/web/context/AppShell.tsx @@ -6,7 +6,7 @@ import ChakraUIContext from '@/web/context/ChakraUI'; import { useInitApp } from '@/web/context/useInitApp'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import NextHead from '@/components/common/NextHead'; -import { type ReactElement, type ReactNode, useEffect } from 'react'; +import React, { type ReactElement, type ReactNode, useEffect } from 'react'; import { type NextPage } from 'next'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import SystemStoreContextProvider from '@fastgpt/web/context/useSystem'; @@ -16,6 +16,7 @@ import { appClientEnv } from '@/web/common/system/env'; import ClientI18nBoundary from '@fastgpt/web/i18n/ClientI18nBoundary'; import ClientI18nGate from '@fastgpt/web/i18n/ClientI18nGate'; import { LANG_KEY } from '@fastgpt/web/i18n/utils'; +import ClientRouteReadyGate from './ClientRouteReadyGate'; type NextPageWithLayout = NextPage & { setLayout?: (page: ReactElement) => JSX.Element; @@ -95,7 +96,9 @@ const AppShell = (props: AppPropsWithLayout) => { - {props.clientOnly ? {content} : content} + + {props.clientOnly ? {content} : content} + diff --git a/projects/app/src/web/context/ClientRouteReadyGate.tsx b/projects/app/src/web/context/ClientRouteReadyGate.tsx new file mode 100644 index 000000000000..d9e1c386672c --- /dev/null +++ b/projects/app/src/web/context/ClientRouteReadyGate.tsx @@ -0,0 +1,29 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState, type ReactNode } from 'react'; + +/** + * CSR 业务子树只在路由首次就绪后挂载,统一保障 query 驱动的请求和表单初始化。 + * SSR 页面直接放行;首次就绪后不再关闭门禁,避免后续导航卸载页面、丢失编辑状态。 + */ +const ClientRouteReadyGate = ({ + enabled, + children +}: { + enabled: boolean; + children?: ReactNode; +}) => { + const { isReady } = useRouter(); + const [ready, setReady] = useState(false); + + useEffect(() => { + // hydration 首次必须与服务端一样关闭门禁,提交后才能开放业务子树。 + // eslint-disable-next-line react-hooks/set-state-in-effect + if (isReady) setReady(true); + }, [isReady]); + + // 服务端和 hydration 首次渲染都关闭 CSR 门禁,由 effect 开放,保持两端输出一致。 + if (enabled && !ready) return null; + return children; +}; + +export default ClientRouteReadyGate; diff --git a/projects/app/src/web/context/useInitApp.ts b/projects/app/src/web/context/useInitApp.ts index 9fe238a7b5db..f77f3b4d7255 100644 --- a/projects/app/src/web/context/useInitApp.ts +++ b/projects/app/src/web/context/useInitApp.ts @@ -157,7 +157,8 @@ export const useInitApp = () => { if (bd_vid) setBdVId(bd_vid); if (msclkid) setMsclkid(msclkid); if (utm_workflow) setUtmWorkflow(utm_workflow); - if (sourceDomain) initFastGPTSemSourceDomain(sourceDomain); + // 未显式携带来源时仍读取 document.referrer,并锁定本次首次归因。 + initFastGPTSemSourceDomain(sourceDomain); const utmParams: ShortUrlParams = { ...(utm_source && { shortUrlSource: utm_source }), diff --git a/projects/app/test/web/context/AppShell.test.ts b/projects/app/test/web/context/AppShell.test.ts new file mode 100644 index 000000000000..001d62d4ab90 --- /dev/null +++ b/projects/app/test/web/context/AppShell.test.ts @@ -0,0 +1,144 @@ +import { JSDOM } from 'jsdom'; +import React, { act, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppProps } from 'next/app'; +import AppShell from '@/web/context/AppShell'; + +const mocks = vi.hoisted(() => ({ + router: { + isReady: false, + pathname: '/dashboard/agent', + query: {} as Record + }, + initializeApp: vi.fn(), + initializeLayout: vi.fn(), + languageReady: true +})); + +vi.mock('next/router', () => ({ useRouter: () => mocks.router })); +vi.mock('next/script', () => ({ default: () => null })); +vi.mock('@/web/context/useInitApp', () => ({ + useInitApp: () => { + useEffect(() => { + mocks.initializeApp(mocks.router.query); + }, []); + return { feConfigs: {}, scripts: [], title: 'FastGPT' }; + } +})); +vi.mock('@/components/Layout', () => ({ + default: ({ children }: { children: React.ReactNode }) => { + useEffect(() => { + mocks.initializeLayout(mocks.router.query); + }, []); + return children; + } +})); +vi.mock('@/web/context/QueryClient', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@/web/context/ChakraUI', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@fastgpt/web/context/useSystem', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@fastgpt/web/i18n/ClientI18nGate', () => ({ + default: ({ children }: { children: React.ReactNode }) => (mocks.languageReady ? children : null) +})); +vi.mock('@fastgpt/web/i18n/ClientI18nBoundary', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@fastgpt/web/hooks/useSafeTranslation', () => ({ + useSafeTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) +})); +vi.mock('@/components/common/NextHead', () => ({ default: () => null })); +vi.mock('@fastgpt/web/common/system/utils', () => ({ getWebReqUrl: (url: string) => url })); +vi.mock('@/web/common/utils/errorLogger', () => ({ errorLogger: { init: vi.fn() } })); +vi.mock('@/web/common/system/env', () => ({ appClientEnv: {} })); + +describe('AppShell startup boundaries', () => { + let container: HTMLDivElement; + let root: Root; + const Page = () => React.createElement('div', null, 'business-page'); + const render = async (clientOnly: boolean) => { + await act(async () => + root.render( + React.createElement(AppShell, { + Component: Page, + pageProps: {}, + router: mocks.router as unknown as AppProps['router'], + clientOnly + }) + ) + ); + }; + + beforeEach(() => { + const dom = new JSDOM(''); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.clearAllMocks(); + mocks.router.isReady = false; + mocks.router.query = {}; + mocks.router.pathname = '/dashboard/agent'; + mocks.languageReady = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + window.close(); + vi.unstubAllGlobals(); + }); + + it('also gates application and Layout initialization until CSR query is ready', async () => { + await render(true); + expect(mocks.initializeApp).not.toHaveBeenCalled(); + expect(mocks.initializeLayout).not.toHaveBeenCalled(); + expect(container.textContent).toBe(''); + + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-1', couponCode: 'SAVE20' }; + await render(true); + + expect(mocks.initializeApp).toHaveBeenCalledExactlyOnceWith(mocks.router.query); + expect(mocks.initializeLayout).toHaveBeenCalledExactlyOnceWith(mocks.router.query); + expect(container.textContent).toBe('business-page'); + }); + + it('waits for both language resources and route readiness', async () => { + mocks.languageReady = false; + mocks.router.isReady = true; + await render(true); + expect(mocks.initializeApp).not.toHaveBeenCalled(); + + mocks.languageReady = true; + await render(true); + expect(mocks.initializeApp).toHaveBeenCalledOnce(); + }); + + it.each(['/chat', '/chat/share'])( + 'keeps %s server content outside CSR startup gates', + (pathname) => { + mocks.router.pathname = pathname; + mocks.languageReady = false; + + expect( + renderToString( + React.createElement(AppShell, { + Component: Page, + pageProps: {}, + router: mocks.router as unknown as AppProps['router'], + clientOnly: false + }) + ) + ).toContain('business-page'); + } + ); +}); diff --git a/projects/app/test/web/context/ClientRouteReadyGate.test.ts b/projects/app/test/web/context/ClientRouteReadyGate.test.ts new file mode 100644 index 000000000000..65f91549e63b --- /dev/null +++ b/projects/app/test/web/context/ClientRouteReadyGate.test.ts @@ -0,0 +1,137 @@ +import { JSDOM } from 'jsdom'; +import React, { act, useEffect, useState } from 'react'; +import { createRoot, hydrateRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ClientRouteReadyGate from '@/web/context/ClientRouteReadyGate'; + +const mocks = vi.hoisted(() => ({ + router: { + isReady: false, + query: {} as Record + } +})); + +vi.mock('next/router', () => ({ useRouter: () => mocks.router })); + +describe('ClientRouteReadyGate', () => { + let container: HTMLDivElement; + let root: Root | undefined; + const requests = vi.fn(); + const mounts = vi.fn(); + const unmounts = vi.fn(); + + const BusinessPage = () => { + const [count, setCount] = useState(0); + const { parentId } = mocks.router.query; + + useEffect(() => { + mounts(); + return () => unmounts(); + }, []); + useEffect(() => { + requests(parentId); + }, [parentId]); + + return React.createElement('button', { onClick: () => setCount(count + 1) }, count); + }; + + const page = (enabled = true) => + React.createElement(ClientRouteReadyGate, { enabled }, React.createElement(BusinessPage)); + + beforeEach(() => { + const dom = new JSDOM(''); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.clearAllMocks(); + mocks.router.isReady = false; + mocks.router.query = {}; + container = document.createElement('div'); + document.body.appendChild(container); + root = undefined; + }); + + afterEach(async () => { + await act(async () => root?.unmount()); + container.remove(); + window.close(); + vi.unstubAllGlobals(); + }); + + it('waits for query hydration before mounting and requesting business data', async () => { + root = createRoot(container); + await act(async () => root!.render(page())); + expect(container.innerHTML).toBe(''); + expect(mounts).not.toHaveBeenCalled(); + expect(requests).not.toHaveBeenCalled(); + + mocks.router.query = { parentId: 'folder-1' }; + mocks.router.isReady = true; + await act(async () => root!.render(page())); + + expect(mounts).toHaveBeenCalledOnce(); + expect(requests).toHaveBeenCalledExactlyOnceWith('folder-1'); + }); + + it('allows SSR pages to render and mount without waiting for isReady', async () => { + expect(renderToString(page(false))).toContain(''); + root = createRoot(container); + await act(async () => root!.render(page(false))); + + expect(mounts).toHaveBeenCalledOnce(); + expect(container.querySelector('button')).not.toBeNull(); + }); + + it('keeps the initial CSR markup consistent when the client router is already ready', async () => { + const serverMarkup = renderToString(page()); + expect(serverMarkup).toBe(''); + container.innerHTML = serverMarkup; + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-hydrated' }; + const onRecoverableError = vi.fn(); + + await act(async () => { + root = hydrateRoot(container, page(), { onRecoverableError }); + }); + + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(requests).toHaveBeenCalledExactlyOnceWith('folder-hydrated'); + }); + + it('preserves page state and refreshes only business dependencies during navigation', async () => { + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-1' }; + root = createRoot(container); + await act(async () => root!.render(page())); + await act(async () => container.querySelector('button')!.click()); + + // 首次初始化完成后,路由状态变化不能关闭门禁或丢失用户编辑状态。 + mocks.router.isReady = false; + await act(async () => root!.render(page())); + expect(container.textContent).toBe('1'); + expect(unmounts).not.toHaveBeenCalled(); + + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-2' }; + await act(async () => root!.render(page())); + + expect(container.textContent).toBe('1'); + expect(mounts).toHaveBeenCalledOnce(); + expect(requests.mock.calls).toEqual([['folder-1'], ['folder-2']]); + }); + + it('does not close an initialized gate when switching between SSR and CSR routes', async () => { + mocks.router.isReady = true; + root = createRoot(container); + await act(async () => root!.render(page(false))); + await act(async () => container.querySelector('button')!.click()); + + await act(async () => root!.render(page(true))); + expect(container.textContent).toBe('1'); + await act(async () => root!.render(page(false))); + expect(container.textContent).toBe('1'); + expect(mounts).toHaveBeenCalledOnce(); + expect(unmounts).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/app/test/web/context/useInitApp.test.ts b/projects/app/test/web/context/useInitApp.test.ts index d91c91af42e5..6caf810529ee 100644 --- a/projects/app/test/web/context/useInitApp.test.ts +++ b/projects/app/test/web/context/useInitApp.test.ts @@ -87,6 +87,7 @@ describe('useInitApp marketing params readiness gate', () => { expect(mocks.setBdVId).not.toHaveBeenCalled(); expect(mocks.setCouponCode).not.toHaveBeenCalled(); + expect(mocks.initFastGPTSemSourceDomain).not.toHaveBeenCalled(); }); it('consumes marketing params after router.isReady becomes true', () => { @@ -109,4 +110,27 @@ describe('useInitApp marketing params readiness gate', () => { }) ); }); + + it('initializes referrer attribution even without an explicit sourceDomain', () => { + mocks.router.isReady = true; + + useInitApp(); + mocks.effects.forEach((effect) => effect()); + + expect(mocks.initFastGPTSemSourceDomain).toHaveBeenCalledWith(undefined); + }); + + it('passes the explicit sourceDomain and initializes it only once', () => { + mocks.router.isReady = true; + mocks.router.query = { sourceDomain: 'https://campaign.example.com' }; + + useInitApp(); + mocks.effects.forEach((effect) => { + effect(); + effect(); + }); + + expect(mocks.initFastGPTSemSourceDomain).toHaveBeenCalledOnce(); + expect(mocks.initFastGPTSemSourceDomain).toHaveBeenCalledWith('https://campaign.example.com'); + }); }); diff --git a/projects/app/test/web/support/marketing/utils.test.ts b/projects/app/test/web/support/marketing/utils.test.ts index 383f5a781077..b97a6ef28c30 100644 --- a/projects/app/test/web/support/marketing/utils.test.ts +++ b/projects/app/test/web/support/marketing/utils.test.ts @@ -24,6 +24,14 @@ describe('marketing utils', () => { vi.stubGlobal('document', { referrer: '' }); }); + it('falls back to document.referrer when no sourceDomain is provided', () => { + vi.stubGlobal('document', { referrer: 'https://campaign.example.com' }); + + initFastGPTSemSourceDomain(); + + expect(getFastGPTSem()?.sourceDomain).toBe('https://campaign.example.com'); + }); + it('should lock sourceDomain after the first init even when source is empty', () => { initFastGPTSemSourceDomain(); vi.stubGlobal('document', { referrer: 'https://redirect.example.com' }); From 559e93108fa364e703b029fff3397ad2de337ca7 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Thu, 8 Oct 2026 23:00:08 +0800 Subject: [PATCH 2/2] fix(i18n): preload member-name translations on SSR chat pages --- .../common/client-i18n-csr-migration.md | 25 ++--- packages/web/hooks/useSafeTranslation.ts | 11 +- .../web/test/i18n/useSafeTranslation.test.ts | 102 ++++++++---------- .../Layout/ForceMemberNameModal.tsx | 2 +- .../account/team/MemberNameForm/index.tsx | 2 +- .../team/MemberNameForm/useMemberNameForm.ts | 2 +- projects/app/src/pages/chat/index.tsx | 11 +- projects/app/src/pages/chat/share.tsx | 3 +- 8 files changed, 72 insertions(+), 86 deletions(-) diff --git a/.agents/design/common/client-i18n-csr-migration.md b/.agents/design/common/client-i18n-csr-migration.md index 448c6ef8a8b7..b579f1399153 100644 --- a/.agents/design/common/client-i18n-csr-migration.md +++ b/.agents/design/common/client-i18n-csr-migration.md @@ -156,25 +156,23 @@ client-only 门禁加载语言包后,通过 `addResourceBundle` 一次性注 `projects/app/public/locales`,并以版本化 URL 通过 HTTP backend 加载。试点期间不同时实现两套 资源加载机制。 -### 3.3 CSR 完整语言包与 SSR 组件按需加载 +### 3.3 CSR 完整语言包与 SSR 页面资源声明 CSR 应用在挂载业务子树前注册完整语言包,因此业务组件统一调用 `useSafeTranslation()`, 不再逐组件复制 namespace 加载声明。翻译调用仍使用可静态识别的 `t('namespace:literal_key')`。 `/chat` 和 `/chat/share` 保留 SSR,`serviceSideProps` 只注入页面声明的 namespace,不能假设它们 -也经过 CSR 完整语言包门禁。跨两种渲染模式复用的组件如果需要额外资源,应通过同一个 hook -显式声明,由已有的 i18next backend 按需补齐: +也经过 CSR 完整语言包门禁。SSR 页面必须声明页面和全局 Layout 可达组件所需的资源: -```tsx -const { t } = useSafeTranslation('account_team'); +```ts +await serviceSideProps(context, ['file', 'app', 'chat', 'workflow', 'account_team']); ``` -强制成员名弹窗、成员名表单及表单校验 hook 显式声明 `account_team`,保证直接访问 `/chat` -且语言未发生切换时,也能加载标题、按钮和校验文案。重复声明由 i18next 缓存复用,不重复请求。 -`common` 是默认 namespace;调用方也可传 namespace 数组。所有资源加载继续使用现有 backend, -组件不自行 `fetch` 或 `import` 翻译文件。 +两个 SSR 聊天页面均声明 `account_team`,保证强制成员名弹窗的标题、按钮和校验文案已随 +SSR props 注入。共享组件继续使用无参数 `useSafeTranslation()`,无需增加组件级加载状态。 +`common` 和 `price` 由 `serviceSideProps` 统一补齐;组件不自行 `fetch` 或 `import` 翻译文件。 -CSR/SSR 路由判定仅决定渲染模式,翻译依赖由完整包门禁或 SSR 组件声明分别保障。 +CSR/SSR 路由判定仅决定渲染模式,翻译依赖由完整包门禁或 SSR 页面声明分别保障。 ### 3.4 缓存与并发去重 @@ -216,9 +214,8 @@ i18n 和静态 chunk,且不渲染不完整翻译。 `localStorage`/Cookie 仅继续存语言偏好,不存翻译正文。 -已迁移组件使用 `useSafeTranslation('业务 namespace')`。该共享 hook 内部组合 -`['common', namespace]` 并关闭 Suspense,调用方不重复声明 `common`,同时保留带 namespace 前缀的 -翻译 key 类型检查。 +已迁移组件统一使用 `useSafeTranslation()`,翻译 key 保留 namespace 前缀。 +CSR 完整语言包门禁和 SSR 页面资源声明分别保证组件挂载时所需翻译已就绪。 ### 3.5 页面渲染门禁 @@ -327,7 +324,7 @@ SSR 聊天页面直接渲染,不等待 CSR 门禁,因此仍输出服务端 验证要求:未就绪时 AppContent 和 Layout 的 effect 不执行;就绪后首次请求携带正确 query; hydration 不报 mismatch;后续 query 更新保留页面编辑状态;SSR 聊天页仍输出业务内容; -直接访问 SSR 页面时缺失的组件 namespace 能按需补齐;营销归因无参数回落正常。 +直接访问 SSR 页面时成员名弹窗资源已注入,无需客户端补加载;营销归因无参数回落正常。 ### 3.8 `deviceSize` 处理 diff --git a/packages/web/hooks/useSafeTranslation.ts b/packages/web/hooks/useSafeTranslation.ts index 3501ead29e9f..9828ffe98cb4 100644 --- a/packages/web/hooks/useSafeTranslation.ts +++ b/packages/web/hooks/useSafeTranslation.ts @@ -2,7 +2,6 @@ import { useTranslation as useNextTranslation } from 'next-i18next'; import type { TFunction } from 'i18next'; import { useMemo } from 'react'; import { I18N_NAMESPACES_MAP } from '../i18n/constants'; -import type { I18nNsType } from '../i18n/i18next'; export type SafeTranslation = T & ((key: string, ...args: any[]) => string); @@ -28,14 +27,8 @@ export const createSafeTranslation = (originalT: T): SafeTr return t as SafeTranslation; }; -/** - * CSR 页面直接使用启动阶段已加载的完整语言包;SSR 页面复用的组件可显式声明业务 - * namespace,由 i18next 按需补齐资源,避免默认 common 已就绪但业务文案仍裸显 key。 - */ -export function useSafeTranslation< - Ns extends I18nNsType[number] | I18nNsType | undefined = undefined ->(namespace?: Ns) { - const { t: originalT, ...rest } = useNextTranslation(namespace); +export function useSafeTranslation() { + const { t: originalT, ...rest } = useNextTranslation(); const t = useMemo(() => createSafeTranslation(originalT), [originalT]); return { diff --git a/packages/web/test/i18n/useSafeTranslation.test.ts b/packages/web/test/i18n/useSafeTranslation.test.ts index c363d27e38b4..ba6069e74276 100644 --- a/packages/web/test/i18n/useSafeTranslation.test.ts +++ b/packages/web/test/i18n/useSafeTranslation.test.ts @@ -1,14 +1,15 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, createElement } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { createRequire } from 'node:module'; -import { createInstance, type ReadCallback } from 'i18next'; +import { createInstance } from 'i18next'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; -import type { I18nNsType } from '@fastgpt/web/i18n/i18next'; -import accountTeamResource from '../../i18n/en/account_team.json'; +import enAccountTeam from '../../i18n/en/account_team.json'; +import zhCNAccountTeam from '../../i18n/zh-CN/account_team.json'; +import zhHantAccountTeam from '../../i18n/zh-Hant/account_team.json'; +import koKRAccountTeam from '../../i18n/ko-KR/account_team.json'; // next-i18next 使用 CJS 版 react-i18next,测试 Provider 也使用同一模块,避免 ESM/CJS 上下文分离。 const require = createRequire(import.meta.url); @@ -70,67 +71,52 @@ describe('useSafeTranslation', () => { }); }); -describe('useSafeTranslation namespace loading on SSR pages', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - }); - - it.each<{ namespace: I18nNsType[number] | I18nNsType }>([ - { namespace: 'account_team' }, - { namespace: ['common', 'account_team'] } +describe('useSafeTranslation with SSR member-name resources', () => { + it.each([ + { language: 'en', resource: enAccountTeam }, + { language: 'zh-CN', resource: zhCNAccountTeam }, + { language: 'zh-Hant', resource: zhHantAccountTeam }, + { language: 'ko-KR', resource: koKRAccountTeam } ])( - 'loads the declared namespace $namespace when it was absent from SSR resources', - async ({ namespace }) => { + 'renders member-name text from preloaded $language resources', + async ({ language, resource }) => { const i18n = createInstance(); - const read = vi.fn((_language: string, ns: string, callback: ReadCallback) => { - callback(null, ns === 'account_team' ? accountTeamResource : {}); - }); - i18n.use({ type: 'backend', init() {}, read }); await i18n.init({ - lng: 'en', + lng: language, fallbackLng: false, defaultNS: 'common', - ns: ['common', 'price', 'file', 'app', 'chat', 'workflow', 'login', 'user'], - partialBundledLanguages: true, - resources: { - en: Object.fromEntries( - ['common', 'price', 'file', 'app', 'chat', 'workflow', 'login', 'user'].map((ns) => [ - ns, - {} - ]) - ) - }, + ns: ['common', 'account_team'], + resources: { [language]: { common: {}, account_team: resource } }, react: { useSuspense: false } }); - const MemberNameTitle = () => { - const { t } = useSafeTranslation(namespace); - return createElement('span', null, t('account_team:set_member_name_title')); + const MemberNameText = () => { + const { t } = useSafeTranslation(); + return createElement( + 'span', + null, + [ + t('account_team:set_member_name_title'), + t('account_team:invite_member_name_placeholder'), + t('account_team:confirm_member_name'), + t('account_team:member_name_required'), + t('account_team:member_name_limit') + ].join('|') + ); }; - await act(async () => { - root.render(createElement(I18nextProvider, { i18n }, createElement(MemberNameTitle))); - }); - - expect(read).toHaveBeenCalledExactlyOnceWith('en', 'account_team', expect.any(Function)); - expect(container.textContent).toBe(accountTeamResource.set_member_name_title); - expect(i18n.hasResourceBundle('en', 'account_team')).toBe(true); - - await act(async () => { - root.render(createElement(I18nextProvider, { i18n }, createElement(MemberNameTitle))); - }); - expect(read).toHaveBeenCalledOnce(); + expect( + renderToStaticMarkup( + createElement(I18nextProvider, { i18n }, createElement(MemberNameText)) + ) + ).toBe( + `${[ + resource.set_member_name_title, + resource.invite_member_name_placeholder, + resource.confirm_member_name, + resource.member_name_required, + resource.member_name_limit + ].join('|')}` + ); } ); }); diff --git a/projects/app/src/components/Layout/ForceMemberNameModal.tsx b/projects/app/src/components/Layout/ForceMemberNameModal.tsx index 30800e782d29..3741dcfbb3c9 100644 --- a/projects/app/src/components/Layout/ForceMemberNameModal.tsx +++ b/projects/app/src/components/Layout/ForceMemberNameModal.tsx @@ -7,7 +7,7 @@ import { useUserStore } from '@/web/support/user/useUserStore'; * 弹窗不可关闭;提交后刷新用户信息仍处于待补齐状态时保留弹窗,允许用户重试。 */ const ForceMemberNameModal = ({ onSuccess }: { onSuccess: () => void }) => { - const { t } = useSafeTranslation('account_team'); + const { t } = useSafeTranslation(); const { userInfo } = useUserStore(); return ( diff --git a/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx b/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx index 3396762b88ef..592cbc1437ac 100644 --- a/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx +++ b/projects/app/src/pageComponents/account/team/MemberNameForm/index.tsx @@ -31,7 +31,7 @@ const MemberNameFormModal = ({ onSubmitted, onClose }: MemberNameFormModalProps) => { - const { t } = useSafeTranslation('account_team'); + const { t } = useSafeTranslation(); const { initUserInfo } = useUserStore(); const { memberName, nameError, showNameError, markInteracted, onNameChange, parseMemberName } = useMemberNameForm({ defaultName }); diff --git a/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts b/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts index 7d7613275a9a..6aa8c3627a2b 100644 --- a/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts +++ b/projects/app/src/pageComponents/account/team/MemberNameForm/useMemberNameForm.ts @@ -18,7 +18,7 @@ export const getMemberNameError = ({ value, t }: { value: string; t: TFunction } * 保证提示时机一致:只有用户交互过之后才展示错误,避免弹窗一打开就标红。 */ export const useMemberNameForm = ({ defaultName }: { defaultName: string }) => { - const { t } = useSafeTranslation('account_team'); + const { t } = useSafeTranslation(); const [memberName, setMemberName] = useState(defaultName); const [hasInteracted, setHasInteracted] = useState(false); diff --git a/projects/app/src/pages/chat/index.tsx b/projects/app/src/pages/chat/index.tsx index f1ce1214a9f8..3ad5cd339603 100644 --- a/projects/app/src/pages/chat/index.tsx +++ b/projects/app/src/pages/chat/index.tsx @@ -330,7 +330,16 @@ export async function getServerSideProps(context: any) { showFullText: chatQuoteReaderConfig?.showFullText ?? true, canDownloadSource: chatQuoteReaderConfig?.canDownloadSource ?? true, showWholeResponse: chatQuoteReaderConfig?.showWholeResponse ?? true, - ...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow', 'login', 'user'])) + // Layout 的强制成员名弹窗也会在聊天页打开,SSR 必须一并注入其翻译资源。 + ...(await serviceSideProps(context, [ + 'file', + 'app', + 'chat', + 'workflow', + 'login', + 'user', + 'account_team' + ])) } }; } diff --git a/projects/app/src/pages/chat/share.tsx b/projects/app/src/pages/chat/share.tsx index bcded885d48b..691716b0c5bc 100644 --- a/projects/app/src/pages/chat/share.tsx +++ b/projects/app/src/pages/chat/share.tsx @@ -635,7 +635,8 @@ export async function getServerSideProps(context: any) { shareId: shareId ?? '', authToken: authToken ?? '', customUid, - ...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow'], { + // 分享页同样挂载全局 Layout,预加载强制成员名弹窗所需的 namespace。 + ...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow', 'account_team'], { langCookieKey: SHARE_LANG_KEY, fallbackLangCookieKey: LANG_KEY }))