mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 手动代理节点支持、系统默认代理与跨格式流式 usage 提取
代理节点: - 支持手动添加代理节点(HTTP/HTTPS/SOCKS5),含地址、认证信息和区域标签 - 新增手动节点的 CRUD API 和前端管理界面 - 提供商代理配置从 URL 字符串迁移至代理节点选择器(proxy_node_id) - 新增系统默认代理节点设置,未单独配置代理的提供商自动回退使用 - 删除节点时自动清除系统默认代理引用并失效缓存 - 健康检查跳过手动节点(无心跳,始终在线) 流式处理: - CLI handler 跨格式转换时委托基类解析 Provider 原始事件的 usage - StreamProcessor 新增 _extract_usage_from_converted_event 从转换后事件补充提取 usage - 支持 Claude/OpenAI/OpenAI Responses/Gemini 多种 usage 格式
This commit is contained in:
@@ -81,12 +81,16 @@ export function sortApiFormats(formats: string[]): string[] {
|
||||
|
||||
/**
|
||||
* 代理配置类型
|
||||
* 支持两种模式:
|
||||
* - 手动配置:设置 url/username/password
|
||||
* - 代理节点:设置 node_id(与 url 互斥)
|
||||
*/
|
||||
export interface ProxyConfig {
|
||||
url: string
|
||||
url?: string
|
||||
username?: string
|
||||
password?: string
|
||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||
node_id?: string // 代理节点 ID(aether-proxy 注册的节点,与 url 互斥)
|
||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface ProxyNode {
|
||||
port: number
|
||||
region: string | null
|
||||
status: 'online' | 'unhealthy' | 'offline'
|
||||
is_manual: boolean
|
||||
// 手动节点专用字段
|
||||
proxy_url?: string
|
||||
proxy_username?: string
|
||||
proxy_password?: string // 脱敏后的密码
|
||||
registered_by: string | null
|
||||
last_heartbeat_at: string | null
|
||||
heartbeat_interval: number
|
||||
@@ -24,13 +29,40 @@ export interface ProxyNodeListResponse {
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface ManualProxyNodeCreateRequest {
|
||||
name: string
|
||||
proxy_url: string
|
||||
username?: string
|
||||
password?: string
|
||||
region?: string
|
||||
}
|
||||
|
||||
export interface ManualProxyNodeUpdateRequest {
|
||||
name?: string
|
||||
proxy_url?: string
|
||||
username?: string
|
||||
password?: string
|
||||
region?: string
|
||||
}
|
||||
|
||||
export const proxyNodesApi = {
|
||||
async listProxyNodes(params?: { status?: string; skip?: number; limit?: number }): Promise<ProxyNodeListResponse> {
|
||||
const response = await apiClient.get<ProxyNodeListResponse>('/api/admin/proxy-nodes', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteProxyNode(nodeId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/admin/proxy-nodes/${nodeId}`)
|
||||
async createManualNode(data: ManualProxyNodeCreateRequest): Promise<{ node_id: string; node: ProxyNode }> {
|
||||
const response = await apiClient.post<{ node_id: string; node: ProxyNode }>('/api/admin/proxy-nodes/manual', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateManualNode(nodeId: string, data: ManualProxyNodeUpdateRequest): Promise<{ node_id: string; node: ProxyNode }> {
|
||||
const response = await apiClient.patch<{ node_id: string; node: ProxyNode }>(`/api/admin/proxy-nodes/${nodeId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteProxyNode(nodeId: string): Promise<{ message: string; node_id: string; cleared_system_proxy: boolean }> {
|
||||
const response = await apiClient.delete<{ message: string; node_id: string; cleared_system_proxy: boolean }>(`/api/admin/proxy-nodes/${nodeId}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
export const anyrouterTemplate: AuthTemplate = {
|
||||
id: 'anyrouter',
|
||||
@@ -52,7 +52,7 @@ export const anyrouterTemplate: AuthTemplate = {
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
proxy: buildProxyUrl(formData),
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
session_cookie: formData.session_cookie,
|
||||
@@ -64,7 +64,7 @@ export const anyrouterTemplate: AuthTemplate = {
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
/**
|
||||
* 格式化窗口限额显示(百分比格式)
|
||||
@@ -92,7 +92,7 @@ export const cubenceTemplate: AuthTemplate = {
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
proxy: buildProxyUrl(formData),
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
token_cookie: formData.token_cookie,
|
||||
@@ -104,7 +104,7 @@ export const cubenceTemplate: AuthTemplate = {
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
token_cookie: config?.connector?.credentials?.token_cookie || '',
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
export const nekocodeTemplate: AuthTemplate = {
|
||||
id: 'nekocode',
|
||||
@@ -51,7 +51,7 @@ export const nekocodeTemplate: AuthTemplate = {
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
proxy: buildProxyUrl(formData),
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
session_cookie: formData.session_cookie,
|
||||
@@ -63,7 +63,7 @@ export const nekocodeTemplate: AuthTemplate = {
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
export const newApiTemplate: AuthTemplate = {
|
||||
id: 'new_api',
|
||||
@@ -80,7 +80,7 @@ export const newApiTemplate: AuthTemplate = {
|
||||
auth_type: 'api_key',
|
||||
config: {
|
||||
auth_method: 'bearer',
|
||||
proxy: buildProxyUrl(formData),
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
// 敏感字段始终发送(空字符串会触发后端合并已保存的值)
|
||||
@@ -95,7 +95,7 @@ export const newApiTemplate: AuthTemplate = {
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
api_key: config?.connector?.credentials?.api_key || '',
|
||||
|
||||
@@ -165,8 +165,30 @@ export interface AuthTemplateRegistry {
|
||||
// ==================== 通用字段定义 ====================
|
||||
|
||||
/**
|
||||
* 代理地址字段
|
||||
* 代理节点 ID 字段
|
||||
*/
|
||||
export const PROXY_NODE_FIELD: AuthTemplateField = {
|
||||
key: 'proxy_node_id',
|
||||
label: '代理节点',
|
||||
type: 'select',
|
||||
placeholder: '选择代理节点...',
|
||||
required: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用代理配置字段组(可折叠,带启用开关)
|
||||
* 由 ProviderAuthDialog 特殊渲染为代理节点选择器
|
||||
*/
|
||||
export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
|
||||
title: '代理配置',
|
||||
fields: [PROXY_NODE_FIELD],
|
||||
collapsible: true,
|
||||
defaultExpanded: false,
|
||||
hasToggle: true,
|
||||
toggleKey: 'proxy_enabled',
|
||||
}
|
||||
|
||||
// 兼容旧代码的字段导出
|
||||
export const PROXY_URL_FIELD: AuthTemplateField = {
|
||||
key: 'proxy_url',
|
||||
label: '代理地址',
|
||||
@@ -174,10 +196,6 @@ export const PROXY_URL_FIELD: AuthTemplateField = {
|
||||
placeholder: 'http://proxy:port 或 socks5://',
|
||||
required: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理用户名字段
|
||||
*/
|
||||
export const PROXY_USERNAME_FIELD: AuthTemplateField = {
|
||||
key: 'proxy_username',
|
||||
label: '用户名',
|
||||
@@ -185,10 +203,6 @@ export const PROXY_USERNAME_FIELD: AuthTemplateField = {
|
||||
placeholder: '可选',
|
||||
required: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理密码字段
|
||||
*/
|
||||
export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
|
||||
key: 'proxy_password',
|
||||
label: '密码',
|
||||
@@ -199,96 +213,46 @@ export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用代理配置字段组(可折叠,带启用开关)
|
||||
*/
|
||||
export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
|
||||
title: '代理配置',
|
||||
fields: [PROXY_URL_FIELD, PROXY_USERNAME_FIELD, PROXY_PASSWORD_FIELD],
|
||||
collapsible: true,
|
||||
defaultExpanded: false,
|
||||
hasToggle: true,
|
||||
toggleKey: 'proxy_enabled',
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建代理 URL(包含认证信息)
|
||||
* 构建代理配置(仅代理节点模式)
|
||||
*
|
||||
* @param formData 表单数据
|
||||
* @returns 完整的代理 URL,或 undefined
|
||||
* @returns 代理配置对象,展开到 connector.config 中
|
||||
*/
|
||||
export function buildProxyUrl(formData: Record<string, any>): string | undefined {
|
||||
if (!formData.proxy_enabled || !formData.proxy_url) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const proxyUrl = formData.proxy_url.trim()
|
||||
const username = formData.proxy_username?.trim()
|
||||
const password = formData.proxy_password?.trim()
|
||||
|
||||
// 如果没有认证信息,直接返回 URL
|
||||
if (!username) {
|
||||
return proxyUrl
|
||||
}
|
||||
|
||||
// 解析 URL 并添加认证信息
|
||||
try {
|
||||
const url = new URL(proxyUrl)
|
||||
url.username = username
|
||||
if (password) {
|
||||
url.password = password
|
||||
}
|
||||
return url.toString()
|
||||
} catch {
|
||||
// URL 解析失败,尝试简单拼接
|
||||
const protocol = proxyUrl.includes('://') ? proxyUrl.split('://')[0] : 'http'
|
||||
const host = proxyUrl.includes('://') ? proxyUrl.split('://')[1] : proxyUrl
|
||||
const auth = password ? `${username}:${password}` : username
|
||||
return `${protocol}://${auth}@${host}`
|
||||
export function buildProxyConfig(formData: Record<string, any>): { proxy_node_id?: string } {
|
||||
if (!formData.proxy_enabled || !formData.proxy_node_id) {
|
||||
return {}
|
||||
}
|
||||
return { proxy_node_id: formData.proxy_node_id }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从代理 URL 解析表单数据
|
||||
* 解析代理配置
|
||||
*
|
||||
* @param proxyUrl 代理 URL
|
||||
* @param config connector.config 对象
|
||||
* @returns 表单数据
|
||||
*/
|
||||
export function parseProxyUrl(proxyUrl: string | undefined): Record<string, any> {
|
||||
if (!proxyUrl) {
|
||||
export function parseProxyConfig(config: any): Record<string, any> {
|
||||
// 代理节点模式
|
||||
if (config?.proxy_node_id) {
|
||||
return {
|
||||
proxy_enabled: false,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
proxy_enabled: true,
|
||||
proxy_node_id: config.proxy_node_id,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(proxyUrl)
|
||||
const username = url.username || ''
|
||||
const password = url.password || ''
|
||||
|
||||
// 移除认证信息后的 URL
|
||||
url.username = ''
|
||||
url.password = ''
|
||||
const cleanUrl = url.toString()
|
||||
|
||||
// 兼容旧数据(手动 URL 模式)- 标记为启用但无节点
|
||||
if (config?.proxy) {
|
||||
return {
|
||||
proxy_enabled: true,
|
||||
proxy_url: cleanUrl,
|
||||
proxy_username: username,
|
||||
proxy_password: password,
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,直接使用原始 URL
|
||||
return {
|
||||
proxy_enabled: true,
|
||||
proxy_url: proxyUrl,
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
proxy_node_id: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
proxy_enabled: false,
|
||||
proxy_node_id: '',
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧的导出(已废弃,请使用 PROXY_FIELD_GROUP)
|
||||
// 兼容旧的导出
|
||||
export const PROXY_FIELD: AuthTemplateField = PROXY_URL_FIELD
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
/**
|
||||
* 格式化限额显示(百分比格式)
|
||||
@@ -65,7 +65,7 @@ export const yescodeTemplate: AuthTemplate = {
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
proxy: buildProxyUrl(formData),
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
auth_cookie: formData.auth_cookie,
|
||||
@@ -77,7 +77,7 @@ export const yescodeTemplate: AuthTemplate = {
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
auth_cookie: config?.connector?.credentials?.auth_cookie || '',
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
v-for="(group, groupIndex) in fieldGroups"
|
||||
:key="groupIndex"
|
||||
>
|
||||
<!-- 可折叠的分组(如代理配置) -->
|
||||
<!-- 可折叠的分组(代理配置 - 代理节点选择) -->
|
||||
<div
|
||||
v-if="group.collapsible && group.hasToggle && group.toggleKey"
|
||||
class="space-y-2"
|
||||
@@ -65,50 +65,22 @@
|
||||
<span class="text-xs text-muted-foreground">启用代理</span>
|
||||
<Switch
|
||||
:model-value="formData[group.toggleKey] || false"
|
||||
@update:model-value="formData[group.toggleKey] = $event"
|
||||
@update:model-value="handleProxyToggle(group.toggleKey, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开内容(卡片) -->
|
||||
<!-- 展开内容(卡片)- 代理节点选择 -->
|
||||
<div
|
||||
v-if="formData[group.toggleKey]"
|
||||
class="rounded-lg border border-border bg-muted/30 px-4 py-3"
|
||||
>
|
||||
<!-- 横向排列的字段:代理地址占更多空间 -->
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
v-for="(field, fieldIndex) in group.fields"
|
||||
:key="field.key"
|
||||
class="space-y-1"
|
||||
:class="fieldIndex === 0 ? 'flex-[2]' : 'flex-1'"
|
||||
>
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ field.label }}
|
||||
</Label>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<Input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
|
||||
:masked="field.sensitive"
|
||||
disable-autofill
|
||||
class="h-8 text-sm"
|
||||
@update:model-value="handleFieldChange(field.key, $event)"
|
||||
/>
|
||||
|
||||
<!-- 密码/敏感输入 -->
|
||||
<Input
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
|
||||
masked
|
||||
class="h-8 text-sm"
|
||||
@update:model-value="handleFieldChange(field.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
ref="proxyNodeSelectRef"
|
||||
:model-value="formData.proxy_node_id || ''"
|
||||
trigger-class="h-8"
|
||||
@update:model-value="(v: string) => { formData.proxy_node_id = v; handleFieldChange('proxy_node_id', v) }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -304,6 +276,8 @@ import {
|
||||
type AuthTemplate,
|
||||
type AuthTemplateFieldGroup,
|
||||
} from '../auth-templates'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -322,6 +296,16 @@ const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cooki
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
/** 启用代理时加载节点列表(直接调用 store,避免 ref 未挂载时静默失败) */
|
||||
function handleProxyToggle(toggleKey: string, value: boolean) {
|
||||
formData.value[toggleKey] = value
|
||||
if (value) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
const isSaving = ref(false)
|
||||
|
||||
@@ -238,47 +238,20 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:model-value="form.proxy_enabled"
|
||||
@update:model-value="(v: boolean) => form.proxy_enabled = v"
|
||||
@update:model-value="handleProxyToggle"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.proxy_enabled"
|
||||
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
||||
class="space-y-1.5 p-3 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">代理地址 *</Label>
|
||||
<Input
|
||||
v-model="form.proxy_url"
|
||||
placeholder="http://proxy:port 或 socks5://proxy:port"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">用户名</Label>
|
||||
<Input
|
||||
v-model="form.proxy_username"
|
||||
placeholder="可选"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">密码</Label>
|
||||
<Input
|
||||
v-model="form.proxy_password"
|
||||
type="password"
|
||||
placeholder="可选"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="text-xs">代理节点 *</Label>
|
||||
<ProxyNodeSelect
|
||||
ref="proxyNodeSelectRef"
|
||||
v-model="form.proxy_node_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -322,6 +295,8 @@ import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -337,6 +312,16 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
/** 启用代理时懒加载节点列表 */
|
||||
function handleProxyToggle(v: boolean) {
|
||||
form.value.proxy_enabled = v
|
||||
if (v) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
@@ -372,11 +357,9 @@ const form = ref({
|
||||
// 超时配置(秒)
|
||||
stream_first_byte_timeout: undefined as number | undefined,
|
||||
request_timeout: undefined as number | undefined,
|
||||
// 代理配置(扁平化便于表单绑定)
|
||||
// 代理配置
|
||||
proxy_enabled: false,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
proxy_node_id: '',
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
@@ -403,9 +386,7 @@ function resetForm() {
|
||||
request_timeout: undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: false,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
proxy_node_id: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,9 +419,12 @@ function loadProviderData() {
|
||||
request_timeout: props.provider.request_timeout ?? undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: proxy?.enabled ?? false,
|
||||
proxy_url: proxy?.url || '',
|
||||
proxy_username: proxy?.username || '',
|
||||
proxy_password: proxy?.password || '',
|
||||
proxy_node_id: proxy?.node_id || '',
|
||||
}
|
||||
|
||||
// 如果有代理配置,确保加载节点列表(直接调用 store,避免 ref 未挂载时静默失败)
|
||||
if (proxy?.enabled) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,19 +446,17 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 启用代理时必须填写代理地址
|
||||
if (form.value.proxy_enabled && !form.value.proxy_url) {
|
||||
showError('启用代理时必须填写代理地址', '验证失败')
|
||||
// 启用代理时的验证
|
||||
if (form.value.proxy_enabled && !form.value.proxy_node_id) {
|
||||
showError('请选择代理节点', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 构建代理配置
|
||||
const proxy = form.value.proxy_enabled ? {
|
||||
url: form.value.proxy_url,
|
||||
username: form.value.proxy_username || undefined,
|
||||
password: form.value.proxy_password || undefined,
|
||||
const proxy = form.value.proxy_enabled && form.value.proxy_node_id ? {
|
||||
node_id: form.value.proxy_node_id,
|
||||
enabled: true,
|
||||
} : null
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:disabled="proxyNodesStore.loading || nodeOptions.length === 0"
|
||||
@update:model-value="(v: string) => $emit('update:modelValue', v)"
|
||||
>
|
||||
<SelectTrigger :class="triggerClass">
|
||||
<SelectValue
|
||||
:placeholder="proxyNodesStore.loading
|
||||
? '加载节点列表中...'
|
||||
: nodeOptions.length === 0
|
||||
? '暂无可用节点'
|
||||
: '选择代理节点...'"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="node in nodeOptions"
|
||||
:key="node.id"
|
||||
:value="node.id"
|
||||
>
|
||||
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
v-if="!proxyNodesStore.loading && nodeOptions.length === 0"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
暂无在线代理节点,请在「代理节点」页面添加
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
triggerClass?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
/** 在线节点 + 保留当前已选节点(可能已离线) */
|
||||
const nodeOptions = computed(() => {
|
||||
const online = proxyNodesStore.onlineNodes
|
||||
if (props.modelValue) {
|
||||
const found = online.find(n => n.id === props.modelValue)
|
||||
if (!found) {
|
||||
const allNode = proxyNodesStore.nodes.find(n => n.id === props.modelValue)
|
||||
if (allNode) return [allNode, ...online]
|
||||
}
|
||||
}
|
||||
return online
|
||||
})
|
||||
|
||||
/** 供父组件调用:启用代理时懒加载节点列表 */
|
||||
function ensureLoaded() {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
}
|
||||
|
||||
defineExpose({ ensureLoaded })
|
||||
</script>
|
||||
@@ -1,12 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { proxyNodesApi, type ProxyNode } from '@/api/proxy-nodes'
|
||||
import { ref, computed } from 'vue'
|
||||
import { proxyNodesApi, type ProxyNode, type ManualProxyNodeCreateRequest } from '@/api/proxy-nodes'
|
||||
|
||||
export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
||||
const nodes = ref<ProxyNode[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** 标记是否已加载过(避免重复请求) */
|
||||
const fetched = ref(false)
|
||||
|
||||
/** 在线节点(可用于代理选择) */
|
||||
const onlineNodes = computed(() =>
|
||||
nodes.value.filter(n => n.status === 'online')
|
||||
)
|
||||
|
||||
async function fetchNodes(params?: { status?: string }) {
|
||||
loading.value = true
|
||||
@@ -16,6 +23,7 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
||||
const data = await proxyNodesApi.listProxyNodes({ ...params, limit: 1000 })
|
||||
nodes.value = data.items
|
||||
total.value = data.total
|
||||
fetched.value = true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取代理节点列表失败'
|
||||
} finally {
|
||||
@@ -23,6 +31,30 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 确保节点列表已加载(懒加载,不重复请求) */
|
||||
async function ensureLoaded() {
|
||||
if (!fetched.value && !loading.value) {
|
||||
await fetchNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async function createManualNode(data: ManualProxyNodeCreateRequest) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await proxyNodesApi.createManualNode(data)
|
||||
// 重新获取列表以保持排序一致
|
||||
await fetchNodes()
|
||||
return result
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建手动代理节点失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNode(nodeId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -44,7 +76,11 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
fetched,
|
||||
onlineNodes,
|
||||
fetchNodes,
|
||||
ensureLoaded,
|
||||
createManualNode,
|
||||
deleteNode,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,10 +9,20 @@
|
||||
<h3 class="text-base font-semibold">
|
||||
代理节点
|
||||
</h3>
|
||||
<RefreshButton
|
||||
:loading="store.loading"
|
||||
@click="refresh"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="showAddDialog = true"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="store.loading"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
@@ -66,6 +76,15 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="h-4 w-px bg-border" />
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="showAddDialog = true"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||
手动添加
|
||||
</Button>
|
||||
<div class="h-4 w-px bg-border" />
|
||||
<RefreshButton
|
||||
:loading="store.loading"
|
||||
@click="refresh"
|
||||
@@ -97,10 +116,19 @@
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell class="py-4">
|
||||
<span class="text-sm font-semibold">{{ node.name }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-semibold">{{ node.name }}</span>
|
||||
<Badge
|
||||
v-if="node.is_manual"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
手动
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<code class="text-xs text-muted-foreground">{{ node.ip }}:{{ node.port }}</code>
|
||||
<code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<span class="text-sm text-muted-foreground">{{ node.region || '-' }}</span>
|
||||
@@ -123,15 +151,27 @@
|
||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="删除"
|
||||
@click="handleDelete(node)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="编辑"
|
||||
@click="handleEdit(node)"
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="删除"
|
||||
@click="handleDelete(node)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="paginatedNodes.length === 0">
|
||||
@@ -152,8 +192,17 @@
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div class="font-semibold text-sm">{{ node.name }}</div>
|
||||
<code class="text-xs text-muted-foreground">{{ node.ip }}:{{ node.port }}</code>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-semibold text-sm">{{ node.name }}</span>
|
||||
<Badge
|
||||
v-if="node.is_manual"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
手动
|
||||
</Badge>
|
||||
</div>
|
||||
<code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code>
|
||||
</div>
|
||||
<Badge :variant="statusVariant(node.status)" class="text-xs">
|
||||
{{ statusLabel(node.status) }}
|
||||
@@ -175,15 +224,27 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
@click="handleDelete(node)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
@click="handleEdit(node)"
|
||||
>
|
||||
<SquarePen class="h-3 w-3 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
@click="handleDelete(node)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="paginatedNodes.length === 0" class="p-8 text-center text-muted-foreground text-sm">
|
||||
@@ -201,6 +262,82 @@
|
||||
@update:page-size="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
<!-- 手动添加/编辑代理节点对话框 -->
|
||||
<Dialog
|
||||
:model-value="showAddDialog"
|
||||
:title="editingNode ? '编辑代理节点' : '手动添加代理节点'"
|
||||
:description="editingNode ? '修改手动代理节点的配置' : '手动配置的代理节点,用于无法部署 aether-proxy 的场景'"
|
||||
:icon="editingNode ? SquarePen : Plus"
|
||||
size="md"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleAddManualNode"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
v-model="addForm.name"
|
||||
placeholder="例如: 美西 VPN 代理"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>代理地址 *</Label>
|
||||
<Input
|
||||
v-model="addForm.proxy_url"
|
||||
placeholder="http://proxy:port 或 socks5://proxy:port"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>用户名</Label>
|
||||
<Input
|
||||
v-model="addForm.username"
|
||||
placeholder="可选"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
v-model="addForm.password"
|
||||
type="password"
|
||||
placeholder="可选"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>区域</Label>
|
||||
<Input
|
||||
v-model="addForm.region"
|
||||
placeholder="可选,例如: US-West"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleDialogClose(false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="addingNode || !addForm.name || !addForm.proxy_url"
|
||||
@click="editingNode ? handleUpdateManualNode() : handleAddManualNode()"
|
||||
>
|
||||
{{ addingNode ? (editingNode ? '保存中...' : '添加中...') : (editingNode ? '保存' : '添加') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -209,13 +346,14 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import type { ProxyNode } from '@/api/proxy-nodes'
|
||||
import { proxyNodesApi, type ProxyNode } from '@/api/proxy-nodes'
|
||||
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -229,9 +367,10 @@ import {
|
||||
TableCell,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
Dialog,
|
||||
} from '@/components/ui'
|
||||
|
||||
import { Search, Trash2 } from 'lucide-vue-next'
|
||||
import { Search, Trash2, Plus, SquarePen } from 'lucide-vue-next'
|
||||
|
||||
const { success, error: toastError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
@@ -242,6 +381,18 @@ const filterStatus = ref('all')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 手动添加/编辑对话框
|
||||
const showAddDialog = ref(false)
|
||||
const addingNode = ref(false)
|
||||
const editingNode = ref<ProxyNode | null>(null)
|
||||
const addForm = ref({
|
||||
name: '',
|
||||
proxy_url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
region: '',
|
||||
})
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
let filtered = [...store.nodes]
|
||||
|
||||
@@ -277,6 +428,70 @@ async function refresh() {
|
||||
await store.fetchNodes()
|
||||
}
|
||||
|
||||
function handleEdit(node: ProxyNode) {
|
||||
editingNode.value = node
|
||||
addForm.value = {
|
||||
name: node.name,
|
||||
proxy_url: node.proxy_url || '',
|
||||
username: node.proxy_username || '',
|
||||
password: '', // 不回填密码(已脱敏)
|
||||
region: node.region || '',
|
||||
}
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
function handleDialogClose(open: boolean) {
|
||||
if (!open) {
|
||||
showAddDialog.value = false
|
||||
editingNode.value = null
|
||||
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateManualNode() {
|
||||
if (!editingNode.value || !addForm.value.name || !addForm.value.proxy_url) return
|
||||
|
||||
addingNode.value = true
|
||||
try {
|
||||
await proxyNodesApi.updateManualNode(editingNode.value.id, {
|
||||
name: addForm.value.name,
|
||||
proxy_url: addForm.value.proxy_url,
|
||||
username: addForm.value.username || undefined,
|
||||
// 空密码不发送(保留原值)
|
||||
password: addForm.value.password || undefined,
|
||||
region: addForm.value.region || undefined,
|
||||
})
|
||||
success('代理节点已更新')
|
||||
handleDialogClose(false)
|
||||
await store.fetchNodes()
|
||||
} catch (err: any) {
|
||||
toastError(err.response?.data?.error?.message || err.response?.data?.detail || '更新失败')
|
||||
} finally {
|
||||
addingNode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddManualNode() {
|
||||
if (!addForm.value.name || !addForm.value.proxy_url) return
|
||||
|
||||
addingNode.value = true
|
||||
try {
|
||||
await store.createManualNode({
|
||||
name: addForm.value.name,
|
||||
proxy_url: addForm.value.proxy_url,
|
||||
username: addForm.value.username || undefined,
|
||||
password: addForm.value.password || undefined,
|
||||
region: addForm.value.region || undefined,
|
||||
})
|
||||
success('代理节点已添加')
|
||||
handleDialogClose(false)
|
||||
} catch (err: any) {
|
||||
toastError(err.response?.data?.error?.message || err.response?.data?.detail || '添加失败')
|
||||
} finally {
|
||||
addingNode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(node: ProxyNode) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除代理节点 "${node.name}" (${node.ip}:${node.port}) 吗?`,
|
||||
@@ -285,8 +500,14 @@ async function handleDelete(node: ProxyNode) {
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await store.deleteNode(node.id)
|
||||
success('代理节点已删除')
|
||||
const result = await proxyNodesApi.deleteProxyNode(node.id)
|
||||
// 同步更新 store 本地状态
|
||||
store.nodes = store.nodes.filter(n => n.id !== node.id)
|
||||
if (result.cleared_system_proxy) {
|
||||
success('代理节点已删除,系统默认代理已自动清除')
|
||||
} else {
|
||||
success('代理节点已删除')
|
||||
}
|
||||
} catch (err: any) {
|
||||
toastError(err.response?.data?.error?.message || '删除失败')
|
||||
}
|
||||
|
||||
@@ -94,6 +94,50 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 网络代理 -->
|
||||
<CardSection
|
||||
title="网络代理"
|
||||
description="配置提供商出站请求的默认代理,仅影响大模型 API、余额查询、OAuth 等提供商请求"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="proxyConfigLoading || !hasProxyConfigChanges"
|
||||
@click="saveProxyConfig"
|
||||
>
|
||||
{{ proxyConfigLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<div class="max-w-md">
|
||||
<Label class="block text-sm font-medium mb-1">
|
||||
默认代理节点
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="systemConfig.system_proxy_node_id || '__direct__'"
|
||||
@update:model-value="(v: string) => systemConfig.system_proxy_node_id = v === '__direct__' ? null : v"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="直连(不使用代理)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__direct__">
|
||||
直连(不使用代理)
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="node in proxyNodesStore.onlineNodes"
|
||||
:key="node.id"
|
||||
:value="node.id"
|
||||
>
|
||||
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
对未单独配置代理的提供商生效,覆盖大模型 API 请求、余额查询、OAuth 刷新等。不影响系统内部接口。
|
||||
</p>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 基础配置 -->
|
||||
<CardSection
|
||||
title="基础配置"
|
||||
@@ -1010,10 +1054,14 @@ import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
interface SystemConfig {
|
||||
// 网络代理
|
||||
system_proxy_node_id: string | null
|
||||
// 基础配置
|
||||
default_user_quota_usd: number
|
||||
rate_limit_per_minute: number
|
||||
@@ -1044,6 +1092,7 @@ interface SystemConfig {
|
||||
enable_oauth_token_refresh: boolean
|
||||
}
|
||||
|
||||
const proxyConfigLoading = ref(false)
|
||||
const basicConfigLoading = ref(false)
|
||||
const logConfigLoading = ref(false)
|
||||
const cleanupConfigLoading = ref(false)
|
||||
@@ -1074,6 +1123,8 @@ const usersMergeModeSelectOpen = ref(false)
|
||||
const systemVersion = ref<string>('')
|
||||
|
||||
const systemConfig = ref<SystemConfig>({
|
||||
// 网络代理
|
||||
system_proxy_node_id: null,
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
rate_limit_per_minute: 0,
|
||||
@@ -1171,6 +1222,7 @@ onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadSystemConfig(),
|
||||
loadSystemVersion(),
|
||||
proxyNodesStore.ensureLoaded(),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1186,6 +1238,8 @@ async function loadSystemVersion() {
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
const configs = [
|
||||
// 网络代理
|
||||
'system_proxy_node_id',
|
||||
// 基础配置
|
||||
'default_user_quota_usd',
|
||||
'rate_limit_per_minute',
|
||||
@@ -1239,6 +1293,31 @@ async function loadSystemConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasProxyConfigChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id
|
||||
})
|
||||
|
||||
async function saveProxyConfig() {
|
||||
proxyConfigLoading.value = true
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'system_proxy_node_id',
|
||||
systemConfig.value.system_proxy_node_id || null,
|
||||
'系统默认代理节点 ID'
|
||||
)
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.system_proxy_node_id = systemConfig.value.system_proxy_node_id
|
||||
}
|
||||
success('网络代理配置已保存')
|
||||
} catch (err) {
|
||||
error('保存代理配置失败')
|
||||
log.error('保存代理配置失败:', err)
|
||||
} finally {
|
||||
proxyConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBasicConfig() {
|
||||
basicConfigLoading.value = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user