mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加网络代理设置 UI
- 在通用设置中添加全局代理 URL 配置 - 在凭证编辑中添加凭证级代理设置 - 添加代理 URL 验证函数及属性测试 - 在凭证列表中显示代理标识 - 修复文档测试问题 版本更新: 0.12.4
This commit is contained in:
Generated
+1013
-3
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -3349,7 +3349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.12.3"
|
||||
version = "0.12.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user