添加多次确认机制
This commit is contained in:
parent
4abab5c164
commit
59901a3cab
100
finance/src/components/FinanceCoinSellerGrantDialog.jsx
Normal file
100
finance/src/components/FinanceCoinSellerGrantDialog.jsx
Normal 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) : "-";
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }));
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user