diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 545ef41..0ed8a73 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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": { diff --git a/finance/src/FinanceApp.jsx b/finance/src/FinanceApp.jsx index bc90ce6..0819878 100644 --- a/finance/src/FinanceApp.jsx +++ b/finance/src/FinanceApp.jsx @@ -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 ? ( { 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 ? ( - ) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? ( + ) : activeView === "withdrawalOperations" && session.canViewOperationsWithdrawalApplications ? ( + + ) : activeView === "withdrawalFinance" && session.canViewWithdrawalApplications ? ( setWithdrawalAuditTarget(null)} onSubmit={submitWithdrawalAudit} /> diff --git a/finance/src/FinanceApp.test.jsx b/finance/src/FinanceApp.test.jsx index 0742e57..ed217c9 100644 --- a/finance/src/FinanceApp.test.jsx +++ b/finance/src/FinanceApp.test.jsx @@ -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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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(); +}); diff --git a/finance/src/api.js b/finance/src/api.js index df63930..65e6185 100644 --- a/finance/src/api.js +++ b/finance/src/api.js @@ -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 }); } diff --git a/finance/src/api.test.js b/finance/src/api.test.js index e0cf3af..ccec3cd 100644 --- a/finance/src/api.test.js +++ b/finance/src/api.test.js @@ -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 () => { diff --git a/finance/src/components/FinanceAuditDialog.jsx b/finance/src/components/FinanceAuditDialog.jsx index ffdbcad..64a1c28 100644 --- a/finance/src/components/FinanceAuditDialog.jsx +++ b/finance/src/components/FinanceAuditDialog.jsx @@ -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({ {application?.userId || "-"} + ) : isOperationsReview && item.operationsStatus === "rejecting" && canAudit ? ( + + {statusLabel(item.operationsStatus)} + + ) : ( statusLabel(isOperationsReview ? item.operationsStatus : item.status) )} diff --git a/finance/src/components/FinanceWithdrawalApplicationList.test.jsx b/finance/src/components/FinanceWithdrawalApplicationList.test.jsx index 313e3e4..f145c4c 100644 --- a/finance/src/components/FinanceWithdrawalApplicationList.test.jsx +++ b/finance/src/components/FinanceWithdrawalApplicationList.test.jsx @@ -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" })); +}); diff --git a/finance/src/constants.js b/finance/src/constants.js index baa72da..ed84806 100644 --- a/finance/src/constants.js +++ b/finance/src/constants.js @@ -14,8 +14,10 @@ export const FINANCE_PERMISSIONS = { export const WITHDRAWAL_STATUS = [ ["pending", "待审核"], + ["rejecting", "拒绝处理中"], ["approved", "已通过"], ["rejected", "已拒绝"], + ["skipped", "存量跳过运营审核"], ]; export const COIN_SELLER_RECHARGE_PROVIDERS = [ diff --git a/finance/src/format.test.js b/finance/src/format.test.js index 1190742..c1116e2 100644 --- a/finance/src/format.test.js +++ b/finance/src/format.test.js @@ -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", () => { diff --git a/src/features/app-users/components/AppUserDetailProvider.test.jsx b/src/features/app-users/components/AppUserDetailProvider.test.jsx index 7fc1a11..05b3d3c 100644 --- a/src/features/app-users/components/AppUserDetailProvider.test.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.test.jsx @@ -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(); diff --git a/src/features/app-users/components/AppUserDetailView.jsx b/src/features/app-users/components/AppUserDetailView.jsx index 392297d..8e78524 100644 --- a/src/features/app-users/components/AppUserDetailView.jsx +++ b/src/features/app-users/components/AppUserDetailView.jsx @@ -488,9 +488,13 @@ function AssetBalanceList({ balances }) {
{formatAssetType(balance.assetType)}
-
冻结 {formatNumber(balance.frozenAmount)}
+
+ 冻结 {formatAssetAmount(balance.assetType, balance.frozenAmount)} +
+
+
+ {formatAssetAmount(balance.assetType, balance.availableAmount)}
-
{formatNumber(balance.availableAmount)}
))} @@ -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: "头像框", diff --git a/src/features/dashboard/components/DashboardWorkspaces.jsx b/src/features/dashboard/components/DashboardWorkspaces.jsx index d6a0670..a74db82 100644 --- a/src/features/dashboard/components/DashboardWorkspaces.jsx +++ b/src/features/dashboard/components/DashboardWorkspaces.jsx @@ -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() { diff --git a/src/features/dashboard/components/DashboardWorkspaces.test.jsx b/src/features/dashboard/components/DashboardWorkspaces.test.jsx index e048462..7b27641 100644 --- a/src/features/dashboard/components/DashboardWorkspaces.test.jsx +++ b/src/features/dashboard/components/DashboardWorkspaces.test.jsx @@ -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(); + + 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(); + + expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/"); + }, + ); }); diff --git a/src/features/games/components/AdapterConfigFields.jsx b/src/features/games/components/AdapterConfigFields.jsx index eeff97c..927ccb5 100644 --- a/src/features/games/components/AdapterConfigFields.jsx +++ b/src/features/games/components/AdapterConfigFields.jsx @@ -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 ) : 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 (
游戏目录 · {rows.length}
- @@ -254,7 +310,7 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) { /> + + ), + }, + }} + type="text" + value={value} + onChange={(event) => onChange(event.target.value)} + /> + +
+ ); +} + function withRowKeys(rows, prefix) { return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` })); } diff --git a/src/features/games/components/AdapterConfigFields.module.css b/src/features/games/components/AdapterConfigFields.module.css index 5723611..ab8f853 100644 --- a/src/features/games/components/AdapterConfigFields.module.css +++ b/src/features/games/components/AdapterConfigFields.module.css @@ -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); diff --git a/src/features/games/components/PlatformFormDialog.jsx b/src/features/games/components/PlatformFormDialog.jsx index f7f42a9..a926a93 100644 --- a/src/features/games/components/PlatformFormDialog.jsx +++ b/src/features/games/components/PlatformFormDialog.jsx @@ -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} > @@ -125,7 +136,8 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o page.setPlatformForm({ ...form, adapterConfigJson })} + onChange={(adapterConfigJson) => + page.setPlatformForm((current) => ({ ...current, adapterConfigJson })) + } + onUploadingChange={setAssetsUploading} /> diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 2411d5f..6227226 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -1317,7 +1317,7 @@ export const API_ENDPOINTS: Record = { 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", diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 4b0ba30..a3733c0 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; };