Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ last-updated: 2025-11-23
- Linux 装 `libwebkit2gtk-4.1-dev`、`libjavascriptcoregtk-4.1-dev`、`patchelf` 等 Tauri v2 依赖;Windows 确保 WebView2 Runtime(先查注册表,winget 安装失败则回退微软官方静默安装包);Node 20.19.0,Rust stable(含 clippy / rustfmt),启用 npm 与 cargo 缓存。
- CI 未通过不得合并;缺少 dist 时会在 `npm run check` 内自动触发 `npm run build` 以满足 Clippy 输入。

## 架构记忆(2025-11-21
## 架构记忆(2025-11-29

- `src-tauri/src/main.rs` 仅保留应用启动与托盘事件注册,所有 Tauri Commands 拆分到 `src-tauri/src/commands/*`,服务实现位于 `services/*`,核心设施放在 `core/*`(HTTP、日志、错误)。
- **透明代理已重构为多工具架构**:
Expand All @@ -82,6 +82,14 @@ last-updated: 2025-11-23
- 工具安装状态由 `services::tool::ToolStatusCache` 并行检测与缓存,`check_installations`/`refresh_tool_status` 命令复用该缓存;安装/更新成功后或手动刷新会清空命中的工具缓存。
- UI 相关的托盘/窗口操作集中在 `src-tauri/src/ui/*`,其它模块如需最小化到托盘请调用 `ui::hide_window_to_tray` 等封装方法。
- 新增 `TransparentProxyPage` 与会话数据库:`SESSION_MANAGER` 使用 SQLite 记录每个代理会话的 endpoint/API Key,前端可按工具启停代理、查看历史并启用「会话级 Endpoint 配置」开关。页面内的 `ProxyControlBar`、`ProxySettingsDialog`、`ProxyConfigDialog` 负责代理启停、配置切换、工具级设置并内建缺失配置提示。
- **余额监控页面(BalancePage)**:
- 后端提供通用 `fetch_api` 命令(位于 `commands/api_commands.rs`),支持 GET/POST、自定义 headers、超时控制
- 前端使用 JavaScript `Function` 构造器执行用户自定义的 extractor 脚本(位于 `utils/extractor.ts`)
- 配置存储在 localStorage,API Key 仅保存在内存(`useApiKeys` hook)
- 支持预设模板(NewAPI、OpenAI、自定义),模板定义在 `templates/index.ts`
- `useBalanceMonitor` hook 负责自动刷新逻辑,支持配置级别的刷新间隔
- 配置表单(`ConfigFormDialog`)支持模板选择、代码编辑、静态 headers(JSON 格式)
- 卡片视图(`ConfigCard`)展示余额信息、使用比例、到期时间、错误提示

### 透明代理扩展指南

Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ last-updated: 2025-11-23
- Linux 装 `libwebkit2gtk-4.1-dev`、`libjavascriptcoregtk-4.1-dev`、`patchelf` 等 Tauri v2 依赖;Windows 确保 WebView2 Runtime(先查注册表,winget 安装失败则回退微软官方静默安装包);Node 20.19.0,Rust stable(含 clippy / rustfmt),启用 npm 与 cargo 缓存。
- CI 未通过不得合并;缺少 dist 时会在 `npm run check` 内自动触发 `npm run build` 以满足 Clippy 输入。

## 架构记忆(2025-11-21
## 架构记忆(2025-11-29

- `src-tauri/src/main.rs` 仅保留应用启动与托盘事件注册,所有 Tauri Commands 拆分到 `src-tauri/src/commands/*`,服务实现位于 `services/*`,核心设施放在 `core/*`(HTTP、日志、错误)。
- **透明代理已重构为多工具架构**:
Expand All @@ -82,6 +82,14 @@ last-updated: 2025-11-23
- 工具安装状态由 `services::tool::ToolStatusCache` 并行检测与缓存,`check_installations`/`refresh_tool_status` 命令复用该缓存;安装/更新成功后或手动刷新会清空命中的工具缓存。
- UI 相关的托盘/窗口操作集中在 `src-tauri/src/ui/*`,其它模块如需最小化到托盘请调用 `ui::hide_window_to_tray` 等封装方法。
- 新增 `TransparentProxyPage` 与会话数据库:`SESSION_MANAGER` 使用 SQLite 记录每个代理会话的 endpoint/API Key,前端可按工具启停代理、查看历史并启用「会话级 Endpoint 配置」开关。页面内的 `ProxyControlBar`、`ProxySettingsDialog`、`ProxyConfigDialog` 负责代理启停、配置切换、工具级设置并内建缺失配置提示。
- **余额监控页面(BalancePage)**:
- 后端提供通用 `fetch_api` 命令(位于 `commands/api_commands.rs`),支持 GET/POST、自定义 headers、超时控制
- 前端使用 JavaScript `Function` 构造器执行用户自定义的 extractor 脚本(位于 `utils/extractor.ts`)
- 配置存储在 localStorage,API Key 仅保存在内存(`useApiKeys` hook)
- 支持预设模板(NewAPI、OpenAI、自定义),模板定义在 `templates/index.ts`
- `useBalanceMonitor` hook 负责自动刷新逻辑,支持配置级别的刷新间隔
- 配置表单(`ConfigFormDialog`)支持模板选择、代码编辑、静态 headers(JSON 格式)
- 卡片视图(`ConfigCard`)展示余额信息、使用比例、到期时间、错误提示

