Browse Source

feat(ui): rich home page, sticky header with user menu and nav

Made-with: Cursor
feat/multitenant-hub
npmrun 10 hours ago
parent
commit
f522fdd049
  1. 240
      app/components/AppShell.vue
  2. 2
      app/composables/useAuthSession.ts
  3. 2
      app/layouts/default.vue
  4. 2
      app/layouts/not-login.vue
  5. 14
      app/layouts/public.vue
  6. 259
      app/pages/index/index.vue
  7. 20
      server/api/auth/me.get.ts
  8. 14
      server/api/config/me.get.ts
  9. 12
      server/service/auth/index.ts

240
app/components/AppShell.vue

@ -1,83 +1,223 @@
<script setup lang="ts">
import { request, unwrapApiBody, type ApiResponse } from '../utils/http/factory'
import { useAuthSession } from '../composables/useAuthSession'
withDefaults(
defineProps<{
/** @deprecated 保留以兼容旧布局 */
showAuthActions?: boolean
}>(),
{
showAuthActions: false,
},
{ showAuthActions: false },
)
const menuItems = [
const route = useRoute()
const { loggedIn, user, refresh, clear, pending } = useAuthSession()
const { allowRegister } = useGlobalConfig()
const logoutLoading = ref(false)
onMounted(() => {
refresh(true).catch(() => {})
})
const displayName = computed(() => {
const u = user.value
if (!u) {
return ''
}
const nick = u.nickname?.trim()
return nick || u.username
})
const primaryNav = [
{ label: '首页', to: '/', icon: 'i-lucide-house' },
{ label: '控制台', to: '/me', icon: 'i-lucide-layout-dashboard' },
{ label: '资料', to: '/me/profile', icon: 'i-lucide-user-round' },
{ label: '文章', to: '/me/posts', icon: 'i-lucide-file-text' },
{ label: '时光机', to: '/me/timeline', icon: 'i-lucide-history' },
{ label: 'RSS', to: '/me/rss', icon: 'i-lucide-rss' },
] as const
function navActive(to: string) {
if (to === '/') {
return route.path === '/'
}
return route.path === to || route.path.startsWith(`${to}/`)
}
const accountMenuItems = computed(() => {
const u = user.value
if (!u) {
return [] as { label: string; icon: string; to?: string; target?: string }[][]
}
const groups: { label: string; icon: string; to?: string; target?: string }[][] = [
[
{
label: '首页',
icon: 'i-lucide-house',
to: '/',
},
{
label: '文档',
icon: 'i-lucide-book-open',
children: [
{
label: 'Nuxt',
to: 'https://nuxt.com/docs',
target: '_blank',
},
{
label: 'Nuxt UI',
to: 'https://ui.nuxt.com/getting-started',
target: '_blank',
},
{ label: '控制台', icon: 'i-lucide-layout-dashboard', to: '/me' },
{ label: '个人资料', icon: 'i-lucide-user-round', to: '/me/profile' },
],
},
{
label: '示例',
icon: 'i-lucide-layout-grid',
children: [
]
if (u.publicSlug) {
groups.push([
{
label: 'Hello API',
to: '/api/hello',
label: '公开主页',
icon: 'i-lucide-external-link',
to: `/@${u.publicSlug}`,
target: '_blank',
},
],
},
],
]
])
}
if (u.role === 'admin') {
groups.push([{ label: '用户管理', icon: 'i-lucide-users', to: '/me/admin/users' }])
}
return groups
})
const { allowRegister } = useGlobalConfig()
async function logout() {
logoutLoading.value = true
try {
unwrapApiBody(await request<ApiResponse<{ success: boolean }>>('/api/auth/logout', { method: 'POST' }))
clear()
await navigateTo('/login')
} finally {
logoutLoading.value = false
}
}
</script>
<template>
<UApp>
<div class="min-h-screen bg-default text-default flex flex-col">
<header class="border-b border-default">
<UContainer class="h-14 flex items-center justify-between">
<div class="flex items-center gap-2">
<NuxtLink to="/" class="font-semibold tracking-tight">
BigHouse
<header class="sticky top-0 z-40 border-b border-default/80 bg-default/95 backdrop-blur supports-[backdrop-filter]:bg-default/80">
<UContainer class="flex h-16 items-center justify-between gap-4">
<div class="flex min-w-0 flex-1 items-center gap-6">
<NuxtLink
to="/"
class="flex shrink-0 items-center gap-2 font-semibold tracking-tight text-highlighted"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon name="i-lucide-orbit" class="size-4" />
</span>
<span class="hidden sm:inline">Person Panel</span>
</NuxtLink>
<UDropdownMenu :items="menuItems" :content="{ align: 'end' }">
<UButton color="neutral" variant="ghost" label="菜单" icon="i-lucide-menu" />
<nav
v-if="loggedIn"
class="hidden min-w-0 flex-1 items-center gap-0.5 overflow-x-auto md:flex"
aria-label="主导航"
>
<UButton
v-for="item in primaryNav"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
color="neutral"
variant="ghost"
size="sm"
:class="[
'shrink-0 rounded-md',
navActive(item.to) ? 'bg-elevated text-highlighted' : 'text-muted',
]"
/>
</nav>
<UDropdownMenu
v-if="loggedIn"
class="md:hidden"
:items="[
primaryNav.map((i) => ({
label: i.label,
icon: i.icon,
to: i.to,
})),
]"
:content="{ align: 'start' }"
>
<UButton color="neutral" variant="outline" icon="i-lucide-menu" size="sm" label="菜单" />
</UDropdownMenu>
</div>
<div v-if="showAuthActions" class="flex items-center gap-2">
<UButton color="neutral" variant="ghost" to="/login" label="登录" />
<UButton v-if="allowRegister" color="neutral" variant="outline" to="/register" label="注册" />
<div class="flex shrink-0 items-center gap-2">
<template v-if="pending && !loggedIn">
<USkeleton class="h-9 w-24 rounded-md" />
</template>
<template v-else-if="loggedIn && user">
<div class="hidden items-center gap-2 sm:flex">
<UBadge
v-if="user.role === 'admin'"
color="primary"
variant="subtle"
size="xs"
>
管理员
</UBadge>
<UBadge
v-else
color="neutral"
variant="subtle"
size="xs"
>
用户
</UBadge>
</div>
<UDropdownMenu :items="accountMenuItems" :content="{ align: 'end' }">
<UButton color="neutral" variant="ghost" class="gap-2 px-2">
<UAvatar
:src="user.avatar || undefined"
:alt="displayName"
size="sm"
class="ring-1 ring-default"
/>
<span class="max-w-[8rem] truncate text-sm font-medium text-highlighted">
{{ displayName }}
</span>
<span class="hidden text-xs text-muted lg:inline">@{{ user.username }}</span>
<UIcon name="i-lucide-chevrons-up-down" class="size-4 text-muted" />
</UButton>
</UDropdownMenu>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-log-out"
:loading="logoutLoading"
class="hidden sm:inline-flex"
aria-label="退出登录"
@click="logout"
/>
</template>
<template v-else>
<UButton to="/login" color="neutral" variant="ghost" label="登录" />
<UButton
v-if="allowRegister"
to="/register"
color="neutral"
variant="outline"
label="注册"
/>
</template>
</div>
</UContainer>
</header>
<main class="flex-1">
<UContainer class="py-8">
<slot />
</UContainer>
</main>
<footer class="border-t border-default">
<UContainer class="h-12 flex items-center text-sm text-muted">
Built with Nuxt + Nuxt UI & BigHouse
<footer class="border-t border-default/80 bg-elevated/30">
<UContainer class="flex flex-col gap-2 py-6 text-sm text-muted sm:flex-row sm:items-center sm:justify-between">
<span>Person Panel 个人资料文章时光机与 RSS</span>
<div class="flex gap-4">
<NuxtLink to="/" class="hover:text-default">
首页
</NuxtLink>
<NuxtLink v-if="loggedIn" to="/me" class="hover:text-default">
控制台
</NuxtLink>
</div>
</UContainer>
</footer>
</div>

