mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
36
frontend/src/api/proxy-nodes.ts
Normal file
36
frontend/src/api/proxy-nodes.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
@@ -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 排序,只显示已激活的)
|
||||
|
||||
@@ -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',
|
||||
|
||||
50
frontend/src/stores/proxy-nodes.ts
Normal file
50
frontend/src/stores/proxy-nodes.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
329
frontend/src/views/admin/ProxyNodes.vue
Normal file
329
frontend/src/views/admin/ProxyNodes.vue
Normal 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>
|
||||
Reference in New Issue
Block a user