refactor(layout): 重构应用布局结构

- 将原有布局中的侧边栏和头部组件拆分为独立的 AppSidebar 和 AppHeader 组件
- 移除内联的导航逻辑和样式,交由专用组件管理
- 更新图标库,优化部分图标的显示效果
- 简化认证存储逻辑,增强状态持久化与安全性
- 优化侧边栏状态管理机制,提高响应式体验
- 改进登录流程错误处理,增加网络异常提示
- 调整路由组件结构,提升代码可维护性
This commit is contained in:
Chaos
2025-11-24 07:17:12 +08:00
parent 71f19b658c
commit 3515faa814
16 changed files with 431 additions and 315 deletions

View File

@@ -1,48 +1,62 @@
import {api} from '$lib/api/httpClient.ts'
import type { AuthResponse, LoginPayload } from '$lib/types/auth.ts';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/authStore.ts';
import { userService } from '$lib/api/services/userService.ts';
import { userStore } from '$lib/stores/userStore.ts';
import { api } from '$lib/api/httpClient'; // 通常不需要 .ts 后缀
import type { AuthResponse, LoginPayload } from '$lib/types/auth';
import { authStore } from '$lib/stores/authStore';
import { userService } from '$lib/api/services/userService';
import { toast } from '$lib/stores/toastStore';
import { get } from 'svelte/store';
export const authService = {
/**
* 登录流程
*/
login: async (payload: LoginPayload): Promise<AuthResponse> => {
// 1. 调用登录接口
const response = await api.post<AuthResponse>('/auth/login', payload);
if (response.code != 200 || !response.data){
throw new Error(response.msg);
}
if (browser){
authService._setToken(response.data.token, response.data.tokenHead)
if (response.code !== 200 || !response.data) {
throw new Error(response.msg || '登录失败');
}
const { token, tokenHead } = response.data;
// 2. 临时设置 Token 到 Store
// 这一步是必须的,因为接下来的 userService.getUserProfile()
// 里的 API 请求拦截器需要读取 Store 中的 Token 才能通过鉴权。
// 我们先以“部分登录”的状态更新 Store。
authStore.update(s => ({ ...s, token, tokenHead, isAuthenticated: true }));
try {
// 3. 获取用户信息
const userProfile = await userService.getUserProfile();
if (browser){
userStore.set(userProfile)
}
// 4. 最终确认登录状态(更新完整信息并持久化)
// 这里调用 Store 封装好的 login 方法,它会负责写入 localStorage
authStore.login({
token,
tokenHead,
user: userProfile
});
return response.data;
} catch (error) {
// 5. 安全回滚
// 如果获取用户信息失败(比如 Token 虽然返回了但无效,或者网络波动),
// 我们应该立即清除刚才设置的临时 Token防止应用处于中间状态。
console.error('获取用户信息失败,回滚登录状态', error);
authStore.logout();
throw error; // 继续抛出错误给 UI 层处理
}
},
/**
* 登出流程
*/
logout: async () => {
// 逻辑大大简化:只负责调用 Store 和 UI 反馈
authStore.logout();
toast.success('退出登录成功');
if (browser){
authStore.clear();
userStore.clear();
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_token_head');
return true;
}else {
return false;
// 如果需要调用后端登出接口(使 Token 失效),在这里 await api.post('/auth/logout')
}
},
_setToken: (token:string ,tokenHead: string)=> {
authStore.set({ token, tokenHead, isAuthenticated: true });
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_token_head', tokenHead);
}
}
};

View File

