video-shuoshan/web/src/pages/TeamMembersPage.tsx
seaislee1209 5bb49b5940
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
feat: v0.10.3 用户在线状态 + logout 会话清理
- 用户管理/团队详情/内容资产页用户名前显示在线状态(绿点/灰点)
- 基于 ActiveSession 表判断在线状态(Exists 子查询)
- 新增 POST /auth/logout 接口,退出时清除 ActiveSession
- 前端退出登录时先用 fetch 发 logout 请求再清 token,确保会话被正确删除

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:30:30 +08:00

250 lines
9.9 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 { teamApi } from '../lib/api';
import type { TeamMember } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import styles from './UsersPage.module.css';
export function TeamMembersPage() {
const [members, setMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
// Create member modal
const [createOpen, setCreateOpen] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newDaily, setNewDaily] = useState('50');
const [newMonthly, setNewMonthly] = useState('500');
const [createError, setCreateError] = useState('');
// Confirm toggle
const [confirmMember, setConfirmMember] = useState<TeamMember | null>(null);
// Edit quota modal
const [editMember, setEditMember] = useState<TeamMember | null>(null);
const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState('');
const fetchMembers = useCallback(async () => {
setLoading(true);
try {
const { data } = await teamApi.getMembers();
setMembers(data.results);
} catch {
showToast('加载成员列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
const formatLimit = (v: number) => v === -1 ? '不限' : v + '次';
const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2);
const handleToggleStatus = async (member: TeamMember) => {
try {
await teamApi.updateMemberStatus(member.id, !member.is_active);
showToast(member.is_active ? '已禁用成员' : '已启用成员');
fetchMembers();
} catch {
showToast('操作失败');
}
};
const openEditModal = (member: TeamMember) => {
setEditMember(member);
setEditDaily(String(member.daily_generation_limit ?? 50));
setEditMonthly(String(member.monthly_generation_limit ?? 500));
};
const handleSaveQuota = async () => {
if (!editMember) return;
try {
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly));
showToast('配额已更新');
setEditMember(null);
fetchMembers();
} catch {
showToast('更新失败');
}
};
const resetCreateForm = () => {
setNewUsername(''); setNewPassword('');
setNewDaily('50'); setNewMonthly('500');
setCreateError('');
};
const handleCreateMember = async () => {
setCreateError('');
if (!newUsername.trim()) { setCreateError('请输入用户名'); return; }
if (newPassword.length < 6) { setCreateError('密码至少6位'); return; }
try {
await teamApi.createMember({
username: newUsername.trim(),
password: newPassword,
daily_generation_limit: Number(newDaily),
monthly_generation_limit: Number(newMonthly),
});
showToast('成员创建成功');
setCreateOpen(false);
resetCreateForm();
fetchMembers();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.username?.[0] || '创建失败';
setCreateError(msg);
}
};
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<div className={styles.searchGroup}>
<button className={styles.refreshBtn} onClick={fetchMembers}></button>
</div>
<div className={styles.searchGroup}>
<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>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : members.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
members.map((m) => (
<tr key={m.id}>
<td>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: m.is_online ? '#00b894' : '#555', marginRight: 6,
verticalAlign: 'middle',
}} />
{m.username}
</td>
<td>
{m.is_team_admin ? (
<span className={styles.statusBadge} style={{ background: 'rgba(108, 99, 255, 0.15)', color: '#6c63ff' }}></span>
) : (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
)}
</td>
<td>
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
{m.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{formatLimit(m.daily_generation_limit)}</td>
<td>{formatLimit(m.monthly_generation_limit)}</td>
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
<td>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(m)}></button>
<button
className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmMember(m)}
>
{m.is_active ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<ConfirmModal
open={!!confirmMember}
title={confirmMember?.is_active ? '禁用成员' : '启用成员'}
message={confirmMember?.is_active
? `确定要禁用成员「${confirmMember?.username}」吗?`
: `确定要启用成员「${confirmMember?.username}」吗?`}
confirmText={confirmMember?.is_active ? '禁用' : '启用'}
danger={confirmMember?.is_active}
onConfirm={() => { if (confirmMember) { handleToggleStatus(confirmMember); setConfirmMember(null); } }}
onCancel={() => setConfirmMember(null)}
/>
{/* Edit Quota Modal */}
{editMember && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditMember(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editMember.username}</h3>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button>
</div>
</div>
</div>
)}
{/* Create Member 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="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>
{createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleCreateMember}></button>
</div>
</div>
</div>
)}
</div>
);
}