feat: 添加网络代理设置 UI

- 在通用设置中添加全局代理 URL 配置
- 在凭证编辑中添加凭证级代理设置
- 添加代理 URL 验证函数及属性测试
- 在凭证列表中显示代理标识
- 修复文档测试问题

版本更新: 0.12.4
This commit is contained in:
coso
2025-12-18 22:12:31 +08:00
parent a4646b022b
commit ef222eb961
13 changed files with 1365 additions and 17 deletions
+1013 -3
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.12.3",
"version": "0.12.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,7 +10,9 @@
"tauri": "tauri",
"lint": "eslint src --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"prepare": "husky"
"prepare": "husky",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
@@ -34,6 +36,7 @@
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@fast-check/vitest": "^0.2.4",
"@tauri-apps/cli": "^2.0.0",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
@@ -45,12 +48,14 @@
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"fast-check": "^4.4.0",
"globals": "^15.12.0",
"husky": "^9.1.7",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
"vite": "^5.4.10",
"vitest": "^4.0.16"
}
}
+1 -1
View File
@@ -3349,7 +3349,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.12.3"
version = "0.12.4"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.12.3"
version = "0.12.4"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+5 -5
View File
@@ -10,9 +10,9 @@
//!
//! # 示例
//!
//! ```rust
//! use proxycast::router::AmpRouter;
//! use proxycast::config::AmpConfig;
//! ```rust,ignore
//! use proxycast_lib::router::AmpRouter;
//! use proxycast_lib::config::AmpConfig;
//!
//! let config = AmpConfig::default();
//! let router = AmpRouter::new(config);
@@ -151,7 +151,7 @@ impl AmpRouter {
///
/// # 示例
///
/// ```rust
/// ```rust,ignore
/// // 配置: claude-opus-4.5 -> claude-sonnet-4
/// let mapped = router.apply_model_mapping("claude-opus-4.5");
/// assert_eq!(mapped, "claude-sonnet-4");
@@ -179,7 +179,7 @@ impl AmpRouter {
///
/// # 示例
///
/// ```rust
/// ```rust,ignore
/// let body = r#"{"model": "claude-opus-4.5", "messages": []}"#;
/// let (transformed, original, mapped) = router.transform_request_model(body);
/// // 如果配置了 claude-opus-4.5 -> claude-sonnet-4
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.12.2",
"version": "0.12.4",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -13,6 +13,7 @@ import {
Upload,
Lock,
User,
Globe,
} from "lucide-react";
import type {
CredentialDisplay,
@@ -150,6 +151,15 @@ export function CredentialCard({
<SourceIcon className="h-3 w-3 shrink-0" />
{sourceInfo.text}
</span>
{credential.proxy_url && (
<span
className="rounded-full px-2 py-0.5 text-xs font-medium inline-flex items-center gap-1 whitespace-nowrap shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
title={`代理: ${credential.proxy_url}`}
>
<Globe className="h-3 w-3 shrink-0" />
代理
</span>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{credential.uuid}
@@ -7,6 +7,7 @@ import {
Upload,
CheckCircle,
Ban,
Globe,
} from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import {
@@ -14,6 +15,7 @@ import {
UpdateCredentialRequest,
PoolProviderType,
} from "@/lib/api/providerPool";
import { validateProxyUrl } from "@/lib/utils";
interface EditCredentialModalProps {
credential: CredentialDisplay | null;
@@ -82,6 +84,10 @@ export function EditCredentialModal({
const [newApiKey, setNewApiKey] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
// 代理 URL 相关状态
const [proxyUrl, setProxyUrl] = useState("");
const [proxyError, setProxyError] = useState<string | null>(null);
// 初始化表单数据
useEffect(() => {
if (credential) {
@@ -104,6 +110,9 @@ export function EditCredentialModal({
// 初始化 api_key 为已保存的值
setNewApiKey(credential.api_key || "");
setShowApiKey(false);
// 初始化代理 URL 为已保存的值
setProxyUrl(credential.proxy_url || "");
setProxyError(null);
setError(null);
}
}, [credential]);
@@ -165,7 +174,26 @@ export function EditCredentialModal({
);
};
const handleProxyUrlChange = (value: string) => {
setProxyUrl(value);
if (value && !validateProxyUrl(value)) {
setProxyError(
"代理 URL 格式无效,请使用 http://、https:// 或 socks5:// 开头的地址",
);
} else {
setProxyError(null);
}
};
const handleSubmit = async () => {
// 验证代理 URL 格式
if (proxyUrl && !validateProxyUrl(proxyUrl)) {
setProxyError(
"代理 URL 格式无效,请使用 http://、https:// 或 socks5:// 开头的地址",
);
return;
}
setLoading(true);
setError(null);
@@ -184,6 +212,8 @@ export function EditCredentialModal({
new_base_url: isApiKey ? newBaseUrl.trim() : undefined,
// API Key 的 api_key(始终传递当前值)
new_api_key: isApiKey ? newApiKey.trim() : undefined,
// 代理 URL(空字符串表示清除,使用全局代理)
new_proxy_url: proxyUrl.trim() || undefined,
};
console.log("[EditCredentialModal] 提交更新请求:", updateRequest);
@@ -407,6 +437,51 @@ export function EditCredentialModal({
</div>
</div>
{/* 高级选项:代理设置 */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-blue-500" />
<label className="text-sm font-medium">凭证代理设置</label>
<span className="text-xs text-muted-foreground">
(高级选项)
</span>
</div>
<div className="rounded-lg border p-4 space-y-3">
<div>
<label className="block text-sm font-medium mb-1.5">
代理 URL(可选)
</label>
<input
type="text"
value={proxyUrl}
onChange={(e) => handleProxyUrlChange(e.target.value)}
placeholder="例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
className={`w-full rounded-lg border bg-background px-3 py-2 text-sm ${
proxyError ? "border-red-500" : ""
}`}
/>
{proxyError ? (
<p className="text-xs text-red-500 mt-1">{proxyError}</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
留空则使用全局代理设置。支持 http://、https://、socks5://
协议
</p>
)}
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3 text-xs">
<p className="font-medium text-blue-700 dark:text-blue-300">
代理优先级说明:
</p>
<ul className="mt-1 list-inside list-disc text-blue-600 dark:text-blue-400">
<li>此凭证代理优先于全局代理</li>
<li>留空时使用全局代理设置</li>
<li>全局代理可在「设置 → 通用」中配置</li>
</ul>
</div>
</div>
</div>
{/* 使用统计(只读) */}
<div className="rounded-lg bg-muted/50 p-4">
<label className="mb-3 block text-sm font-medium">使用统计</label>
@@ -457,7 +532,7 @@ export function EditCredentialModal({
</button>
<button
onClick={handleSubmit}
disabled={loading}
disabled={loading || !!proxyError}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "保存中..." : "保存更改"}
@@ -0,0 +1,78 @@
import { describe, expect } from "vitest";
import { test, fc } from "@fast-check/vitest";
import { validateProxyUrl } from "@/lib/utils";
/**
* Property-Based Tests for validateProxyUrl
*
* **Feature: network-proxy-settings, Property 2: 无效 URL 格式拒绝**
* **Validates: Requirements 1.5**
*/
describe("validateProxyUrl", () => {
// Property 2: 无效 URL 格式拒绝
// *For any* 不符合代理 URL 格式的字符串(如缺少协议、包含空格等),验证函数应该返回 false
test.prop([fc.constant("")])(
"empty string should be valid (no proxy)",
(url) => {
expect(validateProxyUrl(url)).toBe(true);
},
);
test.prop([fc.constant(" ")])(
"whitespace-only string should be valid (no proxy)",
(url) => {
expect(validateProxyUrl(url)).toBe(true);
},
);
// Valid proxy URLs should pass
test.prop([
fc.oneof(fc.constant("http"), fc.constant("https"), fc.constant("socks5")),
fc.ipV4(),
fc.integer({ min: 1, max: 65535 }),
])("valid proxy URL format should pass", (protocol, host, port) => {
const url = `${protocol}://${host}:${port}`;
expect(validateProxyUrl(url)).toBe(true);
});
// URLs without valid protocol should fail
test.prop([
fc.constantFrom("ftp", "file", "tcp", "udp", "ws", "wss", "mailto", "tel"),
fc.ipV4(),
fc.integer({ min: 1, max: 65535 }),
])("invalid protocol should fail", (protocol, host, port) => {
const url = `${protocol}://${host}:${port}`;
expect(validateProxyUrl(url)).toBe(false);
});
// URLs with spaces should fail
test.prop([
fc.oneof(fc.constant("http"), fc.constant("https"), fc.constant("socks5")),
fc.ipV4(),
fc.integer({ min: 1, max: 65535 }),
])("URL with embedded spaces should fail", (protocol, host, port) => {
const url = `${protocol}:// ${host}:${port}`;
expect(validateProxyUrl(url)).toBe(false);
});
// URLs missing protocol separator should fail
test.prop([fc.ipV4(), fc.integer({ min: 1, max: 65535 })])(
"URL without protocol should fail",
(host, port) => {
const url = `${host}:${port}`;
expect(validateProxyUrl(url)).toBe(false);
},
);
// Random strings without protocol pattern should fail
test.prop([
fc
.string({ minLength: 1, maxLength: 50 })
.filter(
(s) => !s.match(/^(https?|socks5?):\/\//i) && s.trim().length > 0,
),
])("random non-URL strings should fail", (str) => {
expect(validateProxyUrl(str)).toBe(false);
});
});
+149 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
import { Moon, Sun, Monitor, Globe, RefreshCw } from "lucide-react";
import { cn, validateProxyUrl } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
type Theme = "light" | "dark" | "system";
@@ -9,14 +10,41 @@ export function GeneralSettings() {
const [launchOnStartup, setLaunchOnStartup] = useState(false);
const [minimizeToTray, setMinimizeToTray] = useState(true);
// 网络代理状态
const [config, setConfig] = useState<Config | null>(null);
const [proxyUrl, setProxyUrl] = useState<string>("");
const [proxyError, setProxyError] = useState<string | null>(null);
const [proxySaving, setProxySaving] = useState(false);
const [proxyMessage, setProxyMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [configLoading, setConfigLoading] = useState(true);
useEffect(() => {
// 读取当前主题
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
// 加载配置
loadConfig();
}, []);
const loadConfig = async () => {
setConfigLoading(true);
try {
const c = await getConfig();
setConfig(c);
setProxyUrl(c.proxy_url || "");
} catch (e) {
console.error("加载配置失败:", e);
} finally {
setConfigLoading(false);
}
};
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
@@ -33,6 +61,48 @@ export function GeneralSettings() {
}
};
const handleProxyUrlChange = (value: string) => {
setProxyUrl(value);
if (value && !validateProxyUrl(value)) {
setProxyError(
"代理 URL 格式无效,请使用 http://、https:// 或 socks5:// 开头的地址",
);
} else {
setProxyError(null);
}
};
const handleSaveProxy = async () => {
if (!config) return;
// 验证格式
if (proxyUrl && !validateProxyUrl(proxyUrl)) {
setProxyError(
"代理 URL 格式无效,请使用 http://、https:// 或 socks5:// 开头的地址",
);
return;
}
setProxySaving(true);
setProxyMessage(null);
try {
const newConfig = {
...config,
proxy_url: proxyUrl.trim() || null,
};
await saveConfig(newConfig);
setConfig(newConfig);
setProxyMessage({ type: "success", text: "代理设置已保存" });
setTimeout(() => setProxyMessage(null), 3000);
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
setProxyMessage({ type: "error", text: `保存失败: ${errorMessage}` });
} finally {
setProxySaving(false);
}
};
const themeOptions = [
{ id: "light" as Theme, label: "浅色", icon: Sun },
{ id: "dark" as Theme, label: "深色", icon: Moon },
@@ -41,6 +111,83 @@ export function GeneralSettings() {
return (
<div className="space-y-6 max-w-2xl">
{/* 网络代理设置 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-500" />
<div>
<h3 className="text-sm font-medium">网络代理</h3>
<p className="text-xs text-muted-foreground">
配置 HTTP/HTTPS/SOCKS5 代理,用于访问海外 API 服务
</p>
</div>
</div>
{configLoading ? (
<div className="flex items-center justify-center p-4">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4 p-4 rounded-lg border">
{/* 代理消息提示 */}
{proxyMessage && (
<div
className={`rounded-lg border p-3 text-sm ${
proxyMessage.type === "error"
? "border-destructive bg-destructive/10 text-destructive"
: "border-green-500 bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
}`}
>
{proxyMessage.text}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5">
全局代理 URL
</label>
<input
type="text"
value={proxyUrl}
onChange={(e) => handleProxyUrlChange(e.target.value)}
placeholder="例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
className={cn(
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none",
proxyError &&
"border-destructive focus:border-destructive focus:ring-destructive/20",
)}
/>
{proxyError ? (
<p className="text-xs text-destructive mt-1">{proxyError}</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
留空表示不使用代理。支持 http://、https://、socks5:// 协议
</p>
)}
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3 text-sm">
<p className="font-medium text-blue-700 dark:text-blue-300">
代理优先级说明:
</p>
<ul className="mt-1 list-inside list-disc text-blue-600 dark:text-blue-400 text-xs">
<li>凭证级代理优先于全局代理</li>
<li>如果凭证未设置代理,则使用此全局代理</li>
<li>全局代理为空时,直接连接 API 服务</li>
</ul>
</div>
<button
onClick={handleSaveProxy}
disabled={proxySaving || !!proxyError}
className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{proxySaving ? "保存中..." : "保存代理设置"}
</button>
</div>
)}
</div>
{/* 主题设置 */}
<div className="space-y-3">
<div>
+4
View File
@@ -133,6 +133,8 @@ export interface CredentialDisplay {
base_url?: string;
// API Key 凭证的完整 api_key(仅用于 OpenAI/Claude API Key 类型,用于编辑)
api_key?: string;
// 凭证级代理 URL(可覆盖全局代理设置)
proxy_url?: string;
}
// Pool statistics
@@ -204,6 +206,8 @@ export interface UpdateCredentialRequest {
new_base_url?: string;
/// API Key 相关:新的 api_key(仅适用于 API Key 凭证)
new_api_key?: string;
/// 新的代理 URL(可覆盖全局代理设置)
new_proxy_url?: string;
}
export const providerPoolApi = {
+14
View File
@@ -4,3 +4,17 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* 验证代理 URL 格式
* 支持的格式:
* - http://host:port
* - https://host:port
* - socks5://host:port
* - http://user:pass@host:port(带认证)
*/
export function validateProxyUrl(url: string): boolean {
if (!url || url.trim() === "") return true; // 空值允许
const pattern = /^(https?|socks5?):\/\/[^\s]+$/i;
return pattern.test(url.trim());
}
+5
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
@@ -17,4 +18,8 @@ export default defineConfig({
ignored: ["**/src-tauri/**"],
},
},
test: {
globals: true,
environment: "jsdom",
},
});