refactor(auth): implement token-based authentication with JWT parsing

- Replace authStore with tokenService for authentication management
- Add JWT parsing utility to extract user info from tokens
- Update login flow to use cookie-based token storage
- Modify logout to properly clear auth state and cookies
- Integrate user data into page context for SSR compatibility
- Remove deprecated authStore and related localStorage logic
- Add cookie constants for consistent token handling
- Implement server-side token validation in hooks
- Update HTTP client to use token from cookies instead of store
- Refactor error handling to use unified ApiError class
- Replace manual redirect logic with resolved paths
- Improve type safety with explicit user and auth interfaces
- Add toast notifications for login/logout feedback
- Remove unused sidebar store and related UI logic
- Migrate theme handling to use cookies and context
- Update icon definitions and component references
- Clean up legacy code and unused imports
This commit is contained in:
Chaos
2025-11-25 16:53:48 +08:00
parent 8f3f2d63a0
commit 4ec8e88e58
32 changed files with 437 additions and 381 deletions

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="TypeScriptCompiler">
<option name="useTypesFromServer" value="true" />
</component>
</project>

View File

@@ -2,5 +2,6 @@
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

19
src/app.d.ts vendored
View File

@@ -2,11 +2,20 @@
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
interface User {
id: string;
username: string;
nickname: string;
avatar?: string;
roles: string[];
}
interface Locals {
user: User | null;
}
interface pageData {
user: User | null;
}
}
}

30
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Handle } from '@sveltejs/kit';
import { parseJwt } from '$lib/utils/tokenUtils.ts';
import type { JwtPayload } from '$lib/types/auth.ts';
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
export const handle: Handle = async ({ event, resolve}) =>{
const authorization = event.cookies.get(COOKIE_TOKEN_KEY);
if (authorization){
const split = authorization?.split(' ');
const token = split[1];
const jwt = parseJwt<JwtPayload>(token);
if (jwt){
event.locals.user = {
id: jwt.userId,
username: jwt.sub,
nickname: jwt.nickname,
avatar: jwt.avatar,
roles: jwt.authorities
}
}
}
return resolve(event);
}

View File

