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

497 lines
22 KiB
TypeScript
Raw Normal View History

2026-03-26 16:12:46 +00:00
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>
);
}