i18n: complete internationalization for v3.8+ features

- Add health status translations (operational, degraded, failed, circuitOpen)
- Add proxy panel translations (serviceAddress, stats, stopped state)
- Add usage filter translations (appType, statusCode, searchPlaceholder)
- Add providerIcon click hints (clickToChange, clickToSelect)
- Add config load error translations for main.tsx
- Complete Japanese proxy section (failoverQueue, autoFailover)
- Fix date/time locale in usage charts and tables
- Use t() function in all hardcoded UI strings
This commit is contained in:
Jason
2025-12-20 21:38:37 +08:00
parent ec649e7718
commit 2fb3b5405a
17 changed files with 373 additions and 82 deletions
@@ -1,6 +1,7 @@
import React from "react";
import { cn } from "@/lib/utils";
import type { HealthStatus } from "@/lib/api/model-test";
import { useTranslation } from "react-i18next";
interface HealthStatusIndicatorProps {
status: HealthStatus;
@@ -11,17 +12,20 @@ interface HealthStatusIndicatorProps {
const statusConfig = {
operational: {
color: "bg-emerald-500",
label: "正常",
labelKey: "health.operational",
labelFallback: "正常",
textColor: "text-emerald-600 dark:text-emerald-400",
},
degraded: {
color: "bg-yellow-500",
label: "降级",
labelKey: "health.degraded",
labelFallback: "降级",
textColor: "text-yellow-600 dark:text-yellow-400",
},
failed: {
color: "bg-red-500",
label: "失败",
labelKey: "health.failed",
labelFallback: "失败",
textColor: "text-red-600 dark:text-red-400",
},
};
@@ -31,13 +35,15 @@ export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
responseTimeMs,
className,
}) => {
const { t } = useTranslation();
const config = statusConfig[status];
const label = t(config.labelKey, { defaultValue: config.labelFallback });
return (
<div className={cn("flex items-center gap-2", className)}>
<div className={cn("w-2 h-2 rounded-full", config.color)} />
<span className={cn("text-xs font-medium", config.textColor)}>
{config.label}
{label}
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
</span>
</div>
@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils";
import { ProviderHealthStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
interface ProviderHealthBadgeProps {
consecutiveFailures: number;
@@ -14,11 +15,14 @@ export function ProviderHealthBadge({
consecutiveFailures,
className,
}: ProviderHealthBadgeProps) {
const { t } = useTranslation();
// 根据失败次数计算状态
const getStatus = () => {
if (consecutiveFailures === 0) {
return {
label: "正常",
labelKey: "health.operational",
labelFallback: "正常",
status: ProviderHealthStatus.Healthy,
color: "bg-green-500",
// 使用更深/柔和的背景色,去除可能的白色内容感
@@ -27,7 +31,8 @@ export function ProviderHealthBadge({
};
} else if (consecutiveFailures < 5) {
return {
label: "降级",
labelKey: "health.degraded",
labelFallback: "降级",
status: ProviderHealthStatus.Degraded,
color: "bg-yellow-500",
bgColor: "bg-yellow-500/10",
@@ -35,7 +40,8 @@ export function ProviderHealthBadge({
};
} else {
return {
label: "熔断",
labelKey: "health.circuitOpen",
labelFallback: "熔断",
status: ProviderHealthStatus.Failed,
color: "bg-red-500",
bgColor: "bg-red-500/10",
@@ -45,6 +51,9 @@ export function ProviderHealthBadge({
};
const statusConfig = getStatus();
const label = t(statusConfig.labelKey, {
defaultValue: statusConfig.labelFallback,
});
return (
<div
@@ -54,10 +63,13 @@ export function ProviderHealthBadge({
statusConfig.textColor,
className,
)}
title={`连续失败 ${consecutiveFailures}`}
title={t("health.consecutiveFailures", {
count: consecutiveFailures,
defaultValue: `连续失败 ${consecutiveFailures}`,
})}
>
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
<span>{statusConfig.label}</span>
<span>{label}</span>
</div>
);
}
@@ -52,7 +52,15 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
<button
type="button"
className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center"
title={currentIcon ? "点击更换图标" : "点击选择图标"}
title={
currentIcon
? t("providerIcon.clickToChange", {
defaultValue: "点击更换图标",
})
: t("providerIcon.clickToSelect", {
defaultValue: "点击选择图标",
})
}
>
<ProviderIcon
icon={currentIcon}
@@ -145,7 +153,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
<FormItem>
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
<FormControl>
<Input {...field} placeholder="https://" />
<Input {...field} placeholder={t("providerForm.websiteUrlPlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
@@ -150,9 +150,7 @@ export function ProviderPresetSelector({
style={getPresetButtonStyle(isSelected, entry.preset)}
title={
presetCategoryLabels[category] ??
t("providerPreset.categoryOther", {
defaultValue: "其他",
})
t("providerPreset.other")
}
>
{renderPresetIcon(entry.preset)}
+44 -16
View File
@@ -15,8 +15,10 @@ import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
export function ProxyPanel() {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
@@ -48,7 +50,9 @@ export function ProxyPanel() {
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">
{t("proxy.panel.serviceAddress", { defaultValue: "服务地址" })}
</p>
<Button
size="sm"
variant="ghost"
@@ -56,7 +60,7 @@ export function ProxyPanel() {
className="h-7 gap-1.5 text-xs"
>
<Settings className="h-3.5 w-3.5" />
{t("common.settings")}
</Button>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
@@ -70,16 +74,23 @@ export function ProxyPanel() {
navigator.clipboard.writeText(
`http://${status.address}:${status.port}`,
);
toast.success("地址已复制", { closeButton: true });
toast.success(
t("proxy.panel.addressCopied", {
defaultValue: "地址已复制",
}),
{ closeButton: true },
);
}}
>
{t("common.copy")}
</Button>
</div>
</div>
<div className="pt-3 border-t border-border space-y-2">
<p className="text-xs text-muted-foreground">使</p>
<p className="text-xs text-muted-foreground">
{t("provider.inUse")}
</p>
{status.active_targets && status.active_targets.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{status.active_targets.map((target) => (
@@ -101,14 +112,18 @@ export function ProxyPanel() {
</div>
) : status.current_provider ? (
<p className="text-sm text-muted-foreground">
Provider{" "}
{t("proxy.panel.currentProvider", {
defaultValue: "当前 Provider",
})}{" "}
<span className="font-medium text-foreground">
{status.current_provider}
</span>
</p>
) : (
<p className="text-sm text-yellow-600 dark:text-yellow-400">
Provider
{t("proxy.panel.waitingFirstRequest", {
defaultValue: "当前 Provider:等待首次请求…",
})}
</p>
)}
</div>
@@ -121,7 +136,7 @@ export function ProxyPanel() {
<div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.title")}
</p>
</div>
@@ -179,23 +194,31 @@ export function ProxyPanel() {
<div className="grid gap-3 md:grid-cols-4">
<StatCard
icon={<Activity className="h-4 w-4" />}
label="活跃连接"
label={t("proxy.panel.stats.activeConnections", {
defaultValue: "活跃连接",
})}
value={status.active_connections}
/>
<StatCard
icon={<TrendingUp className="h-4 w-4" />}
label="总请求数"
label={t("proxy.panel.stats.totalRequests", {
defaultValue: "总请求数",
})}
value={status.total_requests}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="成功率"
label={t("proxy.panel.stats.successRate", {
defaultValue: "成功率",
})}
value={`${status.success_rate.toFixed(1)}%`}
variant={status.success_rate > 90 ? "success" : "warning"}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="运行时间"
label={t("proxy.panel.stats.uptime", {
defaultValue: "运行时间",
})}
value={formatUptime(status.uptime_seconds)}
/>
</div>
@@ -206,10 +229,12 @@ export function ProxyPanel() {
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止" })}
</p>
<p className="text-sm text-muted-foreground mb-4">
使
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
<Button
size="sm"
@@ -218,7 +243,9 @@ export function ProxyPanel() {
className="gap-1.5"
>
<Settings className="h-4 w-4" />
{t("proxy.panel.openSettings", {
defaultValue: "配置代理服务",
})}
</Button>
</div>
)}
@@ -319,6 +346,7 @@ function ProviderQueueItem({
appType,
isCurrent,
}: ProviderQueueItemProps) {
const { t } = useTranslation();
const { data: health } = useProviderHealth(provider.id, appType);
return (
@@ -344,7 +372,7 @@ function ProviderQueueItem({
</span>
{isCurrent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
使
{t("provider.inUse")}
</span>
)}
</div>
@@ -93,7 +93,7 @@ export function ImportExportSection({
type="button"
onClick={onClear}
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
aria-label="Clear selection"
aria-label={t("common.clear")}
>
<XCircle className="h-4 w-4" />
</button>
+6 -2
View File
@@ -106,7 +106,9 @@ export function PricingEditModal({
onChange={(e) =>
setFormData({ ...formData, modelId: e.target.value })
}
placeholder="例如: claude-3-5-sonnet-20241022"
placeholder={t("usage.modelIdPlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022",
})}
required
/>
</div>
@@ -122,7 +124,9 @@ export function PricingEditModal({
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
placeholder="例如: Claude 3.5 Sonnet"
placeholder={t("usage.displayNamePlaceholder", {
defaultValue: "例如: Claude 3.5 Sonnet",
})}
required
/>
</div>
+10 -2
View File
@@ -16,8 +16,14 @@ export function RequestDetailPanel({
requestId,
onClose,
}: RequestDetailPanelProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { data: request, isLoading } = useRequestDetail(requestId);
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
if (isLoading) {
return (
@@ -69,7 +75,9 @@ export function RequestDetailPanel({
{t("usage.time", "时间")}
</dt>
<dd>
{new Date(request.createdAt * 1000).toLocaleString("zh-CN")}
{new Date(request.createdAt * 1000).toLocaleString(
dateLocale,
)}
</dd>
</div>
<div>
+24 -8
View File
@@ -23,7 +23,7 @@ import type { LogFilters } from "@/types/usage";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
export function RequestLogTable() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
// 默认时间范围:过去24小时
@@ -62,6 +62,13 @@ export function RequestLogTable() {
});
};
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
return (
<div className="space-y-4">
{/* 筛选栏 */}
@@ -77,10 +84,12 @@ export function RequestLogTable() {
}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.endpoint", "端点")} />
<SelectValue placeholder={t("usage.appType", "应用类型")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部端点")}</SelectItem>
<SelectItem value="all">
{t("usage.allApps", { defaultValue: "全部应用" })}
</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
@@ -97,7 +106,7 @@ export function RequestLogTable() {
}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.status", "状态码")} />
<SelectValue placeholder={t("usage.statusCode", "状态码")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部状态")}</SelectItem>
@@ -113,7 +122,10 @@ export function RequestLogTable() {
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("usage.provider", "搜索供应商...")}
placeholder={t(
"usage.searchProviderPlaceholder",
"搜索供应商...",
)}
className="pl-9 bg-background"
value={tempFilters.providerName || ""}
onChange={(e) =>
@@ -125,7 +137,7 @@ export function RequestLogTable() {
/>
</div>
<Input
placeholder={t("usage.model", "搜索模型...")}
placeholder={t("usage.searchModelPlaceholder", "搜索模型...")}
className="w-[180px] bg-background"
value={tempFilters.model || ""}
onChange={(e) =>
@@ -140,7 +152,9 @@ export function RequestLogTable() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">:</span>
<span className="whitespace-nowrap">
{t("usage.timeRange", { defaultValue: "时间范围" })}:
</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
@@ -267,7 +281,9 @@ export function RequestLogTable() {
logs.map((log) => (
<TableRow key={log.requestId}>
<TableCell>
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")}
{new Date(log.createdAt * 1000).toLocaleString(
dateLocale,
)}
</TableCell>
<TableCell>
{log.providerName ||
+17 -17
View File
@@ -56,15 +56,15 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
color: "text-purple-500",
bg: "bg-purple-500/10",
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>Input</span>
<span>{t("usage.input", { defaultValue: "Input" })}</span>
<span className="text-foreground/80">
{(inputTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>Output</span>
<span>{t("usage.output", { defaultValue: "Output" })}</span>
<span className="text-foreground/80">
{(outputTokens / 1000).toFixed(1)}k
</span>
@@ -78,21 +78,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
icon: Database,
color: "text-orange-500",
bg: "bg-orange-500/10",
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>Write</span>
<span className="text-foreground/80">
{(cacheWriteTokens / 1000).toFixed(1)}k
</span>
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>{t("usage.cacheWrite", { defaultValue: "Write" })}</span>
<span className="text-foreground/80">
{(cacheWriteTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>{t("usage.cacheRead", { defaultValue: "Read" })}</span>
<span className="text-foreground/80">
{(cacheReadTokens / 1000).toFixed(1)}k
</span>
</div>
</div>
<div className="flex justify-between items-center">
<span>Read</span>
<span className="text-foreground/80">
{(cacheReadTokens / 1000).toFixed(1)}k
</span>
</div>
</div>
),
},
];
+9 -3
View File
@@ -17,7 +17,7 @@ interface UsageTrendChartProps {
}
export function UsageTrendChart({ days }: UsageTrendChartProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days);
if (isLoading) {
@@ -29,14 +29,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
}
const isToday = days === 1;
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
const chartData =
trends?.map((stat) => {
const pointDate = new Date(stat.date);
return {
rawDate: stat.date,
label: isToday
? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" })
: pointDate.toLocaleDateString("zh-CN", {
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
: pointDate.toLocaleDateString(dateLocale, {
month: "2-digit",
day: "2-digit",
}),