2026-07-07 18:05:08 +08:00

1006 lines
41 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 Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import {
approveFinanceApplication,
approveFinanceWithdrawalApplication,
createFinanceApplication,
createFinanceCoinSellerRechargeOrder,
exportFinanceRechargeBills,
fetchFinanceApplicationOptions,
fetchFinanceRechargeApps,
fetchFinanceRechargeOverview,
fetchFinanceRechargeSummaries,
fetchFinanceSession,
filterOperationsByPermissions,
grantFinanceCoinSellerRechargeOrder,
listFinanceApplications,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
listFinanceRechargeRegions,
listMyFinanceApplications,
listFinanceWithdrawalApplications,
refreshFinanceGoogleRechargePaid,
rejectFinanceApplication,
rejectFinanceWithdrawalApplication,
verifyFinanceCoinSellerRechargeReceipt,
verifyFinanceCoinSellerRechargeOrder,
} from "./api.js";
import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx";
import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx";
import { FinanceAuditDialog } from "./components/FinanceAuditDialog.jsx";
import { FinanceCoinSellerRechargeOrderList } from "./components/FinanceCoinSellerRechargeOrderList.jsx";
import { FinanceMyApplicationList } from "./components/FinanceMyApplicationList.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 { DEFAULT_APPLICATION_FORM } from "./constants.js";
import {
EMPTY_RECHARGE_OVERVIEW,
EMPTY_RECHARGE_SUMMARY,
buildApplicationPayload,
validateApplicationPayload,
} from "./format.js";
import { downloadCsv } from "@/shared/api/download";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const emptyOptions = { apps: [], operations: [], walletIdentities: [] };
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 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 } },
{ 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 [myApplicationsState, setMyApplicationsState] = useState({
data: { items: [], page: 1, pageSize, total: 0 },
error: "",
loading: false,
});
const [applicationsState, setApplicationsState] = useState({
data: { items: [], page: 1, pageSize, total: 0 },
error: "",
loading: false,
});
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("create");
const [rechargeDetailFilters, setRechargeDetailFilters] = useState(() => ({
appCode: "",
currency: "usd",
keyword: "",
page: 1,
paidState: "",
rechargeType: "",
regionId: "",
timeRange: timePresets().find((preset) => preset.key === "30d").range,
}));
const [rechargeExporting, setRechargeExporting] = useState(false);
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" });
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({
appCode: "",
grantStatus: "",
keyword: "",
page: 1,
providerCode: "",
status: "",
verifyStatus: "",
});
const [form, setForm] = useState(DEFAULT_APPLICATION_FORM);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [auditTarget, setAuditTarget] = useState(null);
const [auditLoading, setAuditLoading] = useState(false);
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"
: session.canAuditApplication
? "applications"
: session.canCreateApplication
? "create"
: "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 myApplicationQuery = useMemo(
() => ({
app_code: myFilters.appCode,
keyword: myFilters.keyword,
operation: myFilters.operation,
page: myFilters.page,
page_size: pageSize,
status: myFilters.status,
}),
[myFilters],
);
const applicationQuery = useMemo(
() => ({
app_code: filters.appCode,
keyword: filters.keyword,
operation: filters.operation,
page: filters.page,
page_size: pageSize,
status: filters.status,
}),
[filters],
);
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 fetchFinanceApplicationOptions();
setOptionsState({ data, error: "", loading: false });
} catch (err) {
setOptionsState({ data: emptyOptions, error: err.message || "加载财务选项失败", loading: false });
}
}, [canLoadOptions]);
const loadMyApplications = useCallback(async () => {
if (!session?.canCreateApplication) {
return;
}
setMyApplicationsState((current) => ({ ...current, error: "", loading: true }));
try {
const data = await listMyFinanceApplications(myApplicationQuery);
setMyApplicationsState({ data, error: "", loading: false });
} catch (err) {
setMyApplicationsState((current) => ({
...current,
error: err.message || "加载我的申请列表失败",
loading: false,
}));
}
}, [myApplicationQuery, session?.canCreateApplication]);
// 充值详情的 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 loadApplications = useCallback(async () => {
if (!session?.canAuditApplication) {
return;
}
setApplicationsState((current) => ({ ...current, error: "", loading: true }));
try {
const data = await listFinanceApplications(applicationQuery);
setApplicationsState({ data, error: "", loading: false });
} catch (err) {
setApplicationsState((current) => ({
...current,
error: err.message || "加载申请列表失败",
loading: false,
}));
}
}, [applicationQuery, session?.canAuditApplication]);
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(() => {
loadMyApplications();
}, [loadMyApplications]);
useEffect(() => {
loadRechargeApps();
}, [loadRechargeApps]);
useEffect(() => {
loadRechargeDetails();
}, [loadRechargeDetails]);
useEffect(() => {
loadRechargeSummary();
}, [loadRechargeSummary]);
useEffect(() => {
loadRechargeOverview();
}, [loadRechargeOverview]);
useEffect(() => {
loadRecon();
}, [loadRecon]);
useEffect(() => {
loadRechargeRegions();
}, [loadRechargeRegions]);
useEffect(() => {
loadApplications();
}, [loadApplications]);
useEffect(() => {
loadWithdrawals();
}, [loadWithdrawals]);
useEffect(() => {
loadCoinSellerRechargeOrders();
}, [loadCoinSellerRechargeOrders]);
const operationOptions = useMemo(
() => filterOperationsByPermissions(optionsState.data.operations, session?.permissions || []),
[optionsState.data.operations, session?.permissions],
);
const submitApplication = async (event) => {
event.preventDefault();
if (!session?.canCreateApplication) {
showToast("无发起财务申请权限", "error");
return;
}
const payload = buildApplicationPayload(form);
const validationMessage = validateApplicationPayload(payload);
if (validationMessage) {
showToast(validationMessage, "warning");
return;
}
setSubmitting(true);
try {
const result = await createFinanceApplication(cleanPayload(payload));
setForm({ ...DEFAULT_APPLICATION_FORM, appCode: payload.appCode });
setCreateDialogOpen(false);
showToast(
result?.dingTalkNotified === false ? "申请已提交,钉钉通知未确认" : "申请已提交并通知财务",
"success",
);
await loadMyApplications();
if (session.canAuditApplication) {
await loadApplications();
}
} catch (err) {
showToast(err.message || "发起申请失败", "error");
} finally {
setSubmitting(false);
}
};
const submitAudit = async ({ decision, remark }) => {
if (!auditTarget?.id || !session?.canAuditApplication) {
return;
}
setAuditLoading(true);
try {
const submitter = decision === "approved" ? approveFinanceApplication : rejectFinanceApplication;
await submitter(auditTarget.id, { auditRemark: remark });
showToast(decision === "approved" ? "申请已通过" : "申请已拒绝", "success");
setAuditTarget(null);
await loadApplications();
} catch (err) {
showToast(err.message || "审核失败", "error");
} finally {
setAuditLoading(false);
}
};
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 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 updateFilters = (nextFilters) => {
setFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
};
const updateRechargeDetailFilters = (nextFilters) => {
setRechargeDetailFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
};
const updateMyFilters = (nextFilters) => {
setMyFilters((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 }));
};
const closeCreateDialog = (_, reason) => {
if (submitting || reason === "backdropClick") {
return;
}
setCreateDialogOpen(false);
};
if (sessionState.loading) {
return <StateScreen title="正在进入财务系统" />;
}
if (sessionState.error) {
return <StateScreen actionHref="/login" 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 = FINANCE_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: "" })}
>
{app.appName || app.appCode}
</button>
))}
</div>
) : null;
return (
<FinanceShell
activeView={activeView}
appBar={appBar}
canAudit={session.canAuditApplication}
canCreate={session.canCreateApplication}
canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders}
canViewRechargeDetails={session.canViewAppRechargeDetails}
canViewWithdrawals={session.canViewWithdrawalApplications}
loading={
optionsState.loading ||
rechargeDetailsState.loading ||
myApplicationsState.loading ||
applicationsState.loading ||
withdrawalsState.loading ||
coinSellerRechargeOrdersState.loading
}
session={session}
toolbar={financeToolbar}
onViewChange={setActiveView}
>
{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: "" });
setActiveView("rechargeDetails");
}}
onOpenApp={(appCode) => {
updateRechargeDetailFilters({ appCode, regionId: "" });
setActiveView("rechargeDetails");
}}
onOpenWithdrawals={() => setActiveView("withdrawals")}
/>
) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? (
<FinanceCoinSellerRechargeOrderList
actionLoading={coinSellerRechargeOrderActionLoading}
apps={rechargeApps}
canCreate={session.canCreateCoinSellerRechargeOrder}
canGrant={session.canGrantCoinSellerRechargeOrder}
canVerify={session.canVerifyCoinSellerRechargeOrder}
error={coinSellerRechargeOrdersState.error}
filters={coinSellerRechargeOrderFilters}
loading={coinSellerRechargeOrdersState.loading}
orders={coinSellerRechargeOrdersState.data}
onCreate={submitCoinSellerRechargeOrder}
onFiltersChange={updateCoinSellerRechargeOrderFilters}
onGrant={grantCoinSellerRechargeOrder}
onReload={loadCoinSellerRechargeOrders}
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 === "applications" && session.canAuditApplication ? (
<FinanceApplicationList
applications={applicationsState.data}
error={applicationsState.error}
filters={filters}
loading={applicationsState.loading}
operations={optionsState.data.operations}
options={optionsState.data}
onAudit={setAuditTarget}
onFiltersChange={updateFilters}
onReload={loadApplications}
/>
) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? (
<FinanceWithdrawalApplicationList
applications={withdrawalsState.data}
canAudit={session.canAuditWithdrawalApplications}
error={withdrawalsState.error}
filters={withdrawalFilters}
loading={withdrawalsState.loading}
options={optionsState.data}
onAudit={setWithdrawalAuditTarget}
onFiltersChange={updateWithdrawalFilters}
onReload={loadWithdrawals}
/>
) : (
<FinanceMyApplicationList
applications={myApplicationsState.data}
error={myApplicationsState.error}
filters={myFilters}
loading={myApplicationsState.loading}
operations={optionsState.data.operations}
options={optionsState.data}
onCreate={() => setCreateDialogOpen(true)}
onFiltersChange={updateMyFilters}
onReload={loadMyApplications}
/>
)}
<Dialog
fullWidth
maxWidth="md"
open={createDialogOpen}
slotProps={{ paper: { className: "finance-create-dialog-paper" } }}
onClose={closeCreateDialog}
>
<DialogTitle>发起申请</DialogTitle>
<DialogContent className="finance-create-dialog">
<FinanceApplicationForm
apps={optionsState.data.apps}
disabled={!session.canCreateApplication}
error={optionsState.error}
form={form}
loading={optionsState.loading || submitting}
operations={operationOptions}
walletIdentities={optionsState.data.walletIdentities}
onCancel={closeCreateDialog}
onChange={setForm}
onSubmit={submitApplication}
/>
</DialogContent>
</Dialog>
<FinanceAuditDialog
application={auditTarget}
loading={auditLoading}
operations={optionsState.data.operations}
open={Boolean(auditTarget)}
onClose={() => setAuditTarget(null)}
onSubmit={submitAudit}
/>
<FinanceAuditDialog
application={withdrawalAuditTarget}
loading={withdrawalAuditLoading}
operations={optionsState.data.operations}
open={Boolean(withdrawalAuditTarget)}
requireApprovalImage
requireRejectRemark
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 !== ""));
}