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:
@@ -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
|
||||
* RootLayout(P1-4:next-intl 接入)
|
||||
*
|
||||
* 仅负责 <html>/<body> 与字体变量 + 全局 Toaster。
|
||||
* 业务 Providers(Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。
|
||||
* 职责:
|
||||
* - <html lang={locale}>:从 next-intl getLocale() 获取(cookie 驱动)
|
||||
* - NextIntlClientProvider:将 messages 注入客户端,useTranslations 全局可用
|
||||
* - 字体变量 + 全局 Toaster
|
||||
*
|
||||
* suppressHydrationWarning:ThemeI18nProvider 在客户端切换 .dark class,
|
||||
* 业务 Providers(Apollo/Auth/Theme)在 ClientShell 中挂载(spec §5.5)。
|
||||
*
|
||||
* suppressHydrationWarning:ThemeProvider 在客户端切换 .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>
|
||||
);
|
||||
|
||||
@@ -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 getTranslations(Server 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>
|
||||
|
||||
32
apps/portal-shell/src/i18n/request.ts
Normal file
32
apps/portal-shell/src/i18n/request.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* next-intl 请求配置(App Router 无 i18n 路由模式)
|
||||
*
|
||||
* ARCHITECTURE.md §3.4 V3-A6:next-intl 正式接入
|
||||
*
|
||||
* 采用"无 i18n 路由"策略(locale 不出现在 URL 中):
|
||||
* - locale 由 NEXT_LOCALE cookie 决定(locale-switcher 写入)
|
||||
* - 缺省 locale = zh-CN
|
||||
* - 切换 locale 时设置 cookie + router.refresh() 触发 RSC 重新渲染
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A6、§8.7、§11.2
|
||||
*/
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const locales = ["zh-CN", "en"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
export const defaultLocale: Locale = "zh-CN";
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
|
||||
const locale: Locale =
|
||||
cookieLocale && locales.includes(cookieLocale as Locale)
|
||||
? (cookieLocale as Locale)
|
||||
: defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
214
apps/portal-shell/src/messages/en.json
Normal file
214
apps/portal-shell/src/messages/en.json
Normal file
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"common": {
|
||||
"brand": "Edu",
|
||||
"button": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"export": "Export",
|
||||
"search": "Search",
|
||||
"delete": "Delete",
|
||||
"retry": "Retry",
|
||||
"back": "Back"
|
||||
},
|
||||
"label": {
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"student": "Student",
|
||||
"studentNo": "Student No.",
|
||||
"score": "Score",
|
||||
"feedback": "Feedback",
|
||||
"class": "Class",
|
||||
"subject": "Subject",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"roles": "Roles"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
"saved": "Saved"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load: {message}",
|
||||
"pageError": "Something went wrong"
|
||||
},
|
||||
"nav": {
|
||||
"empty": "No viewports available",
|
||||
"loadError": "Navigation failed to load: {message}",
|
||||
"logout": "Log out",
|
||||
"noRoles": "No roles"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"zhCN": "简体中文",
|
||||
"en": "English"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No data",
|
||||
"description": "Adjust filters and retry, or create your first record"
|
||||
},
|
||||
"navLabel": {
|
||||
"dashboard": "Dashboard",
|
||||
"classes": "Classes",
|
||||
"exams": "Exams",
|
||||
"homework": "Homework",
|
||||
"grades": "Grades",
|
||||
"analytics": "Analytics",
|
||||
"knowledgeGraph": "Knowledge Graph",
|
||||
"notifications": "Notifications",
|
||||
"aiAssist": "AI Assist",
|
||||
"aiLessonPlan": "AI Lesson Plan",
|
||||
"aiReport": "AI Report",
|
||||
"attendance": "Attendance",
|
||||
"questions": "Questions",
|
||||
"textbooks": "Textbooks",
|
||||
"lessonPlans": "Lesson Plans",
|
||||
"coursePlans": "Course Plans",
|
||||
"diagnostic": "Diagnostic",
|
||||
"errorBook": "Error Book",
|
||||
"practice": "Practice",
|
||||
"elective": "Elective",
|
||||
"leave": "Leave Requests",
|
||||
"scheduleChanges": "Schedule Changes",
|
||||
"settings": "Settings",
|
||||
"students": "Students"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"toggleSidebar": "Toggle sidebar",
|
||||
"layout": "Layout",
|
||||
"empty": "No visible plugins",
|
||||
"dev": {
|
||||
"templates": {
|
||||
"title": "Page Templates Quartet",
|
||||
"description": "P1-3 preview pages (dev only, 404 in production)",
|
||||
"list": "List Page Template",
|
||||
"listDescription": "PageHeader + FilterBar + DataTable + Pagination + 3 states",
|
||||
"detail": "Detail Page Template",
|
||||
"detailDescription": "PageHeader + info area + Tabs/sections + related list",
|
||||
"form": "Form Page Template",
|
||||
"formDescription": "PageHeader + form + submit/cancel + error summary",
|
||||
"workbench": "Workbench Page Template",
|
||||
"workbenchDescription": "Three-column (tree/canvas/properties) composite",
|
||||
"listExample": {
|
||||
"title": "Exam Management",
|
||||
"subtitle": "List page template example",
|
||||
"searchPlaceholder": "Search exam name...",
|
||||
"newExam": "New Exam"
|
||||
},
|
||||
"detailExample": {
|
||||
"title": "2026 Spring Final Exam",
|
||||
"subtitle": "Detail page template example"
|
||||
},
|
||||
"formExample": {
|
||||
"title": "New Exam",
|
||||
"subtitle": "Form page template example"
|
||||
},
|
||||
"workbenchExample": {
|
||||
"title": "Lesson Plan Editor",
|
||||
"subtitle": "Workbench page template example"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Login"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Welcome",
|
||||
"greeting": "Welcome, {name}",
|
||||
"subtitle": "{email} · Roles: {roles} · Data scope: {dataScope}",
|
||||
"subtitleNoRoles": "None",
|
||||
"error": {
|
||||
"loadFailed": "Dashboard failed to load: {message}"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No data",
|
||||
"description": "Dashboard data is not ready yet"
|
||||
},
|
||||
"stats": {
|
||||
"classes": "Total Classes",
|
||||
"exams": "Total Exams",
|
||||
"pendingGrading": "Pending Grading"
|
||||
},
|
||||
"classes": {
|
||||
"title": "My Classes",
|
||||
"count": "{count} total",
|
||||
"viewAll": "View all",
|
||||
"emptyTitle": "No classes",
|
||||
"emptyDescription": "Please contact the administrator to assign classes",
|
||||
"grade": "Grade: {gradeId}",
|
||||
"studentCount": "{count} students",
|
||||
"viewStudents": "View students"
|
||||
}
|
||||
},
|
||||
"classes": {
|
||||
"title": "Classes"
|
||||
},
|
||||
"exams": {
|
||||
"title": "Exams"
|
||||
},
|
||||
"homework": {
|
||||
"title": "Homework"
|
||||
},
|
||||
"grades": {
|
||||
"title": "Grades"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics"
|
||||
},
|
||||
"knowledgeGraph": {
|
||||
"title": "Knowledge Graph"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications"
|
||||
},
|
||||
"aiAssist": {
|
||||
"title": "AI Assist"
|
||||
},
|
||||
"aiLessonPlan": {
|
||||
"title": "AI Lesson Plan"
|
||||
},
|
||||
"aiReport": {
|
||||
"title": "AI Report"
|
||||
},
|
||||
"attendance": {
|
||||
"title": "Attendance"
|
||||
},
|
||||
"questions": {
|
||||
"title": "Questions"
|
||||
},
|
||||
"textbooks": {
|
||||
"title": "Textbooks"
|
||||
},
|
||||
"lessonPlans": {
|
||||
"title": "Lesson Plans"
|
||||
},
|
||||
"coursePlans": {
|
||||
"title": "Course Plans"
|
||||
},
|
||||
"diagnostic": {
|
||||
"title": "Diagnostic"
|
||||
},
|
||||
"errorBook": {
|
||||
"title": "Error Book"
|
||||
},
|
||||
"practice": {
|
||||
"title": "Practice"
|
||||
},
|
||||
"elective": {
|
||||
"title": "Elective"
|
||||
},
|
||||
"leave": {
|
||||
"title": "Leave Requests"
|
||||
},
|
||||
"scheduleChanges": {
|
||||
"title": "Schedule Changes"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"students": {
|
||||
"title": "Students"
|
||||
}
|
||||
}
|
||||
214
apps/portal-shell/src/messages/zh-CN.json
Normal file
214
apps/portal-shell/src/messages/zh-CN.json
Normal file
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"common": {
|
||||
"brand": "Edu",
|
||||
"button": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"edit": "编辑",
|
||||
"export": "导出",
|
||||
"search": "搜索",
|
||||
"delete": "删除",
|
||||
"retry": "重试",
|
||||
"back": "返回"
|
||||
},
|
||||
"label": {
|
||||
"search": "搜索",
|
||||
"loading": "加载中...",
|
||||
"student": "学生",
|
||||
"studentNo": "学号",
|
||||
"score": "分数",
|
||||
"feedback": "反馈",
|
||||
"class": "班级",
|
||||
"subject": "学科",
|
||||
"name": "姓名",
|
||||
"email": "邮箱",
|
||||
"roles": "角色"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
"saved": "已保存"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "加载失败:{message}",
|
||||
"pageError": "页面出错了"
|
||||
},
|
||||
"nav": {
|
||||
"empty": "暂无可用视口",
|
||||
"loadError": "导航加载失败:{message}",
|
||||
"logout": "退出登录",
|
||||
"noRoles": "无角色"
|
||||
},
|
||||
"locale": {
|
||||
"label": "语言",
|
||||
"zhCN": "简体中文",
|
||||
"en": "English"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无数据",
|
||||
"description": "调整筛选条件后重试,或新建第一条记录"
|
||||
},
|
||||
"navLabel": {
|
||||
"dashboard": "仪表盘",
|
||||
"classes": "班级管理",
|
||||
"exams": "考试管理",
|
||||
"homework": "作业管理",
|
||||
"grades": "成绩查询",
|
||||
"analytics": "学情分析",
|
||||
"knowledgeGraph": "知识图谱",
|
||||
"notifications": "通知中心",
|
||||
"aiAssist": "AI 辅助",
|
||||
"aiLessonPlan": "AI 教案",
|
||||
"aiReport": "AI 学情报告",
|
||||
"attendance": "考勤管理",
|
||||
"questions": "题库",
|
||||
"textbooks": "教材",
|
||||
"lessonPlans": "备课",
|
||||
"coursePlans": "课程计划",
|
||||
"diagnostic": "诊断报告",
|
||||
"errorBook": "错题本",
|
||||
"practice": "练习分析",
|
||||
"elective": "选修课",
|
||||
"leave": "请假审批",
|
||||
"scheduleChanges": "调课申请",
|
||||
"settings": "个人设置",
|
||||
"students": "学生"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"toggleSidebar": "切换侧栏",
|
||||
"layout": "布局",
|
||||
"empty": "暂无可见插件",
|
||||
"dev": {
|
||||
"templates": {
|
||||
"title": "页面模板四件套",
|
||||
"description": "P1-3 验收用示例页(仅 dev 可见,生产环境 404)",
|
||||
"list": "列表页模板",
|
||||
"listDescription": "PageHeader + FilterBar + DataTable + Pagination + 三态",
|
||||
"detail": "详情页模板",
|
||||
"detailDescription": "PageHeader + 信息区 + Tabs/分区 + 关联列表",
|
||||
"form": "表单页模板",
|
||||
"formDescription": "PageHeader + 表单 + 提交/取消 + 错误摘要",
|
||||
"workbench": "工作台页模板",
|
||||
"workbenchDescription": "三栏(树/画布/属性)复合组件",
|
||||
"listExample": {
|
||||
"title": "考试管理",
|
||||
"subtitle": "列表页模板示例",
|
||||
"searchPlaceholder": "搜索考试名称...",
|
||||
"newExam": "新建考试"
|
||||
},
|
||||
"detailExample": {
|
||||
"title": "2026 春季期末考试",
|
||||
"subtitle": "详情页模板示例"
|
||||
},
|
||||
"formExample": {
|
||||
"title": "新建考试",
|
||||
"subtitle": "表单页模板示例"
|
||||
},
|
||||
"workbenchExample": {
|
||||
"title": "教案编辑器",
|
||||
"subtitle": "工作台页模板示例"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "欢迎",
|
||||
"greeting": "欢迎,{name}",
|
||||
"subtitle": "{email} · 角色:{roles} · 数据范围:{dataScope}",
|
||||
"subtitleNoRoles": "无",
|
||||
"error": {
|
||||
"loadFailed": "仪表盘加载失败:{message}"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无数据",
|
||||
"description": "仪表盘数据尚未就绪"
|
||||
},
|
||||
"stats": {
|
||||
"classes": "班级总数",
|
||||
"exams": "考试总数",
|
||||
"pendingGrading": "待批改"
|
||||
},
|
||||
"classes": {
|
||||
"title": "我的班级",
|
||||
"count": "{count} 个",
|
||||
"viewAll": "查看全部",
|
||||
"emptyTitle": "暂无班级",
|
||||
"emptyDescription": "请联系管理员分配班级",
|
||||
"grade": "年级:{gradeId}",
|
||||
"studentCount": "{count} 名学生",
|
||||
"viewStudents": "查看学生"
|
||||
}
|
||||
},
|
||||
"classes": {
|
||||
"title": "班级管理"
|
||||
},
|
||||
"exams": {
|
||||
"title": "考试管理"
|
||||
},
|
||||
"homework": {
|
||||
"title": "作业管理"
|
||||
},
|
||||
"grades": {
|
||||
"title": "成绩查询"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "学情分析"
|
||||
},
|
||||
"knowledgeGraph": {
|
||||
"title": "知识图谱"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知中心"
|
||||
},
|
||||
"aiAssist": {
|
||||
"title": "AI 辅助"
|
||||
},
|
||||
"aiLessonPlan": {
|
||||
"title": "AI 教案"
|
||||
},
|
||||
"aiReport": {
|
||||
"title": "AI 学情报告"
|
||||
},
|
||||
"attendance": {
|
||||
"title": "考勤管理"
|
||||
},
|
||||
"questions": {
|
||||
"title": "题库"
|
||||
},
|
||||
"textbooks": {
|
||||
"title": "教材"
|
||||
},
|
||||
"lessonPlans": {
|
||||
"title": "备课"
|
||||
},
|
||||
"coursePlans": {
|
||||
"title": "课程计划"
|
||||
},
|
||||
"diagnostic": {
|
||||
"title": "诊断报告"
|
||||
},
|
||||
"errorBook": {
|
||||
"title": "错题本"
|
||||
},
|
||||
"practice": {
|
||||
"title": "练习分析"
|
||||
},
|
||||
"elective": {
|
||||
"title": "选修课"
|
||||
},
|
||||
"leave": {
|
||||
"title": "请假审批"
|
||||
},
|
||||
"scheduleChanges": {
|
||||
"title": "调课申请"
|
||||
},
|
||||
"settings": {
|
||||
"title": "个人设置"
|
||||
},
|
||||
"students": {
|
||||
"title": "学生"
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 主题 + i18n Provider(v2.1 M8)
|
||||
*
|
||||
* 通过 PluginStore(Zustand)管理 theme(light/dark)与 locale(zh-CN/en),
|
||||
* 将主题类名同步到 <html>,locale 用于基础文案切换。
|
||||
*
|
||||
* 关联:portal-shell spec §5.2.2 Zustand Store、§9.1 providers
|
||||
*/
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { usePluginStore } from "@/shell/PluginStore";
|
||||
|
||||
const DEFAULT_MESSAGES = {
|
||||
"zh-CN": {
|
||||
"shell.toggleSidebar": "切换侧栏",
|
||||
"shell.layout": "布局",
|
||||
"shell.empty": "暂无可见插件",
|
||||
},
|
||||
en: {
|
||||
"shell.toggleSidebar": "Toggle sidebar",
|
||||
"shell.layout": "Layout",
|
||||
"shell.empty": "No visible plugins",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type MessageKey = keyof (typeof DEFAULT_MESSAGES)["zh-CN"];
|
||||
|
||||
export function ThemeI18nProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
const theme = usePluginStore((s) => s.theme);
|
||||
const locale = usePluginStore((s) => s.locale);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
root.lang = locale;
|
||||
}, [theme, locale]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** 简易 i18n 翻译函数(MVP,复用 PluginStore 的 locale) */
|
||||
export function useT(): (key: MessageKey) => string {
|
||||
const locale = usePluginStore((s) => s.locale);
|
||||
return (key: MessageKey) => DEFAULT_MESSAGES[locale][key];
|
||||
}
|
||||
32
apps/portal-shell/src/providers/ThemeProvider.tsx
Normal file
32
apps/portal-shell/src/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ThemeProvider(v3.0 P1-4:i18n 已迁移至 next-intl)
|
||||
*
|
||||
* 仅管理主题(light/dark),将主题类名同步到 <html>。
|
||||
* locale 现由 next-intl 管理(cookie 驱动),<html lang> 在 root layout 设置。
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A6、spec §5.2.2 Zustand Store
|
||||
*/
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { usePluginStore } from "@/shell/PluginStore";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
const theme = usePluginStore((s) => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -253,9 +253,5 @@ function WorkPageShellLoading({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<WorkbenchPageShell title="t" loading>
|
||||
{children}
|
||||
</WorkbenchPageShell>
|
||||
);
|
||||
return <WorkbenchPageShell title="t" loading center={children} />;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { use, useState, type ReactNode } from "react";
|
||||
import { ApolloProvider } from "@/providers/ApolloProvider";
|
||||
import { AuthProvider, type AuthUser } from "@/providers/AuthProvider";
|
||||
import { ThemeI18nProvider } from "@/providers/ThemeI18nProvider";
|
||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||
import { Shell } from "./Shell";
|
||||
import { usePluginConfig } from "@/lib/usePluginConfig";
|
||||
import type { PluginConfigResponse, Role } from "@/lib/types";
|
||||
@@ -105,7 +105,7 @@ export function ClientShell({
|
||||
return (
|
||||
<ApolloProvider>
|
||||
<AuthProvider user={user}>
|
||||
<ThemeI18nProvider>
|
||||
<ThemeProvider>
|
||||
{useStreaming && configPromise ? (
|
||||
<ShellContent
|
||||
configPromise={configPromise}
|
||||
@@ -124,7 +124,7 @@ export function ClientShell({
|
||||
onDismiss={() => setConfigChanged(false)}
|
||||
/>
|
||||
)}
|
||||
</ThemeI18nProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</ApolloProvider>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
/**
|
||||
* PluginStore - Zustand 全局状态(v2.1 M8)
|
||||
* PluginStore - Zustand 全局状态(v2.1 M8 + v3.0 P1-4)
|
||||
*
|
||||
* 管理纯 UI、不可分享的全局状态(portal-shell spec §5.2.2):
|
||||
* - theme(light/dark)
|
||||
* - locale(zh-CN/en)
|
||||
* - sidebarCollapsed
|
||||
*
|
||||
* locale 已迁移至 next-intl(cookie 驱动,ARCHITECTURE.md §3.4 V3-A6)。
|
||||
*
|
||||
* 跨插件可分享状态走 URL Search Params,不进此 Store。
|
||||
*
|
||||
* 关联:portal-shell spec §5.2.2、§9.1
|
||||
* 关联:portal-shell spec §5.2.2、§9.1、ARCHITECTURE.md §3.4 V3-A6
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface PluginStore {
|
||||
theme: "light" | "dark";
|
||||
locale: "zh-CN" | "en";
|
||||
sidebarCollapsed: boolean;
|
||||
setTheme: (theme: "light" | "dark") => void;
|
||||
setLocale: (locale: "zh-CN" | "en") => void;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginStore>((set) => ({
|
||||
theme: "light",
|
||||
setTheme: (theme) => set({ theme }),
|
||||
locale: "zh-CN",
|
||||
setLocale: (locale) => set({ locale }),
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
}));
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* locale-switcher(topbar / top)
|
||||
* locale-switcher(topbar / top)— P1-4 next-intl 接入
|
||||
*
|
||||
* 语言切换器,通过 usePluginStore 读写 locale(zh-CN / en)。
|
||||
* 无 GraphQL 查询,纯 UI 状态。
|
||||
* 语言切换器,通过 next-intl useLocale() 读取当前 locale,
|
||||
* 切换时写入 NEXT_LOCALE cookie + router.refresh() 触发 RSC 重新渲染。
|
||||
*
|
||||
* 关联:portal-shell spec §5.2.2、M8 验收
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A6、§8.7
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { usePluginStore } from "@/shell/PluginStore";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
|
||||
const LOCALES = [
|
||||
{ value: "zh-CN", label: "简体中文" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "zh-CN", labelKey: "common.locale.zhCN" },
|
||||
{ value: "en", labelKey: "common.locale.en" },
|
||||
] as const;
|
||||
|
||||
type LocaleValue = (typeof LOCALES)[number]["value"];
|
||||
@@ -22,12 +23,17 @@ type LocaleValue = (typeof LOCALES)[number]["value"];
|
||||
export default function LocaleSwitcher(
|
||||
_props: PluginProps,
|
||||
): React.ReactElement {
|
||||
const locale = usePluginStore((s) => s.locale);
|
||||
const setLocale = usePluginStore((s) => s.setLocale);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSelect = (value: LocaleValue): void => {
|
||||
setLocale(value);
|
||||
if (value !== locale) {
|
||||
// 写入 NEXT_LOCALE cookie,next-intl request.ts 读取此 cookie 决定 locale
|
||||
document.cookie = `NEXT_LOCALE=${value};path=/;max-age=31536000;samesite=strict`;
|
||||
router.refresh();
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -35,7 +41,7 @@ export default function LocaleSwitcher(
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换语言"
|
||||
aria-label={t("common.locale.label")}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-md bg-card px-sm py-xs text-sm text-foreground"
|
||||
@@ -55,7 +61,7 @@ export default function LocaleSwitcher(
|
||||
: "w-full rounded-md px-sm py-xs text-left text-sm text-foreground"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* locale-switcher 插件清单(topbar)
|
||||
*
|
||||
* 语言切换器,插入 top slot。通过 usePluginStore 读写 locale。
|
||||
* 语言切换器,插入 top slot。通过 next-intl useLocale() 读写 locale。
|
||||
*/
|
||||
import type { PluginManifest } from "@/lib/types";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user