video-shuoshan/web/src/pages/TeamsPage.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

463 lines
20 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 { Team, TeamDetail } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import styles from './TeamsPage.module.css';
function fmtSec(s: number): string {
return Math.round(s).toLocaleString() + 's';
}
export function TeamsPage() {
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
// Create team modal
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
const [createError, setCreateError] = useState('');
// Top-up modal
const [topupTeam, setTopupTeam] = useState<Team | null>(null);
const [topupSeconds, setTopupSeconds] = useState('3600');
// Create admin modal
const [adminTeam, setAdminTeam] = useState<Team | null>(null);
const [adminUsername, setAdminUsername] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [adminError, setAdminError] = useState('');
// Team detail drawer
const [detailTeam, setDetailTeam] = useState<TeamDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// Edit pool modal
const [editPoolOpen, setEditPoolOpen] = useState(false);
const [editPoolValue, setEditPoolValue] = useState('');
const [editPoolError, setEditPoolError] = useState('');
// Confirm toggle
const [confirmTeam, setConfirmTeam] = useState<Team | null>(null);
const fetchTeams = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getTeams();
setTeams(data.results);
} catch {
showToast('加载团队列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTeams(); }, [fetchTeams]);
const handleToggleStatus = async (team: Team) => {
try {
await adminApi.updateTeam(team.id, { is_active: !team.is_active });
showToast(team.is_active ? '已禁用团队' : '已启用团队');
fetchTeams();
} catch {
showToast('操作失败');
}
};
const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
setCreateError('');
};
const handleCreateTeam = async () => {
setCreateError('');
if (!newName.trim()) { setCreateError('请输入团队名称'); return; }
try {
await adminApi.createTeam({
name: newName.trim(),
monthly_seconds_limit: Number(newMonthlyLimit),
daily_member_limit_default: Number(newDailyMemberLimit),
});
showToast('团队创建成功');
setCreateOpen(false);
resetCreateForm();
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.name?.[0] || '创建失败';
setCreateError(msg);
}
};
const handleTopUp = async () => {
if (!topupTeam) return;
const seconds = Number(topupSeconds);
if (!seconds || seconds <= 0) { showToast('请输入有效的秒数'); return; }
try {
await adminApi.topUpTeam(topupTeam.id, seconds);
showToast(`已为 ${topupTeam.name} 充值 ${fmtSec(seconds)}`);
setTopupTeam(null);
fetchTeams();
} catch {
showToast('充值失败');
}
};
const handleSetPool = async () => {
if (!detailTeam) return;
const newPool = Number(editPoolValue);
if (isNaN(newPool) || newPool < 0) { setEditPoolError('请输入有效的非负数'); return; }
try {
await adminApi.setTeamPool(detailTeam.id, newPool);
showToast(`已将 ${detailTeam.name} 总秒数池修改为 ${fmtSec(newPool)}`);
setEditPoolOpen(false);
// Refresh detail
const { data } = await adminApi.getTeamDetail(detailTeam.id);
setDetailTeam(data);
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || '修改失败';
setEditPoolError(msg);
}
};
const resetAdminForm = () => {
setAdminUsername(''); setAdminEmail(''); setAdminPassword('');
setAdminError('');
};
const handleCreateAdmin = async () => {
if (!adminTeam) return;
setAdminError('');
if (!adminUsername.trim()) { setAdminError('请输入用户名'); return; }
if (!adminEmail.trim()) { setAdminError('请输入邮箱'); return; }
if (adminPassword.length < 6) { setAdminError('密码至少6位'); return; }
try {
await adminApi.createTeamAdmin(adminTeam.id, {
username: adminUsername.trim(),
email: adminEmail.trim(),
password: adminPassword,
});
showToast('团队管理员创建成功');
setAdminTeam(null);
resetAdminForm();
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.username?.[0] || '创建失败';
setAdminError(msg);
}
};
const openDrawer = async (teamId: number) => {
try {
const { data } = await adminApi.getTeamDetail(teamId);
setDetailTeam(data);
setDrawerOpen(true);
} catch {
showToast('加载团队详情失败');
}
};
const colCount = 9;
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<div className={styles.searchGroup}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}> {teams.length} </span>
</div>
<div className={styles.searchGroup}>
<button className={styles.refreshBtn} onClick={fetchTeams}></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: colCount }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : teams.length === 0 ? (
<tr><td colSpan={colCount} className={styles.empty}></td></tr>
) : (
teams.map((t) => (
<tr key={t.id}>
<td>
<button className={styles.teamNameLink} onClick={() => openDrawer(t.id)}>
{t.name}
</button>
</td>
<td>{fmtSec(t.total_seconds_pool)}</td>
<td>{fmtSec(t.total_seconds_used)}</td>
<td>{fmtSec(t.remaining_seconds)}</td>
<td>{fmtSec(t.monthly_seconds_limit)}</td>
<td>{fmtSec(t.monthly_seconds_used)}</td>
<td>{t.member_count}</td>
<td>
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
{t.is_active ? '启用' : '禁用'}
</span>
</td>
<td>
<div className={styles.actions}>
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupSeconds('3600'); }}></button>
<button className={styles.adminBtn} onClick={() => { setAdminTeam(t); resetAdminForm(); }}></button>
<button
className={`${styles.toggleBtn} ${t.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmTeam(t)}
>
{t.is_active ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Create Team 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={newName} onChange={(e) => setNewName(e.target.value)} placeholder="请输入团队名称" />
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={newMonthlyLimit} onChange={(e) => setNewMonthlyLimit(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>()</label>
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
</div>
</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={handleCreateTeam}></button>
</div>
</div>
</div>
)}
{/* Top-up Modal */}
{topupTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setTopupTeam(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {topupTeam.name}</h3>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={topupSeconds} onChange={(e) => setTopupSeconds(e.target.value)} placeholder="输入秒数" />
<div className={styles.formHint}>
: {fmtSec(topupTeam.remaining_seconds)} | : {fmtSec(topupTeam.remaining_seconds + (Number(topupSeconds) || 0))}
</div>
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setTopupTeam(null)}></button>
<button className={styles.saveBtn} onClick={handleTopUp}></button>
</div>
</div>
</div>
)}
{/* Create Team Admin Modal */}
{adminTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setAdminTeam(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {adminTeam.name}</h3>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={adminUsername} onChange={(e) => setAdminUsername(e.target.value)} placeholder="请输入用户名" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} placeholder="请输入邮箱" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} placeholder="至少6位" />
</div>
{adminError && <div className={styles.formError}>{adminError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setAdminTeam(null)}></button>
<button className={styles.saveBtn} onClick={handleCreateAdmin}></button>
</div>
</div>
</div>
)}
<ConfirmModal
open={!!confirmTeam}
title={confirmTeam?.is_active ? '禁用团队' : '启用团队'}
message={confirmTeam?.is_active
? `确定要禁用团队「${confirmTeam?.name}」吗?禁用后该团队所有成员将无法生成视频。`
: `确定要启用团队「${confirmTeam?.name}」吗?`}
confirmText={confirmTeam?.is_active ? '禁用' : '启用'}
danger={confirmTeam?.is_active}
onConfirm={() => { if (confirmTeam) { handleToggleStatus(confirmTeam); setConfirmTeam(null); } }}
onCancel={() => setConfirmTeam(null)}
/>
{/* Team Detail Modal */}
{drawerOpen && detailTeam && (
<div className={styles.detailOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setDrawerOpen(false); }}>
<div className={styles.detailModal}>
<div className={styles.detailHeader}>
<h3>
{detailTeam.name}
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`}>
{detailTeam.is_active ? '启用' : '禁用'}
</span>
</h3>
<button className={styles.detailClose} onClick={() => setDrawerOpen(false)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className={styles.detailBody}>
<div className={styles.detailGrid}>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>
{fmtSec(detailTeam.total_seconds_pool)}
<button
className={styles.editPoolBtn}
onClick={() => { setEditPoolValue(String(detailTeam.total_seconds_pool)); setEditPoolError(''); setEditPoolOpen(true); }}
title="修改秒数"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_used)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.remaining_seconds)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_limit)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_used)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{detailTeam.member_count}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>()</span>
<span className={styles.detailValue}>{fmtSec(detailTeam.daily_member_limit_default)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>
</div>
</div>
<h4 className={styles.membersTitle}> ({detailTeam.members.length})</h4>
{detailTeam.members.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.memberTableWrapper}>
<table className={styles.memberTable}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{detailTeam.members.map((m) => (
<tr key={m.id}>
<td>{m.username}</td>
<td>{m.email}</td>
<td>
{m.is_team_admin ? (
<span className={styles.adminBadge}></span>
) : '成员'}
</td>
<td>
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
{m.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{fmtSec(m.daily_seconds_limit)}</td>
<td>{fmtSec(m.seconds_today)}</td>
<td>{fmtSec(m.seconds_this_month)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
{/* Edit Pool Modal */}
{editPoolOpen && detailTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {detailTeam.name}</h3>
{editPoolError && <div className={styles.formError}>{editPoolError}</div>}
<div className={styles.formGroup}>
<label></label>
<input type="number" value={editPoolValue} onChange={(e) => setEditPoolValue(e.target.value)} placeholder="输入总秒数" />
<div className={styles.formHint}>
: {fmtSec(detailTeam.total_seconds_pool)} | : {fmtSec(detailTeam.total_seconds_used)} | : {fmtSec(Math.max(0, (Number(editPoolValue) || 0) - detailTeam.total_seconds_used))}
</div>
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditPoolOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleSetPool}></button>
</div>
</div>
</div>
)}
</div>
);
}