refactor(settings): 重构用户管理和设备管理页面
- 调整用户管理页面角色数据获取方法,使用 getRolesOptions 替代 getAllRoles - 更新用户表格组件接收的角色数据属性名及类型 - 修改设备管理页面路由路径,从 /device/list 调整为 /devices - 移除调试用 console.log 输出语句 - 添加选项类型 Options 接口定义 - 优化侧边栏导航结构与交互逻辑,支持父级菜单带链接可点击 - 引入日志模块用于 API 请求与响应记录 - 升级依赖包配置,移除 peer 标记 - 微调样式类名增强布局效果和用户体验
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { getContext } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
import type { NavItem, ProcessedNavItem } from '$lib/types/layout';
|
||||
import { TOAST_KEY, type ToastState } from '$lib/stores/toast.svelte.ts';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
// 1. 模拟数据:现在给父级 "device" 也加上了 href
|
||||
import {resolve as _resolve} from '$app/paths'
|
||||
import type { NavItem } from '$lib/types/layout.ts';
|
||||
import { getContext } from 'svelte';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
import { TOAST_KEY, type ToastState } from '$lib/stores/toast.svelte.ts';
|
||||
import type { RouteId } from '$app/types';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const rawNavItems: NavItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
@@ -55,7 +54,7 @@
|
||||
id: 'device',
|
||||
label: '设备管理',
|
||||
icon: 'laptop-settings',
|
||||
href: '/app/settings/devices', // 【修改点】父级现在有链接了,指向列表页
|
||||
href: '/app/settings/devices',
|
||||
subItems: [
|
||||
{
|
||||
id: 'device-type',
|
||||
@@ -64,228 +63,137 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: '高级设置',
|
||||
href: '/app/settings/advanced',
|
||||
subItems: [
|
||||
{
|
||||
id: 'logs',
|
||||
label: '安全日志',
|
||||
href: '/app/settings/advanced/logs'
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: '备份恢复',
|
||||
href: '/app/settings/advanced/backup'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* 递归计算高亮状态 (逻辑修复版)
|
||||
* 修复了当 href 为 undefined 时的潜在报错,并增强了激活判断
|
||||
*/
|
||||
function processNavItems(items: NavItem[], currentPath: string): ProcessedNavItem[] {
|
||||
return items.map((item) => {
|
||||
// 安全获取 href
|
||||
const href = item.href || '';
|
||||
|
||||
// 判断自身是否激活
|
||||
// 如果 href 存在,且 (是根路径全等 OR 当前路径以 href 开头)
|
||||
const isSelfActive = href
|
||||
? (href === '/' ? currentPath === '/' : currentPath.startsWith(href))
|
||||
: false;
|
||||
|
||||
let processedSubItems: ProcessedNavItem[] | undefined = undefined;
|
||||
let isChildActive = false;
|
||||
|
||||
if (item.subItems) {
|
||||
processedSubItems = processNavItems(item.subItems, currentPath);
|
||||
// 只要有一个子项激活,或者子项的子项激活,父级就算 ChildActive
|
||||
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));
|
||||
|
||||
// Toast & Logout 逻辑
|
||||
const toast = getContext<ToastState>(TOAST_KEY);
|
||||
// @ts-expect-error : ES + TS 混合报错手动忽略
|
||||
const resolve = (href: RouteId) => _resolve(href);
|
||||
|
||||
const handleLogout = () => {
|
||||
toast.info('正在退出登录...');
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'redirect') {
|
||||
toast.success('您已安全退出');
|
||||
}
|
||||
await update();
|
||||
};
|
||||
let expandedIds = $state<string[]>([]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
if (expandedIds.includes(id)) {
|
||||
expandedIds = expandedIds.filter(item => item !== id);
|
||||
} else {
|
||||
expandedIds = [...expandedIds, id];
|
||||
}
|
||||
};
|
||||
|
||||
let logoutForm: HTMLFormElement;
|
||||
|
||||
const handleClick = (item: NavItem) => {
|
||||
|
||||
|
||||
if (item.subItems && item.subItems.length > 0) {
|
||||
toggleExpand(item.id);
|
||||
} else {
|
||||
// 叶子节点逻辑
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const computeNavState = (items: NavItem[], currentPath: string, openIds: string[]): NavItem[] => {
|
||||
return items.map((item) => {
|
||||
const newItem = { ...item };
|
||||
|
||||
newItem.isActive = currentPath === newItem.href;
|
||||
|
||||
// 递归处理子项
|
||||
let hasActiveChild = false;
|
||||
if (newItem.subItems && newItem.subItems.length > 0) {
|
||||
newItem.subItems = computeNavState(newItem.subItems, currentPath, openIds);
|
||||
hasActiveChild = newItem.subItems.some(child => child.isActive || child.isOpen);
|
||||
}
|
||||
|
||||
const isManuallyOpen = openIds.includes(newItem.id);
|
||||
|
||||
// 如果你希望“进入父级页面自动展开子菜单”,请保留 `|| newItem.isActive`
|
||||
newItem.isOpen = isManuallyOpen || hasActiveChild || newItem.isActive;
|
||||
|
||||
return newItem;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
let menuItems = $derived(computeNavState(rawNavItems, page.url.pathname, expandedIds));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
定义递归 Snippet
|
||||
【修改点】:在 summary 内部增加了 conditional rendering,支持父级点击跳转
|
||||
-->
|
||||
{#snippet menuItem(item: ProcessedNavItem)}
|
||||
<li>
|
||||
{#if item.subItems && item.subItems.length > 0}
|
||||
<!--
|
||||
open 属性控制:
|
||||
如果是自身激活(点了父级链接) 或者 子项激活(点了子级),都保持展开状态
|
||||
-->
|
||||
<details open={item.isActive || item.isChildActive}>
|
||||
<summary class="group {item.isActive ? 'text-primary' : ''}">
|
||||
{#if item.icon}
|
||||
<Icon id={item.icon} size="20" />
|
||||
{/if}
|
||||
|
||||
<!-- 【核心修改】:父级如果有 href,渲染为链接 -->
|
||||
{#if item.href}
|
||||
<!--
|
||||
onclick stopPropagation 是关键:
|
||||
阻止冒泡,防止点击链接时触发 <details> 的 toggle 行为。
|
||||
这样点击文字是跳转,点击右侧箭头/空白是展开折叠。
|
||||
-->
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
class="flex-1 truncate hover:text-primary-focus transition-colors"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{:else}
|
||||
<!-- 没有 href,纯文本,点击整行触发展开折叠 -->
|
||||
<span class="truncate">{item.label}</span>
|
||||
{/if}
|
||||
</summary>
|
||||
|
||||
<ul>
|
||||
{#each item.subItems as subItem (subItem.id)}
|
||||
{@render menuItem(subItem)}
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<!-- 无子菜单的普通叶子节点 -->
|
||||
<a href={resolve(item.href || '#')} class="group {item.isActive ? 'active font-medium' : ''}">
|
||||
{#if item.icon}
|
||||
<Icon id={item.icon} size="20" />
|
||||
{:else}
|
||||
<span class="w-5 text-center text-xs opacity-50">•</span>
|
||||
{/if}
|
||||
<span class="truncate">{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<!-- Mobile Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-20 cursor-pointer bg-black/50 backdrop-blur-sm md:hidden"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
transition:fade={{ duration: 200 }}
|
||||
></div>
|
||||
|
||||
<!-- Sidebar Container -->
|
||||
<aside
|
||||
class="fixed z-30 flex h-full w-64 flex-shrink-0 flex-col border-r border-base-100/70 bg-base-200 md:relative"
|
||||
in:fly={{ duration: 200, x: -100 }}
|
||||
out:fly={{ duration: 200, x: -100 }}
|
||||
>
|
||||
<div class="flex h-18 flex-shrink-0 items-center p-4">
|
||||
<a class="flex items-center gap-3" href={resolve('/app/dashboard')}>
|
||||
<Icon className="flex-shrink-0 rounded-box" id="logo" size="32" />
|
||||
<p class="truncate font-serif text-lg font-bold">IT DTMS</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="custom-scrollbar flex-1 overflow-y-auto">
|
||||
<ul class="menu menu-vertical w-full gap-1 px-2">
|
||||
{#each navItems as item (item.id)}
|
||||
{@render menuItem(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- User Profile Section -->
|
||||
{#if page.data.user}
|
||||
<div class="flex-shrink-0 border-t border-base-content/10 bg-base-200/50 p-3">
|
||||
<div class="dropdown dropdown-end dropdown-top w-full">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors hover:bg-base-300"
|
||||
>
|
||||
<div class="placeholder avatar">
|
||||
<div class="w-10 rounded-full bg-neutral text-neutral-content">
|
||||
<img src={page.data.user.avatar} alt="avatar" />
|
||||
<span class="text-xs">User</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-sm font-bold">{page.data.user.nickname}</span>
|
||||
<span class="truncate text-xs text-base-content/60">@{page.data.user.username}</span>
|
||||
</div>
|
||||
|
||||
<Icon id="chevron-up-down" size="16" className="opacity-50" />
|
||||
</div>
|
||||
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu z-[1] mb-2 w-60 rounded-box border border-base-content/10 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li class="menu-title px-4 py-2">我的账户</li>
|
||||
<li>
|
||||
<a href="/app/user">
|
||||
<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 class="">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 text-left text-error"
|
||||
onclick={() => logoutForm.requestSubmit()}
|
||||
>
|
||||
<Icon id="sign-out" size="16" />
|
||||
退出登录
|
||||
</button>
|
||||
</li>
|
||||
<form
|
||||
action="/auth/logout"
|
||||
method="POST"
|
||||
use:enhance={handleLogout}
|
||||
bind:this={logoutForm}
|
||||
hidden
|
||||
></form>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="h-screen relative bg-base-200">
|
||||
<aside class="custom-scrollbar h-full w-full overflow-hidden flex flex-col ">
|
||||
<div class="h-14 shadow-2xl shadow-base-100 ">
|
||||
123
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
<div class="overflow-y-auto flex-1 ">
|
||||
<ul class="menu bg-base-200 w-64 p-4 text-base-content flex-nowrap ">
|
||||
{#each menuItems as item (item.id)}
|
||||
<li class="{item.isActive ? 'menu-active' : ''} w-full rounded-box ">
|
||||
{#if item.subItems && item.subItems.length > 0}
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(item.href)} class="p-2" onclick="{() => handleClick(item)}">
|
||||
{#if item.icon}
|
||||
<Icon id="{item.icon}" size="24"/>
|
||||
{/if}
|
||||
<span class="menu-dropdown-toggle {item.isOpen ? 'menu-dropdown-show ' : ''}">{item.label}</span>
|
||||
</a>
|
||||
<ul class="menu-dropdown rounded-box {item.isOpen ? 'menu-dropdown-show' : ''}">
|
||||
{#each item.subItems as subItem (subItem.id)}
|
||||
<li class="{subItem.isActive ? 'menu-active' : ''} rounded-box ">
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(subItem.href)} class="p-2 " onclick={() => handleClick(subItem)}>
|
||||
{#if subItem.icon}
|
||||
<Icon id="{subItem.icon}" size="24"/>
|
||||
{/if}
|
||||
<span class="menu-dropdown-toggle">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</a>
|
||||
{#if subItem.subItems && subItem.subItems.length > 0}
|
||||
<ul class="menu-dropdown {subItem.isOpen ? 'menu-dropdown-show' : ''}" >
|
||||
{#each subItem.subItems as childItem (childItem.id)}
|
||||
<li class="{childItem.isActive ? 'menu-active' : ''} rounded-box">
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(childItem.href)} class="p-2">
|
||||
{#if childItem.icon}
|
||||
<Icon id="{childItem.icon}" size="24"/>
|
||||
{:else}
|
||||
<div class="w-0.5/2 h-1">
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
{childItem.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{:else }
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(item.href)} class="p-2">
|
||||
{#if item.icon}
|
||||
<Icon id="{item.icon}" size="24"/>
|
||||
{/if}
|
||||
{item.label}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="h-24 w-full shadow-2xl ">
|
||||
12312
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
|
||||
147
src/lib/components/table/DevicesTable.svelte
Normal file
147
src/lib/components/table/DevicesTable.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
|
||||
|
||||
import type { PageResult } from '$lib/types/dataTable.ts';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
import type { DeviceResponse, } from '$lib/types/api.ts';
|
||||
|
||||
let { devices } = $props<{
|
||||
devices: PageResult<DeviceResponse[]>,
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const newRowTitles = [
|
||||
{ title: 'ID', width: 5}
|
||||
, { title: '用户名', width: 15 }
|
||||
, { title: '昵称', width: 20 }
|
||||
, { title: '头像', width: 10 }
|
||||
, { title: '用户组', width: 45 }
|
||||
];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div class="h-full">
|
||||
<div class="overflow-x-auto rounded-box shadow bg-base-100 mt-1 min-h-1/2">
|
||||
<div class="flex items-center justify-between px-4 pt-4 pb-2 ">
|
||||
<div class="flex gap-4">
|
||||
<label class="input">
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input type="search" required placeholder="Search" />
|
||||
<button class="btn btn-xs btn-primary">搜索</button>
|
||||
</label>
|
||||
<!--{#if rolesOptions}-->
|
||||
<!-- <div class="filter w-64">-->
|
||||
<!-- <input class="btn filter-reset " type="radio" value='' name="metaframeworks" aria-label="All" onchange={handleRoleChange} />-->
|
||||
<!-- {#each rolesOptions as role(role.value)}-->
|
||||
<!-- <input class="btn" type="radio" name="metaframeworks" aria-label="{role.label}" value={role.value} onchange={handleRoleChange} />-->
|
||||
<!-- {/each}-->
|
||||
<!-- </div>-->
|
||||
<!--{/if}-->
|
||||
</div>
|
||||
<div class=" flex items-center justify-center gap-4">
|
||||
<button class="btn btn-primary">添加设备</button>
|
||||
|
||||
<div class="dropdown dropdown-bottom dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn" ><Icon id="menu" size="24" /></div>
|
||||
<ul tabindex="-1" class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 p-2 mt-2 shadow-sm" >
|
||||
<li><div>删除</div></li>
|
||||
<li><div>封禁</div></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{#if devices.total > 0}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%">
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
{#each newRowTitles as item,index(index)}
|
||||
<th style="width: {item.width}%" >{item.title}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
||||
|
||||
<!--{#if users.records}-->
|
||||
<!-- <tbody>-->
|
||||
<!-- {#each users.records as record(record.id)}-->
|
||||
<!-- <tr>-->
|
||||
<!-- <th>-->
|
||||
<!-- <label>-->
|
||||
<!-- <input type="checkbox" class="checkbox" />-->
|
||||
<!-- </label>-->
|
||||
<!-- </th>-->
|
||||
<!-- <td>{record.id}</td>-->
|
||||
<!-- <td>{record.username}</td>-->
|
||||
<!-- <td>{record.nickname}</td>-->
|
||||
<!-- <td>-->
|
||||
<!-- <div class="w-8 h-8 rounded-box bg-primary-content/10 border-0">-->
|
||||
<!-- {#if record.avatar}-->
|
||||
<!-- <img class="w-8 h-8 rounded-box bg-primary-content/10 border-0" src="{record.avatar}" alt=" ">-->
|
||||
<!-- {/if}-->
|
||||
<!-- </div>-->
|
||||
<!-- </td>-->
|
||||
<!-- <td class="">-->
|
||||
<!-- {#each record.roles as role (role.id)}-->
|
||||
<!-- <span class="badge select-none mr-2 last:mr-0 {role.id === 1 ? 'badge-primary' : 'badge-secondary'}">{role.name}</span>-->
|
||||
<!-- {/each}-->
|
||||
<!-- </td>-->
|
||||
<!-- </tr>-->
|
||||
<!-- {/each}-->
|
||||
|
||||
<!-- </tbody>-->
|
||||
<!-- <tfoot>-->
|
||||
<!-- <tr>-->
|
||||
<!-- <th colspan={newRowTitles.length + 1} class="text-center py-4 ">-->
|
||||
<!-- <div class=" flex items-center justify-between">-->
|
||||
<!-- <div>-->
|
||||
<!-- page {users.current} of {users.pages}-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="join">-->
|
||||
<!-- <button class="join-item btn">1</button>-->
|
||||
<!-- <button class="join-item btn">2</button>-->
|
||||
<!-- <button class="join-item btn btn-disabled">...</button>-->
|
||||
<!-- <button class="join-item btn">99</button>-->
|
||||
<!-- <button class="join-item btn">100</button>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- <button class="btn btn-primary">下一页</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </th>-->
|
||||
<!-- </tr>-->
|
||||
<!-- </tfoot>-->
|
||||
|
||||
<!--{/if}-->
|
||||
|
||||
</table>
|
||||
{:else }
|
||||
<p>No users found</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,11 @@ export interface NavItem {
|
||||
id: string;
|
||||
icon?: IconId;
|
||||
label: string;
|
||||
href?: RouteId;
|
||||
href: RouteId ;
|
||||
isActive?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
isOpen?: boolean;
|
||||
subItems?: NavItem[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user