560 lines
24 KiB
JavaScript
560 lines
24 KiB
JavaScript
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(" / ");
|
||
}
|