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,54 @@
<template>
<button
type="button"
:class="buttonClass"
:title="title"
:aria-label="title"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
:class="iconClass"
/>
<SunMedium
v-else-if="themeMode === 'light'"
:class="iconClass"
/>
<Moon
v-else
:class="iconClass"
/>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Moon, SunMedium, SunMoon } from 'lucide-vue-next'
import { useDarkMode } from '@/composables/useDarkMode'
import { useI18n } from '@/i18n'
const props = withDefaults(defineProps<{
size?: 'sm' | 'md'
class?: string
}>(), {
size: 'md',
class: '',
})
const { themeMode, toggleDarkMode } = useDarkMode()
const { t } = useI18n()
const title = computed(() => {
if (themeMode.value === 'system') return t('theme.system')
if (themeMode.value === 'dark') return t('theme.dark')
return t('theme.light')
})
const buttonClass = computed(() => [
'flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition',
props.size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
props.class,
].filter(Boolean).join(' '))
const iconClass = computed(() => props.size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4')
</script>
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import ThemeModeButton from '@/components/common/ThemeModeButton.vue'
import { useDarkMode } from '@/composables/useDarkMode'
import { createI18n } from '@/i18n'
describe('ThemeModeButton', () => {
it('cycles the shared theme mode from the single button entry point', async () => {
const { setThemeMode } = useDarkMode()
setThemeMode('system')
await nextTick()
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(ThemeModeButton)
app.use(createI18n())
app.mount(root)
const button = root.querySelector('button')
expect(button).toBeTruthy()
expect(button?.getAttribute('title')).toBe('跟随系统')
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(button?.getAttribute('title')).toBe('浅色模式')
expect(localStorage.getItem('theme')).toBe('light')
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(button?.getAttribute('title')).toBe('深色模式')
expect(localStorage.getItem('theme')).toBe('dark')
app.unmount()
root.remove()
})
})
+1
View File
@@ -8,6 +8,7 @@ export { default as EmptyState } from './EmptyState.vue'
export { default as AlertDialog } from './AlertDialog.vue' export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue' export { default as LoadingState } from './LoadingState.vue'
export { default as StripePaymentDialog } from './StripePaymentDialog.vue' export { default as StripePaymentDialog } from './StripePaymentDialog.vue'
export { default as ThemeModeButton } from './ThemeModeButton.vue'
// 表单组件 // 表单组件
export { default as MultiSelect } from './MultiSelect.vue' export { default as MultiSelect } from './MultiSelect.vue'
@@ -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()
})
})
+3 -47
View File
@@ -132,24 +132,7 @@
@rollback="handleRollback" @rollback="handleRollback"
/> />
<LanguageSwitcher /> <LanguageSwitcher />
<button <ThemeModeButton />
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeModeTitle"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<SunMedium
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="h-4 w-4"
/>
</button>
<button <button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition" class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
@click="mobileMenuOpen = !mobileMenuOpen" @click="mobileMenuOpen = !mobileMenuOpen"
@@ -329,24 +312,7 @@
/> />
<LanguageSwitcher /> <LanguageSwitcher />
<!-- Theme Toggle --> <!-- Theme Toggle -->
<button <ThemeModeButton />
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeModeTitle"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<SunMedium
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="h-4 w-4"
/>
</button>
<!-- GitHub Link --> <!-- GitHub Link -->
<a <a
href="https://github.com/fawney19/Aether" href="https://github.com/fawney19/Aether"
@@ -436,7 +402,6 @@ import { useRoute, useRouter } from 'vue-router'
import { marked } from 'marked' import { marked } from 'marked'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules' import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { isDemoMode } from '@/config/demo' import { isDemoMode } from '@/config/demo'
@@ -449,16 +414,14 @@ import AppShell from '@/components/layout/AppShell.vue'
import SidebarNav from '@/components/layout/SidebarNav.vue' import SidebarNav from '@/components/layout/SidebarNav.vue'
import HeaderLogo from '@/components/HeaderLogo.vue' import HeaderLogo from '@/components/HeaderLogo.vue'
import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue' import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue'
import ThemeModeButton from '@/components/common/ThemeModeButton.vue'
import UpdateDialog from '@/components/common/UpdateDialog.vue' import UpdateDialog from '@/components/common/UpdateDialog.vue'
import VersionButton from '@/components/common/VersionButton.vue' import VersionButton from '@/components/common/VersionButton.vue'
import { buildUpdateErrorStatus } from '@/utils/updateStatus' import { buildUpdateErrorStatus } from '@/utils/updateStatus'
import { import {
Settings, Settings,
AlertTriangle, AlertTriangle,
SunMedium,
Moon,
LogOut, LogOut,
SunMoon,
ChevronRight, ChevronRight,
Menu, Menu,
X, X,
@@ -476,7 +439,6 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const moduleStore = useModuleStore() const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo() const { siteName, siteSubtitle } = useSiteInfo()
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { t, locale } = useI18n() const { t, locale } = useI18n()
@@ -537,12 +499,6 @@ const updateDialogReleaseLinkLabel = computed(() => {
if (updateDialogMode.value === 'selected') return t('update.link.tag') if (updateDialogMode.value === 'selected') return t('update.link.tag')
return updateSupported.value ? t('update.link.update') : t('update.link.release') return updateSupported.value ? t('update.link.update') : t('update.link.release')
}) })
const themeModeTitle = computed(() => {
if (themeMode.value === 'system') return t('theme.system')
if (themeMode.value === 'dark') return t('theme.dark')
return t('theme.light')
})
watch(systemUpdatePhase, (val) => { watch(systemUpdatePhase, (val) => {
setSessionStorageItem('aether_update_phase', val) setSessionStorageItem('aether_update_phase', val)
}) })
+14
View File
@@ -44,6 +44,20 @@ function installStorage(name: 'localStorage' | 'sessionStorage') {
installStorage('localStorage') installStorage('localStorage')
installStorage('sessionStorage') installStorage('sessionStorage')
if (typeof globalThis.requestAnimationFrame !== 'function') {
Object.defineProperty(globalThis, 'requestAnimationFrame', {
value: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 16),
configurable: true,
})
}
if (typeof globalThis.cancelAnimationFrame !== 'function') {
Object.defineProperty(globalThis, 'cancelAnimationFrame', {
value: (handle: number) => window.clearTimeout(handle),
configurable: true,
})
}
beforeEach(async () => { beforeEach(async () => {
localStorage.clear() localStorage.clear()
sessionStorage.clear() sessionStorage.clear()
+35 -366
View File
@@ -4,356 +4,40 @@
variant="default" variant="default"
class="overflow-hidden" class="overflow-hidden"
> >
<!-- Header --> <PoolManagementHeader
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60"> v-model:provider-id="selectedProviderIdProxy"
<!-- Mobile --> v-model:status="statusFilter"
<div class="flex flex-col gap-3 xl:hidden"> v-model:search="searchQuery"
<div class="min-w-0"> :providers="poolProviders"
<h3 class="text-base font-semibold"> :provider-select-disabled="providerSelectDisabled"
号池管理 :status-options="poolKeyStatusFilterOptions"
</h3> :meta-text="poolHeaderMetaText"
<p :provider-proxy-node-id="selectedProviderData?.proxy?.node_id"
v-if="poolHeaderMetaText" :provider-proxy-mobile-open="providerProxyMobilePopoverOpen"
class="mt-1 text-xs text-muted-foreground" :provider-proxy-desktop-open="providerProxyDesktopPopoverOpen"
> :provider-proxy-button-title="getProviderProxyButtonTitle()"
{{ poolHeaderMetaText }} :saving-provider-proxy="savingProviderProxy"
</p> :pool-scheduling-label="poolSchedulingLabel"
</div> :show-adaptive-hot-pool-metrics-button="showAdaptiveHotPoolMetricsButton"
<div :provider-toggle-button-title="getProviderToggleButtonTitle()"
class="grid grid-cols-3 items-center gap-2" :provider-toggle-button-class="getProviderToggleButtonClass()"
> :toggling-provider-status="togglingProviderStatus"
<Select :refresh-loading="refreshCurrentPageLoading"
v-model="selectedProviderIdProxy" :refresh-title="refreshButtonTitle"
:disabled="providerSelectDisabled" @import="showImportDialog = true"
> @scheduling="openSchedulingDialog"
<SelectTrigger @account-batch="showAccountBatchDialog = true"
class="h-9 text-xs border-border/60" @edit-provider="openProviderEditDialog"
:disabled="providerSelectDisabled" @edit-endpoint="openEndpointEditDialog"
> @demand-metrics="showDemandMetricsDialog = true"
<SelectValue placeholder="选择 Provider" /> @advanced="showAdvancedDialog = true"
</SelectTrigger> @toggle-provider="toggleSelectedProviderStatus"
<SelectContent> @refresh="refreshCurrentPage"
<SelectItem @update:provider-proxy-mobile-open="handleProviderProxyPopoverToggle('mobile', $event)"
v-for="item in poolProviders" @update:provider-proxy-desktop-open="handleProviderProxyPopoverToggle('desktop', $event)"
:key="item.provider_id" @select-provider-proxy="setProviderProxy"
:value="item.provider_id" @clear-provider-proxy="clearProviderProxy"
> />
{{ 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"
>未启用</span>
</SelectItem>
</SelectContent>
</Select>
<Select v-model="statusFilter">
<SelectTrigger class="h-9 w-full text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in poolKeyStatusFilterOptions"
:key="`mobile-${item.value}`"
:value="item.value"
>
{{ 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="searchQuery"
type="text"
placeholder="搜索账号..."
class="w-full pl-8 pr-3 h-9 text-sm bg-background/50 border-border/60"
/>
</div>
</div>
<div
v-if="selectedProviderId"
class="flex items-center gap-1"
>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
title="添加账号"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<ProviderProxyPopover
:open="providerProxyMobilePopoverOpen"
:node-id="selectedProviderData?.proxy?.node_id"
:saving="savingProviderProxy"
:title="getProviderProxyButtonTitle()"
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('mobile', open)"
@select="setProviderProxy"
@clear="clearProviderProxy"
/>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
title="号池调度"
@click="openSchedulingDialog()"
>
<SlidersHorizontal class="w-3.5 h-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
title="账号批量操作"
@click="showAccountBatchDialog = true"
>
<Users class="w-3.5 h-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
title="编辑提供商"
@click="openProviderEditDialog"
>
<Edit class="w-3.5 h-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
title="编辑端点"
@click="openEndpointEditDialog"
>
<Plug class="w-3.5 h-3.5" />
</Button>
</div>
<div
v-if="showAdaptiveHotPoolMetricsButton"
class="min-w-0 flex-1 flex justify-center"
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
data-testid="pool-demand-metrics-button"
title="查看自适应热池指标"
@click="showDemandMetricsDialog = true"
>
<Activity class="w-3.5 h-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
title="高级设置"
@click="showAdvancedDialog = true"
>
<Settings2 class="w-3.5 h-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="getProviderToggleButtonClass()"
:disabled="togglingProviderStatus"
:title="getProviderToggleButtonTitle()"
@click="toggleSelectedProviderStatus"
>
<Power class="w-3.5 h-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<RefreshButton
:loading="refreshCurrentPageLoading"
:title="refreshButtonTitle"
@click="refreshCurrentPage"
/>
</div>
</div>
</div>
<!-- Desktop -->
<div class="hidden xl:flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<h3 class="text-base font-semibold">
号池管理
<span
v-if="poolHeaderMetaText"
class="ml-2 text-xs font-normal text-muted-foreground"
>
| {{ poolHeaderMetaText }}
</span>
</h3>
</div>
<div
class="flex items-center gap-2"
data-testid="pool-header-actions"
>
<Select
v-model="selectedProviderIdProxy"
:disabled="providerSelectDisabled"
>
<SelectTrigger
class="w-36 h-8 text-xs border-border/60"
:disabled="providerSelectDisabled"
>
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in poolProviders"
: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"
>未启用</span>
</SelectItem>
</SelectContent>
</Select>
<div class="h-4 w-px bg-border" />
<div
v-if="selectedProviderId"
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-40 pl-8 pr-2 h-8 text-xs bg-background/50 border-border/60"
/>
</div>
<div
v-if="selectedProviderId"
class="h-4 w-px bg-border"
/>
<button
v-if="selectedProviderId"
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"
title="点击调整号池调度"
@click="openSchedulingDialog()"
>
<span class="text-muted-foreground/80 hidden lg:inline">调度:</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="selectedProviderId"
class="h-4 w-px bg-border"
/>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="添加账号"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<ProviderProxyPopover
v-if="selectedProviderId"
:open="providerProxyDesktopPopoverOpen"
:node-id="selectedProviderData?.proxy?.node_id"
:saving="savingProviderProxy"
:title="getProviderProxyButtonTitle()"
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('desktop', open)"
@select="setProviderProxy"
@clear="clearProviderProxy"
/>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑提供商"
@click="openProviderEditDialog"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑端点"
@click="openEndpointEditDialog"
>
<Plug class="w-3.5 h-3.5" />
</Button>
<Button
v-if="showAdaptiveHotPoolMetricsButton"
variant="ghost"
size="icon"
class="h-8 w-8"
data-testid="pool-demand-metrics-button"
title="查看自适应热池指标"
@click="showDemandMetricsDialog = true"
>
<Activity class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="高级设置"
@click="showAdvancedDialog = true"
>
<Settings2 class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="账号"
@click="showAccountBatchDialog = true"
>
<Users class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="getProviderToggleButtonClass()"
:disabled="togglingProviderStatus"
:title="getProviderToggleButtonTitle()"
@click="toggleSelectedProviderStatus"
>
<Power class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="refreshCurrentPageLoading"
:title="refreshButtonTitle"
@click="refreshCurrentPage"
/>
</div>
</div>
</div>
<!-- Loading (initial) --> <!-- Loading (initial) -->
<div <div
@@ -1489,11 +1173,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { import {
Search,
Upload, Upload,
ChevronDown,
RefreshCw, RefreshCw,
Activity,
Power, Power,
Database, Database,
KeyRound, KeyRound,
@@ -1505,24 +1186,13 @@ import {
RotateCcw, RotateCcw,
SquarePen, SquarePen,
Trash2, Trash2,
Users,
Settings2,
SlidersHorizontal,
CircleHelp, CircleHelp,
Edit,
Plug,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
Card, Card,
Badge, Badge,
Button, Button,
Input,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Table, Table,
TableHeader, TableHeader,
TableBody, TableBody,
@@ -1536,7 +1206,6 @@ import {
PopoverTrigger, PopoverTrigger,
PopoverContent, PopoverContent,
} from '@/components/ui' } from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer' import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
@@ -1578,7 +1247,7 @@ import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialo
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue' import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
import PoolDemandMetricsDialog from '@/features/pool/components/PoolDemandMetricsDialog.vue' import PoolDemandMetricsDialog from '@/features/pool/components/PoolDemandMetricsDialog.vue'
import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue' import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue'
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.vue' import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue'
import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue' import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue'
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue' import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue' import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
+4 -40
View File
@@ -56,24 +56,7 @@
> >
{{ t('site.home.login') }} {{ t('site.home.login') }}
</button> </button>
<button <ThemeModeButton size="sm" />
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeMode === 'system' ? t('theme.system') : themeMode === 'dark' ? t('theme.dark') : t('theme.light')"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-3.5 w-3.5"
/>
<Sun
v-else-if="themeMode === 'light'"
class="h-3.5 w-3.5"
/>
<Moon
v-else
class="h-3.5 w-3.5"
/>
</button>
<LanguageSwitcher /> <LanguageSwitcher />
<a <a
href="https://github.com/fawney19/Aether" href="https://github.com/fawney19/Aether"
@@ -168,24 +151,7 @@
<!-- Right: Theme Toggle + GitHub Icons --> <!-- Right: Theme Toggle + GitHub Icons -->
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<button <ThemeModeButton />
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeMode === 'system' ? t('theme.system') : themeMode === 'dark' ? t('theme.dark') : t('theme.light')"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<Sun
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="h-4 w-4"
/>
</button>
<LanguageSwitcher /> <LanguageSwitcher />
<a <a
href="https://github.com/fawney19/Aether" href="https://github.com/fawney19/Aether"
@@ -460,11 +426,8 @@ import { RouterLink } from 'vue-router'
import { import {
ChevronDown, ChevronDown,
Code2, Code2,
Moon,
Rocket, Rocket,
Sparkles, Sparkles,
Sun,
SunMoon,
Terminal Terminal
} from 'lucide-vue-next' } from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue' import GithubIcon from '@/components/icons/GithubIcon.vue'
@@ -473,6 +436,7 @@ import { useDarkMode } from '@/composables/useDarkMode'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue' import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue'
import ThemeModeButton from '@/components/common/ThemeModeButton.vue'
import LoginDialog from '@/features/auth/components/LoginDialog.vue' import LoginDialog from '@/features/auth/components/LoginDialog.vue'
import RippleLogo from '@/components/RippleLogo.vue' import RippleLogo from '@/components/RippleLogo.vue'
import HeaderLogo from '@/components/HeaderLogo.vue' import HeaderLogo from '@/components/HeaderLogo.vue'
@@ -496,7 +460,7 @@ import {
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
const authStore = useAuthStore() const authStore = useAuthStore()
const { isDark, themeMode, toggleDarkMode } = useDarkMode() const { isDark } = useDarkMode()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const { siteName, siteSubtitle } = useSiteInfo() const { siteName, siteSubtitle } = useSiteInfo()
const { t } = useI18n() const { t } = useI18n()
@@ -125,24 +125,7 @@
</RouterLink> </RouterLink>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <ThemeModeButton />
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeModeTitle"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<Sun
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="h-4 w-4"
/>
</button>
<LanguageSwitcher /> <LanguageSwitcher />
<button <button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition" class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
@@ -243,24 +226,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <ThemeModeButton />
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeModeTitle"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<Sun
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="h-4 w-4"
/>
</button>
<a <a
href="https://github.com/fawney19/Aether" href="https://github.com/fawney19/Aether"
target="_blank" target="_blank"
@@ -327,23 +293,19 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router' import { RouterLink, RouterView, useRoute } from 'vue-router'
import { import {
Menu, Menu,
Moon,
Sun,
SunMoon,
ChevronRight, ChevronRight,
X X
} from 'lucide-vue-next' } from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue' import GithubIcon from '@/components/icons/GithubIcon.vue'
import HeaderLogo from '@/components/HeaderLogo.vue' import HeaderLogo from '@/components/HeaderLogo.vue'
import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue' import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue'
import ThemeModeButton from '@/components/common/ThemeModeButton.vue'
import AppShell from '@/components/layout/AppShell.vue' import AppShell from '@/components/layout/AppShell.vue'
import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { guideNavItems } from './guide-config' import { guideNavItems } from './guide-config'
const route = useRoute() const route = useRoute()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo() const { siteName, siteSubtitle } = useSiteInfo()
const { t } = useI18n() const { t } = useI18n()
@@ -352,11 +314,6 @@ const baseUrl = ref(typeof window !== 'undefined' ? window.location.origin : 'ht
const activeHash = ref('') const activeHash = ref('')
const lightboxSrc = ref('') const lightboxSrc = ref('')
const lightboxAlt = ref('') const lightboxAlt = ref('')
const themeModeTitle = computed(() => {
if (themeMode.value === 'system') return t('theme.system')
if (themeMode.value === 'dark') return t('theme.dark')
return t('theme.light')
})
const resolvedGuideNavItems = computed(() => guideNavItems.map(item => ({ const resolvedGuideNavItems = computed(() => guideNavItems.map(item => ({
...item, ...item,
name: t(item.nameKey), name: t(item.nameKey),