- Added `routeCompatibility.ts` to handle response compatibility for different endpoints. - Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic. - Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`. - Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints. - Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`. - Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests. - Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
535 lines
24 KiB
TypeScript
535 lines
24 KiB
TypeScript
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||
import { zhToEnSupplemental } from './i18n.supplement.js';
|
||
|
||
export type Language = 'zh' | 'en';
|
||
|
||
const LANGUAGE_STORAGE_KEY = 'app_language';
|
||
|
||
const zhToEn: Record<string, string> = {
|
||
'管理员': 'Admin',
|
||
'登录令牌无效': 'Invalid admin token',
|
||
'当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist',
|
||
'当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):',
|
||
'无法连接到服务器': 'Unable to connect to server',
|
||
'请输入管理员令牌后继续。': 'Enter admin token to continue.',
|
||
'管理员令牌': 'Admin Token',
|
||
'验证中...': 'Verifying...',
|
||
'登录': 'Sign In',
|
||
'用户名不能为空': 'Username cannot be empty',
|
||
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
||
'个人信息': 'Profile',
|
||
'右上角头像实时预览': 'Top-right avatar live preview',
|
||
'用户名': 'Username',
|
||
'例如:小王': 'e.g. Alex',
|
||
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
|
||
'换一个随机头像': 'Randomize Avatar',
|
||
'取消': 'Cancel',
|
||
'保存': 'Save',
|
||
'关闭': 'Close',
|
||
'打开': 'Open',
|
||
'导航': 'Navigate',
|
||
'重置': 'Reset',
|
||
'全部': 'All',
|
||
'清空': 'Clear',
|
||
'全部已读': 'Mark All Read',
|
||
'控制台': 'Console',
|
||
'仪表盘': 'Dashboard',
|
||
'站点': 'Sites',
|
||
'账号': 'Accounts',
|
||
'未关联站点': 'Unlinked Site',
|
||
'余额': 'Balance',
|
||
'令牌管理': 'Account Tokens',
|
||
'签到记录': 'Check-in Logs',
|
||
'全部签到': 'Check In All',
|
||
'签到中...': 'Checking In...',
|
||
'刷新状态中...': 'Refreshing...',
|
||
'刷新账户状态': 'Refresh Account Status',
|
||
'+ 添加账号': '+ Add Account',
|
||
'路由': 'Routes',
|
||
'模型路由': 'Model Routes',
|
||
'使用日志': 'Usage Logs',
|
||
'暂无使用日志': 'No Usage Logs',
|
||
'可用性监控': 'Availability Monitor',
|
||
'系统': 'System',
|
||
'设置': 'Settings',
|
||
'程序日志': 'System Logs',
|
||
'导入/导出': 'Import/Export',
|
||
'通知设置': 'Notification Settings',
|
||
'暂无通知': 'No Notifications',
|
||
'模型广场': 'Model Marketplace',
|
||
'模型广场刷新进行中': 'Marketplace refresh in progress',
|
||
'已开始刷新模型广场': 'Started refreshing marketplace',
|
||
'没有找到匹配结果': 'No matching results',
|
||
'搜索站点、账号、模型、日志...': 'Search sites, accounts, models, logs...',
|
||
'模型操练场': 'Model Playground',
|
||
'模型测试': 'Model Testing',
|
||
'关于': 'About',
|
||
'关于 Metapi': 'About Metapi',
|
||
'站点文档': 'Site Docs',
|
||
'任务状态已更新': 'Task status updated',
|
||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||
'首次使用建议先阅读站点文档:': 'For first-time setup, read site docs: ',
|
||
'首次使用建议先阅读快速上手:': 'For first-time setup, start with Quick Start: ',
|
||
'首次使用建议先阅读快速开始:': 'For first-time setup, start with Quick Start: ',
|
||
'个人信息已保存': 'Profile saved',
|
||
'搜索': 'Search',
|
||
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
|
||
'通知': 'Notifications',
|
||
'浅色': 'Light',
|
||
'深色': 'Dark',
|
||
'跟随系统': 'Follow System',
|
||
'浅色模式': 'Light Mode',
|
||
'深色模式': 'Dark Mode',
|
||
'退出登录': 'Sign Out',
|
||
'收起侧边栏': 'Collapse Sidebar',
|
||
'系统设置': 'System Settings',
|
||
'站点管理': 'Site Management',
|
||
'账号管理': 'Connection Management',
|
||
'导入 / 导出': 'Import / Export',
|
||
'监控内嵌': 'Embedded Monitor',
|
||
'品牌': 'Brands',
|
||
'全部品牌': 'All Brands',
|
||
'其他': 'Other',
|
||
'供应商': 'Providers',
|
||
'排序方式': 'Sort By',
|
||
'账号数': 'Accounts',
|
||
'令牌数': 'Tokens',
|
||
'延迟': 'Latency',
|
||
'成功率': 'Success Rate',
|
||
'名称': 'Name',
|
||
'收起': 'Collapse',
|
||
'筛选': 'Filter',
|
||
'加载元数据中...': 'Loading metadata...',
|
||
'卡片视图': 'Card View',
|
||
'表格视图': 'Table View',
|
||
'模糊搜索模型名称': 'Fuzzy Search Model Name',
|
||
'覆盖槽位': 'Coverage Slots',
|
||
'去重账号': 'Unique Accounts',
|
||
'平均延迟': 'Avg Latency',
|
||
'共': 'Total',
|
||
'个模型': 'models',
|
||
'个账号': 'accounts',
|
||
'个令牌': 'tokens',
|
||
'个站点': 'sites',
|
||
'令牌': 'Token',
|
||
'复制': 'Copy',
|
||
'复制模型名': 'Copy Model Name',
|
||
'展开': 'Expand',
|
||
'健康': 'Healthy',
|
||
'风险': 'Risk',
|
||
'低延迟': 'Low Latency',
|
||
'基础信息': 'Basic Info',
|
||
'接口能力': 'Endpoint Capabilities',
|
||
'分组计费': 'Group Pricing',
|
||
'暂无标签': 'No Tags',
|
||
'未提供': 'Not Provided',
|
||
'暂无价格元数据': 'No Pricing Metadata',
|
||
'正在加载价格元数据...': 'Loading pricing metadata...',
|
||
'正在加载模型元数据...': 'Loading model metadata...',
|
||
'上游未提供模型说明。': 'Upstream did not provide a model description.',
|
||
'上游未提供文字说明,但已同步标签、能力或价格信息。': 'Upstream did not provide a text description, but tags, capabilities, or pricing data were synchronized.',
|
||
'当前上游仅返回模型 ID,未返回说明字段(常见于很多站点)。': 'The upstream returned only model IDs and no description field (common on many sites).',
|
||
'暂无模型数据': 'No Model Data',
|
||
'请先检查站点与账号状态,然后点击刷新。': 'Check site and account status first, then refresh.',
|
||
'模型名称': 'Model Name',
|
||
'操作': 'Actions',
|
||
'每页条数': 'Rows Per Page',
|
||
'查看': 'Viewing',
|
||
'来自供应商': 'From Provider',
|
||
'品牌的所有模型': 'Brand Models',
|
||
'的模型': 'models',
|
||
'其他未归类的模型': 'Other uncategorized models',
|
||
'所有模型 accountCount 累计值,同一账号在多个模型中会重复计数': 'Cumulative accountCount across all models; same account may be counted repeatedly.',
|
||
'当前筛选范围内去重后的唯一账号数': 'Unique deduplicated accounts in current filters.',
|
||
'刷新选中概率': 'Refresh Selection Probability',
|
||
'自动重建': 'Auto Rebuild',
|
||
'手动增改路由': 'Manual Route Edit',
|
||
'隐藏手动模式': 'Hide Manual Mode',
|
||
'新建群组': 'Create Group',
|
||
'收起群组创建': 'Hide Group Creator',
|
||
'用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。': 'Use this to create a group route (aggregate multiple upstream models as one downstream model name); auto-routing remains enabled.',
|
||
'群组显示名(可选,例如 claude-opus-4-6)': 'Group display name (optional, e.g. claude-opus-4-6)',
|
||
'创建群组': 'Create Group',
|
||
'群组已创建': 'Group created',
|
||
'创建群组失败': 'Failed to create group',
|
||
'搜索模型路由...': 'Search model routes...',
|
||
'通道数量': 'Channel Count',
|
||
'排序字段': 'Sort Field',
|
||
'切换排序方向': 'Toggle Sort Direction',
|
||
'升序 ↑': 'Ascending ↑',
|
||
'降序 ↓': 'Descending ↓',
|
||
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode fits advanced scenarios; auto-routing stays enabled.',
|
||
'路由名称(可选,例如 claude 系列)': 'Route name (optional, e.g. Claude Series)',
|
||
'图标(可选,支持 emoji)': 'Icon (optional, supports emoji)',
|
||
'模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)': 'Model pattern (e.g. gpt-4o, claude-*, re:^claude-.*$)',
|
||
'正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$': 'Use re: prefix for regex, e.g. re:^claude-(opus|sonnet)-4-6$',
|
||
'模型映射 key 支持精确匹配、通配符和 re: 正则;按顺序匹配,精确优先。': 'Model mapping keys support exact, glob and re: regex; evaluated in order with exact priority.',
|
||
'规则预览:命中样本': 'Rule preview: matched samples',
|
||
'当前暂无可预览模型,请先同步模型。': 'No preview models yet. Sync models first.',
|
||
'当前规则未命中任何样本模型。': 'Current rule does not match any sample models.',
|
||
'仅展示前 12 个命中样本。': 'Showing only the first 12 matched samples.',
|
||
'映射预览': 'Mapping preview',
|
||
'启用': 'Enabled',
|
||
'禁用': 'Disabled',
|
||
'通道': 'channels',
|
||
'按模型过滤': 'Filter by model',
|
||
'排序保存中': 'Saving order',
|
||
'删除路由': 'Delete Route',
|
||
'选择账号': 'Select Account',
|
||
'条路由': 'routes',
|
||
'品牌路由': 'Brand Routes',
|
||
'群组': 'Groups',
|
||
'全部群组': 'All Groups',
|
||
'群组路由': 'Group Routes',
|
||
'查看群组路由': 'Viewing group routes',
|
||
'查看未归类品牌路由': 'Viewing uncategorized brand routes',
|
||
'当前精确路由': 'Current exact routes',
|
||
'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。': 'routes. To avoid first-screen lag, probabilities are not auto-calculated by default. Click "Load Selection Explanation" to fetch when needed.',
|
||
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routes are decided in real time; probability explanation is shown only for exact model routes.',
|
||
'通配符路由按请求实时决策;概率解释在当前路由内统一估算。': 'Wildcard routes are decided in real time; probability explanation is estimated uniformly within the current route.',
|
||
'系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Exact model routes auto-filter accounts and tokens that support that model. Priority P0 is highest, larger numbers are lower. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
|
||
'代理端点': 'Proxy Endpoints',
|
||
'路由行为': 'Routing Behavior',
|
||
'指标口径': 'Metric Notes',
|
||
'metapi 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'Metapi aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
||
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
|
||
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
|
||
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
|
||
'3. 手动覆盖配置为可选项,且会尽可能保留。': '3. Manual overrides are optional and kept whenever possible.',
|
||
'4. 成本来源优先级:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': '4. Cost source priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
|
||
'5. 同站点多通道会进行概率分摊,避免仅因通道数量导致过度偏置。': '5. Multi-channel routes from the same site share probability to avoid bias from channel count alone.',
|
||
'1. 模型广场价格来自上游目录数据,用于展示参考。': '1. Marketplace prices come from upstream catalog data for reference.',
|
||
'2. 路由实测成本来自代理真实请求统计,两者不是同一数据源。': '2. Route measured cost comes from real proxy requests; it is not the same data source.',
|
||
'3. 覆盖槽位是模型维度累计值;去重账号是唯一账号数。': '3. Coverage slots are model-level cumulative counts; unique accounts are deduplicated account counts.',
|
||
'请求超时(': 'Request timed out (',
|
||
'未知账号': 'Unknown Account',
|
||
'未知站点': 'Unknown Site',
|
||
'未知': 'Unknown',
|
||
'未设置': 'Not Set',
|
||
'成功': 'Success',
|
||
'失败': 'Failed',
|
||
'警告': 'Warning',
|
||
'信息': 'Info',
|
||
'异常': 'Error',
|
||
'加载中...': 'Loading...',
|
||
'刷新': 'Refresh',
|
||
'保存中...': 'Saving...',
|
||
'保存失败': 'Save failed',
|
||
'同步中...': 'Syncing...',
|
||
'同步': 'Sync',
|
||
'添加': 'Add',
|
||
'编辑': 'Edit',
|
||
'删除': 'Delete',
|
||
'选择站点': 'Select Site',
|
||
'选择令牌(可选)': 'Select Token (optional)',
|
||
'选择账号后同步站点令牌': 'Select an account to sync site tokens',
|
||
'站点名称': 'Site Name',
|
||
'站点 URL (例如 https://api.example.com)': 'Site URL (e.g. https://api.example.com)',
|
||
'自动检测': 'Auto Detect',
|
||
'检测中': 'Detecting',
|
||
'保存站点': 'Save Site',
|
||
'保存修改': 'Save Changes',
|
||
'编辑站点': 'Edit Site',
|
||
'添加站点': 'Add Site',
|
||
'暂无站点': 'No Sites',
|
||
'点击“+ 添加站点”开始使用。': 'Click "+ Add Site" to start.',
|
||
'重建中...': 'Rebuilding...',
|
||
'发送中...': 'Sending...',
|
||
'导入中...': 'Importing...',
|
||
'创建中...': 'Creating...',
|
||
'更新中...': 'Updating...',
|
||
'登录并添加...': 'Logging in and adding...',
|
||
'添加中...': 'Adding...',
|
||
'同步站点令牌': 'Sync Site Tokens',
|
||
'同步全部账号': 'Sync All Accounts',
|
||
'+ 新增令牌': '+ New Token',
|
||
'保存通知设置': 'Save Notification Settings',
|
||
'发送测试通知': 'Send Test Notification',
|
||
'通知设置已保存': 'Notification settings saved',
|
||
'测试通知已发送': 'Test notification sent',
|
||
'操作失败': 'Operation failed',
|
||
'操作已中止': 'Operation aborted',
|
||
'清空日志': 'Clear Logs',
|
||
'加载更多': 'Load More',
|
||
'全部类型': 'All Types',
|
||
'仅看未读': 'Unread Only',
|
||
'时间': 'Time',
|
||
'类型': 'Type',
|
||
'级别': 'Level',
|
||
'标题': 'Title',
|
||
'内容': 'Content',
|
||
'状态': 'Status',
|
||
'已读': 'Read',
|
||
'未读': 'Unread',
|
||
'标记已读': 'Mark Read',
|
||
'标记中...': 'Marking...',
|
||
'清空中...': 'Clearing...',
|
||
|
||
// About page
|
||
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'The hub of hubs — aggregate all your New API / One API / OneHub relay sites into one unified gateway. One API Key, one endpoint, with auto model discovery, smart routing, and cost optimization.',
|
||
'核心特色': 'Key Features',
|
||
'统一代理网关': 'Unified Proxy Gateway',
|
||
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one endpoint, compatible with OpenAI / Claude downstream formats',
|
||
'智能路由引擎': 'Smart Routing Engine',
|
||
'按成本、延迟、成功率自动选择最优通道,故障自动转移': 'Auto-selects the optimal channel by cost, latency, and success rate with automatic failover',
|
||
'多站点聚合': 'Multi-Site Aggregation',
|
||
'集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centrally manage New API / One API / OneHub / DoneHub / Veloera and more',
|
||
'自动模型发现': 'Auto Model Discovery',
|
||
'上游新增模型自动出现在模型列表,零配置路由生成': 'New upstream models appear automatically, with zero-config route generation',
|
||
'跨站模型覆盖、定价对比、延迟与成功率实测数据': 'Cross-site model coverage, pricing comparison, latency and success rate metrics',
|
||
'自动签到': 'Auto Check-in',
|
||
'定时签到 + 余额刷新,不再手动操心': 'Scheduled check-in and balance refresh, never miss one again',
|
||
'多渠道告警': 'Multi-Channel Alerts',
|
||
'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒': 'Webhook / Bark / ServerChan / Email — get notified when balance is low',
|
||
'轻量部署': 'Lightweight Deployment',
|
||
'单 Docker 容器,内置 SQLite,无外部依赖': 'Single Docker container with built-in SQLite, no external dependencies',
|
||
'技术栈': 'Tech Stack',
|
||
'高性能 Node.js 后端框架': 'High-performance Node.js backend framework',
|
||
'用户界面库': 'User interface library',
|
||
'端到端类型安全': 'End-to-end type safety',
|
||
'原子化样式框架': 'Utility-first CSS framework',
|
||
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
|
||
'零配置嵌入式数据库': 'Zero-config embedded database',
|
||
'项目链接': 'Project Links',
|
||
'数据与隐私': 'Data & Privacy',
|
||
'Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'Metapi is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
|
||
};
|
||
|
||
for (const [source, target] of Object.entries(zhToEnSupplemental)) {
|
||
if (!zhToEn[source]) {
|
||
zhToEn[source] = target;
|
||
}
|
||
}
|
||
|
||
const HAS_HAN_RE = /[\u3400-\u9fff]/;
|
||
const HAN_BLOCK_RE = /[\u3400-\u9fff]+/g;
|
||
const LATIN_OR_DIGIT_RE = /[A-Za-z0-9]/;
|
||
const TRANSLATABLE_ATTRS = ['placeholder', 'title', 'aria-label'] as const;
|
||
const SKIP_PARENT_SELECTOR = 'script, style, code, pre, kbd, samp';
|
||
const zhToEnPhrases = Object.entries(zhToEn).sort((a, b) => b[0].length - a[0].length);
|
||
const textNodeOriginalMap = new WeakMap<Text, string>();
|
||
const elementAttrOriginalMap = new WeakMap<Element, Map<string, string>>();
|
||
|
||
const CJK_PUNCT_TO_ASCII: Record<string, string> = {
|
||
',': ', ',
|
||
'。': '. ',
|
||
':': ': ',
|
||
';': '; ',
|
||
'!': '! ',
|
||
'?': '? ',
|
||
'(': '(',
|
||
')': ')',
|
||
'【': '[',
|
||
'】': ']',
|
||
'“': '"',
|
||
'”': '"',
|
||
'‘': '\'',
|
||
'’': '\'',
|
||
'、': ', ',
|
||
};
|
||
|
||
function enforceStrictEnglish(text: string): string {
|
||
const normalizedPunctuation = text.replace(/[,。:;!?()【】“”‘’、]/g, (ch) => CJK_PUNCT_TO_ASCII[ch] ?? ch);
|
||
const strippedHan = normalizedPunctuation.replace(HAN_BLOCK_RE, ' ');
|
||
const compacted = strippedHan.replace(/\s+/g, ' ').trim();
|
||
if (!compacted) return 'Untranslated';
|
||
if (!LATIN_OR_DIGIT_RE.test(compacted)) return 'Untranslated';
|
||
return compacted;
|
||
}
|
||
|
||
function resolveStoredLanguage(): Language {
|
||
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||
if (stored === 'zh' || stored === 'en') return stored;
|
||
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
||
}
|
||
|
||
let runtimeLanguage: Language = 'zh';
|
||
|
||
export function translateText(text: string, language: Language): string {
|
||
if (language === 'zh') return text;
|
||
if (!text) return text;
|
||
if (!HAS_HAN_RE.test(text)) return zhToEn[text] ?? text;
|
||
const exact = zhToEn[text];
|
||
if (exact) return exact;
|
||
|
||
let translated = text;
|
||
for (const [source, target] of zhToEnPhrases) {
|
||
if (!source || source === target) continue;
|
||
if (!translated.includes(source)) continue;
|
||
translated = translated.split(source).join(target);
|
||
}
|
||
if (HAS_HAN_RE.test(translated)) return enforceStrictEnglish(translated);
|
||
return translated;
|
||
}
|
||
|
||
export function tr(text: string): string {
|
||
return translateText(text, runtimeLanguage);
|
||
}
|
||
|
||
type I18nContextValue = {
|
||
language: Language;
|
||
setLanguage: (next: Language) => void;
|
||
toggleLanguage: () => void;
|
||
t: (text: string) => string;
|
||
};
|
||
|
||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||
|
||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||
const [language, setLanguageState] = useState<Language>(() => {
|
||
const resolved = resolveStoredLanguage();
|
||
runtimeLanguage = resolved;
|
||
return resolved;
|
||
});
|
||
|
||
useEffect(() => {
|
||
runtimeLanguage = language;
|
||
document.documentElement.setAttribute('lang', language === 'zh' ? 'zh-CN' : 'en');
|
||
}, [language]);
|
||
|
||
useEffect(() => {
|
||
const root = document.body;
|
||
if (!root) return;
|
||
|
||
const shouldTranslateTextNode = (node: Text): boolean => {
|
||
const parent = node.parentElement;
|
||
if (!parent) return false;
|
||
if (parent.closest(SKIP_PARENT_SELECTOR)) return false;
|
||
if (parent.isContentEditable) return false;
|
||
const value = node.nodeValue || '';
|
||
if (!value.trim()) return false;
|
||
if (!HAS_HAN_RE.test(value) && language !== 'zh') return false;
|
||
return true;
|
||
};
|
||
|
||
const processTextNode = (node: Text) => {
|
||
if (!shouldTranslateTextNode(node)) return;
|
||
const current = node.nodeValue || '';
|
||
const stored = textNodeOriginalMap.get(node);
|
||
if (!stored) {
|
||
textNodeOriginalMap.set(node, current);
|
||
} else {
|
||
const expected = translateText(stored, language);
|
||
if (current !== expected && current !== stored) {
|
||
textNodeOriginalMap.set(node, current);
|
||
}
|
||
}
|
||
const source = textNodeOriginalMap.get(node) || current;
|
||
const next = translateText(source, language);
|
||
if (next !== current) {
|
||
node.nodeValue = next;
|
||
}
|
||
};
|
||
|
||
const processElementAttrs = (el: Element) => {
|
||
if (el.matches(SKIP_PARENT_SELECTOR)) return;
|
||
let attrMap = elementAttrOriginalMap.get(el);
|
||
if (!attrMap) {
|
||
attrMap = new Map<string, string>();
|
||
elementAttrOriginalMap.set(el, attrMap);
|
||
}
|
||
|
||
for (const attr of TRANSLATABLE_ATTRS) {
|
||
const current = el.getAttribute(attr);
|
||
if (!current || !current.trim()) continue;
|
||
const stored = attrMap.get(attr);
|
||
if (!stored) {
|
||
attrMap.set(attr, current);
|
||
} else {
|
||
const expected = translateText(stored, language);
|
||
if (current !== expected && current !== stored) {
|
||
attrMap.set(attr, current);
|
||
}
|
||
}
|
||
|
||
const source = attrMap.get(attr) || current;
|
||
const next = translateText(source, language);
|
||
if (next !== current) {
|
||
el.setAttribute(attr, next);
|
||
}
|
||
}
|
||
};
|
||
|
||
const walk = (node: Node) => {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
processTextNode(node as Text);
|
||
return;
|
||
}
|
||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||
|
||
const el = node as Element;
|
||
processElementAttrs(el);
|
||
for (const child of Array.from(el.childNodes)) {
|
||
walk(child);
|
||
}
|
||
};
|
||
|
||
walk(root);
|
||
if (language !== 'en') {
|
||
return;
|
||
}
|
||
|
||
const observer = new MutationObserver((records) => {
|
||
for (const record of records) {
|
||
if (record.type === 'characterData') {
|
||
processTextNode(record.target as Text);
|
||
continue;
|
||
}
|
||
|
||
if (record.type === 'attributes') {
|
||
processElementAttrs(record.target as Element);
|
||
continue;
|
||
}
|
||
|
||
if (record.type === 'childList') {
|
||
for (const node of Array.from(record.addedNodes)) {
|
||
walk(node);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
observer.observe(root, {
|
||
subtree: true,
|
||
childList: true,
|
||
characterData: true,
|
||
attributes: true,
|
||
attributeFilter: [...TRANSLATABLE_ATTRS],
|
||
});
|
||
|
||
return () => {
|
||
observer.disconnect();
|
||
};
|
||
}, [language]);
|
||
|
||
const setLanguage = useCallback((next: Language) => {
|
||
runtimeLanguage = next;
|
||
setLanguageState(next);
|
||
localStorage.setItem(LANGUAGE_STORAGE_KEY, next);
|
||
document.documentElement.setAttribute('lang', next === 'zh' ? 'zh-CN' : 'en');
|
||
}, []);
|
||
|
||
const toggleLanguage = useCallback(() => {
|
||
setLanguage(language === 'zh' ? 'en' : 'zh');
|
||
}, [language, setLanguage]);
|
||
|
||
const t = useCallback((text: string) => translateText(text, language), [language]);
|
||
|
||
const value = useMemo<I18nContextValue>(() => ({
|
||
language,
|
||
setLanguage,
|
||
toggleLanguage,
|
||
t,
|
||
}), [language, setLanguage, toggleLanguage, t]);
|
||
|
||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||
}
|
||
|
||
export function useI18n() {
|
||
const value = useContext(I18nContext);
|
||
if (!value) {
|
||
throw new Error('useI18n must be used within I18nProvider');
|
||
}
|
||
return value;
|
||
}
|