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:
+379
-162
@@ -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>汇率(USD→CNY)</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('当前环境已锁定为仅读 env(H5_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>汇率(USD→CNY)</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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
+16
-3
@@ -887,6 +887,7 @@ export async function updateAdminUser(
|
||||
balanceCents: number;
|
||||
spaceQuotaBytes: number;
|
||||
role: 'user' | 'admin';
|
||||
billingFormula: 'A' | 'B';
|
||||
}>,
|
||||
): Promise<PortalUser> {
|
||||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, {
|
||||
@@ -1409,16 +1410,28 @@ export async function syncSubscriptionPlansToProduction(): Promise<PlanSyncResul
|
||||
return result.sync;
|
||||
}
|
||||
|
||||
export async function getBillingConfig(): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config');
|
||||
export async function getBillingConfig(formula: 'A' | 'B' = 'A'): Promise<BillingAdminConfigResponse> {
|
||||
const q = formula === 'B' ? '?formula=B' : '';
|
||||
return portalFetch(`/admin-api/billing/config${q}`);
|
||||
}
|
||||
|
||||
export async function updateBillingConfig(
|
||||
config: BillingAdminConfig,
|
||||
formula: 'A' | 'B' = 'A',
|
||||
): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
body: JSON.stringify({ config, formula }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignBillingFormula(
|
||||
userIds: string[],
|
||||
formula: 'A' | 'B',
|
||||
): Promise<{ ok: boolean; updated: number; formula: 'A' | 'B'; message?: string }> {
|
||||
return portalFetch('/admin-api/billing/formula-assignments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userIds, formula }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export type PortalUser = {
|
||||
balanceCents: number;
|
||||
totalCreditCents?: number;
|
||||
tokensUsed: number;
|
||||
billingFormula?: 'A' | 'B';
|
||||
spaceQuotaBytes?: number;
|
||||
spaceUsedBytes?: number;
|
||||
spaceReservedBytes?: number;
|
||||
@@ -44,6 +45,7 @@ export type AuthStatus = {
|
||||
};
|
||||
|
||||
export type AdminUserRow = PortalUser & {
|
||||
billingFormula?: 'A' | 'B';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -676,8 +678,20 @@ export type BillingAdminConfig = {
|
||||
costEstimateOutputUsdPer1M: number;
|
||||
};
|
||||
|
||||
export type BillingFormulaKey = 'A' | 'B';
|
||||
|
||||
export type BillingFormulaMeta = {
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type BillingAdminConfigResponse = {
|
||||
config: BillingAdminConfig;
|
||||
formulas?: Record<BillingFormulaKey, BillingAdminConfig>;
|
||||
formulaMeta?: Record<BillingFormulaKey, BillingFormulaMeta>;
|
||||
activeFormula?: BillingFormulaKey;
|
||||
defaultFormula?: BillingFormulaKey;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source?: string;
|
||||
@@ -722,6 +736,7 @@ export type AdminSubscription = {
|
||||
periodImagesLimit: number;
|
||||
periodImagesUsed: number;
|
||||
periodImagesBonus?: number;
|
||||
autoRenew?: boolean;
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user