v0.8.2: DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip v0.8.3: 团队详情抽屉→弹窗重构 + 修改秒数池功能 + member_count 修复 v0.8.4: AdminAuditLog 模型 + 12 处管理操作埋点 + 日志查询页面(/admin/logs) 审计日志覆盖所有管理员 mutation 操作(充值、修改额度、创建/禁用用户等), 记录操作人、变更前后值、IP 地址,支持按操作类型/操作人/日期筛选。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
409 lines
17 KiB
TypeScript
409 lines
17 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import { adminApi } from '../lib/api';
|
||
import type { AdminUser, AdminUserDetail, Team } from '../types';
|
||
import { showToast } from '../components/Toast';
|
||
import { ConfirmModal } from '../components/ConfirmModal';
|
||
import { Select } from '../components/Select';
|
||
import styles from './UsersPage.module.css';
|
||
|
||
export function UsersPage() {
|
||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [search, setSearch] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [teamFilter, setTeamFilter] = useState('');
|
||
const [teams, setTeams] = useState<Team[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const pageSize = 20;
|
||
|
||
// Quota edit modal
|
||
const [editUser, setEditUser] = useState<AdminUser | null>(null);
|
||
const [editDaily, setEditDaily] = useState('');
|
||
const [editMonthly, setEditMonthly] = useState('');
|
||
|
||
// User detail drawer
|
||
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
|
||
// Confirm toggle
|
||
const [confirmUser, setConfirmUser] = useState<AdminUser | null>(null);
|
||
|
||
// Create user modal
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [newUsername, setNewUsername] = useState('');
|
||
const [newEmail, setNewEmail] = useState('');
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [newDaily, setNewDaily] = useState('600');
|
||
const [newMonthly, setNewMonthly] = useState('6000');
|
||
const [newIsStaff, setNewIsStaff] = useState(false);
|
||
const [createError, setCreateError] = useState('');
|
||
|
||
// Load teams for filter dropdown
|
||
useEffect(() => {
|
||
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
|
||
}, []);
|
||
|
||
const fetchUsers = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await adminApi.getUsers({
|
||
page, page_size: pageSize, search, status: statusFilter,
|
||
team_id: teamFilter ? Number(teamFilter) : undefined,
|
||
});
|
||
setUsers(data.results);
|
||
setTotal(data.total);
|
||
} catch {
|
||
showToast('加载用户列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [page, search, statusFilter, teamFilter]);
|
||
|
||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||
|
||
const handleSearch = () => {
|
||
setPage(1);
|
||
fetchUsers();
|
||
};
|
||
|
||
const handleToggleStatus = async (user: AdminUser) => {
|
||
try {
|
||
await adminApi.updateUserStatus(user.id, !user.is_active);
|
||
showToast(user.is_active ? '已禁用用户' : '已启用用户');
|
||
fetchUsers();
|
||
} catch {
|
||
showToast('操作失败');
|
||
}
|
||
};
|
||
|
||
const openEditModal = (user: AdminUser) => {
|
||
setEditUser(user);
|
||
setEditDaily(String(user.daily_seconds_limit));
|
||
setEditMonthly(String(user.monthly_seconds_limit));
|
||
};
|
||
|
||
const handleSaveQuota = async () => {
|
||
if (!editUser) return;
|
||
try {
|
||
await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly));
|
||
showToast('配额已更新');
|
||
setEditUser(null);
|
||
fetchUsers();
|
||
} catch {
|
||
showToast('更新失败');
|
||
}
|
||
};
|
||
|
||
const openDrawer = async (userId: number) => {
|
||
try {
|
||
const { data } = await adminApi.getUserDetail(userId);
|
||
setDetailUser(data);
|
||
setDrawerOpen(true);
|
||
} catch {
|
||
showToast('加载用户详情失败');
|
||
}
|
||
};
|
||
|
||
const resetCreateForm = () => {
|
||
setNewUsername(''); setNewEmail(''); setNewPassword('');
|
||
setNewDaily('600'); setNewMonthly('6000'); setNewIsStaff(false);
|
||
setCreateError('');
|
||
};
|
||
|
||
const handleCreateUser = async () => {
|
||
setCreateError('');
|
||
if (!newUsername.trim()) { setCreateError('请输入用户名'); return; }
|
||
if (!newEmail.trim()) { setCreateError('请输入邮箱'); return; }
|
||
if (newPassword.length < 6) { setCreateError('密码至少6位'); return; }
|
||
try {
|
||
await adminApi.createUser({
|
||
username: newUsername.trim(),
|
||
email: newEmail.trim(),
|
||
password: newPassword,
|
||
daily_seconds_limit: Number(newDaily),
|
||
monthly_seconds_limit: Number(newMonthly),
|
||
is_staff: newIsStaff,
|
||
});
|
||
showToast('用户创建成功');
|
||
setCreateOpen(false);
|
||
resetCreateForm();
|
||
fetchUsers();
|
||
} catch (err: any) {
|
||
const msg = err.response?.data?.error || err.response?.data?.username?.[0] || '创建失败';
|
||
setCreateError(msg);
|
||
}
|
||
};
|
||
|
||
const totalPages = Math.ceil(total / pageSize);
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<h1 className={styles.title}>用户管理</h1>
|
||
|
||
<div className={styles.filters}>
|
||
<div className={styles.searchGroup}>
|
||
<input
|
||
type="text"
|
||
className={styles.searchInput}
|
||
placeholder="搜索用户名/邮箱..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
/>
|
||
<Select
|
||
value={statusFilter}
|
||
onChange={(v) => { setStatusFilter(v); setPage(1); }}
|
||
placeholder="全部状态"
|
||
options={[
|
||
{ label: '全部状态', value: '' },
|
||
{ label: '启用', value: 'active' },
|
||
{ label: '禁用', value: 'disabled' },
|
||
]}
|
||
/>
|
||
<Select
|
||
value={teamFilter}
|
||
onChange={(v) => { setTeamFilter(v); setPage(1); }}
|
||
placeholder="全部团队"
|
||
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
|
||
/>
|
||
<button className={styles.searchBtn} onClick={handleSearch}>查询</button>
|
||
</div>
|
||
<div className={styles.searchGroup}>
|
||
<button className={styles.refreshBtn} onClick={fetchUsers}>刷新</button>
|
||
<button className={styles.createBtn} onClick={() => { resetCreateForm(); setCreateOpen(true); }}>+ 新增用户</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.tableWrapper}>
|
||
<table className={styles.table}>
|
||
<thead>
|
||
<tr>
|
||
<th>用户名</th>
|
||
<th>团队</th>
|
||
<th>邮箱</th>
|
||
<th>注册时间</th>
|
||
<th>状态</th>
|
||
<th>日限额(秒)</th>
|
||
<th>月限额(秒)</th>
|
||
<th>今日消费(秒)</th>
|
||
<th>本月消费(秒)</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
Array.from({ length: 5 }).map((_, i) => (
|
||
<tr key={i}>
|
||
{Array.from({ length: 10 }).map((_, j) => (
|
||
<td key={j}><div className={styles.skeletonCell} /></td>
|
||
))}
|
||
</tr>
|
||
))
|
||
) : users.length === 0 ? (
|
||
<tr><td colSpan={10} className={styles.empty}>暂无数据</td></tr>
|
||
) : (
|
||
users.map((u) => (
|
||
<tr key={u.id}>
|
||
<td>
|
||
<button className={styles.usernameLink} onClick={() => openDrawer(u.id)}>
|
||
{u.username}
|
||
</button>
|
||
</td>
|
||
<td>{u.team_name || '-'}</td>
|
||
<td>{u.email}</td>
|
||
<td>{new Date(u.date_joined).toLocaleDateString('zh-CN')}</td>
|
||
<td>
|
||
<span className={`${styles.statusBadge} ${u.is_active ? styles.active : styles.disabled}`}>
|
||
{u.is_active ? '启用' : '禁用'}
|
||
</span>
|
||
</td>
|
||
<td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
|
||
<td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
|
||
<td>{u.seconds_today.toLocaleString()}s</td>
|
||
<td>{u.seconds_this_month.toLocaleString()}s</td>
|
||
<td>
|
||
<div className={styles.actions}>
|
||
<button className={styles.editBtn} onClick={() => openEditModal(u)}>编辑</button>
|
||
<button
|
||
className={`${styles.toggleBtn} ${u.is_active ? styles.disableBtn : styles.enableBtn}`}
|
||
onClick={() => setConfirmUser(u)}
|
||
>
|
||
{u.is_active ? '禁用' : '启用'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{totalPages > 1 && (
|
||
<div className={styles.pagination}>
|
||
<span className={styles.pageInfo}>共 {total} 条</span>
|
||
<div className={styles.pageButtons}>
|
||
<button disabled={page <= 1} onClick={() => setPage(page - 1)}><</button>
|
||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||
let p: number;
|
||
if (totalPages <= 5) p = i + 1;
|
||
else if (page <= 3) p = i + 1;
|
||
else if (page >= totalPages - 2) p = totalPages - 4 + i;
|
||
else p = page - 2 + i;
|
||
return (
|
||
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
|
||
{p}
|
||
</button>
|
||
);
|
||
})}
|
||
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmModal
|
||
open={!!confirmUser}
|
||
title={confirmUser?.is_active ? '禁用用户' : '启用用户'}
|
||
message={confirmUser?.is_active
|
||
? `确定要禁用用户「${confirmUser?.username}」吗?`
|
||
: `确定要启用用户「${confirmUser?.username}」吗?`}
|
||
confirmText={confirmUser?.is_active ? '禁用' : '启用'}
|
||
danger={confirmUser?.is_active}
|
||
onConfirm={() => { if (confirmUser) { handleToggleStatus(confirmUser); setConfirmUser(null); } }}
|
||
onCancel={() => setConfirmUser(null)}
|
||
/>
|
||
|
||
{/* Quota Edit Modal */}
|
||
{editUser && (
|
||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditUser(null); }}>
|
||
<div className={styles.modal}>
|
||
<h3 className={styles.modalTitle}>编辑配额 — {editUser.username}</h3>
|
||
<div className={styles.formGroup}>
|
||
<label>每日秒数限额</label>
|
||
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
|
||
</div>
|
||
<div className={styles.formGroup}>
|
||
<label>每月秒数限额</label>
|
||
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
||
</div>
|
||
<div className={styles.modalActions}>
|
||
<button className={styles.cancelBtn} onClick={() => setEditUser(null)}>取消</button>
|
||
<button className={styles.saveBtn} onClick={handleSaveQuota}>保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create User Modal */}
|
||
{createOpen && (
|
||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setCreateOpen(false); }}>
|
||
<div className={styles.modal}>
|
||
<h3 className={styles.modalTitle}>新增用户</h3>
|
||
<div className={styles.formGroup}>
|
||
<label>用户名</label>
|
||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="请输入用户名" />
|
||
</div>
|
||
<div className={styles.formGroup}>
|
||
<label>邮箱</label>
|
||
<input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="请输入邮箱" />
|
||
</div>
|
||
<div className={styles.formGroup}>
|
||
<label>密码</label>
|
||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="至少6位" />
|
||
</div>
|
||
<div className={styles.formRow}>
|
||
<div className={styles.formGroup}>
|
||
<label>每日秒数限额</label>
|
||
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
|
||
</div>
|
||
<div className={styles.formGroup}>
|
||
<label>每月秒数限额</label>
|
||
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className={styles.formGroup}>
|
||
<label className={styles.checkboxLabel}>
|
||
<input type="checkbox" checked={newIsStaff} onChange={(e) => setNewIsStaff(e.target.checked)} />
|
||
设为管理员
|
||
</label>
|
||
</div>
|
||
{createError && <div className={styles.formError}>{createError}</div>}
|
||
<div className={styles.modalActions}>
|
||
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}>取消</button>
|
||
<button className={styles.saveBtn} onClick={handleCreateUser}>创建</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* User Detail Drawer */}
|
||
{drawerOpen && detailUser && (
|
||
<div className={styles.drawerOverlay} onClick={() => setDrawerOpen(false)}>
|
||
<div className={styles.drawer} onClick={(e) => e.stopPropagation()}>
|
||
<div className={styles.drawerHeader}>
|
||
<h3>用户详情</h3>
|
||
<button className={styles.drawerClose} onClick={() => setDrawerOpen(false)}>×</button>
|
||
</div>
|
||
<div className={styles.drawerBody}>
|
||
<div className={styles.detailGrid}>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>用户名</span>
|
||
<span className={styles.detailValue}>{detailUser.username}</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>邮箱</span>
|
||
<span className={styles.detailValue}>{detailUser.email}</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>状态</span>
|
||
<span className={`${styles.statusBadge} ${detailUser.is_active ? styles.active : styles.disabled}`}>
|
||
{detailUser.is_active ? '启用' : '禁用'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>注册时间</span>
|
||
<span className={styles.detailValue}>{new Date(detailUser.date_joined).toLocaleString('zh-CN')}</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>日限额/今日消费</span>
|
||
<span className={styles.detailValue}>{detailUser.seconds_today.toLocaleString()}s / {detailUser.daily_seconds_limit === -1 ? '不限' : detailUser.daily_seconds_limit.toLocaleString() + 's'}</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>月限额/本月消费</span>
|
||
<span className={styles.detailValue}>{detailUser.seconds_this_month.toLocaleString()}s / {detailUser.monthly_seconds_limit === -1 ? '不限' : detailUser.monthly_seconds_limit.toLocaleString() + 's'}</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>累计消费</span>
|
||
<span className={styles.detailValue}>{detailUser.seconds_total.toLocaleString()}s</span>
|
||
</div>
|
||
</div>
|
||
|
||
<h4 className={styles.recordsTitle}>近期消费记录</h4>
|
||
<div className={styles.recordsList}>
|
||
{detailUser.recent_records.length === 0 ? (
|
||
<div className={styles.empty}>暂无记录</div>
|
||
) : (
|
||
detailUser.recent_records.map((r) => (
|
||
<div key={r.id} className={styles.recordItem}>
|
||
<div className={styles.recordTime}>{new Date(r.created_at).toLocaleString('zh-CN')}</div>
|
||
<div className={styles.recordMeta}>
|
||
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
|
||
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
|
||
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{
|
||
{ queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]
|
||
}</span>
|
||
</div>
|
||
{r.prompt && <div className={styles.recordPrompt}>{r.prompt.slice(0, 60)}{r.prompt.length > 60 ? '...' : ''}</div>}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|