2026-07-16 18:52:08 +08:00

1004 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from "react";
import {
approveFinanceWithdrawalApplication,
approveOperationsWithdrawalApplication,
createFinanceCoinSellerRechargeOrder,
exportFinanceRechargeBills,
fetchFinanceOptions,
fetchFinanceRechargeApps,
fetchFinanceRechargeOverview,
fetchFinanceRechargeSummaries,
fetchFinanceSession,
fetchFinanceWithdrawalOptions,
grantFinanceCoinSellerRechargeOrder,
getFinanceCoinSellerRechargeExchangeRate,
getFinanceUSDTAddresses,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
listFinanceRechargeRegions,
listFinanceWithdrawalApplications,
listOperationsWithdrawalApplications,
refreshFinanceGoogleRechargePaid,
replaceFinanceCoinSellerRechargeExchangeRate,
rejectFinanceWithdrawalApplication,
rejectOperationsWithdrawalApplication,
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 RECHARGE_VIEWS = new Set(["overview", "rechargeDetails", "recon"]);
const FINANCE_VIEW_ALIASES = new Map([["withdrawals", "withdrawalFinance"]]);
function requestedFinanceView() {
const requested = new URLSearchParams(window.location.search).get("view") || "";
return FINANCE_VIEW_ALIASES.get(requested) || requested;
}
function canAccessFinanceView(session, view) {
if (RECHARGE_VIEWS.has(view)) {
return Boolean(session?.canViewAppRechargeDetails);
}
if (view === "coinSellerRechargeOrders") {
return Boolean(session?.canViewCoinSellerRechargeOrders);
}
if (view === "withdrawalOperations") {
return Boolean(session?.canViewOperationsWithdrawalApplications);
}
if (view === "withdrawalFinance") {
return Boolean(session?.canViewWithdrawalApplications);
}
return false;
}
function defaultFinanceView(session) {
if (session?.canViewAppRechargeDetails) {
return "overview";
}
if (session?.canViewCoinSellerRechargeOrders) {
return "coinSellerRechargeOrders";
}
if (session?.canViewOperationsWithdrawalApplications) {
return "withdrawalOperations";
}
if (session?.canViewWithdrawalApplications) {
return "withdrawalFinance";
}
return "";
}
function resolvedFinanceView(session) {
const requested = requestedFinanceView();
return canAccessFinanceView(session, requested) ? requested : defaultFinanceView(session);
}
function syncFinanceView(view) {
if (!view) {
return;
}
const url = new URL(window.location.href);
if (url.searchParams.get("view") === view) {
return;
}
// 工作台是独立 HTML 入口,内部视图只同步 query保留其他筛选参数和 hash避免点击菜单破坏外部深链上下文。
url.searchParams.set("view", view);
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
}
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 [withdrawalOptionsState, setWithdrawalOptionsState] = 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 [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: "",
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 [operationsWithdrawalFilters, setOperationsWithdrawalFilters] = 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 });
const nextView = resolvedFinanceView(session);
setActiveView(nextView);
syncFinanceView(nextView);
})
.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 canLoadWithdrawalOptions = Boolean(
session?.canViewOperationsWithdrawalApplications || session?.canViewWithdrawalApplications,
);
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 operationsWithdrawalQuery = useMemo(
() => ({
app_code: operationsWithdrawalFilters.appCode,
keyword: operationsWithdrawalFilters.keyword,
page: operationsWithdrawalFilters.page,
page_size: pageSize,
}),
[operationsWithdrawalFilters],
);
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]);
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(
() =>
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 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;
}
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(() => {
loadWithdrawalOptions();
}, [loadWithdrawalOptions]);
useEffect(() => {
loadRechargeApps();
}, [loadRechargeApps]);
useEffect(() => {
loadRechargeDetails();
}, [loadRechargeDetails]);
useEffect(() => {
loadRechargeSummary();
}, [loadRechargeSummary]);
useEffect(() => {
loadRechargeOverview();
}, [loadRechargeOverview]);
useEffect(() => {
loadRecon();
}, [loadRecon]);
useEffect(() => {
loadRechargeRegions();
}, [loadRechargeRegions]);
useEffect(() => {
loadWithdrawals();
}, [loadWithdrawals]);
useEffect(() => {
loadOperationsWithdrawals();
}, [loadOperationsWithdrawals]);
useEffect(() => {
loadCoinSellerRechargeOrders();
}, [loadCoinSellerRechargeOrders]);
const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => {
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 =
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 Promise.all([loadOperationsWithdrawals(), 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 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 }));
};
if (sessionState.loading) {
return <StateScreen title="正在进入财务系统" />;
}
if (sessionState.error) {
return (
<StateScreen
actionHref={buildLoginRedirectPath(window.location)}
actionText="重新登录"
title={sessionState.error}
/>
);
}
if (!session?.canAccessFinance) {
return <StateScreen title="无财务系统权限" />;
}
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 = RECHARGE_VIEWS.has(activeView);
const financeToolbar =
isFinanceView && session.canViewAppRechargeDetails ? (
<>
<div className="finance-seg" role="group" aria-label="时间范围">
{presets.map((preset) => (
<button
className={activePreset?.key === preset.key ? "is-on" : ""}
key={preset.key}
type="button"
onClick={() => updateRechargeDetailFilters({ timeRange: preset.range })}
>
{preset.label}
</button>
))}
</div>
<TimeRangeFilter
label="自定义 · 中国时区"
value={rechargeDetailFilters.timeRange}
onChange={(timeRange) => updateRechargeDetailFilters({ timeRange })}
/>
<div className="finance-seg" role="group" aria-label="计价单位">
{[
["usd", "USD"],
["coin", "金币"],
].map(([value, label]) => (
<button
className={rechargeDetailFilters.currency === value ? "is-on" : ""}
key={value}
type="button"
onClick={() =>
updateRechargeDetailFilters({ currency: value, page: rechargeDetailFilters.page })
}
>
{label}
</button>
))}
</div>
</>
) : null;
const appBar =
isFinanceView && session.canViewAppRechargeDetails ? (
<div className="finance-appbar">
<button
className={["finance-appchip", rechargeDetailFilters.appCode === "" ? "is-on" : ""]
.filter(Boolean)
.join(" ")}
type="button"
onClick={() => updateRechargeDetailFilters({ appCode: "", regionId: "" })}
>
全部 App
</button>
{rechargeApps.map((app) => (
<button
className={["finance-appchip", rechargeDetailFilters.appCode === app.appCode ? "is-on" : ""]
.filter(Boolean)
.join(" ")}
key={app.appCode}
type="button"
onClick={() => updateRechargeDetailFilters({ appCode: app.appCode, regionId: "" })}
>
<AppIdentity app={app} size="small" />
</button>
))}
</div>
) : null;
return (
<FinanceShell
activeView={activeView}
appBar={appBar}
canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders}
canViewFinanceWithdrawals={session.canViewWithdrawalApplications}
canViewOperationsWithdrawals={session.canViewOperationsWithdrawalApplications}
canViewRechargeDetails={session.canViewAppRechargeDetails}
loading={
optionsState.loading ||
rechargeDetailsState.loading ||
withdrawalsState.loading ||
operationsWithdrawalsState.loading ||
coinSellerRechargeOrdersState.loading
}
session={session}
toolbar={financeToolbar}
onViewChange={changeActiveView}
>
{activeView === "overview" && session.canViewAppRechargeDetails ? (
<FinanceOverview
apps={rechargeApps}
currency={rechargeDetailFilters.currency}
filters={rechargeDetailFilters}
loading={rechargeSummaryState.loading || rechargeOverviewState.loading}
overview={rechargeOverviewState.data}
perAppSummaries={rechargeSummaryState.perApp}
prevSummary={rechargeSummaryState.prev}
regions={rechargeOverviewState.data.regions}
summary={rechargeSummaryState.merged}
onFiltersChange={updateRechargeDetailFilters}
onGoUnsynced={() => {
updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" });
changeActiveView("rechargeDetails");
}}
onOpenApp={(appCode) => {
updateRechargeDetailFilters({ appCode, regionId: "" });
changeActiveView("rechargeDetails");
}}
onOpenWithdrawals={() => changeActiveView("withdrawalFinance")}
/>
) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? (
<FinanceCoinSellerRechargeOrderList
actionLoading={coinSellerRechargeOrderActionLoading}
apps={rechargeApps}
canCreate={session.canCreateCoinSellerRechargeOrder}
canConfigureExchangeRate={session.canConfigureCoinSellerRechargeExchangeRate}
canGrant={session.canGrantCoinSellerRechargeOrder}
canUpdateUSDTAddress={session.canUpdateUSDTAddress}
canVerify={session.canVerifyCoinSellerRechargeOrder}
error={coinSellerRechargeOrdersState.error}
filters={coinSellerRechargeOrderFilters}
loading={coinSellerRechargeOrdersState.loading}
orders={coinSellerRechargeOrdersState.data}
onCreate={submitCoinSellerRechargeOrder}
onLoadExchangeRate={loadCoinSellerRechargeExchangeRate}
onLoadUSDTAddresses={loadUSDTAddresses}
onFiltersChange={updateCoinSellerRechargeOrderFilters}
onGrant={grantCoinSellerRechargeOrder}
onReload={loadCoinSellerRechargeOrders}
onQuote={quoteCoinSellerRechargeOrder}
onSaveExchangeRate={saveCoinSellerRechargeExchangeRate}
onSaveUSDTAddress={saveUSDTAddress}
onVerify={verifyCoinSellerRechargeOrder}
onVerifyReceipt={verifyCoinSellerRechargeReceipt}
/>
) : activeView === "recon" && session.canViewAppRechargeDetails ? (
<FinanceReconciliation
loading={reconState.loading}
month={reconMonthMeta(reconMonthOffset)}
overview={reconState.overview}
summary={reconState.summary}
onMonthChange={(delta) => setReconMonthOffset((current) => Math.min(0, current + delta))}
/>
) : activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? (
<FinanceRechargeDetailList
apps={rechargeApps}
bills={rechargeDetailsState.data}
currency={rechargeDetailFilters.currency}
error={rechargeDetailsState.error}
exporting={rechargeExporting}
filters={rechargeDetailFilters}
googlePaidRefreshing={googlePaidRefreshing}
loading={rechargeDetailsState.loading}
regions={rechargeRegionsState.byApp[rechargeAppCode] || []}
regionsByApp={rechargeRegionsState.byApp}
summary={rechargeSummaryState.merged}
onExport={exportRechargeBills}
onFiltersChange={updateRechargeDetailFilters}
onRefreshGooglePaid={refreshGooglePaid}
onReload={() => {
loadRechargeDetails();
loadRechargeSummary();
loadRechargeOverview();
}}
/>
) : activeView === "withdrawalOperations" && session.canViewOperationsWithdrawalApplications ? (
<FinanceWithdrawalApplicationList
applications={operationsWithdrawalsState.data}
canAudit={session.canAuditOperationsWithdrawalApplications}
error={operationsWithdrawalsState.error || withdrawalOptionsState.error}
filters={operationsWithdrawalFilters}
loading={operationsWithdrawalsState.loading}
options={withdrawalOptionsState.data}
reviewStage="operations"
onAudit={setWithdrawalAuditTarget}
onFiltersChange={updateOperationsWithdrawalFilters}
onReload={loadOperationsWithdrawals}
/>
) : activeView === "withdrawalFinance" && session.canViewWithdrawalApplications ? (
<FinanceWithdrawalApplicationList
applications={withdrawalsState.data}
canAudit={session.canAuditWithdrawalApplications}
error={withdrawalsState.error || withdrawalOptionsState.error}
filters={withdrawalFilters}
loading={withdrawalsState.loading}
options={withdrawalOptionsState.data}
reviewStage="finance"
onAudit={setWithdrawalAuditTarget}
onFiltersChange={updateWithdrawalFilters}
onReload={loadWithdrawals}
/>
) : null}
<FinanceAuditDialog
application={withdrawalAuditTarget}
apps={withdrawalOptionsState.data.apps}
loading={withdrawalAuditLoading}
open={Boolean(withdrawalAuditTarget)}
requireApprovalImage={withdrawalAuditTarget?.reviewStage !== "operations"}
requireRejectRemark
reviewLabel={withdrawalAuditTarget?.reviewStage === "operations" ? "运营" : "财务"}
onClose={() => setWithdrawalAuditTarget(null)}
onSubmit={submitWithdrawalAudit}
/>
</FinanceShell>
);
}
function StateScreen({ actionHref = "", actionText = "", title }) {
return (
<main className="finance-state-screen">
<section className="finance-state-panel">
<h1>{title}</h1>
{actionHref ? <a href={actionHref}>{actionText}</a> : null}
</section>
</main>
);
}
function cleanPayload(payload) {
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== ""));
}