feat(i18n): 添加多语言支持,优化用户界面文本
在此提交中,我们引入了多语言支持,允许用户根据其语言偏好查看界面文本。新增了国际化库(i18next)并在多个组件中实现了翻译功能,确保错误消息和界面文本能够根据用户的语言设置动态变化。这一改动旨在提升用户体验,使得应用能够服务更广泛的用户群体,增强可访问性和友好性。同时,更新了相关文档以指导开发者如何使用新的国际化功能。
This commit is contained in:
1 parent
8261eeda34
commit
a7c65b9476
33 files changed
+2328
-609
No files matched your search
@@ -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 中添加类型
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -85,7 +85,7 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
onBlur,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
placeholder = '请选择',
|
||||
placeholder = '',
|
||||
'aria-label': ariaLabel,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "网络连接失败,请检查网络"
|
||||
}
|
||||
}
|
||||
@@ -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": "網路連線失敗,請檢查網路"
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user