Files
NianAIGC/components/billing-manager.tsx

771 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 === "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));
}