feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层

- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate
- aether-data 扩展 repository 层:announcements、auth_modules、billing、
  candidate_selection、gemini_file_mappings、global_models、management_tokens、
  oauth_providers、proxy_nodes、quota、users、wallet 等模块
- aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/
  video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块
- 重构 executor decision 和 gateway state 为模块目录结构
- 新增 gateway router、frontdoor 路由层及对应测试
- Python 侧 API 路由重构,新增 compat/support 模块
- 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
fawney19
2026-03-31 19:19:04 +08:00
parent b5a0070023
commit ddf18fed9a
690 changed files with 235087 additions and 16301 deletions

View File

@@ -207,9 +207,6 @@ export interface PoolBatchAction {
| 'enable'
| 'disable'
| 'delete'
| 'clear_cooldown'
| 'reset_cost'
| 'regenerate_fingerprint'
| 'clear_proxy'
| 'set_proxy'
payload?: Record<string, unknown> | null

View File

@@ -41,10 +41,8 @@ export async function getProvidersSummary(
* 获取单个 Provider 的详细信息
*/
export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> {
return dedupedRequest(`providers:detail:${providerId}`, async () => {
const response = await client.get<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}/summary`)
return response.data
})
const response = await client.get<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}/summary`)
return response.data
}
/**

View File

@@ -43,9 +43,9 @@
<!-- Layer 0: Ghost Tracks (Always visible, faint) -->
<g class="ghost-layer">
<path
v-for="(path, index) in linePaths"
:key="`ghost-${index}`"
:d="path"
v-for="line in orderedLines"
:key="`ghost-${line.order}`"
:d="line.path"
class="ghost-path"
fill="none"
:stroke="currentColors.primary"
@@ -67,15 +67,15 @@
<!-- Layer 2: Animated lines -->
<g class="lines-layer">
<path
v-for="(path, index) in linePaths"
:key="`line-${index}`"
:ref="(el) => setPathRef(el as SVGPathElement, index)"
:d="path"
v-for="line in orderedLines"
:key="`line-${line.order}`"
:ref="(el) => setPathRef(el as SVGPathElement, line.order)"
:d="line.path"
class="line-path"
fill="none"
:stroke="currentColors.primary"
:stroke-width="strokeWidth"
:style="getLineStyle(index)"
:style="getLineStyle(line.order)"
stroke-linecap="round"
stroke-linejoin="round"
/>
@@ -86,7 +86,12 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { AETHER_SVG_VIEWBOX, AETHER_LINE_PATHS, AETHER_FULL_PATH } from '@/constants/logoPaths'
import {
AETHER_SVG_VIEWBOX,
AETHER_ORDERED_LINES,
AETHER_FULL_PATH,
getAetherLineDelayMultiplier
} from '@/constants/logoPaths'
// Animation phases
type AnimationPhase = 'idle' | 'drawOutline' | 'fillFadeIn' | 'hold' | 'fillFadeOut' | 'eraseOutline'
@@ -133,7 +138,7 @@ const emit = defineEmits<{
(e: 'colorChange', colors: ColorScheme): void
}>()
// Constants
const LINE_COUNT = AETHER_LINE_PATHS.length
const LINE_COUNT = AETHER_ORDERED_LINES.length
const DEFAULT_PATH_LENGTH = 3000
// Light mode color schemes
@@ -172,7 +177,7 @@ const DARK_MODE_SCHEMES: ColorScheme[] = [
const gradientId = `aether-gradient-${Math.random().toString(36).slice(2, 9)}`
const viewBox = AETHER_SVG_VIEWBOX
const linePaths = AETHER_LINE_PATHS
const orderedLines = AETHER_ORDERED_LINES
const fullPath = AETHER_FULL_PATH
// Path refs and lengths
@@ -310,9 +315,11 @@ const startAnimation = async () => {
currentPhase.value = 'drawOutline'
emit('phaseChange', 'drawOutline')
for (let i = 0; i < LINE_COUNT; i++) {
lineDrawn.value[i] = true
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
for (const line of orderedLines) {
lineDrawn.value[line.order] = true
if (line.order < LINE_COUNT - 1) {
await wait(Math.round(props.lineDelay * getAetherLineDelayMultiplier(line.order, LINE_COUNT)))
}
}
await wait(props.strokeDuration)
@@ -336,9 +343,11 @@ const startAnimation = async () => {
currentPhase.value = 'eraseOutline'
emit('phaseChange', 'eraseOutline')
for (let i = 0; i < LINE_COUNT; i++) {
lineDrawn.value[i] = false
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
for (const line of [...orderedLines].reverse()) {
lineDrawn.value[line.order] = false
if (line.order > 0) {
await wait(Math.round(props.lineDelay * getAetherLineDelayMultiplier(line.order, LINE_COUNT)))
}
}
await wait(props.strokeDuration)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -146,33 +146,41 @@
</div>
<!-- 端点 API 格式 -->
<div class="flex items-center gap-1.5 flex-wrap mt-3">
<template
v-for="endpoint in endpoints"
:key="endpoint.id"
>
<span
class="text-xs px-2 py-0.5 rounded-md border border-border bg-background hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors font-medium"
:class="{ 'opacity-40': !endpoint.is_active }"
:title="`编辑 ${formatApiFormat(endpoint.api_format)} 端点`"
@click="handleEditEndpoint(endpoint)"
>{{ formatApiFormat(endpoint.api_format) }}</span>
<template v-if="loadingProviderEndpoints && endpoints.length === 0">
<span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
加载端点中
</span>
</template>
<template v-else>
<template
v-for="endpoint in endpoints"
:key="endpoint.id"
>
<span
class="text-xs px-2 py-0.5 rounded-md border border-border bg-background hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors font-medium"
:class="{ 'opacity-40': !endpoint.is_active }"
:title="`编辑 ${formatApiFormat(endpoint.api_format)} 端点`"
@click="handleEditEndpoint(endpoint)"
>{{ formatApiFormat(endpoint.api_format) }}</span>
</template>
<span
v-if="endpoints.length > 0"
class="text-xs px-2 py-0.5 rounded-md border border-dashed border-border hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors text-muted-foreground"
title="编辑端点"
@click="showAddEndpointDialog"
>编辑</span>
<Button
v-else
variant="outline"
size="sm"
class="h-7 text-xs"
@click="showAddEndpointDialog"
>
<Plus class="w-3 h-3 mr-1" />
添加 API 端点
</Button>
</template>
<span
v-if="endpoints.length > 0"
class="text-xs px-2 py-0.5 rounded-md border border-dashed border-border hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors text-muted-foreground"
title="编辑端点"
@click="showAddEndpointDialog"
>编辑</span>
<Button
v-else
variant="outline"
size="sm"
class="h-7 text-xs"
@click="showAddEndpointDialog"
>
<Plus class="w-3 h-3 mr-1" />
添加 API 端点
</Button>
</div>
</div>
@@ -243,7 +251,15 @@
<!-- 密钥列表 -->
<div
v-if="allKeys.length > 0"
v-if="loadingProviderKeys && allKeys.length === 0"
class="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground"
>
<Loader2 class="w-4 h-4 animate-spin" />
正在加载{{ isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号' }}
</div>
<div
v-else-if="allKeys.length > 0"
ref="keysListRef"
class="divide-y divide-border/40"
:class="shouldPaginateKeys && 'flex flex-col'"
@@ -926,6 +942,7 @@
:provider="provider"
:models="providerModels"
:endpoints="endpoints"
:loading="loadingProviderModels"
@edit-model="handleEditModel"
@batch-assign="handleBatchAssign"
@refresh="loadEndpoints"
@@ -941,6 +958,7 @@
:provider-keys="providerKeys"
:models="providerModels"
:mapping-preview="providerMappingPreview"
:loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"
@refresh="handleModelMappingChanged"
/>
</div>
@@ -1065,6 +1083,7 @@ import { useSmartPagination } from '@/composables/useSmartPagination'
import {
Plus,
Key,
Loader2,
Edit,
Trash2,
RefreshCw,
@@ -1155,6 +1174,7 @@ interface ProviderEndpointWithKeys extends ProviderEndpoint {
interface Props {
providerId: string | null
open: boolean
initialProvider?: ProviderWithEndpointsSummary | null
}
const props = defineProps<Props>()
@@ -1176,6 +1196,10 @@ const endpoints = ref<ProviderEndpointWithKeys[]>([])
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
const providerModels = ref<Model[]>([]) // Provider 级别的 models
const providerMappingPreview = ref<ProviderMappingPreviewResponse | null>(null) // 映射预览
const loadingProviderEndpoints = ref(false)
const loadingProviderKeys = ref(false)
const loadingProviderModels = ref(false)
const loadingProviderMappingPreview = ref(false)
let providerLoadRequestId = 0
let endpointsLoadRequestId = 0
let mappingPreviewLoadRequestId = 0
@@ -1377,6 +1401,10 @@ watch(
[() => props.providerId, () => props.open],
async ([newId, newOpen], [_oldId, oldOpen]) => {
if (newOpen && newId) {
if (props.initialProvider?.id === newId) {
provider.value = props.initialProvider
loading.value = false
}
// mapping-preview 较慢,不阻塞首屏渲染
void loadMappingPreview()
await Promise.all([
@@ -1403,6 +1431,10 @@ watch(
providerKeys.value = [] // 清空 Provider 级别的 keys
providerModels.value = []
providerMappingPreview.value = null
loadingProviderEndpoints.value = false
loadingProviderKeys.value = false
loadingProviderModels.value = false
loadingProviderMappingPreview.value = false
// 重置分页状态
resetKeysPagination()
@@ -2775,14 +2807,15 @@ async function loadSystemFormatConversionConfig() {
async function loadProvider() {
if (!props.providerId) return
const requestId = ++providerLoadRequestId
const shouldShowSpinner = !provider.value || provider.value.id !== props.providerId
try {
loading.value = true
// 并行加载 Provider 信息和系统级格式转换配置
const [providerData] = await Promise.all([
getProvider(props.providerId),
loadSystemFormatConversionConfig(),
])
if (shouldShowSpinner) {
loading.value = true
}
// 系统级格式转换配置只影响一个图标状态,不应阻塞详情抽屉首屏。
void loadSystemFormatConversionConfig()
const providerData = await getProvider(props.providerId)
if (requestId !== providerLoadRequestId) return
provider.value = providerData
@@ -2793,7 +2826,7 @@ async function loadProvider() {
if (requestId !== providerLoadRequestId) return
showError(parseApiError(err, '加载失败'), '错误')
} finally {
if (requestId === providerLoadRequestId) {
if (requestId === providerLoadRequestId && shouldShowSpinner) {
loading.value = false
}
}
@@ -2802,22 +2835,14 @@ async function loadProvider() {
// 加载端点列表
async function loadEndpoints() {
if (!props.providerId) return
const providerId = props.providerId
const requestId = ++endpointsLoadRequestId
loadingProviderEndpoints.value = true
loadingProviderKeys.value = true
loadingProviderModels.value = true
try {
// 并行加载端点列表、Provider 级别的 keys 和 models
// mapping-preview 较慢,拆到 loadMappingPreview 独立加载,不阻塞首屏
const [endpointsList, providerKeysResult, modelsResult] = await Promise.all([
getProviderEndpoints(props.providerId),
getProviderKeys(props.providerId).catch(() => []),
getProviderModels(props.providerId).catch(() => []),
])
if (requestId !== endpointsLoadRequestId) return
providerKeys.value = providerKeysResult
providerModels.value = modelsResult
// 按 API 格式排序
const sortedEndpoints = endpointsList.sort((a, b) => {
const sortEndpoints = (items: ProviderEndpoint[]): ProviderEndpointWithKeys[] => {
return [...items].sort((a, b) => {
const aIdx = API_FORMAT_ORDER.indexOf(a.api_format)
const bIdx = API_FORMAT_ORDER.indexOf(b.api_format)
if (aIdx === -1 && bIdx === -1) return 0
@@ -2825,18 +2850,60 @@ async function loadEndpoints() {
if (bIdx === -1) return -1
return aIdx - bIdx
})
endpoints.value = sortedEndpoints
syncCurrentSelections(sortedEndpoints, providerKeysResult)
} catch (err: unknown) {
if (requestId !== endpointsLoadRequestId) return
showError(parseApiError(err, '加载端点失败'), '错误')
}
const endpointsPromise = getProviderEndpoints(providerId)
.then((endpointsList) => {
if (requestId !== endpointsLoadRequestId) return
const sortedEndpoints = sortEndpoints(endpointsList)
endpoints.value = sortedEndpoints
syncCurrentSelections(sortedEndpoints, providerKeys.value)
})
.catch((err: unknown) => {
if (requestId !== endpointsLoadRequestId) return
endpoints.value = []
syncCurrentSelections([], providerKeys.value)
showError(parseApiError(err, '加载端点失败'), '错误')
})
.finally(() => {
if (requestId === endpointsLoadRequestId) {
loadingProviderEndpoints.value = false
}
})
const providerKeysPromise = getProviderKeys(providerId)
.catch(() => [])
.then((providerKeysResult) => {
if (requestId !== endpointsLoadRequestId) return
providerKeys.value = providerKeysResult
syncCurrentSelections(endpoints.value, providerKeysResult)
})
.finally(() => {
if (requestId === endpointsLoadRequestId) {
loadingProviderKeys.value = false
}
})
const modelsPromise = getProviderModels(providerId)
.catch(() => [])
.then((modelsResult) => {
if (requestId !== endpointsLoadRequestId) return
providerModels.value = modelsResult
})
.finally(() => {
if (requestId === endpointsLoadRequestId) {
loadingProviderModels.value = false
}
})
await Promise.allSettled([endpointsPromise, providerKeysPromise, modelsPromise])
}
// 加载映射预览(独立于 loadEndpoints不阻塞首屏渲染
async function loadMappingPreview() {
if (!props.providerId) return
const requestId = ++mappingPreviewLoadRequestId
loadingProviderMappingPreview.value = true
try {
const preview = await getProviderMappingPreview(props.providerId)
if (requestId !== mappingPreviewLoadRequestId) return
@@ -2844,6 +2911,10 @@ async function loadMappingPreview() {
} catch {
if (requestId !== mappingPreviewLoadRequestId) return
providerMappingPreview.value = null
} finally {
if (requestId === mappingPreviewLoadRequestId) {
loadingProviderMappingPreview.value = false
}
}
}

View File

@@ -20,7 +20,7 @@
<!-- 加载状态 -->
<div
v-if="loading"
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
@@ -396,6 +396,7 @@ const props = defineProps<{
providerKeys?: EndpointAPIKey[]
models?: Model[]
mappingPreview?: ProviderMappingPreviewResponse | null
loading?: boolean
}>()
const emit = defineEmits<{
@@ -428,6 +429,7 @@ const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraf
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const isLoading = computed(() => Boolean(props.loading) || loading.value)
// 使用 props 传入的数据
const models = computed(() => props.models ?? [])

View File

@@ -20,7 +20,7 @@
<!-- 加载状态 -->
<div
v-if="loading"
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
@@ -269,6 +269,7 @@ const props = defineProps<{
provider: ProviderWithEndpointsSummary
models?: Model[]
endpoints?: ProviderEndpoint[]
loading?: boolean
}>()
const emit = defineEmits<{
@@ -300,6 +301,7 @@ const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.er
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const models = computed(() => props.models ?? localModels.value)
const isLoading = computed(() => Boolean(props.loading) || loading.value)
// 按名称排序的模型列表
const sortedModels = computed(() => {
return [...models.value].sort((a, b) => {

View File

@@ -241,6 +241,7 @@
<ProviderDetailDrawer
:open="providerDrawerOpen"
:provider-id="selectedProviderId"
:initial-provider="selectedProvider"
@update:open="providerDrawerOpen = $event"
@edit="openEditProviderDialog"
@toggle-status="toggleProviderStatus"
@@ -313,6 +314,10 @@ const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null)
const selectedProvider = computed<ProviderWithEndpointsSummary | null>(() => {
if (!selectedProviderId.value) return null
return providers.value.find(provider => provider.id === selectedProviderId.value) ?? null
})
const providerDeleteProgress = ref<ProviderDeleteProgressState | null>(null)
let deletePollAbort: AbortController | null = null

View File

@@ -160,7 +160,9 @@ const isAdminPage = computed(() => route.path.startsWith('/admin'))
const statsExpanded = useLocalStorage('usage-stats-expanded', true)
// 时间范围选择
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
const timeRange = ref<DateRangeParams>(
getDateRangeFromPeriod(isAdminPage.value ? 'last7days' : 'today')
)
// 分页状态
const currentPage = ref(1)