Files
cc-switch/src/components/UsageScriptModal.tsx
T
Jason f963d58e6a refactor: extract business logic to useProviderActions hook
Major improvements:
- Create `src/hooks/useProviderActions.ts` (147 lines)
  - Consolidate provider operations (add, update, delete, switch)
  - Extract Claude plugin sync logic
  - Extract usage script save logic

- Simplify `App.tsx` (347 → 226 lines, -35%)
  - Remove 8 callback functions
  - Remove Claude plugin sync logic
  - Remove usage script save logic
  - Cleaner and more maintainable

- Replace `onNotify` prop with `toast` in:
  - `UsageScriptModal.tsx`
  - `McpPanel.tsx`
  - `McpFormModal.tsx`
  - `McpWizardModal.tsx`
  - Unified notification system using sonner

Benefits:
- Reduced coupling and improved maintainability
- Business logic isolated in hooks, easier to test
- Consistent notification system across the app
2025-10-17 17:49:16 +08:00

382 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import { Play, Wand2 } from "lucide-react";
import { toast } from "sonner";
import { Provider, UsageScript } from "../types";
import { usageApi, type AppType } from "@/lib/api";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
import * as pluginEstree from "prettier/plugins/estree";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface UsageScriptModalProps {
provider: Provider;
appType: AppType;
isOpen: boolean;
onClose: () => void;
onSave: (script: UsageScript) => void;
}
// 预设模板(JS 对象字面量格式)
const PRESET_TEMPLATES: Record<string, string> = {
通用模板: `({
request: {
url: "{{baseUrl}}/user/balance",
method: "GET",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD"
};
}
})`,
NewAPI: `({
request: {
url: "{{baseUrl}}/api/usage/token",
method: "GET",
headers: {
Authorization: "Bearer {{apiKey}}",
},
},
extractor: function (response) {
if (response.code) {
if (response.data.unlimited_quota) {
return {
planName: response.data.name,
total: -1,
used: response.data.total_used / 500000,
unit: "USD",
};
}
return {
isValid: true,
planName: response.data.name,
total: response.data.total_granted / 500000,
used: response.data.total_used / 500000,
remaining: response.data.total_available / 500000,
unit: "USD",
};
}
if (response.error) {
return {
isValid: false,
invalidMessage: response.error.message,
};
}
},
})`,
};
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
provider,
appType,
isOpen,
onClose,
onSave,
}) => {
const [script, setScript] = useState<UsageScript>(() => {
return (
provider.meta?.usage_script || {
enabled: false,
language: "javascript",
code: PRESET_TEMPLATES["通用模板"],
timeout: 10,
}
);
});
const [testing, setTesting] = useState(false);
const handleSave = () => {
// 验证脚本格式
if (script.enabled && !script.code.trim()) {
toast.error("脚本配置不能为空");
return;
}
// 基本的 JS 语法检查(检查是否包含 return 语句)
if (script.enabled && !script.code.includes("return")) {
toast.error("脚本必须包含 return 语句", { duration: 5000 });
return;
}
onSave(script);
onClose();
};
const handleTest = async () => {
setTesting(true);
try {
const result = await usageApi.query(provider.id, appType);
if (result.success && result.data && result.data.length > 0) {
// 显示所有套餐数据
const summary = result.data
.map((plan) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
return `${planInfo} 剩余: ${plan.remaining} ${plan.unit}`;
})
.join(", ");
toast.success(`测试成功!${summary}`, { duration: 3000 });
} else {
toast.error(`测试失败: ${result.error || "无数据返回"}`, {
duration: 5000,
});
}
} catch (error: any) {
toast.error(`测试失败: ${error?.message || "未知错误"}`, {
duration: 5000,
});
} finally {
setTesting(false);
}
};
const handleFormat = async () => {
try {
const formatted = await prettier.format(script.code, {
parser: "babel",
plugins: [parserBabel as any, pluginEstree as any],
semi: true,
singleQuote: false,
tabWidth: 2,
printWidth: 80,
});
setScript({ ...script, code: formatted.trim() });
toast.success("格式化成功", { duration: 1000 });
} catch (error: any) {
toast.error(`格式化失败: ${error?.message || "语法错误"}`, {
duration: 3000,
});
}
};
const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
setScript({ ...script, code: preset });
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>配置用量查询 - {provider.name}</DialogTitle>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto -mx-6 px-6 space-y-4">
{/* 启用开关 */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={script.enabled}
onChange={(e) =>
setScript({ ...script, enabled: e.target.checked })
}
className="w-4 h-4"
/>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
启用用量查询
</span>
</label>
{script.enabled && (
<>
{/* 预设模板选择 */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
预设模板
</label>
<div className="flex gap-2">
{Object.keys(PRESET_TEMPLATES).map((name) => (
<button
key={name}
onClick={() => handleUsePreset(name)}
className="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
>
{name}
</button>
))}
</div>
</div>
{/* 脚本编辑器 */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
查询脚本(JavaScript
</label>
<JsonEditor
value={script.code}
onChange={(code) => setScript({ ...script, code })}
height="300px"
language="javascript"
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
支持变量: <code>{"{{apiKey}}"}</code>,{" "}
<code>{"{{baseUrl}}"}</code> | extractor 函数接收 API 响应的
JSON 对象
</p>
</div>
{/* 配置选项 */}
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
超时时间(秒)
</span>
<input
type="number"
min="2"
max="30"
value={script.timeout || 10}
onChange={(e) =>
setScript({
...script,
timeout: parseInt(e.target.value),
})
}
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</label>
</div>
{/* 脚本说明 */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<h4 className="font-medium mb-2">脚本编写说明:</h4>
<div className="space-y-3 text-xs">
<div>
<strong>配置格式:</strong>
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
{`({
request: {
url: "{{baseUrl}}/api/usage",
method: "POST",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
},
body: JSON.stringify({ key: "value" }) // 可选
},
extractor: function(response) {
// response 是 API 返回的 JSON 数据
return {
isValid: !response.error,
remaining: response.balance,
unit: "USD"
};
}
})`}
</pre>
</div>
<div>
<strong>extractor 返回格式(所有字段均为可选):</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
<code>isValid</code>: 布尔值,套餐是否有效
</li>
<li>
<code>invalidMessage</code>: 字符串,失效原因说明(当
isValid false 时显示)
</li>
<li>
<code>remaining</code>: 数字,剩余额度
</li>
<li>
<code>unit</code>: 字符串,单位(如 "USD"
</li>
<li>
<code>planName</code>: 字符串,套餐名称
</li>
<li>
<code>total</code>: 数字,总额度
</li>
<li>
<code>used</code>: 数字,已用额度
</li>
<li>
<code>extra</code>:
字符串,扩展字段,可自由补充需要展示的文本
</li>
</ul>
</div>
<div className="text-gray-600 dark:text-gray-400">
<strong>💡 提示:</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
变量 <code>{"{{apiKey}}"}</code> {" "}
<code>{"{{baseUrl}}"}</code> 会自动替换
</li>
<li>
extractor 函数在沙箱环境中执行,支持 ES2020+ 语法
</li>
<li>
整个配置必须用 <code>()</code>{" "}
包裹,形成对象字面量表达式
</li>
</ul>
</div>
</div>
</div>
</>
)}
</div>
{/* 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 ? "测试中..." : "测试脚本"}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={!script.enabled}
title="格式化代码 (Prettier)"
>
<Wand2 size={14} />
格式化
</Button>
</div>
{/* Right side - Cancel and Save buttons */}
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
取消
</Button>
<Button variant="default" size="sm" onClick={handleSave}>
保存配置
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default UsageScriptModal;