hyapp-admin-platform/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx
2026-07-14 11:29:05 +08:00

560 lines
24 KiB
JavaScript
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.

import AddOutlined from "@mui/icons-material/AddOutlined";
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
import PaidOutlined from "@mui/icons-material/PaidOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import {
COIN_SELLER_RECHARGE_PROVIDERS,
COIN_SELLER_RECHARGE_STATUS,
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
} from "../constants.js";
import {
buildCoinSellerRechargeReceiptPayload,
buildCoinSellerRechargeOrderPayload,
buildCoinSellerRechargeQuotePayload,
coinSellerRechargeChainLabel,
coinSellerRechargeGrantStatusLabel,
coinSellerRechargeProviderLabel,
coinSellerRechargeStatusLabel,
coinSellerRechargeStatusTone,
coinSellerRechargeVerifyStatusLabel,
formatAmount,
formatTime,
formatUsdMinor,
isCoinSellerRechargeGranted,
isCoinSellerRechargeVerified,
validateCoinSellerRechargeOrderPayload,
} from "../format.js";
import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx";
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
export function FinanceCoinSellerRechargeOrderList({
actionLoading,
apps,
canCreate,
canConfigureExchangeRate,
canGrant,
canVerify,
error,
filters,
loading,
onCreate,
onLoadExchangeRate,
onFiltersChange,
onGrant,
onReload,
onQuote,
onSaveExchangeRate,
onVerify,
onVerifyReceipt,
orders,
}) {
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState(DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM);
const [formError, setFormError] = useState("");
const [quote, setQuote] = useState(null);
const [quoteError, setQuoteError] = useState("");
const [quoteLoading, setQuoteLoading] = useState(false);
const [rateConfigOpen, setRateConfigOpen] = useState(false);
const [receiptVerification, setReceiptVerification] = useState(null);
const [receiptLoading, setReceiptLoading] = useState(false);
const items = orders.items || [];
const page = Number(orders.page || filters.page || 1);
const pageSize = Number(orders.pageSize || 50);
const total = Number(orders.total || 0);
const hasNextPage = page * pageSize < total;
const quoteAppCode = form.appCode;
const quoteTargetUserID = form.targetUserId;
const quoteUSDAmount = form.usdAmount;
const isMakeup = form.isMakeup;
useEffect(() => {
const payload = buildCoinSellerRechargeQuotePayload({
appCode: quoteAppCode,
targetUserId: quoteTargetUserID,
usdAmount: quoteUSDAmount,
});
if (isMakeup) {
setQuote(null);
setQuoteError("");
setQuoteLoading(false);
setForm((current) => (current.coinAmount === "0" ? current : { ...current, coinAmount: "0" }));
return undefined;
}
if (!createOpen || !payload.appCode || !payload.targetUserId || !Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
setQuote(null);
setQuoteError("");
setQuoteLoading(false);
return undefined;
}
let active = true;
const timer = window.setTimeout(async () => {
setQuoteLoading(true);
setQuoteError("");
try {
const result = await onQuote(payload);
if (active) {
setQuote(result);
setForm((current) => ({ ...current, coinAmount: String(result?.coinAmount ?? "") }));
}
} catch (quoteRequestError) {
if (active) {
setQuote(null);
setForm((current) => ({ ...current, coinAmount: "" }));
setQuoteError(quoteRequestError.message || "未能生成充值金币");
}
} finally {
if (active) {
setQuoteLoading(false);
}
}
}, 300);
return () => {
active = false;
window.clearTimeout(timer);
};
}, [createOpen, isMakeup, onQuote, quoteAppCode, quoteTargetUserID, quoteUSDAmount]);
const openCreate = () => {
setForm({
...DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
appCode: filters.appCode || apps[0]?.appCode || "",
orderDateMs: Date.now(),
});
setFormError("");
setQuote(null);
setQuoteError("");
setReceiptVerification(null);
setCreateOpen(true);
};
const updateForm = (key, value) => {
if (key === "isMakeup") {
setQuote(null);
setQuoteError("");
setForm((current) => ({ ...current, coinAmount: value ? "0" : "", isMakeup: value }));
setFormError("");
return;
}
if (["appCode", "targetUserId", "usdAmount"].includes(key)) {
setQuote(null);
setQuoteError("");
setForm((current) => ({ ...current, [key]: value, coinAmount: current.isMakeup ? "0" : "" }));
setFormError("");
setReceiptVerification(null);
return;
}
if (key === "providerCode") {
setForm((current) => ({
...current,
chain: "",
providerAmount: "",
providerCode: value,
providerCurrencyCode: "",
}));
setFormError("");
setReceiptVerification(null);
return;
}
setForm((current) => ({ ...current, [key]: value }));
setFormError("");
// 订单号校验结果绑定当前 APP、用户输入金额、渠道、链和订单号任一字段变化都不能复用旧凭证。
setReceiptVerification(null);
};
const submitCreate = async (event) => {
event.preventDefault();
if (!receiptVerification?.verified) {
setFormError("请先校验订单号");
return;
}
if (!form.isMakeup && !quote?.coinAmount) {
setFormError(quoteError || "请等待系统生成充值金币");
return;
}
const payload = buildCoinSellerRechargeOrderPayload(form);
const validationMessage = validateCoinSellerRechargeOrderPayload(payload);
if (validationMessage) {
setFormError(validationMessage);
return;
}
const created = await onCreate(payload);
if (created) {
setCreateOpen(false);
setReceiptVerification(null);
}
};
const verifyCreateReceipt = async () => {
const payload = buildCoinSellerRechargeReceiptPayload(form);
const validationMessage = validateCoinSellerRechargeOrderPayload({
...buildCoinSellerRechargeOrderPayload(form),
});
if (validationMessage) {
setFormError(validationMessage);
return;
}
setFormError("");
setReceiptLoading(true);
try {
const verification = await onVerifyReceipt(payload);
setReceiptVerification(verification);
if (!verification?.verified) {
setFormError(verification?.failureReason || "订单号校验未通过");
}
} finally {
setReceiptLoading(false);
}
};
const confirmGrant = (order) => {
const userText = order.targetDisplayUserId || order.targetUserId || "-";
const ok = window.confirm(
isZeroCoinMakeupOrder(order)
? `确认完成用户 ${userText} 的补单?该订单计入充值,不发放金币。`
: `确认给用户 ${userText} 发放 ${formatAmount(order.coinAmount)} 金币?`,
);
if (ok) {
onGrant(order);
}
};
return (
<section className="finance-card finance-list">
<div className="finance-filterrow">
<TextField
className="finance-filterrow__region"
label="APP"
select
size="small"
value={filters.appCode}
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
>
<MenuItem value="">全部 APP</MenuItem>
{apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
<AppIdentity app={app} size="small" />
</MenuItem>
))}
</TextField>
<TextField
label="渠道"
select
size="small"
value={filters.providerCode}
onChange={(event) => onFiltersChange({ providerCode: event.target.value })}
>
<MenuItem value="">全部渠道</MenuItem>
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="订单状态"
select
size="small"
value={filters.status}
onChange={(event) => onFiltersChange({ status: event.target.value })}
>
<MenuItem value="">全部状态</MenuItem>
{COIN_SELLER_RECHARGE_STATUS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className="finance-filterrow__search"
label="搜索"
size="small"
value={filters.keyword}
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
/>
<span className="finance-filterrow__spacer" />
<div className="finance-filterrow__actions">
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
刷新
</Button>
{canConfigureExchangeRate ? (
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
金币汇率配置
</Button>
) : null}
<Button
disabled={!canCreate}
startIcon={<AddOutlined fontSize="small" />}
variant="contained"
onClick={openCreate}
>
创建币商充值
</Button>
</div>
</div>
{error ? <div className="finance-error">{error}</div> : null}
{loading && !items.length ? <div className="finance-skeleton" /> : null}
{!loading || items.length ? (
<div className="finance-tablewrap">
<table className="finance-flat-table finance-flat-table--orders">
<thead>
<tr>
<th>订单</th>
<th>APP</th>
<th>目标用户</th>
<th className="r">金额</th>
<th>渠道</th>
<th>三方订单</th>
<th>备注</th>
<th>状态</th>
<th>钱包</th>
<th>操作人</th>
<th>时间</th>
<th className="finance-flat-table__actions-cell r">操作</th>
</tr>
</thead>
<tbody>
{items.length ? (
items.map((item) => (
<tr className="finance-flat-table__row" key={item.id}>
<td className="finance-order-cell">{item.id || "-"}</td>
<td>
<AppIdentity
app={apps.find((app) => app.appCode === item.appCode)}
appCode={item.appCode}
size="small"
/>
</td>
<td>
<Stacked
primary={item.targetDisplayUserId || item.targetUserId}
secondary={item.targetUserId}
/>
</td>
<td className="r num">
<Stacked
primary={usdText(item)}
secondary={`${formatAmount(item.coinAmount)} 金币${isZeroCoinMakeupOrder(item) ? "(补单)" : ""}`}
/>
</td>
<td>
<Stacked
primary={coinSellerRechargeProviderLabel(item.providerCode)}
secondary={providerMeta(item)}
/>
</td>
<td>
<Stacked
primary={item.externalOrderNo || "-"}
secondary={providerAmountText(item)}
/>
</td>
<td className="finance-remark-cell">{item.remark || "-"}</td>
<td>
<Stacked
primary={<StatusBadge status={item.status} />}
secondary={`${coinSellerRechargeVerifyStatusLabel(item.verifyStatus)} / ${coinSellerRechargeGrantStatusLabel(item.grantStatus)}`}
/>
</td>
<td>
<Stacked
primary={item.walletTransactionId || item.walletCommandId || "-"}
secondary={walletMeta(item)}
/>
</td>
<td>
<Stacked
primary={item.operatorName || "-"}
secondary={[
item.verifiedByName && `校验:${item.verifiedByName}`,
item.grantedByName &&
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
]
.filter(Boolean)
.join(" / ")}
/>
</td>
<td>
<Stacked
primary={formatTime(item.orderDateMs || item.createdAtMs)}
secondary={[
item.orderDateMs && item.createdAtMs !== item.orderDateMs
? `录入 ${formatTime(item.createdAtMs)}`
: "",
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
item.grantedAtMs &&
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
]
.filter(Boolean)
.join(" / ")}
/>
</td>
<td className="finance-flat-table__actions-cell r">
<span className="finance-row-actions">
<Button
disabled={
!canVerify ||
actionLoading === `verify:${item.id}` ||
isCoinSellerRechargeVerified(item) ||
isCoinSellerRechargeGranted(item)
}
size="small"
startIcon={<FactCheckOutlined fontSize="small" />}
variant="outlined"
onClick={() => onVerify(item)}
>
校验
</Button>
<Button
disabled={
!canGrant ||
actionLoading === `grant:${item.id}` ||
!isCoinSellerRechargeVerified(item) ||
isCoinSellerRechargeGranted(item)
}
size="small"
startIcon={<PaidOutlined fontSize="small" />}
variant="contained"
onClick={() => confirmGrant(item)}
>
{isZeroCoinMakeupOrder(item) ? "完成补单" : "发放"}
</Button>
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={12} style={{ textAlign: "center" }}>
当前无数据
</td>
</tr>
)}
</tbody>
</table>
</div>
) : null}
{total > pageSize ? (
<div className="finance-pagination">
<Button
disabled={page <= 1 || loading}
variant="outlined"
onClick={() => onFiltersChange({ page: page - 1 })}
>
上一页
</Button>
<span>
{page} / {total}
</span>
<Button
disabled={!hasNextPage || loading}
variant="outlined"
onClick={() => onFiltersChange({ page: page + 1 })}
>
下一页
</Button>
</div>
) : null}
<FinanceCoinSellerRechargeCreateDialog
apps={apps}
form={form}
formError={formError}
loading={actionLoading === "create"}
open={createOpen}
receiptLoading={receiptLoading}
receiptVerification={receiptVerification}
quote={quote}
quoteError={quoteError}
quoteLoading={quoteLoading}
onChange={updateForm}
onClose={() => {
setCreateOpen(false);
setReceiptVerification(null);
setFormError("");
setQuote(null);
setQuoteError("");
}}
onSubmit={submitCreate}
onVerifyReceipt={verifyCreateReceipt}
/>
<FinanceCoinSellerExchangeRateDialog
apps={apps}
initialAppCode={filters.appCode}
open={rateConfigOpen}
onClose={() => setRateConfigOpen(false)}
onLoad={onLoadExchangeRate}
onSave={onSaveExchangeRate}
/>
</section>
);
}
function isZeroCoinMakeupOrder(order) {
return Number(order?.coinAmount || 0) === 0;
}
function StatusBadge({ status }) {
return (
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>
{coinSellerRechargeStatusLabel(status)}
</span>
);
}
function Stacked({ primary, secondary }) {
return (
<span className="finance-stack">
<span>{primary || "-"}</span>
<small>{secondary || ""}</small>
</span>
);
}
function usdText(item) {
if (item.usdAmount !== undefined && item.usdAmount !== null && item.usdAmount !== "") {
return `USD ${item.usdAmount}`;
}
return formatUsdMinor(item.usdMinorAmount, "USD");
}
function providerAmountText(item) {
return item.providerAmountMinor === null || item.providerAmountMinor === undefined
? "-"
: formatUsdMinor(item.providerAmountMinor, item.providerCurrencyCode || "USD");
}
function providerMeta(item) {
if (item.providerCode === "usdt") {
return [coinSellerRechargeChainLabel(item.chain), item.providerStatus].filter(Boolean).join(" / ") || "-";
}
if (item.providerCode === "mifapay") {
return [item.providerStatus, item.providerOrderId].filter(Boolean).join(" / ") || "-";
}
if (item.providerCode === "v5pay") {
return (
[item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-"
);
}
return "-";
}
function walletMeta(item) {
return [
item.walletAssetType,
item.walletAmountDelta === null || item.walletAmountDelta === undefined
? ""
: `变动 ${formatAmount(item.walletAmountDelta)}`,
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined
? ""
: `余额 ${formatAmount(item.walletBalanceAfter)}`,
]
.filter(Boolean)
.join(" / ");
}