feat(users): 实现用户管理页面功能增强

- 添加用户列表分页、搜索和角色筛选功能
- 实现用户批量选择与操作(删除/封禁)
- 引入ofetch库优化API请求处理
- 添加表格加载状态和错误处理组件
- 更新图标组件属性以支持新特性
- 修复页面跳转状态码问题(302改为303)
- 优化用户表格UI展示细节与交互体验
This commit is contained in:
Chaos
2025-12-01 17:27:02 +08:00
parent bd00e54acd
commit ab43a9a140
16 changed files with 442 additions and 271 deletions

45
package-lock.json generated
View File

@@ -8,7 +8,8 @@
"name": "chaos-it",
"version": "0.0.1",
"dependencies": {
"daisyui": "^5.5.5"
"daisyui": "^5.5.5",
"ofetch": "^1.5.1"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -1473,6 +1474,7 @@
"integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1512,6 +1514,7 @@
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1",
@@ -1844,6 +1847,7 @@
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1894,6 +1898,7 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
@@ -2112,6 +2117,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2396,6 +2402,12 @@
"node": ">=0.10.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2488,6 +2500,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3512,6 +3525,23 @@
"license": "MIT",
"optional": true
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT"
},
"node_modules/ofetch": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/ofetch/-/ofetch-1.5.1.tgz",
"integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
"license": "MIT",
"dependencies": {
"destr": "^2.0.5",
"node-fetch-native": "^1.6.7",
"ufo": "^1.6.1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
@@ -3608,6 +3638,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3635,6 +3666,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3768,6 +3800,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3784,6 +3817,7 @@
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
@@ -4510,6 +4544,7 @@
"integrity": "sha512-d1R+3pFa39LXoHCsxHmV//D2pSFZlEMlnxCVQ54TlrQv+4o5pewJO0/Pc5MUp+j71PJrOrPJHTvREZJHn+ymDQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -4707,6 +4742,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4739,6 +4775,12 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/ufo": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
@@ -4776,6 +4818,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@@ -37,6 +37,7 @@
"vite": "^7.1.10"
},
"dependencies": {
"daisyui": "^5.5.5"
"daisyui": "^5.5.5",
"ofetch": "^1.5.1"
}
}

View File

