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:
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal 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>
|
||||||
1
.idea/inspectionProfiles/Project_Default.xml
generated
1
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -2,5 +2,6 @@
|
|||||||
<profile version="1.0">
|
<profile version="1.0">
|
||||||
<option name="myName" value="Project Default" />
|
<option name="myName" value="Project Default" />
|
||||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
<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>
|
</profile>
|
||||||
</component>
|
</component>
|
||||||
19
src/app.d.ts
vendored
19
src/app.d.ts
vendored
@@ -2,11 +2,20 @@
|
|||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface User {
|
||||||
// interface Locals {}
|
id: string;
|
||||||
// interface PageData {}
|
username: string;
|
||||||
// interface PageState {}
|
nickname: string;
|
||||||
// interface Platform {}
|
avatar?: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
interface Locals {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface pageData {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
src/hooks.server.ts
Normal file
30
src/hooks.server.ts
Normal 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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
// src/lib/api/httpClient.ts
|
// src/lib/api/httpClient.ts
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import type { HttpMethod, JsonObject, JsonValue } from '$lib/types/http.ts';
|
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';
|
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';
|
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 normalizeHeaders = (headers?: HeadersInit):Record<string, string> =>{
|
||||||
const result:Record<string,string> = {};
|
const result:Record<string,string> = {};
|
||||||
|
|
||||||
@@ -74,8 +64,8 @@ const httpRequest = async <T>(
|
|||||||
|
|
||||||
const canHaveBody = method !== 'GET' ;
|
const canHaveBody = method !== 'GET' ;
|
||||||
|
|
||||||
// 【修改点 2】:只有在允许携带 Body 时才处理
|
|
||||||
if (canHaveBody) {
|
if (canHaveBody) {
|
||||||
|
console.log('body', body);
|
||||||
if (body instanceof FormData) {
|
if (body instanceof FormData) {
|
||||||
requestBody = body;
|
requestBody = body;
|
||||||
} else if (body) {
|
} else if (body) {
|
||||||
@@ -85,9 +75,9 @@ const httpRequest = async <T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ... Token 处理逻辑保持不变 ...
|
// ... Token 处理逻辑保持不变 ...
|
||||||
if (currentToken && currentTokenHead) {
|
// if (currentToken && currentTokenHead) {
|
||||||
requestHeaders['authorization'] = `${currentTokenHead} ${currentToken}`;
|
// requestHeaders['authorization'] = `${currentTokenHead} ${currentToken}`;
|
||||||
}
|
// }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(fullUrl, {
|
const response = await fetch(fullUrl, {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { api } from '$lib/api/httpClient'; // 通常不需要 .ts 后缀
|
import { api } from '$lib/api/httpClient'; // 通常不需要 .ts 后缀
|
||||||
import type { AuthResponse, LoginPayload } from '$lib/types/auth';
|
import type { AuthResponse, LoginPayload } from '$lib/types/auth';
|
||||||
import { authStore } from '$lib/stores/authStore';
|
import { ApiError } from '$lib/types/api.ts';
|
||||||
import { toast } from '$lib/stores/toastStore';
|
|
||||||
import { ResponseError } from '$lib/types/error.ts';
|
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
/**
|
/**
|
||||||
@@ -13,23 +12,22 @@ export const authService = {
|
|||||||
const response = await api.post<AuthResponse>('/auth/login', payload);
|
const response = await api.post<AuthResponse>('/auth/login', payload);
|
||||||
|
|
||||||
if (response.code !== 200 || !response.data) {
|
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;
|
return response.data;
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登出流程
|
* 登出流程
|
||||||
*/
|
*/
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
authStore.logout();
|
try {
|
||||||
toast.success('退出登录成功');
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
45
src/lib/api/services/tokenService.ts
Normal file
45
src/lib/api/services/tokenService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toast, type ToastType } from '$lib/stores/toastStore';
|
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import Icon from '$lib/components/icon/Icon.svelte';
|
import Icon from '$lib/components/icon/Icon.svelte';
|
||||||
import type { IconId } from '$lib/types/icon-ids.ts';
|
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> = {
|
const toastIconMap: Record<ToastType, IconId> = {
|
||||||
success: 'success',
|
success: 'success',
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="toast toast-top toast-center z-50">
|
<div class="toast toast-top toast-center z-50">
|
||||||
{#each $toast as t (t.id)}
|
{#each toastState.toasts as t (t.id)}
|
||||||
<div
|
<div
|
||||||
animate:flip={{ duration: 300 }}
|
animate:flip={{ duration: 300 }}
|
||||||
transition:fly={{ x: 100, duration: 300 }}
|
transition:fly={{ x: 100, duration: 300 }}
|
||||||
|
|||||||
2
src/lib/components/constants/cookiesConstants.ts
Normal file
2
src/lib/components/constants/cookiesConstants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const COOKIE_TOKEN_KEY = 'authorization';
|
||||||
|
export const COOKIE_THEME_KEY = 'theme';
|
||||||
@@ -125,4 +125,6 @@
|
|||||||
</symbol>
|
</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="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>
|
</svg>
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
import Icon from '$lib/components/icon/Icon.svelte';
|
import Icon from '$lib/components/icon/Icon.svelte';
|
||||||
import ThemeSelector from '$lib/widget/ThemeSelector.svelte';
|
import ThemeSelector from '$lib/widget/ThemeSelector.svelte';
|
||||||
import { authStore } from '$lib/stores/authStore.ts';
|
|
||||||
import { toggleSidebar } from '$lib/stores/sidebarStore';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="w-full h-18 flex justify-between items-center px-4 bg-base-300 flex-shrink-0">
|
<header class="w-full h-18 flex justify-between items-center px-4 bg-base-300 flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-square btn-ghost"
|
class="btn btn-square btn-ghost"
|
||||||
onclick={toggleSidebar}
|
|
||||||
aria-label="Toggle Sidebar"
|
aria-label="Toggle Sidebar"
|
||||||
>
|
>
|
||||||
<Icon id="menu" size="24" />
|
<Icon id="menu" size="24" />
|
||||||
@@ -22,27 +20,27 @@
|
|||||||
<div class="flex justify-center items-center gap-4 select-none">
|
<div class="flex justify-center items-center gap-4 select-none">
|
||||||
<ThemeSelector/>
|
<ThemeSelector/>
|
||||||
|
|
||||||
{#if $authStore.user }
|
{#if page.data.user }
|
||||||
<div class="dropdown dropdown-end ">
|
<div class="dropdown dropdown-end ">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="rounded-full bg-primary h-8 w-8 p-4 flex items-center justify-center text-primary-content font-bold"
|
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
|
<img
|
||||||
class="w-8 h-8 rounded-full"
|
class="w-8 h-8 rounded-full"
|
||||||
src="{$authStore.user.avatar}"
|
src="{page.data.user.avatar}"
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{$authStore.user.nickname.slice(0, 1)}</span>
|
<span>{page.data.user.nickname.slice(0, 1)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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="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 ">
|
<div class="text-center ">
|
||||||
<p class="font-bold">{$authStore.user.nickname}</p>
|
<p class="font-bold">{page.data.user.nickname}</p>
|
||||||
<p class="text-xs mt-2">{$authStore.user.username}</p>
|
<p class="text-xs mt-2">{page.data.user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { fly, fade } from 'svelte/transition';
|
import { fly, fade } from 'svelte/transition';
|
||||||
|
|
||||||
import Icon from '$lib/components/icon/Icon.svelte';
|
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 type { NavItem, ProcessedNavItem } from '$lib/types/layout';
|
||||||
import { authService } from '$lib/api/services/authService';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 1. 模拟数据:包含三层结构
|
// 1. 模拟数据:包含三层结构
|
||||||
@@ -105,12 +104,7 @@
|
|||||||
// 使用 $derived 动态计算,类型自动推断为 ProcessedNavItem[]
|
// 使用 $derived 动态计算,类型自动推断为 ProcessedNavItem[]
|
||||||
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
|
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
|
||||||
|
|
||||||
function handleMobileClose() {
|
|
||||||
const isMobile = window.innerWidth < 768;
|
|
||||||
if (isMobile && $sidebarStore.isOpen) {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- 定义递归 Snippet,显式指定类型 -->
|
<!-- 定义递归 Snippet,显式指定类型 -->
|
||||||
@@ -134,7 +128,6 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
href={resolve(item.href)}
|
href={resolve(item.href)}
|
||||||
onclick={handleMobileClose}
|
|
||||||
class="group {item.isActive ? 'active font-medium' : ''}"
|
class="group {item.isActive ? 'active font-medium' : ''}"
|
||||||
>
|
>
|
||||||
{#if item.icon}
|
{#if item.icon}
|
||||||
@@ -149,13 +142,12 @@
|
|||||||
</li>
|
</li>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if $sidebarStore.isOpen}
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="fixed inset-0 z-20 cursor-pointer bg-black/50 backdrop-blur-sm md:hidden"
|
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 }}
|
transition:fade={{ duration: 200 }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@@ -168,9 +160,8 @@
|
|||||||
<a
|
<a
|
||||||
href={resolve('/app/dashboard')}
|
href={resolve('/app/dashboard')}
|
||||||
class="flex items-center gap-3"
|
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>
|
<p class="truncate font-serif text-lg font-bold">IT DTMS</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +175,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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="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 class="dropdown dropdown-top dropdown-end w-full">
|
||||||
<div
|
<div
|
||||||
@@ -194,22 +186,19 @@
|
|||||||
>
|
>
|
||||||
<div class="avatar placeholder">
|
<div class="avatar placeholder">
|
||||||
<div class="bg-neutral text-neutral-content w-10 rounded-full">
|
<div class="bg-neutral text-neutral-content w-10 rounded-full">
|
||||||
{#if $authStore.user?.avatar}
|
<img src={page.data.user.avatar} alt="avatar" />
|
||||||
<img src={$authStore.user.avatar} alt="avatar" />
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs">User</span>
|
<span class="text-xs">User</span>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<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"
|
<span class="text-base-content/60 truncate text-xs"
|
||||||
>@{$authStore.user?.username}</span
|
>@{page.data.user.username}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Icon id="chevrons-up-down" size="16" className="opacity-50" />
|
<Icon id="chevron-up-down" size="16" className="opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul
|
<ul
|
||||||
@@ -225,7 +214,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<div class="divider my-1"></div>
|
<div class="divider my-1"></div>
|
||||||
<li>
|
<li>
|
||||||
<button class="text-error" onclick={authService.logout}>
|
<button class="text-error" >
|
||||||
<Icon id="sign-out" size="16" /> 退出登录
|
<Icon id="sign-out" size="16" /> 退出登录
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -234,7 +223,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* 保持原有样式 */
|
/* 保持原有样式 */
|
||||||
|
|||||||
41
src/lib/hooks/useAuth.ts
Normal file
41
src/lib/hooks/useAuth.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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();
|
|
||||||
@@ -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
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
16
src/lib/stores/theme.svelte.ts
Normal file
16
src/lib/stores/theme.svelte.ts
Normal 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');
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
33
src/lib/stores/toast.svelte.ts
Normal file
33
src/lib/stores/toast.svelte.ts
Normal 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');
|
||||||
@@ -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();
|
|
||||||
@@ -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> {
|
export interface ApiResult<T> {
|
||||||
code: number,
|
code: number,
|
||||||
@@ -6,13 +7,20 @@ export interface ApiResult<T> {
|
|||||||
data: T | null;
|
data: T | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApiError<T> extends HttpError {
|
||||||
export type EnhanceResult<T> = ActionResult & {
|
constructor(ApiResult: ApiResult<T>) {
|
||||||
status: number;
|
super(ApiResult.msg, ApiResult.code, JSON.stringify(ApiResult.data));
|
||||||
type: "failure" | "success" | "redirect" | "error";
|
|
||||||
data: T;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginFailure {
|
export interface LoginFailure {
|
||||||
message: string;
|
message: string;
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginSuccess {
|
||||||
|
message: string;
|
||||||
|
data: AuthResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,12 @@ export interface AuthResponse {
|
|||||||
userProfile: UserProfile;
|
userProfile: UserProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string; // 用户标识
|
||||||
|
iat: number; // 签发时间(Unix 时间戳)
|
||||||
|
exp: number; // 过期时间(Unix 时间戳)
|
||||||
|
authorities: string[]; // 权限列表
|
||||||
|
userId: string; // 用户ID
|
||||||
|
nickname: string; // 昵称
|
||||||
|
avatar: string; // 头像URL
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -15,5 +15,6 @@ export type IconId =
|
|||||||
"settings"|
|
"settings"|
|
||||||
"user-settings" |
|
"user-settings" |
|
||||||
"user-profile"|
|
"user-profile"|
|
||||||
"auth"
|
"auth"|
|
||||||
|
"chevron-up-down"
|
||||||
;
|
;
|
||||||
60
src/lib/utils/authUtils.ts
Normal file
60
src/lib/utils/authUtils.ts
Normal 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');
|
||||||
|
};
|
||||||
20
src/lib/utils/tokenUtils.ts
Normal file
20
src/lib/utils/tokenUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { themeStore } from '$lib/stores/themeStore.ts';
|
import { DAISYUI_THEME_OPTIONS } from '$lib/types/theme.ts';
|
||||||
import { DAISYUI_THEME_OPTIONS, type DaisyUIThemeID } from '$lib/types/theme.ts';
|
|
||||||
import ThemePreview from '$lib/widget/ThemePreview.svelte';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="dropdown dropdown-center md:dropdown-end ">
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -24,14 +23,14 @@
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:click={() => handleThemeChange(theme.value)}
|
onclick={() => themeState.setTheme(theme.value)}
|
||||||
on:keydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
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} />
|
<ThemePreview themeId={theme.value} />
|
||||||
<div class=" ">{theme.name}</div>
|
<div class=" ">{theme.name}</div>
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { LayoutServerLoad } from '../../.svelte-kit/types/src/routes/$types';
|
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 === '/') {
|
if (url.pathname === '/') {
|
||||||
// 如果是根路径,则执行重定向到目标路径
|
// 如果是根路径,则执行重定向到目标路径
|
||||||
throw redirect(302, targetPath);
|
throw redirect(302, resolve(targetPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return{
|
||||||
|
theme: cookies.get(COOKIE_THEME_KEY) || 'dark',
|
||||||
|
user: locals.user
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -1,75 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg?url';
|
import favicon from '$lib/assets/favicon.svg?url';
|
||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
import { themeStore } from '$lib/stores/themeStore.ts';
|
|
||||||
let { children } = $props();
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { sidebarStore } from '$lib/stores/sidebarStore.ts';
|
|
||||||
import Sprite from '$lib/components/icon/Sprite.svelte';
|
import Sprite from '$lib/components/icon/Sprite.svelte';
|
||||||
import ToastContainer from '$lib/components/ToastContainer.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);
|
||||||
|
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
const handleMediaQueryChange = (event: MediaQueryListEvent) => {
|
document.documentElement.setAttribute('data-theme', themeState.theme);
|
||||||
const isCurrentlyDesktop = event.matches;
|
document.cookie = `${COOKIE_THEME_KEY}=${themeState.theme}; path=/; max-age=31536000; SameSite=Lax`;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
{#if isMounted}
|
|
||||||
<Sprite />
|
|
||||||
{/if}
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div data-theme={$themeStore} class="text-base-content">
|
<Sprite />
|
||||||
|
<div class="text-base-content">
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
你好
|
||||||
|
{themeState.theme}
|
||||||
|
</div>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Actions } from './$types';
|
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 { authService } from '$lib/api/services/authService.ts';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { HttpError } from '$lib/api/httpClient.ts';
|
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 = {
|
export const actions:Actions = {
|
||||||
@@ -44,7 +46,7 @@ export const actions:Actions = {
|
|||||||
try{
|
try{
|
||||||
const response = await authService.login({username,password});
|
const response = await authService.login({username,password});
|
||||||
|
|
||||||
cookies.set('Authorization',`${response.tokenHead} ${response.token}`,{
|
cookies.set(COOKIE_TOKEN_KEY,`${response.tokenHead} ${response.token}`,{
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
@@ -56,18 +58,16 @@ export const actions:Actions = {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: '登录成功',
|
message: '登录成功',
|
||||||
|
data: response,// 这个传入的data 在前端页面怎么使用?
|
||||||
redirectTo: url.searchParams.get('redirectTo') ?? resolve("/app/dashboard")
|
redirectTo: url.searchParams.get('redirectTo') ?? resolve("/app/dashboard")
|
||||||
};
|
};
|
||||||
|
|
||||||
}catch (error){
|
}catch (error){
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof ApiError) {
|
||||||
|
|
||||||
const msg = (error as unknown as ApiError)?.details?.msg || '登录失败,请检查账号密码';
|
|
||||||
|
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
incorrect: true,
|
incorrect: true,
|
||||||
message: msg,
|
message: error.message,
|
||||||
username // 返回用户名供用户修改
|
username
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +77,6 @@ export const actions:Actions = {
|
|||||||
username
|
username
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ResponseError ){
|
|
||||||
return fail(error.code, {
|
|
||||||
message: error.msg,
|
|
||||||
username
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 4. 兜底的未知错误处理
|
// 4. 兜底的未知错误处理
|
||||||
console.error('Login unexpected error:', error);
|
console.error('Login unexpected error:', error);
|
||||||
return fail(500, {
|
return fail(500, {
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Icon from '$lib/components/icon/Icon.svelte';
|
import Icon from '$lib/components/icon/Icon.svelte';
|
||||||
import { toast } from '$lib/stores/toastStore.ts';
|
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { goto } from '$app/navigation';
|
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;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||||
@@ -23,29 +61,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<form method="post"
|
<form method="post"
|
||||||
use:enhance={() => {
|
use:enhance={handleLogin}
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
|
|||||||
Reference in New Issue
Block a user