All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
背景 #16161e、圆角 var(--radius-card)、输入框 var(--color-bg-page)、 按钮 8px 圆角、onMouseDown 防拖拽误关、z-index 300 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
12 KiB
TypeScript
276 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 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>
|
||
);
|
||
}
|