video-shuoshan/web/src/pages/ProfilePage.tsx
seaislee1209 add3af7904 feat: v0.7.0 — 确认弹窗 + 秒数显示统一 + 弹窗拖拽修复 + 团队模型完善
- 新增 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>
2026-03-15 20:16:21 +08:00

217 lines
9.2 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 } 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>
);
}