diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 5a2c0ad..5621e1c 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -71,6 +71,10 @@ pub struct UsageScript { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "userId")] pub user_id: Option, + /// 自动查询间隔(单位:分钟,0 表示禁用自动查询) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "autoQueryInterval")] + pub auto_query_interval: Option, } /// 用量数据 diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index 654f16c..9851976 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -1,28 +1,43 @@ import React from "react"; -import { RefreshCw, AlertCircle } from "lucide-react"; +import { RefreshCw, AlertCircle, Clock } from "lucide-react"; import { useTranslation } from "react-i18next"; import { type AppId } from "@/lib/api"; import { useUsageQuery } from "@/lib/query/queries"; -import { UsageData } from "../types"; +import { useAutoUsageQuery } from "@/hooks/useAutoUsageQuery"; +import { UsageData, Provider } from "../types"; interface UsageFooterProps { + provider: Provider; providerId: string; appId: AppId; usageEnabled: boolean; // 是否启用了用量查询 + isCurrent: boolean; // 是否为当前激活的供应商 } const UsageFooter: React.FC = ({ + provider, providerId, appId, usageEnabled, + isCurrent, }) => { const { t } = useTranslation(); + + // 手动查询(点击刷新按钮时使用) const { - data: usage, + data: manualUsage, isFetching: loading, refetch, } = useUsageQuery(providerId, appId, usageEnabled); + // 自动查询(仅对当前激活的供应商启用) + const autoQuery = useAutoUsageQuery(provider, appId, isCurrent && usageEnabled); + + // 优先使用自动查询结果,如果没有则使用手动查询结果 + const usage = autoQuery.result || manualUsage; + const isAutoQuerying = autoQuery.isQuerying; + const lastQueriedAt = autoQuery.lastQueriedAt; + // 只在启用用量查询且有数据时显示 if (!usageEnabled || !usage) return null; @@ -57,19 +72,31 @@ const UsageFooter: React.FC = ({ return (
- {/* 标题行:包含刷新按钮 */} + {/* 标题行:包含刷新按钮和自动查询时间 */}
{t("usage.planUsage")} - +
+ {/* 自动查询时间提示 */} + {lastQueriedAt && ( + + + {formatRelativeTime(lastQueriedAt, t)} + + )} + +
{/* 套餐列表 */} @@ -197,4 +224,26 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => { ); }; +// 格式化相对时间 +function formatRelativeTime( + timestamp: number, + t: (key: string, options?: { count?: number }) => string +): string { + const now = Date.now(); + const diff = Math.floor((now - timestamp) / 1000); // 秒 + + if (diff < 60) { + return t("usage.justNow"); + } else if (diff < 3600) { + const minutes = Math.floor(diff / 60); + return t("usage.minutesAgo", { count: minutes }); + } else if (diff < 86400) { + const hours = Math.floor(diff / 3600); + return t("usage.hoursAgo", { count: hours }); + } else { + const days = Math.floor(diff / 86400); + return t("usage.daysAgo", { count: days }); + } +} + export default UsageFooter; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 746bc9b..5b080e5 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -368,6 +368,30 @@ const UsageScriptModal: React.FC = ({ className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" /> + + {/* 🆕 自动查询间隔 */} +
{/* 脚本说明 */} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index a651b9e..cb930ba 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -180,9 +180,11 @@ export function ProviderCard({ ); diff --git a/src/hooks/useAutoUsageQuery.ts b/src/hooks/useAutoUsageQuery.ts new file mode 100644 index 0000000..e549b6a --- /dev/null +++ b/src/hooks/useAutoUsageQuery.ts @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from "react"; +import { usageApi, type AppId } from "@/lib/api"; +import type { Provider, UsageResult } from "@/types"; + +export interface AutoQueryState { + result: UsageResult | null; + lastQueriedAt: number | null; + isQuerying: boolean; + error: string | null; +} + +/** + * 自动用量查询 Hook + * @param provider 供应商对象 + * @param appId 应用 ID(claude 或 codex) + * @param enabled 是否启用(通常只对当前激活的供应商启用) + * @returns 自动查询状态 + */ +export function useAutoUsageQuery( + provider: Provider, + appId: AppId, + enabled: boolean +): AutoQueryState { + const [state, setState] = useState({ + result: null, + lastQueriedAt: null, + isQuerying: false, + error: null, + }); + + const timerRef = useRef(null); + const isMountedRef = useRef(true); + + // 跟踪组件挂载状态 + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + // 清理旧定时器 + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + // 重置状态(切换供应商或禁用时) + if (!enabled) { + setState({ + result: null, + lastQueriedAt: null, + isQuerying: false, + error: null, + }); + return; + } + + // 检查是否启用自动查询 + const usageScript = provider.meta?.usage_script; + if (!usageScript?.enabled) { + return; + } + + const interval = usageScript.autoQueryInterval || 0; + if (interval === 0) { + return; // 间隔为 0,不启用自动查询 + } + + // 限制最小间隔为 1 分钟,避免过于频繁 + const actualInterval = Math.max(interval, 1); + + // 执行查询的函数 + const executeQuery = async () => { + if (!isMountedRef.current) return; + + setState((prev) => ({ ...prev, isQuerying: true, error: null })); + + try { + const result = await usageApi.query(provider.id, appId); + + if (isMountedRef.current) { + setState({ + result, + lastQueriedAt: Date.now(), + isQuerying: false, + error: result.success ? null : result.error || "Unknown error", + }); + } + } catch (error: any) { + if (isMountedRef.current) { + setState((prev) => ({ + ...prev, + isQuerying: false, + error: error?.message || "Query failed", + })); + } + console.error("[AutoQuery] Failed:", error); + } + }; + + // 立即执行一次查询 + executeQuery(); + + // 设置定时器(间隔单位:分钟) + timerRef.current = setInterval(executeQuery, actualInterval * 60 * 1000); + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [provider.id, provider.meta?.usage_script, appId, enabled]); + + return state; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 20250d4..b2b9439 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -338,7 +338,11 @@ "invalid": "Expired", "total": "Total:", "used": "Used:", - "remaining": "Remaining:" + "remaining": "Remaining:", + "justNow": "Just now", + "minutesAgo": "{{count}} min ago", + "hoursAgo": "{{count}} hr ago", + "daysAgo": "{{count}} day ago" }, "usageScript": { "title": "Configure Usage Query", @@ -355,6 +359,8 @@ "queryFailedMessage": "Query failed", "queryScript": "Query script (JavaScript)", "timeoutSeconds": "Timeout (seconds)", + "autoQueryInterval": "Auto Query Interval (minutes)", + "autoQueryIntervalHint": "0 to disable, recommend 5-60 minutes", "scriptHelp": "Script writing instructions:", "configFormat": "Configuration format:", "commentOptional": "optional", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 26d3b3e..5e8ad65 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -338,7 +338,11 @@ "invalid": "已失效", "total": "总:", "used": "使用:", - "remaining": "剩余:" + "remaining": "剩余:", + "justNow": "刚刚", + "minutesAgo": "{{count}} 分钟前", + "hoursAgo": "{{count}} 小时前", + "daysAgo": "{{count}} 天前" }, "usageScript": { "title": "配置用量查询", @@ -355,6 +359,8 @@ "queryFailedMessage": "查询失败", "queryScript": "查询脚本(JavaScript)", "timeoutSeconds": "超时时间(秒)", + "autoQueryInterval": "自动查询间隔(分钟)", + "autoQueryIntervalHint": "0 表示不自动查询,建议设置 5-60 分钟", "scriptHelp": "脚本编写说明:", "configFormat": "配置格式:", "commentOptional": "可选", diff --git a/src/types.ts b/src/types.ts index 81d9369..07f2e6d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,7 @@ export interface UsageScript { timeout?: number; // 超时时间(秒,默认 10) accessToken?: string; // 访问令牌(用于需要登录的接口) userId?: string; // 用户ID(用于需要用户标识的接口) + autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用) } // 单个套餐用量数据