增加创建时间

This commit is contained in:
zhx 2026-07-13 11:10:08 +08:00
parent 2f384687b5
commit 1b9982e52c
15 changed files with 1252 additions and 289 deletions

View File

@ -8581,7 +8581,7 @@
"required": ["track", "level", "duration_days"],
"properties": {
"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 }
}
}
@ -12028,8 +12028,10 @@
"receiveAddress": {
"type": "string"
},
"orderDate": {
"type": "string"
"orderDateMs": {
"type": "integer",
"format": "int64",
"description": "订单业务时间;历史订单为 0 时回退到 createdAtMs"
},
"status": {
"type": "string"
@ -12218,6 +12220,11 @@
"type": "boolean",
"description": "补单时为 true订单正常计入充值但服务端强制金币数为 0"
},
"orderDateMs": {
"type": "integer",
"format": "int64",
"description": "订单业务时间;未传时服务端默认为当前时间"
},
"providerCode": {
"type": "string",
"enum": ["mifapay", "v5pay", "usdt"]
@ -12459,6 +12466,11 @@
"type": "integer",
"minimum": 0
},
"orderDateMs": {
"type": "integer",
"format": "int64",
"description": "订单业务时间,用于三方凭证按日期查询"
},
"providerCurrencyCode": {
"type": "string"
},

View 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>
);
}

View File