### 透明代理扩展指南

Expand Down
74 changes: 74 additions & 0 deletions src-tauri/src/commands/balance_commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// 余额查询相关命令
//
// 支持通过自定义 API 端点和提取器脚本查询余额信息

use ::duckcoding::http_client::build_client;
use ::duckcoding::utils::config::apply_proxy_if_configured;
use std::collections::HashMap;

/// Tauri command: 通用 API 请求
///
/// # 参数
/// - `endpoint`: API 端点 URL
/// - `method`: HTTP 方法 (GET 或 POST)
/// - `headers`: 请求头 (包含 Authorization 等)
/// - `timeout_ms`: 可选的请求超时时间(毫秒)
///
/// # 返回
/// 返回原始 JSON 响应,由前端执行 extractor 脚本提取余额信息
#[tauri::command]
pub async fn fetch_api(
endpoint: String,
method: String,
headers: HashMap<String, String>,
timeout_ms: Option<u64>,
) -> Result<serde_json::Value, String> {
apply_proxy_if_configured();

// 验证 HTTP 方法
let method_normalized = method.to_uppercase();
if method_normalized != "GET" && method_normalized != "POST" {
return Err(format!("不支持的 HTTP 方法: {method},仅支持 GET 和 POST"));
}

// 使用 build_client 确保代理配置等被应用
let client = build_client().map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;

// 构建请求
let mut request_builder = if method_normalized == "GET" {
client.get(&endpoint)
} else {
client.post(&endpoint)
};

// 添加请求头
for (key, value) in headers {
request_builder = request_builder.header(key, value);
}

// 应用自定义超时
if let Some(ms) = timeout_ms {
request_builder = request_builder.timeout(std::time::Duration::from_millis(ms));
}

// 发送请求
let response = request_builder
.send()
.await
.map_err(|e| format!("请求 API 失败: {e}"))?;

// 检查响应状态
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("API 请求失败 ({status}): {error_text}"));
}

// 解析为 JSON
let data: serde_json::Value = response
.json()
.await
.map_err(|e| format!("解析响应 JSON 失败: {e}"))?;

Ok(data)
}
2 changes: 2 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod balance_commands;
pub mod config_commands;
pub mod log_commands;
pub mod proxy_commands;
Expand All @@ -9,6 +10,7 @@ pub mod update_commands;
pub mod window_commands;

// 重新导出所有命令函数
pub use balance_commands::*;
pub use config_commands::*;
pub use log_commands::*;
pub use proxy_commands::*;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ fn main() {
generate_api_key_for_tool,
get_usage_stats,
get_user_quota,
fetch_api,
handle_close_action,
// expose current proxy for debugging/testing
get_current_proxy,
Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AppSidebar } from '@/components/layout/AppSidebar';
import { CloseActionDialog } from '@/components/dialogs/CloseActionDialog';
import { UpdateDialog } from '@/components/dialogs/UpdateDialog';
import { StatisticsPage } from '@/pages/StatisticsPage';
import { BalancePage } from '@/pages/BalancePage';
import { InstallationPage } from '@/pages/InstallationPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { ConfigurationPage } from '@/pages/ConfigurationPage';
Expand Down Expand Up @@ -34,6 +35,7 @@ type TabType =
| 'config'
| 'switch'
| 'statistics'
| 'balance'
| 'transparent-proxy'
| 'settings';

