新财务系统

This commit is contained in:
zhx 2026-07-03 17:34:57 +08:00
parent 9fe67dc8d1
commit f6822d2411
11 changed files with 2134 additions and 407 deletions

View File

@ -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",

View File

@ -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 <StateScreen title="无财务系统权限" />;
}
const presets = timePresets();
const activePreset = presets.find(
(preset) =>
String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") &&
String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || "")
);
const isFinanceView = FINANCE_VIEWS.has(activeView);
const financeToolbar =
isFinanceView && session.canViewAppRechargeDetails ? (
<>
<div className="finance-seg" role="group" aria-label="时间范围">
{presets.map((preset) => (
<button
className={activePreset?.key === preset.key ? "is-on" : ""}
key={preset.key}
type="button"
onClick={() => updateRechargeDetailFilters({ timeRange: preset.range })}
>
{preset.label}
</button>
))}
</div>
<TimeRangeFilter
label="自定义 · 中国时区"
value={rechargeDetailFilters.timeRange}
onChange={(timeRange) => updateRechargeDetailFilters({ timeRange })}
/>
<div className="finance-seg" role="group" aria-label="计价单位">
{[
["usd", "USD"],
["coin", "金币"]
].map(([value, label]) => (
<button
className={rechargeDetailFilters.currency === value ? "is-on" : ""}
key={value}
type="button"
onClick={() => updateRechargeDetailFilters({ currency: value, page: rechargeDetailFilters.page })}
>
{label}
</button>
))}
</div>
</>
) : null;
const appBar =
isFinanceView && session.canViewAppRechargeDetails ? (
<div className="finance-appbar">
<button
className={["finance-appchip", rechargeDetailFilters.appCode === "" ? "is-on" : ""].filter(Boolean).join(" ")}
type="button"
onClick={() => updateRechargeDetailFilters({ appCode: "", regionId: "" })}
>
全部 App
</button>
{rechargeApps.map((app) => (
<button
className={["finance-appchip", rechargeDetailFilters.appCode === app.appCode ? "is-on" : ""].filter(Boolean).join(" ")}
key={app.appCode}
type="button"
onClick={() => updateRechargeDetailFilters({ appCode: app.appCode, regionId: "" })}
>
{app.appName || app.appCode}
</button>
))}
</div>
) : null;
return (
<FinanceShell
activeView={activeView}
@ -443,26 +631,59 @@ export function FinanceApp() {
canViewWithdrawals={session.canViewWithdrawalApplications}
loading={optionsState.loading || rechargeDetailsState.loading || myApplicationsState.loading || applicationsState.loading || withdrawalsState.loading}
session={session}
subtitle={isFinanceView ? "中国时区 · 数据实时" : ""}
toolbar={financeToolbar}
onViewChange={setActiveView}
>
{activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? (
{appBar}
{activeView === "overview" && session.canViewAppRechargeDetails ? (
<FinanceOverview
apps={rechargeApps}
currency={rechargeDetailFilters.currency}
filters={rechargeDetailFilters}
loading={rechargeSummaryState.loading || rechargeOverviewState.loading}
overview={rechargeOverviewState.data}
perAppSummaries={rechargeSummaryState.perApp}
prevSummary={rechargeSummaryState.prev}
regions={rechargeOverviewState.data.regions}
summary={rechargeSummaryState.merged}
onFiltersChange={updateRechargeDetailFilters}
onGoUnsynced={() => {
updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" });
setActiveView("rechargeDetails");
}}
onOpenApp={(appCode) => {
updateRechargeDetailFilters({ appCode, regionId: "" });
setActiveView("rechargeDetails");
}}
/>
) : activeView === "recon" && session.canViewAppRechargeDetails ? (
<FinanceReconciliation
loading={reconState.loading}
month={reconMonthMeta(reconMonthOffset)}
overview={reconState.overview}
summary={reconState.summary}
onMonthChange={(delta) => setReconMonthOffset((current) => Math.min(0, current + delta))}
/>
) : activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? (
<FinanceRechargeDetailList
apps={rechargeApps}
bills={rechargeDetailsState.data}
currency={rechargeDetailFilters.currency}
error={rechargeDetailsState.error}
exporting={rechargeExporting}
filters={rechargeDetailFilters}
googlePaidRefreshing={googlePaidRefreshing}
loading={rechargeDetailsState.loading}
options={{ ...optionsState.data, apps: rechargeApps }}
regions={rechargeRegionsState.items}
summary={rechargeSummaryState.data}
summaryLoading={rechargeSummaryState.loading}
summary={rechargeSummaryState.merged}
onExport={exportRechargeBills}
onFiltersChange={updateRechargeDetailFilters}
onRefreshGooglePaid={refreshGooglePaid}
onReload={() => {
loadRechargeDetails();
loadRechargeSummary();
loadRechargeOverview();
}}
/>
) : activeView === "applications" && session.canAuditApplication ? (

View File

@ -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), {

View File

@ -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 (
<div className="finance-overview">
{unsynced > 0 ? (
<div className="finance-alerts">
<span className="finance-alert finance-alert--warn">
{formatAmount(unsynced)} 笔谷歌订单实付未同步手续费口径尚不完整
<button type="button" onClick={onGoUnsynced}>
去同步
</button>
</span>
</div>
) : null}
<div className="finance-kpi-band">
{KPI_CARDS.map(({ filter, key, label, tone }) => {
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
const active = (filters.rechargeType || "") === filter;
return (
<button
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
.filter(Boolean)
.join(" ")}
key={key}
title={filter ? `点击只看${label}` : "查看全部来源"}
type="button"
onClick={() => onFiltersChange({ rechargeType: active && filter ? "" : filter })}
>
<span className="finance-kpi__label">{label}</span>
<strong className="finance-kpi__value">
{loading
? "…"
: showCoins
? `${formatAmount(bucket.coinAmount)} 金币`
: formatUsdMinor(bucket.usdMinorAmount, "USD")}
</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : kpiMeta(bucket, key, totalUsd, prevSummary)}
</small>
<Sparkline daily={trend.daily} field={key} showCoins={showCoins} tone={tone} />
</button>
);
})}
</div>
<div className="finance-overview__split">
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-card__title">
充值趋势<small>按渠道堆叠 · · {showCoins ? "金币" : "USD"}</small>
</span>
<div className="finance-card__tools finance-chartlegend">
<span>
<i style={{ background: "var(--fin-google)" }} />
谷歌
</span>
<span>
<i style={{ background: "var(--fin-third)" }} />
三方
</span>
<span>
<i style={{ background: "var(--fin-seller)" }} />
币商
</span>
</div>
</div>
<div className="finance-card__body">
<TrendChart daily={trend.daily} showCoins={showCoins} />
</div>
</section>
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-card__title">
净入账拆解<small>谷歌已同步口径</small>
</span>
</div>
<div className="finance-card__body">
<DeductionBreakdown paid={trend.googlePaid} />
<div className="finance-card__divider" />
<div className="finance-card__title" style={{ marginBottom: 8 }}>
{selectedApp ? "区域分布 Top 5" : "区域分布"}
</div>
{selectedApp ? (
<RegionRanks regions={regions} />
) : (
<p className="finance-hint">选择单个 App 后查看区域分布</p>
)}
</div>
</section>
</div>
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-card__title">
App 对比<small>点击行进入该 App 流水</small>
</span>
</div>
<div className="finance-tablewrap">
<table className="finance-flat-table">
<thead>
<tr>
<th>App</th>
<th className="r">充值流水</th>
<th className="r">笔数</th>
<th className="r">谷歌</th>
<th className="r">三方</th>
<th className="r">币商</th>
<th style={{ width: 180 }}>占比</th>
</tr>
</thead>
<tbody>
{(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 (
<tr
className="finance-flat-table__row"
key={app.appCode}
onClick={() => onOpenApp(app.appCode)}
>
<td>
<b>{app.appName || app.appCode}</b>
</td>
<td className="r num">{formatUsdMinor(appTotal, "USD")}</td>
<td className="r num">{formatAmount(app.summary?.total?.billCount || 0)}</td>
<td className="r num">
{formatUsdMinor(app.summary?.googlePlay?.usdMinorAmount || 0, "USD")}
</td>
<td className="r num">
{formatUsdMinor(app.summary?.thirdParty?.usdMinorAmount || 0, "USD")}
</td>
<td className="r num">
{formatUsdMinor(app.summary?.coinSeller?.usdMinorAmount || 0, "USD")}
</td>
<td>
<span className="finance-rankbar">
<i style={{ width: `${(appTotal / maxTotal) * 100}%` }} />
</span>
</td>
</tr>
);
})}
{!perAppSummaries?.length ? (
<tr>
<td colSpan={7} style={{ textAlign: "center" }}>
暂无数据
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
);
}
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 <span className="finance-sparkline" />;
}
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 (
<svg className="finance-sparkline" preserveAspectRatio="none" viewBox={`0 0 ${width} ${height}`}>
<polyline fill={`${color}22`} points={`${points.join(" ")} ${width},${height} 0,${height}`} stroke="none" />
<polyline
fill="none"
points={points.join(" ")}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</svg>
);
}
function TrendChart({ daily, showCoins }) {
const buckets = daily || [];
if (!buckets.length) {
return <p className="finance-hint">当前时间范围内没有充值数据</p>;
}
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 (
<div>
<div className="finance-trend" role="img" aria-label="按渠道堆叠的每日充值趋势">
{buckets.map((bucket) => (
<div
className="finance-trend__col"
key={bucket.date}
title={`${bucket.date} · 谷歌 ${trendTip(bucket, "googlePlay", showCoins)} · 三方 ${trendTip(bucket, "thirdParty", showCoins)} · 币商 ${trendTip(bucket, "coinSeller", showCoins)}`}
>
{["googlePlay", "thirdParty", "coinSeller"].map((channel) => {
const amount = value(bucket, channel);
if (amount <= 0) {
return null;
}
return (
<span
className={`finance-trend__seg finance-trend__seg--${channel}`}
key={channel}
style={{ height: `${(amount / max) * 100}%` }}
/>
);
})}
</div>
))}
</div>
<div className="finance-trend__axis">
<span>{buckets[0].date.slice(5)}</span>
{buckets.length > 2 ? <span>{buckets[Math.floor(buckets.length / 2)].date.slice(5)}</span> : null}
<span>{buckets[buckets.length - 1].date.slice(5)}</span>
</div>
</div>
);
}
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 (
<p className="finance-hint">
暂无已同步的谷歌实付明细查询谷歌实付同步后这里会展示手续费与税费拆解
</p>
);
}
const rows = [
{ amount: paid.estNetUsdMinor, color: "var(--fin-pos)", label: "净入账(估)" },
{ amount: paid.estFeeUsdMinor, color: "#E19B3C", label: "谷歌服务费(估)" },
{ amount: paid.estTaxUsdMinor, color: "#C86A6A", label: "代缴税费(估)" },
];
return (
<div>
<div className="finance-mixbar">
{rows.map((row) => (
<span key={row.label} style={{ background: row.color, flex: Math.max(row.amount, 1) }} />
))}
</div>
{rows.map((row) => (
<div className="finance-mixrow" key={row.label}>
<i style={{ background: row.color }} />
<span className="finance-mixrow__name">{row.label}</span>
<span className="finance-mixrow__amt num">{formatUsdMinor(row.amount, "USD")}</span>
<span className="finance-mixrow__pct num">
{((row.amount / covered) * 100).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%
</span>
</div>
))}
<p className="finance-hint" style={{ marginTop: 8 }}>
口径已同步 {formatAmount(paid.syncedCount)} / {formatAmount(paid.googleBillCount)}{" "}
笔谷歌订单覆盖流水 {formatUsdMinor(covered, "USD")}按各订单实付占比折算成 USD
</p>
</div>
);
}
function RegionRanks({ regions }) {
const top = (regions || []).slice(0, 5);
if (!top.length) {
return <p className="finance-hint">当前筛选下暂无区域数据</p>;
}
const max = Math.max(1, ...top.map((region) => region.usdMinorAmount));
return (
<div>
{top.map((region, index) => (
<div className="finance-rankrow" key={region.regionId || region.name || index}>
<span className="finance-rankrow__idx num">{index + 1}</span>
<span className="finance-rankrow__name">{region.name || `区域 ${region.regionId}`}</span>
<span className="finance-rankbar">
<i style={{ width: `${(region.usdMinorAmount / max) * 100}%` }} />
</span>
<span className="finance-rankrow__amt num">{formatUsdMinor(region.usdMinorAmount, "USD")}</span>
</div>
))}
</div>
);
}

View File

@ -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 (
<section className="finance-panel finance-list">
<RechargeKpiBand
activeFilter={filters.rechargeType || ""}
loading={summaryLoading}
showCoins={showCoinColumns}
summary={summary}
onSelect={(value) => onFiltersChange({ rechargeType: value })}
/>
<div className="finance-filterbar">
<section className="finance-card finance-list">
<div className="finance-views" role="tablist" aria-label="流水视图">
{SAVED_VIEWS.map((view) => (
<button
aria-selected={activeView === view.key}
className={activeView === view.key ? "is-on" : ""}
key={view.key}
role="tab"
type="button"
onClick={() => onFiltersChange({ paidState: view.paidState, rechargeType: view.rechargeType })}
>
{view.label}
{activeView === view.key && !loading ? (
<span className="finance-views__cnt num">{formatAmount(total)}</span>
) : null}
</button>
))}
</div>
<div className="finance-filterrow">
{chips.map((chip) => (
<button
className="finance-fchip"
key={chip.key}
type="button"
onClick={() => onFiltersChange(chip.reset)}
>
{chip.label}
<span aria-hidden="true">×</span>
</button>
))}
<TextField
className="finance-filterbar__field"
label="APP"
select
value={filters.appCode}
onChange={(event) => onFiltersChange({ appCode: event.target.value, regionId: "" })}
>
<MenuItem value="">全部 APP</MenuItem>
{options.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
</MenuItem>
))}
</TextField>
<TextField
className="finance-filterbar__field"
className="finance-filterrow__region"
disabled={!filters.appCode}
label="区域"
select
title={filters.appCode ? "" : "先选择 APP"}
size="small"
title={filters.appCode ? "" : "先选择 App"}
value={filters.regionId}
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
>
<MenuItem value="">全部区域</MenuItem>
{regionOptions.map((region) => (
{(regions || []).map((region) => (
<MenuItem key={region.regionId} value={String(region.regionId)}>
{region.name || region.regionCode || region.regionId}
</MenuItem>
))}
</TextField>
<TextField
className="finance-filterbar__field"
label="充值来源"
select
value={filters.rechargeType || ""}
onChange={(event) => onFiltersChange({ rechargeType: event.target.value })}
>
{SOURCE_FILTERS.map((source) => (
<MenuItem key={source.value} value={source.value}>
{source.label}
</MenuItem>
))}
</TextField>
<TimeRangeFilter
label="充值时间 · 中国时区"
value={filters.timeRange}
onChange={(timeRange) => onFiltersChange({ timeRange })}
/>
<TextField
className="finance-filterbar__field finance-filterbar__field--search"
className="finance-filterrow__search"
label="订单搜索"
placeholder="交易号 / 三方单号 / 凭证号"
placeholder="交易号 / GPA单号 / 凭证号"
size="small"
value={filters.keyword}
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
/>
<span className="finance-filterrow__spacer" />
<Button
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length}
size="small"
startIcon={<CloudSyncOutlined fontSize="small" />}
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 App 后可查询"}
variant="outlined"
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
>
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
</Button>
<Button
disabled={exporting || !filters.appCode}
size="small"
startIcon={<FileDownloadOutlined fontSize="small" />}
title={filters.appCode ? "按当前筛选导出全部明细CSV" : "先选择 App 后可导出"}
variant="contained"
onClick={onExport}
>
{exporting ? "导出中…" : "导出报表"}
</Button>
<Button
size="small"
startIcon={<RefreshOutlined fontSize="small" />}
variant="outlined"
onClick={onReload}
>
刷新
</Button>
</div>
<div className="finance-actionbar">
<div className="finance-actionbar__presets">
{timePresets().map((preset) => (
<button
className={[
"finance-chip",
isPresetActive(filters.timeRange, preset) ? "finance-chip--active" : "",
]
.filter(Boolean)
.join(" ")}
key={preset.label}
type="button"
onClick={() => onFiltersChange({ timeRange: preset.range })}
>
{preset.label}
</button>
))}
</div>
<div className="finance-actionbar__actions">
<label className="finance-switch-field">
<AdminSwitch
checked={showCoinColumns}
label="金币显示"
onChange={(event) => setShowCoinColumns(event.target.checked)}
/>
<span>金币显示</span>
</label>
<Button
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length}
size="small"
startIcon={<CloudSyncOutlined fontSize="small" />}
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 APP 后可查询"}
variant="outlined"
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
>
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
</Button>
<Button
disabled={exporting || !filters.appCode}
size="small"
startIcon={<FileDownloadOutlined fontSize="small" />}
title={
filters.appCode ? "按当前筛选导出全部明细CSV可用 Excel 打开)" : "先选择 APP 后可导出"
}
variant="contained"
onClick={onExport}
>
{exporting ? "导出中…" : "导出报表"}
</Button>
<Button
size="small"
startIcon={<RefreshOutlined fontSize="small" />}
variant="outlined"
onClick={onReload}
>
刷新
</Button>
</div>
</div>
{error ? <div className="finance-error">{error}</div> : null}
{loading && !items.length ? <div className="finance-skeleton" /> : null}
{!loading || items.length ? (
<TableContainer className="finance-table-wrap">
<Table
className={[
"finance-table",
"finance-table--recharge-details",
showCoinColumns ? "finance-table--recharge-details-with-coins" : "",
]
.filter(Boolean)
.join(" ")}
size="small"
stickyHeader
>
<TableHead>
<TableRow>
<TableCell className="finance-time-cell">充值时间</TableCell>
<TableCell>APP</TableCell>
<TableCell>充值来源</TableCell>
<TableCell>充值订单号</TableCell>
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
{showCoinColumns ? <TableCell>金币换算</TableCell> : null}
<TableCell align="right">账单金额</TableCell>
<TableCell align="right">用户实付</TableCell>
<TableCell align="right">三方税率扣款</TableCell>
</TableRow>
</TableHead>
<TableBody>
<div className="finance-tablewrap">
<table className="finance-flat-table finance-flat-table--bills">
<thead>
<tr>
<th>充值时间</th>
<th>APP</th>
<th>充值来源</th>
<th>充值订单号</th>
{showCoins ? <th className="r">充值金币</th> : null}
<th className="r">账单金额</th>
<th className="r">用户实付</th>
<th className="r">三方税率扣款</th>
<th>区域</th>
</tr>
</thead>
<tbody>
{items.length ? (
items.map((item) => (
<TableRow hover key={item.id || item.transactionId}>
<TableCell className="finance-time-cell">
{formatTime(item.createdAtMs)}
</TableCell>
<TableCell>{appName(item, options.apps)}</TableCell>
<TableCell>
<SourceBadge rechargeType={item.rechargeType} />
</TableCell>
<TableCell className="finance-order-cell">
<Stacked
primary={item.transactionId || "-"}
secondary={secondaryOrderNo(item)}
/>
</TableCell>
{showCoinColumns ? (
<TableCell align="right">
<AmountText value={item.coinAmount}>
{coinText(item.coinAmount)}
</AmountText>
</TableCell>
) : null}
{showCoinColumns ? <TableCell>{exchangeText(item)}</TableCell> : null}
<TableCell align="right">
<AmountText value={item.usdMinorAmount}>{billAmountText(item)}</AmountText>
</TableCell>
<TableCell align="right">
<AmountText value={item.userPaidAmountMicro}>
{userPaidText(item)}
</AmountText>
</TableCell>
<TableCell align="right">{taxDeductionText(item)}</TableCell>
</TableRow>
))
items.map((item) => {
const rowId = item.id || item.transactionId;
const expanded = expandedId === rowId;
return (
<Fragment key={rowId}>
<tr
className="finance-flat-table__row"
onClick={() => setExpandedId(expanded ? "" : rowId)}
>
<td className="num">{formatTime(item.createdAtMs)}</td>
<td>{appName(item.appCode, apps)}</td>
<td>
<span
className={`finance-badge finance-badge--${badgeTone(item.rechargeType)}`}
>
{rechargeTypeLabel(item.rechargeType)}
</span>
</td>
<td className="finance-order-cell">
<span className="finance-stack">
<span>{item.transactionId || "-"}</span>
<small>{secondaryOrderNo(item)}</small>
</span>
</td>
{showCoins ? (
<td className="r num">
<AmountText value={item.coinAmount}>
{coinText(item.coinAmount)}
</AmountText>
</td>
) : null}
<td className="r num">
<AmountText value={item.usdMinorAmount}>
{billAmountText(item)}
</AmountText>
</td>
<td className="r num">
<AmountText value={item.userPaidAmountMicro}>
{userPaidText(item)}
</AmountText>
</td>
<td className="r num">{taxDeductionText(item)}</td>
<td>{regionText(item, regionNames)}</td>
</tr>
{expanded ? (
<tr className="finance-detailrow">
<td colSpan={columnCount}>
<BillDetail
googlePaidRefreshing={googlePaidRefreshing}
item={item}
onRefreshGooglePaid={onRefreshGooglePaid}
/>
</td>
</tr>
) : null}
</Fragment>
);
})
) : (
<TableRow>
<TableCell align="center" colSpan={visibleColumnCount}>
<tr>
<td colSpan={columnCount} style={{ textAlign: "center" }}>
当前筛选条件下没有账单
</TableCell>
</TableRow>
</td>
</tr>
)}
</TableBody>
</Table>
</TableContainer>
</tbody>
</table>
</div>
) : null}
<div className="finance-pagination">
<span className="finance-pagination__meta">
{formatAmount(total)} · {page} / {Math.max(1, Math.ceil(total / pageSize))}
<span className="finance-pagination__meta num">
{formatAmount(total)}
{summary ? ` · 筛选合计 ${formatUsdMinor(summary.total?.usdMinorAmount || 0, "USD")}` : ""} · {" "}
{page} / {Math.max(1, Math.ceil(total / pageSize))}
</span>
<div className="finance-pagination__buttons">
<Button
@ -293,55 +288,137 @@ export function FinanceRechargeDetailList({
);
}
function RechargeKpiBand({ activeFilter, loading, onSelect, showCoins, summary }) {
const data = summary || EMPTY_RECHARGE_SUMMARY;
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
// BillDetail + +
function BillDetail({ googlePaidRefreshing, item, onRefreshGooglePaid }) {
const currencyCode = item.userPaidCurrencyCode || item.currencyCode || "USD";
const paid = Number(item.userPaidAmountMicro) || 0;
const fee = Number(item.providerFeeMicro) || 0;
const tax = Number(item.providerTaxMicro) || 0;
const net = Number(item.providerNetMicro) || 0;
const isGoogle = item.rechargeType === "google_play_recharge";
const unsynced = isGoogle && !item.paidSyncedAtMs;
return (
<div className="finance-kpi-band">
{KPI_CARDS.map(({ filter, key, label, tone }) => {
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
const active = activeFilter === filter;
return (
<button
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
.filter(Boolean)
.join(" ")}
key={key}
title={filter ? `点击只看${label}` : "点击查看全部来源"}
type="button"
onClick={() => onSelect(active && filter ? "" : filter)}
>
<span className="finance-kpi__label">{label}</span>
<strong className="finance-kpi__value">
{loading ? "…" : formatUsdMinor(bucket.usdMinorAmount, "USD")}
</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : kpiMetaText(bucket, key, totalUsd, showCoins)}
</small>
</button>
);
})}
<div className="finance-detail">
<div>
<div className="finance-card__title" style={{ marginBottom: 10 }}>
金额拆解{paid ? ` · 实付币种 ${currencyCode}` : ""}
</div>
{paid && (fee || tax || net) ? (
<div className="finance-wfall">
<DetailWfallRow
amount={paid}
color="var(--fin-ink)"
currency={currencyCode}
label="用户实付"
max={paid}
/>
{tax ? (
<DetailWfallRow
amount={-tax}
color="#C86A6A"
currency={currencyCode}
label="代缴税费"
max={paid}
/>
) : null}
{fee ? (
<DetailWfallRow
amount={-fee}
color="#E19B3C"
currency={currencyCode}
label="渠道服务费"
max={paid}
/>
) : null}
{net ? (
<DetailWfallRow
amount={net}
color="var(--fin-pos)"
currency={currencyCode}
label="开发者净收"
max={paid}
/>
) : null}
</div>
) : unsynced ? (
<p className="finance-hint">
实付明细未同步
<Button
disabled={googlePaidRefreshing}
size="small"
style={{ marginLeft: 8 }}
variant="outlined"
onClick={(event) => {
event.stopPropagation();
onRefreshGooglePaid([item.transactionId]);
}}
>
同步这一笔
</Button>
</p>
) : (
<p className="finance-hint">
{paid ? `用户实付 ${formatMicroMoney(paid, currencyCode)} · ` : ""}
该渠道未提供扣费明细
</p>
)}
</div>
<dl className="finance-kv">
<dt>交易号</dt>
<dd className="finance-order-cell">{item.transactionId || "-"}</dd>
{item.commandId ? (
<>
<dt>业务单号</dt>
<dd className="finance-order-cell">{item.commandId}</dd>
</>
) : null}
{item.externalRef ? (
<>
<dt>外部订单</dt>
<dd className="finance-order-cell">{item.externalRef}</dd>
</>
) : null}
{item.user?.username || item.user?.userId ? (
<>
<dt>充值用户</dt>
<dd>
{item.user.username || "-"}
{item.user.userId ? ` · ID ${item.user.userId}` : ""}
</dd>
</>
) : null}
{item.sellerUserId ? (
<>
<dt>币商 ID</dt>
<dd className="num">{item.sellerUserId}</dd>
</>
) : null}
<dt>美金换算</dt>
<dd className="num">{formatUsdMinor(item.usdMinorAmount, "USD")}</dd>
{item.paidSyncedAtMs ? (
<>
<dt>实付同步于</dt>
<dd className="num">{formatTime(item.paidSyncedAtMs)}</dd>
</>
) : null}
</dl>
</div>
);
}
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 (
<span className={`finance-badge finance-badge--${rechargeSourceTone(rechargeType)}`}>
{rechargeTypeLabel(rechargeType)}
</span>
<div className="finance-wfall__row">
<span className="finance-wfall__label">{label}</span>
<span className="finance-wfall__track">
<i style={{ background: color, width: `${Math.min(100, width)}%` }} />
</span>
<span className={negative ? "finance-wfall__val num finance-amount--negative" : "finance-wfall__val num"}>
{negative ? ` ${formatMicroMoney(-amount, currency)}` : formatMicroMoney(amount, currency)}
</span>
</div>
);
}
@ -350,46 +427,30 @@ function AmountText({ children, value }) {
return <span className={negative ? "finance-amount finance-amount--negative" : "finance-amount"}>{children}</span>;
}
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 (
<span className="finance-stack">
<span>{primary || "-"}</span>
<small>{secondary || ""}</small>
</span>
);
}
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 (
<span className="finance-stack">
<span>{parts.join(" / ")}</span>
<small>{deductionBreakdown(fee, tax, currency)}</small>
</span>
);
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";
}

