Files
NianAIGC/components/billing-manager.tsx
2026-08-21 13:44:56 +08:00

772 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent, type ReactNode } from "react";
import {
ArrowUpRight,
Banknote,
Building2,
Check,
ChevronRight,
CircleDollarSign,
Landmark,
Loader2,
Pencil,
Plus,
ReceiptText,
RefreshCw,
Settings2,
UsersRound,
WalletCards,
X
} from "lucide-react";
import { billingUnitLabel, formatBillingAmount } from "@/lib/billing";
import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api";
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types";
type BillingPayload = {
organization: { id: string; name: string };
billingAccount: BillingAccountConfig;
wallet: OrganizationWallet;
ledger: LedgerEntry[];
summary: LedgerSummary;
personal: LedgerSummary;
};
type LedgerEntry = {
id: string;
organizationId: string;
accountId?: string;
jobId?: string;
kind: "recharge" | "charge" | "refund" | "adjustment";
deltaFen: number;
balanceAfterFen: number;
description: string;
metadata: Record<string, unknown>;
createdAt: string;
};
type LedgerSummary = {
rechargeFen: number;
chargedFen: number;
refundedFen: number;
netConsumedFen: number;
};
type AdminMember = {
id: string;
displayName: string;
phone: string;
role: "super_admin" | "organization_admin" | "user";
organizationId: string | null;
status: "active" | "disabled";
};
type AdminPayload = {
billingAccount: BillingAccountConfig;
organizations: Array<{
id: string;
name: string;
status: string;
wallet: OrganizationWallet;
}>;
members: AdminMember[];
ledger: LedgerEntry[];
priceRules: BillingPriceRule[];
};
type BillingTabId = "overview" | "ledger" | "pricing" | "balance" | "account";
type BillingTabDefinition = {
id: BillingTabId;
label: string;
};
type AdjustmentDraft = {
organizationId: string;
direction: "credit" | "debit";
amountYuan: string;
note: string;
};
type PriceEditTarget = {
dimensionKey?: string;
tierValue?: string;
label: string;
markupMultiplier: number;
};
type PriceEditState = {
rule: BillingPriceRule;
target: PriceEditTarget;
};
const emptyAccountDraft: BillingAccountConfig = {
accountName: "",
bankName: "",
accountNumber: "",
contact: ""
};
const emptyAdjustmentDraft: AdjustmentDraft = {
organizationId: "",
direction: "credit",
amountYuan: "",
note: ""
};
export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const [billing, setBilling] = useState<BillingPayload | null>(null);
const [admin, setAdmin] = useState<AdminPayload | null>(null);
const [accountDraft, setAccountDraft] = useState<BillingAccountConfig>(emptyAccountDraft);
const [adjustmentDraft, setAdjustmentDraft] = useState<AdjustmentDraft>(emptyAdjustmentDraft);
const [activeTab, setActiveTab] = useState<BillingTabId>("overview");
const [editingAccount, setEditingAccount] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [priceEditState, setPriceEditState] = useState<PriceEditState | null>(null);
const managerRef = useRef<HTMLDivElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
const tabs = useMemo<BillingTabDefinition[]>(() => isSuperAdmin ? [
{ id: "overview", label: "概览" },
{ id: "pricing", label: "价格与计费" },
{ id: "balance", label: "余额与上账" },
{ id: "account", label: "收款设置" }
] : [
{ id: "overview", label: "概览" },
{ id: "ledger", label: "账务流水" }
], [isSuperAdmin]);
const selectedAdjustmentOrganization = useMemo(
() => admin?.organizations.find((organization) => organization.id === adjustmentDraft.organizationId),
[admin?.organizations, adjustmentDraft.organizationId]
);
useEffect(() => {
void load();
setActiveTab("overview");
}, [isSuperAdmin]);
useEffect(() => runScopedMotion(managerRef, (scope) => revealChildren(scope)), []);
useEffect(() => {
pulseFeedback(feedbackRef.current);
}, [error, notice]);
async function load() {
setLoading(true);
setError(null);
try {
if (isSuperAdmin) {
const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" });
const rawAdminPayload = await readApiPayload<AdminPayload>(adminResponse);
if (!adminResponse.ok) throw new Error(rawAdminPayload.error || "读取超管计费数据失败");
const adminPayload = parseAdminBillingPayload(rawAdminPayload);
setAdmin(adminPayload);
setBilling(buildAdminBillingPayload(adminPayload));
setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount });
setAdjustmentDraft((current) => ({
...current,
organizationId: current.organizationId || adminPayload.organizations[0]?.id || ""
}));
} else {
const billingResponse = await fetch("/api/billing", { cache: "no-store" });
const rawBillingPayload = await readApiPayload<BillingPayload>(billingResponse);
if (!billingResponse.ok) throw new Error(rawBillingPayload.error || "读取计费数据失败");
const billingPayload = parseMemberBillingPayload(rawBillingPayload);
setBilling(billingPayload);
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
} finally {
setLoading(false);
}
}
async function saveBillingAccount(event: FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
setNotice(null);
try {
const response = await fetch("/api/admin/billing/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(accountDraft)
});
const payload = await readApiPayload<{ billingAccount?: BillingAccountConfig }>(response);
if (!response.ok) throw new Error(payload.error || "保存收款账户失败");
setAccountDraft({ ...emptyAccountDraft, ...(payload.billingAccount || accountDraft) });
setEditingAccount(false);
setNotice("对公收款账户已更新,成员将看到最新信息。");
await load();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
} finally {
setSaving(false);
}
}
async function submitAdjustment(event: FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
setNotice(null);
try {
const response = await fetch("/api/admin/billing/adjustments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
organizationId: adjustmentDraft.organizationId,
direction: adjustmentDraft.direction,
amountYuan: adjustmentDraft.amountYuan,
note: adjustmentDraft.note
})
});
const payload = await readApiPayload<{ wallet?: OrganizationWallet }>(response);
if (!response.ok) throw new Error(payload.error || "余额调整失败");
setAdjustmentDraft((current) => ({ ...current, amountYuan: "", note: "" }));
setNotice(`${adjustmentDraft.direction === "credit" ? "上账" : "扣减"}已完成,${selectedAdjustmentOrganization?.name || "组织"}余额为 ${formatBillingAmount(payload.wallet?.balanceFen || 0)}`);
await load();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
} finally {
setSaving(false);
}
}
async function savePriceMultiplier(markupMultiplier: string) {
if (!priceEditState) return;
const { rule, target } = priceEditState;
const saved = await mutate(`/api/admin/billing/prices/${encodeURIComponent(rule.id)}`, {
markupMultiplier,
...(target.dimensionKey && target.tierValue !== undefined ? { dimensionKey: target.dimensionKey, tierValue: target.tierValue } : {})
}, `${target.label}倍率已更新。`);
if (saved) setPriceEditState(null);
}
async function mutate(url: string, body: Record<string, unknown>, successMessage: string, method = "PATCH"): Promise<boolean> {
setSaving(true);
setError(null);
setNotice(null);
try {
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const payload = await readApiPayload<Record<string, never>>(response);
if (!response.ok) throw new Error(payload.error || "保存失败");
setNotice(successMessage);
await load();
return true;
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
return false;
} finally {
setSaving(false);
}
}
return (
<div className="billing-manager billing-modern" ref={managerRef}>
<header className="billing-page-header" data-animate>
<div className="billing-page-heading">
<div className="billing-title-row">
<h1></h1>
<span className="billing-view-tag">{isSuperAdmin ? "超级管理员" : "成员视图"}</span>
</div>
</div>
<button className="billing-refresh" type="button" onClick={() => void load()} disabled={loading || saving}>
{loading ? <Loader2 className="spin" size={16} /> : <RefreshCw size={16} />}
</button>
</header>
<div className="billing-feedback" ref={feedbackRef}>
{error ? <div className="billing-alert billing-alert-error" role="alert">{error}</div> : null}
{notice ? <div className="billing-alert billing-alert-success" role="status">{notice}</div> : null}
</div>
{billing ? <BillingOverview billing={billing} isSuperAdmin={isSuperAdmin} /> : loading ? <BillingLoading /> : null}
{billing ? <BillingTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} /> : null}
{!isSuperAdmin && billing ? (
<>
{activeTab === "overview" ? <>
<LedgerSection entries={billing.ledger} limit={8} title="最近账务" action={<TabLink label="查看全部流水" onClick={() => setActiveTab("ledger")} />} />
</> : null}
{activeTab === "ledger" ? <LedgerSection entries={billing.ledger} title="账务流水" action={<span className="billing-section-count"> {Math.min(billing.ledger.length, 100)} </span>} /> : null}
</>
) : null}
{isSuperAdmin && admin && billing ? (
<>
{activeTab === "overview" ? <AdminOverview billing={billing} admin={admin} onChangeTab={setActiveTab} /> : null}
{activeTab === "pricing" ? <PriceManagement admin={admin} saving={saving} onEdit={(rule, target) => setPriceEditState({ rule, target })} /> : null}
{activeTab === "balance" ? <BalanceManagement admin={admin} adjustmentDraft={adjustmentDraft} setAdjustmentDraft={setAdjustmentDraft} selectedOrganization={selectedAdjustmentOrganization} saving={saving} onSubmit={submitAdjustment} /> : null}
{activeTab === "account" ? <AccountSettings account={admin.billingAccount} draft={accountDraft} editing={editingAccount} saving={saving} onEdit={() => setEditingAccount(true)} onCancel={() => { setEditingAccount(false); setAccountDraft({ ...emptyAccountDraft, ...admin.billingAccount }); }} onChange={(key, value) => setAccountDraft((current) => ({ ...current, [key]: value }))} onSubmit={saveBillingAccount} /> : null}
</>
) : null}
{priceEditState ? <PriceMultiplierDialog state={priceEditState} saving={saving} onClose={() => setPriceEditState(null)} onSubmit={savePriceMultiplier} /> : null}
</div>
);
}
async function readApiPayload<T extends Record<string, unknown>>(response: Response): Promise<T & { error?: string }> {
const text = await response.text();
if (!text.trim()) {
return { error: response.status >= 500 ? "计费服务暂时不可用,请检查服务端日志和 PostgreSQL 迁移状态。" : "服务器未返回有效内容。" } as T & { error?: string };
}
try {
return JSON.parse(text) as T & { error?: string };
} catch {
return {
error: response.status >= 500
? "计费服务返回了服务器错误,请检查 Go API 状态和服务端日志。"
: `服务器返回了无效响应HTTP ${response.status})。`
} as T & { error?: string };
}
}
function BillingTabs({ tabs, activeTab, onChange }: { tabs: BillingTabDefinition[]; activeTab: BillingTabId; onChange: (id: BillingTabId) => void }) {
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
function moveTab(event: KeyboardEvent<HTMLButtonElement>, index: number) {
if (!(["ArrowLeft", "ArrowRight", "Home", "End"] as string[]).includes(event.key)) return;
event.preventDefault();
const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
const next = tabs[nextIndex];
if (!next) return;
onChange(next.id);
window.requestAnimationFrame(() => tabRefs.current[nextIndex]?.focus());
}
return <nav className="billing-tabs-shell" aria-label="计费中心功能"><div className="billing-tabs" role="tablist">
{tabs.map((tab, index) => <button
className={`billing-tab${activeTab === tab.id ? " is-active" : ""}`}
id={`billing-tab-${tab.id}`}
key={tab.id}
ref={(element) => { tabRefs.current[index] = element; }}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`billing-panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onChange(tab.id)}
onKeyDown={(event) => moveTab(event, index)}
><span>{tab.label}</span></button>)}
</div></nav>;
}
function buildAdminBillingPayload(admin: AdminPayload): BillingPayload {
const wallet = admin.organizations.reduce<OrganizationWallet>((total, organization) => ({
organizationId: "platform",
balanceFen: total.balanceFen + organization.wallet.balanceFen,
totalRechargedFen: total.totalRechargedFen + organization.wallet.totalRechargedFen,
totalChargedFen: total.totalChargedFen + organization.wallet.totalChargedFen,
updatedAt: total.updatedAt > organization.wallet.updatedAt ? total.updatedAt : organization.wallet.updatedAt
}), {
organizationId: "platform",
balanceFen: 0,
totalRechargedFen: 0,
totalChargedFen: 0,
updatedAt: new Date(0).toISOString()
});
return {
organization: { id: "platform", name: "全平台组织" },
billingAccount: admin.billingAccount,
wallet,
ledger: admin.ledger,
summary: summarizeLedger(admin.ledger),
personal: emptyLedgerSummary()
};
}
function summarizeLedger(entries: LedgerEntry[]): LedgerSummary {
const rechargeFen = entries
.filter((entry) => entry.kind === "recharge" || entry.kind === "adjustment" && entry.deltaFen > 0)
.reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0);
const chargedFen = entries
.filter((entry) => entry.kind === "charge")
.reduce((sum, entry) => sum + Math.max(0, -entry.deltaFen), 0);
const refundedFen = entries
.filter((entry) => entry.kind === "refund")
.reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0);
return { rechargeFen, chargedFen, refundedFen, netConsumedFen: Math.max(0, chargedFen - refundedFen) };
}
function emptyLedgerSummary(): LedgerSummary {
return { rechargeFen: 0, chargedFen: 0, refundedFen: 0, netConsumedFen: 0 };
}
function AdminOverview({ billing, admin, onChangeTab }: { billing: BillingPayload; admin: AdminPayload; onChangeTab: (id: BillingTabId) => void }) {
const enabledRules = admin.priceRules.filter((rule) => rule.enabled).length;
return <div id="billing-panel-overview" role="tabpanel" aria-labelledby="billing-tab-overview" className="billing-tab-panel">
<div className="billing-admin-overview-grid" data-animate>
<button className="billing-admin-stat" type="button" onClick={() => onChangeTab("pricing")}><span></span><strong>{enabledRules}<small> / {admin.priceRules.length}</small></strong></button>
<button className="billing-admin-stat" type="button" onClick={() => onChangeTab("balance")}><span></span><strong>{admin.organizations.length}</strong></button>
<button className="billing-admin-stat" type="button" onClick={() => onChangeTab("balance")}><span></span><strong>{formatBillingAmount(billing.summary.chargedFen)}</strong></button>
</div>
<LedgerSection entries={billing.ledger} limit={8} title="最近账务" action={<span className="billing-section-count"></span>} />
</div>;
}
function PriceManagement({ admin, saving, onEdit }: { admin: AdminPayload; saving: boolean; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void }) {
return <section id="billing-panel-pricing" role="tabpanel" aria-labelledby="billing-tab-pricing" className="billing-section billing-pricing-section billing-tab-panel" data-animate>
<div className="billing-section-heading billing-pricing-heading"><div><h2></h2></div><div className="billing-formula" aria-label="计费公式"><span></span><b>×</b><span></span><b>=</b><strong></strong></div></div>
<PriceCatalog rules={admin.priceRules} onEdit={onEdit} disabled={saving} />
</section>;
}
function BalanceManagement({ admin, adjustmentDraft, setAdjustmentDraft, selectedOrganization, saving, onSubmit }: { admin: AdminPayload; adjustmentDraft: AdjustmentDraft; setAdjustmentDraft: (draft: AdjustmentDraft) => void; selectedOrganization?: AdminPayload["organizations"][number]; saving: boolean; onSubmit: (event: FormEvent) => void }) {
return <section id="billing-panel-balance" role="tabpanel" aria-labelledby="billing-tab-balance" className="billing-section billing-tab-panel" data-animate>
<SectionHeading title="余额与上账" action={<span className="billing-section-count">{admin.organizations.length} </span>} />
<div className="billing-balance-admin-layout">
<div className="billing-subpanel"><div className="billing-subpanel-heading"><div><h3></h3></div><WalletCards size={18} /></div><WalletTable wallets={admin.organizations} /></div>
<AdjustmentForm draft={adjustmentDraft} selectedOrganization={selectedOrganization} organizations={admin.organizations} saving={saving} onChange={setAdjustmentDraft} onSubmit={onSubmit} />
</div>
<div className="billing-member-balance-panel"><div className="billing-subpanel-heading"><div><h3></h3><p></p></div><UsersRound size={18} /></div><MemberBalanceTable members={admin.members} entries={admin.ledger} /></div>
</section>;
}
function AdjustmentForm({ draft, selectedOrganization, organizations, saving, onChange, onSubmit }: { draft: AdjustmentDraft; selectedOrganization?: AdminPayload["organizations"][number]; organizations: AdminPayload["organizations"]; saving: boolean; onChange: (draft: AdjustmentDraft) => void; onSubmit: (event: FormEvent) => void }) {
return <form className="billing-form billing-adjustment-form" onSubmit={onSubmit}>
<div className="billing-form-heading"><div><strong> / </strong></div><Banknote size={19} aria-hidden="true" /></div>
<div className="billing-adjustment-note"><WalletCards size={15} /><span> <b>{formatBillingAmount(selectedOrganization?.wallet.balanceFen || 0)}</b></span></div>
<div className="billing-form-grid">
<BillingSelect label="组织" value={draft.organizationId} onChange={(value) => onChange({ ...draft, organizationId: value })} options={organizations.map((organization) => ({ value: organization.id, label: `${organization.name}${organization.status === "active" ? "" : "(已停用)"}` }))} />
<BillingSelect label="变动方式" value={draft.direction} onChange={(value) => onChange({ ...draft, direction: value as AdjustmentDraft["direction"] })} options={[{ value: "credit", label: "上账增加余额" }, { value: "debit", label: "扣减余额" }]} />
<BillingField label="金额(元)" value={draft.amountYuan} onChange={(value) => onChange({ ...draft, amountYuan: value })} placeholder="例如 1000" required inputMode="decimal" />
<div className="billing-adjustment-full-field"><BillingField label="备注" value={draft.note} onChange={(value) => onChange({ ...draft, note: value })} placeholder="例如2026 年度服务预存" required /></div>
</div>
<div className="billing-form-footer"><span>使</span><button className="billing-action-button" type="submit" disabled={saving || !draft.organizationId}><Plus size={16} />{draft.direction === "credit" ? "确认上账" : "确认扣减"}</button></div>
</form>;
}
function AccountSettings({ account, draft, editing, saving, onEdit, onCancel, onChange, onSubmit }: { account: BillingAccountConfig; draft: BillingAccountConfig; editing: boolean; saving: boolean; onEdit: () => void; onCancel: () => void; onChange: (key: keyof BillingAccountConfig, value: string) => void; onSubmit: (event: FormEvent) => void }) {
return <section id="billing-panel-account" role="tabpanel" aria-labelledby="billing-tab-account" className="billing-section billing-tab-panel" data-animate>
<SectionHeading title="收款设置" action={!editing ? <button className="billing-secondary-button" type="button" onClick={onEdit}><Pencil size={14} /></button> : null} />
<div className="billing-account-settings-layout">
<BillingAccountCard account={editing ? draft : account} admin />
{editing ? <form className="billing-form billing-account-editor" onSubmit={onSubmit}>
<div className="billing-form-heading"><div><strong></strong></div><Landmark size={19} aria-hidden="true" /></div>
<div className="billing-form-grid billing-account-editor-grid">
<BillingField label="账户名称" value={draft.accountName || ""} onChange={(value) => onChange("accountName", value)} placeholder="公司或平台对公账户名称" />
<BillingField label="开户行" value={draft.bankName || ""} onChange={(value) => onChange("bankName", value)} placeholder="例如:中国银行北京分行" />
<BillingField label="银行账号" value={draft.accountNumber || ""} onChange={(value) => onChange("accountNumber", value)} placeholder="对公银行账号" />
<BillingField label="收款对接信息" value={draft.contact || ""} onChange={(value) => onChange("contact", value)} placeholder="联系人、电话或邮箱" />
</div>
<div className="billing-form-footer"><span></span><div className="billing-form-actions"><button className="billing-secondary-button" type="button" onClick={onCancel}></button><button className="billing-action-button" type="submit" disabled={saving}><Check size={16} /></button></div></div>
</form> : null}
</div>
</section>;
}
function BillingOverview({ billing, isSuperAdmin }: { billing: BillingPayload; isSuperAdmin: boolean }) {
return <section className="billing-overview" data-animate aria-label="余额与消耗概览">
<article className="billing-balance-card">
<div className="billing-balance-topline"><div><span></span><strong>{billing.organization.name}</strong></div><span className="billing-shared-badge"><WalletCards size={14} /></span></div>
<strong className="billing-balance-value">{formatBillingAmount(billing.wallet.balanceFen)}</strong>
<div className="billing-balance-footer"><span> <b>{formatBillingAmount(billing.summary.rechargeFen)}</b></span><span> <b>{formatBillingAmount(billing.summary.chargedFen)}</b></span><span>{isSuperAdmin ? "账本状态" : "我的净消耗"} <b>{isSuperAdmin ? "正常" : formatBillingAmount(billing.personal.netConsumedFen)}</b></span></div>
</article>
<div className="billing-usage-stack">
<article className="billing-usage-card"><div className="billing-usage-icon billing-usage-icon-blue"><CircleDollarSign size={18} /></div><div><span></span><strong>{formatBillingAmount(billing.personal.netConsumedFen)}</strong></div><ArrowUpRight size={17} aria-hidden="true" /></article>
<article className="billing-usage-card"><div className="billing-usage-icon billing-usage-icon-green"><Building2 size={18} /></div><div><span></span><strong>{formatBillingAmount(billing.summary.netConsumedFen)}</strong></div><ArrowUpRight size={17} aria-hidden="true" /></article>
</div>
</section>;
}
function LedgerSection({ entries, title, limit = 100, action }: { entries: LedgerEntry[]; title: string; limit?: number; action?: ReactNode }) {
return <section id="billing-panel-ledger" role="tabpanel" className="billing-section billing-ledger-section billing-tab-panel" data-animate>
<SectionHeading title={title} action={action} />
<LedgerTable entries={entries.slice(0, limit)} />
</section>;
}
function TabLink({ label, onClick }: { label: string; onClick: () => void }) {
return <button className="billing-text-button" type="button" onClick={onClick}>{label}<ChevronRight size={14} /></button>;
}
function BillingLoading() {
return <div className="billing-loading" aria-label="正在读取计费数据"><div className="billing-skeleton billing-skeleton-large" /><div className="billing-skeleton-grid"><div className="billing-skeleton" /><div className="billing-skeleton" /></div><div className="billing-skeleton billing-skeleton-section" /></div>;
}
function SectionHeading({ title, action }: { title: string; action?: ReactNode }) {
return <div className="billing-section-heading"><div><h2>{title}</h2></div>{action ? <div className="billing-section-action">{action}</div> : null}</div>;
}
function BillingField({ label, value, onChange, placeholder, required = false, inputMode }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; required?: boolean; inputMode?: "decimal" | "tel" | "text" }) {
return <label className="billing-field"><span>{label}</span><input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} required={required} inputMode={inputMode} /></label>;
}
function BillingSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) {
return <label className="billing-field"><span>{label}</span><select value={value} onChange={(event) => onChange(event.target.value)}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
}
function BillingAccountCard({ account, admin = false }: { account: BillingAccountConfig; admin?: boolean }) {
const configured = Boolean(account.accountName || account.bankName || account.accountNumber);
return <article className="billing-account-card" aria-label="对公收款账户">
<div className="billing-account-heading"><div className="billing-account-icon"><Landmark size={18} /></div><div><h3></h3></div><span className={configured ? "billing-configured" : "billing-unconfigured"}>{configured ? "已配置" : "待配置"}</span></div>
{configured ? <div className="billing-account-grid"><AccountField label="账户名称" value={account.accountName} /><AccountField label="开户行" value={account.bankName} /><AccountField label="银行账号" value={account.accountNumber} /><AccountField label="收款对接" value={account.contact} /></div> : <p className="billing-empty-note"></p>}
</article>;
}
function AccountField({ label, value }: { label: string; value?: string }) {
return <div className="billing-account-field"><span>{label}</span><strong>{value || "—"}</strong></div>;
}
function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
return <div className="billing-ledger-list">
<div className="billing-list-head"><span></span><span> / </span></div>
{entries.length ? entries.map((entry) => <div className="billing-ledger-row" key={entry.id}>
<div className="billing-ledger-main"><span className={`billing-ledger-icon billing-ledger-icon-${entry.kind}`}><ReceiptText size={16} /></span><div><strong>{entry.description}</strong><small><time dateTime={entry.createdAt}>{formatTime(entry.createdAt)}</time>{entry.kind !== "recharge" && entry.kind !== "adjustment" && entry.metadata.accountName ? ` · ${String(entry.metadata.accountName)}` : entry.jobId ? ` · 任务 ${entry.jobId.slice(0, 10)}` : ""}</small></div></div>
<div className="billing-ledger-amount"><strong className={entry.deltaFen < 0 ? "billing-negative" : "billing-positive"}>{entry.deltaFen < 0 ? "" : "+"}{formatBillingAmount(Math.abs(entry.deltaFen))}</strong><small> {formatBillingAmount(entry.balanceAfterFen)}</small></div>
</div>) : <div className="billing-empty-state"><ReceiptText size={19} /><span></span></div>}
</div>;
}
function PriceMultiplierDialog({
state,
saving,
onClose,
onSubmit
}: {
state: PriceEditState;
saving: boolean;
onClose: () => void;
onSubmit: (value: string) => Promise<void>;
}) {
const [value, setValue] = useState(String(state.target.markupMultiplier));
const inputRef = useRef<HTMLInputElement | null>(null);
const standardUnitPriceFen = priceEditStandardUnitPriceFen(state.rule, state.target);
const parsedValue = Number(value);
const valid = value.trim() !== "" && Number.isFinite(parsedValue) && parsedValue >= 1 && parsedValue <= 1000;
const currentCustomerPrice = customerUnitPriceFen(standardUnitPriceFen, state.target.markupMultiplier);
const nextCustomerPrice = valid ? customerUnitPriceFen(standardUnitPriceFen, parsedValue) : null;
useEffect(() => {
setValue(String(state.target.markupMultiplier));
window.requestAnimationFrame(() => inputRef.current?.focus());
}, [state.rule.id, state.target.dimensionKey, state.target.tierValue, state.target.markupMultiplier]);
useEffect(() => {
function handleKeyDown(event: globalThis.KeyboardEvent) {
if (event.key === "Escape" && !saving) onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose, saving]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!valid || saving) return;
void onSubmit(value.trim());
}
return (
<div className="billing-modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose(); }}>
<section className="billing-modal billing-price-edit-dialog" role="dialog" aria-modal="true" aria-labelledby="billing-price-edit-title" aria-describedby="billing-price-edit-description" onMouseDown={(event) => event.stopPropagation()}>
<div className="billing-modal-header">
<div className="billing-modal-title-wrap">
<span className="billing-modal-icon" aria-hidden="true"><CircleDollarSign size={18} /></span>
<div>
<h2 id="billing-price-edit-title"></h2>
<p id="billing-price-edit-description">{state.target.label}</p>
</div>
</div>
<button className="billing-modal-close" type="button" onClick={onClose} disabled={saving} aria-label="关闭" title="关闭"><X size={17} /></button>
</div>
<form className="billing-price-edit-form" onSubmit={submit}>
<label className="billing-modal-field" htmlFor="billing-price-multiplier">
<span></span>
<div className="billing-modal-input-wrap">
<input ref={inputRef} id="billing-price-multiplier" value={value} onChange={(event) => setValue(event.target.value)} inputMode="decimal" min="1" max="1000" step="0.01" aria-invalid={value.trim() !== "" && !valid} />
<strong>×</strong>
</div>
<small> 1.00× 1000.00×</small>
</label>
<div className="billing-price-edit-preview" aria-label="倍率调整预览">
<div><span></span><strong>{formatBillingAmount(standardUnitPriceFen)}<small> / {billingUnitLabel(state.rule.unit)}</small></strong></div>
<div><span></span><strong>{formatBillingAmount(currentCustomerPrice)}</strong></div>
<div className="billing-price-edit-preview-next"><span></span><strong>{nextCustomerPrice === null ? "—" : formatBillingAmount(nextCustomerPrice)}</strong></div>
</div>
<div className="billing-modal-footer">
<button className="billing-secondary-button" type="button" onClick={onClose} disabled={saving}></button>
<button className="billing-action-button" type="submit" disabled={!valid || saving}>{saving ? <Loader2 className="spin" size={15} /> : <Check size={15} />}{saving ? "保存中" : "保存倍率"}</button>
</div>
</form>
</section>
</div>
);
}
function priceEditStandardUnitPriceFen(rule: BillingPriceRule, target: PriceEditTarget): number {
if (!target.dimensionKey || target.tierValue === undefined) return rule.standardUnitPriceFen;
const dimension = rule.parameterDimensions?.find((item) => item.key === target.dimensionKey);
const tier = dimension?.tiers.find((item) => String(item.value) === target.tierValue);
return Math.ceil(rule.standardUnitPriceFen * Number(tier?.standardFactor || 1));
}
function PriceCatalog({ rules, onEdit, disabled }: { rules: BillingPriceRule[]; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) {
const groups = groupPriceRules(rules);
return <div className="billing-price-catalog">
{groups.length ? groups.map(([key, group]) => group.length === 1 ? <PriceServiceCard key={key} rule={group[0]} onEdit={onEdit} disabled={disabled} /> : <PriceServiceGroup key={key} rules={group} onEdit={onEdit} disabled={disabled} />) : <div className="billing-empty-state"><Settings2 size={19} /><span></span></div>}
</div>;
}
function groupPriceRules(rules: BillingPriceRule[]): Array<[string, BillingPriceRule[]]> {
const groups = new Map<string, BillingPriceRule[]>();
for (const rule of rules) {
const key = [rule.provider, rule.capability, rule.reqKey || ""].join("\u0000");
const group = groups.get(key) || [];
group.push(rule);
groups.set(key, group);
}
return [...groups.entries()];
}
function PriceServiceCard({ rule, onEdit, disabled }: { rule: BillingPriceRule; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) {
const dimensions = rule.parameterDimensions?.filter((dimension) => dimension.tiers.length) || [];
return <article className="billing-price-service-card">
<div className="billing-price-service-heading">
<div className="billing-price-service-title"><span className="billing-provider-mark">{providerName(rule.provider).slice(0, 2)}</span><div><strong>{providerName(rule.provider)} · {rule.capability === "video.generate" ? "视频生成" : "图片生成"}</strong><small>{rule.reqKey || "默认服务"}{rule.variantKey ? ` · ${rule.variantKey}` : ""}</small></div></div>
<div className="billing-price-base"><span></span><strong>{formatBillingAmount(rule.standardUnitPriceFen)}<small>/{billingUnitLabel(rule.unit)}</small></strong></div>
</div>
<div className="billing-price-service-meta">
{rule.source?.url ? <a href={rule.source.url} target="_blank" rel="noreferrer"> <ArrowUpRight size={12} aria-hidden="true" /></a> : <span className="billing-price-source-placeholder"></span>}
<span className="billing-price-note">{rule.note || "平台标准目录"}</span>
</div>
{dimensions.length ? <div className="billing-price-dimensions">
{dimensions.map((dimension) => <PriceDimension key={dimension.key} rule={rule} dimension={dimension} onEdit={onEdit} disabled={disabled} />)}
</div> : <LegacyPriceRow rule={rule} onEdit={onEdit} disabled={disabled} />}
</article>;
}
function PriceServiceGroup({ rules, onEdit, disabled }: { rules: BillingPriceRule[]; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) {
const rule = rules[0];
return <article className="billing-price-service-card">
<div className="billing-price-service-heading">
<div className="billing-price-service-title"><span className="billing-provider-mark">{providerName(rule.provider).slice(0, 2)}</span><div><strong>{providerName(rule.provider)} · {rule.capability === "video.generate" ? "视频生成" : "图片生成"}</strong><small>{rule.reqKey || "默认服务"}</small></div></div>
<div className="billing-price-base"><span></span><strong>{rules.length}<small> </small></strong></div>
</div>
<div className="billing-price-service-meta">
{rule.source?.url ? <a href={rule.source.url} target="_blank" rel="noreferrer"> <ArrowUpRight size={12} aria-hidden="true" /></a> : <span className="billing-price-source-placeholder"></span>}
<span className="billing-price-note"></span>
</div>
<section className="billing-price-dimension">
<div className="billing-price-dimension-heading"><div><strong></strong><small></small></div></div>
<div className="billing-price-tier-head"><span></span><span></span><span></span><span></span><span></span></div>
<div className="billing-price-tier-list">{rules.map((item) => <LegacyPriceRow key={item.id} rule={item} onEdit={onEdit} disabled={disabled} />)}</div>
</section>
</article>;
}
function PriceDimension({ rule, dimension, onEdit, disabled }: { rule: BillingPriceRule; dimension: BillingParameterDimension; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) {
const baseline = dimension.tiers.find((tier) => String(tier.value).toLowerCase() === String(dimension.baselineValue).toLowerCase());
return <section className="billing-price-dimension">
<div className="billing-price-dimension-heading"><div><strong>{dimension.label}</strong><small>{baseline?.label || String(dimension.baselineValue)} · </small></div></div>
<div className="billing-price-tier-head"><span></span><span></span><span></span><span></span><span></span></div>
<div className="billing-price-tier-list">{dimension.tiers.map((tier) => <PriceTierRow key={`${dimension.key}-${String(tier.value)}`} rule={rule} dimension={dimension} tier={tier} onEdit={onEdit} disabled={disabled} />)}</div>
</section>;
}
function PriceTierRow({ rule, dimension, tier, onEdit, disabled }: { rule: BillingPriceRule; dimension: BillingParameterDimension; tier: BillingParameterTier; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) {
const standardUnitPriceFen = Math.ceil(rule.standardUnitPriceFen * tier.standardFactor);
return <div className={`billing-price-tier-row${tier.enabled ? "" : " billing-price-tier-row-disabled"}`}>
<div className="billing-price-tier-name"><strong>{tier.label}</strong><small>{String(tier.value)}{tier.note ? ` · ${tier.note}` : ""}</small></div>
<div className="billing-price-values"><strong>{formatBillingAmount(standardUnitPriceFen)}</strong><small>/{billingUnitLabel(rule.unit)}</small></div>
<div className="billing-price-values"><strong>{formatBillingAmount(customerUnitPriceFen(standardUnitPriceFen, tier.markupMultiplier))}</strong><small>/{billingUnitLabel(rule.unit)}</small></div>
<span className="billing-multiplier">{tier.markupMultiplier.toFixed(2)}×</span>
<div className="billing-price-actions"><button type="button" onClick={() => onEdit(rule, { dimensionKey: dimension.key, tierValue: String(tier.value), label: `${dimension.label} · ${tier.label}`, markupMultiplier: tier.markupMultiplier })} disabled={disabled || !tier.enabled}></button></div>
</div>;
}
function LegacyPriceRow({ rule, onEdit, disabled }: { rule: BillingPriceRule; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) {
return <div className="billing-price-legacy-row">
<div className="billing-price-tier-name"><strong>{rule.variantKey || (describeRuleConditions(rule) || "默认参数")}</strong><small>{rule.enabled ? "平台标准档位" : "已停用"}</small></div>
<div className="billing-price-values"><strong>{formatBillingAmount(rule.standardUnitPriceFen)}</strong><small>/{billingUnitLabel(rule.unit)}</small></div>
<div className="billing-price-values"><strong>{formatBillingAmount(customerUnitPriceFen(rule.standardUnitPriceFen, rule.markupMultiplier))}</strong><small>/{billingUnitLabel(rule.unit)}</small></div>
<span className="billing-multiplier">{rule.markupMultiplier.toFixed(2)}×</span>
<div className="billing-price-actions"><button type="button" onClick={() => onEdit(rule, { label: "服务", markupMultiplier: rule.markupMultiplier })} disabled={disabled || !rule.enabled}></button></div>
</div>;
}
function WalletTable({ wallets }: { wallets: AdminPayload["organizations"] }) {
return <div className="billing-wallet-list">
<div className="billing-list-head"><span></span><span> / </span></div>
{wallets.length ? wallets.map((item) => <div className="billing-wallet-row" key={item.id}><div><strong>{item.name}</strong><small><span className={`billing-org-dot billing-org-dot-${item.status}`} />{item.status === "active" ? "正常" : "已停用"}</small></div><div><strong>{formatBillingAmount(item.wallet.balanceFen)}</strong><small> {formatBillingAmount(item.wallet.totalChargedFen)}</small></div></div>) : <div className="billing-empty-state"><Building2 size={19} /><span></span></div>}
</div>;
}
function MemberBalanceTable({ members, entries }: { members: AdminMember[]; entries: LedgerEntry[] }) {
const organizationMembers = members.filter((member) => member.organizationId);
return <div className="billing-member-balance-list">
<div className="billing-member-balance-head"><span></span><span></span></div>
{organizationMembers.length ? organizationMembers.map((member) => {
const usage = memberUsage(entries, member.id);
return <div className="billing-member-balance-row" key={member.id}><div><strong>{member.displayName}</strong><small>{member.phone}{member.status === "disabled" ? " · 已停用" : ""}</small></div><div><strong>{formatBillingAmount(usage.netConsumedFen)}</strong><small> {formatBillingAmount(usage.chargedFen)} · 退 {formatBillingAmount(usage.refundedFen)}</small></div></div>;
}) : <div className="billing-empty-state"><UsersRound size={19} /><span></span></div>}
</div>;
}
function memberUsage(entries: LedgerEntry[], accountId: string) {
const personal = entries.filter((entry) => entry.accountId === accountId);
const chargedFen = personal.filter((entry) => entry.kind === "charge").reduce((sum, entry) => sum + Math.max(0, -entry.deltaFen), 0);
const refundedFen = personal.filter((entry) => entry.kind === "refund").reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0);
return { chargedFen, refundedFen, netConsumedFen: Math.max(0, chargedFen - refundedFen) };
}
function customerUnitPriceFen(standardUnitPriceFen: number, markupMultiplier: number): number {
return Math.ceil(standardUnitPriceFen * markupMultiplier);
}
function describeRuleConditions(rule: BillingPriceRule): string {
const entries = Object.entries(rule.conditions || {});
if (!entries.length) return "";
return entries.map(([key, value]) => `${conditionName(key)}=${conditionValueLabel(value)}`).join(" · ");
}
function conditionName(key: string) {
const labels: Record<string, string> = {
resolution: "分辨率",
size: "尺寸",
aspectRatio: "比例",
quality: "质量",
duration: "时长",
imageCount: "张数",
referenceImageCount: "参考图",
model: "模型"
};
return labels[key] || key;
}
function conditionValueLabel(value: unknown): string {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const item = value as { min?: number; max?: number; values?: unknown[] };
if (item.values?.length) return item.values.join("/");
if (item.min !== undefined || item.max !== undefined) return `${item.min ?? "-∞"}~${item.max ?? "+∞"}`;
}
return String(value);
}
function providerName(provider: string) {
if (provider === "volcengine-visual") return "即梦";
if (provider === "evolink") return "EvoLink";
if (provider === "seedance") return "Seedance";
if (provider === "seedream") return "Seedream 5.0 Pro";
if (provider === "bailian") return "百炼";
return provider;
}
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}