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>
This commit is contained in:
abingyydsandClaude Sonnet 4.6 committed 2026-03-21 04:23:13 +09:00
commit 9dfc5e79bc
29 files changed
+5540

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
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>
);
}
+247
View File
@@ -0,0 +1,247 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
import { getSiteModels, getSitePackages } from '../api';
import GradientText from '../components/bits/GradientText';
import BlurText from '../components/bits/BlurText';
import SpotlightCard from '../components/bits/SpotlightCard';
import CountUp from '../components/bits/CountUp';
import StarBorder from '../components/bits/StarBorder';
import ShinyText from '../components/bits/ShinyText';
export default function Home() {
const { user } = useAuth();
const { site } = useSite();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
useEffect(() => {
getSiteModels().then(r => { if (r.data.success) setModels(r.data.data || []); }).catch(() => {});
getSitePackages().then(r => { if (r.data.success) setPackages(r.data.data || []); }).catch(() => {});
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
return (
<div className="relative">
{/* Gradient Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-gradient-radial from-brand-600/20 via-purple-600/5 to-transparent blur-3xl" />
<div className="absolute top-[400px] right-0 w-[500px] h-[500px] bg-gradient-radial from-pink-600/10 to-transparent blur-3xl" />
</div>
{/* Hero Section */}
<section className="relative max-w-7xl mx-auto px-6 pt-24 pb-20">
<div className="text-center max-w-4xl mx-auto">
<div className="mb-6">
<GradientText
colors={['#818cf8', '#c084fc', '#f472b6', '#818cf8']}
animationSpeed={6}
className="text-5xl md:text-7xl font-heading font-extrabold leading-tight"
>
{site?.name || 'AI API Platform'}
</GradientText>
</div>
<BlurText
text="Access the world's most powerful AI models through a single API endpoint. Simple, affordable, reliable."
className="text-lg md:text-xl text-neutral-400 max-w-2xl mx-auto mb-10"
delay={50}
animateBy="words"
/>
<div className="flex items-center justify-center gap-4">
{user ? (
<Link to="/dashboard">
<StarBorder as="div" color="#818cf8" speed="5s">
Go to Dashboard
</StarBorder>
</Link>
) : (
<>
<Link to="/register" className="btn-primary text-base !px-8 !py-3">
Get Started Free
</Link>
<Link to="/pricing" className="btn-secondary text-base !px-8 !py-3">
View Pricing
</Link>
</>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-6 max-w-xl mx-auto mt-20">
<div className="text-center">
<div className="text-3xl font-bold text-white">
<CountUp from={0} to={enabledModels.length || 50} duration={2} separator="" />+
</div>
<p className="text-sm text-neutral-500 mt-1">AI Models</p>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-white">
<CountUp from={0} to={99.9} duration={2.5} />%
</div>
<p className="text-sm text-neutral-500 mt-1">Uptime</p>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-white">
&lt;<CountUp from={200} to={50} duration={2} direction="down" />ms
</div>
<p className="text-sm text-neutral-500 mt-1">Latency</p>
</div>
</div>
</section>
{/* Features Section */}
<section className="relative max-w-7xl mx-auto px-6 py-20">
<div className="text-center mb-12">
<h2 className="text-3xl font-heading font-bold text-white mb-3">Why Choose Us</h2>
<p className="text-neutral-400">Everything you need to build AI-powered applications</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(129,140,248,0.15)">
<div className="w-12 h-12 rounded-xl bg-brand-500/10 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Lightning Fast</h3>
<p className="text-sm text-neutral-400 leading-relaxed">
Optimized routing and caching deliver responses with minimal latency. Load-balanced across multiple providers for maximum reliability.
</p>
</SpotlightCard>
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(192,132,252,0.15)">
<div className="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Secure & Private</h3>
<p className="text-sm text-neutral-400 leading-relaxed">
Enterprise-grade security with encrypted API keys. No request logging by default. Your data stays your data.
</p>
</SpotlightCard>
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(244,114,182,0.15)">
<div className="w-12 h-12 rounded-xl bg-pink-500/10 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-pink-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Pay As You Go</h3>
<p className="text-sm text-neutral-400 leading-relaxed">
Transparent pricing with no hidden fees. Only pay for what you use. Multiple top-up options and subscription packages available.
</p>
</SpotlightCard>
</div>
</section>
{/* Models Preview */}
{enabledModels.length > 0 && (
<section className="relative max-w-7xl mx-auto px-6 py-20">
<div className="text-center mb-12">
<h2 className="text-3xl font-heading font-bold text-white mb-3">Available Models</h2>
<p className="text-neutral-400">
Access <span className="text-brand-400 font-medium">{enabledModels.length}</span> AI models through a unified API
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{enabledModels.slice(0, 12).map((m, i) => (
<div
key={m.id || i}
className="glass-sm px-4 py-3 flex items-center gap-3 hover:bg-white/[0.06] transition-colors group"
>
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<span className="text-sm font-mono text-neutral-300 group-hover:text-white transition-colors truncate">
{m.display_name || m.model_name}
</span>
</div>
))}
</div>
{enabledModels.length > 12 && (
<div className="text-center mt-6">
<Link to="/pricing" className="text-sm text-brand-400 hover:text-brand-300 transition-colors">
View all {enabledModels.length} models &rarr;
</Link>
</div>
)}
</section>
)}
{/* Packages Preview */}
{packages.length > 0 && (
<section className="relative max-w-7xl mx-auto px-6 py-20">
<div className="text-center mb-12">
<h2 className="text-3xl font-heading font-bold text-white mb-3">Plans & Packages</h2>
<p className="text-neutral-400">Choose a plan that fits your needs</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg) => (
<div key={pkg.id} className="glass rounded-2xl p-6 flex flex-col hover:border-brand-500/30 transition-colors">
<h3 className="text-lg font-semibold text-white mb-1">{pkg.name}</h3>
{pkg.description && (
<p className="text-sm text-neutral-400 mb-4">{pkg.description}</p>
)}
<div className="mt-auto pt-4">
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-white">${pkg.price}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-500 line-through">${pkg.original_price}</span>
)}
</div>
{pkg.duration > 0 && (
<p className="text-xs text-neutral-500 mt-1">{pkg.duration} days</p>
)}
</div>
<Link
to={user ? '/packages' : '/register'}
className="btn-primary text-center mt-4 !py-2"
>
{user ? 'Subscribe' : 'Get Started'}
</Link>
</div>
))}
</div>
</section>
)}
{/* CTA Section */}
<section className="relative max-w-7xl mx-auto px-6 py-24">
<div className="glass rounded-3xl p-12 text-center relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-brand-600/10 via-transparent to-purple-600/10" />
<div className="relative z-10">
<h2 className="text-3xl font-heading font-bold text-white mb-4">
Ready to Get Started?
</h2>
<p className="text-neutral-400 mb-8 max-w-md mx-auto">
Create your account and start building with AI in minutes. No credit card required.
</p>
<div className="flex items-center justify-center gap-4">
{user ? (
<Link to="/dashboard" className="btn-primary text-base !px-8 !py-3">
Go to Dashboard
</Link>
) : (
<>
<Link to="/register" className="btn-primary text-base !px-8 !py-3">
Create Free Account
</Link>
<Link to="/login" className="btn-secondary text-base !px-8 !py-3">
Sign In
</Link>
</>
)}
</div>
</div>
</div>
</section>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { Link, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
import toast from 'react-hot-toast';
export default function Login() {
const { login, user } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const location = useLocation();
const [form, setForm] = useState({ username: '', password: '' });
const [loading, setLoading] = useState(false);
// If already logged in, redirect via component (not navigate in render)
if (user) {
const from = location.state?.from?.pathname || '/dashboard';
return <Navigate to={from} replace />;
}
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.username || !form.password) {
toast.error('Please fill in all fields');
return;
}
setLoading(true);
try {
const result = await login(form.username, form.password);
if (result.success) {
toast.success('Welcome back!');
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
return; // component may unmount — skip setLoading
}
// error toast is handled by api interceptor for success:false
} catch (err) {
// Network error handled by interceptor
}
setLoading(false);
};
return (
<div className="min-h-[70vh] flex items-center justify-center px-6">
<div className="w-full max-w-md">
<div className="glass rounded-2xl p-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-heading font-bold text-white mb-2">Welcome Back</h1>
<p className="text-sm text-neutral-400">
Sign in to {site?.name || 'your account'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Username</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="input"
placeholder="Enter your username"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Password</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="input"
placeholder="Enter your password"
autoComplete="current-password"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{loading && (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-neutral-400">
Don't have an account?{' '}
<Link to="/register" className="text-brand-400 hover:text-brand-300 transition-colors font-medium">
Create one
</Link>
</p>
</div>
</div>
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { getSitePackages, subscribePackage, Q } from '../api';
import SpotlightCard from '../components/bits/SpotlightCard';
import toast from 'react-hot-toast';
export default function Packages() {
const { user, refreshUser } = useAuth();
const navigate = useNavigate();
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [subscribing, setSubscribing] = useState(null);
const [confirmPkg, setConfirmPkg] = useState(null);
useEffect(() => {
getSitePackages()
.then((r) => { if (r.data.success) setPackages(r.data.data || []); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleSubscribe = async (pkg) => {
if (!user) {
navigate('/register');
return;
}
setConfirmPkg(pkg);
};
const confirmSubscribe = async () => {
if (!confirmPkg) return;
const pkgId = confirmPkg.id;
setSubscribing(pkgId);
try {
const res = await subscribePackage(pkgId);
if (res.data.success) {
toast.success('Subscribed successfully!');
setConfirmPkg(null);
// Refresh user data to update balance
await refreshUser();
}
} catch (e) { /* interceptor */ }
setSubscribing(null);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
</div>
);
}
const enabled = packages.filter((p) => p.enabled);
const spotlightColors = [
'rgba(129,140,248,0.15)',
'rgba(192,132,252,0.15)',
'rgba(244,114,182,0.15)',
'rgba(34,197,94,0.15)',
];
return (
<div className="max-w-7xl mx-auto px-6 py-10">
<div className="text-center mb-12">
<h1 className="text-3xl font-heading font-bold text-white mb-3">Packages & Plans</h1>
<p className="text-neutral-400 max-w-xl mx-auto">
Choose a package that fits your usage. All plans include access to all available models.
</p>
</div>
{enabled.length === 0 ? (
<div className="text-center py-12 text-neutral-400">
<p>No packages available at the moment.</p>
<Link to="/pricing" className="text-brand-400 hover:text-brand-300 transition-colors mt-2 inline-block">
Check model pricing &rarr;
</Link>
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
{enabled.map((pkg, i) => (
<SpotlightCard
key={pkg.id}
className="!bg-neutral-900/60 !border-neutral-800/60 !p-0 !rounded-2xl flex flex-col"
spotlightColor={spotlightColors[i % spotlightColors.length]}
>
<div className="p-6 flex-1 flex flex-col">
{/* Header */}
<div className="mb-4">
<h3 className="text-xl font-semibold text-white">{pkg.name}</h3>
{pkg.description && (
<p className="text-sm text-neutral-400 mt-1">{pkg.description}</p>
)}
</div>
{/* Price */}
<div className="mb-6">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-white">${Number(pkg.price).toFixed(2)}</span>
{pkg.original_price > 0 && pkg.original_price > pkg.price && (
<span className="text-lg text-neutral-500 line-through">${Number(pkg.original_price).toFixed(2)}</span>
)}
</div>
{pkg.duration > 0 && (
<p className="text-sm text-neutral-500 mt-1">{pkg.duration} days access</p>
)}
</div>
{/* Features */}
<ul className="space-y-2 mb-6 flex-1">
{pkg.quota_amount > 0 && (
<li className="flex items-center gap-2 text-sm text-neutral-300">
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
${(pkg.quota_amount / Q).toFixed(2)} credit included
</li>
)}
{pkg.rate_limit > 0 && (
<li className="flex items-center gap-2 text-sm text-neutral-300">
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{pkg.rate_limit} requests/min
</li>
)}
<li className="flex items-center gap-2 text-sm text-neutral-300">
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
All models access
</li>
<li className="flex items-center gap-2 text-sm text-neutral-300">
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
OpenAI-compatible API
</li>
</ul>
{/* CTA */}
<button
onClick={() => handleSubscribe(pkg)}
disabled={subscribing === pkg.id}
className="btn-primary w-full text-center"
>
{subscribing === pkg.id ? 'Processing...' : user ? 'Subscribe Now' : 'Sign Up to Subscribe'}
</button>
</div>
</SpotlightCard>
))}
</div>
)}
{/* Confirmation Modal */}
{confirmPkg && (() => {
const userBalance = (user?.quota || 0) / Q;
const pkgPrice = Number(confirmPkg.price);
const insufficient = userBalance < pkgPrice;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => !subscribing && setConfirmPkg(null)}>
<div className="glass rounded-2xl p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-white mb-3">Confirm Subscription</h2>
<p className="text-sm text-neutral-400 mb-2">
Subscribe to <span className="text-white font-medium">{confirmPkg.name}</span> for{' '}
<span className="text-white font-medium">${pkgPrice.toFixed(2)}</span>?
</p>
<p className="text-sm text-neutral-400 mb-4">
Your balance: <span className={`font-medium ${insufficient ? 'text-red-400' : 'text-green-400'}`}>${userBalance.toFixed(2)}</span>
</p>
{insufficient && (
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-3 mb-4">
<p className="text-sm text-red-300">Insufficient balance. Please top up before subscribing.</p>
</div>
)}
<div className="flex justify-end gap-3">
<button onClick={() => setConfirmPkg(null)} disabled={subscribing} className="btn-secondary">Cancel</button>
<button onClick={confirmSubscribe} disabled={insufficient || subscribing} className="btn-primary">
{subscribing ? 'Processing...' : 'Confirm'}
</button>
</div>
</div>
</div>
);
})()}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useState, useEffect } from 'react';
import { getSiteModels } from '../api';
export default function Pricing() {
const [models, setModels] = useState([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
// /api/dist/site/pricing returns same data as /api/dist/site/models — only need one call
getSiteModels()
.then((r) => {
if (r.data.success) setModels(r.data.data || []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const enabledModels = models.filter((m) => m.enabled !== false);
const filtered = enabledModels.filter((m) =>
!search ||
(m.display_name || m.model_name || '').toLowerCase().includes(search.toLowerCase())
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-6 py-10">
<div className="text-center mb-10">
<h1 className="text-3xl font-heading font-bold text-white mb-3">Model Pricing</h1>
<p className="text-neutral-400 max-w-xl mx-auto">
Transparent, pay-as-you-go pricing for all available models. Prices shown per 1K tokens.
</p>
</div>
{/* Search */}
<div className="max-w-md mx-auto mb-8">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input !pl-10"
placeholder="Search models..."
/>
</div>
</div>
{filtered.length === 0 ? (
<div className="text-center py-12 text-neutral-400">
{search ? 'No models match your search' : 'No models available'}
</div>
) : (
<div className="glass-sm rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06]">
<th className="text-left px-5 py-3.5 font-medium text-neutral-400">Model</th>
<th className="text-right px-5 py-3.5 font-medium text-neutral-400">Input / 1K tokens</th>
<th className="text-right px-5 py-3.5 font-medium text-neutral-400">Output / 1K tokens</th>
<th className="text-center px-5 py-3.5 font-medium text-neutral-400">Status</th>
</tr>
</thead>
<tbody>
{filtered.map((m, i) => (
<tr key={m.model_name || i} className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.02] transition-colors">
<td className="px-5 py-3.5">
<span className="font-mono text-white">{m.display_name || m.model_name}</span>
</td>
<td className="px-5 py-3.5 text-right font-mono text-neutral-300">
{m.input_price != null ? `$${Number(m.input_price).toFixed(6)}` : '-'}
</td>
<td className="px-5 py-3.5 text-right font-mono text-neutral-300">
{m.output_price != null ? `$${Number(m.output_price).toFixed(6)}` : '-'}
</td>
<td className="px-5 py-3.5 text-center">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs border ${
m.status === 'healthy'
? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-neutral-500/10 text-neutral-400 border-neutral-500/20'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.status === 'healthy' ? 'bg-green-500' : 'bg-neutral-500'}`} />
{m.status === 'healthy' ? 'Online' : 'Unknown'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
import toast from 'react-hot-toast';
export default function Register() {
const { register, user } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const [form, setForm] = useState({ username: '', password: '', password2: '', email: '' });
const [loading, setLoading] = useState(false);
// If already logged in, redirect via component
if (user) {
return <Navigate to="/dashboard" replace />;
}
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.username || !form.password) {
toast.error('Please fill in all required fields');
return;
}
if (form.password !== form.password2) {
toast.error('Passwords do not match');
return;
}
if (form.password.length < 8) {
toast.error('Password must be 8-20 characters');
return;
}
if (form.password.length > 20) {
toast.error('Password must be 8-20 characters');
return;
}
setLoading(true);
try {
const result = await register({
username: form.username,
password: form.password,
email: form.email || undefined,
});
if (result.success) {
toast.success('Account created! Please sign in.');
navigate('/login', { replace: true });
return; // component may unmount — skip setLoading
}
// error toast is handled by api interceptor for success:false
} catch (err) {
// Network error handled by interceptor
}
setLoading(false);
};
return (
<div className="min-h-[70vh] flex items-center justify-center px-6">
<div className="w-full max-w-md">
<div className="glass rounded-2xl p-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-heading font-bold text-white mb-2">Create Account</h1>
<p className="text-sm text-neutral-400">
Get started with {site?.name || 'our platform'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Username *</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="input"
placeholder="Choose a username"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="input"
placeholder="your@email.com (optional)"
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Password *</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="input"
placeholder="8-20 characters"
autoComplete="new-password"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Confirm Password *</label>
<input
type="password"
value={form.password2}
onChange={(e) => setForm({ ...form, password2: e.target.value })}
className="input"
placeholder="Repeat your password"
autoComplete="new-password"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{loading && (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{loading ? 'Creating...' : 'Create Account'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-neutral-400">
Already have an account?{' '}
<Link to="/login" className="text-brand-400 hover:text-brand-300 transition-colors font-medium">
Sign in
</Link>
</p>
</div>
</div>
</div>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
import React, { useState, useEffect, useCallback } from 'react';
import { getTokens, createToken, updateToken, deleteToken } from '../api';
import toast from 'react-hot-toast';
export default function Tokens() {
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState('');
const [creating, setCreating] = useState(false);
const [copiedId, setCopiedId] = useState(null);
// Newly created key — shown only once
const [newKey, setNewKey] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getTokens();
if (res.data.success) setTokens(res.data.data || []);
} catch (e) { /* interceptor */ }
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const handleCreate = async (e) => {
e.preventDefault();
if (!createName.trim()) {
toast.error('Please enter a name');
return;
}
setCreating(true);
try {
const res = await createToken({ name: createName.trim() });
if (res.data.success) {
setCreateName('');
setShowCreate(false);
// Show the key — it's only visible at creation time!
const createdKey = res.data.data?.key;
if (createdKey) {
setNewKey(createdKey);
}
await load();
}
} catch (e) { /* interceptor */ }
setCreating(false);
};
const handleToggle = async (token) => {
try {
const res = await updateToken(token.id, {
status: token.status === 1 ? 2 : 1,
});
if (res.data.success) {
toast.success(token.status === 1 ? 'Token disabled' : 'Token enabled');
await load();
}
} catch (e) { /* interceptor */ }
};
const handleDelete = async () => {
if (!deleteConfirm) return;
try {
const res = await deleteToken(deleteConfirm.id);
if (res.data.success) {
toast.success('Token deleted');
setDeleteConfirm(null);
await load();
}
} catch (e) { /* interceptor */ }
};
const handleCopy = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
setCopiedId(text);
toast.success('Copied to clipboard!');
setTimeout(() => setCopiedId(null), 2000);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-heading font-bold text-white">API Keys</h1>
<p className="text-sm text-neutral-400 mt-1">Manage your API access tokens</p>
</div>
<button onClick={() => setShowCreate(true)} className="btn-primary">
+ New Key
</button>
</div>
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
<div className="glass rounded-2xl p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-white mb-4">Create API Key</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Name</label>
<input
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
className="input"
placeholder="e.g. my-app-key"
autoFocus
required
/>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary">
Cancel
</button>
<button type="submit" disabled={creating} className="btn-primary">
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* New Key Reveal Modal — shown only once after creation */}
{newKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass rounded-2xl p-6 w-full max-w-lg">
<h2 className="text-lg font-semibold text-white mb-2">Your New API Key</h2>
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-xl p-3 mb-4">
<p className="text-sm text-yellow-300">
This is the only time you'll see this key. Copy it now and store it securely.
</p>
</div>
<div className="bg-black/40 rounded-xl p-4 flex items-center gap-3">
<code className="text-sm font-mono text-green-400 flex-1 break-all select-all">
{newKey}
</code>
<button
onClick={() => handleCopy(newKey)}
className="btn-primary !px-4 !py-1.5 flex-shrink-0"
>
{copiedId === newKey ? 'Copied!' : 'Copy'}
</button>
</div>
<div className="flex justify-end mt-4">
<button onClick={() => setNewKey(null)} className="btn-secondary">
I've saved my key
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setDeleteConfirm(null)}>
<div className="glass rounded-2xl p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-white mb-3">Delete Token</h2>
<p className="text-sm text-neutral-400 mb-4">
Delete <span className="text-white font-medium">"{deleteConfirm.name}"</span>? This cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteConfirm(null)} className="btn-secondary">Cancel</button>
<button onClick={handleDelete} className="px-6 py-2.5 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-500 transition-colors">
Delete
</button>
</div>
</div>
</div>
)}
{/* Token List */}
{tokens.length === 0 ? (
<div className="glass rounded-2xl p-12 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neutral-800/50 flex items-center justify-center">
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<p className="text-neutral-400 mb-4">No API keys yet</p>
<button onClick={() => setShowCreate(true)} className="btn-primary">
Create Your First Key
</button>
</div>
) : (
<div className="space-y-3">
{tokens.map((token) => (
<div key={token.id} className="glass-sm rounded-xl p-5 flex items-center gap-4">
{/* Status dot */}
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${token.status === 1 ? 'bg-green-500' : 'bg-neutral-600'}`} />
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{token.name}</p>
<p className="text-xs font-mono text-neutral-500 mt-1">
sk-***
</p>
</div>
{/* Created time */}
<span className="text-xs text-neutral-500 hidden md:block">
{token.created_time ? new Date(token.created_time * 1000).toLocaleDateString() : ''}
</span>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => handleToggle(token)}
className={`px-3 py-1 text-xs rounded-lg border transition-colors ${
token.status === 1
? 'border-green-500/30 text-green-400 hover:bg-green-500/10'
: 'border-neutral-600 text-neutral-400 hover:bg-neutral-800'
}`}
>
{token.status === 1 ? 'Enabled' : 'Disabled'}
</button>
<button
onClick={() => setDeleteConfirm(token)}
className="px-3 py-1 text-xs rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
{/* API Usage Note */}
<div className="glass rounded-2xl p-6 mt-8">
<h3 className="text-sm font-semibold text-white mb-3">Quick Start</h3>
<div className="bg-black/40 rounded-xl p-4 font-mono text-sm">
<p className="text-neutral-500"># Use your API key with OpenAI-compatible endpoint</p>
<p className="text-green-400 mt-2">
curl {window.location.origin}/v1/chat/completions \
</p>
<p className="text-neutral-300 pl-4">
-H "Authorization: Bearer sk-your-key" \
</p>
<p className="text-neutral-300 pl-4">
-H "Content-Type: application/json" \
</p>
<p className="text-neutral-300 pl-4">
-d '{`{"model":"gpt-4o","messages":[{"role":"user","content":"Hello!"}]}`}'
</p>
</div>
</div>
</div>
);
}