@ -3,20 +3,11 @@ import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
import PaidOutlined from "@mui/icons-material/PaidOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import 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 { useEffect, useState } from "react";
import {
COIN_SELLER_RECHARGE_CHAINS,
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
COIN_SELLER_RECHARGE_PROVIDERS,
COIN_SELLER_RECHARGE_STATUS,
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
@ -38,6 +29,7 @@ import {
isCoinSellerRechargeVerified,
validateCoinSellerRechargeOrderPayload,
} from "../format.js";
import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx";
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
export function FinanceCoinSellerRechargeOrderList({
@ -131,6 +123,7 @@ export function FinanceCoinSellerRechargeOrderList({
setForm({
...DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
appCode: filters.appCode || apps[0]?.appCode || "",
orderDateMs: Date.now(),
});
setFormError("");
setQuote(null);
@ -277,28 +270,31 @@ export function FinanceCoinSellerRechargeOrderList({
))}
</TextField>
<TextField
className="finance-filterrow__search"
label="搜索"
size="small"
value={filters.keyword}
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
/>
<span className="finance-filterrow__spacer" />
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
刷新
</Button>
{canConfigureExchangeRate ? (
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
金币汇率配置
<div className="finance-filterrow__actions">
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
刷新
</Button>
) : null}
<Button
disabled={!canCreate}
startIcon={<AddOutlined fontSize="small" />}
variant="contained"
onClick={openCreate}
>
创建币商充值
</Button>
{canConfigureExchangeRate ? (
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
金币汇率配置
</Button>
) : null}
<Button
disabled={!canCreate}
startIcon={<AddOutlined fontSize="small" />}
variant="contained"
onClick={openCreate}
>
创建币商充值
</Button>
</div>
</div>
{error ? <div className="finance-error">{error}</div> : null}
@ -379,8 +375,11 @@ export function FinanceCoinSellerRechargeOrderList({
</td>
<td>
<Stacked
primary={formatTime(item.createdAtMs)}
primary={formatTime(item.orderDateMs || item.createdAtMs)}
secondary={[
item.orderDateMs && item.createdAtMs !== item.orderDateMs
? `录入 ${formatTime(item.createdAtMs)}`
: "",
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
item.grantedAtMs &&
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
@ -455,7 +454,7 @@ export function FinanceCoinSellerRechargeOrderList({
</Button>
</div>
) : null}
<CreateDialog
<FinanceCoinSellerRechargeCreateDialog
apps={apps}
form={form}
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) {
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 }) {
return (
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>

View File

@ -3,10 +3,14 @@ import { afterEach, expect, test, vi } from "vitest";
import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
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 onQuote = vi.fn(async () => ({
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("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
await waitFor(() => expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(1000000));
expect(screen.getByRole("spinbutton", { name: "充值金币" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /订单时间/ }));
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();
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
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({
appCode: "lalu",
externalOrderNo: "V5-001",
orderDateMs: historicalOrderMS,
providerAmountMinor: 4800,
providerCode: "v5pay",
providerCurrencyCode: "PHP",
@ -68,6 +75,7 @@ test("coin seller recharge order create dialog verifies receipt before create",
appCode: "lalu",
externalOrderNo: "V5-001",
isMakeup: false,
orderDateMs: historicalOrderMS,
providerAmountMinor: 4800,
providerCode: "v5pay",
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 () => {
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 onQuote = vi.fn();
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: "补单" }));
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();
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",
externalOrderNo: "MIFA-MAKEUP-001",
isMakeup: true,
orderDateMs: nowMS,
providerAmountMinor: 720000,
providerCode: "mifapay",
providerCurrencyCode: "PHP",

View File

@ -113,6 +113,7 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = {
coinAmount: "",
externalOrderNo: "",
isMakeup: false,
orderDateMs: "",
providerAmount: "",
providerCode: "mifapay",
providerCurrencyCode: "",

View File

@ -136,6 +136,7 @@ export function buildCoinSellerRechargeOrderPayload(form) {
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
externalOrderNo: stringValue(form.externalOrderNo),
isMakeup: Boolean(form.isMakeup),
orderDateMs: numberValue(form.orderDateMs),
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
providerCode,
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
@ -156,6 +157,7 @@ export function buildCoinSellerRechargeReceiptPayload(form) {
appCode: stringValue(form.appCode),
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
externalOrderNo: stringValue(form.externalOrderNo),
orderDateMs: numberValue(form.orderDateMs),
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
providerCode,
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
@ -174,6 +176,9 @@ export function validateCoinSellerRechargeOrderPayload(payload) {
if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
return "请填写大于 0 的 USD 金额";
}
if (!Number.isInteger(payload.orderDateMs) || payload.orderDateMs <= 0) {
return "请选择订单时间";
}
if (!payload.providerCode) {
return "请选择支付渠道";
}
@ -266,6 +271,7 @@ export function normalizeCoinSellerRechargeOrder(item = {}) {
grantedByName: stringValue(item.grantedByName ?? item.granted_by_name),
id: stringValue(item.id ?? item.orderId ?? item.order_id),
operatorName: stringValue(item.operatorName ?? item.operator_name),
orderDateMs: numberOrNull(item.orderDateMs ?? item.order_date_ms),
providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor),
providerCode: stringValue(item.providerCode ?? item.provider_code),
providerCurrencyCode: stringValue(item.providerCurrencyCode ?? item.provider_currency_code),

View File

@ -173,6 +173,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
appCode: " lalu ",
coinAmount: "100000",
externalOrderNo: " MIFA-001 ",
orderDateMs: 1760000000000,
providerAmount: "1250.5",
providerCode: "mifapay",
providerCurrencyCode: " php ",
@ -185,6 +186,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
appCode: "lalu",
externalOrderNo: "MIFA-001",
isMakeup: false,
orderDateMs: 1760000000000,
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
@ -201,6 +203,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
).toEqual({
appCode: "lalu",
externalOrderNo: "MIFA-001",
orderDateMs: 1760000000000,
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
@ -263,6 +266,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
chain: "TRON",
externalOrderNo: "MIFA-001",
isMakeup: false,
orderDateMs: 1760000000000,
providerCode: "usdt",
remark: "人工补单",
targetUserId: "10001",
@ -280,6 +284,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
appCode: "lalu",
chain: "BSC",
externalOrderNo: "MIFA-001",
orderDateMs: 1760000000000,
providerCode: "usdt",
usdAmount: 10.5,
});
@ -294,6 +299,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
grant_status: "pending",
id: "order-1",
provider_code: "mifapay",
order_date_ms: 1759900000000,
remark: "人工补单",
target_display_user_id: "165075",
target_user_id: "10001",
@ -312,6 +318,7 @@ test("builds validates and normalizes coin seller recharge orders", () => {
coinAmount: 100000,
externalOrderNo: "MIFA-001",
id: "order-1",
orderDateMs: 1759900000000,
providerCode: "mifapay",
remark: "人工补单",
targetDisplayUserId: "165075",

View File

@ -536,8 +536,11 @@ a {
}
.finance-create-dialog-paper--coin-seller {
width: min(760px, calc(100vw - 64px));
max-width: min(760px, calc(100vw - 64px)) !important;
width: min(1120px, calc(100vw - 64px));
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 {
@ -564,6 +567,503 @@ a {
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 {
display: grid;
gap: 24px;
@ -745,6 +1245,60 @@ a {
.finance-form--dialog .finance-evidence-grid {
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) {
@ -1209,16 +1763,36 @@ a {
padding: var(--space-3) var(--space-4);
}
.finance-filterrow__region {
min-width: 140px;
.finance-filterrow > .MuiFormControl-root,
.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;
flex: 0 1 240px;
}
.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 {

View File

@ -471,7 +471,8 @@ function levelTrackForm(level) {
return {
durationDays: String(remainingDays),
enabled: false,
level: String(Math.min(50, Math.max(1, currentTarget))),
// 旧的临时等级也要保留到产品支持的 100 级,避免打开用户资料时把有效的高等级回填成 50。
level: String(Math.min(100, Math.max(1, currentTarget))),
};
}

View 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 的整数" }));
}
});
});

View File

@ -474,7 +474,7 @@ function LevelAdjustmentFields({ page }) {
<TextField
disabled={!page.abilities.canLevel || !field.enabled}
label="目标等级"
slotProps={{ htmlInput: { max: 50, min: 1, step: 1 } }}
slotProps={{ htmlInput: { max: 100, min: 1, step: 1 } }}
type="number"
value={field.level}
onChange={(event) => updateTrack(track, { level: event.target.value })}

View File

@ -20,7 +20,7 @@ const temporaryLevelTrackSchema = z.object({
level: z.string().trim().optional().default(""),
});
// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界,后端继续负责真实等级、规则启用状态和事务原子性。
// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界(产品等级上限为 100,后端继续负责真实等级、规则启用状态和事务原子性。
export const appUserLevelAdjustmentSchema = z
.object({
charm: temporaryLevelTrackSchema,
@ -41,8 +41,8 @@ export const appUserLevelAdjustmentSchema = z
return;
}
const level = Number(track.level);
if (!Number.isInteger(level) || level < 1 || level > 50) {
context.addIssue({ code: "custom", message: "目标等级必须是 1 到 50 的整数", path: [name, "level"] });
if (!Number.isInteger(level) || level < 1 || level > 100) {
context.addIssue({ code: "custom", message: "目标等级必须是 1 到 100 的整数", path: [name, "level"] });
}
const durationDays = Number(track.durationDays);
if (!Number.isInteger(durationDays) || durationDays < 1 || durationDays > 36500) {

View 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>
</>
);
}

View 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());
});

View File

@ -319,7 +319,7 @@ function setRangeField(range, field, 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 mondayIndex = (firstDay.getDay() + 6) % 7;
const start = new Date(firstDay);
@ -332,7 +332,7 @@ function calendarDays(viewDate) {
});
}
function sameDay(left, right) {
export function sameDay(left, right) {
return (
left.getFullYear() === right.getFullYear() &&
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)}`;
}
function formatRangePart(value) {
export function formatRangePart(value) {
const text = msToDatetimeText(value);
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())}`;
}
function normalizeMs(value) {
export function normalizeMs(value) {
if (value === "" || value === null || value === undefined) {
return "";
}
@ -375,6 +375,6 @@ function normalizeMs(value) {
return Number.isFinite(number) && number > 0 ? number : "";
}
function pad2(value) {
export function pad2(value) {
return String(value).padStart(2, "0");
}