mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
merge: resolve i18n conflicts between smart-url-path-detection and main
This commit is contained in:
@@ -7,13 +7,13 @@ export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleTheme = () => {
|
||||
const toggleTheme = (event: React.MouseEvent) => {
|
||||
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
|
||||
// 否则切换到 dark
|
||||
if (theme === "dark") {
|
||||
setTheme("light");
|
||||
setTheme("light", event);
|
||||
} else {
|
||||
setTheme("dark");
|
||||
setTheme("dark", event);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
@@ -36,7 +35,6 @@ export function AddProviderDialog({
|
||||
onSubmit,
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// OpenCode doesn't support universal providers
|
||||
const showUniversalTab = appId !== "opencode";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
@@ -45,7 +43,6 @@ export function AddProviderDialog({
|
||||
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
||||
useState<UniversalProviderPreset | null>(null);
|
||||
|
||||
// Handle universal provider save
|
||||
const handleUniversalProviderSave = useCallback(
|
||||
async (provider: UniversalProvider) => {
|
||||
try {
|
||||
@@ -73,7 +70,6 @@ export function AddProviderDialog({
|
||||
[t, onOpenChange],
|
||||
);
|
||||
|
||||
// Close universal form and return to main dialog
|
||||
const handleUniversalFormClose = useCallback(() => {
|
||||
setUniversalFormOpen(false);
|
||||
setSelectedUniversalPreset(null);
|
||||
@@ -86,7 +82,6 @@ export function AddProviderDialog({
|
||||
unknown
|
||||
>;
|
||||
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
@@ -98,7 +93,6 @@ export function AddProviderDialog({
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
|
||||
// OpenCode: pass providerKey for ID generation
|
||||
if (appId === "opencode" && values.providerKey) {
|
||||
providerData.providerKey = values.providerKey;
|
||||
}
|
||||
@@ -107,8 +101,7 @@ export function AddProviderDialog({
|
||||
providerData.meta?.custom_endpoints &&
|
||||
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
||||
|
||||
if (!hasCustomEndpoints) {
|
||||
// 收集端点候选(仅在缺少自定义端点时兜底)
|
||||
if (!hasCustomEndpoints && values.presetCategory !== "omo") {
|
||||
const urlSet = new Set<string>();
|
||||
|
||||
const addUrl = (rawUrl?: string) => {
|
||||
@@ -163,7 +156,6 @@ export function AddProviderDialog({
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
|
||||
}
|
||||
|
||||
if (appId === "claude") {
|
||||
@@ -187,7 +179,6 @@ export function AddProviderDialog({
|
||||
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||
}
|
||||
} else if (appId === "opencode") {
|
||||
// OpenCode uses options.baseURL
|
||||
const options = parsedConfig.options as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
@@ -221,7 +212,6 @@ export function AddProviderDialog({
|
||||
[appId, onSubmit, onOpenChange],
|
||||
);
|
||||
|
||||
// 动态 footer:根据当前 Tab 显示不同按钮
|
||||
const footer =
|
||||
!showUniversalTab || activeTab === "app-specific" ? (
|
||||
<>
|
||||
@@ -296,7 +286,6 @@ export function AddProviderDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// OpenCode: directly show form without tabs
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
@@ -306,7 +295,6 @@ export function AddProviderDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Universal Provider Form Modal */}
|
||||
{showUniversalTab && (
|
||||
<UniversalProviderFormModal
|
||||
isOpen={universalFormOpen}
|
||||
|
||||
@@ -19,20 +19,20 @@ import type { AppId } from "@/lib/api";
|
||||
interface ProviderActionsProps {
|
||||
appId?: AppId;
|
||||
isCurrent: boolean;
|
||||
/** OpenCode: 是否已添加到配置 */
|
||||
isInConfig?: boolean;
|
||||
isTesting?: boolean;
|
||||
isProxyTakeover?: boolean;
|
||||
isOmo?: boolean;
|
||||
isLastOmo?: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: () => void;
|
||||
onDisableOmo?: () => void;
|
||||
onOpenTerminal?: () => void;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
onToggleFailover?: (enabled: boolean) => void;
|
||||
@@ -44,6 +44,8 @@ export function ProviderActions({
|
||||
isInConfig = false,
|
||||
isTesting,
|
||||
isProxyTakeover = false,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
@@ -51,8 +53,8 @@ export function ProviderActions({
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onOpenTerminal,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
@@ -60,19 +62,20 @@ export function ProviderActions({
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
|
||||
// OpenCode 使用累加模式
|
||||
const isOpenCodeMode = appId === "opencode";
|
||||
const isOpenCodeMode = appId === "opencode" && !isOmo;
|
||||
|
||||
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
|
||||
const isFailoverMode =
|
||||
!isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
|
||||
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||
|
||||
// 处理主按钮点击
|
||||
const handleMainButtonClick = () => {
|
||||
if (isOpenCodeMode) {
|
||||
// OpenCode 模式:切换配置状态(添加/移除)
|
||||
if (isOmo) {
|
||||
if (isCurrent) {
|
||||
onDisableOmo?.();
|
||||
} else {
|
||||
onSwitch();
|
||||
}
|
||||
} else if (isOpenCodeMode) {
|
||||
if (isInConfig) {
|
||||
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
|
||||
if (onRemoveFromConfig) {
|
||||
onRemoveFromConfig();
|
||||
} else {
|
||||
@@ -82,17 +85,33 @@ export function ProviderActions({
|
||||
onSwitch(); // 添加到配置
|
||||
}
|
||||
} else if (isFailoverMode) {
|
||||
// 故障转移模式:切换队列状态
|
||||
onToggleFailover(!isInFailoverQueue);
|
||||
} else {
|
||||
// 普通模式:切换供应商
|
||||
onSwitch();
|
||||
}
|
||||
};
|
||||
|
||||
// 主按钮的状态和样式
|
||||
const getMainButtonState = () => {
|
||||
// OpenCode 累加模式
|
||||
if (isOmo) {
|
||||
if (isCurrent) {
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
text: t("provider.inUse"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "default" as const,
|
||||
className: "",
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
text: t("provider.enable"),
|
||||
};
|
||||
}
|
||||
|
||||
if (isOpenCodeMode) {
|
||||
if (isInConfig) {
|
||||
return {
|
||||
@@ -114,7 +133,6 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
// 故障转移模式
|
||||
if (isFailoverMode) {
|
||||
if (isInFailoverQueue) {
|
||||
return {
|
||||
@@ -136,7 +154,6 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
// 普通模式
|
||||
if (isCurrent) {
|
||||
return {
|
||||
disabled: true,
|
||||
@@ -161,8 +178,11 @@ export function ProviderActions({
|
||||
|
||||
const buttonState = getMainButtonState();
|
||||
|
||||
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
|
||||
const canDelete = isOpenCodeMode ? true : !isCurrent;
|
||||
const canDelete = isOmo
|
||||
? !(isLastOmo && isCurrent)
|
||||
: isOpenCodeMode
|
||||
? true
|
||||
: !isCurrent;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -27,11 +27,13 @@ interface ProviderCardProps {
|
||||
isCurrent: boolean;
|
||||
appId: AppId;
|
||||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||
isOmo?: boolean;
|
||||
isLastOmo?: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onConfigureUsage: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
@@ -41,7 +43,6 @@ interface ProviderCardProps {
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||
dragHandleProps?: DragHandleProps;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||||
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||
@@ -50,17 +51,14 @@ interface ProviderCardProps {
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
// 优先级 1: 备注
|
||||
if (provider.notes?.trim()) {
|
||||
return provider.notes.trim();
|
||||
}
|
||||
|
||||
// 优先级 2: 官网地址
|
||||
if (provider.websiteUrl) {
|
||||
return provider.websiteUrl;
|
||||
}
|
||||
|
||||
// 优先级 3: 从配置中提取请求地址
|
||||
const config = provider.settingsConfig;
|
||||
|
||||
if (config && typeof config === "object") {
|
||||
@@ -89,10 +87,13 @@ export function ProviderCard({
|
||||
isCurrent,
|
||||
appId,
|
||||
isInConfig = true,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onDuplicate,
|
||||
@@ -102,7 +103,6 @@ export function ProviderCard({
|
||||
isProxyRunning,
|
||||
isProxyTakeover = false,
|
||||
dragHandleProps,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
failoverPriority,
|
||||
isInFailoverQueue = false,
|
||||
@@ -111,7 +111,6 @@ export function ProviderCard({
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 获取供应商健康状态
|
||||
const { data: health } = useProviderHealth(provider.id, appId);
|
||||
|
||||
const fallbackUrlText = t("provider.notConfigured", {
|
||||
@@ -122,24 +121,18 @@ export function ProviderCard({
|
||||
return extractApiUrl(provider, fallbackUrlText);
|
||||
}, [provider, fallbackUrlText]);
|
||||
|
||||
// 判断是否为可点击的 URL(备注不可点击)
|
||||
const isClickableUrl = useMemo(() => {
|
||||
// 如果有备注,则不可点击
|
||||
if (provider.notes?.trim()) {
|
||||
return false;
|
||||
}
|
||||
// 如果显示的是回退文本,也不可点击
|
||||
if (displayUrl === fallbackUrlText) {
|
||||
return false;
|
||||
}
|
||||
// 其他情况(官网地址或请求地址)可点击
|
||||
return true;
|
||||
}, [provider.notes, displayUrl, fallbackUrlText]);
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
|
||||
// 获取用量数据以判断是否有多套餐
|
||||
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
||||
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||
const autoQueryInterval = shouldAutoQuery
|
||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||
@@ -153,21 +146,17 @@ export function ProviderCard({
|
||||
const hasMultiplePlans =
|
||||
usage?.success && usage.data && usage.data.length > 1;
|
||||
|
||||
// 多套餐默认展开
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 操作按钮容器 ref,用于动态计算宽度
|
||||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState(0);
|
||||
|
||||
// 当检测到多套餐时自动展开
|
||||
useEffect(() => {
|
||||
if (hasMultiplePlans) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasMultiplePlans]);
|
||||
|
||||
// 动态获取操作按钮宽度
|
||||
useEffect(() => {
|
||||
if (actionsRef.current) {
|
||||
const updateWidth = () => {
|
||||
@@ -175,7 +164,6 @@ export function ProviderCard({
|
||||
setActionsWidth(width);
|
||||
};
|
||||
updateWidth();
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => window.removeEventListener("resize", updateWidth);
|
||||
}
|
||||
@@ -188,32 +176,27 @@ export function ProviderCard({
|
||||
onOpenWebsite(displayUrl);
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 代理接管模式(非故障转移):isCurrent
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider =
|
||||
appId === "opencode"
|
||||
const isActiveProvider = isOmo
|
||||
? isCurrent
|
||||
: appId === "opencode"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
|
||||
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue =
|
||||
(isOmo && isActiveProvider) ||
|
||||
(!isOmo && !isProxyTakeover && isActiveProvider);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||
"bg-card text-card-foreground group",
|
||||
// hover 时的边框效果
|
||||
isAutoFailoverEnabled || isProxyTakeover
|
||||
? "hover:border-emerald-500/50"
|
||||
: "hover:border-border-active",
|
||||
// 当前激活的供应商边框样式
|
||||
shouldUseGreen &&
|
||||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||
@@ -225,7 +208,6 @@ export function ProviderCard({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
||||
shouldUseGreen && "from-emerald-500/10",
|
||||
shouldUseBlue && "from-blue-500/10",
|
||||
!isActiveProvider && "from-primary/10",
|
||||
@@ -248,7 +230,6 @@ export function ProviderCard({
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 供应商图标 */}
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||
<ProviderIcon
|
||||
icon={provider.icon}
|
||||
@@ -264,14 +245,18 @@ export function ProviderCard({
|
||||
{provider.name}
|
||||
</h3>
|
||||
|
||||
{/* 健康状态徽章 */}
|
||||
{isOmo && (
|
||||
<span className="inline-flex items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
|
||||
OMO
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 故障转移优先级徽章 */}
|
||||
{isAutoFailoverEnabled &&
|
||||
isInFailoverQueue &&
|
||||
failoverPriority && (
|
||||
@@ -318,10 +303,8 @@ export function ProviderCard({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||
<div className="ml-auto">
|
||||
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
||||
{hasMultiplePlans ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">
|
||||
@@ -342,7 +325,6 @@ export function ProviderCard({
|
||||
inline={true}
|
||||
/>
|
||||
)}
|
||||
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
|
||||
{hasMultiplePlans && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -366,7 +348,6 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
|
||||
<div
|
||||
ref={actionsRef}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||
@@ -377,6 +358,8 @@ export function ProviderCard({
|
||||
isInConfig={isInConfig}
|
||||
isTesting={isTesting}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={isLastOmo}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
@@ -388,10 +371,10 @@ export function ProviderCard({
|
||||
? () => onRemoveFromConfig(provider)
|
||||
: undefined
|
||||
}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onOpenTerminal={
|
||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||
}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
@@ -400,7 +383,6 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开的完整套餐列表 */}
|
||||
{isExpanded && hasMultiplePlans && (
|
||||
<div className="mt-4 pt-4 border-t border-border-default">
|
||||
<UsageFooter
|
||||
|
||||
@@ -20,7 +20,6 @@ import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
import {
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
} from "@/lib/query/failover";
|
||||
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,8 +40,8 @@ interface ProviderListProps {
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
@@ -61,6 +61,7 @@ export function ProviderList({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
@@ -77,14 +78,12 @@ export function ProviderList({
|
||||
appId,
|
||||
);
|
||||
|
||||
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
||||
const { data: opencodeLiveIds } = useQuery({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
||||
enabled: appId === "opencode",
|
||||
});
|
||||
|
||||
// OpenCode: 判断供应商是否已添加到 opencode.json
|
||||
const isProviderInConfig = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
||||
@@ -93,20 +92,18 @@ export function ProviderList({
|
||||
[appId, opencodeLiveIds],
|
||||
);
|
||||
|
||||
// 流式健康检查 - 功能已隐藏
|
||||
// const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||
|
||||
// 故障转移相关
|
||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||
|
||||
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
|
||||
const isFailoverModeActive =
|
||||
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
||||
|
||||
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
|
||||
const isOpenCode = appId === "opencode";
|
||||
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
||||
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
|
||||
|
||||
const getFailoverPriority = useCallback(
|
||||
(providerId: string): number | undefined => {
|
||||
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
||||
@@ -118,7 +115,6 @@ export function ProviderList({
|
||||
[isFailoverModeActive, failoverQueue],
|
||||
);
|
||||
|
||||
// 判断供应商是否在故障转移队列中
|
||||
const isInFailoverQueue = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (!isFailoverModeActive || !failoverQueue) return false;
|
||||
@@ -127,7 +123,6 @@ export function ProviderList({
|
||||
[isFailoverModeActive, failoverQueue],
|
||||
);
|
||||
|
||||
// 切换供应商的故障转移队列状态
|
||||
const handleToggleFailover = useCallback(
|
||||
(providerId: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
@@ -139,11 +134,6 @@ export function ProviderList({
|
||||
[appId, addToQueue, removeFromQueue],
|
||||
);
|
||||
|
||||
// handleTest 功能已隐藏 - 供应商请求格式复杂难以统一测试
|
||||
// const handleTest = (provider: Provider) => {
|
||||
// checkProvider(provider.id, provider.name);
|
||||
// };
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -215,36 +205,44 @@ export function ProviderList({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{filteredProviders.map((provider) => (
|
||||
<SortableProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isCurrent={provider.id === currentProviderId}
|
||||
appId={appId}
|
||||
isInConfig={isProviderInConfig(provider.id)}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
// onTest 功能已隐藏 - 供应商请求格式复杂难以统一测试
|
||||
// onTest={appId !== "opencode" ? handleTest : undefined}
|
||||
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
// 故障转移相关:联动状态
|
||||
isAutoFailoverEnabled={isFailoverModeActive}
|
||||
failoverPriority={getFailoverPriority(provider.id)}
|
||||
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
||||
onToggleFailover={(enabled) =>
|
||||
handleToggleFailover(provider.id, enabled)
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
/>
|
||||
))}
|
||||
{filteredProviders.map((provider) => {
|
||||
const isOmo = provider.category === "omo";
|
||||
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
||||
return (
|
||||
<SortableProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isCurrent={
|
||||
isOmo ? isOmoCurrent : provider.id === currentProviderId
|
||||
}
|
||||
appId={appId}
|
||||
isInConfig={isProviderInConfig(provider.id)}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={
|
||||
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
||||
}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
isAutoFailoverEnabled={isFailoverModeActive}
|
||||
failoverPriority={getFailoverPriority(provider.id)}
|
||||
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
||||
onToggleFailover={(enabled) =>
|
||||
handleToggleFailover(provider.id, enabled)
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
@@ -334,11 +332,13 @@ interface SortableProviderCardProps {
|
||||
isCurrent: boolean;
|
||||
appId: AppId;
|
||||
isInConfig: boolean;
|
||||
isOmo: boolean;
|
||||
isLastOmo: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
@@ -347,7 +347,6 @@ interface SortableProviderCardProps {
|
||||
isTesting: boolean;
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover: boolean;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled: boolean;
|
||||
failoverPriority?: number;
|
||||
isInFailoverQueue: boolean;
|
||||
@@ -360,10 +359,13 @@ function SortableProviderCard({
|
||||
isCurrent,
|
||||
appId,
|
||||
isInConfig,
|
||||
isOmo,
|
||||
isLastOmo,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
@@ -399,10 +401,13 @@ function SortableProviderCard({
|
||||
isCurrent={isCurrent}
|
||||
appId={appId}
|
||||
isInConfig={isInConfig}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={isLastOmo}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={
|
||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||
@@ -418,7 +423,6 @@ function SortableProviderCard({
|
||||
listeners,
|
||||
isDragging,
|
||||
}}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
failoverPriority={failoverPriority}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, FolderInput, Loader2 } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import {
|
||||
OmoGlobalConfigFields,
|
||||
type OmoGlobalConfigFieldsRef,
|
||||
} from "./OmoGlobalConfigFields";
|
||||
import type { OmoGlobalConfig } from "@/types/omo";
|
||||
|
||||
interface OmoCommonConfigEditorProps {
|
||||
previewValue: string;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
isModalOpen: boolean;
|
||||
onEditClick: () => void;
|
||||
onModalClose: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
|
||||
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
|
||||
fieldsKey: number;
|
||||
}
|
||||
|
||||
export function OmoCommonConfigEditor({
|
||||
previewValue,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
isModalOpen,
|
||||
onEditClick,
|
||||
onModalClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
onGlobalConfigStateChange,
|
||||
globalConfigRef,
|
||||
fieldsKey,
|
||||
}: OmoCommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
useEffect(() => {
|
||||
const syncDarkMode = () =>
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
syncDarkMode();
|
||||
const observer = new MutationObserver(syncDarkMode);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const handleImportLocal = async () => {
|
||||
if (!globalConfigRef.current) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
await globalConfigRef.current.importFromLocal();
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("provider.configJson")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>
|
||||
{t("omo.writeCommonConfig", {
|
||||
defaultValue: "Write to common config",
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditClick}
|
||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{t("omo.editCommonConfig", { defaultValue: "Edit common config" })}
|
||||
</button>
|
||||
</div>
|
||||
<JsonEditor
|
||||
value={previewValue}
|
||||
onChange={() => {}}
|
||||
darkMode={isDarkMode}
|
||||
rows={14}
|
||||
showValidation={false}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
<FullScreenPanel
|
||||
isOpen={isModalOpen}
|
||||
title={t("omo.editCommonConfigTitle", {
|
||||
defaultValue: "Edit OMO Common Config",
|
||||
})}
|
||||
onClose={onModalClose}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleImportLocal}
|
||||
disabled={isImporting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<FolderInput className="w-4 h-4" />
|
||||
)}
|
||||
{t("common.import", { defaultValue: "Import" })}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("omo.commonConfigHint", {
|
||||
defaultValue:
|
||||
"OMO common config will be merged into all OMO configs that enable it",
|
||||
})}
|
||||
</p>
|
||||
<OmoGlobalConfigFields
|
||||
key={fieldsKey}
|
||||
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
|
||||
onStateChange={onGlobalConfigStateChange}
|
||||
hideSaveButtons
|
||||
/>
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,739 @@
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Save,
|
||||
Loader2,
|
||||
X,
|
||||
FolderInput,
|
||||
RotateCcw,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { OmoGlobalConfig } from "@/types/omo";
|
||||
import {
|
||||
OMO_DISABLEABLE_AGENTS,
|
||||
OMO_DISABLEABLE_MCPS,
|
||||
OMO_DISABLEABLE_HOOKS,
|
||||
OMO_DISABLEABLE_SKILLS,
|
||||
OMO_DEFAULT_SCHEMA_URL,
|
||||
OMO_SISYPHUS_AGENT_PLACEHOLDER,
|
||||
OMO_LSP_PLACEHOLDER,
|
||||
OMO_EXPERIMENTAL_PLACEHOLDER,
|
||||
OMO_BACKGROUND_TASK_PLACEHOLDER,
|
||||
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
||||
OMO_CLAUDE_CODE_PLACEHOLDER,
|
||||
} from "@/types/omo";
|
||||
import {
|
||||
useOmoGlobalConfig,
|
||||
useSaveOmoGlobalConfig,
|
||||
useReadOmoLocalFile,
|
||||
} from "@/lib/query/omo";
|
||||
|
||||
interface PresetOption {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface OmoGlobalConfigFieldsRef {
|
||||
buildCurrentConfig: () => OmoGlobalConfig;
|
||||
buildCurrentConfigStrict: () => OmoGlobalConfig;
|
||||
importFromLocal: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface OmoGlobalConfigFieldsProps {
|
||||
onStateChange?: (config: OmoGlobalConfig) => void;
|
||||
hideSaveButtons?: boolean;
|
||||
}
|
||||
|
||||
type OmoAdvancedFieldKey =
|
||||
| "lspStr"
|
||||
| "experimentalStr"
|
||||
| "backgroundTaskStr"
|
||||
| "browserStr"
|
||||
| "claudeCodeStr";
|
||||
|
||||
const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
|
||||
key: OmoAdvancedFieldKey;
|
||||
labelKey: string;
|
||||
defaultLabel: string;
|
||||
placeholder: string;
|
||||
minHeight: string;
|
||||
}> = [
|
||||
{
|
||||
key: "lspStr",
|
||||
labelKey: "omo.advancedLsp",
|
||||
defaultLabel: "LSP Config",
|
||||
placeholder: OMO_LSP_PLACEHOLDER,
|
||||
minHeight: "200px",
|
||||
},
|
||||
{
|
||||
key: "experimentalStr",
|
||||
labelKey: "omo.advancedExperimental",
|
||||
defaultLabel: "Experimental Features",
|
||||
placeholder: OMO_EXPERIMENTAL_PLACEHOLDER,
|
||||
minHeight: "120px",
|
||||
},
|
||||
{
|
||||
key: "backgroundTaskStr",
|
||||
labelKey: "omo.advancedBackgroundTask",
|
||||
defaultLabel: "Background Tasks",
|
||||
placeholder: OMO_BACKGROUND_TASK_PLACEHOLDER,
|
||||
minHeight: "250px",
|
||||
},
|
||||
{
|
||||
key: "browserStr",
|
||||
labelKey: "omo.advancedBrowserAutomation",
|
||||
defaultLabel: "Browser Automation",
|
||||
placeholder: OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
||||
minHeight: "80px",
|
||||
},
|
||||
{
|
||||
key: "claudeCodeStr",
|
||||
labelKey: "omo.advancedClaudeCode",
|
||||
defaultLabel: "Claude Code",
|
||||
placeholder: OMO_CLAUDE_CODE_PLACEHOLDER,
|
||||
minHeight: "180px",
|
||||
},
|
||||
];
|
||||
|
||||
function TagListEditor({
|
||||
label,
|
||||
values,
|
||||
onChange,
|
||||
placeholder,
|
||||
presets,
|
||||
}: {
|
||||
label: string;
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
presets?: readonly PresetOption[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggleValue = (v: string) => {
|
||||
if (values.includes(v)) {
|
||||
onChange(values.filter((x) => x !== v));
|
||||
} else {
|
||||
onChange([...values, v]);
|
||||
}
|
||||
};
|
||||
const customValue = search.trim();
|
||||
const canAddCustom = customValue.length > 0 && !values.includes(customValue);
|
||||
const triggerText =
|
||||
values.length === 0
|
||||
? placeholder || t("omo.selectPlaceholder", { defaultValue: "Select..." })
|
||||
: values.length === 1
|
||||
? values[0]
|
||||
: `${values[0]} +${values.length - 1}`;
|
||||
|
||||
const availablePresets = presets?.filter(
|
||||
(p) =>
|
||||
!search.trim() ||
|
||||
p.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.value.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{values.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs text-muted-foreground"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
{t("omo.clear", { defaultValue: "Clear" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{values.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{values.map((v, i) => (
|
||||
<Badge
|
||||
key={`${v}-${i}`}
|
||||
variant="secondary"
|
||||
className="text-xs gap-1"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full h-8 px-3 rounded-md border border-input bg-background text-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
open && "ring-2 ring-ring",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
values.length > 0 ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{triggerText}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] p-0 z-[120]"
|
||||
>
|
||||
<div className="p-1.5 border-b border-border/30">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && canAddCustom) {
|
||||
e.preventDefault();
|
||||
onChange([...values, customValue]);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
t("omo.searchOrType", {
|
||||
defaultValue: "Search or type custom value...",
|
||||
})
|
||||
}
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{canAddCustom && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2.5 py-1.5 text-left text-sm border-b border-border/30 hover:bg-accent"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onChange([...values, customValue]);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
+ {customValue}
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
{availablePresets && availablePresets.length > 0 ? (
|
||||
availablePresets.map((p) => {
|
||||
const checked = values.includes(p.value);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={p.value}
|
||||
checked={checked}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={() => toggleValue(p.value)}
|
||||
className="text-sm"
|
||||
>
|
||||
{p.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||
{t("omo.noMatches", { defaultValue: "No matches" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonTextareaField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
minHeight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
minHeight?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || "{}"}
|
||||
className="font-mono text-sm"
|
||||
style={{ minHeight: minHeight || "100px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const OmoGlobalConfigFields = forwardRef<
|
||||
OmoGlobalConfigFieldsRef,
|
||||
OmoGlobalConfigFieldsProps
|
||||
>(function OmoGlobalConfigFields({ onStateChange, hideSaveButtons }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useOmoGlobalConfig();
|
||||
const saveMutation = useSaveOmoGlobalConfig();
|
||||
|
||||
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
|
||||
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
|
||||
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
|
||||
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
|
||||
const [disabledHooks, setDisabledHooks] = useState<string[]>([]);
|
||||
const [disabledSkills, setDisabledSkills] = useState<string[]>([]);
|
||||
const [lspStr, setLspStr] = useState("");
|
||||
const [experimentalStr, setExperimentalStr] = useState("");
|
||||
const [backgroundTaskStr, setBackgroundTaskStr] = useState("");
|
||||
const [browserStr, setBrowserStr] = useState("");
|
||||
const [claudeCodeStr, setClaudeCodeStr] = useState("");
|
||||
const [otherFieldsStr, setOtherFieldsStr] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
|
||||
setSchemaUrl(global.schemaUrl || OMO_DEFAULT_SCHEMA_URL);
|
||||
setSisyphusAgentStr(
|
||||
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
|
||||
);
|
||||
setDisabledAgents(global.disabledAgents || []);
|
||||
setDisabledMcps(global.disabledMcps || []);
|
||||
setDisabledHooks(global.disabledHooks || []);
|
||||
setDisabledSkills(global.disabledSkills || []);
|
||||
setLspStr(global.lsp ? JSON.stringify(global.lsp, null, 2) : "");
|
||||
setExperimentalStr(
|
||||
global.experimental ? JSON.stringify(global.experimental, null, 2) : "",
|
||||
);
|
||||
setBackgroundTaskStr(
|
||||
global.backgroundTask
|
||||
? JSON.stringify(global.backgroundTask, null, 2)
|
||||
: "",
|
||||
);
|
||||
setBrowserStr(
|
||||
global.browserAutomationEngine
|
||||
? JSON.stringify(global.browserAutomationEngine, null, 2)
|
||||
: "",
|
||||
);
|
||||
setClaudeCodeStr(
|
||||
global.claudeCode ? JSON.stringify(global.claudeCode, null, 2) : "",
|
||||
);
|
||||
setOtherFieldsStr(
|
||||
global.otherFields ? JSON.stringify(global.otherFields, null, 2) : "",
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config && !loaded) {
|
||||
applyGlobalState(config);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [config, loaded, applyGlobalState]);
|
||||
|
||||
const parseJsonField = useCallback(
|
||||
(
|
||||
fieldName: string,
|
||||
raw: string,
|
||||
strict: boolean,
|
||||
): Record<string, unknown> | undefined => {
|
||||
if (!raw.trim()) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
if (strict) {
|
||||
throw new Error(
|
||||
t("omo.jsonMustBeObject", {
|
||||
field: fieldName,
|
||||
defaultValue: "{{field}} must be a JSON object",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if (strict) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
t("omo.jsonInvalid", {
|
||||
field: fieldName,
|
||||
defaultValue: "{{field}} contains invalid JSON",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const buildCurrentConfigInternal = useCallback(
|
||||
(strict: boolean): OmoGlobalConfig => {
|
||||
return {
|
||||
id: "global",
|
||||
schemaUrl: schemaUrl || undefined,
|
||||
sisyphusAgent: parseJsonField(
|
||||
t("omo.sisyphusAgentConfig", {
|
||||
defaultValue: "Sisyphus Agent",
|
||||
}),
|
||||
sisyphusAgentStr,
|
||||
strict,
|
||||
),
|
||||
disabledAgents,
|
||||
disabledMcps,
|
||||
disabledHooks,
|
||||
disabledSkills,
|
||||
lsp: parseJsonField(
|
||||
t("omo.advancedLsp", { defaultValue: "LSP" }),
|
||||
lspStr,
|
||||
strict,
|
||||
),
|
||||
experimental: parseJsonField(
|
||||
t("omo.advancedExperimental", { defaultValue: "Experimental" }),
|
||||
experimentalStr,
|
||||
strict,
|
||||
),
|
||||
backgroundTask: parseJsonField(
|
||||
t("omo.advancedBackgroundTask", {
|
||||
defaultValue: "Background Task",
|
||||
}),
|
||||
backgroundTaskStr,
|
||||
strict,
|
||||
),
|
||||
browserAutomationEngine: parseJsonField(
|
||||
t("omo.advancedBrowserAutomation", {
|
||||
defaultValue: "Browser Automation",
|
||||
}),
|
||||
browserStr,
|
||||
strict,
|
||||
),
|
||||
claudeCode: parseJsonField(
|
||||
t("omo.advancedClaudeCode", { defaultValue: "Claude Code" }),
|
||||
claudeCodeStr,
|
||||
strict,
|
||||
),
|
||||
otherFields: parseJsonField(
|
||||
t("omo.otherFields", {
|
||||
defaultValue: "Other Config",
|
||||
}),
|
||||
otherFieldsStr,
|
||||
strict,
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
[
|
||||
schemaUrl,
|
||||
sisyphusAgentStr,
|
||||
disabledAgents,
|
||||
disabledMcps,
|
||||
disabledHooks,
|
||||
disabledSkills,
|
||||
lspStr,
|
||||
experimentalStr,
|
||||
backgroundTaskStr,
|
||||
browserStr,
|
||||
claudeCodeStr,
|
||||
otherFieldsStr,
|
||||
parseJsonField,
|
||||
],
|
||||
);
|
||||
|
||||
const buildCurrentConfig = useCallback(
|
||||
() => buildCurrentConfigInternal(false),
|
||||
[buildCurrentConfigInternal],
|
||||
);
|
||||
|
||||
const buildCurrentConfigStrict = useCallback(
|
||||
() => buildCurrentConfigInternal(true),
|
||||
[buildCurrentConfigInternal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && onStateChange) {
|
||||
onStateChange(buildCurrentConfig());
|
||||
}
|
||||
}, [loaded, onStateChange, buildCurrentConfig]);
|
||||
|
||||
const handleSaveGlobal = useCallback(async () => {
|
||||
try {
|
||||
const result = buildCurrentConfigStrict();
|
||||
await saveMutation.mutateAsync(result);
|
||||
toast.success(
|
||||
t("omo.globalConfigSaved", {
|
||||
defaultValue: "Global config saved",
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(String(err));
|
||||
}
|
||||
}, [buildCurrentConfigStrict, saveMutation, t]);
|
||||
|
||||
const disabledCount =
|
||||
disabledAgents.length +
|
||||
disabledMcps.length +
|
||||
disabledHooks.length +
|
||||
disabledSkills.length;
|
||||
const advancedFieldValues: Record<OmoAdvancedFieldKey, string> = {
|
||||
lspStr,
|
||||
experimentalStr,
|
||||
backgroundTaskStr,
|
||||
browserStr,
|
||||
claudeCodeStr,
|
||||
};
|
||||
|
||||
const advancedFieldSetters: Record<
|
||||
OmoAdvancedFieldKey,
|
||||
(value: string) => void
|
||||
> = {
|
||||
lspStr: setLspStr,
|
||||
experimentalStr: setExperimentalStr,
|
||||
backgroundTaskStr: setBackgroundTaskStr,
|
||||
browserStr: setBrowserStr,
|
||||
claudeCodeStr: setClaudeCodeStr,
|
||||
};
|
||||
|
||||
const disabledEditorConfigs = [
|
||||
{
|
||||
key: "agents",
|
||||
label: t("omo.disabledAgents", { defaultValue: "Agents" }),
|
||||
values: disabledAgents,
|
||||
onChange: setDisabledAgents,
|
||||
placeholder: t("omo.disabledAgentsPlaceholder", {
|
||||
defaultValue: "Disabled Agents",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_AGENTS,
|
||||
},
|
||||
{
|
||||
key: "mcps",
|
||||
label: t("omo.disabledMcps", { defaultValue: "MCPs" }),
|
||||
values: disabledMcps,
|
||||
onChange: setDisabledMcps,
|
||||
placeholder: t("omo.disabledMcpsPlaceholder", {
|
||||
defaultValue: "Disabled MCPs",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_MCPS,
|
||||
},
|
||||
{
|
||||
key: "hooks",
|
||||
label: t("omo.disabledHooks", { defaultValue: "Hooks" }),
|
||||
values: disabledHooks,
|
||||
onChange: setDisabledHooks,
|
||||
placeholder: t("omo.disabledHooksPlaceholder", {
|
||||
defaultValue: "Disabled Hooks",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_HOOKS,
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
|
||||
values: disabledSkills,
|
||||
onChange: setDisabledSkills,
|
||||
placeholder: t("omo.disabledSkillsPlaceholder", {
|
||||
defaultValue: "Disabled Skills",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_SKILLS,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const readLocalFile = useReadOmoLocalFile();
|
||||
|
||||
const handleImportGlobalFromLocal = useCallback(async () => {
|
||||
try {
|
||||
const data = await readLocalFile.mutateAsync();
|
||||
applyGlobalState(data.global);
|
||||
toast.success(
|
||||
t("omo.importGlobalSuccess", {
|
||||
defaultValue: "Imported global config from local file (unsaved)",
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("omo.importGlobalFailed", {
|
||||
error: String(err),
|
||||
defaultValue: "Failed to read local file: {{error}}",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [readLocalFile, applyGlobalState, t]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
buildCurrentConfig,
|
||||
buildCurrentConfigStrict,
|
||||
importFromLocal: handleImportGlobalFromLocal,
|
||||
}),
|
||||
[buildCurrentConfig, buildCurrentConfigStrict, handleImportGlobalFromLocal],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!hideSaveButtons && (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={readLocalFile.isPending}
|
||||
onClick={handleImportGlobalFromLocal}
|
||||
>
|
||||
{readLocalFile.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<FolderInput className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{t("omo.importLocal", { defaultValue: "Import Local" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={handleSaveGlobal}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{t("omo.saveGlobalConfig", { defaultValue: "Save Global Config" })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">
|
||||
{t("omo.schemaUrl", { defaultValue: "$schema" })}
|
||||
</Label>
|
||||
{schemaUrl !== OMO_DEFAULT_SCHEMA_URL && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-1.5"
|
||||
onClick={() => setSchemaUrl(OMO_DEFAULT_SCHEMA_URL)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-0.5" />
|
||||
{t("omo.resetDefault", { defaultValue: "Reset" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={schemaUrl}
|
||||
onChange={(e) => setSchemaUrl(e.target.value)}
|
||||
placeholder={OMO_DEFAULT_SCHEMA_URL}
|
||||
className="text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("omo.sisyphusAgentConfig", {
|
||||
defaultValue: "Sisyphus Agent",
|
||||
})}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={sisyphusAgentStr}
|
||||
onChange={(e) => setSisyphusAgentStr(e.target.value)}
|
||||
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
|
||||
className="font-mono text-sm"
|
||||
style={{ minHeight: "140px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("omo.disabledItems", { defaultValue: "Disabled Items" })}
|
||||
</Label>
|
||||
{disabledCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs h-5">
|
||||
{disabledCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{disabledEditorConfigs.map((editor) => (
|
||||
<TagListEditor
|
||||
key={editor.key}
|
||||
label={editor.label}
|
||||
values={editor.values}
|
||||
onChange={editor.onChange}
|
||||
placeholder={editor.placeholder}
|
||||
presets={editor.presets}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
|
||||
</Label>
|
||||
{OMO_ADVANCED_JSON_FIELDS.map((field) => (
|
||||
<JsonTextareaField
|
||||
key={field.key}
|
||||
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
|
||||
value={advancedFieldValues[field.key]}
|
||||
onChange={advancedFieldSetters[field.key]}
|
||||
placeholder={field.placeholder}
|
||||
minHeight={field.minHeight}
|
||||
/>
|
||||
))}
|
||||
|
||||
<JsonTextareaField
|
||||
label={t("omo.otherFields", {
|
||||
defaultValue: "Other Config",
|
||||
})}
|
||||
value={otherFieldsStr}
|
||||
onChange={setOtherFieldsStr}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,6 @@ export function ProviderPresetSelector({
|
||||
}: ProviderPresetSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 根据分类获取提示文字
|
||||
const getCategoryHint = (): React.ReactNode => {
|
||||
switch (category) {
|
||||
case "official":
|
||||
@@ -63,6 +62,11 @@ export function ProviderPresetSelector({
|
||||
return t("providerForm.customApiKeyHint", {
|
||||
defaultValue: "💡 自定义配置需手动填写所有必要字段",
|
||||
});
|
||||
case "omo":
|
||||
return t("providerForm.omoHint", {
|
||||
defaultValue:
|
||||
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||
});
|
||||
default:
|
||||
return t("providerPreset.hint", {
|
||||
defaultValue: "选择预设后可继续调整下方字段。",
|
||||
@@ -70,7 +74,6 @@ export function ProviderPresetSelector({
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染预设按钮的图标
|
||||
const renderPresetIcon = (
|
||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||
) => {
|
||||
@@ -91,7 +94,6 @@ export function ProviderPresetSelector({
|
||||
}
|
||||
};
|
||||
|
||||
// 获取预设按钮的样式类名
|
||||
const getPresetButtonClass = (
|
||||
isSelected: boolean,
|
||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||
@@ -100,18 +102,15 @@ export function ProviderPresetSelector({
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||
|
||||
if (isSelected) {
|
||||
// 如果有自定义主题,使用自定义颜色
|
||||
if (preset.theme?.backgroundColor) {
|
||||
return `${baseClass} text-white`;
|
||||
}
|
||||
// 默认使用主题蓝色
|
||||
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
|
||||
};
|
||||
|
||||
// 获取预设按钮的内联样式(用于自定义背景色)
|
||||
const getPresetButtonStyle = (
|
||||
isSelected: boolean,
|
||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||
@@ -130,7 +129,6 @@ export function ProviderPresetSelector({
|
||||
<div className="space-y-3">
|
||||
<FormLabel>{t("providerPreset.label")}</FormLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 自定义按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPresetChange("custom")}
|
||||
@@ -143,7 +141,6 @@ export function ProviderPresetSelector({
|
||||
{t("providerPreset.custom")}
|
||||
</button>
|
||||
|
||||
{/* 预设按钮 */}
|
||||
{categoryKeys.map((category) => {
|
||||
const entries = groupedPresets[category];
|
||||
if (!entries || entries.length === 0) return null;
|
||||
@@ -174,7 +171,6 @@ export function ProviderPresetSelector({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 统一供应商预设(新的一行) */}
|
||||
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -196,7 +192,6 @@ export function ProviderPresetSelector({
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{/* 管理统一供应商按钮 */}
|
||||
{onManageUniversalProviders && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -19,21 +19,21 @@ export function ThemeSettings() {
|
||||
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
||||
<ThemeButton
|
||||
active={theme === "light"}
|
||||
onClick={() => setTheme("light")}
|
||||
onClick={(e) => setTheme("light", e)}
|
||||
icon={Sun}
|
||||
>
|
||||
{t("settings.themeLight")}
|
||||
</ThemeButton>
|
||||
<ThemeButton
|
||||
active={theme === "dark"}
|
||||
onClick={() => setTheme("dark")}
|
||||
onClick={(e) => setTheme("dark", e)}
|
||||
icon={Moon}
|
||||
>
|
||||
{t("settings.themeDark")}
|
||||
</ThemeButton>
|
||||
<ThemeButton
|
||||
active={theme === "system"}
|
||||
onClick={() => setTheme("system")}
|
||||
onClick={(e) => setTheme("system", e)}
|
||||
icon={Monitor}
|
||||
>
|
||||
{t("settings.themeSystem")}
|
||||
@@ -45,7 +45,7 @@ export function ThemeSettings() {
|
||||
|
||||
interface ThemeButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface ThemeProviderProps {
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setTheme: (theme: Theme, event?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeContextValue | undefined>(
|
||||
@@ -146,8 +146,30 @@ export function ThemeProvider({
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme: (nextTheme: Theme) => {
|
||||
setThemeState(nextTheme);
|
||||
setTheme: (nextTheme: Theme, event?: React.MouseEvent) => {
|
||||
// Skip if same theme
|
||||
if (nextTheme === theme) return;
|
||||
|
||||
// Set transition origin coordinates from click event
|
||||
const x = event?.clientX ?? window.innerWidth / 2;
|
||||
const y = event?.clientY ?? window.innerHeight / 2;
|
||||
document.documentElement.style.setProperty(
|
||||
"--theme-transition-x",
|
||||
`${x}px`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--theme-transition-y",
|
||||
`${y}px`,
|
||||
);
|
||||
|
||||
// Use View Transitions API if available, otherwise fall back to instant change
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => {
|
||||
setThemeState(nextTheme);
|
||||
});
|
||||
} else {
|
||||
setThemeState(nextTheme);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[theme],
|
||||
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useModelStats } from "@/lib/query/usage";
|
||||
import { fmtUsd } from "./format";
|
||||
|
||||
export function ModelStatsTable() {
|
||||
interface ModelStatsTableProps {
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: stats, isLoading } = useModelStats();
|
||||
const { data: stats, isLoading } = useModelStats({
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
@@ -60,10 +67,10 @@ export function ModelStatsTable() {
|
||||
{stat.totalTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(stat.totalCost).toFixed(4)}
|
||||
{fmtUsd(stat.totalCost, 4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(stat.avgCostPerRequest).toFixed(6)}
|
||||
{fmtUsd(stat.avgCostPerRequest, 6)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -8,10 +8,19 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useProviderStats } from "@/lib/query/usage";
|
||||
import { fmtUsd } from "./format";
|
||||
|
||||
export function ProviderStatsTable() {
|
||||
interface ProviderStatsTableProps {
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function ProviderStatsTable({
|
||||
refreshIntervalMs,
|
||||
}: ProviderStatsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: stats, isLoading } = useProviderStats();
|
||||
const { data: stats, isLoading } = useProviderStats({
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
@@ -63,7 +72,7 @@ export function ProviderStatsTable() {
|
||||
{stat.totalTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(stat.totalCost).toFixed(4)}
|
||||
{fmtUsd(stat.totalCost, 4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.successRate.toFixed(1)}%
|
||||
|
||||
@@ -21,44 +21,122 @@ import { useRequestLogs, usageKeys } from "@/lib/query/usage";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { LogFilters } from "@/types/usage";
|
||||
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
fmtInt,
|
||||
fmtUsd,
|
||||
getLocaleFromLanguage,
|
||||
parseFiniteNumber,
|
||||
} from "./format";
|
||||
|
||||
export function RequestLogTable() {
|
||||
interface RequestLogTableProps {
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
const ONE_DAY_SECONDS = 24 * 60 * 60;
|
||||
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
|
||||
|
||||
type TimeMode = "rolling" | "fixed";
|
||||
|
||||
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 默认时间范围:过去24小时
|
||||
const getDefaultFilters = (): LogFilters => {
|
||||
const getRollingRange = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneDayAgo = now - 24 * 60 * 60;
|
||||
const oneDayAgo = now - ONE_DAY_SECONDS;
|
||||
return { startDate: oneDayAgo, endDate: now };
|
||||
};
|
||||
|
||||
const [filters, setFilters] = useState<LogFilters>(getDefaultFilters);
|
||||
const [tempFilters, setTempFilters] = useState<LogFilters>(getDefaultFilters);
|
||||
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
|
||||
const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
|
||||
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 20;
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const { data: result, isLoading } = useRequestLogs(filters, page, pageSize);
|
||||
const { data: result, isLoading } = useRequestLogs({
|
||||
filters: appliedFilters,
|
||||
timeMode: appliedTimeMode,
|
||||
rollingWindowSeconds: ONE_DAY_SECONDS,
|
||||
page,
|
||||
pageSize,
|
||||
options: {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
},
|
||||
});
|
||||
|
||||
const logs = result?.data ?? [];
|
||||
const total = result?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters(tempFilters);
|
||||
setValidationError(null);
|
||||
|
||||
if (draftTimeMode === "fixed") {
|
||||
const start = draftFilters.startDate;
|
||||
const end = draftFilters.endDate;
|
||||
|
||||
if (typeof start !== "number" || typeof end !== "number") {
|
||||
setValidationError(
|
||||
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (start > end) {
|
||||
setValidationError(
|
||||
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (end - start > MAX_FIXED_RANGE_SECONDS) {
|
||||
setValidationError(
|
||||
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setAppliedTimeMode(draftTimeMode);
|
||||
setAppliedFilters((prev) => {
|
||||
const next = { ...prev, ...draftFilters };
|
||||
if (draftTimeMode === "rolling") {
|
||||
delete next.startDate;
|
||||
delete next.endDate;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults = getDefaultFilters();
|
||||
setTempFilters(defaults);
|
||||
setFilters(defaults);
|
||||
setValidationError(null);
|
||||
setAppliedTimeMode("rolling");
|
||||
setDraftTimeMode("rolling");
|
||||
setDraftFilters({});
|
||||
setAppliedFilters({});
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
const key = {
|
||||
timeMode: appliedTimeMode,
|
||||
rollingWindowSeconds:
|
||||
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
|
||||
appType: appliedFilters.appType,
|
||||
providerName: appliedFilters.providerName,
|
||||
model: appliedFilters.model,
|
||||
statusCode: appliedFilters.statusCode,
|
||||
startDate:
|
||||
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
|
||||
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
|
||||
};
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: usageKeys.logs(filters, page, pageSize),
|
||||
queryKey: usageKeys.logs(key, page, pageSize),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,12 +162,11 @@ export function RequestLogTable() {
|
||||
return Math.floor(timestamp / 1000);
|
||||
};
|
||||
|
||||
const dateLocale =
|
||||
i18n.language === "zh"
|
||||
? "zh-CN"
|
||||
: i18n.language === "ja"
|
||||
? "ja-JP"
|
||||
: "en-US";
|
||||
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||
const locale = getLocaleFromLanguage(language);
|
||||
|
||||
const rollingRangeForDisplay =
|
||||
draftTimeMode === "rolling" ? getRollingRange() : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -97,10 +174,10 @@ export function RequestLogTable() {
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={tempFilters.appType || "all"}
|
||||
value={draftFilters.appType || "all"}
|
||||
onValueChange={(v) =>
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
appType: v === "all" ? undefined : v,
|
||||
})
|
||||
}
|
||||
@@ -117,11 +194,16 @@ export function RequestLogTable() {
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={tempFilters.statusCode?.toString() || "all"}
|
||||
value={draftFilters.statusCode?.toString() || "all"}
|
||||
onValueChange={(v) =>
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
statusCode: v === "all" ? undefined : parseInt(v),
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
statusCode:
|
||||
v === "all"
|
||||
? undefined
|
||||
: Number.isFinite(Number.parseInt(v, 10))
|
||||
? Number.parseInt(v, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -144,10 +226,10 @@ export function RequestLogTable() {
|
||||
<Input
|
||||
placeholder={t("usage.searchProviderPlaceholder")}
|
||||
className="pl-9 bg-background"
|
||||
value={tempFilters.providerName || ""}
|
||||
value={draftFilters.providerName || ""}
|
||||
onChange={(e) =>
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
providerName: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
@@ -156,10 +238,10 @@ export function RequestLogTable() {
|
||||
<Input
|
||||
placeholder={t("usage.searchModelPlaceholder")}
|
||||
className="w-[180px] bg-background"
|
||||
value={tempFilters.model || ""}
|
||||
value={draftFilters.model || ""}
|
||||
onChange={(e) =>
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
model: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
@@ -174,14 +256,18 @@ export function RequestLogTable() {
|
||||
type="datetime-local"
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
tempFilters.startDate
|
||||
? timestampToLocalDatetime(tempFilters.startDate)
|
||||
(rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
|
||||
? timestampToLocalDatetime(
|
||||
(rollingRangeForDisplay?.startDate ??
|
||||
draftFilters.startDate) as number,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
setDraftTimeMode("fixed");
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
startDate: timestamp,
|
||||
});
|
||||
}}
|
||||
@@ -191,14 +277,18 @@ export function RequestLogTable() {
|
||||
type="datetime-local"
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
tempFilters.endDate
|
||||
? timestampToLocalDatetime(tempFilters.endDate)
|
||||
(rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
|
||||
? timestampToLocalDatetime(
|
||||
(rollingRangeForDisplay?.endDate ??
|
||||
draftFilters.endDate) as number,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
setDraftTimeMode("fixed");
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
endDate: timestamp,
|
||||
});
|
||||
}}
|
||||
@@ -234,6 +324,10 @@ export function RequestLogTable() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<div className="text-sm text-red-600">{validationError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -293,9 +387,7 @@ export function RequestLogTable() {
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell>
|
||||
{new Date(log.createdAt * 1000).toLocaleString(
|
||||
dateLocale,
|
||||
)}
|
||||
{new Date(log.createdAt * 1000).toLocaleString(locale)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
@@ -321,19 +413,19 @@ export function RequestLogTable() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.inputTokens.toLocaleString()}
|
||||
{fmtInt(log.inputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.outputTokens.toLocaleString()}
|
||||
{fmtInt(log.outputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.cacheReadTokens.toLocaleString()}
|
||||
{fmtInt(log.cacheReadTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.cacheCreationTokens.toLocaleString()}
|
||||
{fmtInt(log.cacheCreationTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{parseFloat(log.costMultiplier) !== 1 ? (
|
||||
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
|
||||
<span className="text-orange-600">
|
||||
×{log.costMultiplier}
|
||||
</span>
|
||||
@@ -342,24 +434,30 @@ export function RequestLogTable() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||
{fmtUsd(log.totalCostUsd, 6)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{(() => {
|
||||
const durationSec =
|
||||
(log.durationMs ?? log.latencyMs) / 1000;
|
||||
const durationColor =
|
||||
durationSec <= 5
|
||||
const durationMs =
|
||||
typeof log.durationMs === "number"
|
||||
? log.durationMs
|
||||
: log.latencyMs;
|
||||
const durationSec = durationMs / 1000;
|
||||
const durationColor = Number.isFinite(durationSec)
|
||||
? durationSec <= 5
|
||||
? "bg-green-100 text-green-800"
|
||||
: durationSec <= 120
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: "bg-red-200 text-red-900";
|
||||
: "bg-red-200 text-red-900"
|
||||
: "bg-gray-100 text-gray-700";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
|
||||
>
|
||||
{Math.round(durationSec)}s
|
||||
{Number.isFinite(durationSec)
|
||||
? `${Math.round(durationSec)}s`
|
||||
: "--"}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
@@ -367,17 +465,20 @@ export function RequestLogTable() {
|
||||
log.firstTokenMs != null &&
|
||||
(() => {
|
||||
const firstSec = log.firstTokenMs / 1000;
|
||||
const firstColor =
|
||||
firstSec <= 5
|
||||
const firstColor = Number.isFinite(firstSec)
|
||||
? firstSec <= 5
|
||||
? "bg-green-100 text-green-800"
|
||||
: firstSec <= 120
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: "bg-red-200 text-red-900";
|
||||
: "bg-red-200 text-red-900"
|
||||
: "bg-gray-100 text-gray-700";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
|
||||
>
|
||||
{firstSec.toFixed(1)}s
|
||||
{Number.isFinite(firstSec)
|
||||
? `${firstSec.toFixed(1)}s`
|
||||
: "--"}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -8,11 +8,28 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
|
||||
import { ModelStatsTable } from "./ModelStatsTable";
|
||||
import type { TimeRange } from "@/types/usage";
|
||||
import { motion } from "framer-motion";
|
||||
import { BarChart3, ListFilter, Activity } from "lucide-react";
|
||||
import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usageKeys } from "@/lib/query/usage";
|
||||
|
||||
export function UsageDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
|
||||
|
||||
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
|
||||
const changeRefreshInterval = () => {
|
||||
const currentIndex = refreshIntervalOptionsMs.indexOf(
|
||||
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
|
||||
);
|
||||
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s
|
||||
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
|
||||
const next = refreshIntervalOptionsMs[nextIndex];
|
||||
setRefreshIntervalMs(next);
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
};
|
||||
|
||||
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
||||
|
||||
@@ -34,32 +51,45 @@ export function UsageDashboard() {
|
||||
onValueChange={(v) => setTimeRange(v as TimeRange)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
|
||||
<TabsTrigger
|
||||
value="1d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
<div className="flex w-full sm:w-auto items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-10 px-2 text-xs text-muted-foreground"
|
||||
title={t("common.refresh", "刷新")}
|
||||
onClick={changeRefreshInterval}
|
||||
>
|
||||
{t("usage.today")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="7d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last7days")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="30d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last30days")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
|
||||
</Button>
|
||||
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
|
||||
<TabsTrigger
|
||||
value="1d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.today")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="7d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last7days")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="30d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last30days")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<UsageSummaryCards days={days} />
|
||||
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||
|
||||
<UsageTrendChart days={days} />
|
||||
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="logs" className="w-full">
|
||||
@@ -86,15 +116,15 @@ export function UsageDashboard() {
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<TabsContent value="logs" className="mt-0">
|
||||
<RequestLogTable />
|
||||
<RequestLogTable refreshIntervalMs={refreshIntervalMs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="providers" className="mt-0">
|
||||
<ProviderStatsTable />
|
||||
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="mt-0">
|
||||
<ModelStatsTable />
|
||||
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
|
||||
@@ -4,19 +4,26 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useUsageSummary } from "@/lib/query/usage";
|
||||
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { fmtUsd, parseFiniteNumber } from "./format";
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
days: number;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
export function UsageSummaryCards({
|
||||
days,
|
||||
refreshIntervalMs,
|
||||
}: UsageSummaryCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: summary, isLoading } = useUsageSummary(days);
|
||||
const { data: summary, isLoading } = useUsageSummary(days, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalRequests = summary?.totalRequests ?? 0;
|
||||
const totalCost = parseFloat(summary?.totalCost || "0");
|
||||
const totalCost = parseFiniteNumber(summary?.totalCost);
|
||||
|
||||
const inputTokens = summary?.totalInputTokens ?? 0;
|
||||
const outputTokens = summary?.totalOutputTokens ?? 0;
|
||||
@@ -37,7 +44,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
},
|
||||
{
|
||||
title: t("usage.totalCost"),
|
||||
value: `$${totalCost.toFixed(4)}`,
|
||||
value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
|
||||
icon: DollarSign,
|
||||
color: "text-green-500",
|
||||
bg: "bg-green-500/10",
|
||||
|
||||
@@ -11,14 +11,26 @@ import {
|
||||
} from "recharts";
|
||||
import { useUsageTrends } from "@/lib/query/usage";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
fmtInt,
|
||||
fmtUsd,
|
||||
getLocaleFromLanguage,
|
||||
parseFiniteNumber,
|
||||
} from "./format";
|
||||
|
||||
interface UsageTrendChartProps {
|
||||
days: number;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
export function UsageTrendChart({
|
||||
days,
|
||||
refreshIntervalMs,
|
||||
}: UsageTrendChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: trends, isLoading } = useUsageTrends(days);
|
||||
const { data: trends, isLoading } = useUsageTrends(days, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -29,15 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
}
|
||||
|
||||
const isToday = days === 1;
|
||||
const dateLocale =
|
||||
i18n.language === "zh"
|
||||
? "zh-CN"
|
||||
: i18n.language === "ja"
|
||||
? "ja-JP"
|
||||
: "en-US";
|
||||
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||
const dateLocale = getLocaleFromLanguage(language);
|
||||
const chartData =
|
||||
trends?.map((stat) => {
|
||||
const pointDate = new Date(stat.date);
|
||||
const cost = parseFiniteNumber(stat.totalCost);
|
||||
return {
|
||||
rawDate: stat.date,
|
||||
label: isToday
|
||||
@@ -56,7 +65,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
outputTokens: stat.totalOutputTokens,
|
||||
cacheCreationTokens: stat.totalCacheCreationTokens,
|
||||
cacheReadTokens: stat.totalCacheReadTokens,
|
||||
cost: parseFloat(stat.totalCost),
|
||||
cost: cost ?? null,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
@@ -79,9 +88,9 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
/>
|
||||
<span className="font-medium">{entry.name}:</span>
|
||||
<span>
|
||||
{entry.name.includes(t("usage.cost", "成本"))
|
||||
? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}`
|
||||
: entry.value.toLocaleString()}
|
||||
{entry.dataKey === "cost"
|
||||
? fmtUsd(entry.value, 6)
|
||||
: fmtInt(entry.value, dateLocale)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export function parseFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function fmtInt(
|
||||
value: unknown,
|
||||
locale?: string,
|
||||
fallback: string = "--",
|
||||
): string {
|
||||
const num = parseFiniteNumber(value);
|
||||
if (num == null) return fallback;
|
||||
return new Intl.NumberFormat(locale).format(Math.trunc(num));
|
||||
}
|
||||
|
||||
export function fmtUsd(
|
||||
value: unknown,
|
||||
digits: number,
|
||||
fallback: string = "--",
|
||||
): string {
|
||||
const num = parseFiniteNumber(value);
|
||||
if (num == null) return fallback;
|
||||
return `$${num.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function getLocaleFromLanguage(language: string): string {
|
||||
if (!language) return "en-US";
|
||||
if (language.startsWith("zh")) return "zh-CN";
|
||||
if (language.startsWith("ja")) return "ja-JP";
|
||||
return "en-US";
|
||||
}
|
||||
Reference in New Issue
Block a user