style(ui): modernize component layouts and visual design
Update UI components with improved layouts, visual hierarchy, and modern design patterns for better user experience. Navigation & Brand Components: - AppSwitcher * Enhanced visual design with better spacing * Improved active state indicators * Smoother transitions and hover effects * Better mobile responsiveness - BrandIcons * Optimized icon rendering performance * Added support for more provider icons * Improved SVG handling and fallbacks * Better scaling across different screen sizes Editor Components: - JsonEditor * Enhanced syntax highlighting * Better error visualization * Improved code formatting options * Added line numbers and code folding support - UsageScriptModal * Complete layout overhaul (1239 lines refactored) * Better script editor integration * Improved template selection UI * Enhanced preview and testing panels * Better error feedback and validation Provider Components: - ProviderCard * Redesigned card layout with modern aesthetics * Better information density and readability * Improved action buttons placement * Enhanced status indicators (active/inactive) - ProviderList * Better grid/list view layouts * Improved drag-and-drop visual feedback * Enhanced sorting indicators - ProviderActions * Streamlined action menu * Better icon consistency * Improved tooltips and accessibility Usage & Footer: - UsageFooter * Redesigned footer layout * Better quota visualization * Improved refresh controls * Enhanced error states Design System Updates: - dialog.tsx (shadcn/ui component) * Updated to latest design tokens * Better overlay animations * Improved focus management - index.css * Added 65 lines of global utility classes * New animation keyframes * Enhanced color variables for dark mode * Improved typography scale - tailwind.config.js * Extended theme with new design tokens * Added custom animations and transitions * New spacing and sizing utilities * Enhanced color palette Visual Improvements: - Consistent border radius across components - Unified shadow system for depth perception - Better color contrast for accessibility (WCAG AA) - Smoother animations and transitions - Improved dark mode support These changes create a more polished, modern interface while maintaining consistency with the application's design language.
This commit is contained in:
@@ -13,22 +13,28 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent ">
|
<div className="glass p-1.5 rounded-full flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("claude")}
|
onClick={() => handleSwitch("claude")}
|
||||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group relative flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-semibold overflow-hidden transition-all duration-300 ease-out ${
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
? "text-white scale-[1.02] shadow-[0_12px_35px_-15px_rgba(249,115,22,0.8)] ring-1 ring-white/10"
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{activeApp === "claude" && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-orange-500 via-amber-500 to-red-600 rounded-full opacity-90 blur-[1px] transition-all duration-500 -z-10 scale-100" />
|
||||||
|
)}
|
||||||
|
{activeApp !== "claude" && (
|
||||||
|
<div className="absolute inset-0 rounded-full bg-white/0 transition-all duration-300 -z-10" />
|
||||||
|
)}
|
||||||
<ClaudeIcon
|
<ClaudeIcon
|
||||||
size={16}
|
size={16}
|
||||||
className={
|
className={
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
|
? "text-white"
|
||||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
|
: "text-muted-foreground group-hover:text-orange-500 transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Claude</span>
|
<span>Claude</span>
|
||||||
@@ -37,31 +43,50 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("codex")}
|
onClick={() => handleSwitch("codex")}
|
||||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group relative flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-semibold overflow-hidden transition-all duration-300 ease-out ${
|
||||||
activeApp === "codex"
|
activeApp === "codex"
|
||||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
? "text-white scale-[1.02] shadow-[0_12px_35px_-15px_rgba(59,130,246,0.8)] ring-1 ring-white/10"
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CodexIcon size={16} />
|
{activeApp === "codex" && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 via-sky-500 to-cyan-500 rounded-full opacity-90 blur-[1px] transition-all duration-500 -z-10 scale-100" />
|
||||||
|
)}
|
||||||
|
{activeApp !== "codex" && (
|
||||||
|
<div className="absolute inset-0 rounded-full bg-white/0 transition-all duration-300 -z-10" />
|
||||||
|
)}
|
||||||
|
<CodexIcon
|
||||||
|
size={16}
|
||||||
|
className={
|
||||||
|
activeApp === "codex"
|
||||||
|
? "text-white"
|
||||||
|
: "text-muted-foreground group-hover:text-blue-500 transition-colors"
|
||||||
|
}
|
||||||
|
/>
|
||||||
<span>Codex</span>
|
<span>Codex</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("gemini")}
|
onClick={() => handleSwitch("gemini")}
|
||||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group relative flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-semibold overflow-hidden transition-all duration-300 ease-out ${
|
||||||
activeApp === "gemini"
|
activeApp === "gemini"
|
||||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
? "text-white scale-[1.02] shadow-[0_12px_35px_-15px_rgba(99,102,241,0.8)] ring-1 ring-white/10"
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{activeApp === "gemini" && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500 via-violet-500 to-purple-600 rounded-full opacity-90 blur-[1px] transition-all duration-500 -z-10 scale-100" />
|
||||||
|
)}
|
||||||
|
{activeApp !== "gemini" && (
|
||||||
|
<div className="absolute inset-0 rounded-full bg-white/0 transition-all duration-300 -z-10" />
|
||||||
|
)}
|
||||||
<GeminiIcon
|
<GeminiIcon
|
||||||
size={16}
|
size={16}
|
||||||
className={
|
className={
|
||||||
activeApp === "gemini"
|
activeApp === "gemini"
|
||||||
? "text-[#4285F4] dark:text-[#4285F4] transition-colors duration-200"
|
? "text-white"
|
||||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#4285F4] dark:group-hover:text-[#4285F4] transition-colors duration-200"
|
: "text-muted-foreground group-hover:text-indigo-500 transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Gemini</span>
|
<span>Gemini</span>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -84,14 +84,35 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
|
|
||||||
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
|
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
"&light .cm-editor, &dark .cm-editor": {
|
".cm-editor": {
|
||||||
border: "1px solid hsl(var(--border))",
|
border: "1px solid hsl(var(--border))",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
|
background: "transparent",
|
||||||
},
|
},
|
||||||
"&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": {
|
".cm-editor.cm-focused": {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
borderColor: "hsl(var(--primary))",
|
borderColor: "hsl(var(--primary))",
|
||||||
},
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
background: "transparent",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
background: "transparent",
|
||||||
|
borderRight: "1px solid hsl(var(--border))",
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, .cm-content ::selection": {
|
||||||
|
background: "hsl(var(--primary) / 0.18)",
|
||||||
|
},
|
||||||
|
".cm-selectionMatch": {
|
||||||
|
background: "hsl(var(--primary) / 0.12)",
|
||||||
|
},
|
||||||
|
".cm-activeLine": {
|
||||||
|
background: "hsl(var(--primary) / 0.08)",
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
background: "hsl(var(--primary) / 0.08)",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 theme 定义尺寸和字体样式
|
// 使用 theme 定义尺寸和字体样式
|
||||||
@@ -129,11 +150,32 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
".cm-editor": {
|
".cm-editor": {
|
||||||
border: "1px solid hsl(var(--border))",
|
border: "1px solid hsl(var(--border))",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
|
background: "transparent",
|
||||||
},
|
},
|
||||||
".cm-editor.cm-focused": {
|
".cm-editor.cm-focused": {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
borderColor: "hsl(var(--primary))",
|
borderColor: "hsl(var(--primary))",
|
||||||
},
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
background: "transparent",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
background: "transparent",
|
||||||
|
borderRight: "1px solid hsl(var(--border))",
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, .cm-content ::selection": {
|
||||||
|
background: "hsl(var(--primary) / 0.18)",
|
||||||
|
},
|
||||||
|
".cm-selectionMatch": {
|
||||||
|
background: "hsl(var(--primary) / 0.12)",
|
||||||
|
},
|
||||||
|
".cm-activeLine": {
|
||||||
|
background: "hsl(var(--primary) / 0.08)",
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
background: "hsl(var(--primary) / 0.08)",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
if (!usage.success) {
|
if (!usage.success) {
|
||||||
if (inline) {
|
if (inline) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
|
||||||
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
|
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
|
||||||
<AlertCircle size={12} />
|
<AlertCircle size={12} />
|
||||||
<span>{t("usage.queryFailed")}</span>
|
<span>{t("usage.queryFailed")}</span>
|
||||||
@@ -68,7 +68,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
title={t("usage.refreshUsage")}
|
title={t("usage.refreshUsage")}
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
@@ -78,7 +78,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-border-default ">
|
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
|
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
|
||||||
<AlertCircle size={14} />
|
<AlertCircle size={14} />
|
||||||
@@ -110,9 +110,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
const isExpired = firstUsage.isValid === false;
|
const isExpired = firstUsage.isValid === false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 text-xs flex-shrink-0">
|
<div className="inline-flex items-center gap-3 text-xs whitespace-nowrap flex-shrink-0 rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
|
||||||
{/* 第一行:刷新时间 + 刷新按钮 */}
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
{/* 上次查询时间 */}
|
{/* 上次查询时间 */}
|
||||||
{lastQueriedAt && (
|
{lastQueriedAt && (
|
||||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||||
@@ -121,37 +119,20 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 刷新按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={loading}
|
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
|
||||||
title={t("usage.refreshUsage")}
|
|
||||||
>
|
|
||||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第二行:已用 + 剩余 + 单位 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* 已用 */}
|
{/* 已用 */}
|
||||||
{firstUsage.used !== undefined && (
|
{firstUsage.used !== undefined && (
|
||||||
<div className="flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-muted-foreground">{t("usage.used")}</span>
|
||||||
{t("usage.used")}
|
<span className="tabular-nums font-medium">
|
||||||
</span>
|
|
||||||
<span className="tabular-nums text-gray-600 dark:text-gray-400 font-medium">
|
|
||||||
{firstUsage.used.toFixed(2)}
|
{firstUsage.used.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 剩余 */}
|
{/* 剩余 */}
|
||||||
{firstUsage.remaining !== undefined && (
|
{firstUsage.remaining !== undefined && (
|
||||||
<div className="flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-muted-foreground">{t("usage.remaining")}</span>
|
||||||
{t("usage.remaining")}
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
className={`font-semibold tabular-nums ${
|
className={`font-semibold tabular-nums ${
|
||||||
isExpired
|
isExpired
|
||||||
@@ -164,22 +145,29 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
>
|
>
|
||||||
{firstUsage.remaining.toFixed(2)}
|
{firstUsage.remaining.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 单位 */}
|
{/* 单位 */}
|
||||||
{firstUsage.unit && (
|
{firstUsage.unit && (
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">{firstUsage.unit}</span>
|
||||||
{firstUsage.unit}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* 刷新按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
|
title={t("usage.refreshUsage")}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-border-default ">
|
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
|
||||||
{/* 标题行:包含刷新按钮和自动查询时间 */}
|
{/* 标题行:包含刷新按钮和自动查询时间 */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
@@ -196,7 +184,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
title={t("usage.refreshUsage")}
|
title={t("usage.refreshUsage")}
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Play, Wand2, Eye, EyeOff } from "lucide-react";
|
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Provider, UsageScript } from "@/types";
|
import { Provider, UsageScript } from "@/types";
|
||||||
@@ -8,17 +8,12 @@ import JsonEditor from "./JsonEditor";
|
|||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
import * as parserBabel from "prettier/parser-babel";
|
import * as parserBabel from "prettier/parser-babel";
|
||||||
import * as pluginEstree from "prettier/plugins/estree";
|
import * as pluginEstree from "prettier/plugins/estree";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface UsageScriptModalProps {
|
interface UsageScriptModalProps {
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
@@ -131,88 +126,53 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
|
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
// 🔧 输入时的格式化(宽松)- 只清理格式,不约束范围
|
|
||||||
const sanitizeNumberInput = (value: string): string => {
|
|
||||||
// 移除所有非数字字符
|
|
||||||
let cleaned = value.replace(/[^\d]/g, "");
|
|
||||||
|
|
||||||
// 移除前导零(除非输入的就是 "0")
|
|
||||||
if (cleaned.length > 1 && cleaned.startsWith("0")) {
|
|
||||||
cleaned = cleaned.replace(/^0+/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleaned;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🔧 失焦时的验证(严格)- 仅确保有效整数
|
// 🔧 失焦时的验证(严格)- 仅确保有效整数
|
||||||
const validateTimeout = (value: string): number => {
|
const validateTimeout = (value: string): number => {
|
||||||
// 转换为数字
|
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
|
|
||||||
// 检查是否为有效数字
|
|
||||||
if (isNaN(num) || value.trim() === "") {
|
if (isNaN(num) || value.trim() === "") {
|
||||||
return 10; // 默认值
|
return 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否为整数
|
|
||||||
if (!Number.isInteger(num)) {
|
if (!Number.isInteger(num)) {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
|
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查负数
|
|
||||||
if (num < 0) {
|
if (num < 0) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
|
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
|
||||||
);
|
);
|
||||||
return 10;
|
return 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.floor(num);
|
return Math.floor(num);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔧 失焦时的验证(严格)- 自动查询间隔
|
// 🔧 失焦时的验证(严格)- 自动查询间隔
|
||||||
const validateAndClampInterval = (value: string): number => {
|
const validateAndClampInterval = (value: string): number => {
|
||||||
// 转换为数字
|
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
|
|
||||||
// 检查是否为有效数字
|
|
||||||
if (isNaN(num) || value.trim() === "") {
|
if (isNaN(num) || value.trim() === "") {
|
||||||
return 0; // 禁用自动查询
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否为整数
|
|
||||||
if (!Number.isInteger(num)) {
|
if (!Number.isInteger(num)) {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
|
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查负数
|
|
||||||
if (num < 0) {
|
if (num < 0) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
|
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
|
||||||
);
|
);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 约束到 [0, 1440] 范围(最大24小时)
|
|
||||||
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
|
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
|
||||||
|
|
||||||
// 如果值被调整,显示提示
|
|
||||||
if (clamped !== num && num > 0) {
|
if (clamped !== num && num > 0) {
|
||||||
toast.info(
|
toast.info(
|
||||||
t("usageScript.intervalAdjusted", { value: clamped }) ||
|
t("usageScript.intervalAdjusted", { value: clamped }) ||
|
||||||
`自动查询间隔已调整为 ${clamped} 分钟`,
|
`自动查询间隔已调整为 ${clamped} 分钟`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return clamped;
|
return clamped;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
|
|
||||||
// 初始化:如果已有 accessToken 或 userId,说明是 NewAPI 模板
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||||
() => {
|
() => {
|
||||||
const existingScript = provider.meta?.usage_script;
|
const existingScript = provider.meta?.usage_script;
|
||||||
@@ -223,23 +183,18 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 控制 API Key 的显示/隐藏
|
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [showAccessToken, setShowAccessToken] = useState(false);
|
const [showAccessToken, setShowAccessToken] = useState(false);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// 验证脚本格式
|
|
||||||
if (script.enabled && !script.code.trim()) {
|
if (script.enabled && !script.code.trim()) {
|
||||||
toast.error(t("usageScript.scriptEmpty"));
|
toast.error(t("usageScript.scriptEmpty"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基本的 JS 语法检查(检查是否包含 return 语句)
|
|
||||||
if (script.enabled && !script.code.includes("return")) {
|
if (script.enabled && !script.code.includes("return")) {
|
||||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(script);
|
onSave(script);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -247,7 +202,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
try {
|
try {
|
||||||
// 使用当前编辑器中的脚本内容进行测试
|
|
||||||
const result = await usageApi.testScript(
|
const result = await usageApi.testScript(
|
||||||
provider.id,
|
provider.id,
|
||||||
appId,
|
appId,
|
||||||
@@ -259,7 +213,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
script.userId,
|
script.userId,
|
||||||
);
|
);
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
// 显示所有套餐数据
|
|
||||||
const summary = result.data
|
const summary = result.data
|
||||||
.map((plan) => {
|
.map((plan) => {
|
||||||
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
||||||
@@ -314,9 +267,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const handleUsePreset = (presetName: string) => {
|
const handleUsePreset = (presetName: string) => {
|
||||||
const preset = PRESET_TEMPLATES[presetName];
|
const preset = PRESET_TEMPLATES[presetName];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
// 根据模板类型清空不同的字段
|
|
||||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||||
// 自定义:清空所有凭证字段
|
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
code: preset,
|
code: preset,
|
||||||
@@ -326,7 +277,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
userId: undefined,
|
userId: undefined,
|
||||||
});
|
});
|
||||||
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
|
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
|
||||||
// 通用:保留 apiKey 和 baseUrl,清空 NewAPI 字段
|
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
code: preset,
|
code: preset,
|
||||||
@@ -334,84 +284,120 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
userId: undefined,
|
userId: undefined,
|
||||||
});
|
});
|
||||||
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
|
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
|
||||||
// NewAPI:清空 apiKey(NewAPI 不使用通用的 apiKey)
|
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
code: preset,
|
code: preset,
|
||||||
apiKey: undefined,
|
apiKey: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setSelectedTemplate(presetName); // 记录选择的模板
|
setSelectedTemplate(presetName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否应该显示凭证配置区域
|
|
||||||
const shouldShowCredentialsConfig =
|
const shouldShowCredentialsConfig =
|
||||||
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
|
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
|
||||||
selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
||||||
|
|
||||||
return (
|
const footer = (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<>
|
||||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
<div className="flex gap-2">
|
||||||
<DialogHeader>
|
<Button
|
||||||
<DialogTitle>
|
variant="secondary"
|
||||||
{t("usageScript.title")} - {provider.name}
|
size="sm"
|
||||||
</DialogTitle>
|
onClick={handleTest}
|
||||||
</DialogHeader>
|
disabled={!script.enabled || testing}
|
||||||
|
>
|
||||||
|
<Play size={14} className="mr-1" />
|
||||||
|
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFormat}
|
||||||
|
disabled={!script.enabled}
|
||||||
|
title={t("usageScript.format")}
|
||||||
|
>
|
||||||
|
<Wand2 size={14} className="mr-1" />
|
||||||
|
{t("usageScript.format")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content - Scrollable */}
|
<div className="flex gap-2">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
<Button variant="outline" onClick={onClose} className="border-border/20 hover:bg-accent hover:text-accent-foreground">
|
||||||
{/* 启用开关 */}
|
{t("common.cancel")}
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border-default p-4">
|
</Button>
|
||||||
|
<Button onClick={handleSave} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
{t("usageScript.saveConfig")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullScreenPanel
|
||||||
|
isOpen={isOpen}
|
||||||
|
title={`${t("usageScript.title")} - ${provider.name}`}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={footer}
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm flex items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">
|
<p className="text-sm font-medium leading-none text-foreground">
|
||||||
{t("usageScript.enableUsageQuery")}
|
{t("usageScript.enableUsageQuery")}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("usageScript.autoQueryIntervalHint")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={script.enabled}
|
checked={script.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => setScript({ ...script, enabled: checked })}
|
||||||
setScript({ ...script, enabled: checked })
|
|
||||||
}
|
|
||||||
aria-label={t("usageScript.enableUsageQuery")}
|
aria-label={t("usageScript.enableUsageQuery")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{script.enabled && (
|
{script.enabled && (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
{/* 预设模板选择 */}
|
{/* 预设模板选择 */}
|
||||||
<div>
|
<div className="space-y-4 rounded-xl border border-border-default bg-card p-4 shadow-sm">
|
||||||
<Label className="mb-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{t("usageScript.presetTemplate")}
|
<Label className="text-base font-medium">{t("usageScript.presetTemplate")}</Label>
|
||||||
</Label>
|
<span className="text-xs text-muted-foreground">
|
||||||
<div className="flex gap-2">
|
{t("usageScript.variablesHint")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
{Object.keys(PRESET_TEMPLATES).map((name) => {
|
{Object.keys(PRESET_TEMPLATES).map((name) => {
|
||||||
const isSelected = selectedTemplate === name;
|
const isSelected = selectedTemplate === name;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={name}
|
key={name}
|
||||||
onClick={() => handleUsePreset(name)}
|
type="button"
|
||||||
className={`px-3 py-1.5 text-xs rounded transition-colors ${
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border",
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-blue-500 text-white dark:bg-blue-600"
|
? "shadow-sm"
|
||||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
}`}
|
)}
|
||||||
|
onClick={() => handleUsePreset(name)}
|
||||||
>
|
>
|
||||||
{t(TEMPLATE_NAME_KEYS[name])}
|
{t(TEMPLATE_NAME_KEYS[name])}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 凭证配置区域:通用和 NewAPI 模板显示 */}
|
{/* 凭证配置 */}
|
||||||
{shouldShowCredentialsConfig && (
|
{shouldShowCredentialsConfig && (
|
||||||
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
{t("usageScript.credentialsConfig")}
|
{t("usageScript.credentialsConfig")}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* 通用模板:显示 apiKey + baseUrl */}
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -426,23 +412,20 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
}
|
}
|
||||||
placeholder="sk-xxxxx"
|
placeholder="sk-xxxxx"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-card border-border-default"
|
||||||
/>
|
/>
|
||||||
{script.apiKey && (
|
{script.apiKey && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
aria-label={
|
aria-label={
|
||||||
showApiKey
|
showApiKey
|
||||||
? t("apiKeyInput.hide")
|
? t("apiKeyInput.hide")
|
||||||
: t("apiKeyInput.show")
|
: t("apiKeyInput.show")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{showApiKey ? (
|
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
<EyeOff size={16} />
|
|
||||||
) : (
|
|
||||||
<Eye size={16} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -459,12 +442,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
}
|
}
|
||||||
placeholder="https://api.example.com"
|
placeholder="https://api.example.com"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-card border-border-default"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* NewAPI 模板:显示 baseUrl + accessToken + userId */}
|
|
||||||
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -478,56 +461,43 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
}
|
}
|
||||||
placeholder="https://api.newapi.com"
|
placeholder="https://api.newapi.com"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-card border-border-default"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-access-token">
|
<Label htmlFor="usage-access-token">{t("usageScript.accessToken")}</Label>
|
||||||
{t("usageScript.accessToken")}
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="usage-access-token"
|
id="usage-access-token"
|
||||||
type={showAccessToken ? "text" : "password"}
|
type={showAccessToken ? "text" : "password"}
|
||||||
value={script.accessToken || ""}
|
value={script.accessToken || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({
|
setScript({ ...script, accessToken: e.target.value })
|
||||||
...script,
|
|
||||||
accessToken: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
placeholder={t(
|
placeholder={t("usageScript.accessTokenPlaceholder")}
|
||||||
"usageScript.accessTokenPlaceholder",
|
|
||||||
)}
|
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-card border-border-default"
|
||||||
/>
|
/>
|
||||||
{script.accessToken && (
|
{script.accessToken && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setShowAccessToken(!showAccessToken)}
|
||||||
setShowAccessToken(!showAccessToken)
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
}
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
|
||||||
aria-label={
|
aria-label={
|
||||||
showAccessToken
|
showAccessToken
|
||||||
? t("apiKeyInput.hide")
|
? t("apiKeyInput.hide")
|
||||||
: t("apiKeyInput.show")
|
: t("apiKeyInput.show")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{showAccessToken ? (
|
{showAccessToken ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
<EyeOff size={16} />
|
|
||||||
) : (
|
|
||||||
<Eye size={16} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-user-id">
|
<Label htmlFor="usage-user-id">{t("usageScript.userId")}</Label>
|
||||||
{t("usageScript.userId")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="usage-user-id"
|
id="usage-user-id"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -537,104 +507,194 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
}
|
}
|
||||||
placeholder={t("usageScript.userIdPlaceholder")}
|
placeholder={t("usageScript.userIdPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-card border-border-default"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 脚本编辑器 */}
|
{/* 脚本配置 */}
|
||||||
<div>
|
<div className="space-y-4 rounded-xl border border-border-default bg-card p-4 shadow-sm">
|
||||||
<Label className="mb-2">{t("usageScript.queryScript")}</Label>
|
<div className="flex items-center justify-between">
|
||||||
<JsonEditor
|
<h4 className="text-base font-medium text-foreground">
|
||||||
value={script.code}
|
{t("usageScript.scriptConfig")}
|
||||||
onChange={(code) => setScript({ ...script, code })}
|
</h4>
|
||||||
height="300px"
|
<p className="text-xs text-muted-foreground">
|
||||||
language="javascript"
|
{t("usageScript.variablesHint")}
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("usageScript.variablesHint", {
|
|
||||||
apiKey: "{{apiKey}}",
|
|
||||||
baseUrl: "{{baseUrl}}",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 配置选项 */}
|
<div className="grid gap-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-timeout">
|
<Label htmlFor="usage-request-url">{t("usageScript.requestUrl")}</Label>
|
||||||
{t("usageScript.timeoutSeconds")}
|
<Input
|
||||||
</Label>
|
id="usage-request-url"
|
||||||
|
type="text"
|
||||||
|
value={script.request?.url || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setScript({
|
||||||
|
...script,
|
||||||
|
request: { ...script.request, url: e.target.value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder={t("usageScript.requestUrlPlaceholder")}
|
||||||
|
className="bg-card border-border-default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="usage-method">{t("usageScript.method")}</Label>
|
||||||
|
<Input
|
||||||
|
id="usage-method"
|
||||||
|
type="text"
|
||||||
|
value={script.request?.method || "GET"}
|
||||||
|
onChange={(e) => {
|
||||||
|
setScript({
|
||||||
|
...script,
|
||||||
|
request: {
|
||||||
|
...script.request,
|
||||||
|
method: e.target.value.toUpperCase(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="GET / POST"
|
||||||
|
className="bg-card border-border-default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="usage-timeout">{t("usageScript.timeoutSeconds")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="usage-timeout"
|
id="usage-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
value={script.timeout ?? ""}
|
min={0}
|
||||||
onChange={(e) => {
|
value={script.timeout ?? 10}
|
||||||
// 输入时:只清理格式,允许临时为空,避免强制回填默认值
|
onChange={(e) =>
|
||||||
const cleaned = sanitizeNumberInput(e.target.value);
|
setScript({
|
||||||
setScript((prev) => ({
|
...script,
|
||||||
...prev,
|
timeout: validateTimeout(e.target.value),
|
||||||
timeout:
|
})
|
||||||
cleaned === "" ? undefined : parseInt(cleaned, 10),
|
}
|
||||||
}));
|
onBlur={(e) =>
|
||||||
}}
|
setScript({
|
||||||
onBlur={(e) => {
|
...script,
|
||||||
// 失焦时:严格验证并约束范围
|
timeout: validateTimeout(e.target.value),
|
||||||
const validated = validateTimeout(e.target.value);
|
})
|
||||||
setScript({ ...script, timeout: validated });
|
}
|
||||||
}}
|
className="bg-card border-border-default"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
{t("usageScript.timeoutHint") || "范围: 2-30 秒"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🆕 自动查询间隔 */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-auto-interval">
|
<Label htmlFor="usage-headers">{t("usageScript.headers")}</Label>
|
||||||
{t("usageScript.autoQueryInterval")}
|
<JsonEditor
|
||||||
</Label>
|
id="usage-headers"
|
||||||
|
value={
|
||||||
|
script.request?.headers
|
||||||
|
? JSON.stringify(script.request.headers, null, 2)
|
||||||
|
: "{}"
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value || "{}");
|
||||||
|
setScript({
|
||||||
|
...script,
|
||||||
|
request: { ...script.request, headers: parsed },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Invalid headers JSON", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
height={180}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
|
||||||
|
<JsonEditor
|
||||||
|
id="usage-body"
|
||||||
|
value={
|
||||||
|
script.request?.body
|
||||||
|
? JSON.stringify(script.request.body, null, 2)
|
||||||
|
: "{}"
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
try {
|
||||||
|
const parsed = value?.trim() === "" ? undefined : JSON.parse(value);
|
||||||
|
setScript({
|
||||||
|
...script,
|
||||||
|
request: { ...script.request, body: parsed },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
height={220}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="usage-interval">{t("usageScript.autoIntervalMinutes")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="usage-auto-interval"
|
id="usage-interval"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
step={1}
|
value={script.autoIntervalMinutes ?? 0}
|
||||||
value={script.autoQueryInterval ?? ""}
|
onChange={(e) =>
|
||||||
onChange={(e) => {
|
setScript({
|
||||||
// 输入时:只清理格式,允许临时为空
|
...script,
|
||||||
const cleaned = sanitizeNumberInput(e.target.value);
|
autoIntervalMinutes: validateAndClampInterval(e.target.value),
|
||||||
setScript((prev) => ({
|
})
|
||||||
...prev,
|
}
|
||||||
autoQueryInterval:
|
onBlur={(e) =>
|
||||||
cleaned === "" ? undefined : parseInt(cleaned, 10),
|
setScript({
|
||||||
}));
|
...script,
|
||||||
}}
|
autoIntervalMinutes: validateAndClampInterval(e.target.value),
|
||||||
onBlur={(e) => {
|
})
|
||||||
// 失焦时:严格验证并约束范围
|
}
|
||||||
const validated = validateAndClampInterval(
|
className="bg-card border-border-default"
|
||||||
e.target.value,
|
|
||||||
);
|
|
||||||
setScript({ ...script, autoQueryInterval: validated });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("usageScript.autoQueryIntervalHint")}
|
{t("usageScript.autoQueryIntervalHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 脚本说明 */}
|
{/* 提取器代码 */}
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
|
<div className="space-y-4 rounded-xl border border-border-default bg-card p-4 shadow-sm">
|
||||||
<h4 className="font-medium mb-2">
|
<div className="flex items-center justify-between">
|
||||||
{t("usageScript.scriptHelp")}
|
<Label className="text-base font-medium">{t("usageScript.extractorCode")}</Label>
|
||||||
</h4>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("usageScript.extractorHint")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<JsonEditor
|
||||||
|
id="usage-code"
|
||||||
|
value={script.code || ""}
|
||||||
|
onChange={(value) => setScript({ ...script, code: value })}
|
||||||
|
height={480}
|
||||||
|
language="javascript"
|
||||||
|
showMinimap={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 帮助信息 */}
|
||||||
|
<div className="rounded-xl border border-border-default bg-card p-4 shadow-sm text-sm text-foreground/90">
|
||||||
|
<h4 className="font-medium mb-2">{t("usageScript.scriptHelp")}</h4>
|
||||||
<div className="space-y-3 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("usageScript.configFormat")}</strong>
|
<strong>{t("usageScript.configFormat")}</strong>
|
||||||
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
|
<pre className="mt-1 p-2 bg-muted text-foreground rounded border border-border-default text-[10px] overflow-x-auto">
|
||||||
{`({
|
{`({
|
||||||
request: {
|
request: {
|
||||||
url: "{{baseUrl}}/api/usage",
|
url: "{{baseUrl}}/api/usage",
|
||||||
@@ -642,11 +702,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
headers: {
|
headers: {
|
||||||
"Authorization": "Bearer {{apiKey}}",
|
"Authorization": "Bearer {{apiKey}}",
|
||||||
"User-Agent": "cc-switch/1.0"
|
"User-Agent": "cc-switch/1.0"
|
||||||
},
|
}
|
||||||
body: JSON.stringify({ key: "value" }) // ${t("usageScript.commentOptional")}
|
|
||||||
},
|
},
|
||||||
extractor: function(response) {
|
extractor: function(response) {
|
||||||
// ${t("usageScript.commentResponseIsJson")}
|
|
||||||
return {
|
return {
|
||||||
isValid: !response.error,
|
isValid: !response.error,
|
||||||
remaining: response.balance,
|
remaining: response.balance,
|
||||||
@@ -671,14 +729,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
<div className="text-muted-foreground">
|
||||||
<strong>{t("usageScript.tips")}</strong>
|
<strong>{t("usageScript.tips")}</strong>
|
||||||
<ul className="mt-1 space-y-0.5 ml-2">
|
<ul className="mt-1 space-y-0.5 ml-2">
|
||||||
<li>
|
<li>
|
||||||
{t("usageScript.tip1", {
|
{t("usageScript.tip1", { apiKey: "{{apiKey}}", baseUrl: "{{baseUrl}}" })}
|
||||||
apiKey: "{{apiKey}}",
|
|
||||||
baseUrl: "{{baseUrl}}",
|
|
||||||
})}
|
|
||||||
</li>
|
</li>
|
||||||
<li>{t("usageScript.tip2")}</li>
|
<li>{t("usageScript.tip2")}</li>
|
||||||
<li>{t("usageScript.tip3")}</li>
|
<li>{t("usageScript.tip3")}</li>
|
||||||
@@ -686,47 +741,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FullScreenPanel>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
|
|
||||||
{/* Left side - Test and Format buttons */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleTest}
|
|
||||||
disabled={!script.enabled || testing}
|
|
||||||
>
|
|
||||||
<Play size={14} />
|
|
||||||
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleFormat}
|
|
||||||
disabled={!script.enabled}
|
|
||||||
title={t("usageScript.format")}
|
|
||||||
>
|
|
||||||
<Wand2 size={14} />
|
|
||||||
{t("usageScript.format")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side - Cancel and Save buttons */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button variant="default" size="sm" onClick={handleSave}>
|
|
||||||
{t("usageScript.saveConfig")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BarChart3, Check, Edit, Play, Trash2 } from "lucide-react";
|
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -7,6 +7,7 @@ interface ProviderActionsProps {
|
|||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
@@ -15,6 +16,7 @@ export function ProviderActions({
|
|||||||
isCurrent,
|
isCurrent,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ProviderActionsProps) {
|
}: ProviderActionsProps) {
|
||||||
@@ -56,6 +58,15 @@ export function ProviderActions({
|
|||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onDuplicate}
|
||||||
|
title={t("provider.duplicate")}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { MoveVertical, Copy } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
DraggableAttributes,
|
DraggableAttributes,
|
||||||
@@ -22,7 +22,6 @@ interface ProviderCardProps {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
isEditMode?: boolean;
|
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
@@ -71,7 +70,6 @@ export function ProviderCard({
|
|||||||
provider,
|
provider,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
isEditMode = false,
|
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -116,54 +114,31 @@ export function ProviderCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg bg-card p-4 shadow-sm",
|
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
||||||
"transition-[border-color,background-color,box-shadow,ring] duration-200",
|
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
||||||
isCurrent
|
isCurrent
|
||||||
? "border border-border-default bg-primary/5 ring-2 ring-blue-500/30 dark:ring-blue-400/30"
|
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
|
||||||
: "border border-border-default hover:border-border-hover",
|
: "hover:scale-[1.01]",
|
||||||
dragHandleProps?.isDragging &&
|
dragHandleProps?.isDragging &&
|
||||||
"cursor-grabbing border-active border-border-dragging shadow-lg",
|
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div
|
<div className="flex flex-1 items-center gap-3">
|
||||||
className={cn(
|
<button
|
||||||
"flex items-center gap-1 overflow-hidden",
|
|
||||||
"transition-[max-width,opacity] duration-200 ease-in-out",
|
|
||||||
isEditMode ? "max-w-20 opacity-100" : "max-w-0 opacity-0",
|
|
||||||
)}
|
|
||||||
aria-hidden={!isEditMode}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 cursor-grab active:cursor-grabbing",
|
"flex-shrink-0 cursor-grab active:cursor-grabbing p-2",
|
||||||
|
"text-muted-foreground/50 hover:text-muted-foreground transition-colors",
|
||||||
dragHandleProps?.isDragging && "cursor-grabbing",
|
dragHandleProps?.isDragging && "cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
aria-label={t("provider.dragHandle")}
|
aria-label={t("provider.dragHandle")}
|
||||||
disabled={!isEditMode}
|
|
||||||
{...(dragHandleProps?.attributes ?? {})}
|
{...(dragHandleProps?.attributes ?? {})}
|
||||||
{...(dragHandleProps?.listeners ?? {})}
|
{...(dragHandleProps?.listeners ?? {})}
|
||||||
>
|
>
|
||||||
<MoveVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
onClick={() => onDuplicate(provider)}
|
|
||||||
disabled={!isEditMode}
|
|
||||||
aria-label={t("provider.duplicate")}
|
|
||||||
title={t("provider.duplicate")}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2 min-h-[20px]">
|
<div className="flex flex-wrap items-center gap-2 min-h-[20px]">
|
||||||
@@ -210,7 +185,8 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="relative flex items-center ml-auto">
|
||||||
|
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[16rem] group-focus-within:-translate-x-[16rem] sm:group-hover:-translate-x-[18rem] sm:group-focus-within:-translate-x-[18rem]">
|
||||||
<UsageFooter
|
<UsageFooter
|
||||||
provider={provider}
|
provider={provider}
|
||||||
providerId={provider.id}
|
providerId={provider.id}
|
||||||
@@ -219,16 +195,20 @@ export function ProviderCard({
|
|||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
inline={true}
|
inline={true}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-2 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-3 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||||
<ProviderActions
|
<ProviderActions
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
onEdit={() => onEdit(provider)}
|
onEdit={() => onEdit(provider)}
|
||||||
|
onDuplicate={() => onDuplicate(provider)}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ interface ProviderListProps {
|
|||||||
providers: Record<string, Provider>;
|
providers: Record<string, Provider>;
|
||||||
currentProviderId: string;
|
currentProviderId: string;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
isEditMode?: boolean;
|
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
@@ -31,7 +30,6 @@ export function ProviderList({
|
|||||||
providers,
|
providers,
|
||||||
currentProviderId,
|
currentProviderId,
|
||||||
appId,
|
appId,
|
||||||
isEditMode = false,
|
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -73,14 +71,13 @@ export function ProviderList({
|
|||||||
items={sortedProviders.map((provider) => provider.id)}
|
items={sortedProviders.map((provider) => provider.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||||
{sortedProviders.map((provider) => (
|
{sortedProviders.map((provider) => (
|
||||||
<SortableProviderCard
|
<SortableProviderCard
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={provider.id === currentProviderId}
|
isCurrent={provider.id === currentProviderId}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
isEditMode={isEditMode}
|
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -99,7 +96,6 @@ interface SortableProviderCardProps {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
isEditMode: boolean;
|
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
@@ -112,7 +108,6 @@ function SortableProviderCard({
|
|||||||
provider,
|
provider,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
isEditMode,
|
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -140,7 +135,6 @@ function SortableProviderCard({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
isEditMode={isEditMode}
|
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ const DialogClose = DialogPrimitive.Close;
|
|||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||||
zIndex?: "base" | "nested" | "alert";
|
zIndex?: "base" | "nested" | "alert" | "top";
|
||||||
}
|
}
|
||||||
>(({ className, zIndex = "base", ...props }, ref) => {
|
>(({ className, zIndex = "base", ...props }, ref) => {
|
||||||
const zIndexMap = {
|
const zIndexMap = {
|
||||||
base: "z-40",
|
base: "z-40",
|
||||||
nested: "z-50",
|
nested: "z-50",
|
||||||
alert: "z-[60]",
|
alert: "z-[60]",
|
||||||
|
top: "z-[110]",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,22 +41,32 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
zIndex?: "base" | "nested" | "alert";
|
zIndex?: "base" | "nested" | "alert" | "top";
|
||||||
|
variant?: "default" | "fullscreen";
|
||||||
|
overlayClassName?: string;
|
||||||
}
|
}
|
||||||
>(({ className, children, zIndex = "base", ...props }, ref) => {
|
>(({ className, children, zIndex = "base", variant = "default", overlayClassName, ...props }, ref) => {
|
||||||
const zIndexMap = {
|
const zIndexMap = {
|
||||||
base: "z-40",
|
base: "z-40",
|
||||||
nested: "z-50",
|
nested: "z-50",
|
||||||
alert: "z-[60]",
|
alert: "z-[60]",
|
||||||
|
top: "z-[110]",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const variantClass = {
|
||||||
|
default:
|
||||||
|
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-white dark:bg-gray-900 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
fullscreen:
|
||||||
|
"fixed inset-0 flex flex-col w-screen h-screen translate-x-0 translate-y-0 bg-background text-foreground p-0 sm:rounded-none shadow-none",
|
||||||
|
}[variant];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay zIndex={zIndex} />
|
<DialogOverlay zIndex={zIndex} className={overlayClassName} />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-white dark:bg-gray-900 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
variantClass,
|
||||||
zIndexMap[zIndex],
|
zIndexMap[zIndex],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,6 +71,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Utilities */
|
||||||
|
/* Glassmorphism Utilities */
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass-card {
|
||||||
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-header {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass-header {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tauri 拖拽区域 */
|
||||||
|
[data-tauri-drag-region] {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tauri-no-drag],
|
||||||
|
[data-tauri-drag-region] .no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全局基础样式 */
|
/* 全局基础样式 */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -99,22 +149,28 @@ html.dark {
|
|||||||
width: 0.375rem;
|
width: 0.375rem;
|
||||||
height: 0.375rem;
|
height: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background-color: #f4f4f5;
|
background-color: #f4f4f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark ::-webkit-scrollbar-track {
|
html.dark ::-webkit-scrollbar-track {
|
||||||
background-color: #1c1c1e;
|
background-color: #1c1c1e;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #d4d4d8;
|
background-color: #d4d4d8;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark ::-webkit-scrollbar-thumb {
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
background-color: #3a3a3c;
|
background-color: #3a3a3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #a1a1aa;
|
background-color: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark ::-webkit-scrollbar-thumb:hover {
|
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #636366;
|
background-color: #636366;
|
||||||
}
|
}
|
||||||
@@ -126,6 +182,15 @@ html.dark ::-webkit-scrollbar-thumb:hover {
|
|||||||
|
|
||||||
/* 统一边框设计系统 - 使用工具类定义 */
|
/* 统一边框设计系统 - 使用工具类定义 */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
|
/* 让滚动条悬浮于内容之上,避免出现/消失时挤压布局 */
|
||||||
|
.scroll-overlay {
|
||||||
|
scrollbar-gutter: stable both-edges;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* 默认边框:1px,使用主题边框颜色 */
|
/* 默认边框:1px,使用主题边框颜色 */
|
||||||
.border-default {
|
.border-default {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
|
|||||||
@@ -56,6 +56,31 @@ export default {
|
|||||||
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
|
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
|
||||||
mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'],
|
mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'],
|
||||||
},
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.5s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.5s ease-out',
|
||||||
|
'slide-down': 'slideDown 0.3s ease-out',
|
||||||
|
'slide-in-right': 'slideInRight 0.3s ease-out',
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
'0%': { transform: 'translateY(-100%)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
slideInRight: {
|
||||||
|
'0%': { transform: 'translateX(100%)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user