mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
511 lines
14 KiB
Vue
511 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ArrowUpCircle,
|
|
ChartNoAxesColumn,
|
|
FolderTree,
|
|
Info,
|
|
KeyRound,
|
|
LayoutDashboard,
|
|
LogOut,
|
|
Moon,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
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,
|
|
useTemplateRef,
|
|
watch,
|
|
} from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
|
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
|
|
import { useAuthStore } from '@/stores/modules/auth'
|
|
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
|
import { useUiStore } from '@/stores/modules/ui'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
collapsed?: boolean
|
|
mobile?: boolean
|
|
}>(),
|
|
{
|
|
collapsed: false,
|
|
mobile: false,
|
|
},
|
|
)
|
|
const emit = defineEmits<{
|
|
close: []
|
|
navigate: []
|
|
openAbout: []
|
|
openSystemUpdate: []
|
|
toggle: []
|
|
}>()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
const systemUpdateStore = useSystemUpdateStore()
|
|
const uiStore = useUiStore()
|
|
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
|
|
const { effectiveTheme } = storeToRefs(uiStore)
|
|
const { toggleTheme } = uiStore
|
|
const preferredMotion = usePreferredReducedMotion()
|
|
|
|
const navItems = [
|
|
{ label: '概览', icon: LayoutDashboard, path: '/' },
|
|
{ label: '账号管理', icon: Users, path: '/accounts' },
|
|
{ label: '分组管理', icon: FolderTree, path: '/account-groups' },
|
|
{ label: 'API 密钥', icon: KeyRound, path: '/api-keys' },
|
|
{ label: '使用统计', icon: ChartNoAxesColumn, path: '/usage' },
|
|
{ label: '系统设置', icon: Settings, path: '/settings' },
|
|
]
|
|
|
|
function isActive(path: string) {
|
|
if (path === '/')
|
|
return route.path === '/'
|
|
return route.path.startsWith(path)
|
|
}
|
|
|
|
const activeNavIndex = computed(() => {
|
|
const index = navItems.findIndex(item => isActive(item.path))
|
|
return Math.max(0, index)
|
|
})
|
|
const activeNavIndicatorStyle = computed(() => ({
|
|
transform: `translate3d(0, ${activeNavIndex.value * 58}px, 0)`,
|
|
}))
|
|
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 openSystemUpdate() {
|
|
emit('openSystemUpdate')
|
|
}
|
|
|
|
async function handleLogout() {
|
|
await authStore.logout()
|
|
await router.push('/login')
|
|
emit('navigate')
|
|
}
|
|
|
|
const sidebarEl = ref<HTMLElement | null>(null)
|
|
const brandLabelEl = ref<HTMLElement | null>(null)
|
|
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
|
|
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
|
|
const collapsedSidebarWidth = 88
|
|
const expandedSidebarWidth = 251
|
|
const sidebarWidth = computed(() =>
|
|
isCollapsed.value ? collapsedSidebarWidth : expandedSidebarWidth,
|
|
)
|
|
const brandLabelVisible = shallowRef(!isCollapsed.value)
|
|
const themeToggleLabel = computed(() =>
|
|
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
|
)
|
|
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 ? '发现新版本,打开系统更新' : '打开系统更新',
|
|
)
|
|
|
|
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(() => {
|
|
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
|
|
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-cp-surface px-4 shadow-cp-sidebar"
|
|
:class="[
|
|
mobile ? 'flex' : 'hidden min-[961px]:flex',
|
|
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
|
|
]"
|
|
>
|
|
<div
|
|
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
|
|
:class="isCollapsed ? 'w-11 justify-start' : 'w-full gap-3'"
|
|
>
|
|
<BaseMotionIcon
|
|
aria-hidden="true"
|
|
variant="brand"
|
|
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp-control"
|
|
>
|
|
<img
|
|
src="/favicon.svg"
|
|
alt=""
|
|
class="block size-11 select-none"
|
|
draggable="false"
|
|
>
|
|
</BaseMotionIcon>
|
|
<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-cp-primary">
|
|
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-cp-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-cp-control-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-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
|
|
:class="[
|
|
hasUpdate
|
|
? 'bg-cp-success-bg text-cp-success-text hover:bg-cp-success-bg-hover'
|
|
: 'bg-cp-subtle text-cp-muted-text hover:bg-cp-muted hover:text-cp-secondary',
|
|
]"
|
|
:title="updateButtonLabel"
|
|
@click="openSystemUpdate"
|
|
>
|
|
<span>{{ versionLabel }}</span>
|
|
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-cp-success" />
|
|
</button>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<nav
|
|
class="relative mt-6 grid gap-3"
|
|
:class="isCollapsed ? 'w-11.5' : 'w-full'"
|
|
aria-label="主导航"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp-control bg-cp-nav-active transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
|
:style="activeNavIndicatorStyle"
|
|
>
|
|
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
|
|
</span>
|
|
<button
|
|
v-for="item in navItems"
|
|
:key="item.label"
|
|
type="button"
|
|
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp-control border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
|
|
:class="[
|
|
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
|
isActive(item.path)
|
|
? navFeedbackMuted
|
|
? 'bg-transparent font-bold text-cp-primary transition-none'
|
|
: 'bg-transparent font-bold text-cp-primary transition-colors duration-200'
|
|
: navFeedbackMuted
|
|
? 'bg-transparent font-semibold text-cp-secondary transition-none'
|
|
: 'bg-transparent font-semibold text-cp-secondary transition-colors duration-200 hover:bg-cp-subtle hover:text-cp-primary',
|
|
]"
|
|
@click="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>
|
|
</nav>
|
|
|
|
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
|
|
<div
|
|
class="bg-cp-subtle"
|
|
:class="
|
|
isCollapsed
|
|
? 'grid gap-1 rounded-cp-control p-1'
|
|
: 'flex h-11 items-center justify-between rounded-cp-overlay px-2'
|
|
"
|
|
>
|
|
<span
|
|
v-if="!isCollapsed"
|
|
class="inline-flex whitespace-nowrap h-7 items-center gap-1.5 rounded-lg bg-cp-success-bg px-2.5 text-xs leading-none font-emphasis text-cp-success-text"
|
|
>
|
|
<i class="size-1.5 rounded-full bg-cp-success" />
|
|
在线
|
|
</span>
|
|
|
|
<div class="flex items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
|
|
<BaseIconButton
|
|
v-if="isCollapsed && hasUpdate"
|
|
variant="success"
|
|
size="md"
|
|
:label="updateButtonLabel"
|
|
@click="openSystemUpdate"
|
|
>
|
|
<ArrowUpCircle :size="19" />
|
|
</BaseIconButton>
|
|
|
|
<BaseIconButton
|
|
:size="isCollapsed ? 'md' : 'sm'"
|
|
label="退出登录"
|
|
variant="destructive"
|
|
@click="handleLogout"
|
|
>
|
|
<LogOut :size="isCollapsed ? 19 : 18" />
|
|
</BaseIconButton>
|
|
|
|
<BaseIconButton
|
|
variant="ghost"
|
|
:size="isCollapsed ? 'md' : 'sm'"
|
|
:label="themeToggleLabel"
|
|
@click="toggleTheme($event)"
|
|
>
|
|
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
|
|
<Moon v-else :size="isCollapsed ? 19 : 18" />
|
|
</BaseIconButton>
|
|
|
|
<BaseIconButton
|
|
variant="ghost"
|
|
:size="isCollapsed ? 'md' : 'sm'"
|
|
label="关于"
|
|
@click="emit('openAbout')"
|
|
>
|
|
<Info :size="isCollapsed ? 19 : 18" />
|
|
</BaseIconButton>
|
|
|
|
<BaseIconButton
|
|
v-if="mobile"
|
|
variant="ghost"
|
|
size="sm"
|
|
label="关闭侧边栏"
|
|
@click="emit('close')"
|
|
>
|
|
<PanelLeftClose :size="18" />
|
|
</BaseIconButton>
|
|
|
|
<BaseIconButton
|
|
v-else
|
|
variant="ghost"
|
|
:size="isCollapsed ? 'md' : 'sm'"
|
|
data-sidebar-toggle
|
|
:label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
|
|
@click="emit('toggle')"
|
|
>
|
|
<PanelLeftOpen v-if="isCollapsed" :size="19" />
|
|
<PanelLeftClose v-else :size="18" />
|
|
</BaseIconButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sidebar-active-signal {
|
|
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--cp-info) 9%, transparent), transparent);
|
|
}
|
|
</style>
|