feat(portal-shell): integrate next-intl + merge messages from teacher-portal (P1-4)

- Add next-intl v4.13.2 with cookie-based locale (no i18n routing)
- Create src/i18n/request.ts reading NEXT_LOCALE cookie
- Merge zh-CN/en messages from teacher-portal + add shell.dev.templates namespace
- Wrap next.config.js with withNextIntl plugin (Turbopack resolveAlias)
- Refactor RootLayout to async + NextIntlClientProvider + getLocale/getMessages
- Replace ThemeI18nProvider with ThemeProvider (theme-only, i18n removed)
- Remove locale/setLocale from PluginStore
- Rework locale-switcher to useLocale/useTranslations + router.refresh
- Update dev/templates page to use getTranslations (Server Component)
- Fix WorkbenchPageShell test (loading prop + center instead of children)

Verified: locale switch via NEXT_LOCALE cookie changes <html lang> and messages
zh-CN: 保存/取消/切换侧栏 | en: Save/Cancel/Toggle sidebar
typecheck 0 errors | lint 0 errors 2 warnings (generated) | vitest 231 passed
This commit is contained in:
SpecialX
2026-07-22 13:40:44 +08:00
parent 994441c2dc
commit da05c9107a
17 changed files with 1040 additions and 158 deletions

View File

@@ -2,6 +2,8 @@ import "./globals.css";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Toaster } from "@/shared/components/ui/sonner";
@@ -31,24 +33,35 @@ export const viewport: Viewport = {
};
/**
* RootLayout
* RootLayoutP1-4next-intl 接入)
*
* 仅负责 <html>/<body> 与字体变量 + 全局 Toaster。
* 业务 ProvidersApollo/Auth/ThemeI18n在 ClientShell 中挂载spec §5.5)。
* 职责:
* - <html lang={locale}>:从 next-intl getLocale() 获取cookie 驱动)
* - NextIntlClientProvider将 messages 注入客户端useTranslations 全局可用
* - 字体变量 + 全局 Toaster
*
* suppressHydrationWarningThemeI18nProvider 在客户端切换 .dark class
* 业务 ProvidersApollo/Auth/Theme在 ClientShell 中挂载spec §5.5)。
*
* suppressHydrationWarningThemeProvider 在客户端切换 .dark class
* 与 SSR 输出的 <html class=""> 不一致,需抑制 hydration 警告。
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A6、§8.7
*/
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: ReactNode;
}): ReactNode {
}): Promise<ReactNode> {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang="zh-CN" suppressHydrationWarning className={inter.variable}>
<html lang={locale} suppressHydrationWarning className={inter.variable}>
<body className="font-sans antialiased">
{children}
<Toaster />
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<Toaster />
</NextIntlClientProvider>
</body>
</html>
);

View File

@@ -1,65 +1,68 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { PageHeader } from "@/shared/components/ui/page-header";
/**
* 模板预览首页ARCHITECTURE.md §10 P1-3
* 模板预览首页ARCHITECTURE.md §10 P1-3 + P1-4 next-intl
*
* /shell/dev/templates — 仅 dev 可见
*
* 生产环境调用 notFound() 渲染 404避免模板示例暴露到线上。
* middleware 已放行 /shell/dev/**(仅校验登录),此文件做二次守卫。
*
* P1-4文案走 next-intl getTranslationsServer Component
* 切换 NEXT_LOCALE cookie 后页面文案即时切换。
*
* 注:原 ARCHITECTURE.md 使用 `_dev` 命名,但 Next.js 将下划线开头的文件夹
* 视为私有文件夹(不参与路由),故改用 `dev` 命名。
*/
export default function TemplatesIndexPage(): React.ReactElement {
export default async function TemplatesIndexPage(): Promise<React.ReactElement> {
if (process.env.NODE_ENV === "production") {
notFound();
}
const t = await getTranslations("shell.dev.templates");
const templates = [
{
href: "/shell/dev/templates/list",
title: "列表页模板",
description: "PageHeader + FilterBar + DataTable + Pagination + 三态",
title: t("list"),
description: t("listDescription"),
},
{
href: "/shell/dev/templates/detail",
title: "详情页模板",
description: "PageHeader + 信息区 + Tabs/分区 + 关联列表",
title: t("detail"),
description: t("detailDescription"),
},
{
href: "/shell/dev/templates/form",
title: "表单页模板",
description: "PageHeader + 表单 + 提交/取消 + 错误摘要",
title: t("form"),
description: t("formDescription"),
},
{
href: "/shell/dev/templates/workbench",
title: "工作台页模板",
description: "三栏(树/画布/属性)复合组件",
title: t("workbench"),
description: t("workbenchDescription"),
},
];
return (
<div className="flex flex-col gap-6">
<PageHeader
title="页面模板四件套"
description="P1-3 验收用示例页(仅 dev 可见,生产环境 404"
/>
<PageHeader title={t("title")} description={t("description")} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{templates.map((t) => (
{templates.map((tpl) => (
<Link
key={t.href}
href={t.href}
key={tpl.href}
href={tpl.href}
className="block rounded-xl border bg-card p-6 transition-colors hover:bg-accent"
>
<h2 className="text-lg font-semibold">{t.title}</h2>
<h2 className="text-lg font-semibold">{tpl.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t.description}
{tpl.description}
</p>
<p className="mt-3 text-xs text-muted-foreground">{t.href}</p>
<p className="mt-3 text-xs text-muted-foreground">{tpl.href}</p>
</Link>
))}
</div>