View File

@ -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 (
<div className="finance-overview">
<div className="finance-recon-toolbar">
<Button size="small" variant="outlined" onClick={() => onMonthChange(-1)}>
上月
</Button>
<span className="finance-recon-toolbar__month">{month.label} 结算期</span>
<Button disabled={month.isCurrent} size="small" variant="outlined" onClick={() => onMonthChange(1)}>
下月
</Button>
<span className="finance-hint" style={{ marginLeft: "auto" }}>
对账口径平台流水 × 谷歌 Orders API 实付 · 中国时区
</span>
</div>
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-card__title">
结算瀑布<small>全渠道 · USD{paid.unsyncedCount > 0 ? " · 谷歌扣费为已同步部分估算" : ""}</small>
</span>
</div>
<div className="finance-card__body">
{loading ? (
<p className="finance-hint">统计中</p>
) : (
<div className="finance-wfall">
<WaterfallRow amount={totalUsd} color="var(--fin-ink)" label="充值流水" max={totalUsd} />
<WaterfallRow amount={-feeUsd} color="#E19B3C" label="渠道手续费(估)" max={totalUsd} />
<WaterfallRow amount={-taxUsd} color="#C86A6A" label="代缴税费(估)" max={totalUsd} />
<WaterfallRow amount={netUsd} color="var(--fin-pos)" label="净入账(估)" max={totalUsd} />
</div>
)}
</div>
</section>
<div className="finance-recon-grid">
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-badge finance-badge--google">谷歌</span>
{paid.unsyncedCount > 0 ? (
<span className="finance-pill finance-pill--warn">
<i />
{formatAmount(paid.unsyncedCount)} 笔待同步
</span>
) : (
<span className="finance-pill finance-pill--ok">
<i />
实付已全量同步
</span>
)}
</div>
<div className="finance-card__body">
<ReconLine
label="平台流水"
value={formatUsdMinor(data.googlePlay?.usdMinorAmount || 0, "USD")}
/>
<ReconLine
label={`已同步实付覆盖(${formatAmount(paid.syncedCount)}/${formatAmount(paid.googleBillCount)} 笔)`}
value={formatUsdMinor(paid.coveredUsdMinor, "USD")}
/>
<ReconLine
label="谷歌服务费(估)"
negative
value={` ${formatUsdMinor(paid.estFeeUsdMinor, "USD")}`}
/>
<ReconLine
label="代缴税费(估)"
negative
value={` ${formatUsdMinor(paid.estTaxUsdMinor, "USD")}`}
/>
<ReconLine label="预计净入账" total value={formatUsdMinor(paid.estNetUsdMinor, "USD")} />
<p className="finance-hint" style={{ marginTop: 10 }}>
Play Console 财务报告按月核对未同步订单可在流水页批量补数
</p>
</div>
</section>
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-badge finance-badge--thirdparty">三方</span>
</div>
<div className="finance-card__body">
<ReconLine
label="三方流水"
value={formatUsdMinor(data.thirdParty?.usdMinorAmount || 0, "USD")}
/>
<ReconLine label="笔数" value={`${formatAmount(data.thirdParty?.billCount || 0)}`} />
<ReconLine label="渠道扣费" value="V5Pay 逐笔回传 / MiFaPay 未提供" />
<p className="finance-hint" style={{ marginTop: 10 }}>
V5Pay fee/tax 已逐笔展示在流水明细MiFaPay 仅提供毛额需用渠道后台月账单人工核对
</p>
</div>
</section>
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-badge finance-badge--coinseller">币商</span>
</div>
<div className="finance-card__body">
<ReconLine
label="净进货(进货 冲回)"
value={formatUsdMinor(data.coinSeller?.usdMinorAmount || 0, "USD")}
/>
<ReconLine label="记录数" value={`${formatAmount(data.coinSeller?.billCount || 0)}`} />
<ReconLine label="金币入库" value={`${formatAmount(data.coinSeller?.coinAmount || 0)} 金币`} />
<p className="finance-hint" style={{ marginTop: 10 }}>
打款凭证与 TRC20 交易号逐笔登记在流水明细可按凭证号搜索核对
</p>
</div>
</section>
</div>
</div>
);
}
function WaterfallRow({ amount, color, label, max }) {
const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0;
const negative = amount < 0;
return (
<div className="finance-wfall__row">
<span className="finance-wfall__label">{label}</span>
<span className="finance-wfall__track">
<i style={{ background: color, width: `${Math.min(100, width)}%` }} />
</span>
<span className={negative ? "finance-wfall__val num finance-amount--negative" : "finance-wfall__val num"}>
{negative ? ` ${formatUsdMinor(-amount, "USD")}` : formatUsdMinor(amount, "USD")}
</span>
</div>
);
}
function ReconLine({ label, negative, total, value }) {
return (
<div className={["finance-recon-line", total ? "finance-recon-line--total" : ""].filter(Boolean).join(" ")}>
<span>{label}</span>
<b className={negative ? "num finance-amount--negative" : "num"}>{value}</b>
</div>
);
}

