Compare commits

..

2 Commits

Author SHA1 Message Date
YoVinchen 5b16650036 feat(usage): enhance dashboard with auto-refresh control and robust formatting
- Add configurable auto-refresh interval toggle (off/5s/10s/30s/60s) to usage dashboard
- Extract shared format utilities (fmtUsd, fmtInt, parseFiniteNumber, getLocaleFromLanguage)
- Refactor request log time filtering to rolling vs fixed mode with validation
- Use stable serializable query keys instead of filter objects
- Handle NaN/Infinity safely in number formatting across all usage components
- Use RFC 3339 date format in backend trend data
2026-02-06 15:46:53 +08:00
YoVinchen a052af0060 style: format code and apply clippy lint fixes 2026-02-06 15:08:07 +08:00
37 changed files with 537 additions and 266 deletions
+1 -1
View File
@@ -755,7 +755,7 @@ fn launch_macos_open_app(
let output = cmd let output = cmd
.output() .output()
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?; .map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
+2 -2
View File
@@ -12,8 +12,8 @@ mod plugin;
mod prompt; mod prompt;
mod provider; mod provider;
mod proxy; mod proxy;
mod settings;
mod session_manager; mod session_manager;
mod settings;
pub mod skill; pub mod skill;
mod stream_check; mod stream_check;
mod usage; mod usage;
@@ -30,8 +30,8 @@ pub use plugin::*;
pub use prompt::*; pub use prompt::*;
pub use provider::*; pub use provider::*;
pub use proxy::*; pub use proxy::*;
pub use settings::*;
pub use session_manager::*; pub use session_manager::*;
pub use settings::*;
pub use skill::*; pub use skill::*;
pub use stream_check::*; pub use stream_check::*;
pub use usage::*; pub use usage::*;
+1 -1
View File
@@ -20,8 +20,8 @@ mod prompt_files;
mod provider; mod provider;
mod provider_defaults; mod provider_defaults;
mod proxy; mod proxy;
mod session_manager;
mod services; mod services;
mod session_manager;
mod settings; mod settings;
mod store; mod store;
mod tray; mod tray;
+1 -1
View File
@@ -272,7 +272,7 @@ impl Database {
.single() .single()
.unwrap_or_else(Local::now); .unwrap_or_else(Local::now);
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string(); let date = bucket_start.to_rfc3339();
if let Some(mut stat) = map.remove(&i) { if let Some(mut stat) = map.remove(&i) {
stat.date = date; stat.date = date;
@@ -55,17 +55,12 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
let content = message let content = message.get("content").map(extract_text).unwrap_or_default();
.get("content")
.map(extract_text)
.unwrap_or_default();
if content.trim().is_empty() { if content.trim().is_empty() {
continue; continue;
} }
let ts = value let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
.get("timestamp")
.and_then(parse_timestamp_to_ms);
messages.push(SessionMessage { role, content, ts }); messages.push(SessionMessage { role, content, ts });
} }
@@ -127,10 +122,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
None => continue, None => continue,
}; };
let text = message let text = message.get("content").map(extract_text).unwrap_or_default();
.get("content")
.map(extract_text)
.unwrap_or_default();
if text.trim().is_empty() { if text.trim().is_empty() {
continue; continue;
} }
@@ -2,8 +2,8 @@ use std::fs::File;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde_json::Value;
use regex::Regex; use regex::Regex;
use serde_json::Value;
use crate::codex_config::get_codex_config_dir; use crate::codex_config::get_codex_config_dir;
use crate::session_manager::{SessionMessage, SessionMeta}; use crate::session_manager::{SessionMessage, SessionMeta};
@@ -60,17 +60,12 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
let content = payload let content = payload.get("content").map(extract_text).unwrap_or_default();
.get("content")
.map(extract_text)
.unwrap_or_default();
if content.trim().is_empty() { if content.trim().is_empty() {
continue; continue;
} }
let ts = value let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
.get("timestamp")
.and_then(parse_timestamp_to_ms);
messages.push(SessionMessage { role, content, ts }); messages.push(SessionMessage { role, content, ts });
} }
@@ -139,10 +134,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
continue; continue;
} }
let text = payload let text = payload.get("content").map(extract_text).unwrap_or_default();
.get("content")
.map(extract_text)
.unwrap_or_default();
if text.trim().is_empty() { if text.trim().is_empty() {
continue; continue;
} }
@@ -174,9 +166,8 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
fn infer_session_id_from_filename(path: &Path) -> Option<String> { fn infer_session_id_from_filename(path: &Path) -> Option<String> {
let file_name = path.file_name()?.to_string_lossy(); let file_name = path.file_name()?.to_string_lossy();
let re = Regex::new( let re =
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
)
.ok()?; .ok()?;
re.find(&file_name).map(|mat| mat.as_str().to_string()) re.find(&file_name).map(|mat| mat.as_str().to_string())
} }
@@ -13,7 +13,7 @@ pub fn extract_text(content: &Value) -> String {
Value::String(text) => text.to_string(), Value::String(text) => text.to_string(),
Value::Array(items) => items Value::Array(items) => items
.iter() .iter()
.filter_map(|item| extract_text_from_item(item)) .filter_map(extract_text_from_item)
.filter(|text| !text.trim().is_empty()) .filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"), .join("\n"),
@@ -68,10 +68,10 @@ pub fn path_basename(value: &str) -> Option<String> {
if trimmed.is_empty() { if trimmed.is_empty() {
return None; return None;
} }
let normalized = trimmed.trim_end_matches(|c| c == '/' || c == '\\'); let normalized = trimmed.trim_end_matches(['/', '\\']);
let last = normalized let last = normalized
.split(['/', '\\']) .split(['/', '\\'])
.last() .next_back()
.filter(|segment| !segment.is_empty())?; .filter(|segment| !segment.is_empty())?;
Some(last.to_string()) Some(last.to_string())
} }
@@ -32,9 +32,8 @@ fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String>
let script = format!( let script = format!(
r#"tell application "Terminal" r#"tell application "Terminal"
activate activate
do script "{}" do script "{escaped}"
end tell"#, end tell"#
escaped
); );
let status = Command::new("osascript") let status = Command::new("osascript")
@@ -59,10 +58,9 @@ fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
activate activate
create window with default profile create window with default profile
tell current session of current window tell current session of current window
write text "{}" write text "{escaped}"
end tell end tell
end tell"#, end tell"#
escaped
); );
let status = Command::new("osascript") let status = Command::new("osascript")
@@ -88,7 +86,7 @@ fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones. // Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
// Documentation says --working-directory is supported in CLI. // Documentation says --working-directory is supported in CLI.
let work_dir_arg = if let Some(dir) = cwd { let work_dir_arg = if let Some(dir) = cwd {
format!("--working-directory={}", dir) format!("--working-directory={dir}")
} else { } else {
"".to_string() "".to_string()
}; };
@@ -251,7 +249,7 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
fn shell_escape(value: &str) -> String { fn shell_escape(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped) format!("\"{escaped}\"")
} }
fn escape_osascript(value: &str) -> String { fn escape_osascript(value: &str) -> String {
+23 -21
View File
@@ -175,7 +175,7 @@ function App() {
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取) // 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
const activeProviderId = useMemo(() => { const activeProviderId = useMemo(() => {
const target = proxyStatus?.active_targets?.find( const target = proxyStatus?.active_targets?.find(
(t) => t.app_type === activeApp (t) => t.app_type === activeApp,
); );
return target?.provider_id; return target?.provider_id;
}, [proxyStatus?.active_targets, activeApp]); }, [proxyStatus?.active_targets, activeApp]);
@@ -208,7 +208,7 @@ function App() {
if (event.appType === activeApp) { if (event.appType === activeApp) {
await refetch(); await refetch();
} }
} },
); );
} catch (error) { } catch (error) {
console.error("[App] Failed to subscribe provider switch event", error); console.error("[App] Failed to subscribe provider switch event", error);
@@ -242,7 +242,7 @@ function App() {
} catch (error) { } catch (error) {
console.error( console.error(
"[App] Failed to subscribe universal-provider-synced event", "[App] Failed to subscribe universal-provider-synced event",
error error,
); );
} }
}; };
@@ -270,7 +270,7 @@ function App() {
} catch (error) { } catch (error) {
console.error( console.error(
"[App] Failed to check environment conflicts on startup:", "[App] Failed to check environment conflicts on startup:",
error error,
); );
} }
}; };
@@ -286,7 +286,7 @@ function App() {
if (migrated) { if (migrated) {
toast.success( toast.success(
t("migration.success", { defaultValue: "配置迁移成功" }), t("migration.success", { defaultValue: "配置迁移成功" }),
{ closeButton: true } { closeButton: true },
); );
} }
} catch (error) { } catch (error) {
@@ -302,7 +302,7 @@ function App() {
const checkSkillsMigration = async () => { const checkSkillsMigration = async () => {
try { try {
const result = await invoke<{ count: number; error?: string } | null>( const result = await invoke<{ count: number; error?: string } | null>(
"get_skills_migration_result" "get_skills_migration_result",
); );
if (result?.error) { if (result?.error) {
toast.error(t("migration.skillsFailed"), { toast.error(t("migration.skillsFailed"), {
@@ -336,10 +336,10 @@ function App() {
// 合并新检测到的冲突 // 合并新检测到的冲突
setEnvConflicts((prev) => { setEnvConflicts((prev) => {
const existingKeys = new Set( const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`) prev.map((c) => `${c.varName}:${c.sourcePath}`),
); );
const newConflicts = conflicts.filter( const newConflicts = conflicts.filter(
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`) (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
); );
return [...prev, ...newConflicts]; return [...prev, ...newConflicts];
}); });
@@ -351,7 +351,7 @@ function App() {
} catch (error) { } catch (error) {
console.error( console.error(
"[App] Failed to check environment conflicts on app switch:", "[App] Failed to check environment conflicts on app switch:",
error error,
); );
} }
}; };
@@ -433,7 +433,7 @@ function App() {
t("notifications.removeFromConfigSuccess", { t("notifications.removeFromConfigSuccess", {
defaultValue: "已从配置移除", defaultValue: "已从配置移除",
}), }),
{ closeButton: true } { closeButton: true },
); );
} else { } else {
// Delete from database // Delete from database
@@ -445,7 +445,7 @@ function App() {
// Generate a unique provider key for OpenCode duplication // Generate a unique provider key for OpenCode duplication
const generateUniqueOpencodeKey = ( const generateUniqueOpencodeKey = (
originalKey: string, originalKey: string,
existingKeys: string[] existingKeys: string[],
): string => { ): string => {
const baseKey = `${originalKey}-copy`; const baseKey = `${originalKey}-copy`;
@@ -487,7 +487,7 @@ function App() {
const existingKeys = Object.keys(providers); const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey( duplicatedProvider.providerKey = generateUniqueOpencodeKey(
provider.id, provider.id,
existingKeys existingKeys,
); );
} }
@@ -498,7 +498,7 @@ function App() {
(p) => (p) =>
p.sortIndex !== undefined && p.sortIndex !== undefined &&
p.sortIndex >= newSortIndex! && p.sortIndex >= newSortIndex! &&
p.id !== provider.id p.id !== provider.id,
) )
.map((p) => ({ .map((p) => ({
id: p.id, id: p.id,
@@ -514,7 +514,7 @@ function App() {
toast.error( toast.error(
t("provider.sortUpdateFailed", { t("provider.sortUpdateFailed", {
defaultValue: "排序更新失败", defaultValue: "排序更新失败",
}) }),
); );
return; // 如果排序更新失败,不继续添加 return; // 如果排序更新失败,不继续添加
} }
@@ -532,7 +532,7 @@ function App() {
toast.success( toast.success(
t("provider.terminalOpened", { t("provider.terminalOpened", {
defaultValue: "终端已打开", defaultValue: "终端已打开",
}) }),
); );
} catch (error) { } catch (error) {
console.error("[App] Failed to open terminal", error); console.error("[App] Failed to open terminal", error);
@@ -540,7 +540,7 @@ function App() {
toast.error( toast.error(
t("provider.terminalOpenFailed", { t("provider.terminalOpenFailed", {
defaultValue: "打开终端失败", defaultValue: "打开终端失败",
}) + (errorMessage ? `: ${errorMessage}` : "") }) + (errorMessage ? `: ${errorMessage}` : ""),
); );
} }
}; };
@@ -721,7 +721,7 @@ function App() {
} catch (error) { } catch (error) {
console.error( console.error(
"[App] Failed to re-check conflicts after deletion:", "[App] Failed to re-check conflicts after deletion:",
error error,
); );
} }
}} }}
@@ -755,7 +755,9 @@ function App() {
size="icon" size="icon"
onClick={() => onClick={() =>
setCurrentView( setCurrentView(
currentView === "skillsDiscovery" ? "skills" : "providers" currentView === "skillsDiscovery"
? "skills"
: "providers",
) )
} }
className="mr-2 rounded-lg" className="mr-2 rounded-lg"
@@ -788,7 +790,7 @@ function App() {
"text-xl font-semibold transition-colors", "text-xl font-semibold transition-colors",
isProxyRunning && isCurrentAppTakeoverActive isProxyRunning && isCurrentAppTakeoverActive
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300" ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300" : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
)} )}
> >
CC Switch CC Switch
@@ -934,7 +936,7 @@ function App() {
"transition-all duration-300 ease-in-out overflow-hidden", "transition-all duration-300 ease-in-out overflow-hidden",
isCurrentAppTakeoverActive isCurrentAppTakeoverActive
? "opacity-100 max-w-[100px] scale-100" ? "opacity-100 max-w-[100px] scale-100"
: "opacity-0 max-w-0 scale-75 pointer-events-none" : "opacity-0 max-w-0 scale-75 pointer-events-none",
)} )}
> >
<FailoverToggle activeApp={activeApp} /> <FailoverToggle activeApp={activeApp} />
@@ -962,7 +964,7 @@ function App() {
"transition-all duration-200 ease-in-out overflow-hidden", "transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2" ? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1" : "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)} )}
title={t("skills.manage")} title={t("skills.manage")}
> >
+4 -1
View File
@@ -8,7 +8,10 @@ interface AppCountBarProps {
counts: Record<AppId, number>; counts: Record<AppId, number>;
} }
export const AppCountBar: React.FC<AppCountBarProps> = ({ totalLabel, counts }) => { export const AppCountBar: React.FC<AppCountBarProps> = ({
totalLabel,
counts,
}) => {
return ( return (
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4"> <div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
<Badge variant="outline" className="bg-background/50 h-7 px-3"> <Badge variant="outline" className="bg-background/50 h-7 px-3">
+9 -5
View File
@@ -12,7 +12,10 @@ interface AppToggleGroupProps {
onToggle: (app: AppId, enabled: boolean) => void; onToggle: (app: AppId, enabled: boolean) => void;
} }
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({ apps, onToggle }) => { export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
apps,
onToggle,
}) => {
return ( return (
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{APP_IDS.map((app) => { {APP_IDS.map((app) => {
@@ -25,16 +28,17 @@ export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({ apps, onToggle }
type="button" type="button"
onClick={() => onToggle(app, !enabled)} onClick={() => onToggle(app, !enabled)}
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${ className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
enabled enabled ? activeClass : "opacity-35 hover:opacity-70"
? activeClass
: "opacity-35 hover:opacity-70"
}`} }`}
> >
{icon} {icon}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
<p>{label}{enabled ? " ✓" : ""}</p> <p>
{label}
{enabled ? " ✓" : ""}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
+4 -1
View File
@@ -5,7 +5,10 @@ interface ListItemRowProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const ListItemRow: React.FC<ListItemRowProps> = ({ isLast, children }) => { export const ListItemRow: React.FC<ListItemRowProps> = ({
isLast,
children,
}) => {
return ( return (
<div <div
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${ className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${
+7 -2
View File
@@ -245,7 +245,9 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<ListItemRow isLast={isLast}> <ListItemRow isLast={isLast}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-foreground truncate">{name}</span> <span className="font-medium text-sm text-foreground truncate">
{name}
</span>
{docsUrl && ( {docsUrl && (
<button <button
type="button" type="button"
@@ -258,7 +260,10 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
)} )}
</div> </div>
{description && ( {description && (
<p className="text-xs text-muted-foreground truncate" title={description}> <p
className="text-xs text-muted-foreground truncate"
title={description}
>
{description} {description}
</p> </p>
)} )}
@@ -746,7 +746,7 @@ export function ProviderForm({
return; return;
} }
// OpenCode: validate provider key and models // OpenCode: validate provider key
if (appId === "opencode") { if (appId === "opencode") {
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!opencodeProviderKey.trim()) { if (!opencodeProviderKey.trim()) {
@@ -761,11 +761,6 @@ export function ProviderForm({
toast.error(t("opencode.providerKeyDuplicate")); toast.error(t("opencode.providerKeyDuplicate"));
return; return;
} }
// Validate that at least one model is configured
if (Object.keys(opencodeModels).length === 0) {
toast.error(t("opencode.modelsRequired"));
return;
}
} }
// 非官方供应商必填校验:端点和 API Key // 非官方供应商必填校验:端点和 API Key
+2 -2
View File
@@ -40,7 +40,7 @@ export function SessionItem({
"w-full text-left rounded-lg px-3 py-2.5 transition-all group", "w-full text-left rounded-lg px-3 py-2.5 transition-all group",
isSelected isSelected
? "bg-primary/10 border border-primary/30" ? "bg-primary/10 border border-primary/30"
: "hover:bg-muted/60 border border-transparent" : "hover:bg-muted/60 border border-transparent",
)} )}
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -62,7 +62,7 @@ export function SessionItem({
<ChevronRight <ChevronRight
className={cn( className={cn(
"size-4 text-muted-foreground/50 shrink-0 transition-transform", "size-4 text-muted-foreground/50 shrink-0 transition-transform",
isSelected && "text-primary rotate-90" isSelected && "text-primary rotate-90",
)} )}
/> />
</div> </div>
+15 -13
View File
@@ -56,7 +56,7 @@ export function SessionManagerPage() {
const messagesEndRef = useRef<HTMLDivElement | null>(null); const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map()); const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>( const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
null null,
); );
const [tocDialogOpen, setTocDialogOpen] = useState(false); const [tocDialogOpen, setTocDialogOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
@@ -83,7 +83,7 @@ export function SessionManagerPage() {
} }
const exists = selectedKey const exists = selectedKey
? filteredSessions.some( ? filteredSessions.some(
(session) => getSessionKey(session) === selectedKey (session) => getSessionKey(session) === selectedKey,
) )
: false; : false;
if (!exists) { if (!exists) {
@@ -95,7 +95,7 @@ export function SessionManagerPage() {
if (!selectedKey) return null; if (!selectedKey) return null;
return ( return (
filteredSessions.find( filteredSessions.find(
(session) => getSessionKey(session) === selectedKey (session) => getSessionKey(session) === selectedKey,
) || null ) || null
); );
}, [filteredSessions, selectedKey]); }, [filteredSessions, selectedKey]);
@@ -103,7 +103,7 @@ export function SessionManagerPage() {
const { data: messages = [], isLoading: isLoadingMessages } = const { data: messages = [], isLoading: isLoadingMessages } =
useSessionMessagesQuery( useSessionMessagesQuery(
selectedSession?.providerId, selectedSession?.providerId,
selectedSession?.sourcePath selectedSession?.sourcePath,
); );
// 提取用户消息用于目录 // 提取用户消息用于目录
@@ -147,7 +147,7 @@ export function SessionManagerPage() {
} catch (error) { } catch (error) {
toast.error( toast.error(
extractErrorMessage(error) || extractErrorMessage(error) ||
t("common.error", { defaultValue: "Copy failed" }) t("common.error", { defaultValue: "Copy failed" }),
); );
} }
}; };
@@ -158,7 +158,7 @@ export function SessionManagerPage() {
if (!isMac()) { if (!isMac()) {
await handleCopy( await handleCopy(
selectedSession.resumeCommand, selectedSession.resumeCommand,
t("sessionManager.resumeCommandCopied") t("sessionManager.resumeCommandCopied"),
); );
return; return;
} }
@@ -240,14 +240,16 @@ export function SessionManagerPage() {
setIsSearchOpen(true); setIsSearchOpen(true);
setTimeout( setTimeout(
() => searchInputRef.current?.focus(), () => searchInputRef.current?.focus(),
0 0,
); );
}} }}
> >
<Search className="size-3.5" /> <Search className="size-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("sessionManager.searchSessions")}</TooltipContent> <TooltipContent>
{t("sessionManager.searchSessions")}
</TooltipContent>
</Tooltip> </Tooltip>
<Select <Select
@@ -387,7 +389,7 @@ export function SessionManagerPage() {
<span className="shrink-0"> <span className="shrink-0">
<ProviderIcon <ProviderIcon
icon={getProviderIconName( icon={getProviderIconName(
selectedSession.providerId selectedSession.providerId,
)} )}
name={selectedSession.providerId} name={selectedSession.providerId}
size={20} size={20}
@@ -410,7 +412,7 @@ export function SessionManagerPage() {
<span> <span>
{formatTimestamp( {formatTimestamp(
selectedSession.lastActiveAt ?? selectedSession.lastActiveAt ??
selectedSession.createdAt selectedSession.createdAt,
)} )}
</span> </span>
</div> </div>
@@ -422,7 +424,7 @@ export function SessionManagerPage() {
onClick={() => onClick={() =>
void handleCopy( void handleCopy(
selectedSession.projectDir!, selectedSession.projectDir!,
t("sessionManager.projectDirCopied") t("sessionManager.projectDirCopied"),
) )
} }
className="flex items-center gap-1 hover:text-foreground transition-colors" className="flex items-center gap-1 hover:text-foreground transition-colors"
@@ -497,7 +499,7 @@ export function SessionManagerPage() {
onClick={() => onClick={() =>
void handleCopy( void handleCopy(
selectedSession.resumeCommand!, selectedSession.resumeCommand!,
t("sessionManager.resumeCommandCopied") t("sessionManager.resumeCommandCopied"),
) )
} }
> >
@@ -559,7 +561,7 @@ export function SessionManagerPage() {
content, content,
t("sessionManager.messageCopied", { t("sessionManager.messageCopied", {
defaultValue: "已复制消息内容", defaultValue: "已复制消息内容",
}) }),
) )
} }
/> />
@@ -37,7 +37,7 @@ export function SessionMessageItem({
: message.role.toLowerCase() === "assistant" : message.role.toLowerCase() === "assistant"
? "bg-blue-500/5 border-blue-500/20 mr-8" ? "bg-blue-500/5 border-blue-500/20 mr-8"
: "bg-muted/40 border-border/60", : "bg-muted/40 border-border/60",
isActive && "ring-2 ring-primary ring-offset-2" isActive && "ring-2 ring-primary ring-offset-2",
)} )}
> >
<Tooltip> <Tooltip>
+2 -2
View File
@@ -48,7 +48,7 @@ export function SessionTocSidebar({
className={cn( className={cn(
"w-full text-left px-2 py-1.5 rounded text-xs transition-colors", "w-full text-left px-2 py-1.5 rounded text-xs transition-colors",
"hover:bg-muted/80 text-muted-foreground hover:text-foreground", "hover:bg-muted/80 text-muted-foreground hover:text-foreground",
"flex items-start gap-2" "flex items-start gap-2",
)} )}
> >
<span className="shrink-0 w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-medium"> <span className="shrink-0 w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-medium">
@@ -118,7 +118,7 @@ export function SessionTocDialog({
"w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all", "w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all",
"hover:bg-primary/10 text-foreground", "hover:bg-primary/10 text-foreground",
"flex items-start gap-3", "flex items-start gap-3",
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset" "focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset",
)} )}
> >
<span className="shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-semibold"> <span className="shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-semibold">
+3 -6
View File
@@ -19,7 +19,7 @@ export const formatTimestamp = (value?: number) => {
export const formatRelativeTime = ( export const formatRelativeTime = (
value: number | undefined, value: number | undefined,
t: (key: string, options?: Record<string, unknown>) => string t: (key: string, options?: Record<string, unknown>) => string,
) => { ) => {
if (!value) return ""; if (!value) return "";
const now = Date.now(); const now = Date.now();
@@ -37,7 +37,7 @@ export const formatRelativeTime = (
export const getProviderLabel = ( export const getProviderLabel = (
providerId: string, providerId: string,
t: (key: string) => string t: (key: string) => string,
) => { ) => {
const key = `apps.${providerId}`; const key = `apps.${providerId}`;
const translated = t(key); const translated = t(key);
@@ -60,10 +60,7 @@ export const getRoleTone = (role: string) => {
return "text-muted-foreground"; return "text-muted-foreground";
}; };
export const getRoleLabel = ( export const getRoleLabel = (role: string, t: (key: string) => string) => {
role: string,
t: (key: string) => string
) => {
const normalized = role.toLowerCase(); const normalized = role.toLowerCase();
if (normalized === "assistant") return "AI"; if (normalized === "assistant") return "AI";
if (normalized === "user") return t("sessionManager.roleUser"); if (normalized === "user") return t("sessionManager.roleUser");
+11 -8
View File
@@ -63,11 +63,7 @@ const UnifiedSkillsPanel = React.forwardRef<
return counts; return counts;
}, [skills]); }, [skills]);
const handleToggleApp = async ( const handleToggleApp = async (id: string, app: AppId, enabled: boolean) => {
id: string,
app: AppId,
enabled: boolean,
) => {
try { try {
await toggleAppMutation.mutateAsync({ id, app, enabled }); await toggleAppMutation.mutateAsync({ id, app, enabled });
} catch (error) { } catch (error) {
@@ -257,7 +253,9 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
<ListItemRow isLast={isLast}> <ListItemRow isLast={isLast}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-foreground truncate">{skill.name}</span> <span className="font-medium text-sm text-foreground truncate">
{skill.name}
</span>
{skill.readmeUrl && ( {skill.readmeUrl && (
<button <button
type="button" type="button"
@@ -267,10 +265,15 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
<ExternalLink size={12} /> <ExternalLink size={12} />
</button> </button>
)} )}
<span className="text-xs text-muted-foreground/50 flex-shrink-0">{sourceLabel}</span> <span className="text-xs text-muted-foreground/50 flex-shrink-0">
{sourceLabel}
</span>
</div> </div>
{skill.description && ( {skill.description && (
<p className="text-xs text-muted-foreground truncate" title={skill.description}> <p
className="text-xs text-muted-foreground truncate"
title={skill.description}
>
{skill.description} {skill.description}
</p> </p>
)} )}
+1 -1
View File
@@ -36,7 +36,7 @@ const ScrollBar = React.forwardRef<
"h-full w-2.5 border-l border-l-transparent p-[1px]", "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]", "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className className,
)} )}
{...props} {...props}
> >
+1 -1
View File
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className,
)} )}
{...props} {...props}
/> />
+11 -4
View File
@@ -8,10 +8,17 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useModelStats } from "@/lib/query/usage"; import { useModelStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
export function ModelStatsTable() { interface ModelStatsTableProps {
refreshIntervalMs: number;
}
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats(); const { data: stats, isLoading } = useModelStats({
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) { if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />; return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
@@ -60,10 +67,10 @@ export function ModelStatsTable() {
{stat.totalTokens.toLocaleString()} {stat.totalTokens.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)} {fmtUsd(stat.totalCost, 4)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
${parseFloat(stat.avgCostPerRequest).toFixed(6)} {fmtUsd(stat.avgCostPerRequest, 6)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
+12 -3
View File
@@ -8,10 +8,19 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useProviderStats } from "@/lib/query/usage"; import { useProviderStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
export function ProviderStatsTable() { interface ProviderStatsTableProps {
refreshIntervalMs: number;
}
export function ProviderStatsTable({
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats(); const { data: stats, isLoading } = useProviderStats({
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) { if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />; return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
@@ -63,7 +72,7 @@ export function ProviderStatsTable() {
{stat.totalTokens.toLocaleString()} {stat.totalTokens.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)} {fmtUsd(stat.totalCost, 4)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{stat.successRate.toFixed(1)}% {stat.successRate.toFixed(1)}%
+159 -58
View File
@@ -21,44 +21,122 @@ import { useRequestLogs, usageKeys } from "@/lib/query/usage";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { LogFilters } from "@/types/usage"; import type { LogFilters } from "@/types/usage";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
import {
fmtInt,
fmtUsd,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
export function RequestLogTable() { interface RequestLogTableProps {
refreshIntervalMs: number;
}
const ONE_DAY_SECONDS = 24 * 60 * 60;
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// 默认时间范围:过去24小时 const getRollingRange = () => {
const getDefaultFilters = (): LogFilters => {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const oneDayAgo = now - 24 * 60 * 60; const oneDayAgo = now - ONE_DAY_SECONDS;
return { startDate: oneDayAgo, endDate: now }; return { startDate: oneDayAgo, endDate: now };
}; };
const [filters, setFilters] = useState<LogFilters>(getDefaultFilters); const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
const [tempFilters, setTempFilters] = useState<LogFilters>(getDefaultFilters); const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 20; const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
const { data: result, isLoading } = useRequestLogs(filters, page, pageSize); const { data: result, isLoading } = useRequestLogs({
filters: appliedFilters,
timeMode: appliedTimeMode,
rollingWindowSeconds: ONE_DAY_SECONDS,
page,
pageSize,
options: {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
});
const logs = result?.data ?? []; const logs = result?.data ?? [];
const total = result?.total ?? 0; const total = result?.total ?? 0;
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => { const handleSearch = () => {
setFilters(tempFilters); setValidationError(null);
if (draftTimeMode === "fixed") {
const start = draftFilters.startDate;
const end = draftFilters.endDate;
if (typeof start !== "number" || typeof end !== "number") {
setValidationError(
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
);
return;
}
if (start > end) {
setValidationError(
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
);
return;
}
if (end - start > MAX_FIXED_RANGE_SECONDS) {
setValidationError(
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
);
return;
}
}
setAppliedTimeMode(draftTimeMode);
setAppliedFilters((prev) => {
const next = { ...prev, ...draftFilters };
if (draftTimeMode === "rolling") {
delete next.startDate;
delete next.endDate;
}
return next;
});
setPage(0); setPage(0);
}; };
const handleReset = () => { const handleReset = () => {
const defaults = getDefaultFilters(); setValidationError(null);
setTempFilters(defaults); setAppliedTimeMode("rolling");
setFilters(defaults); setDraftTimeMode("rolling");
setDraftFilters({});
setAppliedFilters({});
setPage(0); setPage(0);
}; };
const handleRefresh = () => { const handleRefresh = () => {
const key = {
timeMode: appliedTimeMode,
rollingWindowSeconds:
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
appType: appliedFilters.appType,
providerName: appliedFilters.providerName,
model: appliedFilters.model,
statusCode: appliedFilters.statusCode,
startDate:
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
};
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: usageKeys.logs(filters, page, pageSize), queryKey: usageKeys.logs(key, page, pageSize),
}); });
}; };
@@ -84,12 +162,11 @@ export function RequestLogTable() {
return Math.floor(timestamp / 1000); return Math.floor(timestamp / 1000);
}; };
const dateLocale = const language = i18n.resolvedLanguage || i18n.language || "en";
i18n.language === "zh" const locale = getLocaleFromLanguage(language);
? "zh-CN"
: i18n.language === "ja" const rollingRangeForDisplay =
? "ja-JP" draftTimeMode === "rolling" ? getRollingRange() : null;
: "en-US";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -97,10 +174,10 @@ export function RequestLogTable() {
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm"> <div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Select <Select
value={tempFilters.appType || "all"} value={draftFilters.appType || "all"}
onValueChange={(v) => onValueChange={(v) =>
setTempFilters({ setDraftFilters({
...tempFilters, ...draftFilters,
appType: v === "all" ? undefined : v, appType: v === "all" ? undefined : v,
}) })
} }
@@ -117,11 +194,16 @@ export function RequestLogTable() {
</Select> </Select>
<Select <Select
value={tempFilters.statusCode?.toString() || "all"} value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) => onValueChange={(v) =>
setTempFilters({ setDraftFilters({
...tempFilters, ...draftFilters,
statusCode: v === "all" ? undefined : parseInt(v), statusCode:
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
}) })
} }
> >
@@ -144,10 +226,10 @@ export function RequestLogTable() {
<Input <Input
placeholder={t("usage.searchProviderPlaceholder")} placeholder={t("usage.searchProviderPlaceholder")}
className="pl-9 bg-background" className="pl-9 bg-background"
value={tempFilters.providerName || ""} value={draftFilters.providerName || ""}
onChange={(e) => onChange={(e) =>
setTempFilters({ setDraftFilters({
...tempFilters, ...draftFilters,
providerName: e.target.value || undefined, providerName: e.target.value || undefined,
}) })
} }
@@ -156,10 +238,10 @@ export function RequestLogTable() {
<Input <Input
placeholder={t("usage.searchModelPlaceholder")} placeholder={t("usage.searchModelPlaceholder")}
className="w-[180px] bg-background" className="w-[180px] bg-background"
value={tempFilters.model || ""} value={draftFilters.model || ""}
onChange={(e) => onChange={(e) =>
setTempFilters({ setDraftFilters({
...tempFilters, ...draftFilters,
model: e.target.value || undefined, model: e.target.value || undefined,
}) })
} }
@@ -174,14 +256,18 @@ export function RequestLogTable() {
type="datetime-local" type="datetime-local"
className="h-8 w-[200px] bg-background" className="h-8 w-[200px] bg-background"
value={ value={
tempFilters.startDate (rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
? timestampToLocalDatetime(tempFilters.startDate) ? timestampToLocalDatetime(
(rollingRangeForDisplay?.startDate ??
draftFilters.startDate) as number,
)
: "" : ""
} }
onChange={(e) => { onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value); const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({ setDraftTimeMode("fixed");
...tempFilters, setDraftFilters({
...draftFilters,
startDate: timestamp, startDate: timestamp,
}); });
}} }}
@@ -191,14 +277,18 @@ export function RequestLogTable() {
type="datetime-local" type="datetime-local"
className="h-8 w-[200px] bg-background" className="h-8 w-[200px] bg-background"
value={ value={
tempFilters.endDate (rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
? timestampToLocalDatetime(tempFilters.endDate) ? timestampToLocalDatetime(
(rollingRangeForDisplay?.endDate ??
draftFilters.endDate) as number,
)
: "" : ""
} }
onChange={(e) => { onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value); const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({ setDraftTimeMode("fixed");
...tempFilters, setDraftFilters({
...draftFilters,
endDate: timestamp, endDate: timestamp,
}); });
}} }}
@@ -234,6 +324,10 @@ export function RequestLogTable() {
</Button> </Button>
</div> </div>
</div> </div>
{validationError && (
<div className="text-sm text-red-600">{validationError}</div>
)}
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -293,9 +387,7 @@ export function RequestLogTable() {
logs.map((log) => ( logs.map((log) => (
<TableRow key={log.requestId}> <TableRow key={log.requestId}>
<TableCell> <TableCell>
{new Date(log.createdAt * 1000).toLocaleString( {new Date(log.createdAt * 1000).toLocaleString(locale)}
dateLocale,
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{log.providerName || t("usage.unknownProvider")} {log.providerName || t("usage.unknownProvider")}
@@ -321,19 +413,19 @@ export function RequestLogTable() {
)} )}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{log.inputTokens.toLocaleString()} {fmtInt(log.inputTokens, locale)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{log.outputTokens.toLocaleString()} {fmtInt(log.outputTokens, locale)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{log.cacheReadTokens.toLocaleString()} {fmtInt(log.cacheReadTokens, locale)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()} {fmtInt(log.cacheCreationTokens, locale)}
</TableCell> </TableCell>
<TableCell className="text-right font-mono text-xs"> <TableCell className="text-right font-mono text-xs">
{parseFloat(log.costMultiplier) !== 1 ? ( {(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
<span className="text-orange-600"> <span className="text-orange-600">
×{log.costMultiplier} ×{log.costMultiplier}
</span> </span>
@@ -342,24 +434,30 @@ export function RequestLogTable() {
)} )}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)} {fmtUsd(log.totalCostUsd, 6)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{(() => { {(() => {
const durationSec = const durationMs =
(log.durationMs ?? log.latencyMs) / 1000; typeof log.durationMs === "number"
const durationColor = ? log.durationMs
durationSec <= 5 : log.latencyMs;
const durationSec = durationMs / 1000;
const durationColor = Number.isFinite(durationSec)
? durationSec <= 5
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"
: durationSec <= 120 : durationSec <= 120
? "bg-orange-100 text-orange-800" ? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900"; : "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return ( return (
<span <span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`} className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
> >
{Math.round(durationSec)}s {Number.isFinite(durationSec)
? `${Math.round(durationSec)}s`
: "--"}
</span> </span>
); );
})()} })()}
@@ -367,17 +465,20 @@ export function RequestLogTable() {
log.firstTokenMs != null && log.firstTokenMs != null &&
(() => { (() => {
const firstSec = log.firstTokenMs / 1000; const firstSec = log.firstTokenMs / 1000;
const firstColor = const firstColor = Number.isFinite(firstSec)
firstSec <= 5 ? firstSec <= 5
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"
: firstSec <= 120 : firstSec <= 120
? "bg-orange-100 text-orange-800" ? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900"; : "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return ( return (
<span <span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`} className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
> >
{firstSec.toFixed(1)}s {Number.isFinite(firstSec)
? `${firstSec.toFixed(1)}s`
: "--"}
</span> </span>
); );
})()} })()}
+36 -6
View File
@@ -8,11 +8,28 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable"; import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage"; import type { TimeRange } from "@/types/usage";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { BarChart3, ListFilter, Activity } from "lucide-react"; import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
export function UsageDashboard() { export function UsageDashboard() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [timeRange, setTimeRange] = useState<TimeRange>("1d"); const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
const changeRefreshInterval = () => {
const currentIndex = refreshIntervalOptionsMs.indexOf(
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
);
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
const next = refreshIntervalOptionsMs[nextIndex];
setRefreshIntervalMs(next);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
};
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
@@ -34,6 +51,18 @@ export function UsageDashboard() {
onValueChange={(v) => setTimeRange(v as TimeRange)} onValueChange={(v) => setTimeRange(v as TimeRange)}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<div className="flex w-full sm:w-auto items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-10 px-2 text-xs text-muted-foreground"
title={t("common.refresh", "刷新")}
onClick={changeRefreshInterval}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
</Button>
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1"> <TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
<TabsTrigger <TabsTrigger
value="1d" value="1d"
@@ -54,12 +83,13 @@ export function UsageDashboard() {
{t("usage.last30days")} {t("usage.last30days")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div>
</Tabs> </Tabs>
</div> </div>
<UsageSummaryCards days={days} /> <UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
<UsageTrendChart days={days} /> <UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
<div className="space-y-4"> <div className="space-y-4">
<Tabs defaultValue="logs" className="w-full"> <Tabs defaultValue="logs" className="w-full">
@@ -86,15 +116,15 @@ export function UsageDashboard() {
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
> >
<TabsContent value="logs" className="mt-0"> <TabsContent value="logs" className="mt-0">
<RequestLogTable /> <RequestLogTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent> </TabsContent>
<TabsContent value="providers" className="mt-0"> <TabsContent value="providers" className="mt-0">
<ProviderStatsTable /> <ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent> </TabsContent>
<TabsContent value="models" className="mt-0"> <TabsContent value="models" className="mt-0">
<ModelStatsTable /> <ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent> </TabsContent>
</motion.div> </motion.div>
</Tabs> </Tabs>
+11 -4
View File
@@ -4,19 +4,26 @@ import { Card, CardContent } from "@/components/ui/card";
import { useUsageSummary } from "@/lib/query/usage"; import { useUsageSummary } from "@/lib/query/usage";
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { fmtUsd, parseFiniteNumber } from "./format";
interface UsageSummaryCardsProps { interface UsageSummaryCardsProps {
days: number; days: number;
refreshIntervalMs: number;
} }
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { export function UsageSummaryCards({
days,
refreshIntervalMs,
}: UsageSummaryCardsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days); const { data: summary, isLoading } = useUsageSummary(days, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const stats = useMemo(() => { const stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0; const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFloat(summary?.totalCost || "0"); const totalCost = parseFiniteNumber(summary?.totalCost);
const inputTokens = summary?.totalInputTokens ?? 0; const inputTokens = summary?.totalInputTokens ?? 0;
const outputTokens = summary?.totalOutputTokens ?? 0; const outputTokens = summary?.totalOutputTokens ?? 0;
@@ -37,7 +44,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
}, },
{ {
title: t("usage.totalCost"), title: t("usage.totalCost"),
value: `$${totalCost.toFixed(4)}`, value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
icon: DollarSign, icon: DollarSign,
color: "text-green-500", color: "text-green-500",
bg: "bg-green-500/10", bg: "bg-green-500/10",
+21 -12
View File
@@ -11,14 +11,26 @@ import {
} from "recharts"; } from "recharts";
import { useUsageTrends } from "@/lib/query/usage"; import { useUsageTrends } from "@/lib/query/usage";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import {
fmtInt,
fmtUsd,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
interface UsageTrendChartProps { interface UsageTrendChartProps {
days: number; days: number;
refreshIntervalMs: number;
} }
export function UsageTrendChart({ days }: UsageTrendChartProps) { export function UsageTrendChart({
days,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days); const { data: trends, isLoading } = useUsageTrends(days, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) { if (isLoading) {
return ( return (
@@ -29,15 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
} }
const isToday = days === 1; const isToday = days === 1;
const dateLocale = const language = i18n.resolvedLanguage || i18n.language || "en";
i18n.language === "zh" const dateLocale = getLocaleFromLanguage(language);
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
const chartData = const chartData =
trends?.map((stat) => { trends?.map((stat) => {
const pointDate = new Date(stat.date); const pointDate = new Date(stat.date);
const cost = parseFiniteNumber(stat.totalCost);
return { return {
rawDate: stat.date, rawDate: stat.date,
label: isToday label: isToday
@@ -56,7 +65,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
outputTokens: stat.totalOutputTokens, outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens, cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens, cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost), cost: cost ?? null,
}; };
}) || []; }) || [];
@@ -79,9 +88,9 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
/> />
<span className="font-medium">{entry.name}:</span> <span className="font-medium">{entry.name}:</span>
<span> <span>
{entry.name.includes(t("usage.cost", "成本")) {entry.dataKey === "cost"
? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}` ? fmtUsd(entry.value, 6)
: entry.value.toLocaleString()} : fmtInt(entry.value, dateLocale)}
</span> </span>
</div> </div>
))} ))}
+39
View File
@@ -0,0 +1,39 @@
export function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function fmtInt(
value: unknown,
locale?: string,
fallback: string = "--",
): string {
const num = parseFiniteNumber(value);
if (num == null) return fallback;
return new Intl.NumberFormat(locale).format(Math.trunc(num));
}
export function fmtUsd(
value: unknown,
digits: number,
fallback: string = "--",
): string {
const num = parseFiniteNumber(value);
if (num == null) return fallback;
return `$${num.toFixed(digits)}`;
}
export function getLocaleFromLanguage(language: string): string {
if (!language) return "en-US";
if (language.startsWith("zh")) return "zh-CN";
if (language.startsWith("ja")) return "ja-JP";
return "en-US";
}
+24 -9
View File
@@ -16,25 +16,40 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
claude: { claude: {
label: "Claude", label: "Claude",
icon: <ClaudeIcon size={14} />, icon: <ClaudeIcon size={14} />,
activeClass: "bg-orange-500/10 ring-1 ring-orange-500/20 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400", activeClass:
badgeClass: "bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5", "bg-orange-500/10 ring-1 ring-orange-500/20 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400",
badgeClass:
"bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5",
}, },
codex: { codex: {
label: "Codex", label: "Codex",
icon: <CodexIcon size={14} />, icon: <CodexIcon size={14} />,
activeClass: "bg-green-500/10 ring-1 ring-green-500/20 hover:bg-green-500/20 text-green-600 dark:text-green-400", activeClass:
badgeClass: "bg-green-500/10 text-green-700 dark:text-green-300 hover:bg-green-500/20 border-0 gap-1.5", "bg-green-500/10 ring-1 ring-green-500/20 hover:bg-green-500/20 text-green-600 dark:text-green-400",
badgeClass:
"bg-green-500/10 text-green-700 dark:text-green-300 hover:bg-green-500/20 border-0 gap-1.5",
}, },
gemini: { gemini: {
label: "Gemini", label: "Gemini",
icon: <GeminiIcon size={14} />, icon: <GeminiIcon size={14} />,
activeClass: "bg-blue-500/10 ring-1 ring-blue-500/20 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400", activeClass:
badgeClass: "bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5", "bg-blue-500/10 ring-1 ring-blue-500/20 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400",
badgeClass:
"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
}, },
opencode: { opencode: {
label: "OpenCode", label: "OpenCode",
icon: <ProviderIcon icon="opencode" name="OpenCode" size={14} showFallback={false} />, icon: (
activeClass: "bg-indigo-500/10 ring-1 ring-indigo-500/20 hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400", <ProviderIcon
badgeClass: "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5", icon="opencode"
name="OpenCode"
size={14}
showFallback={false}
/>
),
activeClass:
"bg-indigo-500/10 ring-1 ring-indigo-500/20 hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400",
badgeClass:
"bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5",
}, },
}; };
+2 -2
View File
@@ -117,7 +117,7 @@ export function useSessionSearch({
.filter( .filter(
(session) => (session) =>
session && session &&
(providerFilter === "all" || session.providerId === providerFilter) (providerFilter === "all" || session.providerId === providerFilter),
); );
// 按时间排序 // 按时间排序
@@ -127,7 +127,7 @@ export function useSessionSearch({
return bTs - aTs; return bTs - aTs;
}); });
}, },
[sessions, providerFilter] [sessions, providerFilter],
); );
return useMemo(() => ({ search, isIndexing }), [search, isIndexing]); return useMemo(() => ({ search, isIndexing }), [search, isIndexing]);
-1
View File
@@ -633,7 +633,6 @@
"modelId": "Model ID", "modelId": "Model ID",
"modelName": "Display Name", "modelName": "Display Name",
"noModels": "No models configured", "noModels": "No models configured",
"modelsRequired": "Please add at least one model",
"providerKey": "Provider Key", "providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
-1
View File
@@ -633,7 +633,6 @@
"modelId": "モデル ID", "modelId": "モデル ID",
"modelName": "表示名", "modelName": "表示名",
"noModels": "モデルが設定されていません", "noModels": "モデルが設定されていません",
"modelsRequired": "モデルを少なくとも1つ追加してください",
"providerKey": "プロバイダーキー", "providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。", "providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
-1
View File
@@ -633,7 +633,6 @@
"modelId": "模型 ID", "modelId": "模型 ID",
"modelName": "显示名称", "modelName": "显示名称",
"noModels": "暂无模型配置", "noModels": "暂无模型配置",
"modelsRequired": "请至少添加一个模型配置",
"providerKey": "供应商标识", "providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
+1 -1
View File
@@ -8,7 +8,7 @@ export const sessionsApi = {
async getMessages( async getMessages(
providerId: string, providerId: string,
sourcePath: string sourcePath: string,
): Promise<SessionMessage[]> { ): Promise<SessionMessage[]> {
return await invoke("get_session_messages", { providerId, sourcePath }); return await invoke("get_session_messages", { providerId, sourcePath });
}, },
+2 -9
View File
@@ -91,11 +91,7 @@ export const skillsApi = {
}, },
/** 切换 Skill 的应用启用状态 */ /** 切换 Skill 的应用启用状态 */
async toggleApp( async toggleApp(id: string, app: AppId, enabled: boolean): Promise<boolean> {
id: string,
app: AppId,
enabled: boolean,
): Promise<boolean> {
return await invoke("toggle_skill_app", { id, app, enabled }); return await invoke("toggle_skill_app", { id, app, enabled });
}, },
@@ -133,10 +129,7 @@ export const skillsApi = {
}, },
/** 卸载技能(兼容旧 API) */ /** 卸载技能(兼容旧 API) */
async uninstall( async uninstall(directory: string, app: AppId = "claude"): Promise<boolean> {
directory: string,
app: AppId = "claude",
): Promise<boolean> {
if (app === "claude") { if (app === "claude") {
return await invoke("uninstall_skill", { directory }); return await invoke("uninstall_skill", { directory });
} }
+92 -23
View File
@@ -2,6 +2,35 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage"; import { usageApi } from "@/lib/api/usage";
import type { LogFilters } from "@/types/usage"; import type { LogFilters } from "@/types/usage";
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
type UsageQueryOptions = {
refetchInterval?: number | false;
refetchIntervalInBackground?: boolean;
};
type RequestLogsTimeMode = "rolling" | "fixed";
type RequestLogsQueryArgs = {
filters: LogFilters;
timeMode: RequestLogsTimeMode;
page?: number;
pageSize?: number;
rollingWindowSeconds?: number;
options?: UsageQueryOptions;
};
type RequestLogsKey = {
timeMode: RequestLogsTimeMode;
rollingWindowSeconds?: number;
appType?: string;
providerName?: string;
model?: string;
statusCode?: number;
startDate?: number;
endDate?: number;
};
// Query keys // Query keys
export const usageKeys = { export const usageKeys = {
all: ["usage"] as const, all: ["usage"] as const,
@@ -9,8 +38,21 @@ export const usageKeys = {
trends: (days: number) => [...usageKeys.all, "trends", days] as const, trends: (days: number) => [...usageKeys.all, "trends", days] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const, providerStats: () => [...usageKeys.all, "provider-stats"] as const,
modelStats: () => [...usageKeys.all, "model-stats"] as const, modelStats: () => [...usageKeys.all, "model-stats"] as const,
logs: (filters: LogFilters, page: number, pageSize: number) => logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[...usageKeys.all, "logs", filters, page, pageSize] as const, [
...usageKeys.all,
"logs",
key.timeMode,
key.rollingWindowSeconds ?? 0,
key.appType ?? "",
key.providerName ?? "",
key.model ?? "",
key.statusCode ?? -1,
key.startDate ?? 0,
key.endDate ?? 0,
page,
pageSize,
] as const,
detail: (requestId: string) => detail: (requestId: string) =>
[...usageKeys.all, "detail", requestId] as const, [...usageKeys.all, "detail", requestId] as const,
pricing: () => [...usageKeys.all, "pricing"] as const, pricing: () => [...usageKeys.all, "pricing"] as const,
@@ -25,58 +67,85 @@ const getWindow = (days: number) => {
}; };
// Hooks // Hooks
export function useUsageSummary(days: number) { export function useUsageSummary(days: number, options?: UsageQueryOptions) {
return useQuery({ return useQuery({
queryKey: usageKeys.summary(days), queryKey: usageKeys.summary(days),
queryFn: () => { queryFn: () => {
const { startDate, endDate } = getWindow(days); const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate); return usageApi.getUsageSummary(startDate, endDate);
}, },
refetchInterval: 30000, // 每30秒自动刷新 refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: false, // 后台不刷新 refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新
}); });
} }
export function useUsageTrends(days: number) { export function useUsageTrends(days: number, options?: UsageQueryOptions) {
return useQuery({ return useQuery({
queryKey: usageKeys.trends(days), queryKey: usageKeys.trends(days),
queryFn: () => { queryFn: () => {
const { startDate, endDate } = getWindow(days); const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate); return usageApi.getUsageTrends(startDate, endDate);
}, },
refetchInterval: 30000, // 每30秒自动刷新 refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
export function useProviderStats() { export function useProviderStats(options?: UsageQueryOptions) {
return useQuery({ return useQuery({
queryKey: usageKeys.providerStats(), queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats, queryFn: usageApi.getProviderStats,
refetchInterval: 30000, // 每30秒自动刷新 refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
export function useModelStats() { export function useModelStats(options?: UsageQueryOptions) {
return useQuery({ return useQuery({
queryKey: usageKeys.modelStats(), queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats, queryFn: usageApi.getModelStats,
refetchInterval: 30000, // 每30秒自动刷新 refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: false, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }
export function useRequestLogs( const getRollingRange = (windowSeconds: number) => {
filters: LogFilters, const endDate = Math.floor(Date.now() / 1000);
page: number = 0, const startDate = endDate - windowSeconds;
pageSize: number = 20, return { startDate, endDate };
) { };
export function useRequestLogs({
filters,
timeMode,
page = 0,
pageSize = 20,
rollingWindowSeconds = 24 * 60 * 60,
options,
}: RequestLogsQueryArgs) {
const key: RequestLogsKey = {
timeMode,
rollingWindowSeconds:
timeMode === "rolling" ? rollingWindowSeconds : undefined,
appType: filters.appType,
providerName: filters.providerName,
model: filters.model,
statusCode: filters.statusCode,
startDate: timeMode === "fixed" ? filters.startDate : undefined,
endDate: timeMode === "fixed" ? filters.endDate : undefined,
};
return useQuery({ return useQuery({
queryKey: usageKeys.logs(filters, page, pageSize), queryKey: usageKeys.logs(key, page, pageSize),
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize), queryFn: () => {
refetchInterval: 30000, // 每30秒自动刷新 const effectiveFilters =
refetchIntervalInBackground: false, timeMode === "rolling"
? { ...filters, ...getRollingRange(rollingWindowSeconds) }
: filters;
return usageApi.getRequestLogs(effectiveFilters, page, pageSize);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
}); });
} }