- web/: React + Vite + TypeScript 前端 - backend/: Django + DRF + SimpleJWT 后端 - prototype/: HTML 设计原型 - docs/: PRD 和设计评审文档 - test: 单元测试 + E2E 极限测试
375 lines
15 KiB
TypeScript
375 lines
15 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import { adminApi } from '../lib/api';
|
||
import type { AdminUser, AdminUserDetail } from '../types';
|
||
import { showToast } from '../components/Toast';
|
||
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 [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);
|
||
|
||
// 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('');
|
||
|
||
const fetchUsers = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await adminApi.getUsers({
|
||
page, page_size: pageSize, search, status: statusFilter,
|
||
});
|
||
setUsers(data.results);
|
||
setTotal(data.total);
|
||
} catch {
|
||
showToast('加载用户列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [page, search, statusFilter]);
|
||
|
||
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
|
||
className={styles.statusSelect}
|
||
value={statusFilter}
|
||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||
>
|
||
<option value="">全部状态</option>
|
||
<option value="active">启用</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
<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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
Array.from({ length: 5 }).map((_, i) => (
|
||
<tr key={i}>
|
||
{Array.from({ length: 9 }).map((_, j) => (
|
||
<td key={j}><div className={styles.skeletonCell} /></td>
|
||
))}
|
||
</tr>
|
||
))
|
||
) : users.length === 0 ? (
|
||
<tr><td colSpan={9} 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.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}</td>
|
||
<td>{u.monthly_seconds_limit}</td>
|
||
<td>{u.seconds_today}</td>
|
||
<td>{u.seconds_this_month}</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={() => handleToggleStatus(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>
|
||
)}
|
||
|
||
{/* Quota Edit Modal */}
|
||
{editUser && (
|
||
<div className={styles.modalOverlay} onClick={() => setEditUser(null)}>
|
||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||
<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} onClick={() => setCreateOpen(false)}>
|
||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||
<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}s / {detailUser.daily_seconds_limit}s</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>月限额/本月消费</span>
|
||
<span className={styles.detailValue}>{detailUser.seconds_this_month}s / {detailUser.monthly_seconds_limit}s</span>
|
||
</div>
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>累计消费</span>
|
||
<span className={styles.detailValue}>{detailUser.seconds_total}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}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>
|
||
);
|
||
}
|