@@ -1,8 +1,6 @@
// src/lib/api/httpClient.ts
import { browser } from '$app/environment';
import type { HttpMethod, JsonObject, JsonValue } from '$lib/types/http.ts';
import { authStore } from '$lib/stores/authStore.ts';
import type { ApiResult } from '$lib/types/api.ts';
@@ -11,16 +9,8 @@ interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
}
const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
let currentToken: string | null = null;
let currentTokenHead: string | null = null;
if (browser) {
// 只有在浏览器环境下才订阅,防止 SSR 内存泄漏
authStore.subscribe(state => {
currentToken = state.token;
currentTokenHead = state.tokenHead;
});
}
const normalizeHeaders = (headers?: HeadersInit):Record<string, string> =>{
const result:Record<string,string> = {};
@@ -74,8 +64,8 @@ const httpRequest = async <T>(
const canHaveBody = method !== 'GET' ;
// 【修改点 2】只有在允许携带 Body 时才处理
if (canHaveBody) {
console.log('body', body);
if (body instanceof FormData) {
requestBody = body;
} else if (body) {
@@ -85,9 +75,9 @@ const httpRequest = async <T>(
}
// ... Token 处理逻辑保持不变 ...
if (currentToken && currentTokenHead) {
requestHeaders['authorization'] = `${currentTokenHead} ${currentToken}`;
}
// if (currentToken && currentTokenHead) {
// requestHeaders['authorization'] = `${currentTokenHead} ${currentToken}`;
// }
try {
const response = await fetch(fullUrl, {

View File

@@ -1,8 +1,7 @@
import { api } from '$lib/api/httpClient'; // 通常不需要 .ts 后缀
import type { AuthResponse, LoginPayload } from '$lib/types/auth';
import { authStore } from '$lib/stores/authStore';
import { toast } from '$lib/stores/toastStore';
import { ResponseError } from '$lib/types/error.ts';
import { ApiError } from '$lib/types/api.ts';
export const authService = {
/**
@@ -13,23 +12,22 @@ export const authService = {
const response = await api.post<AuthResponse>('/auth/login', payload);
if (response.code !== 200 || !response.data) {
throw new ResponseError(response);
throw new ApiError(response);
}
const { token, tokenHead,userProfile } = response.data;
authStore.update(s => ({ ...s, token, tokenHead, isAuthenticated: true,user: userProfile }));
return response.data;
},
/**
* 登出流程
*/
logout: async () => {
authStore.logout();
toast.success('退出登录成功');
try {
// Optionally call the backend logout endpoint
await api.post('/auth/logout', {});
} catch (error) {
// Even if the backend call fails, we still want to clear local state
console.warn('Logout API call failed:', error);
}
}
};

View File

@@ -0,0 +1,45 @@
import { api } from '$lib/api/httpClient';
import type { ApiResult } from '$lib/types/api';
import { authStore } from '$lib/stores/authStore';
import { browser } from '$app/environment';
export const tokenService = {
/**
* Check if the current token is valid
*/
validateToken: async (): Promise<boolean> => {
if (!browser) return false;
try {
const response = await api.get<null>('/auth/validate');
return response.code === 200;
} catch (error) {
console.error('Token validation failed:', error);
return false;
}
},
/**
* Refresh the current token
*/
refreshToken: async (): Promise<boolean> => {
if (!browser) return false;
try {
const response = await api.post<{token: string, tokenHead: string}>('/auth/refresh', {});
if (response.code === 200 && response.data) {
// Update the auth store with new token
authStore.update(state => ({
...state,
token: response.data!.token,
tokenHead: response.data!.tokenHead
}));
return true;
}
return false;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
};

View File

@@ -1,2 +0,0 @@

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { toast, type ToastType } from '$lib/stores/toastStore';
import { fly } from 'svelte/transition';
import { flip } from 'svelte/animate';
import Icon from '$lib/components/icon/Icon.svelte';
import type { IconId } from '$lib/types/icon-ids.ts';
import { getContext } from 'svelte';
import { TOAST_KEY, type ToastState, type ToastType } from '$lib/stores/toast.svelte.ts';
const toastState = getContext<ToastState>(TOAST_KEY);
const toastIconMap: Record<ToastType, IconId> = {
success: 'success',
@@ -14,7 +16,7 @@
</script>
<div class="toast toast-top toast-center z-50">
{#each $toast as t (t.id)}
{#each toastState.toasts as t (t.id)}
<div
animate:flip={{ duration: 300 }}
transition:fly={{ x: 100, duration: 300 }}

View File

@@ -0,0 +1,2 @@
export const COOKIE_TOKEN_KEY = 'authorization';
export const COOKIE_THEME_KEY = 'theme';

View File

@@ -125,4 +125,6 @@
</symbol>
<symbol id="auth" viewBox="0 0 16 16"><path fill="currentColor" d="M9.07 6.746a2 2 0 0 0 2.91.001l.324-.344c.297.14.577.316.835.519l-.126.422a2 2 0 0 0 1.456 2.518l.348.083a4.7 4.7 0 0 1 .011 1.017l-.46.117a2 2 0 0 0-1.431 2.479l.156.556q-.383.294-.822.497l-.337-.357a2 2 0 0 0-2.91-.002l-.325.345a4.3 4.3 0 0 1-.835-.519l.126-.423a2 2 0 0 0-1.456-2.518l-.35-.083a4.7 4.7 0 0 1-.01-1.016l.462-.118a2 2 0 0 0 1.43-2.478l-.156-.557q.383-.294.822-.497zm-1.423-4.6a.5.5 0 0 1 .707 0C9.594 3.39 10.97 4 12.5 4a.5.5 0 0 1 .5.5v1.1a5.5 5.5 0 0 0-7.494 7.204C3.846 11.59 3 9.812 3 7.502V4.5a.5.5 0 0 1 .5-.5c1.53 0 2.904-.61 4.147-1.854M10.501 9.5a1 1 0 1 0 0 2a1 1 0 0 0 0-2"/></symbol>
<symbol id="chevron-up-down" viewBox="0 0 16 16"><path fill="currentColor" d="M4.22 6.53a.75.75 0 0 0 1.06 0L8 3.81l2.72 2.72a.75.75 0 1 0 1.06-1.06L8.53 2.22a.75.75 0 0 0-1.06 0L4.22 5.47a.75.75 0 0 0 0 1.06m0 2.94a.75.75 0 0 1 1.06 0L8 12.19l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0l-3.25-3.25a.75.75 0 0 1 0-1.06"/></symbol>
</svg>

View File

@@ -1,18 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Icon from '$lib/components/icon/Icon.svelte';
import ThemeSelector from '$lib/widget/ThemeSelector.svelte';
import { authStore } from '$lib/stores/authStore.ts';
import { toggleSidebar } from '$lib/stores/sidebarStore';
</script>
<header class="w-full h-18 flex justify-between items-center px-4 bg-base-300 flex-shrink-0">
<div>
<button
class="btn btn-square btn-ghost"
onclick={toggleSidebar}
aria-label="Toggle Sidebar"
>
<Icon id="menu" size="24" />
@@ -22,27 +20,27 @@
<div class="flex justify-center items-center gap-4 select-none">
<ThemeSelector/>
{#if $authStore.user }
{#if page.data.user }
<div class="dropdown dropdown-end ">
<div
role="button"
tabindex="0"
class="rounded-full bg-primary h-8 w-8 p-4 flex items-center justify-center text-primary-content font-bold"
>
{#if $authStore.user.avatar}
{#if page.data.user.avatar}
<img
class="w-8 h-8 rounded-full"
src="{$authStore.user.avatar}"
src="{page.data.user.avatar}"
alt="Avatar"
/>
{:else}
<span>{$authStore.user.nickname.slice(0, 1)}</span>
<span>{page.data.user.nickname.slice(0, 1)}</span>
{/if}
</div>
<div class="dropdown-content mt-2 w-64 shadow-base-300 p-12 shadow-2xl bg-base-200 border border-base-content/10 rounded-box ">
<div class="text-center ">
<p class="font-bold">{$authStore.user.nickname}</p>
<p class="text-xs mt-2">{$authStore.user.username}</p>
<p class="font-bold">{page.data.user.nickname}</p>
<p class="text-xs mt-2">{page.data.user.username}</p>
</div>
</div>
</div>

View File

@@ -2,12 +2,11 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { fly, fade } from 'svelte/transition';
import Icon from '$lib/components/icon/Icon.svelte';
import { sidebarStore, setSidebarOpen } from '$lib/stores/sidebarStore';
import { authStore } from '$lib/stores/authStore';
import type { NavItem, ProcessedNavItem } from '$lib/types/layout';
import { authService } from '$lib/api/services/authService';
// 1. 模拟数据:包含三层结构
@@ -105,12 +104,7 @@
// 使用 $derived 动态计算,类型自动推断为 ProcessedNavItem[]
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
function handleMobileClose() {
const isMobile = window.innerWidth < 768;
if (isMobile && $sidebarStore.isOpen) {
setSidebarOpen(false);
}
}
</script>
<!-- 定义递归 Snippet显式指定类型 -->
@@ -134,7 +128,6 @@
{:else}
<a
href={resolve(item.href)}
onclick={handleMobileClose}
class="group {item.isActive ? 'active font-medium' : ''}"
>
{#if item.icon}
@@ -149,13 +142,12 @@
</li>
{/snippet}
{#if $sidebarStore.isOpen}
<div
role="button"
tabindex="0"
class="fixed inset-0 z-20 cursor-pointer bg-black/50 backdrop-blur-sm md:hidden"
onclick={handleMobileClose}
onkeydown={(e) => e.key === 'Escape' && handleMobileClose()}
transition:fade={{ duration: 200 }}
></div>
@@ -168,9 +160,8 @@
<a
href={resolve('/app/dashboard')}
class="flex items-center gap-3"
onclick={handleMobileClose}
>
<Icon id="logo" size="32" className="flex-shrink-0" />
<Icon id="logo" size="32" className="flex-shrink-0 rounded-box" />
<p class="truncate font-serif text-lg font-bold">IT DTMS</p>
</a>
</div>
@@ -184,7 +175,8 @@
</ul>
</div>
{#if $authStore.isAuthenticated && $authStore.user}
{#if page.data.user}
<div class="border-base-content/10 bg-base-200/50 flex-shrink-0 border-t p-3">
<div class="dropdown dropdown-top dropdown-end w-full">
<div
@@ -194,22 +186,19 @@
>
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content w-10 rounded-full">
{#if $authStore.user?.avatar}
<img src={$authStore.user.avatar} alt="avatar" />
{:else}
<img src={page.data.user.avatar} alt="avatar" />
<span class="text-xs">User</span>
{/if}
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-bold">{$authStore.user.nickname}</span>
<span class="truncate text-sm font-bold">{page.data.user.nickname}</span>
<span class="text-base-content/60 truncate text-xs"
>@{$authStore.user?.username}</span
>@{page.data.user.username}</span
>
</div>
<Icon id="chevrons-up-down" size="16" className="opacity-50" />
<Icon id="chevron-up-down" size="16" className="opacity-50" />
</div>
<ul
@@ -225,7 +214,7 @@
</li>
<div class="divider my-1"></div>
<li>
<button class="text-error" onclick={authService.logout}>
<button class="text-error" >
<Icon id="sign-out" size="16" /> 退出登录
</button>
</li>
@@ -234,7 +223,6 @@
</div>
{/if}
</aside>
{/if}
<style>
/* 保持原有样式 */

41
src/lib/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,41 @@
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/authStore';
import { goto } from '$app/navigation';
import { isAuthenticated } from '$lib/utils/authUtils';
import type { UserProfile } from '$lib/types/user';
/**
* Hook to protect routes and provide auth utilities
*/
export const useAuth = () => {
/**
* Protect a route by checking authentication
*/
const protectRoute = () => {
onMount(async () => {
const authenticated = await isAuthenticated();
if (!authenticated) {
goto('/auth/login');
}
});
};
/**
* Get current user
*/
const getUser = (): UserProfile | null => {
let user: UserProfile | null = null;
const unsubscribe = authStore.subscribe(state => {
user = state.user;
});
unsubscribe(); // Immediately unsubscribe
return user;
};
return {
protectRoute,
getUser,
logout: authStore.logout,
isAuthenticated
};
};

View File

@@ -1,69 +0,0 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { UserProfile } from '$lib/types/user'; // 修正导入路径后缀
export interface AuthStore {
token: string | null;
tokenHead: string | null;
isAuthenticated: boolean;
user: UserProfile | null;
}
const STORAGE_KEY = 'app_auth_state';
const emptyAuth: AuthStore = {
token: null,
tokenHead: null,
isAuthenticated: false,
user: null
};
const getInitialState = (): AuthStore => {
if (browser) {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
console.error('Auth state parse error', e);
localStorage.removeItem(STORAGE_KEY);
}
}
}
return emptyAuth;
};
function createAuthStore() {
const { subscribe, set, update } = writable<AuthStore>(getInitialState());
return {
subscribe,
update,
set: (value: AuthStore) => {
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
}
set(value);
},
login: (data: Omit<AuthStore, 'isAuthenticated'>) => {
const newState = { ...data, isAuthenticated: true };
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
document.cookie = `Authorization=${data.tokenHead} ${data.token}`;
}
set(newState);
},
logout: () => {
if (browser) {
localStorage.removeItem(STORAGE_KEY);
document.cookie = 'Authorization=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}
set(emptyAuth);
}
};
}
export const authStore = createAuthStore();

View File

@@ -1,30 +0,0 @@
import { writable } from 'svelte/store';
interface SidebarState {
isOpen: boolean;
}
// 初始状态:默认可能为 true (显示) 或 false (隐藏),根据你的需求设定
export const sidebarStore = writable<SidebarState>({
isOpen: true
});
/**
* 切换侧边栏显示/隐藏状态
*/
export const toggleSidebar = () => {
sidebarStore.update(state => ({
...state,
isOpen: !state.isOpen,
}));
};
/**
* 强制设置侧边栏状态 (例如在移动端点击链接后关闭)
*/
export const setSidebarOpen = (isOpen: boolean) => {
sidebarStore.update(state => ({
...state,
isOpen
}));
};

View File

@@ -0,0 +1,16 @@
import type { DaisyUIThemeID } from '$lib/types/theme.ts';
export class ThemeState {
theme: DaisyUIThemeID = $state('dark');
constructor(initialTheme = 'dark' as DaisyUIThemeID) {
this.theme = initialTheme;
}
setTheme(theme: DaisyUIThemeID) {
this.theme = theme;
}
}
export const THEME_KEY = Symbol('THEME');

View File

@@ -1,24 +0,0 @@
import { writable } from 'svelte/store';
import type { DaisyUIThemeID } from '$lib/types/theme.ts';
import { browser } from '$app/environment';
let initialTheme: DaisyUIThemeID = 'light';
if (browser){
initialTheme = localStorage.getItem('theme') as DaisyUIThemeID || 'light';
}
const themeStatusStore = writable<DaisyUIThemeID>(initialTheme);
export const themeStore = {
subscribe: themeStatusStore.subscribe,
set: (theme: DaisyUIThemeID) => {
if (browser){
localStorage.setItem('theme', theme);
}
themeStatusStore.set(theme);
},
};

View File

@@ -0,0 +1,33 @@
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastMessage {
id: string;
type: ToastType;
message: string;
duration?: number;
}
export class ToastState {
toasts = $state<ToastMessage[]>([]);
add(message:string, type:ToastType = 'info', duration = 3000){
const id = crypto.randomUUID();
this.toasts.push({id,type,message,duration});
if (duration > 0){
setTimeout(()=>{
this.remove(id);
},duration)
}
}
remove(id:string) {
this.toasts = this.toasts.filter(toast => toast.id !== id)
}
success(msg: string, duration = 3000) { this.add(msg, 'success', duration); }
error(msg: string, duration = 3000) { this.add(msg, 'error', duration); }
warning(msg: string, duration = 3000) { this.add(msg, 'warning', duration); }
info(msg: string, duration = 3000) { this.add(msg, 'info', duration); }
}
export const TOAST_KEY = Symbol('TOAST');

View File

@@ -1,39 +0,0 @@
import { writable } from 'svelte/store';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastMessage {
id: string;
type: ToastType;
message: string;
duration?: number;
}
const createToastStore = () => {
const {subscribe, update} = writable<ToastMessage[]>([]);
const send = (message:string, type:ToastType = 'info',duration = 3000)=> {
const id = crypto.randomUUID();
update((toasts) => [...toasts,{id,type,message,duration}])
if (duration > 0){
setTimeout(()=>{
remove(id);
},duration)
}
}
const remove = (id:string) => {
update((toasts) => toasts.filter(toast => toast.id !== id))
}
return {
subscribe,
success: (msg: string, duration = 3000) => send(msg, 'success', duration),
error: (msg: string, duration = 3000) => send(msg, 'error', duration),
warning: (msg: string, duration = 3000) => send(msg, 'warning', duration),
info: (msg: string, duration = 3000) => send(msg, 'info', duration),
remove
};
};
export const toast = createToastStore();

View File

@@ -1,4 +1,5 @@
import type { ActionResult } from '@sveltejs/kit';
import type { AuthResponse } from '$lib/types/auth.ts';
import { HttpError } from '$lib/api/httpClient.ts';
export interface ApiResult<T> {
code: number,
@@ -6,13 +7,20 @@ export interface ApiResult<T> {
data: T | null;
}
export type EnhanceResult<T> = ActionResult & {
status: number;
type: "failure" | "success" | "redirect" | "error";
data: T;
export class ApiError<T> extends HttpError {
constructor(ApiResult: ApiResult<T>) {
super(ApiResult.msg, ApiResult.code, JSON.stringify(ApiResult.data));
}
}
export interface LoginFailure {
message: string;
username: string;
}
export interface LoginSuccess {
message: string;
data: AuthResponse;
}

View File

@@ -13,3 +13,12 @@ export interface AuthResponse {
userProfile: UserProfile;
}
export interface JwtPayload {
sub: string; // 用户标识
iat: number; // 签发时间Unix 时间戳)
exp: number; // 过期时间Unix 时间戳)
authorities: string[]; // 权限列表
userId: string; // 用户ID
nickname: string; // 昵称
avatar: string; // 头像URL
}

View File

@@ -1,22 +0,0 @@
import type { ApiResult } from '$lib/types/api.ts';
export interface ApiError {
status: number;
details: {
code: number;
msg: string;
};
name: string;
}
export class ResponseError extends Error {
code: number;
msg: string;
constructor(response: ApiResult<unknown>) {
super(response.msg);
this.code = response.code;
this.msg = response.msg;
}
}

View File

@@ -15,5 +15,6 @@ export type IconId =
"settings"|
"user-settings" |
"user-profile"|
"auth"
"auth"|
"chevron-up-down"
;

View File

@@ -0,0 +1,60 @@
import { authStore } from '$lib/stores/authStore';
import { tokenService } from '$lib/api/services/tokenService';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
/**
* Check if user is authenticated
* This function will validate the token if needed
*/
export const isAuthenticated = async (): Promise<boolean> => {
if (!browser) return false;
const state = authStore.isAuthenticated();
if (!state) return false;
// Optionally validate token with server
const isValid = await tokenService.validateToken();
if (!isValid) {
// If token is invalid, logout user
authStore.logout();
return false;
}
return true;
};
/**
* Require authentication for a page
* Redirects to login if not authenticated
*/
export const requireAuth = async (): Promise<void> => {
if (!browser) return;
const authenticated = await isAuthenticated();
if (!authenticated) {
// Redirect to login page
goto('/auth/login?redirect=' + encodeURIComponent(window.location.pathname));
}
};
/**
* Logout user and redirect to login
*/
export const logout = async (): Promise<void> => {
if (!browser) return;
// Call logout API
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
}).catch(() => {
// Ignore errors during logout
});
// Clear local state
authStore.logout();
// Redirect to login
goto('/auth/login');
};

View File

@@ -0,0 +1,20 @@
export const parseJwt = <T>(token:string):T | null => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
return JSON.parse(jsonPayload) as T;
}catch (e){
console.error('parseJwt error', e);
return null;
}
}

View File

@@ -1,16 +1,15 @@
<script lang="ts">
import { themeStore } from '$lib/stores/themeStore.ts';
import { DAISYUI_THEME_OPTIONS, type DaisyUIThemeID } from '$lib/types/theme.ts';
import { DAISYUI_THEME_OPTIONS } from '$lib/types/theme.ts';
import ThemePreview from '$lib/widget/ThemePreview.svelte';
import { getContext } from 'svelte';
import { THEME_KEY, ThemeState } from '$lib/stores/theme.svelte.ts';
const themeState = getContext<ThemeState>(THEME_KEY);
const handleThemeChange = (themeValue: DaisyUIThemeID) => {
themeStore.set(themeValue);
};
</script>
<div class="dropdown dropdown-center md:dropdown-end ">
<div tabindex="0" role="button" class="rounded hover:bg-base-100 active:bg-base-200 p-2 overflow-hidden flex items-center gap-2">
<ThemePreview themeId={$themeStore} />
<ThemePreview themeId={themeState.theme} />
<svg width="12px" height="12px" class="mt-px text-base-content size-2 fill-current opacity-60 sm:inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path></svg>
</div>
@@ -24,14 +23,14 @@
<div
role="button"
tabindex="0"
on:click={() => handleThemeChange(theme.value)}
on:keydown={(e) => {
onclick={() => themeState.setTheme(theme.value)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleThemeChange(theme.value);
themeState.setTheme(theme.value);
}
}}
class="gap-3 w-full flex hover:bg-base-300 active:bg-primary p-2 items-center {theme.value === $themeStore ? 'active' : ''}"
class="gap-3 w-full flex hover:bg-base-300 active:bg-primary p-2 items-center {theme.value === themeState.theme ? 'active' : ''}"
>
<ThemePreview themeId={theme.value} />
<div class=" ">{theme.name}</div>

View File

@@ -1,14 +1,23 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from '../../.svelte-kit/types/src/routes/$types';
import { resolve } from '$app/paths';
import type { RouteId } from '$app/types';
import { COOKIE_THEME_KEY } from '$lib/components/constants/cookiesConstants.ts';
export const load: LayoutServerLoad = async ({url}) => {
const targetPath = '/app/dashboard';
export const load: LayoutServerLoad = async ({url,cookies,locals}) => {
console.log("locals",locals);
const targetPath: RouteId = '/app/dashboard';
// 2. 检查当前访问的路径是否为根路径 '/'
// 并且确保当前路径不是目标路径本身,以避免无限循环
if (url.pathname === '/') {
// 如果是根路径,则执行重定向到目标路径
throw redirect(302, targetPath);
throw redirect(302, resolve(targetPath));
}
return{
theme: cookies.get(COOKIE_THEME_KEY) || 'dark',
user: locals.user
}
};

View File

@@ -1,75 +1,36 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg?url';
import { themeStore } from '$lib/stores/themeStore.ts';
let { children } = $props();
import { onMount } from 'svelte';
import { sidebarStore } from '$lib/stores/sidebarStore.ts';
import { getContext, setContext } from 'svelte';
import Sprite from '$lib/components/icon/Sprite.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import {ThemeState,THEME_KEY} from '$lib/stores/theme.svelte.ts';
import type { DaisyUIThemeID } from '$lib/types/theme.ts';
import { COOKIE_THEME_KEY } from '$lib/components/constants/cookiesConstants.ts';
import { TOAST_KEY, ToastState } from '$lib/stores/toast.svelte.ts';
let { data ,children} = $props();
const MD_BREAKPOINT = '(min-width: 768px)';
setContext(THEME_KEY, new ThemeState(data.theme as DaisyUIThemeID ?? 'dark'))
setContext(TOAST_KEY,new ToastState())
const themeState = getContext<ThemeState>(THEME_KEY);
const handleMediaQueryChange = (event: MediaQueryListEvent) => {
const isCurrentlyDesktop = event.matches;
sidebarStore.update((store) => {
if (!store.isManualOverride) {
return {
...store,
isOpen: isCurrentlyDesktop
};
}
if (isCurrentlyDesktop) {
return {
...store,
isOpen: true,
isManualOverride: false // PC端时恢复自动模式
};
}
return store;
});
}
let isMounted = $state(false);
onMount(()=>{
isMounted = true;
const mediaQuery = window.matchMedia(MD_BREAKPOINT);
const isDesktop = mediaQuery.matches;
sidebarStore.update((store) => ({
...store,
isOpen: isDesktop,
isManualOverride: false,
}));
mediaQuery.addEventListener('change', handleMediaQueryChange);
return () => {
mediaQuery.removeEventListener('change', handleMediaQueryChange);
}
$effect(() => {
document.documentElement.setAttribute('data-theme', themeState.theme);
document.cookie = `${COOKIE_THEME_KEY}=${themeState.theme}; path=/; max-age=31536000; SameSite=Lax`;
})
</script>
<svelte:head>
<link rel="icon" href={favicon} />
{#if isMounted}
<Sprite />
{/if}
</svelte:head>
<div data-theme={$themeStore} class="text-base-content">
<Sprite />
<div class="text-base-content">
<ToastContainer />
{@render children()}
</div>

View File

@@ -1,6 +1,11 @@
<script>
<script lang="ts">
import { getContext } from 'svelte';
import { THEME_KEY, ThemeState } from '$lib/stores/theme.svelte.ts';
const themeState = getContext<ThemeState>(THEME_KEY);
</script>
<div>
你好
{themeState.theme}
</div>

View File

@@ -1,9 +1,11 @@
import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import { authService } from '$lib/api/services/authService.ts';
import { resolve } from '$app/paths';
import { HttpError } from '$lib/api/httpClient.ts';
import { type ApiError, ResponseError } from '$lib/types/error.ts';
import { ApiError } from '$lib/types/api.ts';
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
export const actions:Actions = {
@@ -44,7 +46,7 @@ export const actions:Actions = {
try{
const response = await authService.login({username,password});
cookies.set('Authorization',`${response.tokenHead} ${response.token}`,{
cookies.set(COOKIE_TOKEN_KEY,`${response.tokenHead} ${response.token}`,{
path: '/',
httpOnly: true,
sameSite: 'strict',
@@ -56,18 +58,16 @@ export const actions:Actions = {
return {
success: true,
message: '登录成功',
data: response,// 这个传入的data 在前端页面怎么使用?
redirectTo: url.searchParams.get('redirectTo') ?? resolve("/app/dashboard")
};
}catch (error){
if (error instanceof HttpError) {
const msg = (error as unknown as ApiError)?.details?.msg || '登录失败,请检查账号密码';
if (error instanceof ApiError) {
return fail(400, {
incorrect: true,
message: msg,
username // 返回用户名供用户修改
message: error.message,
username
});
}
@@ -77,13 +77,6 @@ export const actions:Actions = {
username
});
}
if (error instanceof ResponseError ){
return fail(error.code, {
message: error.msg,
username
});
}
// 4. 兜底的未知错误处理
console.error('Login unexpected error:', error);
return fail(500, {

View File

@@ -1,12 +1,50 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icon from '$lib/components/icon/Icon.svelte';
import { toast } from '$lib/stores/toastStore.ts';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import type { AuthResponse } from '$lib/types/auth';
import type { SubmitFunction } from '@sveltejs/kit';
import type { RouteId } from '$app/types';
import { getContext } from 'svelte';
import { TOAST_KEY, ToastState } from '$lib/stores/toast.svelte.ts';
const toast = getContext<ToastState>(TOAST_KEY);
let loading = false;
const handleLogin:SubmitFunction = () => {
loading = true;
return async ({ result , update }) => {
loading = false;
if (result.type === 'failure') {
if (typeof result.data?.message === 'string' ){
toast.error(result.data?.message)
}
await update();
}else if (result.type === 'success') {
console.log('result', result)
const { userProfile } = result.data as AuthResponse;
if (typeof result.data?.message === 'string' ){
toast.success(result.data?.message || '登录成功')
}
if (result.data?.redirectTo && typeof result.data?.redirectTo === 'string') {
await goto(resolve(result.data.redirectTo as RouteId ));
}
}
};
}
</script>
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
@@ -23,29 +61,7 @@
<form method="post"
use:enhance={() => {
loading = true;
return async ({ result , update }) => {
loading = false;
if (result.type === 'failure') {
if (typeof result.data?.message === 'string' ){
toast.error(result.data?.message)
}
await update();
}else if (result.type === 'success') {
if (typeof result.data?.message === 'string' ){
toast.success(result.data?.message || '登录成功')
}
if (result.data?.redirectTo && typeof result.data?.redirectTo === 'string') {
await goto(resolve(result.data.redirectTo));
}
}
};
}}
use:enhance={handleLogin}
class="space-y-4">
<div class="form-control">