Add nested distributor pricing and profit split

This commit is contained in:
abingyyds committed 2026-04-08 14:17:07 +08:00
1 parent 1138015cc4
commit de5d047371
14 files changed
+384 -3

No files matched your search

+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>AI Platform</title>
<script type="module" crossorigin src="/assets/index-C_OxsqDt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CWIuyqhC.css">
<script type="module" crossorigin src="/assets/index-CpYyIBrK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1hnkxZ6J.css">
</head>
<body class="antialiased">
<script>
+2
View File
@@ -11,6 +11,7 @@ const Packages = lazy(() => import('./pages/Packages'));
const Pricing = lazy(() => import('./pages/Pricing'));
const Topup = lazy(() => import('./pages/Topup'));
const Logs = lazy(() => import('./pages/Logs'));
const SubDistributor = lazy(() => import('./pages/SubDistributor'));
const Loading = () => (
<div className="flex items-center justify-center min-h-screen" style={{ background: 'var(--page-bg)' }}>
@@ -30,6 +31,7 @@ function ThemedRoutes() {
<Route path="/" element={<Home />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/packages" element={<Packages />} />
<Route path="/sub-site" element={<SubDistributor />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
+2
View File
@@ -48,6 +48,7 @@ export const getSiteModels = () => api.get('/api/dist/site/models');
export const getSitePricing = () => api.get('/api/dist/site/pricing');
export const getSitePackages = () => api.get('/api/dist/site/packages');
export const getSiteKeyGroups = () => api.get('/api/dist/site/key-groups');
export const getSubDistributorInfo = () => api.get('/api/dist/site/sub-distributor/info');
// ===== Auth =====
export const register = (data) => api.post('/api/dist/user/register', data);
@@ -86,6 +87,7 @@ export const transferAffQuota = (data) => api.post('/api/dist/aff_transfer', dat
export const getAffEarnings = (params) => api.get('/api/dist/aff_earnings', { params });
export const getAffPayouts = (params) => api.get('/api/dist/aff_payouts', { params });
export const requestAffWithdraw = (data) => api.post('/api/dist/aff_withdraw', data);
export const createSubDistributorOrder = (data) => api.post('/api/dist/site/sub-distributor/pay', data);
// ===== Helpers =====
export const quotaToDollar = (quota) => (quota / Q).toFixed(4);
+40
View File
@@ -93,6 +93,46 @@
"dashboard.apiDocs": "API Docs",
"dashboard.comingSoon": "Coming soon",
"subDist.nav": "Launch Sub-site",
"subDist.dashboardEntry": "Pay once and manage your own sub-site",
"subDist.badge": "Site Owner Plan",
"subDist.title": "Launch Your Own Sub-site",
"subDist.subtitle": "Pay through {{name}} to open your own sub-site. After payment, you can manage it from the main distributor console.",
"subDist.defaultSiteName": "this site",
"subDist.notAvailable": "Sub-site launch is currently unavailable",
"subDist.disabledFallback": "Please contact the site owner to enable this feature",
"subDist.loginHint": "Please log in or register on this site first. After successful payment, your account will be upgraded to a site owner.",
"subDist.goLogin": "Log In",
"subDist.goRegister": "Register",
"subDist.siteName": "Site Name",
"subDist.siteNamePlaceholder": "e.g. My AI Site",
"subDist.siteSlug": "Site Slug",
"subDist.siteSlugHelp": "This will become your site identifier and cannot be changed after payment.",
"subDist.paymentMethod": "Payment Method",
"subDist.chain": "Blockchain",
"subDist.token": "Token",
"subDist.payAndOpen": "Pay ¥{{price}} to Launch",
"subDist.currentUserHint": "Current paying account: {{user}}, payment method: {{method}}",
"subDist.manageHint": "After payment, go to the main distributor console to manage your new sub-site. If you are not logged in there yet, sign in with the same account.",
"subDist.cryptoTitle": "Crypto Order Created",
"subDist.cryptoHint": "Please transfer using the details below. Your sub-site will be activated automatically after confirmation.",
"subDist.wallet": "Wallet",
"subDist.amount": "Amount",
"subDist.tradeNo": "Trade No",
"subDist.priceCardTitle": "Pricing",
"subDist.openPrice": "Launch Price",
"subDist.platformBase": "Platform Base Cost",
"subDist.parentSpread": "Owner Spread",
"subDist.ruleTitle": "Profit Rules",
"subDist.rule1": "You pay the launch price set by the current site owner. Any amount above the platform base cost is recorded as the parent site's one-time launch profit.",
"subDist.rule2": "If the parent site owner has already purchased a higher discount tier and you have not, the discount spread will continue to accrue to the parent site. Current parent discount: {{discount}}%.",
"subDist.rule3": "Once activated, your account is upgraded to a site owner and you can continue setup from the main distributor console.",
"subDist.manageUrlLabel": "Main distributor console:",
"subDist.loginRequired": "Please log in before launching a sub-site",
"subDist.fillRequired": "Please fill in the site name, slug, and payment method",
"subDist.selectPayment": "Please select a payment method",
"subDist.createFailed": "Failed to create order",
"tokens.title": "API Keys",
"tokens.subtitle": "Manage your API access tokens",
"tokens.newKey": "+ New Key",
+40
View File
@@ -93,6 +93,46 @@
"dashboard.apiDocs": "API 文档",
"dashboard.comingSoon": "即将推出",
"subDist.nav": "开通分站",
"subDist.dashboardEntry": "付费开通并管理自己的分站",
"subDist.badge": "站长计划",
"subDist.title": "开通你的下级分站",
"subDist.subtitle": "基于 {{name}} 直接付费开通自己的分站。支付完成后,可前往主站分销后台管理和设置。",
"subDist.defaultSiteName": "当前分站",
"subDist.notAvailable": "当前暂未开放下级分站",
"subDist.disabledFallback": "请联系站长开启该功能",
"subDist.loginHint": "请先登录或注册当前分站账户,支付成功后会自动获得分站站长权限。",
"subDist.goLogin": "去登录",
"subDist.goRegister": "去注册",
"subDist.siteName": "站点名称",
"subDist.siteNamePlaceholder": "例如:我的 AI 分站",
"subDist.siteSlug": "站点标识",
"subDist.siteSlugHelp": "将作为您的专属分站标识,支付成功后不可修改。",
"subDist.paymentMethod": "支付方式",
"subDist.chain": "区块链",
"subDist.token": "代币",
"subDist.payAndOpen": "支付开通 ¥{{price}}",
"subDist.currentUserHint": "当前支付账号:{{user}},支付方式:{{method}}",
"subDist.manageHint": "支付完成后,请前往主站的分销后台管理您的新分站;如未登录主站,请使用同一账号重新登录即可。",
"subDist.cryptoTitle": "加密货币订单已创建",
"subDist.cryptoHint": "请按照以下信息转账,到账后系统会自动开通分站。",
"subDist.wallet": "收款地址",
"subDist.amount": "支付金额",
"subDist.tradeNo": "订单号",
"subDist.priceCardTitle": "费用说明",
"subDist.openPrice": "当前开通价",
"subDist.platformBase": "平台基础成本",
"subDist.parentSpread": "站长开通差价",
"subDist.ruleTitle": "收益规则",
"subDist.rule1": "您支付的是当前分站站长设置的开通价;若高于平台基础成本,差价会直接记入上级站长收益。",
"subDist.rule2": "后续如果上级站长已经买了更高折扣,而您还没买到同档折扣,这部分折扣差也会持续沉淀为上级收益。当前上级折扣:{{discount}}%。",
"subDist.rule3": "开通成功后,您会升级为分站站长,可以进入主站分销后台继续配置域名、主题、价格和模型。",
"subDist.manageUrlLabel": "主站分销后台:",
"subDist.loginRequired": "请先登录后再开通分站",
"subDist.fillRequired": "请填写站点名称、标识和支付方式",
"subDist.selectPayment": "请选择支付方式",
"subDist.createFailed": "创建订单失败",
"tokens.title": "API 密钥",
"tokens.subtitle": "管理您的 API 访问令牌",
"tokens.newKey": "+ 新建密钥",
+8 -1
View File
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { getUserUsage, redeemCode, getAffCode, transferAffQuota, getAffEarnings, Q, quotaToDollar } from '../api';
import { useCurrency } from '../context/SiteContext';
import { useCurrency, useSite } from '../context/SiteContext';
import CountUp from '../components/bits/CountUp';
import toast from 'react-hot-toast';
@@ -11,6 +11,7 @@ export default function Dashboard() {
const { t } = useTranslation();
const { user, refreshUser } = useAuth();
const { symbol, rate } = useCurrency();
const { site } = useSite();
const [usage, setUsage] = useState(null);
const [redeemInput, setRedeemInput] = useState('');
const [redeeming, setRedeeming] = useState(false);
@@ -175,6 +176,12 @@ export default function Dashboard() {
<p className="text-sm font-medium text-page group-hover:text-page-link transition-colors">{t('dashboard.logs')}</p>
<p className="text-xs text-page-muted">{t('dashboard.viewLogs')}</p>
</Link>
{site?.allow_sub_dist && (
<Link to="/sub-site" className="glass-sm !rounded-xl px-4 py-3 hover:bg-page-surface-hover transition-colors group">
<p className="text-sm font-medium text-page group-hover:text-page-link transition-colors">{t('subDist.nav')}</p>
<p className="text-xs text-page-muted">{t('subDist.dashboardEntry')}</p>
</Link>
)}
</div>
</div>
</div>
+283
View File
@@ -0,0 +1,283 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { createSubDistributorOrder, getSubDistributorInfo } from '../api';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
function submitEpayForm(resData) {
const params = resData.data;
const url = resData.url;
if (!params || !url) return;
const form = document.createElement('form');
form.action = url;
form.method = 'POST';
const isSafari = navigator.userAgent.indexOf('Safari') > -1 && navigator.userAgent.indexOf('Chrome') < 1;
if (!isSafari) {
form.target = '_blank';
}
Object.keys(params).forEach((key) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = params[key];
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
export default function SubDistributor() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const [loading, setLoading] = useState(true);
const [subInfo, setSubInfo] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [cryptoOrder, setCryptoOrder] = useState(null);
const [form, setForm] = useState({
name: '',
slug: '',
payment_method: '',
chain: 'tron',
token: 'usdt',
});
useEffect(() => {
getSubDistributorInfo()
.then((res) => {
if (res.data.success) {
const info = res.data.data;
setSubInfo(info);
if (info.pay_methods?.length > 0) {
setForm((prev) => ({ ...prev, payment_method: info.pay_methods[0].type }));
}
}
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const paymentMethods = subInfo?.pay_methods || [];
const currentPayMethod = useMemo(
() => paymentMethods.find((item) => item.type === form.payment_method),
[paymentMethods, form.payment_method]
);
const handleSubmit = async (e) => {
e.preventDefault();
if (!user) {
toast.error(t('subDist.loginRequired'));
return;
}
if (!form.name.trim() || !form.slug.trim()) {
toast.error(t('subDist.fillRequired'));
return;
}
if (!form.payment_method) {
toast.error(t('subDist.selectPayment'));
return;
}
setSubmitting(true);
setCryptoOrder(null);
try {
const payload = {
name: form.name.trim(),
slug: form.slug.trim().toLowerCase(),
payment_method: form.payment_method,
return_url: subInfo?.manage_url || '',
};
if (form.payment_method === 'crypto') {
payload.chain = form.chain;
payload.token = form.token;
}
const res = await createSubDistributorOrder(payload);
if (res.data.message === 'success') {
if (res.data.payment_type === 'stripe' && res.data.data?.pay_link) {
window.open(res.data.data.pay_link, '_blank');
} else if (res.data.payment_type === 'crypto') {
setCryptoOrder(res.data.data);
} else {
submitEpayForm(res.data);
}
} else if (res.data.data) {
toast.error(typeof res.data.data === 'string' ? res.data.data : t('subDist.createFailed'));
}
} catch (e) {
// handled by interceptor
}
setSubmitting(false);
};
if (loading) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-white/20 border-t-white animate-spin" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto px-6 py-12">
<div className="grid lg:grid-cols-[1.1fr_0.9fr] gap-6 items-start">
<div className="glass rounded-3xl p-8">
<div className="mb-8">
<p className="text-sm text-page-link font-medium mb-3">{t('subDist.badge')}</p>
<h1 className="text-3xl font-heading font-bold text-page mb-3">{t('subDist.title')}</h1>
<p className="text-sm text-page-secondary leading-6">
{t('subDist.subtitle', { name: site?.name || t('subDist.defaultSiteName') })}
</p>
</div>
{!subInfo?.enabled ? (
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 p-5">
<p className="text-sm font-medium text-page mb-2">{t('subDist.notAvailable')}</p>
<p className="text-sm text-page-secondary">{subInfo?.disabled_reason || t('subDist.disabledFallback')}</p>
</div>
) : !user ? (
<div className="rounded-2xl border border-border p-5 space-y-4">
<p className="text-sm text-page-secondary">{t('subDist.loginHint')}</p>
<div className="flex flex-wrap gap-3">
<Link to="/login" className="btn-primary">{t('subDist.goLogin')}</Link>
<Link to="/register" className="btn-secondary">{t('subDist.goRegister')}</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-page-label mb-2">{t('subDist.siteName')}</label>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="input"
placeholder={t('subDist.siteNamePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-page-label mb-2">{t('subDist.siteSlug')}</label>
<input
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value.toLowerCase() })}
className="input"
placeholder="my-sub-site"
required
/>
<p className="text-xs text-page-muted mt-2">{t('subDist.siteSlugHelp')}</p>
</div>
<div>
<label className="block text-sm font-medium text-page-label mb-2">{t('subDist.paymentMethod')}</label>
<div className="grid sm:grid-cols-2 gap-3">
{paymentMethods.map((method) => (
<label
key={method.type}
className={`rounded-2xl border px-4 py-3 cursor-pointer transition-colors ${
form.payment_method === method.type
? 'border-page-link bg-page-link/10'
: 'border-border hover:bg-page-surface-hover'
}`}
>
<input
type="radio"
className="sr-only"
checked={form.payment_method === method.type}
onChange={() => setForm({ ...form, payment_method: method.type })}
/>
<div className="text-sm font-medium text-page">{method.name || method.type}</div>
</label>
))}
</div>
</div>
{form.payment_method === 'crypto' && (
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-page-label mb-2">{t('subDist.chain')}</label>
<select value={form.chain} onChange={(e) => setForm({ ...form, chain: e.target.value })} className="input">
<option value="tron">TRON (TRC20)</option>
<option value="eth">Ethereum (ERC20)</option>
<option value="bsc">BSC (BEP20)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-page-label mb-2">{t('subDist.token')}</label>
<select value={form.token} onChange={(e) => setForm({ ...form, token: e.target.value })} className="input">
<option value="usdt">USDT</option>
<option value="usdc">USDC</option>
</select>
</div>
</div>
)}
<button type="submit" disabled={submitting} className="btn-primary w-full justify-center flex items-center gap-2">
{submitting && <span className="w-4 h-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
{t('subDist.payAndOpen', { price: Number(subInfo?.price || 0).toFixed(2) })}
</button>
<p className="text-xs text-page-muted">
{t('subDist.currentUserHint', { user: user.display_name || user.username || 'User', method: currentPayMethod?.name || form.payment_method })}
</p>
<p className="text-xs text-page-muted">
{t('subDist.manageHint')}
</p>
</form>
)}
{cryptoOrder && (
<div className="mt-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-5 space-y-2">
<p className="text-sm font-medium text-page">{t('subDist.cryptoTitle')}</p>
<p className="text-sm text-page-secondary">{t('subDist.cryptoHint')}</p>
<div className="text-sm text-page">
<div>{t('subDist.wallet')}: <span className="font-mono break-all">{cryptoOrder.wallet}</span></div>
<div>{t('subDist.amount')}: <span className="font-mono">{cryptoOrder.amount} {cryptoOrder.token}</span></div>
<div>{t('subDist.tradeNo')}: <span className="font-mono break-all">{cryptoOrder.trade_no}</span></div>
</div>
</div>
)}
</div>
<div className="space-y-6">
<div className="glass rounded-3xl p-6">
<h2 className="text-lg font-semibold text-page mb-4">{t('subDist.priceCardTitle')}</h2>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-page-secondary">{t('subDist.openPrice')}</span>
<span className="text-2xl font-bold text-page">¥{Number(subInfo?.price || 0).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-page-secondary">{t('subDist.platformBase')}</span>
<span className="text-page">¥{Number(subInfo?.base_cost || 0).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-page-secondary">{t('subDist.parentSpread')}</span>
<span className="text-page-link font-semibold">¥{Number(subInfo?.price_spread || 0).toFixed(2)}</span>
</div>
</div>
</div>
<div className="glass rounded-3xl p-6">
<h2 className="text-lg font-semibold text-page mb-4">{t('subDist.ruleTitle')}</h2>
<div className="space-y-3 text-sm text-page-secondary leading-6">
<p>{t('subDist.rule1')}</p>
<p>{t('subDist.rule2', { discount: Number(subInfo?.seller_discount || 0).toFixed(0) })}</p>
<p>{t('subDist.rule3')}</p>
{subInfo?.manage_url && (
<p>
{t('subDist.manageUrlLabel')}{' '}
<a className="text-page-link hover:underline" href={subInfo.manage_url} target="_blank" rel="noreferrer">
{subInfo.manage_url}
</a>
</p>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+1
View File
@@ -19,6 +19,7 @@ export default function ClaudeLayout() {
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
+1
View File
@@ -19,6 +19,7 @@ export default function CleanLayout() {
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
+1
View File
@@ -19,6 +19,7 @@ export default function CorporateLayout() {
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
+1
View File
@@ -19,6 +19,7 @@ export default function DarkLayout() {
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
+1
View File
@@ -19,6 +19,7 @@ export default function DefaultLayout() {
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
+1
View File
@@ -19,6 +19,7 @@ export default function MinimalLayout() {
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
+1
View File
@@ -19,6 +19,7 @@ export default function StarterLayout() {
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },