feat: 添加用户登录认证功能

- 新增登录页面(使用 shadcn-svelte 组件)
- 后端添加 JWT 认证 API (/api/auth/login, /api/auth/validate)
- 用户账号通过 server/config.yaml 配置
- 前端路由保护(未登录跳转登录页)
- 侧边栏显示当前用户信息
- 支持退出登录功能
This commit is contained in:
clz
2026-01-11 18:50:01 +08:00
parent 4884993d27
commit 829b3445bc
11 changed files with 639 additions and 16 deletions

179
web/src/lib/stores/auth.ts Normal file
View File

@@ -0,0 +1,179 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface User {
username: string;
name: string;
email?: string;
avatar?: string;
role: 'admin' | 'user';
}
export interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: string | null;
}
// API 基础路径
const API_BASE = '/api';
// 从 localStorage 恢复状态
function getInitialState(): AuthState {
if (browser) {
const stored = localStorage.getItem('auth');
if (stored) {
try {
const state = JSON.parse(stored);
if (state.isAuthenticated && state.token) {
return state;
}
localStorage.removeItem('auth');
} catch {
localStorage.removeItem('auth');
}
}
}
return {
isAuthenticated: false,
user: null,
token: null,
};
}
function createAuthStore() {
const { subscribe, set } = writable<AuthState>(getInitialState());
return {
subscribe,
// 通过后端 API 登录验证
loginAsync: async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
try {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.error || '登录失败' };
}
const { token, user } = result.data;
const state: AuthState = {
isAuthenticated: true,
user: {
username: user.username,
name: user.name,
email: user.email,
role: user.role,
},
token,
};
if (browser) {
localStorage.setItem('auth', JSON.stringify(state));
}
set(state);
return { success: true };
} catch (error) {
console.error('Login error:', error);
return { success: false, error: '网络错误,请稍后重试' };
}
},
// 验证 token 是否有效
validateToken: async (): Promise<boolean> => {
if (!browser) return false;
const stored = localStorage.getItem('auth');
if (!stored) return false;
try {
const state = JSON.parse(stored);
if (!state.token) return false;
const response = await fetch(`${API_BASE}/auth/validate`, {
headers: {
'Authorization': `Bearer ${state.token}`,
},
});
if (!response.ok) {
localStorage.removeItem('auth');
set({ isAuthenticated: false, user: null, token: null });
return false;
}
return true;
} catch {
return false;
}
},
// 兼容旧的同步登录方法
login: (user: User, token: string) => {
const state: AuthState = {
isAuthenticated: true,
user,
token,
};
if (browser) {
localStorage.setItem('auth', JSON.stringify(state));
}
set(state);
},
logout: () => {
if (browser) {
localStorage.removeItem('auth');
}
set({
isAuthenticated: false,
user: null,
token: null,
});
},
// 检查是否已登录
check: (): boolean => {
if (browser) {
const stored = localStorage.getItem('auth');
if (stored) {
try {
const state = JSON.parse(stored);
return state.isAuthenticated === true && !!state.token;
} catch {
return false;
}
}
}
return false;
},
// 获取当前 token
getToken: (): string | null => {
if (browser) {
const stored = localStorage.getItem('auth');
if (stored) {
try {
const state = JSON.parse(stored);
return state.token;
} catch {
return null;
}
}
}
return null;
}
};
}
export const auth = createAuthStore();

View File

@@ -1,8 +1,11 @@
<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';
@@ -37,6 +40,17 @@
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;
@@ -44,10 +58,23 @@
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 秒检查一次
@@ -83,12 +110,12 @@
{ href: '/help', label: '帮助', icon: HelpCircle },
];
// 用户数据
const user = {
name: '用户',
email: 'user@example.com',
avatar: ''
};
// 用户数据(从认证状态中获取)
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 === '/';
@@ -106,8 +133,14 @@
};
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 -->
@@ -249,7 +282,7 @@
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<DropdownMenu.Item onclick={handleLogout}>
<LogOut class="mr-2 size-4" />
退出登录
</DropdownMenu.Item>
@@ -266,7 +299,7 @@
<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 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"
@@ -299,3 +332,4 @@
</main>
</Sidebar.Inset>
</Sidebar.Provider>
{/if}

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import Wallet from '@lucide/svelte/icons/wallet';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Eye from '@lucide/svelte/icons/eye';
import EyeOff from '@lucide/svelte/icons/eye-off';
import Lock from '@lucide/svelte/icons/lock';
let username = $state('');
let password = $state('');
let isLoading = $state(false);
let errorMessage = $state('');
let showPassword = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
if (!username.trim() || !password.trim()) {
errorMessage = '请输入用户名和密码';
return;
}
isLoading = true;
errorMessage = '';
try {
// 使用验证登录
const result = await auth.loginAsync(username.trim(), password);
console.log('Login result:', result);
if (result.success) {
// 跳转到首页
await goto('/');
} else {
errorMessage = result.error || '登录失败';
}
} catch (err) {
console.error('Login error:', err);
errorMessage = '登录过程中发生错误';
} finally {
isLoading = false;
}
}
function togglePasswordVisibility() {
showPassword = !showPassword;
}
</script>
<svelte:head>
<title>登录 - BillAI</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
<div class="w-full max-w-md">
<!-- Logo 和标题 -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-orange-500 to-amber-500 text-white mb-4 shadow-lg">
<Wallet class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold tracking-tight">BillAI</h1>
<p class="text-muted-foreground mt-2">智能账单管理系统</p>
</div>
<!-- 登录卡片 -->
<Card.Root class="shadow-xl border-0">
<Card.Header class="space-y-1 pb-4">
<Card.Title class="text-2xl text-center">欢迎回来</Card.Title>
<Card.Description class="text-center">
请输入您的账号登录系统
</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit={handleLogin} class="space-y-4">
<!-- 错误提示 -->
{#if errorMessage}
<div class="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle class="h-4 w-4 shrink-0" />
<span>{errorMessage}</span>
</div>
{/if}
<!-- 用户名 -->
<div class="space-y-2">
<Label for="username">用户名</Label>
<Input
id="username"
type="text"
placeholder="请输入用户名"
bind:value={username}
disabled={isLoading}
autocomplete="username"
class="h-11"
/>
</div>
<!-- 密码 -->
<div class="space-y-2">
<Label for="password">密码</Label>
<div class="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="请输入密码"
bind:value={password}
disabled={isLoading}
autocomplete="current-password"
class="h-11 pr-10"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onclick={togglePasswordVisibility}
tabindex="-1"
>
{#if showPassword}
<EyeOff class="h-4 w-4" />
{:else}
<Eye class="h-4 w-4" />
{/if}
</button>
</div>
</div>
<!-- 记住我 & 忘记密码 -->
<div class="flex items-center justify-between text-sm">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="rounded border-gray-300" />
<span class="text-muted-foreground">记住我</span>
</label>
<button type="button" class="text-primary hover:underline">忘记密码?</button>
</div>
<!-- 登录按钮 -->
<Button type="submit" class="w-full h-11" disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
登录中...
{:else}
登录
{/if}
</Button>
</form>
</Card.Content>
</Card.Root>
<!-- 安全提示 -->
<div class="flex items-center justify-center gap-2 mt-4 text-xs text-muted-foreground">
<Lock class="h-3 w-3" />
<span>密码已加密传输</span>
</div>
<!-- 底部信息 -->
<p class="text-center text-sm text-muted-foreground mt-4">
© 2026 BillAI. All rights reserved.
</p>
</div>
</div>