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,7 +2,7 @@
> 版本3.0
> 日期2026-07-20
> 状态:**P0 已完成 + P1-1/P1-2/P1-3 已完成2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿**
> 状态:**P0 已完成 + P1-1/P1-2/P1-3/P1-4 已完成2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿**
> 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。
>
> 关联文档(按效力排序):
@@ -757,7 +757,7 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
- **P0-7**`eslint .` → 0 errors, 2 warnings均在 `__generated__/types.ts` 生成文件);[eslint.config.js](file:///e:/Desktop/Edu/apps/portal-shell/eslint.config.js) 含 `no-restricted-imports`
- **P0-8**`git status` 干净commit `cfb7b00``.env.local``tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore)
### P1 · 框架与数据源接通12 周)— 进行中P1-1/P1-2 ✅ 2026-07-22 验收)
### P1 · 框架与数据源接通12 周)— 进行中P1-1/P1-2/P1-3/P1-4 ✅ 2026-07-22 验收)
**目标**AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n页面迁移的"流水线"建成。
@@ -766,7 +766,7 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
| P1-1 | `src/app/shell/layout.tsx` AppFrameTopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ |
| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈4 角色仪表盘显示真实聚合数据截图widget 假契约查询grades/homeworks/schedule/attendance/exams/announcements全部下线或改造 | ✅ |
| P1-3 | 页面模板四件套list/detail/form/workbench+ 三态规范 | Storybook 或示例页 4 张(/shell/dev/templates/*,仅 dev 可见Next.js 私有文件夹 `_xxx` 不参与路由,故使用 `dev` 而非 `_dev` | ✅ |
| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | |
| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | |
| P1-5 | MSW 兜底层(迁移旧 handlers覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | ⏳ |
| P1-6 | 31 widget 令牌清债271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0`pnpm lint:tokens` 通过 | ⏳ |
| P1-7 | codegen 恢复 typescript-operationsconfig + data-ana 两域先行关闭 skipDocumentsValidation | 生成操作级类型lib/api 对应域删除手写 interfacetypecheck 通过 | ⏳ |
@@ -823,6 +823,27 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
- **单测**`vitest run --reporter=verbose page-templates` → 19/19 passed覆盖 4 个模板的三态、字段渲染、提交按钮禁用、errorSummary alert 等)
- **质量校验**`tsc --noEmit` 通过;`eslint src/shared/components/page-templates src/app/shell/dev src/shared/lib/route-permissions.ts` 通过;`vitest run` 全量 21 test files / 231 tests 全部通过212 原有 + 19 新增)
**P1-4 验收证据2026-07-22**
- **next-intl v4.13.2 接入(无 i18n 路由模式)**
- [next.config.js](file:///e:/Desktop/Edu/apps/portal-shell/next.config.js)`createNextIntlPlugin("./src/i18n/request.ts")` 包装 `nextConfig`Turbopack resolveAlias 注入 `next-intl/config`
- [src/i18n/request.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/i18n/request.ts)`getRequestConfig``NEXT_LOCALE` cookie 读取 locale缺省 `zh-CN`),动态 import 对应 messages JSON
- [src/app/layout.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/layout.tsx)`async RootLayout` + `getLocale()` / `getMessages()` + `<NextIntlClientProvider>` 注入全局 messages`<html lang={locale}>` 随 cookie 切换
- **messages 合并迁移**
- [src/messages/zh-CN.json](file:///e:/Desktop/Edu/apps/portal-shell/src/messages/zh-CN.json):从 teacher-portal 合并 + 新增 `shell.dev.templates` 命名空间title/description/list/detail/form/workbench 及其 listExample 等子键)
- [src/messages/en.json](file:///e:/Desktop/Edu/apps/portal-shell/src/messages/en.json):与 zh-CN.json 结构完全对称
- **自造 i18n 删除**
- [src/providers/ThemeI18nProvider.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/providers/ThemeI18nProvider.tsx) 已删除(含 `useT()` 函数 + `DEFAULT_MESSAGES` 常量)
- [src/providers/ThemeProvider.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/providers/ThemeProvider.tsx):新建,仅保留主题切换(`usePluginStore(s => s.theme)` + `document.documentElement.classList` 切换 `.dark`
- [src/shell/PluginStore.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/shell/PluginStore.ts):移除 `locale` / `setLocale` 字段
- `grep -rn "useT(\|ThemeI18nProvider\|DEFAULT_MESSAGES" src` → 无匹配(自造函数全删除)
- **locale-switcher 改造**[src/widgets/topbar/locale-switcher/index.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/widgets/topbar/locale-switcher/index.tsx) 使用 `useLocale()` + `useTranslations()` + cookie 写入 + `router.refresh()` 触发 RSC 重新渲染
- **dev/templates 页面演示**[src/app/shell/dev/templates/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/dev/templates/page.tsx) 使用 `getTranslations("shell.dev.templates")`Server Component
- **locale 切换 HTTP 验证**dev server `next dev --turbopack -p 4010`
- `GET /login`(无 cookie`<html lang="zh-CN">` + `NextIntlClientProvider locale="zh-CN"` + 中文文案("保存"/"取消"/"切换侧栏"
- `GET /login``Cookie: NEXT_LOCALE=en`)→ `<html lang="en">` + `NextIntlClientProvider locale="en"` + 英文文案("Save"/"Cancel"/"Toggle sidebar"
- **质量校验**`tsc --noEmit` 通过;`eslint src` → 0 errors, 2 warnings`__generated__/types.ts` 生成文件);`vitest run` 全量 21 test files / 231 tests 全部通过
### P2 · 教师域页面23 周,可与 P3 部分并行)
- 范围§9.1 全表(~50 页。顺序建议exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。

View File

@@ -1,18 +1,22 @@
/**
* Next.js 配置v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack
* Next.js 配置v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack + P1-4 next-intl
*
* 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker
* - output: standalone单容器部署
* - output:standalone单容器部署
* - transpilePackages: @edu/* workspace 包
* - 反向代理:/api/v1/* → api-gateway :8080JWT 校验 + 注入 x-user-id/x-user-role
* - GraphQL 查询走 apollo-router :3000M8 验收点,由 Apollo Client 直连)
* - next-intl无 i18n 路由模式locale 由 cookie 决定ARCHITECTURE.md §3.4 V3-A6
*
* Next 16 默认 Turbopack
* - turbopack.resolveExtensions 处理 ESM 包 .js 后缀导入源码 TS 文件的映射
* - webpack 配置保留作为 fallback--webpack flag 时生效)
*
* 关联portal-shell spec §2、project_rules §3.2
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A6、spec §2、project_rules §3.2
*/
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
@@ -65,4 +69,4 @@ const nextConfig = {
},
};
export default nextConfig;
export default withNextIntl(nextConfig);

View File

@@ -39,6 +39,7 @@
"jose": "^5.9.6",
"lucide-react": "^0.562.0",
"next": "^16.0.10",
"next-intl": "^4.13.2",
"next-themes": "^0.4.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",

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>

View File

@@ -0,0 +1,32 @@
/**
* next-intl 请求配置App Router 无 i18n 路由模式)
*
* ARCHITECTURE.md §3.4 V3-A6next-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,
};
});

View 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"
}
}

View 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": "学生"
}
}

View File

@@ -1,55 +0,0 @@
"use client";
/**
* 主题 + i18n Providerv2.1 M8
*
* 通过 PluginStoreZustand管理 themelight/dark与 localezh-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];
}

View File

@@ -0,0 +1,32 @@
"use client";
/**
* ThemeProviderv3.0 P1-4i18n 已迁移至 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}</>;
}

View File

@@ -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} />;
}

View File

@@ -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>
);

View File

@@ -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
* - themelight/dark
* - localezh-CN/en
* - sidebarCollapsed
*
* locale 已迁移至 next-intlcookie 驱动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 })),
}));

View File

@@ -1,20 +1,21 @@
"use client";
"use client";
/**
* locale-switchertopbar / top
* locale-switchertopbar / top— P1-4 next-intl 接入
*
* 语言切换器,通过 usePluginStore 读写 localezh-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 cookienext-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>
))}

View File

@@ -1,7 +1,7 @@
/**
* locale-switcher 插件清单topbar
*
* 语言切换器,插入 top slot。通过 usePluginStore 读写 locale。
* 语言切换器,插入 top slot。通过 next-intl useLocale() 读写 locale。
*/
import type { PluginManifest } from "@/lib/types";