hyapp-admin-platform/finance/src/components/FinanceCoinSellerRechargeCreateDialog.jsx
2026-07-13 11:10:08 +08:00

367 lines
20 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 AccountCircleOutlined from "@mui/icons-material/AccountCircleOutlined";
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import Alert from "@mui/material/Alert";
import Autocomplete from "@mui/material/Autocomplete";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { DateTimePicker } from "@/shared/ui/DateTimePicker.jsx";
import {
COIN_SELLER_RECHARGE_CHAINS,
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
COIN_SELLER_RECHARGE_PROVIDERS,
} from "../constants.js";
import { formatAmount, formatTime, formatUsdMinor } from "../format.js";
export function FinanceCoinSellerRechargeCreateDialog({
apps,
form,
formError,
loading,
onChange,
onClose,
onSubmit,
onVerifyReceipt,
open,
quote,
quoteError,
quoteLoading,
receiptLoading,
receiptVerification,
}) {
const disabled = loading || receiptLoading;
// 财务录单的主按钮只在“金额结果已确定 + 三方凭证已校验”时开放,避免界面优化后弱化原有风控门槛。
const canCreate = receiptVerification?.verified === true && (form.isMakeup || Boolean(quote?.coinAmount)) && !disabled;
const selectedAppName = apps.find((app) => app.appCode === form.appCode)?.appName || form.appCode || "待选择";
return (
<Dialog
fullWidth
maxWidth={false}
open={open}
slotProps={{ paper: { className: "finance-create-dialog-paper finance-create-dialog-paper--coin-seller" } }}
onClose={disabled ? undefined : onClose}
>
<DialogTitle className="finance-entry-header">
<span className="finance-entry-header__icon" aria-hidden="true">
<PaymentsOutlined />
</span>
<span>
<strong>录入币商充值</strong>
<small>完成付款凭证校验后订单将计入充值流水</small>
</span>
</DialogTitle>
<DialogContent className="finance-create-dialog finance-create-dialog--entry">
<form className="finance-entry-form" id="coin-seller-recharge-order-form" onSubmit={onSubmit}>
{formError ? <Alert severity="error">{formError}</Alert> : null}
<div className="finance-entry-layout">
<div className="finance-entry-fields">
<FormSection icon={<AccountCircleOutlined />} index="1" title="充值对象">
<div className="finance-entry-field-grid finance-entry-field-grid--2">
{apps.length ? (
<TextField
disabled={disabled}
label="APP"
required
select
value={form.appCode}
onChange={(event) => onChange("appCode", event.target.value)}
>
{apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
</MenuItem>
))}
</TextField>
) : (
<TextField
disabled={disabled}
label="APP"
required
value={form.appCode}
onChange={(event) => onChange("appCode", event.target.value)}
/>
)}
<TextField
disabled={disabled}
label="目标用户 ID"
required
value={form.targetUserId}
onChange={(event) => onChange("targetUserId", event.target.value)}
/>
</div>
</FormSection>
<FormSection icon={<PaymentsOutlined />} index="2" title="入账金额">
<div className="finance-entry-field-grid finance-entry-field-grid--2">
<TextField
className="finance-entry-usd-field"
disabled={disabled}
label="充值金额USD"
required
slotProps={{
htmlInput: { "aria-label": "USD 金额", min: 0, step: "0.01" },
input: { startAdornment: <InputAdornment position="start">$</InputAdornment> },
}}
type="number"
value={form.usdAmount}
onChange={(event) => onChange("usdAmount", event.target.value)}
/>
<DateTimePicker
className="finance-order-date-picker"
disabled={disabled}
label="订单时间"
value={form.orderDateMs}
onChange={(value) => onChange("orderDateMs", value)}
/>
</div>
<div className="finance-entry-mode" role="group" aria-label="入账类型">
<button
aria-label="正常充值"
aria-pressed={!form.isMakeup}
className={!form.isMakeup ? "is-active" : ""}
disabled={disabled}
type="button"
onClick={() => onChange("isMakeup", false)}
>
<strong>正常充值</strong>
<small>按兑换比例发放金币</small>
</button>
<button
aria-label="补单"
aria-pressed={form.isMakeup}
className={form.isMakeup ? "is-active is-warning" : ""}
disabled={disabled}
type="button"
onClick={() => onChange("isMakeup", true)}
>
<strong>补单</strong>
<small>只计入充值金币为 0</small>
</button>
</div>
<CoinQuoteResult
form={form}
quote={quote}
quoteError={quoteError}
quoteLoading={quoteLoading}
/>
</FormSection>
<FormSection icon={<ReceiptLongOutlined />} index="3" title="支付凭证">
<div className="finance-entry-field-grid finance-entry-field-grid--3">
<TextField
disabled={disabled}
label="支付渠道"
required
select
value={form.providerCode}
onChange={(event) => onChange("providerCode", event.target.value)}
>
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.providerCode === "usdt" ? (
<TextField
disabled={disabled}
label="USDT 链"
required
select
value={form.chain}
onChange={(event) => onChange("chain", event.target.value)}
>
{COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : (
<Autocomplete
disabled={disabled}
getOptionLabel={(option) => option || ""}
isOptionEqualToValue={(option, value) => option === value}
noOptionsText="无匹配币种"
options={COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[form.providerCode] || []}
renderInput={(params) => <TextField {...params} label="币种" required />}
value={form.providerCurrencyCode || null}
onChange={(_, value) => onChange("providerCurrencyCode", value || "")}
/>
)}
{form.providerCode !== "usdt" ? (
<TextField
disabled={disabled}
label="实付金额"
required
slotProps={{ htmlInput: { "aria-label": "币种金额", min: 0, step: "0.01" } }}
type="number"
value={form.providerAmount}
onChange={(event) => onChange("providerAmount", event.target.value)}
/>
) : null}
</div>
<div className="finance-receipt-field">
<TextField
disabled={disabled}
label="三方订单号"
required
value={form.externalOrderNo}
onChange={(event) => onChange("externalOrderNo", event.target.value)}
/>
<Button
aria-label="校验"
disabled={disabled || !onVerifyReceipt}
startIcon={<FactCheckOutlined fontSize="small" />}
variant="outlined"
onClick={onVerifyReceipt}
>
校验订单
</Button>
</div>
{receiptVerification ? <ReceiptVerificationBanner verification={receiptVerification} /> : null}
<TextField
disabled={disabled}
label="备注(选填)"
multiline
minRows={2}
slotProps={{ htmlInput: { "aria-label": "备注", maxLength: 512 } }}
value={form.remark}
onChange={(event) => onChange("remark", event.target.value)}
/>
</FormSection>
</div>
<OrderPreview
appName={selectedAppName}
form={form}
quote={quote}
quoteLoading={quoteLoading}
receiptVerification={receiptVerification}
/>
</div>
</form>
</DialogContent>
<DialogActions className="finance-entry-actions">
<span className="finance-entry-actions__hint">
{receiptVerification?.verified ? <CheckCircleOutlineOutlined fontSize="small" /> : <FactCheckOutlined fontSize="small" />}
{receiptVerification?.verified ? "订单已校验,可以创建" : "校验三方订单后才能创建"}
</span>
<Button disabled={disabled} variant="outlined" onClick={onClose}>
取消
</Button>
<Button aria-label="创建" disabled={!canCreate} form="coin-seller-recharge-order-form" type="submit" variant="contained">
{form.isMakeup ? "确认补单" : "确认入账"}
</Button>
</DialogActions>
</Dialog>
);
}
function FormSection({ children, icon, index, title }) {
return (
<section className="finance-entry-section">
<header className="finance-entry-section__header">
<span className="finance-entry-section__index">{index}</span>
<span className="finance-entry-section__icon" aria-hidden="true">
{icon}
</span>
<h3>{title}</h3>
</header>
<div className="finance-entry-section__body">{children}</div>
</section>
);
}
function CoinQuoteResult({ form, quote, quoteError, quoteLoading }) {
const coinAmount = form.isMakeup ? 0 : quote?.coinAmount;
return (
<div className={`finance-quote-result${form.isMakeup ? " finance-quote-result--makeup" : ""}`}>
<span>
<small>{form.isMakeup ? "本次不发放" : "预计到账"}</small>
<output aria-label="充值金币">{coinAmount === undefined ? "—" : formatAmount(coinAmount)}</output>
<em>金币</em>
</span>
<p>{quoteHelperText(quote, quoteError, quoteLoading, form.isMakeup)}</p>
</div>
);
}
function OrderPreview({ appName, form, quote, quoteLoading, receiptVerification }) {
const usdAmount = Number(form.usdAmount || 0);
const coinAmount = form.isMakeup ? 0 : Number(quote?.coinAmount || 0);
const providerLabel = COIN_SELLER_RECHARGE_PROVIDERS.find(([value]) => value === form.providerCode)?.[1] || "待选择";
return (
<aside className="finance-entry-preview" aria-label="入账预览">
<header>
<span>入账预览</span>
<b className={form.isMakeup ? "is-warning" : ""}>{form.isMakeup ? "补单" : "正常充值"}</b>
</header>
<div className="finance-entry-preview__amount">
<small>充值金额</small>
<strong><span>$</span>{usdAmount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
<p>USD</p>
</div>
<div className="finance-entry-preview__coins">
<span>到账金币</span>
<strong>{quoteLoading ? "计算中" : formatAmount(coinAmount)}</strong>
</div>
<dl>
<div><dt>APP</dt><dd>{appName}</dd></div>
<div><dt>目标用户</dt><dd>{form.targetUserId || ""}</dd></div>
<div><dt>订单时间</dt><dd>{formatTime(form.orderDateMs) || ""}</dd></div>
<div><dt>支付渠道</dt><dd>{providerLabel}</dd></div>
<div><dt>支付币种</dt><dd>{form.providerCode === "usdt" ? form.chain || "" : form.providerCurrencyCode || ""}</dd></div>
</dl>
<div className={`finance-entry-preview__verify${receiptVerification?.verified ? " is-verified" : ""}`}>
{receiptVerification?.verified ? <CheckCircleOutlineOutlined /> : <FactCheckOutlined />}
<span>
<strong>{receiptVerification?.verified ? "三方订单已校验" : "等待校验订单"}</strong>
<small>{receiptVerification?.verified ? "凭证金额与订单信息已核对" : "填写三方订单号并完成校验"}</small>
</span>
</div>
<p className={`finance-entry-preview__notice${form.isMakeup ? " is-warning" : ""}`}>
{form.isMakeup
? `将记录 USD ${usdAmount || 0} 充值,金币发放数量为 0。`
: `创建后将向目标用户发放 ${formatAmount(coinAmount)} 金币。`}
</p>
</aside>
);
}
function quoteHelperText(quote, error, loading, isMakeup) {
if (isMakeup) return "补单不发放金币,充值金额仍正常计入统计";
if (loading) return "正在计算兑换比例与金币数量";
if (error) return error;
if (!quote) return "填写 APP、用户 ID 和 USD 金额后自动计算";
const source = quote.rateSource === "whitelist"
? `用户白名单 ${quote.matchedUserId || ""}`.trim()
: `${quote.minUsdAmount}-${quote.maxUsdAmount} USD 区间`;
return `${source} · 1 USD = ${formatAmount(quote.coinsPerUsd)} 金币`;
}
function ReceiptVerificationBanner({ verification }) {
const detail = [
verification.status && `状态 ${verification.status}`,
verification.providerOrderId && `三方单 ${verification.providerOrderId}`,
verification.currencyCode && verification.providerAmountMinor !== undefined
? formatUsdMinor(verification.providerAmountMinor, verification.currencyCode)
: "",
verification.failureReason,
].filter(Boolean).join(" / ");
return (
<Alert className="finance-receipt-result" severity={verification.verified ? "success" : "error"}>
{verification.verified ? "校验通过" : "校验未通过"}{detail ? `${detail}` : ""}
</Alert>
);
}