Files
image2api/frontend/src/layouts/PublicLayout.vue
T
2026-07-05 12:58:48 +08:00

206 lines
8.8 KiB
Vue

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { auth, isAuthed, isAdmin, openLogin } from '../auth'
import { isDark, toggleTheme } from '../theme'
import { site } from '../site'
import Icon from '../components/Icon.vue'
import Logo from '../components/Logo.vue'
import { pointsLabel } from '../credits'
import { draft } from '../playground'
const route = useRoute()
// 画图/记录 only show once signed in; clicking 设置 while logged out opens login.
const nav = computed(() => {
const items = [{ to: '/', label: '首页', icon: 'overview' }]
if (isAuthed()) {
items.push({ to: '/user', label: '画图', icon: 'spark' })
items.push({ to: '/logs', label: '图片', icon: 'files' })
items.push({ to: '/mylogs', label: '日志', icon: 'log' })
items.push({ to: '/invite', label: '邀请', icon: 'accounts' })
items.push({ to: '/orders', label: '订单', icon: 'receipt' })
}
// 文档 + 关于 are public — visible to guests too.
items.push({ to: '/docs', label: '文档', icon: 'log' })
items.push({ to: '/about', label: '关于', icon: 'accounts' })
return items
})
function onSettings(e) {
if (!isAuthed()) { e.preventDefault(); openLogin('/settings') }
}
// credits — the logged-in user's real server-side balance (auth.user.credits)
const credits = computed(() => Number(auth.user?.credits || 0))
const showBalance = computed(() => route.path === '/user')
const creditsLabel = computed(() => pointsLabel(credits.value))
// On the 画图 workbench the header label tracks the active mode — it flips
// with the 生图/生视频 tab, and on state restore (回显) reflects whatever a
// pending job is generating (video → 生视频). draft.mode is kept in sync by
// PlaygroundView for both cases.
const currentLabel = computed(() => {
if (route.path === '/user') return draft.mode === 'video' ? '生视频' : '生图'
return route.meta?.label || ''
})
</script>
<template>
<div class="theme-x min-h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30">
<!-- ===== Left rail ===== -->
<aside class="fixed inset-y-0 left-0 z-30 w-16 md:w-20 flex flex-col items-center py-5 border-r border-[color:var(--hairline)]">
<!-- Logo -->
<router-link to="/" class="mb-8 group transition-transform hover:scale-105">
<img v-if="site.logo" :src="site.logo" :alt="site.title"
class="w-10 h-10 rounded-xl object-contain shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
<Logo v-else :size="40" class="rounded-xl shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
</router-link>
<!-- Nav -->
<nav class="flex flex-col gap-1.5 flex-1">
<router-link
v-for="n in nav" :key="n.to" :to="n.to"
:exact-active-class="n.to === '/' ? 'active' : ''"
:active-class="n.to === '/' ? '' : 'active'"
:title="n.label"
class="rail-link group">
<span class="w-10 h-10 rounded-xl grid place-items-center transition-all ring-1 ring-transparent group-hover:bg-[var(--hover)] group-hover:ring-[color:var(--hairline)]">
<Icon :name="n.icon" class="w-4 h-4 transition-colors" />
</span>
</router-link>
</nav>
<!-- Bottom: admin shortcut (admins only) + settings -->
<div class="flex flex-col items-center gap-3">
<router-link v-if="isAdmin()" to="/admin/overview" title="进入管理后台"
class="rail-bottom">
<Icon name="shield" class="w-4 h-4" />
</router-link>
<!-- Light/dark toggle sits right above 设置. Sun when dark (clicklight),
moon when light (clickdark). -->
<button type="button" @click="toggleTheme"
:title="isDark ? '切换到亮色' : '切换到暗色'" class="rail-bottom">
<svg v-if="isDark" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<router-link to="/settings" title="设置" @click="onSettings"
:class="$route.path === '/settings' ? 'rail-bottom active' : 'rail-bottom'">
<Icon name="accounts" class="w-4 h-4" />
</router-link>
</div>
</aside>
<!-- ===== Main column ===== -->
<div class="flex-1 min-w-0 ml-16 md:ml-20 relative">
<!-- soft background mesh -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-32 left-1/3 w-[40rem] h-[40rem] rounded-full opacity-[0.16]"
style="background: radial-gradient(circle, #a855f7, transparent 60%); filter: blur(100px)"></div>
<div class="absolute top-1/2 -right-40 w-[36rem] h-[36rem] rounded-full opacity-[0.14]"
style="background: radial-gradient(circle, #06b6d4, transparent 60%); filter: blur(100px)"></div>
<div class="absolute bottom-0 left-0 w-[32rem] h-[32rem] rounded-full opacity-[0.10]"
style="background: radial-gradient(circle, #f43f5e, transparent 60%); filter: blur(100px)"></div>
</div>
<!-- Page header shows the Vivid wordmark on home, route label
elsewhere. Same vertical position across routes so the brand
stamp doesn't jump around. -->
<header class="relative z-10 px-8 md:px-14 pt-10 pb-4 flex items-center justify-between gap-4">
<div class="flex items-baseline gap-2">
<span class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
{{ site.title }}
</span>
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? 'AI 生图 · 生视频' : currentLabel }}</span>
</div>
<router-link v-if="showBalance" to="/settings"
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
<span class="text-[color:var(--fg-faint)] ml-1">· 充值</span>
</router-link>
</header>
<main :class="['relative z-10 px-8 md:px-14 pb-24 pt-2', { 'public-dark': isDark }]">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<style scoped>
.rail-link {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.25rem 0;
color: var(--fg-3);
transition: color 0.15s ease;
}
.rail-link:hover { color: var(--fg); }
.rail-link.active { color: var(--fg); }
.rail-link.active::before {
content: '';
position: absolute;
left: -1rem;
/* Anchor to the icon box, not the whole link (which includes the label
underneath). rail-link has padding-top 0.25rem and the icon span is 2.5rem
tall — so the icon center sits at 0.25rem + 1.25rem = 1.5rem from top. */
top: 1.5rem;
transform: translateY(-50%);
width: 3px;
height: 24px;
border-radius: 4px;
background: linear-gradient(180deg, #f0abfc, #a78bfa);
}
.rail-link.active > span:first-child {
background: var(--hover) !important;
--tw-ring-color: var(--hairline);
}
.rail-bottom {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
display: grid;
place-items: center;
color: var(--fg-3);
--tw-ring-color: transparent;
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.rail-bottom:hover {
color: var(--fg);
background: var(--hover);
box-shadow: inset 0 0 0 1px var(--hairline);
}
.rail-bottom.active {
color: var(--fg);
background: var(--hover);
box-shadow: inset 0 0 0 1px var(--hairline);
}
/* Admin shortcut — only rendered for admins. Tinted so it doesn't get lost
next to the plain settings cog right below it. */
.admin-shortcut {
color: rgb(196 181 253); /* violet-300 */
background: linear-gradient(135deg, rgb(167 139 250 / 0.12), rgb(236 72 153 / 0.10));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.25);
}
.admin-shortcut:hover {
color: white;
background: linear-gradient(135deg, rgb(167 139 250 / 0.22), rgb(236 72 153 / 0.18));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.45);
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(8px); }
.fade-leave-to { opacity: 0; }
</style>