运营审核
This commit is contained in:
parent
ad4d4151f5
commit
1c4e6d7f38
@ -4411,7 +4411,13 @@
|
||||
}
|
||||
},
|
||||
"x-permission": "finance:view",
|
||||
"x-permissions": ["finance:view"]
|
||||
"x-permissions": [
|
||||
"finance:view",
|
||||
"finance-withdrawal:view",
|
||||
"finance-withdrawal:audit",
|
||||
"operations-withdrawal:view",
|
||||
"operations-withdrawal:audit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/finance/withdrawal-applications": {
|
||||
@ -4467,6 +4473,9 @@
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/AppCodeHeader"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@ -4492,6 +4501,9 @@
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/AppCodeHeader"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@ -4559,6 +4571,9 @@
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/AppCodeHeader"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@ -4584,6 +4599,9 @@
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/AppCodeHeader"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@ -14162,7 +14180,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"operationsStatus": {
|
||||
"enum": ["pending", "approved", "rejected"],
|
||||
"description": "运营审核状态;rejecting 表示拒绝已持久认领,钱包释放或通知仍待重试。",
|
||||
"enum": ["pending", "rejecting", "approved", "rejected", "skipped"],
|
||||
"type": "string"
|
||||
},
|
||||
"operationsReviewerUserId": {
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
fetchFinanceRechargeOverview,
|
||||
fetchFinanceRechargeSummaries,
|
||||
fetchFinanceSession,
|
||||
fetchFinanceWithdrawalOptions,
|
||||
grantFinanceCoinSellerRechargeOrder,
|
||||
getFinanceCoinSellerRechargeExchangeRate,
|
||||
getFinanceUSDTAddresses,
|
||||
@ -142,6 +143,11 @@ export function FinanceApp() {
|
||||
const { showToast } = useToast();
|
||||
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
|
||||
const [withdrawalOptionsState, setWithdrawalOptionsState] = useState({
|
||||
data: emptyOptions,
|
||||
error: "",
|
||||
loading: false,
|
||||
});
|
||||
const [rechargeDetailsState, setRechargeDetailsState] = useState({
|
||||
data: { items: [], page: 1, pageSize, total: 0 },
|
||||
error: "",
|
||||
@ -171,6 +177,11 @@ export function FinanceApp() {
|
||||
error: "",
|
||||
loading: false,
|
||||
});
|
||||
const [operationsWithdrawalsState, setOperationsWithdrawalsState] = useState({
|
||||
data: { items: [], page: 1, pageSize, total: 0 },
|
||||
error: "",
|
||||
loading: false,
|
||||
});
|
||||
const [coinSellerRechargeOrdersState, setCoinSellerRechargeOrdersState] = useState({
|
||||
data: { items: [], page: 1, pageSize, total: 0 },
|
||||
error: "",
|
||||
@ -190,6 +201,11 @@ export function FinanceApp() {
|
||||
}));
|
||||
const [rechargeExporting, setRechargeExporting] = useState(false);
|
||||
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
|
||||
const [operationsWithdrawalFilters, setOperationsWithdrawalFilters] = useState({
|
||||
appCode: "",
|
||||
keyword: "",
|
||||
page: 1,
|
||||
});
|
||||
const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({
|
||||
appCode: "",
|
||||
grantStatus: "",
|
||||
@ -210,13 +226,9 @@ export function FinanceApp() {
|
||||
return;
|
||||
}
|
||||
setSessionState({ error: "", loading: false, session });
|
||||
setActiveView(
|
||||
session.canViewAppRechargeDetails
|
||||
? "overview"
|
||||
: session.canViewCoinSellerRechargeOrders
|
||||
? "coinSellerRechargeOrders"
|
||||
: "withdrawals",
|
||||
);
|
||||
const nextView = resolvedFinanceView(session);
|
||||
setActiveView(nextView);
|
||||
syncFinanceView(nextView);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
@ -230,6 +242,9 @@ export function FinanceApp() {
|
||||
|
||||
const session = sessionState.session;
|
||||
const canLoadOptions = Boolean(session?.canAccessFinance);
|
||||
const canLoadWithdrawalOptions = Boolean(
|
||||
session?.canViewOperationsWithdrawalApplications || session?.canViewWithdrawalApplications,
|
||||
);
|
||||
const canViewFinance = Boolean(session?.canViewAppRechargeDetails);
|
||||
|
||||
// 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。
|
||||
@ -264,6 +279,15 @@ export function FinanceApp() {
|
||||
}),
|
||||
[withdrawalFilters],
|
||||
);
|
||||
const operationsWithdrawalQuery = useMemo(
|
||||
() => ({
|
||||
app_code: operationsWithdrawalFilters.appCode,
|
||||
keyword: operationsWithdrawalFilters.keyword,
|
||||
page: operationsWithdrawalFilters.page,
|
||||
page_size: pageSize,
|
||||
}),
|
||||
[operationsWithdrawalFilters],
|
||||
);
|
||||
const coinSellerRechargeOrderQuery = useMemo(
|
||||
() => ({
|
||||
app_code: coinSellerRechargeOrderFilters.appCode,
|
||||
@ -291,6 +315,24 @@ export function FinanceApp() {
|
||||
}
|
||||
}, [canLoadOptions]);
|
||||
|
||||
const loadWithdrawalOptions = useCallback(async () => {
|
||||
if (!canLoadWithdrawalOptions) {
|
||||
return;
|
||||
}
|
||||
setWithdrawalOptionsState((current) => ({ ...current, error: "", loading: true }));
|
||||
try {
|
||||
const data = await fetchFinanceWithdrawalOptions();
|
||||
setWithdrawalOptionsState({ data, error: "", loading: false });
|
||||
} catch (err) {
|
||||
// 财务范围加载失败时必须保持空目录,不能回退全量 options 造成越权筛选入口。
|
||||
setWithdrawalOptionsState({
|
||||
data: emptyOptions,
|
||||
error: err.message || "加载提现 App 范围失败",
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
}, [canLoadWithdrawalOptions]);
|
||||
|
||||
// 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。
|
||||
const rechargeApps = useMemo(
|
||||
() =>
|
||||
@ -474,6 +516,23 @@ export function FinanceApp() {
|
||||
}
|
||||
}, [session?.canViewWithdrawalApplications, withdrawalQuery]);
|
||||
|
||||
const loadOperationsWithdrawals = useCallback(async () => {
|
||||
if (!session?.canViewOperationsWithdrawalApplications) {
|
||||
return;
|
||||
}
|
||||
setOperationsWithdrawalsState((current) => ({ ...current, error: "", loading: true }));
|
||||
try {
|
||||
const data = await listOperationsWithdrawalApplications(operationsWithdrawalQuery);
|
||||
setOperationsWithdrawalsState({ data, error: "", loading: false });
|
||||
} catch (err) {
|
||||
setOperationsWithdrawalsState((current) => ({
|
||||
...current,
|
||||
error: err.message || "加载用户提现运营审核列表失败",
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}, [operationsWithdrawalQuery, session?.canViewOperationsWithdrawalApplications]);
|
||||
|
||||
const loadCoinSellerRechargeOrders = useCallback(async () => {
|
||||
if (!session?.canViewCoinSellerRechargeOrders) {
|
||||
return;
|
||||
@ -495,6 +554,10 @@ export function FinanceApp() {
|
||||
loadOptions();
|
||||
}, [loadOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWithdrawalOptions();
|
||||
}, [loadWithdrawalOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRechargeApps();
|
||||
}, [loadRechargeApps]);
|
||||
@ -523,24 +586,48 @@ export function FinanceApp() {
|
||||
loadWithdrawals();
|
||||
}, [loadWithdrawals]);
|
||||
|
||||
useEffect(() => {
|
||||
loadOperationsWithdrawals();
|
||||
}, [loadOperationsWithdrawals]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCoinSellerRechargeOrders();
|
||||
}, [loadCoinSellerRechargeOrders]);
|
||||
|
||||
const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => {
|
||||
if (!withdrawalAuditTarget?.id || !session?.canAuditWithdrawalApplications) {
|
||||
const reviewStage = withdrawalAuditTarget?.reviewStage === "operations" ? "operations" : "finance";
|
||||
const canAudit =
|
||||
reviewStage === "operations"
|
||||
? session?.canAuditOperationsWithdrawalApplications
|
||||
: session?.canAuditWithdrawalApplications;
|
||||
const isOperationsRejectRetry =
|
||||
reviewStage === "operations" && withdrawalAuditTarget?.operationsStatus === "rejecting";
|
||||
// rejecting 已锁定为拒绝方向,前端门禁不得再把该记录提交到通过接口。
|
||||
if (!withdrawalAuditTarget?.id || !canAudit || (isOperationsRejectRetry && decision !== "rejected")) {
|
||||
return;
|
||||
}
|
||||
setWithdrawalAuditLoading(true);
|
||||
try {
|
||||
const submitter =
|
||||
decision === "approved" ? approveFinanceWithdrawalApplication : rejectFinanceWithdrawalApplication;
|
||||
await submitter(withdrawalAuditTarget.id, cleanPayload({ auditImageUrl, auditRemark: remark }));
|
||||
showToast(decision === "approved" ? "提现申请已通过" : "提现申请已拒绝", "success");
|
||||
reviewStage === "operations"
|
||||
? decision === "approved"
|
||||
? approveOperationsWithdrawalApplication
|
||||
: rejectOperationsWithdrawalApplication
|
||||
: decision === "approved"
|
||||
? approveFinanceWithdrawalApplication
|
||||
: rejectFinanceWithdrawalApplication;
|
||||
const payload =
|
||||
reviewStage === "operations"
|
||||
? cleanPayload({ auditRemark: remark })
|
||||
: cleanPayload({ auditImageUrl, auditRemark: remark });
|
||||
await submitter(withdrawalAuditTarget.id, withdrawalAuditTarget.appCode, payload);
|
||||
const stageLabel = reviewStage === "operations" ? "运营审核" : "财务审核";
|
||||
showToast(`${stageLabel}${decision === "approved" ? "已通过" : "已拒绝"}`, "success");
|
||||
setWithdrawalAuditTarget(null);
|
||||
await loadWithdrawals();
|
||||
// 平台管理员可能同时打开两个队列;任一阶段落库后刷新两边,运营通过即可立即出现在财务队列。
|
||||
await Promise.all([loadOperationsWithdrawals(), loadWithdrawals()]);
|
||||
} catch (err) {
|
||||
showToast(err.message || "审核提现申请失败", "error");
|
||||
showToast(err.message || "审核用户提现失败", "error");
|
||||
} finally {
|
||||
setWithdrawalAuditLoading(false);
|
||||
}
|
||||
@ -653,6 +740,19 @@ export function FinanceApp() {
|
||||
setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||
};
|
||||
|
||||
const updateOperationsWithdrawalFilters = (nextFilters) => {
|
||||
setOperationsWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||
};
|
||||
|
||||
const changeActiveView = (nextView) => {
|
||||
// URL 只能进入当前会话有权访问的视图;无权深链按初始化回退处理,菜单点击则直接忽略越权目标。
|
||||
if (!canAccessFinanceView(session, nextView)) {
|
||||
return;
|
||||
}
|
||||
setActiveView(nextView);
|
||||
syncFinanceView(nextView);
|
||||
};
|
||||
|
||||
const updateCoinSellerRechargeOrderFilters = (nextFilters) => {
|
||||
setCoinSellerRechargeOrderFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||
};
|
||||
@ -681,7 +781,7 @@ export function FinanceApp() {
|
||||
String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") &&
|
||||
String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || ""),
|
||||
);
|
||||
const isFinanceView = FINANCE_VIEWS.has(activeView);
|
||||
const isFinanceView = RECHARGE_VIEWS.has(activeView);
|
||||
const financeToolbar =
|
||||
isFinanceView && session.canViewAppRechargeDetails ? (
|
||||
<>
|
||||
@ -754,17 +854,19 @@ export function FinanceApp() {
|
||||
activeView={activeView}
|
||||
appBar={appBar}
|
||||
canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders}
|
||||
canViewFinanceWithdrawals={session.canViewWithdrawalApplications}
|
||||
canViewOperationsWithdrawals={session.canViewOperationsWithdrawalApplications}
|
||||
canViewRechargeDetails={session.canViewAppRechargeDetails}
|
||||
canViewWithdrawals={session.canViewWithdrawalApplications}
|
||||
loading={
|
||||
optionsState.loading ||
|
||||
rechargeDetailsState.loading ||
|
||||
withdrawalsState.loading ||
|
||||
operationsWithdrawalsState.loading ||
|
||||
coinSellerRechargeOrdersState.loading
|
||||
}
|
||||
session={session}
|
||||
toolbar={financeToolbar}
|
||||
onViewChange={setActiveView}
|
||||
onViewChange={changeActiveView}
|
||||
>
|
||||
{activeView === "overview" && session.canViewAppRechargeDetails ? (
|
||||
<FinanceOverview
|
||||
@ -780,13 +882,13 @@ export function FinanceApp() {
|
||||
onFiltersChange={updateRechargeDetailFilters}
|
||||
onGoUnsynced={() => {
|
||||
updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" });
|
||||
setActiveView("rechargeDetails");
|
||||
changeActiveView("rechargeDetails");
|
||||
}}
|
||||
onOpenApp={(appCode) => {
|
||||
updateRechargeDetailFilters({ appCode, regionId: "" });
|
||||
setActiveView("rechargeDetails");
|
||||
changeActiveView("rechargeDetails");
|
||||
}}
|
||||
onOpenWithdrawals={() => setActiveView("withdrawals")}
|
||||
onOpenWithdrawals={() => changeActiveView("withdrawalFinance")}
|
||||
/>
|
||||
) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? (
|
||||
<FinanceCoinSellerRechargeOrderList
|
||||
@ -843,14 +945,28 @@ export function FinanceApp() {
|
||||
loadRechargeOverview();
|
||||
}}
|
||||
/>
|
||||
) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? (
|
||||
) : activeView === "withdrawalOperations" && session.canViewOperationsWithdrawalApplications ? (
|
||||
<FinanceWithdrawalApplicationList
|
||||
applications={operationsWithdrawalsState.data}
|
||||
canAudit={session.canAuditOperationsWithdrawalApplications}
|
||||
error={operationsWithdrawalsState.error || withdrawalOptionsState.error}
|
||||
filters={operationsWithdrawalFilters}
|
||||
loading={operationsWithdrawalsState.loading}
|
||||
options={withdrawalOptionsState.data}
|
||||
reviewStage="operations"
|
||||
onAudit={setWithdrawalAuditTarget}
|
||||
onFiltersChange={updateOperationsWithdrawalFilters}
|
||||
onReload={loadOperationsWithdrawals}
|
||||
/>
|
||||
) : activeView === "withdrawalFinance" && session.canViewWithdrawalApplications ? (
|
||||
<FinanceWithdrawalApplicationList
|
||||
applications={withdrawalsState.data}
|
||||
canAudit={session.canAuditWithdrawalApplications}
|
||||
error={withdrawalsState.error}
|
||||
error={withdrawalsState.error || withdrawalOptionsState.error}
|
||||
filters={withdrawalFilters}
|
||||
loading={withdrawalsState.loading}
|
||||
options={optionsState.data}
|
||||
options={withdrawalOptionsState.data}
|
||||
reviewStage="finance"
|
||||
onAudit={setWithdrawalAuditTarget}
|
||||
onFiltersChange={updateWithdrawalFilters}
|
||||
onReload={loadWithdrawals}
|
||||
@ -858,11 +974,12 @@ export function FinanceApp() {
|
||||
) : null}
|
||||
<FinanceAuditDialog
|
||||
application={withdrawalAuditTarget}
|
||||
apps={optionsState.data.apps}
|
||||
apps={withdrawalOptionsState.data.apps}
|
||||
loading={withdrawalAuditLoading}
|
||||
open={Boolean(withdrawalAuditTarget)}
|
||||
requireApprovalImage
|
||||
requireApprovalImage={withdrawalAuditTarget?.reviewStage !== "operations"}
|
||||
requireRejectRemark
|
||||
reviewLabel={withdrawalAuditTarget?.reviewStage === "operations" ? "运营" : "财务"}
|
||||
onClose={() => setWithdrawalAuditTarget(null)}
|
||||
onSubmit={submitWithdrawalAudit}
|
||||
/>
|
||||
|
||||
@ -1,22 +1,27 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { FinanceApp } from "./FinanceApp.jsx";
|
||||
import {
|
||||
approveOperationsWithdrawalApplication,
|
||||
fetchFinanceOptions,
|
||||
fetchFinanceRechargeApps,
|
||||
fetchFinanceRechargeOverview,
|
||||
fetchFinanceRechargeSummaries,
|
||||
fetchFinanceSession,
|
||||
fetchFinanceWithdrawalOptions,
|
||||
listFinanceCoinSellerRechargeOrders,
|
||||
listFinanceRechargeDetails,
|
||||
listFinanceRechargeRegions,
|
||||
listFinanceWithdrawalApplications,
|
||||
listOperationsWithdrawalApplications,
|
||||
rejectOperationsWithdrawalApplication,
|
||||
} from "./api.js";
|
||||
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
approveFinanceWithdrawalApplication: vi.fn(),
|
||||
approveOperationsWithdrawalApplication: vi.fn(),
|
||||
createFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||
exportFinanceRechargeBills: vi.fn(),
|
||||
fetchFinanceOptions: vi.fn(),
|
||||
@ -24,6 +29,7 @@ vi.mock("./api.js", () => ({
|
||||
fetchFinanceRechargeOverview: vi.fn(),
|
||||
fetchFinanceRechargeSummaries: vi.fn(),
|
||||
fetchFinanceSession: vi.fn(),
|
||||
fetchFinanceWithdrawalOptions: vi.fn(),
|
||||
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||
getFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
||||
getFinanceUSDTAddresses: vi.fn(),
|
||||
@ -31,7 +37,9 @@ vi.mock("./api.js", () => ({
|
||||
listFinanceRechargeDetails: vi.fn(),
|
||||
listFinanceRechargeRegions: vi.fn(),
|
||||
listFinanceWithdrawalApplications: vi.fn(),
|
||||
listOperationsWithdrawalApplications: vi.fn(),
|
||||
rejectFinanceWithdrawalApplication: vi.fn(),
|
||||
rejectOperationsWithdrawalApplication: vi.fn(),
|
||||
refreshFinanceGoogleRechargePaid: vi.fn(),
|
||||
replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
||||
upsertFinanceUSDTAddress: vi.fn(),
|
||||
@ -42,6 +50,7 @@ vi.mock("./api.js", () => ({
|
||||
|
||||
const defaultSession = {
|
||||
canAccessFinance: true,
|
||||
canAuditOperationsWithdrawalApplications: false,
|
||||
canAuditWithdrawalApplications: false,
|
||||
canConfigureCoinSellerRechargeExchangeRate: false,
|
||||
canCreateCoinSellerRechargeOrder: false,
|
||||
@ -50,6 +59,7 @@ const defaultSession = {
|
||||
canVerifyCoinSellerRechargeOrder: false,
|
||||
canViewAppRechargeDetails: false,
|
||||
canViewCoinSellerRechargeOrders: false,
|
||||
canViewOperationsWithdrawalApplications: false,
|
||||
canViewWithdrawalApplications: true,
|
||||
permissions: ["finance-withdrawal:view"],
|
||||
user: { account: "admin", name: "admin" }
|
||||
@ -61,10 +71,12 @@ beforeEach(() => {
|
||||
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
|
||||
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
|
||||
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession);
|
||||
vi.mocked(fetchFinanceWithdrawalOptions).mockResolvedValue({ apps: [{ appCode: "lalu", appName: "lalu" }] });
|
||||
vi.mocked(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
vi.mocked(listFinanceRechargeDetails).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
vi.mocked(listFinanceRechargeRegions).mockResolvedValue([]);
|
||||
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
vi.mocked(listOperationsWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -112,3 +124,159 @@ test("defaults recharge finance views to yesterday in China timezone", async ()
|
||||
expect(query.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
|
||||
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 16));
|
||||
});
|
||||
|
||||
test("opens the permitted operations withdrawal deep link and synchronizes menu changes", async () => {
|
||||
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||
...defaultSession,
|
||||
canAuditOperationsWithdrawalApplications: true,
|
||||
canViewOperationsWithdrawalApplications: true,
|
||||
permissions: ["finance-withdrawal:view", "operations-withdrawal:audit"]
|
||||
});
|
||||
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "用户提现运营审核" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(listOperationsWithdrawalApplications).toHaveBeenCalled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "用户提现财务审核" }));
|
||||
expect(window.location.search).toBe("?view=withdrawalFinance");
|
||||
expect(screen.getByRole("heading", { name: "用户提现财务审核" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("falls back from an unauthorized operations deep link to the finance review queue", async () => {
|
||||
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "用户提现财务审核" })).toBeInTheDocument();
|
||||
expect(window.location.search).toBe("?view=withdrawalFinance");
|
||||
expect(screen.queryByRole("button", { name: "用户提现运营审核" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("withdrawal app filter uses only the MoneyAccess scope catalog", async () => {
|
||||
vi.mocked(fetchFinanceOptions).mockResolvedValue({
|
||||
apps: [
|
||||
{ appCode: "lalu", appName: "Lalu" },
|
||||
{ appCode: "aslan", appName: "Aslan" }
|
||||
]
|
||||
});
|
||||
vi.mocked(fetchFinanceWithdrawalOptions).mockResolvedValue({
|
||||
apps: [{ appCode: "lalu", appName: "Lalu" }]
|
||||
});
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
const appFilter = await screen.findByRole("combobox", { name: "APP" });
|
||||
fireEvent.mouseDown(appFilter);
|
||||
|
||||
expect(await screen.findByRole("option", { name: "全部 APP" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Lalu" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Aslan" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("submits operations review with the withdrawal record app instead of the global app", async () => {
|
||||
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||
...defaultSession,
|
||||
canAuditOperationsWithdrawalApplications: true,
|
||||
canViewOperationsWithdrawalApplications: true,
|
||||
canViewWithdrawalApplications: false,
|
||||
permissions: ["operations-withdrawal:audit"]
|
||||
});
|
||||
vi.mocked(listOperationsWithdrawalApplications).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
appCode: "aslan",
|
||||
createdAtMs: 1760000000000,
|
||||
id: 16,
|
||||
operationsStatus: "pending",
|
||||
status: "pending",
|
||||
userId: "10002",
|
||||
withdrawAddress: "TRX-operations",
|
||||
withdrawAmount: 20,
|
||||
withdrawMethod: "USDT-TRC20"
|
||||
}
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1
|
||||
});
|
||||
vi.mocked(approveOperationsWithdrawalApplication).mockResolvedValue({ id: 16 });
|
||||
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "通过" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(approveOperationsWithdrawalApplication).toHaveBeenCalledWith(16, "aslan", {});
|
||||
});
|
||||
});
|
||||
|
||||
test("retries a claimed operations rejection with its saved remark", async () => {
|
||||
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||
...defaultSession,
|
||||
canAuditOperationsWithdrawalApplications: true,
|
||||
canViewOperationsWithdrawalApplications: true,
|
||||
canViewWithdrawalApplications: false,
|
||||
permissions: ["operations-withdrawal:audit"]
|
||||
});
|
||||
vi.mocked(listOperationsWithdrawalApplications).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
appCode: "aslan",
|
||||
createdAtMs: 1760000000000,
|
||||
id: 17,
|
||||
operationsAuditRemark: "命中运营风控",
|
||||
operationsStatus: "rejecting",
|
||||
status: "pending",
|
||||
userId: "10003",
|
||||
withdrawAddress: "TRX-rejecting",
|
||||
withdrawAmount: 30,
|
||||
withdrawMethod: "USDT-TRC20"
|
||||
}
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1
|
||||
});
|
||||
vi.mocked(rejectOperationsWithdrawalApplication).mockResolvedValue({ id: 17 });
|
||||
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByText("拒绝处理中")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "通过" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试拒绝" }));
|
||||
const savedRemark = await screen.findByRole("textbox", { name: "审核备注" });
|
||||
expect(savedRemark).toHaveValue("命中运营风控");
|
||||
expect(savedRemark).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rejectOperationsWithdrawalApplication).toHaveBeenCalledWith(17, "aslan", {
|
||||
auditRemark: "命中运营风控"
|
||||
});
|
||||
});
|
||||
expect(approveOperationsWithdrawalApplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -69,6 +69,16 @@ export async function fetchFinanceOptions() {
|
||||
return normalizeOptions({ apps: data });
|
||||
}
|
||||
|
||||
export async function fetchFinanceWithdrawalOptions() {
|
||||
const endpoint = API_ENDPOINTS.getFinanceScope;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getFinanceScope), {
|
||||
method: endpoint.method
|
||||
});
|
||||
// 提现是跨 App 财务工作台,目录只能使用后端 MoneyAccess 裁剪后的 apps;
|
||||
// 不能回退全量 App 或主站 AppScope,否则“全部 APP”会越过财务范围。
|
||||
return normalizeOptions({ apps: data?.apps });
|
||||
}
|
||||
|
||||
// 充值详情的 App 目录与其他财务页不同:包含 Yumi 这类 legacy 外部账单源,只用于账单查询。
|
||||
export async function fetchFinanceRechargeApps() {
|
||||
const endpoint = API_ENDPOINTS.listRechargeApps;
|
||||
@ -303,34 +313,39 @@ export async function refreshFinanceGoogleRechargePaid(appCode, transactionIds)
|
||||
return Array.isArray(data?.items) ? data.items : [];
|
||||
}
|
||||
|
||||
export async function approveFinanceWithdrawalApplication(applicationId, payload) {
|
||||
export async function approveFinanceWithdrawalApplication(applicationId, appCode, payload) {
|
||||
const endpoint = API_ENDPOINTS.approveFinanceWithdrawalApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
// 审核对象可能来自跨 App 列表;必须用记录所属 App 覆盖主站全局选择,避免按错误 App 锁单和执行钱包命令。
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectFinanceWithdrawalApplication(applicationId, payload) {
|
||||
export async function rejectFinanceWithdrawalApplication(applicationId, appCode, payload) {
|
||||
const endpoint = API_ENDPOINTS.rejectFinanceWithdrawalApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveOperationsWithdrawalApplication(applicationId, payload) {
|
||||
export async function approveOperationsWithdrawalApplication(applicationId, appCode, payload) {
|
||||
const endpoint = API_ENDPOINTS.approveOperationsWithdrawalApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.approveOperationsWithdrawalApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectOperationsWithdrawalApplication(applicationId, payload) {
|
||||
export async function rejectOperationsWithdrawalApplication(applicationId, appCode, payload) {
|
||||
const endpoint = API_ENDPOINTS.rejectOperationsWithdrawalApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectOperationsWithdrawalApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { API_ENDPOINTS } from "@/shared/api/generated/endpoints";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import {
|
||||
approveFinanceWithdrawalApplication,
|
||||
approveOperationsWithdrawalApplication,
|
||||
createFinanceCoinSellerRechargeOrder,
|
||||
fetchFinanceOptions,
|
||||
fetchFinanceSession,
|
||||
fetchFinanceWithdrawalOptions,
|
||||
getFinanceCoinSellerRechargeExchangeRate,
|
||||
getFinanceUSDTAddresses,
|
||||
grantFinanceCoinSellerRechargeOrder,
|
||||
listFinanceCoinSellerRechargeOrders,
|
||||
listFinanceRechargeDetails,
|
||||
listFinanceWithdrawalApplications,
|
||||
listOperationsWithdrawalApplications,
|
||||
rejectFinanceWithdrawalApplication,
|
||||
rejectOperationsWithdrawalApplication,
|
||||
quoteFinanceCoinSellerRechargeOrder,
|
||||
replaceFinanceCoinSellerRechargeExchangeRate,
|
||||
upsertFinanceUSDTAddress,
|
||||
@ -43,13 +48,15 @@ test("finance session no longer treats removed application permissions as withdr
|
||||
const session = await fetchFinanceSession();
|
||||
|
||||
expect(session.canAccessFinance).toBe(false);
|
||||
expect(session.canAuditOperationsWithdrawalApplications).toBe(false);
|
||||
expect(session.canAuditWithdrawalApplications).toBe(false);
|
||||
expect(session.canViewOperationsWithdrawalApplications).toBe(false);
|
||||
expect(session.canViewWithdrawalApplications).toBe(false);
|
||||
expect(session).not.toHaveProperty("canAuditApplication");
|
||||
expect(session).not.toHaveProperty("canCreateApplication");
|
||||
});
|
||||
|
||||
test("finance options and withdrawal APIs use generated endpoint paths", async () => {
|
||||
test("finance options and both withdrawal review stages use generated endpoint paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url) => {
|
||||
@ -71,8 +78,11 @@ test("finance options and withdrawal APIs use generated endpoint paths", async (
|
||||
|
||||
const options = await fetchFinanceOptions();
|
||||
await listFinanceWithdrawalApplications({ page: 1, page_size: 50 });
|
||||
await approveFinanceWithdrawalApplication(14, { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||
await rejectFinanceWithdrawalApplication(15, { auditRemark: "bad" });
|
||||
await approveFinanceWithdrawalApplication(14, "lalu", { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||
await rejectFinanceWithdrawalApplication(15, "aslan", { auditRemark: "bad" });
|
||||
await listOperationsWithdrawalApplications({ page: 1, page_size: 50 });
|
||||
await approveOperationsWithdrawalApplication(16, "huwaa", { auditRemark: "checked" });
|
||||
await rejectOperationsWithdrawalApplication(17, "yumi", { auditRemark: "risk" });
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(options.apps).toEqual([
|
||||
@ -84,10 +94,83 @@ test("finance options and withdrawal APIs use generated endpoint paths", async (
|
||||
expect(calls[1][1]?.method).toBe("GET");
|
||||
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/withdrawal-applications/14/approve");
|
||||
expect(calls[2][1]?.method).toBe("POST");
|
||||
expect(calls[2][1]?.headers?.["X-App-Code"]).toBe("lalu");
|
||||
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({ auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/withdrawal-applications/15/reject");
|
||||
expect(calls[3][1]?.method).toBe("POST");
|
||||
expect(calls[3][1]?.headers?.["X-App-Code"]).toBe("aslan");
|
||||
expect(JSON.parse(String(calls[3][1]?.body))).toEqual({ auditRemark: "bad" });
|
||||
expect(String(calls[4][0])).toContain("/api/v1/admin/operations/withdrawal-applications?");
|
||||
expect(calls[4][1]?.method).toBe("GET");
|
||||
expect(String(calls[5][0])).toContain("/api/v1/admin/operations/withdrawal-applications/16/approve");
|
||||
expect(calls[5][1]?.method).toBe("POST");
|
||||
expect(calls[5][1]?.headers?.["X-App-Code"]).toBe("huwaa");
|
||||
expect(JSON.parse(String(calls[5][1]?.body))).toEqual({ auditRemark: "checked" });
|
||||
expect(String(calls[6][0])).toContain("/api/v1/admin/operations/withdrawal-applications/17/reject");
|
||||
expect(calls[6][1]?.method).toBe("POST");
|
||||
expect(calls[6][1]?.headers?.["X-App-Code"]).toBe("yumi");
|
||||
expect(JSON.parse(String(calls[6][1]?.body))).toEqual({ auditRemark: "risk" });
|
||||
});
|
||||
|
||||
test("withdrawal options use the MoneyAccess-filtered finance scope catalog", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
all: false,
|
||||
apps: [{ appCode: "aslan", appName: "Aslan", logoUrl: "https://media.example.com/aslan.png" }],
|
||||
countries: [],
|
||||
regions: [],
|
||||
scopes: [{ appCode: "aslan", regionId: 7 }]
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const options = await fetchFinanceWithdrawalOptions();
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||
|
||||
expect(String(url)).toContain("/api/v1/admin/finance/scope");
|
||||
expect(init?.method).toBe("GET");
|
||||
expect(API_ENDPOINTS.getFinanceScope.permissions).toEqual([
|
||||
"finance:view",
|
||||
"finance-withdrawal:view",
|
||||
"finance-withdrawal:audit",
|
||||
"operations-withdrawal:view",
|
||||
"operations-withdrawal:audit"
|
||||
]);
|
||||
expect(options.apps).toEqual([
|
||||
{ appCode: "aslan", appName: "Aslan", logoUrl: "https://media.example.com/aslan.png" }
|
||||
]);
|
||||
});
|
||||
|
||||
test("operations withdrawal audit permission grants only the operations review queue", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
permissions: ["operations-withdrawal:audit"],
|
||||
user: { account: "operations-reviewer" }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const session = await fetchFinanceSession();
|
||||
|
||||
expect(session.canAccessFinance).toBe(true);
|
||||
expect(session.canAuditOperationsWithdrawalApplications).toBe(true);
|
||||
expect(session.canViewOperationsWithdrawalApplications).toBe(true);
|
||||
expect(session.canAuditWithdrawalApplications).toBe(false);
|
||||
expect(session.canViewWithdrawalApplications).toBe(false);
|
||||
});
|
||||
|
||||
test("finance coin seller recharge order APIs use generated endpoint paths", async () => {
|
||||
|
||||
@ -24,14 +24,17 @@ export function FinanceAuditDialog({
|
||||
const decision = application?.decision || "approved";
|
||||
const needsImage = decision === "approved" && requireApprovalImage;
|
||||
const needsRejectRemark = decision === "rejected" && requireRejectRemark;
|
||||
const isOperationsRejectRetry =
|
||||
decision === "rejected" && application?.operationsStatus === "rejecting" && application?.reviewStage === "operations";
|
||||
const submitDisabled = loading || (needsImage && !auditImageUrl) || (needsRejectRemark && !remark.trim());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRemark("");
|
||||
// rejecting 已持久化了原拒绝原因;重试必须默认复用该原因,避免补偿动作改写审核语义。
|
||||
setRemark(isOperationsRejectRetry ? application?.operationsAuditRemark || "" : "");
|
||||
setAuditImageUrl("");
|
||||
}
|
||||
}, [open]);
|
||||
}, [application?.operationsAuditRemark, isOperationsRejectRetry, open]);
|
||||
|
||||
const submit = () => {
|
||||
onSubmit({ auditImageUrl, decision, remark });
|
||||
@ -52,7 +55,8 @@ export function FinanceAuditDialog({
|
||||
<span>{application?.userId || "-"}</span>
|
||||
</div>
|
||||
<TextField
|
||||
disabled={loading}
|
||||
// 后端会固定沿用首次 claim 的原因;只读展示可避免用户误以为重试能修改审核事实。
|
||||
disabled={loading || isOperationsRejectRetry}
|
||||
label="审核备注"
|
||||
minRows={4}
|
||||
multiline
|
||||
|
||||
@ -204,7 +204,7 @@ export function FinanceOverview({
|
||||
}
|
||||
|
||||
// WithdrawalKpi 是两张独立口径的资金流出卡:
|
||||
// 「用户提现」只统计提现申请审核通过的 USDT(点击进入提现审核页);
|
||||
// 「用户提现」只统计提现申请财务审核通过的 USDT(点击进入用户提现财务审核页);
|
||||
// 「工资兑换」只统计用户工资转币商的 USDT(likei 平台为银行卡余额兑换金币)。
|
||||
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
|
||||
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
|
||||
@ -219,7 +219,7 @@ function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
|
||||
<>
|
||||
<button
|
||||
className="finance-kpi finance-kpi--withdrawal"
|
||||
title="点击进入用户提现申请"
|
||||
title="点击进入用户提现财务审核"
|
||||
type="button"
|
||||
onClick={onOpenWithdrawals}
|
||||
>
|
||||
|
||||
@ -185,6 +185,27 @@ export function FinanceWithdrawalApplicationList({
|
||||
拒绝
|
||||
</Button>
|
||||
</span>
|
||||
) : isOperationsReview && item.operationsStatus === "rejecting" && canAudit ? (
|
||||
<span className="finance-row-actions">
|
||||
<span>{statusLabel(item.operationsStatus)}</span>
|
||||
<Button
|
||||
color="error"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
// rejecting 是已持久认领的拒绝方向,只允许继续调用拒绝接口完成外部补偿。
|
||||
onAudit({
|
||||
...item,
|
||||
decision: "rejected",
|
||||
operation: "用户提现运营审核",
|
||||
reviewStage,
|
||||
targetUserId: item.userId,
|
||||
})
|
||||
}
|
||||
>
|
||||
重试拒绝
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
statusLabel(isOperationsReview ? item.operationsStatus : item.status)
|
||||
)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx";
|
||||
@ -17,6 +17,7 @@ const baseProps = {
|
||||
page: 1
|
||||
},
|
||||
loading: false,
|
||||
onAudit: vi.fn(),
|
||||
onFiltersChange: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
options: { apps: [] }
|
||||
@ -44,6 +45,12 @@ test("renders withdrawal application table with requested finance columns", () =
|
||||
auditRemark: "地址不符合要求",
|
||||
createdAtMs: 1760000000000,
|
||||
id: 7,
|
||||
operationsAuditRemark: "资料无误",
|
||||
operationsReviewedAtMs: 1760003600000,
|
||||
operationsReviewerName: "运营一",
|
||||
operationsReviewerUserId: 11,
|
||||
operationsStatus: "approved",
|
||||
status: "approved",
|
||||
userId: "10001",
|
||||
withdrawAddress: "TRX-address",
|
||||
withdrawAmount: 12.5,
|
||||
@ -60,9 +67,10 @@ test("renders withdrawal application table with requested finance columns", () =
|
||||
expect(screen.getByRole("columnheader", { name: "提现方式" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "提现地址" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "申请时间" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "审批时间" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "审核原因" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "运营审核" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "财务审核人" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "财务审核时间" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "财务审核原因" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "操作" })).toBeInTheDocument();
|
||||
expect(screen.getByText("lalu")).toBeInTheDocument();
|
||||
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||
@ -71,12 +79,119 @@ test("renders withdrawal application table with requested finance columns", () =
|
||||
expect(screen.getByText("TRX-address")).toBeInTheDocument();
|
||||
expect(screen.getByText("财务一")).toBeInTheDocument();
|
||||
expect(screen.getByText("21")).toBeInTheDocument();
|
||||
expect(screen.getByText("运营一")).toBeInTheDocument();
|
||||
expect(screen.getByText("11")).toBeInTheDocument();
|
||||
expect(screen.getByText("资料无误")).toBeInTheDocument();
|
||||
expect(screen.getByText("地址不符合要求")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps empty withdrawal table colspan aligned with visible columns", () => {
|
||||
const { container } = renderList(baseProps);
|
||||
|
||||
expect(container.querySelector("tbody td")?.colSpan).toBe(10);
|
||||
expect(container.querySelector("tbody td")?.colSpan).toBe(11);
|
||||
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("operations review only exposes actions for operations pending applications", () => {
|
||||
const onAudit = vi.fn();
|
||||
renderList({
|
||||
...baseProps,
|
||||
canAudit: true,
|
||||
onAudit,
|
||||
reviewStage: "operations",
|
||||
applications: {
|
||||
...baseProps.applications,
|
||||
items: [
|
||||
{
|
||||
appCode: "lalu",
|
||||
createdAtMs: 1760000000000,
|
||||
id: 8,
|
||||
operationsStatus: "pending",
|
||||
status: "pending",
|
||||
userId: "10002",
|
||||
withdrawAddress: "TRX-operations",
|
||||
withdrawAmount: 20,
|
||||
withdrawMethod: "USDT-TRC20"
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "运营审核人" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "运营审核时间" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "运营审核原因" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "通过" }));
|
||||
expect(onAudit).toHaveBeenCalledWith(expect.objectContaining({ id: 8, decision: "approved", reviewStage: "operations" }));
|
||||
});
|
||||
|
||||
test("operations rejecting applications expose only rejection retry", () => {
|
||||
const onAudit = vi.fn();
|
||||
renderList({
|
||||
...baseProps,
|
||||
canAudit: true,
|
||||
onAudit,
|
||||
reviewStage: "operations",
|
||||
applications: {
|
||||
...baseProps.applications,
|
||||
items: [
|
||||
{
|
||||
appCode: "lalu",
|
||||
createdAtMs: 1760000000000,
|
||||
id: 10,
|
||||
operationsAuditRemark: "命中运营风控",
|
||||
operationsStatus: "rejecting",
|
||||
status: "pending",
|
||||
userId: "10004",
|
||||
withdrawAddress: "TRX-rejecting",
|
||||
withdrawAmount: 40,
|
||||
withdrawMethod: "USDT-TRC20"
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText("拒绝处理中")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "通过" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "拒绝" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试拒绝" }));
|
||||
expect(onAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
decision: "rejected",
|
||||
id: 10,
|
||||
operationsAuditRemark: "命中运营风控",
|
||||
reviewStage: "operations"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("finance review actions follow the overall pending status", () => {
|
||||
const onAudit = vi.fn();
|
||||
renderList({
|
||||
...baseProps,
|
||||
canAudit: true,
|
||||
onAudit,
|
||||
reviewStage: "finance",
|
||||
applications: {
|
||||
...baseProps.applications,
|
||||
items: [
|
||||
{
|
||||
appCode: "lalu",
|
||||
createdAtMs: 1760000000000,
|
||||
id: 9,
|
||||
operationsStatus: "approved",
|
||||
status: "pending",
|
||||
userId: "10003",
|
||||
withdrawAddress: "TRX-finance",
|
||||
withdrawAmount: 30,
|
||||
withdrawMethod: "USDT-TRC20"
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
}
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "拒绝" }));
|
||||
expect(onAudit).toHaveBeenCalledWith(expect.objectContaining({ id: 9, decision: "rejected", reviewStage: "finance" }));
|
||||
});
|
||||
|
||||
@ -14,8 +14,10 @@ export const FINANCE_PERMISSIONS = {
|
||||
|
||||
export const WITHDRAWAL_STATUS = [
|
||||
["pending", "待审核"],
|
||||
["rejecting", "拒绝处理中"],
|
||||
["approved", "已通过"],
|
||||
["rejected", "已拒绝"],
|
||||
["skipped", "存量跳过运营审核"],
|
||||
];
|
||||
|
||||
export const COIN_SELLER_RECHARGE_PROVIDERS = [
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
normalizeRechargeBillPage,
|
||||
normalizeWithdrawalApplicationPage,
|
||||
rechargeTypeLabel,
|
||||
statusLabel,
|
||||
validateCoinSellerRechargeOrderPayload,
|
||||
validateCoinSellerRechargeExchangeRatePayload,
|
||||
} from "./format.js";
|
||||
@ -27,6 +28,12 @@ test("normalizes finance withdrawal application list rows", () => {
|
||||
created_at_ms: 1760000000000,
|
||||
freeze_transaction_id: "tx-freeze",
|
||||
id: 7,
|
||||
operations_audit_remark: "资料无误",
|
||||
operations_audit_transaction_id: "tx-operations-audit",
|
||||
operations_reviewed_at_ms: 1760003600000,
|
||||
operations_reviewer_name: "运营一",
|
||||
operations_reviewer_user_id: 11,
|
||||
operations_status: "approved",
|
||||
salary_asset_type: "HOST_SALARY_USD",
|
||||
status: "approved",
|
||||
user_id: 10001,
|
||||
@ -57,6 +64,12 @@ test("normalizes finance withdrawal application list rows", () => {
|
||||
createdAtMs: 1760000000000,
|
||||
freezeTransactionId: "tx-freeze",
|
||||
id: 7,
|
||||
operationsAuditRemark: "资料无误",
|
||||
operationsAuditTransactionId: "tx-operations-audit",
|
||||
operationsReviewedAtMs: 1760003600000,
|
||||
operationsReviewerName: "运营一",
|
||||
operationsReviewerUserId: 11,
|
||||
operationsStatus: "approved",
|
||||
salaryAssetType: "HOST_SALARY_USD",
|
||||
userId: "10001",
|
||||
withdrawAddress: "TRX-address",
|
||||
@ -64,6 +77,8 @@ test("normalizes finance withdrawal application list rows", () => {
|
||||
withdrawAmountMinor: 1250,
|
||||
withdrawMethod: "USDT-TRC20",
|
||||
});
|
||||
expect(statusLabel("rejecting")).toBe("拒绝处理中");
|
||||
expect(statusLabel("skipped")).toBe("存量跳过运营审核");
|
||||
});
|
||||
|
||||
test("builds validates and normalizes coin seller recharge orders", () => {
|
||||
|
||||
@ -25,6 +25,11 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
||||
avatar: "",
|
||||
ban: { active: true, permanent: true, reason: "risk" },
|
||||
birth: "2000-02-29",
|
||||
balances: [
|
||||
{ assetType: "AGENCY_SALARY_USD", availableAmount: 100, frozenAmount: 0 },
|
||||
{ assetType: "COIN", availableAmount: 15, frozenAmount: 0 },
|
||||
{ assetType: "HOST_SALARY_USD", availableAmount: 400, frozenAmount: 0 },
|
||||
],
|
||||
buildNumber: "345",
|
||||
coin: 1200,
|
||||
countryDisplayName: "阿联酋",
|
||||
@ -109,6 +114,9 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
||||
fireEvent.click(screen.getByRole("tab", { name: "资产" }));
|
||||
expect(screen.getAllByText("累计充值").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/123\.45/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("$1.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("$4.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("15", { selector: "div" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "状态记录" }));
|
||||
expect(screen.getByText("永久")).toBeInTheDocument();
|
||||
|
||||
@ -488,9 +488,13 @@ function AssetBalanceList({ balances }) {
|
||||
<div className={styles.assetRow} key={balance.assetType}>
|
||||
<div>
|
||||
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
||||
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
||||
<div className={styles.assetMeta}>
|
||||
冻结 {formatAssetAmount(balance.assetType, balance.frozenAmount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.assetAmount}>
|
||||
{formatAssetAmount(balance.assetType, balance.availableAmount)}
|
||||
</div>
|
||||
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -616,6 +620,23 @@ function formatAssetType(value) {
|
||||
return labels[normalized] || normalized || "-";
|
||||
}
|
||||
|
||||
function formatAssetAmount(assetType, value) {
|
||||
const normalizedAssetType = String(assetType || "").toUpperCase();
|
||||
if (normalizedAssetType.endsWith("_USD")) {
|
||||
const amountMinor = Number(value);
|
||||
if (!Number.isFinite(amountMinor)) {
|
||||
return "-";
|
||||
}
|
||||
// 工资类 USD 资产由钱包按美分存储;只在展示层换算为美元,避免把 100 美分误显示为 100 美元。
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
currencyDisplay: "narrowSymbol",
|
||||
style: "currency",
|
||||
}).format(amountMinor / 100);
|
||||
}
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
function formatResourceType(value) {
|
||||
const labels = {
|
||||
avatar_frame: "头像框",
|
||||
|
||||
@ -9,7 +9,13 @@ export const WORKSPACE_ENTRIES = [
|
||||
href: "/finance/",
|
||||
icon: AccountBalanceOutlined,
|
||||
label: "财务工作台",
|
||||
permission: PERMISSIONS.financeView,
|
||||
permissions: [
|
||||
PERMISSIONS.financeView,
|
||||
PERMISSIONS.financeWithdrawalView,
|
||||
PERMISSIONS.financeWithdrawalAudit,
|
||||
PERMISSIONS.operationsWithdrawalView,
|
||||
PERMISSIONS.operationsWithdrawalAudit,
|
||||
],
|
||||
},
|
||||
{
|
||||
description: "运营管理",
|
||||
@ -21,8 +27,8 @@ export const WORKSPACE_ENTRIES = [
|
||||
];
|
||||
|
||||
export function getPermittedWorkspaceEntries(can) {
|
||||
// 两个工作台是独立 HTML 入口,展示权限必须与各入口后端接口的既有权限保持一致,避免仅靠前端隐藏造成权限口径漂移。
|
||||
return WORKSPACE_ENTRIES.filter((entry) => can(entry.permission));
|
||||
// 独立工作台按任一真实入口权限展示;提现财务/运营审核者没有 finance:view 时也必须能发现自己的审批队列。
|
||||
return WORKSPACE_ENTRIES.filter((entry) => (entry.permissions || [entry.permission]).some((permission) => can(permission)));
|
||||
}
|
||||
|
||||
export function DashboardWorkspaces() {
|
||||
|
||||
@ -31,4 +31,23 @@ describe("DashboardWorkspaces", () => {
|
||||
expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/");
|
||||
expect(screen.queryByRole("link", { name: /财务工作台/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the finance document entry to withdrawal operations reviewers", () => {
|
||||
can.mockImplementation((permission) => permission === PERMISSIONS.operationsWithdrawalAudit);
|
||||
|
||||
render(<DashboardWorkspaces />);
|
||||
|
||||
expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/");
|
||||
});
|
||||
|
||||
test.each([PERMISSIONS.financeWithdrawalView, PERMISSIONS.financeWithdrawalAudit])(
|
||||
"shows the finance document entry to finance withdrawal roles with %s",
|
||||
(grantedPermission) => {
|
||||
can.mockImplementation((permission) => permission === grantedPermission);
|
||||
|
||||
render(<DashboardWorkspaces />);
|
||||
|
||||
expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
@ -14,11 +16,22 @@ import {
|
||||
updateAdapterCatalog,
|
||||
updateAdapterConfigField,
|
||||
} from "@/features/games/adapterConfigModel.js";
|
||||
import { uploadImage } from "@/shared/api/upload";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import styles from "@/features/games/components/AdapterConfigFields.module.css";
|
||||
|
||||
export function AdapterConfigFields({ adapterType, disabled = false, value, onChange }) {
|
||||
const catalogImageAccept = "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp";
|
||||
|
||||
export function AdapterConfigFields({
|
||||
adapterType,
|
||||
disabled = false,
|
||||
onChange,
|
||||
onUploadingChange,
|
||||
uploadDisabled = false,
|
||||
value,
|
||||
}) {
|
||||
const definition = adapterDefinition(adapterType);
|
||||
const configValid = Boolean(readAdapterConfig(value));
|
||||
const visualFieldsDisabled = disabled || !configValid;
|
||||
@ -46,8 +59,10 @@ export function AdapterConfigFields({ adapterType, disabled = false, value, onCh
|
||||
<GameCatalogEditor
|
||||
adapterType={adapterType}
|
||||
disabled={visualFieldsDisabled}
|
||||
uploadDisabled={uploadDisabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onUploadingChange={onUploadingChange}
|
||||
/>
|
||||
) : null}
|
||||
{(definition.advancedFields || []).length > 0 ? (
|
||||
@ -144,7 +159,7 @@ function AdapterField({ disabled, field, value, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
||||
function GameCatalogEditor({ adapterType, disabled, uploadDisabled, value, onChange, onUploadingChange }) {
|
||||
const definition = adapterDefinition(adapterType);
|
||||
const externalRows = useMemo(() => adapterCatalogRows(value), [value]);
|
||||
const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]);
|
||||
@ -152,28 +167,53 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
||||
const adapterTypeRef = useRef(adapterType);
|
||||
const rowSequence = useRef(externalRows.length);
|
||||
const [rows, setRows] = useState(() => withRowKeys(externalRows, "initial"));
|
||||
const rowsRef = useRef(rows);
|
||||
const valueRef = useRef(value);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const [uploadingAssets, setUploadingAssets] = useState(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange, value]);
|
||||
|
||||
useEffect(() => {
|
||||
onUploadingChange?.(uploadingAssets.size > 0);
|
||||
}, [onUploadingChange, uploadingAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onUploadingChange?.(false);
|
||||
}, [onUploadingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const adapterChanged = adapterTypeRef.current !== adapterType;
|
||||
if (adapterChanged || externalSignature !== lastEmittedSignature.current) {
|
||||
rowSequence.current += externalRows.length;
|
||||
setRows(withRowKeys(externalRows, `sync-${rowSequence.current}`));
|
||||
const nextRows = withRowKeys(externalRows, `sync-${rowSequence.current}`);
|
||||
rowsRef.current = nextRows;
|
||||
setRows(nextRows);
|
||||
if (adapterChanged) {
|
||||
// 切换厂商会卸载原素材输入;清空聚合状态,迟到的上传结果由字段自身的代次保护丢弃。
|
||||
setUploadingAssets(new Set());
|
||||
}
|
||||
}
|
||||
adapterTypeRef.current = adapterType;
|
||||
lastEmittedSignature.current = externalSignature;
|
||||
}, [adapterType, externalRows, externalSignature]);
|
||||
|
||||
const emitRows = (nextRows) => {
|
||||
// 上传完成属于异步回调,必须基于最新 rows/config patch,避免并发上传或期间编辑被旧闭包覆盖。
|
||||
rowsRef.current = nextRows;
|
||||
setRows(nextRows);
|
||||
const nextValue = updateAdapterCatalog(value, nextRows);
|
||||
const nextValue = updateAdapterCatalog(valueRef.current, nextRows);
|
||||
lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue));
|
||||
onChange(nextValue);
|
||||
onChangeRef.current(nextValue);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
rowSequence.current += 1;
|
||||
setRows((current) => [
|
||||
...current,
|
||||
const nextRows = [
|
||||
...rowsRef.current,
|
||||
{
|
||||
rowKey: `draft-${rowSequence.current}`,
|
||||
providerGameId: "",
|
||||
@ -183,22 +223,38 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
||||
coverUrl: "",
|
||||
gameLevelUid: "",
|
||||
},
|
||||
]);
|
||||
];
|
||||
rowsRef.current = nextRows;
|
||||
setRows(nextRows);
|
||||
};
|
||||
|
||||
const changeRow = (rowKey, patch) => {
|
||||
emitRows(rows.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
|
||||
emitRows(rowsRef.current.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
const removeRow = (rowKey) => {
|
||||
emitRows(rows.filter((row) => row.rowKey !== rowKey));
|
||||
emitRows(rowsRef.current.filter((row) => row.rowKey !== rowKey));
|
||||
};
|
||||
|
||||
const changeUploadingState = (assetKey, uploading) => {
|
||||
setUploadingAssets((current) => {
|
||||
const next = new Set(current);
|
||||
if (uploading) {
|
||||
next.add(assetKey);
|
||||
} else {
|
||||
next.delete(assetKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const assetsUploading = uploadingAssets.size > 0;
|
||||
|
||||
return (
|
||||
<section className={styles.catalog}>
|
||||
<div className={styles.catalogHeader}>
|
||||
<div className={styles.groupTitle}>游戏目录 · {rows.length}</div>
|
||||
<Button disabled={disabled} onClick={addRow}>
|
||||
<Button disabled={disabled || assetsUploading} onClick={addRow}>
|
||||
<AddOutlined fontSize="small" />
|
||||
添加游戏
|
||||
</Button>
|
||||
@ -254,7 +310,7 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
||||
/>
|
||||
<Button
|
||||
aria-label={`删除第 ${index + 1} 个游戏`}
|
||||
disabled={disabled}
|
||||
disabled={disabled || assetsUploading}
|
||||
onClick={() => removeRow(row.rowKey)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
@ -263,18 +319,24 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
||||
<details className={styles.rowDetails}>
|
||||
<summary>素材与游戏参数</summary>
|
||||
<div className={styles.rowExtra}>
|
||||
<TextField
|
||||
<CatalogImageURLField
|
||||
disabled={disabled}
|
||||
label="图标 URL"
|
||||
uploadDisabled={uploadDisabled}
|
||||
value={row.iconUrl}
|
||||
onChange={(event) => changeRow(row.rowKey, { iconUrl: event.target.value })}
|
||||
onChange={(iconUrl) => changeRow(row.rowKey, { iconUrl })}
|
||||
onUploadingChange={(uploading) =>
|
||||
changeUploadingState(`${row.rowKey}:icon`, uploading)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
<CatalogImageURLField
|
||||
disabled={disabled}
|
||||
label="封面 URL"
|
||||
uploadDisabled={uploadDisabled}
|
||||
value={row.coverUrl}
|
||||
onChange={(event) =>
|
||||
changeRow(row.rowKey, { coverUrl: event.target.value })
|
||||
onChange={(coverUrl) => changeRow(row.rowKey, { coverUrl })}
|
||||
onUploadingChange={(uploading) =>
|
||||
changeUploadingState(`${row.rowKey}:cover`, uploading)
|
||||
}
|
||||
/>
|
||||
{adapterType === "yomi_v4" ? (
|
||||
@ -302,6 +364,106 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogImageURLField({ disabled, label, onChange, onUploadingChange, uploadDisabled, value }) {
|
||||
const inputRef = useRef(null);
|
||||
const uploadGenerationRef = useRef(0);
|
||||
const onUploadingChangeRef = useRef(onUploadingChange);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
onUploadingChangeRef.current = onUploadingChange;
|
||||
}, [onUploadingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 网络请求不能中途取消;字段卸载后作废结果,防止回写已经切换的厂商或已删除行。
|
||||
uploadGenerationRef.current += 1;
|
||||
onUploadingChangeRef.current?.(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPicker = () => {
|
||||
if (!disabled && !uploadDisabled && !uploading) {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const uploadGeneration = ++uploadGenerationRef.current;
|
||||
setUploading(true);
|
||||
onUploadingChange?.(true);
|
||||
try {
|
||||
const result = await uploadImage(file);
|
||||
if (uploadGeneration !== uploadGenerationRef.current) {
|
||||
return;
|
||||
}
|
||||
onChange(result.url);
|
||||
showToast(`${label.replace(/ URL$/, "")}上传成功`, "success");
|
||||
} catch (err) {
|
||||
if (uploadGeneration !== uploadGenerationRef.current) {
|
||||
return;
|
||||
}
|
||||
// 上传失败只提示,不清空用户原先手填或已保存的 URL。
|
||||
showToast(err.message || "图片上传失败", "error");
|
||||
} finally {
|
||||
if (uploadGeneration === uploadGenerationRef.current) {
|
||||
setUploading(false);
|
||||
onUploadingChange?.(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.imageURLField}>
|
||||
<TextField
|
||||
disabled={disabled || uploading}
|
||||
label={label}
|
||||
placeholder="粘贴图片 URL,或点击上传"
|
||||
slotProps={{
|
||||
// 历史厂商配置允许相对路径和协议相对地址;保留文本校验,只用 inputMode 提供 URL 输入体验。
|
||||
htmlInput: { inputMode: "url" },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<button
|
||||
aria-label={`上传${label.replace(/ URL$/, "")}`}
|
||||
className={styles.uploadButton}
|
||||
disabled={disabled || uploadDisabled || uploading}
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
>
|
||||
{uploading ? (
|
||||
<CircularProgress color="inherit" size={14} />
|
||||
) : (
|
||||
<FileUploadOutlined fontSize="small" />
|
||||
)}
|
||||
上传
|
||||
</button>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
accept={catalogImageAccept}
|
||||
className={styles.fileInput}
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function withRowKeys(rows, prefix) {
|
||||
return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` }));
|
||||
}
|
||||
|
||||
@ -119,6 +119,52 @@
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.imageURLField {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.imageURLField > :global(.MuiTextField-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.uploadButton {
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input-strong);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.uploadButton:hover:not(:disabled) {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.uploadButton:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.uploadButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.advancedEditor,
|
||||
.details > .fieldGrid {
|
||||
padding: var(--space-3);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useState } from "react";
|
||||
import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx";
|
||||
import {
|
||||
adapterDefinition,
|
||||
@ -17,6 +18,7 @@ import {
|
||||
import styles from "@/features/games/components/PlatformFormDialog.module.css";
|
||||
|
||||
export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||
const [assetsUploading, setAssetsUploading] = useState(false);
|
||||
const definition = adapterDefinition(form.adapterType);
|
||||
const configValid = Boolean(readAdapterConfig(form.adapterConfigJson));
|
||||
const disabled = !page.abilities.canUpdate;
|
||||
@ -25,6 +27,15 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
||||
)?.[0];
|
||||
const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit");
|
||||
|
||||
const submitForm = (event) => {
|
||||
if (assetsUploading) {
|
||||
// 禁用按钮之外再拦截 Enter 隐式提交,避免把上传前的旧 URL 写入平台配置。
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onSubmit(event);
|
||||
};
|
||||
|
||||
const changeAdapter = (adapterType) => {
|
||||
const previousDefinition = adapterDefinition(form.adapterType);
|
||||
const nextDefinition = adapterDefinition(adapterType);
|
||||
@ -77,11 +88,11 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={disabled || loading || !configValid}
|
||||
submitDisabled={disabled || loading || assetsUploading || !configValid}
|
||||
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
|
||||
title={mode === "edit" ? "编辑平台" : "添加平台"}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={submitForm}
|
||||
>
|
||||
<AdminFormSection title="平台信息">
|
||||
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
|
||||
@ -125,7 +136,8 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
||||
<AdminFormSection title="接入与回调">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled || (mode === "edit" && Boolean(fixedAdapterType))}
|
||||
// 上传结果归属当前厂商目录;上传结束前不允许切换适配器,避免结果落到另一份协议草稿。
|
||||
disabled={disabled || assetsUploading || (mode === "edit" && Boolean(fixedAdapterType))}
|
||||
helperText={mode === "edit" && fixedAdapterType ? "协议固定适配器" : undefined}
|
||||
label="适配器"
|
||||
select
|
||||
@ -183,8 +195,14 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
||||
<AdapterConfigFields
|
||||
adapterType={form.adapterType}
|
||||
disabled={disabled}
|
||||
// open 变化时立即重挂载,关闭过渡期间也会作废旧上传结果,不能回写下一次打开的表单。
|
||||
key={open ? "active-platform-form" : "inactive-platform-form"}
|
||||
uploadDisabled={!page.abilities.canUpload}
|
||||
value={form.adapterConfigJson}
|
||||
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
|
||||
onChange={(adapterConfigJson) =>
|
||||
page.setPlatformForm((current) => ({ ...current, adapterConfigJson }))
|
||||
}
|
||||
onUploadingChange={setAssetsUploading}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
|
||||
@ -1317,7 +1317,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
operationId: API_OPERATIONS.getFinanceScope,
|
||||
path: "/v1/admin/finance/scope",
|
||||
permission: "finance:view",
|
||||
permissions: ["finance:view"]
|
||||
permissions: ["finance:view","finance-withdrawal:view","finance-withdrawal:audit","operations-withdrawal:view","operations-withdrawal:audit"]
|
||||
},
|
||||
getFinanceUSDTAddresses: {
|
||||
method: "GET",
|
||||
|
||||
23
src/shared/api/generated/schema.d.ts
vendored
23
src/shared/api/generated/schema.d.ts
vendored
@ -6533,8 +6533,11 @@ export interface components {
|
||||
freezeTransactionId?: string;
|
||||
auditTransactionId?: string;
|
||||
status?: string;
|
||||
/** @enum {string} */
|
||||
operationsStatus?: "pending" | "approved" | "rejected";
|
||||
/**
|
||||
* @description 运营审核状态;rejecting 表示拒绝已持久认领,钱包释放或通知仍待重试。
|
||||
* @enum {string}
|
||||
*/
|
||||
operationsStatus?: "pending" | "rejecting" | "approved" | "rejected" | "skipped";
|
||||
operationsReviewerUserId?: number | null;
|
||||
operationsReviewerName?: string;
|
||||
operationsAuditRemark?: string;
|
||||
@ -11257,7 +11260,9 @@ export interface operations {
|
||||
approveFinanceWithdrawalApplication: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
application_id: number;
|
||||
};
|
||||
@ -11271,7 +11276,9 @@ export interface operations {
|
||||
rejectFinanceWithdrawalApplication: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
application_id: number;
|
||||
};
|
||||
@ -11302,7 +11309,9 @@ export interface operations {
|
||||
approveOperationsWithdrawalApplication: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
application_id: number;
|
||||
};
|
||||
@ -11316,7 +11325,9 @@ export interface operations {
|
||||
rejectOperationsWithdrawalApplication: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
application_id: number;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user