@@ -44,43 +44,12 @@
</g>
</symbol>
<symbol id="data-pie" viewBox="0 0 32 32">
<g fill="none">
<path fill="url(#SVGkSGRmbrV)" d="M15 7.012a1 1 0 0 0-1.047-1C7.855 6.3 3 11.333 3 17.5C3 23.851 8.149 29 14.5 29c6.168 0 11.201-4.855 11.487-10.953A1 1 0 0 0 24.988 17H17.5a2.5 2.5 0 0 1-2.5-2.5z" />
<path fill="url(#SVGOonY3dQP)" d="M18.047 3.013A1 1 0 0 0 17 4.012V14a1 1 0 0 0 1 1h9.988a1 1 0 0 0 1-1.047C28.71 8.037 23.962 3.29 18.046 3.013" />
<defs>
<linearGradient id="SVGkSGRmbrV" x1="25.988" x2="-10.793" y1="29" y2="-7.781" gradientUnits="userSpaceOnUse">
<stop stop-color="#6d37cd" />
<stop offset=".641" stop-color="#ea71ef" />
</linearGradient>
<linearGradient id="SVGOonY3dQP" x1="27.989" x2="19.398" y1="12.802" y2="3.012" gradientUnits="userSpaceOnUse">
<stop stop-color="#e23cb4" />
<stop offset="1" stop-color="#ea71ef" />
</linearGradient>
</defs>
</g>
<symbol id="data-pie" viewBox="0 0 16 16">
<path fill="currentColor" d="M14 4.5A2.5 2.5 0 0 0 11.5 2h-7A2.5 2.5 0 0 0 2 4.5v7A2.5 2.5 0 0 0 4.5 14H7v-1H4.5A1.5 1.5 0 0 1 3 11.5V6h10v.268A2 2 0 0 1 14 8zM4.5 3h7A1.5 1.5 0 0 1 13 4.5V5H3v-.5A1.5 1.5 0 0 1 4.5 3M12 7a1 1 0 0 0-1 1v7a1 1 0 1 0 2 0V8a1 1 0 0 0-1-1m-3 4a1 1 0 0 0-1 1v3a1 1 0 1 0 2 0v-3a1 1 0 0 0-1-1m5-1a1 1 0 1 1 2 0v5a1 1 0 1 1-2 0z" />
</symbol>
<symbol id="home" viewBox="0 0 48 48">
<g fill="none">
<path fill="url(#SVGHYC0XdPj)" d="M18.067 27h12v16h-12z" />
<path fill="url(#SVGAip0Sdul)" d="M26.461 7.855a3.78 3.78 0 0 0-4.787 0L8.499 18.597a3.91 3.91 0 0 0-1.432 3.031v17.485C7.067 41.26 8.78 43 10.892 43h8.175V30.5a2.5 2.5 0 0 1 2.5-2.5h5a2.5 2.5 0 0 1 2.5 2.5V43h8.175c2.113 0 3.825-1.74 3.825-3.887V21.628a3.91 3.91 0 0 0-1.43-3.031z" />
<path fill="url(#SVGDweprdys)" fill-rule="evenodd" d="m24.068 9.329l-16 13.215a2.054 2.054 0 0 1-2.852-.262a1.956 1.956 0 0 1 .267-2.794L22.28 5.628a2.83 2.83 0 0 1 3.523-.024l16.805 13.588a1.957 1.957 0 0 1 .307 2.79a2.054 2.054 0 0 1-2.848.3z" clip-rule="evenodd" />
<defs>
<linearGradient id="SVGHYC0XdPj" x1="24.067" x2="13.481" y1="27" y2="44.65" gradientUnits="userSpaceOnUse">
<stop stop-color="#944600" />
<stop offset="1" stop-color="#cd8e02" />
</linearGradient>
<linearGradient id="SVGAip0Sdul" x1="10.313" x2="45.173" y1="5.24" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffd394" />
<stop offset="1" stop-color="#ffb357" />
</linearGradient>
<linearGradient id="SVGDweprdys" x1="17.817" x2="25.308" y1=".725" y2="22.452" gradientUnits="userSpaceOnUse">
<stop stop-color="#ff921f" />
<stop offset="1" stop-color="#eb4824" />
</linearGradient>
</defs>
</g>
<symbol id="home" viewBox="0 0 20 20">
<path fill="currentColor" d="M8.998 2.388a1.5 1.5 0 0 1 2.005 0l5.5 4.942A1.5 1.5 0 0 1 17 8.445V15.5a1.5 1.5 0 0 1-1.5 1.5H13a1.5 1.5 0 0 1-1.5-1.5V12a.5.5 0 0 0-.5-.5H9a.5.5 0 0 0-.5.5v3.5A1.5 1.5 0 0 1 7 17H4.5A1.5 1.5 0 0 1 3 15.5V8.445c0-.425.18-.83.498-1.115zm1.336.744a.5.5 0 0 0-.668 0l-5.5 4.942A.5.5 0 0 0 4 8.445V15.5a.5.5 0 0 0 .5.5H7a.5.5 0 0 0 .5-.5V12A1.5 1.5 0 0 1 9 10.5h2a1.5 1.5 0 0 1 1.5 1.5v3.5a.5.5 0 0 0 .5.5h2.5a.5.5 0 0 0 .5-.5V8.445a.5.5 0 0 0-.166-.371z" />
</symbol>
<symbol id="menu" viewBox="0 0 24 24">
@@ -137,4 +106,21 @@
<path fill="#EF4444" d="m13.299 3.148l8.634 14.954a1.5 1.5 0 0 1-1.299 2.25H3.366a1.5 1.5 0 0 1-1.299-2.25l8.634-14.954c.577-1 2.02-1 2.598 0" class="duoicon-secondary-layer" opacity="0.3" />
<path fill="#EF4444" d="M12 8a1 1 0 0 0-.993.883L11 9v4a1 1 0 0 0 1.993.117L13 13V9a1 1 0 0 0-1-1m0 7a1 1 0 1 0 0 2a1 1 0 0 0 0-2" class="duoicon-primary-layer" />
</symbol>
<symbol id="settings" viewBox="0 0 16 16">
<path fill="currentColor" d="M8 6a2 2 0 1 0 0 4a2 2 0 0 0 0-4M7 8a1 1 0 1 1 2 0a1 1 0 0 1-2 0m3.618-3.602a.71.71 0 0 1-.824-.567l-.26-1.416a.35.35 0 0 0-.275-.282a6.1 6.1 0 0 0-2.519 0a.35.35 0 0 0-.275.282l-.259 1.416a.71.71 0 0 1-.936.538l-1.359-.484a.36.36 0 0 0-.382.095a6 6 0 0 0-1.262 2.173a.35.35 0 0 0 .108.378l1.102.931q.045.037.081.081a.704.704 0 0 1-.081.995l-1.102.931a.35.35 0 0 0-.108.378A6 6 0 0 0 3.53 12.02a.36.36 0 0 0 .382.095l1.36-.484a.708.708 0 0 1 .936.538l.258 1.416c.026.14.135.252.275.281a6.1 6.1 0 0 0 2.52 0a.35.35 0 0 0 .274-.281l.26-1.416a.71.71 0 0 1 .936-.538l1.359.484c.135.048.286.01.382-.095a6 6 0 0 0 1.262-2.173a.35.35 0 0 0-.108-.378l-1.102-.931a.703.703 0 0 1 0-1.076l1.102-.931a.35.35 0 0 0 .108-.378A6 6 0 0 0 12.47 3.98a.36.36 0 0 0-.382-.095l-1.36.484a1 1 0 0 1-.111.03m-6.62.58l.937.333a1.71 1.71 0 0 0 2.255-1.3l.177-.97a5 5 0 0 1 1.265 0l.178.97a1.708 1.708 0 0 0 2.255 1.3L12 4.977q.384.503.63 1.084l-.754.637a1.704 1.704 0 0 0 0 2.604l.755.637a5 5 0 0 1-.63 1.084l-.937-.334a1.71 1.71 0 0 0-2.255 1.3l-.178.97a5 5 0 0 1-1.265 0l-.177-.97a1.708 1.708 0 0 0-2.255-1.3L4 11.023a5 5 0 0 1-.63-1.084l.754-.638a1.704 1.704 0 0 0 0-2.603l-.755-.637q.248-.581.63-1.084" />
</symbol>
<symbol id="user-settings" viewBox="0 0 32 32">
<path fill="currentColor" d="M25.303 16.86a7.5 7.5 0 0 1 2.749 1.596l-.495 1.725a1.52 1.52 0 0 0 1.095 1.892l1.698.423a7.5 7.5 0 0 1-.04 3.189l-1.536.351a1.52 1.52 0 0 0-1.117 1.927l.467 1.514a7.5 7.5 0 0 1-2.737 1.635L24.15 29.84a1.53 1.53 0 0 0-2.192 0l-1.26 1.3a7.5 7.5 0 0 1-2.75-1.597l.495-1.724a1.52 1.52 0 0 0-1.095-1.892l-1.698-.424a7.5 7.5 0 0 1 .04-3.189l1.536-.35a1.52 1.52 0 0 0 1.117-1.928l-.467-1.513a7.5 7.5 0 0 1 2.737-1.635l1.237 1.272a1.53 1.53 0 0 0 2.192 0zM16 17c.387 0 .757.075 1.097.209a8.98 8.98 0 0 0-2.962 8.342c-.995.28-2.192.449-3.635.449C2.04 26 2 20.205 2 20.15V20a3 3 0 0 1 3-3zm7 5a2 2 0 1 0 0 4a2 2 0 0 0 0-4M10.5 4a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11M23 7a4 4 0 1 1 0 8a4 4 0 0 1 0-8" />
</symbol>
<symbol id="user-profile" viewBox="0 0 16 16">
<path fill="currentColor" d="M1 4.75C1 3.784 1.784 3 2.75 3h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 13H2.75A1.75 1.75 0 0 1 1 11.25zM2.75 4a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75h10.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75zM9.5 6a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zM5.261 7.714a1.357 1.357 0 1 0 0-2.714a1.357 1.357 0 0 0 0 2.714m-1.403.678A.86.86 0 0 0 3 9.25a1.67 1.67 0 0 0 1.265 1.62l.053.014c.62.155 1.267.155 1.886 0l.054-.013a1.67 1.67 0 0 0 1.265-1.62a.86.86 0 0 0-.858-.859z" />
</symbol>
<symbol id="sign-out" viewBox="0 0 20 20">
<path fill="currentColor" d="M8.5 11.25a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5M11 3.5a.5.5 0 0 0-.576-.494l-7 1.07A.5.5 0 0 0 3 4.57v10.86a.5.5 0 0 0 .424.494l7 1.071a.5.5 0 0 0 .576-.494V10h5.172l-.997.874a.5.5 0 0 0 .658.752l1.996-1.75a.5.5 0 0 0 0-.752l-1.996-1.75a.499.499 0 1 0-.658.752l.997.874H11zm-1 .582V15.92L4 15V4.999zM12.5 16H12v-5h1v4.5a.5.5 0 0 1-.5.5M12 8V4h.5a.5.5 0 0 1 .5.5V8z" />
</symbol>
</svg>

