- 全站紫/青/粉写死色值与 CSS 变量改为墨黑中性色板 - 移除 aurora 光斑/星点装饰,登录弹窗改纯白卡片 - theme.js 固定亮色并清除历史 dark 选择,删除前台/后台/画布切换按钮 - 模型页空状态文案对齐「保存渠道即自动同步」 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
397 lines
15 KiB
Vue
397 lines
15 KiB
Vue
<script setup>
|
||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { auth, isAuthed, isAdmin, openLogin, openRegister, logout } from '../auth'
|
||
import { site } from '../site'
|
||
import Icon from '../components/Icon.vue'
|
||
import Logo from '../components/Logo.vue'
|
||
import { pointsLabel } from '../credits'
|
||
import { draft } from '../playground'
|
||
import { announcement, openAnnouncement } from '../announcement'
|
||
|
||
const route = useRoute()
|
||
|
||
// Top-bar links. 画图/图片 only show once signed in; everything else a
|
||
// signed-in user needs lives in the avatar menu so the bar stays short.
|
||
const nav = computed(() => {
|
||
const items = [{ to: '/', label: '首页' }]
|
||
if (isAuthed()) {
|
||
items.push({ to: '/user', label: '画图' })
|
||
items.push({ to: '/canvas', label: '画布' })
|
||
items.push({ to: '/logs', label: '图片' })
|
||
}
|
||
items.push({ to: '/docs', label: '文档' })
|
||
items.push({ to: '/about', label: '关于' })
|
||
return items
|
||
})
|
||
|
||
// Avatar menu — only the surfaces the top nav doesn't already expose
|
||
// (画图/图片 live in the bar, so they're intentionally left out here).
|
||
const userMenu = [
|
||
{ to: '/mylogs', label: '生成日志' },
|
||
{ to: '/invite', label: '邀请返利' },
|
||
{ to: '/orders', label: '订单' },
|
||
{ to: '/settings', label: '设置' },
|
||
]
|
||
|
||
const menuOpen = ref(false) // avatar dropdown
|
||
const mobileOpen = ref(false) // hamburger panel
|
||
watch(() => route.fullPath, () => { menuOpen.value = false; mobileOpen.value = false })
|
||
|
||
// 悬浮胶囊顶栏:静止时浮在页面上方(999px 圆角),滚动后展开为通栏。
|
||
const scrolled = ref(false)
|
||
function onScroll() { scrolled.value = window.scrollY > 4 }
|
||
onMounted(() => { onScroll(); window.addEventListener('scroll', onScroll, { passive: true }) })
|
||
onUnmounted(() => window.removeEventListener('scroll', onScroll))
|
||
|
||
// credits — the logged-in user's real server-side balance (auth.user.credits)
|
||
const credits = computed(() => Number(auth.user?.credits || 0))
|
||
const creditsLabel = computed(() => pointsLabel(credits.value))
|
||
const displayName = computed(() => {
|
||
const n = (auth.user?.name || '').trim()
|
||
if (n) return n
|
||
const e = (auth.user?.email || '').trim()
|
||
return e ? e.split('@')[0] : '我的'
|
||
})
|
||
|
||
// On the 画图 workbench the label tracks the active mode — it flips with the
|
||
// 生图/生视频 tab, and on state restore reflects a pending job's kind.
|
||
const currentLabel = computed(() => {
|
||
if (route.path === '/user') return draft.mode === 'video' ? '生视频' : '生图'
|
||
return route.meta?.label || ''
|
||
})
|
||
|
||
// The home page paints its own full-bleed hero; other pages keep the padded
|
||
// shell plus a route-label header.
|
||
const isHome = computed(() => route.path === '/')
|
||
|
||
// CanvasView's root is a <Teleport to="body"> — keep it outside the
|
||
// out-in transition so leaving the canvas cannot stall the next page.
|
||
|
||
async function onLogout() {
|
||
menuOpen.value = false
|
||
await logout()
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-400/30"
|
||
:class="{ 'home-bg': isHome }">
|
||
<!-- ===== Top bar:悬浮胶囊,滚动展开为通栏 ===== -->
|
||
<header class="top-nav fixed top-0 inset-x-0 z-40" :class="{ scrolled }">
|
||
<div class="nav-inner mx-auto flex items-center gap-3">
|
||
<!-- brand -->
|
||
<router-link to="/" class="flex items-center gap-2.5 shrink-0 transition-transform hover:scale-[1.03]">
|
||
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 object-contain" />
|
||
<Logo v-else :size="30" />
|
||
<span class="text-[17px] font-bold tracking-tight text-[color:var(--fg)]">{{ site.title }}</span>
|
||
<span class="brand-spark" aria-hidden="true">✦</span>
|
||
</router-link>
|
||
|
||
<!-- links (desktop) -->
|
||
<nav class="hidden md:flex items-center gap-0.5 ml-4">
|
||
<router-link v-for="n in nav" :key="n.to" :to="n.to"
|
||
:exact-active-class="n.to === '/' ? 'on' : ''"
|
||
:active-class="n.to === '/' ? '' : 'on'"
|
||
class="top-link">{{ n.label }}</router-link>
|
||
</nav>
|
||
|
||
<div class="flex-1"></div>
|
||
|
||
<!-- balance (signed in) -->
|
||
<router-link v-if="isAuthed()" to="/settings"
|
||
class="hidden sm:block 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>
|
||
</router-link>
|
||
|
||
<!-- 公告 -->
|
||
<button v-if="isAuthed() && announcement.content.trim()" type="button"
|
||
@click="openAnnouncement" class="round-btn" title="公告">
|
||
<svg 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="M3 11l18-7-4 14-6-3-3 4-1-6z"/></svg>
|
||
</button>
|
||
|
||
<!-- admin shortcut -->
|
||
<router-link v-if="isAdmin()" to="/admin/overview" class="round-btn" title="进入管理后台">
|
||
<Icon name="shield" class="w-4 h-4" />
|
||
</router-link>
|
||
|
||
<!-- guest actions -->
|
||
<template v-if="!isAuthed()">
|
||
<button type="button" class="login-pill" @click="openLogin('/user')">登录 / 注册</button>
|
||
</template>
|
||
|
||
<!-- signed-in: avatar menu -->
|
||
<div v-else class="relative">
|
||
<button type="button" class="name-btn" @click.stop="menuOpen = !menuOpen" title="我的">
|
||
<span class="name-txt">{{ displayName }}</span>
|
||
<svg class="w-3.5 h-3.5 shrink-0 transition-transform" :class="menuOpen && 'rotate-180'"
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||
</button>
|
||
<div v-if="menuOpen" class="menu" @click.stop>
|
||
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" class="menu-item">{{ m.label }}</router-link>
|
||
<div class="menu-sep"></div>
|
||
<button type="button" class="menu-item w-full text-left" @click="onLogout">退出登录</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- hamburger -->
|
||
<button type="button" class="round-btn hamburger" @click.stop="mobileOpen = !mobileOpen" aria-label="菜单">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- mobile panel -->
|
||
<div v-if="mobileOpen" class="mobile-panel md:hidden px-3 py-3">
|
||
<router-link v-for="n in nav" :key="n.to" :to="n.to"
|
||
:exact-active-class="n.to === '/' ? 'm-on' : ''"
|
||
:active-class="n.to === '/' ? '' : 'm-on'"
|
||
class="m-link">{{ n.label }}</router-link>
|
||
<template v-if="isAuthed()">
|
||
<div class="menu-sep"></div>
|
||
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" active-class="m-on" class="m-link">{{ m.label }}</router-link>
|
||
</template>
|
||
<template v-else>
|
||
<div class="menu-sep"></div>
|
||
<button type="button" class="m-link w-full text-left" @click="openLogin('/user')">登录</button>
|
||
</template>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- click-away for the dropdowns -->
|
||
<div v-if="menuOpen || mobileOpen" class="fixed inset-0 z-30"
|
||
@click="menuOpen = false; mobileOpen = false"></div>
|
||
|
||
<!-- ===== Main column ===== -->
|
||
<div class="relative">
|
||
|
||
<!-- Route label header — the home page brings its own hero instead. -->
|
||
<header v-if="!isHome" class="relative z-10 mx-auto max-w-[1400px] px-4 md:px-8 pt-[104px] pb-4">
|
||
<span class="text-[22px] font-bold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</span>
|
||
</header>
|
||
|
||
<main :class="['relative z-10 mx-auto max-w-[1400px] pb-24',
|
||
isHome ? 'pt-0 px-0' : 'px-4 md:px-8 pt-2',]">
|
||
<router-view v-slot="{ Component, route: viewRoute }">
|
||
<component :is="Component" v-if="viewRoute.path === '/canvas'" />
|
||
<transition v-else name="fade" mode="out-in">
|
||
<component :is="Component" />
|
||
</transition>
|
||
</router-view>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 悬浮胶囊导航:不压满宽黑线,毛玻璃胶囊浮在页面上方;滚动后展开为通栏 */
|
||
.top-nav {
|
||
padding-top: 10px;
|
||
background: transparent;
|
||
pointer-events: none;
|
||
transition: padding-top 0.22s ease;
|
||
}
|
||
.nav-inner {
|
||
pointer-events: auto;
|
||
height: 64px;
|
||
width: min(1200px, calc(100% - 40px));
|
||
padding: 0 14px 0 20px;
|
||
border-radius: 999px;
|
||
background: var(--menu-bg);
|
||
-webkit-backdrop-filter: blur(20px) saturate(1.7);
|
||
backdrop-filter: blur(20px) saturate(1.7);
|
||
border: 1px solid var(--ink-soft);
|
||
box-shadow: 0 8px 30px -14px rgb(15 23 42 / 0.18);
|
||
transition: width 0.25s ease, border-radius 0.25s ease, border-color 0.25s ease,
|
||
box-shadow 0.25s ease, background 0.25s ease;
|
||
}
|
||
.top-nav.scrolled { padding-top: 0; }
|
||
.top-nav.scrolled .nav-inner {
|
||
width: 100%;
|
||
border-radius: 0;
|
||
border-color: transparent;
|
||
border-bottom: 1px solid var(--ink-soft);
|
||
box-shadow: 0 10px 30px -12px rgb(15 23 42 / 0.12);
|
||
}
|
||
html.dark .nav-inner { box-shadow: 0 8px 30px -14px rgb(0 0 0 / 0.5); }
|
||
|
||
.mobile-panel {
|
||
pointer-events: auto;
|
||
width: min(1200px, calc(100% - 32px));
|
||
margin: 6px auto 0;
|
||
border-radius: 1rem;
|
||
background: var(--menu-bg);
|
||
-webkit-backdrop-filter: blur(18px);
|
||
backdrop-filter: blur(18px);
|
||
border: 1px solid var(--ink-soft);
|
||
box-shadow: var(--glass-shadow-lg);
|
||
}
|
||
|
||
.top-link {
|
||
padding: 0.55rem 1.1rem;
|
||
border-radius: 999px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--fg-2);
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: color 0.2s ease, background 0.2s ease, transform 0.15s ease;
|
||
}
|
||
.top-link:active { transform: scale(0.95); }
|
||
.top-link:hover { color: var(--fg); background: var(--hover); }
|
||
.home-bg .top-link.on { color: #18181b; background: rgba(24, 24, 27, 0.07); }
|
||
.home-bg .brand-spark { display: none; }
|
||
.top-link.on {
|
||
color: var(--m-orange);
|
||
font-weight: 700;
|
||
background: rgb(24 24 27 / 0.12);
|
||
}
|
||
|
||
.brand-spark {
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
color: var(--m-orange);
|
||
animation: brandSpark 3.5s ease-in-out infinite;
|
||
}
|
||
@keyframes brandSpark {
|
||
0%, 100% { opacity: 0.55; transform: scale(0.9) rotate(0deg); }
|
||
50% { opacity: 1; transform: scale(1.15) rotate(20deg); }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { .brand-spark { animation: none; } }
|
||
|
||
.m-link {
|
||
display: block;
|
||
padding: 0.6rem 0.75rem;
|
||
border-radius: 0.6rem;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--fg-2);
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
.m-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(24 24 27 / 0.3); }
|
||
.m-link.m-on {
|
||
color: #fff;
|
||
font-weight: 700;
|
||
background: linear-gradient(120deg, #18181b, #27272a);
|
||
border-color: transparent;
|
||
box-shadow: 0 4px 14px var(--accent-glow);
|
||
}
|
||
|
||
.round-btn {
|
||
width: 2.125rem;
|
||
height: 2.125rem;
|
||
border-radius: 0.6rem;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--fg-3);
|
||
background: var(--surface);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid var(--ink-soft);
|
||
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
.round-btn:hover { color: var(--fg); background: var(--hover); border-color: rgb(24 24 27 / 0.4); box-shadow: 0 4px 14px rgb(24 24 27 / 0.16); transform: translateY(-1px); }
|
||
/* 首页整页淡紫渐变底(铺满整个页面高度) */
|
||
.home-bg {
|
||
background: #fafaf8 !important;
|
||
}
|
||
/* 游客登录入口:vivid 同款渐变胶囊 */
|
||
.login-pill {
|
||
display: inline-flex; align-items: center;
|
||
padding: 8px 18px; border: none; border-radius: 999px;
|
||
background: #18181b;
|
||
color: #fff; font-family: inherit; font-size: 13px; font-weight: 700;
|
||
cursor: pointer; box-shadow: 0 8px 20px -8px rgba(9, 9, 11, 0.5);
|
||
transition: transform 0.15s ease, filter 0.2s ease;
|
||
}
|
||
.login-pill:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||
.round-btn:active { transform: scale(0.95) translateY(1px); box-shadow: none; }
|
||
|
||
/* mobile-only: .round-btn's display:grid outranks Tailwind's md:hidden, so
|
||
the breakpoint lives here instead */
|
||
@media (min-width: 768px) { .hamburger { display: none; } }
|
||
|
||
.solid-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.5rem 1.1rem;
|
||
border-radius: 0.65rem;
|
||
font-size: 13.5px;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
|
||
border: 1px solid rgb(255 255 255 / 0.16);
|
||
cursor: pointer;
|
||
box-shadow: 0 6px 18px var(--accent-glow);
|
||
transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
.solid-btn:hover { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 10px 26px var(--accent-glow); }
|
||
.solid-btn:active { transform: scale(0.97) translateY(1px); box-shadow: 0 3px 10px var(--accent-glow); }
|
||
|
||
.name-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
max-width: 10rem;
|
||
height: 2.125rem;
|
||
padding: 0 0.7rem 0 0.85rem;
|
||
border-radius: 0.6rem;
|
||
border: 1px solid var(--ink-soft);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--fg);
|
||
background: var(--surface);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
backdrop-filter: blur(10px);
|
||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
.name-btn:hover {
|
||
background: var(--hover);
|
||
border-color: rgb(24 24 27 / 0.4);
|
||
box-shadow: 0 4px 14px rgb(24 24 27 / 0.16);
|
||
transform: translateY(-1px);
|
||
}
|
||
.name-txt {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.menu {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 2.75rem;
|
||
width: 11.5rem;
|
||
padding: 0.4rem;
|
||
border-radius: 0.85rem;
|
||
background: var(--menu-bg);
|
||
-webkit-backdrop-filter: blur(18px);
|
||
backdrop-filter: blur(18px);
|
||
border: 1px solid var(--glass-border);
|
||
box-shadow: var(--glass-shadow-lg);
|
||
z-index: 50;
|
||
}
|
||
.menu-item {
|
||
display: block;
|
||
padding: 0.5rem 0.65rem;
|
||
border-radius: 0.5rem;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--fg-2);
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
.menu-item:hover { background: var(--hover); color: var(--fg); }
|
||
.menu-sep { height: 1px; background: var(--hairline); margin: 0.35rem 0.25rem; }
|
||
|
||
.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>
|