## 计费体系 - 团队额度从秒数改为金额(余额/冻结/月消费上限) - 用户限额从秒数改为次数(每日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>
172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||
import * as echarts from 'echarts/core';
|
||
import { LineChart, BarChart } from 'echarts/charts';
|
||
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components';
|
||
import { CanvasRenderer } from 'echarts/renderers';
|
||
import { teamApi } from '../lib/api';
|
||
import type { TeamInfo, TeamStats } from '../types';
|
||
import { showToast } from '../components/Toast';
|
||
import styles from './DashboardPage.module.css';
|
||
|
||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
|
||
|
||
export function TeamDashboardPage() {
|
||
const [info, setInfo] = useState<(TeamInfo & { daily_member_limit_default: number; member_count: number }) | null>(null);
|
||
const [stats, setStats] = useState<TeamStats | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
const [infoRes, statsRes] = await Promise.all([
|
||
teamApi.getInfo(),
|
||
teamApi.getStats(),
|
||
]);
|
||
setInfo(infoRes.data);
|
||
setStats(statsRes.data);
|
||
} catch {
|
||
showToast('加载团队数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { fetchData(); }, [fetchData]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={styles.page}>
|
||
<div className={styles.skeleton}>
|
||
<div className={styles.skeletonCards}>
|
||
{[1, 2, 3, 4, 5].map((i) => <div key={i} className={styles.skeletonCard} />)}
|
||
</div>
|
||
<div className={styles.skeletonChart} />
|
||
<div className={styles.skeletonChart} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!info || !stats) return null;
|
||
|
||
const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2);
|
||
|
||
const statCards = [
|
||
{ label: '余额', value: fmtMoney(info.balance) },
|
||
{ label: '累计消费', value: fmtMoney(info.total_spent) },
|
||
{ label: '可用余额', value: fmtMoney(info.available_balance) },
|
||
{ label: '月消费限额', value: fmtMoney(info.monthly_spending_limit) },
|
||
{ label: '本月消费', value: fmtMoney(info.monthly_spent) },
|
||
];
|
||
|
||
const trendOption: echarts.EChartsCoreOption = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
backgroundColor: 'rgba(13, 13, 26, 0.95)',
|
||
borderColor: 'rgba(255, 255, 255, 0.10)',
|
||
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
||
formatter: (params: unknown) => {
|
||
const p = (params as { name: string; value: number }[])[0];
|
||
return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`;
|
||
},
|
||
},
|
||
grid: { left: 50, right: 20, top: 20, bottom: 60 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: stats.daily_trend.map((d) => d.date.slice(5)),
|
||
axisLabel: { color: '#8b8ea8', fontSize: 11 },
|
||
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
axisLabel: { color: '#8b8ea8', fontSize: 11 },
|
||
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
|
||
},
|
||
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
|
||
series: [{
|
||
type: 'line',
|
||
data: stats.daily_trend.map((d) => d.cost ?? d.seconds),
|
||
smooth: true,
|
||
lineStyle: { color: '#6c63ff', width: 2 },
|
||
areaStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
|
||
{ offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
|
||
]),
|
||
},
|
||
itemStyle: { color: '#6c63ff' },
|
||
}],
|
||
};
|
||
|
||
const sortedMembers = [...stats.member_consumption].sort((a, b) => (a.cost_consumed ?? a.seconds_consumed) - (b.cost_consumed ?? b.seconds_consumed));
|
||
const barOption: echarts.EChartsCoreOption = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
backgroundColor: 'rgba(13, 13, 26, 0.95)',
|
||
borderColor: 'rgba(255, 255, 255, 0.10)',
|
||
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
||
},
|
||
grid: { left: 80, right: 40, top: 10, bottom: 20 },
|
||
xAxis: {
|
||
type: 'value',
|
||
axisLabel: { color: '#8b8ea8', fontSize: 11 },
|
||
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: sortedMembers.map((m) => m.username),
|
||
axisLabel: { color: '#8b8ea8', fontSize: 12 },
|
||
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
|
||
},
|
||
series: [{
|
||
type: 'bar',
|
||
data: sortedMembers.map((m) => m.cost_consumed ?? m.seconds_consumed),
|
||
barWidth: 16,
|
||
itemStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||
{ offset: 0, color: '#6c63ff' },
|
||
{ offset: 1, color: '#8b5cf6' },
|
||
]),
|
||
borderRadius: [0, 4, 4, 0],
|
||
},
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
color: '#8b8ea8',
|
||
fontSize: 11,
|
||
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
|
||
},
|
||
}],
|
||
};
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<h1 className={styles.title}>团队概览</h1>
|
||
|
||
<div className={styles.statsGrid} style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||
{statCards.map((card) => (
|
||
<div key={card.label} className={styles.statCard}>
|
||
<div className={styles.statLabel}>{card.label}</div>
|
||
<div className={styles.statValue}>{card.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className={styles.chartSection}>
|
||
<h2 className={styles.sectionTitle}>团队消费趋势(近30天 · 元)</h2>
|
||
<div className={styles.chartWrapper}>
|
||
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.chartSection}>
|
||
<h2 className={styles.sectionTitle}>成员消费排行</h2>
|
||
<div className={styles.chartWrapper}>
|
||
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedMembers.length * 36) }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|