diff --git a/finance/src/FinanceApp.jsx b/finance/src/FinanceApp.jsx
index 1ac8e92..b10f6ea 100644
--- a/finance/src/FinanceApp.jsx
+++ b/finance/src/FinanceApp.jsx
@@ -3,24 +3,24 @@ import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import {
- approveFinanceApplication,
- approveFinanceWithdrawalApplication,
- createFinanceApplication,
- exportFinanceRechargeBills,
- fetchFinanceApplicationOptions,
- fetchFinanceRechargeApps,
- fetchFinanceRechargeOverview,
- fetchFinanceRechargeSummaries,
- fetchFinanceSession,
- filterOperationsByPermissions,
- listFinanceApplications,
- listFinanceRechargeDetails,
- listFinanceRechargeRegions,
- listMyFinanceApplications,
- listFinanceWithdrawalApplications,
- refreshFinanceGoogleRechargePaid,
- rejectFinanceApplication,
- rejectFinanceWithdrawalApplication
+ approveFinanceApplication,
+ approveFinanceWithdrawalApplication,
+ createFinanceApplication,
+ exportFinanceRechargeBills,
+ fetchFinanceApplicationOptions,
+ fetchFinanceRechargeApps,
+ fetchFinanceRechargeOverview,
+ fetchFinanceRechargeSummaries,
+ fetchFinanceSession,
+ filterOperationsByPermissions,
+ listFinanceApplications,
+ listFinanceRechargeDetails,
+ listFinanceRechargeRegions,
+ listMyFinanceApplications,
+ listFinanceWithdrawalApplications,
+ refreshFinanceGoogleRechargePaid,
+ rejectFinanceApplication,
+ rejectFinanceWithdrawalApplication,
} from "./api.js";
import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx";
import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx";
@@ -33,10 +33,10 @@ 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
+ EMPTY_RECHARGE_OVERVIEW,
+ EMPTY_RECHARGE_SUMMARY,
+ buildApplicationPayload,
+ validateApplicationPayload,
} from "./format.js";
import { downloadCsv } from "@/shared/api/download";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
@@ -49,736 +49,810 @@ 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;
+ 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: "" } }
- ];
+ 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
- };
+ 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({ appCode: "", items: [], 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 [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 [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);
+ 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 [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 [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;
+ useEffect(() => {
+ let mounted = true;
+ fetchFinanceSession()
+ .then((session) => {
+ if (!mounted) {
+ return;
+ }
+ setSessionState({ error: "", loading: false, session });
+ setActiveView(
+ session.canViewAppRechargeDetails
+ ? "overview"
+ : 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 loadOptions = useCallback(async () => {
+ if (!canLoadOptions) {
+ return;
}
- setSessionState({ error: "", loading: false, session });
- setActiveView(
- session.canViewAppRechargeDetails
- ? "overview"
- : session.canAuditApplication
- ? "applications"
- : session.canCreateApplication
- ? "create"
- : "withdrawals"
- );
- })
- .catch((err) => {
- if (mounted) {
- setSessionState({ error: err.message || "登录已失效", loading: false, session: null });
+ 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]);
+
+ 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]);
+
+ 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);
}
- });
- 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 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
+ 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);
+ }
};
- 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;
- const loadRechargeRegions = useCallback(async () => {
- if (!canViewFinance || !rechargeAppCode) {
- setRechargeRegionsState({ appCode: "", items: [], loading: false });
- return;
- }
- setRechargeRegionsState((current) => ({ ...current, appCode: rechargeAppCode, loading: true }));
- try {
- const items = await listFinanceRechargeRegions(rechargeAppCode);
- setRechargeRegionsState({ appCode: rechargeAppCode, items, loading: false });
- } catch {
- setRechargeRegionsState({ appCode: rechargeAppCode, items: [], loading: false });
- }
- }, [canViewFinance, rechargeAppCode]);
+ 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 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 updateFilters = (nextFilters) => {
+ setFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
+ };
- 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 updateRechargeDetailFilters = (nextFilters) => {
+ setRechargeDetailFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
+ };
- 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 updateMyFilters = (nextFilters) => {
+ setMyFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
+ };
- 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 updateWithdrawalFilters = (nextFilters) => {
+ setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
+ };
- useEffect(() => {
- loadOptions();
- }, [loadOptions]);
+ const closeCreateDialog = (_, reason) => {
+ if (submitting || reason === "backdropClick") {
+ return;
+ }
+ setCreateDialogOpen(false);
+ };
- 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]);
-
- 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;
+ if (sessionState.loading) {
+ return