From f6822d24113d2b276660f0e0a91905907b1b996a Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 3 Jul 2026 17:34:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E8=B4=A2=E5=8A=A1=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 63 ++ finance/src/FinanceApp.jsx | 279 ++++++- finance/src/api.js | 41 + finance/src/components/FinanceOverview.jsx | 372 ++++++++++ .../components/FinanceRechargeDetailList.jsx | 644 ++++++++-------- .../src/components/FinanceReconciliation.jsx | 152 ++++ finance/src/components/FinanceShell.jsx | 163 ++-- finance/src/format.js | 85 +++ finance/src/styles/index.css | 698 +++++++++++++++++- src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 36 + 11 files changed, 2134 insertions(+), 407 deletions(-) create mode 100644 finance/src/components/FinanceOverview.jsx create mode 100644 finance/src/components/FinanceReconciliation.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index f2aac4e..27bbddd 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2825,6 +2825,69 @@ "x-permissions": ["payment-bill:view"] } }, + "/admin/payment/recharge-bills/overview": { + "get": { + "operationId": "getRechargeBillOverview", + "parameters": [ + { + "in": "query", + "name": "keyword", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "recharge_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "region_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "start_at_ms", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "end_at_ms", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "tz_offset_minutes", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "payment-bill:view", + "x-permissions": ["payment-bill:view"] + } + }, "/admin/payment/recharge-bills/summary": { "get": { "operationId": "getRechargeBillSummary", diff --git a/finance/src/FinanceApp.jsx b/finance/src/FinanceApp.jsx index 6cdeb0e..1ac8e92 100644 --- a/finance/src/FinanceApp.jsx +++ b/finance/src/FinanceApp.jsx @@ -9,7 +9,8 @@ import { exportFinanceRechargeBills, fetchFinanceApplicationOptions, fetchFinanceRechargeApps, - fetchFinanceRechargeSummary, + fetchFinanceRechargeOverview, + fetchFinanceRechargeSummaries, fetchFinanceSession, filterOperationsByPermissions, listFinanceApplications, @@ -25,31 +26,87 @@ import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx" import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx"; import { FinanceAuditDialog } from "./components/FinanceAuditDialog.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_SUMMARY, buildApplicationPayload, validateApplicationPayload } from "./format.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({ data: EMPTY_RECHARGE_SUMMARY, 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: "", keyword: "", page: 1, rechargeType: "", regionId: "", timeRange: { endMs: "", startMs: "" } }); + 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" }); @@ -72,7 +129,7 @@ export function FinanceApp() { setSessionState({ error: "", loading: false, session }); setActiveView( session.canViewAppRechargeDetails - ? "rechargeDetails" + ? "overview" : session.canAuditApplication ? "applications" : session.canCreateApplication @@ -92,13 +149,14 @@ export function FinanceApp() { const session = sessionState.session; const canLoadOptions = Boolean(session?.canAccessFinance); - const rechargeDetailQuery = useMemo( + const canViewFinance = Boolean(session?.canViewAppRechargeDetails); + + // 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。 + const rechargeBaseQuery = useMemo( () => ({ app_code: rechargeDetailFilters.appCode, end_at_ms: rechargeDetailFilters.timeRange.endMs, keyword: rechargeDetailFilters.keyword, - page: rechargeDetailFilters.page, - page_size: pageSize, recharge_type: rechargeDetailFilters.rechargeType, region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "", start_at_ms: rechargeDetailFilters.timeRange.startMs, @@ -107,6 +165,15 @@ export function FinanceApp() { }), [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, @@ -165,14 +232,14 @@ export function FinanceApp() { } }, [myApplicationQuery, session?.canCreateApplication]); - // 充值详情的 App 目录来自 recharge-apps(含 Yumi 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。 + // 充值详情的 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 (!session?.canViewAppRechargeDetails) { + if (!canViewFinance) { return; } try { @@ -181,10 +248,10 @@ export function FinanceApp() { } catch { setRechargeAppsState({ items: [], loaded: false }); } - }, [session?.canViewAppRechargeDetails]); + }, [canViewFinance]); const loadRechargeDetails = useCallback(async () => { - if (!session?.canViewAppRechargeDetails) { + if (!canViewFinance) { return; } setRechargeDetailsState((current) => ({ ...current, error: "", loading: true })); @@ -192,26 +259,71 @@ export function FinanceApp() { const data = await listFinanceRechargeDetails(rechargeDetailQuery, rechargeApps); setRechargeDetailsState({ data, error: "", loading: false }); } catch (err) { - setRechargeDetailsState((current) => ({ ...current, error: err.message || "加载 APP 充值详情失败", loading: false })); + setRechargeDetailsState((current) => ({ ...current, error: err.message || "加载充值流水失败", loading: false })); } - }, [rechargeApps, rechargeDetailQuery, session?.canViewAppRechargeDetails]); + }, [canViewFinance, rechargeApps, rechargeDetailQuery]); const loadRechargeSummary = useCallback(async () => { - if (!session?.canViewAppRechargeDetails) { + if (!canViewFinance) { return; } - setRechargeSummaryState((current) => ({ ...current, error: "", loading: true })); + setRechargeSummaryState((current) => ({ ...current, loading: true })); try { - const data = await fetchFinanceRechargeSummary(rechargeDetailQuery, rechargeApps); - setRechargeSummaryState({ data, error: "", loading: false }); - } catch (err) { - setRechargeSummaryState((current) => ({ ...current, error: err.message || "加载充值汇总失败", loading: false })); + 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 })); } - }, [rechargeApps, rechargeDetailQuery, session?.canViewAppRechargeDetails]); + }, [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; const loadRechargeRegions = useCallback(async () => { - if (!session?.canViewAppRechargeDetails || !rechargeAppCode) { + if (!canViewFinance || !rechargeAppCode) { setRechargeRegionsState({ appCode: "", items: [], loading: false }); return; } @@ -222,7 +334,7 @@ export function FinanceApp() { } catch { setRechargeRegionsState({ appCode: rechargeAppCode, items: [], loading: false }); } - }, [rechargeAppCode, session?.canViewAppRechargeDetails]); + }, [canViewFinance, rechargeAppCode]); const refreshGooglePaid = useCallback( async (transactionIds) => { @@ -238,14 +350,14 @@ export function FinanceApp() { failed ? `谷歌实付同步完成:成功 ${succeeded} 笔,失败 ${failed} 笔` : `谷歌实付同步完成:${succeeded} 笔`, failed ? "warning" : "success" ); - await loadRechargeDetails(); + await Promise.all([loadRechargeDetails(), loadRechargeOverview()]); } catch (err) { showToast(err.message || "查询谷歌实付失败", "error"); } finally { setGooglePaidRefreshing(false); } }, - [googlePaidRefreshing, loadRechargeDetails, rechargeAppCode, showToast] + [googlePaidRefreshing, loadRechargeDetails, loadRechargeOverview, rechargeAppCode, showToast] ); const exportRechargeBills = useCallback(async () => { @@ -315,6 +427,14 @@ export function FinanceApp() { loadRechargeSummary(); }, [loadRechargeSummary]); + useEffect(() => { + loadRechargeOverview(); + }, [loadRechargeOverview]); + + useEffect(() => { + loadRecon(); + }, [loadRecon]); + useEffect(() => { loadRechargeRegions(); }, [loadRechargeRegions]); @@ -434,6 +554,74 @@ export function FinanceApp() { return ; } + const presets = timePresets(); + const activePreset = presets.find( + (preset) => + String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") && + String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || "") + ); + const isFinanceView = FINANCE_VIEWS.has(activeView); + const financeToolbar = + isFinanceView && session.canViewAppRechargeDetails ? ( + <> +
+ {presets.map((preset) => ( + + ))} +
+ updateRechargeDetailFilters({ timeRange })} + /> +
+ {[ + ["usd", "USD"], + ["coin", "金币"] + ].map(([value, label]) => ( + + ))} +
+ + ) : null; + + const appBar = + isFinanceView && session.canViewAppRechargeDetails ? ( +
+ + {rechargeApps.map((app) => ( + + ))} +
+ ) : null; + return ( - {activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? ( + {appBar} + {activeView === "overview" && session.canViewAppRechargeDetails ? ( + { + updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" }); + setActiveView("rechargeDetails"); + }} + onOpenApp={(appCode) => { + updateRechargeDetailFilters({ appCode, regionId: "" }); + setActiveView("rechargeDetails"); + }} + /> + ) : activeView === "recon" && session.canViewAppRechargeDetails ? ( + setReconMonthOffset((current) => Math.min(0, current + delta))} + /> + ) : activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? ( { loadRechargeDetails(); loadRechargeSummary(); + loadRechargeOverview(); }} /> ) : activeView === "applications" && session.canAuditApplication ? ( diff --git a/finance/src/api.js b/finance/src/api.js index d1bb44d..5771fc0 100644 --- a/finance/src/api.js +++ b/finance/src/api.js @@ -3,9 +3,11 @@ import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/gen import { apiRequest, getAccessToken } from "@/shared/api/request"; import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js"; import { + mergeRechargeOverviews, mergeRechargeSummaries, normalizeApplicationPage, normalizeRechargeBillPage, + normalizeRechargeOverview, normalizeRechargeRegions, normalizeRechargeSummary, normalizeWithdrawalApplicationPage @@ -137,6 +139,35 @@ export async function listFinanceRechargeRegions(appCode) { return normalizeRechargeRegions(data); } +// fetchFinanceRechargeSummaries 返回逐 App 的汇总(App 对比表用)和全局合并值。 +export async function fetchFinanceRechargeSummaries(query = {}, apps = []) { + const selectedAppCode = stringValue(query.app_code ?? query.appCode); + const cleanQuery = cleanRechargeBillQuery(query); + const appList = selectedAppCode + ? normalizeApps(apps).filter((app) => app.appCode === selectedAppCode) + : normalizeApps(apps); + if (!appList.length && selectedAppCode) { + appList.push({ appCode: selectedAppCode, appName: selectedAppCode }); + } + const summaries = await Promise.all(appList.map((app) => getRechargeSummaryForApp(app.appCode, cleanQuery))); + const perApp = appList.map((app, index) => ({ ...app, summary: summaries[index] })); + return { merged: mergeRechargeSummaries(summaries), perApp }; +} + +export async function fetchFinanceRechargeOverview(query = {}, apps = []) { + const selectedAppCode = stringValue(query.app_code ?? query.appCode); + const cleanQuery = { ...cleanRechargeBillQuery(query), tz_offset_minutes: 480 }; + if (selectedAppCode) { + return getRechargeOverviewForApp(selectedAppCode, cleanQuery); + } + const appCodes = normalizeApps(apps).map((app) => app.appCode); + if (!appCodes.length) { + return normalizeRechargeOverview({}); + } + const overviews = await Promise.all(appCodes.map((appCode) => getRechargeOverviewForApp(appCode, cleanQuery))); + return mergeRechargeOverviews(overviews); +} + export async function exportFinanceRechargeBills(appCode, query = {}) { const endpoint = API_ENDPOINTS.exportRechargeBills; return apiRequest(apiEndpointPath(API_OPERATIONS.exportRechargeBills), { @@ -213,6 +244,16 @@ async function currentSession() { } } +async function getRechargeOverviewForApp(appCode, query = {}) { + const endpoint = API_ENDPOINTS.getRechargeBillOverview; + const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillOverview), { + headers: { [appCodeHeader]: appCode }, + method: endpoint.method, + query + }); + return normalizeRechargeOverview(data); +} + async function getRechargeSummaryForApp(appCode, query = {}) { const endpoint = API_ENDPOINTS.getRechargeBillSummary; const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillSummary), { diff --git a/finance/src/components/FinanceOverview.jsx b/finance/src/components/FinanceOverview.jsx new file mode 100644 index 0000000..8c9765a --- /dev/null +++ b/finance/src/components/FinanceOverview.jsx @@ -0,0 +1,372 @@ +import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js"; + +const KPI_CARDS = [ + { filter: "", key: "total", label: "充值总和", tone: "total" }, + { filter: "google_play_recharge", key: "googlePlay", label: "谷歌充值", tone: "google" }, + { filter: "third_party", key: "thirdParty", label: "三方充值", tone: "thirdparty" }, + { filter: "coin_seller", key: "coinSeller", label: "币商充值", tone: "coinseller" }, +]; + +const DAILY_FIELDS = { + coinSeller: { coin: "coinSellerCoinAmount", usd: "coinSellerUsdMinor" }, + googlePlay: { coin: "googleCoinAmount", usd: "googleUsdMinor" }, + thirdParty: { coin: "thirdPartyCoinAmount", usd: "thirdPartyUsdMinor" }, +}; + +export function FinanceOverview({ + currency, + filters, + loading, + onFiltersChange, + onGoUnsynced, + onOpenApp, + overview, + perAppSummaries, + prevSummary, + regions, + summary, +}) { + const data = summary || EMPTY_RECHARGE_SUMMARY; + const trend = overview || EMPTY_RECHARGE_OVERVIEW; + const showCoins = currency === "coin"; + const totalUsd = Number(data.total?.usdMinorAmount) || 0; + const unsynced = trend.googlePaid.unsyncedCount; + const selectedApp = filters.appCode; + + return ( +
+ {unsynced > 0 ? ( +
+ + ⚠ {formatAmount(unsynced)} 笔谷歌订单实付未同步,手续费口径尚不完整 + + +
+ ) : null} + +
+ {KPI_CARDS.map(({ filter, key, label, tone }) => { + const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 }; + const active = (filters.rechargeType || "") === filter; + return ( + + ); + })} +
+ +
+
+
+ + 充值趋势按渠道堆叠 · 日 · {showCoins ? "金币" : "USD"} + +
+ + + 谷歌 + + + + 三方 + + + + 币商 + +
+
+
+ +
+
+ +
+
+ + 净入账拆解谷歌已同步口径 + +
+
+ +
+
+ {selectedApp ? "区域分布 Top 5" : "区域分布"} +
+ {selectedApp ? ( + + ) : ( +

选择单个 App 后查看区域分布

+ )} +
+
+
+ +
+
+ + App 对比点击行进入该 App 流水 + +
+
+ + + + + + + + + + + + + + {(perAppSummaries || []).map((app) => { + const appTotal = Number(app.summary?.total?.usdMinorAmount) || 0; + const maxTotal = Math.max( + 1, + ...(perAppSummaries || []).map( + (item) => Number(item.summary?.total?.usdMinorAmount) || 0, + ), + ); + return ( + onOpenApp(app.appCode)} + > + + + + + + + + + ); + })} + {!perAppSummaries?.length ? ( + + + + ) : null} + +
App充值流水笔数谷歌三方币商占比
+ {app.appName || app.appCode} + {formatUsdMinor(appTotal, "USD")}{formatAmount(app.summary?.total?.billCount || 0)} + {formatUsdMinor(app.summary?.googlePlay?.usdMinorAmount || 0, "USD")} + + {formatUsdMinor(app.summary?.thirdParty?.usdMinorAmount || 0, "USD")} + + {formatUsdMinor(app.summary?.coinSeller?.usdMinorAmount || 0, "USD")} + + + + +
+ 暂无数据 +
+
+
+
+ ); +} + +function kpiMeta(bucket, key, totalUsd, prevSummary) { + const parts = [`${formatAmount(bucket.billCount)} 笔`]; + if (key !== "total" && totalUsd > 0) { + const share = ((Number(bucket.usdMinorAmount) || 0) / totalUsd) * 100; + parts.push(`占比 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`); + } + const prev = prevSummary?.[key]; + if (prev && Number(prev.usdMinorAmount) > 0) { + const delta = + ((Number(bucket.usdMinorAmount) - Number(prev.usdMinorAmount)) / Number(prev.usdMinorAmount)) * 100; + const arrow = delta >= 0 ? "▲" : "▼"; + parts.push(`${arrow} ${Math.abs(delta).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}% vs 上期`); + } + return parts.join(" · "); +} + +function Sparkline({ daily, field, showCoins, tone }) { + const values = (daily || []).map((bucket) => { + if (field === "total") { + return showCoins + ? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount + : bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor; + } + const fields = DAILY_FIELDS[field]; + return fields ? bucket[showCoins ? fields.coin : fields.usd] : 0; + }); + if (values.length < 2) { + return ; + } + const width = 100; + const height = 28; + const max = Math.max(...values); + const min = Math.min(...values); + const points = values.map((value, index) => { + const x = (index * width) / (values.length - 1); + const y = height - 3 - ((value - min) / (max - min || 1)) * (height - 8); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const colors = { + coinseller: "#B45309", + google: "#1B873F", + thirdparty: "#0E7490", + total: "#2557D6", + }; + const color = colors[tone] || colors.total; + return ( + + + + + ); +} + +function TrendChart({ daily, showCoins }) { + const buckets = daily || []; + if (!buckets.length) { + return

当前时间范围内没有充值数据

; + } + const totals = buckets.map((bucket) => + showCoins + ? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount + : bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor, + ); + const max = Math.max(1, ...totals); + const value = (bucket, channel) => { + const fields = DAILY_FIELDS[channel]; + return bucket[showCoins ? fields.coin : fields.usd]; + }; + return ( +
+
+ {buckets.map((bucket) => ( +
+ {["googlePlay", "thirdParty", "coinSeller"].map((channel) => { + const amount = value(bucket, channel); + if (amount <= 0) { + return null; + } + return ( + + ); + })} +
+ ))} +
+
+ {buckets[0].date.slice(5)} + {buckets.length > 2 ? {buckets[Math.floor(buckets.length / 2)].date.slice(5)} : null} + {buckets[buckets.length - 1].date.slice(5)} +
+
+ ); +} + +function trendTip(bucket, channel, showCoins) { + const fields = DAILY_FIELDS[channel]; + const amount = bucket[showCoins ? fields.coin : fields.usd]; + return showCoins ? formatAmount(amount) : formatUsdMinor(amount, "USD"); +} + +function DeductionBreakdown({ paid }) { + const covered = paid.coveredUsdMinor; + if (covered <= 0) { + return ( +

+ 暂无已同步的谷歌实付明细;点「查询谷歌实付」同步后这里会展示手续费与税费拆解。 +

+ ); + } + const rows = [ + { amount: paid.estNetUsdMinor, color: "var(--fin-pos)", label: "净入账(估)" }, + { amount: paid.estFeeUsdMinor, color: "#E19B3C", label: "谷歌服务费(估)" }, + { amount: paid.estTaxUsdMinor, color: "#C86A6A", label: "代缴税费(估)" }, + ]; + return ( +
+
+ {rows.map((row) => ( + + ))} +
+ {rows.map((row) => ( +
+ + {row.label} + {formatUsdMinor(row.amount, "USD")} + + {((row.amount / covered) * 100).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}% + +
+ ))} +

+ 口径:已同步 {formatAmount(paid.syncedCount)} / {formatAmount(paid.googleBillCount)}{" "} + 笔谷歌订单,覆盖流水 {formatUsdMinor(covered, "USD")};按各订单实付占比折算成 USD +

+
+ ); +} + +function RegionRanks({ regions }) { + const top = (regions || []).slice(0, 5); + if (!top.length) { + return

当前筛选下暂无区域数据

; + } + const max = Math.max(1, ...top.map((region) => region.usdMinorAmount)); + return ( +
+ {top.map((region, index) => ( +
+ {index + 1} + {region.name || `区域 ${region.regionId}`} + + + + {formatUsdMinor(region.usdMinorAmount, "USD")} +
+ ))} +
+ ); +} diff --git a/finance/src/components/FinanceRechargeDetailList.jsx b/finance/src/components/FinanceRechargeDetailList.jsx index 8558949..06b44f3 100644 --- a/finance/src/components/FinanceRechargeDetailList.jsx +++ b/finance/src/components/FinanceRechargeDetailList.jsx @@ -3,18 +3,9 @@ import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import Button from "@mui/material/Button"; import MenuItem from "@mui/material/MenuItem"; -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; -import { useState } from "react"; -import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; -import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import { Fragment, useState } from "react"; import { - EMPTY_RECHARGE_SUMMARY, formatAmount, formatMicroMoney, formatTime, @@ -23,22 +14,19 @@ import { rechargeTypeLabel, } from "../format.js"; -const SOURCE_FILTERS = [ - { label: "全部来源", value: "" }, - { label: "谷歌充值", value: "google_play_recharge" }, - { label: "三方充值", value: "third_party" }, - { label: "币商充值", value: "coin_seller" }, -]; - -const KPI_CARDS = [ - { filter: "", key: "total", label: "充值总和", tone: "total" }, - { filter: "google_play_recharge", key: "googlePlay", label: "谷歌充值", tone: "google" }, - { filter: "third_party", key: "thirdParty", label: "三方充值", tone: "thirdparty" }, - { filter: "coin_seller", key: "coinSeller", label: "币商充值", tone: "coinseller" }, +// 系统保存视图:财务高频筛选组合一键切换;与全局 rechargeType/paidState 联动。 +const SAVED_VIEWS = [ + { key: "all", label: "全部", paidState: "", rechargeType: "" }, + { key: "google", label: "谷歌充值", paidState: "", rechargeType: "google_play_recharge" }, + { key: "third", label: "三方充值", paidState: "", rechargeType: "third_party" }, + { key: "seller", label: "币商充值", paidState: "", rechargeType: "coin_seller" }, + { key: "unsynced", label: "实付未同步", paidState: "unsynced", rechargeType: "" }, ]; export function FinanceRechargeDetailList({ + apps, bills, + currency, error, exporting, filters, @@ -48,227 +36,234 @@ export function FinanceRechargeDetailList({ onFiltersChange, onRefreshGooglePaid, onReload, - options, regions, summary, - summaryLoading, }) { - const [showCoinColumns, setShowCoinColumns] = useState(false); + const [expandedId, setExpandedId] = useState(""); const items = bills.items || []; const page = Number(bills.page || filters.page || 1); const pageSize = Number(bills.pageSize || 50); const total = Number(bills.total || 0); const hasNextPage = page * pageSize < total; - const regionOptions = regions || []; - const visibleColumnCount = showCoinColumns ? 9 : 7; + const showCoins = currency === "coin"; + const regionNames = new Map( + (regions || []).map((region) => [String(region.regionId), region.name || region.regionCode]), + ); + const activeView = + SAVED_VIEWS.find( + (view) => + view.rechargeType === (filters.rechargeType || "") && view.paidState === (filters.paidState || ""), + )?.key || "all"; const pendingGoogleTxIds = items .filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs) .map((item) => item.transactionId) .filter(Boolean); + const columnCount = showCoins ? 9 : 8; + + const chips = [ + filters.appCode + ? { key: "appCode", label: `App:${appName(filters.appCode, apps)}`, reset: { appCode: "", regionId: "" } } + : null, + filters.regionId + ? { + key: "regionId", + label: `区域:${regionNames.get(String(filters.regionId)) || filters.regionId}`, + reset: { regionId: "" }, + } + : null, + filters.timeRange?.startMs || filters.timeRange?.endMs + ? { + key: "time", + label: `时间:${rangeLabel(filters.timeRange)}`, + reset: { timeRange: { endMs: "", startMs: "" } }, + } + : null, + filters.keyword ? { key: "keyword", label: `搜索:${filters.keyword}`, reset: { keyword: "" } } : null, + ].filter(Boolean); return ( -
- onFiltersChange({ rechargeType: value })} - /> -
+
+
+ {SAVED_VIEWS.map((view) => ( + + ))} +
+ +
+ {chips.map((chip) => ( + + ))} onFiltersChange({ appCode: event.target.value, regionId: "" })} - > - 全部 APP - {options.apps.map((app) => ( - - {app.appName || app.appCode} - - ))} - - onFiltersChange({ regionId: event.target.value })} > 全部区域 - {regionOptions.map((region) => ( + {(regions || []).map((region) => ( {region.name || region.regionCode || region.regionId} ))} onFiltersChange({ rechargeType: event.target.value })} - > - {SOURCE_FILTERS.map((source) => ( - - {source.label} - - ))} - - onFiltersChange({ timeRange })} - /> - onFiltersChange({ keyword: event.target.value })} /> + + + +
-
-
- {timePresets().map((preset) => ( - - ))} -
-
- - - - -
-
+ {error ?
{error}
: null} {loading && !items.length ?
: null} {!loading || items.length ? ( - - - - - 充值时间 - APP - 充值来源 - 充值订单号 - {showCoinColumns ? 充值金币 : null} - {showCoinColumns ? 金币换算 : null} - 账单金额 - 用户实付 - 三方税率扣款 - - - +
+
+ + + + + + + {showCoins ? : null} + + + + + + + {items.length ? ( - items.map((item) => ( - - - {formatTime(item.createdAtMs)} - - {appName(item, options.apps)} - - - - - - - {showCoinColumns ? ( - - - {coinText(item.coinAmount)} - - - ) : null} - {showCoinColumns ? {exchangeText(item)} : null} - - {billAmountText(item)} - - - - {userPaidText(item)} - - - {taxDeductionText(item)} - - )) + items.map((item) => { + const rowId = item.id || item.transactionId; + const expanded = expandedId === rowId; + return ( + + setExpandedId(expanded ? "" : rowId)} + > + + + + + {showCoins ? ( + + ) : null} + + + + + + {expanded ? ( + + + + ) : null} + + ); + }) ) : ( - - + + + )} - -
充值时间APP充值来源充值订单号充值金币账单金额用户实付三方税率扣款区域
{formatTime(item.createdAtMs)}{appName(item.appCode, apps)} + + {rechargeTypeLabel(item.rechargeType)} + + + + {item.transactionId || "-"} + {secondaryOrderNo(item)} + + + + {coinText(item.coinAmount)} + + + + {billAmountText(item)} + + + + {userPaidText(item)} + + {taxDeductionText(item)}{regionText(item, regionNames)}
+ +
当前筛选条件下没有账单 - - +
-
+ + +
) : null} +
- - 共 {formatAmount(total)} 笔 · 第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页 + + 共 {formatAmount(total)} 笔 + {summary ? ` · 筛选合计 ${formatUsdMinor(summary.total?.usdMinorAmount || 0, "USD")}` : ""} · 第{" "} + {page} / {Math.max(1, Math.ceil(total / pageSize))} 页
- ); - })} +
+
+
+ 金额拆解{paid ? ` · 实付币种 ${currencyCode}` : ""} +
+ {paid && (fee || tax || net) ? ( +
+ + {tax ? ( + + ) : null} + {fee ? ( + + ) : null} + {net ? ( + + ) : null} +
+ ) : unsynced ? ( +

+ 实付明细未同步。 + +

+ ) : ( +

+ {paid ? `用户实付 ${formatMicroMoney(paid, currencyCode)} · ` : ""} + 该渠道未提供扣费明细 +

+ )} +
+
+
交易号
+
{item.transactionId || "-"}
+ {item.commandId ? ( + <> +
业务单号
+
{item.commandId}
+ + ) : null} + {item.externalRef ? ( + <> +
外部订单
+
{item.externalRef}
+ + ) : null} + {item.user?.username || item.user?.userId ? ( + <> +
充值用户
+
+ {item.user.username || "-"} + {item.user.userId ? ` · ID ${item.user.userId}` : ""} +
+ + ) : null} + {item.sellerUserId ? ( + <> +
币商 ID
+
{item.sellerUserId}
+ + ) : null} +
美金换算
+
{formatUsdMinor(item.usdMinorAmount, "USD")}
+ {item.paidSyncedAtMs ? ( + <> +
实付同步于
+
{formatTime(item.paidSyncedAtMs)}
+ + ) : null} +
); } -function kpiMetaText(bucket, key, totalUsd, showCoins) { - const parts = [`${formatAmount(bucket.billCount)} 笔`]; - if (key !== "total" && totalUsd > 0) { - const share = ((Number(bucket.usdMinorAmount) || 0) / totalUsd) * 100; - parts.push(`占比 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`); - } - if (showCoins) { - parts.push(`${formatAmount(bucket.coinAmount)} 金币`); - } - return parts.join(" · "); -} - -function SourceBadge({ rechargeType }) { +function DetailWfallRow({ amount, color, currency, label, max }) { + const negative = amount < 0; + const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0; return ( - - {rechargeTypeLabel(rechargeType)} - +
+ {label} + + + + + {negative ? `− ${formatMicroMoney(-amount, currency)}` : formatMicroMoney(amount, currency)} + +
); } @@ -350,46 +427,30 @@ function AmountText({ children, value }) { return {children}; } -const DAY_MS = 86_400_000; -const CHINA_OFFSET_MS = 8 * 3_600_000; - -function chinaDayStart(timestamp) { - return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS; +function badgeTone(rechargeType) { + return rechargeSourceTone(rechargeType) === "google" + ? "google" + : rechargeSourceTone(rechargeType) === "coinseller" + ? "coinseller" + : "thirdparty"; } -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 [ - { label: "不限", range: { endMs: "", startMs: "" } }, - { label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } }, - { label: "昨天", range: { endMs: todayStart, startMs: todayStart - DAY_MS } }, - { label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } }, - { label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } }, - ]; +function rangeLabel(timeRange) { + const start = timeRange?.startMs ? formatTime(timeRange.startMs) : "…"; + const end = timeRange?.endMs ? formatTime(timeRange.endMs) : "…"; + return `${start} ~ ${end}`; } -function isPresetActive(timeRange, preset) { - const current = timeRange || { endMs: "", startMs: "" }; - return ( - String(current.startMs || "") === String(preset.range.startMs || "") && - String(current.endMs || "") === String(preset.range.endMs || "") - ); +function appName(appCode, apps) { + return (apps || []).find((app) => app.appCode === appCode)?.appName || appCode || "-"; } -function Stacked({ primary, secondary }) { - return ( - - {primary || "-"} - {secondary || ""} - - ); -} - -function appName(item, apps) { - return item.appName || apps.find((app) => app.appCode === item.appCode)?.appName || item.appCode || "-"; +function regionText(item, regionNames) { + const regionId = item.targetRegionId || item.sellerRegionId; + if (!regionId) { + return "—"; + } + return regionNames.get(String(regionId)) || `区域 ${regionId}`; } function secondaryOrderNo(item) { @@ -401,13 +462,6 @@ function coinText(value) { return text === "-" ? "-" : `${text} 金币`; } -function exchangeText(item) { - if (hasNumber(item.exchangeCoinAmount) || hasNumber(item.exchangeUsdMinorAmount)) { - return `${coinText(item.exchangeCoinAmount)} = ${formatUsdMinor(item.exchangeUsdMinorAmount, "USD")}`; - } - return formatUsdMinor(item.usdMinorAmount, "USD"); -} - function billAmountText(item) { if (item.billAmountText) { return item.billAmountText; @@ -428,7 +482,7 @@ function userPaidText(item) { return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode); } if (isGoogleBill(item) && !item.paidSyncedAtMs) { - return "未同步(点“查询谷歌实付”)"; + return "未同步"; } if (hasPositiveNumber(item.userPaidAmountMinor)) { return formatUsdMinor(item.userPaidAmountMinor, item.userPaidCurrencyCode || item.currencyCode || "USD"); @@ -447,12 +501,7 @@ function taxDeductionText(item) { const percent = (deduction / item.userPaidAmountMicro) * 100; parts.push(`${percent.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`); } - return ( - - {parts.join(" / ")} - {deductionBreakdown(fee, tax, currency)} - - ); + return parts.join(" / "); } if (item.taxDeductionText) { return item.taxDeductionText; @@ -463,17 +512,6 @@ function taxDeductionText(item) { return "-"; } -function deductionBreakdown(fee, tax, currency) { - const parts = []; - if (fee > 0) { - parts.push(`手续费 ${formatMicroMoney(fee, currency)}`); - } - if (tax > 0) { - parts.push(`税费 ${formatMicroMoney(tax, currency)}`); - } - return parts.join(" + "); -} - function isGoogleBill(item) { return item.rechargeType === "google_play_recharge" || item.providerCode === "google_play"; } diff --git a/finance/src/components/FinanceReconciliation.jsx b/finance/src/components/FinanceReconciliation.jsx new file mode 100644 index 0000000..d7d2094 --- /dev/null +++ b/finance/src/components/FinanceReconciliation.jsx @@ -0,0 +1,152 @@ +import Button from "@mui/material/Button"; +import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js"; + +// 渠道对账页:按结算月核对各渠道的流水与扣费。谷歌口径来自 Orders API 同步的实付明细; +// 三方与币商没有渠道侧扣费数据时如实标注,不做拍脑袋估算。 +export function FinanceReconciliation({ loading, month, onMonthChange, overview, summary }) { + const data = summary || EMPTY_RECHARGE_SUMMARY; + const paid = (overview || EMPTY_RECHARGE_OVERVIEW).googlePaid; + const totalUsd = Number(data.total?.usdMinorAmount) || 0; + const feeUsd = paid.estFeeUsdMinor; + const taxUsd = paid.estTaxUsdMinor; + const netUsd = totalUsd - feeUsd - taxUsd; + + return ( +
+
+ + {month.label} 结算期 + + + 对账口径:平台流水 × 谷歌 Orders API 实付 · 中国时区 + +
+ +
+
+ + 结算瀑布全渠道 · USD{paid.unsyncedCount > 0 ? " · 谷歌扣费为已同步部分估算" : ""} + +
+
+ {loading ? ( +

统计中…

+ ) : ( +
+ + + + +
+ )} +
+
+ +
+
+
+ 谷歌 + {paid.unsyncedCount > 0 ? ( + + + {formatAmount(paid.unsyncedCount)} 笔待同步 + + ) : ( + + + 实付已全量同步 + + )} +
+
+ + + + + +

+ 与 Play Console 财务报告按月核对;未同步订单可在流水页批量补数 +

+
+
+ +
+
+ 三方 +
+
+ + + +

+ V5Pay 的 fee/tax 已逐笔展示在流水明细;MiFaPay 仅提供毛额,需用渠道后台月账单人工核对 +

+
+
+ +
+
+ 币商 +
+
+ + + +

+ 打款凭证与 TRC20 交易号逐笔登记在流水明细,可按凭证号搜索核对 +

+
+
+
+
+ ); +} + +function WaterfallRow({ amount, color, label, max }) { + const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0; + const negative = amount < 0; + return ( +
+ {label} + + + + + {negative ? `− ${formatUsdMinor(-amount, "USD")}` : formatUsdMinor(amount, "USD")} + +
+ ); +} + +function ReconLine({ label, negative, total, value }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/finance/src/components/FinanceShell.jsx b/finance/src/components/FinanceShell.jsx index f2e24f4..00c5ad0 100644 --- a/finance/src/components/FinanceShell.jsx +++ b/finance/src/components/FinanceShell.jsx @@ -1,64 +1,117 @@ import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined"; import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined"; +import InsightsOutlined from "@mui/icons-material/InsightsOutlined"; import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined"; import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined"; +import RuleFolderOutlined from "@mui/icons-material/RuleFolderOutlined"; import LinearProgress from "@mui/material/LinearProgress"; -const views = [ - { icon: ReceiptLongOutlined, id: "rechargeDetails", label: "APP充值详情" }, - { icon: AssignmentOutlined, id: "create", label: "发起申请" }, - { icon: FactCheckOutlined, id: "applications", label: "申请审核" }, - { icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" } +const NAV_GROUPS = [ + { + label: "经营", + views: [ + { icon: InsightsOutlined, id: "overview", label: "财务概览" }, + { icon: ReceiptLongOutlined, id: "rechargeDetails", label: "充值流水" }, + { icon: RuleFolderOutlined, id: "recon", label: "渠道对账" }, + ], + }, + { + label: "审批", + views: [ + { icon: AssignmentOutlined, id: "create", label: "发起申请" }, + { icon: FactCheckOutlined, id: "applications", label: "申请审核" }, + { icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" }, + ], + }, ]; -export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeDetails, canViewWithdrawals, children, loading, onViewChange, session }) { - const visibleViews = views.filter((view) => { - if (view.id === "rechargeDetails") { - return canViewRechargeDetails; - } - if (view.id === "applications") { - return canAudit; - } - if (view.id === "withdrawals") { - return canViewWithdrawals; - } - return canCreate; - }); - return ( -
- -
- {loading ? : null} -
{children}
-
-
- ); +const VIEW_TITLES = { + applications: "申请审核", + create: "发起申请", + overview: "财务概览", + recon: "渠道对账", + rechargeDetails: "充值流水", + withdrawals: "用户提现申请", +}; + +export function FinanceShell({ + activeView, + canAudit, + canCreate, + canViewRechargeDetails, + canViewWithdrawals, + children, + loading, + onViewChange, + session, + subtitle, + toolbar, +}) { + const allows = (viewId) => { + if (viewId === "overview" || viewId === "rechargeDetails" || viewId === "recon") { + return canViewRechargeDetails; + } + if (viewId === "applications") { + return canAudit; + } + if (viewId === "withdrawals") { + return canViewWithdrawals; + } + return canCreate; + }; + return ( +
+ +
+ {loading ? : null} +
+
+

{VIEW_TITLES[activeView] || "财务系统"}

+ {subtitle ? {subtitle} : null} +
+ {toolbar ?
{toolbar}
: null} +
+
{children}
+
+
+ ); } diff --git a/finance/src/format.js b/finance/src/format.js index dca5b51..34ee93d 100644 --- a/finance/src/format.js +++ b/finance/src/format.js @@ -145,7 +145,10 @@ export function normalizeRechargeBill(item = {}) { providerNetMicro: numberOrNull(item.providerNetMicro ?? item.provider_net_micro), providerTaxMicro: numberOrNull(item.providerTaxMicro ?? item.provider_tax_micro), rechargeType: stringValue(item.rechargeType ?? item.recharge_type), + sellerRegionId: numberOrNull(item.sellerRegionId ?? item.seller_region_id), + sellerUserId: numberOrNull(item.sellerUserId ?? item.seller_user_id), status: stringValue(item.status), + targetRegionId: numberOrNull(item.targetRegionId ?? item.target_region_id), taxDeductionMinor: numberOrNull( item.taxDeductionMinor ?? item.tax_deduction_minor ?? @@ -161,6 +164,11 @@ export function normalizeRechargeBill(item = {}) { taxRate: numberOrNull(item.taxRate ?? item.tax_rate ?? item.thirdPartyTaxRate ?? item.third_party_tax_rate), transactionId: stringValue(item.transactionId ?? item.transaction_id), usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount), + user: { + displayUserId: stringValue(item.user?.displayUserId ?? item.user?.display_user_id), + userId: stringValue(item.user?.userId ?? item.user?.user_id) || stringValue(item.userId ?? item.user_id), + username: stringValue(item.user?.username), + }, userPaidAmountMicro: numberOrNull( item.userPaidAmountMicro ?? item.user_paid_amount_micro ?? item.paidAmountMicro ?? item.paid_amount_micro, ), @@ -234,6 +242,83 @@ export function mergeRechargeSummaries(summaries = []) { return merged; } +export const EMPTY_RECHARGE_OVERVIEW = { + daily: [], + googlePaid: { + coveredUsdMinor: 0, + estFeeUsdMinor: 0, + estNetUsdMinor: 0, + estTaxUsdMinor: 0, + googleBillCount: 0, + syncedCount: 0, + unsyncedCount: 0, + }, + regions: [], +}; + +export function normalizeRechargeOverview(data = {}) { + const daily = Array.isArray(data.daily) ? data.daily : []; + const regions = Array.isArray(data.regions) ? data.regions : []; + const paid = data.googlePaid ?? data.google_paid ?? {}; + return { + daily: daily.map((item) => ({ + coinSellerCoinAmount: numberValue(item.coinSellerCoinAmount ?? item.coin_seller_coin_amount) || 0, + coinSellerUsdMinor: numberValue(item.coinSellerUsdMinor ?? item.coin_seller_usd_minor) || 0, + date: stringValue(item.date), + googleCoinAmount: numberValue(item.googleCoinAmount ?? item.google_coin_amount) || 0, + googleUsdMinor: numberValue(item.googleUsdMinor ?? item.google_usd_minor) || 0, + thirdPartyCoinAmount: numberValue(item.thirdPartyCoinAmount ?? item.third_party_coin_amount) || 0, + thirdPartyUsdMinor: numberValue(item.thirdPartyUsdMinor ?? item.third_party_usd_minor) || 0, + })), + googlePaid: { + coveredUsdMinor: numberValue(paid.coveredUsdMinor ?? paid.covered_usd_minor) || 0, + estFeeUsdMinor: numberValue(paid.estFeeUsdMinor ?? paid.est_fee_usd_minor) || 0, + estNetUsdMinor: numberValue(paid.estNetUsdMinor ?? paid.est_net_usd_minor) || 0, + estTaxUsdMinor: numberValue(paid.estTaxUsdMinor ?? paid.est_tax_usd_minor) || 0, + googleBillCount: numberValue(paid.googleBillCount ?? paid.google_bill_count) || 0, + syncedCount: numberValue(paid.syncedCount ?? paid.synced_count) || 0, + unsyncedCount: numberValue(paid.unsyncedCount ?? paid.unsynced_count) || 0, + }, + regions: regions.map((item) => ({ + billCount: numberValue(item.billCount ?? item.bill_count) || 0, + name: stringValue(item.name), + regionId: numberOrNull(item.regionId ?? item.region_id), + usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount) || 0, + })), + }; +} + +// mergeRechargeOverviews 把多 App 概览合并成全局口径:daily 按日期求和,googlePaid 求和; +// 区域 ID 属于单 App 语义,跨 App 合并时丢弃区域分布。 +export function mergeRechargeOverviews(overviews = []) { + const dailyByDate = new Map(); + const merged = normalizeRechargeOverview({}); + for (const overview of overviews) { + for (const bucket of overview.daily) { + const existing = dailyByDate.get(bucket.date); + if (!existing) { + dailyByDate.set(bucket.date, { ...bucket }); + continue; + } + for (const key of [ + "coinSellerCoinAmount", + "coinSellerUsdMinor", + "googleCoinAmount", + "googleUsdMinor", + "thirdPartyCoinAmount", + "thirdPartyUsdMinor", + ]) { + existing[key] += bucket[key]; + } + } + for (const key of Object.keys(merged.googlePaid)) { + merged.googlePaid[key] += overview.googlePaid[key]; + } + } + merged.daily = [...dailyByDate.values()].sort((left, right) => left.date.localeCompare(right.date)); + return merged; +} + export function normalizeRechargeRegions(data = {}) { const items = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []; return items diff --git a/finance/src/styles/index.css b/finance/src/styles/index.css index d86f003..d3054f6 100644 --- a/finance/src/styles/index.css +++ b/finance/src/styles/index.css @@ -30,10 +30,10 @@ a { display: flex; min-height: 100vh; flex-direction: column; - gap: var(--space-5); - border-right: 1px solid var(--border); - background: var(--bg-card); - padding: var(--space-5); + gap: var(--space-4); + border-right: 1px solid #1c2a44; + background: #101b2d; + padding: var(--space-5) var(--space-3); } .finance-brand { @@ -54,10 +54,25 @@ a { font-weight: 800; } +.finance-brand { + padding: 0 var(--space-2); +} + .finance-brand strong { - color: var(--text-primary); - font-size: 18px; - line-height: 1.3; + display: block; + color: #fff; + font-size: 16px; + line-height: 1.25; +} + +.finance-brand small { + color: #7e8db0; + font-size: 11px; +} + +.finance-brand span { + background: linear-gradient(135deg, #2557d6, #0e7490); + color: #fff; } .finance-nav { @@ -65,31 +80,45 @@ a { gap: var(--space-2); } +.finance-nav__group { + display: grid; + gap: 2px; +} + +.finance-nav__label { + margin: var(--space-3) var(--space-3) var(--space-1); + color: #5a6c89; + font-size: 10.5px; + letter-spacing: 0.14em; + text-transform: uppercase; +} + .finance-nav button { display: flex; align-items: center; gap: var(--space-2); - height: 40px; - border: 1px solid transparent; - border-radius: var(--radius-control); + width: 100%; + height: 38px; + border: none; + border-radius: 8px; background: transparent; - color: var(--text-secondary); + color: #93a1b8; cursor: pointer; font: inherit; + font-size: 13.5px; padding: 0 var(--space-3); text-align: left; - transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, transform 180ms ease; + transition: background-color 160ms ease, color 160ms ease; } .finance-nav button:hover { - background: var(--bg-card-strong); - color: var(--text-primary); + background: rgb(255 255 255 / 6%); + color: #dce4f0; } .finance-nav button.is-active { - border-color: var(--primary-border); - background: var(--primary-hover); - color: var(--primary); + background: #1b2a44; + color: #fff; font-weight: 700; } @@ -97,12 +126,16 @@ a { display: grid; gap: 4px; margin-top: auto; - border-top: 1px solid var(--border); - color: var(--text-primary); + padding-left: var(--space-3); + color: #dce4f0; + border-top: 1px solid #1c2a44; padding-top: var(--space-4); } -.finance-user small, +.finance-user small { + color: #7e8db0; +} + .finance-stack small { color: var(--text-tertiary); } @@ -621,3 +654,628 @@ a { transition-duration: 0.001ms !important; } } + + +/* ===== 财务中台 v2:全局变量与新版组件 ===== */ +:root { + --fin-ink: #17212f; + --fin-google: #1b873f; + --fin-third: #0e7490; + --fin-seller: #b45309; + --fin-pos: #16a34a; + --fin-neg: #dc2626; +} + +.num { + font-variant-numeric: tabular-nums; +} + +.finance-topbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + min-height: 64px; + border-bottom: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-3) var(--space-6); + position: sticky; + top: 0; + z-index: 20; +} + +.finance-topbar__title { + margin-right: auto; +} + +.finance-topbar__title h1 { + margin: 0; + font-size: 17px; + line-height: 1.3; +} + +.finance-topbar__title small { + color: var(--text-tertiary); + font-size: 11.5px; +} + +.finance-topbar__tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} + +.finance-seg { + display: inline-flex; + gap: 2px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); + padding: 3px; +} + +.finance-seg button { + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 12.5px; + padding: 5px 12px; + white-space: nowrap; +} + +.finance-seg button.is-on { + background: var(--fin-ink); + color: #fff; + font-weight: 650; +} + +.finance-appbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.finance-appchip { + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 12.5px; + font-weight: 650; + padding: 5px 14px; + transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.finance-appchip:hover { + border-color: var(--text-tertiary); +} + +.finance-appchip.is-on { + border-color: var(--fin-ink); + box-shadow: inset 0 0 0 1px var(--fin-ink); + color: var(--text-primary); +} + +.finance-overview { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.finance-overview__split { + display: grid; + grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr); + gap: var(--space-4); +} + +.finance-alerts { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.finance-alert { + display: inline-flex; + align-items: center; + gap: var(--space-2); + border: 1px solid transparent; + border-radius: 8px; + font-size: 12.5px; + font-weight: 650; + padding: 8px 13px; +} + +.finance-alert button { + border: none; + background: none; + color: inherit; + cursor: pointer; + font: inherit; + font-weight: 750; + text-decoration: underline; + text-underline-offset: 2px; + padding: 0; +} + +.finance-alert--warn { + background: color-mix(in srgb, #b45309 10%, transparent); + border-color: color-mix(in srgb, #b45309 28%, transparent); + color: #8a4b06; +} + +.finance-card { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + box-shadow: var(--shadow-soft); + min-width: 0; +} + +.finance-card__head { + display: flex; + align-items: center; + gap: var(--space-3); + border-bottom: 1px solid var(--border); + padding: var(--space-3) var(--space-4); +} + +.finance-card__title { + font-size: 13.5px; + font-weight: 750; +} + +.finance-card__title small { + color: var(--text-tertiary); + font-weight: 400; + margin-left: var(--space-2); +} + +.finance-card__tools { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--space-2); +} + +.finance-card__body { + padding: var(--space-4); +} + +.finance-card__divider { + border-top: 1px solid var(--border); + margin: var(--space-4) 0; +} + +.finance-hint { + margin: 0; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.6; +} + +.finance-sparkline { + width: 100%; + height: 28px; + margin-top: var(--space-2); +} + +.finance-chartlegend { + display: flex; + gap: var(--space-3); + color: var(--text-secondary); + font-size: 11.5px; +} + +.finance-chartlegend i { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 3px; + margin-right: 5px; +} + +.finance-trend { + display: flex; + align-items: flex-end; + gap: 3px; + height: 190px; +} + +.finance-trend__col { + display: flex; + flex: 1; + flex-direction: column-reverse; + gap: 1px; + min-width: 4px; + border-radius: 3px 3px 0 0; + overflow: hidden; +} + +.finance-trend__col:hover { + filter: brightness(0.94); +} + +.finance-trend__seg { + display: block; + width: 100%; +} + +.finance-trend__seg--googlePlay { + background: var(--fin-google); +} + +.finance-trend__seg--thirdParty { + background: var(--fin-third); +} + +.finance-trend__seg--coinSeller { + background: var(--fin-seller); +} + +.finance-trend__axis { + display: flex; + justify-content: space-between; + border-top: 1px solid var(--border); + color: var(--text-tertiary); + font-size: 10.5px; + margin-top: var(--space-2); + padding-top: var(--space-2); +} + +.finance-mixbar { + display: flex; + height: 12px; + border-radius: 999px; + overflow: hidden; + margin-bottom: var(--space-3); +} + +.finance-mixbar span { + display: block; +} + +.finance-mixrow { + display: flex; + align-items: center; + gap: var(--space-2); + border-top: 1px solid var(--border); + font-size: 12.5px; + padding: 7px 0; +} + +.finance-mixrow:first-of-type { + border-top: none; +} + +.finance-mixrow i { + width: 9px; + height: 9px; + flex: none; + border-radius: 3px; +} + +.finance-mixrow__name { + color: var(--text-secondary); +} + +.finance-mixrow__amt { + margin-left: auto; + font-weight: 750; +} + +.finance-mixrow__pct { + width: 48px; + color: var(--text-tertiary); + text-align: right; +} + +.finance-rankrow { + display: grid; + grid-template-columns: 18px 96px minmax(0, 1fr) 96px; + align-items: center; + gap: var(--space-2); + font-size: 12.5px; + padding: 6px 0; +} + +.finance-rankrow__idx { + color: var(--text-tertiary); + font-weight: 650; +} + +.finance-rankrow__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.finance-rankrow__amt { + font-weight: 750; + text-align: right; +} + +.finance-rankbar { + display: block; + height: 7px; + border-radius: 999px; + background: var(--bg-card-strong); + overflow: hidden; +} + +.finance-rankbar i { + display: block; + height: 100%; + border-radius: 999px; + background: var(--primary, #2557d6); +} + +.finance-tablewrap { + overflow-x: auto; +} + +.finance-flat-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.finance-flat-table th { + background: var(--bg-card-strong); + border-bottom: 1px solid var(--border); + color: var(--text-tertiary); + font-size: 11px; + font-weight: 750; + letter-spacing: 0.06em; + padding: 9px 14px; + text-align: left; + text-transform: uppercase; + white-space: nowrap; +} + +.finance-flat-table td { + border-bottom: 1px solid var(--border); + padding: 10px 14px; + vertical-align: middle; + white-space: nowrap; +} + +.finance-flat-table th.r, +.finance-flat-table td.r { + text-align: right; +} + +.finance-flat-table__row:hover td { + background: var(--bg-card-strong); + cursor: pointer; +} + +.finance-flat-table--bills { + min-width: 1180px; +} + +.finance-views { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + padding: 0 var(--space-2); +} + +.finance-views button { + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 12.5px; + font-weight: 650; + margin-bottom: -1px; + padding: 11px 14px; +} + +.finance-views button.is-on { + border-bottom-color: var(--primary, #2557d6); + color: var(--primary, #2557d6); +} + +.finance-views__cnt { + color: var(--text-tertiary); + font-weight: 400; + margin-left: 5px; +} + +.finance-filterrow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); +} + +.finance-filterrow__region { + min-width: 140px; +} + +.finance-filterrow__search { + min-width: 220px; +} + +.finance-filterrow__spacer { + flex: 1; +} + +.finance-fchip { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--primary, #2557d6) 40%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--primary, #2557d6) 10%, transparent); + color: var(--primary, #2557d6); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 650; + padding: 4px 12px; +} + +.finance-fchip span { + opacity: 0.7; + font-weight: 800; +} + +.finance-detailrow td { + background: var(--bg-card-strong); + padding: 0 var(--space-4) var(--space-4); + white-space: normal; +} + +.finance-detail { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card); + margin-top: var(--space-3); + padding: var(--space-4); +} + +.finance-kv { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-content: start; + gap: 5px 16px; + font-size: 12px; + margin: 0; +} + +.finance-kv dt { + color: var(--text-tertiary); +} + +.finance-kv dd { + font-weight: 650; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.finance-wfall { + display: flex; + flex-direction: column; + gap: 7px; +} + +.finance-wfall__row { + display: grid; + grid-template-columns: 104px minmax(0, 1fr) 130px; + align-items: center; + gap: var(--space-2); + font-size: 12px; +} + +.finance-wfall__label { + color: var(--text-secondary); +} + +.finance-wfall__track { + display: block; + height: 14px; +} + +.finance-wfall__track i { + display: block; + height: 100%; + border-radius: 4px; +} + +.finance-wfall__val { + font-weight: 750; + text-align: right; +} + +.finance-pill { + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 750; + padding: 2px 10px; +} + +.finance-pill i { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentcolor; +} + +.finance-pill--ok { + background: color-mix(in srgb, #16a34a 12%, transparent); + color: #136b31; +} + +.finance-pill--warn { + background: color-mix(in srgb, #b45309 14%, transparent); + color: #8a4b06; +} + +.finance-recon-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} + +.finance-recon-toolbar__month { + font-size: 15px; + font-weight: 750; +} + +.finance-recon-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-4); +} + +.finance-recon-line { + display: flex; + justify-content: space-between; + gap: var(--space-3); + border-top: 1px solid var(--border); + font-size: 12.5px; + padding: 7px 0; +} + +.finance-recon-line:first-of-type { + border-top: none; +} + +.finance-recon-line--total { + border-top: 2px solid var(--border); + font-weight: 800; +} + +@media (max-width: 1050px) { + .finance-overview__split, + .finance-recon-grid { + grid-template-columns: 1fr; + } + + .finance-detail { + grid-template-columns: 1fr; + } +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 86b151a..37f497a 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -121,6 +121,7 @@ export const API_OPERATIONS = { getInviteActivityRewardConfig: "getInviteActivityRewardConfig", getLuckyGiftConfig: "getLuckyGiftConfig", getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary", + getRechargeBillOverview: "getRechargeBillOverview", getRechargeBillSummary: "getRechargeBillSummary", getRedPacket: "getRedPacket", getRedPacketConfig: "getRedPacketConfig", @@ -1090,6 +1091,13 @@ export const API_ENDPOINTS: Record = { permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, + getRechargeBillOverview: { + method: "GET", + operationId: API_OPERATIONS.getRechargeBillOverview, + path: "/v1/admin/payment/recharge-bills/overview", + permission: "payment-bill:view", + permissions: ["payment-bill:view"] + }, getRechargeBillSummary: { method: "GET", operationId: API_OPERATIONS.getRechargeBillSummary, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 4de9c42..0c6f0ac 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1876,6 +1876,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/payment/recharge-bills/overview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRechargeBillOverview"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/payment/recharge-bills/summary": { parameters: { query?: never; @@ -6815,6 +6831,26 @@ export interface operations { 200: components["responses"]["RechargeBillPageResponse"]; }; }; + getRechargeBillOverview: { + parameters: { + query?: { + keyword?: string; + recharge_type?: string; + status?: string; + region_id?: number; + start_at_ms?: number; + end_at_ms?: number; + tz_offset_minutes?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; getRechargeBillSummary: { parameters: { query?: {