View File

@@ -1,7 +0,0 @@
<script lang="ts">
</script>
<div>
header
</div>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
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" />
</button>
</div>
<div class="flex justify-center items-center gap-4 select-none">
<ThemeSelector/>
{#if $authStore.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}
<img
class="w-8 h-8 rounded-full"
src="{$authStore.user.avatar}"
alt="Avatar"
/>
{:else}
<span>{$authStore.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>
</div>
</div>
</div>
{:else}
<div class="flex items-center">
<div class="">
<button class="btn btn-primary btn-sm" onclick={() => goto(resolve("/auth/login"))}>
登录
</button>
</div>
</div>
{/if}
</div>
</header>

View File

@@ -0,0 +1,213 @@
<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 { sidebarStore, setSidebarOpen } from '$lib/stores/sidebarStore';
import { authStore } from '$lib/stores/authStore.ts'; // 假设你有这个store
import { } from '$lib/stores/authStore.ts'; // 假设你有这个store
import type { NavItem } from '$lib/types/layout.ts';
import { authService } from '$lib/api/services/authService.ts';
// 模拟一些数据,增加了分组的概念(如果需要)
const rawNavItems: NavItem[] = [
{
id: 'dashboard',
label: '仪表盘',
icon: 'home',
href: '/app/dashboard',
},
{
id: 'statistics',
label: '数据看板',
icon: 'data-pie',
href: '/app/statistics',
},
{
id: 'settings',
label: '系统设置',
icon: 'settings',
href: '/app/settings',
subItems: [
{
id: 'users',
label: '用户管理',
href: '/app/settings/users',
},
{
id: 'roles',
label: '角色权限',
href: '/app/settings/roles',
},
]
}
];
/**
* 递归计算高亮状态
* 只要子元素有一个是激活的,父元素就算激活
*/
function processNavItems(items: NavItem[], currentPath: string): any[] {
return items.map(item => {
// 判断当前项是否匹配
const isSelfActive = item.href === '/'
? currentPath === '/'
: currentPath.startsWith(item.href);
// 处理子项
let processedSubItems = undefined;
let isChildActive = false;
if (item.subItems) {
processedSubItems = processNavItems(item.subItems, currentPath);
// 检查是否有子项被激活
isChildActive = processedSubItems.some(sub => sub.isActive || sub.isChildActive);
}
return {
...item,
subItems: processedSubItems,
isActive: isSelfActive, // 自身路径匹配
isChildActive: isChildActive // 子路径匹配(用于展开菜单)
};
});
}
// 使用 $derived 动态计算
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
function handleMobileClose() {
const isMobile = window.innerWidth < 768;
if (isMobile && $sidebarStore.isOpen) {
setSidebarOpen(false);
}
}
</script>
{#if $sidebarStore.isOpen}
<div
role="button"
tabindex="0"
class="fixed inset-0 bg-black/50 z-20 md:hidden cursor-pointer backdrop-blur-sm"
onclick={handleMobileClose}
onkeydown={(e) => e.key === 'Escape' && handleMobileClose()}
transition:fade={{ duration: 200 }}
></div>
<aside
in:fly={{duration: 200, x: -100}}
out:fly={{duration: 200, x: -100}}
class="flex-shrink-0 flex flex-col bg-base-200 border-r border-base-100/70 fixed h-full md:relative z-30 w-64"
>
<div class="h-18 p-4 flex items-center flex-shrink-0">
<a href={resolve("/app/dashboard")} class="flex items-center gap-3" onclick={handleMobileClose}>
<Icon id="logo" size="32" className="flex-shrink-0" />
<p class="truncate font-bold font-serif text-lg">IT DTMS</p>
</a>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<ul class="menu px-2 menu-vertical w-full gap-1">
{#each navItems as item (item.id)}
{#if item.subItems && item.subItems.length > 0}
<li>
<details open={item.isChildActive}>
<summary class="group {item.isActive ? 'text-primary font-medium' : ''}">
<Icon id={item.icon} size="20" />
<span class="truncate">{item.label}</span>
</summary>
<ul>
{#each item.subItems as subItem (subItem.id)}
<li>
<a
href={resolve(subItem.href)}
onclick={handleMobileClose}
class={subItem.isActive ? 'active font-medium' : ''}
>
<span class="text-xs opacity-50"></span>
<span class="truncate">{subItem.label}</span>
</a>
</li>
{/each}
</ul>
</details>
</li>
{:else}
<li>
<a
href={resolve(item.href)}
onclick={handleMobileClose}
class="group {item.isActive ? 'active font-medium' : ''}"
>
<Icon id={item.icon} size="20" />
<span class="truncate">{item.label}</span>
</a>
</li>
{/if}
{/each}
</ul>
</div>
{#if $authStore.isAuthenticated && $authStore.user}
<div class="p-3 border-t border-base-content/10 flex-shrink-0 bg-base-200/50">
<div class="dropdown dropdown-top dropdown-end w-full">
<div
tabindex="0"
role="button"
class="flex items-center gap-3 p-2 rounded-lg hover:bg-base-300 transition-colors cursor-pointer w-full"
>
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
{#if $authStore.user?.avatar}
<img src={$authStore.user.avatar} alt="avatar" />
{:else}
<span class="text-xs">User</span>
{/if}
</div>
</div>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-bold truncate">{$authStore.user.nickname}</span>
<span class="text-xs text-base-content/60 truncate">@{$authStore.user?.username}</span>
</div>
<Icon id="chevrons-up-down" size="16" className="opacity-50" />
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-60 mb-2 border border-base-content/10">
<li class="menu-title px-4 py-2">我的账户</li>
<li><a href="/app/profile"><Icon id="user-profile" size="16"/> 个人资料</a></li>
<li><a href="/app/settings"><Icon id="settings" size="16"/> 设置</a></li>
<div class="divider my-1"></div>
<li>
<button class="text-error" onclick={authService.logout}>
<Icon id="sign-out" size="16"/> 退出登录
</button>
</li>
</ul>
</div>
</div>
{/if}
</aside>
{/if}
<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

@@ -1,38 +1,67 @@
import {writable} from 'svelte/store';
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;
}
let initialToken: string | null = null;
let initialTokenHead: string | null = null;
const STORAGE_KEY = 'app_auth_state';
if (browser) {
initialToken = localStorage.getItem('auth_token');
initialTokenHead = localStorage.getItem('auth_token_head');
}
const initialAuthStore: AuthStore = {
token: initialToken,
tokenHead: initialTokenHead,
isAuthenticated: initialToken !== null
}
const authStatusStore = writable<AuthStore>({
token: initialToken,
tokenHead: initialTokenHead,
isAuthenticated: initialToken !== null
})
export const authStore = {
subscribe: authStatusStore.subscribe,
set: authStatusStore.set,
update: authStatusStore.update,
clear: () => {
authStatusStore.set(initialAuthStore);
},
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));
}
set(newState);
},
logout: () => {
if (browser) {
localStorage.removeItem(STORAGE_KEY);
}
set(emptyAuth);
}
};
}
export const authStore = createAuthStore();

View File

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

View File

@@ -1,37 +0,0 @@
import { type Writable, writable} from 'svelte/store';
import type { UserProfile } from '$lib/types/user.ts';
export const userStateStore:Writable<UserProfile > = writable<UserProfile>( {
id: '',
name: '',
nickname: '',
roles: [],
});
const initialUserProfile: UserProfile = {
id: '',
name: '',
nickname: '',
roles: [],
};
const clearUserProfile = () => {
userStore.set(initialUserProfile);
};
export const userStore = {
// 导出 subscribe 方法供组件订阅
subscribe: userStateStore.subscribe,
// 导出 set 方法
set: userStateStore.set,
// 导出 update 方法
update: userStateStore.update,
// 导出清晰的 'clear' 方法
clear: clearUserProfile
};

View File

@@ -11,5 +11,8 @@ export type IconId =
"success"|
"error"|
"warning"|
"info"
"info"|
"settings"|
"user-settings" |
"user-profile"
;

View File

@@ -3,7 +3,7 @@ import type { RouteId } from '$app/types';
export interface NavItem {
id: string;
icon: IconId;
icon?: IconId;
label: string;
href: RouteId;
isActive?: boolean;

View File

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

View File

@@ -14,7 +14,9 @@
<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>
<ul class="dropdown-content shadow bg-base-200 border border-base-content/10 z-[1] rounded-box w-64 max-h-80 overflow-x-hidden overflow-y-auto flex flex-col ">
<ul
class="dropdown-content shadow bg-base-200 border border-base-content/10 z-[1] rounded-box w-64 max-h-80 overflow-x-hidden overflow-y-auto ">
{#each DAISYUI_THEME_OPTIONS as theme (theme.value)}

View File

@@ -1,154 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authService } from '$lib/api/services/authService.ts';
import ThemeSelector from '$lib/widget/ThemeSelector.svelte';
import Icon from '$lib/components/icon/Icon.svelte';
import { authStore } from '$lib/stores/authStore.ts';
import { sidebarStore, toggleSidebar, toggleSidebarOpen } from '$lib/stores/sidebarStore';
import { page } from '$app/state';
import { fly, fade } from 'svelte/transition';
import type { NavItem } from '$lib/types/layout.ts';
import AppHeader from '$lib/components/layout/app/AppHeader.svelte';
import AppSidebar from '$lib/components/layout/app/AppSidebar.svelte';
let { children } = $props();
const rawNavItems: NavItem[] = [
{
id: 'dashboard',
label: '仪表盘',
icon: 'home',
href: '/app/dashboard',
isActive: true
},
{
id: 'statistics',
label: '数据看板',
icon: 'data-pie',
href: '/app/statistics',
}
];
/**
* 处理移动端关闭逻辑
* 仅在屏幕宽度小于 768px (Tailwind 的 md 断点) 时触发关闭
*/
function handleMobileClose() {
// 获取当前窗口宽度
const isMobile = window.innerWidth < 768;
// 如果是移动端且侧边栏是打开状态,则关闭它
if (isMobile && $sidebarStore.isOpen) {
toggleSidebarOpen();
}
}
// 2. 使用 $derived 生成响应式的新数组
// Svelte 5 会自动追踪 $page.url.pathname 的变化
let navItems = $derived(rawNavItems.map(item => {
// 处理根路径 '/' 的特殊逻辑,防止所有页面都高亮首页
const isActive = item.href === '/'
? page.url.pathname === '/'
: page.url.pathname.startsWith(item.href);
return {
...item,
isActive // 将计算结果合并进去
};
}));
</script>
<div class="flex h-screen bg-base-300 overflow-hidden relative">
{#if $sidebarStore.isOpen}
<div
role="button"
tabindex="0"
class="fixed inset-0 bg-black/50 z-20 md:hidden cursor-pointer backdrop-blur-sm"
onclick={handleMobileClose}
onkeydown={(e) => e.key === 'Escape' && handleMobileClose()}
transition:fade={{ duration: 200 }}
></div>
<AppSidebar />
<aside
in:fly={{duration: 200, x: -100}}
out:fly={{duration: 200, x: -100}}
class="flex-shrink-0 flex flex-col bg-base-200 border-r border-base-100/70 fixed h-full md:relative z-30
transition-all duration-500 ease-in-out {$sidebarStore.isExpanded ? 'w-56' : 'w-16'}"
>
<div class="h-12 p-4 transition-all duration-1000">
<a href={resolve("/app/dashboard")} class="flex items-center gap-2" onclick={handleMobileClose}>
<Icon id="logo" size="32" className="flex-shrink-0" />
<p class="truncate flex-shrink-1"> </p>
{#if $sidebarStore.isExpanded}
<p class="truncate font-bold font-serif">IT DTMS</p>
{/if}
</a>
</div>
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<ul class="menu pt-6 menu-vertical transition-all duration-1000 w-full">
{#each navItems as item(item.id)}
<li class="w-full {item.isActive ? 'menu-active' : ''}">
<a href={resolve(item.href)} onclick={handleMobileClose}>
<Icon id={item.icon} size="24" />
{#if $sidebarStore.isExpanded}
<p class="truncate">{item.label}</p>
{/if}
</a>
</li>
{/each}
</ul>
<AppHeader />
<div class="absolute bottom-2 right-3">
<button
class="btn btn-square btn-ghost"
onclick={toggleSidebar}
>
<Icon id="menu" size="24" />
</button>
</div>
</aside>
{/if}
<div class="w-full overflow-y-auto">
<header class="w-full h-18 flex justify-between items-center px-4 bg-base-300">
<div class="md:hidden">
<button
class="btn btn-square btn-ghost"
onclick={toggleSidebarOpen}
>
<Icon id="menu" size="24" />
</button>
</div>
<div class=""></div>
<div class="flex justify-center items-center gap-4">
<ThemeSelector/>
{#if $authStore.isAuthenticated}
<button
tabindex="0"
class="rounded-full bg-primary h-12 w-12"
onclick={()=>{
console.log("退出登录")
authService.logout()
}}
aria-label="Logout"
></button>
{:else}
<div class="flex items-center">
<div class="w-24">
<button class="btn btn-primary btn-wide" onclick={()=>{goto(resolve("/auth/login"))}}>登录</button>
</div>
</div>
{/if}
</div>
</header>
<main class="">
<main class="flex-1 overflow-y-auto p-4">
{@render children()}
</main>
</div>
</div>

View File

@@ -37,8 +37,12 @@
}
} catch (e:unknown) {
if (e instanceof Error) {
if (e.name === 'TypeError' && e.message.includes('fetch')) {
toast.error("网络错误,请检查网络连接");
} else {
console.error(e);
toast.error(e.message);
toast.error(e.message || '登录失败,请重试');
}
} else {
console.error(e);
toast.error('登录失败,请重试');