feat(nav): 余额与后台入口并入用户下拉,下拉重新设计

顶栏右侧只留头像+用户名胶囊;下拉含用户信息头、
余额卡(点击去充值)、带图标的菜单项、管理后台、退出登录。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-27 16:26:18 +08:00
co-authored by Devin
parent f6e916c985
commit 5e42db52b9
+106 -48
View File
@@ -28,10 +28,10 @@ const nav = computed(() => {
// Avatar menu — only the surfaces the top nav doesn't already expose // Avatar menu — only the surfaces the top nav doesn't already expose
// (画图/图片 live in the bar, so they're intentionally left out here). // (画图/图片 live in the bar, so they're intentionally left out here).
const userMenu = [ const userMenu = [
{ to: '/mylogs', label: '生成日志' }, { to: '/mylogs', label: '生成日志', icon: 'log' },
{ to: '/invite', label: '邀请返利' }, { to: '/invite', label: '邀请返利', icon: 'team' },
{ to: '/orders', label: '订单' }, { to: '/orders', label: '订单', icon: 'receipt' },
{ to: '/settings', label: '设置' }, { to: '/settings', label: '设置', icon: 'config' },
] ]
const menuOpen = ref(false) // avatar dropdown const menuOpen = ref(false) // avatar dropdown
@@ -47,6 +47,7 @@ onUnmounted(() => window.removeEventListener('scroll', onScroll))
// credits — the logged-in user's real server-side balance (auth.user.credits) // credits — the logged-in user's real server-side balance (auth.user.credits)
const credits = computed(() => Number(auth.user?.credits || 0)) const credits = computed(() => Number(auth.user?.credits || 0))
const creditsLabel = computed(() => pointsLabel(credits.value)) const creditsLabel = computed(() => pointsLabel(credits.value))
const avatarChar = computed(() => Array.from(displayName.value)[0]?.toUpperCase() || '?')
const displayName = computed(() => { const displayName = computed(() => {
const n = (auth.user?.name || '').trim() const n = (auth.user?.name || '').trim()
if (n) return n if (n) return n
@@ -98,39 +99,46 @@ async function onLogout() {
<div class="flex-1"></div> <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" <button v-if="isAuthed() && announcement.content.trim()" type="button"
@click="openAnnouncement" class="round-btn" title="公告"> @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> <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> </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 --> <!-- guest actions -->
<template v-if="!isAuthed()"> <template v-if="!isAuthed()">
<button type="button" class="login-pill" @click="openLogin('/user')">登录 / 注册</button> <button type="button" class="login-pill" @click="openLogin('/user')">登录 / 注册</button>
</template> </template>
<!-- signed-in: avatar menu --> <!-- signed-in: avatar menu(余额 / 后台入口都收进来) -->
<div v-else class="relative"> <div v-else class="relative">
<button type="button" class="name-btn" @click.stop="menuOpen = !menuOpen" title="我的"> <button type="button" class="name-btn" :class="{ open: menuOpen }" @click.stop="menuOpen = !menuOpen" title="我的">
<span class="avatar">{{ avatarChar }}</span>
<span class="name-txt">{{ displayName }}</span> <span class="name-txt">{{ displayName }}</span>
<svg class="w-3.5 h-3.5 shrink-0 transition-transform" :class="menuOpen && 'rotate-180'" <svg class="w-3.5 h-3.5 shrink-0 text-[color:var(--fg-3)] 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> 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> </button>
<div v-if="menuOpen" class="menu" @click.stop> <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-head">
<span class="avatar avatar--lg">{{ avatarChar }}</span>
<div class="min-w-0">
<div class="menu-name">{{ displayName }}</div>
<div v-if="auth.user?.email" class="menu-mail">{{ auth.user.email }}</div>
</div>
</div>
<router-link to="/settings" class="menu-balance">
<span class="mb-label">余额</span>
<span class="mb-val">{{ creditsLabel }}</span>
<span class="mb-go">充值 →</span>
</router-link>
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" class="menu-item">
<Icon :name="m.icon" :size="15" /> {{ m.label }}
</router-link>
<router-link v-if="isAdmin()" to="/admin/overview" class="menu-item">
<Icon name="shield" :size="15" /> 管理后台
</router-link>
<div class="menu-sep"></div> <div class="menu-sep"></div>
<button type="button" class="menu-item w-full text-left" @click="onLogout">退出登录</button> <button type="button" class="menu-item menu-item--out w-full text-left" @click="onLogout">退出登录</button>
</div> </div>
</div> </div>
@@ -149,6 +157,7 @@ async function onLogout() {
<template v-if="isAuthed()"> <template v-if="isAuthed()">
<div class="menu-sep"></div> <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> <router-link v-for="m in userMenu" :key="m.to" :to="m.to" active-class="m-on" class="m-link">{{ m.label }}</router-link>
<router-link v-if="isAdmin()" to="/admin/overview" class="m-link">管理后台</router-link>
</template> </template>
<template v-else> <template v-else>
<div class="menu-sep"></div> <div class="menu-sep"></div>
@@ -335,59 +344,108 @@ html.dark .nav-inner { box-shadow: 0 8px 30px -14px rgb(0 0 0 / 0.5); }
.name-btn { .name-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.5rem;
max-width: 10rem; max-width: 12rem;
height: 2.125rem; height: 2.5rem;
padding: 0 0.7rem 0 0.85rem; padding: 0 0.7rem 0 0.3rem;
border-radius: 0.6rem; border-radius: 999px;
border: 1px solid var(--ink-soft); border: 1px solid rgb(24 24 27 / 0.1);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: var(--fg); color: var(--fg);
background: var(--surface); background: #fff;
-webkit-backdrop-filter: blur(10px); transition: background 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
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 { .name-btn:hover, .name-btn.open {
background: var(--hover); border-color: rgb(24 24 27 / 0.28);
border-color: rgb(24 24 27 / 0.4); box-shadow: 0 4px 14px rgb(24 24 27 / 0.1);
box-shadow: 0 4px 14px rgb(24 24 27 / 0.16);
transform: translateY(-1px);
} }
.name-txt { .name-txt {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.avatar {
flex-shrink: 0;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
display: grid;
place-items: center;
background: #18181b;
color: #fff;
font-size: 13px;
font-weight: 800;
}
.avatar--lg { width: 2.6rem; height: 2.6rem; font-size: 16px; }
.menu { .menu {
position: absolute; position: absolute;
right: 0; right: 0;
top: 2.75rem; top: 3.1rem;
width: 11.5rem; width: 16rem;
padding: 0.4rem; padding: 0.5rem;
border-radius: 0.85rem; border-radius: 1rem;
background: var(--menu-bg); background: #fff;
-webkit-backdrop-filter: blur(18px); border: 1px solid rgb(24 24 27 / 0.08);
backdrop-filter: blur(18px); box-shadow: 0 2px 6px rgb(24 24 27 / 0.05), 0 24px 48px -12px rgb(24 24 27 / 0.22);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow-lg);
z-index: 50; z-index: 50;
} }
.menu-head {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0.5rem 0.65rem;
}
.menu-name {
font-size: 14px;
font-weight: 800;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-mail {
margin-top: 1px;
font-size: 12px;
color: var(--fg-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-balance {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 0 0 0.35rem;
padding: 0.7rem 0.8rem;
border-radius: 0.75rem;
background: #f4f4f5;
text-decoration: none;
transition: background 0.15s ease;
}
.menu-balance:hover { background: #ececee; }
.mb-label { font-size: 12px; font-weight: 600; color: var(--fg-3); }
.mb-val { font-size: 20px; font-weight: 800; color: var(--fg); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.mb-go { margin-left: auto; font-size: 12px; font-weight: 700; color: var(--fg-2); }
.menu-item { .menu-item {
display: block; display: flex;
padding: 0.5rem 0.65rem; align-items: center;
border-radius: 0.5rem; gap: 0.6rem;
padding: 0.55rem 0.7rem;
border-radius: 0.6rem;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--fg-2); color: var(--fg-2);
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
text-decoration: none;
} }
.menu-item:hover { background: var(--hover); color: var(--fg); } .menu-item:hover { background: rgb(24 24 27 / 0.06); color: var(--fg); }
.menu-item--out { color: #dc2626; }
.menu-item--out:hover { background: rgb(220 38 38 / 0.08); color: #dc2626; }
.menu-sep { height: 1px; background: var(--hairline); margin: 0.35rem 0.25rem; } .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-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }