feat: 全栈功能增强 - 扩展 provider/pool 管理、完善调度与数据层、重构前端 Pool 页面

后端:
- 扩展 pool_admin payloads 和 provider query models,增强 endpoint key 管理
- 完善 scheduler-core 候选排序与请求候选逻辑
- 增强 usage-runtime 写入、provider-transport 网络层与 OAuth 刷新
- 改进 AI pipeline 响应转换与流式处理
- 扩展 global_models/provider_catalog 数据层查询能力
- 增强 video-tasks-core 多 provider 支持
- 新增大量集成测试覆盖 pool/keys/provider_query/frontdoor

前端:
- 重构 PoolManagement 页面,拆分状态管理/对话框逻辑到独立模块
- 新增 poolAdvancedDialog/poolSchedulingDialog/poolManagementState/poolMobilePresentation 工具函数及测试
- 改进 Dialog 组件与 provider tabs 显示

部署:
- 更新 Rust CI workflow 和 Dockerfile 构建配置

Closes #275
Co-authored-by: AAEE86 <[email protected]>
This commit is contained in:
fawney19
2026-04-09 13:51:50 +08:00
parent 4fc95adfb9
commit b0b40c16ff
97 changed files with 5816 additions and 1881 deletions
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
buildPoolCooldownFieldLayout,
buildPoolHealthToggleCards,
buildPoolCostFieldLayout,
buildPoolSecondarySectionLayout,
} from '@/features/pool/utils/poolAdvancedDialog'
describe('poolAdvancedDialog', () => {
it('returns health toggle cards in the desktop display order', () => {
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
'health_policy_enabled',
'probing_enabled',
'auto_remove_banned_keys',
])
})
it('provides tooltip copy for every desktop health toggle card', () => {
expect(buildPoolHealthToggleCards()).toEqual([
{
key: 'health_policy_enabled',
label: '健康策略',
description: '按上游错误自动冷却并跳过异常账号。',
},
{
key: 'probing_enabled',
label: '主动探测',
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
},
{
key: 'auto_remove_banned_keys',
label: '异常自动清除',
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
},
])
})
it('returns the four cooldown-related fields in one desktop row order', () => {
expect(buildPoolCooldownFieldLayout()).toEqual({
fields: [
'rate_limit_cooldown_seconds',
'overload_cooldown_seconds',
'sticky_session_ttl_seconds',
'global_priority',
],
desktopColumnsClass: 'xl:grid-cols-4',
})
})
it('stacks batch and cost sections as full-width rows on desktop', () => {
expect(buildPoolSecondarySectionLayout()).toEqual({
wrapperClass: 'space-y-4',
})
})
it('returns the three cost fields in one desktop row order', () => {
expect(buildPoolCostFieldLayout()).toEqual({
fields: [
'cost_window_seconds',
'cost_limit_per_key_tokens',
'cost_soft_threshold_percent',
],
desktopColumnsClass: 'xl:grid-cols-3',
})
})
})
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
buildPoolManagementQueryPatch,
readPoolManagementViewState,
writePoolManagementViewState,
} from '@/features/pool/utils/poolManagementState'
function createMemoryStorage() {
const store = new Map<string, string>()
return {
getItem(key: string) {
return store.get(key) ?? null
},
setItem(key: string, value: string) {
store.set(key, value)
},
removeItem(key: string) {
store.delete(key)
},
}
}
describe('poolManagementState', () => {
let storage: ReturnType<typeof createMemoryStorage>
beforeEach(() => {
storage = createMemoryStorage()
})
it('restores provider, filters and paging from query first', () => {
writePoolManagementViewState(
{
providerId: 'provider-a',
search: 'stored search',
status: 'cooldown',
page: 5,
pageSize: 20,
},
storage,
)
const state = readPoolManagementViewState(
{
providerId: 'provider-b',
search: 'query search',
status: 'inactive',
page: '3',
pageSize: '100',
},
storage,
)
expect(state).toEqual({
providerId: 'provider-b',
search: 'query search',
status: 'inactive',
page: 3,
pageSize: 100,
})
})
it('falls back to storage when query is missing', () => {
writePoolManagementViewState(
{
providerId: 'provider-c',
search: 'stored only',
status: 'active',
page: 2,
pageSize: 50,
},
storage,
)
const state = readPoolManagementViewState({}, storage)
expect(state).toEqual({
providerId: 'provider-c',
search: 'stored only',
status: 'active',
page: 2,
pageSize: 50,
})
})
it('omits defaults when building query patch', () => {
expect(
buildPoolManagementQueryPatch({
providerId: 'provider-d',
search: ' ',
status: 'all',
page: 1,
pageSize: 50,
}),
).toEqual({
providerId: 'provider-d',
search: undefined,
status: undefined,
page: undefined,
pageSize: undefined,
})
})
})
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
buildPoolMobileTagItems,
splitPoolMobileActions,
} from '@/features/pool/utils/poolMobilePresentation'
describe('poolMobilePresentation', () => {
it('prioritizes critical mobile tags before secondary identity tags', () => {
expect(
buildPoolMobileTagItems({
priorityLabel: 'P40',
authLabel: 'OAuth',
oauthStatusLabel: 'Token 过期',
oauthStatusTone: 'danger',
accountStatusLabel: '账号停用',
accountStatusTone: 'danger',
planLabel: 'Team',
orgLabel: 'Org A',
proxyLabel: '独立代理',
}).map(item => item.label),
).toEqual([
'账号停用',
'Token 过期',
'P40',
'OAuth',
'Team',
'Org A',
'独立代理',
])
})
it('keeps all available mobile actions inline without overflow grouping', () => {
expect(
splitPoolMobileActions({
canRefreshToken: true,
canClearCooldown: true,
canRecoverHealth: true,
canDownloadOrCopy: true,
hasProxy: true,
}),
).toEqual({
primary: [
'copy_or_download',
'refresh_token',
'clear_cooldown',
'recover_health',
'permissions',
'proxy',
'edit',
'toggle',
'delete',
],
overflow: [],
})
})
})
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
interface TestPresetItem {
preset: string
mutexGroup: string | null
enabled: boolean
}
function buildItems(): TestPresetItem[] {
return [
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true },
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'recent_refresh', mutexGroup: null, enabled: true },
{ preset: 'quota_balanced', mutexGroup: null, enabled: false },
{ preset: 'priority_first', mutexGroup: null, enabled: true },
]
}
describe('poolSchedulingDialog', () => {
it('moves only strategy items upward without disturbing distribution presets', () => {
const moved = moveStrategyItem(buildItems(), 6, -1)
expect(moved.map(item => item.preset)).toEqual([
'cache_affinity',
'lru',
'single_account',
'load_balance',
'recent_refresh',
'priority_first',
'quota_balanced',
])
})
it('keeps the original order when a strategy item is already at the top boundary', () => {
const original = buildItems()
const moved = moveStrategyItem(original, 4, -1)
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
it('moves a strategy item downward within the strategy group', () => {
const moved = moveStrategyItem(buildItems(), 4, 1)
expect(moved.map(item => item.preset)).toEqual([
'cache_affinity',
'lru',
'single_account',
'load_balance',
'quota_balanced',
'recent_refresh',
'priority_first',
])
})
it('keeps the original order when a strategy item is already at the bottom boundary', () => {
const original = buildItems()
const moved = moveStrategyItem(original, 6, 1)
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
it('keeps the original order when the target item is not a strategy preset', () => {
const original = buildItems()
const moved = moveStrategyItem(original, 1, 1)
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
})
@@ -0,0 +1,73 @@
export type PoolHealthToggleKey =
| 'health_policy_enabled'
| 'probing_enabled'
| 'auto_remove_banned_keys'
export interface PoolHealthToggleCard {
key: PoolHealthToggleKey
label: string
description: string
}
export interface PoolCooldownFieldLayout {
fields: string[]
desktopColumnsClass: string
}
export interface PoolSecondarySectionLayout {
wrapperClass: string
}
export interface PoolCostFieldLayout {
fields: string[]
desktopColumnsClass: string
}
export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
return [
{
key: 'health_policy_enabled',
label: '健康策略',
description: '按上游错误自动冷却并跳过异常账号。',
},
{
key: 'probing_enabled',
label: '主动探测',
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
},
{
key: 'auto_remove_banned_keys',
label: '异常自动清除',
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
},
]
}
export function buildPoolCooldownFieldLayout(): PoolCooldownFieldLayout {
return {
fields: [
'rate_limit_cooldown_seconds',
'overload_cooldown_seconds',
'sticky_session_ttl_seconds',
'global_priority',
],
desktopColumnsClass: 'xl:grid-cols-4',
}
}
export function buildPoolSecondarySectionLayout(): PoolSecondarySectionLayout {
return {
wrapperClass: 'space-y-4',
}
}
export function buildPoolCostFieldLayout(): PoolCostFieldLayout {
return {
fields: [
'cost_window_seconds',
'cost_limit_per_key_tokens',
'cost_soft_threshold_percent',
],
desktopColumnsClass: 'xl:grid-cols-3',
}
}
@@ -0,0 +1,129 @@
export type PoolManagementStatus = 'all' | 'active' | 'cooldown' | 'inactive'
export interface PoolManagementViewState {
providerId: string | null
search: string
status: PoolManagementStatus
page: number
pageSize: number
}
export interface PoolManagementStateSource {
providerId?: string
search?: string
status?: string
page?: string
pageSize?: string
}
export interface StorageLike {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
export const POOL_MANAGEMENT_VIEW_STORAGE_KEY = 'aether:pool-management:view-state'
export const DEFAULT_POOL_MANAGEMENT_VIEW_STATE: PoolManagementViewState = {
providerId: null,
search: '',
status: 'all',
page: 1,
pageSize: 50,
}
function normalizeProviderId(value: unknown): string | null {
const normalized = String(value ?? '').trim()
return normalized || null
}
function normalizeSearch(value: unknown): string {
return String(value ?? '')
}
function normalizeStatus(value: unknown): PoolManagementStatus {
if (value === 'active' || value === 'cooldown' || value === 'inactive') {
return value
}
return 'all'
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const normalized = Number.parseInt(String(value ?? ''), 10)
if (!Number.isFinite(normalized) || normalized <= 0) {
return fallback
}
return normalized
}
function normalizeViewState(input: Partial<PoolManagementViewState>): PoolManagementViewState {
return {
providerId: normalizeProviderId(input.providerId),
search: normalizeSearch(input.search),
status: normalizeStatus(input.status),
page: normalizePositiveInteger(input.page, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.page),
pageSize: normalizePositiveInteger(input.pageSize, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize),
}
}
function readStoredState(storage?: StorageLike): Partial<PoolManagementViewState> {
if (!storage) return {}
try {
const raw = storage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<PoolManagementViewState> | null
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
export function readPoolManagementViewState(
source: PoolManagementStateSource,
storage?: StorageLike,
): PoolManagementViewState {
const stored = normalizeViewState(readStoredState(storage))
return normalizeViewState({
providerId: source.providerId ?? stored.providerId,
search: source.search ?? stored.search,
status: source.status ?? stored.status,
page: source.page ?? stored.page,
pageSize: source.pageSize ?? stored.pageSize,
})
}
export function writePoolManagementViewState(
state: PoolManagementViewState,
storage?: StorageLike,
): void {
if (!storage) return
try {
storage.setItem(
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
JSON.stringify(normalizeViewState(state)),
)
} catch {
// 忽略存储失败,避免影响主流程。
}
}
export function buildPoolManagementQueryPatch(
state: PoolManagementViewState,
): Record<string, string | undefined> {
const normalized = normalizeViewState(state)
const search = normalized.search.trim()
return {
providerId: normalized.providerId || undefined,
search: search || undefined,
status: normalized.status === 'all' ? undefined : normalized.status,
page: normalized.page <= 1 ? undefined : String(normalized.page),
pageSize:
normalized.pageSize === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize
? undefined
: String(normalized.pageSize),
}
}
@@ -0,0 +1,108 @@
export type PoolMobileTagTone = 'default' | 'muted' | 'warning' | 'danger' | 'accent'
export interface PoolMobileTagItem {
key: string
label: string
tone: PoolMobileTagTone
}
export interface PoolMobileTagInput {
priorityLabel?: string | null
authLabel?: string | null
oauthStatusLabel?: string | null
oauthStatusTone?: PoolMobileTagTone | null
accountStatusLabel?: string | null
accountStatusTone?: PoolMobileTagTone | null
planLabel?: string | null
orgLabel?: string | null
proxyLabel?: string | null
}
export type PoolMobileActionId =
| 'copy_or_download'
| 'refresh_token'
| 'clear_cooldown'
| 'recover_health'
| 'permissions'
| 'proxy'
| 'edit'
| 'toggle'
| 'delete'
export interface PoolMobileActionInput {
canDownloadOrCopy?: boolean
canRefreshToken?: boolean
canClearCooldown?: boolean
canRecoverHealth?: boolean
hasProxy?: boolean
}
function createTagItem(
key: string,
label: string | null | undefined,
tone: PoolMobileTagTone,
): PoolMobileTagItem | null {
if (!label) return null
return { key, label, tone }
}
export function buildPoolMobileTagItems(input: PoolMobileTagInput): PoolMobileTagItem[] {
return [
createTagItem('account', input.accountStatusLabel, input.accountStatusTone ?? 'warning'),
createTagItem('oauth', input.oauthStatusLabel, input.oauthStatusTone ?? 'warning'),
createTagItem('priority', input.priorityLabel, 'muted'),
createTagItem('auth', input.authLabel, 'default'),
createTagItem('plan', input.planLabel, 'accent'),
createTagItem('org', input.orgLabel, 'accent'),
createTagItem('proxy', input.proxyLabel, 'muted'),
].filter((item): item is PoolMobileTagItem => item !== null)
}
export function splitPoolMobileActions(input: PoolMobileActionInput): {
primary: PoolMobileActionId[]
overflow: PoolMobileActionId[]
} {
if (input.canDownloadOrCopy) {
const primary: PoolMobileActionId[] = ['copy_or_download']
if (input.canRefreshToken) {
primary.push('refresh_token')
}
if (input.canClearCooldown) {
primary.push('clear_cooldown')
}
if (input.canRecoverHealth) {
primary.push('recover_health')
}
primary.push('permissions')
if (input.hasProxy) {
primary.push('proxy')
}
primary.push('edit', 'toggle', 'delete')
return {
primary,
overflow: [],
}
}
const primary: PoolMobileActionId[] = []
if (input.canRefreshToken) {
primary.push('refresh_token')
}
if (input.canClearCooldown) {
primary.push('clear_cooldown')
}
if (input.canRecoverHealth) {
primary.push('recover_health')
}
primary.push('permissions')
if (input.hasProxy) {
primary.push('proxy')
}
primary.push('edit', 'toggle', 'delete')
return {
primary,
overflow: [],
}
}
@@ -0,0 +1,35 @@
export interface SchedulingDialogPresetLike {
mutexGroup: string | null
}
export function moveStrategyItem<T extends SchedulingDialogPresetLike>(
items: readonly T[],
itemIndex: number,
direction: -1 | 1,
): T[] {
const strategyIndexes: number[] = []
items.forEach((item, index) => {
if (!item.mutexGroup) {
strategyIndexes.push(index)
}
})
const currentPosition = strategyIndexes.indexOf(itemIndex)
if (currentPosition === -1) {
return [...items]
}
const targetPosition = currentPosition + direction
if (targetPosition < 0 || targetPosition >= strategyIndexes.length) {
return [...items]
}
const sourceIndex = strategyIndexes[currentPosition]
const targetIndex = strategyIndexes[targetPosition]
const nextItems = [...items]
;[nextItems[sourceIndex], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[sourceIndex]]
return nextItems
}