video-shuoshan/web/src/pages/UsersPage.tsx
seaislee1209 85f76d8543 feat: v0.8.2~v0.8.4 — 管理后台 UI 修复 + 团队详情重构 + 审计日志系统
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>
2026-03-16 01:18:44 +08:00

409 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)}>&lt;</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)}>&gt;</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>
);
}