Files
Sub-Router/src/pages/Dashboard.jsx
T
abingyydsandClaude Sonnet 4.6 9dfc5e79bc feat: 完整分销系统 — 后端 + 主站管理 + 分站前端模板
分销商后端:
- 分销商注册/审核/CRUD API
- 分站用户注册/登录/登出 (session cookie auth)
- 分站 Token CRUD、充值兑换、套餐订阅
- 分站域名/子域名自动识别中间件 (DistIdentify)
- 分销商收益异步记账 + markup 加价计费
- 模型价格快照、渠道路由配置
- 管理员审核/管理分销商

主站前端 (web/):
- 分销商注册申请、设置、客户管理(分页)、套餐管理(含速率限制)
- 收益管理(含汇总卡片+分页列表)、模型管理
- 管理员分销商审核面板
- DistributorLayout 标签页布局 + 路由

分站前端模板 (dist-site/):
- React + Vite + Tailwind 暗色主题 glassmorphism 设计
- react-bits 动画组件 (GradientText, BlurText, CountUp, SpotlightCard 等)
- Session cookie 认证 + New-Api-User header
- 首页/登录/注册/Dashboard/API Keys/定价/套餐 7 个页面
- Token 创建后一次性密钥展示、余额检查订阅确认

Bug 修复:
- 修复 DistUserRegister 密码双重哈希导致用户无法登录
- 修复 401 响应不清除 React user 状态
- 修复订阅确认弹窗不显示余额且提前关闭
- 修复 Home 页 Subscribe 按钮跳转错误 (/dashboard → /packages)
- 修复 Login/Register 组件卸载后 setState 警告
- 修复 DistributorEarnings 引用不存在的 username 字段
- 修复 DistributorCustomers 引用不存在的 created_at 字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 04:23:13 +09:00

128 lines
5.6 KiB
React

import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { getUserUsage, redeemCode, Q, quotaToDollar } from '../api';
import SpotlightCard from '../components/bits/SpotlightCard';
import CountUp from '../components/bits/CountUp';
import ShinyText from '../components/bits/ShinyText';
import toast from 'react-hot-toast';
export default function Dashboard() {
const { user, refreshUser } = useAuth();
const [usage, setUsage] = useState(null);
const [redeemInput, setRedeemInput] = useState('');
const [redeeming, setRedeeming] = useState(false);
const loadUsage = async () => {
try {
const r = await getUserUsage();
if (r.data.success) setUsage(r.data.data);
} catch (e) { /* interceptor */ }
};
useEffect(() => { loadUsage(); }, []);
const handleRedeem = async (e) => {
e.preventDefault();
if (!redeemInput.trim()) return;
setRedeeming(true);
try {
const res = await redeemCode(redeemInput.trim());
if (res.data.success) {
toast.success('Code redeemed successfully!');
setRedeemInput('');
// Refresh both usage AND user data to update balance
await Promise.all([loadUsage(), refreshUser()]);
}
} catch (err) { /* interceptor */ }
setRedeeming(false);
};
// Use usage data (fresh from API) for balance/used — NOT stale user object
const quota = usage?.quota ?? user?.quota ?? 0;
const usedQuota = usage?.used_quota ?? user?.used_quota ?? 0;
const requestCount = usage?.request_count ?? user?.request_count ?? 0;
const balanceDollars = quota / Q;
return (
<div className="max-w-7xl mx-auto px-6 py-10">
{/* Welcome */}
<div className="mb-8">
<h1 className="text-2xl font-heading font-bold text-white mb-1">
Welcome, <ShinyText text={user?.display_name || user?.username || 'User'} className="!inline" speed={3} color="#a5b4fc" shineColor="#e0e7ff" />
</h1>
<p className="text-sm text-neutral-400">Manage your API usage, keys, and account</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60 !p-6" spotlightColor="rgba(34,197,94,0.15)">
<p className="text-sm text-neutral-400 mb-2">Balance</p>
<div className="text-3xl font-bold text-white">
$<CountUp from={0} to={balanceDollars} duration={1.5} />
</div>
<p className="text-xs text-neutral-500 mt-1">{quota.toLocaleString()} quota units</p>
</SpotlightCard>
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60 !p-6" spotlightColor="rgba(129,140,248,0.15)">
<p className="text-sm text-neutral-400 mb-2">Used</p>
<div className="text-3xl font-bold text-white">
$<CountUp from={0} to={usedQuota / Q} duration={1.5} />
</div>
<p className="text-xs text-neutral-500 mt-1">{usedQuota.toLocaleString()} quota units</p>
</SpotlightCard>
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60 !p-6" spotlightColor="rgba(244,114,182,0.15)">
<p className="text-sm text-neutral-400 mb-2">Total Requests</p>
<div className="text-3xl font-bold text-white">
<CountUp from={0} to={requestCount} duration={1.5} />
</div>
</SpotlightCard>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Redeem Code */}
<div className="glass rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Redeem Code</h2>
<form onSubmit={handleRedeem} className="flex gap-3">
<input
type="text"
value={redeemInput}
onChange={(e) => setRedeemInput(e.target.value)}
className="input flex-1"
placeholder="Enter redemption code"
/>
<button type="submit" disabled={redeeming} className="btn-primary whitespace-nowrap">
{redeeming ? 'Redeeming...' : 'Redeem'}
</button>
</form>
</div>
{/* Quick Links */}
<div className="glass rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Quick Links</h2>
<div className="grid grid-cols-2 gap-3">
<Link to="/tokens" className="glass-sm !rounded-xl px-4 py-3 hover:bg-white/[0.06] transition-colors group">
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">API Keys</p>
<p className="text-xs text-neutral-500">Manage your keys</p>
</Link>
<Link to="/packages" className="glass-sm !rounded-xl px-4 py-3 hover:bg-white/[0.06] transition-colors group">
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">Packages</p>
<p className="text-xs text-neutral-500">View plans</p>
</Link>
<Link to="/pricing" className="glass-sm !rounded-xl px-4 py-3 hover:bg-white/[0.06] transition-colors group">
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">Pricing</p>
<p className="text-xs text-neutral-500">Model prices</p>
</Link>
<div className="glass-sm !rounded-xl px-4 py-3 opacity-50">
<p className="text-sm font-medium text-white">API Docs</p>
<p className="text-xs text-neutral-500">Coming soon</p>
</div>
</div>
</div>
</div>
</div>
);
}