feat(app): 实现用户管理页面功能

- 修复用户列表分页参数获取错误的问题
- 添加搜索和角色筛选功能
- 实现批量删除和封禁用户操作
- 优化页面布局和样式
- 添加调试日志输出
- 更新图标资源库
- 修复API客户端请求头传递问题
- 调整侧边栏导航样式和结构
- 减少模拟数据加载时间
- 添加COOKIE常量引用
This commit is contained in:
Chaos
2025-12-02 11:45:38 +08:00
parent ab43a9a140
commit 4cdf6bade8
10 changed files with 344 additions and 410 deletions

View File

@@ -1,8 +1,9 @@
import { ofetch, type FetchOptions, type SearchParameters } from 'ofetch'; import { ofetch, type FetchOptions, type SearchParameters } from 'ofetch';
import { log } from '$lib/log'; import { log } from '$lib/log';
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
// 1. 定义更安全的类型,替代 any // 1. 定义更安全的类型,替代 any
type QueryParams = SearchParameters; // 使用 ofetch 内置的查询参数类型,或者自定义 Record<string, string | number | boolean> type QueryParams = SearchParameters;
type RequestBody = Record<string, unknown> | FormData | unknown[]; // 替代 any使用 unknown type RequestBody = Record<string, unknown> | FormData | unknown[]; // 替代 any使用 unknown
export interface ApiResult<T> { export interface ApiResult<T> {
@@ -19,12 +20,14 @@ const client = ofetch.create({
onRequest({ options, request }) { onRequest({ options, request }) {
log.debug(`[API] ${options.method} ${request}`, { log.debug(`[API] ${options.method} ${request}`, {
body: options.body as unknown, // 类型断言为 unknown 避免隐式 any body: options.body as unknown, // 类型断言为 unknown 避免隐式 any
headers: options.headers,
query: options.query query: options.query
}); });
}, },
onResponseError({ request, response }) { onResponseError({ request, response }) {
log.error(`[API] Error ${request}`, { log.error(`[API] Error ${request}`, {
status: response.status, status: response.status,
headers: response.headers,
data: response._data as unknown data: response._data as unknown
}); });
} }

View File

@@ -5,7 +5,7 @@ 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',undefined, {headers: {Authorization: `${token}`}});
if (response.code != 200 || !response.data){ if (response.code != 200 || !response.data){
log.error(response.msg); log.error(response.msg);
throw new Error(response.msg); throw new Error(response.msg);

View File

@@ -1,29 +1,31 @@
import { api } from '$lib/api/httpClient.ts'; import { api } from '$lib/api/httpClient.ts';
import type { UserProfile } from '$lib/types/user.ts'; import type { UserProfile } from '$lib/types/user.ts';
import type { PageResult } from '$lib/types/dataTable.ts'; import type { PageResult } from '$lib/types/dataTable.ts';
import { type SearchParameters } from 'ofetch';
// 1. 定义更安全的类型,替代 any
type QueryParams = SearchParameters;
export const userService = { export const userService = {
getUserProfile: async (token:string) => { getUserProfile: async (token:string) => {
const response = await api.get<UserProfile>('/users/me', {headers: {Authorization: `${token}`}}); const response = await api.get<UserProfile>('/users/me',undefined, {headers: {Authorization: `${token}`}});
if (response.code != 200 || !response.data){ if (response.code != 200 || !response.data){
throw new Error(response.msg); throw new Error(response.msg);
} }
return response.data; return response.data;
}, },
getAllUsers: async ({ page, size,token , keyword, roleId}: { page: number, size: number, token:string , keyword?: string, roleId?: number}) => { 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()); const params: QueryParams= {
formData.append('pageSize', size.toString()); pageNum: page,
if ( keyword){ pageSize: size,
formData.append('keyword', keyword); ...(keyword && { keyword }),
} ...(roleId && { roleId })
if ( roleId){ } ;
formData.append('roleId', roleId.toString());
}
const response = await api.get<PageResult<UserProfile[]>>( const response = await api.get<PageResult<UserProfile[]>>(
'/users', '/users',
params,
{ {
body: formData,
headers: {Authorization: `${token}`} headers: {Authorization: `${token}`}
}); });
if (response.code != 200 || !response.data){ if (response.code != 200 || !response.data){

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
// import SpriteSvg from '$lib/assets/sprite.svg'
</script> </script>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;" > <svg xmlns="http://www.w3.org/2000/svg" style="display: none;" >
@@ -56,39 +56,6 @@
<path fill="currentColor" d="M3.75 6.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75" /> <path fill="currentColor" d="M3.75 6.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75" />
</symbol> </symbol>
<symbol id="logo" viewBox="0 0 375 374.999991">
<defs>
<clipPath id="fc5bc8767a">
<path d="M 58.5 0 L 316.5 0 C 332.015625 0 346.894531 6.164062 357.867188 17.132812 C 368.835938 28.105469 375 42.984375 375 58.5 L 375 316.5 C 375 332.015625 368.835938 346.894531 357.867188 357.867188 C 346.894531 368.835938 332.015625 375 316.5 375 L 58.5 375 C 26.191406 375 0 348.808594 0 316.5 L 0 58.5 C 0 26.191406 26.191406 0 58.5 0 Z M 58.5 0 " clip-rule="nonzero"/>
</clipPath>
<clipPath id="b295c49c3e">
<path d="M 0 0 L 375 0 L 375 375 L 0 375 Z M 0 0 " clip-rule="nonzero"/>
</clipPath>
<clipPath id="bfbc279fc9">
<path d="M 58.5 0 L 316.5 0 C 332.015625 0 346.894531 6.164062 357.867188 17.132812 C 368.835938 28.105469 375 42.984375 375 58.5 L 375 316.5 C 375 332.015625 368.835938 346.894531 357.867188 357.867188 C 346.894531 368.835938 332.015625 375 316.5 375 L 58.5 375 C 26.191406 375 0 348.808594 0 316.5 L 0 58.5 C 0 26.191406 26.191406 0 58.5 0 Z M 58.5 0 " clip-rule="nonzero"/>
</clipPath>
<clipPath id="063da92238">
<rect x="0" width="375" y="0" height="375"/>
</clipPath>
<clipPath id="cfbb605b8a">
<path d="M 75 88.359375 L 300 88.359375 L 300 286.359375 L 75 286.359375 Z M 75 88.359375 " clip-rule="nonzero"/>
</clipPath>
</defs>
<g clip-path="url(#fc5bc8767a)">
<g transform="matrix(1, 0, 0, 1, 0, 0)">
<g clip-path="url(#063da92238)">
<g clip-path="url(#b295c49c3e)">
<g clip-path="url(#bfbc279fc9)">
<rect x="-82.5" width="540" fill="#1e1e1e" height="539.999987" y="-82.499998" fill-opacity="1"/>
</g>
</g>
</g>
</g>
</g>
<g clip-path="url(#cfbb605b8a)">
<path fill="#ffffff" d="M 294.644531 174.898438 L 291.878906 170.023438 L 291.832031 170.023438 L 273.359375 138.019531 L 249.835938 97.03125 L 247.199219 92.507812 C 245.707031 89.945312 242.964844 88.371094 240.003906 88.371094 L 221.074219 88.371094 C 214.949219 88.371094 210.917969 94.753906 213.546875 100.285156 L 223.050781 120.25 C 223.140625 120.445312 223.242188 120.636719 223.351562 120.824219 L 243.175781 155.304688 L 251.59375 169.8125 C 257.941406 180.757812 257.964844 194.253906 251.660156 205.222656 L 244.746094 217.246094 L 244.5625 217.246094 L 242.058594 221.632812 C 238.851562 227.257812 230.734375 227.230469 227.558594 221.585938 L 155.027344 92.621094 C 153.550781 89.996094 150.773438 88.371094 147.765625 88.371094 L 135.160156 88.371094 C 132.257812 88.371094 129.5625 89.882812 128.050781 92.359375 L 125.230469 96.972656 L 101.675781 138.019531 L 78.15625 178.835938 C 75.042969 184.171875 75.003906 190.757812 78.050781 196.132812 L 78.132812 196.277344 L 81.566406 202.160156 L 101.675781 237.128906 L 125.203125 277.953125 L 127.835938 282.472656 C 129.332031 285.03125 132.070312 286.605469 135.035156 286.605469 L 156.164062 286.605469 C 162.601562 286.605469 166.609375 279.613281 163.351562 274.0625 L 151.742188 254.253906 L 131.859375 219.671875 L 121.308594 201.484375 C 116.289062 192.828125 116.289062 182.148438 121.308594 173.496094 L 131.851562 155.320312 L 133.03125 153.261719 C 136.242188 147.679688 144.296875 147.691406 147.492188 153.28125 L 221.234375 282.40625 C 222.71875 285.003906 225.476562 286.605469 228.46875 286.605469 L 240.003906 286.605469 C 242.964844 286.605469 245.707031 285.03125 247.199219 282.472656 L 249.820312 277.976562 L 273.363281 237.121094 L 296.882812 196.136719 C 299.996094 190.796875 300.015625 184.195312 296.921875 178.839844 L 294.644531 174.898438 " fill-opacity="1" fill-rule="nonzero"/>
</g>
</symbol>
<symbol id="info" viewBox="0 0 24 24"> <symbol id="info" viewBox="0 0 24 24">
<path fill="#3B82F6" d="M12 1.999c5.524 0 10.002 4.478 10.002 10.002c0 5.523-4.478 10.001-10.002 10.001S2 17.524 2 12.001C1.999 6.477 6.476 1.999 12 1.999" class="duoicon-secondary-layer" opacity="0.3" /> <path fill="#3B82F6" d="M12 1.999c5.524 0 10.002 4.478 10.002 10.002c0 5.523-4.478 10.001-10.002 10.001S2 17.524 2 12.001C1.999 6.477 6.476 1.999 12 1.999" class="duoicon-secondary-layer" opacity="0.3" />
@@ -120,14 +87,14 @@
<path fill="currentColor" d="M1 4.75C1 3.784 1.784 3 2.75 3h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 13H2.75A1.75 1.75 0 0 1 1 11.25zM2.75 4a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75h10.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75zM9.5 6a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zM5.261 7.714a1.357 1.357 0 1 0 0-2.714a1.357 1.357 0 0 0 0 2.714m-1.403.678A.86.86 0 0 0 3 9.25a1.67 1.67 0 0 0 1.265 1.62l.053.014c.62.155 1.267.155 1.886 0l.054-.013a1.67 1.67 0 0 0 1.265-1.62a.86.86 0 0 0-.858-.859z" /> <path fill="currentColor" d="M1 4.75C1 3.784 1.784 3 2.75 3h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 13H2.75A1.75 1.75 0 0 1 1 11.25zM2.75 4a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75h10.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75zM9.5 6a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zM5.261 7.714a1.357 1.357 0 1 0 0-2.714a1.357 1.357 0 0 0 0 2.714m-1.403.678A.86.86 0 0 0 3 9.25a1.67 1.67 0 0 0 1.265 1.62l.053.014c.62.155 1.267.155 1.886 0l.054-.013a1.67 1.67 0 0 0 1.265-1.62a.86.86 0 0 0-.858-.859z" />
</symbol> </symbol>
<symbol id="sign-out" viewBox="0 0 20 20"> <symbol id="sign-out" viewBox="0 0 20 20"><path fill="currentColor" d="M8.5 11.25a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5M11 3.5a.5.5 0 0 0-.576-.494l-7 1.07A.5.5 0 0 0 3 4.57v10.86a.5.5 0 0 0 .424.494l7 1.071a.5.5 0 0 0 .576-.494V10h5.172l-.997.874a.5.5 0 0 0 .658.752l1.996-1.75a.5.5 0 0 0 0-.752l-1.996-1.75a.499.499 0 1 0-.658.752l.997.874H11zm-1 .582V15.92L4 15V4.999zM12.5 16H12v-5h1v4.5a.5.5 0 0 1-.5.5M12 8V4h.5a.5.5 0 0 1 .5.5V8z" /></symbol>
<path fill="currentColor" d="M8.5 11.25a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5M11 3.5a.5.5 0 0 0-.576-.494l-7 1.07A.5.5 0 0 0 3 4.57v10.86a.5.5 0 0 0 .424.494l7 1.071a.5.5 0 0 0 .576-.494V10h5.172l-.997.874a.5.5 0 0 0 .658.752l1.996-1.75a.5.5 0 0 0 0-.752l-1.996-1.75a.499.499 0 1 0-.658.752l.997.874H11zm-1 .582V15.92L4 15V4.999zM12.5 16H12v-5h1v4.5a.5.5 0 0 1-.5.5M12 8V4h.5a.5.5 0 0 1 .5.5V8z" />
</symbol>
<symbol id="auth" viewBox="0 0 16 16"><path fill="currentColor" d="M9.07 6.746a2 2 0 0 0 2.91.001l.324-.344c.297.14.577.316.835.519l-.126.422a2 2 0 0 0 1.456 2.518l.348.083a4.7 4.7 0 0 1 .011 1.017l-.46.117a2 2 0 0 0-1.431 2.479l.156.556q-.383.294-.822.497l-.337-.357a2 2 0 0 0-2.91-.002l-.325.345a4.3 4.3 0 0 1-.835-.519l.126-.423a2 2 0 0 0-1.456-2.518l-.35-.083a4.7 4.7 0 0 1-.01-1.016l.462-.118a2 2 0 0 0 1.43-2.478l-.156-.557q.383-.294.822-.497zm-1.423-4.6a.5.5 0 0 1 .707 0C9.594 3.39 10.97 4 12.5 4a.5.5 0 0 1 .5.5v1.1a5.5 5.5 0 0 0-7.494 7.204C3.846 11.59 3 9.812 3 7.502V4.5a.5.5 0 0 1 .5-.5c1.53 0 2.904-.61 4.147-1.854M10.501 9.5a1 1 0 1 0 0 2a1 1 0 0 0 0-2"/></symbol> <symbol id="auth" viewBox="0 0 16 16"><path fill="currentColor" d="M9.07 6.746a2 2 0 0 0 2.91.001l.324-.344c.297.14.577.316.835.519l-.126.422a2 2 0 0 0 1.456 2.518l.348.083a4.7 4.7 0 0 1 .011 1.017l-.46.117a2 2 0 0 0-1.431 2.479l.156.556q-.383.294-.822.497l-.337-.357a2 2 0 0 0-2.91-.002l-.325.345a4.3 4.3 0 0 1-.835-.519l.126-.423a2 2 0 0 0-1.456-2.518l-.35-.083a4.7 4.7 0 0 1-.01-1.016l.462-.118a2 2 0 0 0 1.43-2.478l-.156-.557q.383-.294.822-.497zm-1.423-4.6a.5.5 0 0 1 .707 0C9.594 3.39 10.97 4 12.5 4a.5.5 0 0 1 .5.5v1.1a5.5 5.5 0 0 0-7.494 7.204C3.846 11.59 3 9.812 3 7.502V4.5a.5.5 0 0 1 .5-.5c1.53 0 2.904-.61 4.147-1.854M10.501 9.5a1 1 0 1 0 0 2a1 1 0 0 0 0-2"/></symbol>
<symbol id="chevron-up-down" viewBox="0 0 16 16"><path fill="currentColor" d="M4.22 6.53a.75.75 0 0 0 1.06 0L8 3.81l2.72 2.72a.75.75 0 1 0 1.06-1.06L8.53 2.22a.75.75 0 0 0-1.06 0L4.22 5.47a.75.75 0 0 0 0 1.06m0 2.94a.75.75 0 0 1 1.06 0L8 12.19l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0l-3.25-3.25a.75.75 0 0 1 0-1.06"/></symbol> <symbol id="chevron-up-down" viewBox="0 0 16 16"><path fill="currentColor" d="M4.22 6.53a.75.75 0 0 0 1.06 0L8 3.81l2.72 2.72a.75.75 0 1 0 1.06-1.06L8.53 2.22a.75.75 0 0 0-1.06 0L4.22 5.47a.75.75 0 0 0 0 1.06m0 2.94a.75.75 0 0 1 1.06 0L8 12.19l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0l-3.25-3.25a.75.75 0 0 1 0-1.06"/></symbol>
<symbol id="laptop-settings" viewBox="0 0 20 20"><path fill="currentColor" d="M4.5 5A1.5 1.5 0 0 0 3 6.5v6A1.5 1.5 0 0 0 4.5 14h4.522A5.5 5.5 0 0 1 17 9.6V6.5A1.5 1.5 0 0 0 15.5 5zm-2 10h6.522q.047.516.185 1H2.5a.5.5 0 0 1 0-1m9.565-3.558a2 2 0 0 1-1.43 2.478l-.462.118a4.7 4.7 0 0 0 .01 1.016l.35.083a2 2 0 0 1 1.456 2.519l-.127.423q.388.306.835.517l.325-.344a2 2 0 0 1 2.91.002l.337.358q.44-.203.822-.498l-.156-.556a2 2 0 0 1 1.43-2.478l.46-.118a4.7 4.7 0 0 0-.01-1.017l-.348-.082a2 2 0 0 1-1.456-2.52l.126-.421a4.3 4.3 0 0 0-.835-.519l-.325.344a2 2 0 0 1-2.91-.001l-.337-.358a4.3 4.3 0 0 0-.821.497zm2.434 4.058a1 1 0 1 1 0-2a1 1 0 0 1 0 2"/></symbol> <symbol id="laptop-settings" viewBox="0 0 20 20"><path fill="currentColor" d="M4.5 5A1.5 1.5 0 0 0 3 6.5v6A1.5 1.5 0 0 0 4.5 14h4.522A5.5 5.5 0 0 1 17 9.6V6.5A1.5 1.5 0 0 0 15.5 5zm-2 10h6.522q.047.516.185 1H2.5a.5.5 0 0 1 0-1m9.565-3.558a2 2 0 0 1-1.43 2.478l-.462.118a4.7 4.7 0 0 0 .01 1.016l.35.083a2 2 0 0 1 1.456 2.519l-.127.423q.388.306.835.517l.325-.344a2 2 0 0 1 2.91.002l.337.358q.44-.203.822-.498l-.156-.556a2 2 0 0 1 1.43-2.478l.46-.118a4.7 4.7 0 0 0-.01-1.017l-.348-.082a2 2 0 0 1-1.456-2.52l.126-.421a4.3 4.3 0 0 0-.835-.519l-.325.344a2 2 0 0 1-2.91-.001l-.337-.358a4.3 4.3 0 0 0-.821.497zm2.434 4.058a1 1 0 1 1 0-2a1 1 0 0 1 0 2"/></symbol>
<symbol id="people-search" viewBox="0 0 20 20"><path fill="currentColor" d="M10 2a4 4 0 1 0 0 8a4 4 0 0 0 0-8m4.865 14.797c-1.071.683-2.454 1.064-3.962 1.171a1.5 1.5 0 0 0-.342-.529l-2-1.999A4.5 4.5 0 0 0 9 13.5a4.5 4.5 0 0 0-.758-2.5H15a2 2 0 0 1 2 2c0 1.691-.833 2.966-2.135 3.797M4.5 17c.786 0 1.512-.26 2.096-.697l2.55 2.55a.5.5 0 1 0 .708-.707l-2.55-2.55A3.5 3.5 0 1 0 4.5 17m0-1a2.5 2.5 0 1 1 0-5a2.5 2.5 0 0 1 0 5"/></symbol>
<symbol id="search-12" viewBox="0 0 12 12"><path fill="currentColor" d="M5 1a4 4 0 1 0 2.248 7.31l2.472 2.47a.75.75 0 1 0 1.06-1.06L8.31 7.248A4 4 0 0 0 5 1M2.5 5a2.5 2.5 0 1 1 5 0a2.5 2.5 0 0 1-5 0"/></symbol>
<symbol id="delete-12" viewBox="0 0 12 12"><path fill="currentColor" d="M5 3h2a1 1 0 0 0-2 0M4 3a2 2 0 1 1 4 0h2.5a.5.5 0 0 1 0 1h-.441l-.443 5.17A2 2 0 0 1 7.623 11H4.377a2 2 0 0 1-1.993-1.83L1.941 4H1.5a.5.5 0 0 1 0-1zm3.5 3a.5.5 0 0 0-1 0v2a.5.5 0 0 0 1 0zM5 5.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5M3.38 9.085a1 1 0 0 0 .997.915h3.246a1 1 0 0 0 .996-.915L9.055 4h-6.11z"/></symbol>
<symbol id="person-add" viewBox="0 0 16 16"><path fill="currentColor" d="M9.626 5.07a5.5 5.5 0 0 0-3.299 1.847A2.751 2.751 0 1 1 9.626 5.07M5.6 8c-.384.75-.6 1.6-.6 2.5c0 1.31.458 2.512 1.222 3.457C3.555 13.653 2 11.803 2 10v-.5A1.5 1.5 0 0 1 3.5 8zm4.9 7a4.5 4.5 0 1 0 0-9a4.5 4.5 0 0 0 0 9m0-7a.5.5 0 0 1 .5.5V10h1.5a.5.5 0 0 1 0 1H11v1.5a.5.5 0 0 1-1 0V11H8.5a.5.5 0 0 1 0-1H10V8.5a.5.5 0 0 1 .5-.5"/></symbol>
<symbol id="logo" viewBox="0 0 1028 1024"><path d="M550.68864 672c25.6-54.4 76.8-96 134.4-115.2l41.6-89.6c-3.2-6.4-6.4-12.8-6.4-19.2-3.2-6.4-3.2-16-6.4-22.4-25.6-6.4-41.6-19.2-51.2-38.4-16-32 3.2-76.8 41.6-115.2-25.6-35.2-57.6-64-92.8-89.6-38.4 41.6-80 60.8-112 48-35.2-12.8-51.2-57.6-51.2-112-41.6-6.4-86.4-3.2-128 3.2 3.2 60.8-12.8 105.6-44.8 121.6-35.2 12.8-76.8-3.2-118.4-41.6-35.2 25.6-64 57.6-89.6 92.8 41.6 38.4 60.8 80 48 112-12.8 35.2-57.6 51.2-112 51.2-6.4 41.6-3.2 86.4 3.2 128 54.4-3.2 99.2 12.8 115.2 44.8 16 32-3.2 76.8-41.6 115.2 25.6 35.2 57.6 64 92.8 89.6 38.4-41.6 80-60.8 112-48 35.2 12.8 51.2 57.6 51.2 112 41.6 6.4 86.4 3.2 128-3.2-3.2-54.4 12.8-99.2 44.8-115.2 3.2-3.2 9.6-3.2 12.8-3.2 6.4-35.2 12.8-73.6 28.8-105.6z m-156.8 6.4C304.28864 678.4 227.48864 604.8 227.48864 512c0-92.8 73.6-166.4 166.4-166.4s166.4 73.6 166.4 166.4c3.2 92.8-73.6 166.4-166.4 166.4z" fill="currentColor" ></path><path d="M1001.88864 288l-54.4 96-99.2-48 41.6-102.4c-48 3.2-96 28.8-118.4 76.8-22.4 48-16 105.6 16 144L707.48864 620.8c-48 3.2-92.8 28.8-115.2 76.8-22.4 48-16 99.2 12.8 140.8l60.8-102.4 99.2 48-44.8 112c48-3.2 96-28.8 118.4-76.8 22.4-48 16-102.4-16-144l80-166.4c48-3.2 92.8-28.8 115.2-76.8 19.2-51.2 12.8-105.6-16-144z" fill="currentColor" ></path></symbol>
</svg> </svg>

View File

@@ -123,13 +123,15 @@
let menuItems = $derived(computeNavState(rawNavItems, page.url.pathname, expandedIds)); let menuItems = $derived(computeNavState(rawNavItems, page.url.pathname, expandedIds));
</script> </script>
<div class="h-screen relative bg-base-200">
<aside class="custom-scrollbar h-full w-full overflow-hidden flex flex-col "> <aside class="custom-scrollbar h-screen bg-base-200 flex flex-col ">
<div class="h-14 shadow-2xl shadow-base-100 "> <div class="flex items-center h-18 space-x-2 w-full">
123 <div class="space-x-4 pl-6 w-full">
<Icon id="logo" className="inline" size="36"/><h1 class="font-sans text-xs inline align-bottom">IT Management System</h1>
</div>
</div> </div>
<div class="overflow-y-auto flex-1 "> <div class="overflow-y-auto flex-1 ">
<ul class="menu bg-base-200 w-64 p-4 text-base-content flex-nowrap "> <ul class="menu bg-base-200 w-64 px-4 pb-4text-base-content flex-nowrap ">
{#each menuItems as item (item.id)} {#each menuItems as item (item.id)}
<li class="{item.isActive ? 'menu-active' : ''} w-full rounded-box "> <li class="{item.isActive ? 'menu-active' : ''} w-full rounded-box ">
{#if item.subItems && item.subItems.length > 0} {#if item.subItems && item.subItems.length > 0}
@@ -193,7 +195,7 @@
12312 12312
</div> </div>
</aside> </aside>
</div>
<style> <style>
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {

View File

@@ -1,28 +1,26 @@
<script lang="ts"> <script lang="ts">
import type { PageResult } from '$lib/types/dataTable'; import type { PageResult } from '$lib/types/dataTable';
import type { UserProfile } from '$lib/types/user'; import type { UserProfile } from '$lib/types/user';
import type { Options } from '$lib/types/api';
import Icon from '$lib/components/icon/Icon.svelte'; import Icon from '$lib/components/icon/Icon.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
let { users, rolesOptions } = $props<{ let {
users,
selectedIds = $bindable([]),
onPageChange
} = $props<{
users: PageResult<UserProfile[]>; users: PageResult<UserProfile[]>;
rolesOptions: Options[]; selectedIds: number[];
onPageChange: (page: number) => void;
}>(); }>();
// --- 1. 状态管理 (Svelte 5 Runes) --- // --- 内部状态逻辑 (与UI展示紧密相关) ---
let selectedIds = $state<number[]>([]);
let searchQuery = $state(page.url.searchParams.get('q') || '');
let currentRole = $derived(page.url.searchParams.get('role') || '');
// --- 2. 选择逻辑 ---
// 计算属性:是否全选 // 计算属性:是否全选
let isAllSelected = $derived( let isAllSelected = $derived(
users.records.length > 0 && selectedIds.length === users.records.length users.records.length > 0 && selectedIds.length === users.records.length
); );
// 计算属性:是否部分选中 (用于控制 checkbox 的 indeterminate 状态)
// 计算属性:是否部分选中 (indeterminate)
let isIndeterminate = $derived( let isIndeterminate = $derived(
selectedIds.length > 0 && selectedIds.length < users.records.length selectedIds.length > 0 && selectedIds.length < users.records.length
); );
@@ -50,46 +48,9 @@
}); });
} }
// --- 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) { function getPaginationRange(current: number, total: number) {
const delta = 2; // 当前页码前后显示的页数 const delta = 2;
const range = []; const range = [];
const rangeWithDots: (number | string)[] = []; const rangeWithDots: (number | string)[] = [];
let l: number | undefined; let l: number | undefined;
@@ -123,88 +84,9 @@
]; ];
</script> </script>
<div class="flex flex-col h-full"> <div class="overflow-x-auto flex-1 bg-base-100 min-h-[300px] ">
{#if users.total > 0 || searchQuery || currentRole} <table class="table ">
<div class="bg-base-100 rounded-box shadow-sm border border-base-200 flex flex-col h-full"> <thead class="z-0">
<!-- 工具栏 -->
<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>
{/if}
</div>
<!-- 操作按钮 -->
<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> <tr>
<th class="w-12"> <th class="w-12">
<label> <label>
@@ -223,7 +105,6 @@
</tr> </tr>
</thead> </thead>
{#if users.records && users.records.length > 0}
<tbody> <tbody>
{#each users.records as record (record.id)} {#each users.records as record (record.id)}
<tr class="hover"> <tr class="hover">
@@ -265,7 +146,6 @@
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
{/if}
</table> </table>
{#if users.records.length === 0} {#if users.records.length === 0}
@@ -276,7 +156,6 @@
{/if} {/if}
</div> </div>
<!-- 分页底栏 -->
{#if users.total > 0} {#if users.total > 0}
<div class="border-t border-base-200 p-4 flex items-center justify-between bg-base-100"> <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"> <div class="text-sm text-base-content/70">
@@ -287,18 +166,18 @@
<button <button
class="join-item btn btn-sm" class="join-item btn btn-sm"
disabled={users.current === 1} disabled={users.current === 1}
onclick={() => handlePageChange(users.current - 1)} onclick={() => onPageChange(users.current - 1)}
> >
上一页 上一页
</button> </button>
{#each getPaginationRange(users.current, users.pages) as pageNum} {#each getPaginationRange(users.current, users.pages) as pageNum (pageNum)}
{#if pageNum === '...'} {#if pageNum === '...'}
<button class="join-item btn btn-sm btn-disabled">...</button> <button class="join-item btn btn-sm btn-disabled">...</button>
{:else} {:else}
<button <button
class="join-item btn btn-sm {users.current === pageNum ? 'btn-active btn-primary' : ''}" class="join-item btn btn-sm {users.current === pageNum ? 'btn-active btn-primary' : ''}"
onclick={() => handlePageChange(Number(pageNum))} onclick={() => onPageChange(Number(pageNum))}
> >
{pageNum} {pageNum}
</button> </button>
@@ -308,22 +187,10 @@
<button <button
class="join-item btn btn-sm" class="join-item btn btn-sm"
disabled={users.current === users.pages} disabled={users.current === users.pages}
onclick={() => handlePageChange(users.current + 1)} onclick={() => onPageChange(users.current + 1)}
> >
下一页 下一页
</button> </button>
</div> </div>
</div> </div>
{/if} {/if}
</div>
{: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

@@ -17,5 +17,9 @@ export type IconId =
"user-profile"| "user-profile"|
"auth"| "auth"|
"chevron-up-down"| "chevron-up-down"|
"laptop-settings" "laptop-settings"|
"people-search"|
"search-12"|
"delete-12" |
"person-add"
; ;

View File

@@ -6,16 +6,13 @@
</script> </script>
<div class="flex h-screen bg-base-300 overflow-hidden relative"> <div class="flex h-screen bg-base-300 overflow-hidden relative">
<AppSidebar /> <AppSidebar />
<div class="flex-1 flex flex-col min-w-0 overflow-hidden relative h-full">
<div class="flex-1 flex flex-col min-w-0 overflow-hidden relative">
<AppHeader /> <AppHeader />
<main class="flex-1 p-4 ">
<main class="flex-1 px-4">
{@render children()} {@render children()}
</main> </main>
</div> </div>
</div> </div>

View File

@@ -14,10 +14,11 @@ export const load:PageServerLoad = async ({ cookies ,url }) => {
} }
const page = Number(url.searchParams.get('page')) || 1; const page = Number(url.searchParams.get('page')) || 1;
const size = Number(url.searchParams.get('page')) || 10; const size = Number(url.searchParams.get('size')) || 10;
const keyword = url.searchParams.get('q') || undefined; const keyword = url.searchParams.get('q') || undefined;
const role = Number(url.searchParams.get('role')) || undefined; const role = Number(url.searchParams.get('role')) || undefined;
log.debug('getAllUsers', { page, size, keyword, role });
const getRoles = async() => { const getRoles = async() => {
@@ -26,7 +27,7 @@ export const load:PageServerLoad = async ({ cookies ,url }) => {
const getUserList = async() => { const getUserList = async() => {
await new Promise(resolve => setTimeout(resolve, 3000)); await new Promise(resolve => setTimeout(resolve, 1000));
return await userService.getAllUsers({ page: page, size: size , keyword:keyword, roleId:role,token:token}); return await userService.getAllUsers({ page: page, size: size , keyword:keyword, roleId:role,token:token});
} }

View File

@@ -1,25 +1,67 @@
<script lang="ts"> <script lang="ts">
import UsersTable from '$lib/components/table/UsersTable.svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Icon from '$lib/components/icon/Icon.svelte';
import TableLoadingState from '$lib/components/loading/TableLoadingState.svelte'; import TableLoadingState from '$lib/components/loading/TableLoadingState.svelte';
import TableLoadingError from '$lib/components/error/TableLoadingError.svelte'; import TableLoadingError from '$lib/components/error/TableLoadingError.svelte';
import UsersTable from '$lib/components/table/UsersTable.svelte';
const { data } = $props(); const { data } = $props();
// --- 1. 状态管理 ---
// selectedIds 需要在父组件,因为"批量操作按钮"在 Toolbar 里
let selectedIds = $state<number[]>([]);
let promiseCombined = $derived(Promise.all([ let searchQuery = $state(page.url.searchParams.get('q') || '');
data.streamed.userList, let currentRole = $derived(page.url.searchParams.get('role') || '');
data.streamed.rolesOptions
])); // --- 2. 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');
}
// 切换筛选条件时清空选中状态,避免误操作
selectedIds = [];
goto(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) {
updateParams('page', newPage);
}
function handleBatchAction(action: 'delete' | 'ban') {
if (selectedIds.length === 0) return alert('请先选择用户');
console.log(`执行批量操作: ${action}`, selectedIds);
// TODO: 调用 API...
}
</script> </script>
<svelte:head> <svelte:head>
<title>用户管理 | 系统设置</title> <title>用户管理 | 系统设置</title>
</svelte:head> </svelte:head>
<div class="flex justify-between items-center select-none"> <div class=" h-full flex flex-col ">
<p class="font-bold">用户管理</p> <div class="flex justify-between items-center select-none pb-2">
<div class="breadcrumbs textarea-md text-base-content/70 "> <p class="font-bold text-lg">用户管理</p>
<div class="breadcrumbs text-sm text-base-content/70">
<ul> <ul>
<li><a href={resolve('/app/dashboard')}>仪表盘</a></li> <li><a href={resolve('/app/dashboard')}>仪表盘</a></li>
<li>系统设置</li> <li>系统设置</li>
@@ -28,18 +70,67 @@ let promiseCombined = $derived(Promise.all([
</ul> </ul>
</div> </div>
</div> </div>
<div class="flex-1 bg-base-100 rounded-box shadow-sm relative overflow-hidden min-h-[400px]"> <div class="flex flex-wrap items-center justify-between p-4 border-b border-base-200 bg-base-100 relative rounded-t-box">
{#await promiseCombined} <div class="flex flex-wrap items-center ">
<TableLoadingState /> <label class="input input-bordered input-sm flex items-center gap-2">
{:then [users, rolesOptions]} <Icon id="search-12" size="16" class="opacity-50" />
<!-- 数据加载完成,渲染表格 --> <input
<div class="h-full w-full "> type="search"
<UsersTable {users} {rolesOptions} /> bind:value={searchQuery}
</div> onkeydown={(e) => e.key === 'Enter' && handleSearch()}
{:catch error} placeholder="搜索用户..."
class="grow"
/>
<button class="btn btn-xs btn-ghost" onclick={handleSearch}>搜索</button>
</label>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-primary btn-sm">
<Icon id="person-add" 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="menu" size="20" />
</div>
<div tabindex="-1" class="dropdown-content w-48 bg-base-100 rounded-box z-[1] mt-2 p-2 shadow-lg border border-base-200 join join-vertical">
<button
onclick={() => handleBatchAction('delete')}
class:btn-disabled={selectedIds.length === 0}
class=" join-item btn btn-error"
>
<span>批量删除 ({selectedIds.length})</span>
</button>
<button
onclick={() => handleBatchAction('ban')}
class:btn-disabled={selectedIds.length === 0}
class="join-item btn btn-neutral"
>
<span>批量封禁 ({selectedIds.length})</span>
</button>
</div>
</div>
</div>
</div>
<div class="flex-1 relative bg-base-100 flex flex-col rounded-b-box">
{#await data.streamed.userList}
<TableLoadingState />
{:then users}
<UsersTable
{users}
bind:selectedIds
onPageChange={handlePageChange}
/>
{:catch error}
<TableLoadingError error={error} /> <TableLoadingError error={error} />
{/await} {/await}
</div> </div>
</div>