feat(users): 实现用户管理页面功能增强
- 添加用户列表分页、搜索和角色筛选功能 - 实现用户批量选择与操作(删除/封禁) - 引入ofetch库优化API请求处理 - 添加表格加载状态和错误处理组件 - 更新图标组件属性以支持新特性 - 修复页面跳转状态码问题(302改为303) - 优化用户表格UI展示细节与交互体验
This commit is contained in:
45
package-lock.json
generated
45
package-lock.json
generated
@@ -8,7 +8,8 @@
|
|||||||
"name": "chaos-it",
|
"name": "chaos-it",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"daisyui": "^5.5.5"
|
"daisyui": "^5.5.5",
|
||||||
|
"ofetch": "^1.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
@@ -1473,6 +1474,7 @@
|
|||||||
"integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==",
|
"integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
@@ -1512,6 +1514,7 @@
|
|||||||
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
@@ -1844,6 +1847,7 @@
|
|||||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1894,6 +1898,7 @@
|
|||||||
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
|
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.47.0",
|
"@typescript-eslint/scope-manager": "8.47.0",
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.47.0",
|
||||||
@@ -2112,6 +2117,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2396,6 +2402,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2488,6 +2500,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3512,6 +3525,23 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -3608,6 +3638,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3635,6 +3666,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3768,6 +3800,7 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3784,6 +3817,7 @@
|
|||||||
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
|
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.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==",
|
"integrity": "sha512-d1R+3pFa39LXoHCsxHmV//D2pSFZlEMlnxCVQ54TlrQv+4o5pewJO0/Pc5MUp+j71PJrOrPJHTvREZJHn+ymDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -4707,6 +4742,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4739,6 +4775,12 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
@@ -4776,6 +4818,7 @@
|
|||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"vite": "^7.1.10"
|
"vite": "^7.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"daisyui": "^5.5.5"
|
"daisyui": "^5.5.5",
|
||||||
|
"ofetch": "^1.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
|
import { ofetch, type FetchOptions, type SearchParameters } from 'ofetch';
|
||||||
|
import { log } from '$lib/log';
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
// 1. 定义更安全的类型,替代 any
|
||||||
import { log } from '$lib/log.ts';
|
type QueryParams = SearchParameters; // 使用 ofetch 内置的查询参数类型,或者自定义 Record<string, string | number | boolean>
|
||||||
|
type RequestBody = Record<string, unknown> | FormData | unknown[]; // 替代 any,使用 unknown
|
||||||
|
|
||||||
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
|
|
||||||
interface JsonObject { [key: string]: JsonValue }
|
|
||||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
|
|
||||||
|
|
||||||
export interface ApiResult<T> {
|
export interface ApiResult<T> {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -13,130 +11,41 @@ export interface ApiResult<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
|
const BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
|
||||||
body?: JsonObject | FormData | object;
|
|
||||||
customFetch?: typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
|
// 2. 指定 create 的默认类型为 json
|
||||||
|
const client = ofetch.create({
|
||||||
const normalizeHeaders = (headers?: HeadersInit): Record<string, string> => {
|
baseURL: BASE_URL,
|
||||||
const result: Record<string, string> = {};
|
onRequest({ options, request }) {
|
||||||
if (!headers) return result;
|
log.debug(`[API] ${options.method} ${request}`, {
|
||||||
|
body: options.body as unknown, // 类型断言为 unknown 避免隐式 any
|
||||||
if (headers instanceof Headers) {
|
query: options.query
|
||||||
headers.forEach((value, key) => {
|
|
||||||
result[key.toLowerCase()] = value;
|
|
||||||
});
|
});
|
||||||
} else if (Array.isArray(headers)) {
|
},
|
||||||
headers.forEach(([key, value]) => {
|
onResponseError({ request, response }) {
|
||||||
result[key.toLowerCase()] = value;
|
log.error(`[API] Error ${request}`, {
|
||||||
});
|
status: response.status,
|
||||||
} else {
|
data: response._data as unknown
|
||||||
Object.keys(headers).forEach(key => {
|
|
||||||
const value = (headers as Record<string, string>)[key];
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
result[key.toLowerCase()] = value;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
export class HttpError extends Error {
|
// 3. 辅助类型:剔除我们手动处理的属性,并强制 responseType 为 'json'
|
||||||
public status: number;
|
type AppFetchOptions = Omit<FetchOptions<'json'>, 'method' | 'body' | 'query'>;
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(url: string, options?: RequestOptions) =>
|
get: <T>(url: string, query?: QueryParams, options?: AppFetchOptions) =>
|
||||||
httpRequest<T>(url, 'GET', options),
|
client<ApiResult<T>>(url, { ...options, method: 'GET', query }),
|
||||||
|
|
||||||
post: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
|
post: <T>(url: string, body?: RequestBody, options?: AppFetchOptions) =>
|
||||||
httpRequest<T>(url, 'POST', { ...options, body }),
|
client<ApiResult<T>>(url, { ...options, method: 'POST', body }),
|
||||||
|
|
||||||
put: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
|
put: <T>(url: string, body?: RequestBody, options?: AppFetchOptions) =>
|
||||||
httpRequest<T>(url, 'PUT', { ...options, body }),
|
client<ApiResult<T>>(url, { ...options, method: 'PUT', body }),
|
||||||
|
|
||||||
delete: <T>(url: string, options?: RequestOptions) =>
|
patch: <T>(url: string, body?: RequestBody, options?: AppFetchOptions) =>
|
||||||
httpRequest<T>(url, 'DELETE', options),
|
client<ApiResult<T>>(url, { ...options, method: 'PATCH', body }),
|
||||||
|
|
||||||
patch: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
|
delete: <T>(url: string, query?: QueryParams, options?: AppFetchOptions) =>
|
||||||
httpRequest<T>(url, 'PATCH', { ...options, body }),
|
client<ApiResult<T>>(url, { ...options, method: 'DELETE', query })
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { api } from '$lib/api/httpClient.ts';
|
import { api } from '$lib/api/httpClient.ts';
|
||||||
import type { Options } from '$lib/types/api.ts';
|
import type { Options } from '$lib/types/api.ts';
|
||||||
|
import { log } from '$lib/log.ts';
|
||||||
|
|
||||||
|
|
||||||
export const roleService = {
|
export const roleService = {
|
||||||
getRolesOptions: async (token:string) => {
|
getRolesOptions: async (token:string) => {
|
||||||
const response = await api.get<Options[]>('/roles/options', {headers: {Authorization: `${token}`}});
|
const response = await api.get<Options[]>('/roles/options', {headers: {Authorization: `${token}`}});
|
||||||
if (response.code != 200 || !response.data){
|
if (response.code != 200 || !response.data){
|
||||||
|
log.error(response.msg);
|
||||||
throw new Error(response.msg);
|
throw new Error(response.msg);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ export const userService = {
|
|||||||
}
|
}
|
||||||
return response.data;
|
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();
|
const formData = new FormData();
|
||||||
formData.append('pageNum', page.toString());
|
formData.append('pageNum', page.toString());
|
||||||
formData.append('pageSize', size.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[]>>(
|
const response = await api.get<PageResult<UserProfile[]>>(
|
||||||
'/users',
|
'/users',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
transition:fly={{ x: 100, duration: 300 }}
|
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"
|
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>
|
<span>{t.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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"-->
|
<!-- aria-label="Toggle Sidebar"-->
|
||||||
<!-- onclick={sidebarState.toggleSidebar}-->
|
<!-- onclick={sidebarState.toggleSidebar}-->
|
||||||
<!-- >-->
|
<!-- >-->
|
||||||
<!-- <Icon id="menu" size="24" />-->
|
<!-- <Icon Cid="menu" size="24" />-->
|
||||||
<!-- </button>-->
|
<!-- </button>-->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={resolve(item.href)} class="p-2" onclick="{() => handleClick(item)}">
|
<a href={resolve(item.href)} class="p-2" onclick="{() => handleClick(item)}">
|
||||||
{#if item.icon}
|
{#if item.icon}
|
||||||
<Icon id="{item.icon}" size="24"/>
|
<Icon id={item.icon} size="24"/>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="menu-dropdown-toggle {item.isOpen ? 'menu-dropdown-show ' : ''}">{item.label}</span>
|
<span class="menu-dropdown-toggle {item.isOpen ? 'menu-dropdown-show ' : ''}">{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={resolve(subItem.href)} class="p-2 " onclick={() => handleClick(subItem)}>
|
<a href={resolve(subItem.href)} class="p-2 " onclick={() => handleClick(subItem)}>
|
||||||
{#if subItem.icon}
|
{#if subItem.icon}
|
||||||
<Icon id="{subItem.icon}" size="24"/>
|
<Icon id={subItem.icon} size="24"/>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="menu-dropdown-toggle">
|
<span class="menu-dropdown-toggle">
|
||||||
{subItem.label}
|
{subItem.label}
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={resolve(childItem.href)} class="p-2">
|
<a href={resolve(childItem.href)} class="p-2">
|
||||||
{#if childItem.icon}
|
{#if childItem.icon}
|
||||||
<Icon id="{childItem.icon}" size="24"/>
|
<Icon id={childItem.icon} size="24"/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-0.5/2 h-1">
|
<div class="w-0.5/2 h-1">
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={resolve(item.href)} class="p-2">
|
<a href={resolve(item.href)} class="p-2">
|
||||||
{#if item.icon}
|
{#if item.icon}
|
||||||
<Icon id="{item.icon}" size="24"/>
|
<Icon id={item.icon} size="24"/>
|
||||||
{/if}
|
{/if}
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</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>
|
<button class="btn btn-primary">添加设备</button>
|
||||||
|
|
||||||
<div class="dropdown dropdown-bottom dropdown-end">
|
<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" >
|
<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>
|
||||||
<li><div>封禁</div></li>
|
<li><div>封禁</div></li>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
<button class="btn btn-primary">添加用户</button>
|
<button class="btn btn-primary">添加用户</button>
|
||||||
|
|
||||||
<div class="dropdown dropdown-bottom dropdown-end">
|
<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" >
|
<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>
|
||||||
<li><div>封禁</div></li>
|
<li><div>封禁</div></li>
|
||||||
|
|||||||
@@ -1,157 +1,329 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { PageResult } from '$lib/types/dataTable';
|
||||||
|
import type { UserProfile } from '$lib/types/user';
|
||||||
import type { PageResult } from '$lib/types/dataTable.ts';
|
import type { Options } from '$lib/types/api';
|
||||||
import type { UserProfile } from '$lib/types/user.ts';
|
|
||||||
import Icon from '$lib/components/icon/Icon.svelte';
|
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<{
|
let { users, rolesOptions } = $props<{
|
||||||
users: PageResult<UserProfile[]>,
|
users: PageResult<UserProfile[]>;
|
||||||
rolesOptions: Options[]
|
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 ;
|
function toggleAll() {
|
||||||
|
if (isAllSelected) {
|
||||||
const handleRoleChange = (e) => {
|
selectedIds = [];
|
||||||
console.log(e.target.value);
|
} else {
|
||||||
x = e.target.value;
|
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 = [
|
const newRowTitles = [
|
||||||
{ title: 'ID', width: 5}
|
{ title: 'ID', width: 5 },
|
||||||
, { title: '用户名', width: 15 }
|
{ title: '用户名', width: 15 },
|
||||||
, { title: '昵称', width: 20 }
|
{ title: '昵称', width: 20 },
|
||||||
, { title: '头像', width: 10 }
|
{ title: '头像', width: 10 },
|
||||||
, { title: '用户组', width: 45 }
|
{ title: '用户组', width: 45 }
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log("users", users);
|
|
||||||
</script>
|
</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 class="flex flex-col h-full">
|
||||||
<div tabindex="0" role="button" class="btn" ><Icon id="menu" size="24" /></div>
|
{#if users.total > 0 || searchQuery || currentRole}
|
||||||
<ul tabindex="-1" class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 p-2 mt-2 shadow-sm" >
|
<div class="bg-base-100 rounded-box shadow-sm border border-base-200 flex flex-col h-full">
|
||||||
<li><div>删除</div></li>
|
<!-- 工具栏 -->
|
||||||
<li><div>封禁</div></li>
|
<div class="flex flex-wrap items-center justify-between gap-4 p-4 border-b border-base-200">
|
||||||
</ul>
|
<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>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 5%">
|
<th class="w-12">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" class="checkbox" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={isAllSelected}
|
||||||
|
use:indeterminate={isIndeterminate}
|
||||||
|
onchange={toggleAll}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</th>
|
</th>
|
||||||
{#each newRowTitles as item,index(index)}
|
{#each newRowTitles as item (item.title)}
|
||||||
<th style="width: {item.width}%" >{item.title}</th>
|
<th style="width: {item.width}%">{item.title}</th>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
{#if users.records && users.records.length > 0}
|
||||||
|
|
||||||
{#if users.records}
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each users.records as record(record.id)}
|
{#each users.records as record (record.id)}
|
||||||
<tr>
|
<tr class="hover">
|
||||||
<th>
|
<th>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" class="checkbox" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={selectedIds.includes(record.id)}
|
||||||
|
onchange={() => toggleOne(record.id)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</th>
|
</th>
|
||||||
<td>{record.id}</td>
|
<td class="font-mono text-xs opacity-70">{record.id}</td>
|
||||||
<td>{record.username}</td>
|
<td class="font-bold">{record.username}</td>
|
||||||
<td>{record.nickname}</td>
|
<td>{record.nickname || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="w-8 h-8 rounded-box bg-primary-content/10 border-0">
|
<div class="avatar">
|
||||||
{#if record.avatar}
|
<div class="w-8 h-8 rounded-full bg-base-300 ring ring-base-200 ring-offset-base-100 ring-offset-2">
|
||||||
<img class="w-8 h-8 rounded-box bg-primary-content/10 border-0" src="{record.avatar}" alt=" ">
|
{#if record.avatar}
|
||||||
{/if}
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="">
|
<td>
|
||||||
{#each record.roles as role (role.id)}
|
<div class="flex flex-wrap gap-1">
|
||||||
<span class="badge select-none mr-2 last:mr-0 {role.id === 1 ? 'badge-primary' : 'badge-secondary'}">{role.name}</span>
|
{#each record.roles as role (role.id)}
|
||||||
{/each}
|
<span class="badge badge-sm {role.id === 1 ? 'badge-primary' : 'badge-ghost'}">
|
||||||
|
{role.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
</tbody>
|
</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}
|
{/if}
|
||||||
|
|
||||||
</table>
|
</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>
|
</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>
|
</div>
|
||||||
{:else }
|
{:else}
|
||||||
<p>No users found</p>
|
<div class="hero bg-base-200 rounded-box min-h-[400px]">
|
||||||
{/if}
|
<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>
|
</div>
|
||||||
@@ -3,15 +3,23 @@ import { userService } from '$lib/api/services/userService.ts';
|
|||||||
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { roleService } from '$lib/api/services/roleService.ts';
|
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);
|
const token = cookies.get(COOKIE_TOKEN_KEY);
|
||||||
|
|
||||||
if (!token) {
|
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() => {
|
const getRoles = async() => {
|
||||||
return await roleService.getRolesOptions(token);
|
return await roleService.getRolesOptions(token);
|
||||||
}
|
}
|
||||||
@@ -19,7 +27,7 @@ export const load:PageServerLoad = async ({ cookies }) => {
|
|||||||
|
|
||||||
const getUserList = async() => {
|
const getUserList = async() => {
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
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 {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import UsersTable from '$lib/components/table/UsersTable.svelte';
|
import UsersTable from '$lib/components/table/UsersTable.svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import TableLoadingState from '$lib/components/loading/TableLoadingState.svelte';
|
||||||
|
import TableLoadingError from '$lib/components/error/TableLoadingError.svelte';
|
||||||
|
|
||||||
const {data} = $props();
|
const {data} = $props();
|
||||||
|
|
||||||
|
|
||||||
|
let promiseCombined = $derived(Promise.all([
|
||||||
|
data.streamed.userList,
|
||||||
|
data.streamed.rolesOptions
|
||||||
|
]));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>用户管理 | 系统设置</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex justify-between items-center select-none">
|
<div class="flex justify-between items-center select-none">
|
||||||
<p class="font-bold">用户管理</p>
|
<p class="font-bold">用户管理</p>
|
||||||
<div class="breadcrumbs textarea-md text-base-content/70 ">
|
<div class="breadcrumbs textarea-md text-base-content/70 ">
|
||||||
@@ -16,26 +28,18 @@ const {data} = $props();
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#await data.streamed.userList}
|
<div class="flex-1 bg-base-100 rounded-box shadow-sm relative overflow-hidden min-h-[400px]">
|
||||||
<div class=" flex flex-col items-center justify-center absolute top-0 left-0 bottom-0 right-0 backdrop-blur-2xl z-0">
|
{#await promiseCombined}
|
||||||
<div class="loading w-28 h-28">
|
<TableLoadingState />
|
||||||
|
{:then [users, rolesOptions]}
|
||||||
|
<!-- 数据加载完成,渲染表格 -->
|
||||||
|
<div class="h-full w-full ">
|
||||||
|
<UsersTable {users} {rolesOptions} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-base-content mt-4">加载中...</p>
|
{:catch error}
|
||||||
</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">
|
|
||||||
|
|
||||||
</div>
|
<TableLoadingError error={error}/>
|
||||||
<p class="text-base-content mt-4">加载中...</p>
|
|
||||||
</div>
|
|
||||||
{:then rolesOptions}
|
|
||||||
<UsersTable users={result} rolesOptions={rolesOptions}/>
|
|
||||||
{:catch err}
|
|
||||||
<p>出错了: {err.message}</p>
|
|
||||||
{/await}
|
{/await}
|
||||||
{:catch err}
|
</div>
|
||||||
<p>出错了: {err.message}</p>
|
|
||||||
{/await}
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<h2 class="text-2xl font-bold flex justify-center items-center gap-2">
|
<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>
|
<span>IT DTMS登录</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user