1004 lines
42 KiB
JavaScript
1004 lines
42 KiB
JavaScript
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 !== ""));
|
||
}
|