添加多次确认机制
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";
|
} from "../format.js";
|
||||||
import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx";
|
import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx";
|
||||||
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
|
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
|
||||||
|
import { FinanceCoinSellerGrantDialog } from "./FinanceCoinSellerGrantDialog.jsx";
|
||||||
|
|
||||||
export function FinanceCoinSellerRechargeOrderList({
|
export function FinanceCoinSellerRechargeOrderList({
|
||||||
actionLoading,
|
actionLoading,
|
||||||
@ -63,6 +64,7 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
const [rateConfigOpen, setRateConfigOpen] = useState(false);
|
const [rateConfigOpen, setRateConfigOpen] = useState(false);
|
||||||
const [receiptVerification, setReceiptVerification] = useState(null);
|
const [receiptVerification, setReceiptVerification] = useState(null);
|
||||||
const [receiptLoading, setReceiptLoading] = useState(false);
|
const [receiptLoading, setReceiptLoading] = useState(false);
|
||||||
|
const [grantTarget, setGrantTarget] = useState(null);
|
||||||
const items = orders.items || [];
|
const items = orders.items || [];
|
||||||
const page = Number(orders.page || filters.page || 1);
|
const page = Number(orders.page || filters.page || 1);
|
||||||
const pageSize = Number(orders.pageSize || 50);
|
const pageSize = Number(orders.pageSize || 50);
|
||||||
@ -212,16 +214,13 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmGrant = (order) => {
|
const requestGrant = (order) => {
|
||||||
const userText = order.targetDisplayUserId || order.targetUserId || "-";
|
if (isZeroCoinMakeupOrder(order)) {
|
||||||
const ok = window.confirm(
|
// 0 金币订单只完成补单状态且不会触发钱包发币,因此按业务要求跳过金币发放确认流程。
|
||||||
isZeroCoinMakeupOrder(order)
|
|
||||||
? `确认完成用户 ${userText} 的补单?该订单计入充值,不发放金币。`
|
|
||||||
: `确认给用户 ${userText} 发放 ${formatAmount(order.coinAmount)} 金币?`,
|
|
||||||
);
|
|
||||||
if (ok) {
|
|
||||||
onGrant(order);
|
onGrant(order);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setGrantTarget(order);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -426,7 +425,7 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
size="small"
|
size="small"
|
||||||
startIcon={<PaidOutlined fontSize="small" />}
|
startIcon={<PaidOutlined fontSize="small" />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => confirmGrant(item)}
|
onClick={() => requestGrant(item)}
|
||||||
>
|
>
|
||||||
{isZeroCoinMakeupOrder(item) ? "完成补单" : "发放"}
|
{isZeroCoinMakeupOrder(item) ? "完成补单" : "发放"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -496,6 +495,12 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
onLoad={onLoadExchangeRate}
|
onLoad={onLoadExchangeRate}
|
||||||
onSave={onSaveExchangeRate}
|
onSave={onSaveExchangeRate}
|
||||||
/>
|
/>
|
||||||
|
<FinanceCoinSellerGrantDialog
|
||||||
|
loading={Boolean(grantTarget && actionLoading === `grant:${grantTarget.id}`)}
|
||||||
|
order={grantTarget}
|
||||||
|
onClose={() => setGrantTarget(null)}
|
||||||
|
onGrant={onGrant}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,7 +84,7 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
usdAmount: 10,
|
usdAmount: 10,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
}, 10000);
|
||||||
|
|
||||||
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();
|
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", () => {
|
test("coin seller recharge order actions follow verify and grant status", () => {
|
||||||
const onVerify = vi.fn();
|
const onVerify = vi.fn();
|
||||||
const onGrant = vi.fn();
|
const onGrant = vi.fn();
|
||||||
vi.stubGlobal(
|
|
||||||
"confirm",
|
|
||||||
vi.fn(() => true),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FinanceCoinSellerRechargeOrderList
|
<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(actionHeader).toHaveClass("finance-flat-table__actions-cell");
|
||||||
expect(actionCell).toHaveClass("finance-flat-table__actions-cell");
|
expect(actionCell).toHaveClass("finance-flat-table__actions-cell");
|
||||||
expect(onVerify).toHaveBeenCalledWith(expect.objectContaining({ id: "pending" }));
|
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(screen.getAllByText("人工补单")[0]).toBeInTheDocument();
|
||||||
expect(grantButtons[0]).toBeDisabled();
|
expect(grantButtons[0]).toBeDisabled();
|
||||||
expect(verifyButtons[1]).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 onGrant = vi.fn();
|
||||||
const confirm = vi.fn(() => true);
|
|
||||||
vi.stubGlobal("confirm", confirm);
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FinanceCoinSellerRechargeOrderList
|
<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("USD 10.00")).toHaveClass("finance-recharge-amount");
|
||||||
expect(screen.getByText("0 金币(补单)")).not.toHaveClass("finance-recharge-coins--positive");
|
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 }));
|
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "makeup", coinAmount: 0 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -536,6 +536,31 @@ a {
|
|||||||
font-weight: 600;
|
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 {
|
.finance-create-dialog {
|
||||||
padding: var(--space-2) var(--space-5) var(--space-5) !important;
|
padding: var(--space-2) var(--space-5) var(--space-5) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user