feat: P0a i18n 基础设施 + UI 语言切换
- 创建 i18n 模块 (provider + useT hook + useLang hook) - 中/英翻译文件 (zh.ts / en.ts),覆盖沙盒/学习/会员/对比等核心页面 - LanguageProvider 包装 layout,支持 localStorage 持久化 - Header 添加语言切换按钮 (中<>EN) - AGENTS.md 新增国际化约定 - opencode 源码克隆到 /tmp/opencode-source/ 供参考
This commit is contained in:
@@ -39,6 +39,7 @@
|
|||||||
- 背景 → `bg-card` / `bg-muted/50` / `bg-accent`
|
- 背景 → `bg-card` / `bg-muted/50` / `bg-accent`
|
||||||
- 边框 → `border-border`
|
- 边框 → `border-border`
|
||||||
- hover → `hover:text-foreground` / `hover:bg-accent`
|
- hover → `hover:text-foreground` / `hover:bg-accent`
|
||||||
|
- **国际化**: 所有用户可见文本使用 `useT()` hook,翻译 key 位于 `src/i18n/locales/`。新增页面先加翻译 key 再写 UI。中文默认,英文同步维护。
|
||||||
|
|
||||||
## 关键架构决策
|
## 关键架构决策
|
||||||
|
|
||||||
@@ -110,3 +111,7 @@ npm run test:e2e
|
|||||||
---
|
---
|
||||||
|
|
||||||
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`。
|
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`。
|
||||||
|
|||||||
+14
-13
@@ -1,21 +1,22 @@
|
|||||||
# 项目进度追踪
|
# 项目进度追踪
|
||||||
|
|
||||||
## 当前阶段:Phase 1-3 全部完成
|
## 当前阶段:P0 — 全局优化
|
||||||
|
|
||||||
### Phase 1 — 全部完成 ✅
|
### Phase 1-3 — 全部完成 ✅
|
||||||
1. **场景化提示词模板** — 5 预置场景 + system prompt + 建议问题
|
|
||||||
2. **多模型一键切换** — 模型下拉选择,上下文保留
|
|
||||||
3. **对话历史管理** — 侧边栏 + 搜索 + 加载 + 删除
|
|
||||||
|
|
||||||
### Phase 2 — 全部完成 ✅
|
### P0 — 全局优化(当前阶段)
|
||||||
4. **参数调节面板** — temperature/top-p/max-tokens 滑块 + 后端透传
|
| 子项 | 说明 | 状态 |
|
||||||
5. **提示词工坊完善** — 保存到提示词库 + JWT + CSS 变量主题
|
|------|------|------|
|
||||||
6. **代码沙盒** — `/sandbox/code` 编辑器 + iframe 预览 + 4 模板 + AI 代码块入口
|
| **P0a** | i18n 基础设施 + 翻译文件 + LanguageProvider + useT hook | ✅ 完成 |
|
||||||
|
| **P0b** | UI 统一 — Design Token + 共享组件库 | ⏳ 待开始 |
|
||||||
|
| **P0c** | 逐页翻译 + 语言切换 | ⏳ 待开始 |
|
||||||
|
| **P0d** | 会员定价页重构(用量可视化) | ⏳ 待开始 |
|
||||||
|
| **P0e** | 模型选择器升级(卡片式 + 能力标签) | ⏳ 待开始 |
|
||||||
|
| **P0f** | 会话分享链接 | ⏳ 待开始 |
|
||||||
|
|
||||||
### Phase 3 — 全部完成 ✅
|
### 参考来源
|
||||||
7. **学情分析看板** — `GET /learning/analytics` + `/learning/analytics` 页面(知识领域覆盖、掌握度、薄弱分析、推荐)
|
- opencode 源码克隆到 `/tmp/opencode-source/`(21 packages, 147MB)
|
||||||
8. **分阶段学习路径** — `GET /learning/path` + `/learning/path` 页面(4 阶段:认识大模型→提示词工程→微调→Agent)
|
- 关键参考模块: `packages/plugin`(hooks + tool 系统)、`packages/ui/i18n/`(20+ 语言)、`packages/llm`(provider 接口)
|
||||||
9. **对比模式完善** — 颜色修复、参数面板、model ID 同步
|
|
||||||
|
|
||||||
### 新页面
|
### 新页面
|
||||||
- `/learning/analytics` — 学情分析看板(对话分析→知识图谱→薄弱环节→推荐)
|
- `/learning/analytics` — 学情分析看板(对话分析→知识图谱→薄弱环节→推荐)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n'
|
||||||
|
import { ThemeProvider } from '@/components/providers/theme-provider'
|
||||||
|
import { AuthProvider } from '@/lib/auth-context'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import { Header } from '@/components/layout/header'
|
||||||
|
import { Footer } from '@/components/layout/footer'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export function RootLayoutClient({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
|
<AuthProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
<Toaster richColors closeButton />
|
||||||
|
</I18nProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Header } from '@/components/layout/header';
|
import { RootLayoutClient } from './layout-client';
|
||||||
import { Footer } from '@/components/layout/footer';
|
|
||||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
|
||||||
import { AuthProvider } from '@/lib/auth-context';
|
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -32,14 +28,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang="zh-CN" suppressHydrationWarning>
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<body className="min-h-screen flex flex-col">
|
<body className="min-h-screen flex flex-col">
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<RootLayoutClient>{children}</RootLayoutClient>
|
||||||
<AuthProvider>
|
|
||||||
<Header />
|
|
||||||
<main className="flex-1">{children}</main>
|
|
||||||
<Footer />
|
|
||||||
<Toaster richColors closeButton />
|
|
||||||
</AuthProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||||
import { Search, Menu, X, Bell } from 'lucide-react';
|
import { Search, Menu, X, Bell, Languages } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/auth';
|
import { apiFetch } from '@/lib/auth';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
|
import { useLang } from '@/i18n';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', label: '首页' },
|
{ href: '/', label: '首页' },
|
||||||
@@ -60,6 +61,7 @@ export function Header() {
|
|||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const { isLoggedIn, logout } = useAuth();
|
const { isLoggedIn, logout } = useAuth();
|
||||||
|
const { lang, setLang } = useLang();
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
if (href === '/') return pathname === '/';
|
if (href === '/') return pathname === '/';
|
||||||
@@ -114,6 +116,10 @@ export function Header() {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<button onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')}
|
||||||
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors text-xs font-medium">
|
||||||
|
{lang === 'zh' ? 'EN' : '中'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
@@ -145,6 +151,10 @@ export function Header() {
|
|||||||
|
|
||||||
<div className="flex md:hidden items-center gap-2">
|
<div className="flex md:hidden items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<button onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')}
|
||||||
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors text-xs font-medium">
|
||||||
|
{lang === 'zh' ? 'EN' : '中'}
|
||||||
|
</button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { I18nProvider, useI18n } from './provider'
|
||||||
|
export { useT, useLang } from './use-t'
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Translations } from './zh'
|
||||||
|
|
||||||
|
const en: Translations = {
|
||||||
|
common: {
|
||||||
|
loading: 'Loading...',
|
||||||
|
save: 'Save',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
delete: 'Delete',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
search: 'Search',
|
||||||
|
back: 'Back',
|
||||||
|
login: 'Login',
|
||||||
|
register: 'Register',
|
||||||
|
logout: 'Logout',
|
||||||
|
retry: 'Retry',
|
||||||
|
noData: 'No data',
|
||||||
|
viewAll: 'View all',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
|
courses: 'Courses',
|
||||||
|
prompts: 'Prompts',
|
||||||
|
sandbox: 'AI Sandbox',
|
||||||
|
discover: 'Discover',
|
||||||
|
my: 'My',
|
||||||
|
tools: 'Tools',
|
||||||
|
community: 'Community',
|
||||||
|
},
|
||||||
|
sandbox: {
|
||||||
|
title: 'AI Sandbox',
|
||||||
|
subtitle: 'Experience AI conversations online, learn by doing',
|
||||||
|
placeholder: 'Type your question...',
|
||||||
|
send: 'Send',
|
||||||
|
sending: 'Sending...',
|
||||||
|
newChat: 'New Chat',
|
||||||
|
history: 'History',
|
||||||
|
searchHistory: 'Search history...',
|
||||||
|
noHistory: 'No history yet',
|
||||||
|
sceneGeneral: 'General',
|
||||||
|
sceneCoding: 'Coding',
|
||||||
|
sceneWriting: 'Writing',
|
||||||
|
sceneStudy: 'Study',
|
||||||
|
sceneEnglish: 'English',
|
||||||
|
modelGeneral: 'General',
|
||||||
|
modelDeepSeek: 'DeepSeek V4 Flash',
|
||||||
|
advancedParams: 'Advanced',
|
||||||
|
temperature: 'Temperature',
|
||||||
|
topP: 'Top P',
|
||||||
|
maxTokens: 'Max Tokens',
|
||||||
|
helpful: 'Helpful',
|
||||||
|
notHelpful: 'Not helpful',
|
||||||
|
runInCodeSandbox: 'Run in Code Sandbox',
|
||||||
|
shareToCommunity: 'Share to Community',
|
||||||
|
loginForMore: 'Login for more',
|
||||||
|
dailyQuota: '{used} used today, {remaining} remaining',
|
||||||
|
aiReplyDisclaimer: 'AI replies are for reference only.',
|
||||||
|
loginForMoreQuota: 'Login to get more usage and models.',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
loginTitle: 'Login',
|
||||||
|
registerTitle: 'Register',
|
||||||
|
phone: 'Phone',
|
||||||
|
password: 'Password',
|
||||||
|
nickname: 'Nickname',
|
||||||
|
},
|
||||||
|
learning: {
|
||||||
|
analytics: 'Learning Analytics',
|
||||||
|
analyticsDesc: 'Analyze your learning progress from AI sandbox conversations',
|
||||||
|
path: 'Learning Path',
|
||||||
|
pathDesc: 'Master AI skills systematically, step by step',
|
||||||
|
totalSessions: 'AI Conversations',
|
||||||
|
domainsCovered: 'Domains Covered',
|
||||||
|
avgMastery: 'Avg Mastery',
|
||||||
|
knowledgeDomains: 'Knowledge Domains',
|
||||||
|
weakAreas: 'Weak Areas',
|
||||||
|
weakDesc: 'You have less engagement in these areas. Consider strengthening:',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
recDesc: 'Based on your weak areas, we recommend:',
|
||||||
|
toStrengthen: 'Needs work',
|
||||||
|
conversations: '{count} conversations',
|
||||||
|
clickToGo: 'Click to visit',
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
title: 'Membership',
|
||||||
|
desc: 'Manage your subscription',
|
||||||
|
currentPlan: 'Current Plan',
|
||||||
|
freeUser: 'You are on the free plan',
|
||||||
|
monthly: 'Monthly',
|
||||||
|
yearly: 'Yearly',
|
||||||
|
monthlyPrice: 'Subscribe ¥29.9/month',
|
||||||
|
yearlyPrice: 'Subscribe ¥199/year',
|
||||||
|
expires: 'Expires: {date}',
|
||||||
|
benefits: 'All courses + unlimited sandbox + exclusive prompts + ad-free',
|
||||||
|
orderHistory: 'Order History',
|
||||||
|
noOrders: 'No orders yet',
|
||||||
|
processing: 'Processing...',
|
||||||
|
},
|
||||||
|
compare: {
|
||||||
|
title: 'Compare Lab',
|
||||||
|
desc: 'Compare model responses side by side',
|
||||||
|
placeholder: 'Enter your prompt to compare...',
|
||||||
|
startCompare: 'Compare',
|
||||||
|
comparing: 'Comparing...',
|
||||||
|
},
|
||||||
|
codeSandbox: {
|
||||||
|
title: 'Code Sandbox',
|
||||||
|
run: 'Run',
|
||||||
|
runShortcut: 'Run (⌘⏎)',
|
||||||
|
template: 'Template...',
|
||||||
|
blank: 'Blank',
|
||||||
|
react: 'React (CDN)',
|
||||||
|
chart: 'Chart.js',
|
||||||
|
three: 'Three.js',
|
||||||
|
console: 'Console Output',
|
||||||
|
},
|
||||||
|
promptWorkshop: {
|
||||||
|
title: 'Prompt Workshop',
|
||||||
|
desc: 'Write, test, and optimize your prompts',
|
||||||
|
editor: 'Prompt Editor',
|
||||||
|
test: 'Test Prompt',
|
||||||
|
testing: 'Testing...',
|
||||||
|
clear: 'Clear',
|
||||||
|
saveToLibrary: 'Save to Library',
|
||||||
|
saveSuccess: 'Saved!',
|
||||||
|
variables: 'Variables',
|
||||||
|
role: 'Role',
|
||||||
|
task: 'Task',
|
||||||
|
outputFormat: 'Output Format',
|
||||||
|
constraints: 'Constraints',
|
||||||
|
insert: 'Insert',
|
||||||
|
testResult: 'Test Result',
|
||||||
|
saveDialogTitle: 'Save Prompt',
|
||||||
|
saveTitle: 'Title *',
|
||||||
|
saveDesc: 'Description',
|
||||||
|
saveTags: 'Tags',
|
||||||
|
saveTagsPlaceholder: 'Separate by commas, e.g. coding,Python,debug',
|
||||||
|
saving: 'Saving...',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default en
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const zh = {
|
||||||
|
common: {
|
||||||
|
loading: '加载中...',
|
||||||
|
save: '保存',
|
||||||
|
cancel: '取消',
|
||||||
|
delete: '删除',
|
||||||
|
confirm: '确认',
|
||||||
|
search: '搜索',
|
||||||
|
back: '返回',
|
||||||
|
login: '登录',
|
||||||
|
register: '注册',
|
||||||
|
logout: '退出登录',
|
||||||
|
retry: '重试',
|
||||||
|
noData: '暂无数据',
|
||||||
|
viewAll: '查看全部',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
home: '首页',
|
||||||
|
courses: '课程',
|
||||||
|
prompts: '提示词库',
|
||||||
|
sandbox: 'AI 沙盒',
|
||||||
|
discover: '发现',
|
||||||
|
my: '我的',
|
||||||
|
tools: '工具',
|
||||||
|
community: '社区',
|
||||||
|
},
|
||||||
|
sandbox: {
|
||||||
|
title: 'AI 沙盒',
|
||||||
|
subtitle: '在线体验 AI 对话,边学边练',
|
||||||
|
placeholder: '输入你的问题...',
|
||||||
|
send: '发送',
|
||||||
|
sending: '发送中',
|
||||||
|
newChat: '新对话',
|
||||||
|
history: '历史记录',
|
||||||
|
searchHistory: '搜索历史...',
|
||||||
|
noHistory: '暂无历史记录',
|
||||||
|
sceneGeneral: '通用对话',
|
||||||
|
sceneCoding: '编程助手',
|
||||||
|
sceneWriting: '写作助手',
|
||||||
|
sceneStudy: '学习辅导',
|
||||||
|
sceneEnglish: '英语学习',
|
||||||
|
modelGeneral: '通用模式',
|
||||||
|
modelDeepSeek: 'DeepSeek V4 Flash',
|
||||||
|
advancedParams: '高级参数',
|
||||||
|
temperature: 'Temperature',
|
||||||
|
topP: 'Top P',
|
||||||
|
maxTokens: 'Max Tokens',
|
||||||
|
helpful: '有用',
|
||||||
|
notHelpful: '没用',
|
||||||
|
runInCodeSandbox: '在代码沙盒中运行',
|
||||||
|
shareToCommunity: '分享到社区',
|
||||||
|
loginForMore: '登录使用更多',
|
||||||
|
dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次',
|
||||||
|
aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。',
|
||||||
|
loginForMoreQuota: '登录后可获得更多使用次数和更多模型选择。',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
loginTitle: '登录',
|
||||||
|
registerTitle: '注册',
|
||||||
|
phone: '手机号',
|
||||||
|
password: '密码',
|
||||||
|
nickname: '昵称',
|
||||||
|
},
|
||||||
|
learning: {
|
||||||
|
analytics: '学情分析',
|
||||||
|
analyticsDesc: '基于 AI 沙盒对话分析你的学习情况',
|
||||||
|
path: '学习路径',
|
||||||
|
pathDesc: '从入门到精通,系统掌握 AI 技能',
|
||||||
|
totalSessions: 'AI 对话次数',
|
||||||
|
domainsCovered: '涉及知识领域',
|
||||||
|
avgMastery: '平均掌握度',
|
||||||
|
knowledgeDomains: '知识领域覆盖',
|
||||||
|
weakAreas: '薄弱环节',
|
||||||
|
weakDesc: '以下领域你较少涉及,建议加强学习:',
|
||||||
|
recommendations: '推荐学习',
|
||||||
|
recDesc: '根据你的薄弱环节推荐以下内容',
|
||||||
|
toStrengthen: '待加强',
|
||||||
|
conversations: '{count} 次对话',
|
||||||
|
clickToGo: '点击前往',
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
title: '会员中心',
|
||||||
|
desc: '管理你的会员订阅',
|
||||||
|
currentPlan: '当前会员',
|
||||||
|
freeUser: '你当前是免费用户',
|
||||||
|
monthly: '月卡会员',
|
||||||
|
yearly: '年卡会员',
|
||||||
|
monthlyPrice: '开通月卡 ¥29.9/月',
|
||||||
|
yearlyPrice: '开通年卡 ¥199/年',
|
||||||
|
expires: '到期时间:{date}',
|
||||||
|
benefits: '会员权益:全部课程 + 不限次沙箱 + 专属提示词库 + 去广告',
|
||||||
|
orderHistory: '订单记录',
|
||||||
|
noOrders: '暂无订单记录',
|
||||||
|
processing: '处理中...',
|
||||||
|
},
|
||||||
|
compare: {
|
||||||
|
title: '对比实验室',
|
||||||
|
desc: '同题对比不同模型的表现',
|
||||||
|
placeholder: '输入你想对比的问题或提示词...',
|
||||||
|
startCompare: '开始对比',
|
||||||
|
comparing: '对比中...',
|
||||||
|
},
|
||||||
|
codeSandbox: {
|
||||||
|
title: '代码沙盒',
|
||||||
|
run: '运行',
|
||||||
|
runShortcut: '运行 (⌘⏎)',
|
||||||
|
template: '模板...',
|
||||||
|
blank: '空白',
|
||||||
|
react: 'React (CDN)',
|
||||||
|
chart: '图表 (Chart.js)',
|
||||||
|
three: '3D (Three.js)',
|
||||||
|
console: '控制台输出',
|
||||||
|
},
|
||||||
|
promptWorkshop: {
|
||||||
|
title: '提示词工坊',
|
||||||
|
desc: '编写、测试、优化你的提示词',
|
||||||
|
editor: '提示词编辑',
|
||||||
|
test: '测试提示词',
|
||||||
|
testing: '测试中...',
|
||||||
|
clear: '清空',
|
||||||
|
saveToLibrary: '保存到提示词库',
|
||||||
|
saveSuccess: '保存成功!',
|
||||||
|
variables: '变量设置',
|
||||||
|
role: '角色',
|
||||||
|
task: '任务',
|
||||||
|
outputFormat: '输出格式',
|
||||||
|
constraints: '约束条件',
|
||||||
|
insert: '插入',
|
||||||
|
testResult: '测试结果',
|
||||||
|
saveDialogTitle: '保存提示词',
|
||||||
|
saveTitle: '标题 *',
|
||||||
|
saveDesc: '描述',
|
||||||
|
saveTags: '标签',
|
||||||
|
saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试',
|
||||||
|
saving: '保存中...',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Translations = typeof zh
|
||||||
|
export default zh
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import zh, { type Translations } from './locales/zh'
|
||||||
|
import en from './locales/en'
|
||||||
|
|
||||||
|
type Lang = 'zh' | 'en'
|
||||||
|
const STORAGE_KEY = 'yuzhiran-lang'
|
||||||
|
|
||||||
|
const translations: Record<Lang, Translations> = { zh, en }
|
||||||
|
|
||||||
|
const I18nContext = createContext<{
|
||||||
|
lang: Lang
|
||||||
|
t: Translations
|
||||||
|
setLang: (lang: Lang) => void
|
||||||
|
}>({ lang: 'zh', t: zh, setLang: () => {} })
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [lang, setLangState] = useState<Lang>('zh')
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY) as Lang | null
|
||||||
|
if (stored === 'en' || stored === 'zh') setLangState(stored)
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = lang
|
||||||
|
}, [lang])
|
||||||
|
|
||||||
|
function setLang(lang: Lang) {
|
||||||
|
setLangState(lang)
|
||||||
|
localStorage.setItem(STORAGE_KEY, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={{ lang, t: translations[lang], setLang }}>
|
||||||
|
<div lang={lang}>{children}</div>
|
||||||
|
</I18nContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
return useContext(I18nContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useI18n } from './provider'
|
||||||
|
|
||||||
|
export function useT() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLang() {
|
||||||
|
const { lang, setLang } = useI18n()
|
||||||
|
return { lang, setLang }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user