import { useCallback, useEffect, useMemo, useState } from "react"; import { approveFinanceWithdrawalApplication, createFinanceCoinSellerRechargeOrder, exportFinanceRechargeBills, fetchFinanceOptions, fetchFinanceRechargeApps, fetchFinanceRechargeOverview, fetchFinanceRechargeSummaries, fetchFinanceSession, grantFinanceCoinSellerRechargeOrder, getFinanceCoinSellerRechargeExchangeRate, getFinanceUSDTAddresses, listFinanceCoinSellerRechargeOrders, listFinanceRechargeDetails, listFinanceRechargeRegions, listFinanceWithdrawalApplications, refreshFinanceGoogleRechargePaid, replaceFinanceCoinSellerRechargeExchangeRate, rejectFinanceWithdrawalApplication, quoteFinanceCoinSellerRechargeOrder, upsertFinanceUSDTAddress, verifyFinanceCoinSellerRechargeReceipt, verifyFinanceCoinSellerRechargeOrder, } from "./api.js"; import { FinanceAuditDialog } from "./components/FinanceAuditDialog.jsx"; import { FinanceCoinSellerRechargeOrderList } from "./components/FinanceCoinSellerRechargeOrderList.jsx"; import { FinanceOverview } from "./components/FinanceOverview.jsx"; import { FinanceReconciliation } from "./components/FinanceReconciliation.jsx"; import { FinanceRechargeDetailList } from "./components/FinanceRechargeDetailList.jsx"; import { FinanceShell } from "./components/FinanceShell.jsx"; import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx"; import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js"; import { downloadCsv } from "@/shared/api/download"; import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js"; import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; const emptyOptions = { apps: [] }; const pageSize = 50; const DAY_MS = 86_400_000; const CHINA_OFFSET_MS = 8 * 3_600_000; const FINANCE_VIEWS = new Set(["overview", "rechargeDetails", "recon"]); function chinaDayStart(timestamp) { return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS; } function timePresets() { const now = Date.now(); const todayStart = chinaDayStart(now); const yesterdayStart = todayStart - DAY_MS; const chinaNow = new Date(now + CHINA_OFFSET_MS); const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS; return [ { key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } }, // 财务后台统计按中国自然日切分,昨天固定为昨日 00:00 到今日 00:00,避免滚动 24 小时影响对账口径。 { key: "yesterday", label: "昨天", range: { endMs: todayStart, startMs: yesterdayStart } }, { key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } }, { key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } }, { key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } }, { key: "all", label: "不限", range: { endMs: "", startMs: "" } }, ]; } function reconMonthMeta(offset) { const chinaNow = new Date(Date.now() + CHINA_OFFSET_MS); const year = chinaNow.getUTCFullYear(); const month = chinaNow.getUTCMonth() + offset; const startMs = Date.UTC(year, month, 1) - CHINA_OFFSET_MS; const endMs = Date.UTC(year, month + 1, 1) - CHINA_OFFSET_MS; const startDate = new Date(startMs + CHINA_OFFSET_MS); return { endMs, isCurrent: offset === 0, label: `${startDate.getUTCFullYear()}年${startDate.getUTCMonth() + 1}月`, startMs, }; } 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 [rechargeDetailsState, setRechargeDetailsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false, }); const [rechargeSummaryState, setRechargeSummaryState] = useState({ merged: EMPTY_RECHARGE_SUMMARY, loading: false, perApp: [], prev: null, }); const [rechargeOverviewState, setRechargeOverviewState] = useState({ data: EMPTY_RECHARGE_OVERVIEW, loading: false, }); const [rechargeRegionsState, setRechargeRegionsState] = useState({ byApp: {}, loading: false }); const [rechargeAppsState, setRechargeAppsState] = useState({ items: [], loaded: false }); const [googlePaidRefreshing, setGooglePaidRefreshing] = useState(false); const [reconMonthOffset, setReconMonthOffset] = useState(0); const [reconState, setReconState] = useState({ loading: false, overview: EMPTY_RECHARGE_OVERVIEW, summary: EMPTY_RECHARGE_SUMMARY, }); const [withdrawalsState, setWithdrawalsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false, }); const [coinSellerRechargeOrdersState, setCoinSellerRechargeOrdersState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false, }); const [coinSellerRechargeOrderActionLoading, setCoinSellerRechargeOrderActionLoading] = useState(""); const [activeView, setActiveView] = useState("overview"); const [rechargeDetailFilters, setRechargeDetailFilters] = useState(() => ({ appCode: "", currency: "usd", keyword: "", page: 1, paidState: "", rechargeType: "", regionId: "", timeRange: timePresets().find((preset) => preset.key === "yesterday").range, })); const [rechargeExporting, setRechargeExporting] = useState(false); const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 }); const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({ appCode: "", grantStatus: "", keyword: "", page: 1, providerCode: "", status: "", verifyStatus: "", }); const [withdrawalAuditTarget, setWithdrawalAuditTarget] = useState(null); const [withdrawalAuditLoading, setWithdrawalAuditLoading] = useState(false); useEffect(() => { let mounted = true; fetchFinanceSession() .then((session) => { if (!mounted) { return; } setSessionState({ error: "", loading: false, session }); setActiveView( session.canViewAppRechargeDetails ? "overview" : session.canViewCoinSellerRechargeOrders ? "coinSellerRechargeOrders" : "withdrawals", ); }) .catch((err) => { if (mounted) { setSessionState({ error: err.message || "登录已失效", loading: false, session: null }); } }); return () => { mounted = false; }; }, []); const session = sessionState.session; const canLoadOptions = Boolean(session?.canAccessFinance); const canViewFinance = Boolean(session?.canViewAppRechargeDetails); // 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。 const rechargeBaseQuery = useMemo( () => ({ app_code: rechargeDetailFilters.appCode, end_at_ms: rechargeDetailFilters.timeRange.endMs, keyword: rechargeDetailFilters.keyword, recharge_type: rechargeDetailFilters.rechargeType, region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "", start_at_ms: rechargeDetailFilters.timeRange.startMs, stat_tz: "Asia/Shanghai", status: "succeeded", }), [rechargeDetailFilters], ); const rechargeDetailQuery = useMemo( () => ({ ...rechargeBaseQuery, page: rechargeDetailFilters.page, page_size: pageSize, paid_state: rechargeDetailFilters.paidState, }), [rechargeBaseQuery, rechargeDetailFilters.page, rechargeDetailFilters.paidState], ); const withdrawalQuery = useMemo( () => ({ app_code: withdrawalFilters.appCode, keyword: withdrawalFilters.keyword, page: withdrawalFilters.page, page_size: pageSize, }), [withdrawalFilters], ); const coinSellerRechargeOrderQuery = useMemo( () => ({ app_code: coinSellerRechargeOrderFilters.appCode, grant_status: coinSellerRechargeOrderFilters.grantStatus, keyword: coinSellerRechargeOrderFilters.keyword, page: coinSellerRechargeOrderFilters.page, page_size: pageSize, provider_code: coinSellerRechargeOrderFilters.providerCode, status: coinSellerRechargeOrderFilters.status, verify_status: coinSellerRechargeOrderFilters.verifyStatus, }), [coinSellerRechargeOrderFilters], ); const loadOptions = useCallback(async () => { if (!canLoadOptions) { return; } setOptionsState((current) => ({ ...current, error: "", loading: true })); try { const data = await fetchFinanceOptions(); setOptionsState({ data, error: "", loading: false }); } catch (err) { setOptionsState({ data: emptyOptions, error: err.message || "加载财务选项失败", loading: false }); } }, [canLoadOptions]); // 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。 const rechargeApps = useMemo( () => rechargeAppsState.loaded && rechargeAppsState.items.length ? rechargeAppsState.items : optionsState.data.apps, [optionsState.data.apps, rechargeAppsState], ); const loadRechargeApps = useCallback(async () => { if (!canViewFinance) { return; } try { const items = await fetchFinanceRechargeApps(); setRechargeAppsState({ items, loaded: true }); } catch { setRechargeAppsState({ items: [], loaded: false }); } }, [canViewFinance]); const loadRechargeDetails = useCallback(async () => { if (!canViewFinance) { return; } setRechargeDetailsState((current) => ({ ...current, error: "", loading: true })); try { const data = await listFinanceRechargeDetails(rechargeDetailQuery, rechargeApps); setRechargeDetailsState({ data, error: "", loading: false }); } catch (err) { setRechargeDetailsState((current) => ({ ...current, error: err.message || "加载充值流水失败", loading: false, })); } }, [canViewFinance, rechargeApps, rechargeDetailQuery]); const loadRechargeSummary = useCallback(async () => { if (!canViewFinance) { return; } setRechargeSummaryState((current) => ({ ...current, loading: true })); try { const { merged, perApp } = await fetchFinanceRechargeSummaries(rechargeBaseQuery, rechargeApps); // 环比:把当前时间范围整体前移一个周期再查一次;未选时间范围时不算环比。 let prev = null; const { endMs, startMs } = rechargeDetailFilters.timeRange; if (startMs && endMs && endMs > startMs) { const length = endMs - startMs; const prevQuery = { ...rechargeBaseQuery, end_at_ms: startMs, start_at_ms: startMs - length }; prev = (await fetchFinanceRechargeSummaries(prevQuery, rechargeApps)).merged; } setRechargeSummaryState({ merged, loading: false, perApp, prev }); } catch { setRechargeSummaryState((current) => ({ ...current, loading: false })); } }, [canViewFinance, rechargeApps, rechargeBaseQuery, rechargeDetailFilters.timeRange]); const loadRechargeOverview = useCallback(async () => { if (!canViewFinance) { return; } setRechargeOverviewState((current) => ({ ...current, loading: true })); try { const data = await fetchFinanceRechargeOverview(rechargeBaseQuery, rechargeApps); setRechargeOverviewState({ data, loading: false }); } catch { setRechargeOverviewState((current) => ({ ...current, loading: false })); } }, [canViewFinance, rechargeApps, rechargeBaseQuery]); const loadRecon = useCallback(async () => { if (!canViewFinance || activeView !== "recon") { return; } const month = reconMonthMeta(reconMonthOffset); const query = { ...rechargeBaseQuery, end_at_ms: month.endMs, keyword: "", recharge_type: "", start_at_ms: month.startMs, }; setReconState((current) => ({ ...current, loading: true })); try { const [{ merged }, overview] = await Promise.all([ fetchFinanceRechargeSummaries(query, rechargeApps), fetchFinanceRechargeOverview(query, rechargeApps), ]); setReconState({ loading: false, overview, summary: merged }); } catch { setReconState((current) => ({ ...current, loading: false })); } }, [activeView, canViewFinance, rechargeApps, rechargeBaseQuery, reconMonthOffset]); const rechargeAppCode = rechargeDetailFilters.appCode; // 一次拉齐所有 App 的区域目录:区域筛选下拉用当前 App 的,流水区域列在“全部 App”下要跨 App 显示名称。 const loadRechargeRegions = useCallback(async () => { if (!canViewFinance || !rechargeApps.length) { return; } setRechargeRegionsState((current) => ({ ...current, loading: true })); try { const entries = await Promise.all( rechargeApps.map(async (app) => { try { return [app.appCode, await listFinanceRechargeRegions(app.appCode)]; } catch { return [app.appCode, []]; } }), ); setRechargeRegionsState({ byApp: Object.fromEntries(entries), loading: false }); } catch { setRechargeRegionsState((current) => ({ ...current, loading: false })); } }, [canViewFinance, rechargeApps]); const refreshGooglePaid = useCallback( async (transactionIds) => { if (!rechargeAppCode || !transactionIds?.length || googlePaidRefreshing) { return; } setGooglePaidRefreshing(true); try { const results = await refreshFinanceGoogleRechargePaid(rechargeAppCode, transactionIds); const failed = results.filter((item) => item.error).length; const succeeded = results.length - failed; showToast( failed ? `谷歌实付同步完成:成功 ${succeeded} 笔,失败 ${failed} 笔` : `谷歌实付同步完成:${succeeded} 笔`, failed ? "warning" : "success", ); await Promise.all([loadRechargeDetails(), loadRechargeOverview()]); } catch (err) { showToast(err.message || "查询谷歌实付失败", "error"); } finally { setGooglePaidRefreshing(false); } }, [googlePaidRefreshing, loadRechargeDetails, loadRechargeOverview, rechargeAppCode, showToast], ); const exportRechargeBills = useCallback(async () => { if (!rechargeAppCode || rechargeExporting) { return; } setRechargeExporting(true); try { const timestamp = new Date() .toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }) .replaceAll(/[: ]/g, "-"); await downloadCsv( () => exportFinanceRechargeBills(rechargeAppCode, rechargeDetailQuery), `充值明细-${rechargeAppCode}-${timestamp}.csv`, ); showToast("充值明细已导出", "success"); } catch (err) { showToast(err.message || "导出充值明细失败", "error"); } finally { setRechargeExporting(false); } }, [rechargeAppCode, rechargeDetailQuery, rechargeExporting, showToast]); const loadWithdrawals = useCallback(async () => { if (!session?.canViewWithdrawalApplications) { return; } setWithdrawalsState((current) => ({ ...current, error: "", loading: true })); try { const data = await listFinanceWithdrawalApplications(withdrawalQuery); setWithdrawalsState({ data, error: "", loading: false }); } catch (err) { setWithdrawalsState((current) => ({ ...current, error: err.message || "加载提现申请列表失败", loading: false, })); } }, [session?.canViewWithdrawalApplications, withdrawalQuery]); const loadCoinSellerRechargeOrders = useCallback(async () => { if (!session?.canViewCoinSellerRechargeOrders) { return; } setCoinSellerRechargeOrdersState((current) => ({ ...current, error: "", loading: true })); try { const data = await listFinanceCoinSellerRechargeOrders(coinSellerRechargeOrderQuery); setCoinSellerRechargeOrdersState({ data, error: "", loading: false }); } catch (err) { setCoinSellerRechargeOrdersState((current) => ({ ...current, error: err.message || "加载币商充值订单失败", loading: false, })); } }, [coinSellerRechargeOrderQuery, session?.canViewCoinSellerRechargeOrders]); useEffect(() => { loadOptions(); }, [loadOptions]); useEffect(() => { loadRechargeApps(); }, [loadRechargeApps]); useEffect(() => { loadRechargeDetails(); }, [loadRechargeDetails]); useEffect(() => { loadRechargeSummary(); }, [loadRechargeSummary]); useEffect(() => { loadRechargeOverview(); }, [loadRechargeOverview]); useEffect(() => { loadRecon(); }, [loadRecon]); useEffect(() => { loadRechargeRegions(); }, [loadRechargeRegions]); useEffect(() => { loadWithdrawals(); }, [loadWithdrawals]); useEffect(() => { loadCoinSellerRechargeOrders(); }, [loadCoinSellerRechargeOrders]); const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => { if (!withdrawalAuditTarget?.id || !session?.canAuditWithdrawalApplications) { return; } setWithdrawalAuditLoading(true); try { const submitter = decision === "approved" ? approveFinanceWithdrawalApplication : rejectFinanceWithdrawalApplication; await submitter(withdrawalAuditTarget.id, cleanPayload({ auditImageUrl, auditRemark: remark })); showToast(decision === "approved" ? "提现申请已通过" : "提现申请已拒绝", "success"); setWithdrawalAuditTarget(null); await loadWithdrawals(); } catch (err) { showToast(err.message || "审核提现申请失败", "error"); } finally { setWithdrawalAuditLoading(false); } }; const submitCoinSellerRechargeOrder = async (payload) => { if (!session?.canCreateCoinSellerRechargeOrder) { showToast("无创建币商充值订单权限", "error"); return null; } setCoinSellerRechargeOrderActionLoading("create"); try { const order = await createFinanceCoinSellerRechargeOrder(cleanPayload(payload)); showToast("币商充值订单已创建", "success"); await loadCoinSellerRechargeOrders(); return order; } catch (err) { showToast(err.message || "创建币商充值订单失败", "error"); return null; } finally { setCoinSellerRechargeOrderActionLoading(""); } }; const quoteCoinSellerRechargeOrder = useCallback( (payload) => quoteFinanceCoinSellerRechargeOrder(cleanPayload(payload)), [], ); const loadCoinSellerRechargeExchangeRate = useCallback( (appCode) => getFinanceCoinSellerRechargeExchangeRate(appCode), [], ); const loadUSDTAddresses = useCallback((appCode) => getFinanceUSDTAddresses(appCode), []); const saveUSDTAddress = useCallback( async (appCode, chain, payload) => { if (!session?.canUpdateUSDTAddress) { throw new Error("无修改 USDT 收款地址权限"); } const saved = await upsertFinanceUSDTAddress(appCode, chain, cleanPayload(payload)); showToast(`${chain} 收款地址已保存`, "success"); return saved; }, [session?.canUpdateUSDTAddress, showToast], ); const saveCoinSellerRechargeExchangeRate = useCallback( async (appCode, payload) => { const saved = await replaceFinanceCoinSellerRechargeExchangeRate(appCode, cleanPayload(payload)); showToast("金币汇率配置已保存", "success"); return saved; }, [showToast], ); const verifyCoinSellerRechargeReceipt = async (payload) => { if (!session?.canVerifyCoinSellerRechargeOrder) { showToast("无校验币商充值订单权限", "error"); return null; } try { const verification = await verifyFinanceCoinSellerRechargeReceipt(cleanPayload(payload)); showToast(verification?.verified ? "订单号校验通过" : verification?.failureReason || "订单号校验未通过", verification?.verified ? "success" : "error"); return verification; } catch (err) { showToast(err.message || "校验订单号失败", "error"); return null; } }; const verifyCoinSellerRechargeOrder = async (order) => { if (!order?.id || !session?.canVerifyCoinSellerRechargeOrder) { return; } setCoinSellerRechargeOrderActionLoading(`verify:${order.id}`); try { await verifyFinanceCoinSellerRechargeOrder(order.id); showToast("订单校验已完成", "success"); await loadCoinSellerRechargeOrders(); } catch (err) { showToast(err.message || "校验币商充值订单失败", "error"); } finally { setCoinSellerRechargeOrderActionLoading(""); } }; const grantCoinSellerRechargeOrder = async (order) => { if (!order?.id || !session?.canGrantCoinSellerRechargeOrder) { return; } setCoinSellerRechargeOrderActionLoading(`grant:${order.id}`); try { await grantFinanceCoinSellerRechargeOrder(order.id); showToast("币商充值已发放", "success"); await loadCoinSellerRechargeOrders(); } catch (err) { showToast(err.message || "发放币商充值失败", "error"); } finally { setCoinSellerRechargeOrderActionLoading(""); } }; const updateRechargeDetailFilters = (nextFilters) => { setRechargeDetailFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); }; const updateWithdrawalFilters = (nextFilters) => { setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); }; const updateCoinSellerRechargeOrderFilters = (nextFilters) => { setCoinSellerRechargeOrderFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); }; if (sessionState.loading) { return ; } if (sessionState.error) { return ( ); } if (!session?.canAccessFinance) { return ; } const presets = timePresets(); const activePreset = presets.find( (preset) => String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") && String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || ""), ); const isFinanceView = FINANCE_VIEWS.has(activeView); const financeToolbar = isFinanceView && session.canViewAppRechargeDetails ? ( <>
{presets.map((preset) => ( ))}
updateRechargeDetailFilters({ timeRange })} />
{[ ["usd", "USD"], ["coin", "金币"], ].map(([value, label]) => ( ))}
) : null; const appBar = isFinanceView && session.canViewAppRechargeDetails ? (
{rechargeApps.map((app) => ( ))}
) : null; return ( {activeView === "overview" && session.canViewAppRechargeDetails ? ( { updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" }); setActiveView("rechargeDetails"); }} onOpenApp={(appCode) => { updateRechargeDetailFilters({ appCode, regionId: "" }); setActiveView("rechargeDetails"); }} onOpenWithdrawals={() => setActiveView("withdrawals")} /> ) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? ( ) : activeView === "recon" && session.canViewAppRechargeDetails ? ( setReconMonthOffset((current) => Math.min(0, current + delta))} /> ) : activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? ( { loadRechargeDetails(); loadRechargeSummary(); loadRechargeOverview(); }} /> ) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? ( ) : null} setWithdrawalAuditTarget(null)} onSubmit={submitWithdrawalAudit} /> ); } function StateScreen({ actionHref = "", actionText = "", title }) { return (

{title}

{actionHref ? {actionText} : null}
); } function cleanPayload(payload) { return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== "")); }