2
app/composables/useAuthSession.ts

@ -5,6 +5,8 @@ export type AuthUser = {
username: string;
role: string;
publicSlug: string | null;
nickname: string | null;
avatar: string | null;
};
type MeResult = {

2
app/layouts/default.vue

@ -1,5 +1,7 @@
<template>
<AppShell>
<UContainer class="py-8">
<slot />
</UContainer>
</AppShell>
</template>

2
app/layouts/not-login.vue

@ -1,5 +1,5 @@
<template>
<AppShell show-auth-actions>
<AppShell>
<slot />
</AppShell>
</template>

14
app/layouts/public.vue

@ -1,12 +1,18 @@
<template>
<UApp>
<div class="min-h-screen bg-default text-default">
<header class="border-b border-default">
<UContainer class="h-14 flex items-center justify-between">
<NuxtLink to="/" class="font-semibold tracking-tight">
<header class="sticky top-0 z-40 border-b border-default/80 bg-default/95 backdrop-blur">
<UContainer class="flex h-14 items-center justify-between gap-4">
<NuxtLink to="/" class="flex items-center gap-2 font-semibold tracking-tight text-highlighted">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon name="i-lucide-orbit" class="size-4" />
</span>
Person Panel
</NuxtLink>
<div class="flex gap-2 text-sm">
<div class="flex items-center gap-3 text-sm">
<NuxtLink to="/" class="text-muted hover:text-default">
站点首页
</NuxtLink>
<NuxtLink to="/login" class="text-muted hover:text-default">
登录
</NuxtLink>

259
app/pages/index/index.vue

@ -7,6 +7,33 @@ const { allowRegister } = useGlobalConfig()
const logoutLoading = ref(false)
const features = [
{
title: '个人资料',
desc: '生平叙事、头像、社交链接与公开主页 slug,支持分块可见性。',
icon: 'i-lucide-user-round',
to: '/me/profile',
},
{
title: 'Markdown 文章',
desc: '自写内容、slug、摘要与封面,每条可公开、私密或仅链接分享。',
icon: 'i-lucide-file-text',
to: '/me/posts',
},
{
title: '时光机',
desc: '按时间轴记录里程碑事件,与文章、阅读流在同一叙事里呈现。',
icon: 'i-lucide-history',
to: '/me/timeline',
},
{
title: 'RSS 收件箱',
desc: '订阅源仅自己可见;定时拉取、去重,条目默认可再设为公开展示。',
icon: 'i-lucide-rss',
to: '/me/rss',
},
] as const
async function logout() {
logoutLoading.value = true
try {
@ -20,72 +47,208 @@ async function logout() {
</script>
<template>
<div class="max-w-5xl mx-auto py-8 space-y-6">
<UCard v-if="loggedIn">
<template #header>
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">欢迎回来{{ user?.username }}</h1>
<p class="text-sm text-muted mt-1">你已登录可访问站内受保护内容</p>
<div class="mx-auto max-w-6xl space-y-12 pb-4">
<!-- Hero -->
<section class="relative overflow-hidden rounded-2xl border border-default bg-gradient-to-br from-primary/5 via-default to-elevated px-6 py-12 sm:px-10 sm:py-16">
<div class="pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl" />
<div class="pointer-events-none absolute -bottom-24 -left-16 size-72 rounded-full bg-primary/5 blur-3xl" />
<div class="relative max-w-2xl space-y-4">
<UBadge color="primary" variant="subtle" size="md">
个人数据中心
</UBadge>
<h1 class="text-3xl font-bold tracking-tight text-highlighted sm:text-4xl">
把你的故事文章与阅读收进一个地方
</h1>
<p class="text-base text-muted sm:text-lg leading-relaxed">
Person Panel 面向多用户邀请制场景每位用户独立资料与订阅公开主页可只展示你愿意公开的部分RSS 在后台静默同步
</p>
<div class="flex flex-wrap gap-3 pt-2">
<template v-if="loggedIn">
<UButton to="/me" size="lg" icon="i-lucide-layout-dashboard">
进入控制台
</UButton>
<UButton
v-if="user?.publicSlug"
:to="`/@${user.publicSlug}`"
target="_blank"
size="lg"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
>
预览公开主页
</UButton>
<UButton
v-else
to="/me/profile"
size="lg"
color="neutral"
variant="outline"
icon="i-lucide-link"
>
设置公开链接
</UButton>
</template>
<template v-else>
<UButton to="/login" size="lg" icon="i-lucide-log-in">
登录使用
</UButton>
<UButton
v-if="allowRegister"
to="/register"
size="lg"
color="neutral"
variant="outline"
>
注册账号
</UButton>
</template>
</div>
<UBadge color="success" variant="subtle">已登录</UBadge>
</div>
</template>
</section>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<UCard>
<p class="text-xs text-muted">状态</p>
<p class="mt-1 font-medium">会话有效</p>
</UCard>
<UCard>
<p class="text-xs text-muted">入口</p>
<p class="mt-1 font-medium">首页 / 配置 / API</p>
<!-- 已登录摘要条 -->
<section v-if="loggedIn && user" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UCard class="ring-1 ring-default/60">
<div class="flex items-start gap-3">
<UAvatar :src="user.avatar || undefined" :alt="user.username" size="md" class="ring-1 ring-default" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-muted">
当前用户
</p>
<p class="truncate font-semibold text-highlighted">
{{ user.nickname?.trim() || user.username }}
</p>
<p class="truncate text-xs text-muted">
@{{ user.username }} · {{ user.role === 'admin' ? '管理员' : '用户' }}
</p>
</div>
</div>
</UCard>
<UCard>
<p class="text-xs text-muted">建议</p>
<p class="mt-1 font-medium">可继续扩展个人中心</p>
<p class="text-xs font-medium text-muted">
公开主页
</p>
<p class="mt-1 font-semibold text-highlighted">
{{ user.publicSlug ? `/@${user.publicSlug}` : '尚未设置' }}
</p>
<UButton to="/me/profile" size="xs" variant="link" class="mt-2 px-0">
去资料页配置
</UButton>
</UCard>
</div>
<div class="mt-5 flex flex-wrap items-center gap-3">
<UButton to="/me" variant="solid">
进入控制台
<UCard class="sm:col-span-2">
<p class="text-xs font-medium text-muted">
快捷入口
</p>
<div class="mt-2 flex flex-wrap gap-2">
<UButton to="/me/posts" size="xs" variant="soft">
文章
</UButton>
<UButton color="neutral" :loading="logoutLoading" @click="logout">
退出登录
<UButton to="/me/timeline" size="xs" variant="soft">
时光机
</UButton>
<UButton to="/me/rss" size="xs" variant="soft">
RSS
</UButton>
<UButton to="/me/profile" size="xs" variant="soft">
资料
</UButton>
<UButton to="/api/config/me" target="_blank" variant="outline">
配置 API
<UButton
v-if="user.role === 'admin'"
to="/me/admin/users"
size="xs"
color="primary"
variant="soft"
>
用户管理
</UButton>
</div>
</UCard>
</section>
<UCard v-else>
<template #header>
<div class="flex items-start justify-between gap-4">
<UAlert
v-if="loggedIn && user && !user.publicSlug"
color="warning"
variant="subtle"
title="还没有公开主页地址"
description="在「资料」中设置 public slug 后,访客即可通过 /@你的地址 访问你的公开内容。"
icon="i-lucide-alert-circle"
/>
<!-- 功能矩阵 -->
<section class="space-y-4">
<div>
<h1 class="text-2xl font-semibold">欢迎来到本站</h1>
<p class="text-sm text-muted mt-1">当前为访客模式仅开放首页与认证页面</p>
<h2 class="text-xl font-semibold text-highlighted">
你能做什么
</h2>
<p class="mt-1 text-sm text-muted">
四大模块覆盖展示创作与阅读输入权限与可见性按条目细粒度控制
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<UCard
v-for="item in features"
:key="item.title"
class="group transition-shadow hover:shadow-md"
>
<div class="flex gap-4">
<span class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<UIcon :name="item.icon" class="size-6" />
</span>
<div class="min-w-0 flex-1 space-y-2">
<h3 class="font-semibold text-highlighted">
{{ item.title }}
</h3>
<p class="text-sm leading-relaxed text-muted">
{{ item.desc }}
</p>
<UButton
v-if="loggedIn"
:to="item.to"
size="xs"
variant="link"
class="px-0"
trailing-icon="i-lucide-arrow-right"
>
打开
</UButton>
</div>
<UBadge color="neutral" variant="subtle">未登录</UBadge>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<UCard>
<p class="text-xs text-muted">访问策略</p>
<p class="mt-1 font-medium">登录优先白名单放行</p>
</UCard>
<UCard>
<p class="text-xs text-muted">安全能力</p>
<p class="mt-1 font-medium">API 默认鉴权 + 401 统一处理</p>
</UCard>
</div>
</section>
<div class="mt-5 flex gap-3">
<UButton to="/login">去登录</UButton>
<UButton v-if="allowRegister" to="/register" color="neutral" variant="outline">去注册</UButton>
<!-- 访客说明 -->
<section v-if="!loggedIn" class="rounded-xl border border-dashed border-default bg-elevated/30 px-6 py-8">
<h3 class="font-semibold text-highlighted">
访客说明
</h3>
<p class="mt-2 max-w-2xl text-sm text-muted leading-relaxed">
本站默认需登录后使用控制台若你已有账号请点击登录新账号由管理员在后台创建邀请制
</p>
<div class="mt-4 flex flex-wrap gap-2">
<UButton to="/login">
登录
</UButton>
<UButton v-if="allowRegister" to="/register" color="neutral" variant="outline">
注册
</UButton>
</div>
</UCard>
</section>
<!-- 已登录底部操作 -->
<section v-if="loggedIn" class="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-default bg-elevated/20 px-4 py-4">
<p class="text-sm text-muted">
需要退出当前会话
</p>
<div class="flex gap-2">
<UButton to="/api/config/me" target="_blank" variant="outline" color="neutral" size="sm">
配置 API调试
</UButton>
<UButton color="neutral" :loading="logoutLoading" size="sm" @click="logout">
退出登录
</UButton>
</div>
</section>
</div>
</template>

20
server/api/auth/me.get.ts

@ -1,5 +1,8 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { eq } from "drizzle-orm";
import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth";
import { clearSessionCookie, getSessionId } from "#server/service/auth/cookie";
import { clearSessionCookie } from "#server/service/auth/cookie";
import { toPublicAuthError } from "#server/service/auth/errors";
export default defineWrappedResponseHandler(async (event) => {
@ -13,8 +16,21 @@ export default defineWrappedResponseHandler(async (event) => {
});
}
const [row] = await dbGlobal
.select({
nickname: users.nickname,
avatar: users.avatar,
})
.from(users)
.where(eq(users.id, user.id))
.limit(1);
return R.success({
user,
user: {
...user,
nickname: row?.nickname ?? null,
avatar: row?.avatar ?? null,
},
});
} catch (err) {
throw toPublicAuthError(err);

14
server/api/config/me.get.ts

@ -1,3 +1,6 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { eq } from "drizzle-orm";
import { KNOWN_CONFIG_KEYS } from "#server/service/config/registry";
export default defineWrappedResponseHandler(async (event) => {
@ -10,12 +13,23 @@ export default defineWrappedResponseHandler(async (event) => {
}),
);
const [row] = await dbGlobal
.select({
nickname: users.nickname,
avatar: users.avatar,
})
.from(users)
.where(eq(users.id, user.id))
.limit(1);
return R.success({
user: {
id: user.id,
username: user.username,
role: user.role,
publicSlug: user.publicSlug,
nickname: row?.nickname ?? null,
avatar: row?.avatar ?? null,
},
config: Object.fromEntries(entries),
});

12
server/service/auth/index.ts

@ -22,6 +22,8 @@ export type MinimalUser = {
username: string;
role: string;
publicSlug: string | null;
nickname: string | null;
avatar: string | null;
};
export class AuthValidationError extends Error {
@ -110,6 +112,8 @@ async function insertUserWithRetry(
username: users.username,
role: users.role,
publicSlug: users.publicSlug,
nickname: users.nickname,
avatar: users.avatar,
});
return newUser as MinimalUser;
} catch (err) {
@ -161,6 +165,8 @@ export async function loginUser(payload: AuthPayload) {
status: users.status,
role: users.role,
publicSlug: users.publicSlug,
nickname: users.nickname,
avatar: users.avatar,
})
.from(users)
.where(eq(users.username, username));
@ -187,6 +193,8 @@ export async function loginUser(payload: AuthPayload) {
username: user.username,
role: user.role,
publicSlug: user.publicSlug,
nickname: user.nickname,
avatar: user.avatar,
} satisfies MinimalUser,
sessionId,
expiresAt,
@ -207,6 +215,8 @@ export async function getCurrentUser(sessionId: string): Promise<MinimalUser | n
username: users.username,
role: users.role,
publicSlug: users.publicSlug,
nickname: users.nickname,
avatar: users.avatar,
status: users.status,
expiresAt: sessions.expiresAt,
})
@ -229,5 +239,7 @@ export async function getCurrentUser(sessionId: string): Promise<MinimalUser | n
username: row.username,
role: row.role,
publicSlug: row.publicSlug,
nickname: row.nickname,
avatar: row.avatar,
};
}
Loading…
Cancel
Save