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>
463 lines
20 KiB
TypeScript
463 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|