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 91bebfe44 fix(studio): localize the user management page (#4621)
91bebfe44 is described below
commit 91bebfe448741ebc7f52039b885a1df4b032c879
Author: Apulupie <[email protected]>
AuthorDate: Mon Sep 21 21:06:04 2026 +0800
fix(studio): localize the user management page (#4621)
`web/src/pages/studio/UserManagement.tsx` rendered every string it owns as
hard-coded Chinese — page header, the non-admin `InfoBanner`, both tables'
column titles, role and status tags, the session drawer, the two modals, filter
placeholders and aria-labels, pagination `showTotal` and the toasts — so an
English UI showed the whole page in Chinese.
The page now reads its text through `t`, with a new `userMgmt.*` section in
`translations.ts` carrying both languages. Status, actions, enabled, disabled,
cancel, refresh and export reuse existing `common.*` keys whose Chinese values
match the removed literals, so the Chinese UI is unchanged; the module-level
`sessionStatusTags` helper became a `SessionStatusTags` component because it
now needs the i18n context. The CSV export headers stay English, as they are
part of the export contract.
Maintainer edits on top of the contribution: this was merged after #4576
touched the same test file, and the import conflict was resolved by keeping
both sides' imports. The new `it()` description does not carry the `Test`
suffix our Java convention uses and the frontend naming policy is still being
decided, so it was left as authored.
Fixes #4622
---
web/src/i18n/translations.ts | 109 ++++++++
web/src/pages/studio/UserManagement.tsx | 283 ++++++++++++---------
.../pages/studio/__tests__/UserManagement.test.tsx | 34 ++-
3 files changed, 304 insertions(+), 122 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 3e05f0687..6dbe72543 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -2402,6 +2402,115 @@ const translations: Record<string, Record<Lang,
string>> = {
'user.profile': { zh: '个人中心', en: 'Profile' },
'user.logout': { zh: '退出登录', en: 'Logout' },
+ // ─── User Management ───
+ 'userMgmt.title': { zh: '用户管理', en: 'User Management' },
+ 'userMgmt.subtitle': {
+ zh: 'Studio 本地账号、会话与密码管理',
+ en: 'Studio local accounts, sessions and password management',
+ },
+ 'userMgmt.createUser': { zh: '新建用户', en: 'Create User' },
+ 'userMgmt.notAdminTitle': {
+ zh: '当前账号不是管理员',
+ en: 'Current account is not an administrator',
+ },
+ 'userMgmt.notAdminDescription': {
+ zh: '你可以修改自己的密码;用户列表和账号状态仅对管理员开放。',
+ en: 'You can change your own password; the user list and account status
are only available to administrators.',
+ },
+ 'userMgmt.myAccount': { zh: '我的账号', en: 'My Account' },
+ 'userMgmt.changeMyPassword': { zh: '修改我的密码', en: 'Change My Password' },
+ 'userMgmt.sessionOverview': { zh: '会话概览', en: 'Session Overview' },
+ 'userMgmt.activeSessions': { zh: '活跃会话', en: 'Active Sessions' },
+ 'userMgmt.activeUsers': { zh: '活跃用户', en: 'Active Users' },
+ 'userMgmt.expiringWithin': {
+ zh: '未来 {minutes} 分钟过期',
+ en: 'Expiring within {minutes} minutes',
+ },
+ 'userMgmt.idleMinutes': { zh: '{minutes} 分钟未活跃', en: 'Idle for {minutes}
minutes' },
+ 'userMgmt.searchPlaceholder': { zh: '搜索用户名', en: 'Search username' },
+ 'userMgmt.filterByRole': { zh: '按权限筛选', en: 'Filter by role' },
+ 'userMgmt.allRoles': { zh: '全部权限', en: 'All roles' },
+ 'userMgmt.roleAdmin': { zh: '管理员', en: 'Administrator' },
+ 'userMgmt.roleUser': { zh: '普通用户', en: 'Regular user' },
+ 'userMgmt.filterByStatus': { zh: '按状态筛选', en: 'Filter by status' },
+ 'userMgmt.allStatuses': { zh: '全部状态', en: 'All statuses' },
+ 'userMgmt.totalUsers': { zh: '共 {count} 个用户', en: '{count} users in total' },
+ 'userMgmt.sessionId': { zh: '会话 ID', en: 'Session ID' },
+ 'userMgmt.lastActive': { zh: '最近活跃', en: 'Last Active' },
+ 'userMgmt.idleFor': { zh: '已空闲', en: 'Idle For' },
+ 'userMgmt.expiresAt': { zh: '过期时间', en: 'Expires At' },
+ 'userMgmt.remainingValidity': { zh: '剩余有效期', en: 'Remaining Validity' },
+ 'userMgmt.createdAt': { zh: '创建时间', en: 'Created At' },
+ 'userMgmt.username': { zh: '用户名', en: 'Username' },
+ 'userMgmt.userId': { zh: '用户 ID', en: 'User ID' },
+ 'userMgmt.role': { zh: '权限', en: 'Role' },
+ 'userMgmt.nearestExpiry': { zh: '最近过期', en: 'Nearest Expiry' },
+ 'userMgmt.changePassword': { zh: '改密', en: 'Password' },
+ 'userMgmt.sessions': { zh: '会话', en: 'Sessions' },
+ 'userMgmt.revokeConfirm': {
+ zh: '注销 {username} 的活跃会话?',
+ en: 'Revoke active sessions of {username}?',
+ },
+ 'userMgmt.revokeDescription': {
+ zh: '用户需要重新登录,账号状态不会改变。',
+ en: 'The user will need to sign in again; the account status is
unchanged.',
+ },
+ 'userMgmt.revoke': { zh: '注销', en: 'Revoke' },
+ 'userMgmt.enable': { zh: '启用', en: 'Enable' },
+ 'userMgmt.disable': { zh: '禁用', en: 'Disable' },
+ 'userMgmt.userSessions': { zh: '用户会话', en: 'User Sessions' },
+ 'userMgmt.sessionsOf': { zh: '{username} 的会话', en: 'Sessions of {username}'
},
+ 'userMgmt.revokeAll': { zh: '注销全部', en: 'Revoke All' },
+ 'userMgmt.accountStatus': { zh: '账号状态', en: 'Account Status' },
+ 'userMgmt.passwordChangedAt': { zh: '密码修改时间', en: 'Password Changed At' },
+ 'userMgmt.noActiveSessions': { zh: '暂无活跃会话', en: 'No active sessions' },
+ 'userMgmt.createTitle': { zh: '新建 Studio 用户', en: 'Create Studio User' },
+ 'userMgmt.initialPassword': { zh: '初始密码', en: 'Initial Password' },
+ 'userMgmt.passwordMinLength': {
+ zh: '密码至少 8 位',
+ en: 'Password must be at least 8 characters',
+ },
+ 'userMgmt.adminAccess': { zh: '管理员权限', en: 'Administrator Access' },
+ 'userMgmt.resetPasswordOf': {
+ zh: '重置 {username} 的密码',
+ en: 'Reset password of {username}',
+ },
+ 'userMgmt.currentPassword': { zh: '当前密码', en: 'Current Password' },
+ 'userMgmt.newPassword': { zh: '新密码', en: 'New Password' },
+ 'userMgmt.tagActive': { zh: '活跃', en: 'Active' },
+ 'userMgmt.tagExpiringSoon': { zh: '即将过期', en: 'Expiring Soon' },
+ 'userMgmt.tagStale': { zh: '长时间未活跃', en: 'Inactive' },
+ 'userMgmt.loadFailed': { zh: '加载用户列表失败', en: 'Failed to load users' },
+ 'userMgmt.loadSessionsFailed': { zh: '加载用户会话失败', en: 'Failed to load user
sessions' },
+ 'userMgmt.userCreated': { zh: '用户已创建', en: 'User created' },
+ 'userMgmt.createFailed': { zh: '创建用户失败', en: 'Failed to create user' },
+ 'userMgmt.userEnabled': { zh: '用户已启用', en: 'User enabled' },
+ 'userMgmt.userDisabled': {
+ zh: '用户已禁用,全部会话已注销',
+ en: 'User disabled, all sessions revoked',
+ },
+ 'userMgmt.updateStatusFailed': { zh: '更新用户状态失败', en: 'Failed to update user
status' },
+ 'userMgmt.passwordChanged': {
+ zh: '密码已修改,请使用新密码重新登录',
+ en: 'Password changed, please sign in with the new password',
+ },
+ 'userMgmt.passwordReset': {
+ zh: '密码已重置,用户的现有会话已注销',
+ en: "Password reset, the user's existing sessions have been revoked",
+ },
+ 'userMgmt.changePasswordFailed': { zh: '修改密码失败', en: 'Failed to change
password' },
+ 'userMgmt.revokedCount': {
+ zh: '已注销 {count} 个活跃会话',
+ en: 'Revoked {count} active sessions',
+ },
+ 'userMgmt.nothingToRevoke': { zh: '没有可注销的活跃会话', en: 'No active sessions to
revoke' },
+ 'userMgmt.revokeFailed': { zh: '注销用户会话失败', en: 'Failed to revoke user
sessions' },
+ 'userMgmt.exportedCount': { zh: '已导出 {count} 个用户', en: 'Exported {count}
users' },
+ 'userMgmt.exportFailed': {
+ zh: '导出用户列表失败,请稍后重试',
+ en: 'Failed to export users, please try again later',
+ },
+
// ─── Login ───
'login.title': { zh: '登录', en: 'Login' },
'login.username': { zh: '用户名', en: 'Username' },
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index e5bf179ff..c40068e06 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -63,6 +63,7 @@ import useAuthStore from '../../stores/authStore';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
import { formatDelay, formatUtcDateTime } from '../../utils/format';
import { tableScrollX } from '../../utils/table';
+import { useLang } from '../../i18n/LangContext';
interface CreateFormValues {
username: string;
@@ -103,15 +104,19 @@ const STUDIO_USER_EXPORT_COLUMNS: CsvColumn<StudioUser>[]
= [
{ 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 SessionStatusTags = ({ session }: { session: StudioUserSessionDetail })
=> {
+ const { t } = useLang();
+ return (
+ <Space size={4} wrap>
+ <Tag color="processing">{t('userMgmt.tagActive')}</Tag>
+ {session.expiringSoon && <Tag
color="gold">{t('userMgmt.tagExpiringSoon')}</Tag>}
+ {session.stale && <Tag color="orange">{t('userMgmt.tagStale')}</Tag>}
+ </Space>
+ );
+};
const UserManagementPage = () => {
+ const { t } = useLang();
const navigate = useNavigate();
const admin = useAuthStore((state) => state.admin);
const userId = useAuthStore((state) => state.userId);
@@ -181,11 +186,11 @@ const UserManagementPage = () => {
setUsers(result.items);
setTotal(result.total);
} catch {
- if (requestId === requestSeqRef.current) message.error('加载用户列表失败');
+ if (requestId === requestSeqRef.current)
message.error(t('userMgmt.loadFailed'));
} finally {
if (requestId === requestSeqRef.current) setLoading(false);
}
- }, [admin, debouncedSearch, page, pageSize, roleFilter, statusFilter]);
+ }, [admin, debouncedSearch, page, pageSize, roleFilter, statusFilter, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -202,27 +207,30 @@ const UserManagementPage = () => {
[],
);
- 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 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(t('userMgmt.loadSessionsFailed'));
+ }
+ } finally {
+ if (requestId === sessionDetailsRequestSeqRef.current) {
+ setSessionDetailsLoading(false);
+ }
}
- }
- }, []);
+ },
+ [t],
+ );
const openSessionDrawer = (record: StudioUser) => {
setSessionDrawerUser(record);
@@ -241,13 +249,13 @@ const UserManagementPage = () => {
const values = await createForm.validateFields();
try {
await createStudioUser(values);
- message.success('用户已创建');
+ message.success(t('userMgmt.userCreated'));
setCreateOpen(false);
createForm.resetFields();
if (page === 1) await loadUsers();
else setPage(1);
} catch {
- message.error('创建用户失败');
+ message.error(t('userMgmt.createFailed'));
}
};
@@ -279,10 +287,10 @@ const UserManagementPage = () => {
record.id,
async () => {
await setStudioUserEnabled(record.id, enabled);
- message.success(enabled ? '用户已启用' : '用户已禁用,全部会话已注销');
+ message.success(enabled ? t('userMgmt.userEnabled') :
t('userMgmt.userDisabled'));
await loadUsers();
},
- '更新用户状态失败',
+ t('userMgmt.updateStatusFailed'),
);
const updatePassword = async () => {
@@ -293,15 +301,15 @@ const UserManagementPage = () => {
await changePassword(values.currentPassword ?? '', values.newPassword);
clearAuth();
navigate('/login', { replace: true });
- message.success('密码已修改,请使用新密码重新登录');
+ message.success(t('userMgmt.passwordChanged'));
} else {
await resetStudioUserPassword(passwordTarget.id, values.newPassword);
- message.success('密码已重置,用户的现有会话已注销');
+ message.success(t('userMgmt.passwordReset'));
}
setPasswordTarget(null);
passwordForm.resetFields();
} catch {
- message.error('修改密码失败');
+ message.error(t('userMgmt.changePasswordFailed'));
}
};
@@ -311,9 +319,9 @@ const UserManagementPage = () => {
async () => {
const result = await revokeStudioUserSessions(record.id);
if (result.revokedSessionCount > 0) {
- message.success(`已注销 ${result.revokedSessionCount} 个活跃会话`);
+ message.success(t('userMgmt.revokedCount', { count:
result.revokedSessionCount }));
} else {
- message.success('没有可注销的活跃会话');
+ message.success(t('userMgmt.nothingToRevoke'));
}
if (record.id === userId && result.revokedSessionCount > 0) {
clearAuth();
@@ -325,7 +333,7 @@ const UserManagementPage = () => {
}
await loadUsers();
},
- '注销用户会话失败',
+ t('userMgmt.revokeFailed'),
);
const openCreateUserModal = () => setCreateOpen(true);
@@ -345,48 +353,48 @@ const UserManagementPage = () => {
`rocketmq-studio-users-${today}.csv`,
buildCsv(STUDIO_USER_EXPORT_COLUMNS, exportedUsers),
);
- message.success(`已导出 ${exportedUsers.length} 个用户`);
+ message.success(t('userMgmt.exportedCount', { count:
exportedUsers.length }));
} catch {
- message.error('导出用户列表失败,请稍后重试');
+ message.error(t('userMgmt.exportFailed'));
}
setUserExporting(false);
- }, [admin, debouncedSearch, roleFilter, statusFilter]);
+ }, [admin, debouncedSearch, roleFilter, statusFilter, t]);
const sessionDetailColumns: ColumnsType<StudioUserSessionDetail> = [
- { title: '会话 ID', dataIndex: 'id', width: 96 },
+ { title: t('userMgmt.sessionId'), dataIndex: 'id', width: 96 },
{
- title: '状态',
+ title: t('common.status'),
key: 'status',
width: 172,
- render: (_, record) => sessionStatusTags(record),
+ render: (_, record) => <SessionStatusTags session={record} />,
},
{
- title: '最近活跃',
+ title: t('userMgmt.lastActive'),
dataIndex: 'lastSeenAt',
width: 160,
ellipsis: true,
render: dateTime,
},
{
- title: '已空闲',
+ title: t('userMgmt.idleFor'),
dataIndex: 'idleSeconds',
width: 112,
render: durationText,
},
{
- title: '过期时间',
+ title: t('userMgmt.expiresAt'),
dataIndex: 'expiresAt',
width: 160,
ellipsis: true,
render: dateTime,
},
{
- title: '剩余有效期',
+ title: t('userMgmt.remainingValidity'),
dataIndex: 'remainingSeconds',
width: 126,
render: durationText,
},
{
- title: '创建时间',
+ title: t('userMgmt.createdAt'),
dataIndex: 'gmtCreate',
width: 160,
ellipsis: true,
@@ -398,23 +406,32 @@ const UserManagementPage = () => {
// 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: 80 },
+ { title: t('userMgmt.username'), dataIndex: 'username', width: 120,
ellipsis: true },
+ { title: t('userMgmt.userId'), dataIndex: 'id', width: 80 },
{
- title: '权限',
+ title: t('userMgmt.role'),
dataIndex: 'admin',
width: 88,
- render: (value: boolean) => (value ? <Tag color="blue">管理员</Tag> :
<Tag>普通用户</Tag>),
+ render: (value: boolean) =>
+ value ? (
+ <Tag color="blue">{t('userMgmt.roleAdmin')}</Tag>
+ ) : (
+ <Tag>{t('userMgmt.roleUser')}</Tag>
+ ),
},
{
- title: '状态',
+ title: t('common.status'),
dataIndex: 'enabled',
width: 88,
render: (value: boolean) =>
- value ? <Tag color="green">已启用</Tag> : <Tag color="default">已禁用</Tag>,
+ value ? (
+ <Tag color="green">{t('common.enabled')}</Tag>
+ ) : (
+ <Tag color="default">{t('common.disabled')}</Tag>
+ ),
},
{
- title: '活跃会话',
+ title: t('userMgmt.activeSessions'),
dataIndex: 'activeSessionCount',
width: 80,
render: (value?: number) => {
@@ -423,47 +440,47 @@ const UserManagementPage = () => {
},
},
{
- title: '最近活跃',
+ title: t('userMgmt.lastActive'),
dataIndex: 'lastSessionSeenAt',
width: 132,
ellipsis: true,
render: dateTime,
},
{
- title: '最近过期',
+ title: t('userMgmt.nearestExpiry'),
dataIndex: 'nearestSessionExpiresAt',
width: 132,
ellipsis: true,
render: dateTime,
},
{
- title: '创建时间',
+ title: t('userMgmt.createdAt'),
dataIndex: 'gmtCreate',
width: 132,
ellipsis: true,
render: dateTime,
},
{
- title: '操作',
+ title: t('common.actions'),
key: 'actions',
width: 264,
render: (_, record) => (
<Space size={4}>
<Button size="small" icon={<Key size={14} />} onClick={() =>
setPasswordTarget(record)}>
- 改密
+ {t('userMgmt.changePassword')}
</Button>
<Button
size="small"
icon={<ListBullets size={14} />}
onClick={() => openSessionDrawer(record)}
>
- 会话
+ {t('userMgmt.sessions')}
</Button>
<Popconfirm
- title={`注销 ${record.username} 的活跃会话?`}
- description="用户需要重新登录,账号状态不会改变。"
- okText="注销"
- cancelText="取消"
+ title={t('userMgmt.revokeConfirm', { username: record.username })}
+ description={t('userMgmt.revokeDescription')}
+ okText={t('userMgmt.revoke')}
+ cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
disabled={(record.activeSessionCount ?? 0) === 0}
onConfirm={() => void revokeSessions(record)}
@@ -475,14 +492,14 @@ const UserManagementPage = () => {
disabled={(record.activeSessionCount ?? 0) === 0}
loading={mutatingUserIds.has(record.id)}
>
- 注销
+ {t('userMgmt.revoke')}
</Button>
</Popconfirm>
<Switch
checked={record.enabled}
loading={mutatingUserIds.has(record.id)}
- checkedChildren="启用"
- unCheckedChildren="禁用"
+ checkedChildren={t('userMgmt.enable')}
+ unCheckedChildren={t('userMgmt.disable')}
onChange={(enabled) => void setEnabled(record, enabled)}
/>
</Space>
@@ -493,8 +510,8 @@ const UserManagementPage = () => {
return (
<div style={{ padding: 24 }}>
<PageHeader
- title="用户管理"
- subtitle="Studio 本地账号、会话与密码管理"
+ title={t('userMgmt.title')}
+ subtitle={t('userMgmt.subtitle')}
extra={
admin ? (
<Space>
@@ -504,10 +521,10 @@ const UserManagementPage = () => {
loading={userExporting}
onClick={() => void handleExportUsers()}
>
- 导出
+ {t('common.export')}
</Button>
<Button type="primary" icon={<Plus size={16} />}
onClick={openCreateUserModal}>
- 新建用户
+ {t('userMgmt.createUser')}
</Button>
</Space>
) : undefined
@@ -515,11 +532,11 @@ const UserManagementPage = () => {
/>
{!admin && (
<InfoBanner
- title="当前账号不是管理员"
- description="你可以修改自己的密码;用户列表和账号状态仅对管理员开放。"
+ title={t('userMgmt.notAdminTitle')}
+ description={t('userMgmt.notAdminDescription')}
/>
)}
- <Card title="我的账号" style={{ marginBottom: 16 }}>
+ <Card title={t('userMgmt.myAccount')} style={{ marginBottom: 16 }}>
<Button
icon={<Key size={16} />}
disabled={!userId}
@@ -536,20 +553,27 @@ const UserManagementPage = () => {
})
}
>
- 修改我的密码
+ {t('userMgmt.changeMyPassword')}
</Button>
</Card>
{admin && sessionOverview && (
- <Card title="会话概览" style={{ marginBottom: 16 }}>
+ <Card title={t('userMgmt.sessionOverview')} style={{ marginBottom: 16
}}>
<Flex gap={32} wrap>
- <Statistic title="活跃会话" value={sessionOverview.activeSessionCount}
/>
- <Statistic title="活跃用户" value={sessionOverview.activeUserCount} />
<Statistic
- title={`未来 ${sessionOverview.expiringSoonWindowMinutes} 分钟过期`}
+ title={t('userMgmt.activeSessions')}
+ value={sessionOverview.activeSessionCount}
+ />
+ <Statistic title={t('userMgmt.activeUsers')}
value={sessionOverview.activeUserCount} />
+ <Statistic
+ title={t('userMgmt.expiringWithin', {
+ minutes: sessionOverview.expiringSoonWindowMinutes,
+ })}
value={sessionOverview.expiringSoonSessionCount}
/>
<Statistic
- title={`${sessionOverview.staleSessionThresholdMinutes} 分钟未活跃`}
+ title={t('userMgmt.idleMinutes', {
+ minutes: sessionOverview.staleSessionThresholdMinutes,
+ })}
value={sessionOverview.staleSessionCount}
/>
</Flex>
@@ -560,7 +584,7 @@ const UserManagementPage = () => {
<Flex gap={12} wrap style={{ marginBottom: 16 }}>
<Input.Search
allowClear
- placeholder="搜索用户名"
+ placeholder={t('userMgmt.searchPlaceholder')}
style={{ width: 240 }}
value={search}
onChange={(event) => {
@@ -570,8 +594,8 @@ const UserManagementPage = () => {
/>
<Select<RoleFilter>
allowClear
- aria-label="按权限筛选"
- placeholder="全部权限"
+ aria-label={t('userMgmt.filterByRole')}
+ placeholder={t('userMgmt.allRoles')}
style={{ width: 140 }}
value={roleFilter}
onChange={(value) => {
@@ -579,14 +603,14 @@ const UserManagementPage = () => {
setPage(1);
}}
options={[
- { label: '管理员', value: 'admin' },
- { label: '普通用户', value: 'reader' },
+ { label: t('userMgmt.roleAdmin'), value: 'admin' },
+ { label: t('userMgmt.roleUser'), value: 'reader' },
]}
/>
<Select<StatusFilter>
allowClear
- aria-label="按状态筛选"
- placeholder="全部状态"
+ aria-label={t('userMgmt.filterByStatus')}
+ placeholder={t('userMgmt.allStatuses')}
style={{ width: 140 }}
value={statusFilter}
onChange={(value) => {
@@ -594,8 +618,8 @@ const UserManagementPage = () => {
setPage(1);
}}
options={[
- { label: '已启用', value: 'enabled' },
- { label: '已禁用', value: 'disabled' },
+ { label: t('common.enabled'), value: 'enabled' },
+ { label: t('common.disabled'), value: 'disabled' },
]}
/>
</Flex>
@@ -612,7 +636,7 @@ const UserManagementPage = () => {
total,
showSizeChanger: true,
pageSizeOptions: PAGE_SIZE_OPTIONS.map(String),
- showTotal: (count) => `共 ${count} 个用户`,
+ showTotal: (count) => t('userMgmt.totalUsers', { count }),
onChange: (nextPage, nextPageSize) => {
if (nextPageSize !== pageSize) {
setPage(1);
@@ -627,7 +651,11 @@ const UserManagementPage = () => {
)}
<Drawer
- title={sessionDrawerUser ? `${sessionDrawerUser.username} 的会话` :
'用户会话'}
+ title={
+ sessionDrawerUser
+ ? t('userMgmt.sessionsOf', { username: sessionDrawerUser.username
})
+ : t('userMgmt.userSessions')
+ }
width={1040}
open={sessionDrawerUser !== null}
onClose={closeSessionDrawer}
@@ -640,13 +668,13 @@ const UserManagementPage = () => {
loading={sessionDetailsLoading}
onClick={() => void loadSessionDetails(sessionDrawerUser)}
>
- 刷新
+ {t('common.refresh')}
</Button>
<Popconfirm
- title={`注销 ${sessionDrawerUser.username} 的活跃会话?`}
- description="用户需要重新登录,账号状态不会改变。"
- okText="注销"
- cancelText="取消"
+ title={t('userMgmt.revokeConfirm', { username:
sessionDrawerUser.username })}
+ description={t('userMgmt.revokeDescription')}
+ okText={t('userMgmt.revoke')}
+ cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
disabled={sessionDetails.length === 0}
onConfirm={() => void revokeSessions(sessionDrawerUser)}
@@ -657,7 +685,7 @@ const UserManagementPage = () => {
disabled={sessionDetails.length === 0}
loading={mutatingUserIds.has(sessionDrawerUser.id)}
>
- 注销全部
+ {t('userMgmt.revokeAll')}
</Button>
</Popconfirm>
</Space>
@@ -671,35 +699,37 @@ const UserManagementPage = () => {
size="small"
column={2}
items={[
- { key: 'userId', label: '用户 ID', children:
sessionDrawerUser.id },
+ { key: 'userId', label: t('userMgmt.userId'), children:
sessionDrawerUser.id },
{
key: 'role',
- label: '权限',
- children: sessionDrawerUser.admin ? '管理员' : '普通用户',
+ label: t('userMgmt.role'),
+ children: sessionDrawerUser.admin
+ ? t('userMgmt.roleAdmin')
+ : t('userMgmt.roleUser'),
},
{
key: 'activeSessionCount',
- label: '活跃会话',
+ label: t('userMgmt.activeSessions'),
children: sessionDetailsLoading ? '-' :
sessionDetails.length,
},
{
key: 'status',
- label: '账号状态',
- children: sessionDrawerUser.enabled ? '已启用' : '已禁用',
+ label: t('userMgmt.accountStatus'),
+ children: sessionDrawerUser.enabled ? t('common.enabled') :
t('common.disabled'),
},
{
key: 'lastSessionSeenAt',
- label: '最近活跃',
+ label: t('userMgmt.lastActive'),
children: dateTime(sessionDrawerUser.lastSessionSeenAt),
},
{
key: 'nearestSessionExpiresAt',
- label: '最近过期',
+ label: t('userMgmt.nearestExpiry'),
children:
dateTime(sessionDrawerUser.nearestSessionExpiresAt),
},
{
key: 'passwordChangedAt',
- label: '密码修改时间',
+ label: t('userMgmt.passwordChangedAt'),
children: dateTime(sessionDrawerUser.passwordChangedAt),
},
]}
@@ -712,31 +742,38 @@ const UserManagementPage = () => {
tableLayout="fixed"
pagination={false}
scroll={{ x: tableScrollX(sessionDetailColumns), y: 420 }}
- locale={{ emptyText: '暂无活跃会话' }}
+ locale={{ emptyText: t('userMgmt.noActiveSessions') }}
/>
</Space>
)}
</Drawer>
<Modal
- title="新建 Studio 用户"
+ title={t('userMgmt.createTitle')}
open={createOpen}
onOk={() => void createUser()}
onCancel={() => setCreateOpen(false)}
>
<Form form={createForm} layout="vertical" initialValues={{ admin:
false }}>
- <Form.Item name="username" label="用户名" rules={[{ required: true }, {
max: 128 }]}>
+ <Form.Item
+ name="username"
+ label={t('userMgmt.username')}
+ rules={[{ required: true }, { max: 128 }]}
+ >
<Input autoComplete="username" />
</Form.Item>
<Form.Item
name="password"
- label="初始密码"
- rules={[{ required: true }, { min: 8, message: '密码至少 8 位' }]}
+ label={t('userMgmt.initialPassword')}
+ rules={[{ required: true }, { min: 8, message:
t('userMgmt.passwordMinLength') }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
- <Form.Item name="admin" label="管理员权限" valuePropName="checked">
- <Switch checkedChildren="管理员" unCheckedChildren="普通用户" />
+ <Form.Item name="admin" label={t('userMgmt.adminAccess')}
valuePropName="checked">
+ <Switch
+ checkedChildren={t('userMgmt.roleAdmin')}
+ unCheckedChildren={t('userMgmt.roleUser')}
+ />
</Form.Item>
</Form>
</Modal>
@@ -744,8 +781,8 @@ const UserManagementPage = () => {
<Modal
title={
passwordTarget?.id === userId
- ? '修改我的密码'
- : `重置 ${passwordTarget?.username ?? ''} 的密码`
+ ? t('userMgmt.changeMyPassword')
+ : t('userMgmt.resetPasswordOf', { username:
passwordTarget?.username ?? '' })
}
open={passwordTarget !== null}
onOk={() => void updatePassword()}
@@ -756,14 +793,18 @@ const UserManagementPage = () => {
>
<Form form={passwordForm} layout="vertical">
{passwordTarget?.id === userId && (
- <Form.Item name="currentPassword" label="当前密码" rules={[{ required:
true }]}>
+ <Form.Item
+ name="currentPassword"
+ label={t('userMgmt.currentPassword')}
+ rules={[{ required: true }]}
+ >
<Input.Password autoComplete="current-password" />
</Form.Item>
)}
<Form.Item
name="newPassword"
- label="新密码"
- rules={[{ required: true }, { min: 8, message: '密码至少 8 位' }]}
+ label={t('userMgmt.newPassword')}
+ rules={[{ required: true }, { min: 8, message:
t('userMgmt.passwordMinLength') }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 91643c1aa..87db6f59b 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -31,6 +31,8 @@ import {
type StudioUserSessionDetail,
} from '../../../api/studioUsers';
import { downloadCsv } from '../../../utils/download';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
import { formatUtcDateTime } from '../../../utils/format';
import UserManagementPage from '../UserManagement';
@@ -108,7 +110,9 @@ const renderPage = () =>
render(
<MemoryRouter>
<App>
- <UserManagementPage />
+ <LangProvider>
+ <UserManagementPage />
+ </LangProvider>
</App>
</MemoryRouter>,
);
@@ -396,4 +400,32 @@ describe('UserManagementPage', () => {
expect(setStudioUserEnabled).toHaveBeenCalledWith(7, false);
await act(async () => resolveUpdate());
});
+
+ it('renders the page in English when the stored language preference is en',
async () => {
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ try {
+ render(
+ <MemoryRouter>
+ <App>
+ <LangProvider>
+ <UserManagementPage />
+ </LangProvider>
+ </App>
+ </MemoryRouter>,
+ );
+
+ expect(await screen.findByText('User Management')).toBeInTheDocument();
+ expect(
+ screen.getByText('Studio local accounts, sessions and password
management'),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Create User'
})).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Change My Password'
})).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Search
username')).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'Username'
})).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'Active Sessions'
})).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Filter by role'
})).toBeInTheDocument();
+ } finally {
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+ }
+ });
});