添加多次确认机制

This commit is contained in:
zhx 2026-07-14 18:48:02 +08:00
parent 4abab5c164
commit 59901a3cab
4 changed files with 191 additions and 19 deletions

View File

@ -0,0 +1,100 @@
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 TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
export function FinanceCoinSellerGrantDialog({ loading, onClose, onGrant, order }) {
const [confirmationInput, setConfirmationInput] = useState("");
const [finalConfirmOpen, setFinalConfirmOpen] = useState(false);
const confirmationText = buildCoinSellerGrantConfirmationText(order);
const inputMatches = confirmationInput === confirmationText;
useEffect(() => {
//
setConfirmationInput("");
setFinalConfirmOpen(false);
}, [order?.id]);
const close = () => {
if (!loading) {
onClose();
}
};
const openFinalConfirm = () => {
//
if (inputMatches) {
setFinalConfirmOpen(true);
}
};
const grant = () => {
//
if (order && inputMatches) {
onGrant(order);
// toast
onClose();
}
};
return (
<>
<Dialog fullWidth maxWidth="sm" open={Boolean(order) && !finalConfirmOpen} onClose={close}>
<DialogTitle>发放金币验证</DialogTitle>
<DialogContent className="finance-grant-dialog">
<strong className="finance-grant-dialog__statement">{confirmationText}</strong>
<span className="finance-grant-dialog__warning">请在下方输入框完整填写一遍上面的文字中间不能有空格</span>
<TextField
autoComplete="off"
autoFocus
disabled={loading}
error={Boolean(confirmationInput) && !inputMatches}
fullWidth
label="完整输入发放文字"
value={confirmationInput}
onChange={(event) => setConfirmationInput(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button disabled={loading} onClick={close}>
取消
</Button>
<Button disabled={loading || !inputMatches} variant="contained" onClick={openFinalConfirm}>
确认
</Button>
</DialogActions>
</Dialog>
<Dialog fullWidth maxWidth="xs" open={Boolean(order) && finalConfirmOpen} onClose={close}>
<DialogTitle>确认发放金币</DialogTitle>
<DialogContent className="finance-grant-dialog finance-grant-dialog--final">
<strong>是否确认发放</strong>
<span>{confirmationText}</span>
</DialogContent>
<DialogActions>
<Button disabled={loading} onClick={() => setFinalConfirmOpen(false)}>
返回
</Button>
<Button disabled={loading} variant="contained" onClick={grant}>
{loading ? "发放中" : "确认发放"}
</Button>
</DialogActions>
</Dialog>
</>
);
}
export function buildCoinSellerGrantConfirmationText(order) {
const usdAmount = plainNumber(order?.usdAmount ?? Number(order?.usdMinorAmount || 0) / 100);
const userId = order?.targetDisplayUserId || order?.targetUserId || "-";
const coinAmount = plainNumber(order?.coinAmount);
return `本订单金额${usdAmount}USDT将会向用户${userId}的币商账户发放${coinAmount}金币`;
}
function plainNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? String(number) : "-";
}

View File

@ -32,6 +32,7 @@ import {
} from "../format.js";
import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx";
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
import { FinanceCoinSellerGrantDialog } from "./FinanceCoinSellerGrantDialog.jsx";
export function FinanceCoinSellerRechargeOrderList({
actionLoading,
@ -63,6 +64,7 @@ export function FinanceCoinSellerRechargeOrderList({
const [rateConfigOpen, setRateConfigOpen] = useState(false);
const [receiptVerification, setReceiptVerification] = useState(null);
const [receiptLoading, setReceiptLoading] = useState(false);
const [grantTarget, setGrantTarget] = useState(null);
const items = orders.items || [];
const page = Number(orders.page || filters.page || 1);
const pageSize = Number(orders.pageSize || 50);
@ -212,16 +214,13 @@ export function FinanceCoinSellerRechargeOrderList({
}
};
const confirmGrant = (order) => {
const userText = order.targetDisplayUserId || order.targetUserId || "-";
const ok = window.confirm(
isZeroCoinMakeupOrder(order)
? `确认完成用户 ${userText} 的补单?该订单计入充值,不发放金币。`
: `确认给用户 ${userText} 发放 ${formatAmount(order.coinAmount)} 金币?`,
);
if (ok) {
const requestGrant = (order) => {
if (isZeroCoinMakeupOrder(order)) {
// 0
onGrant(order);
return;
}
setGrantTarget(order);
};
return (
@ -426,7 +425,7 @@ export function FinanceCoinSellerRechargeOrderList({
size="small"
startIcon={<PaidOutlined fontSize="small" />}
variant="contained"
onClick={() => confirmGrant(item)}
onClick={() => requestGrant(item)}
>
{isZeroCoinMakeupOrder(item) ? "完成补单" : "发放"}
</Button>
@ -496,6 +495,12 @@ export function FinanceCoinSellerRechargeOrderList({
onLoad={onLoadExchangeRate}
onSave={onSaveExchangeRate}
/>
<FinanceCoinSellerGrantDialog
loading={Boolean(grantTarget && actionLoading === `grant:${grantTarget.id}`)}
order={grantTarget}
onClose={() => setGrantTarget(null)}
onGrant={onGrant}
/>
</section>
);
}

View File

@ -84,7 +84,7 @@ test("coin seller recharge order create dialog verifies receipt before create",
usdAmount: 10,
}),
);
});
}, 10000);
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();
@ -136,10 +136,6 @@ test("makeup order keeps zero coins while creating a normal recharge record", as
test("coin seller recharge order actions follow verify and grant status", () => {
const onVerify = vi.fn();
const onGrant = vi.fn();
vi.stubGlobal(
"confirm",
vi.fn(() => true),
);
render(
<FinanceCoinSellerRechargeOrderList
@ -169,16 +165,62 @@ test("coin seller recharge order actions follow verify and grant status", () =>
expect(actionHeader).toHaveClass("finance-flat-table__actions-cell");
expect(actionCell).toHaveClass("finance-flat-table__actions-cell");
expect(onVerify).toHaveBeenCalledWith(expect.objectContaining({ id: "pending" }));
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "verified" }));
expect(screen.getByRole("dialog", { name: "发放金币验证" })).toBeInTheDocument();
expect(onGrant).not.toHaveBeenCalled();
expect(screen.getAllByText("人工补单")[0]).toBeInTheDocument();
expect(grantButtons[0]).toBeDisabled();
expect(verifyButtons[1]).toBeDisabled();
});
test("zero coin seller recharge order uses makeup completion action", () => {
test("coin grant requires exact text and a final confirmation before calling the API", () => {
const onGrant = vi.fn();
render(
<FinanceCoinSellerRechargeOrderList
{...baseProps({
orders: {
items: [
orderFixture({
coinAmount: 80000,
grantStatus: "pending",
id: "7777",
targetDisplayUserId: "7777",
usdAmount: "1.00",
verifyStatus: "verified",
}),
],
page: 1,
pageSize: 50,
total: 1,
},
})}
onGrant={onGrant}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "发放" }));
const statement = "本订单金额1USDT将会向用户7777的币商账户发放80000金币";
const input = screen.getByRole("textbox", { name: "完整输入发放文字" });
const firstConfirm = screen.getByRole("button", { name: "确认" });
expect(screen.getByText(statement)).toBeInTheDocument();
expect(firstConfirm).toBeDisabled();
fireEvent.change(input, { target: { value: `${statement} ` } });
expect(firstConfirm).toBeDisabled();
fireEvent.change(input, { target: { value: statement } });
expect(firstConfirm).not.toBeDisabled();
fireEvent.click(firstConfirm);
expect(screen.getByRole("dialog", { name: "确认发放金币" })).toBeInTheDocument();
expect(onGrant).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "确认发放" }));
expect(onGrant).toHaveBeenCalledTimes(1);
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "7777", coinAmount: 80000 }));
});
test("zero coin seller recharge order completes makeup without confirmation", () => {
const onGrant = vi.fn();
const confirm = vi.fn(() => true);
vi.stubGlobal("confirm", confirm);
render(
<FinanceCoinSellerRechargeOrderList
@ -200,7 +242,7 @@ test("zero coin seller recharge order uses makeup completion action", () => {
expect(screen.getByText("USD 10.00")).toHaveClass("finance-recharge-amount");
expect(screen.getByText("0 金币(补单)")).not.toHaveClass("finance-recharge-coins--positive");
expect(confirm).toHaveBeenCalledWith(expect.stringContaining("不发放金币"));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "makeup", coinAmount: 0 }));
});

View File

@ -536,6 +536,31 @@ a {
font-weight: 600;
}
.finance-grant-dialog {
display: grid;
gap: var(--space-4);
padding-top: var(--space-2) !important;
}
.finance-grant-dialog__statement {
color: var(--text-primary);
font-size: 16px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.finance-grant-dialog__warning {
color: var(--danger);
font-size: 13px;
font-weight: 700;
}
.finance-grant-dialog--final span {
color: var(--text-secondary);
line-height: 1.65;
overflow-wrap: anywhere;
}
.finance-create-dialog {
padding: var(--space-2) var(--space-5) var(--space-5) !important;
}