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,
+      }),
+    );
+  });
+});

Reply via email to