feat(i18n): 添加多语言支持,优化用户界面文本

在此提交中,我们引入了多语言支持,允许用户根据其语言偏好查看界面文本。新增了国际化库(i18next)并在多个组件中实现了翻译功能,确保错误消息和界面文本能够根据用户的语言设置动态变化。这一改动旨在提升用户体验,使得应用能够服务更广泛的用户群体,增强可访问性和友好性。同时,更新了相关文档以指导开发者如何使用新的国际化功能。
This commit is contained in:
Jeff Gu committed 2026-04-15 09:31:29 +08:00
1 parent 8261eeda34
commit a7c65b9476
33 files changed
+2328 -609

No files matched your search

+84
View File
@@ -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 中添加类型
+2
View File
@@ -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"
+11 -7
View File
@@ -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<RequestInit> {
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<T>(
} 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
}
@@ -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 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
排行数据加载失败
{t('analytics.breakdownLoadFailed')}
</div>
) : visibleItems.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
当前时间范围暂无维度排行数据
{t('analytics.breakdownEmpty')}
</div>
) : (
visibleItems.map((item, index) => {
@@ -71,9 +73,9 @@ export function BreakdownChartCard({
{item.label}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>成功率 {formatPercent(item.successRate)}</span>
<span>消耗 {formatCompactNumber(item.totalTokens)}</span>
<span>费用 {formatCurrency(item.totalCost, displayDecimals)}</span>
<span>{t('analytics.breakdownSuccessRate', { rate: formatPercent(item.successRate) })}</span>
<span>{t('analytics.breakdownTokens', { count: formatCompactNumber(item.totalTokens) })}</span>
<span>{t('analytics.breakdownCost', { cost: formatCurrency(item.totalCost, displayDecimals) })}</span>
</div>
</div>
</div>
@@ -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({
<div
className="group relative flex h-56 items-end bg-muted/50 outline-none hover:bg-muted"
tabIndex={0}
aria-label={`${label},请求数 ${formatCompactNumber(point.requests)},花费 ${formatCurrency(point.totalCost, displayDecimals)}`}
aria-label={`${label}, ${formatCompactNumber(point.requests)} requests, ${formatCurrency(point.totalCost, displayDecimals)}`}
>
<div className="pointer-events-none absolute top-3 left-[-100%] right-[-100%] mx-auto z-20 w-24 rounded-md border border-border/50 bg-card px-3 py-2 text-left opacity-0 shadow-lg transition-all duration-200 translate-y-2 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<div className="text-[10px] text-muted-foreground">Requests</div>
+26 -23
View File
@@ -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')}
>
<Menu className="h-4 w-4" />
</Button>
@@ -198,11 +201,11 @@ export function AppLayout({ children }: AppLayoutProps) {
<Dialog open={showAuthModal} onOpenChange={(open) => !open && handleCloseAuthModal()}>
<DialogContent>
<DialogHeader>
<DialogTitle>管理员身份验证</DialogTitle>
<DialogTitle>{t('auth.title')}</DialogTitle>
<DialogDescription>
{authStep === 'token'
? '请输入管理员令牌以访问管理功能'
: '验证码已发送到 Telegram,请输入 6 位数字验证码完成登录'}
? t('auth.descToken')
: t('auth.descVerification')}
</DialogDescription>
</DialogHeader>
@@ -210,11 +213,11 @@ export function AppLayout({ children }: AppLayoutProps) {
<div className="space-y-4">
{authStep === 'token' ? (
<div className="space-y-2">
<Label className="block" htmlFor="adminToken">管理员令牌</Label>
<Label className="block" htmlFor="adminToken">{t('auth.tokenLabel')}</Label>
<Input
id="adminToken"
type="password"
placeholder="请输入管理员令牌"
placeholder={t('auth.tokenPlaceholder')}
value={adminToken}
onChange={(e) => setAdminToken(e.target.value)}
required
@@ -222,19 +225,19 @@ export function AppLayout({ children }: AppLayoutProps) {
</div>
) : (
<div className="space-y-2">
<Label className="block" htmlFor="verificationCode">Telegram 验证码</Label>
<Label className="block" htmlFor="verificationCode">{t('auth.verificationLabel')}</Label>
<Input
id="verificationCode"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="请输入 6 位验证码"
placeholder={t('auth.verificationPlaceholder')}
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
required
/>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{challengeExpiresText ? `验证码有效期至 ${challengeExpiresText}` : '验证码 5 分钟内有效'}</span>
<span>{challengeExpiresText ? t('auth.verificationExpiry', { time: challengeExpiresText }) : t('auth.verificationDefault')}</span>
<button
type="button"
className="inline-flex items-center gap-1 text-foreground hover:text-muted-foreground"
@@ -242,7 +245,7 @@ export function AppLayout({ children }: AppLayoutProps) {
disabled={isSubmitting}
>
<RotateCcw className="h-3.5 w-3.5" />
重新发送
{t('auth.resend')}
</button>
</div>
</div>
@@ -270,16 +273,16 @@ export function AppLayout({ children }: AppLayoutProps) {
: handleCloseAuthModal}
className='mr-0'
>
{authStep === 'verification' ? '返回上一步' : '取消'}
{authStep === 'verification' ? t('auth.backToPrevious') : t('common.cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? authStep === 'verification'
? '验证中...'
: '发送中...'
? t('auth.verifying')
: t('auth.sending')
: authStep === 'verification'
? '验证并登录'
: '登录'}
? t('auth.verifyAndLogin')
: t('auth.login')}
</Button>
</DialogFooter>
</form>
+58 -24
View File
@@ -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<SupportedLanguage, string> = {
"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 (
<Link
to={item.href}
onClick={() => 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 && <span>{item.title}</span>}
{!collapsed && <span>{title}</span>}
</Link>
);
};
const groupedNavItems = (() => {
const groups: Record<NavGroup, NavItem[]> = {
总览: [],
管理: [],
工具: [],
系统: [],
overview: [],
management: [],
tools: [],
system: [],
};
navItems.forEach((item) => {
navItemDefs.forEach((item) => {
groups[item.group].push(item);
});
return groups;
})();
const groupOrder: NavGroup[] = ["总览", "管理", "工具", "系统"];
return (
<aside
className={cn(
@@ -180,7 +199,7 @@ export function Sidebar({
<div className={cn("flex gap-1 mb-2", collapsed ? "flex-col items-center" : "")}>
<button
type="button"
title={collapsed ? (theme === "dark" ? "浅色模式" : "深色模式") : undefined}
title={collapsed ? (theme === "dark" ? t('sidebar.lightMode') : t('sidebar.darkMode')) : undefined}
className={cn(
"flex items-center justify-center rounded-md transition-colors duration-150",
collapsed
@@ -190,7 +209,22 @@ export function Sidebar({
onClick={toggleTheme}
>
{theme === "dark" ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
{!collapsed && <span>{theme === "dark" ? "浅色" : "深色"}</span>}
{!collapsed && <span>{theme === "dark" ? t('sidebar.lightMode') : t('sidebar.darkMode')}</span>}
</button>
<button
type="button"
title={collapsed ? currentLangLabel : undefined}
className={cn(
"flex items-center justify-center rounded-md transition-colors duration-150",
collapsed
? "w-10 h-9 hover:bg-muted text-muted-foreground hover:text-foreground"
: "flex-1 h-8 gap-1.5 px-2.5 hover:bg-muted/70 text-muted-foreground hover:text-foreground text-[11px] font-medium",
)}
onClick={cycleLang}
>
<Languages className="h-3.5 w-3.5" />
{!collapsed && <span>{currentLangLabel}</span>}
</button>
<a
@@ -227,7 +261,7 @@ export function Sidebar({
) : (
<>
<PanelLeftClose className="h-3.5 w-3.5" />
<span>收起</span>
<span>{t('sidebar.collapse')}</span>
</>
)}
</button>
@@ -238,7 +272,7 @@ export function Sidebar({
{/* Logout Button */}
<button
type="button"
title={collapsed ? "退出登录" : undefined}
title={collapsed ? t('sidebar.logout') : undefined}
className={cn(
"flex items-center justify-center gap-2 w-full rounded-md text-[13px] font-medium transition-all duration-150",
collapsed ? "h-9" : "h-9 px-3",
@@ -247,7 +281,7 @@ export function Sidebar({
onClick={handleLogout}
>
<LogOut className="h-3.5 w-3.5" />
{!collapsed && <span>退出登录</span>}
{!collapsed && <span>{t('sidebar.logout')}</span>}
</button>
</div>
</aside>
+2 -2
View File
@@ -49,7 +49,7 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
onChange,
options,
maxOptions,
emptyText = "无匹配结果",
emptyText = "No match",
className,
dropdownClassName,
inputClassName,
@@ -168,7 +168,7 @@ export const MultiSelectAutoCompleteInput = React.forwardRef<HTMLInputElement, M
onChange,
options,
maxOptions,
emptyText = "无匹配结果",
emptyText = "No match",
className,
dropdownClassName,
inputClassName,
+1 -1
View File
@@ -85,7 +85,7 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
onBlur,
onFocus,
onKeyDown,
placeholder = '请选择',
placeholder = '',
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
'aria-labelledby': ariaLabelledBy,
+67
View File
@@ -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
+439
View File
@@ -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"
}
}
+439
View File
@@ -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": "网络连接失败,请检查网络"
}
}
+439
View File
@@ -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": "網路連線失敗,請檢查網路"
}
}
+3 -4
View File
@@ -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 => {
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './i18n'
import App from './App.tsx'
import './index.css'
+61 -57
View File
@@ -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<AnalyticsRange>(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<AnalyticsRange>(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<AnalyticsOverviewData>(getAnalyticsOverviewCacheKey(range)),
[range],
@@ -228,8 +232,8 @@ export function Analytics() {
return (
<PageContainer
title="总览看板"
description="聚合查看请求、成本、成功率与关键维度分布。"
title={t('analytics.title')}
description={t('analytics.description')}
actions={
<div className="flex items-center gap-2">
<div className="w-28">
@@ -251,40 +255,40 @@ export function Analytics() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>请求总量</CardDescription>
<CardDescription>{t('analytics.totalRequests')}</CardDescription>
<CardTitle className="text-3xl">{formatCompactNumber(overview?.totals.requests || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>成功 {formatCompactNumber(overview?.totals.successes || 0)}</span>
<span>{t('analytics.successCount', { count: formatCompactNumber(overview?.totals.successes || 0) })}</span>
<Activity className="h-4 w-4 text-sky-500" />
</CardContent>
</Card>
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>成功率</CardDescription>
<CardDescription>{t('analytics.successRate')}</CardDescription>
<CardTitle className="text-3xl">{formatPercent(overview?.totals.successRate || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>失败 {formatCompactNumber(overview?.totals.failures || 0)}</span>
<span>{t('analytics.failureCount', { count: formatCompactNumber(overview?.totals.failures || 0) })}</span>
<ShieldCheck className="h-4 w-4 text-emerald-500" />
</CardContent>
</Card>
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>输入 Tokens</CardDescription>
<CardDescription>{t('analytics.inputTokens')}</CardDescription>
<CardTitle className="text-3xl">{formatCompactNumber(overview?.totals.promptTokens || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>总计 {formatCompactNumber(overview?.totals.totalTokens || 0)}</span>
<span>{t('analytics.totalTokensCount', { count: formatCompactNumber(overview?.totals.totalTokens || 0) })}</span>
<DatabaseZap className="h-4 w-4 text-violet-500" />
</CardContent>
</Card>
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>输出 Tokens</CardDescription>
<CardDescription>{t('analytics.outputTokens')}</CardDescription>
<CardTitle className="text-3xl">{formatCompactNumber(overview?.totals.completionTokens || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
@@ -295,7 +299,7 @@ export function Analytics() {
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>平均延迟</CardDescription>
<CardDescription>{t('analytics.avgLatency')}</CardDescription>
<CardTitle className="text-3xl">{formatDuration(overview?.totals.avgLatencyMs || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
@@ -307,8 +311,8 @@ export function Analytics() {
{!trend || trend.points.length === 0 ? (
<Card className="border-0 flex flex-col items-center justify-center py-16">
<p className="text-sm font-medium">暂无趋势数据</p>
<p className="mt-1 text-sm text-muted-foreground">写入第一批使用事件后,这里会展示完整柱状趋势。</p>
<p className="text-sm font-medium">{t('analytics.noTrendData')}</p>
<p className="mt-1 text-sm text-muted-foreground">{t('analytics.noTrendDataHint')}</p>
</Card>
) : (
<TrendBarChart points={trend.points} range={range} displayDecimals={displayDecimals} />
+23 -21
View File
@@ -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<string, any> = {
'/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 (
<PageContainer
title="API 测试"
description="测试 API 连接和配置"
title={t('apiTest.title')}
description={t('apiTest.description')}
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
@@ -234,12 +236,12 @@ export function ApiTest() {
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-5">
<div className="w-2 h-2 rounded-full bg-primary" />
<h3 className="font-semibold text-sm">请求</h3>
<h3 className="font-semibold text-sm">{t('apiTest.request')}</h3>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">端点</Label>
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('apiTest.endpoint')}</Label>
<Select
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
@@ -253,32 +255,32 @@ export function ApiTest() {
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API 令牌</Label>
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('apiTest.apiToken')}</Label>
<AutoCompleteInput
value={apiToken}
onChange={setApiToken}
placeholder="sk-..."
inputClassName="font-mono"
options={tokenOptions}
emptyText="没有匹配的令牌"
emptyText={t('apiTest.noMatchingTokens')}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">模型</Label>
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('apiTest.model')}</Label>
<AutoCompleteInput
value={modelValue}
onChange={updateRequestBodyModel}
placeholder="选择或输入模型名称"
placeholder={t('apiTest.modelPlaceholder')}
inputClassName="font-mono"
options={modelOptions}
maxOptions={availableModels.length}
emptyText="没有匹配的模型"
emptyText={t('apiTest.noMatchingModels')}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">请求体</Label>
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('apiTest.requestBody')}</Label>
<Textarea
value={requestBody}
onChange={(e) => handleRequestBodyChange(e.target.value)}
@@ -291,12 +293,12 @@ export function ApiTest() {
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-primary-foreground border-t-transparent rounded-full animate-spin" />
发送中...
{t('apiTest.sending')}
</>
) : (
<>
<Send className="h-4 w-4" />
发送请求
{t('apiTest.send')}
</>
)}
</Button>
@@ -317,7 +319,7 @@ export function ApiTest() {
!statusCode ? "bg-muted-foreground/30" :
statusCode === 200 ? "bg-success" : "bg-destructive"
)} />
<h3 className="font-semibold text-sm">响应</h3>
<h3 className="font-semibold text-sm">{t('apiTest.response')}</h3>
</div>
{statusCode && (
<div className="flex items-center gap-2">
@@ -337,7 +339,7 @@ export function ApiTest() {
size="icon"
className="h-7 w-7"
onClick={handleCopyResponse}
title="复制响应"
title={t('apiTest.copyResponse')}
>
<Copy className="h-3.5 w-3.5" />
</Button>
@@ -349,7 +351,7 @@ export function ApiTest() {
isAudioResponse(response) ? (
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
<div className="space-y-1">
<div className="text-sm font-medium">音频返回成功</div>
<div className="text-sm font-medium">{t('apiTest.audioSuccess')}</div>
<div className="text-xs text-muted-foreground font-mono">
{response.contentType} · {response.size} bytes
</div>
@@ -358,7 +360,7 @@ export function ApiTest() {
<Button asChild variant="outline" size="sm">
<a href={response.url} download={response.filename}>
<Download className="h-4 w-4" />
下载音频
{t('apiTest.downloadAudio')}
</a>
</Button>
</div>
@@ -370,7 +372,7 @@ export function ApiTest() {
) : (
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground/40">
<Send className="h-10 w-10 mb-3" />
<p className="text-sm">发送请求后在此查看响应</p>
<p className="text-sm">{t('apiTest.responseEmpty')}</p>
</div>
)}
</CardContent>
+101 -101
View File
@@ -31,6 +31,8 @@ import {
Cpu,
} from "lucide-react";
import { PageContainer } from "@/components/ui/page-container";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
type EditMode = "form" | "json";
type ModelEditorMode = "visual" | "json";
@@ -54,11 +56,6 @@ const channelWeightOptions = Array.from({ length: 6 }, (_, weight) => ({
label: String(weight),
}));
const modelEditorModeOptions = [
{ value: "visual", label: "可视化" },
{ value: "json", label: "JSON" },
] as const;
const createEmptyModelRow = (): ModelRow => ({
id: "",
name: "",
@@ -230,13 +227,13 @@ const serializeModels = (models: ChannelModelMapping[]): string => {
const validateModels = (models: ChannelModelMapping[]): string | null => {
if (models.length === 0) {
return "请至少配置一个模型";
return i18n.t('channels.validation.atLeastOneModel');
}
const names = new Set<string>();
for (const model of models) {
if (names.has(model.name)) {
return `模型名称重复:${model.name}`;
return i18n.t('channels.validation.duplicateModelName', { name: model.name });
}
names.add(model.name);
}
@@ -249,7 +246,7 @@ const parseModelsFromRows = (rows: ModelRow[]): { models: ChannelModelMapping[];
for (const row of activeRows) {
if (!row.id.trim()) {
return { models: [], error: "模型 ID 不能为空" };
return { models: [], error: i18n.t('channels.validation.modelIdEmpty') };
}
}
@@ -273,18 +270,18 @@ const parseModelsFromJson = (value: string): { models: ChannelModelMapping[]; er
try {
parsed = JSON.parse(value);
} catch {
return { models: [], error: "模型 JSON 格式错误" };
return { models: [], error: i18n.t('channels.validation.modelJsonInvalid') };
}
if (!Array.isArray(parsed)) {
return { models: [], error: "模型 JSON 必须是数组" };
return { models: [], error: i18n.t('channels.validation.modelJsonMustBeArray') };
}
const models: ChannelModelMapping[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object") {
return { models: [], error: "模型 JSON 项必须是对象" };
return { models: [], error: i18n.t('channels.validation.modelJsonItemMustBeObject') };
}
const id = typeof (item as ChannelModelMapping).id === "string" ? (item as ChannelModelMapping).id.trim() : "";
@@ -292,7 +289,7 @@ const parseModelsFromJson = (value: string): { models: ChannelModelMapping[]; er
typeof (item as ChannelModelMapping).name === "string" ? (item as ChannelModelMapping).name.trim() : "";
if (!id) {
return { models: [], error: "模型 JSON 中的 id 不能为空" };
return { models: [], error: i18n.t('channels.validation.modelJsonIdEmpty') };
}
models.push({
@@ -490,6 +487,7 @@ const getChannelEndpointPreview = (type: string | undefined, endpoint: string):
};
export function Channels({ createMode = false, editRoute = false }: { createMode?: boolean; editRoute?: boolean }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { key: routeKey } = useParams<{ key: string }>();
const isRouteEdit = editRoute && Boolean(routeKey);
@@ -513,6 +511,11 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
const { addToast } = useToast();
const queryClient = useQueryClient();
const modelEditorModeOptions = [
{ value: "visual" as const, label: t('common.visual') },
{ value: "json" as const, label: t('common.json') },
];
const applyModels = (models: ChannelModelMapping[]) => {
const normalizedModels = normalizeModels(models);
setFormData((prev) => ({ ...prev, models: normalizedModels }));
@@ -568,11 +571,11 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
addToast(editingKey ? "渠道更新成功" : "渠道添加成功", "success");
addToast(editingKey ? t('channels.updateSuccess') : t('channels.addSuccess'), "success");
closeForm();
},
onError: (error: Error) => {
addToast("保存失败:" + error.message, "error");
addToast(t('common.saveFailed', { message: error.message }), "error");
},
});
@@ -582,10 +585,10 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
addToast("渠道已删除", "success");
addToast(t('channels.deleteSuccess'), "success");
},
onError: (error: Error) => {
addToast("删除失败:" + error.message, "error");
addToast(t('common.deleteFailed', { message: error.message }), "error");
},
});
@@ -619,13 +622,13 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
return { previousChannels };
},
onSuccess: (_, { enabled }) => {
addToast(enabled ? "渠道已启用" : "渠道已停用", "success");
addToast(enabled ? t('channels.enabledToast') : t('channels.disabledToast'), "success");
},
onError: (error: Error, _variables, context) => {
if (context?.previousChannels) {
queryClient.setQueryData(["channels"], context.previousChannels);
}
addToast("更新渠道状态失败:" + error.message, "error");
addToast(t('channels.toggleFailed', { message: error.message }), "error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
@@ -639,7 +642,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
onSuccess: (response) => {
const candidates = normalizeFetchedModelCandidates((response.data as ChannelModelMapping[]) || []);
if (candidates.length === 0) {
addToast("未获取到可选模型", "error");
addToast(t('channels.noFetchedModels'), "error");
return;
}
@@ -650,7 +653,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
setIsFetchedModelsDialogOpen(true);
},
onError: (error: Error) => {
addToast("获取模型失败:" + error.message, "error");
addToast(t('channels.fetchModelsFailed', { message: error.message }), "error");
},
});
@@ -687,7 +690,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
if (!targetChannel) {
resetForm();
setView("list");
addToast("未找到对应渠道", "error");
addToast(t('channels.notFound'), "error");
navigate("/channels", { replace: true });
return;
}
@@ -698,7 +701,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
resetForm();
setView("list");
}, [addToast, createMode, data, isLoading, isRouteEdit, navigate, openChannelForEdit, resetForm, routeKey]);
}, [addToast, createMode, data, isLoading, isRouteEdit, navigate, openChannelForEdit, resetForm, routeKey, t]);
const closeForm = () => {
resetForm();
@@ -719,7 +722,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
};
const handleDelete = (key: string) => {
if (confirm("确定要删除此渠道吗?")) {
if (confirm(t('channels.deleteConfirm'))) {
deleteMutation.mutate(key);
}
};
@@ -732,7 +735,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
const apiKeys = parseApiKeys(apiKeysInput);
if (!formData.type || !formData.endpoint || apiKeys.length === 0) {
addToast("请先填写渠道类型、API 端点和 API 密钥", "error");
addToast(t('channels.fillTypeEndpointKey'), "error");
return;
}
@@ -780,18 +783,18 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
);
if (selectedModels.length === 0) {
addToast("请至少选择一个模型", "error");
addToast(t('channels.selectAtLeastOneModel'), "error");
return;
}
applyModels(selectedModels);
setIsFetchedModelsDialogOpen(false);
addToast(`已写入 ${selectedModels.length} 个模型`, "success");
addToast(t('channels.modelsApplied', { count: selectedModels.length }), "success");
};
const handleSave = () => {
if (!channelKey) {
addToast("请填写渠道标识", "error");
addToast(t('channels.fillChannelKey'), "error");
return;
}
@@ -804,7 +807,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
}
if (!formData.name || !formData.endpoint || apiKeys.length === 0) {
addToast("请填写所有必填字段", "error");
addToast(t('channels.fillRequired'), "error");
return;
}
@@ -820,7 +823,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
try {
config = normalizeChannelFormConfig(JSON.parse(jsonValue));
} catch {
addToast("JSON 格式错误", "error");
addToast(t('common.jsonFormatError'), "error");
return;
}
@@ -864,7 +867,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
loadFormConfig(config);
setEditMode("form");
} catch {
addToast("JSON 格式错误", "error");
addToast(t('common.jsonFormatError'), "error");
}
};
@@ -955,8 +958,8 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
if (view === "list") {
return (
<PageContainer
title="渠道管理"
description="配置 AI 服务商接口,管理模型和负载均衡策略"
title={t('channels.title')}
description={t('channels.description')}
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
@@ -964,7 +967,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
</Button>
<Button size="sm" onClick={handleAdd}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-1">添加</span>
<span className="hidden sm:inline ml-1">{t('common.add')}</span>
</Button>
</div>
}
@@ -974,7 +977,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索渠道..."
placeholder={t('channels.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -987,7 +990,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">加载中...</span>
<span className="text-sm text-muted-foreground">{t('common.loading')}</span>
</div>
</div>
) : !data || data.length === 0 ? (
@@ -996,13 +999,13 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<LinkIcon className="h-7 w-7 text-primary" />
</div>
<h3 className="font-semibold text-lg mb-2">添加您的第一个渠道</h3>
<h3 className="font-semibold text-lg mb-2">{t('channels.emptyTitle')}</h3>
<p className="text-muted-foreground text-sm text-center max-w-sm mb-6">
连接到 AI 服务商(如 OpenAI、Gemini、Azure、Claude),用于代理和转发 API 请求。
{t('channels.emptyDescription')}
</p>
<Button onClick={handleAdd} size="lg">
<Plus className="h-4 w-4 mr-2" />
添加渠道
{t('channels.addChannel')}
</Button>
</CardContent>
</Card>
@@ -1016,8 +1019,8 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
const enabledModelCount = (config.models || []).filter((model) => model.enabled !== false).length;
const modelSummary =
enabledModelCount === modelCount
? `${modelCount} 个模型`
: `${enabledModelCount}/${modelCount} 个模型`;
? t('channels.modelCount', { count: modelCount })
: t('channels.modelCountPartial', { enabled: enabledModelCount, total: modelCount });
const isMenuOpen = openMenu === channel.key;
const isEnabled = config.enabled !== false;
const isToggling =
@@ -1050,14 +1053,14 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
onClick={() => handleEdit(channel)}
>
<Pencil className="h-4 w-4" />
编辑
{t('common.edit')}
</button>
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2 text-destructive"
onClick={() => handleDelete(channel.key)}
>
<Trash2 className="h-4 w-4" />
删除
{t('common.delete')}
</button>
</div>
)}
@@ -1076,7 +1079,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2">
<div>
<div className="text-sm font-medium">运行状态</div>
<div className="text-sm font-medium">{t('channels.runningStatus')}</div>
<div
className={cn(
"mt-1 text-xs font-medium",
@@ -1085,14 +1088,14 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
: "text-amber-600 dark:text-amber-400",
)}
>
{isEnabled ? "已启用" : "已停用"}
{isEnabled ? t('common.enabled') : t('common.disabled')}
</div>
</div>
<Switch
checked={isEnabled}
disabled={isToggling}
onCheckedChange={(checked) => handleToggleEnabled(channel, checked)}
aria-label={`${config.name || channel.key} 渠道启用开关`}
aria-label={`${config.name || channel.key} ${t('channels.channelStatus')}`}
/>
</div>
</div>
@@ -1110,9 +1113,6 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="w-40 text-sm text-muted-foreground truncate font-mono" title={config.endpoint}>
{config.endpoint.replace(/^https?:\/\//, "").split("/")[0]}
</div>
{/* <div className="w-20 text-sm text-center flex-shrink-0">
<span className="text-muted-foreground">{} 模型</span>
</div> */}
<div className="text-xs text-center flex-shrink-0">
<span className="text-indigo-500 bg-indigo-500/10 px-3 h-6 rounded-full flex items-center justify-center">
{modelSummary}
@@ -1128,7 +1128,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
checked={isEnabled}
disabled={isToggling}
onCheckedChange={(checked) => handleToggleEnabled(channel, checked)}
aria-label={`${config.name || channel.key} 渠道启用开关`}
aria-label={`${config.name || channel.key} ${t('channels.channelStatus')}`}
/>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
@@ -1149,7 +1149,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
);
})}
{filteredData?.length === 0 && searchQuery && (
<div className="p-8 text-center text-muted-foreground">未找到匹配的渠道</div>
<div className="p-8 text-center text-muted-foreground">{t('channels.noMatchingChannels')}</div>
)}
</div>
</Card>
@@ -1164,7 +1164,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="max-w-4xl mx-auto flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">加载渠道中...</span>
<span className="text-sm text-muted-foreground">{t('channels.loadingChannel')}</span>
</div>
</div>
</div>
@@ -1177,13 +1177,13 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="mb-6">
<Button variant="ghost" size="sm" className="mb-3 -ml-2 text-muted-foreground" onClick={closeForm}>
<ArrowLeft className="h-4 w-4 mr-1" />
返回列表
{t('common.back')}
</Button>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">{editingKey ? "编辑渠道" : "添加渠道"}</h1>
<h1 className="text-2xl font-bold tracking-tight">{editingKey ? t('channels.editChannel') : t('channels.addChannel')}</h1>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === "form" ? <FileJson className="h-4 w-4 mr-1" /> : <FileText className="h-4 w-4 mr-1" />}
{editMode === "form" ? "JSON" : "表单"}
{editMode === "form" ? t('common.json') : t('common.form')}
</Button>
</div>
</div>
@@ -1191,12 +1191,12 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="space-y-6">
<Card>
<CardContent className="p-5">
<h3 className="font-medium">渠道标识</h3>
<p className="text-sm text-muted-foreground mb-4">用于内部识别的唯一标识</p>
<h3 className="font-medium">{t('channels.channelKey')}</h3>
<p className="text-sm text-muted-foreground mb-4">{t('channels.channelKeyDesc')}</p>
<Input
value={channelKey}
onChange={(e) => setChannelKey(e.target.value)}
placeholder="例如:azure-gpt4-east"
placeholder={t('channels.channelKeyPlaceholder')}
disabled={!!editingKey}
className="font-mono text-sm"
/>
@@ -1207,21 +1207,21 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<>
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">基本信息</h3>
<h3 className="font-medium mb-4">{t('channels.basicInfo')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">
渠道名称 <span className="text-destructive">*</span>
{t('channels.channelNameRequired')} <span className="text-destructive">*</span>
</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:Azure GPT-4 东部"
placeholder={t('channels.channelNamePlaceholder')}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">
渠道类型 <span className="text-destructive">*</span>
{t('channels.channelTypeRequired')} <span className="text-destructive">*</span>
</Label>
<Select
value={formData.type}
@@ -1235,9 +1235,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">渠道权重</Label>
<Label className="text-sm">{t('channels.channelWeight')}</Label>
<ButtonGroup
aria-label="渠道权重"
aria-label={t('channels.channelWeight')}
value={normalizeChannelWeight(formData.weight)}
options={channelWeightOptions}
onValueChange={(value) =>
@@ -1249,10 +1249,10 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
className="flex h-10 items-center px-2 gap-1"
buttonClassName="rounded-sm w-8 h-6 data-[state=on]:bg-amber-600 data-[state=on]:text-white"
/>
<p className="text-xs text-muted-foreground">相同模型权重优先,相同权重随机分配。</p>
<p className="text-xs text-muted-foreground">{t('channels.channelWeightHint')}</p>
</div>
<div className="">
<Label className="text-sm">渠道状态</Label>
<Label className="text-sm">{t('channels.channelStatus')}</Label>
<div className="h-10 flex items-center gap-3 mb-2 rounded-md px-3 border border-input hover:border-muted-foreground/30">
<span
className={cn(
@@ -1262,16 +1262,16 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
: "text-amber-600 dark:text-amber-400",
)}
>
{formData.enabled !== false ? "已启用" : "已停用"}
{formData.enabled !== false ? t('common.enabled') : t('common.disabled')}
</span>
<Switch
checked={formData.enabled !== false}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
aria-label="渠道状态开关"
aria-label={t('channels.channelStatus')}
/>
</div>
<p className="text-xs text-muted-foreground">
停用后不会参与请求选路,也不会出现在可用模型列表中。
{t('channels.channelStatusHint')}
</p>
</div>
</div>
@@ -1284,25 +1284,25 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div>
<h3 className="font-medium flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
连接配置
{t('channels.connectionConfig')}
</h3>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm flex items-center gap-2">
API 端点 <span className="text-destructive">*</span>
{t('channels.apiEndpoint')} <span className="text-destructive">*</span>
</Label>
<Input
value={formData.endpoint}
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
placeholder={endpointPlaceholder}
/>
<p className="text-xs text-muted-foreground">预览:{endpointPreview || formData.type}</p>
<p className="text-xs text-muted-foreground">{t('channels.apiEndpointPreview', { preview: endpointPreview || formData.type })}</p>
</div>
<div className="space-y-2">
<Label className="text-sm">
API 密钥 <span className="text-destructive">*</span>
{t('channels.apiKeys')} <span className="text-destructive">*</span>
</Label>
<Textarea
value={apiKeysInput}
@@ -1315,7 +1315,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
可配置多个密钥,每行一个,请求会随机选取一个密钥发起调用。
{t('channels.apiKeysHint')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -1326,8 +1326,8 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
onCheckedChange={(checked) => setFormData({ ...formData, auto_retry: checked })}
/>
<div>
<div className="text-sm font-medium">自动重试</div>
<p className="mt-1 text-xs text-muted-foreground">渠道遇可重试错误时,最多自动重试 3 次</p>
<div className="text-sm font-medium">{t('channels.autoRetry')}</div>
<p className="mt-1 text-xs text-muted-foreground">{t('channels.autoRetryHint')}</p>
</div>
</label>
<label className="flex items-start gap-3 p-4 rounded-lg border bg-muted/30 cursor-pointer">
@@ -1337,9 +1337,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
onCheckedChange={(checked) => setFormData({ ...formData, auto_rotate: checked })}
/>
<div>
<div className="text-sm font-medium">自动轮换</div>
<div className="text-sm font-medium">{t('channels.autoRotate')}</div>
<p className="mt-1 text-xs text-muted-foreground">
重试时随机切换同渠道其他密钥;关闭后始终重试当前密钥
{t('channels.autoRotateHint')}
</p>
</div>
</label>
@@ -1354,15 +1354,15 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div>
<h3 className="font-medium flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
模型配置
{t('channels.modelConfig')}
</h3>
<p className="text-sm text-muted-foreground">
外部使用模型名称调用,程序会自动映射到渠道模型 ID。
{t('channels.modelConfigDesc')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ButtonGroup
aria-label="模型配置编辑模式"
aria-label={t('channels.modelConfig')}
value={modelEditorMode}
options={modelEditorModeOptions}
onValueChange={setModelEditorModeWithSync}
@@ -1376,7 +1376,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
disabled={fetchModelsMutation.isPending}
>
<RefreshCw className={cn("h-4 w-4", fetchModelsMutation.isPending && "animate-spin")} />
获取模型列表
{t('channels.fetchModelList')}
</Button>
</div>
</div>
@@ -1384,9 +1384,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
{modelEditorMode === "visual" ? (
<div className="space-y-2">
<div className="hidden md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_100px] md:gap-2 text-xs font-medium text-muted-foreground">
<div>模型 ID</div>
<div>模型名称</div>
<div className="text-center">状态 / 删除</div>
<div>{t('channels.modelId')}</div>
<div>{t('channels.modelName')}</div>
<div className="text-center">{t('channels.modelStatusDelete')}</div>
</div>
{modelRows.map((row, index) => {
const canDelete = !isEmptyModelRow(row);
@@ -1399,13 +1399,13 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<Input
value={row.id}
onChange={(e) => updateModelRow(index, "id", e.target.value)}
placeholder="模型ID,例如:gpt-4.1-mini"
placeholder={t('channels.modelIdPlaceholder')}
className="text-sm"
/>
<Input
value={row.name}
onChange={(e) => updateModelRow(index, "name", e.target.value)}
placeholder="模型名称,默认等于模型ID"
placeholder={t('channels.modelNamePlaceholder')}
className="text-sm"
/>
<div className="flex h-10 items-center justify-center gap-2">
@@ -1417,7 +1417,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
updateModelRowsState(nextRows);
}}
disabled={!canDelete}
aria-label={`模型 ${row.name || row.id || index} 启用开关`}
aria-label={`${row.name || row.id || index}`}
/>
<Button
type="button"
@@ -1444,7 +1444,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
placeholder='[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini","enabled":true}]'
/>
<p className="text-xs text-muted-foreground">
JSON 结构为数组,每项包含 `id`、`name`、`enabled`。
{t('channels.modelJsonStructure')}
</p>
</div>
)}
@@ -1454,7 +1454,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
) : (
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">JSON 配置</h3>
<h3 className="font-medium mb-4">{t('channels.jsonConfig')}</h3>
<Textarea
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
@@ -1469,9 +1469,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<Dialog open={isFetchedModelsDialogOpen} onOpenChange={setIsFetchedModelsDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>选择要写入的模型</DialogTitle>
<DialogTitle>{t('channels.selectModelsTitle')}</DialogTitle>
<DialogDescription>
先从上游模型列表中批量勾选,再写入当前渠道配置。已存在且 ID 相同的模型会保留原有别名和启用状态。
{t('channels.selectModelsDesc')}
</DialogDescription>
</DialogHeader>
@@ -1482,13 +1482,13 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<Input
value={fetchedModelsSearchQuery}
onChange={(event) => setFetchedModelsSearchQuery(event.target.value)}
placeholder="搜索模型 ID 或展示名称"
placeholder={t('channels.searchModelPlaceholder')}
className="pl-9"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedFetchedModelIds.length} / {fetchedModelCandidates.length}
{t('channels.selectedCount', { selected: selectedFetchedModelIds.length, total: fetchedModelCandidates.length })}
</span>
<Button
type="button"
@@ -1498,10 +1498,10 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
setSelectedFetchedModelIds(fetchedModelCandidates.map((candidate) => candidate.id))
}
>
全选
{t('common.selectAll')}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedFetchedModelIds([])}>
清空
{t('common.clearAll')}
</Button>
</div>
</div>
@@ -1533,16 +1533,16 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
})}
</div>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">没有匹配的模型</div>
<div className="px-4 py-12 text-center text-sm text-muted-foreground">{t('channels.noMatchingModels')}</div>
)}
</div>
<div className="flex items-center justify-end gap-3">
<Button type="button" variant="outline" onClick={() => setIsFetchedModelsDialogOpen(false)}>
取消
{t('common.cancel')}
</Button>
<Button type="button" onClick={handleApplyFetchedModels}>
写入所选模型
{t('channels.applyModels')}
</Button>
</div>
</div>
@@ -1551,18 +1551,18 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
<div className="flex items-center justify-end gap-3 pt-2">
<Button variant="outline" onClick={closeForm}>
取消
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
保存中...
{t('common.saving')}
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
保存渠道
{t('channels.saveChannel')}
</>
)}
</Button>
+52 -54
View File
@@ -2,53 +2,54 @@ import { Card, CardContent } from "@/components/ui/card";
import { Globe, Gauge, BarChart3, Shield, Zap, ArrowRight, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuthStore } from "@/store/auth";
import { Link } from "react-router-dom";
const features = [
{
icon: Globe,
title: "多渠道支持",
description: "支持 OpenAI、Claude、Gemini、Azure OpenAI 等多种 AI 服务商。",
gradient: "from-blue-500/10 to-cyan-500/10",
iconBg: "from-blue-500 to-cyan-500",
delay: "0ms",
},
{
icon: Gauge,
title: "负载均衡",
description: "支持按权重多渠道路由、单渠道多密钥重试/轮换,以及跨渠道 fallback。",
gradient: "from-emerald-500/10 to-teal-500/10",
iconBg: "from-emerald-500 to-teal-500",
delay: "50ms",
},
{
icon: BarChart3,
title: "用量追踪",
description: "Analytics Engine 实时统计,支持日志回溯便于成本分析。",
gradient: "from-amber-500/10 to-orange-500/10",
iconBg: "from-amber-500 to-orange-500",
delay: "100ms",
},
{
icon: Shield,
title: "安全管理",
description: "基于安全令牌的访问控制与独立 API 密钥分配。",
gradient: "from-violet-500/10 to-purple-500/10",
iconBg: "from-violet-500 to-purple-500",
delay: "150ms",
},
];
const steps = [
{ num: "01", title: "管理员登录", desc: "使用管理员令牌登录系统", color: "text-blue-500" },
{ num: "02", title: "配置渠道", desc: "添加 API 服务商", color: "text-emerald-500" },
{ num: "03", title: "创建令牌", desc: "为应用创建 API 令牌", color: "text-amber-500" },
{ num: "04", title: "开始使用", desc: "使用令牌调用 API", color: "text-violet-500" },
];
import { useTranslation } from "react-i18next";
export function Dashboard() {
const { t } = useTranslation();
const { openAuthModal } = useAuthStore();
const features = [
{
icon: Globe,
title: t('dashboard.featureMultiChannel'),
description: t('dashboard.featureMultiChannelDesc'),
gradient: "from-blue-500/10 to-cyan-500/10",
iconBg: "from-blue-500 to-cyan-500",
delay: "0ms",
},
{
icon: Gauge,
title: t('dashboard.featureLoadBalance'),
description: t('dashboard.featureLoadBalanceDesc'),
gradient: "from-emerald-500/10 to-teal-500/10",
iconBg: "from-emerald-500 to-teal-500",
delay: "50ms",
},
{
icon: BarChart3,
title: t('dashboard.featureUsageTracking'),
description: t('dashboard.featureUsageTrackingDesc'),
gradient: "from-amber-500/10 to-orange-500/10",
iconBg: "from-amber-500 to-orange-500",
delay: "100ms",
},
{
icon: Shield,
title: t('dashboard.featureSecurity'),
description: t('dashboard.featureSecurityDesc'),
gradient: "from-violet-500/10 to-purple-500/10",
iconBg: "from-violet-500 to-purple-500",
delay: "150ms",
},
];
const steps = [
{ num: "01", title: t('dashboard.step1Title'), desc: t('dashboard.step1Desc'), color: "text-blue-500" },
{ num: "02", title: t('dashboard.step2Title'), desc: t('dashboard.step2Desc'), color: "text-emerald-500" },
{ num: "03", title: t('dashboard.step3Title'), desc: t('dashboard.step3Desc'), color: "text-amber-500" },
{ num: "04", title: t('dashboard.step4Title'), desc: t('dashboard.step4Desc'), color: "text-violet-500" },
];
return (
<div className="animate-in">
{/* Hero Section */}
@@ -57,15 +58,15 @@ export function Dashboard() {
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-primary/8 border border-primary/10 text-primary text-sm font-medium mb-6">
<Sparkles className="h-3.5 w-3.5" />
<span>Unified AI Gateway</span>
<span>{t('dashboard.tagline')}</span>
</div>
<h1 className="text-4xl md:text-5xl lg:text-[3.5rem] font-bold tracking-tight leading-[1.1] mb-5">
<span className="bg-gradient-to-r from-foreground via-foreground to-foreground/70 bg-clip-text text-transparent">
One API on Workers
{t('dashboard.heroTitle')}
</span>
</h1>
<p className="text-lg md:text-xl text-muted-foreground leading-relaxed mb-8 max-w-2xl">
基于 Cloudflare Workers<br/>分布式、低延迟、高性能的 AI 统一网关
<p className="text-lg md:text-xl text-muted-foreground leading-relaxed mb-8 max-w-2xl whitespace-pre-line">
{t('dashboard.heroDescription')}
</p>
<div className="flex flex-wrap gap-3">
<Button
@@ -73,12 +74,9 @@ export function Dashboard() {
onClick={openAuthModal}
className="h-12 px-6 text-[15px] shadow-lg shadow-primary/20"
>
管理员登录
{t('auth.adminLogin')}
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
{/* <Button variant="outline" size="lg" asChild className="h-12 px-6 text-[15px] rounded-xl">
<Link to="/api-test">体验 API</Link>
</Button> */}
</div>
</div>
</div>
@@ -120,8 +118,8 @@ export function Dashboard() {
<Card className="border-0">
<CardContent className="py-6">
<div className="pb-4">
<h2 className="text-lg font-semibold mb-1">快速开始</h2>
<p className="text-sm text-muted-foreground">四步完成配置,开始使用统一接口</p>
<h2 className="text-lg font-semibold mb-1">{t('dashboard.quickStart')}</h2>
<p className="text-sm text-muted-foreground">{t('dashboard.quickStartDesc')}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{steps.map((step) => (
@@ -147,7 +145,7 @@ export function Dashboard() {
{/* Supported Providers */}
<div className="text-center py-4">
<p className="text-xs text-muted-foreground/60 uppercase tracking-wider mb-4">支持的服务端点</p>
<p className="text-xs text-muted-foreground/60 uppercase tracking-wider mb-4">{t('dashboard.supportedEndpoints')}</p>
<div className="flex items-center justify-center gap-6 flex-wrap text-muted-foreground/40">
{["OpenAI", "OpenAI Responses", "Claude", "Gemini", "Azure OpenAI"].map((name) => (
<div
+5 -2
View File
@@ -1,8 +1,11 @@
import { Button } from '@/components/ui/button'
import { Link } from 'react-router-dom'
import { ArrowLeft, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export function NotFound() {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4 animate-in">
<div className="relative mb-8">
@@ -13,12 +16,12 @@ export function NotFound() {
</div>
<h1 className="text-6xl font-bold text-muted-foreground/20 mb-2">404</h1>
<p className="text-lg text-muted-foreground mb-8">
页面不存在
{t('notFound.title')}
</p>
<Button variant="outline" size="lg" asChild className="rounded-xl">
<Link to="/">
<ArrowLeft className="h-4 w-4 mr-2" />
返回首页
{t('notFound.backHome')}
</Link>
</Button>
</div>
+66 -83
View File
@@ -20,6 +20,8 @@ import {
import { cn } from "@/lib/utils";
import { Check, FileJson, FileText, RefreshCw, Search } from "lucide-react";
import { PageContainer } from "@/components/ui/page-container";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
type EditMode = "cards" | "json";
type PricingField = "input" | "output" | "cache";
@@ -55,45 +57,6 @@ type BuildPricingRowsOptions = {
sortConfiguredFirst?: boolean;
};
const FIELD_META: Array<{
key: PricingField;
label: string;
placeholder: string;
}> = [
{
key: "input",
label: "输入",
placeholder: "0.000000",
},
{
key: "output",
label: "输出",
placeholder: "0.000000",
},
{
key: "cache",
label: "缓存",
placeholder: "0.000000",
},
];
const BILLING_MODE_OPTIONS: Array<{
value: PricingBillingMode;
label: string;
description: string;
}> = [
{
value: "volume",
label: "按量",
description: "输入 / 输出 / 缓存按每 M tokens 计费",
},
{
value: "request",
label: "按次",
description: "成功响应后按固定金额计费",
},
];
const normalizePricingNumber = (value: unknown, fallback = 0): number => {
const parsed =
typeof value === "number" && Number.isFinite(value)
@@ -136,7 +99,6 @@ const normalizePricingEntry = (pricing?: Partial<PricingModel> | null): PricingR
const billingMode = normalizePricingBillingMode(pricing?.billingMode);
const hasVisiblePricing = input > 0 || output > 0 || cache > 0;
// 旧版仅配置了独立 request 时,直接映射成新版按次计费。
if (!billingMode && legacyRequest > 0 && !hasVisiblePricing) {
return {
model: "",
@@ -259,9 +221,11 @@ const formatEstimatedPricingCost = (
pricing: Pick<PricingRow, "billingMode" | "input" | "output" | "cache">,
): string => {
const estimatedCost = calculateEstimatedPricingCost(pricing).toFixed(PRICING_DECIMALS);
const unitLabel = pricing.billingMode === "request" ? "/ 次" : "/ 1M tokens";
const unitLabel = pricing.billingMode === "request"
? i18n.t('pricing.estimatedCostRequest', { cost: estimatedCost })
: i18n.t('pricing.estimatedCostVolume', { cost: estimatedCost });
return `$${estimatedCost} ${unitLabel}`;
return unitLabel;
};
const buildModelChannelMap = (channels: Channel[]): Map<string, ChannelRef[]> => {
@@ -394,6 +358,8 @@ function MultiSelectAutocompleteField({
options,
emptyText,
}: MultiSelectAutocompleteFieldProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -404,7 +370,7 @@ function MultiSelectAutocompleteField({
onClick={() => onChange([])}
className="text-xs text-muted-foreground hover:text-foreground"
>
清空
{t('common.clearAll')}
</button>
)}
</div>
@@ -423,6 +389,7 @@ function MultiSelectAutocompleteField({
}
export function Pricing() {
const { t } = useTranslation();
const [editMode, setEditMode] = useState<EditMode>("cards");
const [jsonValue, setJsonValue] = useState("");
const [pricingRows, setPricingRows] = useState<PricingRow[]>([]);
@@ -441,6 +408,25 @@ export function Pricing() {
const lastSavedSignatureRef = useRef(JSON.stringify({}));
const skipNextPricingSyncRef = useRef(false);
const FIELD_META: Array<{
key: PricingField;
label: string;
placeholder: string;
}> = [
{ key: "input", label: t('pricing.fieldInput'), placeholder: "0.000000" },
{ key: "output", label: t('pricing.fieldOutput'), placeholder: "0.000000" },
{ key: "cache", label: t('pricing.fieldCache'), placeholder: "0.000000" },
];
const BILLING_MODE_OPTIONS = useMemo<Array<{
value: PricingBillingMode;
label: string;
description: string;
}>>(() => [
{ value: "volume", label: t('pricing.billingVolume'), description: t('pricing.billingVolumeDesc') },
{ value: "request", label: t('pricing.billingRequest'), description: t('pricing.billingRequestDesc') },
], [t]);
const pricingQuery = useQuery({
queryKey: ["pricing"],
queryFn: async () => {
@@ -515,13 +501,13 @@ export function Pricing() {
setLastSavedAt(new Date());
if (variables.source === "manual") {
addToast("定价配置已保存", "success");
addToast(t('pricing.savedSuccess'), "success");
}
},
onError: (error: Error, variables) => {
setSaveState("error");
if (variables.source === "manual") {
addToast(`保存失败:${error.message}`, "error");
addToast(t('common.saveFailed', { message: error.message }), "error");
}
},
});
@@ -570,7 +556,7 @@ export function Pricing() {
description: option.description,
keywords: [option.value, option.label, option.description],
}));
}, []);
}, [BILLING_MODE_OPTIONS]);
const pricingCards = useMemo(() => {
return pricingRows.map((row) => {
@@ -594,7 +580,7 @@ export function Pricing() {
searchTarget,
};
});
}, [modelChannelMap, modelTokenMap, pricingRows]);
}, [modelChannelMap, modelTokenMap, pricingRows, BILLING_MODE_OPTIONS]);
useEffect(() => {
setSelectedChannels((current) =>
@@ -749,7 +735,7 @@ export function Pricing() {
});
} catch {
setSaveState("invalid");
addToast("JSON 格式错误", "error");
addToast(t('common.jsonFormatError'), "error");
}
};
@@ -765,7 +751,7 @@ export function Pricing() {
setPricingRows(buildPricingRows(modelChannelMap, parsed, { sortConfiguredFirst: true }));
setEditMode("cards");
} catch {
addToast("JSON 格式错误", "error");
addToast(t('common.jsonFormatError'), "error");
}
};
@@ -803,20 +789,20 @@ export function Pricing() {
const saveHint = (() => {
switch (saveState) {
case "saving":
return "自动保存中...";
return t('common.autoSaving');
case "error":
return "自动保存失败";
return t('common.autoSaveFailed');
case "invalid":
return "JSON 无法自动保存";
return t('common.jsonCannotAutoSave');
default:
return `保存于 ${formatSaveTime(lastSavedAt)}`;
return t('common.savedAt', { time: formatSaveTime(lastSavedAt) });
}
})();
return (
<PageContainer
title="定价管理"
description="展示和配置系统模型价格"
title={t('pricing.title')}
description={t('pricing.description')}
actions={
<div className="flex items-center gap-2">
<span className="hidden text-xs text-muted-foreground md:inline">{saveHint}</span>
@@ -825,11 +811,11 @@ export function Pricing() {
</Button>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === "cards" ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
<span className="hidden sm:inline ml-1">{editMode === "cards" ? "JSON" : "卡片"}</span>
<span className="hidden sm:inline ml-1">{editMode === "cards" ? t('common.json') : t('pricing.cards')}</span>
</Button>
<Button size="sm" onClick={handleSave} disabled={saveMutation.isPending}>
<Check className="h-4 w-4 mr-1" />
保存
{t('common.save')}
</Button>
</div>
}
@@ -838,40 +824,40 @@ export function Pricing() {
<Card className="border-0 p-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MultiSelectAutocompleteField
label="渠道筛选"
placeholder="选择渠道"
label={t('pricing.channelFilter')}
placeholder={t('pricing.channelFilterPlaceholder')}
selectedValues={selectedChannels}
onChange={setSelectedChannels}
options={channelOptions}
emptyText="没有匹配渠道"
emptyText={t('pricing.noMatchingChannels')}
/>
<MultiSelectAutocompleteField
label="收费类型"
placeholder="选择收费类型"
label={t('pricing.billingTypeFilter')}
placeholder={t('pricing.billingTypePlaceholder')}
selectedValues={selectedBillingModes}
onChange={setSelectedBillingModes}
options={billingModeOptions}
emptyText="没有匹配收费类型"
emptyText={t('pricing.noMatchingBillingType')}
/>
<MultiSelectAutocompleteField
label="令牌筛选"
placeholder="选择令牌"
label={t('pricing.tokenFilter')}
placeholder={t('pricing.tokenFilterPlaceholder')}
selectedValues={selectedTokens}
onChange={setSelectedTokens}
options={tokenOptions}
emptyText="没有匹配令牌"
emptyText={t('pricing.noMatchingTokens')}
/>
<div className="space-y-2">
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">搜索</Label>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t('common.search')}</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索模型、渠道、令牌或收费类型"
placeholder={t('pricing.searchPlaceholder')}
className="h-10 pl-9"
/>
</div>
@@ -884,24 +870,24 @@ export function Pricing() {
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">正在汇总模型、渠道与令牌信息...</span>
<span className="text-sm text-muted-foreground">{t('pricing.loadingModels')}</span>
</div>
</div>
) : pricingCards.length === 0 ? (
<Card className="border-0 py-16 text-center">
<CardContent>
<div className="text-lg font-semibold">暂无可管理模型</div>
<div className="text-lg font-semibold">{t('pricing.noModels')}</div>
<p className="mt-2 text-sm text-muted-foreground">
先在渠道管理中同步模型列表,或者切换到 JSON 模式手动补充价格配置。
{t('pricing.noModelsHint')}
</p>
</CardContent>
</Card>
) : filteredCards.length === 0 ? (
<Card className="border-0 py-16 text-center">
<CardContent>
<div className="text-lg font-semibold">没有匹配的模型卡片</div>
<div className="text-lg font-semibold">{t('pricing.noMatchingCards')}</div>
<p className="mt-2 text-sm text-muted-foreground">
当前筛选条件过严,建议放宽渠道、令牌、类型或关键词。
{t('pricing.noMatchingCardsHint')}
</p>
</CardContent>
</Card>
@@ -917,9 +903,9 @@ export function Pricing() {
<Badge className={cn("w-3 h-3 p-0", card.isCustomized ? "bg-success" : "bg-background")}></Badge>
</div>
{card.isCustomized ? (
<p className="text-xs text-muted-foreground/60">预估成本 ≈ {formatEstimatedPricingCost(card)}</p>
<p className="text-xs text-muted-foreground/60">{formatEstimatedPricingCost(card)}</p>
) : (
<p className="text-xs text-muted-foreground/60">免费</p>
<p className="text-xs text-muted-foreground/60">{t('common.free')}</p>
)}
</CardHeader>
@@ -964,8 +950,7 @@ export function Pricing() {
{card.legacyRequest > 0 && (
<p className="text-[11px] leading-5 text-muted-foreground">
兼容旧版独立按次价格 {card.legacyRequest.toFixed(PRICING_DECIMALS)}
,修改后会切换到新版计费规则。
{t('pricing.legacyRequestNote', { price: card.legacyRequest.toFixed(PRICING_DECIMALS) })}
</p>
)}
@@ -980,7 +965,7 @@ export function Pricing() {
</div>
))
) : (
<span className="text-xs text-muted-foreground">无匹配渠道</span>
<span className="text-xs text-muted-foreground">{t('pricing.noChannelsForModel')}</span>
)}
</div>
</CardContent>
@@ -991,10 +976,9 @@ export function Pricing() {
) : (
<Card className="border-0">
<CardHeader>
<CardTitle className="text-lg">JSON 配置</CardTitle>
<CardTitle className="text-lg">{t('pricing.jsonConfigTitle')}</CardTitle>
<CardDescription>
默认只会保存大于 0 的价格字段;但 `billingMode: "request"` 即使价格全为
0,也会作为有效配置保留。切回卡片模式时,会与当前渠道中的模型集合自动合并。
{t('pricing.jsonConfigDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
@@ -1006,8 +990,7 @@ export function Pricing() {
placeholder='{"gpt-4.1": {"billingMode": "volume", "input": 0.8, "output": 3.2}}'
/>
<p className="text-xs text-muted-foreground">
格式:模型名称 → {"{"} billingMode?: "volume" | "request", input?: 输入价格, output?: 输出价格, cache?:
缓存价格, request?: 旧版独立按次价格 {"}"}
{t('pricing.jsonFormatHint')}
</p>
</CardContent>
</Card>
+31 -27
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bot, Check, FileText, Globe, Shield, SlidersHorizontal } from "lucide-react";
import { Bot, Check, FileText, Shield, SlidersHorizontal } from "lucide-react";
import { apiClient } from "@/api/client";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ import {
normalizeSystemConfig,
} from "@/lib/system-config";
import type { SystemConfig } from "@/types";
import { useTranslation } from "react-i18next";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
const AUTO_SAVE_DELAY_MS = 800;
@@ -52,6 +53,7 @@ const formatSaveTime = (value: Date | null): string => {
};
export function SystemSettings() {
const { t } = useTranslation();
const [systemConfig, setSystemConfig] = useState(DEFAULT_SYSTEM_CONFIG);
const [saveState, setSaveState] = useState<SaveState>("idle");
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
@@ -115,13 +117,13 @@ export function SystemSettings() {
setLastSavedAt(new Date());
if (variables.source === "manual") {
addToast("系统设置已保存", "success");
addToast(t('settings.configSaved'), "success");
}
},
onError: (error: Error, variables) => {
setSaveState("error");
if (variables.source === "manual") {
addToast(`保存失败:${error.message}`, "error");
addToast(t('common.saveFailed', { message: error.message }), "error");
}
},
});
@@ -142,10 +144,10 @@ export function SystemSettings() {
verifiedAt: verification?.verifiedAt || null,
},
}));
addToast("测试消息已发送,请到 Telegram 查收", "success");
addToast(t('settings.testMessageSent'), "success");
},
onError: (error: Error) => {
addToast(`测试失败:${error.message}`, "error");
addToast(t('settings.testFailed', { message: error.message }), "error");
},
});
@@ -201,11 +203,11 @@ export function SystemSettings() {
const saveHint = (() => {
switch (saveState) {
case "saving":
return "自动保存中...";
return t('common.autoSaving');
case "error":
return "自动保存失败";
return t('common.autoSaveFailed');
default:
return `保存于 ${formatSaveTime(lastSavedAt)}`;
return t('common.savedAt', { time: formatSaveTime(lastSavedAt) });
}
})();
@@ -241,8 +243,8 @@ export function SystemSettings() {
return (
<PageContainer
title="系统设置"
description="统一管理全局系统设置"
title={t('settings.title')}
description={t('settings.description')}
actions={
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{saveHint}</span>
@@ -252,7 +254,7 @@ export function SystemSettings() {
disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading}
>
<Check className="mr-1 h-4 w-4" />
保存
{t('common.save')}
</Button>
</div>
}
@@ -266,15 +268,15 @@ export function SystemSettings() {
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-bold tracking-tight">Telegram 安全设置</h3>
<h3 className="font-bold tracking-tight">{t('settings.telegramSecurity')}</h3>
{!normalizedSystemConfig.adminSecurity.enabled && telegramConfigComplete ? (
<p className="text-sm text-amber-600 dark:text-amber-300">
发送测试消息,验证通过后即可开启 Telegram 安全设置。
{t('settings.telegramNeedTest')}
</p>
) : telegramSecurityVerified ? (
<p className="text-sm text-green-600 dark:text-green-300">已开启 Telegram 登录验证与登录通知。</p>
<p className="text-sm text-green-600 dark:text-green-300">{t('settings.telegramVerified')}</p>
) : (
<p className="text-sm text-muted-foreground">绑定 Telegram,开启登录验证与登录通知。</p>
<p className="text-sm text-muted-foreground">{t('settings.telegramNotVerified')}</p>
)}
</div>
<Switch
@@ -327,14 +329,14 @@ export function SystemSettings() {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<Bot className="h-4 w-4" />
发送测试消息
{t('settings.sendTestMessage')}
</div>
<p className="text-xs text-muted-foreground/60">
{!telegramConfigComplete
? "请先填写完整的 Bot Token 和 Chat ID。"
? t('settings.fillBotTokenFirst')
: telegramSecurityVerified
? `最近一次验证通过时间:${normalizedSystemConfig.adminSecurity.verifiedAt || "--"}`
: "测试通过后,安全开关才会解锁。"}
? t('settings.lastVerified', { time: normalizedSystemConfig.adminSecurity.verifiedAt || "--" })
: t('settings.testUnlockHint')}
</p>
</div>
@@ -344,7 +346,7 @@ export function SystemSettings() {
onClick={() => telegramTestMutation.mutate()}
disabled={telegramTestMutation.isPending || !telegramConfigComplete}
>
{telegramTestMutation.isPending ? "发送中..." : "发送测试消息"}
{telegramTestMutation.isPending ? t('settings.sendTestMessageSending') : t('settings.sendTestMessage')}
</Button>
</div>
</div>
@@ -358,16 +360,18 @@ export function SystemSettings() {
<SlidersHorizontal className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-bold tracking-tight">显示精度设置</h3>
<h3 className="font-bold tracking-tight">{t('settings.displayPrecision')}</h3>
<p className="text-sm text-muted-foreground">
页面显示金额保留 {normalizedSystemConfig.displayDecimals} 位小数,示例:$
{(0.123456789).toFixed(normalizedSystemConfig.displayDecimals)}
{t('settings.displayPrecisionDesc', {
decimals: normalizedSystemConfig.displayDecimals,
example: (0.123456789).toFixed(normalizedSystemConfig.displayDecimals),
})}
</p>
</div>
</div>
<div className="flex-1" />
<ButtonGroup
aria-label="显示金额精度"
aria-label={t('settings.displayPrecision')}
value={normalizedSystemConfig.displayDecimals}
options={PRECISION_OPTIONS}
onValueChange={(value) =>
@@ -389,9 +393,9 @@ export function SystemSettings() {
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-bold tracking-tight">开启接口文档</h3>
<h3 className="font-bold tracking-tight">{t('settings.apiDocs')}</h3>
<p className="text-sm text-muted-foreground">
开启后自动暴露 Swagger、ReDoc 与 OpenAPI JSON;关闭后对应路由会直接返回 404。
{t('settings.apiDocsDesc')}
</p>
</div>
<Switch
@@ -423,7 +427,7 @@ export function SystemSettings() {
</div>
<Button asChild variant="outline" className="h-8 px-3">
<a href={item.href} target="_blank" rel="noreferrer">
打开
{t('common.open')}
</a>
</Button>
</div>
+72 -70
View File
@@ -32,6 +32,7 @@ import {
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { PageContainer } from "@/components/ui/page-container";
import { useTranslation } from "react-i18next";
type EditMode = "form" | "json";
const UNLIMITED_TOKEN_QUOTA = -1;
@@ -80,17 +81,9 @@ const isUnlimitedTokenQuota = (value: number): boolean => {
return value === UNLIMITED_TOKEN_QUOTA;
};
const tokenQuotaOptions = [
{ label: "不限制", value: UNLIMITED_TOKEN_QUOTA },
{ label: "$10", value: usdToRawBilling(10) },
{ label: "$20", value: usdToRawBilling(20) },
{ label: "$50", value: usdToRawBilling(50) },
{ label: "$100", value: usdToRawBilling(100) },
] as const;
const formatAvailableQuota = (value: number): string => {
const formatAvailableQuota = (value: number, unlimitedLabel: string): string => {
if (isUnlimitedTokenQuota(value)) {
return "无限";
return unlimitedLabel;
}
const usdValue = Math.max(0, Math.floor(rawBillingToUsd(value)));
@@ -98,6 +91,7 @@ const formatAvailableQuota = (value: number): string => {
};
export function Tokens({ createMode = false, editRoute = false }: { createMode?: boolean; editRoute?: boolean }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { key: routeKey } = useParams<{ key: string }>();
const isRouteEdit = editRoute && Boolean(routeKey);
@@ -122,6 +116,14 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
const { data: billingConfig } = useBillingConfig();
const displayDecimals = billingConfig?.displayDecimals ?? DEFAULT_BILLING_DISPLAY_DECIMALS;
const tokenQuotaOptions = [
{ label: t('tokens.quotaUnlimited'), value: UNLIMITED_TOKEN_QUOTA },
{ label: "$10", value: usdToRawBilling(10) },
{ label: "$20", value: usdToRawBilling(20) },
{ label: "$50", value: usdToRawBilling(50) },
{ label: "$100", value: usdToRawBilling(100) },
] as const;
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["tokens"],
queryFn: async () => {
@@ -172,11 +174,11 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tokens"] });
addToast(editingKey ? "令牌更新成功" : "令牌添加成功", "success");
addToast(editingKey ? t('tokens.updateSuccess') : t('tokens.addSuccess'), "success");
closeForm();
},
onError: (error: any) => {
addToast("保存失败:" + error.message, "error");
addToast(t('common.saveFailed', { message: error.message }), "error");
},
});
@@ -186,10 +188,10 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tokens"] });
addToast("令牌已删除", "success");
addToast(t('tokens.deleteSuccess'), "success");
},
onError: (error: any) => {
addToast("删除失败:" + error.message, "error");
addToast(t('common.deleteFailed', { message: error.message }), "error");
},
});
@@ -199,10 +201,10 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tokens"] });
addToast("已用额度已重置", "success");
addToast(t('tokens.resetSuccess'), "success");
},
onError: (error: any) => {
addToast("重置失败:" + error.message, "error");
addToast(t('tokens.resetFailed', { message: error.message }), "error");
},
});
@@ -234,7 +236,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
if (!targetToken) {
resetForm();
setView("list");
addToast("未找到对应令牌", "error");
addToast(t('tokens.notFound'), "error");
navigate("/tokens", { replace: true });
return;
}
@@ -245,7 +247,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
resetForm();
setView("list");
}, [addToast, createMode, data, isLoading, isRouteEdit, navigate, openTokenForEdit, resetForm, routeKey]);
}, [addToast, createMode, data, isLoading, isRouteEdit, navigate, openTokenForEdit, resetForm, routeKey, t]);
const closeForm = () => {
resetForm();
@@ -266,13 +268,13 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
};
const handleDelete = (key: string) => {
if (confirm(`确定要删除此令牌吗?`)) {
if (confirm(t('tokens.deleteConfirm'))) {
deleteMutation.mutate(key);
}
};
const handleResetUsage = (key: string) => {
if (confirm(`确定要重置此令牌的已用额度吗?`)) {
if (confirm(t('tokens.resetConfirm'))) {
resetUsageMutation.mutate(key);
}
};
@@ -280,22 +282,22 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
const handleCopy = async (text: string) => {
try {
await copyToClipboard(text);
addToast("已复制到剪贴板", "success");
addToast(t('common.copiedToClipboard'), "success");
} catch {
addToast("复制失败", "error");
addToast(t('common.copyFailed'), "error");
}
};
const handleSave = () => {
if (!tokenKey) {
addToast("请填写令牌标识", "error");
addToast(t('tokens.fillTokenKey'), "error");
return;
}
let config: any;
if (editMode === "form") {
if (!formData.name) {
addToast("请填写令牌名称", "error");
addToast(t('tokens.fillTokenName'), "error");
return;
}
config = {
@@ -308,7 +310,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
config = JSON.parse(jsonValue);
config.total_quota = normalizeTokenQuota(config.total_quota);
} catch {
addToast("JSON格式错误", "error");
addToast(t('common.jsonFormatError'), "error");
return;
}
}
@@ -337,7 +339,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
setQuotaInputValue(formatQuotaInputValue(normalizedConfig.total_quota));
setEditMode("form");
} catch {
addToast("JSON格式错误", "error");
addToast(t('common.jsonFormatError'), "error");
}
}
};
@@ -361,8 +363,8 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
if (view === "list") {
return (
<PageContainer
title="令牌管理"
description="管理 API 访问令牌和配额"
title={t('tokens.title')}
description={t('tokens.description')}
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
@@ -370,7 +372,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
</Button>
<Button size="sm" onClick={handleAdd}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-1">添加</span>
<span className="hidden sm:inline ml-1">{t('common.add')}</span>
</Button>
</div>
}
@@ -381,7 +383,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索令牌..."
placeholder={t('tokens.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -394,7 +396,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">加载中...</span>
<span className="text-sm text-muted-foreground">{t('common.loading')}</span>
</div>
</div>
) : !data || data.length === 0 ? (
@@ -403,13 +405,13 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<Key className="h-7 w-7 text-primary" />
</div>
<h3 className="font-semibold text-lg mb-2">创建您的第一个令牌</h3>
<h3 className="font-semibold text-lg mb-2">{t('tokens.emptyTitle')}</h3>
<p className="text-muted-foreground text-sm text-center max-w-sm mb-6">
令牌用于验证 API 请求。每个令牌可以设置独立的访问权限和使用配额。
{t('tokens.emptyDescription')}
</p>
<Button onClick={handleAdd} size="lg">
<Plus className="h-4 w-4 mr-2" />
添加令牌
{t('tokens.addToken')}
</Button>
</CardContent>
</Card>
@@ -467,21 +469,21 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
onClick={() => handleEdit(token)}
>
<Pencil className="h-4 w-4" />
编辑
{t('common.edit')}
</button>
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2"
onClick={() => handleResetUsage(token.key)}
>
<RotateCcw className="h-4 w-4" />
重置额度
{t('tokens.resetQuota')}
</button>
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2 text-destructive"
onClick={() => handleDelete(token.key)}
>
<Trash2 className="h-4 w-4" />
删除
{t('common.delete')}
</button>
</div>
)}
@@ -489,15 +491,15 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">
渠道:{" "}
{t('tokens.channels')}:{" "}
<span className="text-foreground">
{channelKeys.length === 0 ? "全部" : `${channelKeys.length}个`}
{channelKeys.length === 0 ? t('tokens.channelsAll') : t('tokens.channelsCount', { count: channelKeys.length })}
</span>
</span>
<span className="text-muted-foreground">
已用/可用:{" "}
{t('tokens.usedAvailable')}:{" "}
<span className="text-foreground">
{formatCurrency(usedQuota, displayDecimals)}/{formatAvailableQuota(availableQuota)}
{formatCurrency(usedQuota, displayDecimals)}/{formatAvailableQuota(availableQuota, t('common.unlimited'))}
</span>
</span>
</div>
@@ -527,13 +529,13 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
</div>
<div className="w-24 text-sm text-center flex-shrink-0">
<span className="px-2 py-1 rounded-md bg-muted text-muted-foreground">
{channelKeys.length === 0 ? "全部" : `${channelKeys.length} 渠道`}
{channelKeys.length === 0 ? t('tokens.channelsAll') : t('tokens.channelsCountWithUnit', { count: channelKeys.length })}
</span>
</div>
<div className="w-48 flex-shrink-0">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">
{formatCurrency(usedQuota, displayDecimals)} / {formatAvailableQuota(availableQuota)}
{formatCurrency(usedQuota, displayDecimals)} / {formatAvailableQuota(availableQuota, t('common.unlimited'))}
</span>
<span
className={cn(
@@ -564,7 +566,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(token)}
title="编辑"
title={t('common.edit')}
>
<Pencil className="h-4 w-4" />
</Button>
@@ -573,7 +575,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
size="icon"
className="h-8 w-8"
onClick={() => handleResetUsage(token.key)}
title="重置额度"
title={t('tokens.resetQuota')}
>
<RotateCcw className="h-4 w-4" />
</Button>
@@ -582,7 +584,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDelete(token.key)}
title="删除"
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -592,7 +594,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
);
})}
{filteredData?.length === 0 && searchQuery && (
<div className="p-8 text-center text-muted-foreground">未找到匹配的令牌</div>
<div className="p-8 text-center text-muted-foreground">{t('tokens.noMatchingTokens')}</div>
)}
</div>
</Card>
@@ -607,7 +609,7 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
<div className="max-w-2xl mx-auto flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">加载令牌中...</span>
<span className="text-sm text-muted-foreground">{t('tokens.loadingToken')}</span>
</div>
</div>
</div>
@@ -622,13 +624,13 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
<div className="mb-6">
<Button variant="ghost" size="sm" className="mb-3 -ml-2 text-muted-foreground" onClick={closeForm}>
<ArrowLeft className="h-4 w-4 mr-1" />
返回列表
{t('common.back')}
</Button>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">{editingKey ? "编辑令牌" : "添加令牌"}</h1>
<h1 className="text-2xl font-bold tracking-tight">{editingKey ? t('tokens.editToken') : t('tokens.addToken')}</h1>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === "form" ? <FileJson className="h-4 w-4 mr-1" /> : <FileText className="h-4 w-4 mr-1" />}
{editMode === "form" ? "JSON" : "表单"}
{editMode === "form" ? t('common.json') : t('common.form')}
</Button>
</div>
</div>
@@ -637,8 +639,8 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
{/* Token Key Section */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-1">令牌标识</h3>
<p className="text-sm text-muted-foreground mb-3">用于 API 认证的唯一标识</p>
<h3 className="font-medium mb-1">{t('tokens.tokenKey')}</h3>
<p className="text-sm text-muted-foreground mb-3">{t('tokens.tokenKeyDesc')}</p>
<div className="flex gap-2">
<Input
value={tokenKey}
@@ -661,16 +663,16 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
{/* Basic Info */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">基本信息</h3>
<h3 className="font-medium mb-4">{t('tokens.basicInfo')}</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">
令牌名称 <span className="text-destructive">*</span>
{t('tokens.tokenNameRequired')} <span className="text-destructive">*</span>
</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:生产环境、测试用户"
placeholder={t('tokens.tokenNamePlaceholder')}
/>
</div>
</div>
@@ -682,11 +684,11 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium">渠道访问权限</h3>
<h3 className="font-medium">{t('tokens.channelAccess')}</h3>
<p className="text-sm text-muted-foreground">
{selectedChannels.length === 0
? "允许访问所有渠道"
: `已选择 ${selectedChannels.length} 个渠道`}
? t('tokens.channelAccessAll')
: t('tokens.channelAccessSelected', { count: selectedChannels.length })}
</p>
</div>
</div>
@@ -694,8 +696,8 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
<div className="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<AlertCircle className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">暂无可用渠道</p>
<p className="text-xs text-muted-foreground">请先在渠道管理中添加渠道</p>
<p className="text-sm font-medium">{t('tokens.noChannels')}</p>
<p className="text-xs text-muted-foreground">{t('tokens.noChannelsHint')}</p>
</div>
</div>
) : (
@@ -725,8 +727,8 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
{/* Quota */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-1">可用额度</h3>
<p className="text-sm text-muted-foreground mb-4">额度耗尽后将不可调用付费模型。</p>
<h3 className="font-medium mb-1">{t('tokens.quota')}</h3>
<p className="text-sm text-muted-foreground mb-4">{t('tokens.quotaHint')}</p>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
<Input
@@ -755,10 +757,10 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
onBlur={() => {
setQuotaInputValue(formatQuotaInputValue(normalizeTokenQuota(formData.total_quota)));
}}
placeholder="输入美元额度,0 表示无额度,-1 表示无限"
placeholder={t('tokens.quotaPlaceholder')}
/>
<ButtonGroup
aria-label="令牌可用额度"
aria-label={t('tokens.quota')}
value={normalizeTokenQuota(formData.total_quota)}
options={tokenQuotaOptions}
onValueChange={(value) => {
@@ -776,13 +778,13 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
) : (
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">JSON 配置</h3>
<h3 className="font-medium mb-4">{t('channels.jsonConfig')}</h3>
<Textarea
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
rows={14}
className="font-mono text-sm"
placeholder='{"name": "令牌名称", "channel_keys": [], "total_quota": -1}'
placeholder='{"name": "Token Name", "channel_keys": [], "total_quota": -1}'
/>
</CardContent>
</Card>
@@ -791,18 +793,18 @@ export function Tokens({ createMode = false, editRoute = false }: { createMode?:
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button variant="outline" onClick={closeForm}>
取消
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
保存中...
{t('common.saving')}
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
保存令牌
{t('tokens.saveToken')}
</>
)}
</Button>
+107 -101
View File
@@ -20,6 +20,8 @@ import { useBillingConfig } from "@/hooks/use-billing-config";
import { readScopedCache, writeScopedCache } from "@/lib/local-cache";
import { cn, formatCompactNumber, formatCurrency, parseUtcTimestamp } from "@/lib/utils";
import { Eye, RefreshCw, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getLocaleString } from "@/i18n";
type UsageLogFilterState = {
range: AnalyticsRange;
@@ -35,38 +37,7 @@ type UsageLogPageCacheSnapshot = {
data: UsageLogSearchData;
};
const FILTER_DIMENSIONS: Array<{ value: UsageLogFilterDimension; label: string }> = [
{ value: "token", label: "令牌名称" },
{ value: "channel", label: "渠道标识" },
{ value: "model", label: "请求模型" },
{ value: "provider", label: "服务商类型" },
{ value: "route", label: "接口路由" },
{ value: "requestId", label: "Request ID" },
{ value: "traceId", label: "Trace ID" },
{ value: "clientIp", label: "请求 IP" },
{ value: "userAgent", label: "User-Agent" },
{ value: "country", label: "国家地区" },
{ value: "region", label: "省州 / Region" },
{ value: "city", label: "城市 / City" },
{ value: "colo", label: "边缘节点 / Colo" },
{ value: "timezone", label: "时区 / Timezone" },
{ value: "result", label: "结果" },
{ value: "errorCode", label: "错误码" },
{ value: "errorSummary", label: "错误摘要" },
];
const RESULT_OPTIONS: Array<{ value: "all" | "success" | "failure"; label: string }> = [
{ value: "all", label: "全部状态" },
{ value: "success", label: "仅成功" },
{ value: "failure", label: "仅失败" },
];
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 RANGE_VALUES: AnalyticsRange[] = ["24h", "7d", "30d", "90d"];
const PAGINATION_WINDOW_SIZE = 5;
const USAGE_LOGS_PAGE_CACHE_KEY = "usage-logs:page:v2";
@@ -78,7 +49,7 @@ const USAGE_LOG_RANGE_DURATION_MS: Record<AnalyticsRange, number> = {
};
const isAnalyticsRange = (value: unknown): value is AnalyticsRange => {
return RANGE_OPTIONS.some((option) => option.value === value);
return RANGE_VALUES.some((v) => v === value);
};
const createFilterPreset = (range: AnalyticsRange): UsageLogFilterState => {
@@ -108,7 +79,7 @@ const formatDateTime = (value: string): string => {
return value;
}
return date.toLocaleString("zh-CN", {
return date.toLocaleString(getLocaleString(), {
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -156,13 +127,14 @@ const isSameUsageLogFilterState = (left: UsageLogFilterState, right: UsageLogFil
};
const ClientSummary = ({ item }: { item: AnalyticsEventItem }) => {
const { t } = useTranslation();
const locationParts = [item.country, item.region, item.city].filter(Boolean);
return (
<div className="min-w-[190px]">
<div className="font-medium">{item.clientIp || "--"}</div>
<div className="mt-1 text-xs text-muted-foreground">
{locationParts.length > 0 ? locationParts.join(" / ") : "未知位置"}
{locationParts.length > 0 ? locationParts.join(" / ") : t('usageLogs.unknownLocation')}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.colo || "--"} {item.timezone ? `· ${item.timezone}` : ""}
@@ -233,6 +205,7 @@ const DetailRow = ({
};
export function UsageLogs() {
const { t } = useTranslation();
const { data: billingConfig } = useBillingConfig();
const displayDecimals = billingConfig?.displayDecimals ?? 6;
const defaultFilters = useMemo(() => createFilterPreset("24h"), []);
@@ -256,6 +229,39 @@ export function UsageLogs() {
? cachedLogsSnapshot.data.data
: undefined;
const FILTER_DIMENSIONS: Array<{ value: UsageLogFilterDimension; label: string }> = [
{ value: "token", label: t('usageLogs.dimToken') },
{ value: "channel", label: t('usageLogs.dimChannel') },
{ value: "model", label: t('usageLogs.dimModel') },
{ value: "provider", label: t('usageLogs.dimProvider') },
{ value: "route", label: t('usageLogs.dimRoute') },
{ value: "requestId", label: t('usageLogs.dimRequestId') },
{ value: "traceId", label: t('usageLogs.dimTraceId') },
{ value: "clientIp", label: t('usageLogs.dimClientIp') },
{ value: "userAgent", label: t('usageLogs.dimUserAgent') },
{ value: "country", label: t('usageLogs.dimCountry') },
{ value: "region", label: t('usageLogs.dimRegion') },
{ value: "city", label: t('usageLogs.dimCity') },
{ value: "colo", label: t('usageLogs.dimColo') },
{ value: "timezone", label: t('usageLogs.dimTimezone') },
{ value: "result", label: t('usageLogs.dimResult') },
{ value: "errorCode", label: t('usageLogs.dimErrorCode') },
{ value: "errorSummary", label: t('usageLogs.dimErrorSummary') },
];
const RESULT_OPTIONS: Array<{ value: "all" | "success" | "failure"; label: string }> = [
{ value: "all", label: t('usageLogs.resultAll') },
{ value: "success", label: t('usageLogs.resultSuccess') },
{ value: "failure", label: t('usageLogs.resultFailure') },
];
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 logsQuery = useQuery({
queryKey: ["usage-logs", appliedFilters, currentPage],
queryFn: async () => {
@@ -286,7 +292,7 @@ export function UsageLogs() {
const halfWindow = Math.floor(PAGINATION_WINDOW_SIZE / 2);
let start = Math.max(1, activePage - halfWindow);
let end = Math.min(totalPages, start + PAGINATION_WINDOW_SIZE - 1);
const end = Math.min(totalPages, start + PAGINATION_WINDOW_SIZE - 1);
start = Math.max(1, end - PAGINATION_WINDOW_SIZE + 1);
@@ -312,8 +318,8 @@ export function UsageLogs() {
return (
<PageContainer
title="使用日志"
description="按最近时间范围与维度筛选可读取的使用记录,重点用于排查异常设备、失败请求和上游问题。"
title={t('usageLogs.title')}
description={t('usageLogs.description')}
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => logsQuery.refetch()} disabled={logsQuery.isFetching}>
@@ -332,7 +338,7 @@ export function UsageLogs() {
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-5">
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">时间范围</label>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">{t('usageLogs.timeRange')}</label>
<Select
value={draftFilters.range}
onChange={(event) =>
@@ -350,7 +356,7 @@ export function UsageLogs() {
</Select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">查询维度</label>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">{t('usageLogs.queryDimension')}</label>
<Select
value={draftFilters.dimension}
onChange={(event) =>
@@ -368,7 +374,7 @@ export function UsageLogs() {
</Select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">查询状态</label>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">{t('usageLogs.queryStatus')}</label>
<Select
value={draftFilters.result}
onChange={(event) =>
@@ -386,11 +392,11 @@ export function UsageLogs() {
</Select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">关键字</label>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">{t('usageLogs.keyword')}</label>
<Input
value={draftFilters.keyword}
onChange={(event) => setDraftFilters((current) => ({ ...current, keyword: event.target.value }))}
placeholder="支持 ILIKE 模糊匹配"
placeholder={t('usageLogs.keywordPlaceholder')}
/>
</div>
@@ -398,7 +404,7 @@ export function UsageLogs() {
<div className="flex-1"></div>
<Button onClick={handleSearch}>
<Search className="h-4 w-4" />
查询
{t('usageLogs.query')}
</Button>
</div>
</div>
@@ -407,19 +413,19 @@ export function UsageLogs() {
<Card className="p-6 pt-4">
{logsQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">日志加载中...</div>
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">{t('usageLogs.logsLoading')}</div>
) : logsQuery.data?.items.length ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-2 py-3 font-medium">时间</th>
<th className="px-2 py-3 font-medium">模型 / 令牌</th>
<th className="px-2 py-3 font-medium">端点 / 渠道</th>
<th className="px-2 py-3 font-medium">结果 / 状态</th>
<th className="px-2 py-3 font-medium">方式 / 用时</th>
<th className="px-2 py-3 font-medium">资源消耗</th>
<th className="px-2 py-3 font-medium text-right">详情</th>
<th className="px-2 py-3 font-medium">{t('usageLogs.colTime')}</th>
<th className="px-2 py-3 font-medium">{t('usageLogs.colModelToken')}</th>
<th className="px-2 py-3 font-medium">{t('usageLogs.colEndpointChannel')}</th>
<th className="px-2 py-3 font-medium">{t('usageLogs.colResultStatus')}</th>
<th className="px-2 py-3 font-medium">{t('usageLogs.colModeLatency')}</th>
<th className="px-2 py-3 font-medium">{t('usageLogs.colResourceUsage')}</th>
<th className="px-2 py-3 font-medium text-right">{t('usageLogs.colDetail')}</th>
</tr>
</thead>
<tbody>
@@ -435,7 +441,7 @@ export function UsageLogs() {
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">{item.requestedModel || "--"}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.tokenName || "未命名令牌"}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.tokenName || t('usageLogs.unknownToken')}</div>
</td>
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">{item.providerType || "--"}</div>
@@ -451,17 +457,17 @@ export function UsageLogs() {
: "text-rose-600 dark:text-rose-400",
)}
>
{item.result === "success" ? "成功" : "失败"}
{item.result === "success" ? t('usageLogs.success') : t('usageLogs.failure')}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.upstreamStatus || "--"} · 重试 {item.retryCount}
{item.upstreamStatus || "--"} · {t('usageLogs.retry', { count: item.retryCount })}
</div>
</td>
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">
{item.streamMode === "stream" ? "流式" : "非流式"}
{item.streamMode === "stream" ? t('usageLogs.stream') : t('usageLogs.nonStream')}
</div>
<div className="mt-1 text-xs text-muted-foreground">{formatDuration(item.latencyMs)}</div>
</td>
@@ -483,7 +489,7 @@ export function UsageLogs() {
size="icon"
className="h-8 w-8 text-gray-600 border-0 rounded-0 shadow-none"
onClick={() => setSelectedItem(item)}
aria-label="查看详情"
aria-label={t('usageLogs.colDetail')}
>
<Eye className="h-4 w-4" />
</Button>
@@ -494,7 +500,7 @@ export function UsageLogs() {
</table>
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">
{totalItems > 0 ? `显示第 ${currentStart}-${currentEnd} 条,共 ${totalItems} 条` : "暂无记录"}
{totalItems > 0 ? t('usageLogs.showingRange', { start: currentStart, end: currentEnd, total: totalItems }) : t('usageLogs.noRecords')}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -503,7 +509,7 @@ export function UsageLogs() {
onClick={() => handlePageChange(activePage - 1)}
disabled={!logsQuery.data?.hasPrevPage || logsQuery.isFetching}
>
上一页
{t('common.previousPage')}
</Button>
{visiblePages.map((page) => (
<Button
@@ -522,15 +528,15 @@ export function UsageLogs() {
onClick={() => handlePageChange(activePage + 1)}
disabled={!logsQuery.data?.hasNextPage || logsQuery.isFetching}
>
下一页
{t('common.nextPage')}
</Button>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-dashed bg-muted/20 px-4 py-14 text-center">
<p className="text-sm font-medium">没有匹配的日志记录</p>
<p className="mt-1 text-sm text-muted-foreground">调整时间范围、维度或关键字后重试。</p>
<p className="text-sm font-medium">{t('usageLogs.noMatchingLogs')}</p>
<p className="mt-1 text-sm text-muted-foreground">{t('usageLogs.noMatchingLogsHint')}</p>
</div>
)}
</Card>
@@ -539,7 +545,7 @@ export function UsageLogs() {
<Dialog open={Boolean(selectedItem)} onOpenChange={(open) => !open && setSelectedItem(null)}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>日志详情</DialogTitle>
<DialogTitle>{t('usageLogs.detailTitle')}</DialogTitle>
</DialogHeader>
{selectedItem && (
@@ -552,13 +558,13 @@ export function UsageLogs() {
variant={selectedItem.result === "success" ? "success" : "destructive"}
className="rounded-full px-3 py-1 text-xs uppercase tracking-[0.18em]"
>
{selectedItem.result === "success" ? "成功" : "失败"}
{selectedItem.result === "success" ? t('usageLogs.success') : t('usageLogs.failure')}
</Badge>
<Badge
variant="outline"
className="rounded-full border-white/10 backdrop-blur-xl bg-white/10 px-3 py-1 text-xs text-slate-100"
>
{selectedItem.streamMode === "stream" ? "流式" : "非流式"}
{selectedItem.streamMode === "stream" ? t('usageLogs.stream') : t('usageLogs.nonStream')}
</Badge>
<Badge
variant="outline"
@@ -576,17 +582,17 @@ export function UsageLogs() {
<div className="flex-1"></div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs/5 text-slate-300/80 uppercase">
<div className="border-r border-slate-50/10 pr-4">
令牌
{t('usageLogs.detailToken')}
<br />
<span className="text-slate-100">{formatDetailValue(selectedItem.tokenName)}</span>
</div>
<div className="border-r border-slate-50/10 pr-4">
端点
{t('usageLogs.detailEndpoint')}
<br />
<span className="text-slate-100">{formatDetailValue(selectedItem.providerType)}</span>
</div>
<div>
渠道
{t('usageLogs.detailChannel')}
<br />
<span className="text-slate-100">{formatDetailValue(selectedItem.channelKey)}</span>
</div>
@@ -596,63 +602,63 @@ export function UsageLogs() {
</div>
<div className="min-w-0 rounded-lg bg-white/[0.04] px-4 py-2 xl:w-[340px] backdrop-blur-xl">
<DetailMetric label="总成本" value={formatCurrency(selectedItem.totalCost, displayDecimals)} />
<DetailMetric label="延迟" value={formatDuration(selectedItem.latencyMs)} />
<DetailMetric label="总 Tokens" value={formatCompactNumber(selectedItem.totalTokens)} />
<DetailMetric label="发生时间" value={formatDateTime(selectedItem.timestamp)} />
<DetailMetric label={t('usageLogs.labelTotalCost')} value={formatCurrency(selectedItem.totalCost, displayDecimals)} />
<DetailMetric label={t('usageLogs.labelLatency')} value={formatDuration(selectedItem.latencyMs)} />
<DetailMetric label={t('usageLogs.labelTotalTokens')} value={formatCompactNumber(selectedItem.totalTokens)} />
<DetailMetric label={t('usageLogs.labelTime')} value={formatDateTime(selectedItem.timestamp)} />
</div>
</div>
</section>
<DetailSection title="请求概览">
<DetailSection title={t('usageLogs.sectionOverview')}>
<div className="grid gap-x-8 border-t border-border/60 pt-2 md:grid-cols-2">
<div className="divide-y divide-border/60">
<DetailRow label="请求模型" value={selectedItem.requestedModel} />
<DetailRow label="上游模型" value={selectedItem.upstreamModel} />
<DetailRow label="令牌名称" value={selectedItem.tokenName} />
<DetailRow label="渠道标识" value={selectedItem.channelKey} />
<DetailRow label="服务商" value={selectedItem.providerType} />
<DetailRow label={t('usageLogs.labelRequestModel')} value={selectedItem.requestedModel} />
<DetailRow label={t('usageLogs.labelUpstreamModel')} value={selectedItem.upstreamModel} />
<DetailRow label={t('usageLogs.labelTokenName')} value={selectedItem.tokenName} />
<DetailRow label={t('usageLogs.labelChannelKey')} value={selectedItem.channelKey} />
<DetailRow label={t('usageLogs.labelProvider')} value={selectedItem.providerType} />
</div>
<div className="divide-y divide-border/60">
<DetailRow label="接口路由" value={selectedItem.routeId} />
<DetailRow label={t('usageLogs.labelRouteId')} value={selectedItem.routeId} />
<DetailRow
label="HTTP 状态"
label={t('usageLogs.labelHttpStatus')}
value={selectedItem.upstreamStatus}
tone={selectedItem.result === "success" ? "success" : "danger"}
/>
<DetailRow label="流式模式" value={selectedItem.streamMode === "stream" ? "流式" : "非流式"} />
<DetailRow label="重试次数" value={selectedItem.retryCount} />
<DetailRow label="状态族" value={selectedItem.statusFamily} tone="subtle" />
<DetailRow label={t('usageLogs.labelStreamMode')} value={selectedItem.streamMode === "stream" ? t('usageLogs.stream') : t('usageLogs.nonStream')} />
<DetailRow label={t('usageLogs.labelRetryCount')} value={selectedItem.retryCount} />
<DetailRow label={t('usageLogs.labelStatusFamily')} value={selectedItem.statusFamily} tone="subtle" />
</div>
</div>
</DetailSection>
<DetailSection title="计费与性能">
<DetailSection title={t('usageLogs.sectionBilling')}>
<div className="grid gap-x-8 border-t border-border/60 pt-2 md:grid-cols-2">
<div className="divide-y divide-border/60">
<DetailRow label="总成本" value={formatCurrency(selectedItem.totalCost, displayDecimals)} />
<DetailRow label="缓存成本" value={formatCurrency(selectedItem.cacheCost, displayDecimals)} />
<DetailRow label="延迟" value={formatDuration(selectedItem.latencyMs)} />
<DetailRow label="总 Tokens" value={formatCompactNumber(selectedItem.totalTokens)} />
<DetailRow label={t('usageLogs.labelTotalCost')} value={formatCurrency(selectedItem.totalCost, displayDecimals)} />
<DetailRow label={t('usageLogs.labelCacheCost')} value={formatCurrency(selectedItem.cacheCost, displayDecimals)} />
<DetailRow label={t('usageLogs.labelLatency')} value={formatDuration(selectedItem.latencyMs)} />
<DetailRow label={t('usageLogs.labelTotalTokens')} value={formatCompactNumber(selectedItem.totalTokens)} />
</div>
<div className="divide-y divide-border/60">
<DetailRow label="输入 Tokens" value={formatCompactNumber(selectedItem.promptTokens)} />
<DetailRow label="输出 Tokens" value={formatCompactNumber(selectedItem.completionTokens)} />
<DetailRow label="缓存 Tokens" value={formatCompactNumber(selectedItem.cachedTokens)} />
<DetailRow label={t('usageLogs.labelInputTokens')} value={formatCompactNumber(selectedItem.promptTokens)} />
<DetailRow label={t('usageLogs.labelOutputTokens')} value={formatCompactNumber(selectedItem.completionTokens)} />
<DetailRow label={t('usageLogs.labelCachedTokens')} value={formatCompactNumber(selectedItem.cachedTokens)} />
</div>
</div>
</DetailSection>
<DetailSection title="诊断标识">
<DetailSection title={t('usageLogs.sectionDiagnostics')}>
<div className="grid gap-x-8 border-t border-border/60 pt-2 md:grid-cols-2">
<div className="divide-y divide-border/60">
<DetailRow label="Request ID" value={selectedItem.requestId} />
<DetailRow label="Trace ID" value={selectedItem.traceId} />
<DetailRow label={t('usageLogs.labelRequestId')} value={selectedItem.requestId} />
<DetailRow label={t('usageLogs.labelTraceId')} value={selectedItem.traceId} />
</div>
<div className="divide-y divide-border/60">
<DetailRow label="时间" value={formatDateTime(selectedItem.timestamp)} />
<DetailRow label={t('usageLogs.labelTime')} value={formatDateTime(selectedItem.timestamp)} />
<DetailRow
label="错误码"
label={t('usageLogs.labelErrorCode')}
value={selectedItem.errorCode}
tone={selectedItem.result === "success" ? "subtle" : "danger"}
/>
@@ -666,21 +672,21 @@ export function UsageLogs() {
)}
</DetailSection>
<DetailSection title="客户端环境">
<DetailSection title={t('usageLogs.sectionClient')}>
<div className="grid gap-x-8 border-t border-border/60 pt-2 md:grid-cols-2">
<div className="divide-y divide-border/60">
<DetailRow label="客户端 IP" value={selectedItem.clientIp} />
<DetailRow label={t('usageLogs.labelClientIp')} value={selectedItem.clientIp} />
<DetailRow
label="地理位置"
label={t('usageLogs.labelGeoLocation')}
value={[selectedItem.country, selectedItem.region, selectedItem.city].filter(Boolean).join(" / ")}
/>
</div>
<div className="divide-y divide-border/60">
<DetailRow
label="边缘节点 / 时区"
label={t('usageLogs.labelEdgeTimezone')}
value={[selectedItem.colo, selectedItem.timezone].filter(Boolean).join(" · ")}
/>
<DetailRow label="User-Agent" value={selectedItem.userAgent} />
<DetailRow label={t('usageLogs.labelUserAgent')} value={selectedItem.userAgent} />
</div>
</div>
</DetailSection>
+4 -3
View File
@@ -3,6 +3,7 @@ import { apiClient } from '@/api/client'
import { clearScopedCacheByPrefix } from '@/lib/local-cache'
import { type AdminLoginResponse } from '@/types'
import { clearAdminCredentials } from '@/lib/admin-auth'
import i18n from '@/i18n'
interface AuthState {
isAuthenticated: boolean
@@ -31,7 +32,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const loginResult = response.data as AdminLoginResponse
if (!loginResult) {
throw new Error('登录响应无效')
throw new Error(i18n.t('auth.loginResponseInvalid'))
}
clearAdminCredentials()
@@ -42,7 +43,7 @@ export const useAuthStore = create<AuthState>((set) => ({
}
if (!loginResult.challengeId) {
throw new Error('登录响应缺少验证码挑战信息')
throw new Error(i18n.t('auth.loginMissingChallenge'))
}
set({ isAuthenticated: false, isLoading: false })
@@ -66,7 +67,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const loginResult = response.data as AdminLoginResponse
if (!loginResult || loginResult.requiresVerification) {
throw new Error('验证码验证后未建立会话')
throw new Error(i18n.t('auth.verificationSessionFailed'))
}
clearAdminCredentials()
+3 -2
View File
@@ -32,6 +32,7 @@ import {
UsageLogSearchEndpoint,
} from "./analytics_api"
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"
import { t } from "../i18n"
import {
clearAdminSessionCookie,
getAdminSessionTokenFromRequest,
@@ -81,8 +82,8 @@ app.use('/api/admin/*', async (c, next) => {
return c.text(
securityEnabled
? "Telegram 登录验证已开启,请先完成验证码登录"
: "Unauthorized",
? t(c.get('lang') || 'zh-CN', 'auth.telegramRequired')
: t(c.get('lang') || 'zh-CN', 'auth.unauthorized'),
401
);
});
+12 -7
View File
@@ -12,6 +12,7 @@ import {
saveSystemConfig,
} from "../system-config";
import { sendTelegramTestNotification } from "./auth_shared";
import { t } from "../i18n";
const adminSecurityConfigSchema = z.object({
enabled: z.boolean(),
@@ -41,12 +42,12 @@ const telegramTestResponseSchema = z.object({
verifiedAt: z.string(),
});
const ensureSystemConfigValid = (config: SystemConfig) => {
const ensureSystemConfigValid = (config: SystemConfig, lang: string) => {
if (
config.adminSecurity.enabled
&& !isTelegramSecurityEnabled(config.adminSecurity)
) {
throw new Error("开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID");
throw new Error(t(lang, "system.telegramConfigRequired"));
}
};
@@ -90,12 +91,13 @@ export class SystemConfigUpdateEndpoint extends OpenAPIRoute {
async handle(c: Context<HonoCustomType>) {
const body = await c.req.json<SystemConfig>();
const config = normalizeSystemConfig(body);
const lang = c.get('lang') || 'zh-CN';
try {
ensureSystemConfigValid(config);
ensureSystemConfigValid(config, lang);
} catch (error) {
return c.text(
error instanceof Error ? error.message : "系统设置无效",
error instanceof Error ? error.message : t(lang, "system.configInvalid"),
400
);
}
@@ -128,11 +130,12 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute {
};
async handle(c: Context<HonoCustomType>) {
const lang = c.get('lang') || 'zh-CN';
const parsedBody = telegramTestRequestSchema.safeParse(
await c.req.json<Pick<AdminSecurityConfig, "telegramBotToken" | "telegramChatId">>()
);
if (!parsedBody.success) {
return c.text(parsedBody.error.issues[0]?.message || "Telegram 配置无效", 400);
return c.text(parsedBody.error.issues[0]?.message || t(lang, "system.telegramConfigInvalid"), 400);
}
const body = parsedBody.data;
@@ -142,14 +145,16 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute {
} satisfies AdminSecurityConfig;
if (!isTelegramSecurityEnabled(securityConfig)) {
return c.text("请先填写有效的 Bot Token 和 Chat ID", 400);
return c.text(t(lang, "system.fillBotTokenAndChatId"), 400);
}
try {
await sendTelegramTestNotification(securityConfig, c);
} catch (error) {
return c.text(
`Telegram 测试消息发送失败:${error instanceof Error ? error.message : "unknown error"}`,
t(lang, "system.telegramTestFailed", {
error: error instanceof Error ? error.message : "unknown error",
}),
500
);
}
+18 -12
View File
@@ -2,6 +2,7 @@ import { Context } from "hono";
import { BILLING_RAW_SCALE, LEGACY_TO_RAW_FACTOR } from "../billing";
import { DEFAULT_USAGE_ANALYTICS_DATASET_NAME } from "./usage-logger";
import { t } from "../i18n";
export type AnalyticsRange = "24h" | "7d" | "30d" | "90d";
export type AnalyticsBreakdownDimension = "token" | "channel" | "model" | "provider";
@@ -285,14 +286,16 @@ const formatSqlDateTime = (date: Date): string => {
return date.toISOString().slice(0, 19).replace("T", " ");
};
const parseDateTimeInput = (value: string | undefined, fieldLabel: string): string | undefined => {
const parseDateTimeInput = (value: string | undefined, fieldKey: string, lang: string): string | undefined => {
if (!value) {
return undefined;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new AnalyticsQueryValidationError(`${fieldLabel} 格式无效`);
throw new AnalyticsQueryValidationError(
t(lang, "analytics.invalidFormat", { field: t(lang, fieldKey) })
);
}
return formatSqlDateTime(date);
@@ -373,13 +376,14 @@ const buildTrendWindow = (range: AnalyticsRange, config: RangeConfig) => {
const buildCustomTimeWindow = (
requestedStart?: string,
requestedEnd?: string
requestedEnd?: string,
lang: string = "zh-CN"
): TimeWindow => {
const startTime = parseDateTimeInput(requestedStart, "开始时间");
const endTime = parseDateTimeInput(requestedEnd, "结束时间");
const startTime = parseDateTimeInput(requestedStart, "analytics.startTime", lang);
const endTime = parseDateTimeInput(requestedEnd, "analytics.endTime", lang);
if (startTime && endTime && startTime >= endTime) {
throw new AnalyticsQueryValidationError("开始时间必须早于结束时间");
throw new AnalyticsQueryValidationError(t(lang, "analytics.startBeforeEnd"));
}
const clauses: string[] = [];
@@ -563,13 +567,13 @@ const hasExtendedLogSchema = (support: DatasetColumnSupport): boolean => {
return EXTENDED_LOG_COLUMNS.every((columnName) => isColumnAvailable(support, columnName));
};
const getUsageLogCompatibilityWarning = (support: DatasetColumnSupport): string | undefined => {
const getUsageLogCompatibilityWarning = (support: DatasetColumnSupport, lang: string): string | undefined => {
if (!hasAnyLegacyLogSchema(support)) {
return "当前 Analytics Engine dataset 不包含使用日志基础字段,通常说明它是旧的、空的,或并非当前版本写入的 usage log dataset。";
return t(lang, "analytics.schemaV1Warning");
}
if (!hasExtendedLogSchema(support)) {
return "当前 Analytics Engine dataset 仍是旧日志 schema,request id / trace id / IP / UA / 地理位置 / 错误摘要 等新字段需要新版数据列可用后才可查询。";
return t(lang, "analytics.schemaV2Warning");
}
return undefined;
@@ -770,6 +774,7 @@ export const queryUsageEvents = async (
) => {
const { range, config } = getRangeConfig(requestedRange);
const dataset = getDatasetName(c);
const lang = c.get('lang') || 'zh-CN';
const limit = Math.min(Math.max(Number(requestedLimit || 40) || 40, 1), 100);
const countRows = await runAnalyticsQuery<Record<string, unknown>>(c, `
SELECT
@@ -789,7 +794,7 @@ WHERE ${buildRangeWhereClause(config)}
}
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport);
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport, lang);
const normalizedTotalCostExpression = buildNormalizedCostExpression(columnSupport, DOUBLE_FIELDS.totalCost);
const normalizedCacheCostExpression = buildNormalizedCostExpression(columnSupport, DOUBLE_FIELDS.cacheCost);
const rows = await runAnalyticsQuery<Record<string, unknown>>(c, `
@@ -874,7 +879,8 @@ export const queryUsageLogRecords = async (
params: UsageLogQueryParams
) => {
const dataset = getDatasetName(c);
const timeWindow = buildCustomTimeWindow(params.start, params.end);
const lang = c.get('lang') || 'zh-CN';
const timeWindow = buildCustomTimeWindow(params.start, params.end, lang);
const requestedPage = Math.min(Math.max(Number(params.page || 1) || 1, 1), 1000);
const dimension = (params.dimension && params.dimension in LOG_FILTER_FIELDS
? params.dimension
@@ -900,7 +906,7 @@ WHERE ${timeWindow.whereClause}
}
const columnSupport = await getDatasetColumnSupport(c, dataset, timeWindow.whereClause);
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport);
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport, lang);
if (!hasAnyLegacyLogSchema(columnSupport)) {
return buildUsageLogEmptyResponse(
+24
View File
@@ -0,0 +1,24 @@
const en: Record<string, string> = {
// auth
"auth.telegramRequired": "Telegram login verification is enabled. Please complete verification first",
"auth.unauthorized": "Unauthorized",
// system
"system.botTokenRequired": "Bot Token cannot be empty",
"system.chatIdRequired": "Chat ID cannot be empty",
"system.telegramConfigRequired": "Please fill in Bot Token and Chat ID before enabling Telegram verification",
"system.configInvalid": "Invalid system configuration",
"system.telegramConfigInvalid": "Invalid Telegram configuration",
"system.fillBotTokenAndChatId": "Please fill in a valid Bot Token and Chat ID first",
"system.telegramTestFailed": "Telegram test message failed: {{error}}",
// analytics
"analytics.invalidFormat": "{{field}} has invalid format",
"analytics.startTime": "Start time",
"analytics.endTime": "End time",
"analytics.startBeforeEnd": "Start time must be earlier than end time",
"analytics.schemaV1Warning": "The current Analytics Engine dataset does not contain basic usage log fields, which usually means it is old, empty, or not a usage log dataset written by the current version.",
"analytics.schemaV2Warning": "The current Analytics Engine dataset is still using the legacy log schema. Fields such as request id / trace id / IP / UA / geolocation / error summary require the new data columns to be available before they can be queried.",
};
export default en;
+109
View File
@@ -0,0 +1,109 @@
import type { Context } from "hono";
import zhCN from "./zh-CN";
import zhTW from "./zh-TW";
import en from "./en";
type SupportedLang = "zh-CN" | "zh-TW" | "en";
const locales: Record<SupportedLang, Record<string, string>> = {
"zh-CN": zhCN,
"zh-TW": zhTW,
"en": en,
};
const DEFAULT_LANG: SupportedLang = "zh-CN";
const ACCEPT_LANGUAGE_PATTERN = /([a-zA-Z]{1,8}(?:-[a-zA-Z0-9]{1,8})*)\s*(?:;\s*q\s*=\s*([\d.]+))?/g;
/**
* Resolve the preferred language from the request context.
*
* Priority: `x-lang` header > `Accept-Language` header > default (`zh-CN`).
*/
export const resolveLanguage = (c: Context<HonoCustomType>): string => {
const explicit = c.req.header("x-lang");
if (explicit) {
const matched = matchLang(explicit);
if (matched) {
return matched;
}
}
const acceptLanguage = c.req.header("accept-language");
if (acceptLanguage) {
const candidates = parseAcceptLanguage(acceptLanguage);
for (const candidate of candidates) {
const matched = matchLang(candidate);
if (matched) {
return matched;
}
}
}
return DEFAULT_LANG;
};
/**
* Translate a key for the given language.
*
* Supports simple `{{variable}}` interpolation via an optional `params` map.
*/
export const t = (
lang: string,
key: string,
params?: Record<string, string>
): string => {
const locale = locales[lang as SupportedLang] ?? locales[DEFAULT_LANG];
let text = locale[key] ?? locales[DEFAULT_LANG][key] ?? key;
if (params) {
for (const [name, value] of Object.entries(params)) {
text = text.replaceAll(`{{${name}}}`, value);
}
}
return text;
};
/**
* Match a raw language tag to one of the supported locales.
* Returns the matched locale key, or `undefined` if no match.
*/
const matchLang = (tag: string): SupportedLang | undefined => {
const normalized = tag.trim().toLowerCase();
if (normalized === "zh-cn" || normalized === "zh-hans") {
return "zh-CN";
}
if (normalized === "zh-tw" || normalized === "zh-hant" || normalized === "zh-hk") {
return "zh-TW";
}
if (normalized === "en" || normalized.startsWith("en-")) {
return "en";
}
if (normalized === "zh" || normalized.startsWith("zh-")) {
return "zh-CN";
}
return undefined;
};
/**
* Parse an `Accept-Language` header value and return language tags
* sorted by quality (descending).
*/
const parseAcceptLanguage = (header: string): string[] => {
const entries: Array<{ tag: string; quality: number }> = [];
let match: RegExpExecArray | null;
ACCEPT_LANGUAGE_PATTERN.lastIndex = 0;
while ((match = ACCEPT_LANGUAGE_PATTERN.exec(header)) !== null) {
entries.push({
tag: match[1],
quality: match[2] !== undefined ? parseFloat(match[2]) : 1.0,
});
}
entries.sort((a, b) => b.quality - a.quality);
return entries.map((e) => e.tag);
};
+24
View File
@@ -0,0 +1,24 @@
const zhCN: Record<string, string> = {
// auth
"auth.telegramRequired": "Telegram 登录验证已开启,请先完成验证码登录",
"auth.unauthorized": "Unauthorized",
// system
"system.botTokenRequired": "Bot Token 不能为空",
"system.chatIdRequired": "Chat ID 不能为空",
"system.telegramConfigRequired": "开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID",
"system.configInvalid": "系统设置无效",
"system.telegramConfigInvalid": "Telegram 配置无效",
"system.fillBotTokenAndChatId": "请先填写有效的 Bot Token 和 Chat ID",
"system.telegramTestFailed": "Telegram 测试消息发送失败:{{error}}",
// analytics
"analytics.invalidFormat": "{{field}} 格式无效",
"analytics.startTime": "开始时间",
"analytics.endTime": "结束时间",
"analytics.startBeforeEnd": "开始时间必须早于结束时间",
"analytics.schemaV1Warning": "当前 Analytics Engine dataset 不包含使用日志基础字段,通常说明它是旧的、空的,或并非当前版本写入的 usage log dataset。",
"analytics.schemaV2Warning": "当前 Analytics Engine dataset 仍是旧日志 schema,request id / trace id / IP / UA / 地理位置 / 错误摘要 等新字段需要新版数据列可用后才可查询。",
};
export default zhCN;
+24
View File
@@ -0,0 +1,24 @@
const zhTW: Record<string, string> = {
// auth
"auth.telegramRequired": "Telegram 登入驗證已開啟,請先完成驗證碼登入",
"auth.unauthorized": "Unauthorized",
// system
"system.botTokenRequired": "Bot Token 不能為空",
"system.chatIdRequired": "Chat ID 不能為空",
"system.telegramConfigRequired": "開啟 Telegram 驗證前,請先填寫 Bot Token 和 Chat ID",
"system.configInvalid": "系統設定無效",
"system.telegramConfigInvalid": "Telegram 配置無效",
"system.fillBotTokenAndChatId": "請先填寫有效的 Bot Token 和 Chat ID",
"system.telegramTestFailed": "Telegram 測試訊息傳送失敗:{{error}}",
// analytics
"analytics.invalidFormat": "{{field}} 格式無效",
"analytics.startTime": "開始時間",
"analytics.endTime": "結束時間",
"analytics.startBeforeEnd": "開始時間必須早於結束時間",
"analytics.schemaV1Warning": "目前 Analytics Engine dataset 不包含使用日誌基礎欄位,通常表示它是舊的、空的,或並非目前版本寫入的 usage log dataset。",
"analytics.schemaV2Warning": "目前 Analytics Engine dataset 仍是舊日誌 schema,request id / trace id / IP / UA / 地理位置 / 錯誤摘要 等新欄位需要新版資料列可用後才可查詢。",
};
export default zhTW;
+7
View File
@@ -6,6 +6,7 @@ import { api as adminApi } from './admin'
import { fromHono } from 'chanfana';
import db from './db';
import { getSystemConfig } from './system-config';
import { resolveLanguage } from './i18n';
const FRONTEND_ENTRY = '/'
const FRONTEND_STATIC_PATHS = new Set([
@@ -100,6 +101,12 @@ const openapi = fromHono(app, {
// cors
openapi.use('/*', cors());
app.use('*', async (c, next) => {
const lang = resolveLanguage(c)
c.set('lang', lang)
await next()
})
app.use('*', async (c, next) => {
const requestUrl = new URL(c.req.url)