video-shuoshan/web/src/pages/UsersPage.tsx
zyc ffe92f7b15 Initial commit: 即梦视频生成平台
- web/: React + Vite + TypeScript 前端
- backend/: Django + DRF + SimpleJWT 后端
- prototype/: HTML 设计原型
- docs/: PRD 和设计评审文档
- test: 单元测试 + E2E 极限测试
2026-03-13 09:59:33 +08:00

375 lines
15 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 } 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)}>&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>
)}
{/* 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>
);
}