增加创建时间
This commit is contained in:
parent
2f384687b5
commit
1b9982e52c
@ -8581,7 +8581,7 @@
|
|||||||
"required": ["track", "level", "duration_days"],
|
"required": ["track", "level", "duration_days"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"track": { "type": "string", "enum": ["wealth", "charm"] },
|
"track": { "type": "string", "enum": ["wealth", "charm"] },
|
||||||
"level": { "type": "integer", "minimum": 1, "maximum": 50 },
|
"level": { "type": "integer", "minimum": 1, "maximum": 100 },
|
||||||
"duration_days": { "type": "integer", "minimum": 1 }
|
"duration_days": { "type": "integer", "minimum": 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12028,8 +12028,10 @@
|
|||||||
"receiveAddress": {
|
"receiveAddress": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"orderDate": {
|
"orderDateMs": {
|
||||||
"type": "string"
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "订单业务时间;历史订单为 0 时回退到 createdAtMs"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -12218,6 +12220,11 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "补单时为 true;订单正常计入充值,但服务端强制金币数为 0"
|
"description": "补单时为 true;订单正常计入充值,但服务端强制金币数为 0"
|
||||||
},
|
},
|
||||||
|
"orderDateMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "订单业务时间;未传时服务端默认为当前时间"
|
||||||
|
},
|
||||||
"providerCode": {
|
"providerCode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["mifapay", "v5pay", "usdt"]
|
"enum": ["mifapay", "v5pay", "usdt"]
|
||||||
@ -12459,6 +12466,11 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
|
"orderDateMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "订单业务时间,用于三方凭证按日期查询"
|
||||||
|
},
|
||||||
"providerCurrencyCode": {
|
"providerCurrencyCode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
366
finance/src/components/FinanceCoinSellerRechargeCreateDialog.jsx
Normal file
366
finance/src/components/FinanceCoinSellerRechargeCreateDialog.jsx
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,20 +3,11 @@ import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
|||||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
import Alert from "@mui/material/Alert";
|
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
|
||||||
import Button from "@mui/material/Button";
|
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 MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
COIN_SELLER_RECHARGE_CHAINS,
|
|
||||||
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
|
|
||||||
COIN_SELLER_RECHARGE_PROVIDERS,
|
COIN_SELLER_RECHARGE_PROVIDERS,
|
||||||
COIN_SELLER_RECHARGE_STATUS,
|
COIN_SELLER_RECHARGE_STATUS,
|
||||||
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
||||||
@ -38,6 +29,7 @@ import {
|
|||||||
isCoinSellerRechargeVerified,
|
isCoinSellerRechargeVerified,
|
||||||
validateCoinSellerRechargeOrderPayload,
|
validateCoinSellerRechargeOrderPayload,
|
||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
|
import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx";
|
||||||
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
|
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
|
||||||
|
|
||||||
export function FinanceCoinSellerRechargeOrderList({
|
export function FinanceCoinSellerRechargeOrderList({
|
||||||
@ -131,6 +123,7 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
setForm({
|
setForm({
|
||||||
...DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
...DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
||||||
appCode: filters.appCode || apps[0]?.appCode || "",
|
appCode: filters.appCode || apps[0]?.appCode || "",
|
||||||
|
orderDateMs: Date.now(),
|
||||||
});
|
});
|
||||||
setFormError("");
|
setFormError("");
|
||||||
setQuote(null);
|
setQuote(null);
|
||||||
@ -277,28 +270,31 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
|
className="finance-filterrow__search"
|
||||||
label="搜索"
|
label="搜索"
|
||||||
size="small"
|
size="small"
|
||||||
value={filters.keyword}
|
value={filters.keyword}
|
||||||
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<span className="finance-filterrow__spacer" />
|
<span className="finance-filterrow__spacer" />
|
||||||
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
<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>
|
</Button>
|
||||||
) : null}
|
{canConfigureExchangeRate ? (
|
||||||
<Button
|
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
|
||||||
disabled={!canCreate}
|
金币汇率配置
|
||||||
startIcon={<AddOutlined fontSize="small" />}
|
</Button>
|
||||||
variant="contained"
|
) : null}
|
||||||
onClick={openCreate}
|
<Button
|
||||||
>
|
disabled={!canCreate}
|
||||||
创建币商充值
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
</Button>
|
variant="contained"
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
|
创建币商充值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div className="finance-error">{error}</div> : null}
|
{error ? <div className="finance-error">{error}</div> : null}
|
||||||
@ -379,8 +375,11 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Stacked
|
<Stacked
|
||||||
primary={formatTime(item.createdAtMs)}
|
primary={formatTime(item.orderDateMs || item.createdAtMs)}
|
||||||
secondary={[
|
secondary={[
|
||||||
|
item.orderDateMs && item.createdAtMs !== item.orderDateMs
|
||||||
|
? `录入 ${formatTime(item.createdAtMs)}`
|
||||||
|
: "",
|
||||||
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
|
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
|
||||||
item.grantedAtMs &&
|
item.grantedAtMs &&
|
||||||
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
|
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
|
||||||
@ -455,7 +454,7 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<CreateDialog
|
<FinanceCoinSellerRechargeCreateDialog
|
||||||
apps={apps}
|
apps={apps}
|
||||||
form={form}
|
form={form}
|
||||||
formError={formError}
|
formError={formError}
|
||||||
@ -489,250 +488,10 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateDialog({
|
|
||||||
apps,
|
|
||||||
form,
|
|
||||||
formError,
|
|
||||||
loading,
|
|
||||||
onChange,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
onVerifyReceipt,
|
|
||||||
open,
|
|
||||||
receiptLoading,
|
|
||||||
receiptVerification,
|
|
||||||
quote,
|
|
||||||
quoteError,
|
|
||||||
quoteLoading,
|
|
||||||
}) {
|
|
||||||
const disabled = loading || receiptLoading;
|
|
||||||
const canCreate = receiptVerification?.verified === true && (form.isMakeup || Boolean(quote?.coinAmount)) && !disabled;
|
|
||||||
|
|
||||||
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>创建币商充值</DialogTitle>
|
|
||||||
<DialogContent className="finance-create-dialog">
|
|
||||||
<form
|
|
||||||
className="finance-form finance-form--dialog"
|
|
||||||
id="coin-seller-recharge-order-form"
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
>
|
|
||||||
{formError ? <Alert severity="error">{formError}</Alert> : null}
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
form.providerCode === "usdt"
|
|
||||||
? "finance-form-grid finance-form-grid--coin-seller-create"
|
|
||||||
: "finance-form-grid finance-form-grid--coin-seller-create finance-form-grid--coin-seller-no-chain"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{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)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
|
|
||||||
inputProps={{ min: 0, step: "0.01" }}
|
|
||||||
label="USD 金额"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={form.usdAmount}
|
|
||||||
onChange={(event) => onChange("usdAmount", event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
aria-pressed={form.isMakeup}
|
|
||||||
disabled={disabled}
|
|
||||||
type="button"
|
|
||||||
variant={form.isMakeup ? "contained" : "outlined"}
|
|
||||||
onClick={() => onChange("isMakeup", !form.isMakeup)}
|
|
||||||
>
|
|
||||||
补单
|
|
||||||
</Button>
|
|
||||||
<TextField
|
|
||||||
disabled
|
|
||||||
InputProps={{ endAdornment: <InputAdornment position="end">金币</InputAdornment> }}
|
|
||||||
helperText={quoteHelperText(quote, quoteError, quoteLoading, form.isMakeup)}
|
|
||||||
label="充值金币"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={form.coinAmount}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
) : null}
|
|
||||||
{form.providerCode !== "usdt" ? (
|
|
||||||
<>
|
|
||||||
<Autocomplete
|
|
||||||
disabled={disabled}
|
|
||||||
getOptionLabel={currencyOptionLabel}
|
|
||||||
isOptionEqualToValue={(option, value) => option === value}
|
|
||||||
noOptionsText="无匹配币种"
|
|
||||||
options={providerCurrencyOptions(form.providerCode)}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label="币种" required />
|
|
||||||
)}
|
|
||||||
value={form.providerCurrencyCode || null}
|
|
||||||
onChange={(_, value) => onChange("providerCurrencyCode", value || "")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
inputProps={{ min: 0, step: "0.01" }}
|
|
||||||
label="币种金额"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={form.providerAmount}
|
|
||||||
onChange={(event) => onChange("providerAmount", event.target.value)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<div className="finance-receipt-field">
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="三方订单号"
|
|
||||||
required
|
|
||||||
value={form.externalOrderNo}
|
|
||||||
onChange={(event) => onChange("externalOrderNo", event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={disabled || !onVerifyReceipt}
|
|
||||||
startIcon={<FactCheckOutlined fontSize="small" />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onVerifyReceipt}
|
|
||||||
>
|
|
||||||
校验
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
inputProps={{ maxLength: 512 }}
|
|
||||||
label="备注"
|
|
||||||
value={form.remark}
|
|
||||||
onChange={(event) => onChange("remark", event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{receiptVerification ? <ReceiptVerificationBanner verification={receiptVerification} /> : null}
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button disabled={disabled} variant="outlined" onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button disabled={!canCreate} form="coin-seller-recharge-order-form" type="submit" variant="contained">
|
|
||||||
创建
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isZeroCoinMakeupOrder(order) {
|
function isZeroCoinMakeupOrder(order) {
|
||||||
return Number(order?.coinAmount || 0) === 0;
|
return Number(order?.coinAmount || 0) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerCurrencyOptions(providerCode) {
|
|
||||||
return COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[providerCode] || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function currencyOptionLabel(option) {
|
|
||||||
return option || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
function StatusBadge({ status }) {
|
||||||
return (
|
return (
|
||||||
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>
|
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>
|
||||||
|
|||||||
@ -3,10 +3,14 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx";
|
import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("coin seller recharge order create dialog verifies receipt before create", async () => {
|
test("coin seller recharge order create dialog verifies receipt before create", async () => {
|
||||||
|
const nowMS = new Date(2026, 6, 13, 12, 30, 0, 0).getTime();
|
||||||
|
const historicalOrderMS = new Date(2026, 6, 11, 12, 30, 0, 0).getTime();
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(nowMS);
|
||||||
const onCreate = vi.fn(async () => ({ id: "order-2" }));
|
const onCreate = vi.fn(async () => ({ id: "order-2" }));
|
||||||
const onQuote = vi.fn(async () => ({
|
const onQuote = vi.fn(async () => ({
|
||||||
coinAmount: 1000000,
|
coinAmount: 1000000,
|
||||||
@ -41,8 +45,10 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
|
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
|
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
|
||||||
await waitFor(() => expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(1000000));
|
fireEvent.click(screen.getByRole("button", { name: /订单时间/ }));
|
||||||
expect(screen.getByRole("spinbutton", { name: "充值金币" })).toBeDisabled();
|
fireEvent.click(screen.getByRole("button", { name: "2026-07-11" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "确定" }));
|
||||||
|
await waitFor(() => expect(screen.getByRole("status", { name: "充值金币" })).toHaveTextContent("1,000,000"));
|
||||||
expect(screen.getByText(/白名单 10001.*1 USD = 100,000 金币/)).toBeInTheDocument();
|
expect(screen.getByText(/白名单 10001.*1 USD = 100,000 金币/)).toBeInTheDocument();
|
||||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
|
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
|
||||||
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
|
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
|
||||||
@ -57,6 +63,7 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
expect(onVerifyReceipt).toHaveBeenCalledWith({
|
expect(onVerifyReceipt).toHaveBeenCalledWith({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "V5-001",
|
externalOrderNo: "V5-001",
|
||||||
|
orderDateMs: historicalOrderMS,
|
||||||
providerAmountMinor: 4800,
|
providerAmountMinor: 4800,
|
||||||
providerCode: "v5pay",
|
providerCode: "v5pay",
|
||||||
providerCurrencyCode: "PHP",
|
providerCurrencyCode: "PHP",
|
||||||
@ -68,6 +75,7 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "V5-001",
|
externalOrderNo: "V5-001",
|
||||||
isMakeup: false,
|
isMakeup: false,
|
||||||
|
orderDateMs: historicalOrderMS,
|
||||||
providerAmountMinor: 4800,
|
providerAmountMinor: 4800,
|
||||||
providerCode: "v5pay",
|
providerCode: "v5pay",
|
||||||
providerCurrencyCode: "PHP",
|
providerCurrencyCode: "PHP",
|
||||||
@ -79,6 +87,8 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("makeup order keeps zero coins while creating a normal recharge record", async () => {
|
test("makeup order keeps zero coins while creating a normal recharge record", async () => {
|
||||||
|
const nowMS = new Date(2026, 6, 13, 9, 0, 0, 0).getTime();
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(nowMS);
|
||||||
const onCreate = vi.fn(async () => ({ id: "makeup-order" }));
|
const onCreate = vi.fn(async () => ({ id: "makeup-order" }));
|
||||||
const onQuote = vi.fn();
|
const onQuote = vi.fn();
|
||||||
const onVerifyReceipt = vi.fn(async () => ({ status: "paid", verified: true }));
|
const onVerifyReceipt = vi.fn(async () => ({ status: "paid", verified: true }));
|
||||||
@ -94,7 +104,7 @@ test("makeup order keeps zero coins while creating a normal recharge record", as
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "补单" }));
|
fireEvent.click(screen.getByRole("button", { name: "补单" }));
|
||||||
expect(screen.getByRole("button", { name: "补单" })).toHaveAttribute("aria-pressed", "true");
|
expect(screen.getByRole("button", { name: "补单" })).toHaveAttribute("aria-pressed", "true");
|
||||||
expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(0);
|
expect(screen.getByRole("status", { name: "充值金币" })).toHaveTextContent("0");
|
||||||
expect(screen.getByText("补单不发放金币,充值金额仍正常计入统计")).toBeInTheDocument();
|
expect(screen.getByText("补单不发放金币,充值金额仍正常计入统计")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "123456" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "123456" } });
|
||||||
@ -112,6 +122,7 @@ test("makeup order keeps zero coins while creating a normal recharge record", as
|
|||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "MIFA-MAKEUP-001",
|
externalOrderNo: "MIFA-MAKEUP-001",
|
||||||
isMakeup: true,
|
isMakeup: true,
|
||||||
|
orderDateMs: nowMS,
|
||||||
providerAmountMinor: 720000,
|
providerAmountMinor: 720000,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
providerCurrencyCode: "PHP",
|
providerCurrencyCode: "PHP",
|
||||||
|
|||||||
@ -113,6 +113,7 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = {
|
|||||||
coinAmount: "",
|
coinAmount: "",
|
||||||
externalOrderNo: "",
|
externalOrderNo: "",
|
||||||
isMakeup: false,
|
isMakeup: false,
|
||||||
|
orderDateMs: "",
|
||||||
providerAmount: "",
|
providerAmount: "",
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
providerCurrencyCode: "",
|
providerCurrencyCode: "",
|
||||||
|
|||||||
@ -136,6 +136,7 @@ export function buildCoinSellerRechargeOrderPayload(form) {
|
|||||||
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
||||||
externalOrderNo: stringValue(form.externalOrderNo),
|
externalOrderNo: stringValue(form.externalOrderNo),
|
||||||
isMakeup: Boolean(form.isMakeup),
|
isMakeup: Boolean(form.isMakeup),
|
||||||
|
orderDateMs: numberValue(form.orderDateMs),
|
||||||
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
||||||
providerCode,
|
providerCode,
|
||||||
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
||||||
@ -156,6 +157,7 @@ export function buildCoinSellerRechargeReceiptPayload(form) {
|
|||||||
appCode: stringValue(form.appCode),
|
appCode: stringValue(form.appCode),
|
||||||
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
||||||
externalOrderNo: stringValue(form.externalOrderNo),
|
externalOrderNo: stringValue(form.externalOrderNo),
|
||||||
|
orderDateMs: numberValue(form.orderDateMs),
|
||||||
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
||||||
providerCode,
|
providerCode,
|
||||||
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
||||||
@ -174,6 +176,9 @@ export function validateCoinSellerRechargeOrderPayload(payload) {
|
|||||||
if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
|
if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
|
||||||
return "请填写大于 0 的 USD 金额";
|
return "请填写大于 0 的 USD 金额";
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(payload.orderDateMs) || payload.orderDateMs <= 0) {
|
||||||
|
return "请选择订单时间";
|
||||||
|
}
|
||||||
if (!payload.providerCode) {
|
if (!payload.providerCode) {
|
||||||
return "请选择支付渠道";
|
return "请选择支付渠道";
|
||||||
}
|
}
|
||||||
@ -266,6 +271,7 @@ export function normalizeCoinSellerRechargeOrder(item = {}) {
|
|||||||
grantedByName: stringValue(item.grantedByName ?? item.granted_by_name),
|
grantedByName: stringValue(item.grantedByName ?? item.granted_by_name),
|
||||||
id: stringValue(item.id ?? item.orderId ?? item.order_id),
|
id: stringValue(item.id ?? item.orderId ?? item.order_id),
|
||||||
operatorName: stringValue(item.operatorName ?? item.operator_name),
|
operatorName: stringValue(item.operatorName ?? item.operator_name),
|
||||||
|
orderDateMs: numberOrNull(item.orderDateMs ?? item.order_date_ms),
|
||||||
providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor),
|
providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor),
|
||||||
providerCode: stringValue(item.providerCode ?? item.provider_code),
|
providerCode: stringValue(item.providerCode ?? item.provider_code),
|
||||||
providerCurrencyCode: stringValue(item.providerCurrencyCode ?? item.provider_currency_code),
|
providerCurrencyCode: stringValue(item.providerCurrencyCode ?? item.provider_currency_code),
|
||||||
|
|||||||
@ -173,6 +173,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
appCode: " lalu ",
|
appCode: " lalu ",
|
||||||
coinAmount: "100000",
|
coinAmount: "100000",
|
||||||
externalOrderNo: " MIFA-001 ",
|
externalOrderNo: " MIFA-001 ",
|
||||||
|
orderDateMs: 1760000000000,
|
||||||
providerAmount: "1250.5",
|
providerAmount: "1250.5",
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
providerCurrencyCode: " php ",
|
providerCurrencyCode: " php ",
|
||||||
@ -185,6 +186,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
isMakeup: false,
|
isMakeup: false,
|
||||||
|
orderDateMs: 1760000000000,
|
||||||
providerAmountMinor: 125050,
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
providerCurrencyCode: "PHP",
|
providerCurrencyCode: "PHP",
|
||||||
@ -201,6 +203,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
orderDateMs: 1760000000000,
|
||||||
providerAmountMinor: 125050,
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
providerCurrencyCode: "PHP",
|
providerCurrencyCode: "PHP",
|
||||||
@ -263,6 +266,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
chain: "TRON",
|
chain: "TRON",
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
isMakeup: false,
|
isMakeup: false,
|
||||||
|
orderDateMs: 1760000000000,
|
||||||
providerCode: "usdt",
|
providerCode: "usdt",
|
||||||
remark: "人工补单",
|
remark: "人工补单",
|
||||||
targetUserId: "10001",
|
targetUserId: "10001",
|
||||||
@ -280,6 +284,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
chain: "BSC",
|
chain: "BSC",
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
orderDateMs: 1760000000000,
|
||||||
providerCode: "usdt",
|
providerCode: "usdt",
|
||||||
usdAmount: 10.5,
|
usdAmount: 10.5,
|
||||||
});
|
});
|
||||||
@ -294,6 +299,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
grant_status: "pending",
|
grant_status: "pending",
|
||||||
id: "order-1",
|
id: "order-1",
|
||||||
provider_code: "mifapay",
|
provider_code: "mifapay",
|
||||||
|
order_date_ms: 1759900000000,
|
||||||
remark: "人工补单",
|
remark: "人工补单",
|
||||||
target_display_user_id: "165075",
|
target_display_user_id: "165075",
|
||||||
target_user_id: "10001",
|
target_user_id: "10001",
|
||||||
@ -312,6 +318,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
coinAmount: 100000,
|
coinAmount: 100000,
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
id: "order-1",
|
id: "order-1",
|
||||||
|
orderDateMs: 1759900000000,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
remark: "人工补单",
|
remark: "人工补单",
|
||||||
targetDisplayUserId: "165075",
|
targetDisplayUserId: "165075",
|
||||||
|
|||||||
@ -536,8 +536,11 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.finance-create-dialog-paper--coin-seller {
|
.finance-create-dialog-paper--coin-seller {
|
||||||
width: min(760px, calc(100vw - 64px));
|
width: min(1120px, calc(100vw - 64px));
|
||||||
max-width: min(760px, calc(100vw - 64px)) !important;
|
height: min(760px, calc(100vh - 32px));
|
||||||
|
max-width: min(1120px, calc(100vw - 64px)) !important;
|
||||||
|
max-height: calc(100vh - 32px) !important;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-create-dialog-paper .MuiDialogTitle-root {
|
.finance-create-dialog-paper .MuiDialogTitle-root {
|
||||||
@ -564,6 +567,503 @@ a {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finance-form-grid .finance-order-date-picker {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 币商充值按虚拟收银台组织:录入区负责分步操作,深色预览区持续强调最终入账结果。 */
|
||||||
|
.finance-entry-header.MuiDialogTitle-root {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
padding: 18px 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-header__icon {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex: 0 0 42px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary-surface);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-header__icon svg {
|
||||||
|
font-size: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-header > span:last-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-header strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-header small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-create-dialog--entry {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-form {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-form > .MuiAlert-root {
|
||||||
|
margin: var(--space-4) var(--space-5) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 304px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-fields {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section {
|
||||||
|
min-width: 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section__index {
|
||||||
|
display: grid;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--active-contrast);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section__icon svg {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section__header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section__body {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-field-grid {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-field-grid--2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-field-grid--3 {
|
||||||
|
grid-template-columns: 1fr 0.8fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-usd-field .MuiInputBase-input {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-usd-field .MuiInputAdornment-root {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section .finance-order-date-picker {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: var(--control-height) !important;
|
||||||
|
min-height: var(--control-height) !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: end !important;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 5px 28px 1px var(--control-padding-x) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode button {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 7px 12px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), box-shadow var(--motion-base) var(--ease-emphasized), transform var(--motion-base) var(--ease-emphasized);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-card);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode button.is-active {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 3px 10px rgb(15 23 42 / 8%);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode button.is-active.is-warning {
|
||||||
|
border-color: var(--warning-border);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-mode small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result {
|
||||||
|
display: flex;
|
||||||
|
min-height: 64px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
border: 1px solid var(--primary-border);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(100deg, var(--primary-surface), rgb(255 255 255 / 80%));
|
||||||
|
padding: 11px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result--makeup {
|
||||||
|
border-color: var(--warning-border);
|
||||||
|
border-left-color: var(--warning);
|
||||||
|
background: linear-gradient(100deg, var(--warning-surface), rgb(255 255 255 / 80%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result output {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result--makeup output {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result em {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 20px 20px 20px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(160deg, #17233a 0%, #0f172a 65%, #111d33 100%);
|
||||||
|
box-shadow: 0 18px 34px rgb(15 23 42 / 18%);
|
||||||
|
color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview::before {
|
||||||
|
position: absolute;
|
||||||
|
top: -70px;
|
||||||
|
right: -70px;
|
||||||
|
width: 170px;
|
||||||
|
height: 170px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(37 99 235 / 25%);
|
||||||
|
content: "";
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview > * {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #dbeafe;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview > header b {
|
||||||
|
border: 1px solid rgb(96 165 250 / 45%);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgb(37 99 235 / 18%);
|
||||||
|
color: #bfdbfe;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview > header b.is-warning {
|
||||||
|
border-color: rgb(251 191 36 / 45%);
|
||||||
|
background: rgb(217 119 6 / 18%);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__amount {
|
||||||
|
padding: 38px 0 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__amount small {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__amount strong {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__amount strong span {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 20px;
|
||||||
|
vertical-align: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__amount p {
|
||||||
|
margin: 7px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__coins {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border-top: 1px solid rgb(148 163 184 / 16%);
|
||||||
|
border-bottom: 1px solid rgb(148 163 184 / 16%);
|
||||||
|
padding: 13px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__coins span {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__coins strong {
|
||||||
|
color: #93c5fd;
|
||||||
|
font-size: 17px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview dd {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__verify {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
border: 1px solid rgb(148 163 184 / 18%);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgb(255 255 255 / 4%);
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__verify.is-verified {
|
||||||
|
border-color: rgb(74 222 128 / 28%);
|
||||||
|
background: rgb(22 163 74 / 10%);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__verify svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__verify span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__verify strong {
|
||||||
|
color: currentColor;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__verify small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__notice {
|
||||||
|
margin: auto 0 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(37 99 235 / 12%);
|
||||||
|
color: #93c5fd;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview__notice.is-warning {
|
||||||
|
background: rgb(217 119 6 / 14%);
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-actions.MuiDialogActions-root {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
padding: 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-actions__hint {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-actions__hint svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-actions .MuiButton-root {
|
||||||
|
min-width: 88px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-actions .MuiButton-contained {
|
||||||
|
min-width: 116px;
|
||||||
|
box-shadow: var(--brand-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
.finance-rate-config {
|
.finance-rate-config {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
@ -745,6 +1245,60 @@ a {
|
|||||||
.finance-form--dialog .finance-evidence-grid {
|
.finance-form--dialog .finance-evidence-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finance-entry-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview {
|
||||||
|
min-height: 480px;
|
||||||
|
margin: 0 20px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.finance-entry-header.MuiDialogTitle-root {
|
||||||
|
padding: 14px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-fields {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-section__body {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-field-grid--2,
|
||||||
|
.finance-entry-field-grid--3,
|
||||||
|
.finance-entry-mode {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-quote-result p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-receipt-field {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-preview {
|
||||||
|
margin: 0 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-actions.MuiDialogActions-root {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-entry-actions__hint {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@ -1209,16 +1763,36 @@ a {
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-filterrow__region {
|
.finance-filterrow > .MuiFormControl-root,
|
||||||
min-width: 140px;
|
.finance-filterrow > .MuiAutocomplete-root {
|
||||||
|
width: 132px;
|
||||||
|
min-width: 132px;
|
||||||
|
flex: 0 0 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-filterrow__search {
|
.finance-filterrow > .finance-filterrow__region {
|
||||||
|
width: 140px;
|
||||||
|
min-width: 140px;
|
||||||
|
flex-basis: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-filterrow > .finance-filterrow__search {
|
||||||
|
width: 240px;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
|
flex: 0 1 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-filterrow__spacer {
|
.finance-filterrow__spacer {
|
||||||
flex: 1;
|
min-width: var(--space-2);
|
||||||
|
flex: 1 1 var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-filterrow__actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-fchip {
|
.finance-fchip {
|
||||||
|
|||||||
@ -471,7 +471,8 @@ function levelTrackForm(level) {
|
|||||||
return {
|
return {
|
||||||
durationDays: String(remainingDays),
|
durationDays: String(remainingDays),
|
||||||
enabled: false,
|
enabled: false,
|
||||||
level: String(Math.min(50, Math.max(1, currentTarget))),
|
// 旧的临时等级也要保留到产品支持的 100 级,避免打开用户资料时把有效的高等级回填成 50。
|
||||||
|
level: String(Math.min(100, Math.max(1, currentTarget))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
src/features/app-users/levelAdjustmentSchema.test.ts
Normal file
22
src/features/app-users/levelAdjustmentSchema.test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { appUserLevelAdjustmentSchema } from "./schema";
|
||||||
|
|
||||||
|
const adjustment = (level: string) => ({
|
||||||
|
charm: { durationDays: "7", enabled: false, level: "" },
|
||||||
|
reason: "运营修正",
|
||||||
|
wealth: { durationDays: "7", enabled: true, level },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appUserLevelAdjustmentSchema", () => {
|
||||||
|
test("accepts the product maximum of level 100", () => {
|
||||||
|
expect(appUserLevelAdjustmentSchema.safeParse(adjustment("100")).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a level above 100", () => {
|
||||||
|
const result = appUserLevelAdjustmentSchema.safeParse(adjustment("101"));
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues).toContainEqual(expect.objectContaining({ message: "目标等级必须是 1 到 100 的整数" }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -474,7 +474,7 @@ function LevelAdjustmentFields({ page }) {
|
|||||||
<TextField
|
<TextField
|
||||||
disabled={!page.abilities.canLevel || !field.enabled}
|
disabled={!page.abilities.canLevel || !field.enabled}
|
||||||
label="目标等级"
|
label="目标等级"
|
||||||
slotProps={{ htmlInput: { max: 50, min: 1, step: 1 } }}
|
slotProps={{ htmlInput: { max: 100, min: 1, step: 1 } }}
|
||||||
type="number"
|
type="number"
|
||||||
value={field.level}
|
value={field.level}
|
||||||
onChange={(event) => updateTrack(track, { level: event.target.value })}
|
onChange={(event) => updateTrack(track, { level: event.target.value })}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const temporaryLevelTrackSchema = z.object({
|
|||||||
level: z.string().trim().optional().default(""),
|
level: z.string().trim().optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界,后端继续负责真实等级、规则启用状态和事务原子性。
|
// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界(产品等级上限为 100),后端继续负责真实等级、规则启用状态和事务原子性。
|
||||||
export const appUserLevelAdjustmentSchema = z
|
export const appUserLevelAdjustmentSchema = z
|
||||||
.object({
|
.object({
|
||||||
charm: temporaryLevelTrackSchema,
|
charm: temporaryLevelTrackSchema,
|
||||||
@ -41,8 +41,8 @@ export const appUserLevelAdjustmentSchema = z
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const level = Number(track.level);
|
const level = Number(track.level);
|
||||||
if (!Number.isInteger(level) || level < 1 || level > 50) {
|
if (!Number.isInteger(level) || level < 1 || level > 100) {
|
||||||
context.addIssue({ code: "custom", message: "目标等级必须是 1 到 50 的整数", path: [name, "level"] });
|
context.addIssue({ code: "custom", message: "目标等级必须是 1 到 100 的整数", path: [name, "level"] });
|
||||||
}
|
}
|
||||||
const durationDays = Number(track.durationDays);
|
const durationDays = Number(track.durationDays);
|
||||||
if (!Number.isInteger(durationDays) || durationDays < 1 || durationDays > 36500) {
|
if (!Number.isInteger(durationDays) || durationDays < 1 || durationDays > 36500) {
|
||||||
|
|||||||
189
src/shared/ui/DateTimePicker.jsx
Normal file
189
src/shared/ui/DateTimePicker.jsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import AccessTimeOutlined from "@mui/icons-material/AccessTimeOutlined";
|
||||||
|
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
|
||||||
|
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
||||||
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Popover from "@mui/material/Popover";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import {
|
||||||
|
calendarDays,
|
||||||
|
formatRangePart,
|
||||||
|
monthTitle,
|
||||||
|
normalizeMs,
|
||||||
|
pad2,
|
||||||
|
sameDay,
|
||||||
|
} from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import styles from "@/shared/ui/TimeRangeFilter.module.css";
|
||||||
|
|
||||||
|
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||||
|
|
||||||
|
// 单点业务时间与区间筛选复用同一套日历、时分下拉和动效,避免财务表单退回浏览器原生 datetime 输入。
|
||||||
|
export function DateTimePicker({ className = "", disabled = false, label = "时间", onChange, value }) {
|
||||||
|
const selectedMs = normalizeMs(value);
|
||||||
|
// 空值只用于首次内部 state 占位;真正打开时在点击事件中读取当前时间,保持 render 纯函数。
|
||||||
|
const selectedDate = useMemo(() => new Date(selectedMs || 0), [selectedMs]);
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const [draft, setDraft] = useState(selectedDate);
|
||||||
|
const [viewDate, setViewDate] = useState(selectedDate);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const days = useMemo(() => calendarDays(viewDate), [viewDate]);
|
||||||
|
|
||||||
|
const openPopover = (event) => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = new Date(selectedMs || Date.now());
|
||||||
|
setDraft(current);
|
||||||
|
setViewDate(current);
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
const closePopover = () => setAnchorEl(null);
|
||||||
|
const changeMonth = (offset) => {
|
||||||
|
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
|
||||||
|
};
|
||||||
|
const changeDay = (day) => {
|
||||||
|
// 切换日期时保留已选时分,便于补录前天同一时刻的订单,而不是悄悄归零到当天零点。
|
||||||
|
setDraft(
|
||||||
|
(current) =>
|
||||||
|
new Date(
|
||||||
|
day.getFullYear(),
|
||||||
|
day.getMonth(),
|
||||||
|
day.getDate(),
|
||||||
|
current.getHours(),
|
||||||
|
current.getMinutes(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||||
|
};
|
||||||
|
const changeTime = (unit, valueText) => {
|
||||||
|
setDraft((current) => {
|
||||||
|
const next = new Date(current);
|
||||||
|
if (unit === "hour") {
|
||||||
|
next.setHours(Number(valueText));
|
||||||
|
} else {
|
||||||
|
next.setMinutes(Number(valueText));
|
||||||
|
}
|
||||||
|
next.setSeconds(0, 0);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const selectNow = () => {
|
||||||
|
const now = new Date();
|
||||||
|
setDraft(now);
|
||||||
|
setViewDate(now);
|
||||||
|
};
|
||||||
|
const submit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
// Popover 通过 portal 渲染,必须截断 submit 冒泡,否则确认时间会误提交外层“创建币商充值”表单。
|
||||||
|
event.stopPropagation();
|
||||||
|
onChange(draft.getTime());
|
||||||
|
closePopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
className={[styles.trigger, className].filter(Boolean).join(" ")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={openPopover}
|
||||||
|
>
|
||||||
|
<span className={styles.triggerMeta}>
|
||||||
|
<AccessTimeOutlined fontSize="inherit" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.triggerText}>{formatRangePart(selectedMs) || "请选择"}</span>
|
||||||
|
<KeyboardArrowDownOutlined className={styles.triggerChevron} fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
|
||||||
|
slotProps={{ paper: { className: styles.paper } }}
|
||||||
|
transformOrigin={{ horizontal: "left", vertical: "top" }}
|
||||||
|
onClose={closePopover}
|
||||||
|
>
|
||||||
|
<form className={styles.panel} onSubmit={submit}>
|
||||||
|
<div className={styles.quickRanges}>
|
||||||
|
<Button className={styles.quickButton} onClick={selectNow}>
|
||||||
|
现在
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.calendarHeader}>
|
||||||
|
<IconButton aria-label="上个月" size="small" onClick={() => changeMonth(-1)}>
|
||||||
|
<ChevronLeftOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<span className={styles.calendarTitle}>{monthTitle(viewDate)}</span>
|
||||||
|
<IconButton aria-label="下个月" size="small" onClick={() => changeMonth(1)}>
|
||||||
|
<ChevronRightOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className={styles.calendarGrid}>
|
||||||
|
{weekLabels.map((weekday) => (
|
||||||
|
<span className={styles.weekday} key={weekday}>
|
||||||
|
{weekday}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{days.map((day) => (
|
||||||
|
<button
|
||||||
|
aria-label={`${day.getFullYear()}-${pad2(day.getMonth() + 1)}-${pad2(day.getDate())}`}
|
||||||
|
className={[
|
||||||
|
styles.dayButton,
|
||||||
|
day.getMonth() !== viewDate.getMonth() ? styles.dayOutside : "",
|
||||||
|
sameDay(day, draft) ? styles.daySelected : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeDay(day)}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.timeGrid}>
|
||||||
|
<TextField
|
||||||
|
label="时"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={String(draft.getHours())}
|
||||||
|
onChange={(event) => changeTime("hour", event.target.value)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, hour) => (
|
||||||
|
<MenuItem key={hour} value={String(hour)}>
|
||||||
|
{pad2(hour)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="分"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={String(draft.getMinutes())}
|
||||||
|
onChange={(event) => changeTime("minute", event.target.value)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 60 }, (_, minute) => (
|
||||||
|
<MenuItem key={minute} value={String(minute)}>
|
||||||
|
{pad2(minute)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button onClick={closePopover}>取消</Button>
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/shared/ui/DateTimePicker.test.jsx
Normal file
15
src/shared/ui/DateTimePicker.test.jsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { DateTimePicker } from "@/shared/ui/DateTimePicker.jsx";
|
||||||
|
|
||||||
|
test("selects one business date and time without submitting an outer form", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const initial = new Date(2026, 6, 13, 14, 30, 0, 0).getTime();
|
||||||
|
render(<DateTimePicker label="订单时间" value={initial} onChange={onChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /订单时间/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "2026-07-11" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "确定" }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(new Date(2026, 6, 11, 14, 30, 0, 0).getTime());
|
||||||
|
});
|
||||||
@ -319,7 +319,7 @@ function setRangeField(range, field, value) {
|
|||||||
return field === "start" ? { ...range, startMs: value } : { ...range, endMs: value };
|
return field === "start" ? { ...range, startMs: value } : { ...range, endMs: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
function calendarDays(viewDate) {
|
export function calendarDays(viewDate) {
|
||||||
const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
|
const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
|
||||||
const mondayIndex = (firstDay.getDay() + 6) % 7;
|
const mondayIndex = (firstDay.getDay() + 6) % 7;
|
||||||
const start = new Date(firstDay);
|
const start = new Date(firstDay);
|
||||||
@ -332,7 +332,7 @@ function calendarDays(viewDate) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameDay(left, right) {
|
export function sameDay(left, right) {
|
||||||
return (
|
return (
|
||||||
left.getFullYear() === right.getFullYear() &&
|
left.getFullYear() === right.getFullYear() &&
|
||||||
left.getMonth() === right.getMonth() &&
|
left.getMonth() === right.getMonth() &&
|
||||||
@ -340,11 +340,11 @@ function sameDay(left, right) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthTitle(date) {
|
export function monthTitle(date) {
|
||||||
return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`;
|
return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRangePart(value) {
|
export function formatRangePart(value) {
|
||||||
const text = msToDatetimeText(value);
|
const text = msToDatetimeText(value);
|
||||||
return text ? text.replace("-", "/").replace("-", "/") : "";
|
return text ? text.replace("-", "/").replace("-", "/") : "";
|
||||||
}
|
}
|
||||||
@ -367,7 +367,7 @@ function msToDatetimeText(value) {
|
|||||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMs(value) {
|
export function normalizeMs(value) {
|
||||||
if (value === "" || value === null || value === undefined) {
|
if (value === "" || value === null || value === undefined) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@ -375,6 +375,6 @@ function normalizeMs(value) {
|
|||||||
return Number.isFinite(number) && number > 0 ? number : "";
|
return Number.isFinite(number) && number > 0 ? number : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad2(value) {
|
export function pad2(value) {
|
||||||
return String(value).padStart(2, "0");
|
return String(value).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user