feat(usage): add auto-refresh interval for usage queries
New Features: - Users can configure auto-query interval in "Configure Usage Query" dialog - Interval in minutes (0 = disabled, recommend 5-60 minutes) - Auto-query only enabled for currently active provider - Display last query timestamp in relative time format (e.g., "5 min ago") - Execute first query immediately when enabled, then repeat at intervals Technical Implementation: - Backend: Add auto_query_interval field to UsageScript struct - Frontend: Create useAutoUsageQuery Hook to manage timers and query state - UI: Add auto-query interval input field in UsageScriptModal - Integration: Display auto-query results and timestamp in UsageFooter - i18n: Add Chinese and English translations UX Improvements: - Minimum interval protection (1 minute) to prevent API abuse - Auto-cleanup timers on component unmount - Silent failure handling for auto-queries, non-intrusive to users - Prioritize auto-query results, fallback to manual query results - Timestamp display positioned next to refresh button for better clarity
This commit is contained in:
@@ -71,6 +71,10 @@ pub struct UsageScript {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "userId")]
|
#[serde(rename = "userId")]
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "autoQueryInterval")]
|
||||||
|
pub auto_query_interval: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用量数据
|
/// 用量数据
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
import { RefreshCw, AlertCircle, Clock } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type AppId } from "@/lib/api";
|
import { type AppId } from "@/lib/api";
|
||||||
import { useUsageQuery } from "@/lib/query/queries";
|
import { useUsageQuery } from "@/lib/query/queries";
|
||||||
import { UsageData } from "../types";
|
import { useAutoUsageQuery } from "@/hooks/useAutoUsageQuery";
|
||||||
|
import { UsageData, Provider } from "../types";
|
||||||
|
|
||||||
interface UsageFooterProps {
|
interface UsageFooterProps {
|
||||||
|
provider: Provider;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
usageEnabled: boolean; // 是否启用了用量查询
|
usageEnabled: boolean; // 是否启用了用量查询
|
||||||
|
isCurrent: boolean; // 是否为当前激活的供应商
|
||||||
}
|
}
|
||||||
|
|
||||||
const UsageFooter: React.FC<UsageFooterProps> = ({
|
const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||||
|
provider,
|
||||||
providerId,
|
providerId,
|
||||||
appId,
|
appId,
|
||||||
usageEnabled,
|
usageEnabled,
|
||||||
|
isCurrent,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 手动查询(点击刷新按钮时使用)
|
||||||
const {
|
const {
|
||||||
data: usage,
|
data: manualUsage,
|
||||||
isFetching: loading,
|
isFetching: loading,
|
||||||
refetch,
|
refetch,
|
||||||
} = useUsageQuery(providerId, appId, usageEnabled);
|
} = 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;
|
if (!usageEnabled || !usage) return null;
|
||||||
|
|
||||||
@@ -57,19 +72,31 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-border-default ">
|
<div className="mt-3 pt-3 border-t border-border-default ">
|
||||||
{/* 标题行:包含刷新按钮 */}
|
{/* 标题行:包含刷新按钮和自动查询时间 */}
|
||||||
<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">
|
||||||
{t("usage.planUsage")}
|
{t("usage.planUsage")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => refetch()}
|
{/* 自动查询时间提示 */}
|
||||||
disabled={loading}
|
{lastQueriedAt && (
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||||
title={t("usage.refreshUsage")}
|
<Clock size={10} />
|
||||||
>
|
{formatRelativeTime(lastQueriedAt, t)}
|
||||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
</span>
|
||||||
</button>
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={loading || isAutoQuerying}
|
||||||
|
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||||
|
title={t("usage.refreshUsage")}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
size={12}
|
||||||
|
className={loading || isAutoQuerying ? "animate-spin" : ""}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 套餐列表 */}
|
{/* 套餐列表 */}
|
||||||
@@ -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;
|
export default UsageFooter;
|
||||||
|
|||||||
@@ -368,6 +368,30 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* 🆕 自动查询间隔 */}
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("usageScript.autoQueryInterval")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1440"
|
||||||
|
step="1"
|
||||||
|
value={script.autoQueryInterval || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setScript({
|
||||||
|
...script,
|
||||||
|
autoQueryInterval: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("usageScript.autoQueryIntervalHint")}
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 脚本说明 */}
|
{/* 脚本说明 */}
|
||||||
|
|||||||
@@ -180,9 +180,11 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UsageFooter
|
<UsageFooter
|
||||||
|
provider={provider}
|
||||||
providerId={provider.id}
|
providerId={provider.id}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
usageEnabled={usageEnabled}
|
usageEnabled={usageEnabled}
|
||||||
|
isCurrent={isCurrent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
118
src/hooks/useAutoUsageQuery.ts
Normal file
118
src/hooks/useAutoUsageQuery.ts
Normal file
@@ -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<AutoQueryState>({
|
||||||
|
result: null,
|
||||||
|
lastQueriedAt: null,
|
||||||
|
isQuerying: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(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;
|
||||||
|
}
|
||||||
@@ -338,7 +338,11 @@
|
|||||||
"invalid": "Expired",
|
"invalid": "Expired",
|
||||||
"total": "Total:",
|
"total": "Total:",
|
||||||
"used": "Used:",
|
"used": "Used:",
|
||||||
"remaining": "Remaining:"
|
"remaining": "Remaining:",
|
||||||
|
"justNow": "Just now",
|
||||||
|
"minutesAgo": "{{count}} min ago",
|
||||||
|
"hoursAgo": "{{count}} hr ago",
|
||||||
|
"daysAgo": "{{count}} day ago"
|
||||||
},
|
},
|
||||||
"usageScript": {
|
"usageScript": {
|
||||||
"title": "Configure Usage Query",
|
"title": "Configure Usage Query",
|
||||||
@@ -355,6 +359,8 @@
|
|||||||
"queryFailedMessage": "Query failed",
|
"queryFailedMessage": "Query failed",
|
||||||
"queryScript": "Query script (JavaScript)",
|
"queryScript": "Query script (JavaScript)",
|
||||||
"timeoutSeconds": "Timeout (seconds)",
|
"timeoutSeconds": "Timeout (seconds)",
|
||||||
|
"autoQueryInterval": "Auto Query Interval (minutes)",
|
||||||
|
"autoQueryIntervalHint": "0 to disable, recommend 5-60 minutes",
|
||||||
"scriptHelp": "Script writing instructions:",
|
"scriptHelp": "Script writing instructions:",
|
||||||
"configFormat": "Configuration format:",
|
"configFormat": "Configuration format:",
|
||||||
"commentOptional": "optional",
|
"commentOptional": "optional",
|
||||||
|
|||||||
@@ -338,7 +338,11 @@
|
|||||||
"invalid": "已失效",
|
"invalid": "已失效",
|
||||||
"total": "总:",
|
"total": "总:",
|
||||||
"used": "使用:",
|
"used": "使用:",
|
||||||
"remaining": "剩余:"
|
"remaining": "剩余:",
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"minutesAgo": "{{count}} 分钟前",
|
||||||
|
"hoursAgo": "{{count}} 小时前",
|
||||||
|
"daysAgo": "{{count}} 天前"
|
||||||
},
|
},
|
||||||
"usageScript": {
|
"usageScript": {
|
||||||
"title": "配置用量查询",
|
"title": "配置用量查询",
|
||||||
@@ -355,6 +359,8 @@
|
|||||||
"queryFailedMessage": "查询失败",
|
"queryFailedMessage": "查询失败",
|
||||||
"queryScript": "查询脚本(JavaScript)",
|
"queryScript": "查询脚本(JavaScript)",
|
||||||
"timeoutSeconds": "超时时间(秒)",
|
"timeoutSeconds": "超时时间(秒)",
|
||||||
|
"autoQueryInterval": "自动查询间隔(分钟)",
|
||||||
|
"autoQueryIntervalHint": "0 表示不自动查询,建议设置 5-60 分钟",
|
||||||
"scriptHelp": "脚本编写说明:",
|
"scriptHelp": "脚本编写说明:",
|
||||||
"configFormat": "配置格式:",
|
"configFormat": "配置格式:",
|
||||||
"commentOptional": "可选",
|
"commentOptional": "可选",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface UsageScript {
|
|||||||
timeout?: number; // 超时时间(秒,默认 10)
|
timeout?: number; // 超时时间(秒,默认 10)
|
||||||
accessToken?: string; // 访问令牌(用于需要登录的接口)
|
accessToken?: string; // 访问令牌(用于需要登录的接口)
|
||||||
userId?: string; // 用户ID(用于需要用户标识的接口)
|
userId?: string; // 用户ID(用于需要用户标识的接口)
|
||||||
|
autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单个套餐用量数据
|
// 单个套餐用量数据
|
||||||
|
|||||||
Reference in New Issue
Block a user