View File

@ -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 (
<main className="finance-shell">
<aside className="finance-sidebar">
<div className="finance-brand">
<span>HY</span>
<strong>财务系统</strong>
</div>
<nav className="finance-nav" aria-label="财务系统菜单">
{visibleViews.map((view) => {
const Icon = view.icon;
return (
<button className={activeView === view.id ? "is-active" : ""} key={view.id} type="button" onClick={() => onViewChange(view.id)}>
<Icon fontSize="small" />
<span>{view.label}</span>
</button>
);
})}
</nav>
<div className="finance-user">
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
<small>
{canViewRechargeDetails
? "payment-bill:view"
: canAudit
? "finance-application:audit"
: canViewWithdrawals
? "finance-withdrawal:view"
: "finance-application:create"}
</small>
</div>
</aside>
<section className="finance-main">
{loading ? <LinearProgress className="finance-progress" /> : null}
<div className="finance-content">{children}</div>
</section>
</main>
);
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 (
<main className="finance-shell">
<aside className="finance-sidebar">
<div className="finance-brand">
<span>HY</span>
<div>
<strong>财务中台</strong>
<small>Finance Console</small>
</div>
</div>
<nav className="finance-nav" aria-label="财务系统菜单">
{NAV_GROUPS.map((group) => {
const visible = group.views.filter((view) => allows(view.id));
if (!visible.length) {
return null;
}
return (
<div className="finance-nav__group" key={group.label}>
<span className="finance-nav__label">{group.label}</span>
{visible.map((view) => {
const Icon = view.icon;
return (
<button
className={activeView === view.id ? "is-active" : ""}
key={view.id}
type="button"
onClick={() => onViewChange(view.id)}
>
<Icon fontSize="small" />
<span>{view.label}</span>
</button>
);
})}
</div>
);
})}
</nav>
<div className="finance-user">
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
<small>财务系统</small>
</div>
</aside>
<section className="finance-main">
{loading ? <LinearProgress className="finance-progress" /> : null}
<header className="finance-topbar">
<div className="finance-topbar__title">
<h1>{VIEW_TITLES[activeView] || "财务系统"}</h1>
{subtitle ? <small>{subtitle}</small> : null}
</div>
{toolbar ? <div className="finance-topbar__tools">{toolbar}</div> : null}
</header>
<div className="finance-content">{children}</div>
</section>
</main>
);
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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?: {