## 计费体系 - 团队额度从秒数改为金额(余额/冻结/月消费上限) - 用户限额从秒数改为次数(每日50次/每月1500次) - 新增 billing.py 工具模块(分辨率→像素映射 + token/费用计算) - 扣费流程:预扣制→冻结制(提交冻结预估金额,完成按实际tokens扣费,失败释放) - 允许小额透支(实际费用超预估时余额可变负) - 团队加价比例(markup_percentage),创建团队时必填 ## Token 追踪 - GenerationRecord 新增 tokens_consumed/cost_amount/base_cost_amount - 任务完成时从 Seedance API usage.total_tokens 获取精确值 - 生成页显示预估消耗(tokens + 金额),按团队售价计算 ## 管理后台 - 仪表盘新增利润分析板块(总收入/成本/利润/利润率 + 团队利润排行) - 消费记录新增 Tokens/售价/成本/利润列 - 团队管理:充值改为充金额,新增加价比例设置 - 系统设置:默认限额改为次数,新增基础token单价配置 ## Bug 修复 - 登录弹窗:拖选输入框内容不再误关闭(onClick→mousedown+mouseup) - 视频详情弹窗:遮罩层覆盖全视口(left:76px→0),admin/团管侧栏不再露出 ## UI 增强 - 图片大图预览:上传区和视频详情弹窗的图片支持点击查看大图(ImageLightbox) - 移除 adaptive 比例和智能时长选项,确保 token 预估可精确计算 - 视频详情弹窗显示实际消耗 tokens 和费用 ## 前端全量更新 - 所有页面秒数显示替换为金额(元)和次数(次) - TypeScript 类型全量更新 - API 调用参数同步更新 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
9.6 KiB
TypeScript
243 lines
9.6 KiB
TypeScript
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>{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>
|
||
);
|
||
}
|