Expand Down Expand Up @@ -285,6 +287,7 @@ function App() {
onLoadStatistics={loadStatistics}
/>
)}
{activeTab === 'balance' && <BalancePage />}
{activeTab === 'transparent-proxy' && (
<TransparentProxyPage selectedToolId={selectedProxyToolId} />
)}
Expand Down
10 changes: 10 additions & 0 deletions src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Key,
ArrowRightLeft,
BarChart3,
Wallet,
Radio,
Settings as SettingsIcon,
} from 'lucide-react';
Expand Down Expand Up @@ -77,6 +78,15 @@ export function AppSidebar({ activeTab, onTabChange }: AppSidebarProps) {
用量统计
</Button>

<Button
variant={activeTab === 'balance' ? 'default' : 'ghost'}
className="w-full justify-start transition-all hover:scale-105"
onClick={() => onTabChange('balance')}
>
<Wallet className="mr-2 h-4 w-4" />
余额查询
</Button>

<Button
variant={activeTab === 'transparent-proxy' ? 'default' : 'ghost'}
className="w-full justify-start transition-all hover:scale-105"
Expand Down
23 changes: 23 additions & 0 deletions src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';

import { cn } from '@/lib/utils';

export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';

export { Textarea };
14 changes: 14 additions & 0 deletions src/lib/tauri-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,20 @@ export async function getUserQuota(): Promise<UserQuotaResult> {
return await invoke<UserQuotaResult>('get_user_quota');
}

export async function fetchApi(
endpoint: string,
method: string,
headers: Record<string, string>,
timeoutMs?: number,
): Promise<unknown> {
return await invoke('fetch_api', {
endpoint,
method,
headers,
timeout_ms: timeoutMs,
});
}

export async function applyCloseAction(action: CloseAction): Promise<void> {
return await invoke<void>('handle_close_action', { action });
}
Expand Down
135 changes: 135 additions & 0 deletions src/pages/BalancePage/components/ConfigCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { AlertCircle, Clock3, Edit3, Loader2, RefreshCw, Trash2, Wallet } from 'lucide-react';
import { BalanceConfig, BalanceRuntimeState } from '../types';

interface ConfigCardProps {
config: BalanceConfig;
state: BalanceRuntimeState;
onRefresh: (id: string) => void;
onEdit: (config: BalanceConfig) => void;
onDelete: (id: string) => void;
}

export function ConfigCard({ config, state, onRefresh, onEdit, onDelete }: ConfigCardProps) {
const progress = useMemo(() => {
const total = state.lastResult?.total ?? 0;
const used = state.lastResult?.used ?? 0;
if (!total || total <= 0) return 0;
return Math.min(100, Math.max(0, (used / total) * 100));
}, [state.lastResult]);

const unit = state.lastResult?.unit ?? 'USD';
const remaining = state.lastResult?.remaining;
const total = state.lastResult?.total;
const used = state.lastResult?.used;
const planName = state.lastResult?.planName;

return (
<Card className="shadow-sm border">
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">{config.name}</CardTitle>
{planName && (
<Badge variant="secondary" className="text-xs">
{planName}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button size="icon" variant="ghost" onClick={() => onEdit(config)} aria-label="编辑">
<Edit3 className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" onClick={() => onDelete(config.id)} aria-label="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardHeader>

<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-sm">
<Metric label="总额度" value={formatNumber(total, unit)} />
<Metric label="已用额度" value={formatNumber(used, unit)} />
<Metric label="剩余额度" value={formatNumber(remaining, unit)} />
</div>

<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>使用比例</span>
<span>{progress.toFixed(1)}%</span>
</div>
<Progress value={progress} />
{state.lastResult?.expiresAt && (
<div className="text-xs text-muted-foreground">
到期时间:{state.lastResult.expiresAt}
</div>
)}
</div>

<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock3 className="h-4 w-4" />
{state.lastFetchedAt ? new Date(state.lastFetchedAt).toLocaleString() : '尚未查询'}
</div>
<div className="flex items-center gap-2">
{state.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wallet className="h-4 w-4" />
)}
{config.intervalSec && config.intervalSec > 0
? `自动:每 ${config.intervalSec}s`
: '手动刷新'}
</div>
</div>

{state.error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertCircle className="h-4 w-4 mt-0.5" />
<span>{state.error}</span>
</div>
)}

<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className={cn('flex-1', state.loading && 'pointer-events-none')}
onClick={() => onRefresh(config.id)}
>
{state.loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
查询中...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
刷新
</>
)}
</Button>
</div>
</CardContent>
</Card>
);
}

function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-sm font-semibold">{value}</div>
</div>
);
}

function formatNumber(value?: number | null, currency?: string) {
if (value == null) return '--';
const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 2 });
return currency ? `${formatted} ${currency}` : formatted;
}
Loading
Loading