refactor(frontend): extract pool header and theme toggle

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent 9f138d09e6
commit d7411a3104
10 changed files with 620 additions and 499 deletions
@@ -0,0 +1,402 @@
<template>
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 xl:hidden">
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ legacyT('号池管理') }}
</h3>
<p
v-if="metaText"
class="mt-1 text-xs text-muted-foreground"
>
{{ metaText }}
</p>
</div>
<div class="grid grid-cols-3 items-center gap-2">
<Select
v-model="providerModel"
:disabled="providerSelectDisabled"
>
<SelectTrigger
class="h-9 text-xs border-border/60"
:disabled="providerSelectDisabled"
>
<SelectValue :placeholder="legacyT('选择 Provider')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in providers"
:key="item.provider_id"
:value="item.provider_id"
>
{{ item.provider_name }}
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
<span
v-if="!item.pool_enabled"
class="ml-1 text-[10px] text-amber-600"
>{{ legacyT('未启用') }}</span>
</SelectItem>
</SelectContent>
</Select>
<Select v-model="statusModel">
<SelectTrigger class="h-9 w-full text-xs border-border/60">
<SelectValue :placeholder="legacyT('状态')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in statusOptions"
:key="`mobile-${item.value}`"
:value="item.value"
>
{{ legacyT(item.label) }}
</SelectItem>
</SelectContent>
</Select>
<div class="relative min-w-0">
<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="searchModel"
type="text"
:placeholder="legacyT('搜索账号...')"
class="w-full pl-8 pr-3 h-9 text-sm bg-background/50 border-border/60"
/>
</div>
</div>
<div
v-if="hasSelectedProvider"
class="flex items-center gap-1"
>
<div
v-for="action in mobileActions"
:key="action.key"
class="min-w-0 flex-1 flex justify-center"
>
<Button
v-if="action.key !== 'providerProxy' && action.key !== 'refresh'"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="action.key === 'toggleProvider' ? providerToggleButtonClass : ''"
:disabled="action.key === 'toggleProvider' ? togglingProviderStatus : false"
:title="action.title"
@click="emit(action.event)"
>
<component
:is="action.icon"
class="w-3.5 h-3.5"
/>
</Button>
<ProviderProxyPopover
v-else-if="action.key === 'providerProxy'"
:open="providerProxyMobileOpen"
:node-id="providerProxyNodeId"
:saving="savingProviderProxy"
:title="providerProxyButtonTitle"
@update:open="emit('update:providerProxyMobileOpen', $event)"
@select="emit('selectProviderProxy', $event)"
@clear="emit('clearProviderProxy')"
/>
<RefreshButton
v-else
:loading="refreshLoading"
:title="refreshTitle"
@click="emit('refresh')"
/>
</div>
</div>
</div>
<div class="hidden xl:flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<h3 class="text-base font-semibold">
{{ legacyT('号池管理') }}
<span
v-if="metaText"
class="ml-2 text-xs font-normal text-muted-foreground"
>
| {{ metaText }}
</span>
</h3>
</div>
<div
class="flex items-center gap-2"
data-testid="pool-header-actions"
>
<Select
v-model="providerModel"
:disabled="providerSelectDisabled"
>
<SelectTrigger
class="w-36 h-8 text-xs border-border/60"
:disabled="providerSelectDisabled"
>
<SelectValue :placeholder="legacyT('选择 Provider')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in providers"
:key="item.provider_id"
:value="item.provider_id"
>
{{ item.provider_name }}
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
<span
v-if="!item.pool_enabled"
class="ml-1 text-[10px] text-amber-600"
>{{ legacyT('未启用') }}</span>
</SelectItem>
</SelectContent>
</Select>
<div class="h-4 w-px bg-border" />
<div
v-if="hasSelectedProvider"
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="searchModel"
type="text"
:placeholder="legacyT('搜索账号...')"
class="w-40 pl-8 pr-2 h-8 text-xs bg-background/50 border-border/60"
/>
</div>
<div
v-if="hasSelectedProvider"
class="h-4 w-px bg-border"
/>
<button
v-if="hasSelectedProvider"
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
type="button"
:title="legacyT('点击调整号池调度')"
@click="emit('scheduling')"
>
<span class="text-muted-foreground/80 hidden lg:inline">{{ legacyT('调度:') }}</span>
<span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button>
<div
v-if="hasSelectedProvider"
class="h-4 w-px bg-border"
/>
<Button
v-if="hasSelectedProvider"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('添加账号')"
@click="emit('import')"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<ProviderProxyPopover
v-if="hasSelectedProvider"
:open="providerProxyDesktopOpen"
:node-id="providerProxyNodeId"
:saving="savingProviderProxy"
:title="providerProxyButtonTitle"
@update:open="emit('update:providerProxyDesktopOpen', $event)"
@select="emit('selectProviderProxy', $event)"
@clear="emit('clearProviderProxy')"
/>
<Button
v-for="action in desktopPostProxyActions"
:key="action.key"
v-show="hasSelectedProvider"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="action.key === 'toggleProvider' ? providerToggleButtonClass : ''"
:disabled="action.key === 'toggleProvider' ? togglingProviderStatus : false"
:data-testid="action.key === 'demandMetrics' ? 'pool-demand-metrics-button' : undefined"
:title="action.title"
@click="emit(action.event)"
>
<component
:is="action.icon"
class="w-3.5 h-3.5"
/>
</Button>
<RefreshButton
:loading="refreshLoading"
:title="refreshTitle"
@click="emit('refresh')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Activity,
ChevronDown,
Edit,
Plug,
Power,
Search,
Settings2,
SlidersHorizontal,
Upload,
Users,
} from 'lucide-vue-next'
import {
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.vue'
import { useI18n } from '@/i18n'
import type { PoolOverviewItem } from '@/api/endpoints/pool'
type HeaderActionEvent =
| 'import'
| 'scheduling'
| 'accountBatch'
| 'editProvider'
| 'editEndpoint'
| 'demandMetrics'
| 'advanced'
| 'toggleProvider'
type HeaderActionKey =
| 'import'
| 'providerProxy'
| 'scheduling'
| 'accountBatch'
| 'editProvider'
| 'editEndpoint'
| 'demandMetrics'
| 'advanced'
| 'toggleProvider'
| 'refresh'
interface HeaderAction {
key: HeaderActionKey
title: string
event: HeaderActionEvent
icon: unknown
}
const props = withDefaults(defineProps<{
providers: PoolOverviewItem[]
providerId: string
providerSelectDisabled: boolean
status: string
statusOptions: Array<{ value: string, label: string }>
search: string
metaText?: string
providerProxyNodeId?: string | null
providerProxyMobileOpen: boolean
providerProxyDesktopOpen: boolean
providerProxyButtonTitle: string
savingProviderProxy: boolean
poolSchedulingLabel: string
showAdaptiveHotPoolMetricsButton: boolean
providerToggleButtonTitle: string
providerToggleButtonClass?: string
togglingProviderStatus: boolean
refreshLoading: boolean
refreshTitle: string
}>(), {
metaText: '',
providerProxyNodeId: null,
providerToggleButtonClass: '',
})
const emit = defineEmits<{
'update:providerId': [value: string]
'update:status': [value: string]
'update:search': [value: string]
'update:providerProxyMobileOpen': [value: boolean]
'update:providerProxyDesktopOpen': [value: boolean]
import: []
scheduling: []
accountBatch: []
editProvider: []
editEndpoint: []
demandMetrics: []
advanced: []
toggleProvider: []
refresh: []
selectProviderProxy: [nodeId: string]
clearProviderProxy: []
}>()
const { legacyT } = useI18n()
const providerModel = computed({
get: () => props.providerId,
set: value => emit('update:providerId', value),
})
const statusModel = computed({
get: () => props.status,
set: value => emit('update:status', value),
})
const searchModel = computed({
get: () => props.search,
set: value => emit('update:search', value),
})
const hasSelectedProvider = computed(() => Boolean(props.providerId))
const mobileActions = computed(() => {
const actions: Array<HeaderAction | { key: 'providerProxy' | 'refresh' }> = [
{ key: 'import', title: legacyT('添加账号'), event: 'import', icon: Upload },
{ key: 'providerProxy' },
{ key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal },
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit },
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
]
if (props.showAdaptiveHotPoolMetricsButton) {
actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity })
}
actions.push(
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
{ key: 'refresh' },
)
return actions
})
const desktopPostProxyActions = computed<HeaderAction[]>(() => {
const actions: HeaderAction[] = [
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit },
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
]
if (props.showAdaptiveHotPoolMetricsButton) {
actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity })
}
actions.push(
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
{ key: 'accountBatch', title: legacyT('账号'), event: 'accountBatch', icon: Users },
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
)
return actions
})
</script>
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue'
import { createI18n } from '@/i18n'
import type { PoolOverviewItem } from '@/api/endpoints/pool'
const provider = {
provider_id: 'provider-1',
provider_name: 'Codex Provider',
provider_type: 'codex',
pool_enabled: true,
total_keys: 2,
} as PoolOverviewItem
describe('PoolManagementHeader', () => {
it('keeps page actions wired through component events', () => {
const events: string[] = []
const Probe = defineComponent({
setup() {
return () => h(PoolManagementHeader, {
providers: [provider],
providerId: 'provider-1',
providerSelectDisabled: false,
status: 'all',
statusOptions: [{ value: 'all', label: '全部状态' }],
search: '',
metaText: 'codex | 启用',
providerProxyNodeId: null,
providerProxyMobileOpen: false,
providerProxyDesktopOpen: false,
providerProxyButtonTitle: '提供商代理(未设置)',
savingProviderProxy: false,
poolSchedulingLabel: '2 维度',
showAdaptiveHotPoolMetricsButton: true,
providerToggleButtonTitle: '当前状态:已启用,点击禁用提供商',
togglingProviderStatus: false,
refreshLoading: false,
refreshTitle: '刷新',
onImport: () => events.push('import'),
onScheduling: () => events.push('scheduling'),
onDemandMetrics: () => events.push('demandMetrics'),
onRefresh: () => events.push('refresh'),
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Probe)
app.use(createI18n())
app.mount(root)
root.querySelector<HTMLButtonElement>('[title="添加账号"]')?.click()
root.querySelector<HTMLButtonElement>('[title="点击调整号池调度"]')?.click()
root.querySelector<HTMLButtonElement>('[title="查看自适应热池指标"]')?.click()
root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click()
expect(events).toEqual(['import', 'scheduling', 'demandMetrics', 'refresh'])
expect(root.textContent).toContain('2 维度')
app.unmount()
root.remove()
})
})