- 新增 ConfirmModal 组件,为6处危险操作添加二次确认弹窗 (禁用团队/用户/成员、删除视频×3处) - 所有秒数显示统一为千位分隔符+s后缀(如 36,000s) - 修复 modal/drawer 在 input 中拖拽导致误关闭的 bug (onClick → onMouseDown + e.target === e.currentTarget) - 团队模型完善:三种角色(超管/团管/成员)、四层额度检查、 团管成员管理页、超管团队管理页 - 关闭公开注册,所有账号由管理员创建 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
9.2 KiB
TypeScript
217 lines
9.2 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 } from '../lib/api';
|
||
import type { ProfileOverview, AdminRecord } from '../types';
|
||
import { showToast } from '../components/Toast';
|
||
import styles from './ProfilePage.module.css';
|
||
|
||
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 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 });
|
||
};
|
||
|
||
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 dailyPercent = overview.daily_seconds_limit > 0 ? (overview.daily_seconds_used / overview.daily_seconds_limit) * 100 : 0;
|
||
const monthlyPercent = overview.monthly_seconds_limit > 0 ? (overview.monthly_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
|
||
|
||
const totalRemaining = Math.max(0, overview.monthly_seconds_limit - overview.total_seconds_used);
|
||
const totalPercent = overview.monthly_seconds_limit > 0 ? (overview.total_seconds_used / overview.monthly_seconds_limit) * 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('/')}>
|
||
<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.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}>已消耗: {overview.total_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</div>
|
||
<div className={styles.progressBar}>
|
||
<div className={styles.progressFill} style={{
|
||
width: `${Math.min(totalPercent, 100)}%`,
|
||
background: totalPercent > 80 ? (totalPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
|
||
}} />
|
||
</div>
|
||
<div className={styles.quotaPercent}>剩余 {totalRemaining.toLocaleString()}s</div>
|
||
</div>
|
||
<div className={styles.quotaCard}>
|
||
<div className={styles.quotaLabel}>今日额度</div>
|
||
<div className={styles.quotaValue}>已用: {overview.daily_seconds_used.toLocaleString()}s / {overview.daily_seconds_limit.toLocaleString()}s</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}>{dailyPercent.toFixed(1)}%</div>
|
||
</div>
|
||
<div className={styles.quotaCard}>
|
||
<div className={styles.quotaLabel}>本月额度</div>
|
||
<div className={styles.quotaValue}>已用: {overview.monthly_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</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}>{monthlyPercent.toFixed(1)}%</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.seconds_consumed.toLocaleString()}s</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>
|
||
</div>
|
||
);
|
||
}
|