feat(billing): add formula A/B admin UI and subscription renew status

Expose dual metering formula editing with batch user assignment, show
auto-renew and pending-recharge states in subscriptions, and document
billing acceptance checks for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-22 08:45:43 +08:00
parent a503fa9cbe
commit f6bb02254e
8 changed files with 503 additions and 171 deletions
+379 -162
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
assignBillingFormula,
cancelUserSubscription,
createSubscriptionPlan,
deleteSubscriptionPlan,
@@ -22,6 +23,7 @@ import type {
AdminSubscription,
AdminUserRow,
BillingAdminConfig,
BillingFormulaKey,
LedgerEntry,
PlanDefinition,
UsageRecord,
@@ -175,11 +177,298 @@ const DEFAULT_BILLING_FORMULA: BillingAdminConfig = {
costEstimateOutputUsdPer1M: 1.1,
};
const FORMULA_TABS: BillingFormulaKey[] = ['A', 'B'];
function FormulaConfigForm({
formulaKey,
draft,
source,
updatedAt,
envLocked,
saving,
onDraftChange,
onSave,
}: {
formulaKey: BillingFormulaKey;
draft: BillingAdminConfig;
source: string;
updatedAt: number | null;
envLocked: boolean;
saving: boolean;
onDraftChange: (next: BillingAdminConfig) => void;
onSave: (event: React.FormEvent) => void;
}) {
const patchNumber = (key: keyof BillingAdminConfig, value: string) => {
const num = Number(value);
onDraftChange({
...draft,
[key]: Number.isFinite(num) ? num : draft[key],
});
};
return (
<form className="admin-form" onSubmit={onSave}>
<p className="muted">
{formulaKey} · {SOURCE_LABELS[source] ?? source}
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
<label className="inline-check">
<input
type="checkbox"
checked={draft.useBackendCost}
disabled={envLocked}
onChange={(event) => onDraftChange({ ...draft, useBackendCost: event.target.checked })}
/>
<span>
<strong></strong>
<span className="muted"> USD </span>
</span>
</label>
<label>
<span>USDCNY</span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.usdCnyRate}
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.marginMultiplier}
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
step="1"
disabled={envLocked}
value={draft.minBillCents}
onChange={(e) => patchNumber('minBillCents', e.target.value)}
/>
</label>
<h3>Token 退</h3>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.inputCentsPer1k}
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
/>
</label>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.outputCentsPer1k}
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
/>
</label>
<h3>Finish cost </h3>
<label className="inline-check">
<input
type="checkbox"
checked={draft.costEstimateFromTokens}
disabled={envLocked || !draft.useBackendCost}
onChange={(event) => onDraftChange({
...draft,
costEstimateFromTokens: event.target.checked,
})}
/>
<span>
<strong> Token </strong>
<span className="muted"> </span>
</span>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateInputUsdPer1M}
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
/>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateOutputUsdPer1M}
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
/>
</label>
<p className="muted">
USD × {draft.usdCnyRate} × {draft.marginMultiplier}
</p>
<button type="submit" className="send-btn" disabled={saving || envLocked}>
{saving ? '保存中…' : `保存计量公式 ${formulaKey}`}
</button>
</form>
);
}
function FormulaAssignmentSection() {
const { users, reload, error, setError } = useAdminUsers();
const [selected, setSelected] = useState<string[]>([]);
const [formulaFilter, setFormulaFilter] = useState<'all' | BillingFormulaKey>('all');
const [search, setSearch] = useState('');
const [assigning, setAssigning] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const filteredUsers = users.filter((user) => {
if (user.role !== 'user') return false;
const formula = user.billingFormula ?? 'A';
if (formulaFilter !== 'all' && formula !== formulaFilter) return false;
if (!search.trim()) return true;
const q = search.toLowerCase();
return user.username.toLowerCase().includes(q) || user.displayName.toLowerCase().includes(q);
});
const toggleUser = (userId: string) => {
setSelected((prev) => (
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
));
};
const toggleAll = () => {
const ids = filteredUsers.map((user) => user.id);
const allSelected = ids.length > 0 && ids.every((id) => selected.includes(id));
setSelected((prev) => (
allSelected ? prev.filter((id) => !ids.includes(id)) : [...new Set([...prev, ...ids])]
));
};
const handleAssign = async (formula: BillingFormulaKey) => {
if (!selected.length) return;
setAssigning(true);
setMessage(null);
setError(null);
try {
const result = await assignBillingFormula(selected, formula);
setMessage(`已将 ${result.updated} 位用户分配到计量公式 ${formula}`);
setSelected([]);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : '批量分配失败');
} finally {
setAssigning(false);
}
};
return (
<section className="admin-card">
<h2></h2>
<p className="muted"> A B使 A</p>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<div className="billing-toolbar">
<input
className="users-search-input"
placeholder="搜索用户名 / 显示名"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<select
className="billing-filter-select"
value={formulaFilter}
onChange={(event) => setFormulaFilter(event.target.value as 'all' | BillingFormulaKey)}
>
<option value="all"></option>
<option value="A"> A</option>
<option value="B"> B</option>
</select>
<button type="button" className="ghost-btn" onClick={() => void reload()} disabled={assigning}>
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('A')}>
A
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('B')}>
B
</button>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={filteredUsers.length > 0 && filteredUsers.every((user) => selected.includes(user.id))}
onChange={toggleAll}
/>
</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id}>
<td>
<input
type="checkbox"
checked={selected.includes(user.id)}
onChange={() => toggleUser(user.id)}
/>
</td>
<td>
<span>{user.displayName}</span>
<span className="muted"> @{user.username}</span>
</td>
<td><strong>{user.billingFormula ?? 'A'}</strong></td>
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredUsers.length === 0 ? <p className="muted billing-empty"></p> : null}
</section>
);
}
function FormulaTab() {
const [draft, setDraft] = useState<BillingAdminConfig>(DEFAULT_BILLING_FORMULA);
const [source, setSource] = useState('default');
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [formula, setFormula] = useState<string>('');
const [activeFormula, setActiveFormula] = useState<BillingFormulaKey>('A');
const [drafts, setDrafts] = useState<Record<BillingFormulaKey, BillingAdminConfig>>({
A: DEFAULT_BILLING_FORMULA,
B: DEFAULT_BILLING_FORMULA,
});
const [meta, setMeta] = useState<Record<BillingFormulaKey, { source: string; updatedAt: number | null }>>({
A: { source: 'default', updatedAt: null },
B: { source: 'default', updatedAt: null },
});
const [formulaText, setFormulaText] = useState('');
const [envLocked, setEnvLocked] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -190,11 +479,22 @@ function FormulaTab() {
setLoading(true);
setError(null);
try {
const result = await getBillingConfig();
setDraft(result.config);
setSource(result.source ?? 'default');
setUpdatedAt(result.updatedAt ?? null);
setFormula(result.formula ?? '');
const result = await getBillingConfig('A');
setDrafts({
A: result.formulas?.A ?? result.config,
B: result.formulas?.B ?? result.config,
});
setMeta({
A: {
source: result.formulaMeta?.A?.source ?? result.source ?? 'default',
updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null,
},
B: {
source: result.formulaMeta?.B?.source ?? result.source ?? 'default',
updatedAt: result.formulaMeta?.B?.updatedAt ?? null,
},
});
setFormulaText(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
} catch (err) {
setError(err instanceof Error ? err.message : '加载计量公式失败');
@@ -207,16 +507,9 @@ function FormulaTab() {
void load();
}, [load]);
const patchNumber = (key: keyof BillingAdminConfig, value: string) => {
const num = Number(value);
setDraft((prev) => ({
...prev,
[key]: Number.isFinite(num) ? num : prev[key],
}));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const draft = drafts[activeFormula];
if (envLocked) {
setError('当前环境已锁定为仅读 envH5_BILLING_CONFIG_SOURCE=env),无法保存。');
return;
@@ -228,7 +521,7 @@ function FormulaTab() {
if (
draft.useBackendCost
&& !window.confirm(
`确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
`确认保存公式 ${activeFormula} 成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
)
) {
return;
@@ -237,13 +530,24 @@ function FormulaTab() {
setError(null);
setMessage(null);
try {
const result = await updateBillingConfig(draft);
setDraft(result.config);
setSource(result.source ?? 'admin-db');
setUpdatedAt(result.updatedAt ?? null);
setFormula(result.formula ?? '');
const result = await updateBillingConfig(draft, activeFormula);
setDrafts({
A: result.formulas?.A ?? drafts.A,
B: result.formulas?.B ?? drafts.B,
});
setMeta({
A: {
source: result.formulaMeta?.A?.source ?? result.source ?? 'admin-db',
updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null,
},
B: {
source: result.formulaMeta?.B?.source ?? result.source ?? 'admin-db',
updatedAt: result.formulaMeta?.B?.updatedAt ?? result.updatedAt ?? null,
},
});
setFormulaText(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
setMessage('计量公式已保存。Portal 扣费会在数秒内读取新配置,无需重启。');
setMessage(`计量公式 ${activeFormula} 已保存。Portal 扣费会在数秒内读取新配置,无需重启。`);
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
@@ -252,144 +556,45 @@ function FormulaTab() {
};
return (
<section className="admin-card">
<h2></h2>
<p className="muted">
= (USD) × × 退 Token
</p>
{loading ? <p className="muted"></p> : null}
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{!loading ? (
<form className="admin-form" onSubmit={handleSave}>
<p className="muted">
{SOURCE_LABELS[source] ?? source}
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
{formula ? <p className="muted">{formula}</p> : null}
<label className="inline-check">
<input
type="checkbox"
checked={draft.useBackendCost}
disabled={envLocked}
onChange={(event) => setDraft((prev) => ({ ...prev, useBackendCost: event.target.checked }))}
/>
<span>
<strong></strong>
<span className="muted"> USD </span>
</span>
</label>
<label>
<span>USDCNY</span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.usdCnyRate}
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.marginMultiplier}
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
step="1"
disabled={envLocked}
value={draft.minBillCents}
onChange={(e) => patchNumber('minBillCents', e.target.value)}
/>
</label>
<h3>Token 退</h3>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.inputCentsPer1k}
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
/>
</label>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.outputCentsPer1k}
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
/>
</label>
<h3>Finish cost </h3>
<label className="inline-check">
<input
type="checkbox"
checked={draft.costEstimateFromTokens}
disabled={envLocked || !draft.useBackendCost}
onChange={(event) => setDraft((prev) => ({
...prev,
costEstimateFromTokens: event.target.checked,
}))}
/>
<span>
<strong> Token </strong>
<span className="muted"> </span>
</span>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateInputUsdPer1M}
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
/>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateOutputUsdPer1M}
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
/>
</label>
<p className="muted">
USD × {draft.usdCnyRate} × {draft.marginMultiplier}
</p>
<button type="submit" className="send-btn" disabled={saving || envLocked}>
{saving ? '保存中…' : '保存计量公式'}
</button>
</form>
) : null}
</section>
<>
<section className="admin-card">
<h2></h2>
<p className="muted">
A / B A B
</p>
{formulaText ? <p className="muted">{formulaText}</p> : null}
<div className="admin-tabs" role="tablist">
{FORMULA_TABS.map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={activeFormula === key}
className={`admin-tab${activeFormula === key ? ' active' : ''}`}
onClick={() => setActiveFormula(key)}
>
{key}
</button>
))}
</div>
{loading ? <p className="muted"></p> : null}
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{!loading ? (
<FormulaConfigForm
formulaKey={activeFormula}
draft={drafts[activeFormula]}
source={meta[activeFormula].source}
updatedAt={meta[activeFormula].updatedAt}
envLocked={envLocked}
saving={saving}
onDraftChange={(next) => setDrafts((prev) => ({ ...prev, [activeFormula]: next }))}
onSave={handleSave}
/>
) : null}
</section>
<FormulaAssignmentSection />
</>
);
}
@@ -1545,6 +1750,12 @@ function SubscriptionsTab() {
};
const STATUS_LABEL: Record<string, string> = { active: '有效', expired: '已到期', cancelled: '已取消' };
const autoRenewLabel = (row: AdminSubscription) => {
if (!row.autoRenew) return '未开启';
if (row.status === 'active') return '已开启';
if (row.status === 'expired') return '待补扣';
return '—';
};
return (
<>
@@ -1637,6 +1848,7 @@ function SubscriptionsTab() {
<th></th>
<th></th>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}>Token </th>
<th style={{ textAlign: 'right' }}></th>
<th></th>
@@ -1657,6 +1869,11 @@ function SubscriptionsTab() {
{STATUS_LABEL[row.status] ?? row.status}
</span>
</td>
<td>
<span className={row.autoRenew && row.status === 'expired' ? 'text-error' : undefined}>
{autoRenewLabel(row)}
</span>
</td>
<td className="billing-num">
{row.periodTokensUsed.toLocaleString()}
{row.periodTokensLimit > 0 && (
+23
View File
@@ -273,6 +273,29 @@ export function UserDetailPage() {
)}
</dd>
</div>
<div>
<dt></dt>
<dd>
<select
className="admin-select"
value={user.billingFormula ?? 'A'}
onChange={(event) => {
const billingFormula = event.target.value as 'A' | 'B';
setLocalError(null);
void updateAdminUser(user.id, { billingFormula }).then((nextUser) => {
setUser(nextUser);
setMessage(`已切换到计量公式 ${billingFormula}`);
void reload();
}).catch((err) => {
setLocalError(err instanceof Error ? err.message : '计量公式更新失败');
});
}}
>
<option value="A"> A</option>
<option value="B"> B</option>
</select>
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">{user.workspaceRoot}</dd>