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:
@@ -61,66 +61,68 @@ export class HttpError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const httpRequest= async <T>(
|
const httpRequest = async <T>(
|
||||||
url:string,
|
url: string,
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
options: RequestOptions = {}
|
options: RequestOptions = {}
|
||||||
):Promise<ApiResult<T>> =>{
|
): Promise<ApiResult<T>> => {
|
||||||
const fullUrl = `${API_BASE_URL}${url}`;
|
const fullUrl = `${API_BASE_URL}${url}`;
|
||||||
const { body, headers, ...rest} = options;
|
const { body, headers, ...rest } = options;
|
||||||
|
|
||||||
const requestHeaders: Record<string, string> = normalizeHeaders(headers);
|
const requestHeaders: Record<string, string> = normalizeHeaders(headers);
|
||||||
let requestBody:BodyInit | undefined;
|
let requestBody: BodyInit | undefined;
|
||||||
|
|
||||||
if (body instanceof FormData){
|
const canHaveBody = method !== 'GET' ;
|
||||||
|
|
||||||
|
// 【修改点 2】:只有在允许携带 Body 时才处理
|
||||||
|
if (canHaveBody) {
|
||||||
|
if (body instanceof FormData) {
|
||||||
requestBody = body;
|
requestBody = body;
|
||||||
}else if (body){
|
} else if (body) {
|
||||||
requestHeaders['content-type'] = 'application/json';
|
requestHeaders['content-type'] = 'application/json';
|
||||||
requestBody = JSON.stringify(body);
|
requestBody = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 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, {
|
||||||
method,
|
method,
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
body: requestBody,
|
// 【修改点 3】:确保 GET 请求的 body 显式为 undefined
|
||||||
|
// 虽然通常 undefined 是被允许的,但加上 canHaveBody 判断更加严谨
|
||||||
|
body: canHaveBody ? requestBody : undefined,
|
||||||
...rest
|
...rest
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
||||||
let errorDetail;
|
let errorDetail;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
errorDetail = await response.json();
|
||||||
errorDetail = await response.json()
|
} catch (e) {
|
||||||
|
|
||||||
}catch (e){
|
|
||||||
console.error('Error parsing JSON:', e);
|
console.error('Error parsing JSON:', e);
|
||||||
errorDetail = await response.text()
|
errorDetail = await response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = `HTTP Error ${response.status} (${response.statusText})`;
|
const message = `HTTP Error ${response.status} (${response.statusText})`;
|
||||||
throw new HttpError(message, response.status, errorDetail);
|
throw new HttpError(message, response.status, errorDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('Content-Type');
|
const contentType = response.headers.get('Content-Type');
|
||||||
if (contentType && contentType.includes('application/json')){
|
if (contentType && contentType.includes('application/json')) {
|
||||||
return (await response.json() ) as ApiResult<T>;
|
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);
|
console.error(`API Request Failed to ${fullUrl}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { AuthResponse, LoginPayload } from '$lib/types/auth';
|
|||||||
import { authStore } from '$lib/stores/authStore';
|
import { authStore } from '$lib/stores/authStore';
|
||||||
import { userService } from '$lib/api/services/userService';
|
import { userService } from '$lib/api/services/userService';
|
||||||
import { toast } from '$lib/stores/toastStore';
|
import { toast } from '$lib/stores/toastStore';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
/**
|
/**
|
||||||
@@ -19,15 +18,11 @@ export const authService = {
|
|||||||
|
|
||||||
const { token, tokenHead } = response.data;
|
const { token, tokenHead } = response.data;
|
||||||
|
|
||||||
// 2. 临时设置 Token 到 Store
|
|
||||||
// 这一步是必须的,因为接下来的 userService.getUserProfile()
|
|
||||||
// 里的 API 请求拦截器需要读取 Store 中的 Token 才能通过鉴权。
|
|
||||||
// 我们先以“部分登录”的状态更新 Store。
|
|
||||||
authStore.update(s => ({ ...s, token, tokenHead, isAuthenticated: true }));
|
authStore.update(s => ({ ...s, token, tokenHead, isAuthenticated: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 3. 获取用户信息
|
// 3. 获取用户信息
|
||||||
const userProfile = await userService.getUserProfile();
|
const userProfile = await userService.getUserProfile({tokenHead,token});
|
||||||
|
|
||||||
// 4. 最终确认登录状态(更新完整信息并持久化)
|
// 4. 最终确认登录状态(更新完整信息并持久化)
|
||||||
// 这里调用 Store 封装好的 login 方法,它会负责写入 localStorage
|
// 这里调用 Store 封装好的 login 方法,它会负责写入 localStorage
|
||||||
@@ -40,9 +35,7 @@ export const authService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 5. 安全回滚
|
|
||||||
// 如果获取用户信息失败(比如 Token 虽然返回了但无效,或者网络波动),
|
|
||||||
// 我们应该立即清除刚才设置的临时 Token,防止应用处于中间状态。
|
|
||||||
console.error('获取用户信息失败,回滚登录状态', error);
|
console.error('获取用户信息失败,回滚登录状态', error);
|
||||||
authStore.logout();
|
authStore.logout();
|
||||||
throw error; // 继续抛出错误给 UI 层处理
|
throw error; // 继续抛出错误给 UI 层处理
|
||||||
@@ -53,10 +46,7 @@ export const authService = {
|
|||||||
* 登出流程
|
* 登出流程
|
||||||
*/
|
*/
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
// 逻辑大大简化:只负责调用 Store 和 UI 反馈
|
|
||||||
authStore.logout();
|
authStore.logout();
|
||||||
toast.success('退出登录成功');
|
toast.success('退出登录成功');
|
||||||
|
|
||||||
// 如果需要调用后端登出接口(使 Token 失效),在这里 await api.post('/auth/logout')
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import { api } from '$lib/api/httpClient.ts';
|
import { api } from '$lib/api/httpClient.ts';
|
||||||
import type { UserProfile } from '$lib/types/user.ts';
|
import type { UserProfile } from '$lib/types/user.ts';
|
||||||
|
import type { PageResult } from '$lib/types/dataTable.ts';
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
getUserProfile: async () => {
|
getUserProfile: async ({ tokenHead, token}) => {
|
||||||
const response = await api.get<UserProfile>('/user/profile');
|
const response = await api.get<UserProfile>('/user/profile');
|
||||||
if (response.code != 200 || !response.data){
|
if (response.code != 200 || !response.data){
|
||||||
throw new Error(response.msg);
|
throw new Error(response.msg);
|
||||||
}
|
}
|
||||||
return response.data;
|
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;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
148
src/lib/components/DataTable.svelte
Normal file
148
src/lib/components/DataTable.svelte
Normal 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>
|
||||||
@@ -44,12 +44,12 @@
|
|||||||
</g>
|
</g>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|
||||||
<symbol id="data-pie" viewBox="0 0 16 16">
|
<symbol id="data" 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" />
|
<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>
|
||||||
|
|
||||||
<symbol id="home" viewBox="0 0 20 20">
|
<symbol id="home" viewBox="0 0 16 16">
|
||||||
<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" />
|
<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>
|
||||||
|
|
||||||
<symbol id="menu" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<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">
|
<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" />
|
<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>
|
||||||
@@ -123,4 +123,6 @@
|
|||||||
<symbol id="sign-out" viewBox="0 0 20 20">
|
<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" />
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</svg>
|
||||||
@@ -5,76 +5,104 @@
|
|||||||
|
|
||||||
import Icon from '$lib/components/icon/Icon.svelte';
|
import Icon from '$lib/components/icon/Icon.svelte';
|
||||||
import { sidebarStore, setSidebarOpen } from '$lib/stores/sidebarStore';
|
import { sidebarStore, setSidebarOpen } from '$lib/stores/sidebarStore';
|
||||||
import { authStore } from '$lib/stores/authStore.ts'; // 假设你有这个store
|
import { authStore } from '$lib/stores/authStore';
|
||||||
import { } from '$lib/stores/authStore.ts'; // 假设你有这个store
|
import type { NavItem, ProcessedNavItem } from '$lib/types/layout';
|
||||||
import type { NavItem } from '$lib/types/layout.ts';
|
import { authService } from '$lib/api/services/authService';
|
||||||
import { authService } from '$lib/api/services/authService.ts';
|
|
||||||
|
|
||||||
// 模拟一些数据,增加了分组的概念(如果需要)
|
|
||||||
|
// 1. 模拟数据:包含三层结构
|
||||||
const rawNavItems: NavItem[] = [
|
const rawNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
id: 'dashboard',
|
id: 'dashboard',
|
||||||
label: '仪表盘',
|
label: '仪表盘',
|
||||||
icon: 'home',
|
icon: 'home',
|
||||||
href: '/app/dashboard',
|
href: '/app/dashboard'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'statistics',
|
id: 'statistics',
|
||||||
label: '数据看板',
|
label: '数据看板',
|
||||||
icon: 'data-pie',
|
icon: 'data',
|
||||||
href: '/app/statistics',
|
href: '/app/statistics'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: '系统设置',
|
label: '系统设置',
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
href: '/app/settings',
|
href: '/app/settings',
|
||||||
|
subItems: [
|
||||||
|
{
|
||||||
|
id: 'auth',
|
||||||
|
label: '认证管理',
|
||||||
|
href: '/app/settings/auth',
|
||||||
|
icon: 'auth',
|
||||||
subItems: [
|
subItems: [
|
||||||
{
|
{
|
||||||
id: 'users',
|
id: 'users',
|
||||||
label: '用户管理',
|
label: '用户管理',
|
||||||
href: '/app/settings/users',
|
href: '/app/settings/auth/users'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'roles',
|
id: 'roles',
|
||||||
label: '角色权限',
|
label: '角色权限',
|
||||||
href: '/app/settings/roles',
|
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): any[] {
|
function processNavItems(items: NavItem[], currentPath: string): ProcessedNavItem[] {
|
||||||
return items.map(item => {
|
return items.map((item) => {
|
||||||
// 判断当前项是否匹配
|
const isSelfActive =
|
||||||
const isSelfActive = item.href === '/'
|
item.href === '/' ? currentPath === '/' : currentPath.startsWith(item.href);
|
||||||
? currentPath === '/'
|
|
||||||
: currentPath.startsWith(item.href);
|
|
||||||
|
|
||||||
// 处理子项
|
let processedSubItems: ProcessedNavItem[] | undefined = undefined;
|
||||||
let processedSubItems = undefined;
|
|
||||||
let isChildActive = false;
|
let isChildActive = false;
|
||||||
|
|
||||||
if (item.subItems) {
|
if (item.subItems) {
|
||||||
|
// 递归调用
|
||||||
processedSubItems = processNavItems(item.subItems, currentPath);
|
processedSubItems = processNavItems(item.subItems, currentPath);
|
||||||
// 检查是否有子项被激活
|
// 检查子项激活状态
|
||||||
isChildActive = processedSubItems.some(sub => sub.isActive || sub.isChildActive);
|
isChildActive = processedSubItems.some((sub) => sub.isActive || sub.isChildActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
subItems: processedSubItems,
|
subItems: processedSubItems, // 这里类型现在是 ProcessedNavItem[]
|
||||||
isActive: isSelfActive, // 自身路径匹配
|
isActive: isSelfActive,
|
||||||
isChildActive: isChildActive // 子路径匹配(用于展开菜单)
|
isChildActive: isChildActive
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 $derived 动态计算
|
// 使用 $derived 动态计算,类型自动推断为 ProcessedNavItem[]
|
||||||
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
|
let navItems = $derived(processNavItems(rawNavItems, page.url.pathname));
|
||||||
|
|
||||||
function handleMobileClose() {
|
function handleMobileClose() {
|
||||||
@@ -85,85 +113,87 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if $sidebarStore.isOpen}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
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}
|
onclick={handleMobileClose}
|
||||||
onkeydown={(e) => e.key === 'Escape' && handleMobileClose()}
|
onkeydown={(e) => e.key === 'Escape' && handleMobileClose()}
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade={{ duration: 200 }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
in:fly={{duration: 200, x: -100}}
|
in:fly={{ duration: 200, x: -100 }}
|
||||||
out: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"
|
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"
|
||||||
|
onclick={handleMobileClose}
|
||||||
>
|
>
|
||||||
|
|
||||||
<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" />
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
<div class="custom-scrollbar flex-1 overflow-y-auto">
|
||||||
<ul class="menu px-2 menu-vertical w-full gap-1">
|
<ul class="menu menu-vertical w-full gap-1 px-2">
|
||||||
{#each navItems as item (item.id)}
|
{#each navItems as item (item.id)}
|
||||||
|
<!-- 初始渲染调用 -->
|
||||||
{#if item.subItems && item.subItems.length > 0}
|
{@render menuItem(item)}
|
||||||
<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}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $authStore.isAuthenticated && $authStore.user}
|
{#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 class="dropdown dropdown-top dropdown-end w-full">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
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="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}
|
{#if $authStore.user?.avatar}
|
||||||
<img src={$authStore.user.avatar} alt="avatar" />
|
<img src={$authStore.user.avatar} alt="avatar" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -172,34 +202,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col flex-1 min-w-0">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<span class="text-sm font-bold truncate">{$authStore.user.nickname}</span>
|
<span class="truncate text-sm font-bold">{$authStore.user.nickname}</span>
|
||||||
<span class="text-xs text-base-content/60 truncate">@{$authStore.user?.username}</span>
|
<span class="text-base-content/60 truncate text-xs"
|
||||||
|
>@{$authStore.user?.username}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Icon id="chevrons-up-down" size="16" className="opacity-50" />
|
<Icon id="chevrons-up-down" size="16" className="opacity-50" />
|
||||||
</div>
|
</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 class="menu-title px-4 py-2">我的账户</li>
|
||||||
<li><a href="/app/profile"><Icon id="user-profile" size="16"/> 个人资料</a></li>
|
<li>
|
||||||
<li><a href="/app/settings"><Icon id="settings" size="16"/> 设置</a></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>
|
<div class="divider my-1"></div>
|
||||||
<li>
|
<li>
|
||||||
<button class="text-error" onclick={authService.logout}>
|
<button class="text-error" onclick={authService.logout}>
|
||||||
<Icon id="sign-out" size="16"/> 退出登录
|
<Icon id="sign-out" size="16" /> 退出登录
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* 可选:美化滚动条 */
|
/* 保持原有样式 */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ function createAuthStore() {
|
|||||||
const newState = { ...data, isAuthenticated: true };
|
const newState = { ...data, isAuthenticated: true };
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||||
|
document.cookie = `Authorization=${data.tokenHead} ${data.token}`;
|
||||||
}
|
}
|
||||||
set(newState);
|
set(newState);
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
document.cookie = 'Authorization=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
}
|
}
|
||||||
set(emptyAuth);
|
set(emptyAuth);
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/lib/types/dataTable.ts
Normal file
21
src/lib/types/dataTable.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ export type IconId =
|
|||||||
"panel-right-close-solid"|
|
"panel-right-close-solid"|
|
||||||
"panel-left-close"|
|
"panel-left-close"|
|
||||||
"panel-left-close-solid"|
|
"panel-left-close-solid"|
|
||||||
"data-pie"|
|
"data"|
|
||||||
"starburst"|
|
"starburst"|
|
||||||
"home"|
|
"home"|
|
||||||
"menu"|
|
"menu"|
|
||||||
@@ -14,5 +14,6 @@ export type IconId =
|
|||||||
"info"|
|
"info"|
|
||||||
"settings"|
|
"settings"|
|
||||||
"user-settings" |
|
"user-settings" |
|
||||||
"user-profile"
|
"user-profile"|
|
||||||
|
"auth"
|
||||||
;
|
;
|
||||||
@@ -11,3 +11,10 @@ export interface NavItem {
|
|||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
subItems?: NavItem[];
|
subItems?: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProcessedNavItem extends Omit<NavItem, 'subItems'> {
|
||||||
|
isActive: boolean;
|
||||||
|
isChildActive: boolean;
|
||||||
|
// 递归定义:子项也是 ProcessedNavItem
|
||||||
|
subItems?: ProcessedNavItem[];
|
||||||
|
}
|
||||||
0
src/routes/app/settings/auth/roles/+page.svelte
Normal file
0
src/routes/app/settings/auth/roles/+page.svelte
Normal file
12
src/routes/app/settings/auth/users/+page.server.ts
Normal file
12
src/routes/app/settings/auth/users/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
41
src/routes/app/settings/auth/users/+page.svelte
Normal file
41
src/routes/app/settings/auth/users/+page.svelte
Normal 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>
|
||||||
38
src/routes/auth/login/+page.server.ts
Normal file
38
src/routes/auth/login/+page.server.ts
Normal 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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authService } from '$lib/api/services/authService.ts';
|
import { enhance } from '$app/forms';
|
||||||
import type { LoginPayload } from '$lib/types/auth.ts';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { resolve } from '$app/paths';
|
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 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>
|
</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">
|
||||||
@@ -64,8 +17,12 @@
|
|||||||
<span>IT DTMS登录</span>
|
<span>IT DTMS登录</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{#if form?.message}
|
||||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
<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">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -74,9 +31,10 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
name="username"
|
||||||
placeholder="username"
|
placeholder="username"
|
||||||
class="input input-bordered w-full pl-10"
|
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">
|
<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>
|
<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">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
name="password"
|
||||||
placeholder="password"
|
placeholder="password"
|
||||||
class="input input-bordered w-full pl-10"
|
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">
|
<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>
|
<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">
|
<div class="form-control mt-6 flex justify-between">
|
||||||
<label class="label cursor-pointer justify-start gap-2">
|
<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>
|
<span class="label-text">记住我</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="label" >
|
<div class="label" >
|
||||||
@@ -113,13 +72,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control mt-2">
|
<div class="form-control mt-2">
|
||||||
<button class="btn btn-primary w-full" disabled={isLoading}>
|
<button class="btn btn-primary w-full" >
|
||||||
{#if isLoading}
|
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
|
||||||
登录中...
|
|
||||||
{:else}
|
|
||||||
登录
|
登录
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user