dashboard-nanobot/frontend/src/modules/platform/components/UserManagementPage.tsx

497 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, UserRound, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import { LucentDrawer } from '../../../components/lucent/LucentDrawer';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import type { BotState } from '../../../types/bot';
import type { SysRoleSummary, SysUserSummary } from '../../../types/sys';
import { normalizePlatformPageSize, readCachedPlatformPageSize, writeCachedPlatformPageSize } from '../../../utils/platformPageSize';
interface UserManagementPageProps {
isZh: boolean;
}
interface UserDraft {
username: string;
display_name: string;
password: string;
role_id: number;
is_active: boolean;
bot_ids: string[];
}
const emptyDraft: UserDraft = {
username: '',
display_name: '',
password: '',
role_id: 0,
is_active: true,
bot_ids: [],
};
function formatLastLogin(value: string | null | undefined, isZh: boolean) {
if (!value) return isZh ? '从未登录' : 'Never logged in';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString(isZh ? 'zh-CN' : 'en-US', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function UserManagementPage({ isZh }: UserManagementPageProps) {
const { notify, confirm } = useLucentPrompt();
const [items, setItems] = useState<SysUserSummary[]>([]);
const [roles, setRoles] = useState<SysRoleSummary[]>([]);
const [bots, setBots] = useState<BotState[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [togglingUserId, setTogglingUserId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() => readCachedPlatformPageSize(10));
const [editorOpen, setEditorOpen] = useState(false);
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [draft, setDraft] = useState<UserDraft>(emptyDraft);
const roleOptions = useMemo(
() => roles.filter((role) => role.is_active).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id),
[roles],
);
const loadAll = async () => {
setLoading(true);
try {
const [usersRes, rolesRes, botsRes, settingsRes] = await Promise.all([
axios.get<{ items: SysUserSummary[] }>(`${APP_ENDPOINTS.apiBase}/sys/users`),
axios.get<{ items: SysRoleSummary[] }>(`${APP_ENDPOINTS.apiBase}/sys/roles`),
axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`),
axios.get<{ page_size?: number }>(`${APP_ENDPOINTS.apiBase}/platform/settings`),
]);
setItems(Array.isArray(usersRes.data?.items) ? usersRes.data.items : []);
setRoles(Array.isArray(rolesRes.data?.items) ? rolesRes.data.items : []);
setBots(Array.isArray(botsRes.data) ? botsRes.data : []);
const normalized = normalizePlatformPageSize(settingsRes.data?.page_size, readCachedPlatformPageSize(10));
writeCachedPlatformPageSize(normalized);
setPageSize(normalized);
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '读取用户管理失败。' : 'Failed to load user management.'), { tone: 'error' });
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadAll();
}, []);
useEffect(() => {
setPage(1);
}, [search, pageSize, items.length]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) =>
[item.username, item.display_name, item.role?.name, item.role?.role_key].some((value) =>
String(value || '').toLowerCase().includes(keyword),
),
);
}, [items, search]);
const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pagedItems = useMemo(
() => filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize),
[currentPage, filteredItems, pageSize],
);
const openCreate = () => {
setEditingUserId(null);
setDraft({
...emptyDraft,
role_id: roleOptions[0]?.id || 0,
bot_ids: [],
});
setEditorOpen(true);
};
const openEdit = (item: SysUserSummary) => {
setEditingUserId(item.id);
setDraft({
username: item.username,
display_name: item.display_name || item.username,
password: '',
role_id: Number(item.role?.id || roleOptions[0]?.id || 0),
is_active: item.is_active !== false,
bot_ids: [...(item.bot_ids || [])],
});
setEditorOpen(true);
};
const handleSave = async () => {
const payload = {
display_name: draft.display_name.trim(),
password: draft.password,
role_id: Number(draft.role_id || 0),
is_active: draft.is_active,
bot_ids: [...draft.bot_ids],
};
if (!payload.display_name) {
notify(isZh ? '请填写显示名称。' : 'Display name is required.', { tone: 'warning' });
return;
}
if (!payload.role_id) {
notify(isZh ? '请选择角色。' : 'Select a role.', { tone: 'warning' });
return;
}
if (editingUserId == null) {
if (!draft.username.trim()) {
notify(isZh ? '请填写用户名。' : 'Username is required.', { tone: 'warning' });
return;
}
if (draft.password.trim().length < 6) {
notify(isZh ? '初始化密码至少 6 位。' : 'Initial password must be at least 6 characters.', { tone: 'warning' });
return;
}
}
setSaving(true);
try {
if (editingUserId == null) {
await axios.post(`${APP_ENDPOINTS.apiBase}/sys/users`, {
username: draft.username.trim().toLowerCase(),
...payload,
});
notify(isZh ? '用户已创建。' : 'User created.', { tone: 'success' });
} else {
await axios.put(`${APP_ENDPOINTS.apiBase}/sys/users/${editingUserId}`, payload);
notify(isZh ? '用户已更新。' : 'User updated.', { tone: 'success' });
}
setEditorOpen(false);
setDraft(emptyDraft);
setEditingUserId(null);
await loadAll();
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '保存用户失败。' : 'Failed to save user.'), { tone: 'error' });
} finally {
setSaving(false);
}
};
const handleToggleActive = async (item: SysUserSummary, checked: boolean) => {
const roleId = Number(item.role?.id || 0);
if (!roleId) {
notify(isZh ? '当前用户未绑定有效角色,无法修改启停状态。' : 'This user has no valid role assigned.', { tone: 'warning' });
return;
}
setTogglingUserId(item.id);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/sys/users/${item.id}`, {
display_name: String(item.display_name || item.username || '').trim() || item.username,
password: '',
role_id: roleId,
is_active: checked,
bot_ids: [...(item.bot_ids || [])],
});
setItems((prev) => prev.map((entry) => (entry.id === item.id ? { ...entry, is_active: checked } : entry)));
notify(checked ? (isZh ? '用户已启用。' : 'User enabled.') : (isZh ? '用户已停用。' : 'User disabled.'), { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '更新用户状态失败。' : 'Failed to update user status.'), { tone: 'error' });
await loadAll();
} finally {
setTogglingUserId(null);
}
};
const handleDelete = async (item: SysUserSummary) => {
const ok = await confirm({
title: isZh ? '删除用户' : 'Delete User',
message: isZh ? `确认删除用户 ${item.display_name || item.username}` : `Delete user ${item.display_name || item.username}?`,
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/sys/users/${item.id}`);
notify(isZh ? '用户已删除。' : 'User deleted.', { tone: 'success' });
await loadAll();
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '删除用户失败。' : 'Failed to delete user.'), { tone: 'error' });
}
};
return (
<div className="platform-page-stack">
<section className="panel stack skill-market-page-shell admin-management-shell">
<div className="skill-market-admin-toolbar">
<div className="ops-searchbar platform-searchbar skill-market-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索用户名、显示名或角色...' : 'Search username, display name, or role...'}
aria-label={isZh ? '搜索用户' : 'Search users'}
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => setSearch('')}
title={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索用户' : 'Search users')}
aria-label={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索用户' : 'Search users')}
>
{search.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
<div className="skill-market-admin-actions">
<button className="btn btn-secondary btn-sm" type="button" disabled={loading} onClick={() => void loadAll()}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
<span style={{ marginLeft: 6 }}>{isZh ? '刷新' : 'Refresh'}</span>
</button>
<button className="btn btn-primary btn-sm" type="button" onClick={openCreate}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{isZh ? '新增用户' : 'Add User'}</span>
</button>
</div>
</div>
<div className="admin-table-shell">
<table className="admin-table">
<thead>
<tr>
<th>{isZh ? '用户' : 'User'}</th>
<th>{isZh ? '角色' : 'Role'}</th>
<th>{isZh ? 'Bot 绑定' : 'Bot Binding'}</th>
<th>{isZh ? '最近登录' : 'Last Login'}</th>
<th>{isZh ? '状态' : 'Status'}</th>
<th>{isZh ? '操作' : 'Actions'}</th>
</tr>
</thead>
<tbody>
{pagedItems.length === 0 ? (
<tr>
<td className="admin-table-empty" colSpan={6}>
{filteredItems.length === 0
? (isZh ? '当前没有可展示的用户。' : 'No users available right now.')
: (isZh ? '当前页没有用户。' : 'No users on this page.')}
</td>
</tr>
) : (
pagedItems.map((item) => {
const busy = togglingUserId === item.id;
return (
<tr key={item.id}>
<td>
<div className="admin-table-name">
<strong>{item.display_name || item.username}</strong>
<span className="mono">{item.username}</span>
</div>
</td>
<td>
<div className="admin-table-secondary">
<strong>{item.role?.name || (isZh ? '未分配角色' : 'No role')}</strong>
<span className="mono">{item.role?.role_key || '-'}</span>
</div>
</td>
<td>
<div className="admin-table-secondary">
<strong>{isZh ? `已绑定 ${item.bot_ids?.length || 0}` : `${item.bot_ids?.length || 0} bound`}</strong>
<span className="mono">{(item.bot_ids || []).slice(0, 2).join(', ') || '-'}</span>
</div>
</td>
<td>{formatLastLogin(item.last_login_at, isZh)}</td>
<td>
<div className="admin-status-cell">
<label className="admin-switch" title={item.is_active ? (isZh ? '停用' : 'Disable') : (isZh ? '启用' : 'Enable')}>
<input
type="checkbox"
checked={item.is_active}
disabled={busy}
onChange={(event) => void handleToggleActive(item, event.target.checked)}
/>
<span className="admin-switch-track" />
</label>
<span>{item.is_active ? (isZh ? '启用' : 'Active') : (isZh ? '停用' : 'Disabled')}</span>
</div>
</td>
<td>
<div className="admin-table-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => openEdit(item)}
tooltip={isZh ? '编辑用户' : 'Edit user'}
aria-label={isZh ? '编辑用户' : 'Edit user'}
>
<Pencil size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => void handleDelete(item)}
tooltip={isZh ? '删除用户' : 'Delete user'}
aria-label={isZh ? '删除用户' : 'Delete user'}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="skill-market-pager">
<span className="pager-status">
{isZh ? `${currentPage} / ${pageCount} 页,共 ${filteredItems.length} 个用户` : `Page ${currentPage} / ${pageCount}, ${filteredItems.length} users`}
</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
tooltip={isZh ? '上一页' : 'Previous'}
aria-label={isZh ? '上一页' : 'Previous'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage >= pageCount}
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
tooltip={isZh ? '下一页' : 'Next'}
aria-label={isZh ? '下一页' : 'Next'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
</section>
<LucentDrawer
open={editorOpen}
size="default"
title={editingUserId == null ? (isZh ? '新增用户' : 'Add User') : (isZh ? '编辑用户' : 'Edit User')}
description={isZh ? '统一使用 Drawer 管理账号、角色和启停状态。' : 'Use the same Drawer interaction to manage account profile, role, and status.'}
onClose={() => setEditorOpen(false)}
bodyClassName="admin-management-drawer-body"
closeLabel={isZh ? '关闭面板' : 'Close panel'}
footer={(
<div className="row-between">
<div className="field-label">
{editingUserId == null
? (isZh ? '创建后可继续在列表中修改密码、角色和启停状态。' : 'After creation, password, role, and status can be updated from the list.')
: (isZh ? '留空密码表示保持原密码不变。' : 'Leave password empty to keep the existing password.')}
</div>
<button className="btn btn-primary" type="button" onClick={() => void handleSave()} disabled={saving}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <UserRound size={14} />}
<span style={{ marginLeft: 6 }}>{saving ? (isZh ? '保存中...' : 'Saving...') : (editingUserId == null ? (isZh ? '创建用户' : 'Create User') : (isZh ? '保存修改' : 'Save Changes'))}</span>
</button>
</div>
)}
>
<section className="platform-node-native-panel">
<div className="platform-node-native-panel-title">{isZh ? '账户信息' : 'Account Details'}</div>
<div className="platform-node-editor-grid">
<label className="field">
<span className="field-label">{isZh ? '用户名' : 'Username'}</span>
<input
className="input mono"
value={draft.username}
disabled={editingUserId != null}
onChange={(event) => setDraft((prev) => ({ ...prev, username: event.target.value.trim().toLowerCase() }))}
placeholder={isZh ? '例如 admin_ops' : 'For example: admin_ops'}
/>
</label>
<label className="field">
<span className="field-label">{isZh ? '显示名称' : 'Display Name'}</span>
<input
className="input"
value={draft.display_name}
onChange={(event) => setDraft((prev) => ({ ...prev, display_name: event.target.value }))}
placeholder={isZh ? '例如 运维管理员' : 'For example: Ops Admin'}
/>
</label>
<label className="field">
<span className="field-label">{editingUserId == null ? (isZh ? '初始化密码' : 'Initial Password') : (isZh ? '重置密码' : 'Reset Password')}</span>
<input
className="input"
type="password"
value={draft.password}
onChange={(event) => setDraft((prev) => ({ ...prev, password: event.target.value }))}
placeholder={editingUserId == null ? (isZh ? '至少 6 位' : 'At least 6 characters') : (isZh ? '留空则不修改' : 'Leave empty to keep unchanged')}
/>
</label>
<label className="field">
<span className="field-label">{isZh ? '所属角色' : 'Role'}</span>
<select
className="input"
value={draft.role_id || ''}
onChange={(event) => setDraft((prev) => ({ ...prev, role_id: Number(event.target.value || 0) }))}
>
<option value="">{isZh ? '请选择角色' : 'Select role'}</option>
{roleOptions.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</label>
<label className="field platform-node-editor-span-2">
<span className="field-label">{isZh ? 'Bot 绑定' : 'Bot Bindings'}</span>
<div className="admin-user-bot-grid">
{bots.length === 0 ? (
<div className="field-label">{isZh ? '暂无可绑定 Bot。' : 'No bots available for binding.'}</div>
) : bots.map((bot) => {
const checked = draft.bot_ids.includes(bot.id);
return (
<label key={bot.id} className={`admin-user-bot-option ${checked ? 'is-active' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
setDraft((prev) => ({
...prev,
bot_ids: event.target.checked
? Array.from(new Set([...prev.bot_ids, bot.id]))
: prev.bot_ids.filter((item) => item !== bot.id),
}));
}}
/>
<span className="admin-user-bot-option-copy">
<strong>{bot.name || bot.id}</strong>
<small className="mono">{bot.id}</small>
</span>
</label>
);
})}
</div>
</label>
<label className="field platform-node-editor-span-2">
<span className="field-label">{isZh ? '启停状态' : 'Status'}</span>
<div className="admin-switch-row">
<label className="admin-switch" title={draft.is_active ? (isZh ? '停用' : 'Disable') : (isZh ? '启用' : 'Enable')}>
<input
type="checkbox"
checked={draft.is_active}
onChange={(event) => setDraft((prev) => ({ ...prev, is_active: event.target.checked }))}
/>
<span className="admin-switch-track" />
</label>
<span>{draft.is_active ? (isZh ? '启用' : 'Active') : (isZh ? '停用' : 'Disabled')}</span>
</div>
</label>
</div>
</section>
</LucentDrawer>
</div>
);
}