video-shuoshan/web/src/pages/ProfilePage.tsx
seaislee1209 45b7ca00d1
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
fix: 密码弹窗样式对齐 ConfirmModal 规范 (v0.9.4)
背景 #16161e、圆角 var(--radius-card)、输入框 var(--color-bg-page)、
按钮 8px 圆角、onMouseDown 防拖拽误关、z-index 300

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:15:32 +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} 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>
);
}