This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 1f4666be3 feat(studio): paginate Studio user management (#2541)
1f4666be3 is described below
commit 1f4666be3847b108499e6a3ef4ce22cfe1ffecde
Author: xdz997 <[email protected]>
AuthorDate: Tue Aug 25 17:46:59 2026 +0800
feat(studio): paginate Studio user management (#2541)
---
.../apache/rocketmq/studio/auth/AuthService.java | 28 +++-
.../rocketmq/studio/auth/StudioUserController.java | 18 ++-
.../studio/auth/AuthServiceDatabaseTest.java | 48 +++++++
.../studio/auth/StudioUserControllerTest.java | 101 ++++++++++++++
web/src/api/studioUsers.test.ts | 62 +++++++++
web/src/api/studioUsers.ts | 21 ++-
web/src/pages/studio/UserManagement.tsx | 149 +++++++++++++++++++--
.../pages/studio/__tests__/UserManagement.test.tsx | 123 +++++++++++++++++
8 files changed, 532 insertions(+), 18 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index 21ae0d07a..148f12761 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -19,7 +19,9 @@ package org.apache.rocketmq.studio.auth;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.persistence.entity.RmqStudioSession;
import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
@@ -65,6 +67,8 @@ public class AuthService {
private static final int MIN_SESSION_TIMEOUT_MINUTES = 5;
private static final int MAX_SESSION_TIMEOUT_MINUTES = 1440;
private static final Duration LAST_SEEN_UPDATE_INTERVAL =
Duration.ofMinutes(5);
+ private static final int MAX_USER_PAGE_SIZE = 100;
+ private static final int MAX_USER_SEARCH_LENGTH = 128;
private static final String TOKEN_PREFIX = "Bearer ";
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
@@ -159,9 +163,29 @@ public class AuthService {
});
}
- public List<RmqStudioUser> listUsers() {
+ public PageResult<RmqStudioUser> listUsers(String search, Boolean admin,
Boolean enabled,
+ int page, int pageSize) {
requireDatabaseBacked();
- return userMapper.selectList(new
QueryWrapper<RmqStudioUser>().orderByAsc("username"));
+ if (page < 1) {
+ throw new BusinessException(400, "page must be greater than zero");
+ }
+ if (pageSize < 1 || pageSize > MAX_USER_PAGE_SIZE) {
+ throw new BusinessException(400,
+ "pageSize must be between 1 and " + MAX_USER_PAGE_SIZE);
+ }
+ String normalizedSearch = search == null ? "" : search.trim();
+ if (normalizedSearch.length() > MAX_USER_SEARCH_LENGTH) {
+ throw new BusinessException(400,
+ "search must not exceed " + MAX_USER_SEARCH_LENGTH + "
characters");
+ }
+ QueryWrapper<RmqStudioUser> query = new QueryWrapper<RmqStudioUser>()
+ .like(!normalizedSearch.isEmpty(), "username",
normalizedSearch)
+ .eq(admin != null, "admin", admin)
+ .eq(enabled != null, "enabled", enabled)
+ .orderByAsc("username")
+ .orderByAsc("id");
+ Page<RmqStudioUser> result = userMapper.selectPage(new Page<>(page,
pageSize), query);
+ return PageResult.of(result.getRecords(), result.getTotal(), page,
pageSize);
}
public RmqStudioUser createUser(String username, String password, boolean
admin) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
index 0869ec902..5350e00a2 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
@@ -18,16 +18,17 @@ package org.apache.rocketmq.studio.auth;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
-import java.util.List;
-
@RestController
@RequestMapping("/api/studio-users")
@RequiredArgsConstructor
@@ -36,8 +37,17 @@ public class StudioUserController {
private final AuthService authService;
@GetMapping
- public Result<List<StudioUserVO>> list() {
- return
Result.ok(authService.listUsers().stream().map(StudioUserVO::from).toList());
+ public Result<PageResult<StudioUserVO>> list(
+ @RequestParam(required = false) String search,
+ @RequestParam(required = false) Boolean admin,
+ @RequestParam(required = false) Boolean enabled,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize) {
+ PageResult<RmqStudioUser> result = authService.listUsers(
+ search, admin, enabled, page, pageSize);
+ return Result.ok(PageResult.of(
+ result.getItems().stream().map(StudioUserVO::from).toList(),
+ result.getTotal(), result.getPage(), result.getSize()));
}
@PostMapping
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
index 394a410f9..afe69409f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
@@ -17,6 +17,9 @@
package org.apache.rocketmq.studio.auth;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.persistence.entity.RmqStudioSession;
import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
@@ -37,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -63,6 +67,50 @@ class AuthServiceDatabaseTest {
sessionMapper, passwordHasher);
}
+ @Test
+ void listUsersReturnsTheRequestedFilteredPage() {
+ RmqStudioUser operator = user(2L, "operator", false, true,
"password-1");
+ Page<RmqStudioUser> databasePage = new Page<>(2, 20, 41);
+ databasePage.setRecords(List.of(operator));
+ when(userMapper.selectPage(any(Page.class),
any(Wrapper.class))).thenReturn(databasePage);
+
+ PageResult<RmqStudioUser> result = authService.listUsers(
+ " oper ", false, true, 2, 20);
+
+ assertThat(result.getItems()).containsExactly(operator);
+ assertThat(result.getTotal()).isEqualTo(41);
+ assertThat(result.getPage()).isEqualTo(2);
+ assertThat(result.getSize()).isEqualTo(20);
+ org.mockito.ArgumentCaptor<QueryWrapper<RmqStudioUser>> queryCaptor =
+ org.mockito.ArgumentCaptor.forClass(QueryWrapper.class);
+ verify(userMapper).selectPage(any(Page.class), queryCaptor.capture());
+ assertThat(queryCaptor.getValue().getSqlSegment())
+ .contains("username", "admin", "enabled", "ORDER BY username
ASC,id ASC");
+ assertThat(queryCaptor.getValue().getParamNameValuePairs().values())
+ .contains("%oper%", false, true);
+ }
+
+ @Test
+ void listUsersRejectsInvalidPaginationBeforeDatabaseAccess() {
+ assertThatThrownBy(() -> authService.listUsers(null, null, null, 0,
20))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("page must be greater than zero");
+ assertThatThrownBy(() -> authService.listUsers(null, null, null, 1,
101))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("pageSize must be between 1 and 100");
+
+ verifyNoInteractions(userMapper, sessionMapper);
+ }
+
+ @Test
+ void listUsersRejectsOversizedSearchBeforeDatabaseAccess() {
+ assertThatThrownBy(() -> authService.listUsers("x".repeat(129), null,
null, 1, 20))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("search must not exceed 128 characters");
+
+ verifyNoInteractions(userMapper, sessionMapper);
+ }
+
@Test
void databaseLoginPersistsOnlyTokenHashAndReturnsImmutableUserId() {
RmqStudioUser user = user(1L, "operator", true, true, "password-1");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
new file mode 100644
index 000000000..f9c9d133c
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.auth;
+
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
+import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(StudioUserController.class)
+@AutoConfigureMockMvc(addFilters = false)
+@TestPropertySource(properties = "studio.auth.login-required=false")
+class StudioUserControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private AuthService authService;
+
+ @MockBean
+ private SettingsRepository settingsRepository;
+
+ @BeforeEach
+ void disableLoginForControllerSlice() {
+ when(settingsRepository.loadGeneralSettings())
+
.thenReturn(GeneralSettingsVO.builder().requireLogin(false).build());
+ }
+
+ @Test
+ void listReturnsAFilteredPageWithoutPasswordHashes() throws Exception {
+ RmqStudioUser user = new RmqStudioUser();
+ user.setId(7L);
+ user.setUsername("operator");
+ user.setPasswordHash("must-not-be-exposed");
+ user.setAdmin(false);
+ user.setEnabled(true);
+ user.setGmtCreate(LocalDateTime.parse("2026-08-22T08:00:00"));
+ when(authService.listUsers("oper", false, true, 2, 20))
+ .thenReturn(PageResult.of(List.of(user), 21, 2, 20));
+
+ mockMvc.perform(get("/api/studio-users")
+ .param("search", "oper")
+ .param("admin", "false")
+ .param("enabled", "true")
+ .param("page", "2")
+ .param("pageSize", "20"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items[0].id").value(7))
+
.andExpect(jsonPath("$.data.items[0].username").value("operator"))
+
.andExpect(jsonPath("$.data.items[0].passwordHash").doesNotExist())
+ .andExpect(jsonPath("$.data.total").value(21))
+ .andExpect(jsonPath("$.data.page").value(2))
+ .andExpect(jsonPath("$.data.size").value(20));
+
+ verify(authService).listUsers("oper", false, true, 2, 20);
+ }
+
+ @Test
+ void listUsesBoundedDefaults() throws Exception {
+ when(authService.listUsers(null, null, null, 1, 20))
+ .thenReturn(PageResult.empty(1, 20));
+
+ mockMvc.perform(get("/api/studio-users"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty())
+ .andExpect(jsonPath("$.data.page").value(1))
+ .andExpect(jsonPath("$.data.size").value(20));
+ }
+}
diff --git a/web/src/api/studioUsers.test.ts b/web/src/api/studioUsers.test.ts
new file mode 100644
index 000000000..66662f88d
--- /dev/null
+++ b/web/src/api/studioUsers.test.ts
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import client from './client';
+import { listStudioUsers } from './studioUsers';
+
+const mock = new MockAdapter(client);
+
+describe('studio users API', () => {
+ beforeEach(() => mock.reset());
+ afterEach(() => mock.reset());
+
+ it('requests and returns the filtered server page', async () => {
+ mock.onGet('/studio-users').reply((config) => [
+ 200,
+ {
+ code: 200,
+ data: {
+ items: [{ id: 7, username: 'operator', admin: false, enabled: true
}],
+ total: 21,
+ page: 2,
+ size: 20,
+ },
+ receivedParams: config.params,
+ },
+ ]);
+
+ const page = await listStudioUsers({
+ search: 'oper',
+ admin: false,
+ enabled: true,
+ page: 2,
+ pageSize: 20,
+ });
+
+ expect(page.items[0].username).toBe('operator');
+ expect(page.total).toBe(21);
+ expect(mock.history.get[0].params).toEqual({
+ search: 'oper',
+ admin: false,
+ enabled: true,
+ page: 2,
+ pageSize: 20,
+ });
+ });
+});
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index 9a59aa144..8d6534e47 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -26,8 +26,25 @@ export interface StudioUser {
gmtModified: string;
}
-export async function listStudioUsers() {
- const response = await client.get<{ data: StudioUser[] }>('/studio-users');
+export interface StudioUserPage {
+ items: StudioUser[];
+ total: number;
+ page: number;
+ size: number;
+}
+
+export interface StudioUserQuery {
+ search?: string;
+ admin?: boolean;
+ enabled?: boolean;
+ page?: number;
+ pageSize?: number;
+}
+
+export async function listStudioUsers(query: StudioUserQuery = {}) {
+ const response = await client.get<{ data: StudioUserPage }>('/studio-users',
{
+ params: query,
+ });
return response.data.data;
}
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index 98d3933d8..79113a8ce 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -14,8 +14,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { useCallback, useEffect, useState } from 'react';
-import { Button, Card, Form, Input, Modal, Space, Switch, Table, Tag, message
} from 'antd';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ Button,
+ Card,
+ Flex,
+ Form,
+ Input,
+ Modal,
+ Select,
+ Space,
+ Switch,
+ Table,
+ Tag,
+ message,
+} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Key, Plus } from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom';
@@ -43,6 +56,10 @@ interface PasswordFormValues {
}
const dateTime = (value?: string) => (value ? new Date(value).toLocaleString()
: '-');
+const PAGE_SIZE_OPTIONS = [20, 50, 100];
+
+type RoleFilter = 'admin' | 'reader';
+type StatusFilter = 'enabled' | 'disabled';
const UserManagementPage = () => {
const navigate = useNavigate();
@@ -50,23 +67,60 @@ const UserManagementPage = () => {
const userId = useAuthStore((state) => state.userId);
const clearAuth = useAuthStore((state) => state.logout);
const [users, setUsers] = useState<StudioUser[]>([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [search, setSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [roleFilter, setRoleFilter] = useState<RoleFilter>();
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>();
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [passwordTarget, setPasswordTarget] = useState<StudioUser |
null>(null);
const [createForm] = Form.useForm<CreateFormValues>();
const [passwordForm] = Form.useForm<PasswordFormValues>();
+ const requestSeqRef = useRef(0);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => setDebouncedSearch(search.trim()),
300);
+ return () => window.clearTimeout(timer);
+ }, [search]);
const loadUsers = useCallback(async () => {
- if (!admin) return;
- setLoading(true);
+ if (!admin) {
+ requestSeqRef.current += 1;
+ setUsers([]);
+ setTotal(0);
+ return;
+ }
+ const requestId = ++requestSeqRef.current;
+ Promise.resolve().then(() => {
+ if (requestId === requestSeqRef.current) setLoading(true);
+ });
try {
- setUsers(await listStudioUsers());
+ const result = await listStudioUsers({
+ search: debouncedSearch || undefined,
+ admin: roleFilter === undefined ? undefined : roleFilter === 'admin',
+ enabled: statusFilter === undefined ? undefined : statusFilter ===
'enabled',
+ page,
+ pageSize,
+ });
+ if (requestId !== requestSeqRef.current) return;
+ if (result.items.length === 0 && result.total > 0 && page > 1) {
+ const lastPage = Math.max(1, Math.ceil(result.total / result.size));
+ if (page > lastPage) {
+ setPage(lastPage);
+ return;
+ }
+ }
+ setUsers(result.items);
+ setTotal(result.total);
} catch {
- message.error('加载用户列表失败');
+ if (requestId === requestSeqRef.current) message.error('加载用户列表失败');
} finally {
- setLoading(false);
+ if (requestId === requestSeqRef.current) setLoading(false);
}
- }, [admin]);
+ }, [admin, debouncedSearch, page, pageSize, roleFilter, statusFilter]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -75,6 +129,13 @@ const UserManagementPage = () => {
return () => window.clearTimeout(timer);
}, [loadUsers]);
+ useEffect(
+ () => () => {
+ requestSeqRef.current += 1;
+ },
+ [],
+ );
+
const createUser = async () => {
const values = await createForm.validateFields();
try {
@@ -82,7 +143,8 @@ const UserManagementPage = () => {
message.success('用户已创建');
setCreateOpen(false);
createForm.resetFields();
- await loadUsers();
+ if (page === 1) await loadUsers();
+ else setPage(1);
} catch {
message.error('创建用户失败');
}
@@ -190,7 +252,74 @@ const UserManagementPage = () => {
修改我的密码
</Button>
</Card>
- {admin && <Table rowKey="id" loading={loading} columns={columns}
dataSource={users} />}
+ {admin && (
+ <Card>
+ <Flex gap={12} wrap style={{ marginBottom: 16 }}>
+ <Input.Search
+ allowClear
+ placeholder="搜索用户名"
+ style={{ width: 240 }}
+ value={search}
+ onChange={(event) => {
+ setSearch(event.target.value);
+ setPage(1);
+ }}
+ />
+ <Select<RoleFilter>
+ allowClear
+ aria-label="按权限筛选"
+ placeholder="全部权限"
+ style={{ width: 140 }}
+ value={roleFilter}
+ onChange={(value) => {
+ setRoleFilter(value);
+ setPage(1);
+ }}
+ options={[
+ { label: '管理员', value: 'admin' },
+ { label: '普通用户', value: 'reader' },
+ ]}
+ />
+ <Select<StatusFilter>
+ allowClear
+ aria-label="按状态筛选"
+ placeholder="全部状态"
+ style={{ width: 140 }}
+ value={statusFilter}
+ onChange={(value) => {
+ setStatusFilter(value);
+ setPage(1);
+ }}
+ options={[
+ { label: '已启用', value: 'enabled' },
+ { label: '已禁用', value: 'disabled' },
+ ]}
+ />
+ </Flex>
+ <Table
+ rowKey="id"
+ loading={loading}
+ columns={columns}
+ dataSource={users}
+ pagination={{
+ current: page,
+ pageSize,
+ total,
+ showSizeChanger: true,
+ pageSizeOptions: PAGE_SIZE_OPTIONS.map(String),
+ showTotal: (count) => `共 ${count} 个用户`,
+ onChange: (nextPage, nextPageSize) => {
+ if (nextPageSize !== pageSize) {
+ setPage(1);
+ setPageSize(nextPageSize);
+ } else {
+ setPage(nextPage);
+ }
+ },
+ }}
+ />
+ </Card>
+ )}
<Modal
title="新建 Studio 用户"
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
new file mode 100644
index 000000000..69d90488b
--- /dev/null
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from 'antd';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { listStudioUsers } from '../../../api/studioUsers';
+import UserManagementPage from '../UserManagement';
+
+vi.mock('../../../api/studioUsers', () => ({
+ createStudioUser: vi.fn(),
+ listStudioUsers: vi.fn(),
+ resetStudioUserPassword: vi.fn(),
+ setStudioUserEnabled: vi.fn(),
+}));
+
+vi.mock('../../../stores/authStore', () => ({
+ default: (
+ selector: (state: { admin: boolean; userId: number; logout: () => void })
=> unknown,
+ ) =>
+ selector({ admin: true, userId: 1, logout: vi.fn() }),
+}));
+
+const page = {
+ items: [
+ {
+ id: 7,
+ username: 'operator',
+ admin: false,
+ enabled: true,
+ passwordChangedAt: '2026-08-22T08:00:00',
+ gmtCreate: '2026-08-22T08:00:00',
+ gmtModified: '2026-08-22T08:00:00',
+ },
+ ],
+ total: 21,
+ page: 1,
+ size: 20,
+};
+
+const renderPage = () =>
+ render(
+ <MemoryRouter>
+ <App>
+ <UserManagementPage />
+ </App>
+ </MemoryRouter>,
+ );
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+describe('UserManagementPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(listStudioUsers).mockResolvedValue(page);
+ });
+
+ it('loads a bounded first page and renders the server total', async () => {
+ renderPage();
+
+ await screen.findByText('operator');
+ expect(listStudioUsers).toHaveBeenCalledWith({
+ search: undefined,
+ admin: undefined,
+ enabled: undefined,
+ page: 1,
+ pageSize: 20,
+ });
+ expect(screen.getByText('共 21 个用户')).toBeInTheDocument();
+ });
+
+ it('debounces username search and sends role and status filters', async ()
=> {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+ await screen.findByText('operator');
+
+ await user.type(screen.getByPlaceholderText('搜索用户名'), 'ops');
+ await user.click(screen.getByRole('combobox', { name: '按权限筛选' }));
+ await user.click(await screen.findByText('管理员', { selector:
'.ant-select-item-option-content' }));
+ await user.click(screen.getByRole('combobox', { name: '按状态筛选' }));
+ await user.click(await screen.findByText('已禁用', { selector:
'.ant-select-item-option-content' }));
+
+ await waitFor(() =>
+ expect(listStudioUsers).toHaveBeenLastCalledWith({
+ search: 'ops',
+ admin: true,
+ enabled: false,
+ page: 1,
+ pageSize: 20,
+ }),
+ );
+ });
+});