feat(auth): 实现基于令牌的用户认证和访问控制

- 在用户相关页面服务端加载函数中添加令牌检查,防止未授权访问
- 更新用户服务方法以支持携带认证令牌请求API
- 修改用户资料和用户列表组件以适配新的认证流程
- 引入侧边栏状态管理并在布局中注册上下文
- 调整HTTP客户端逻辑以正确传递请求头信息
- 更新用户类型定义以匹配后端返回的角色结构
- 优化应用头部和侧边栏组件的UI细节和交互逻辑
This commit is contained in:
Chaos
2025-11-25 23:33:32 +08:00
parent 81c61f433d
commit 7d627a45fb
14 changed files with 523 additions and 137 deletions

View File

@@ -14,6 +14,7 @@ const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18
const normalizeHeaders = (headers?: HeadersInit):Record<string, string> =>{
const result:Record<string,string> = {};
console.log('normalizeHeaders', headers);
if (!headers){
return result;
}
@@ -28,9 +29,14 @@ const normalizeHeaders = (headers?: HeadersInit):Record<string, string> =>{
})
}else {
Object.keys(headers).forEach(key => {
result[key.toLowerCase()] = headers[key.toLowerCase()] as string;
const value = (headers as Record<string, string>)[key];
if (value !== undefined && value !== null) {
result[key.toLowerCase()] = value;
}
})
}
console.log('normalizeHeaders result:', result);
return result;
}
export class HttpError extends Error {
@@ -59,13 +65,14 @@ const httpRequest = async <T>(
const fullUrl = `${API_BASE_URL}${url}`;
const { body, headers, ...rest } = options;
const requestHeaders: Record<string, string> = normalizeHeaders(headers);
let requestBody: BodyInit | undefined;
const canHaveBody = method !== 'GET' ;
if (canHaveBody) {
console.log('body', body);
if (body instanceof FormData) {
requestBody = body;
} else if (body) {
@@ -74,12 +81,11 @@ const httpRequest = async <T>(
}
}
// ... Token 处理逻辑保持不变 ...
// if (currentToken && currentTokenHead) {
// requestHeaders['authorization'] = `${currentTokenHead} ${currentToken}`;
// }
try {
const response = await fetch(fullUrl, {
method,
headers: requestHeaders,
@@ -89,6 +95,8 @@ const httpRequest = async <T>(
...rest
});
console.log('response', response);
if (!response.ok) {
let errorDetail;
try {

View File

@@ -3,18 +3,23 @@ import type { UserProfile } from '$lib/types/user.ts';
import type { PageResult } from '$lib/types/dataTable.ts';
export const userService = {
getUserProfile: async ({ tokenHead, token}) => {
const response = await api.get<UserProfile>('/user/profile');
getUserProfile: async (token:string) => {
const response = await api.get<UserProfile>('/user/profile', {headers: {Authorization: `${token}`}});
if (response.code != 200 || !response.data){
throw new Error(response.msg);
}
return response.data;
},
getAllUsers: async ({ page, size}: { page: number, size: number}) => {
getAllUsers: async ({ page, size,token}: { page: number, size: number, token:string}) => {
const formData = new FormData();
formData.append('pageNum', page.toString());
formData.append('pageSize', size.toString());
const response = await api.get<PageResult<UserProfile>[]>('/user/all', {body: formData});
const response = await api.get<PageResult<UserProfile>>(
'/user/all',
{
body: formData,
headers: {Authorization: `${token}`}
});
if (response.code != 200 || !response.data){
throw new Error(response.msg);
}

View File

@@ -2,21 +2,24 @@
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';
</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"
aria-label="Toggle Sidebar"
>
<Icon id="menu" size="24" />
</button>
<!-- <button-->
<!-- class="btn btn-square btn-ghost"-->
<!-- aria-label="Toggle Sidebar"-->
<!-- onclick={sidebarState.toggleSidebar}-->
<!-- >-->
<!-- <Icon id="menu" size="24" />-->
<!-- </button>-->
</div>
<div class="flex justify-center items-center gap-4 select-none">
<ThemeSelector/>
@@ -25,11 +28,11 @@
<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"
class="rounded-full cursor-pointer shadow-base-content bg-base-100/50 p-0.5 flex items-center justify-center text-primary-content font-bold"
>
{#if page.data.user.avatar}
<img
class="w-8 h-8 rounded-full"
class="w-8 h-8 rounded-full z-10"
src="{page.data.user.avatar}"
alt="Avatar"
/>

View File

@@ -1,272 +1,522 @@
<script lang="ts">
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 type { NavItem, ProcessedNavItem } from '$lib/types/layout';
import { getContext } from 'svelte';
import { TOAST_KEY, type ToastState } from '$lib/stores/toast.svelte.ts';
import { enhance } from '$app/forms';
import { SIDEBAR_KEY, SidebarState } from '$lib/stores/sidebar.svelte.ts';
const sidebarState = getContext<SidebarState>(SIDEBAR_KEY);
// 1. 模拟数据:包含三层结构
const rawNavItems: NavItem[] = [
{
id: 'dashboard',
label: '仪表盘',
icon: 'home',
href: '/app/dashboard'
},
{
id: 'statistics',
label: '数据看板',
icon: 'data',
href: '/app/statistics'
},
{
id: 'settings',
label: '系统设置',
icon: 'settings',
href: '/app/settings',
subItems: [
{
id: 'auth',
label: '认证管理',
href: '/app/settings/auth',
icon: 'auth',
subItems: [
{
id: 'users',
label: '用户管理',
href: '/app/settings/auth/users'
},
{
id: 'roles',
label: '角色权限',
href: '/app/settings/auth/roles'
},
{
id: 'permissions',
label: '权限管理',
href: '/app/settings/auth/permissions'
}
]
},
{
id: 'advanced',
label: '高级设置',
href: '/app/settings/advanced',
subItems: [
{
id: 'logs',
label: '安全日志',
href: '/app/settings/advanced/logs'
id: 'statistics',
label: '数据看板',
icon: 'data',
href: '/app/statistics'
},
{
id: 'settings',
label: '系统设置',
icon: 'settings',
href: '/app/settings',
subItems: [
{
id: 'auth',
label: '认证管理',
href: '/app/settings/auth',
icon: 'auth',
subItems: [
{
id: 'users',
label: '用户管理',
href: '/app/settings/auth/users'
},
{
id: 'roles',
label: '角色权限',
href: '/app/settings/auth/roles'
},
{
id: 'permissions',
label: '权限管理',
href: '/app/settings/auth/permissions'
}
]
},
{
id: 'advanced',
label: '高级设置',
href: '/app/settings/advanced',
subItems: [
{
id: 'logs',
label: '安全日志',
href: '/app/settings/advanced/logs'
},
{
id: 'backup',
label: '备份恢复',
href: '/app/settings/advanced/backup'
}
]
}
]
}
];
/**
* 递归计算高亮状态 (强类型版本)
*/
function processNavItems(items: NavItem[], currentPath: string): ProcessedNavItem[] {
return items.map((item) => {
const isSelfActive =
item.href === '/' ? currentPath === '/' : currentPath.startsWith(item.href);
let processedSubItems: ProcessedNavItem[] | undefined = undefined;
let isChildActive = false;
if (item.subItems) {
// 递归调用
processedSubItems = processNavItems(item.subItems, currentPath);
// 检查子项激活状态
isChildActive = processedSubItems.some((sub) => sub.isActive || sub.isChildActive);
}
return {
...item,
subItems: processedSubItems, // 这里类型现在是 ProcessedNavItem[]
isActive: isSelfActive,
isChildActive: isChildActive
};
});
}
// 使用 $derived 动态计算,类型自动推断为 ProcessedNavItem[]
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
// 获取 Toast 以便提示用户
const toast = getContext<ToastState>(TOAST_KEY);
// 处理提交结果的回调
const handleLogout = () => {
toast.info('正在退出登录...');
return async ({ result, update }) => {
// result.type 可能是 'redirect', 'success', 'failure'
if (result.type === 'redirect') {
toast.success('您已安全退出');
}
// update() 会触发默认行为(也就是执行 redirect 跳转)
await update();
};
};
let logoutForm: HTMLFormElement;
</script>
<!-- 定义递归 Snippet显式指定类型 -->
{#snippet menuItem(item: ProcessedNavItem)}
<li>
{#if item.subItems && item.subItems.length > 0}
<details open={item.isChildActive}>
<summary class="group {item.isActive ? 'text-primary font-medium' : ''}">
{#if item.icon}
<Icon id={item.icon} size="20" />
{/if}
<span class="truncate">{item.label}</span>
</summary>
<ul>
{#each item.subItems as subItem (subItem.id)}
<!-- 递归渲染子项 -->
{@render menuItem(subItem)}
{/each}
</ul>
</details>
{:else}
<a
href={resolve(item.href)}
class="group {item.isActive ? 'active font-medium' : ''}"
>
{#if item.icon}
<Icon id={item.icon} size="20" />
{:else}
<!-- 无图标时的占位符,保持对齐 -->
<span class="w-5 text-center text-xs opacity-50"></span>
{/if}
<span class="truncate">{item.label}</span>
</a>
{/if}
</li>
{/snippet}
<div
role="button"
tabindex="0"
class="fixed inset-0 z-20 cursor-pointer bg-black/50 backdrop-blur-sm md:hidden"
transition:fade={{ duration: 200 }}
></div>
<aside
in:fly={{ duration: 200, x: -100 }}
out:fly={{ duration: 200, x: -100 }}
class="bg-base-200 border-base-100/70 fixed z-30 flex h-full w-64 flex-shrink-0 flex-col border-r md:relative"
>
<div class="h-18 flex flex-shrink-0 items-center p-4">
<a
href={resolve('/app/dashboard')}
class="flex items-center gap-3"
>
<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>
<div class="custom-scrollbar flex-1 overflow-y-auto">
<ul class="menu menu-vertical w-full gap-1 px-2">
{#each navItems as item (item.id)}
<!-- 初始渲染调用 -->
{@render menuItem(item)}
{/each}
</ul>
<!-- 状态: {sidebarState.isSidebarExpanded ? '展开' : '收起'}-->
<!-- <button-->
<!-- onclick="{sidebarState.toggleSidebar}"-->
<!-- class="btn btn-square btn-ghost">-->
<!-- 123-->
<!-- </button>-->
</div>
{#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
tabindex="0"
role="button"
class="hover:bg-base-300 flex w-full cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors"
>
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content w-10 rounded-full">
<img src={page.data.user.avatar} alt="avatar" />
<span class="text-xs">User</span>
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-bold">{page.data.user.nickname}</span>
<span class="text-base-content/60 truncate text-xs"
>@{page.data.user.username}</span
>
</div>
<Icon id="chevron-up-down" size="16" className="opacity-50" />
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu rounded-box border-base-content/10 mb-2 w-60 border bg-base-100 p-2 shadow-lg"
>
<li class="menu-title px-4 py-2">我的账户</li>
<li>
<a href="/app/profile"><Icon id="user-profile" size="16" /> 个人资料</a>
<a href="/app/user">
<Icon id="user-profile" size="16" />
个人资料</a>
</li>
<li>
<a href="/app/settings"><Icon id="settings" size="16" /> 设置</a>
<a href="/app/settings">
<Icon id="settings" size="16" />
设置</a>
</li>
<div class="divider my-1"></div>
<li class="">
<button
class="text-error w-full text-left flex items-center gap-2"
on:click={() => logoutForm.requestSubmit()}
onclick={() => logoutForm.requestSubmit()}
>
<Icon id="sign-out" size="16" /> 退出登录
<Icon id="sign-out" size="16" />
退出登录
</button>
</li>
<form
action="/auth/logout"
method="POST"
use:enhance={handleLogout}
bind:this={logoutForm}
hidden
>
</form>
</ul>
</div>
</div>
{/if}
</aside>
<style>
/* 保持原有样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.3);
border-radius: 20px;
}
</style>

View File

@@ -0,0 +1,20 @@
export class SidebarState {
isSidebarExpanded = $state(true);
constructor(initialIsSidebarExpanded = true) {
this.isSidebarExpanded = initialIsSidebarExpanded;
}
toggleSidebar = ()=> {
this.isSidebarExpanded = !this.isSidebarExpanded;
}
closeSidebar() {
this.isSidebarExpanded = false;
}
openSidebar() {
this.isSidebarExpanded = true;
}
}
export const SIDEBAR_KEY = Symbol('SIDEBAR');

View File

@@ -1,8 +1,11 @@
export interface UserProfile{
id: string;
id: number;
username : string;
nickname : string;
roles : string[];
roles : {
id: number;
name: string;
}[];
avatar? : string;
}

View File

@@ -8,10 +8,12 @@
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';
import { SIDEBAR_KEY, SidebarState } from '$lib/stores/sidebar.svelte.ts';
let { data ,children} = $props();
setContext(THEME_KEY, new ThemeState(data.theme as DaisyUIThemeID ?? 'dark'))
setContext(TOAST_KEY,new ToastState())
setContext(SIDEBAR_KEY,new SidebarState())
const themeState = getContext<ThemeState>(THEME_KEY);

View File

@@ -1,9 +1,18 @@
import type { PageServerLoad } from './$types';
import { userService } from '$lib/api/services/userService.ts';
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
import { redirect } from '@sveltejs/kit';
export const load:PageServerLoad = async ({ locals }) => {
export const load:PageServerLoad = async ({ cookies }) => {
const allUsers = await userService.getAllUsers({ page: 1, size: 10 });
const token = cookies.get(COOKIE_TOKEN_KEY);
if (!token) {
throw redirect(302, '/auth/login');
}
const allUsers = await userService.getAllUsers({ page: 1, size: 10 ,token:token});
return {

View File

@@ -1,41 +1,80 @@
<script lang="ts">
import DataTable from '$lib/components/DataTable.svelte';
import type { BaseRecord, TableColumn } from '$lib/types/dataTable.ts';
import type { PageData } from './$types'; // SvelteKit 自动生成的类型
import { resolve } from '$app/paths';
const {data} = $props();
// 从 load 函数获取的数据
export let data: PageData;
console.log(data);
// 1. 定义具体的业务接口
interface Role {
id: number;
name: string;
}
interface UserRecord extends BaseRecord {
username: string;
nickname: string;
roles: Role[];
email?: string; // 可选字段
}
// 2. 定义列配置
// 注意:这里显式声明了 TableColumn<UserRecord>[]
// 好处:如果我在 key 里写 "mobile"TS 会报错,因为 UserRecord 里没有 mobile。
const columns: TableColumn<UserRecord>[] = [
{ key: 'id', label: 'ID', width: '50px' },
{ key: 'nickname', label: '用户昵称' }, // 用 nickname 作为 key
{ key: 'roles', label: '角色列表' }, // key 必须是 'roles'
{ key: 'username', label: '登录账号' }
];
function handleEdit(e: CustomEvent<UserRecord>) {
// e.detail 自动被推断为 UserRecord 类型,不是 any
console.log(e.detail.username);
}
const {current,pages,size,total,records} = data.userList;
const rowTitles = ['ID','用户名','昵称','头像','用户组']
</script>
<div class="p-6">
<div class="flex justify-between items-center bg-base-100">
<div>
<h1 class="text-2xl font-bold">用户列表</h1>
</div>
<div class="breadcrumbs text-sm">
<ul>
<li><a href={resolve('/app/dashboard')} >仪表盘</a></li>
<li><a href={resolve('/app/settings')} >系统设置</a></li>
<li><a href={resolve('/app/settings/auth')} >认证管理</a></li>
<li><a href={resolve('/app/settings/auth/users')} >用户管理</a></li>
</ul>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>
<label>
<input type="checkbox" class="checkbox" />
</label>
</th>
{#each rowTitles as title,index(index)}
<th>{title}</th>
{/each}
</tr>
</thead>
<tbody>
{#each records as record(record.id)}
<tr>
<th>
<label>
<input type="checkbox" class="checkbox" />
</label>
</th>
<td>{record.id}</td>
<td>{record.username}</td>
<td>{record.nickname}</td>
<td><img class="w-8 h-8 rounded-box bg-primary-content/10 border-0" src="{record.avatar}" alt=" "></td>
<td class="flex gap-2">
{#each record.roles as role(role.id)}
<span class="badge {role.id === 1 ? 'badge-primary' : 'badge-secondary'}">{role.name}</span>
{/each}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<th>
</th>
{#each rowTitles as title,index(index)}
<th>{title}</th>
{/each}
</tr>
</tfoot>
</table>
</div>
</div>

View File

@@ -0,0 +1,16 @@
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
import { redirect } from '@sveltejs/kit';
import { userService } from '$lib/api/services/userService.ts';
export async function load({cookies}) {
const token = cookies.get(COOKIE_TOKEN_KEY);
if (!token) {
throw redirect(302, '/auth/login');
}
const profile = await userService.getUserProfile(token);
return {
profile
}
}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
export let data;
</script>
<div>
这里展示个人信息
{JSON.stringify(data.profile)}
</div>

View File

@@ -0,0 +1,8 @@
export function load({ params }) {
console.log('params:', params);
}

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import {page} from '$app/state';
export let data;
</script>
<div>
{JSON.stringify(data)}
<div>
</div>
{JSON.stringify(page.params)}
</div>

View File

@@ -10,6 +10,7 @@
import { getContext } from 'svelte';
import { TOAST_KEY, ToastState } from '$lib/stores/toast.svelte.ts';
const toast = getContext<ToastState>(TOAST_KEY);
let loading = false;