772 lines
45 KiB
TypeScript
772 lines
45 KiB
TypeScript
"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));
|
||
}
|