mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
refactor(frontend): extract pool header and theme toggle
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user