feat: ProxyNode 代理节点管理系统与 OpenAI Responses API 解析增强

ProxyNode 系统:新增 aether-proxy(Rust)海外 VPS 代理组件,后端实现节点注册/心跳/
HMAC 认证/健康检测调度器/模块化集成,前端新增代理节点管理页面。ProxyConfig 支持
node_id 模式,http_client 支持 HMAC 签名代理 URL 构建与 TTL 缓存。

OpenAI CLI 解析器:适配 Responses API 格式,支持 input_tokens/output_tokens 提取、
output[].content[].text 文本解析、response.completed 流式事件 usage 嵌套结构。
This commit is contained in:
fawney19
2026-02-07 12:24:42 +08:00
parent 62f852b851
commit 1180634269
40 changed files with 4761 additions and 11 deletions

View File

@@ -0,0 +1,36 @@
import apiClient from './client'
export interface ProxyNode {
id: string
name: string
ip: string
port: number
region: string | null
status: 'online' | 'unhealthy' | 'offline'
registered_by: string | null
last_heartbeat_at: string | null
heartbeat_interval: number
active_connections: number
total_requests: number
avg_latency_ms: number | null
created_at: string
updated_at: string
}
export interface ProxyNodeListResponse {
items: ProxyNode[]
total: number
skip: number
limit: number
}
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}`)
},
}

View File

@@ -368,6 +368,7 @@ import {
Video,
Zap,
FileUp,
Server,
type LucideIcon,
} from 'lucide-vue-next'
@@ -520,6 +521,7 @@ const navigation = computed(() => {
FileUp,
Shield,
Puzzle,
Server,
}
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)

View File

@@ -230,6 +230,12 @@ const routes: RouteRecordRaw[] = [
name: 'AsyncTasks',
component: () => importWithRetry(() => import('@/views/admin/AsyncTasks.vue'))
},
{
path: 'proxy-nodes',
name: 'ProxyNodes',
component: () => importWithRetry(() => import('@/views/admin/ProxyNodes.vue')),
meta: { module: 'proxy_nodes' }
},
{
path: 'gemini-files',
name: 'GeminiFilesManagement',

View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { proxyNodesApi, type ProxyNode } 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)
async function fetchNodes(params?: { status?: string }) {
loading.value = true
error.value = null
try {
const data = await proxyNodesApi.listProxyNodes({ ...params, limit: 1000 })
nodes.value = data.items
total.value = data.total
} catch (err: any) {
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取代理节点列表失败'
} finally {
loading.value = false
}
}
async function deleteNode(nodeId: string) {
loading.value = true
error.value = null
try {
await proxyNodesApi.deleteProxyNode(nodeId)
nodes.value = nodes.value.filter(n => n.id !== nodeId)
total.value = Math.max(0, total.value - 1)
} catch (err: any) {
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除代理节点失败'
throw err
} finally {
loading.value = false
}
}
return {
nodes,
total,
loading,
error,
fetchNodes,
deleteNode,
}
})

View File

@@ -0,0 +1,329 @@
<template>
<div class="space-y-6 pb-8">
<Card variant="default" class="overflow-hidden">
<!-- 标题和筛选器 -->
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<!-- 移动端 -->
<div class="flex flex-col gap-3 sm:hidden">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
代理节点
</h3>
<RefreshButton
:loading="store.loading"
@click="refresh"
/>
</div>
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
<Select v-model="filterStatus">
<SelectTrigger class="w-24 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="online">在线</SelectItem>
<SelectItem value="unhealthy">异常</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- 桌面端 -->
<div class="hidden sm:flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">
代理节点
</h3>
<div class="flex items-center gap-2">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="w-48 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
<div class="h-4 w-px bg-border" />
<Select v-model="filterStatus">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="online">在线</SelectItem>
<SelectItem value="unhealthy">异常</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
<div class="h-4 w-px bg-border" />
<RefreshButton
:loading="store.loading"
@click="refresh"
/>
</div>
</div>
</div>
<!-- 桌面端表格 -->
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[160px] h-12 font-semibold">名称</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">地址</TableHead>
<TableHead class="w-[100px] h-12 font-semibold">区域</TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">状态</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">连接数</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">总请求</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">延迟</TableHead>
<TableHead class="w-[160px] h-12 font-semibold">最后心跳</TableHead>
<TableHead class="w-[80px] h-12 font-semibold text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="node in paginatedNodes"
:key="node.id"
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>
</TableCell>
<TableCell class="py-4">
<code class="text-xs text-muted-foreground">{{ node.ip }}:{{ node.port }}</code>
</TableCell>
<TableCell class="py-4">
<span class="text-sm text-muted-foreground">{{ node.region || '-' }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<Badge :variant="statusVariant(node.status)" class="font-medium px-2.5 py-0.5 text-xs">
{{ statusLabel(node.status) }}
</Badge>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.active_connections }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
</TableCell>
<TableCell class="py-4">
<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>
</TableCell>
</TableRow>
<TableRow v-if="paginatedNodes.length === 0">
<TableCell colspan="9" class="py-12 text-center text-muted-foreground text-sm">
{{ store.loading ? '加载中...' : '暂无代理节点' }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 移动端卡片列表 -->
<div class="xl:hidden divide-y divide-border/40">
<div
v-for="node in paginatedNodes"
:key="node.id"
class="p-4 sm:p-5"
>
<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>
<Badge :variant="statusVariant(node.status)" class="text-xs">
{{ statusLabel(node.status) }}
</Badge>
</div>
<div class="grid grid-cols-3 gap-2 text-xs text-muted-foreground mb-3">
<div>
<span class="block text-foreground/60">区域</span>
<span>{{ node.region || '-' }}</span>
</div>
<div>
<span class="block text-foreground/60">连接</span>
<span class="tabular-nums">{{ node.active_connections }}</span>
</div>
<div>
<span class="block text-foreground/60">延迟</span>
<span class="tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
</div>
</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>
</div>
<div v-if="paginatedNodes.length === 0" class="p-8 text-center text-muted-foreground text-sm">
{{ store.loading ? '加载中...' : '暂无代理节点' }}
</div>
</div>
<!-- 分页 -->
<Pagination
:current="currentPage"
:total="filteredNodes.length"
:page-size="pageSize"
cache-key="proxy-nodes-page-size"
@update:current="currentPage = $event"
@update:page-size="pageSize = $event"
/>
</Card>
</div>
</template>
<script setup lang="ts">
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 {
Card,
Button,
Badge,
Input,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Pagination,
RefreshButton,
} from '@/components/ui'
import { Search, Trash2 } from 'lucide-vue-next'
const { success, error: toastError } = useToast()
const { confirmDanger } = useConfirm()
const store = useProxyNodesStore()
const searchQuery = ref('')
const filterStatus = ref('all')
const currentPage = ref(1)
const pageSize = ref(20)
const filteredNodes = computed(() => {
let filtered = [...store.nodes]
if (searchQuery.value) {
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
filtered = filtered.filter(node => {
const text = `${node.name} ${node.ip} ${node.region || ''}`.toLowerCase()
return keywords.every(kw => text.includes(kw))
})
}
if (filterStatus.value !== 'all') {
filtered = filtered.filter(node => node.status === filterStatus.value)
}
return filtered
})
const paginatedNodes = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredNodes.value.slice(start, start + pageSize.value)
})
watch([searchQuery, filterStatus], () => {
currentPage.value = 1
})
onMounted(async () => {
await store.fetchNodes()
})
async function refresh() {
await store.fetchNodes()
}
async function handleDelete(node: ProxyNode) {
const confirmed = await confirmDanger(
`确定要删除代理节点 "${node.name}" (${node.ip}:${node.port}) 吗?`,
'删除节点'
)
if (!confirmed) return
try {
await store.deleteNode(node.id)
success('代理节点已删除')
} catch (err: any) {
toastError(err.response?.data?.error?.message || '删除失败')
}
}
function statusVariant(status: string) {
switch (status) {
case 'online': return 'success' as const
case 'unhealthy': return 'secondary' as const
case 'offline': return 'destructive' as const
default: return 'secondary' as const
}
}
function statusLabel(status: string) {
switch (status) {
case 'online': return '在线'
case 'unhealthy': return '异常'
case 'offline': return '离线'
default: return status
}
}
function formatNumber(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function formatTime(iso: string | null) {
if (!iso) return '-'
const d = new Date(iso)
const now = new Date()
const diff = (now.getTime() - d.getTime()) / 1000
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
</script>