- 新增登录页面(使用 shadcn-svelte 组件) - 后端添加 JWT 认证 API (/api/auth/login, /api/auth/validate) - 用户账号通过 server/config.yaml 配置 - 前端路由保护(未登录跳转登录页) - 侧边栏显示当前用户信息 - 支持退出登录功能
336 lines
12 KiB
Svelte
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}
|