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:
fawney19
2026-02-07 14:30:46 +08:00
parent 86dbbe83c2
commit db96c9a46e
30 changed files with 1182 additions and 243 deletions

View File

@@ -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 || '删除失败')
}

View File

@@ -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 {