367 lines
20 KiB
JavaScript
367 lines
20 KiB
JavaScript
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>
|
||
);
|
||
}
|