## 计费体系 - 团队额度从秒数改为金额(余额/冻结/月消费上限) - 用户限额从秒数改为次数(每日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>
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|