feat(auth): implement login and user management features

- Added server-side login action with form handling and cookie storage
- Implemented user authentication service with token management
- Created user list page with data fetching from userService
- Developed reusable DataTable component with selection and pagination
- Enhanced AppSidebar with nested navigation and active state tracking
- Updated icon definitions and sprite symbols for UI consistency
- Improved HTTP client to properly handle request bodies for different methods
- Refactored auth store to manage authentication state and cookies
- Added strict typing for navigation items and table columns
- Removed obsolete code and simplified authentication flow
This commit is contained in:
Chaos
2025-11-24 17:11:41 +08:00
parent 3515faa814
commit ed542f108c
16 changed files with 472 additions and 203 deletions

View File

@@ -61,66 +61,68 @@ export class HttpError extends Error {
}
const httpRequest= async <T>(
url:string,
const httpRequest = async <T>(
url: string,
method: HttpMethod,
options: RequestOptions = {}
):Promise<ApiResult<T>> =>{
): Promise<ApiResult<T>> => {
const fullUrl = `${API_BASE_URL}${url}`;
const { body, headers, ...rest} = options;
const { body, headers, ...rest } = options;
const requestHeaders: Record<string, string> = normalizeHeaders(headers);
let requestBody:BodyInit | undefined;
const requestHeaders: Record<string, string> = normalizeHeaders(headers);
let requestBody: BodyInit | undefined;
if (body instanceof FormData){
requestBody = body;
}else if (body){
requestHeaders['content-type'] = 'application/json';
requestBody = JSON.stringify(body);
const canHaveBody = method !== 'GET' ;
// 【修改点 2】只有在允许携带 Body 时才处理
if (canHaveBody) {
if (body instanceof FormData) {
requestBody = body;
} else if (body) {
requestHeaders['content-type'] = 'application/json';
requestBody = JSON.stringify(body);
}
}
// ... Token 处理逻辑保持不变 ...
if (currentToken && currentTokenHead) {
requestHeaders['authorization'] = `${currentTokenHead} ${currentToken}`;
}
try {
const response = await fetch(fullUrl,{
const response = await fetch(fullUrl, {
method,
headers: requestHeaders,
body: requestBody,
// 【修改点 3】确保 GET 请求的 body 显式为 undefined
// 虽然通常 undefined 是被允许的,但加上 canHaveBody 判断更加严谨
body: canHaveBody ? requestBody : undefined,
...rest
})
});
if (!response.ok) {
let errorDetail;
try {
errorDetail = await response.json()
}catch (e){
errorDetail = await response.json();
} catch (e) {
console.error('Error parsing JSON:', e);
errorDetail = await response.text()
errorDetail = await response.text();
}
const message = `HTTP Error ${response.status} (${response.statusText})`;
throw new HttpError(message, response.status, errorDetail);
}
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')){
return (await response.json() ) as ApiResult<T>;
if (contentType && contentType.includes('application/json')) {
return (await response.json()) as ApiResult<T>;
}
return {code:200, msg:'OK', data:null} ;
return { code: 200, msg: 'OK', data: null } ; // 这里的 as any 是为了兼容 T 可能是 null 的情况
}catch (error){
} catch (error) {
console.error(`API Request Failed to ${fullUrl}:`, error);
throw error;
}
}
};
export const api = {

View File

@@ -3,7 +3,6 @@ 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 = {
/**
@@ -19,15 +18,11 @@ export const authService = {
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();
const userProfile = await userService.getUserProfile({tokenHead,token});
// 4. 最终确认登录状态(更新完整信息并持久化)
// 这里调用 Store 封装好的 login 方法,它会负责写入 localStorage
@@ -40,9 +35,7 @@ export const authService = {
return response.data;
} catch (error) {
// 5. 安全回滚
// 如果获取用户信息失败(比如 Token 虽然返回了但无效,或者网络波动),
// 我们应该立即清除刚才设置的临时 Token防止应用处于中间状态。
console.error('获取用户信息失败,回滚登录状态', error);
authStore.logout();
throw error; // 继续抛出错误给 UI 层处理
@@ -53,10 +46,7 @@ export const authService = {
* 登出流程
*/
logout: async () => {
// 逻辑大大简化:只负责调用 Store 和 UI 反馈
authStore.logout();
toast.success('退出登录成功');
// 如果需要调用后端登出接口(使 Token 失效),在这里 await api.post('/auth/logout')
}
};

View File

@@ -1,12 +1,23 @@
import { api } from '$lib/api/httpClient.ts';
import type { UserProfile } from '$lib/types/user.ts';
import type { PageResult } from '$lib/types/dataTable.ts';
export const userService = {
getUserProfile: async () => {
getUserProfile: async ({ tokenHead, token}) => {
const response = await api.get<UserProfile>('/user/profile');
if (response.code != 200 || !response.data){
throw new Error(response.msg);
}
return response.data;
}
},
getAllUsers: async ({ page, size}: { page: number, size: number}) => {
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});
if (response.code != 200 || !response.data){
throw new Error(response.msg);
}
return response.data;
},
}

