From a7c65b94767a2f7eea84f419cdc7217cd9dad151 Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Wed, 15 Apr 2026 09:31:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=E6=B7=BB=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=95=8C=E9=9D=A2=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在此提交中,我们引入了多语言支持,允许用户根据其语言偏好查看界面文本。新增了国际化库(i18next)并在多个组件中实现了翻译功能,确保错误消息和界面文本能够根据用户的语言设置动态变化。这一改动旨在提升用户体验,使得应用能够服务更广泛的用户群体,增强可访问性和友好性。同时,更新了相关文档以指导开发者如何使用新的国际化功能。 --- CLAUDE.md | 84 ++++ frontend/package.json | 2 + frontend/src/api/client.ts | 18 +- .../analytics/BreakdownChartCard.tsx | 12 +- .../components/analytics/TrendBarChart.tsx | 9 +- frontend/src/components/layout/AppLayout.tsx | 49 +- frontend/src/components/layout/Sidebar.tsx | 82 +++- frontend/src/components/ui/autocomplete.tsx | 4 +- frontend/src/components/ui/select.tsx | 2 +- frontend/src/i18n/index.ts | 67 +++ frontend/src/i18n/locales/en.json | 439 ++++++++++++++++++ frontend/src/i18n/locales/zh-CN.json | 439 ++++++++++++++++++ frontend/src/i18n/locales/zh-TW.json | 439 ++++++++++++++++++ frontend/src/lib/system-config.ts | 7 +- frontend/src/main.tsx | 1 + frontend/src/pages/Analytics.tsx | 118 ++--- frontend/src/pages/ApiTest.tsx | 44 +- frontend/src/pages/Channels.tsx | 202 ++++---- frontend/src/pages/Dashboard.tsx | 106 +++-- frontend/src/pages/NotFound.tsx | 7 +- frontend/src/pages/Pricing.tsx | 149 +++--- frontend/src/pages/SystemSettings.tsx | 58 +-- frontend/src/pages/Tokens.tsx | 142 +++--- frontend/src/pages/UsageLogs.tsx | 208 +++++---- frontend/src/store/auth.ts | 7 +- src/admin/index.ts | 5 +- src/admin/system_api.ts | 19 +- src/analytics/query.ts | 30 +- src/i18n/en.ts | 24 + src/i18n/index.ts | 109 +++++ src/i18n/zh-CN.ts | 24 + src/i18n/zh-TW.ts | 24 + src/index.ts | 7 + 33 files changed, 2328 insertions(+), 609 deletions(-) create mode 100644 CLAUDE.md create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.json create mode 100644 frontend/src/i18n/locales/zh-CN.json create mode 100644 frontend/src/i18n/locales/zh-TW.json create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/zh-CN.ts create mode 100644 src/i18n/zh-TW.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0f7a0f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +One API on Workers — 基于 Cloudflare Workers 的 AI 统一网关,支持多渠道管理、负载均衡、用量统计。将 OpenAI、Azure OpenAI、Claude、Gemini 等多家 AI 服务商统一为 OpenAI 兼容 API。 + +## Commands + +```bash +bun install # 安装所有依赖(Bun workspaces,根目录一次安装包含 frontend) +bun run dev # 同时启动 Vite 前端 (5173) + Worker 本地 (8788) +bun run dev:worker # 仅启动 Worker(使用 wrangler.local.jsonc) +bun run dev:web # 仅启动前端 Vite 开发服务器 +bun run build # 构建前端到 public/(含 tsc 类型检查) +bun run deploy # 构建 + 部署到 Cloudflare +bun run cf-typegen # 生成 Cloudflare 绑定类型到 worker-configuration.d.ts +cd frontend && bun run lint # 前端 ESLint 检查 +``` + +无专用测试框架。`bun run build` 作为基本类型检查;后端变更通过 `bun run dev` 启动后用管理界面的 API 测试页面验证。 + +## Architecture + +### Tech Stack + +- **Runtime**: Cloudflare Workers +- **Backend**: Hono + chanfana (OpenAPI) + Zod +- **Database**: Cloudflare D1 (SQLite),自动迁移(`src/db/index.ts` 中 `ensureReady()` 懒初始化) +- **Analytics**: Cloudflare Analytics Engine(用量统计写入) +- **Frontend**: React 19 + Vite + TailwindCSS 4 + shadcn/ui + Zustand + TanStack Query + React Router 7 +- **Package Manager**: Bun(workspaces monorepo:根目录 = Worker,`frontend/` = React UI) + +### Request Flow + +``` +Client → src/index.ts (Hono app) + ├─ /v1/* → providers/index.ts → UnifiedProxyEndpoint + │ 1. route-policy.ts: URL → RouteId(决定允许的渠道类型) + │ 2. channel-resolver.ts: 认证 token → 查可用渠道 → 按权重选取 + │ 3. upstream-retry.ts: 执行请求,支持同渠道重试 + 跨渠道 fallback + │ 4. provider-registry.ts: 按 ChannelType 分发到具体 proxy 实现 + ├─ /api/admin/* → admin/index.ts(管理 API,需 x-admin-token 或 session 认证) + └─ 其他路径 → 前端静态资源 / Vite dev server 代理 +``` + +### Key Modules + +- **`src/providers/shared/`**: 核心代理逻辑 — 认证、渠道解析、负载均衡、重试、流式处理 +- **`src/providers/*.ts`**: 各服务商代理实现(统一 `ProviderFetch` 接口) +- **`src/admin/`**: 管理 API(渠道/令牌/定价/计费/系统配置/认证/统计) +- **`src/billing.ts`**: 计费精度处理(`BILLING_RAW_SCALE = 1e9`,内部整数运算) +- **`src/channel-config.ts`**: 渠道配置标准化(`normalizeChannelConfig` / `sanitizeChannelConfig`) +- **`src/analytics/`**: Analytics Engine 写入 + Cloudflare GraphQL 查询 +- **`type.d.ts`**: 全局类型定义(`CloudflareBindings`, `ChannelConfig`, `ApiTokenData` 等),不需要 import + +### Frontend Structure + +- **`frontend/src/pages/`**: 各页面组件(Dashboard、Channels、Tokens、Pricing、UsageLogs 等) +- **`frontend/src/api/client.ts`**: API 客户端,自动附加 admin token +- **`frontend/src/store/auth.ts`**: Zustand 认证状态管理 +- **`frontend/src/components/ui/`**: shadcn/ui 基础组件 +- **`frontend/src/lib/`**: 工具函数(计费格式化、渠道模型处理、本地缓存等) +- 构建输出到 `../public/`,由 Worker 的 `ASSETS` 绑定提供服务 + +### Database + +D1 表:`channel_config`、`api_token`、`settings`、`admin_login_challenge`、`admin_session`、`admin_rate_limit`。Schema 定义在 `src/db/index.ts`,首次请求自动建表和迁移。版本号在 `src/constants.ts` 的 `DB_VERSION` 中管理。 + +### Configuration + +- **`wrangler.jsonc`**: 生产部署配置 +- **`wrangler.local.jsonc`**: 本地开发配置(含 `FRONTEND_DEV_SERVER_URL`) +- **`.dev.vars`**: 本地 secrets(`ADMIN_TOKEN`、`CF_API_TOKEN`、`CF_ACCOUNT_ID`) +- **`type.d.ts`**: `CloudflareBindings` 定义所有环境绑定 + +## Code Style + +- 后端 TypeScript 缩进 4 空格,前端 TSX 缩进 2 空格 +- 渠道类型使用字符串字面量(`"openai"`, `"azure-openai"`, `"claude"` 等),定义在 `type.d.ts` 的 `ChannelType` +- Admin API 路由命名:`src/admin/*_api.ts` +- Provider 文件命名:`src/providers/{type}-proxy.ts` +- 新增 Provider 需在 `src/providers/shared/provider-registry.ts` 注册,并在 `route-policy.ts` 的对应 RouteId 中添加类型 diff --git a/frontend/package.json b/frontend/package.json index c8ce1a0..2f78ce9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,9 +32,11 @@ "@tanstack/react-query": "^5.97.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^26.0.4", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-i18next": "^17.0.3", "react-router-dom": "^7.14.0", "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2cdddfe..f4d9cfe 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -15,6 +15,7 @@ import { UsageLogSearchData, } from '@/types' import { clearAdminCredentials } from '@/lib/admin-auth' +import i18n, { getCurrentLanguage } from '@/i18n' const BASE_URL = import.meta.env.VITE_API_BASE_URL || '' @@ -56,6 +57,9 @@ async function requestInterceptor(config: RequestInit): Promise { headers.set('Content-Type', 'application/json') } + // 附加语言偏好 + headers.set('x-lang', getCurrentLanguage()) + return { ...config, headers } } @@ -79,7 +83,7 @@ async function responseInterceptor(response: Response, behavior: RequestBehavior } } catch { // 不是 JSON,直接使用文本 - errorMessage = responseText || `请求失败: ${response.status}` + errorMessage = responseText || `Request failed: ${response.status}` } const error = new ApiError(errorMessage, response.status) @@ -90,22 +94,22 @@ async function responseInterceptor(response: Response, behavior: RequestBehavior if (!behavior.skipUnauthorizedHandler) { clearAdminCredentials() onUnauthorized?.() - error.message = '认证已过期,请重新登录' + error.message = i18n.t('apiClient.authExpired') } break case 403: - error.message = '没有权限执行此操作' + error.message = i18n.t('apiClient.forbidden') break case 404: - error.message = '请求的资源不存在' + error.message = i18n.t('apiClient.notFound') break case 500: - error.message = '服务器内部错误' + error.message = i18n.t('apiClient.serverError') break case 502: case 503: case 504: - error.message = '服务暂时不可用,请稍后重试' + error.message = i18n.t('apiClient.serviceUnavailable') break } @@ -169,7 +173,7 @@ async function request( } catch (error) { // 处理网络错误 if (error instanceof TypeError && error.message.includes('fetch')) { - const networkError = new ApiError('网络连接失败,请检查网络', 0) + const networkError = new ApiError(i18n.t('apiClient.networkError'), 0) onError?.(networkError) throw networkError } diff --git a/frontend/src/components/analytics/BreakdownChartCard.tsx b/frontend/src/components/analytics/BreakdownChartCard.tsx index 53975fd..6e17993 100644 --- a/frontend/src/components/analytics/BreakdownChartCard.tsx +++ b/frontend/src/components/analytics/BreakdownChartCard.tsx @@ -1,6 +1,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AnalyticsBreakdownItem } from "@/types"; import { cn, formatCompactNumber, formatCurrency } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; const formatPercent = (value: number): string => `${(value * 100).toFixed(1)}%`; @@ -25,6 +26,7 @@ export function BreakdownChartCard({ badgeClassName, displayDecimals, }: BreakdownChartCardProps) { + const { t } = useTranslation(); const visibleItems = items.slice(0, 6); const maxRequests = Math.max(...visibleItems.map((item) => item.requests), 1); @@ -44,11 +46,11 @@ export function BreakdownChartCard({ )) ) : isError ? (
- 排行数据加载失败 + {t('analytics.breakdownLoadFailed')}
) : visibleItems.length === 0 ? (
- 当前时间范围暂无维度排行数据 + {t('analytics.breakdownEmpty')}
) : ( visibleItems.map((item, index) => { @@ -71,9 +73,9 @@ export function BreakdownChartCard({ {item.label}
- 成功率 {formatPercent(item.successRate)} - 消耗 {formatCompactNumber(item.totalTokens)} - 费用 {formatCurrency(item.totalCost, displayDecimals)} + {t('analytics.breakdownSuccessRate', { rate: formatPercent(item.successRate) })} + {t('analytics.breakdownTokens', { count: formatCompactNumber(item.totalTokens) })} + {t('analytics.breakdownCost', { cost: formatCurrency(item.totalCost, displayDecimals) })}
diff --git a/frontend/src/components/analytics/TrendBarChart.tsx b/frontend/src/components/analytics/TrendBarChart.tsx index 20f8912..5e1c45b 100644 --- a/frontend/src/components/analytics/TrendBarChart.tsx +++ b/frontend/src/components/analytics/TrendBarChart.tsx @@ -1,6 +1,7 @@ import { AnalyticsRange, AnalyticsTrendPoint } from "@/types"; import { formatCompactNumber, formatCurrency, parseUtcTimestamp } from "@/lib/utils"; import { Card } from "../ui/card"; +import { getLocaleString } from "@/i18n"; const formatAxisLabel = (value: string, range: AnalyticsRange): string => { const date = parseUtcTimestamp(value); @@ -8,13 +9,15 @@ const formatAxisLabel = (value: string, range: AnalyticsRange): string => { return value; } + const locale = getLocaleString(); + if (range === "24h") { - return date.toLocaleTimeString("zh-CN", { + return date.toLocaleTimeString(locale, { hour: "2-digit", }); } - return date.toLocaleDateString("zh-CN", { + return date.toLocaleDateString(locale, { month: "2-digit", day: "2-digit", }); @@ -74,7 +77,7 @@ export function TrendBarChart({
Requests
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 9ca8868..0bcc256 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -17,12 +17,15 @@ import { useToast } from '@/components/ui/use-toast' import { Alert, AlertDescription } from '@/components/ui/alert' import { Menu, RotateCcw, Zap } from 'lucide-react' import { parseUtcTimestamp } from '@/lib/utils' +import { useTranslation } from 'react-i18next' +import { getLocaleString } from '@/i18n' interface AppLayoutProps { children: ReactNode } export function AppLayout({ children }: AppLayoutProps) { + const { t } = useTranslation() const navigate = useNavigate() const [adminToken, setAdminToken] = useState('') const [verificationCode, setVerificationCode] = useState('') @@ -61,7 +64,7 @@ export function AppLayout({ children }: AppLayoutProps) { const finalizeLogin = () => { resetAuthDialog() setIsMobileNavOpen(false) - addToast('登录成功', 'success') + addToast(t('auth.loginSuccess'), 'success') navigate('/dashboard', { replace: true }) } @@ -79,7 +82,7 @@ export function AppLayout({ children }: AppLayoutProps) { setChallengeId(loginResult.challengeId) setChallengeExpiresAt(loginResult.challengeExpiresAt) setVerificationCode('') - addToast('验证码已发送到 Telegram', 'success') + addToast(t('auth.verificationSent'), 'success') return } @@ -88,13 +91,13 @@ export function AppLayout({ children }: AppLayoutProps) { } if (!challengeId) { - throw new Error('验证码会话不存在,请重新获取') + throw new Error(t('auth.sessionMissing')) } await verifyLogin(challengeId, verificationCode) finalizeLogin() } catch (error) { - setAuthError(error instanceof Error ? error.message : '管理员令牌无效') + setAuthError(error instanceof Error ? error.message : t('auth.loginFailed')) } finally { setIsSubmitting(false) } @@ -102,7 +105,7 @@ export function AppLayout({ children }: AppLayoutProps) { const handleResendVerificationCode = async () => { if (!adminToken.trim()) { - setAuthError('请先重新输入管理员令牌') + setAuthError(t('auth.reenterToken')) setAuthStep('token') return } @@ -121,9 +124,9 @@ export function AppLayout({ children }: AppLayoutProps) { setChallengeId(loginResult.challengeId) setChallengeExpiresAt(loginResult.challengeExpiresAt) setVerificationCode('') - addToast('验证码已重新发送到 Telegram', 'success') + addToast(t('auth.verificationResent'), 'success') } catch (error) { - setAuthError(error instanceof Error ? error.message : '验证码发送失败') + setAuthError(error instanceof Error ? error.message : t('auth.loginFailed')) } finally { setIsSubmitting(false) } @@ -132,7 +135,7 @@ export function AppLayout({ children }: AppLayoutProps) { const challengeExpiresText = (() => { const date = challengeExpiresAt ? parseUtcTimestamp(challengeExpiresAt) : null return date - ? date.toLocaleString('zh-CN', { hour12: false }) + ? date.toLocaleString(getLocaleString(), { hour12: false }) : '' })() @@ -156,7 +159,7 @@ export function AppLayout({ children }: AppLayoutProps) { size="icon" className="h-9 w-9" onClick={() => setIsMobileNavOpen(true)} - aria-label="打开侧边栏" + aria-label={t('sidebar.openSidebar')} > @@ -198,11 +201,11 @@ export function AppLayout({ children }: AppLayoutProps) { !open && handleCloseAuthModal()}> - 管理员身份验证 + {t('auth.title')} {authStep === 'token' - ? '请输入管理员令牌以访问管理功能' - : '验证码已发送到 Telegram,请输入 6 位数字验证码完成登录'} + ? t('auth.descToken') + : t('auth.descVerification')} @@ -210,11 +213,11 @@ export function AppLayout({ children }: AppLayoutProps) {
{authStep === 'token' ? (
- + setAdminToken(e.target.value)} required @@ -222,19 +225,19 @@ export function AppLayout({ children }: AppLayoutProps) {
) : (
- + setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} required />
- {challengeExpiresText ? `验证码有效期至 ${challengeExpiresText}` : '验证码 5 分钟内有效'} + {challengeExpiresText ? t('auth.verificationExpiry', { time: challengeExpiresText }) : t('auth.verificationDefault')}
@@ -270,16 +273,16 @@ export function AppLayout({ children }: AppLayoutProps) { : handleCloseAuthModal} className='mr-0' > - {authStep === 'verification' ? '返回上一步' : '取消'} + {authStep === 'verification' ? t('auth.backToPrevious') : t('common.cancel')} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c783dbc..1ed7f2b 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { FileText, TestTube2, GitBranch, + Languages, Moon, Sun, LogOut, @@ -19,26 +20,30 @@ import { } from "lucide-react"; import { useAuthStore } from "@/store/auth"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { changeLanguage, SUPPORTED_LANGUAGES, type SupportedLanguage } from "@/i18n"; -type NavGroup = "总览" | "管理" | "工具" | "系统"; +type NavGroup = "overview" | "management" | "tools" | "system"; interface NavItem { - title: string; + titleKey: string; href: string; icon: React.ComponentType<{ className?: string }>; group: NavGroup; } -const navItems: NavItem[] = [ - { title: "总览看板", href: "/dashboard", icon: BarChart3, group: "总览" }, - { title: "渠道管理", href: "/channels", icon: LinkIcon, group: "管理" }, - { title: "令牌管理", href: "/tokens", icon: Key, group: "管理" }, - { title: "定价管理", href: "/pricing", icon: DollarSign, group: "管理" }, - { title: "使用日志", href: "/usage-logs", icon: FileText, group: "工具" }, - { title: "API 测试", href: "/api-test", icon: TestTube2, group: "工具" }, - { title: "系统设置", href: "/settings", icon: SlidersHorizontal, group: "系统" }, +const navItemDefs: NavItem[] = [ + { titleKey: "sidebar.dashboard", href: "/dashboard", icon: BarChart3, group: "overview" }, + { titleKey: "sidebar.channels", href: "/channels", icon: LinkIcon, group: "management" }, + { titleKey: "sidebar.tokens", href: "/tokens", icon: Key, group: "management" }, + { titleKey: "sidebar.pricing", href: "/pricing", icon: DollarSign, group: "management" }, + { titleKey: "sidebar.usageLogs", href: "/usage-logs", icon: FileText, group: "tools" }, + { titleKey: "sidebar.apiTest", href: "/api-test", icon: TestTube2, group: "tools" }, + { titleKey: "sidebar.settings", href: "/settings", icon: SlidersHorizontal, group: "system" }, ]; +const groupOrder: NavGroup[] = ["overview", "management", "tools", "system"]; + interface SidebarProps { className?: string; onNavigate?: () => void; @@ -56,6 +61,7 @@ export function Sidebar({ onToggleCollapse, showCollapseToggle = false, }: SidebarProps) { + const { t, i18n } = useTranslation(); const location = useLocation(); const navigate = useNavigate(); const { logout } = useAuthStore(); @@ -82,6 +88,20 @@ export function Sidebar({ } }; + const langLabels: Record = { + "zh-CN": t("language.zhCN"), + "zh-TW": t("language.zhTW"), + "en": t("language.en"), + }; + + const cycleLang = () => { + const currentIndex = SUPPORTED_LANGUAGES.indexOf(i18n.language as SupportedLanguage); + const nextIndex = (currentIndex + 1) % SUPPORTED_LANGUAGES.length; + changeLanguage(SUPPORTED_LANGUAGES[nextIndex]); + }; + + const currentLangLabel = langLabels[i18n.language as SupportedLanguage] || langLabels["zh-CN"]; + const handleLogout = async () => { await logout(); navigate("/", { replace: true }); @@ -90,11 +110,12 @@ export function Sidebar({ const NavLink = ({ item }: { item: NavItem }) => { const isActive = location.pathname === item.href || location.pathname.startsWith(`${item.href}/`); + const title = t(item.titleKey); return ( onNavigate?.()} - title={collapsed ? item.title : undefined} + title={collapsed ? title : undefined} className={cn( "group relative flex items-center gap-3 px-3 py-2 rounded-md text-[13px] font-medium transition-all duration-150", collapsed && "justify-center px-0 w-10 h-10 mx-auto", @@ -109,26 +130,24 @@ export function Sidebar({ !isActive && "group-hover:scale-105 transition-transform duration-150", )} /> - {!collapsed && {item.title}} + {!collapsed && {title}} ); }; const groupedNavItems = (() => { const groups: Record = { - 总览: [], - 管理: [], - 工具: [], - 系统: [], + overview: [], + management: [], + tools: [], + system: [], }; - navItems.forEach((item) => { + navItemDefs.forEach((item) => { groups[item.group].push(item); }); return groups; })(); - const groupOrder: NavGroup[] = ["总览", "管理", "工具", "系统"]; - return (
diff --git a/frontend/src/components/ui/autocomplete.tsx b/frontend/src/components/ui/autocomplete.tsx index 19254e7..75d92bd 100644 --- a/frontend/src/components/ui/autocomplete.tsx +++ b/frontend/src/components/ui/autocomplete.tsx @@ -49,7 +49,7 @@ export const AutoCompleteInput = React.forwardRef( onBlur, onFocus, onKeyDown, - placeholder = '请选择', + placeholder = '', 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, 'aria-labelledby': ariaLabelledBy, diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..05b198d --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,67 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import zhCN from './locales/zh-CN.json' +import zhTW from './locales/zh-TW.json' +import en from './locales/en.json' + +const STORAGE_KEY = 'lang' +const SUPPORTED_LANGUAGES = ['zh-CN', 'zh-TW', 'en'] as const +type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number] +const DEFAULT_LANGUAGE: SupportedLanguage = 'zh-CN' + +const detectLanguage = (): SupportedLanguage => { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored && SUPPORTED_LANGUAGES.includes(stored as SupportedLanguage)) { + return stored as SupportedLanguage + } + + const browserLang = navigator.language + if (SUPPORTED_LANGUAGES.includes(browserLang as SupportedLanguage)) { + return browserLang as SupportedLanguage + } + + if (browserLang.startsWith('zh-TW') || browserLang.startsWith('zh-Hant')) { + return 'zh-TW' + } + if (browserLang.startsWith('zh')) { + return 'zh-CN' + } + if (browserLang.startsWith('en')) { + return 'en' + } + + return DEFAULT_LANGUAGE +} + +i18n.use(initReactI18next).init({ + resources: { + 'zh-CN': { translation: zhCN }, + 'zh-TW': { translation: zhTW }, + 'en': { translation: en }, + }, + lng: detectLanguage(), + fallbackLng: DEFAULT_LANGUAGE, + interpolation: { + escapeValue: false, + }, +}) + +export const changeLanguage = (lang: SupportedLanguage) => { + localStorage.setItem(STORAGE_KEY, lang) + i18n.changeLanguage(lang) +} + +export const getCurrentLanguage = (): string => { + return i18n.language || DEFAULT_LANGUAGE +} + +export const getLocaleString = (): string => { + const lang = getCurrentLanguage() + if (lang === 'zh-TW') return 'zh-TW' + if (lang === 'en') return 'en-US' + return 'zh-CN' +} + +export { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } +export type { SupportedLanguage } +export default i18n diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..d1a6575 --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,439 @@ +{ + "common": { + "save": "Save", + "saving": "Saving...", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "search": "Search", + "loading": "Loading...", + "refresh": "Refresh", + "back": "Back to list", + "confirm": "Confirm", + "copy": "Copy", + "close": "Close", + "enable": "Enable", + "disable": "Disable", + "enabled": "Enabled", + "disabled": "Disabled", + "all": "All", + "none": "None", + "unknown": "Unknown", + "form": "Form", + "json": "JSON", + "visual": "Visual", + "open": "Open", + "selectAll": "Select All", + "clearAll": "Clear", + "copiedToClipboard": "Copied to clipboard", + "copyFailed": "Copy failed", + "saveFailed": "Save failed: {{message}}", + "deleteFailed": "Delete failed: {{message}}", + "jsonFormatError": "Invalid JSON format", + "savedAt": "Saved at {{time}}", + "autoSaving": "Auto-saving...", + "autoSaveFailed": "Auto-save failed", + "jsonCannotAutoSave": "JSON cannot be auto-saved", + "previousPage": "Previous", + "nextPage": "Next", + "free": "Free", + "unlimited": "Unlimited", + "noMatch": "No match" + }, + "sidebar": { + "groupOverview": "Overview", + "groupManagement": "Management", + "groupTools": "Tools", + "groupSystem": "System", + "dashboard": "Dashboard", + "channels": "Channels", + "tokens": "Tokens", + "pricing": "Pricing", + "usageLogs": "Usage Logs", + "apiTest": "API Test", + "settings": "Settings", + "lightMode": "Light", + "darkMode": "Dark", + "collapse": "Collapse", + "logout": "Logout", + "openSidebar": "Open sidebar" + }, + "auth": { + "title": "Admin Authentication", + "descToken": "Enter your admin token to access management features", + "descVerification": "A verification code has been sent to Telegram. Enter the 6-digit code to log in", + "tokenLabel": "Admin Token", + "tokenPlaceholder": "Enter admin token", + "verificationLabel": "Telegram Verification Code", + "verificationPlaceholder": "Enter 6-digit code", + "verificationExpiry": "Code expires at {{time}}", + "verificationDefault": "Code valid for 5 minutes", + "resend": "Resend", + "backToPrevious": "Back", + "verifying": "Verifying...", + "sending": "Sending...", + "verifyAndLogin": "Verify & Login", + "login": "Login", + "loginSuccess": "Login successful", + "loginFailed": "Invalid admin token", + "verificationSent": "Verification code sent to Telegram", + "verificationResent": "Verification code resent to Telegram", + "sessionMissing": "Verification session not found, please try again", + "reenterToken": "Please re-enter the admin token first", + "loginResponseInvalid": "Invalid login response", + "loginMissingChallenge": "Login response missing verification challenge", + "verificationSessionFailed": "Session not established after verification", + "adminLogin": "Admin Login" + }, + "dashboard": { + "tagline": "Unified AI Gateway", + "heroTitle": "One API on Workers", + "heroDescription": "Built on Cloudflare Workers\nDistributed, low-latency, high-performance AI unified gateway", + "featureMultiChannel": "Multi-Channel", + "featureMultiChannelDesc": "Supports OpenAI, Claude, Gemini, Azure OpenAI and more AI providers.", + "featureLoadBalance": "Load Balancing", + "featureLoadBalanceDesc": "Weighted multi-channel routing, single-channel multi-key retry/rotation, and cross-channel fallback.", + "featureUsageTracking": "Usage Tracking", + "featureUsageTrackingDesc": "Real-time Analytics Engine statistics with log retrospection for cost analysis.", + "featureSecurity": "Security", + "featureSecurityDesc": "Token-based access control with independent API key allocation.", + "quickStart": "Quick Start", + "quickStartDesc": "Four steps to configure and start using the unified API", + "step1Title": "Admin Login", + "step1Desc": "Log in with admin token", + "step2Title": "Configure Channels", + "step2Desc": "Add API providers", + "step3Title": "Create Tokens", + "step3Desc": "Create API tokens for apps", + "step4Title": "Start Using", + "step4Desc": "Call APIs with tokens", + "supportedEndpoints": "Supported Endpoints" + }, + "channels": { + "title": "Channels", + "description": "Configure AI provider endpoints, manage models and load balancing", + "searchPlaceholder": "Search channels...", + "addChannel": "Add Channel", + "editChannel": "Edit Channel", + "emptyTitle": "Add your first channel", + "emptyDescription": "Connect to AI providers (OpenAI, Gemini, Azure, Claude) for proxying and forwarding API requests.", + "updateSuccess": "Channel updated", + "addSuccess": "Channel added", + "deleteSuccess": "Channel deleted", + "deleteConfirm": "Are you sure you want to delete this channel?", + "enabledToast": "Channel enabled", + "disabledToast": "Channel disabled", + "toggleFailed": "Failed to update channel status: {{message}}", + "notFound": "Channel not found", + "noMatchingChannels": "No matching channels", + "loadingChannel": "Loading channel...", + "channelKey": "Channel Key", + "channelKeyDesc": "Unique identifier for internal use", + "channelKeyPlaceholder": "e.g. azure-gpt4-east", + "basicInfo": "Basic Info", + "channelName": "Channel Name", + "channelNameRequired": "Channel Name", + "channelNamePlaceholder": "e.g. Azure GPT-4 East", + "channelType": "Channel Type", + "channelTypeRequired": "Channel Type", + "channelWeight": "Weight", + "channelWeightHint": "Higher weight is preferred for the same model; equal weights are randomly distributed.", + "channelStatus": "Status", + "channelStatusHint": "Disabled channels won't receive requests or appear in model lists.", + "runningStatus": "Status", + "connectionConfig": "Connection", + "apiEndpoint": "API Endpoint", + "apiEndpointPreview": "Preview: {{preview}}", + "apiKeys": "API Keys", + "apiKeysHint": "One key per line. Requests will randomly select a key.", + "autoRetry": "Auto Retry", + "autoRetryHint": "Automatically retry up to 3 times on retryable errors", + "autoRotate": "Auto Rotate", + "autoRotateHint": "Rotate to a different key on retry; when disabled, retries use the same key", + "modelConfig": "Models", + "modelConfigDesc": "External calls use the model name; the system maps it to the channel model ID.", + "modelId": "Model ID", + "modelName": "Model Name", + "modelStatusDelete": "Status / Delete", + "modelIdPlaceholder": "Model ID, e.g. gpt-4.1-mini", + "modelNamePlaceholder": "Model name, defaults to model ID", + "fetchModelList": "Fetch Models", + "noFetchedModels": "No models found", + "fetchModelsFailed": "Failed to fetch models: {{message}}", + "selectModelsTitle": "Select models to import", + "selectModelsDesc": "Select from upstream model list. Existing models with matching IDs will keep their aliases and enabled status.", + "searchModelPlaceholder": "Search model ID or display name", + "selectedCount": "{{selected}} / {{total}} selected", + "applyModels": "Import Selected", + "modelsApplied": "{{count}} models imported", + "selectAtLeastOneModel": "Please select at least one model", + "noMatchingModels": "No matching models", + "saveChannel": "Save Channel", + "jsonConfig": "JSON Config", + "modelJsonStructure": "JSON array with `id`, `name`, `enabled` fields per item.", + "fillRequired": "Please fill in all required fields", + "fillChannelKey": "Please enter a channel key", + "fillTypeEndpointKey": "Please fill in channel type, API endpoint, and API key first", + "modelCount": "{{count}} models", + "modelCountPartial": "{{enabled}}/{{total}} models", + "validation": { + "atLeastOneModel": "At least one model is required", + "duplicateModelName": "Duplicate model name: {{name}}", + "modelIdEmpty": "Model ID cannot be empty", + "modelJsonInvalid": "Invalid model JSON format", + "modelJsonMustBeArray": "Model JSON must be an array", + "modelJsonItemMustBeObject": "Model JSON items must be objects", + "modelJsonIdEmpty": "Model ID in JSON cannot be empty" + } + }, + "tokens": { + "title": "Tokens", + "description": "Manage API access tokens and quotas", + "searchPlaceholder": "Search tokens...", + "addToken": "Add Token", + "editToken": "Edit Token", + "emptyTitle": "Create your first token", + "emptyDescription": "Tokens authenticate API requests. Each token can have independent access permissions and usage quotas.", + "updateSuccess": "Token updated", + "addSuccess": "Token added", + "deleteSuccess": "Token deleted", + "deleteConfirm": "Are you sure you want to delete this token?", + "resetConfirm": "Are you sure you want to reset this token's usage?", + "resetSuccess": "Usage reset successfully", + "resetFailed": "Reset failed: {{message}}", + "notFound": "Token not found", + "noMatchingTokens": "No matching tokens", + "loadingToken": "Loading token...", + "tokenKey": "Token Key", + "tokenKeyDesc": "Unique identifier for API authentication", + "basicInfo": "Basic Info", + "tokenName": "Token Name", + "tokenNameRequired": "Token Name", + "tokenNamePlaceholder": "e.g. Production, Test User", + "channelAccess": "Channel Access", + "channelAccessAll": "Access to all channels", + "channelAccessSelected": "{{count}} channels selected", + "noChannels": "No channels available", + "noChannelsHint": "Please add channels in Channel Management first", + "quota": "Quota", + "quotaHint": "Paid models will be unavailable after quota is exhausted.", + "quotaPlaceholder": "Enter USD amount, 0 for no quota, -1 for unlimited", + "quotaUnlimited": "Unlimited", + "saveToken": "Save Token", + "fillTokenKey": "Please enter a token key", + "fillTokenName": "Please enter a token name", + "channels": "Channels", + "channelsAll": "All", + "channelsCount": "{{count}}", + "channelsCountWithUnit": "{{count}} channels", + "usedAvailable": "Used/Available", + "resetQuota": "Reset Usage" + }, + "pricing": { + "title": "Pricing", + "description": "View and configure model pricing", + "cards": "Cards", + "channelFilter": "Channel Filter", + "channelFilterPlaceholder": "Select channels", + "noMatchingChannels": "No matching channels", + "billingTypeFilter": "Billing Type", + "billingTypePlaceholder": "Select billing type", + "noMatchingBillingType": "No matching billing type", + "tokenFilter": "Token Filter", + "tokenFilterPlaceholder": "Select tokens", + "noMatchingTokens": "No matching tokens", + "searchPlaceholder": "Search models, channels, tokens, or billing types", + "fieldInput": "Input", + "fieldOutput": "Output", + "fieldCache": "Cache", + "billingVolume": "Volume", + "billingVolumeDesc": "Input / Output / Cache billed per M tokens", + "billingRequest": "Per Request", + "billingRequestDesc": "Fixed amount billed per successful response", + "estimatedCostVolume": "${{cost}} / 1M tokens", + "estimatedCostRequest": "${{cost}} / request", + "legacyRequestNote": "Legacy per-request price {{price}}. Editing will switch to the new billing model.", + "noChannelsForModel": "No matching channels", + "loadingModels": "Aggregating models, channels, and tokens...", + "noModels": "No models to manage", + "noModelsHint": "Sync model lists in Channel Management first, or switch to JSON mode to add pricing manually.", + "noMatchingCards": "No matching model cards", + "noMatchingCardsHint": "Current filters are too strict. Try broadening channels, tokens, types, or keywords.", + "savedSuccess": "Pricing saved", + "jsonConfigTitle": "JSON Config", + "jsonConfigDesc": "Only pricing fields > 0 are saved by default; however `billingMode: \"request\"` is preserved even with all-zero prices. Switching back to card mode auto-merges with current channel models.", + "jsonFormatHint": "Format: model name → { billingMode?: \"volume\" | \"request\", input?: input price, output?: output price, cache?: cache price, request?: legacy per-request price }" + }, + "analytics": { + "title": "Dashboard", + "description": "Aggregate view of requests, costs, success rates, and key dimension breakdowns.", + "range24h": "24 Hours", + "range7d": "7 Days", + "range30d": "30 Days", + "range90d": "90 Days", + "totalRequests": "Total Requests", + "successCount": "Success {{count}}", + "successRate": "Success Rate", + "failureCount": "Failures {{count}}", + "inputTokens": "Input Tokens", + "totalTokensCount": "Total {{count}}", + "outputTokens": "Output Tokens", + "avgLatency": "Avg Latency", + "noTrendData": "No trend data yet", + "noTrendDataHint": "Trend charts will appear after the first usage events are recorded.", + "modelRanking": "Model Ranking", + "modelRankingDesc": "See which models handle the most requests, along with quality and cost metrics.", + "channelRanking": "Channel Ranking", + "channelRankingDesc": "Compare request capacity and stability across channels.", + "tokenRanking": "Token Ranking", + "tokenRankingDesc": "Identify the most active access tokens by request volume.", + "providerRanking": "Provider Ranking", + "providerRankingDesc": "Measure each upstream provider's request share and stability.", + "breakdownLoadFailed": "Failed to load ranking data", + "breakdownEmpty": "No ranking data for the current time range", + "breakdownSuccessRate": "Success {{rate}}", + "breakdownTokens": "Tokens {{count}}", + "breakdownCost": "Cost {{cost}}" + }, + "usageLogs": { + "title": "Usage Logs", + "description": "Filter usage records by time range and dimension to investigate anomalous devices, failed requests, and upstream issues.", + "timeRange": "Time Range", + "queryDimension": "Dimension", + "queryStatus": "Status", + "keyword": "Keyword", + "keywordPlaceholder": "Supports ILIKE fuzzy matching", + "query": "Search", + "dimToken": "Token Name", + "dimChannel": "Channel Key", + "dimModel": "Requested Model", + "dimProvider": "Provider Type", + "dimRoute": "Route", + "dimRequestId": "Request ID", + "dimTraceId": "Trace ID", + "dimClientIp": "Client IP", + "dimUserAgent": "User-Agent", + "dimCountry": "Country", + "dimRegion": "Region", + "dimCity": "City", + "dimColo": "Edge / Colo", + "dimTimezone": "Timezone", + "dimResult": "Result", + "dimErrorCode": "Error Code", + "dimErrorSummary": "Error Summary", + "resultAll": "All", + "resultSuccess": "Success Only", + "resultFailure": "Failures Only", + "colTime": "Time", + "colModelToken": "Model / Token", + "colEndpointChannel": "Endpoint / Channel", + "colResultStatus": "Result / Status", + "colModeLatency": "Mode / Latency", + "colResourceUsage": "Resources", + "colDetail": "Detail", + "success": "Success", + "failure": "Failed", + "stream": "Stream", + "nonStream": "Non-stream", + "retry": "Retry {{count}}", + "unknownToken": "Unnamed Token", + "unknownLocation": "Unknown location", + "logsLoading": "Loading logs...", + "showingRange": "Showing {{start}}-{{end}} of {{total}}", + "noRecords": "No records", + "noMatchingLogs": "No matching log records", + "noMatchingLogsHint": "Try adjusting the time range, dimension, or keyword.", + "detailTitle": "Log Detail", + "sectionOverview": "Request Overview", + "sectionBilling": "Billing & Performance", + "sectionDiagnostics": "Diagnostics", + "sectionClient": "Client Environment", + "labelRequestModel": "Requested Model", + "labelUpstreamModel": "Upstream Model", + "labelTokenName": "Token Name", + "labelChannelKey": "Channel Key", + "labelProvider": "Provider", + "labelRouteId": "Route", + "labelHttpStatus": "HTTP Status", + "labelStreamMode": "Stream Mode", + "labelRetryCount": "Retries", + "labelStatusFamily": "Status Family", + "labelTotalCost": "Total Cost", + "labelCacheCost": "Cache Cost", + "labelLatency": "Latency", + "labelTotalTokens": "Total Tokens", + "labelInputTokens": "Input Tokens", + "labelOutputTokens": "Output Tokens", + "labelCachedTokens": "Cached Tokens", + "labelRequestId": "Request ID", + "labelTraceId": "Trace ID", + "labelTime": "Time", + "labelErrorCode": "Error Code", + "labelClientIp": "Client IP", + "labelGeoLocation": "Geo Location", + "labelEdgeTimezone": "Edge / Timezone", + "labelUserAgent": "User-Agent", + "detailToken": "Token", + "detailEndpoint": "Endpoint", + "detailChannel": "Channel" + }, + "apiTest": { + "title": "API Test", + "description": "Test API connections and configurations", + "request": "Request", + "response": "Response", + "endpoint": "Endpoint", + "apiToken": "API Token", + "model": "Model", + "modelPlaceholder": "Select or enter model name", + "requestBody": "Request Body", + "send": "Send Request", + "sending": "Sending...", + "noMatchingTokens": "No matching tokens", + "noMatchingModels": "No matching models", + "tokenRequired": "Please enter an API token", + "bodyJsonError": "Invalid request body JSON", + "copyResponse": "Copy response", + "audioSuccess": "Audio response received", + "downloadAudio": "Download Audio", + "responseEmpty": "Send a request to see the response here" + }, + "settings": { + "title": "Settings", + "description": "Manage global system settings", + "telegramSecurity": "Telegram Security", + "telegramNotVerified": "Bind Telegram to enable login verification and notifications.", + "telegramNeedTest": "Send a test message. Security settings will unlock after verification.", + "telegramVerified": "Telegram login verification and notifications are enabled.", + "sendTestMessage": "Send Test Message", + "sendTestMessageSending": "Sending...", + "testMessageSent": "Test message sent, please check Telegram", + "testFailed": "Test failed: {{message}}", + "fillBotTokenFirst": "Please fill in Bot Token and Chat ID first.", + "lastVerified": "Last verified: {{time}}", + "testUnlockHint": "The security toggle will unlock after a successful test.", + "displayPrecision": "Display Precision", + "displayPrecisionDesc": "Display amounts with {{decimals}} decimal places, e.g. ${{example}}", + "apiDocs": "API Documentation", + "apiDocsDesc": "When enabled, exposes Swagger, ReDoc, and OpenAPI JSON. When disabled, these routes return 404.", + "configSaved": "Settings saved" + }, + "notFound": { + "title": "Page Not Found", + "backHome": "Back to Home" + }, + "language": { + "zhCN": "简体", + "zhTW": "繁體", + "en": "EN" + }, + "apiClient": { + "authExpired": "Session expired, please log in again", + "forbidden": "You don't have permission for this action", + "notFound": "The requested resource was not found", + "serverError": "Internal server error", + "serviceUnavailable": "Service temporarily unavailable, please try again later", + "networkError": "Network connection failed, please check your connection" + } +} diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..d6d1c7f --- /dev/null +++ b/frontend/src/i18n/locales/zh-CN.json @@ -0,0 +1,439 @@ +{ + "common": { + "save": "保存", + "saving": "保存中...", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "add": "添加", + "search": "搜索", + "loading": "加载中...", + "refresh": "刷新", + "back": "返回列表", + "confirm": "确认", + "copy": "复制", + "close": "关闭", + "enable": "启用", + "disable": "停用", + "enabled": "已启用", + "disabled": "已停用", + "all": "全部", + "none": "无", + "unknown": "未知", + "form": "表单", + "json": "JSON", + "visual": "可视化", + "open": "打开", + "selectAll": "全选", + "clearAll": "清空", + "copiedToClipboard": "已复制到剪贴板", + "copyFailed": "复制失败", + "saveFailed": "保存失败:{{message}}", + "deleteFailed": "删除失败:{{message}}", + "jsonFormatError": "JSON 格式错误", + "savedAt": "保存于 {{time}}", + "autoSaving": "自动保存中...", + "autoSaveFailed": "自动保存失败", + "jsonCannotAutoSave": "JSON 无法自动保存", + "previousPage": "上一页", + "nextPage": "下一页", + "free": "免费", + "unlimited": "无限", + "noMatch": "没有匹配" + }, + "sidebar": { + "groupOverview": "总览", + "groupManagement": "管理", + "groupTools": "工具", + "groupSystem": "系统", + "dashboard": "总览看板", + "channels": "渠道管理", + "tokens": "令牌管理", + "pricing": "定价管理", + "usageLogs": "使用日志", + "apiTest": "API 测试", + "settings": "系统设置", + "lightMode": "浅色", + "darkMode": "深色", + "collapse": "收起", + "logout": "退出登录", + "openSidebar": "打开侧边栏" + }, + "auth": { + "title": "管理员身份验证", + "descToken": "请输入管理员令牌以访问管理功能", + "descVerification": "验证码已发送到 Telegram,请输入 6 位数字验证码完成登录", + "tokenLabel": "管理员令牌", + "tokenPlaceholder": "请输入管理员令牌", + "verificationLabel": "Telegram 验证码", + "verificationPlaceholder": "请输入 6 位验证码", + "verificationExpiry": "验证码有效期至 {{time}}", + "verificationDefault": "验证码 5 分钟内有效", + "resend": "重新发送", + "backToPrevious": "返回上一步", + "verifying": "验证中...", + "sending": "发送中...", + "verifyAndLogin": "验证并登录", + "login": "登录", + "loginSuccess": "登录成功", + "loginFailed": "管理员令牌无效", + "verificationSent": "验证码已发送到 Telegram", + "verificationResent": "验证码已重新发送到 Telegram", + "sessionMissing": "验证码会话不存在,请重新获取", + "reenterToken": "请先重新输入管理员令牌", + "loginResponseInvalid": "登录响应无效", + "loginMissingChallenge": "登录响应缺少验证码挑战信息", + "verificationSessionFailed": "验证码验证后未建立会话", + "adminLogin": "管理员登录" + }, + "dashboard": { + "tagline": "Unified AI Gateway", + "heroTitle": "One API on Workers", + "heroDescription": "基于 Cloudflare Workers\n分布式、低延迟、高性能的 AI 统一网关", + "featureMultiChannel": "多渠道支持", + "featureMultiChannelDesc": "支持 OpenAI、Claude、Gemini、Azure OpenAI 等多种 AI 服务商。", + "featureLoadBalance": "负载均衡", + "featureLoadBalanceDesc": "支持按权重多渠道路由、单渠道多密钥重试/轮换,以及跨渠道 fallback。", + "featureUsageTracking": "用量追踪", + "featureUsageTrackingDesc": "Analytics Engine 实时统计,支持日志回溯便于成本分析。", + "featureSecurity": "安全管理", + "featureSecurityDesc": "基于安全令牌的访问控制与独立 API 密钥分配。", + "quickStart": "快速开始", + "quickStartDesc": "四步完成配置,开始使用统一接口", + "step1Title": "管理员登录", + "step1Desc": "使用管理员令牌登录系统", + "step2Title": "配置渠道", + "step2Desc": "添加 API 服务商", + "step3Title": "创建令牌", + "step3Desc": "为应用创建 API 令牌", + "step4Title": "开始使用", + "step4Desc": "使用令牌调用 API", + "supportedEndpoints": "支持的服务端点" + }, + "channels": { + "title": "渠道管理", + "description": "配置 AI 服务商接口,管理模型和负载均衡策略", + "searchPlaceholder": "搜索渠道...", + "addChannel": "添加渠道", + "editChannel": "编辑渠道", + "emptyTitle": "添加您的第一个渠道", + "emptyDescription": "连接到 AI 服务商(如 OpenAI、Gemini、Azure、Claude),用于代理和转发 API 请求。", + "updateSuccess": "渠道更新成功", + "addSuccess": "渠道添加成功", + "deleteSuccess": "渠道已删除", + "deleteConfirm": "确定要删除此渠道吗?", + "enabledToast": "渠道已启用", + "disabledToast": "渠道已停用", + "toggleFailed": "更新渠道状态失败:{{message}}", + "notFound": "未找到对应渠道", + "noMatchingChannels": "未找到匹配的渠道", + "loadingChannel": "加载渠道中...", + "channelKey": "渠道标识", + "channelKeyDesc": "用于内部识别的唯一标识", + "channelKeyPlaceholder": "例如:azure-gpt4-east", + "basicInfo": "基本信息", + "channelName": "渠道名称", + "channelNameRequired": "渠道名称", + "channelNamePlaceholder": "例如:Azure GPT-4 东部", + "channelType": "渠道类型", + "channelTypeRequired": "渠道类型", + "channelWeight": "渠道权重", + "channelWeightHint": "相同模型权重优先,相同权重随机分配。", + "channelStatus": "渠道状态", + "channelStatusHint": "停用后不会参与请求选路,也不会出现在可用模型列表中。", + "runningStatus": "运行状态", + "connectionConfig": "连接配置", + "apiEndpoint": "API 端点", + "apiEndpointPreview": "预览:{{preview}}", + "apiKeys": "API 密钥", + "apiKeysHint": "可配置多个密钥,每行一个,请求会随机选取一个密钥发起调用。", + "autoRetry": "自动重试", + "autoRetryHint": "渠道遇可重试错误时,最多自动重试 3 次", + "autoRotate": "自动轮换", + "autoRotateHint": "重试时随机切换同渠道其他密钥;关闭后始终重试当前密钥", + "modelConfig": "模型配置", + "modelConfigDesc": "外部使用模型名称调用,程序会自动映射到渠道模型 ID。", + "modelId": "模型 ID", + "modelName": "模型名称", + "modelStatusDelete": "状态 / 删除", + "modelIdPlaceholder": "模型ID,例如:gpt-4.1-mini", + "modelNamePlaceholder": "模型名称,默认等于模型ID", + "fetchModelList": "获取模型列表", + "noFetchedModels": "未获取到可选模型", + "fetchModelsFailed": "获取模型失败:{{message}}", + "selectModelsTitle": "选择要写入的模型", + "selectModelsDesc": "先从上游模型列表中批量勾选,再写入当前渠道配置。已存在且 ID 相同的模型会保留原有别名和启用状态。", + "searchModelPlaceholder": "搜索模型 ID 或展示名称", + "selectedCount": "已选 {{selected}} / {{total}}", + "applyModels": "写入所选模型", + "modelsApplied": "已写入 {{count}} 个模型", + "selectAtLeastOneModel": "请至少选择一个模型", + "noMatchingModels": "没有匹配的模型", + "saveChannel": "保存渠道", + "jsonConfig": "JSON 配置", + "modelJsonStructure": "JSON 结构为数组,每项包含 `id`、`name`、`enabled`。", + "fillRequired": "请填写所有必填字段", + "fillChannelKey": "请填写渠道标识", + "fillTypeEndpointKey": "请先填写渠道类型、API 端点和 API 密钥", + "modelCount": "{{count}} 个模型", + "modelCountPartial": "{{enabled}}/{{total}} 个模型", + "validation": { + "atLeastOneModel": "请至少配置一个模型", + "duplicateModelName": "模型名称重复:{{name}}", + "modelIdEmpty": "模型 ID 不能为空", + "modelJsonInvalid": "模型 JSON 格式错误", + "modelJsonMustBeArray": "模型 JSON 必须是数组", + "modelJsonItemMustBeObject": "模型 JSON 项必须是对象", + "modelJsonIdEmpty": "模型 JSON 中的 id 不能为空" + } + }, + "tokens": { + "title": "令牌管理", + "description": "管理 API 访问令牌和配额", + "searchPlaceholder": "搜索令牌...", + "addToken": "添加令牌", + "editToken": "编辑令牌", + "emptyTitle": "创建您的第一个令牌", + "emptyDescription": "令牌用于验证 API 请求。每个令牌可以设置独立的访问权限和使用配额。", + "updateSuccess": "令牌更新成功", + "addSuccess": "令牌添加成功", + "deleteSuccess": "令牌已删除", + "deleteConfirm": "确定要删除此令牌吗?", + "resetConfirm": "确定要重置此令牌的已用额度吗?", + "resetSuccess": "已用额度已重置", + "resetFailed": "重置失败:{{message}}", + "notFound": "未找到对应令牌", + "noMatchingTokens": "未找到匹配的令牌", + "loadingToken": "加载令牌中...", + "tokenKey": "令牌标识", + "tokenKeyDesc": "用于 API 认证的唯一标识", + "basicInfo": "基本信息", + "tokenName": "令牌名称", + "tokenNameRequired": "令牌名称", + "tokenNamePlaceholder": "例如:生产环境、测试用户", + "channelAccess": "渠道访问权限", + "channelAccessAll": "允许访问所有渠道", + "channelAccessSelected": "已选择 {{count}} 个渠道", + "noChannels": "暂无可用渠道", + "noChannelsHint": "请先在渠道管理中添加渠道", + "quota": "可用额度", + "quotaHint": "额度耗尽后将不可调用付费模型。", + "quotaPlaceholder": "输入美元额度,0 表示无额度,-1 表示无限", + "quotaUnlimited": "不限制", + "saveToken": "保存令牌", + "fillTokenKey": "请填写令牌标识", + "fillTokenName": "请填写令牌名称", + "channels": "渠道", + "channelsAll": "全部", + "channelsCount": "{{count}}个", + "channelsCountWithUnit": "{{count}} 渠道", + "usedAvailable": "已用/可用", + "resetQuota": "重置额度" + }, + "pricing": { + "title": "定价管理", + "description": "展示和配置系统模型价格", + "cards": "卡片", + "channelFilter": "渠道筛选", + "channelFilterPlaceholder": "选择渠道", + "noMatchingChannels": "没有匹配渠道", + "billingTypeFilter": "收费类型", + "billingTypePlaceholder": "选择收费类型", + "noMatchingBillingType": "没有匹配收费类型", + "tokenFilter": "令牌筛选", + "tokenFilterPlaceholder": "选择令牌", + "noMatchingTokens": "没有匹配令牌", + "searchPlaceholder": "搜索模型、渠道、令牌或收费类型", + "fieldInput": "输入", + "fieldOutput": "输出", + "fieldCache": "缓存", + "billingVolume": "按量", + "billingVolumeDesc": "输入 / 输出 / 缓存按每 M tokens 计费", + "billingRequest": "按次", + "billingRequestDesc": "成功响应后按固定金额计费", + "estimatedCostVolume": "${{cost}} / 1M tokens", + "estimatedCostRequest": "${{cost}} / 次", + "legacyRequestNote": "兼容旧版独立按次价格 {{price}},修改后会切换到新版计费规则。", + "noChannelsForModel": "无匹配渠道", + "loadingModels": "正在汇总模型、渠道与令牌信息...", + "noModels": "暂无可管理模型", + "noModelsHint": "先在渠道管理中同步模型列表,或者切换到 JSON 模式手动补充价格配置。", + "noMatchingCards": "没有匹配的模型卡片", + "noMatchingCardsHint": "当前筛选条件过严,建议放宽渠道、令牌、类型或关键词。", + "savedSuccess": "定价配置已保存", + "jsonConfigTitle": "JSON 配置", + "jsonConfigDesc": "默认只会保存大于 0 的价格字段;但 `billingMode: \"request\"` 即使价格全为 0,也会作为有效配置保留。切回卡片模式时,会与当前渠道中的模型集合自动合并。", + "jsonFormatHint": "格式:模型名称 → { billingMode?: \"volume\" | \"request\", input?: 输入价格, output?: 输出价格, cache?: 缓存价格, request?: 旧版独立按次价格 }" + }, + "analytics": { + "title": "总览看板", + "description": "聚合查看请求、成本、成功率与关键维度分布。", + "range24h": "24 小时", + "range7d": "7 天", + "range30d": "30 天", + "range90d": "90 天", + "totalRequests": "请求总量", + "successCount": "成功 {{count}}", + "successRate": "成功率", + "failureCount": "失败 {{count}}", + "inputTokens": "输入 Tokens", + "totalTokensCount": "总计 {{count}}", + "outputTokens": "输出 Tokens", + "avgLatency": "平均延迟", + "noTrendData": "暂无趋势数据", + "noTrendDataHint": "写入第一批使用事件后,这里会展示完整柱状趋势。", + "modelRanking": "模型排行", + "modelRankingDesc": "看清哪些模型承载最多请求,并同步观察质量与成本。", + "channelRanking": "渠道排行", + "channelRankingDesc": "对比不同渠道的请求承载能力与稳定性表现。", + "tokenRanking": "令牌排行", + "tokenRankingDesc": "按请求量识别最活跃的访问凭证。", + "providerRanking": "服务商排行", + "providerRankingDesc": "衡量各上游服务商承接的请求占比与稳定性表现。", + "breakdownLoadFailed": "排行数据加载失败", + "breakdownEmpty": "当前时间范围暂无维度排行数据", + "breakdownSuccessRate": "成功率 {{rate}}", + "breakdownTokens": "消耗 {{count}}", + "breakdownCost": "费用 {{cost}}" + }, + "usageLogs": { + "title": "使用日志", + "description": "按最近时间范围与维度筛选可读取的使用记录,重点用于排查异常设备、失败请求和上游问题。", + "timeRange": "时间范围", + "queryDimension": "查询维度", + "queryStatus": "查询状态", + "keyword": "关键字", + "keywordPlaceholder": "支持 ILIKE 模糊匹配", + "query": "查询", + "dimToken": "令牌名称", + "dimChannel": "渠道标识", + "dimModel": "请求模型", + "dimProvider": "服务商类型", + "dimRoute": "接口路由", + "dimRequestId": "Request ID", + "dimTraceId": "Trace ID", + "dimClientIp": "请求 IP", + "dimUserAgent": "User-Agent", + "dimCountry": "国家地区", + "dimRegion": "省州 / Region", + "dimCity": "城市 / City", + "dimColo": "边缘节点 / Colo", + "dimTimezone": "时区 / Timezone", + "dimResult": "结果", + "dimErrorCode": "错误码", + "dimErrorSummary": "错误摘要", + "resultAll": "全部状态", + "resultSuccess": "仅成功", + "resultFailure": "仅失败", + "colTime": "时间", + "colModelToken": "模型 / 令牌", + "colEndpointChannel": "端点 / 渠道", + "colResultStatus": "结果 / 状态", + "colModeLatency": "方式 / 用时", + "colResourceUsage": "资源消耗", + "colDetail": "详情", + "success": "成功", + "failure": "失败", + "stream": "流式", + "nonStream": "非流式", + "retry": "重试 {{count}}", + "unknownToken": "未命名令牌", + "unknownLocation": "未知位置", + "logsLoading": "日志加载中...", + "showingRange": "显示第 {{start}}-{{end}} 条,共 {{total}} 条", + "noRecords": "暂无记录", + "noMatchingLogs": "没有匹配的日志记录", + "noMatchingLogsHint": "调整时间范围、维度或关键字后重试。", + "detailTitle": "日志详情", + "sectionOverview": "请求概览", + "sectionBilling": "计费与性能", + "sectionDiagnostics": "诊断标识", + "sectionClient": "客户端环境", + "labelRequestModel": "请求模型", + "labelUpstreamModel": "上游模型", + "labelTokenName": "令牌名称", + "labelChannelKey": "渠道标识", + "labelProvider": "服务商", + "labelRouteId": "接口路由", + "labelHttpStatus": "HTTP 状态", + "labelStreamMode": "流式模式", + "labelRetryCount": "重试次数", + "labelStatusFamily": "状态族", + "labelTotalCost": "总成本", + "labelCacheCost": "缓存成本", + "labelLatency": "延迟", + "labelTotalTokens": "总 Tokens", + "labelInputTokens": "输入 Tokens", + "labelOutputTokens": "输出 Tokens", + "labelCachedTokens": "缓存 Tokens", + "labelRequestId": "Request ID", + "labelTraceId": "Trace ID", + "labelTime": "时间", + "labelErrorCode": "错误码", + "labelClientIp": "客户端 IP", + "labelGeoLocation": "地理位置", + "labelEdgeTimezone": "边缘节点 / 时区", + "labelUserAgent": "User-Agent", + "detailToken": "令牌", + "detailEndpoint": "端点", + "detailChannel": "渠道" + }, + "apiTest": { + "title": "API 测试", + "description": "测试 API 连接和配置", + "request": "请求", + "response": "响应", + "endpoint": "端点", + "apiToken": "API 令牌", + "model": "模型", + "modelPlaceholder": "选择或输入模型名称", + "requestBody": "请求体", + "send": "发送请求", + "sending": "发送中...", + "noMatchingTokens": "没有匹配的令牌", + "noMatchingModels": "没有匹配的模型", + "tokenRequired": "请输入 API 令牌", + "bodyJsonError": "请求体 JSON 格式错误", + "copyResponse": "复制响应", + "audioSuccess": "音频返回成功", + "downloadAudio": "下载音频", + "responseEmpty": "发送请求后在此查看响应" + }, + "settings": { + "title": "系统设置", + "description": "统一管理全局系统设置", + "telegramSecurity": "Telegram 安全设置", + "telegramNotVerified": "绑定 Telegram,开启登录验证与登录通知。", + "telegramNeedTest": "发送测试消息,验证通过后即可开启 Telegram 安全设置。", + "telegramVerified": "已开启 Telegram 登录验证与登录通知。", + "sendTestMessage": "发送测试消息", + "sendTestMessageSending": "发送中...", + "testMessageSent": "测试消息已发送,请到 Telegram 查收", + "testFailed": "测试失败:{{message}}", + "fillBotTokenFirst": "请先填写完整的 Bot Token 和 Chat ID。", + "lastVerified": "最近一次验证通过时间:{{time}}", + "testUnlockHint": "测试通过后,安全开关才会解锁。", + "displayPrecision": "显示精度设置", + "displayPrecisionDesc": "页面显示金额保留 {{decimals}} 位小数,示例:${{example}}", + "apiDocs": "开启接口文档", + "apiDocsDesc": "开启后自动暴露 Swagger、ReDoc 与 OpenAPI JSON;关闭后对应路由会直接返回 404。", + "configSaved": "系统设置已保存" + }, + "notFound": { + "title": "页面不存在", + "backHome": "返回首页" + }, + "language": { + "zhCN": "简体", + "zhTW": "繁體", + "en": "EN" + }, + "apiClient": { + "authExpired": "认证已过期,请重新登录", + "forbidden": "没有权限执行此操作", + "notFound": "请求的资源不存在", + "serverError": "服务器内部错误", + "serviceUnavailable": "服务暂时不可用,请稍后重试", + "networkError": "网络连接失败,请检查网络" + } +} diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json new file mode 100644 index 0000000..2b177b8 --- /dev/null +++ b/frontend/src/i18n/locales/zh-TW.json @@ -0,0 +1,439 @@ +{ + "common": { + "save": "儲存", + "saving": "儲存中...", + "cancel": "取消", + "delete": "刪除", + "edit": "編輯", + "add": "新增", + "search": "搜尋", + "loading": "載入中...", + "refresh": "重新整理", + "back": "返回列表", + "confirm": "確認", + "copy": "複製", + "close": "關閉", + "enable": "啟用", + "disable": "停用", + "enabled": "已啟用", + "disabled": "已停用", + "all": "全部", + "none": "無", + "unknown": "未知", + "form": "表單", + "json": "JSON", + "visual": "視覺化", + "open": "開啟", + "selectAll": "全選", + "clearAll": "清空", + "copiedToClipboard": "已複製到剪貼簿", + "copyFailed": "複製失敗", + "saveFailed": "儲存失敗:{{message}}", + "deleteFailed": "刪除失敗:{{message}}", + "jsonFormatError": "JSON 格式錯誤", + "savedAt": "儲存於 {{time}}", + "autoSaving": "自動儲存中...", + "autoSaveFailed": "自動儲存失敗", + "jsonCannotAutoSave": "JSON 無法自動儲存", + "previousPage": "上一頁", + "nextPage": "下一頁", + "free": "免費", + "unlimited": "無限", + "noMatch": "沒有匹配" + }, + "sidebar": { + "groupOverview": "總覽", + "groupManagement": "管理", + "groupTools": "工具", + "groupSystem": "系統", + "dashboard": "總覽看板", + "channels": "渠道管理", + "tokens": "令牌管理", + "pricing": "定價管理", + "usageLogs": "使用日誌", + "apiTest": "API 測試", + "settings": "系統設定", + "lightMode": "淺色", + "darkMode": "深色", + "collapse": "收起", + "logout": "登出", + "openSidebar": "開啟側邊欄" + }, + "auth": { + "title": "管理員身分驗證", + "descToken": "請輸入管理員令牌以存取管理功能", + "descVerification": "驗證碼已傳送至 Telegram,請輸入 6 位數字驗證碼完成登入", + "tokenLabel": "管理員令牌", + "tokenPlaceholder": "請輸入管理員令牌", + "verificationLabel": "Telegram 驗證碼", + "verificationPlaceholder": "請輸入 6 位驗證碼", + "verificationExpiry": "驗證碼有效期至 {{time}}", + "verificationDefault": "驗證碼 5 分鐘內有效", + "resend": "重新傳送", + "backToPrevious": "返回上一步", + "verifying": "驗證中...", + "sending": "傳送中...", + "verifyAndLogin": "驗證並登入", + "login": "登入", + "loginSuccess": "登入成功", + "loginFailed": "管理員令牌無效", + "verificationSent": "驗證碼已傳送至 Telegram", + "verificationResent": "驗證碼已重新傳送至 Telegram", + "sessionMissing": "驗證碼工作階段不存在,請重新取得", + "reenterToken": "請先重新輸入管理員令牌", + "loginResponseInvalid": "登入回應無效", + "loginMissingChallenge": "登入回應缺少驗證碼挑戰資訊", + "verificationSessionFailed": "驗證碼驗證後未建立工作階段", + "adminLogin": "管理員登入" + }, + "dashboard": { + "tagline": "Unified AI Gateway", + "heroTitle": "One API on Workers", + "heroDescription": "基於 Cloudflare Workers\n分散式、低延遲、高效能的 AI 統一閘道", + "featureMultiChannel": "多渠道支援", + "featureMultiChannelDesc": "支援 OpenAI、Claude、Gemini、Azure OpenAI 等多種 AI 服務商。", + "featureLoadBalance": "負載均衡", + "featureLoadBalanceDesc": "支援按權重多渠道路由、單渠道多金鑰重試/輪換,以及跨渠道 fallback。", + "featureUsageTracking": "用量追蹤", + "featureUsageTrackingDesc": "Analytics Engine 即時統計,支援日誌回溯便於成本分析。", + "featureSecurity": "安全管理", + "featureSecurityDesc": "基於安全令牌的存取控制與獨立 API 金鑰分配。", + "quickStart": "快速開始", + "quickStartDesc": "四步完成設定,開始使用統一介面", + "step1Title": "管理員登入", + "step1Desc": "使用管理員令牌登入系統", + "step2Title": "設定渠道", + "step2Desc": "新增 API 服務商", + "step3Title": "建立令牌", + "step3Desc": "為應用建立 API 令牌", + "step4Title": "開始使用", + "step4Desc": "使用令牌呼叫 API", + "supportedEndpoints": "支援的服務端點" + }, + "channels": { + "title": "渠道管理", + "description": "設定 AI 服務商介面,管理模型和負載均衡策略", + "searchPlaceholder": "搜尋渠道...", + "addChannel": "新增渠道", + "editChannel": "編輯渠道", + "emptyTitle": "新增您的第一個渠道", + "emptyDescription": "連接到 AI 服務商(如 OpenAI、Gemini、Azure、Claude),用於代理和轉發 API 請求。", + "updateSuccess": "渠道更新成功", + "addSuccess": "渠道新增成功", + "deleteSuccess": "渠道已刪除", + "deleteConfirm": "確定要刪除此渠道嗎?", + "enabledToast": "渠道已啟用", + "disabledToast": "渠道已停用", + "toggleFailed": "更新渠道狀態失敗:{{message}}", + "notFound": "未找到對應渠道", + "noMatchingChannels": "未找到匹配的渠道", + "loadingChannel": "載入渠道中...", + "channelKey": "渠道標識", + "channelKeyDesc": "用於內部識別的唯一標識", + "channelKeyPlaceholder": "例如:azure-gpt4-east", + "basicInfo": "基本資訊", + "channelName": "渠道名稱", + "channelNameRequired": "渠道名稱", + "channelNamePlaceholder": "例如:Azure GPT-4 東部", + "channelType": "渠道類型", + "channelTypeRequired": "渠道類型", + "channelWeight": "渠道權重", + "channelWeightHint": "相同模型權重優先,相同權重隨機分配。", + "channelStatus": "渠道狀態", + "channelStatusHint": "停用後不會參與請求選路,也不會出現在可用模型列表中。", + "runningStatus": "運行狀態", + "connectionConfig": "連接設定", + "apiEndpoint": "API 端點", + "apiEndpointPreview": "預覽:{{preview}}", + "apiKeys": "API 金鑰", + "apiKeysHint": "可設定多個金鑰,每行一個,請求會隨機選取一個金鑰發起呼叫。", + "autoRetry": "自動重試", + "autoRetryHint": "渠道遇可重試錯誤時,最多自動重試 3 次", + "autoRotate": "自動輪換", + "autoRotateHint": "重試時隨機切換同渠道其他金鑰;關閉後始終重試當前金鑰", + "modelConfig": "模型設定", + "modelConfigDesc": "外部使用模型名稱呼叫,程式會自動對應到渠道模型 ID。", + "modelId": "模型 ID", + "modelName": "模型名稱", + "modelStatusDelete": "狀態 / 刪除", + "modelIdPlaceholder": "模型ID,例如:gpt-4.1-mini", + "modelNamePlaceholder": "模型名稱,預設等於模型ID", + "fetchModelList": "取得模型列表", + "noFetchedModels": "未取得到可選模型", + "fetchModelsFailed": "取得模型失敗:{{message}}", + "selectModelsTitle": "選擇要寫入的模型", + "selectModelsDesc": "先從上游模型列表中批次勾選,再寫入當前渠道設定。已存在且 ID 相同的模型會保留原有別名和啟用狀態。", + "searchModelPlaceholder": "搜尋模型 ID 或顯示名稱", + "selectedCount": "已選 {{selected}} / {{total}}", + "applyModels": "寫入所選模型", + "modelsApplied": "已寫入 {{count}} 個模型", + "selectAtLeastOneModel": "請至少選擇一個模型", + "noMatchingModels": "沒有匹配的模型", + "saveChannel": "儲存渠道", + "jsonConfig": "JSON 設定", + "modelJsonStructure": "JSON 結構為陣列,每項包含 `id`、`name`、`enabled`。", + "fillRequired": "請填寫所有必填欄位", + "fillChannelKey": "請填寫渠道標識", + "fillTypeEndpointKey": "請先填寫渠道類型、API 端點和 API 金鑰", + "modelCount": "{{count}} 個模型", + "modelCountPartial": "{{enabled}}/{{total}} 個模型", + "validation": { + "atLeastOneModel": "請至少設定一個模型", + "duplicateModelName": "模型名稱重複:{{name}}", + "modelIdEmpty": "模型 ID 不能為空", + "modelJsonInvalid": "模型 JSON 格式錯誤", + "modelJsonMustBeArray": "模型 JSON 必須是陣列", + "modelJsonItemMustBeObject": "模型 JSON 項必須是物件", + "modelJsonIdEmpty": "模型 JSON 中的 id 不能為空" + } + }, + "tokens": { + "title": "令牌管理", + "description": "管理 API 存取令牌和配額", + "searchPlaceholder": "搜尋令牌...", + "addToken": "新增令牌", + "editToken": "編輯令牌", + "emptyTitle": "建立您的第一個令牌", + "emptyDescription": "令牌用於驗證 API 請求。每個令牌可以設定獨立的存取權限和使用配額。", + "updateSuccess": "令牌更新成功", + "addSuccess": "令牌新增成功", + "deleteSuccess": "令牌已刪除", + "deleteConfirm": "確定要刪除此令牌嗎?", + "resetConfirm": "確定要重設此令牌的已用配額嗎?", + "resetSuccess": "已用配額已重設", + "resetFailed": "重設失敗:{{message}}", + "notFound": "未找到對應令牌", + "noMatchingTokens": "未找到匹配的令牌", + "loadingToken": "載入令牌中...", + "tokenKey": "令牌標識", + "tokenKeyDesc": "用於 API 認證的唯一標識", + "basicInfo": "基本資訊", + "tokenName": "令牌名稱", + "tokenNameRequired": "令牌名稱", + "tokenNamePlaceholder": "例如:生產環境、測試使用者", + "channelAccess": "渠道存取權限", + "channelAccessAll": "允許存取所有渠道", + "channelAccessSelected": "已選擇 {{count}} 個渠道", + "noChannels": "暫無可用渠道", + "noChannelsHint": "請先在渠道管理中新增渠道", + "quota": "可用配額", + "quotaHint": "配額耗盡後將不可呼叫付費模型。", + "quotaPlaceholder": "輸入美元配額,0 表示無配額,-1 表示無限", + "quotaUnlimited": "不限制", + "saveToken": "儲存令牌", + "fillTokenKey": "請填寫令牌標識", + "fillTokenName": "請填寫令牌名稱", + "channels": "渠道", + "channelsAll": "全部", + "channelsCount": "{{count}}個", + "channelsCountWithUnit": "{{count}} 渠道", + "usedAvailable": "已用/可用", + "resetQuota": "重設配額" + }, + "pricing": { + "title": "定價管理", + "description": "展示和設定系統模型價格", + "cards": "卡片", + "channelFilter": "渠道篩選", + "channelFilterPlaceholder": "選擇渠道", + "noMatchingChannels": "沒有匹配渠道", + "billingTypeFilter": "收費類型", + "billingTypePlaceholder": "選擇收費類型", + "noMatchingBillingType": "沒有匹配收費類型", + "tokenFilter": "令牌篩選", + "tokenFilterPlaceholder": "選擇令牌", + "noMatchingTokens": "沒有匹配令牌", + "searchPlaceholder": "搜尋模型、渠道、令牌或收費類型", + "fieldInput": "輸入", + "fieldOutput": "輸出", + "fieldCache": "快取", + "billingVolume": "按量", + "billingVolumeDesc": "輸入 / 輸出 / 快取按每 M tokens 計費", + "billingRequest": "按次", + "billingRequestDesc": "成功回應後按固定金額計費", + "estimatedCostVolume": "${{cost}} / 1M tokens", + "estimatedCostRequest": "${{cost}} / 次", + "legacyRequestNote": "相容舊版獨立按次價格 {{price}},修改後會切換到新版計費規則。", + "noChannelsForModel": "無匹配渠道", + "loadingModels": "正在彙總模型、渠道與令牌資訊...", + "noModels": "暫無可管理模型", + "noModelsHint": "先在渠道管理中同步模型列表,或者切換到 JSON 模式手動補充價格設定。", + "noMatchingCards": "沒有匹配的模型卡片", + "noMatchingCardsHint": "當前篩選條件過嚴,建議放寬渠道、令牌、類型或關鍵字。", + "savedSuccess": "定價設定已儲存", + "jsonConfigTitle": "JSON 設定", + "jsonConfigDesc": "預設只會儲存大於 0 的價格欄位;但 `billingMode: \"request\"` 即使價格全為 0,也會作為有效設定保留。切回卡片模式時,會與當前渠道中的模型集合自動合併。", + "jsonFormatHint": "格式:模型名稱 → { billingMode?: \"volume\" | \"request\", input?: 輸入價格, output?: 輸出價格, cache?: 快取價格, request?: 舊版獨立按次價格 }" + }, + "analytics": { + "title": "總覽看板", + "description": "聚合查看請求、成本、成功率與關鍵維度分佈。", + "range24h": "24 小時", + "range7d": "7 天", + "range30d": "30 天", + "range90d": "90 天", + "totalRequests": "請求總量", + "successCount": "成功 {{count}}", + "successRate": "成功率", + "failureCount": "失敗 {{count}}", + "inputTokens": "輸入 Tokens", + "totalTokensCount": "總計 {{count}}", + "outputTokens": "輸出 Tokens", + "avgLatency": "平均延遲", + "noTrendData": "暫無趨勢資料", + "noTrendDataHint": "寫入第一批使用事件後,這裡會展示完整柱狀趨勢。", + "modelRanking": "模型排行", + "modelRankingDesc": "看清哪些模型承載最多請求,並同步觀察品質與成本。", + "channelRanking": "渠道排行", + "channelRankingDesc": "對比不同渠道的請求承載能力與穩定性表現。", + "tokenRanking": "令牌排行", + "tokenRankingDesc": "按請求量識別最活躍的存取憑證。", + "providerRanking": "服務商排行", + "providerRankingDesc": "衡量各上游服務商承接的請求佔比與穩定性表現。", + "breakdownLoadFailed": "排行資料載入失敗", + "breakdownEmpty": "當前時間範圍暫無維度排行資料", + "breakdownSuccessRate": "成功率 {{rate}}", + "breakdownTokens": "消耗 {{count}}", + "breakdownCost": "費用 {{cost}}" + }, + "usageLogs": { + "title": "使用日誌", + "description": "按最近時間範圍與維度篩選可讀取的使用記錄,重點用於排查異常裝置、失敗請求和上游問題。", + "timeRange": "時間範圍", + "queryDimension": "查詢維度", + "queryStatus": "查詢狀態", + "keyword": "關鍵字", + "keywordPlaceholder": "支援 ILIKE 模糊匹配", + "query": "查詢", + "dimToken": "令牌名稱", + "dimChannel": "渠道標識", + "dimModel": "請求模型", + "dimProvider": "服務商類型", + "dimRoute": "介面路由", + "dimRequestId": "Request ID", + "dimTraceId": "Trace ID", + "dimClientIp": "請求 IP", + "dimUserAgent": "User-Agent", + "dimCountry": "國家地區", + "dimRegion": "省州 / Region", + "dimCity": "城市 / City", + "dimColo": "邊緣節點 / Colo", + "dimTimezone": "時區 / Timezone", + "dimResult": "結果", + "dimErrorCode": "錯誤碼", + "dimErrorSummary": "錯誤摘要", + "resultAll": "全部狀態", + "resultSuccess": "僅成功", + "resultFailure": "僅失敗", + "colTime": "時間", + "colModelToken": "模型 / 令牌", + "colEndpointChannel": "端點 / 渠道", + "colResultStatus": "結果 / 狀態", + "colModeLatency": "方式 / 用時", + "colResourceUsage": "資源消耗", + "colDetail": "詳情", + "success": "成功", + "failure": "失敗", + "stream": "串流", + "nonStream": "非串流", + "retry": "重試 {{count}}", + "unknownToken": "未命名令牌", + "unknownLocation": "未知位置", + "logsLoading": "日誌載入中...", + "showingRange": "顯示第 {{start}}-{{end}} 條,共 {{total}} 條", + "noRecords": "暫無記錄", + "noMatchingLogs": "沒有匹配的日誌記錄", + "noMatchingLogsHint": "調整時間範圍、維度或關鍵字後重試。", + "detailTitle": "日誌詳情", + "sectionOverview": "請求概覽", + "sectionBilling": "計費與效能", + "sectionDiagnostics": "診斷標識", + "sectionClient": "用戶端環境", + "labelRequestModel": "請求模型", + "labelUpstreamModel": "上游模型", + "labelTokenName": "令牌名稱", + "labelChannelKey": "渠道標識", + "labelProvider": "服務商", + "labelRouteId": "介面路由", + "labelHttpStatus": "HTTP 狀態", + "labelStreamMode": "串流模式", + "labelRetryCount": "重試次數", + "labelStatusFamily": "狀態族", + "labelTotalCost": "總成本", + "labelCacheCost": "快取成本", + "labelLatency": "延遲", + "labelTotalTokens": "總 Tokens", + "labelInputTokens": "輸入 Tokens", + "labelOutputTokens": "輸出 Tokens", + "labelCachedTokens": "快取 Tokens", + "labelRequestId": "Request ID", + "labelTraceId": "Trace ID", + "labelTime": "時間", + "labelErrorCode": "錯誤碼", + "labelClientIp": "用戶端 IP", + "labelGeoLocation": "地理位置", + "labelEdgeTimezone": "邊緣節點 / 時區", + "labelUserAgent": "User-Agent", + "detailToken": "令牌", + "detailEndpoint": "端點", + "detailChannel": "渠道" + }, + "apiTest": { + "title": "API 測試", + "description": "測試 API 連接和設定", + "request": "請求", + "response": "回應", + "endpoint": "端點", + "apiToken": "API 令牌", + "model": "模型", + "modelPlaceholder": "選擇或輸入模型名稱", + "requestBody": "請求體", + "send": "傳送請求", + "sending": "傳送中...", + "noMatchingTokens": "沒有匹配的令牌", + "noMatchingModels": "沒有匹配的模型", + "tokenRequired": "請輸入 API 令牌", + "bodyJsonError": "請求體 JSON 格式錯誤", + "copyResponse": "複製回應", + "audioSuccess": "音訊返回成功", + "downloadAudio": "下載音訊", + "responseEmpty": "傳送請求後在此查看回應" + }, + "settings": { + "title": "系統設定", + "description": "統一管理全域系統設定", + "telegramSecurity": "Telegram 安全設定", + "telegramNotVerified": "繫結 Telegram,開啟登入驗證與登入通知。", + "telegramNeedTest": "傳送測試訊息,驗證通過後即可開啟 Telegram 安全設定。", + "telegramVerified": "已開啟 Telegram 登入驗證與登入通知。", + "sendTestMessage": "傳送測試訊息", + "sendTestMessageSending": "傳送中...", + "testMessageSent": "測試訊息已傳送,請到 Telegram 查收", + "testFailed": "測試失敗:{{message}}", + "fillBotTokenFirst": "請先填寫完整的 Bot Token 和 Chat ID。", + "lastVerified": "最近一次驗證通過時間:{{time}}", + "testUnlockHint": "測試通過後,安全開關才會解鎖。", + "displayPrecision": "顯示精度設定", + "displayPrecisionDesc": "頁面顯示金額保留 {{decimals}} 位小數,示例:${{example}}", + "apiDocs": "開啟介面文件", + "apiDocsDesc": "開啟後自動暴露 Swagger、ReDoc 與 OpenAPI JSON;關閉後對應路由會直接返回 404。", + "configSaved": "系統設定已儲存" + }, + "notFound": { + "title": "頁面不存在", + "backHome": "返回首頁" + }, + "language": { + "zhCN": "简体", + "zhTW": "繁體", + "en": "EN" + }, + "apiClient": { + "authExpired": "認證已過期,請重新登入", + "forbidden": "沒有權限執行此操作", + "notFound": "請求的資源不存在", + "serverError": "伺服器內部錯誤", + "serviceUnavailable": "服務暫時不可用,請稍後重試", + "networkError": "網路連線失敗,請檢查網路" + } +} diff --git a/frontend/src/lib/system-config.ts b/frontend/src/lib/system-config.ts index 413892a..8b1eafb 100644 --- a/frontend/src/lib/system-config.ts +++ b/frontend/src/lib/system-config.ts @@ -23,10 +23,9 @@ export const DEFAULT_SYSTEM_CONFIG: SystemConfig = { }; export const PRECISION_OPTIONS = [ - { label: "低", value: 2 }, - { label: "中", value: 4 }, - { label: "高", value: 6 }, - // { label: "超高", value: 9 }, + { label: "2", value: 2 }, + { label: "4", value: 4 }, + { label: "6", value: 6 }, ] as const; const normalizeString = (value: unknown): string => { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6f4ac9b..c92646c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import './i18n' import App from './App.tsx' import './index.css' diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index befab14..ae6c26b 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -18,50 +18,7 @@ import { readScopedCache, writeScopedCache } from "@/lib/local-cache"; import { cn, formatCompactNumber, formatCurrency } from "@/lib/utils"; import { Activity, CircleDollarSign, Clock3, DatabaseZap, RefreshCw, ShieldCheck } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; - -const RANGE_OPTIONS: Array<{ value: AnalyticsRange; label: string }> = [ - { value: "24h", label: "24 小时" }, - { value: "7d", label: "7 天" }, - { value: "30d", label: "30 天" }, - { value: "90d", label: "90 天" }, -]; - -const BREAKDOWN_CHARTS: Array<{ - dimension: AnalyticsBreakdownDimension; - title: string; - description: string; - barClassName: string; - badgeClassName: string; -}> = [ - { - dimension: "model", - title: "模型排行", - description: "看清哪些模型承载最多请求,并同步观察质量与成本。", - barClassName: "from-amber-500 to-orange-400", - badgeClassName: "bg-amber-500/12 text-amber-700 dark:text-amber-400", - }, - { - dimension: "channel", - title: "渠道排行", - description: "对比不同渠道的请求承载能力与稳定性表现。", - barClassName: "from-emerald-500 to-teal-400", - badgeClassName: "bg-emerald-500/12 text-emerald-600 dark:text-emerald-400", - }, - { - dimension: "token", - title: "令牌排行", - description: "按请求量识别最活跃的访问凭证。", - barClassName: "from-sky-500 to-cyan-400", - badgeClassName: "bg-sky-500/12 text-sky-600 dark:text-sky-400", - }, - { - dimension: "provider", - title: "服务商排行", - description: "衡量各上游服务商承接的请求占比与稳定性表现。", - barClassName: "from-rose-500 to-pink-400", - badgeClassName: "bg-rose-500/12 text-rose-600 dark:text-rose-400", - }, -]; +import { useTranslation } from "react-i18next"; const formatPercent = (value: number): string => `${(value * 100).toFixed(1)}%`; @@ -81,17 +38,64 @@ const getAnalyticsTrendCacheKey = (range: AnalyticsRange): string => `analytics: const getAnalyticsBreakdownCacheKey = (range: AnalyticsRange, dimension: AnalyticsBreakdownDimension): string => `analytics:breakdown:${range}:${dimension}`; +const RANGE_VALUES: AnalyticsRange[] = ["24h", "7d", "30d", "90d"]; + const getInitialAnalyticsRange = (): AnalyticsRange => { const cachedRange = readScopedCache(ANALYTICS_RANGE_CACHE_KEY)?.data; - return (cachedRange && RANGE_OPTIONS.some((option) => option.value === cachedRange)) ? cachedRange : "24h"; + return (cachedRange && RANGE_VALUES.includes(cachedRange)) ? cachedRange : "24h"; }; export function Analytics() { + const { t } = useTranslation(); const [range, setRange] = useState(getInitialAnalyticsRange); const { data: billingConfig } = useBillingConfig(); const displayDecimals = billingConfig?.displayDecimals ?? 6; + const RANGE_OPTIONS: Array<{ value: AnalyticsRange; label: string }> = [ + { value: "24h", label: t('analytics.range24h') }, + { value: "7d", label: t('analytics.range7d') }, + { value: "30d", label: t('analytics.range30d') }, + { value: "90d", label: t('analytics.range90d') }, + ]; + + const BREAKDOWN_CHARTS: Array<{ + dimension: AnalyticsBreakdownDimension; + title: string; + description: string; + barClassName: string; + badgeClassName: string; + }> = [ + { + dimension: "model", + title: t('analytics.modelRanking'), + description: t('analytics.modelRankingDesc'), + barClassName: "from-amber-500 to-orange-400", + badgeClassName: "bg-amber-500/12 text-amber-700 dark:text-amber-400", + }, + { + dimension: "channel", + title: t('analytics.channelRanking'), + description: t('analytics.channelRankingDesc'), + barClassName: "from-emerald-500 to-teal-400", + badgeClassName: "bg-emerald-500/12 text-emerald-600 dark:text-emerald-400", + }, + { + dimension: "token", + title: t('analytics.tokenRanking'), + description: t('analytics.tokenRankingDesc'), + barClassName: "from-sky-500 to-cyan-400", + badgeClassName: "bg-sky-500/12 text-sky-600 dark:text-sky-400", + }, + { + dimension: "provider", + title: t('analytics.providerRanking'), + description: t('analytics.providerRankingDesc'), + barClassName: "from-rose-500 to-pink-400", + badgeClassName: "bg-rose-500/12 text-rose-600 dark:text-rose-400", + }, + ]; + const overviewCacheEntry = useMemo( () => readScopedCache(getAnalyticsOverviewCacheKey(range)), [range], @@ -228,8 +232,8 @@ export function Analytics() { return (
@@ -251,40 +255,40 @@ export function Analytics() {
- 请求总量 + {t('analytics.totalRequests')} {formatCompactNumber(overview?.totals.requests || 0)} - 成功 {formatCompactNumber(overview?.totals.successes || 0)} + {t('analytics.successCount', { count: formatCompactNumber(overview?.totals.successes || 0) })} - 成功率 + {t('analytics.successRate')} {formatPercent(overview?.totals.successRate || 0)} - 失败 {formatCompactNumber(overview?.totals.failures || 0)} + {t('analytics.failureCount', { count: formatCompactNumber(overview?.totals.failures || 0) })} - 输入 Tokens + {t('analytics.inputTokens')} {formatCompactNumber(overview?.totals.promptTokens || 0)} - 总计 {formatCompactNumber(overview?.totals.totalTokens || 0)} + {t('analytics.totalTokensCount', { count: formatCompactNumber(overview?.totals.totalTokens || 0) })} - 输出 Tokens + {t('analytics.outputTokens')} {formatCompactNumber(overview?.totals.completionTokens || 0)} @@ -295,7 +299,7 @@ export function Analytics() { - 平均延迟 + {t('analytics.avgLatency')} {formatDuration(overview?.totals.avgLatencyMs || 0)} @@ -307,8 +311,8 @@ export function Analytics() { {!trend || trend.points.length === 0 ? ( -

暂无趋势数据

-

写入第一批使用事件后,这里会展示完整柱状趋势。

+

{t('analytics.noTrendData')}

+

{t('analytics.noTrendDataHint')}

) : ( diff --git a/frontend/src/pages/ApiTest.tsx b/frontend/src/pages/ApiTest.tsx index ec8851d..2590fb6 100644 --- a/frontend/src/pages/ApiTest.tsx +++ b/frontend/src/pages/ApiTest.tsx @@ -13,6 +13,7 @@ import { Send, Clock, CheckCircle, XCircle, Copy, Download } from 'lucide-react' import { PageContainer } from '@/components/ui/page-container' import { cn, copyToClipboard } from '@/lib/utils' import { getModelNamesForToken, parseTokenConfig } from '@/lib/channel-models' +import { useTranslation } from 'react-i18next' const requestTemplates: Record = { '/v1/chat/completions': { @@ -65,6 +66,7 @@ const buildRequestBody = (endpoint: string, model = '') => { } export function ApiTest() { + const { t } = useTranslation() const [endpoint, setEndpoint] = useState('/v1/chat/completions') const [apiToken, setApiToken] = useState('') const [modelValue, setModelValue] = useState('') @@ -132,9 +134,9 @@ export function ApiTest() { } : response await copyToClipboard(JSON.stringify(payload, null, 2)) - addToast('已复制到剪贴板', 'success') + addToast(t('common.copiedToClipboard'), 'success') } catch { - addToast('复制失败', 'error') + addToast(t('common.copyFailed'), 'error') } } } @@ -174,7 +176,7 @@ export function ApiTest() { e.preventDefault() if (!apiToken) { - addToast('请输入 API 令牌', 'error') + addToast(t('apiTest.tokenRequired'), 'error') return } @@ -182,7 +184,7 @@ export function ApiTest() { try { body = JSON.parse(requestBody) } catch { - addToast('请求体 JSON 格式错误', 'error') + addToast(t('apiTest.bodyJsonError'), 'error') return } @@ -225,8 +227,8 @@ export function ApiTest() { return (
{/* Request Panel */} @@ -234,12 +236,12 @@ export function ApiTest() {
-

请求

+

{t('apiTest.request')}

- +