新财务系统
This commit is contained in:
parent
9fe67dc8d1
commit
f6822d2411
@ -2825,6 +2825,69 @@
|
|||||||
"x-permissions": ["payment-bill:view"]
|
"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": {
|
"/admin/payment/recharge-bills/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getRechargeBillSummary",
|
"operationId": "getRechargeBillSummary",
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import {
|
|||||||
exportFinanceRechargeBills,
|
exportFinanceRechargeBills,
|
||||||
fetchFinanceApplicationOptions,
|
fetchFinanceApplicationOptions,
|
||||||
fetchFinanceRechargeApps,
|
fetchFinanceRechargeApps,
|
||||||
fetchFinanceRechargeSummary,
|
fetchFinanceRechargeOverview,
|
||||||
|
fetchFinanceRechargeSummaries,
|
||||||
fetchFinanceSession,
|
fetchFinanceSession,
|
||||||
filterOperationsByPermissions,
|
filterOperationsByPermissions,
|
||||||
listFinanceApplications,
|
listFinanceApplications,
|
||||||
@ -25,31 +26,87 @@ import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx"
|
|||||||
import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx";
|
import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx";
|
||||||
import { FinanceAuditDialog } from "./components/FinanceAuditDialog.jsx";
|
import { FinanceAuditDialog } from "./components/FinanceAuditDialog.jsx";
|
||||||
import { FinanceMyApplicationList } from "./components/FinanceMyApplicationList.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 { FinanceRechargeDetailList } from "./components/FinanceRechargeDetailList.jsx";
|
||||||
import { FinanceShell } from "./components/FinanceShell.jsx";
|
import { FinanceShell } from "./components/FinanceShell.jsx";
|
||||||
import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx";
|
import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx";
|
||||||
import { DEFAULT_APPLICATION_FORM } from "./constants.js";
|
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 { downloadCsv } from "@/shared/api/download";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const emptyOptions = { apps: [], operations: [], walletIdentities: [] };
|
const emptyOptions = { apps: [], operations: [], walletIdentities: [] };
|
||||||
const pageSize = 50;
|
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() {
|
export function FinanceApp() {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||||
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
|
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
|
||||||
const [rechargeDetailsState, setRechargeDetailsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, 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 [rechargeRegionsState, setRechargeRegionsState] = useState({ appCode: "", items: [], loading: false });
|
||||||
const [rechargeAppsState, setRechargeAppsState] = useState({ items: [], loaded: false });
|
const [rechargeAppsState, setRechargeAppsState] = useState({ items: [], loaded: false });
|
||||||
const [googlePaidRefreshing, setGooglePaidRefreshing] = useState(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 [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 [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 [withdrawalsState, setWithdrawalsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||||
const [activeView, setActiveView] = useState("create");
|
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 [rechargeExporting, setRechargeExporting] = useState(false);
|
||||||
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
|
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
|
||||||
const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" });
|
const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" });
|
||||||
@ -72,7 +129,7 @@ export function FinanceApp() {
|
|||||||
setSessionState({ error: "", loading: false, session });
|
setSessionState({ error: "", loading: false, session });
|
||||||
setActiveView(
|
setActiveView(
|
||||||
session.canViewAppRechargeDetails
|
session.canViewAppRechargeDetails
|
||||||
? "rechargeDetails"
|
? "overview"
|
||||||
: session.canAuditApplication
|
: session.canAuditApplication
|
||||||
? "applications"
|
? "applications"
|
||||||
: session.canCreateApplication
|
: session.canCreateApplication
|
||||||
@ -92,13 +149,14 @@ export function FinanceApp() {
|
|||||||
|
|
||||||
const session = sessionState.session;
|
const session = sessionState.session;
|
||||||
const canLoadOptions = Boolean(session?.canAccessFinance);
|
const canLoadOptions = Boolean(session?.canAccessFinance);
|
||||||
const rechargeDetailQuery = useMemo(
|
const canViewFinance = Boolean(session?.canViewAppRechargeDetails);
|
||||||
|
|
||||||
|
// 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。
|
||||||
|
const rechargeBaseQuery = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
app_code: rechargeDetailFilters.appCode,
|
app_code: rechargeDetailFilters.appCode,
|
||||||
end_at_ms: rechargeDetailFilters.timeRange.endMs,
|
end_at_ms: rechargeDetailFilters.timeRange.endMs,
|
||||||
keyword: rechargeDetailFilters.keyword,
|
keyword: rechargeDetailFilters.keyword,
|
||||||
page: rechargeDetailFilters.page,
|
|
||||||
page_size: pageSize,
|
|
||||||
recharge_type: rechargeDetailFilters.rechargeType,
|
recharge_type: rechargeDetailFilters.rechargeType,
|
||||||
region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "",
|
region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "",
|
||||||
start_at_ms: rechargeDetailFilters.timeRange.startMs,
|
start_at_ms: rechargeDetailFilters.timeRange.startMs,
|
||||||
@ -107,6 +165,15 @@ export function FinanceApp() {
|
|||||||
}),
|
}),
|
||||||
[rechargeDetailFilters]
|
[rechargeDetailFilters]
|
||||||
);
|
);
|
||||||
|
const rechargeDetailQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
...rechargeBaseQuery,
|
||||||
|
page: rechargeDetailFilters.page,
|
||||||
|
page_size: pageSize,
|
||||||
|
paid_state: rechargeDetailFilters.paidState
|
||||||
|
}),
|
||||||
|
[rechargeBaseQuery, rechargeDetailFilters.page, rechargeDetailFilters.paidState]
|
||||||
|
);
|
||||||
const myApplicationQuery = useMemo(
|
const myApplicationQuery = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
app_code: myFilters.appCode,
|
app_code: myFilters.appCode,
|
||||||
@ -165,14 +232,14 @@ export function FinanceApp() {
|
|||||||
}
|
}
|
||||||
}, [myApplicationQuery, session?.canCreateApplication]);
|
}, [myApplicationQuery, session?.canCreateApplication]);
|
||||||
|
|
||||||
// 充值详情的 App 目录来自 recharge-apps(含 Yumi 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。
|
// 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。
|
||||||
const rechargeApps = useMemo(
|
const rechargeApps = useMemo(
|
||||||
() => (rechargeAppsState.loaded && rechargeAppsState.items.length ? rechargeAppsState.items : optionsState.data.apps),
|
() => (rechargeAppsState.loaded && rechargeAppsState.items.length ? rechargeAppsState.items : optionsState.data.apps),
|
||||||
[optionsState.data.apps, rechargeAppsState]
|
[optionsState.data.apps, rechargeAppsState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadRechargeApps = useCallback(async () => {
|
const loadRechargeApps = useCallback(async () => {
|
||||||
if (!session?.canViewAppRechargeDetails) {
|
if (!canViewFinance) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -181,10 +248,10 @@ export function FinanceApp() {
|
|||||||
} catch {
|
} catch {
|
||||||
setRechargeAppsState({ items: [], loaded: false });
|
setRechargeAppsState({ items: [], loaded: false });
|
||||||
}
|
}
|
||||||
}, [session?.canViewAppRechargeDetails]);
|
}, [canViewFinance]);
|
||||||
|
|
||||||
const loadRechargeDetails = useCallback(async () => {
|
const loadRechargeDetails = useCallback(async () => {
|
||||||
if (!session?.canViewAppRechargeDetails) {
|
if (!canViewFinance) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRechargeDetailsState((current) => ({ ...current, error: "", loading: true }));
|
setRechargeDetailsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
@ -192,26 +259,71 @@ export function FinanceApp() {
|
|||||||
const data = await listFinanceRechargeDetails(rechargeDetailQuery, rechargeApps);
|
const data = await listFinanceRechargeDetails(rechargeDetailQuery, rechargeApps);
|
||||||
setRechargeDetailsState({ data, error: "", loading: false });
|
setRechargeDetailsState({ data, error: "", loading: false });
|
||||||
} catch (err) {
|
} 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 () => {
|
const loadRechargeSummary = useCallback(async () => {
|
||||||
if (!session?.canViewAppRechargeDetails) {
|
if (!canViewFinance) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRechargeSummaryState((current) => ({ ...current, error: "", loading: true }));
|
setRechargeSummaryState((current) => ({ ...current, loading: true }));
|
||||||
try {
|
try {
|
||||||
const data = await fetchFinanceRechargeSummary(rechargeDetailQuery, rechargeApps);
|
const { merged, perApp } = await fetchFinanceRechargeSummaries(rechargeBaseQuery, rechargeApps);
|
||||||
setRechargeSummaryState({ data, error: "", loading: false });
|
// 环比:把当前时间范围整体前移一个周期再查一次;未选时间范围时不算环比。
|
||||||
} catch (err) {
|
let prev = null;
|
||||||
setRechargeSummaryState((current) => ({ ...current, error: err.message || "加载充值汇总失败", loading: false }));
|
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 rechargeAppCode = rechargeDetailFilters.appCode;
|
||||||
const loadRechargeRegions = useCallback(async () => {
|
const loadRechargeRegions = useCallback(async () => {
|
||||||
if (!session?.canViewAppRechargeDetails || !rechargeAppCode) {
|
if (!canViewFinance || !rechargeAppCode) {
|
||||||
setRechargeRegionsState({ appCode: "", items: [], loading: false });
|
setRechargeRegionsState({ appCode: "", items: [], loading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -222,7 +334,7 @@ export function FinanceApp() {
|
|||||||
} catch {
|
} catch {
|
||||||
setRechargeRegionsState({ appCode: rechargeAppCode, items: [], loading: false });
|
setRechargeRegionsState({ appCode: rechargeAppCode, items: [], loading: false });
|
||||||
}
|
}
|
||||||
}, [rechargeAppCode, session?.canViewAppRechargeDetails]);
|
}, [canViewFinance, rechargeAppCode]);
|
||||||
|
|
||||||
const refreshGooglePaid = useCallback(
|
const refreshGooglePaid = useCallback(
|
||||||
async (transactionIds) => {
|
async (transactionIds) => {
|
||||||
@ -238,14 +350,14 @@ export function FinanceApp() {
|
|||||||
failed ? `谷歌实付同步完成:成功 ${succeeded} 笔,失败 ${failed} 笔` : `谷歌实付同步完成:${succeeded} 笔`,
|
failed ? `谷歌实付同步完成:成功 ${succeeded} 笔,失败 ${failed} 笔` : `谷歌实付同步完成:${succeeded} 笔`,
|
||||||
failed ? "warning" : "success"
|
failed ? "warning" : "success"
|
||||||
);
|
);
|
||||||
await loadRechargeDetails();
|
await Promise.all([loadRechargeDetails(), loadRechargeOverview()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "查询谷歌实付失败", "error");
|
showToast(err.message || "查询谷歌实付失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setGooglePaidRefreshing(false);
|
setGooglePaidRefreshing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[googlePaidRefreshing, loadRechargeDetails, rechargeAppCode, showToast]
|
[googlePaidRefreshing, loadRechargeDetails, loadRechargeOverview, rechargeAppCode, showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportRechargeBills = useCallback(async () => {
|
const exportRechargeBills = useCallback(async () => {
|
||||||
@ -315,6 +427,14 @@ export function FinanceApp() {
|
|||||||
loadRechargeSummary();
|
loadRechargeSummary();
|
||||||
}, [loadRechargeSummary]);
|
}, [loadRechargeSummary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRechargeOverview();
|
||||||
|
}, [loadRechargeOverview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecon();
|
||||||
|
}, [loadRecon]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRechargeRegions();
|
loadRechargeRegions();
|
||||||
}, [loadRechargeRegions]);
|
}, [loadRechargeRegions]);
|
||||||
@ -434,6 +554,74 @@ export function FinanceApp() {
|
|||||||
return <StateScreen title="无财务系统权限" />;
|
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 (
|
return (
|
||||||
<FinanceShell
|
<FinanceShell
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
@ -443,26 +631,59 @@ export function FinanceApp() {
|
|||||||
canViewWithdrawals={session.canViewWithdrawalApplications}
|
canViewWithdrawals={session.canViewWithdrawalApplications}
|
||||||
loading={optionsState.loading || rechargeDetailsState.loading || myApplicationsState.loading || applicationsState.loading || withdrawalsState.loading}
|
loading={optionsState.loading || rechargeDetailsState.loading || myApplicationsState.loading || applicationsState.loading || withdrawalsState.loading}
|
||||||
session={session}
|
session={session}
|
||||||
|
subtitle={isFinanceView ? "中国时区 · 数据实时" : ""}
|
||||||
|
toolbar={financeToolbar}
|
||||||
onViewChange={setActiveView}
|
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
|
<FinanceRechargeDetailList
|
||||||
|
apps={rechargeApps}
|
||||||
bills={rechargeDetailsState.data}
|
bills={rechargeDetailsState.data}
|
||||||
|
currency={rechargeDetailFilters.currency}
|
||||||
error={rechargeDetailsState.error}
|
error={rechargeDetailsState.error}
|
||||||
exporting={rechargeExporting}
|
exporting={rechargeExporting}
|
||||||
filters={rechargeDetailFilters}
|
filters={rechargeDetailFilters}
|
||||||
googlePaidRefreshing={googlePaidRefreshing}
|
googlePaidRefreshing={googlePaidRefreshing}
|
||||||
loading={rechargeDetailsState.loading}
|
loading={rechargeDetailsState.loading}
|
||||||
options={{ ...optionsState.data, apps: rechargeApps }}
|
|
||||||
regions={rechargeRegionsState.items}
|
regions={rechargeRegionsState.items}
|
||||||
summary={rechargeSummaryState.data}
|
summary={rechargeSummaryState.merged}
|
||||||
summaryLoading={rechargeSummaryState.loading}
|
|
||||||
onExport={exportRechargeBills}
|
onExport={exportRechargeBills}
|
||||||
onFiltersChange={updateRechargeDetailFilters}
|
onFiltersChange={updateRechargeDetailFilters}
|
||||||
onRefreshGooglePaid={refreshGooglePaid}
|
onRefreshGooglePaid={refreshGooglePaid}
|
||||||
onReload={() => {
|
onReload={() => {
|
||||||
loadRechargeDetails();
|
loadRechargeDetails();
|
||||||
loadRechargeSummary();
|
loadRechargeSummary();
|
||||||
|
loadRechargeOverview();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : activeView === "applications" && session.canAuditApplication ? (
|
) : activeView === "applications" && session.canAuditApplication ? (
|
||||||
|
|||||||
@ -3,9 +3,11 @@ import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/gen
|
|||||||
import { apiRequest, getAccessToken } from "@/shared/api/request";
|
import { apiRequest, getAccessToken } from "@/shared/api/request";
|
||||||
import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js";
|
import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js";
|
||||||
import {
|
import {
|
||||||
|
mergeRechargeOverviews,
|
||||||
mergeRechargeSummaries,
|
mergeRechargeSummaries,
|
||||||
normalizeApplicationPage,
|
normalizeApplicationPage,
|
||||||
normalizeRechargeBillPage,
|
normalizeRechargeBillPage,
|
||||||
|
normalizeRechargeOverview,
|
||||||
normalizeRechargeRegions,
|
normalizeRechargeRegions,
|
||||||
normalizeRechargeSummary,
|
normalizeRechargeSummary,
|
||||||
normalizeWithdrawalApplicationPage
|
normalizeWithdrawalApplicationPage
|
||||||
@ -137,6 +139,35 @@ export async function listFinanceRechargeRegions(appCode) {
|
|||||||
return normalizeRechargeRegions(data);
|
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 = {}) {
|
export async function exportFinanceRechargeBills(appCode, query = {}) {
|
||||||
const endpoint = API_ENDPOINTS.exportRechargeBills;
|
const endpoint = API_ENDPOINTS.exportRechargeBills;
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.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 = {}) {
|
async function getRechargeSummaryForApp(appCode, query = {}) {
|
||||||
const endpoint = API_ENDPOINTS.getRechargeBillSummary;
|
const endpoint = API_ENDPOINTS.getRechargeBillSummary;
|
||||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillSummary), {
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillSummary), {
|
||||||
|
|||||||
372
finance/src/components/FinanceOverview.jsx
Normal file
372
finance/src/components/FinanceOverview.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,18 +3,9 @@ import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
|||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
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 TextField from "@mui/material/TextField";
|
||||||
import { useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
|
||||||
import {
|
import {
|
||||||
EMPTY_RECHARGE_SUMMARY,
|
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatMicroMoney,
|
formatMicroMoney,
|
||||||
formatTime,
|
formatTime,
|
||||||
@ -23,22 +14,19 @@ import {
|
|||||||
rechargeTypeLabel,
|
rechargeTypeLabel,
|
||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
|
|
||||||
const SOURCE_FILTERS = [
|
// 系统保存视图:财务高频筛选组合一键切换;与全局 rechargeType/paidState 联动。
|
||||||
{ label: "全部来源", value: "" },
|
const SAVED_VIEWS = [
|
||||||
{ label: "谷歌充值", value: "google_play_recharge" },
|
{ key: "all", label: "全部", paidState: "", rechargeType: "" },
|
||||||
{ label: "三方充值", value: "third_party" },
|
{ key: "google", label: "谷歌充值", paidState: "", rechargeType: "google_play_recharge" },
|
||||||
{ label: "币商充值", value: "coin_seller" },
|
{ key: "third", label: "三方充值", paidState: "", rechargeType: "third_party" },
|
||||||
];
|
{ key: "seller", label: "币商充值", paidState: "", rechargeType: "coin_seller" },
|
||||||
|
{ key: "unsynced", label: "实付未同步", paidState: "unsynced", rechargeType: "" },
|
||||||
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" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function FinanceRechargeDetailList({
|
export function FinanceRechargeDetailList({
|
||||||
|
apps,
|
||||||
bills,
|
bills,
|
||||||
|
currency,
|
||||||
error,
|
error,
|
||||||
exporting,
|
exporting,
|
||||||
filters,
|
filters,
|
||||||
@ -48,227 +36,234 @@ export function FinanceRechargeDetailList({
|
|||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
onRefreshGooglePaid,
|
onRefreshGooglePaid,
|
||||||
onReload,
|
onReload,
|
||||||
options,
|
|
||||||
regions,
|
regions,
|
||||||
summary,
|
summary,
|
||||||
summaryLoading,
|
|
||||||
}) {
|
}) {
|
||||||
const [showCoinColumns, setShowCoinColumns] = useState(false);
|
const [expandedId, setExpandedId] = useState("");
|
||||||
const items = bills.items || [];
|
const items = bills.items || [];
|
||||||
const page = Number(bills.page || filters.page || 1);
|
const page = Number(bills.page || filters.page || 1);
|
||||||
const pageSize = Number(bills.pageSize || 50);
|
const pageSize = Number(bills.pageSize || 50);
|
||||||
const total = Number(bills.total || 0);
|
const total = Number(bills.total || 0);
|
||||||
const hasNextPage = page * pageSize < total;
|
const hasNextPage = page * pageSize < total;
|
||||||
const regionOptions = regions || [];
|
const showCoins = currency === "coin";
|
||||||
const visibleColumnCount = showCoinColumns ? 9 : 7;
|
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
|
const pendingGoogleTxIds = items
|
||||||
.filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs)
|
.filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs)
|
||||||
.map((item) => item.transactionId)
|
.map((item) => item.transactionId)
|
||||||
.filter(Boolean);
|
.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 (
|
return (
|
||||||
<section className="finance-panel finance-list">
|
<section className="finance-card finance-list">
|
||||||
<RechargeKpiBand
|
<div className="finance-views" role="tablist" aria-label="流水视图">
|
||||||
activeFilter={filters.rechargeType || ""}
|
{SAVED_VIEWS.map((view) => (
|
||||||
loading={summaryLoading}
|
<button
|
||||||
showCoins={showCoinColumns}
|
aria-selected={activeView === view.key}
|
||||||
summary={summary}
|
className={activeView === view.key ? "is-on" : ""}
|
||||||
onSelect={(value) => onFiltersChange({ rechargeType: value })}
|
key={view.key}
|
||||||
/>
|
role="tab"
|
||||||
<div className="finance-filterbar">
|
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
|
<TextField
|
||||||
className="finance-filterbar__field"
|
className="finance-filterrow__region"
|
||||||
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"
|
|
||||||
disabled={!filters.appCode}
|
disabled={!filters.appCode}
|
||||||
label="区域"
|
label="区域"
|
||||||
select
|
select
|
||||||
title={filters.appCode ? "" : "先选择 APP"}
|
size="small"
|
||||||
|
title={filters.appCode ? "" : "先选择 App"}
|
||||||
value={filters.regionId}
|
value={filters.regionId}
|
||||||
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
|
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
|
||||||
>
|
>
|
||||||
<MenuItem value="">全部区域</MenuItem>
|
<MenuItem value="">全部区域</MenuItem>
|
||||||
{regionOptions.map((region) => (
|
{(regions || []).map((region) => (
|
||||||
<MenuItem key={region.regionId} value={String(region.regionId)}>
|
<MenuItem key={region.regionId} value={String(region.regionId)}>
|
||||||
{region.name || region.regionCode || region.regionId}
|
{region.name || region.regionCode || region.regionId}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
className="finance-filterbar__field"
|
className="finance-filterrow__search"
|
||||||
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"
|
|
||||||
label="订单搜索"
|
label="订单搜索"
|
||||||
placeholder="交易号 / 三方单号 / 凭证号"
|
placeholder="交易号 / GPA单号 / 凭证号"
|
||||||
|
size="small"
|
||||||
value={filters.keyword}
|
value={filters.keyword}
|
||||||
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
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>
|
||||||
<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}
|
{error ? <div className="finance-error">{error}</div> : null}
|
||||||
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||||
{!loading || items.length ? (
|
{!loading || items.length ? (
|
||||||
<TableContainer className="finance-table-wrap">
|
<div className="finance-tablewrap">
|
||||||
<Table
|
<table className="finance-flat-table finance-flat-table--bills">
|
||||||
className={[
|
<thead>
|
||||||
"finance-table",
|
<tr>
|
||||||
"finance-table--recharge-details",
|
<th>充值时间</th>
|
||||||
showCoinColumns ? "finance-table--recharge-details-with-coins" : "",
|
<th>APP</th>
|
||||||
]
|
<th>充值来源</th>
|
||||||
.filter(Boolean)
|
<th>充值订单号</th>
|
||||||
.join(" ")}
|
{showCoins ? <th className="r">充值金币</th> : null}
|
||||||
size="small"
|
<th className="r">账单金额</th>
|
||||||
stickyHeader
|
<th className="r">用户实付</th>
|
||||||
>
|
<th className="r">三方税率扣款</th>
|
||||||
<TableHead>
|
<th>区域</th>
|
||||||
<TableRow>
|
</tr>
|
||||||
<TableCell className="finance-time-cell">充值时间</TableCell>
|
</thead>
|
||||||
<TableCell>APP</TableCell>
|
<tbody>
|
||||||
<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>
|
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((item) => (
|
items.map((item) => {
|
||||||
<TableRow hover key={item.id || item.transactionId}>
|
const rowId = item.id || item.transactionId;
|
||||||
<TableCell className="finance-time-cell">
|
const expanded = expandedId === rowId;
|
||||||
{formatTime(item.createdAtMs)}
|
return (
|
||||||
</TableCell>
|
<Fragment key={rowId}>
|
||||||
<TableCell>{appName(item, options.apps)}</TableCell>
|
<tr
|
||||||
<TableCell>
|
className="finance-flat-table__row"
|
||||||
<SourceBadge rechargeType={item.rechargeType} />
|
onClick={() => setExpandedId(expanded ? "" : rowId)}
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className="finance-order-cell">
|
<td className="num">{formatTime(item.createdAtMs)}</td>
|
||||||
<Stacked
|
<td>{appName(item.appCode, apps)}</td>
|
||||||
primary={item.transactionId || "-"}
|
<td>
|
||||||
secondary={secondaryOrderNo(item)}
|
<span
|
||||||
/>
|
className={`finance-badge finance-badge--${badgeTone(item.rechargeType)}`}
|
||||||
</TableCell>
|
>
|
||||||
{showCoinColumns ? (
|
{rechargeTypeLabel(item.rechargeType)}
|
||||||
<TableCell align="right">
|
</span>
|
||||||
<AmountText value={item.coinAmount}>
|
</td>
|
||||||
{coinText(item.coinAmount)}
|
<td className="finance-order-cell">
|
||||||
</AmountText>
|
<span className="finance-stack">
|
||||||
</TableCell>
|
<span>{item.transactionId || "-"}</span>
|
||||||
) : null}
|
<small>{secondaryOrderNo(item)}</small>
|
||||||
{showCoinColumns ? <TableCell>{exchangeText(item)}</TableCell> : null}
|
</span>
|
||||||
<TableCell align="right">
|
</td>
|
||||||
<AmountText value={item.usdMinorAmount}>{billAmountText(item)}</AmountText>
|
{showCoins ? (
|
||||||
</TableCell>
|
<td className="r num">
|
||||||
<TableCell align="right">
|
<AmountText value={item.coinAmount}>
|
||||||
<AmountText value={item.userPaidAmountMicro}>
|
{coinText(item.coinAmount)}
|
||||||
{userPaidText(item)}
|
</AmountText>
|
||||||
</AmountText>
|
</td>
|
||||||
</TableCell>
|
) : null}
|
||||||
<TableCell align="right">{taxDeductionText(item)}</TableCell>
|
<td className="r num">
|
||||||
</TableRow>
|
<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>
|
<tr>
|
||||||
<TableCell align="center" colSpan={visibleColumnCount}>
|
<td colSpan={columnCount} style={{ textAlign: "center" }}>
|
||||||
当前筛选条件下没有账单
|
当前筛选条件下没有账单
|
||||||
</TableCell>
|
</td>
|
||||||
</TableRow>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</tbody>
|
||||||
</Table>
|
</table>
|
||||||
</TableContainer>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="finance-pagination">
|
<div className="finance-pagination">
|
||||||
<span className="finance-pagination__meta">
|
<span className="finance-pagination__meta num">
|
||||||
共 {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))} 页
|
||||||
</span>
|
</span>
|
||||||
<div className="finance-pagination__buttons">
|
<div className="finance-pagination__buttons">
|
||||||
<Button
|
<Button
|
||||||
@ -293,55 +288,137 @@ export function FinanceRechargeDetailList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RechargeKpiBand({ activeFilter, loading, onSelect, showCoins, summary }) {
|
// BillDetail 是行内展开的单笔明细:金额瀑布 + 单号集合 + 谷歌实付操作。
|
||||||
const data = summary || EMPTY_RECHARGE_SUMMARY;
|
function BillDetail({ googlePaidRefreshing, item, onRefreshGooglePaid }) {
|
||||||
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
|
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 (
|
return (
|
||||||
<div className="finance-kpi-band">
|
<div className="finance-detail">
|
||||||
{KPI_CARDS.map(({ filter, key, label, tone }) => {
|
<div>
|
||||||
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
|
<div className="finance-card__title" style={{ marginBottom: 10 }}>
|
||||||
const active = activeFilter === filter;
|
金额拆解{paid ? ` · 实付币种 ${currencyCode}` : ""}
|
||||||
return (
|
</div>
|
||||||
<button
|
{paid && (fee || tax || net) ? (
|
||||||
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
|
<div className="finance-wfall">
|
||||||
.filter(Boolean)
|
<DetailWfallRow
|
||||||
.join(" ")}
|
amount={paid}
|
||||||
key={key}
|
color="var(--fin-ink)"
|
||||||
title={filter ? `点击只看${label}` : "点击查看全部来源"}
|
currency={currencyCode}
|
||||||
type="button"
|
label="用户实付"
|
||||||
onClick={() => onSelect(active && filter ? "" : filter)}
|
max={paid}
|
||||||
>
|
/>
|
||||||
<span className="finance-kpi__label">{label}</span>
|
{tax ? (
|
||||||
<strong className="finance-kpi__value">
|
<DetailWfallRow
|
||||||
{loading ? "…" : formatUsdMinor(bucket.usdMinorAmount, "USD")}
|
amount={-tax}
|
||||||
</strong>
|
color="#C86A6A"
|
||||||
<small className="finance-kpi__meta">
|
currency={currencyCode}
|
||||||
{loading ? "统计中" : kpiMetaText(bucket, key, totalUsd, showCoins)}
|
label="代缴税费"
|
||||||
</small>
|
max={paid}
|
||||||
</button>
|
/>
|
||||||
);
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function kpiMetaText(bucket, key, totalUsd, showCoins) {
|
function DetailWfallRow({ amount, color, currency, label, max }) {
|
||||||
const parts = [`${formatAmount(bucket.billCount)} 笔`];
|
const negative = amount < 0;
|
||||||
if (key !== "total" && totalUsd > 0) {
|
const width = max > 0 ? (Math.abs(amount) / max) * 100 : 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 }) {
|
|
||||||
return (
|
return (
|
||||||
<span className={`finance-badge finance-badge--${rechargeSourceTone(rechargeType)}`}>
|
<div className="finance-wfall__row">
|
||||||
{rechargeTypeLabel(rechargeType)}
|
<span className="finance-wfall__label">{label}</span>
|
||||||
</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>;
|
return <span className={negative ? "finance-amount finance-amount--negative" : "finance-amount"}>{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
function badgeTone(rechargeType) {
|
||||||
const CHINA_OFFSET_MS = 8 * 3_600_000;
|
return rechargeSourceTone(rechargeType) === "google"
|
||||||
|
? "google"
|
||||||
function chinaDayStart(timestamp) {
|
: rechargeSourceTone(rechargeType) === "coinseller"
|
||||||
return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS;
|
? "coinseller"
|
||||||
|
: "thirdparty";
|
||||||
}
|
}
|
||||||
|
|
||||||
function timePresets() {
|
function rangeLabel(timeRange) {
|
||||||
const now = Date.now();
|
const start = timeRange?.startMs ? formatTime(timeRange.startMs) : "…";
|
||||||
const todayStart = chinaDayStart(now);
|
const end = timeRange?.endMs ? formatTime(timeRange.endMs) : "…";
|
||||||
const chinaNow = new Date(now + CHINA_OFFSET_MS);
|
return `${start} ~ ${end}`;
|
||||||
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 isPresetActive(timeRange, preset) {
|
function appName(appCode, apps) {
|
||||||
const current = timeRange || { endMs: "", startMs: "" };
|
return (apps || []).find((app) => app.appCode === appCode)?.appName || appCode || "-";
|
||||||
return (
|
|
||||||
String(current.startMs || "") === String(preset.range.startMs || "") &&
|
|
||||||
String(current.endMs || "") === String(preset.range.endMs || "")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stacked({ primary, secondary }) {
|
function regionText(item, regionNames) {
|
||||||
return (
|
const regionId = item.targetRegionId || item.sellerRegionId;
|
||||||
<span className="finance-stack">
|
if (!regionId) {
|
||||||
<span>{primary || "-"}</span>
|
return "—";
|
||||||
<small>{secondary || ""}</small>
|
}
|
||||||
</span>
|
return regionNames.get(String(regionId)) || `区域 ${regionId}`;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appName(item, apps) {
|
|
||||||
return item.appName || apps.find((app) => app.appCode === item.appCode)?.appName || item.appCode || "-";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function secondaryOrderNo(item) {
|
function secondaryOrderNo(item) {
|
||||||
@ -401,13 +462,6 @@ function coinText(value) {
|
|||||||
return text === "-" ? "-" : `${text} 金币`;
|
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) {
|
function billAmountText(item) {
|
||||||
if (item.billAmountText) {
|
if (item.billAmountText) {
|
||||||
return item.billAmountText;
|
return item.billAmountText;
|
||||||
@ -428,7 +482,7 @@ function userPaidText(item) {
|
|||||||
return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode);
|
return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode);
|
||||||
}
|
}
|
||||||
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
|
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
|
||||||
return "未同步(点“查询谷歌实付”)";
|
return "未同步";
|
||||||
}
|
}
|
||||||
if (hasPositiveNumber(item.userPaidAmountMinor)) {
|
if (hasPositiveNumber(item.userPaidAmountMinor)) {
|
||||||
return formatUsdMinor(item.userPaidAmountMinor, item.userPaidCurrencyCode || item.currencyCode || "USD");
|
return formatUsdMinor(item.userPaidAmountMinor, item.userPaidCurrencyCode || item.currencyCode || "USD");
|
||||||
@ -447,12 +501,7 @@ function taxDeductionText(item) {
|
|||||||
const percent = (deduction / item.userPaidAmountMicro) * 100;
|
const percent = (deduction / item.userPaidAmountMicro) * 100;
|
||||||
parts.push(`${percent.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`);
|
parts.push(`${percent.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`);
|
||||||
}
|
}
|
||||||
return (
|
return parts.join(" / ");
|
||||||
<span className="finance-stack">
|
|
||||||
<span>{parts.join(" / ")}</span>
|
|
||||||
<small>{deductionBreakdown(fee, tax, currency)}</small>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (item.taxDeductionText) {
|
if (item.taxDeductionText) {
|
||||||
return item.taxDeductionText;
|
return item.taxDeductionText;
|
||||||
@ -463,17 +512,6 @@ function taxDeductionText(item) {
|
|||||||
return "-";
|
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) {
|
function isGoogleBill(item) {
|
||||||
return item.rechargeType === "google_play_recharge" || item.providerCode === "google_play";
|
return item.rechargeType === "google_play_recharge" || item.providerCode === "google_play";
|
||||||
}
|
}
|
||||||
|
|||||||
152
finance/src/components/FinanceReconciliation.jsx
Normal file
152
finance/src/components/FinanceReconciliation.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,64 +1,117 @@
|
|||||||
import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined";
|
import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined";
|
||||||
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
||||||
|
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||||
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
||||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
|
import RuleFolderOutlined from "@mui/icons-material/RuleFolderOutlined";
|
||||||
import LinearProgress from "@mui/material/LinearProgress";
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
|
|
||||||
const views = [
|
const NAV_GROUPS = [
|
||||||
{ icon: ReceiptLongOutlined, id: "rechargeDetails", label: "APP充值详情" },
|
{
|
||||||
{ icon: AssignmentOutlined, id: "create", label: "发起申请" },
|
label: "经营",
|
||||||
{ icon: FactCheckOutlined, id: "applications", label: "申请审核" },
|
views: [
|
||||||
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" }
|
{ 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 VIEW_TITLES = {
|
||||||
const visibleViews = views.filter((view) => {
|
applications: "申请审核",
|
||||||
if (view.id === "rechargeDetails") {
|
create: "发起申请",
|
||||||
return canViewRechargeDetails;
|
overview: "财务概览",
|
||||||
}
|
recon: "渠道对账",
|
||||||
if (view.id === "applications") {
|
rechargeDetails: "充值流水",
|
||||||
return canAudit;
|
withdrawals: "用户提现申请",
|
||||||
}
|
};
|
||||||
if (view.id === "withdrawals") {
|
|
||||||
return canViewWithdrawals;
|
export function FinanceShell({
|
||||||
}
|
activeView,
|
||||||
return canCreate;
|
canAudit,
|
||||||
});
|
canCreate,
|
||||||
return (
|
canViewRechargeDetails,
|
||||||
<main className="finance-shell">
|
canViewWithdrawals,
|
||||||
<aside className="finance-sidebar">
|
children,
|
||||||
<div className="finance-brand">
|
loading,
|
||||||
<span>HY</span>
|
onViewChange,
|
||||||
<strong>财务系统</strong>
|
session,
|
||||||
</div>
|
subtitle,
|
||||||
<nav className="finance-nav" aria-label="财务系统菜单">
|
toolbar,
|
||||||
{visibleViews.map((view) => {
|
}) {
|
||||||
const Icon = view.icon;
|
const allows = (viewId) => {
|
||||||
return (
|
if (viewId === "overview" || viewId === "rechargeDetails" || viewId === "recon") {
|
||||||
<button className={activeView === view.id ? "is-active" : ""} key={view.id} type="button" onClick={() => onViewChange(view.id)}>
|
return canViewRechargeDetails;
|
||||||
<Icon fontSize="small" />
|
}
|
||||||
<span>{view.label}</span>
|
if (viewId === "applications") {
|
||||||
</button>
|
return canAudit;
|
||||||
);
|
}
|
||||||
})}
|
if (viewId === "withdrawals") {
|
||||||
</nav>
|
return canViewWithdrawals;
|
||||||
<div className="finance-user">
|
}
|
||||||
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
|
return canCreate;
|
||||||
<small>
|
};
|
||||||
{canViewRechargeDetails
|
return (
|
||||||
? "payment-bill:view"
|
<main className="finance-shell">
|
||||||
: canAudit
|
<aside className="finance-sidebar">
|
||||||
? "finance-application:audit"
|
<div className="finance-brand">
|
||||||
: canViewWithdrawals
|
<span>HY</span>
|
||||||
? "finance-withdrawal:view"
|
<div>
|
||||||
: "finance-application:create"}
|
<strong>财务中台</strong>
|
||||||
</small>
|
<small>Finance Console</small>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
<section className="finance-main">
|
<nav className="finance-nav" aria-label="财务系统菜单">
|
||||||
{loading ? <LinearProgress className="finance-progress" /> : null}
|
{NAV_GROUPS.map((group) => {
|
||||||
<div className="finance-content">{children}</div>
|
const visible = group.views.filter((view) => allows(view.id));
|
||||||
</section>
|
if (!visible.length) {
|
||||||
</main>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -145,7 +145,10 @@ export function normalizeRechargeBill(item = {}) {
|
|||||||
providerNetMicro: numberOrNull(item.providerNetMicro ?? item.provider_net_micro),
|
providerNetMicro: numberOrNull(item.providerNetMicro ?? item.provider_net_micro),
|
||||||
providerTaxMicro: numberOrNull(item.providerTaxMicro ?? item.provider_tax_micro),
|
providerTaxMicro: numberOrNull(item.providerTaxMicro ?? item.provider_tax_micro),
|
||||||
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
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),
|
status: stringValue(item.status),
|
||||||
|
targetRegionId: numberOrNull(item.targetRegionId ?? item.target_region_id),
|
||||||
taxDeductionMinor: numberOrNull(
|
taxDeductionMinor: numberOrNull(
|
||||||
item.taxDeductionMinor ??
|
item.taxDeductionMinor ??
|
||||||
item.tax_deduction_minor ??
|
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),
|
taxRate: numberOrNull(item.taxRate ?? item.tax_rate ?? item.thirdPartyTaxRate ?? item.third_party_tax_rate),
|
||||||
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||||
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount),
|
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(
|
userPaidAmountMicro: numberOrNull(
|
||||||
item.userPaidAmountMicro ?? item.user_paid_amount_micro ?? item.paidAmountMicro ?? item.paid_amount_micro,
|
item.userPaidAmountMicro ?? item.user_paid_amount_micro ?? item.paidAmountMicro ?? item.paid_amount_micro,
|
||||||
),
|
),
|
||||||
@ -234,6 +242,83 @@ export function mergeRechargeSummaries(summaries = []) {
|
|||||||
return merged;
|
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 = {}) {
|
export function normalizeRechargeRegions(data = {}) {
|
||||||
const items = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : [];
|
const items = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : [];
|
||||||
return items
|
return items
|
||||||
|
|||||||
@ -30,10 +30,10 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-5);
|
gap: var(--space-4);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid #1c2a44;
|
||||||
background: var(--bg-card);
|
background: #101b2d;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5) var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-brand {
|
.finance-brand {
|
||||||
@ -54,10 +54,25 @@ a {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finance-brand {
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.finance-brand strong {
|
.finance-brand strong {
|
||||||
color: var(--text-primary);
|
display: block;
|
||||||
font-size: 18px;
|
color: #fff;
|
||||||
line-height: 1.3;
|
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 {
|
.finance-nav {
|
||||||
@ -65,31 +80,45 @@ a {
|
|||||||
gap: var(--space-2);
|
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 {
|
.finance-nav button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
height: 40px;
|
width: 100%;
|
||||||
border: 1px solid transparent;
|
height: 38px;
|
||||||
border-radius: var(--radius-control);
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: #93a1b8;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
text-align: left;
|
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 {
|
.finance-nav button:hover {
|
||||||
background: var(--bg-card-strong);
|
background: rgb(255 255 255 / 6%);
|
||||||
color: var(--text-primary);
|
color: #dce4f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-nav button.is-active {
|
.finance-nav button.is-active {
|
||||||
border-color: var(--primary-border);
|
background: #1b2a44;
|
||||||
background: var(--primary-hover);
|
color: #fff;
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,12 +126,16 @@ a {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
border-top: 1px solid var(--border);
|
padding-left: var(--space-3);
|
||||||
color: var(--text-primary);
|
color: #dce4f0;
|
||||||
|
border-top: 1px solid #1c2a44;
|
||||||
padding-top: var(--space-4);
|
padding-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-user small,
|
.finance-user small {
|
||||||
|
color: #7e8db0;
|
||||||
|
}
|
||||||
|
|
||||||
.finance-stack small {
|
.finance-stack small {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
@ -621,3 +654,628 @@ a {
|
|||||||
transition-duration: 0.001ms !important;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -121,6 +121,7 @@ export const API_OPERATIONS = {
|
|||||||
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
||||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||||
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
||||||
|
getRechargeBillOverview: "getRechargeBillOverview",
|
||||||
getRechargeBillSummary: "getRechargeBillSummary",
|
getRechargeBillSummary: "getRechargeBillSummary",
|
||||||
getRedPacket: "getRedPacket",
|
getRedPacket: "getRedPacket",
|
||||||
getRedPacketConfig: "getRedPacketConfig",
|
getRedPacketConfig: "getRedPacketConfig",
|
||||||
@ -1090,6 +1091,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["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: {
|
getRechargeBillSummary: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getRechargeBillSummary,
|
operationId: API_OPERATIONS.getRechargeBillSummary,
|
||||||
|
|||||||
36
src/shared/api/generated/schema.d.ts
vendored
36
src/shared/api/generated/schema.d.ts
vendored
@ -1876,6 +1876,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/payment/recharge-bills/summary": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -6815,6 +6831,26 @@ export interface operations {
|
|||||||
200: components["responses"]["RechargeBillPageResponse"];
|
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: {
|
getRechargeBillSummary: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user