gm-2api / frontend /src /layouts /AppShell.vue
yu
Add Dockerfile and code
4bcd925
<template>
<div class="min-h-screen">
<div class="flex min-h-screen flex-col lg:flex-row">
<div
v-if="isSidebarOpen"
class="fixed inset-0 z-30 bg-black/20 backdrop-blur-sm lg:hidden"
@click="isSidebarOpen = false"
></div>
<aside
class="fixed inset-y-0 left-0 z-40 w-72 -translate-x-full bg-card/90 backdrop-blur-sm lg:backdrop-blur-none border-r border-border
transition-[width,transform] duration-200 ease-out will-change-[transform] transform-gpu flex flex-col lg:static lg:translate-x-0 lg:bg-card/80
lg:border-b-0 lg:border-r lg:sticky lg:top-0 lg:h-screen"
:class="[
{ 'translate-x-0': isSidebarOpen, 'w-20 lg:w-20': isSidebarCollapsed },
]"
>
<div
class="flex h-16 items-center justify-between px-6 pt-4 lg:h-20 lg:pt-5"
:class="isSidebarCollapsed ? 'justify-center px-0' : ''"
>
<div class="flex items-center gap-2" :class="isSidebarCollapsed ? 'gap-0 justify-center w-full' : ''">
<a
href="https://github.com/Dreamy-rain/gemini-business2api"
target="_blank"
rel="noopener noreferrer"
class="text-foreground transition-colors hover:text-primary"
aria-label="GitHub"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
class="h-6 w-6"
fill="currentColor"
>
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.419 2.865 8.166 6.839 9.489.5.09.682-.217.682-.483 0-.237-.009-.868-.014-1.703-2.782.604-3.369-1.341-3.369-1.341-.454-1.154-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.004.071 1.532 1.031 1.532 1.031.892 1.529 2.341 1.087 2.91.832.091-.647.349-1.087.636-1.337-2.22-.253-4.555-1.11-4.555-4.944 0-1.092.39-1.987 1.029-2.687-.103-.253-.446-1.272.098-2.65 0 0 .84-.269 2.75 1.026A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.295 2.748-1.026 2.748-1.026.546 1.378.202 2.397.1 2.65.64.7 1.028 1.595 1.028 2.687 0 3.842-2.338 4.687-4.566 4.936.359.309.678.919.678 1.852 0 1.337-.012 2.418-.012 2.747 0 .268.18.577.688.479A10.002 10.002 0 0 0 22 12c0-5.523-4.477-10-10-10z" />
</svg>
</a>
<span v-if="!isSidebarCollapsed" class="text-base font-semibold text-foreground">Gemini Business2API</span>
</div>
</div>
<nav
class="pb-4 pt-4 lg:pt-6 flex-1 overflow-y-auto"
:class="isSidebarCollapsed ? 'px-2' : 'px-3'"
>
<p
v-if="!isSidebarCollapsed"
class="px-3 pb-2 text-xs uppercase tracking-[0.28em] text-muted-foreground"
>
导航
</p>
<div class="space-y-1">
<RouterLink
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="group flex items-center rounded-2xl py-2 text-sm font-medium transition-colors overflow-hidden"
:class="navItemClass(item.path)"
:title="isSidebarCollapsed ? item.label : undefined"
>
<span
class="inline-flex h-9 w-9 items-center justify-center rounded-2xl border border-border"
:class="navIconClass(item.path)"
>
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor">
<path :d="item.icon" />
</svg>
</span>
<span v-if="!isSidebarCollapsed" class="flex-1 min-w-0 truncate">{{ item.label }}</span>
<span v-if="!isSidebarCollapsed" class="ml-auto text-xs opacity-0 transition-opacity group-hover:opacity-100">
进入
</span>
</RouterLink>
</div>
</nav>
<div class="mt-auto border-t border-border px-6 py-3 lg:py-4">
<div v-if="!isSidebarCollapsed" class="rounded-2xl bg-secondary/60 p-3">
<p class="text-xs tracking-[0.12em] text-muted-foreground">
<a
href="https://github.com/Dreamy-rain/gemini-business2api"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 transition-colors hover:text-foreground"
>
gemini-business2api
</a>
<span> · 声明</span>
</p>
<p class="mt-2 text-xs text-muted-foreground">
本项目仅限学习与研究用途,禁止用于商业用途。请保留本声明、原作者信息与开源来源。
</p>
</div>
<div
class="mt-4 flex items-center gap-3"
:class="isSidebarCollapsed ? 'justify-center' : ''"
>
<button
v-if="!isSidebarCollapsed"
@click="handleLogout"
class="flex-1 rounded-2xl border border-border bg-background px-4 py-3 text-sm font-medium
text-muted-foreground transition-colors hover:border-destructive/40 hover:text-destructive"
>
退出登录
</button>
<button
class="h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-border text-muted-foreground transition-all
hover:border-primary hover:text-primary flex"
@click="isSidebarCollapsed = !isSidebarCollapsed"
:title="isSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
class="h-4 w-4 shrink-0"
fill="currentColor"
>
<path d="M6 4h2v16H6V4zm4 4h8v2h-8V8zm0 6h8v2h-8v-2z" />
</svg>
</button>
</div>
</div>
</aside>
<main class="min-w-0 flex-1 overflow-hidden lg:ml-0">
<header class="min-w-0 flex flex-col gap-4 border-b border-border bg-card/70 px-6 py-5 backdrop-blur lg:flex-row lg:items-center lg:justify-between lg:px-10">
<div class="flex items-center gap-3">
<button
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border text-foreground transition-colors
hover:border-primary hover:text-primary lg:hidden"
@click="isSidebarOpen = true"
aria-label="打开导航"
>
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor">
<path d="M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h16v2H4v-2z" />
</svg>
</button>
<svg
aria-hidden="true"
viewBox="0 0 130 150"
class="logo-mark h-9 w-9 shrink-0 text-foreground"
>
<defs>
<filter id="head-shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="rgba(0, 188, 212, 0.2)"/>
</filter>
</defs>
<g class="logo-cat-wrapper" transform="translate(0, 12)">
<g transform="translate(16, 20) rotate(-10, 9, 12)">
<path d="M14 0 L18 24 L0 24 Z" fill="#2c3e50" />
</g>
<g transform="translate(96, 20) rotate(10, 9, 12)">
<path d="M4 0 L18 24 L0 24 Z" fill="#2c3e50" />
</g>
<g filter="url(#head-shadow)">
<path d="M 32 40 L 98 40 A 12 12 0 0 1 110 52 L 110 90 A 30 30 0 0 1 80 120 L 50 120 A 30 30 0 0 1 20 90 L 20 52 A 12 12 0 0 1 32 40 Z"
fill="rgba(255, 255, 255, 0.9)"
stroke="#2c3e50"
stroke-width="3"
/>
</g>
<rect class="logo-eye" x="35" y="68" width="14" height="4" rx="1" />
<rect class="logo-eye" x="81" y="68" width="14" height="4" rx="1" />
</g>
</svg>
<h2 class="text-xl font-semibold text-foreground lg:text-2xl">
{{ currentPageTitle }}
</h2>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
@click="refreshPage"
class="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors
hover:border-primary hover:text-primary"
title="刷新"
>
刷新
</button>
<button
class="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors
hover:border-primary hover:text-primary"
@click="openApiInfo"
>
接口信息
</button>
<RouterLink
to="/public/uptime"
target="_blank"
class="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors
hover:border-primary hover:text-primary"
>
状态监控
</RouterLink>
<RouterLink
to="/public/logs"
target="_blank"
class="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors
hover:border-primary hover:text-primary"
>
公开日志
</RouterLink>
</div>
</header>
<div class="h-full overflow-y-auto overflow-x-hidden bg-card/70 px-4 pb-10 pt-6 backdrop-blur lg:px-10 lg:pt-10">
<RouterView />
</div>
</main>
</div>
<ConfirmDialog
:open="confirmDialog.open.value"
:title="confirmDialog.title.value"
:message="confirmDialog.message.value"
:confirm-text="confirmDialog.confirmText.value"
:cancel-text="confirmDialog.cancelText.value"
@confirm="confirmDialog.confirm"
@cancel="confirmDialog.cancel"
/>
<Teleport to="body">
<div v-if="isApiInfoOpen" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/30 px-4">
<div class="w-full max-w-lg rounded-3xl border border-border bg-card p-6 shadow-xl">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-foreground">API 接口</p>
<button
class="text-xs text-muted-foreground transition-colors hover:text-foreground"
@click="isApiInfoOpen = false"
>
关闭
</button>
</div>
<p class="mt-2 text-xs text-muted-foreground">根据客户端选择对应接口</p>
<div class="mt-4 space-y-3 text-sm">
<div>
<p class="text-xs text-muted-foreground">基础端点</p>
<div class="mt-1 flex items-start gap-2">
<p class="min-w-0 flex-1 break-all rounded-2xl border border-border bg-background px-3 py-2 font-mono text-xs">
{{ apiBaseUrl }}
</p>
<button
class="shrink-0 rounded-full border border-border px-3 py-1 text-[11px] text-muted-foreground transition-colors
hover:border-primary hover:text-primary"
@click="copyText(apiBaseUrl)"
>
复制
</button>
</div>
</div>
<div>
<p class="text-xs text-muted-foreground">SDK 接口</p>
<div class="mt-1 flex items-start gap-2">
<p class="min-w-0 flex-1 break-all rounded-2xl border border-border bg-background px-3 py-2 font-mono text-xs">
{{ apiSdkUrl }}
</p>
<button
class="shrink-0 rounded-full border border-border px-3 py-1 text-[11px] text-muted-foreground transition-colors
hover:border-primary hover:text-primary"
@click="copyText(apiSdkUrl)"
>
复制
</button>
</div>
</div>
<div>
<p class="text-xs text-muted-foreground">完整接口</p>
<div class="mt-1 flex items-start gap-2">
<p class="min-w-0 flex-1 break-all rounded-2xl border border-border bg-background px-3 py-2 font-mono text-xs">
{{ apiFullUrl }}
</p>
<button
class="shrink-0 rounded-full border border-border px-3 py-1 text-[11px] text-muted-foreground transition-colors
hover:border-primary hover:text-primary"
@click="copyText(apiFullUrl)"
>
复制
</button>
</div>
</div>
<div>
<p class="text-xs text-muted-foreground">支持模型</p>
<div class="mt-1 rounded-2xl border border-border bg-background px-3 py-2 text-xs text-muted-foreground">
<div class="flex flex-wrap gap-2 text-foreground">
<span
v-for="model in supportedModels"
:key="model"
class="rounded-full border border-border px-2 py-0.5 text-[11px]"
>
{{ model }}
</span>
</div>
</div>
</div>
<div>
<p class="text-xs text-muted-foreground">API 密钥</p>
<div class="mt-1 flex items-start gap-2">
<p class="min-w-0 flex-1 rounded-2xl border border-border bg-background px-3 py-2 font-mono text-xs">
{{ apiKeyDisplay }}
</p>
<button
class="shrink-0 rounded-full border border-border px-3 py-1 text-[11px] text-muted-foreground transition-colors
hover:border-primary hover:text-primary"
@click="copyText(apiKeyDisplay)"
>
复制
</button>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end">
<button
class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity
hover:opacity-90"
@click="isApiInfoOpen = false"
>
知道了
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
import { useAuthStore, useSettingsStore } from '@/stores'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import { useConfirmDialog } from '@/composables/useConfirmDialog'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const settingsStore = useSettingsStore()
const isSidebarOpen = ref(false)
const isSidebarCollapsed = ref(false)
const confirmDialog = useConfirmDialog()
const isApiInfoOpen = ref(false)
const menuItems = [
{
path: '/',
label: '概览',
icon: 'M4 4h7v7H4V4zm9 0h7v4h-7V4zm0 6h7v10h-7V10zM4 13h7v7H4v-7z',
},
{
path: '/accounts',
label: '账号管理',
icon: 'M12 12a3.5 3.5 0 1 0-3.5-3.5A3.5 3.5 0 0 0 12 12zm0 2c-4.1 0-7.5 2.2-7.5 5v1h15v-1c0-2.8-3.4-5-7.5-5z',
},
{
path: '/settings',
label: '系统设置',
icon: 'M4 6h10v2H4V6zm12 0h4v2h-4V6zM4 11h6v2H4v-2zm8 0h8v2h-8v-2zM4 16h10v2H4v-2zm12 0h4v2h-4v-2z',
},
{
path: '/monitor',
label: '监控状态',
icon: 'M3 12h4l2-4 4 8 3-6h5v2h-4l-4 8-4-8-2 4H3v-2z',
},
{
path: '/logs',
label: '运行日志',
icon: 'M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h10v2H4v-2z',
},
{
path: '/docs',
label: '文档中心',
icon: 'M6 3h9l4 4v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm8 1.5V8h3.5L14 4.5zM8 11h8v2H8v-2zm0 4h8v2H8v-2z',
},
]
const currentPageTitle = computed(() => {
const item = menuItems.find(item => item.path === route.path)
return item?.label || '概览'
})
const navItemClass = (path: string) => {
const baseLayout = isSidebarCollapsed.value ? 'px-2 justify-center gap-0' : 'px-3 gap-3'
const base = `transition-colors ${baseLayout}`
if (route.path === path) {
return `${base} bg-primary text-primary-foreground`
}
return `${base} text-muted-foreground hover:bg-accent hover:text-accent-foreground`
}
const navIconClass = (path: string) => {
if (route.path === path) {
return 'bg-primary-foreground/15 text-primary-foreground border-primary-foreground/40'
}
return 'bg-secondary text-muted-foreground group-hover:text-accent-foreground'
}
const apiBaseUrl = computed(() => {
const raw = settingsStore.settings?.basic?.base_url
|| import.meta.env.VITE_API_URL
|| window.location.origin
return raw.replace(/\/$/, '')
})
const apiSdkUrl = computed(() => `${apiBaseUrl.value}/v1`)
const apiFullUrl = computed(() => `${apiBaseUrl.value}/v1/chat/completions`)
const apiKeyDisplay = computed(() => settingsStore.settings?.basic?.api_key || '未设置')
const supportedModels = [
'gemini-auto',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gemini-3-flash-preview',
'gemini-3-pro-preview',
]
watch(
() => route.path,
() => {
isSidebarOpen.value = false
}
)
const storedCollapse = localStorage.getItem('sidebar-collapsed')
if (storedCollapse) {
isSidebarCollapsed.value = storedCollapse === 'true'
}
watch(isSidebarCollapsed, (value) => {
localStorage.setItem('sidebar-collapsed', value ? 'true' : 'false')
})
async function handleLogout() {
const confirmed = await confirmDialog.ask({
title: '退出登录',
message: '确定退出管理控制台吗?',
})
if (!confirmed) return
await authStore.logout()
router.push({ name: 'login' })
}
function refreshPage() {
window.location.reload()
}
async function openApiInfo() {
isApiInfoOpen.value = true
if (!settingsStore.settings && !settingsStore.isLoading) {
await settingsStore.loadSettings()
}
}
async function copyText(value: string) {
if (!value) return
try {
await navigator.clipboard.writeText(value)
} catch (error) {
console.error('Copy failed', error)
}
}
</script>