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 09b7b1f5c feat(studio): add per-user session details (#4231)
09b7b1f5c is described below

commit 09b7b1f5c46f510ceebdca93b2a16b24811fc6f0
Author: coder999o <[email protected]>
AuthorDate: Wed Sep 16 16:05:30 2026 +0800

    feat(studio): add per-user session details (#4231)
---
 .../apache/rocketmq/studio/auth/AuthService.java   |  39 ++++
 .../rocketmq/studio/auth/StudioUserController.java |   5 +
 .../studio/auth/StudioUserSessionDetailVO.java     |  37 ++++
 .../AuthServiceSessionOverviewIntegrationTest.java |  64 ++++++
 .../studio/auth/StudioUserControllerTest.java      |  31 +++
 web/src/api/studioUsers.test.ts                    |  26 +++
 web/src/api/studioUsers.ts                         |  19 ++
 web/src/pages/studio/UserManagement.tsx            | 232 +++++++++++++++++++--
 .../pages/studio/__tests__/UserManagement.test.tsx | 103 ++++++++-
 9 files changed, 538 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 dbc0b7dcc..a918ad129 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
@@ -274,6 +274,20 @@ public class AuthService {
                 .build();
     }
 
+    public List<StudioUserSessionDetailVO> listActiveSessionsForUser(Long 
userId) {
+        requireDatabaseBacked();
+        getUser(userId);
+        LocalDateTime current = now();
+        return sessionMapper.selectList(activeSessionQuery(current)
+                        .select("id", "user_id", "last_seen_at", "expires_at", 
"gmt_create")
+                        .eq("user_id", userId)
+                        .orderByDesc("last_seen_at")
+                        .orderByAsc("id"))
+                .stream()
+                .map(session -> sessionDetail(session, current))
+                .toList();
+    }
+
     public int revokeSessionsForUser(Long userId) {
         requireDatabaseBacked();
         getUser(userId);
@@ -503,6 +517,31 @@ public class AuthService {
                 .gt("expires_at", current);
     }
 
+    private StudioUserSessionDetailVO sessionDetail(RmqStudioSession session, 
LocalDateTime current) {
+        LocalDateTime expiresAt = session.getExpiresAt();
+        LocalDateTime lastSeenAt = session.getLastSeenAt();
+        return StudioUserSessionDetailVO.builder()
+                .id(session.getId())
+                .userId(session.getUserId())
+                .lastSeenAt(lastSeenAt)
+                .expiresAt(expiresAt)
+                .gmtCreate(session.getGmtCreate())
+                .remainingSeconds(nonNegativeSecondsBetween(current, 
expiresAt))
+                .idleSeconds(lastSeenAt == null ? null : 
nonNegativeSecondsBetween(lastSeenAt, current))
+                .expiringSoon(expiresAt != null
+                        && 
!expiresAt.isAfter(current.plus(SESSION_EXPIRING_SOON_WINDOW)))
+                .stale(lastSeenAt != null
+                        && 
lastSeenAt.isBefore(current.minus(STALE_SESSION_THRESHOLD)))
+                .build();
+    }
+
+    private long nonNegativeSecondsBetween(LocalDateTime start, LocalDateTime 
end) {
+        if (start == null || end == null) {
+            return 0;
+        }
+        return Math.max(0, Duration.between(start, end).getSeconds());
+    }
+
     /**
      * Renders the bind-variable reference MyBatis-Plus resolves for a value 
registered in the
      * wrapper's {@code paramNameValuePairs}, so an aggregate expression can 
be parameterized
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 7019e34de..406387a7e 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
@@ -44,6 +44,11 @@ public class StudioUserController {
         return Result.ok(authService.getSessionOverview());
     }
 
+    @GetMapping("/{userId}/sessions")
+    public Result<List<StudioUserSessionDetailVO>> 
listActiveSessions(@PathVariable Long userId) {
+        return Result.ok(authService.listActiveSessionsForUser(userId));
+    }
+
     @GetMapping
     public Result<PageResult<StudioUserVO>> list(
             @RequestParam(required = false) String search,
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
new file mode 100644
index 000000000..652407b34
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
@@ -0,0 +1,37 @@
+/*
+ * 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 lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class StudioUserSessionDetailVO {
+
+    private Long id;
+    private Long userId;
+    private LocalDateTime lastSeenAt;
+    private LocalDateTime expiresAt;
+    private LocalDateTime gmtCreate;
+    private long remainingSeconds;
+    private Long idleSeconds;
+    private boolean expiringSoon;
+    private boolean stale;
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
index 462f993bb..397062ea6 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.auth;
 
+import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.persistence.entity.RmqStudioSession;
 import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
 import org.apache.rocketmq.studio.persistence.mapper.RmqStudioSessionMapper;
@@ -32,6 +33,7 @@ import java.util.HexFormat;
 import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * Runs the session overview aggregate against the real database, because the 
bucket boundaries
@@ -92,6 +94,68 @@ class AuthServiceSessionOverviewIntegrationTest {
         }
     }
 
+    @Test
+    void 
listActiveSessionsForUserReturnsOnlyThatUsersActiveSessionDetailsTest() {
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        RmqStudioUser user = studioUser("session-detail-it-" + 
System.nanoTime(), now);
+        RmqStudioUser otherUser = studioUser("other-session-detail-it-" + 
System.nanoTime(), now);
+        userMapper.insert(user);
+        userMapper.insert(otherUser);
+        List<RmqStudioSession> inserted = new ArrayList<>();
+        try {
+            RmqStudioSession fresh = session(user.getId(), now.plusMinutes(30),
+                    now.minusMinutes(1), null);
+            RmqStudioSession expiringAndStale = session(user.getId(), 
now.plusMinutes(2),
+                    now.minusMinutes(20), null);
+            inserted.add(fresh);
+            inserted.add(expiringAndStale);
+            inserted.add(session(user.getId(), now.plusMinutes(30), 
now.minusMinutes(2), now));
+            inserted.add(session(user.getId(), now.minusMinutes(1), 
now.minusMinutes(30), null));
+            inserted.add(session(otherUser.getId(), now.plusMinutes(30), now, 
null));
+
+            List<StudioUserSessionDetailVO> details =
+                    authService.listActiveSessionsForUser(user.getId());
+
+            assertThat(details)
+                    .extracting(StudioUserSessionDetailVO::getId)
+                    .containsExactly(fresh.getId(), expiringAndStale.getId());
+            assertThat(details)
+                    .extracting(StudioUserSessionDetailVO::getUserId)
+                    .containsOnly(user.getId());
+            assertThat(details.get(0).isExpiringSoon()).isFalse();
+            assertThat(details.get(0).isStale()).isFalse();
+            
assertThat(details.get(0).getIdleSeconds()).isGreaterThanOrEqualTo(60L);
+            assertThat(details.get(0).getRemainingSeconds()).isGreaterThan(0L);
+            assertThat(details.get(1).isExpiringSoon()).isTrue();
+            assertThat(details.get(1).isStale()).isTrue();
+            
assertThat(details.get(1).getIdleSeconds()).isGreaterThanOrEqualTo(20 * 60L);
+            assertThat(details.get(1).getRemainingSeconds()).isGreaterThan(0L);
+        } finally {
+            for (RmqStudioSession session : inserted) {
+                sessionMapper.deleteById(session.getId());
+            }
+            userMapper.deleteById(user.getId());
+            userMapper.deleteById(otherUser.getId());
+        }
+    }
+
+    @Test
+    void listActiveSessionsForUserFailsWhenTheUserDoesNotExistTest() {
+        assertThatThrownBy(() -> 
authService.listActiveSessionsForUser(Long.MIN_VALUE))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("User not found");
+    }
+
+    private RmqStudioUser studioUser(String username, LocalDateTime now) {
+        RmqStudioUser user = new RmqStudioUser();
+        user.setUsername(username);
+        user.setPasswordHash("not-a-real-password-hash");
+        user.setAdmin(false);
+        user.setEnabled(true);
+        user.setPasswordChangedAt(now);
+        return user;
+    }
+
     private RmqStudioSession session(Long userId, LocalDateTime expiresAt, 
LocalDateTime lastSeenAt,
                                      LocalDateTime revokedAt) {
         RmqStudioSession session = new RmqStudioSession();
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
index 79a0642b5..c35cb9870 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
@@ -135,6 +135,37 @@ class StudioUserControllerTest extends 
WebMvcAuthTestSupport {
         verify(authService).getSessionOverview();
     }
 
+    @Test
+    void listActiveSessionsReturnsSafeSessionDetails() throws Exception {
+        when(authService.listActiveSessionsForUser(7L))
+                .thenReturn(List.of(StudioUserSessionDetailVO.builder()
+                        .id(19L)
+                        .userId(7L)
+                        .lastSeenAt(LocalDateTime.parse("2026-08-22T09:45:00"))
+                        .expiresAt(LocalDateTime.parse("2026-08-22T09:50:00"))
+                        .gmtCreate(LocalDateTime.parse("2026-08-22T09:15:00"))
+                        .remainingSeconds(300)
+                        .idleSeconds(60L)
+                        .expiringSoon(true)
+                        .stale(false)
+                        .build()));
+
+        mockMvc.perform(get("/api/studio-users/7/sessions"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data[0].id").value(19))
+                .andExpect(jsonPath("$.data[0].userId").value(7))
+                
.andExpect(jsonPath("$.data[0].lastSeenAt").value("2026-08-22T09:45:00"))
+                
.andExpect(jsonPath("$.data[0].expiresAt").value("2026-08-22T09:50:00"))
+                
.andExpect(jsonPath("$.data[0].gmtCreate").value("2026-08-22T09:15:00"))
+                .andExpect(jsonPath("$.data[0].remainingSeconds").value(300))
+                .andExpect(jsonPath("$.data[0].idleSeconds").value(60))
+                .andExpect(jsonPath("$.data[0].expiringSoon").value(true))
+                .andExpect(jsonPath("$.data[0].stale").value(false))
+                .andExpect(jsonPath("$.data[0].tokenHash").doesNotExist());
+
+        verify(authService).listActiveSessionsForUser(7L);
+    }
+
     @Test
     void revokeSessionsReturnsTheRevokedSessionCount() throws Exception {
         when(authService.revokeSessionsForUser(7L)).thenReturn(3);
diff --git a/web/src/api/studioUsers.test.ts b/web/src/api/studioUsers.test.ts
index 4172d1585..191557c17 100644
--- a/web/src/api/studioUsers.test.ts
+++ b/web/src/api/studioUsers.test.ts
@@ -21,6 +21,7 @@ import client from './client';
 import {
   getStudioUserSessionOverview,
   listAllStudioUsers as loadStudioUsersForExport,
+  listStudioUserSessions,
   listStudioUsers,
   revokeStudioUserSessions,
 } from './studioUsers';
@@ -115,6 +116,31 @@ describe('studio users API', () => {
     expect(mock.history.get[0].url).toBe('/studio-users/sessions/overview');
   });
 
+  it('loads active Studio session details for a user', async () => {
+    mock.onGet('/studio-users/7/sessions').reply(200, {
+      code: 200,
+      data: [
+        {
+          id: 19,
+          userId: 7,
+          lastSeenAt: '2026-08-22T09:45:00',
+          expiresAt: '2026-08-22T09:50:00',
+          gmtCreate: '2026-08-22T09:15:00',
+          remainingSeconds: 300,
+          idleSeconds: 60,
+          expiringSoon: true,
+          stale: false,
+        },
+      ],
+    });
+
+    const sessions = await listStudioUserSessions(7);
+
+    expect(sessions).toHaveLength(1);
+    expect(sessions[0]).toMatchObject({ id: 19, userId: 7, expiringSoon: true 
});
+    expect(mock.history.get[0].url).toBe('/studio-users/7/sessions');
+  });
+
   it('revokes a users active Studio sessions', async () => {
     mock.onPost('/studio-users/7/sessions/revoke').reply(200, {
       code: 200,
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index f70a37b05..3e465fe59 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -62,6 +62,18 @@ export interface StudioUserSessionOverview {
   staleSessionThresholdMinutes: number;
 }
 
+export interface StudioUserSessionDetail {
+  id: number;
+  userId: number;
+  lastSeenAt?: string | null;
+  expiresAt?: string | null;
+  gmtCreate?: string | null;
+  remainingSeconds: number;
+  idleSeconds?: number | null;
+  expiringSoon: boolean;
+  stale: boolean;
+}
+
 export async function listStudioUsers(query: StudioUserQuery = {}) {
   const response = await client.get<{ data: StudioUserPage }>('/studio-users', 
{
     params: query,
@@ -76,6 +88,13 @@ export async function getStudioUserSessionOverview() {
   return response.data.data;
 }
 
+export async function listStudioUserSessions(userId: number) {
+  const response = await client.get<{ data: StudioUserSessionDetail[] }>(
+    `/studio-users/${userId}/sessions`,
+  );
+  return response.data.data;
+}
+
 export const listAllStudioUsers = async (
   query: StudioUserExportQuery = {},
 ): Promise<StudioUser[]> => {
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index 633be58f2..bec63c911 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -18,6 +18,8 @@ import { useCallback, useEffect, useRef, useState } from 
'react';
 import {
   Button,
   Card,
+  Descriptions,
+  Drawer,
   Flex,
   Form,
   Input,
@@ -32,7 +34,14 @@ import {
   message,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { DownloadSimple, Key, Plus, SignOut } from '@phosphor-icons/react';
+import {
+  ArrowClockwise,
+  DownloadSimple,
+  Key,
+  ListBullets,
+  Plus,
+  SignOut,
+} from '@phosphor-icons/react';
 import { useNavigate } from 'react-router-dom';
 import PageHeader from '../../components/PageHeader';
 import InfoBanner from '../../components/InfoBanner';
@@ -41,15 +50,18 @@ import {
   createStudioUser,
   getStudioUserSessionOverview,
   listAllStudioUsers as exportStudioUsers,
+  listStudioUserSessions,
   listStudioUsers,
   resetStudioUserPassword,
   revokeStudioUserSessions,
   setStudioUserEnabled,
   type StudioUser,
+  type StudioUserSessionDetail,
   type StudioUserSessionOverview,
 } from '../../api/studioUsers';
 import useAuthStore from '../../stores/authStore';
 import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { formatDelay } from '../../utils/format';
 import { tableScrollX } from '../../utils/table';
 
 interface CreateFormValues {
@@ -63,7 +75,8 @@ interface PasswordFormValues {
   newPassword: string;
 }
 
-const dateTime = (value?: string) => (value ? new Date(value).toLocaleString() 
: '-');
+const dateTime = (value?: string | null) => (value ? new 
Date(value).toLocaleString() : '-');
+const durationText = (value?: number | null) => (value == null ? '-' : 
formatDelay(value));
 const PAGE_SIZE_OPTIONS = [20, 50, 100];
 
 type RoleFilter = 'admin' | 'reader';
@@ -87,6 +100,15 @@ const STUDIO_USER_EXPORT_COLUMNS: CsvColumn<StudioUser>[] = 
[
   { header: 'Created At', value: (user) => dateTime(user.gmtCreate) },
   { header: 'Modified At', value: (user) => dateTime(user.gmtModified) },
 ];
+
+const sessionStatusTags = (session: StudioUserSessionDetail) => (
+  <Space size={4} wrap>
+    <Tag color="processing">活跃</Tag>
+    {session.expiringSoon && <Tag color="gold">即将过期</Tag>}
+    {session.stale && <Tag color="orange">长时间未活跃</Tag>}
+  </Space>
+);
+
 const UserManagementPage = () => {
   const navigate = useNavigate();
   const admin = useAuthStore((state) => state.admin);
@@ -102,6 +124,9 @@ const UserManagementPage = () => {
   const [statusFilter, setStatusFilter] = useState<StatusFilter>();
   const [loading, setLoading] = useState(false);
   const [sessionOverview, setSessionOverview] = 
useState<StudioUserSessionOverview | null>(null);
+  const [sessionDrawerUser, setSessionDrawerUser] = useState<StudioUser | 
null>(null);
+  const [sessionDetails, setSessionDetails] = 
useState<StudioUserSessionDetail[]>([]);
+  const [sessionDetailsLoading, setSessionDetailsLoading] = useState(false);
   const [createOpen, setCreateOpen] = useState(false);
   const [passwordTarget, setPasswordTarget] = useState<StudioUser | 
null>(null);
   const [userExporting, setUserExporting] = useState(false);
@@ -109,6 +134,7 @@ const UserManagementPage = () => {
   const [createForm] = Form.useForm<CreateFormValues>();
   const [passwordForm] = Form.useForm<PasswordFormValues>();
   const requestSeqRef = useRef(0);
+  const sessionDetailsRequestSeqRef = useRef(0);
   const mutatingUserIdsRef = useRef(new Set<number>());
 
   useEffect(() => {
@@ -122,6 +148,8 @@ const UserManagementPage = () => {
       setUsers([]);
       setTotal(0);
       setSessionOverview(null);
+      setSessionDrawerUser(null);
+      setSessionDetails([]);
       return;
     }
     const requestId = ++requestSeqRef.current;
@@ -167,10 +195,46 @@ const UserManagementPage = () => {
   useEffect(
     () => () => {
       requestSeqRef.current += 1;
+      sessionDetailsRequestSeqRef.current += 1;
     },
     [],
   );
 
+  const loadSessionDetails = useCallback(async (record: StudioUser) => {
+    const requestId = ++sessionDetailsRequestSeqRef.current;
+    setSessionDetailsLoading(true);
+    try {
+      const details = await listStudioUserSessions(record.id);
+      if (requestId !== sessionDetailsRequestSeqRef.current) return;
+      setSessionDetails(details);
+      setSessionDrawerUser((current) =>
+        current?.id === record.id ? { ...current, activeSessionCount: 
details.length } : current,
+      );
+    } catch {
+      if (requestId === sessionDetailsRequestSeqRef.current) {
+        setSessionDetails([]);
+        message.error('加载用户会话失败');
+      }
+    } finally {
+      if (requestId === sessionDetailsRequestSeqRef.current) {
+        setSessionDetailsLoading(false);
+      }
+    }
+  }, []);
+
+  const openSessionDrawer = (record: StudioUser) => {
+    setSessionDrawerUser(record);
+    setSessionDetails([]);
+    void loadSessionDetails(record);
+  };
+
+  const closeSessionDrawer = () => {
+    sessionDetailsRequestSeqRef.current += 1;
+    setSessionDrawerUser(null);
+    setSessionDetails([]);
+    setSessionDetailsLoading(false);
+  };
+
   const createUser = async () => {
     const values = await createForm.validateFields();
     try {
@@ -254,6 +318,9 @@ const UserManagementPage = () => {
           navigate('/login', { replace: true });
           return;
         }
+        if (sessionDrawerUser?.id === record.id) {
+          await loadSessionDetails({ ...record, activeSessionCount: 0 });
+        }
         await loadUsers();
       },
       '注销用户会话失败',
@@ -282,30 +349,72 @@ const UserManagementPage = () => {
     }
     setUserExporting(false);
   }, [admin, debouncedSearch, roleFilter, statusFilter]);
+  const sessionDetailColumns: ColumnsType<StudioUserSessionDetail> = [
+    { title: '会话 ID', dataIndex: 'id', width: 96 },
+    {
+      title: '状态',
+      key: 'status',
+      width: 172,
+      render: (_, record) => sessionStatusTags(record),
+    },
+    {
+      title: '最近活跃',
+      dataIndex: 'lastSeenAt',
+      width: 160,
+      ellipsis: true,
+      render: dateTime,
+    },
+    {
+      title: '已空闲',
+      dataIndex: 'idleSeconds',
+      width: 112,
+      render: durationText,
+    },
+    {
+      title: '过期时间',
+      dataIndex: 'expiresAt',
+      width: 160,
+      ellipsis: true,
+      render: dateTime,
+    },
+    {
+      title: '剩余有效期',
+      dataIndex: 'remainingSeconds',
+      width: 126,
+      render: durationText,
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'gmtCreate',
+      width: 160,
+      ellipsis: true,
+      render: dateTime,
+    },
+  ];
   // Declared widths total 1116px, which stays inside the usable content width 
of a normal
   // 1440px viewport (220px Sider plus page and Card padding), so the table 
does not show a
   // horizontal scrollbar by default. Columns whose text can be longer than 
that truncate with
   // the full value on hover instead of wrapping.
   const columns: ColumnsType<StudioUser> = [
     { title: '用户名', dataIndex: 'username', width: 120, ellipsis: true },
-    { title: '用户 ID', dataIndex: 'id', width: 88 },
+    { title: '用户 ID', dataIndex: 'id', width: 80 },
     {
       title: '权限',
       dataIndex: 'admin',
-      width: 92,
+      width: 88,
       render: (value: boolean) => (value ? <Tag color="blue">管理员</Tag> : 
<Tag>普通用户</Tag>),
     },
     {
       title: '状态',
       dataIndex: 'enabled',
-      width: 92,
+      width: 88,
       render: (value: boolean) =>
         value ? <Tag color="green">已启用</Tag> : <Tag color="default">已禁用</Tag>,
     },
     {
       title: '活跃会话',
       dataIndex: 'activeSessionCount',
-      width: 84,
+      width: 80,
       render: (value?: number) => {
         const count = value ?? 0;
         return <Tag color={count > 0 ? 'processing' : 'default'}>{count}</Tag>;
@@ -314,33 +423,40 @@ const UserManagementPage = () => {
     {
       title: '最近活跃',
       dataIndex: 'lastSessionSeenAt',
-      width: 140,
+      width: 132,
       ellipsis: true,
       render: dateTime,
     },
     {
       title: '最近过期',
       dataIndex: 'nearestSessionExpiresAt',
-      width: 140,
+      width: 132,
       ellipsis: true,
       render: dateTime,
     },
     {
       title: '创建时间',
       dataIndex: 'gmtCreate',
-      width: 140,
+      width: 132,
       ellipsis: true,
       render: dateTime,
     },
     {
       title: '操作',
       key: 'actions',
-      width: 220,
+      width: 264,
       render: (_, record) => (
-        <Space>
+        <Space size={4}>
           <Button size="small" icon={<Key size={14} />} onClick={() => 
setPasswordTarget(record)}>
             改密
           </Button>
+          <Button
+            size="small"
+            icon={<ListBullets size={14} />}
+            onClick={() => openSessionDrawer(record)}
+          >
+            会话
+          </Button>
           <Popconfirm
             title={`注销 ${record.username} 的活跃会话?`}
             description="用户需要重新登录,账号状态不会改变。"
@@ -357,7 +473,7 @@ const UserManagementPage = () => {
               disabled={(record.activeSessionCount ?? 0) === 0}
               loading={mutatingUserIds.has(record.id)}
             >
-              会话
+              注销
             </Button>
           </Popconfirm>
           <Switch
@@ -508,6 +624,98 @@ const UserManagementPage = () => {
         </Card>
       )}
 
+      <Drawer
+        title={sessionDrawerUser ? `${sessionDrawerUser.username} 的会话` : 
'用户会话'}
+        width={1040}
+        open={sessionDrawerUser !== null}
+        onClose={closeSessionDrawer}
+        destroyOnHidden
+        extra={
+          sessionDrawerUser ? (
+            <Space>
+              <Button
+                icon={<ArrowClockwise size={16} />}
+                loading={sessionDetailsLoading}
+                onClick={() => void loadSessionDetails(sessionDrawerUser)}
+              >
+                刷新
+              </Button>
+              <Popconfirm
+                title={`注销 ${sessionDrawerUser.username} 的活跃会话?`}
+                description="用户需要重新登录,账号状态不会改变。"
+                okText="注销"
+                cancelText="取消"
+                okButtonProps={{ danger: true }}
+                disabled={sessionDetails.length === 0}
+                onConfirm={() => void revokeSessions(sessionDrawerUser)}
+              >
+                <Button
+                  danger
+                  icon={<SignOut size={16} />}
+                  disabled={sessionDetails.length === 0}
+                  loading={mutatingUserIds.has(sessionDrawerUser.id)}
+                >
+                  注销全部
+                </Button>
+              </Popconfirm>
+            </Space>
+          ) : undefined
+        }
+      >
+        {sessionDrawerUser && (
+          <Space direction="vertical" size={16} style={{ width: '100%' }}>
+            <Descriptions
+              bordered
+              size="small"
+              column={2}
+              items={[
+                { key: 'userId', label: '用户 ID', children: 
sessionDrawerUser.id },
+                {
+                  key: 'role',
+                  label: '权限',
+                  children: sessionDrawerUser.admin ? '管理员' : '普通用户',
+                },
+                {
+                  key: 'activeSessionCount',
+                  label: '活跃会话',
+                  children: sessionDetailsLoading ? '-' : 
sessionDetails.length,
+                },
+                {
+                  key: 'status',
+                  label: '账号状态',
+                  children: sessionDrawerUser.enabled ? '已启用' : '已禁用',
+                },
+                {
+                  key: 'lastSessionSeenAt',
+                  label: '最近活跃',
+                  children: dateTime(sessionDrawerUser.lastSessionSeenAt),
+                },
+                {
+                  key: 'nearestSessionExpiresAt',
+                  label: '最近过期',
+                  children: 
dateTime(sessionDrawerUser.nearestSessionExpiresAt),
+                },
+                {
+                  key: 'passwordChangedAt',
+                  label: '密码修改时间',
+                  children: dateTime(sessionDrawerUser.passwordChangedAt),
+                },
+              ]}
+            />
+            <Table
+              rowKey="id"
+              loading={sessionDetailsLoading}
+              columns={sessionDetailColumns}
+              dataSource={sessionDetails}
+              tableLayout="fixed"
+              pagination={false}
+              scroll={{ x: tableScrollX(sessionDetailColumns), y: 420 }}
+              locale={{ emptyText: '暂无活跃会话' }}
+            />
+          </Space>
+        )}
+      </Drawer>
+
       <Modal
         title="新建 Studio 用户"
         open={createOpen}
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx 
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 29aba00b2..22a74a9d6 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -17,16 +17,18 @@
 
 import { App } from 'antd';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent, { type UserEvent } from '@testing-library/user-event';
 import { MemoryRouter } from 'react-router-dom';
 import {
   getStudioUserSessionOverview,
   listAllStudioUsers as downloadStudioUsers,
+  listStudioUserSessions,
   listStudioUsers,
   revokeStudioUserSessions,
   setStudioUserEnabled,
   type StudioUser,
+  type StudioUserSessionDetail,
 } from '../../../api/studioUsers';
 import { downloadCsv } from '../../../utils/download';
 import UserManagementPage from '../UserManagement';
@@ -36,6 +38,7 @@ vi.mock('../../../api/studioUsers', () => ({
   createStudioUser: vi.fn(),
   getStudioUserSessionOverview: vi.fn(),
   listAllStudioUsers: vi.fn(),
+  listStudioUserSessions: vi.fn(),
   listStudioUsers: vi.fn(),
   resetStudioUserPassword: vi.fn(),
   revokeStudioUserSessions: vi.fn(),
@@ -75,6 +78,31 @@ const studioUserPage = {
   size: 20,
 };
 
+const sessionDetails: StudioUserSessionDetail[] = [
+  {
+    id: 19,
+    userId: 7,
+    lastSeenAt: '2026-08-22T09:45:00',
+    expiresAt: '2026-08-22T09:50:00',
+    gmtCreate: '2026-08-22T09:15:00',
+    remainingSeconds: 300,
+    idleSeconds: 60,
+    expiringSoon: true,
+    stale: false,
+  },
+  {
+    id: 20,
+    userId: 7,
+    lastSeenAt: '2026-08-22T09:20:00',
+    expiresAt: '2026-08-22T10:30:00',
+    gmtCreate: '2026-08-22T09:00:00',
+    remainingSeconds: 4200,
+    idleSeconds: 1500,
+    expiringSoon: false,
+    stale: true,
+  },
+];
+
 const renderPage = () =>
   render(
     <MemoryRouter>
@@ -96,6 +124,11 @@ const applyAdminDisabledFilter = async (user: UserEvent, 
keyword = 'ops') => {
   await selectOption(user, '按权限筛选', '管理员');
   await selectOption(user, '按状态筛选', '已禁用');
 };
+const confirmRevokePopover = async (user: UserEvent) => {
+  await waitFor(() => 
expect(document.querySelector('.ant-popover')).toBeTruthy());
+  const popover = document.querySelector('.ant-popover') as HTMLElement;
+  await user.click(within(popover).getByRole('button', { name: /注\s*销/ }));
+};
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -125,6 +158,7 @@ describe('UserManagementPage', () => {
       staleSessionThresholdMinutes: 15,
     });
     vi.mocked(downloadStudioUsers).mockResolvedValue(studioUserPage.items);
+    vi.mocked(listStudioUserSessions).mockResolvedValue(sessionDetails);
     vi.mocked(revokeStudioUserSessions).mockResolvedValue({
       userId: 7,
       revokedSessionCount: 2,
@@ -220,7 +254,26 @@ describe('UserManagementPage', () => {
     expect(downloadCsv).toHaveBeenCalledTimes(1);
   });
 
-  it('renders active session metadata and revokes sessions after 
confirmation', async () => {
+  it('opens the active session detail drawer for a user', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('operator');
+    await user.click(screen.getByRole('button', { name: '会话' }));
+
+    const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+    expect(listStudioUserSessions).toHaveBeenCalledWith(7);
+    expect(within(drawer).getAllByText('会话 ID').length).toBeGreaterThan(0);
+    expect(within(drawer).getByText('19')).toBeInTheDocument();
+    expect(within(drawer).getByText('20')).toBeInTheDocument();
+    expect(within(drawer).getByText('即将过期')).toBeInTheDocument();
+    expect(within(drawer).getByText('长时间未活跃')).toBeInTheDocument();
+    expect(within(drawer).getByText('5分钟')).toBeInTheDocument();
+    expect(within(drawer).getByText('1分钟')).toBeInTheDocument();
+    expect(within(drawer).queryByText(/token/i)).not.toBeInTheDocument();
+  });
+
+  it('revokes sessions after row confirmation', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
 
@@ -228,14 +281,52 @@ describe('UserManagementPage', () => {
     expect(screen.getAllByText('2').length).toBeGreaterThan(0);
     expect(screen.getByText(new 
Date('2026-08-22T09:30:00').toLocaleString())).toBeInTheDocument();
 
-    await user.click(screen.getByRole('button', { name: '会话' }));
+    await user.click(screen.getByRole('button', { name: '注销' }));
     await screen.findByText('注销 operator 的活跃会话?');
-    await user.click(screen.getByRole('button', { name: /注\s*销/ }));
+    await confirmRevokePopover(user);
 
     await waitFor(() => 
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
     expect(listStudioUsers).toHaveBeenCalledTimes(2);
   });
 
+  it('revokes sessions from the detail drawer and refreshes the detail list', 
async () => {
+    vi.mocked(listStudioUserSessions)
+      .mockResolvedValueOnce(sessionDetails)
+      .mockResolvedValueOnce([]);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('operator');
+    await user.click(screen.getByRole('button', { name: '会话' }));
+    const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+    await user.click(within(drawer).getByRole('button', { name: '注销全部' }));
+    await screen.findByText('注销 operator 的活跃会话?');
+    await confirmRevokePopover(user);
+
+    await waitFor(() => 
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
+    await waitFor(() => 
expect(listStudioUserSessions).toHaveBeenCalledTimes(2));
+    expect(within(drawer).getByText('暂无活跃会话')).toBeInTheDocument();
+  });
+
+  it('refreshes the open session detail drawer', async () => {
+    vi.mocked(listStudioUserSessions)
+      .mockResolvedValueOnce(sessionDetails)
+      .mockResolvedValueOnce([sessionDetails[0]]);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('operator');
+    await user.click(screen.getByRole('button', { name: '会话' }));
+    const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+    expect(within(drawer).getByText('20')).toBeInTheDocument();
+
+    await user.click(within(drawer).getByRole('button', { name: '刷新' }));
+
+    await waitFor(() => 
expect(listStudioUserSessions).toHaveBeenCalledTimes(2));
+    expect(within(drawer).getByText('19')).toBeInTheDocument();
+    expect(within(drawer).queryByText('20')).not.toBeInTheDocument();
+  });
+
   it('blocks the row status switch while the same user revocation is in 
flight', async () => {
     let resolveRevoke!: () => void;
     vi.mocked(revokeStudioUserSessions).mockImplementationOnce(
@@ -248,9 +339,9 @@ describe('UserManagementPage', () => {
     renderPage();
     await screen.findByText('operator');
 
-    await user.click(screen.getByRole('button', { name: '会话' }));
+    await user.click(screen.getByRole('button', { name: '注销' }));
     await screen.findByText('注销 operator 的活跃会话?');
-    await user.click(screen.getByRole('button', { name: /注\s*销/ }));
+    await confirmRevokePopover(user);
     await waitFor(() => 
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
 
     // Revocation and status updates share one in-flight guard, so the row is 
blocked meanwhile.

Reply via email to