video-shuoshan/web/src/pages/ProfilePage.tsx
seaislee1209 b520b429c5
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
feat: 密码管理 + 错误提示体系统一 (v0.9.2 & v0.9.3)
密码管理:用户自助修改密码(个人中心弹窗)、管理员重置用户密码(审计日志记录)
错误提示:补全火山 ARK 错误码映射(+7 个)、修复创建失败时前端不显示真实错误、
轮询失败走 ERROR_MESSAGES 映射、前端 catch 统一取后端 message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:12:40 +08:00

276 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 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('/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}>: {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>
{pwModalOpen && (
<div className={styles.modalOverlay} onClick={() => setPwModalOpen(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<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>
);
}