feat: add provider usage query with JavaScript scripting support (#101)
* feat: add provider usage query functionality
- Updated `Cargo.toml` to include `regex` and `rquickjs` dependencies for usage script execution.
- Implemented `query_provider_usage` command in `commands.rs` to handle usage queries.
- Created `UsageScript` and `UsageData` structs in `provider.rs` for managing usage script configurations and results.
- Added `execute_usage_script` function in `usage_script.rs` to run user-defined scripts for querying usage.
- Enhanced `ProviderList` component to include a button for configuring usage scripts and a modal for editing scripts.
- Introduced `UsageFooter` component to display usage information and status.
- Added `UsageScriptModal` for editing and testing usage scripts with preset templates.
- Updated Tauri API to support querying provider usage.
- Modified types in `types.ts` to include structures for usage scripts and results.
* feat(usage): support multi-plan usage display for providers
- 【Feature】
- Update `UsageResult` to support an array of `UsageData` for displaying multiple usage plans per provider.
- Refactor `query_provider_usage` command to parse both single `UsageData` objects (for backward compatibility) and arrays of `UsageData`.
- Enhance `usage_script` validation to accept either a single usage object or an array of usage objects.
- 【Frontend】
- Redesign `UsageFooter` to iterate and display details for all available usage plans, introducing `UsagePlanItem` for individual plan rendering.
- Improve usage display with color-coded remaining balance and clear plan information.
- Update `UsageScriptModal` test notification to summarize all returned plans.
- Remove redundant `isCurrent` prop from `UsageFooter` in `ProviderList`.
- 【Build】
- Change frontend development server port from `3000` to `3005` in `tauri.conf.json` and `vite.config.mts`.
* feat(usage): enhance query flexibility and display
- 【`src/types.ts`, `src-tauri/src/provider.rs`】Make `UsageData` fields optional and introduce `extra` and `invalidMessage` for more flexible reporting.
- `expiresAt` replaced by generic `extra` field.
- `isValid`, `remaining`, `unit` are now optional.
- Added `invalidMessage` to provide specific reasons for invalid status.
- 【`src-tauri/src/usage_script.rs`】Relax usage script result validation to accommodate optional fields in `UsageData`.
- 【`src/components/UsageFooter.tsx`】Update UI to display `extra` field and `invalidMessage`, and conditionally render `remaining` and `unit` based on availability.
- 【`src/components/UsageScriptModal.tsx`】
- Add a new `NewAPI` preset template demonstrating advanced extractor logic for complex API responses.
- Update script instructions to reflect optional fields and new variable syntax (`{{apiKey}}`).
- Remove old "DeepSeek" and "OpenAI" templates.
- Remove basic syntax check for `return` statement.
- 【`.vscode/settings.json`】Add `dish-ai-commit.base.language` setting.
- 【`src-tauri/src/commands.rs`】Adjust usage logging to handle optional `remaining` and `unit` fields.
* chore(config): remove VS Code settings from version control
- delete .vscode/settings.json to remove editor-specific configurations
- add /.vscode to .gitignore to prevent tracking of local VS Code settings
- ensure personalized editor preferences are not committed to the repository
* fix(provider): preserve usage script during provider update
- When updating a provider, the `usage_script` configuration within `ProviderMeta` was not explicitly merged.
- This could lead to the accidental loss of `usage_script` settings if the incoming `provider` object in the update request did not contain this field.
- Ensure `usage_script` is cloned from the existing provider's meta when merging `ProviderMeta` during an update.
* refactor(provider): enforce base_url for usage scripts and update dev ports
- 【Backend】
- `src-tauri/src/commands.rs`: Made `ANTHROPIC_BASE_URL` a required field for Claude providers and `base_url` a required field in `config.toml` for Codex providers when extracting credentials for usage script execution. This improves error handling by explicitly failing if these critical URLs are missing or malformed.
- 【Frontend】
- `src/App.tsx`, `src/components/ProviderList.tsx`: Passed `appType` prop to `ProviderList` component to ensure `updateProvider` calls within `handleSaveUsageScript` correctly identify the application type.
- 【Config】
- `src-tauri/tauri.conf.json`, `vite.config.mts`: Updated development server ports from `3005` to `3000` to standardize local development environment.
* refactor(usage): improve usage data fetching logic
- Prevent redundant API calls by tracking last fetched parameters in `useEffect`.
- Avoid concurrent API requests by adding a guard in `fetchUsage`.
- Clear usage data and last fetch parameters when usage query is disabled.
- Add `queryProviderUsage` API declaration to `window.api` interface.
* fix(usage-script): ensure usage script updates and improve reactivity
- correctly update `usage_script` from new provider meta during updates
- replace full page reload with targeted provider data refresh after saving usage script settings
- trigger usage data fetch or clear when `usageEnabled` status changes in `UsageFooter`
- reduce logging verbosity for usage script execution in backend commands and script execution
* style(usage-footer): adjust usage plan item layout
- Decrease width of extra field column from 35% to 30%
- Increase width of usage information column from 40% to 45%
- Improve visual balance and readability of usage plan items
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { placeholder } from "@codemirror/view";
|
||||
@@ -14,6 +15,8 @@ interface JsonEditorProps {
|
||||
darkMode?: boolean;
|
||||
rows?: number;
|
||||
showValidation?: boolean;
|
||||
language?: "json" | "javascript";
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
@@ -23,6 +26,8 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
darkMode = false,
|
||||
rows = 12,
|
||||
showValidation = true,
|
||||
language = "json",
|
||||
height,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -33,7 +38,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
() =>
|
||||
linter((view) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (!showValidation) return diagnostics;
|
||||
if (!showValidation || language !== "json") return diagnostics;
|
||||
|
||||
const doc = view.state.doc.toString();
|
||||
if (!doc.trim()) return diagnostics;
|
||||
@@ -65,16 +70,16 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
|
||||
return diagnostics;
|
||||
}),
|
||||
[showValidation, t],
|
||||
[showValidation, language, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// 创建编辑器扩展
|
||||
const minHeightPx = Math.max(1, rows) * 18; // 降低最小高度以减少抖动
|
||||
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
|
||||
const sizingTheme = EditorView.theme({
|
||||
"&": { minHeight: `${minHeightPx}px` },
|
||||
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": {
|
||||
fontFamily:
|
||||
@@ -85,7 +90,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
json(),
|
||||
language === "javascript" ? javascript() : json(),
|
||||
placeholder(placeholderText || ""),
|
||||
sizingTheme,
|
||||
jsonLinter,
|
||||
@@ -121,7 +126,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
|
||||
// 当 value 从外部改变时更新编辑器内容
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../types";
|
||||
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
|
||||
import { Provider, UsageScript } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3 } from "lucide-react";
|
||||
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||
import UsageFooter from "./UsageFooter";
|
||||
import UsageScriptModal from "./UsageScriptModal";
|
||||
// 不再在列表中显示分类徽章,避免造成困惑
|
||||
|
||||
interface ProviderListProps {
|
||||
@@ -11,11 +14,13 @@ interface ProviderListProps {
|
||||
onSwitch: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
appType: AppType;
|
||||
onNotify?: (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration?: number,
|
||||
) => void;
|
||||
onProvidersUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
@@ -24,9 +29,13 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onEdit,
|
||||
appType,
|
||||
onNotify,
|
||||
onProvidersUpdated,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [usageModalProviderId, setUsageModalProviderId] = useState<string | null>(null);
|
||||
|
||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||
const getApiUrl = (provider: Provider): string => {
|
||||
try {
|
||||
@@ -62,6 +71,29 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
|
||||
// 列表页不再提供 Claude 插件按钮,统一在“设置”中控制
|
||||
|
||||
// 处理用量配置保存
|
||||
const handleSaveUsageScript = async (providerId: string, script: UsageScript) => {
|
||||
try {
|
||||
const provider = providers[providerId];
|
||||
const updatedProvider = {
|
||||
...provider,
|
||||
meta: {
|
||||
...provider.meta,
|
||||
usage_script: script,
|
||||
},
|
||||
};
|
||||
await window.api.updateProvider(updatedProvider, appType);
|
||||
onNotify?.("用量查询配置已保存", "success", 2000);
|
||||
// 重新加载供应商列表,触发 UsageFooter 的 useEffect
|
||||
if (onProvidersUpdated) {
|
||||
await onProvidersUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("保存用量配置失败:", error);
|
||||
onNotify?.("保存失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 对供应商列表进行排序
|
||||
const sortedProviders = Object.values(providers).sort((a, b) => {
|
||||
// 按添加时间排序
|
||||
@@ -177,6 +209,15 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
|
||||
{/* 新增:用量配置按钮 */}
|
||||
<button
|
||||
onClick={() => setUsageModalProviderId(provider.id)}
|
||||
className={buttonStyles.icon}
|
||||
title="配置用量查询"
|
||||
>
|
||||
<BarChart3 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(provider.id)}
|
||||
disabled={isCurrent}
|
||||
@@ -192,11 +233,31 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用量信息 Footer */}
|
||||
<UsageFooter
|
||||
providerId={provider.id}
|
||||
appType={appType!}
|
||||
usageEnabled={provider.meta?.usage_script?.enabled || false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用量配置模态框 */}
|
||||
{usageModalProviderId && providers[usageModalProviderId] && (
|
||||
<UsageScriptModal
|
||||
provider={providers[usageModalProviderId]}
|
||||
appType={appType!}
|
||||
onClose={() => setUsageModalProviderId(null)}
|
||||
onSave={(script) =>
|
||||
handleSaveUsageScript(usageModalProviderId, script)
|
||||
}
|
||||
onNotify={onNotify}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
210
src/components/UsageFooter.tsx
Normal file
210
src/components/UsageFooter.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { UsageResult, UsageData } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||
|
||||
interface UsageFooterProps {
|
||||
providerId: string;
|
||||
appType: AppType;
|
||||
usageEnabled: boolean; // 是否启用了用量查询
|
||||
}
|
||||
|
||||
const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
providerId,
|
||||
appType,
|
||||
usageEnabled,
|
||||
}) => {
|
||||
const [usage, setUsage] = useState<UsageResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 记录上次请求的关键参数,防止重复请求
|
||||
const lastFetchParamsRef = useRef<string>('');
|
||||
|
||||
const fetchUsage = async () => {
|
||||
// 防止并发请求
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await window.api.queryProviderUsage(
|
||||
providerId,
|
||||
appType
|
||||
);
|
||||
setUsage(result);
|
||||
} catch (error: any) {
|
||||
console.error("查询用量失败:", error);
|
||||
setUsage({
|
||||
success: false,
|
||||
error: error?.message || "查询失败",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (usageEnabled) {
|
||||
// 生成当前参数的唯一标识(包含 usageEnabled 状态)
|
||||
const currentParams = `${providerId}-${appType}-${usageEnabled}`;
|
||||
|
||||
// 只有参数真正变化时才发起请求
|
||||
if (currentParams !== lastFetchParamsRef.current) {
|
||||
lastFetchParamsRef.current = currentParams;
|
||||
fetchUsage();
|
||||
}
|
||||
} else {
|
||||
// 如果禁用了,清空记录和数据
|
||||
lastFetchParamsRef.current = '';
|
||||
setUsage(null);
|
||||
}
|
||||
}, [providerId, usageEnabled, appType]);
|
||||
|
||||
// 只在启用用量查询且有数据时显示
|
||||
if (!usageEnabled || !usage) return null;
|
||||
|
||||
// 错误状态
|
||||
if (!usage.success) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<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">
|
||||
<AlertCircle size={14} />
|
||||
<span>{usage.error || "查询失败"}</span>
|
||||
</div>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<button
|
||||
onClick={() => fetchUsage()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
title="刷新用量"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const usageDataList = usage.data || [];
|
||||
|
||||
// 无数据时不显示
|
||||
if (usageDataList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
{/* 标题行:包含刷新按钮 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
套餐用量
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fetchUsage()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
title="刷新用量"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 套餐列表 */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{usageDataList.map((usageData, index) => (
|
||||
<UsagePlanItem key={index} data={usageData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 单个套餐数据展示组件
|
||||
const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
||||
const { planName, extra, isValid, invalidMessage, total, used, remaining, unit } = data;
|
||||
|
||||
// 判断套餐是否失效(isValid 为 false 或未定义时视为有效)
|
||||
const isExpired = isValid === false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 标题部分:25% */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 min-w-0" style={{ width: "25%" }}>
|
||||
{planName ? (
|
||||
<span
|
||||
className={`font-medium truncate block ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
||||
title={planName}
|
||||
>
|
||||
💰 {planName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="opacity-50">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 扩展字段:30% */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 min-w-0 flex items-center gap-2" style={{ width: "30%" }}>
|
||||
{extra && (
|
||||
<span
|
||||
className={`truncate ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
||||
title={extra}
|
||||
>
|
||||
{extra}
|
||||
</span>
|
||||
)}
|
||||
{isExpired && (
|
||||
<span className="text-red-500 dark:text-red-400 font-medium text-[10px] px-1.5 py-0.5 bg-red-50 dark:bg-red-900/20 rounded flex-shrink-0">
|
||||
{invalidMessage || "已失效"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 用量信息:45% */}
|
||||
<div className="flex items-center justify-end gap-2 text-xs flex-shrink-0" style={{ width: "45%" }}>
|
||||
{/* 总额度 */}
|
||||
{total !== undefined && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-gray-400">总:</span>
|
||||
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
||||
{total === -1 ? "∞" : total.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-gray-400 dark:text-gray-600">|</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 已用额度 */}
|
||||
{used !== undefined && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-gray-400">使用:</span>
|
||||
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
||||
{used.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-gray-400 dark:text-gray-600">|</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 剩余额度 - 突出显示 */}
|
||||
{remaining !== undefined && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-gray-400">剩余:</span>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${
|
||||
isExpired
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: remaining < (total || remaining) * 0.1
|
||||
? "text-orange-500 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}`}
|
||||
>
|
||||
{remaining.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{unit && <span className="text-gray-500 dark:text-gray-400">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default UsageFooter;
|
||||
355
src/components/UsageScriptModal.tsx
Normal file
355
src/components/UsageScriptModal.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import React, { useState } from "react";
|
||||
import { X, Play, Wand2 } from "lucide-react";
|
||||
import { Provider, UsageScript } from "../types";
|
||||
import { AppType } from "../lib/tauri-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";
|
||||
|
||||
interface UsageScriptModalProps {
|
||||
provider: Provider;
|
||||
appType: AppType;
|
||||
onClose: () => void;
|
||||
onSave: (script: UsageScript) => void;
|
||||
onNotify?: (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration?: number
|
||||
) => 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,
|
||||
onClose,
|
||||
onSave,
|
||||
onNotify,
|
||||
}) => {
|
||||
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()) {
|
||||
onNotify?.("脚本配置不能为空", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 基本的 JS 语法检查(检查是否包含 return 语句)
|
||||
if (script.enabled && !script.code.includes("return")) {
|
||||
onNotify?.("脚本必须包含 return 语句", "error", 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(script);
|
||||
onClose();
|
||||
onNotify?.("用量查询配置已保存", "success", 2000);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await window.api.queryProviderUsage(
|
||||
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(", ");
|
||||
onNotify?.(`测试成功!${summary}`, "success", 3000);
|
||||
} else {
|
||||
onNotify?.(`测试失败: ${result.error || "无数据返回"}`, "error", 5000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onNotify?.(`测试失败: ${error?.message || "未知错误"}`, "error", 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() });
|
||||
onNotify?.("格式化成功", "success", 1000);
|
||||
} catch (error: any) {
|
||||
onNotify?.(`格式化失败: ${error?.message || "语法错误"}`, "error", 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsePreset = (presetName: string) => {
|
||||
const preset = PRESET_TEMPLATES[presetName];
|
||||
if (preset) {
|
||||
setScript({ ...script, code: preset });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
配置用量查询 - {provider.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 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 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={!script.enabled || testing}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play size={14} />
|
||||
{testing ? "测试中..." : "测试脚本"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFormat}
|
||||
disabled={!script.enabled}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="格式化代码 (Prettier)"
|
||||
>
|
||||
<Wand2 size={14} />
|
||||
格式化
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageScriptModal;
|
||||
Reference in New Issue
Block a user