Files
codex-proxy-rs/frontend/src/layout/components/AppSidebar.vue
T
2026-11-07 14:23:49 +08:00

680 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { PluginManagementView } from '@/api'
import { ZIconButton, ZMotionIcon, ZPopover, ZScrollbar } from '@codex-proxy/ui'
import {
ArrowUpCircle,
Blocks,
BringToFront,
ChartNoAxesColumn,
ChevronDown,
FolderTree,
KeyRound,
LayoutDashboard,
LogOut,
Moon,
Network,
Palette,
PanelLeftClose,
PanelLeftOpen,
Puzzle,
Settings,
Sun,
Users,
} from '@lucide/vue'
import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { gsap } from 'gsap'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AppBrandMark from '@/components/AppBrandMark.vue'
import AppLanguageMenu from '@/components/AppLanguageMenu.vue'
import { useAuthStore } from '@/stores/modules/auth'
import { usePluginViewsStore } from '@/stores/modules/plugin-views'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useThemeStore } from '@/stores/modules/theme'
import { pluginPageLocation, shortPluginInstanceId } from '@/utils/plugin'
const props = withDefaults(
defineProps<{
collapsed?: boolean
mobile?: boolean
}>(),
{
collapsed: false,
mobile: false,
},
)
const emit = defineEmits<{
close: []
navigate: []
openSystemUpdate: []
toggle: []
}>()
const { t } = useI18n({ useScope: 'global' })
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const pluginViewsStore = usePluginViewsStore()
const systemUpdateStore = useSystemUpdateStore()
const themeStore = useThemeStore()
const { pageViews: pluginPageViews } = storeToRefs(pluginViewsStore)
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
const { effectiveTheme } = storeToRefs(themeStore)
const { toggleTheme } = themeStore
const preferredMotion = usePreferredReducedMotion()
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
const pluginMenuId = useId()
const pluginGroupExpanded = shallowRef(route.path.startsWith('/plugins'))
const navItems = computed(() => ([
{
label: t('navigation.overview'),
icon: LayoutDashboard,
path: '/',
},
{
label: t('navigation.accounts'),
icon: Users,
path: '/accounts',
},
{
label: t('navigation.proxies'),
icon: Network,
path: '/proxies',
},
{
label: t('navigation.groups'),
icon: FolderTree,
path: '/groups',
},
{
label: t('navigation.apiKeys'),
icon: KeyRound,
path: '/keys',
},
{
label: t('navigation.usage'),
icon: ChartNoAxesColumn,
path: '/usage',
},
{
label: t('navigation.plugins'),
icon: Puzzle,
path: '/plugins',
},
{
label: t('navigation.appearance'),
icon: Palette,
path: '/theme',
},
{
label: t('navigation.settings'),
icon: Settings,
path: '/settings',
},
]))
const pluginNavIndex = navItems.value.findIndex(item => item.path === '/plugins')
const pluginMenuVisible = computed(() => pluginGroupExpanded.value && !isCollapsed.value)
const pluginViewNameCounts = computed(() => pluginPageViews.value.reduce((counts, view) => {
const title = view.pages[0]?.title ?? view.name
counts.set(title, (counts.get(title) ?? 0) + 1)
return counts
}, new Map<string, number>()))
const pluginChildrenHeight = computed(() => pluginMenuVisible.value
? (pluginPageViews.value.length + 1) * 44
: 0)
function isActive(path: string) {
if (path === '/')
return route.path === '/'
return route.path.startsWith(path)
}
const activeNavIndex = computed(() => {
const index = navItems.value.findIndex(item => isActive(item.path))
return Math.max(0, index)
})
const activePluginIndex = computed(() => route.name === 'plugins'
? 0
: pluginPageViews.value.findIndex(isPluginViewActive) + 1)
const activeNavIndicatorStyle = computed(() => {
const inPluginMenu = isActive('/plugins') && pluginMenuVisible.value
const offset = inPluginMenu
? 50 + activePluginIndex.value * 44
: activeNavIndex.value > pluginNavIndex
? pluginChildrenHeight.value
: 0
// 主菜单与子菜单共享选中框,位置和尺寸在同一次过渡中变化
return {
transform: `translate3d(${inPluginMenu ? 24 : 0}px, ${activeNavIndex.value * 58 + offset}px, 0)`,
width: inPluginMenu ? 'calc(100% - 24px)' : '100%',
height: inPluginMenu ? '40px' : '46px',
}
})
const navFeedbackMuted = shallowRef(false)
const { start: restoreNavFeedback, stop: stopNavFeedbackRestore } = useTimeoutFn(
() => {
navFeedbackMuted.value = false
},
300,
{ immediate: false },
)
function muteNavFeedbackDuringMove() {
navFeedbackMuted.value = true
stopNavFeedbackRestore()
restoreNavFeedback()
}
function navigate(path: string) {
muteNavFeedbackDuringMove()
void router.push(path)
emit('navigate')
}
function togglePluginGroup() {
if (isCollapsed.value) {
navigate('/plugins')
return
}
pluginGroupExpanded.value = !pluginGroupExpanded.value
}
function navigatePluginView(view: PluginManagementView) {
const page = view.pages[0]
if (page)
navigate(router.resolve(pluginPageLocation(view, page)).fullPath)
}
function isPluginViewActive(view: PluginManagementView) {
const value = route.params.instanceId
const routeInstanceId = Array.isArray(value)
? value[0]
: value
return route.name === 'plugin-page' && routeInstanceId === view.target.instanceId
}
function pluginViewLabel(view: PluginManagementView) {
const title = view.pages[0]?.title ?? view.name
if (pluginViewNameCounts.value.get(title) === 1)
return title
const duplicateName = pluginPageViews.value.some(item => item !== view && item.name === view.name && item.pages[0]?.title === title)
return `${title} · ${duplicateName
? shortPluginInstanceId(view.target.instanceId)
: view.name}`
}
function openSystemUpdate() {
emit('openSystemUpdate')
}
async function handleLogout() {
if (!await authStore.logout())
return
await router.push('/login')
emit('navigate')
}
const sidebarEl = ref<HTMLElement | null>(null)
const brandLabelEl = ref<HTMLElement | null>(null)
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
const collapsedSidebarWidth = 88
const expandedSidebarWidth = 251
const sidebarWidth = computed(() => (isCollapsed.value ? collapsedSidebarWidth : expandedSidebarWidth))
const brandLabelVisible = shallowRef(!isCollapsed.value)
const themeToggleLabel = computed(() => (effectiveTheme.value === 'dark'
? t('appearance.switchToLight')
: t('appearance.switchToDark')))
const versionText = computed(() => version.value?.version.trim() ?? '')
const hasVersionLabel = computed(() => versionText.value.length > 0)
const versionLabel = computed(() => `v${versionText.value}`)
const updateButtonLabel = computed(() => (hasUpdate.value
? t('navigation.update.available')
: t('navigation.update.open')))
function prefersReducedMotion() {
return preferredMotion.value === 'reduce'
}
function animateSidebarLabels(collapsed: boolean) {
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (!labels?.length) {
return
}
if (prefersReducedMotion()) {
gsap.set(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed
? -6
: 0,
})
return
}
gsap.to(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed
? -6
: 0,
duration: collapsed ? 0.16 : 0.2,
ease: collapsed ? 'power2.in' : 'power3.out',
stagger: collapsed ? 0 : 0.018,
overwrite: true,
})
}
function hideBrandLabel() {
const label = brandLabelEl.value
if (label) {
gsap.killTweensOf(label)
gsap.set(label, {
opacity: 0,
x: -6,
})
}
brandLabelVisible.value = false
}
function animateBrandLabelEnter() {
const label = brandLabelEl.value
if (!label) {
return
}
if (prefersReducedMotion()) {
gsap.set(label, {
opacity: 1,
x: 0,
})
return
}
gsap.fromTo(
label,
{
opacity: 0,
x: -6,
},
{
opacity: 1,
x: 0,
duration: 0.2,
ease: 'power3.out',
overwrite: true,
},
)
}
function animateSidebarWidth(collapsed: boolean) {
if (!sidebarEl.value) {
return
}
const targetWidth = collapsed ? collapsedSidebarWidth : expandedSidebarWidth
const currentWidth = sidebarEl.value.getBoundingClientRect().width
if (prefersReducedMotion()) {
gsap.set(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
})
return
}
gsap.set(sidebarEl.value, {
width: currentWidth,
flexBasis: currentWidth,
})
gsap.to(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
duration: 0.34,
ease: 'power3.out',
overwrite: true,
})
}
function animateNavSignal() {
const signal = navSignalEl.value
if (!signal)
return
gsap.killTweensOf(signal)
if (prefersReducedMotion()) {
gsap.set(signal, { opacity: 0, xPercent: -70 })
return
}
gsap.fromTo(
signal,
{ opacity: 0.48, xPercent: -70 },
{
opacity: 0,
xPercent: 120,
duration: 0.52,
ease: 'power2.out',
overwrite: true,
},
)
}
onMounted(() => {
void pluginViewsStore.ensureLoaded().catch(() => undefined)
gsap.set(sidebarEl.value, {
width: sidebarWidth.value,
flexBasis: sidebarWidth.value,
})
if (isCollapsed.value) {
hideBrandLabel()
}
else {
gsap.set(brandLabelEl.value, {
opacity: 1,
x: 0,
})
}
animateSidebarLabels(isCollapsed.value)
gsap.set(navSignalEl.value, { opacity: 0, xPercent: -70 })
})
watch(
() => isCollapsed.value,
async (collapsed) => {
if (collapsed) {
hideBrandLabel()
}
else {
brandLabelVisible.value = true
}
animateSidebarLabels(Boolean(collapsed))
animateSidebarWidth(Boolean(collapsed))
await nextTick()
if (!collapsed) {
animateBrandLabelEnter()
}
},
)
watch(
() => route.path,
async (path, previousPath) => {
if (path === previousPath)
return
if (path.startsWith('/plugins'))
pluginGroupExpanded.value = true
muteNavFeedbackDuringMove()
await nextTick()
animateNavSignal()
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
stopNavFeedbackRestore()
const targets = [
sidebarEl.value,
brandLabelEl.value,
navSignalEl.value,
].filter((target): target is HTMLElement =>
Boolean(target),
)
gsap.killTweensOf(targets)
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (labels?.length) {
gsap.killTweensOf(labels)
}
})
</script>
<template>
<aside
ref="sidebarEl"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--z-layout-sider-bg) shadow-z-layout-sider"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
]"
>
<div
class="mx-4 mt-6 grid h-12 shrink-0 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="isCollapsed ? 'w-11 justify-start' : 'self-stretch gap-3'"
>
<ZMotionIcon
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-z"
>
<AppBrandMark class="block size-11 select-none" />
</ZMotionIcon>
<span v-show="brandLabelVisible" ref="brandLabelEl" class="grid min-w-33 content-center overflow-hidden">
<strong class="text-base leading-[1.1] font-heavy text-z-text"> Codex Proxy </strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-z-text-secondary"> Rust build </span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-z-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-z-bg-container"
:class="[
hasUpdate
? 'bg-z-success-container text-z-success-on-container hover:bg-z-success-container-hover'
: 'bg-z-fill-quaternary text-z-text-quaternary hover:bg-z-fill-tertiary hover:text-z-text-secondary',
]"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-z-success" />
</button>
</span>
</span>
</div>
<ZScrollbar class="my-6 w-full flex-1">
<div class="px-4">
<!-- 焦点环绘制在按钮内侧,避免被滚动区和折叠菜单裁切 -->
<nav class="relative grid gap-3" :class="isCollapsed ? 'mx-auto w-11.5' : 'w-full'" :aria-label="t('navigation.mainLabel')">
<span
class="pointer-events-none absolute left-0 top-0 overflow-hidden rounded-z bg-z-menu-item-selected-bg transition-[transform,width,height] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span
ref="navSignal"
class="absolute inset-y-0 left-0 w-2/3 [background:linear-gradient(90deg,transparent,color-mix(in_srgb,var(--z-color-info)_9%,transparent),transparent)]"
/>
</span>
<template v-for="item in navItems" :key="item.label">
<div v-if="item.path === '/plugins'" class="relative z-10 grid min-w-0">
<ZPopover trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="w-full">
<template #reference="{ close }">
<button
type="button"
class="inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 bg-transparent text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'font-bold text-z-text transition-none'
: 'font-bold text-z-text transition-colors duration-200'
: navFeedbackMuted
? 'font-semibold text-z-text-secondary transition-none'
: 'font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
]"
:aria-expanded="isCollapsed ? undefined : pluginGroupExpanded"
:aria-controls="isCollapsed ? undefined : pluginMenuId"
:aria-label="isCollapsed ? item.label : undefined"
@click="close(); togglePluginGroup()"
>
<component :is="item.icon" class="shrink-0" :size="20" />
<span
class="sidebar-label min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left transition-[opacity,transform] duration-200"
:class="isCollapsed ? 'hidden' : 'w-auto'"
>
{{ item.label }}
</span>
<span
v-if="!isCollapsed"
class="sidebar-label inline-flex size-4 shrink-0"
aria-hidden="true"
>
<ChevronDown
class="size-4 transition-transform duration-200 motion-reduce:transition-none"
:class="pluginGroupExpanded ? undefined : '-rotate-90'"
/>
</span>
</button>
</template>
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
</ZPopover>
<div
:id="pluginMenuId"
class="grid transition-[grid-template-rows] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:class="pluginMenuVisible ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
:inert="!pluginMenuVisible"
:aria-label="t('navigation.pluginsLabel')"
>
<div class="min-h-0 overflow-hidden">
<div class="relative ml-3 grid gap-1 pt-1 pl-3">
<button
type="button"
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
:class="route.name === 'plugins'
? 'font-emphasis text-z-text'
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
:aria-current="route.name === 'plugins' ? 'page' : undefined"
@click="navigate('/plugins')"
>
<BringToFront class="block size-4 shrink-0" />
<span class="sidebar-label truncate leading-none">{{ t('navigation.pluginManagement') }}</span>
</button>
<button
v-for="view in pluginPageViews"
:key="view.target.instanceId"
type="button"
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
:class="isPluginViewActive(view)
? 'font-emphasis text-z-text'
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
:aria-current="isPluginViewActive(view) ? 'page' : undefined"
@click="navigatePluginView(view)"
>
<Blocks class="block size-4 shrink-0" />
<span class="sidebar-label truncate leading-none">{{ pluginViewLabel(view) }}</span>
</button>
</div>
</div>
</div>
</div>
<ZPopover v-else trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="relative z-10 w-full">
<template #reference="{ close }">
<button
type="button"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'bg-transparent font-bold text-z-text transition-none'
: 'bg-transparent font-bold text-z-text transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-z-text-secondary transition-none'
: 'bg-transparent font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
]"
:aria-label="isCollapsed ? item.label : undefined"
@click="close(); navigate(item.path)"
>
<component :is="item.icon" class="shrink-0" :size="20" />
<span
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
>
{{ item.label }}
</span>
</button>
</template>
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
</ZPopover>
</template>
</nav>
</div>
</ZScrollbar>
<div class="mx-4 mb-6 shrink-0" :class="isCollapsed ? 'w-11' : 'self-stretch'">
<div
class="bg-z-fill-quaternary"
:class="isCollapsed ? 'grid gap-1 rounded-z p-1' : 'grid h-11 grid-cols-[4.25rem_auto] items-center justify-between rounded-z-lg px-2'"
>
<span
v-if="!isCollapsed"
class="inline-flex h-7 items-center justify-self-start gap-1.5 whitespace-nowrap rounded-lg bg-z-success-container px-2 text-xs leading-none font-emphasis text-z-success-on-container"
>
<i class="size-1.5 rounded-full bg-z-success" />
{{ t('navigation.online') }}
</span>
<div class="flex shrink-0 items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
<ZIconButton
v-if="isCollapsed && hasUpdate"
type="success" variant="plain"
size="default"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<ArrowUpCircle :size="19" />
</ZIconButton>
<ZIconButton
:size="isCollapsed ? 'default' : 'small'"
:aria-label="t('auth.signOut')"
type="danger"
:disabled="authStore.loading || !authStore.isAuthenticated"
@click="handleLogout"
>
<LogOut :size="isCollapsed ? 19 : 18" />
</ZIconButton>
<ZIconButton
:size="isCollapsed ? 'default' : 'small'"
:aria-label="themeToggleLabel"
@click="toggleTheme($event)"
>
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
<Moon v-else :size="isCollapsed ? 19 : 18" />
</ZIconButton>
<AppLanguageMenu :size="isCollapsed ? 'default' : 'small'" />
<ZIconButton v-if="mobile" size="small" :aria-label="t('navigation.sidebar.close')" @click="emit('close')">
<PanelLeftClose :size="18" />
</ZIconButton>
<ZIconButton
v-else
:size="isCollapsed ? 'default' : 'small'"
data-sidebar-toggle
:aria-label="isCollapsed ? t('navigation.sidebar.expand') : t('navigation.sidebar.collapse')"
@click="emit('toggle')"
>
<PanelLeftOpen v-if="isCollapsed" :size="19" />
<PanelLeftClose v-else :size="18" />
</ZIconButton>
</div>
</div>
</div>
</aside>
</template>