运营审核

This commit is contained in:
zhx 2026-07-16 18:52:08 +08:00
parent ad4d4151f5
commit 1c4e6d7f38
20 changed files with 927 additions and 77 deletions

View File

@ -4411,7 +4411,13 @@
} }
}, },
"x-permission": "finance:view", "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": { "/admin/finance/withdrawal-applications": {
@ -4467,6 +4473,9 @@
"schema": { "schema": {
"type": "integer" "type": "integer"
} }
},
{
"$ref": "#/components/parameters/AppCodeHeader"
} }
], ],
"requestBody": { "requestBody": {
@ -4492,6 +4501,9 @@
"schema": { "schema": {
"type": "integer" "type": "integer"
} }
},
{
"$ref": "#/components/parameters/AppCodeHeader"
} }
], ],
"requestBody": { "requestBody": {
@ -4559,6 +4571,9 @@
"schema": { "schema": {
"type": "integer" "type": "integer"
} }
},
{
"$ref": "#/components/parameters/AppCodeHeader"
} }
], ],
"requestBody": { "requestBody": {
@ -4584,6 +4599,9 @@
"schema": { "schema": {
"type": "integer" "type": "integer"
} }
},
{
"$ref": "#/components/parameters/AppCodeHeader"
} }
], ],
"requestBody": { "requestBody": {
@ -14162,7 +14180,8 @@
"type": "string" "type": "string"
}, },
"operationsStatus": { "operationsStatus": {
"enum": ["pending", "approved", "rejected"], "description": "运营审核状态rejecting 表示拒绝已持久认领,钱包释放或通知仍待重试。",
"enum": ["pending", "rejecting", "approved", "rejected", "skipped"],
"type": "string" "type": "string"
}, },
"operationsReviewerUserId": { "operationsReviewerUserId": {

View File

@ -9,6 +9,7 @@ import {
fetchFinanceRechargeOverview, fetchFinanceRechargeOverview,
fetchFinanceRechargeSummaries, fetchFinanceRechargeSummaries,
fetchFinanceSession, fetchFinanceSession,
fetchFinanceWithdrawalOptions,
grantFinanceCoinSellerRechargeOrder, grantFinanceCoinSellerRechargeOrder,
getFinanceCoinSellerRechargeExchangeRate, getFinanceCoinSellerRechargeExchangeRate,
getFinanceUSDTAddresses, getFinanceUSDTAddresses,
@ -142,6 +143,11 @@ export function FinanceApp() {
const { showToast } = useToast(); const { showToast } = useToast();
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null }); const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false }); const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
const [withdrawalOptionsState, setWithdrawalOptionsState] = useState({
data: emptyOptions,
error: "",
loading: false,
});
const [rechargeDetailsState, setRechargeDetailsState] = useState({ const [rechargeDetailsState, setRechargeDetailsState] = useState({
data: { items: [], page: 1, pageSize, total: 0 }, data: { items: [], page: 1, pageSize, total: 0 },
error: "", error: "",
@ -171,6 +177,11 @@ export function FinanceApp() {
error: "", error: "",
loading: false, loading: false,
}); });
const [operationsWithdrawalsState, setOperationsWithdrawalsState] = useState({
data: { items: [], page: 1, pageSize, total: 0 },
error: "",
loading: false,
});
const [coinSellerRechargeOrdersState, setCoinSellerRechargeOrdersState] = useState({ const [coinSellerRechargeOrdersState, setCoinSellerRechargeOrdersState] = useState({
data: { items: [], page: 1, pageSize, total: 0 }, data: { items: [], page: 1, pageSize, total: 0 },
error: "", error: "",
@ -190,6 +201,11 @@ export function FinanceApp() {
})); }));
const [rechargeExporting, setRechargeExporting] = useState(false); const [rechargeExporting, setRechargeExporting] = useState(false);
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 }); const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
const [operationsWithdrawalFilters, setOperationsWithdrawalFilters] = useState({
appCode: "",
keyword: "",
page: 1,
});
const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({ const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({
appCode: "", appCode: "",
grantStatus: "", grantStatus: "",
@ -210,13 +226,9 @@ export function FinanceApp() {
return; return;
} }
setSessionState({ error: "", loading: false, session }); setSessionState({ error: "", loading: false, session });
setActiveView( const nextView = resolvedFinanceView(session);
session.canViewAppRechargeDetails setActiveView(nextView);
? "overview" syncFinanceView(nextView);
: session.canViewCoinSellerRechargeOrders
? "coinSellerRechargeOrders"
: "withdrawals",
);
}) })
.catch((err) => { .catch((err) => {
if (mounted) { if (mounted) {
@ -230,6 +242,9 @@ export function FinanceApp() {
const session = sessionState.session; const session = sessionState.session;
const canLoadOptions = Boolean(session?.canAccessFinance); const canLoadOptions = Boolean(session?.canAccessFinance);
const canLoadWithdrawalOptions = Boolean(
session?.canViewOperationsWithdrawalApplications || session?.canViewWithdrawalApplications,
);
const canViewFinance = Boolean(session?.canViewAppRechargeDetails); const canViewFinance = Boolean(session?.canViewAppRechargeDetails);
// // App × × // // App × ×
@ -264,6 +279,15 @@ export function FinanceApp() {
}), }),
[withdrawalFilters], [withdrawalFilters],
); );
const operationsWithdrawalQuery = useMemo(
() => ({
app_code: operationsWithdrawalFilters.appCode,
keyword: operationsWithdrawalFilters.keyword,
page: operationsWithdrawalFilters.page,
page_size: pageSize,
}),
[operationsWithdrawalFilters],
);
const coinSellerRechargeOrderQuery = useMemo( const coinSellerRechargeOrderQuery = useMemo(
() => ({ () => ({
app_code: coinSellerRechargeOrderFilters.appCode, app_code: coinSellerRechargeOrderFilters.appCode,
@ -291,6 +315,24 @@ export function FinanceApp() {
} }
}, [canLoadOptions]); }, [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 // App recharge-apps Yumi/Aslan legacy 退 App
const rechargeApps = useMemo( const rechargeApps = useMemo(
() => () =>
@ -474,6 +516,23 @@ export function FinanceApp() {
} }
}, [session?.canViewWithdrawalApplications, withdrawalQuery]); }, [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 () => { const loadCoinSellerRechargeOrders = useCallback(async () => {
if (!session?.canViewCoinSellerRechargeOrders) { if (!session?.canViewCoinSellerRechargeOrders) {
return; return;
@ -495,6 +554,10 @@ export function FinanceApp() {
loadOptions(); loadOptions();
}, [loadOptions]); }, [loadOptions]);
useEffect(() => {
loadWithdrawalOptions();
}, [loadWithdrawalOptions]);
useEffect(() => { useEffect(() => {
loadRechargeApps(); loadRechargeApps();
}, [loadRechargeApps]); }, [loadRechargeApps]);
@ -523,24 +586,48 @@ export function FinanceApp() {
loadWithdrawals(); loadWithdrawals();
}, [loadWithdrawals]); }, [loadWithdrawals]);
useEffect(() => {
loadOperationsWithdrawals();
}, [loadOperationsWithdrawals]);
useEffect(() => { useEffect(() => {
loadCoinSellerRechargeOrders(); loadCoinSellerRechargeOrders();
}, [loadCoinSellerRechargeOrders]); }, [loadCoinSellerRechargeOrders]);
const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => { 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; return;
} }
setWithdrawalAuditLoading(true); setWithdrawalAuditLoading(true);
try { try {
const submitter = const submitter =
decision === "approved" ? approveFinanceWithdrawalApplication : rejectFinanceWithdrawalApplication; reviewStage === "operations"
await submitter(withdrawalAuditTarget.id, cleanPayload({ auditImageUrl, auditRemark: remark })); ? decision === "approved"
showToast(decision === "approved" ? "提现申请已通过" : "提现申请已拒绝", "success"); ? 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); setWithdrawalAuditTarget(null);
await loadWithdrawals(); //
await Promise.all([loadOperationsWithdrawals(), loadWithdrawals()]);
} catch (err) { } catch (err) {
showToast(err.message || "审核提现申请失败", "error"); showToast(err.message || "审核用户提现失败", "error");
} finally { } finally {
setWithdrawalAuditLoading(false); setWithdrawalAuditLoading(false);
} }
@ -653,6 +740,19 @@ export function FinanceApp() {
setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); 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) => { const updateCoinSellerRechargeOrderFilters = (nextFilters) => {
setCoinSellerRechargeOrderFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); 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.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") &&
String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || ""), String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || ""),
); );
const isFinanceView = FINANCE_VIEWS.has(activeView); const isFinanceView = RECHARGE_VIEWS.has(activeView);
const financeToolbar = const financeToolbar =
isFinanceView && session.canViewAppRechargeDetails ? ( isFinanceView && session.canViewAppRechargeDetails ? (
<> <>
@ -754,17 +854,19 @@ export function FinanceApp() {
activeView={activeView} activeView={activeView}
appBar={appBar} appBar={appBar}
canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders} canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders}
canViewFinanceWithdrawals={session.canViewWithdrawalApplications}
canViewOperationsWithdrawals={session.canViewOperationsWithdrawalApplications}
canViewRechargeDetails={session.canViewAppRechargeDetails} canViewRechargeDetails={session.canViewAppRechargeDetails}
canViewWithdrawals={session.canViewWithdrawalApplications}
loading={ loading={
optionsState.loading || optionsState.loading ||
rechargeDetailsState.loading || rechargeDetailsState.loading ||
withdrawalsState.loading || withdrawalsState.loading ||
operationsWithdrawalsState.loading ||
coinSellerRechargeOrdersState.loading coinSellerRechargeOrdersState.loading
} }
session={session} session={session}
toolbar={financeToolbar} toolbar={financeToolbar}
onViewChange={setActiveView} onViewChange={changeActiveView}
> >
{activeView === "overview" && session.canViewAppRechargeDetails ? ( {activeView === "overview" && session.canViewAppRechargeDetails ? (
<FinanceOverview <FinanceOverview
@ -780,13 +882,13 @@ export function FinanceApp() {
onFiltersChange={updateRechargeDetailFilters} onFiltersChange={updateRechargeDetailFilters}
onGoUnsynced={() => { onGoUnsynced={() => {
updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" }); updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" });
setActiveView("rechargeDetails"); changeActiveView("rechargeDetails");
}} }}
onOpenApp={(appCode) => { onOpenApp={(appCode) => {
updateRechargeDetailFilters({ appCode, regionId: "" }); updateRechargeDetailFilters({ appCode, regionId: "" });
setActiveView("rechargeDetails"); changeActiveView("rechargeDetails");
}} }}
onOpenWithdrawals={() => setActiveView("withdrawals")} onOpenWithdrawals={() => changeActiveView("withdrawalFinance")}
/> />
) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? ( ) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? (
<FinanceCoinSellerRechargeOrderList <FinanceCoinSellerRechargeOrderList
@ -843,14 +945,28 @@ export function FinanceApp() {
loadRechargeOverview(); 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 <FinanceWithdrawalApplicationList
applications={withdrawalsState.data} applications={withdrawalsState.data}
canAudit={session.canAuditWithdrawalApplications} canAudit={session.canAuditWithdrawalApplications}
error={withdrawalsState.error} error={withdrawalsState.error || withdrawalOptionsState.error}
filters={withdrawalFilters} filters={withdrawalFilters}
loading={withdrawalsState.loading} loading={withdrawalsState.loading}
options={optionsState.data} options={withdrawalOptionsState.data}
reviewStage="finance"
onAudit={setWithdrawalAuditTarget} onAudit={setWithdrawalAuditTarget}
onFiltersChange={updateWithdrawalFilters} onFiltersChange={updateWithdrawalFilters}
onReload={loadWithdrawals} onReload={loadWithdrawals}
@ -858,11 +974,12 @@ export function FinanceApp() {
) : null} ) : null}
<FinanceAuditDialog <FinanceAuditDialog
application={withdrawalAuditTarget} application={withdrawalAuditTarget}
apps={optionsState.data.apps} apps={withdrawalOptionsState.data.apps}
loading={withdrawalAuditLoading} loading={withdrawalAuditLoading}
open={Boolean(withdrawalAuditTarget)} open={Boolean(withdrawalAuditTarget)}
requireApprovalImage requireApprovalImage={withdrawalAuditTarget?.reviewStage !== "operations"}
requireRejectRemark requireRejectRemark
reviewLabel={withdrawalAuditTarget?.reviewStage === "operations" ? "运营" : "财务"}
onClose={() => setWithdrawalAuditTarget(null)} onClose={() => setWithdrawalAuditTarget(null)}
onSubmit={submitWithdrawalAudit} onSubmit={submitWithdrawalAudit}
/> />

View File

@ -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 { afterEach, beforeEach, expect, test, vi } from "vitest";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { FinanceApp } from "./FinanceApp.jsx"; import { FinanceApp } from "./FinanceApp.jsx";
import { import {
approveOperationsWithdrawalApplication,
fetchFinanceOptions, fetchFinanceOptions,
fetchFinanceRechargeApps, fetchFinanceRechargeApps,
fetchFinanceRechargeOverview, fetchFinanceRechargeOverview,
fetchFinanceRechargeSummaries, fetchFinanceRechargeSummaries,
fetchFinanceSession, fetchFinanceSession,
fetchFinanceWithdrawalOptions,
listFinanceCoinSellerRechargeOrders, listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails, listFinanceRechargeDetails,
listFinanceRechargeRegions, listFinanceRechargeRegions,
listFinanceWithdrawalApplications, listFinanceWithdrawalApplications,
listOperationsWithdrawalApplications,
rejectOperationsWithdrawalApplication,
} from "./api.js"; } from "./api.js";
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js"; import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
vi.mock("./api.js", () => ({ vi.mock("./api.js", () => ({
approveFinanceWithdrawalApplication: vi.fn(), approveFinanceWithdrawalApplication: vi.fn(),
approveOperationsWithdrawalApplication: vi.fn(),
createFinanceCoinSellerRechargeOrder: vi.fn(), createFinanceCoinSellerRechargeOrder: vi.fn(),
exportFinanceRechargeBills: vi.fn(), exportFinanceRechargeBills: vi.fn(),
fetchFinanceOptions: vi.fn(), fetchFinanceOptions: vi.fn(),
@ -24,6 +29,7 @@ vi.mock("./api.js", () => ({
fetchFinanceRechargeOverview: vi.fn(), fetchFinanceRechargeOverview: vi.fn(),
fetchFinanceRechargeSummaries: vi.fn(), fetchFinanceRechargeSummaries: vi.fn(),
fetchFinanceSession: vi.fn(), fetchFinanceSession: vi.fn(),
fetchFinanceWithdrawalOptions: vi.fn(),
grantFinanceCoinSellerRechargeOrder: vi.fn(), grantFinanceCoinSellerRechargeOrder: vi.fn(),
getFinanceCoinSellerRechargeExchangeRate: vi.fn(), getFinanceCoinSellerRechargeExchangeRate: vi.fn(),
getFinanceUSDTAddresses: vi.fn(), getFinanceUSDTAddresses: vi.fn(),
@ -31,7 +37,9 @@ vi.mock("./api.js", () => ({
listFinanceRechargeDetails: vi.fn(), listFinanceRechargeDetails: vi.fn(),
listFinanceRechargeRegions: vi.fn(), listFinanceRechargeRegions: vi.fn(),
listFinanceWithdrawalApplications: vi.fn(), listFinanceWithdrawalApplications: vi.fn(),
listOperationsWithdrawalApplications: vi.fn(),
rejectFinanceWithdrawalApplication: vi.fn(), rejectFinanceWithdrawalApplication: vi.fn(),
rejectOperationsWithdrawalApplication: vi.fn(),
refreshFinanceGoogleRechargePaid: vi.fn(), refreshFinanceGoogleRechargePaid: vi.fn(),
replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(), replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(),
upsertFinanceUSDTAddress: vi.fn(), upsertFinanceUSDTAddress: vi.fn(),
@ -42,6 +50,7 @@ vi.mock("./api.js", () => ({
const defaultSession = { const defaultSession = {
canAccessFinance: true, canAccessFinance: true,
canAuditOperationsWithdrawalApplications: false,
canAuditWithdrawalApplications: false, canAuditWithdrawalApplications: false,
canConfigureCoinSellerRechargeExchangeRate: false, canConfigureCoinSellerRechargeExchangeRate: false,
canCreateCoinSellerRechargeOrder: false, canCreateCoinSellerRechargeOrder: false,
@ -50,6 +59,7 @@ const defaultSession = {
canVerifyCoinSellerRechargeOrder: false, canVerifyCoinSellerRechargeOrder: false,
canViewAppRechargeDetails: false, canViewAppRechargeDetails: false,
canViewCoinSellerRechargeOrders: false, canViewCoinSellerRechargeOrders: false,
canViewOperationsWithdrawalApplications: false,
canViewWithdrawalApplications: true, canViewWithdrawalApplications: true,
permissions: ["finance-withdrawal:view"], permissions: ["finance-withdrawal:view"],
user: { account: "admin", name: "admin" } user: { account: "admin", name: "admin" }
@ -61,10 +71,12 @@ beforeEach(() => {
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW); vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] }); vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession); 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(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listFinanceRechargeDetails).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(listFinanceRechargeRegions).mockResolvedValue([]);
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listOperationsWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
}); });
afterEach(() => { 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.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 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();
});

View File

@ -69,6 +69,16 @@ export async function fetchFinanceOptions() {
return normalizeOptions({ apps: data }); 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 外部账单源,只用于账单查询。 // 充值详情的 App 目录与其他财务页不同:包含 Yumi 这类 legacy 外部账单源,只用于账单查询。
export async function fetchFinanceRechargeApps() { export async function fetchFinanceRechargeApps() {
const endpoint = API_ENDPOINTS.listRechargeApps; const endpoint = API_ENDPOINTS.listRechargeApps;
@ -303,34 +313,39 @@ export async function refreshFinanceGoogleRechargePaid(appCode, transactionIds)
return Array.isArray(data?.items) ? data.items : []; 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; const endpoint = API_ENDPOINTS.approveFinanceWithdrawalApplication;
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceWithdrawalApplication, { application_id: applicationId }), { return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceWithdrawalApplication, { application_id: applicationId }), {
body: payload, body: payload,
// 审核对象可能来自跨 App 列表;必须用记录所属 App 覆盖主站全局选择,避免按错误 App 锁单和执行钱包命令。
headers: { [appCodeHeader]: appCode },
method: endpoint.method method: endpoint.method
}); });
} }
export async function rejectFinanceWithdrawalApplication(applicationId, payload) { export async function rejectFinanceWithdrawalApplication(applicationId, appCode, payload) {
const endpoint = API_ENDPOINTS.rejectFinanceWithdrawalApplication; const endpoint = API_ENDPOINTS.rejectFinanceWithdrawalApplication;
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceWithdrawalApplication, { application_id: applicationId }), { return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceWithdrawalApplication, { application_id: applicationId }), {
body: payload, body: payload,
headers: { [appCodeHeader]: appCode },
method: endpoint.method method: endpoint.method
}); });
} }
export async function approveOperationsWithdrawalApplication(applicationId, payload) { export async function approveOperationsWithdrawalApplication(applicationId, appCode, payload) {
const endpoint = API_ENDPOINTS.approveOperationsWithdrawalApplication; const endpoint = API_ENDPOINTS.approveOperationsWithdrawalApplication;
return apiRequest(apiEndpointPath(API_OPERATIONS.approveOperationsWithdrawalApplication, { application_id: applicationId }), { return apiRequest(apiEndpointPath(API_OPERATIONS.approveOperationsWithdrawalApplication, { application_id: applicationId }), {
body: payload, body: payload,
headers: { [appCodeHeader]: appCode },
method: endpoint.method method: endpoint.method
}); });
} }
export async function rejectOperationsWithdrawalApplication(applicationId, payload) { export async function rejectOperationsWithdrawalApplication(applicationId, appCode, payload) {
const endpoint = API_ENDPOINTS.rejectOperationsWithdrawalApplication; const endpoint = API_ENDPOINTS.rejectOperationsWithdrawalApplication;
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectOperationsWithdrawalApplication, { application_id: applicationId }), { return apiRequest(apiEndpointPath(API_OPERATIONS.rejectOperationsWithdrawalApplication, { application_id: applicationId }), {
body: payload, body: payload,
headers: { [appCodeHeader]: appCode },
method: endpoint.method method: endpoint.method
}); });
} }

View File

@ -1,17 +1,22 @@
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { API_ENDPOINTS } from "@/shared/api/generated/endpoints";
import { setAccessToken } from "@/shared/api/request"; import { setAccessToken } from "@/shared/api/request";
import { import {
approveFinanceWithdrawalApplication, approveFinanceWithdrawalApplication,
approveOperationsWithdrawalApplication,
createFinanceCoinSellerRechargeOrder, createFinanceCoinSellerRechargeOrder,
fetchFinanceOptions, fetchFinanceOptions,
fetchFinanceSession, fetchFinanceSession,
fetchFinanceWithdrawalOptions,
getFinanceCoinSellerRechargeExchangeRate, getFinanceCoinSellerRechargeExchangeRate,
getFinanceUSDTAddresses, getFinanceUSDTAddresses,
grantFinanceCoinSellerRechargeOrder, grantFinanceCoinSellerRechargeOrder,
listFinanceCoinSellerRechargeOrders, listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails, listFinanceRechargeDetails,
listFinanceWithdrawalApplications, listFinanceWithdrawalApplications,
listOperationsWithdrawalApplications,
rejectFinanceWithdrawalApplication, rejectFinanceWithdrawalApplication,
rejectOperationsWithdrawalApplication,
quoteFinanceCoinSellerRechargeOrder, quoteFinanceCoinSellerRechargeOrder,
replaceFinanceCoinSellerRechargeExchangeRate, replaceFinanceCoinSellerRechargeExchangeRate,
upsertFinanceUSDTAddress, upsertFinanceUSDTAddress,
@ -43,13 +48,15 @@ test("finance session no longer treats removed application permissions as withdr
const session = await fetchFinanceSession(); const session = await fetchFinanceSession();
expect(session.canAccessFinance).toBe(false); expect(session.canAccessFinance).toBe(false);
expect(session.canAuditOperationsWithdrawalApplications).toBe(false);
expect(session.canAuditWithdrawalApplications).toBe(false); expect(session.canAuditWithdrawalApplications).toBe(false);
expect(session.canViewOperationsWithdrawalApplications).toBe(false);
expect(session.canViewWithdrawalApplications).toBe(false); expect(session.canViewWithdrawalApplications).toBe(false);
expect(session).not.toHaveProperty("canAuditApplication"); expect(session).not.toHaveProperty("canAuditApplication");
expect(session).not.toHaveProperty("canCreateApplication"); 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( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (url) => { vi.fn(async (url) => {
@ -71,8 +78,11 @@ test("finance options and withdrawal APIs use generated endpoint paths", async (
const options = await fetchFinanceOptions(); const options = await fetchFinanceOptions();
await listFinanceWithdrawalApplications({ page: 1, page_size: 50 }); await listFinanceWithdrawalApplications({ page: 1, page_size: 50 });
await approveFinanceWithdrawalApplication(14, { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" }); await approveFinanceWithdrawalApplication(14, "lalu", { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
await rejectFinanceWithdrawalApplication(15, { auditRemark: "bad" }); 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; const calls = vi.mocked(fetch).mock.calls;
expect(options.apps).toEqual([ 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(calls[1][1]?.method).toBe("GET");
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/withdrawal-applications/14/approve"); 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]?.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(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(String(calls[3][0])).toContain("/api/v1/admin/finance/withdrawal-applications/15/reject");
expect(calls[3][1]?.method).toBe("POST"); 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(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 () => { test("finance coin seller recharge order APIs use generated endpoint paths", async () => {

View File

@ -24,14 +24,17 @@ export function FinanceAuditDialog({
const decision = application?.decision || "approved"; const decision = application?.decision || "approved";
const needsImage = decision === "approved" && requireApprovalImage; const needsImage = decision === "approved" && requireApprovalImage;
const needsRejectRemark = decision === "rejected" && requireRejectRemark; const needsRejectRemark = decision === "rejected" && requireRejectRemark;
const isOperationsRejectRetry =
decision === "rejected" && application?.operationsStatus === "rejecting" && application?.reviewStage === "operations";
const submitDisabled = loading || (needsImage && !auditImageUrl) || (needsRejectRemark && !remark.trim()); const submitDisabled = loading || (needsImage && !auditImageUrl) || (needsRejectRemark && !remark.trim());
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setRemark(""); // rejecting
setRemark(isOperationsRejectRetry ? application?.operationsAuditRemark || "" : "");
setAuditImageUrl(""); setAuditImageUrl("");
} }
}, [open]); }, [application?.operationsAuditRemark, isOperationsRejectRetry, open]);
const submit = () => { const submit = () => {
onSubmit({ auditImageUrl, decision, remark }); onSubmit({ auditImageUrl, decision, remark });
@ -52,7 +55,8 @@ export function FinanceAuditDialog({
<span>{application?.userId || "-"}</span> <span>{application?.userId || "-"}</span>
</div> </div>
<TextField <TextField
disabled={loading} // 沿 claim
disabled={loading || isOperationsRejectRetry}
label="审核备注" label="审核备注"
minRows={4} minRows={4}
multiline multiline

View File

@ -204,7 +204,7 @@ export function FinanceOverview({
} }
// WithdrawalKpi // WithdrawalKpi
// USDT // USDT
// USDTlikei // USDTlikei
// / // /
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) { function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
@ -219,7 +219,7 @@ function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
<> <>
<button <button
className="finance-kpi finance-kpi--withdrawal" className="finance-kpi finance-kpi--withdrawal"
title="点击进入用户提现申请" title="点击进入用户提现财务审核"
type="button" type="button"
onClick={onOpenWithdrawals} onClick={onOpenWithdrawals}
> >

View File

@ -185,6 +185,27 @@ export function FinanceWithdrawalApplicationList({
拒绝 拒绝
</Button> </Button>
</span> </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) statusLabel(isOperationsReview ? item.operationsStatus : item.status)
)} )}

View File

@ -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 { expect, test, vi } from "vitest";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx"; import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx";
@ -17,6 +17,7 @@ const baseProps = {
page: 1 page: 1
}, },
loading: false, loading: false,
onAudit: vi.fn(),
onFiltersChange: vi.fn(), onFiltersChange: vi.fn(),
onReload: vi.fn(), onReload: vi.fn(),
options: { apps: [] } options: { apps: [] }
@ -44,6 +45,12 @@ test("renders withdrawal application table with requested finance columns", () =
auditRemark: "地址不符合要求", auditRemark: "地址不符合要求",
createdAtMs: 1760000000000, createdAtMs: 1760000000000,
id: 7, id: 7,
operationsAuditRemark: "资料无误",
operationsReviewedAtMs: 1760003600000,
operationsReviewerName: "运营一",
operationsReviewerUserId: 11,
operationsStatus: "approved",
status: "approved",
userId: "10001", userId: "10001",
withdrawAddress: "TRX-address", withdrawAddress: "TRX-address",
withdrawAmount: 12.5, 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.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("lalu")).toBeInTheDocument();
expect(screen.getByText("10001")).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("TRX-address")).toBeInTheDocument();
expect(screen.getByText("财务一")).toBeInTheDocument(); expect(screen.getByText("财务一")).toBeInTheDocument();
expect(screen.getByText("21")).toBeInTheDocument(); expect(screen.getByText("21")).toBeInTheDocument();
expect(screen.getByText("运营一")).toBeInTheDocument();
expect(screen.getByText("11")).toBeInTheDocument();
expect(screen.getByText("资料无误")).toBeInTheDocument();
expect(screen.getByText("地址不符合要求")).toBeInTheDocument(); expect(screen.getByText("地址不符合要求")).toBeInTheDocument();
}); });
test("keeps empty withdrawal table colspan aligned with visible columns", () => { test("keeps empty withdrawal table colspan aligned with visible columns", () => {
const { container } = renderList(baseProps); const { container } = renderList(baseProps);
expect(container.querySelector("tbody td")?.colSpan).toBe(10); expect(container.querySelector("tbody td")?.colSpan).toBe(11);
expect(screen.getByText("当前无数据")).toBeInTheDocument(); 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" }));
});

View File

@ -14,8 +14,10 @@ export const FINANCE_PERMISSIONS = {
export const WITHDRAWAL_STATUS = [ export const WITHDRAWAL_STATUS = [
["pending", "待审核"], ["pending", "待审核"],
["rejecting", "拒绝处理中"],
["approved", "已通过"], ["approved", "已通过"],
["rejected", "已拒绝"], ["rejected", "已拒绝"],
["skipped", "存量跳过运营审核"],
]; ];
export const COIN_SELLER_RECHARGE_PROVIDERS = [ export const COIN_SELLER_RECHARGE_PROVIDERS = [

View File

@ -9,6 +9,7 @@ import {
normalizeRechargeBillPage, normalizeRechargeBillPage,
normalizeWithdrawalApplicationPage, normalizeWithdrawalApplicationPage,
rechargeTypeLabel, rechargeTypeLabel,
statusLabel,
validateCoinSellerRechargeOrderPayload, validateCoinSellerRechargeOrderPayload,
validateCoinSellerRechargeExchangeRatePayload, validateCoinSellerRechargeExchangeRatePayload,
} from "./format.js"; } from "./format.js";
@ -27,6 +28,12 @@ test("normalizes finance withdrawal application list rows", () => {
created_at_ms: 1760000000000, created_at_ms: 1760000000000,
freeze_transaction_id: "tx-freeze", freeze_transaction_id: "tx-freeze",
id: 7, 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", salary_asset_type: "HOST_SALARY_USD",
status: "approved", status: "approved",
user_id: 10001, user_id: 10001,
@ -57,6 +64,12 @@ test("normalizes finance withdrawal application list rows", () => {
createdAtMs: 1760000000000, createdAtMs: 1760000000000,
freezeTransactionId: "tx-freeze", freezeTransactionId: "tx-freeze",
id: 7, id: 7,
operationsAuditRemark: "资料无误",
operationsAuditTransactionId: "tx-operations-audit",
operationsReviewedAtMs: 1760003600000,
operationsReviewerName: "运营一",
operationsReviewerUserId: 11,
operationsStatus: "approved",
salaryAssetType: "HOST_SALARY_USD", salaryAssetType: "HOST_SALARY_USD",
userId: "10001", userId: "10001",
withdrawAddress: "TRX-address", withdrawAddress: "TRX-address",
@ -64,6 +77,8 @@ test("normalizes finance withdrawal application list rows", () => {
withdrawAmountMinor: 1250, withdrawAmountMinor: 1250,
withdrawMethod: "USDT-TRC20", withdrawMethod: "USDT-TRC20",
}); });
expect(statusLabel("rejecting")).toBe("拒绝处理中");
expect(statusLabel("skipped")).toBe("存量跳过运营审核");
}); });
test("builds validates and normalizes coin seller recharge orders", () => { test("builds validates and normalizes coin seller recharge orders", () => {

View File

@ -25,6 +25,11 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
avatar: "", avatar: "",
ban: { active: true, permanent: true, reason: "risk" }, ban: { active: true, permanent: true, reason: "risk" },
birth: "2000-02-29", 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", buildNumber: "345",
coin: 1200, coin: 1200,
countryDisplayName: "阿联酋", countryDisplayName: "阿联酋",
@ -109,6 +114,9 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
fireEvent.click(screen.getByRole("tab", { name: "资产" })); fireEvent.click(screen.getByRole("tab", { name: "资产" }));
expect(screen.getAllByText("累计充值").length).toBeGreaterThan(0); expect(screen.getAllByText("累计充值").length).toBeGreaterThan(0);
expect(screen.getAllByText(/123\.45/).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: "状态记录" })); fireEvent.click(screen.getByRole("tab", { name: "状态记录" }));
expect(screen.getByText("永久")).toBeInTheDocument(); expect(screen.getByText("永久")).toBeInTheDocument();

View File

@ -488,9 +488,13 @@ function AssetBalanceList({ balances }) {
<div className={styles.assetRow} key={balance.assetType}> <div className={styles.assetRow} key={balance.assetType}>
<div> <div>
<div className={styles.assetName}>{formatAssetType(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>
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
</div> </div>
))} ))}
</div> </div>
@ -616,6 +620,23 @@ function formatAssetType(value) {
return labels[normalized] || normalized || "-"; 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) { function formatResourceType(value) {
const labels = { const labels = {
avatar_frame: "头像框", avatar_frame: "头像框",

View File

@ -9,7 +9,13 @@ export const WORKSPACE_ENTRIES = [
href: "/finance/", href: "/finance/",
icon: AccountBalanceOutlined, icon: AccountBalanceOutlined,
label: "财务工作台", label: "财务工作台",
permission: PERMISSIONS.financeView, permissions: [
PERMISSIONS.financeView,
PERMISSIONS.financeWithdrawalView,
PERMISSIONS.financeWithdrawalAudit,
PERMISSIONS.operationsWithdrawalView,
PERMISSIONS.operationsWithdrawalAudit,
],
}, },
{ {
description: "运营管理", description: "运营管理",
@ -21,8 +27,8 @@ export const WORKSPACE_ENTRIES = [
]; ];
export function getPermittedWorkspaceEntries(can) { export function getPermittedWorkspaceEntries(can) {
// HTML // / finance:view
return WORKSPACE_ENTRIES.filter((entry) => can(entry.permission)); return WORKSPACE_ENTRIES.filter((entry) => (entry.permissions || [entry.permission]).some((permission) => can(permission)));
} }
export function DashboardWorkspaces() { export function DashboardWorkspaces() {

View File

@ -31,4 +31,23 @@ describe("DashboardWorkspaces", () => {
expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/"); expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/");
expect(screen.queryByRole("link", { name: /财务工作台/ })).not.toBeInTheDocument(); 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/");
},
);
}); });

View File

@ -1,5 +1,7 @@
import AddOutlined from "@mui/icons-material/AddOutlined"; import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; 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 InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
@ -14,11 +16,22 @@ import {
updateAdapterCatalog, updateAdapterCatalog,
updateAdapterConfigField, updateAdapterConfigField,
} from "@/features/games/adapterConfigModel.js"; } from "@/features/games/adapterConfigModel.js";
import { uploadImage } from "@/shared/api/upload";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx"; import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import styles from "@/features/games/components/AdapterConfigFields.module.css"; 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 definition = adapterDefinition(adapterType);
const configValid = Boolean(readAdapterConfig(value)); const configValid = Boolean(readAdapterConfig(value));
const visualFieldsDisabled = disabled || !configValid; const visualFieldsDisabled = disabled || !configValid;
@ -46,8 +59,10 @@ export function AdapterConfigFields({ adapterType, disabled = false, value, onCh
<GameCatalogEditor <GameCatalogEditor
adapterType={adapterType} adapterType={adapterType}
disabled={visualFieldsDisabled} disabled={visualFieldsDisabled}
uploadDisabled={uploadDisabled}
value={value} value={value}
onChange={onChange} onChange={onChange}
onUploadingChange={onUploadingChange}
/> />
) : null} ) : null}
{(definition.advancedFields || []).length > 0 ? ( {(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 definition = adapterDefinition(adapterType);
const externalRows = useMemo(() => adapterCatalogRows(value), [value]); const externalRows = useMemo(() => adapterCatalogRows(value), [value]);
const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]); const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]);
@ -152,28 +167,53 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
const adapterTypeRef = useRef(adapterType); const adapterTypeRef = useRef(adapterType);
const rowSequence = useRef(externalRows.length); const rowSequence = useRef(externalRows.length);
const [rows, setRows] = useState(() => withRowKeys(externalRows, "initial")); 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(() => { useEffect(() => {
const adapterChanged = adapterTypeRef.current !== adapterType; const adapterChanged = adapterTypeRef.current !== adapterType;
if (adapterChanged || externalSignature !== lastEmittedSignature.current) { if (adapterChanged || externalSignature !== lastEmittedSignature.current) {
rowSequence.current += externalRows.length; 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; adapterTypeRef.current = adapterType;
lastEmittedSignature.current = externalSignature; lastEmittedSignature.current = externalSignature;
}, [adapterType, externalRows, externalSignature]); }, [adapterType, externalRows, externalSignature]);
const emitRows = (nextRows) => { const emitRows = (nextRows) => {
// rows/config patch
rowsRef.current = nextRows;
setRows(nextRows); setRows(nextRows);
const nextValue = updateAdapterCatalog(value, nextRows); const nextValue = updateAdapterCatalog(valueRef.current, nextRows);
lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue)); lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue));
onChange(nextValue); onChangeRef.current(nextValue);
}; };
const addRow = () => { const addRow = () => {
rowSequence.current += 1; rowSequence.current += 1;
setRows((current) => [ const nextRows = [
...current, ...rowsRef.current,
{ {
rowKey: `draft-${rowSequence.current}`, rowKey: `draft-${rowSequence.current}`,
providerGameId: "", providerGameId: "",
@ -183,22 +223,38 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
coverUrl: "", coverUrl: "",
gameLevelUid: "", gameLevelUid: "",
}, },
]); ];
rowsRef.current = nextRows;
setRows(nextRows);
}; };
const changeRow = (rowKey, patch) => { 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) => { 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 ( return (
<section className={styles.catalog}> <section className={styles.catalog}>
<div className={styles.catalogHeader}> <div className={styles.catalogHeader}>
<div className={styles.groupTitle}>游戏目录 · {rows.length}</div> <div className={styles.groupTitle}>游戏目录 · {rows.length}</div>
<Button disabled={disabled} onClick={addRow}> <Button disabled={disabled || assetsUploading} onClick={addRow}>
<AddOutlined fontSize="small" /> <AddOutlined fontSize="small" />
添加游戏 添加游戏
</Button> </Button>
@ -254,7 +310,7 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
/> />
<Button <Button
aria-label={`删除第 ${index + 1} 个游戏`} aria-label={`删除第 ${index + 1} 个游戏`}
disabled={disabled} disabled={disabled || assetsUploading}
onClick={() => removeRow(row.rowKey)} onClick={() => removeRow(row.rowKey)}
> >
<DeleteOutlineOutlined fontSize="small" /> <DeleteOutlineOutlined fontSize="small" />
@ -263,18 +319,24 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
<details className={styles.rowDetails}> <details className={styles.rowDetails}>
<summary>素材与游戏参数</summary> <summary>素材与游戏参数</summary>
<div className={styles.rowExtra}> <div className={styles.rowExtra}>
<TextField <CatalogImageURLField
disabled={disabled} disabled={disabled}
label="图标 URL" label="图标 URL"
uploadDisabled={uploadDisabled}
value={row.iconUrl} 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} disabled={disabled}
label="封面 URL" label="封面 URL"
uploadDisabled={uploadDisabled}
value={row.coverUrl} value={row.coverUrl}
onChange={(event) => onChange={(coverUrl) => changeRow(row.rowKey, { coverUrl })}
changeRow(row.rowKey, { coverUrl: event.target.value }) onUploadingChange={(uploading) =>
changeUploadingState(`${row.rowKey}:cover`, uploading)
} }
/> />
{adapterType === "yomi_v4" ? ( {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) { function withRowKeys(rows, prefix) {
return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` })); return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` }));
} }

View File

@ -119,6 +119,52 @@
padding-top: var(--space-2); 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, .advancedEditor,
.details > .fieldGrid { .details > .fieldGrid {
padding: var(--space-3); padding: var(--space-3);

View File

@ -1,5 +1,6 @@
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useState } from "react";
import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx"; import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx";
import { import {
adapterDefinition, adapterDefinition,
@ -17,6 +18,7 @@ import {
import styles from "@/features/games/components/PlatformFormDialog.module.css"; import styles from "@/features/games/components/PlatformFormDialog.module.css";
export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const [assetsUploading, setAssetsUploading] = useState(false);
const definition = adapterDefinition(form.adapterType); const definition = adapterDefinition(form.adapterType);
const configValid = Boolean(readAdapterConfig(form.adapterConfigJson)); const configValid = Boolean(readAdapterConfig(form.adapterConfigJson));
const disabled = !page.abilities.canUpdate; const disabled = !page.abilities.canUpdate;
@ -25,6 +27,15 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
)?.[0]; )?.[0];
const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit"); const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit");
const submitForm = (event) => {
if (assetsUploading) {
// Enter URL
event.preventDefault();
return;
}
onSubmit(event);
};
const changeAdapter = (adapterType) => { const changeAdapter = (adapterType) => {
const previousDefinition = adapterDefinition(form.adapterType); const previousDefinition = adapterDefinition(form.adapterType);
const nextDefinition = adapterDefinition(adapterType); const nextDefinition = adapterDefinition(adapterType);
@ -77,11 +88,11 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
loading={loading} loading={loading}
open={open} open={open}
size="large" size="large"
submitDisabled={disabled || loading || !configValid} submitDisabled={disabled || loading || assetsUploading || !configValid}
submitLabel={mode === "edit" ? "保存平台" : "创建平台"} submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
title={mode === "edit" ? "编辑平台" : "添加平台"} title={mode === "edit" ? "编辑平台" : "添加平台"}
onClose={onClose} onClose={onClose}
onSubmit={onSubmit} onSubmit={submitForm}
> >
<AdminFormSection title="平台信息"> <AdminFormSection title="平台信息">
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))"> <AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
@ -125,7 +136,8 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
<AdminFormSection title="接入与回调"> <AdminFormSection title="接入与回调">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))"> <AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField <TextField
disabled={disabled || (mode === "edit" && Boolean(fixedAdapterType))} // 稿
disabled={disabled || assetsUploading || (mode === "edit" && Boolean(fixedAdapterType))}
helperText={mode === "edit" && fixedAdapterType ? "协议固定适配器" : undefined} helperText={mode === "edit" && fixedAdapterType ? "协议固定适配器" : undefined}
label="适配器" label="适配器"
select select
@ -183,8 +195,14 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
<AdapterConfigFields <AdapterConfigFields
adapterType={form.adapterType} adapterType={form.adapterType}
disabled={disabled} disabled={disabled}
// open
key={open ? "active-platform-form" : "inactive-platform-form"}
uploadDisabled={!page.abilities.canUpload}
value={form.adapterConfigJson} value={form.adapterConfigJson}
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })} onChange={(adapterConfigJson) =>
page.setPlatformForm((current) => ({ ...current, adapterConfigJson }))
}
onUploadingChange={setAssetsUploading}
/> />
</AdminFormSection> </AdminFormSection>
</AdminFormDialog> </AdminFormDialog>

View File

@ -1317,7 +1317,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.getFinanceScope, operationId: API_OPERATIONS.getFinanceScope,
path: "/v1/admin/finance/scope", path: "/v1/admin/finance/scope",
permission: "finance:view", permission: "finance:view",
permissions: ["finance:view"] permissions: ["finance:view","finance-withdrawal:view","finance-withdrawal:audit","operations-withdrawal:view","operations-withdrawal:audit"]
}, },
getFinanceUSDTAddresses: { getFinanceUSDTAddresses: {
method: "GET", method: "GET",

View File

@ -6533,8 +6533,11 @@ export interface components {
freezeTransactionId?: string; freezeTransactionId?: string;
auditTransactionId?: string; auditTransactionId?: string;
status?: string; status?: string;
/** @enum {string} */ /**
operationsStatus?: "pending" | "approved" | "rejected"; * @description rejecting
* @enum {string}
*/
operationsStatus?: "pending" | "rejecting" | "approved" | "rejected" | "skipped";
operationsReviewerUserId?: number | null; operationsReviewerUserId?: number | null;
operationsReviewerName?: string; operationsReviewerName?: string;
operationsAuditRemark?: string; operationsAuditRemark?: string;
@ -11257,7 +11260,9 @@ export interface operations {
approveFinanceWithdrawalApplication: { approveFinanceWithdrawalApplication: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header: {
"X-App-Code": components["parameters"]["AppCodeHeader"];
};
path: { path: {
application_id: number; application_id: number;
}; };
@ -11271,7 +11276,9 @@ export interface operations {
rejectFinanceWithdrawalApplication: { rejectFinanceWithdrawalApplication: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header: {
"X-App-Code": components["parameters"]["AppCodeHeader"];
};
path: { path: {
application_id: number; application_id: number;
}; };
@ -11302,7 +11309,9 @@ export interface operations {
approveOperationsWithdrawalApplication: { approveOperationsWithdrawalApplication: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header: {
"X-App-Code": components["parameters"]["AppCodeHeader"];
};
path: { path: {
application_id: number; application_id: number;
}; };
@ -11316,7 +11325,9 @@ export interface operations {
rejectOperationsWithdrawalApplication: { rejectOperationsWithdrawalApplication: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header: {
"X-App-Code": components["parameters"]["AppCodeHeader"];
};
path: { path: {
application_id: number; application_id: number;
}; };