video-shuoshan/web/src/pages/ProfilePage.tsx
seaislee1209 9259988094 feat: v0.10.0 计费体系重构 — 秒数→金额+次数,token追踪,利润分析
## 计费体系
- 团队额度从秒数改为金额(余额/冻结/月消费上限)
- 用户限额从秒数改为次数(每日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>
2026-03-20 20:32:12 +08:00

277 lines
12 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 { useNavigate } from 'react-router-dom';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useAuthStore } from '../store/auth';
import { profileApi, authApi } from '../lib/api';
import type { ProfileOverview, AdminRecord } from '../types';
import { showToast } from '../components/Toast';
import styles from './ProfilePage.module.css';
import { AxiosError } from 'axios';
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
export function ProfilePage() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [overview, setOverview] = useState<ProfileOverview | null>(null);
const [records, setRecords] = useState<AdminRecord[]>([]);
const [recordsTotal, setRecordsTotal] = useState(0);
const [recordsPage, setRecordsPage] = useState(1);
const [trendPeriod, setTrendPeriod] = useState<'7d' | '30d'>('7d');
const [loading, setLoading] = useState(true);
const [pwModalOpen, setPwModalOpen] = useState(false);
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwError, setPwError] = useState('');
const [pwSaving, setPwSaving] = useState(false);
const fetchOverview = useCallback(async () => {
try {
const { data } = await profileApi.getOverview(trendPeriod);
setOverview(data);
} catch {
showToast('加载消费概览失败');
}
}, [trendPeriod]);
const fetchRecords = useCallback(async () => {
try {
const { data } = await profileApi.getRecords(recordsPage, 20);
if (recordsPage === 1) {
setRecords(data.results);
} else {
setRecords((prev) => [...prev, ...data.results]);
}
setRecordsTotal(data.total);
} catch {
showToast('加载消费记录失败');
}
}, [recordsPage]);
useEffect(() => {
Promise.all([fetchOverview(), fetchRecords()]).finally(() => setLoading(false));
}, []);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
const handleChangePassword = async () => {
setPwError('');
if (!oldPw) { setPwError('请输入旧密码'); return; }
if (newPw.length < 8) { setPwError('新密码至少8位'); return; }
if (newPw !== confirmPw) { setPwError('两次输入的新密码不一致'); return; }
setPwSaving(true);
try {
await authApi.changePassword(oldPw, newPw);
showToast('密码修改成功,请重新登录');
setPwModalOpen(false);
setOldPw(''); setNewPw(''); setConfirmPw('');
setTimeout(() => { logout(); navigate('/login', { replace: true }); }, 1500);
} catch (err) {
const msg = (err as AxiosError<{ message?: string }>)?.response?.data?.message || '修改失败';
setPwError(msg);
} finally {
setPwSaving(false);
}
};
if (loading || !overview) {
return (
<div className={styles.page}>
<div className={styles.skeleton}>
<div className={styles.skeletonBar} />
<div className={styles.skeletonCards} />
<div className={styles.skeletonChart} />
</div>
</div>
);
}
const dailyGenLimit = overview.daily_generation_limit || 0;
const dailyGenUsed = overview.daily_generation_used || 0;
const monthlyGenLimit = overview.monthly_generation_limit || 0;
const monthlyGenUsed = overview.monthly_generation_used || 0;
const dailyPercent = dailyGenLimit > 0 ? (dailyGenUsed / dailyGenLimit) * 100 : 0;
const monthlyPercent = monthlyGenLimit > 0 ? (monthlyGenUsed / monthlyGenLimit) * 100 : 0;
const sparklineOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
backgroundColor: '#1e1e2a',
borderColor: '#2a2a38',
textStyle: { color: '#e2e8f0', fontSize: 12 },
},
grid: { left: 0, right: 0, top: 5, bottom: 0, containLabel: false },
xAxis: { type: 'category', show: false, data: overview.daily_trend.map((d) => d.date.slice(5)) },
yAxis: { type: 'value', show: false },
series: [{
type: 'line',
data: overview.daily_trend.map((d) => d.seconds),
smooth: true,
symbol: 'none',
lineStyle: { color: '#00b8e6', width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(0, 184, 230, 0.3)' },
{ offset: 1, color: 'rgba(0, 184, 230, 0.02)' },
]),
},
}],
};
const statusMap: Record<string, string> = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' };
return (
<div className={styles.page}>
<header className={styles.header}>
<button className={styles.backBtn} onClick={() => navigate('/app')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button>
<h1 className={styles.pageTitle}></h1>
<div className={styles.headerRight}>
<span className={styles.username}>{user?.username}</span>
<button className={styles.changePwBtn} onClick={() => setPwModalOpen(true)}></button>
<button className={styles.logoutBtn} onClick={handleLogout}>退</button>
</div>
</header>
{/* Quota warning */}
{dailyPercent >= 80 && dailyPercent < 100 && (
<div className={styles.warningBanner}>使 {dailyPercent.toFixed(0)}%使</div>
)}
{dailyPercent >= 100 && (
<div className={styles.dangerBanner}></div>
)}
{/* Consumption Overview */}
<div className={styles.overviewSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.overviewGrid}>
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}></div>
<div className={styles.quotaValue}>{dailyGenUsed} / {dailyGenLimit === -1 ? '不限' : dailyGenLimit + '次'}</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{
width: `${Math.min(dailyPercent, 100)}%`,
background: dailyPercent > 80 ? (dailyPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
}} />
</div>
<div className={styles.quotaPercent}> ¥{(overview.daily_spent || 0).toFixed(2)}</div>
</div>
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}></div>
<div className={styles.quotaValue}>{monthlyGenUsed} / {monthlyGenLimit === -1 ? '不限' : monthlyGenLimit + '次'}</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{
width: `${Math.min(monthlyPercent, 100)}%`,
background: monthlyPercent > 80 ? 'var(--color-warning)' : 'var(--color-primary)',
}} />
</div>
<div className={styles.quotaPercent}> ¥{(overview.monthly_spent || 0).toFixed(2)}</div>
</div>
{overview.team && (
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}> {overview.team.name}</div>
<div className={styles.quotaValue}>: ¥{(overview.team.balance || 0).toFixed(2)}</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: '0%' }} />
</div>
<div className={styles.quotaPercent}> ¥{(overview.team.available_balance || 0).toFixed(2)}</div>
</div>
)}
</div>
</div>
{/* Consumption Trend */}
<div className={styles.trendSection}>
<div className={styles.trendHeader}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.trendTabs}>
<button className={trendPeriod === '7d' ? styles.tabActive : styles.tab} onClick={() => setTrendPeriod('7d')}>7</button>
<button className={trendPeriod === '30d' ? styles.tabActive : styles.tab} onClick={() => setTrendPeriod('30d')}>30</button>
</div>
</div>
<div className={styles.sparklineWrapper}>
<ReactEChartsCore echarts={echarts} option={sparklineOption} style={{ height: 80 }} />
</div>
</div>
{/* Consumption Records */}
<div className={styles.recordsSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.recordsList}>
{records.length === 0 ? (
<div className={styles.empty}></div>
) : (
records.map((r) => (
<div key={r.id} className={styles.recordItem}>
<div className={styles.recordLeft}>
<div className={styles.recordTime}>{new Date(r.created_at).toLocaleString('zh-CN')}</div>
<div className={styles.recordPrompt}>{r.prompt || '-'}</div>
</div>
<div className={styles.recordRight}>
<span className={styles.recordSeconds}>¥{(r.cost_amount || 0).toFixed(2)}</span>
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{statusMap[r.status]}</span>
</div>
</div>
))
)}
</div>
{records.length < recordsTotal && (
<button className={styles.loadMoreBtn} onClick={() => setRecordsPage((p) => p + 1)}>
</button>
)}
</div>
{pwModalOpen && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setPwModalOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}></h3>
<div className={styles.formGroup}>
<label className={styles.formLabel}></label>
<input type="password" className={styles.formInput} value={oldPw}
onChange={(e) => setOldPw(e.target.value)} placeholder="请输入当前密码" />
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}></label>
<input type="password" className={styles.formInput} value={newPw}
onChange={(e) => setNewPw(e.target.value)} placeholder="至少8位" />
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}></label>
<input type="password" className={styles.formInput} value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)} placeholder="再次输入新密码"
onKeyDown={(e) => e.key === 'Enter' && handleChangePassword()} />
</div>
{pwError && <div className={styles.formError}>{pwError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setPwModalOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleChangePassword} disabled={pwSaving}>
{pwSaving ? '修改中...' : '确认修改'}
</button>
</div>
</div>
</div>
)}
</div>
);
}