feat(users): 实现用户管理页面功能增强
- 添加用户列表分页、搜索和角色筛选功能 - 实现用户批量选择与操作(删除/封禁) - 引入ofetch库优化API请求处理 - 添加表格加载状态和错误处理组件 - 更新图标组件属性以支持新特性 - 修复页面跳转状态码问题(302改为303) - 优化用户表格UI展示细节与交互体验
This commit is contained in:
@@ -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 })
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
22
src/lib/components/error/TableLoadingError.svelte
Normal file
22
src/lib/components/error/TableLoadingError.svelte
Normal 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>
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- aria-label="Toggle Sidebar"-->
|
||||
<!-- onclick={sidebarState.toggleSidebar}-->
|
||||
<!-- >-->
|
||||
<!-- <Icon id="menu" size="24" />-->
|
||||
<!-- <Icon Cid="menu" size="24" />-->
|
||||
<!-- </button>-->
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
4
src/lib/components/loading/TableLoadingState.svelte
Normal file
4
src/lib/components/loading/TableLoadingState.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user