View File

@@ -0,0 +1,148 @@
<script lang="ts" generics="T extends import('$lib/types/dataTable').BaseRecord">
// --- Props ---
import type { PageResult, TableColumn } from '$lib/types/dataTable.ts';
import { createEventDispatcher } from 'svelte';
export let data: PageResult<T>;
// 这里的 columns 被严格约束,传入错误的 key 会报错
export let columns: TableColumn<T>[];
export let loading: boolean = false;
// --- State ---
let selectedIds: Set<number | string> = new Set();
// 响应式计算
$: allSelected = data.records.length > 0 && data.records.every(item => selectedIds.has(item.id));
$: indeterminate = data.records.some(item => selectedIds.has(item.id)) && !allSelected;
// 定义事件,为了严格起见,我们明确 Payload 类型
const dispatch = createEventDispatcher<{
pageChange: number;
delete: T;
edit: T;
batchDelete: (number | string)[];
}>();
// --- Logic ---
function toggleAll() {
if (allSelected) {
data.records.forEach(item => selectedIds.delete(item.id));
} else {
data.records.forEach(item => selectedIds.add(item.id));
}
selectedIds = selectedIds;
}
function toggleOne(id: number | string) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
selectedIds = selectedIds;
}
function handleBatchDelete() {
dispatch('batchDelete', Array.from(selectedIds));
selectedIds = new Set();
}
</script>
<div class="bg-base-100 rounded-box shadow-md w-full border border-base-200">
<div class="p-4 border-b border-base-200 flex justify-between items-center bg-base-100 rounded-t-box">
<div class="flex gap-2 items-center">
{#if selectedIds.size > 0}
<div class="badge badge-neutral">已选 {selectedIds.size}</div>
<button class="btn btn-error btn-sm text-white" on:click={handleBatchDelete}>
批量删除
</button>
{:else}
<slot name="toolbar"></slot>
{/if}
</div>
<div><slot name="toolbar-right"></slot></div>
</div>
<div class="overflow-x-auto">
<table class="table w-full">
<thead class="bg-base-200/50">
<tr>
<th class="w-12">
<label>
<input type="checkbox" class="checkbox checkbox-sm"
checked={allSelected}
indeterminate={indeterminate}
on:change={toggleAll} />
</label>
</th>
{#each columns as col(col.key)}
<th class="{col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left'} font-semibold">
{col.label}
</th>
{/each}
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
{#if loading}
{#each Array(5) as _}
<tr><td colspan={columns.length + 2} class="skeleton h-12 w-full rounded-none opacity-50"></td></tr>
{/each}
{:else if data.records.length === 0}
<tr>
<td colspan={columns.length + 2} class="text-center py-10 text-base-content/50">
暂无数据
</td>
</tr>
{:else}
{#each data.records as row (row.id)}
<tr class="hover group {selectedIds.has(row.id) ? 'bg-base-200/30' : ''}">
<td>
<label>
<input type="checkbox" class="checkbox checkbox-sm"
checked={selectedIds.has(row.id)}
on:change={() => toggleOne(row.id)} />
</label>
</td>
{#each columns as col}
<td class="{col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left'}">
<slot name="cell" row={row} key={col.key} value={row[col.key]}>
{String(row[col.key] ?? '-')}
</slot>
</td>
{/each}
<td class="text-right">
<div class="join opacity-0 group-hover:opacity-100 transition-opacity">
<button class="btn btn-xs btn-ghost" on:click={() => dispatch('edit', row)}>编辑</button>
<button class="btn btn-xs btn-ghost text-error" on:click={() => dispatch('delete', row)}>删除</button>
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{#if data.total > 0}
<div class="p-4 flex justify-between items-center border-t border-base-200">
<span class="text-sm opacity-60">{data.current} / {data.pages}</span>
<div class="join">
<button class="join-item btn btn-sm" disabled={data.current === 1}
on:click={() => dispatch('pageChange', data.current - 1)}>«</button>
<button class="join-item btn btn-sm pointer-events-none bg-base-100">
{data.current}
</button>
<button class="join-item btn btn-sm" disabled={data.current === data.pages}
on:click={() => dispatch('pageChange', data.current + 1)}>»</button>
</div>
</div>
{/if}
</div>

View File

@@ -44,12 +44,12 @@
</g>
</symbol>
<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 id="data" viewBox="0 0 16 16">
<path fill="currentColor" d="M10 4a2 2 0 1 0-4 0v10h4zM5 7H4a2 2 0 0 0-2 2v4.5a.5.5 0 0 0 .5.5H5zm6 7h2.5a.5.5 0 0 0 .5-.5V7a2 2 0 0 0-2-2h-1z" />
</symbol>
<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 id="home" viewBox="0 0 16 16">
<path fill="currentColor" d="M8.687 1.262a1 1 0 0 0-1.374 0L2.469 5.84A1.5 1.5 0 0 0 2 6.931v5.57A1.5 1.5 0 0 0 3.5 14H5a1.5 1.5 0 0 0 1.5-1.5V10a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2.5A1.5 1.5 0 0 0 11 14h1.5a1.5 1.5 0 0 0 1.5-1.5V6.93a1.5 1.5 0 0 0-.47-1.09z" />
</symbol>
<symbol id="menu" viewBox="0 0 24 24">
@@ -107,10 +107,10 @@
<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="settings" viewBox="0 0 16 16">
<path fill="currentColor" d="M2.267 6.153A6 6 0 0 1 3.53 3.98a.36.36 0 0 1 .382-.095l1.36.484a.71.71 0 0 0 .935-.538l.26-1.416a.35.35 0 0 1 .274-.282a6.1 6.1 0 0 1 2.52 0c.14.03.248.141.274.282l.26 1.416a.708.708 0 0 0 .935.538l1.36-.484a.36.36 0 0 1 .382.095a6 6 0 0 1 1.262 2.173a.35.35 0 0 1-.108.378l-1.102.931a.703.703 0 0 0 0 1.076l1.102.931c.11.093.152.242.108.378a6 6 0 0 1-1.262 2.173a.36.36 0 0 1-.382.095l-1.36-.484a.71.71 0 0 0-.935.538l-.26 1.416a.35.35 0 0 1-.275.282a6.1 6.1 0 0 1-2.519 0a.35.35 0 0 1-.275-.282l-.259-1.416a.708.708 0 0 0-.935-.538l-1.36.484a.36.36 0 0 1-.382-.095a6 6 0 0 1-1.262-2.173a.35.35 0 0 1 .108-.378l1.102-.931a.704.704 0 0 0 0-1.076l-1.102-.931a.35.35 0 0 1-.108-.378M6.25 8a1.75 1.75 0 1 0 3.5 0a1.75 1.75 0 0 0-3.5 0" />
</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>
@@ -123,4 +123,6 @@
<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>
<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>
</svg>

View File

@@ -5,24 +5,24 @@
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';
import { authStore } from '$lib/stores/authStore';
import type { NavItem, ProcessedNavItem } from '$lib/types/layout';
import { authService } from '$lib/api/services/authService';
// 模拟一些数据,增加了分组的概念(如果需要)
// 1. 模拟数据:包含三层结构
const rawNavItems: NavItem[] = [
{
id: 'dashboard',
label: '仪表盘',
icon: 'home',
href: '/app/dashboard',
href: '/app/dashboard'
},
{
id: 'statistics',
label: '数据看板',
icon: 'data-pie',
href: '/app/statistics',
icon: 'data',
href: '/app/statistics'
},
{
id: 'settings',
@@ -31,50 +31,78 @@
href: '/app/settings',
subItems: [
{
id: 'users',
label: '用户管理',
href: '/app/settings/users',
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: 'roles',
label: '角色权限',
href: '/app/settings/roles',
},
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): any[] {
return items.map(item => {
// 判断当前项是否匹配
const isSelfActive = item.href === '/'
? currentPath === '/'
: currentPath.startsWith(item.href);
function processNavItems(items: NavItem[], currentPath: string): ProcessedNavItem[] {
return items.map((item) => {
const isSelfActive =
item.href === '/' ? currentPath === '/' : currentPath.startsWith(item.href);
// 处理子项
let processedSubItems = undefined;
let processedSubItems: ProcessedNavItem[] | undefined = undefined;
let isChildActive = false;
if (item.subItems) {
// 递归调用
processedSubItems = processNavItems(item.subItems, currentPath);
// 检查是否有子项激活
isChildActive = processedSubItems.some(sub => sub.isActive || sub.isChildActive);
// 检查子项激活状态
isChildActive = processedSubItems.some((sub) => sub.isActive || sub.isChildActive);
}
return {
...item,
subItems: processedSubItems,
isActive: isSelfActive, // 自身路径匹配
isChildActive: isChildActive // 子路径匹配(用于展开菜单)
subItems: processedSubItems, // 这里类型现在是 ProcessedNavItem[]
isActive: isSelfActive,
isChildActive: isChildActive
};
});
}
// 使用 $derived 动态计算
// 使用 $derived 动态计算,类型自动推断为 ProcessedNavItem[]
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
function handleMobileClose() {
@@ -85,85 +113,87 @@
}
</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)}
onclick={handleMobileClose}
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}
{#if $sidebarStore.isOpen}
<div
role="button"
tabindex="0"
class="fixed inset-0 bg-black/50 z-20 md:hidden cursor-pointer backdrop-blur-sm"
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>
<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"
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 p-4 flex items-center flex-shrink-0">
<a href={resolve("/app/dashboard")} class="flex items-center gap-3" onclick={handleMobileClose}>
<div class="h-18 flex flex-shrink-0 items-center p-4">
<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>
<p class="truncate font-serif text-lg font-bold">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">
<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)}
{#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}
<!-- 初始渲染调用 -->
{@render menuItem(item)}
{/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="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="flex items-center gap-3 p-2 rounded-lg hover:bg-base-300 transition-colors cursor-pointer w-full"
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 rounded-full w-10">
<div class="bg-neutral text-neutral-content w-10 rounded-full">
{#if $authStore.user?.avatar}
<img src={$authStore.user.avatar} alt="avatar" />
{:else}
@@ -172,34 +202,42 @@
</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 class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-bold">{$authStore.user.nickname}</span>
<span class="text-base-content/60 truncate text-xs"
>@{$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">
<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></li>
<li><a href="/app/settings"><Icon id="settings" size="16"/> 设置</a></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"/> 退出登录
<Icon id="sign-out" size="16" /> 退出登录
</button>
</li>
</ul>
</div>
</div>
{/if}
</aside>
{/if}
<style>
/* 可选:美化滚动条 */
/* 保持原有样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
}

View File

@@ -52,12 +52,14 @@ function createAuthStore() {
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);
}

View File

@@ -0,0 +1,21 @@
export interface BaseRecord {
id: number | string;
}
export interface TableColumn<T> {
key: keyof T; // 核心修改:强制 key 必须存在于数据模型中
label: string;
width?: string;
align?: 'left' | 'center' | 'right';
}
export interface PageResult<T> {
records: T[];
total: number;
size: number;
current: number;
pages: number;
}

View File

@@ -3,7 +3,7 @@ export type IconId =
"panel-right-close-solid"|
"panel-left-close"|
"panel-left-close-solid"|
"data-pie"|
"data"|
"starburst"|
"home"|
"menu"|
@@ -14,5 +14,6 @@ export type IconId =
"info"|
"settings"|
"user-settings" |
"user-profile"
"user-profile"|
"auth"
;

View File

@@ -11,3 +11,10 @@ export interface NavItem {
isHidden?: boolean;
subItems?: NavItem[];
}
export interface ProcessedNavItem extends Omit<NavItem, 'subItems'> {
isActive: boolean;
isChildActive: boolean;
// 递归定义:子项也是 ProcessedNavItem
subItems?: ProcessedNavItem[];
}

View File

@@ -0,0 +1,12 @@
import type { PageServerLoad } from './$types';
import { userService } from '$lib/api/services/userService.ts';
export const load:PageServerLoad = async ({ locals }) => {
const allUsers = await userService.getAllUsers({ page: 1, size: 10 });
return {
"userList": allUsers
};
};

View File

@@ -0,0 +1,41 @@
<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 自动生成的类型
// 从 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);
}
</script>
<div class="p-6">
</div>

View File

@@ -0,0 +1,38 @@
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { authService } from '$lib/api/services/authService.ts';
import { HttpError } from '$lib/api/httpClient.ts';
export const actions:Actions = {
default: async ({ request,cookies }) => {
const formData = await request.formData();
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!username || !password) {
return fail(400, {missing:true})
}
try {
const authResponse = await authService.login({username, password});
cookies.set('auth_token', authResponse.token, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: import.meta.env.PROD,
maxAge: 60 * 60 * 24 * 7 // 7 days
});
return {success:true};
}catch ( error){
if (error instanceof HttpError){
return fail(400, {message:error.details.msg});
}
}
}
}

View File

@@ -1,56 +1,9 @@
<script lang="ts">
import { authService } from '$lib/api/services/authService.ts';
import type { LoginPayload } from '$lib/types/auth.ts';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { get } from 'svelte/store';
import { authStore } from '$lib/stores/authStore.ts';
import Icon from '$lib/components/icon/Icon.svelte';
import { toast } from '$lib/stores/toastStore.ts';
export let form;
// 使用 bind:value 直接绑定,不需要手动写 handleChange
let loginPayload: LoginPayload = {
username: '',
password: ''
};
let rememberMe = false; // 变量名语义更清晰,原 isSaving 容易歧义
let isLoading = false; // 新增:控制按钮加载状态
const handleSubmit = async () => {
if (isLoading) return;
isLoading = true;
try {
// 模拟延时效果,让用户感觉到正在处理 (可选)
// await new Promise(r => setTimeout(r, 500));
await authService.login(loginPayload);
if (get(authStore).isAuthenticated) {
toast.success('登录成功,正在跳转到首页')
setTimeout( async () => {
await goto(resolve('/app/dashboard'));
}, 1000)
}
} 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 || '登录失败,请重试');
}
} else {
console.error(e);
toast.error('登录失败,请重试');
}
} finally {
isLoading = false;
}
};
</script>
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
@@ -64,8 +17,12 @@
<span>IT DTMS登录</span>
</h2>
</div>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
{#if form?.message}
<div class="alert alert-error text-sm py-2 mb-4">
{form.message}
</div>
{/if}
<form method="post" use:enhance class="space-y-4">
<div class="form-control">
<label class="label">
@@ -74,9 +31,10 @@
<div class="relative">
<input
type="text"
name="username"
placeholder="username"
class="input input-bordered w-full pl-10"
bind:value={loginPayload.username}
required
/>
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 opacity-70"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" /></svg>
@@ -91,9 +49,10 @@
<div class="relative">
<input
type="password"
name="password"
placeholder="password"
class="input input-bordered w-full pl-10"
bind:value={loginPayload.password}
required
/>
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 opacity-70"><path fill-rule="evenodd" d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z" clip-rule="evenodd" /></svg>
@@ -104,7 +63,7 @@
<div class="form-control mt-6 flex justify-between">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" bind:checked={rememberMe} class="checkbox checkbox-sm checkbox-primary" />
<input type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span class="label-text">记住我</span>
</label>
<div class="label" >
@@ -113,13 +72,10 @@
</div>
<div class="form-control mt-2">
<button class="btn btn-primary w-full" disabled={isLoading}>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
登录中...
{:else}
<button class="btn btn-primary w-full" >
登录
{/if}
</button>
</div>