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:
YoVinchen
2025-11-21 09:31:36 +08:00
parent 977185e2d5
commit 17cf701bad
11 changed files with 1006 additions and 813 deletions

View File

@@ -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

View File

@@ -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)",
},
}), }),
); );
} }

View File

@@ -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" : ""} />

View File

@@ -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清空 apiKeyNewAPI 不使用通用的 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>
); );
}; };

View File

@@ -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"

View File

@@ -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>
); );
} }

View File

@@ -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}

View File

@@ -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,
)} )}

View File

@@ -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;

View File

@@ -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: [],