@@ -1,11 +1,9 @@
import { ofetch, type FetchOptions, type SearchParameters } from 'ofetch';
import { log } from '$lib/log';
import { browser } from '$app/environment';
import { log } from '$lib/log.ts';
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
interface JsonObject { [key: string]: JsonValue }
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
// 1. 定义更安全的类型,替代 any
type QueryParams = SearchParameters; // 使用 ofetch 内置的查询参数类型,或者自定义 Record<string, string | number | boolean>
type RequestBody = Record<string, unknown> | FormData | unknown[]; // 替代 any使用 unknown
export interface ApiResult<T> {
code: number;
@@ -13,130 +11,41 @@ export interface ApiResult<T> {
data: T;
}
interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
body?: JsonObject | FormData | object;
customFetch?: typeof fetch;
}
const BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
const normalizeHeaders = (headers?: HeadersInit): Record<string, string> => {
const result: Record<string, string> = {};
if (!headers) return result;
if (headers instanceof Headers) {
headers.forEach((value, key) => {
result[key.toLowerCase()] = value;
// 2. 指定 create 的默认类型为 json
const client = ofetch.create({
baseURL: BASE_URL,
onRequest({ options, request }) {
log.debug(`[API] ${options.method} ${request}`, {
body: options.body as unknown, // 类型断言为 unknown 避免隐式 any
query: options.query
});
} else if (Array.isArray(headers)) {
headers.forEach(([key, value]) => {
result[key.toLowerCase()] = value;
});
} else {
Object.keys(headers).forEach(key => {
const value = (headers as Record<string, string>)[key];
if (value !== undefined && value !== null) {
result[key.toLowerCase()] = value;
}
},
onResponseError({ request, response }) {
log.error(`[API] Error ${request}`, {
status: response.status,
data: response._data as unknown
});
}
return result;
};
});
export class HttpError extends Error {
public status: number;
public details: JsonValue | string;
constructor(message: string, status: number, details: JsonValue | string) {
super(message);
this.name = 'HttpError';
this.status = status;
this.details = details;
}
}
const httpRequest = async <T>(
url: string,
method: HttpMethod,
options: RequestOptions = {}
): Promise<ApiResult<T>> => {
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`;
const { body, headers, customFetch, ...rest } = options;
const fetchImpl = customFetch || fetch;
const requestHeaders: Record<string, string> = normalizeHeaders(headers);
let requestBody: BodyInit | undefined;
const canHaveBody = method !== 'GET' && method !== 'HEAD';
if (canHaveBody) {
if (body instanceof FormData) {
requestBody = body;
delete requestHeaders['content-type'];
} else if (body) {
requestHeaders['content-type'] = 'application/json';
requestBody = JSON.stringify(body);
}
}
try {
if (browser) {
console.debug(`[API] ${method} ${fullUrl}`, { body: requestBody, headers: requestHeaders });
}
const response = await fetchImpl(fullUrl, {
method,
headers: requestHeaders,
body: canHaveBody ? requestBody : undefined,
...rest
});
if (!response.ok) {
let errorDetail;
try {
errorDetail = await response.json();
} catch (e) {
log.error('Error parsing JSON response:', e)
errorDetail = await response.text();
}
const message = `HTTP Error ${response.status} (${response.statusText})`;
console.warn(message, errorDetail);
throw new HttpError(message, response.status, errorDetail);
}
// 处理空响应
if (response.status === 204) {
return { code: 200, msg: 'OK', data: null as unknown as T };
}
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) {
const res = await response.json();
return res as ApiResult<T>;
}
return { code: 200, msg: 'OK', data: null as unknown as T };
} catch (err) {
console.error(`API Request Failed to ${fullUrl}:`, err);
throw err;
}
};
// 3. 辅助类型:剔除我们手动处理的属性,并强制 responseType 为 'json'
type AppFetchOptions = Omit<FetchOptions<'json'>, 'method' | 'body' | 'query'>;
export const api = {
get: <T>(url: string, options?: RequestOptions) =>
httpRequest<T>(url, 'GET', options),
get: <T>(url: string, query?: QueryParams, options?: AppFetchOptions) =>
client<ApiResult<T>>(url, { ...options, method: 'GET', query }),
post: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
httpRequest<T>(url, 'POST', { ...options, body }),
post: <T>(url: string, body?: RequestBody, options?: AppFetchOptions) =>
client<ApiResult<T>>(url, { ...options, method: 'POST', body }),
put: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
httpRequest<T>(url, 'PUT', { ...options, body }),
put: <T>(url: string, body?: RequestBody, options?: AppFetchOptions) =>
client<ApiResult<T>>(url, { ...options, method: 'PUT', body }),
delete: <T>(url: string, options?: RequestOptions) =>
httpRequest<T>(url, 'DELETE', options),
patch: <T>(url: string, body?: RequestBody, options?: AppFetchOptions) =>
client<ApiResult<T>>(url, { ...options, method: 'PATCH', body }),
patch: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
httpRequest<T>(url, 'PATCH', { ...options, body }),
delete: <T>(url: string, query?: QueryParams, options?: AppFetchOptions) =>
client<ApiResult<T>>(url, { ...options, method: 'DELETE', query })
};

View File

@@ -1,11 +1,13 @@
import { api } from '$lib/api/httpClient.ts';
import type { Options } from '$lib/types/api.ts';
import { log } from '$lib/log.ts';
export const roleService = {
getRolesOptions: async (token:string) => {
const response = await api.get<Options[]>('/roles/options', {headers: {Authorization: `${token}`}});
if (response.code != 200 || !response.data){
log.error(response.msg);
throw new Error(response.msg);
}
return response.data;

View File

@@ -10,10 +10,16 @@ export const userService = {
}
return response.data;
},
getAllUsers: async ({ page, size,token}: { page: number, size: number, token:string}) => {
getAllUsers: async ({ page, size,token , keyword, roleId}: { page: number, size: number, token:string , keyword?: string, roleId?: number}) => {
const formData = new FormData();
formData.append('pageNum', page.toString());
formData.append('pageSize', size.toString());
if ( keyword){
formData.append('keyword', keyword);
}
if ( roleId){
formData.append('roleId', roleId.toString());
}
const response = await api.get<PageResult<UserProfile[]>>(
'/users',
{

View File

@@ -22,7 +22,7 @@
transition:fly={{ x: 100, duration: 300 }}
class="alert bg-base-100 text-base-content border-0 shadow-base-300/50 shadow-lg min-w-[200px] flex justify-start"
>
<span><Icon id={toastIconMap[t.type]} size="24"></Icon></span>
<span><Icon Cid={toastIconMap[t.type]} size="24"></Icon></span>
<span>{t.message}</span>
</div>
{/each}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
const { error } = $props();
let message = $state("");
if (error){
message = error.message;
}
</script>
<div class="absolute inset-0 flex flex-col items-center justify-center text-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p class="font-bold">加载失败</p>
<p class="text-sm opacity-80">{message}</p>
<button class="btn btn-sm btn-outline btn-error mt-4" onclick={() => location.reload()}>重试</button>
</div>

View File

@@ -13,7 +13,7 @@
<!-- aria-label="Toggle Sidebar"-->
<!-- onclick={sidebarState.toggleSidebar}-->
<!-- >-->
<!-- <Icon id="menu" size="24" />-->
<!-- <Icon Cid="menu" size="24" />-->
<!-- </button>-->
</div>

View File

@@ -136,7 +136,7 @@
<!-- 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"/>
<Icon id={item.icon} size="24"/>
{/if}
<span class="menu-dropdown-toggle {item.isOpen ? 'menu-dropdown-show ' : ''}">{item.label}</span>
</a>
@@ -146,7 +146,7 @@
<!-- 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"/>
<Icon id={subItem.icon} size="24"/>
{/if}
<span class="menu-dropdown-toggle">
{subItem.label}
@@ -159,7 +159,7 @@
<!-- 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"/>
<Icon id={childItem.icon} size="24"/>
{:else}
<div class="w-0.5/2 h-1">
@@ -179,7 +179,7 @@
<!-- 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"/>
<Icon id={item.icon} size="24"/>
{/if}
{item.label}
</a>

View File

@@ -0,0 +1,4 @@
<div class="absolute inset-0 flex flex-col items-center justify-center bg-base-100/50 backdrop-blur-sm z-10">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-base-content/70 mt-4 font-medium animate-pulse">正在加载数据...</p>
</div>

View File

@@ -55,7 +55,7 @@
<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>
<div tabindex="0" role="button" class="btn" ><Icon Cid="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>

View File

@@ -80,7 +80,7 @@
<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>
<div tabindex="0" role="button" class="btn" ><Icon Cid="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>

View File

@@ -1,157 +1,329 @@
<script lang="ts">
import type { PageResult } from '$lib/types/dataTable.ts';
import type { UserProfile } from '$lib/types/user.ts';
import type { PageResult } from '$lib/types/dataTable';
import type { UserProfile } from '$lib/types/user';
import type { Options } from '$lib/types/api';
import Icon from '$lib/components/icon/Icon.svelte';
import type { Options } from '$lib/types/api.ts';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
let { users , rolesOptions } = $props<{
users: PageResult<UserProfile[]>,
rolesOptions: Options[]
let { users, rolesOptions } = $props<{
users: PageResult<UserProfile[]>;
rolesOptions: Options[];
}>();
// --- 1. 状态管理 (Svelte 5 Runes) ---
let selectedIds = $state<number[]>([]);
let searchQuery = $state(page.url.searchParams.get('q') || '');
let currentRole = $derived(page.url.searchParams.get('role') || '');
// --- 2. 选择逻辑 ---
// 计算属性:是否全选
let isAllSelected = $derived(
users.records.length > 0 && selectedIds.length === users.records.length
);
// 计算属性:是否部分选中 (用于控制 checkbox 的 indeterminate 状态)
let isIndeterminate = $derived(
selectedIds.length > 0 && selectedIds.length < users.records.length
);
let x ;
const handleRoleChange = (e) => {
console.log(e.target.value);
x = e.target.value;
function toggleAll() {
if (isAllSelected) {
selectedIds = [];
} else {
selectedIds = users.records.map((u) => u.id);
}
}
function toggleOne(id: number) {
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter((itemId) => itemId !== id);
} else {
selectedIds = [...selectedIds, id];
}
}
// Action: 处理 checkbox 的 indeterminate 视觉状态
function indeterminate(node: HTMLInputElement, isIndeterminate: boolean) {
$effect(() => {
node.indeterminate = isIndeterminate;
});
}
// --- 3. URL 参数更新逻辑 (搜索/筛选/分页) ---
function updateParams(key: string, value: string | number | null) {
const url = new URL(page.url);
if (value === null || value === '') {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, String(value));
}
// 重置页码回第一页 (除非我们在通过分页器操作)
if (key !== 'page') {
url.searchParams.set('page', '1');
}
goto(resolve(url), { keepFocus: true, noScroll: true });
}
function handleSearch() {
updateParams('q', searchQuery);
}
function handleRoleChange(e: Event) {
const target = e.target as HTMLInputElement;
updateParams('role', target.value);
}
function handlePageChange(newPage: number) {
if (newPage < 1 || newPage > users.pages) return;
updateParams('page', newPage);
}
function handleBatchAction(action: 'delete' | 'ban') {
if (selectedIds.length === 0) return alert('请先选择用户');
console.log(`执行批量操作: ${action}`, selectedIds);
// 这里调用 API...
}
function getPaginationRange(current: number, total: number) {
const delta = 2; // 当前页码前后显示的页数
const range = [];
const rangeWithDots: (number | string)[] = [];
let l: number | undefined;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) {
range.push(i);
}
}
for (let i of range) {
if (l) {
if (i - l === 2) {
rangeWithDots.push(l + 1);
} else if (i - l !== 1) {
rangeWithDots.push('...');
}
}
rangeWithDots.push(i);
l = i;
}
return rangeWithDots;
}
const newRowTitles = [
{ title: 'ID', width: 5}
, { title: '用户名', width: 15 }
, { title: '昵称', width: 20 }
, { title: '头像', width: 10 }
, { title: '用户组', width: 45 }
{ title: 'ID', width: 5 },
{ title: '用户名', width: 15 },
{ title: '昵称', width: 20 },
{ title: '头像', width: 10 },
{ title: '用户组', width: 45 }
];
console.log("users", users);
</script>
<div>
{#if users.total > 0}
<div class="">
<div class="overflow-x-auto rounded-box shadow bg-base-100 mt-1 ">
<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 class="flex flex-col h-full">
{#if users.total > 0 || searchQuery || currentRole}
<div class="bg-base-100 rounded-box shadow-sm border border-base-200 flex flex-col h-full">
<!-- 工具栏 -->
<div class="flex flex-wrap items-center justify-between gap-4 p-4 border-b border-base-200">
<div class="flex flex-wrap items-center gap-4">
<!-- 搜索框 -->
<label class="input input-bordered flex items-center gap-2">
<svg class="h-4 w-4 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"
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="搜索用户..."
class="grow"
/>
<button class="btn btn-xs btn-ghost" onclick={handleSearch}>搜索</button>
</label>
<!-- 角色筛选 -->
{#if rolesOptions}
<div class="join">
<input
class="join-item btn btn-sm {currentRole === '' ? 'btn-active btn-neutral' : ''}"
type="radio"
name="roles"
aria-label="全部"
value=""
checked={currentRole === ''}
onchange={handleRoleChange}
/>
{#each rolesOptions as role (role.value)}
<input
class="join-item btn btn-sm {currentRole === String(role.value) ? 'btn-active btn-neutral' : ''}"
type="radio"
name="roles"
aria-label={role.label}
value={role.value}
checked={currentRole === String(role.value)}
onchange={handleRoleChange}
/>
{/each}
</div>
</div>
{/if}
</div>
<table class="table">
<!-- 操作按钮 -->
<div class="flex items-center gap-2">
<button class="btn btn-primary btn-sm">
<Icon id="user-plus" size="16" /> 添加用户
</button>
<div class="dropdown dropdown-bottom dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-square btn-ghost">
<Icon id="dots-vertical" size="20" />
</div>
<ul tabindex="-1" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-200">
<li>
<button onclick={() => handleBatchAction('delete')} class="text-error">
<Icon id="trash" size="16"/> 批量删除 ({selectedIds.length})
</button>
</li>
<li>
<button onclick={() => handleBatchAction('ban')}>
<Icon id="ban" size="16"/> 批量封禁 ({selectedIds.length})
</button>
</li>
</ul>
</div>
</div>
</div>
<!-- 表格区域 -->
<div class="overflow-x-auto flex-1">
<table class="table table-pin-rows">
<thead>
<tr>
<th style="width: 5%">
<th class="w-12">
<label>
<input type="checkbox" class="checkbox" />
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={isAllSelected}
use:indeterminate={isIndeterminate}
onchange={toggleAll}
/>
</label>
</th>
{#each newRowTitles as item,index(index)}
<th style="width: {item.width}%" >{item.title}</th>
{#each newRowTitles as item (item.title)}
<th style="width: {item.width}%">{item.title}</th>
{/each}
</tr>
</thead>
{#if users.records}
{#if users.records && users.records.length > 0}
<tbody>
{#each users.records as record(record.id)}
<tr>
{#each users.records as record (record.id)}
<tr class="hover">
<th>
<label>
<input type="checkbox" class="checkbox" />
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={selectedIds.includes(record.id)}
onchange={() => toggleOne(record.id)}
/>
</label>
</th>
<td>{record.id}</td>
<td>{record.username}</td>
<td>{record.nickname}</td>
<td class="font-mono text-xs opacity-70">{record.id}</td>
<td class="font-bold">{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 class="avatar">
<div class="w-8 h-8 rounded-full bg-base-300 ring ring-base-200 ring-offset-base-100 ring-offset-2">
{#if record.avatar}
<img src={record.avatar} alt={record.username} />
{:else}
<span class="text-xs flex items-center justify-center h-full w-full uppercase">
{record.username.slice(0, 2)}
</span>
{/if}
</div>
</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>
<div class="flex flex-wrap gap-1">
{#each record.roles as role (role.id)}
<span class="badge badge-sm {role.id === 1 ? 'badge-primary' : 'badge-ghost'}">
{role.name}
</span>
{/each}
</div>
</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>
{#if users.records.length === 0}
<div class="flex flex-col items-center justify-center p-10 text-base-content/50">
<Icon id="search-off" size="48" />
<p class="mt-2">未找到匹配的用户</p>
</div>
{/if}
</div>
<!-- 分页底栏 -->
{#if users.total > 0}
<div class="border-t border-base-200 p-4 flex items-center justify-between bg-base-100">
<div class="text-sm text-base-content/70">
显示 {(users.current - 1) * users.size + 1}{Math.min(users.current * users.size, users.total)} 条,共 {users.total}
</div>
<div class="join">
<button
class="join-item btn btn-sm"
disabled={users.current === 1}
onclick={() => handlePageChange(users.current - 1)}
>
上一页
</button>
{#each getPaginationRange(users.current, users.pages) as pageNum}
{#if pageNum === '...'}
<button class="join-item btn btn-sm btn-disabled">...</button>
{:else}
<button
class="join-item btn btn-sm {users.current === pageNum ? 'btn-active btn-primary' : ''}"
onclick={() => handlePageChange(Number(pageNum))}
>
{pageNum}
</button>
{/if}
{/each}
<button
class="join-item btn btn-sm"
disabled={users.current === users.pages}
onclick={() => handlePageChange(users.current + 1)}
>
下一页
</button>
</div>
</div>
{/if}
</div>
{:else }
<p>No users found</p>
{/if}
{:else}
<div class="hero bg-base-200 rounded-box min-h-[400px]">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-3xl font-bold">没有数据</h1>
<p class="py-6">当前系统中没有用户数据。</p>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -3,15 +3,23 @@ import { userService } from '$lib/api/services/userService.ts';
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
import { redirect } from '@sveltejs/kit';
import { roleService } from '$lib/api/services/roleService.ts';
import { log } from '$lib/log.ts';
export const load:PageServerLoad = async ({ cookies }) => {
export const load:PageServerLoad = async ({ cookies ,url }) => {
const token = cookies.get(COOKIE_TOKEN_KEY);
if (!token) {
throw redirect(302, '/auth/login');
throw redirect(303, '/auth/login');
}
const page = Number(url.searchParams.get('page')) || 1;
const size = Number(url.searchParams.get('page')) || 10;
const keyword = url.searchParams.get('q') || undefined;
const role = Number(url.searchParams.get('role')) || undefined;
const getRoles = async() => {
return await roleService.getRolesOptions(token);
}
@@ -19,7 +27,7 @@ export const load:PageServerLoad = async ({ cookies }) => {
const getUserList = async() => {
await new Promise(resolve => setTimeout(resolve, 3000));
return await userService.getAllUsers({ page: 1, size: 10 ,token:token});
return await userService.getAllUsers({ page: page, size: size , keyword:keyword, roleId:role,token:token});
}
return {

View File

@@ -1,10 +1,22 @@
<script lang="ts">
import UsersTable from '$lib/components/table/UsersTable.svelte';
import { resolve } from '$app/paths';
import TableLoadingState from '$lib/components/loading/TableLoadingState.svelte';
import TableLoadingError from '$lib/components/error/TableLoadingError.svelte';
const {data} = $props();
let promiseCombined = $derived(Promise.all([
data.streamed.userList,
data.streamed.rolesOptions
]));
</script>
<svelte:head>
<title>用户管理 | 系统设置</title>
</svelte:head>
<div class="flex justify-between items-center select-none">
<p class="font-bold">用户管理</p>
<div class="breadcrumbs textarea-md text-base-content/70 ">
@@ -16,26 +28,18 @@ const {data} = $props();
</ul>
</div>
</div>
{#await data.streamed.userList}
<div class=" flex flex-col items-center justify-center absolute top-0 left-0 bottom-0 right-0 backdrop-blur-2xl z-0">
<div class="loading w-28 h-28">
<div class="flex-1 bg-base-100 rounded-box shadow-sm relative overflow-hidden min-h-[400px]">
{#await promiseCombined}
<TableLoadingState />
{:then [users, rolesOptions]}
<!-- 数据加载完成,渲染表格 -->
<div class="h-full w-full ">
<UsersTable {users} {rolesOptions} />
</div>
<p class="text-base-content mt-4">加载中...</p>
</div>
{:then result}
{#await data.streamed.rolesOptions}
<div class=" flex flex-col items-center justify-center absolute top-0 left-0 bottom-0 right-0 backdrop-blur-2xl z-0">
<div class="loading w-28 h-28">
{:catch error}
</div>
<p class="text-base-content mt-4">加载中...</p>
</div>
{:then rolesOptions}
<UsersTable users={result} rolesOptions={rolesOptions}/>
{:catch err}
<p>出错了: {err.message}</p>
<TableLoadingError error={error}/>
{/await}
{:catch err}
<p>出错了: {err.message}</p>
{/await}
</div>

View File

@@ -55,7 +55,7 @@
<div class="text-center mb-4">
<h2 class="text-2xl font-bold flex justify-center items-center gap-2">
<Icon id="logo" size="40" className="inline-block"></Icon>
<Icon Cid="logo" size="40" className="inline-block"></Icon>
<span>IT DTMS登录</span>
</h2>
</div>