diff --git a/finance/src/components/FinanceCoinSellerGrantDialog.jsx b/finance/src/components/FinanceCoinSellerGrantDialog.jsx
new file mode 100644
index 0000000..ba2b812
--- /dev/null
+++ b/finance/src/components/FinanceCoinSellerGrantDialog.jsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
+
+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) : "-";
+}
diff --git a/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx b/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx
index df87fc2..38fe601 100644
--- a/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx
+++ b/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx
@@ -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={}
variant="contained"
- onClick={() => confirmGrant(item)}
+ onClick={() => requestGrant(item)}
>
{isZeroCoinMakeupOrder(item) ? "完成补单" : "发放"}
@@ -496,6 +495,12 @@ export function FinanceCoinSellerRechargeOrderList({
onLoad={onLoadExchangeRate}
onSave={onSaveExchangeRate}
/>
+ setGrantTarget(null)}
+ onGrant={onGrant}
+ />
);
}
diff --git a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx
index 424d8b6..da709f3 100644
--- a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx
+++ b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx
@@ -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(
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(
+ ,
+ );
+
+ 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(
{
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 }));
});
diff --git a/finance/src/styles/index.css b/finance/src/styles/index.css
index 74b86fd..55ee2af 100644
--- a/finance/src/styles/index.css
+++ b/finance/src/styles/index.css
@@ -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;
}