Files
billai/web/src/routes/+layout.svelte
clz 829b3445bc feat: 添加用户登录认证功能
- 新增登录页面(使用 shadcn-svelte 组件)
- 后端添加 JWT 认证 API (/api/auth/login, /api/auth/validate)
- 用户账号通过 server/config.yaml 配置
- 前端路由保护(未登录跳转登录页)
- 侧边栏显示当前用户信息
- 支持退出登录功能
2026-01-11 18:50:01 +08:00

336 lines
12 KiB
Svelte

<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { checkHealth } from '$lib/api';
import { auth, type User as AuthUser } from '$lib/stores/auth';
import * as Sidebar from '$lib/components/ui/sidebar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar';
import { Separator } from '$lib/components/ui/separator';
// Icons
import Upload from '@lucide/svelte/icons/upload';
import ClipboardCheck from '@lucide/svelte/icons/clipboard-check';
import FileText from '@lucide/svelte/icons/file-text';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Settings from '@lucide/svelte/icons/settings';
import HelpCircle from '@lucide/svelte/icons/help-circle';
import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
import Wallet from '@lucide/svelte/icons/wallet';
import LogOut from '@lucide/svelte/icons/log-out';
import User from '@lucide/svelte/icons/user';
import Bell from '@lucide/svelte/icons/bell';
import Sparkles from '@lucide/svelte/icons/sparkles';
// Theme
import {
type ThemeMode,
themeConfig,
getNextTheme,
applyThemeToDocument,
loadThemeFromStorage,
saveThemeToStorage
} from '$lib/config/theme';
let { children } = $props();
let themeMode = $state<ThemeMode>('system');
let serverOnline = $state(true);
let checkingHealth = $state(true);
let isAuthenticated = $state(false);
let currentUser = $state<AuthUser | null>(null);
// 订阅认证状态
$effect(() => {
const unsubscribe = auth.subscribe(state => {
isAuthenticated = state.isAuthenticated;
currentUser = state.user;
});
return unsubscribe;
});
async function checkServerHealth() {
checkingHealth = true;
serverOnline = await checkHealth();
checkingHealth = false;
}
// 登出
function handleLogout() {
auth.logout();
goto('/login');
}
onMount(() => {
themeMode = loadThemeFromStorage();
applyThemeToDocument(themeMode);
// 检查登录状态,未登录则跳转到登录页
const pathname = $page.url.pathname;
if (!auth.check() && pathname !== '/login' && pathname !== '/health') {
goto('/login');
return;
}
// 检查服务器状态
checkServerHealth();
// 每 30 秒检查一次
const healthInterval = setInterval(checkServerHealth, 30000);
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => applyThemeToDocument(themeMode);
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
clearInterval(healthInterval);
};
});
function cycleTheme() {
themeMode = getNextTheme(themeMode);
saveThemeToStorage(themeMode);
applyThemeToDocument(themeMode);
}
// 主导航
const navMain = [
{ href: '/', label: '上传账单', icon: Upload },
{ href: '/review', label: '智能复核', icon: ClipboardCheck },
{ href: '/bills', label: '账单管理', icon: FileText },
{ href: '/analysis', label: '数据分析', icon: BarChart3 },
];
// 次级导航(底部)
const navSecondary = [
{ href: '/settings', label: '设置', icon: Settings },
{ href: '/help', label: '帮助', icon: HelpCircle },
];
// 用户数据(从认证状态中获取)
let user = $derived({
name: currentUser?.username || '用户',
email: currentUser?.email || 'user@example.com',
avatar: currentUser?.avatar || ''
});
function isActive(href: string, pathname: string): boolean {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
}
// 根据路径获取页面标题
function getPageTitle(pathname: string): string {
const titles: Record<string, string> = {
'/': '上传账单',
'/review': '智能复核',
'/bills': '账单管理',
'/analysis': '数据分析',
'/settings': '设置',
'/help': '帮助'
};
return titles[pathname] || 'BillAI';
}
// 检查是否是登录页面(登录页不显示侧边栏)
let isLoginPage = $derived($page.url.pathname === '/login');
</script>
{#if isLoginPage}
{@render children()}
{:else}
<Sidebar.Provider>
<Sidebar.Root collapsible="offcanvas">
<!-- Header: Logo + App Name -->
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton class="!p-1.5">
{#snippet child({ props })}
<a href="/" {...props} class="flex items-center gap-2">
<div class="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-amber-500 text-white">
<Wallet class="size-5" />
</div>
<span class="text-base font-semibold">BillAI</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<!-- 主导航 -->
<Sidebar.Group>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each navMain as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive(item.href, $page.url.pathname)}>
{#snippet child({ props })}
<a href={item.href} {...props}>
<item.icon class="size-4" />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
<!-- 次级导航 (底部) -->
<Sidebar.Group class="mt-auto">
<Sidebar.GroupContent>
<Sidebar.Menu>
<!-- 主题切换 -->
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
{@const theme = themeConfig[themeMode]}
<button {...props} onclick={cycleTheme}>
<theme.icon class="size-4" />
<span>{theme.label}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#each navSecondary as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<a href={item.href} {...props}>
<item.icon class="size-4" />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<!-- Footer: 用户信息 -->
<Sidebar.Footer>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar.Root class="h-8 w-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 text-white font-medium">
{user.name.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{user.name}</span>
<span class="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-[--bits-dropdown-menu-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="h-8 w-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 text-white font-medium">
{user.name.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{user.name}</span>
<span class="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<Sparkles class="mr-2 size-4" />
升级到 Pro
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<User class="mr-2 size-4" />
账户
</DropdownMenu.Item>
<DropdownMenu.Item>
<Settings class="mr-2 size-4" />
设置
</DropdownMenu.Item>
<DropdownMenu.Item>
<Bell class="mr-2 size-4" />
通知
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleLogout}>
<LogOut class="mr-2 size-4" />
退出登录
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
<Sidebar.Inset>
<header class="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 h-4" />
<h1 class="text-lg font-semibold">{getPageTitle($page.url.pathname)}</h1>
<div class="flex-1"></div>
<div class="flex items-center gap-3">
<button
class="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity"
onclick={checkServerHealth}
title="点击刷新状态"
>
{#if checkingHealth}
<span class="relative flex h-2 w-2">
<span class="relative inline-flex rounded-full h-2 w-2 bg-gray-400 animate-pulse"></span>
</span>
<span class="text-muted-foreground">检查中...</span>
{:else if serverOnline}
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-muted-foreground">服务运行中</span>
{:else}
<span class="relative flex h-2 w-2">
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span class="text-red-500">服务离线</span>
{/if}
</button>
</div>
</header>
<main class="flex-1 p-6">
{@render children()}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
{/if}