新财务系统
This commit is contained in:
parent
9fe67dc8d1
commit
f6822d2411
@ -2825,6 +2825,69 @@
|
||||
"x-permissions": ["payment-bill:view"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/recharge-bills/overview": {
|
||||
"get": {
|
||||
"operationId": "getRechargeBillOverview",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "keyword",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "recharge_type",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "status",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "region_id",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "start_at_ms",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "end_at_ms",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tz_offset_minutes",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "payment-bill:view",
|
||||
"x-permissions": ["payment-bill:view"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/recharge-bills/summary": {
|
||||
"get": {
|
||||
"operationId": "getRechargeBillSummary",
|
||||
|
||||
@ -9,7 +9,8 @@ import {
|
||||
exportFinanceRechargeBills,
|
||||
fetchFinanceApplicationOptions,
|
||||
fetchFinanceRechargeApps,
|
||||
fetchFinanceRechargeSummary,
|
||||
fetchFinanceRechargeOverview,
|
||||
fetchFinanceRechargeSummaries,
|
||||
fetchFinanceSession,
|
||||
filterOperationsByPermissions,
|
||||
listFinanceApplications,
|
||||
@ -25,31 +26,87 @@ import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx"
|
||||
import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx";
|
||||
import { FinanceAuditDialog } from "./components/FinanceAuditDialog.jsx";
|
||||
import { FinanceMyApplicationList } from "./components/FinanceMyApplicationList.jsx";
|
||||
import { FinanceOverview } from "./components/FinanceOverview.jsx";
|
||||
import { FinanceReconciliation } from "./components/FinanceReconciliation.jsx";
|
||||
import { FinanceRechargeDetailList } from "./components/FinanceRechargeDetailList.jsx";
|
||||
import { FinanceShell } from "./components/FinanceShell.jsx";
|
||||
import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx";
|
||||
import { DEFAULT_APPLICATION_FORM } from "./constants.js";
|
||||
import { EMPTY_RECHARGE_SUMMARY, buildApplicationPayload, validateApplicationPayload } from "./format.js";
|
||||
import {
|
||||
EMPTY_RECHARGE_OVERVIEW,
|
||||
EMPTY_RECHARGE_SUMMARY,
|
||||
buildApplicationPayload,
|
||||
validateApplicationPayload
|
||||
} from "./format.js";
|
||||
import { downloadCsv } from "@/shared/api/download";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const emptyOptions = { apps: [], operations: [], walletIdentities: [] };
|
||||
const pageSize = 50;
|
||||
const DAY_MS = 86_400_000;
|
||||
const CHINA_OFFSET_MS = 8 * 3_600_000;
|
||||
const FINANCE_VIEWS = new Set(["overview", "rechargeDetails", "recon"]);
|
||||
|
||||
function chinaDayStart(timestamp) {
|
||||
return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS;
|
||||
}
|
||||
|
||||
function timePresets() {
|
||||
const now = Date.now();
|
||||
const todayStart = chinaDayStart(now);
|
||||
const chinaNow = new Date(now + CHINA_OFFSET_MS);
|
||||
const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS;
|
||||
return [
|
||||
{ key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } },
|
||||
{ key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } },
|
||||
{ key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } },
|
||||
{ key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } },
|
||||
{ key: "all", label: "不限", range: { endMs: "", startMs: "" } }
|
||||
];
|
||||
}
|
||||
|
||||
function reconMonthMeta(offset) {
|
||||
const chinaNow = new Date(Date.now() + CHINA_OFFSET_MS);
|
||||
const year = chinaNow.getUTCFullYear();
|
||||
const month = chinaNow.getUTCMonth() + offset;
|
||||
const startMs = Date.UTC(year, month, 1) - CHINA_OFFSET_MS;
|
||||
const endMs = Date.UTC(year, month + 1, 1) - CHINA_OFFSET_MS;
|
||||
const startDate = new Date(startMs + CHINA_OFFSET_MS);
|
||||
return {
|
||||
endMs,
|
||||
isCurrent: offset === 0,
|
||||
label: `${startDate.getUTCFullYear()}年${startDate.getUTCMonth() + 1}月`,
|
||||
startMs
|
||||
};
|
||||
}
|
||||
|
||||
export function FinanceApp() {
|
||||
const { showToast } = useToast();
|
||||
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
|
||||
const [rechargeDetailsState, setRechargeDetailsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||
const [rechargeSummaryState, setRechargeSummaryState] = useState({ data: EMPTY_RECHARGE_SUMMARY, error: "", loading: false });
|
||||
const [rechargeSummaryState, setRechargeSummaryState] = useState({ merged: EMPTY_RECHARGE_SUMMARY, loading: false, perApp: [], prev: null });
|
||||
const [rechargeOverviewState, setRechargeOverviewState] = useState({ data: EMPTY_RECHARGE_OVERVIEW, loading: false });
|
||||
const [rechargeRegionsState, setRechargeRegionsState] = useState({ appCode: "", items: [], loading: false });
|
||||
const [rechargeAppsState, setRechargeAppsState] = useState({ items: [], loaded: false });
|
||||
const [googlePaidRefreshing, setGooglePaidRefreshing] = useState(false);
|
||||
const [reconMonthOffset, setReconMonthOffset] = useState(0);
|
||||
const [reconState, setReconState] = useState({ loading: false, overview: EMPTY_RECHARGE_OVERVIEW, summary: EMPTY_RECHARGE_SUMMARY });
|
||||
const [myApplicationsState, setMyApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||
const [applicationsState, setApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||
const [withdrawalsState, setWithdrawalsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||
const [activeView, setActiveView] = useState("create");
|
||||
const [rechargeDetailFilters, setRechargeDetailFilters] = useState({ appCode: "", keyword: "", page: 1, rechargeType: "", regionId: "", timeRange: { endMs: "", startMs: "" } });
|
||||
const [rechargeDetailFilters, setRechargeDetailFilters] = useState(() => ({
|
||||
appCode: "",
|
||||
currency: "usd",
|
||||
keyword: "",
|
||||
page: 1,
|
||||
paidState: "",
|
||||
rechargeType: "",
|
||||
regionId: "",
|
||||
timeRange: timePresets().find((preset) => preset.key === "30d").range
|
||||
}));
|
||||
const [rechargeExporting, setRechargeExporting] = useState(false);
|
||||
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
|
||||
const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" });
|
||||
@ -72,7 +129,7 @@ export function FinanceApp() {
|
||||
setSessionState({ error: "", loading: false, session });
|
||||
setActiveView(
|
||||
session.canViewAppRechargeDetails
|
||||
? "rechargeDetails"
|
||||
? "overview"
|
||||
: session.canAuditApplication
|
||||
? "applications"
|
||||
: session.canCreateApplication
|
||||
@ -92,13 +149,14 @@ export function FinanceApp() {
|
||||
|
||||
const session = sessionState.session;
|
||||
const canLoadOptions = Boolean(session?.canAccessFinance);
|
||||
const rechargeDetailQuery = useMemo(
|
||||
const canViewFinance = Boolean(session?.canViewAppRechargeDetails);
|
||||
|
||||
// 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。
|
||||
const rechargeBaseQuery = useMemo(
|
||||
() => ({
|
||||
app_code: rechargeDetailFilters.appCode,
|
||||
end_at_ms: rechargeDetailFilters.timeRange.endMs,
|
||||
keyword: rechargeDetailFilters.keyword,
|
||||
page: rechargeDetailFilters.page,
|
||||
page_size: pageSize,
|
||||
recharge_type: rechargeDetailFilters.rechargeType,
|
||||
region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "",
|
||||
start_at_ms: rechargeDetailFilters.timeRange.startMs,
|
||||
@ -107,6 +165,15 @@ export function FinanceApp() {
|
||||
}),
|
||||
[rechargeDetailFilters]
|
||||
);
|
||||
const rechargeDetailQuery = useMemo(
|
||||
() => ({
|
||||
...rechargeBaseQuery,
|
||||
page: rechargeDetailFilters.page,
|
||||
page_size: pageSize,
|
||||
paid_state: rechargeDetailFilters.paidState
|
||||
}),
|
||||
[rechargeBaseQuery, rechargeDetailFilters.page, rechargeDetailFilters.paidState]
|
||||
);
|
||||
const myApplicationQuery = useMemo(
|
||||
() => ({
|
||||
app_code: myFilters.appCode,
|
||||
@ -165,14 +232,14 @@ export function FinanceApp() {
|
||||
}
|
||||
}, [myApplicationQuery, session?.canCreateApplication]);
|
||||
|
||||
// 充值详情的 App 目录来自 recharge-apps(含 Yumi 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。
|
||||
// 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。
|
||||
const rechargeApps = useMemo(
|
||||
() => (rechargeAppsState.loaded && rechargeAppsState.items.length ? rechargeAppsState.items : optionsState.data.apps),
|
||||
[optionsState.data.apps, rechargeAppsState]
|
||||
);
|
||||
|
||||
const loadRechargeApps = useCallback(async () => {
|
||||
if (!session?.canViewAppRechargeDetails) {
|
||||
if (!canViewFinance) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -181,10 +248,10 @@ export function FinanceApp() {
|
||||
} catch {
|
||||
setRechargeAppsState({ items: [], loaded: false });
|
||||
}
|
||||
}, [session?.canViewAppRechargeDetails]);
|
||||
}, [canViewFinance]);
|
||||
|
||||
const loadRechargeDetails = useCallback(async () => {
|
||||
if (!session?.canViewAppRechargeDetails) {
|
||||
if (!canViewFinance) {
|
||||
return;
|
||||
}
|
||||
setRechargeDetailsState((current) => ({ ...current, error: "", loading: true }));
|
||||
@ -192,26 +259,71 @@ export function FinanceApp() {
|
||||
const data = await listFinanceRechargeDetails(rechargeDetailQuery, rechargeApps);
|
||||
setRechargeDetailsState({ data, error: "", loading: false });
|
||||
} catch (err) {
|
||||
setRechargeDetailsState((current) => ({ ...current, error: err.message || "加载 APP 充值详情失败", loading: false }));
|
||||
setRechargeDetailsState((current) => ({ ...current, error: err.message || "加载充值流水失败", loading: false }));
|
||||
}
|
||||
}, [rechargeApps, rechargeDetailQuery, session?.canViewAppRechargeDetails]);
|
||||
}, [canViewFinance, rechargeApps, rechargeDetailQuery]);
|
||||
|
||||
const loadRechargeSummary = useCallback(async () => {
|
||||
if (!session?.canViewAppRechargeDetails) {
|
||||
if (!canViewFinance) {
|
||||
return;
|
||||
}
|
||||
setRechargeSummaryState((current) => ({ ...current, error: "", loading: true }));
|
||||
setRechargeSummaryState((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const data = await fetchFinanceRechargeSummary(rechargeDetailQuery, rechargeApps);
|
||||
setRechargeSummaryState({ data, error: "", loading: false });
|
||||
} catch (err) {
|
||||
setRechargeSummaryState((current) => ({ ...current, error: err.message || "加载充值汇总失败", loading: false }));
|
||||
const { merged, perApp } = await fetchFinanceRechargeSummaries(rechargeBaseQuery, rechargeApps);
|
||||
// 环比:把当前时间范围整体前移一个周期再查一次;未选时间范围时不算环比。
|
||||
let prev = null;
|
||||
const { endMs, startMs } = rechargeDetailFilters.timeRange;
|
||||
if (startMs && endMs && endMs > startMs) {
|
||||
const length = endMs - startMs;
|
||||
const prevQuery = { ...rechargeBaseQuery, end_at_ms: startMs, start_at_ms: startMs - length };
|
||||
prev = (await fetchFinanceRechargeSummaries(prevQuery, rechargeApps)).merged;
|
||||
}
|
||||
setRechargeSummaryState({ merged, loading: false, perApp, prev });
|
||||
} catch {
|
||||
setRechargeSummaryState((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [rechargeApps, rechargeDetailQuery, session?.canViewAppRechargeDetails]);
|
||||
}, [canViewFinance, rechargeApps, rechargeBaseQuery, rechargeDetailFilters.timeRange]);
|
||||
|
||||
const loadRechargeOverview = useCallback(async () => {
|
||||
if (!canViewFinance) {
|
||||
return;
|
||||
}
|
||||
setRechargeOverviewState((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const data = await fetchFinanceRechargeOverview(rechargeBaseQuery, rechargeApps);
|
||||
setRechargeOverviewState({ data, loading: false });
|
||||
} catch {
|
||||
setRechargeOverviewState((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [canViewFinance, rechargeApps, rechargeBaseQuery]);
|
||||
|
||||
const loadRecon = useCallback(async () => {
|
||||
if (!canViewFinance || activeView !== "recon") {
|
||||
return;
|
||||
}
|
||||
const month = reconMonthMeta(reconMonthOffset);
|
||||
const query = {
|
||||
...rechargeBaseQuery,
|
||||
end_at_ms: month.endMs,
|
||||
keyword: "",
|
||||
recharge_type: "",
|
||||
start_at_ms: month.startMs
|
||||
};
|
||||
setReconState((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const [{ merged }, overview] = await Promise.all([
|
||||
fetchFinanceRechargeSummaries(query, rechargeApps),
|
||||
fetchFinanceRechargeOverview(query, rechargeApps)
|
||||
]);
|
||||
setReconState({ loading: false, overview, summary: merged });
|
||||
} catch {
|
||||
setReconState((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [activeView, canViewFinance, rechargeApps, rechargeBaseQuery, reconMonthOffset]);
|
||||
|
||||
const rechargeAppCode = rechargeDetailFilters.appCode;
|
||||
const loadRechargeRegions = useCallback(async () => {
|
||||
if (!session?.canViewAppRechargeDetails || !rechargeAppCode) {
|
||||
if (!canViewFinance || !rechargeAppCode) {
|
||||
setRechargeRegionsState({ appCode: "", items: [], loading: false });
|
||||
return;
|
||||
}
|
||||
@ -222,7 +334,7 @@ export function FinanceApp() {
|
||||
} catch {
|
||||
setRechargeRegionsState({ appCode: rechargeAppCode, items: [], loading: false });
|
||||
}
|
||||
}, [rechargeAppCode, session?.canViewAppRechargeDetails]);
|
||||
}, [canViewFinance, rechargeAppCode]);
|
||||
|
||||
const refreshGooglePaid = useCallback(
|
||||
async (transactionIds) => {
|
||||
@ -238,14 +350,14 @@ export function FinanceApp() {
|
||||
failed ? `谷歌实付同步完成:成功 ${succeeded} 笔,失败 ${failed} 笔` : `谷歌实付同步完成:${succeeded} 笔`,
|
||||
failed ? "warning" : "success"
|
||||
);
|
||||
await loadRechargeDetails();
|
||||
await Promise.all([loadRechargeDetails(), loadRechargeOverview()]);
|
||||
} catch (err) {
|
||||
showToast(err.message || "查询谷歌实付失败", "error");
|
||||
} finally {
|
||||
setGooglePaidRefreshing(false);
|
||||
}
|
||||
},
|
||||
[googlePaidRefreshing, loadRechargeDetails, rechargeAppCode, showToast]
|
||||
[googlePaidRefreshing, loadRechargeDetails, loadRechargeOverview, rechargeAppCode, showToast]
|
||||
);
|
||||
|
||||
const exportRechargeBills = useCallback(async () => {
|
||||
@ -315,6 +427,14 @@ export function FinanceApp() {
|
||||
loadRechargeSummary();
|
||||
}, [loadRechargeSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRechargeOverview();
|
||||
}, [loadRechargeOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecon();
|
||||
}, [loadRecon]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRechargeRegions();
|
||||
}, [loadRechargeRegions]);
|
||||
@ -434,6 +554,74 @@ export function FinanceApp() {
|
||||
return <StateScreen title="无财务系统权限" />;
|
||||
}
|
||||
|
||||
const presets = timePresets();
|
||||
const activePreset = presets.find(
|
||||
(preset) =>
|
||||
String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") &&
|
||||
String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || "")
|
||||
);
|
||||
const isFinanceView = FINANCE_VIEWS.has(activeView);
|
||||
const financeToolbar =
|
||||
isFinanceView && session.canViewAppRechargeDetails ? (
|
||||
<>
|
||||
<div className="finance-seg" role="group" aria-label="时间范围">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
className={activePreset?.key === preset.key ? "is-on" : ""}
|
||||
key={preset.key}
|
||||
type="button"
|
||||
onClick={() => updateRechargeDetailFilters({ timeRange: preset.range })}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TimeRangeFilter
|
||||
label="自定义 · 中国时区"
|
||||
value={rechargeDetailFilters.timeRange}
|
||||
onChange={(timeRange) => updateRechargeDetailFilters({ timeRange })}
|
||||
/>
|
||||
<div className="finance-seg" role="group" aria-label="计价单位">
|
||||
{[
|
||||
["usd", "USD"],
|
||||
["coin", "金币"]
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
className={rechargeDetailFilters.currency === value ? "is-on" : ""}
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => updateRechargeDetailFilters({ currency: value, page: rechargeDetailFilters.page })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const appBar =
|
||||
isFinanceView && session.canViewAppRechargeDetails ? (
|
||||
<div className="finance-appbar">
|
||||
<button
|
||||
className={["finance-appchip", rechargeDetailFilters.appCode === "" ? "is-on" : ""].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={() => updateRechargeDetailFilters({ appCode: "", regionId: "" })}
|
||||
>
|
||||
全部 App
|
||||
</button>
|
||||
{rechargeApps.map((app) => (
|
||||
<button
|
||||
className={["finance-appchip", rechargeDetailFilters.appCode === app.appCode ? "is-on" : ""].filter(Boolean).join(" ")}
|
||||
key={app.appCode}
|
||||
type="button"
|
||||
onClick={() => updateRechargeDetailFilters({ appCode: app.appCode, regionId: "" })}
|
||||
>
|
||||
{app.appName || app.appCode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<FinanceShell
|
||||
activeView={activeView}
|
||||
@ -443,26 +631,59 @@ export function FinanceApp() {
|
||||
canViewWithdrawals={session.canViewWithdrawalApplications}
|
||||
loading={optionsState.loading || rechargeDetailsState.loading || myApplicationsState.loading || applicationsState.loading || withdrawalsState.loading}
|
||||
session={session}
|
||||
subtitle={isFinanceView ? "中国时区 · 数据实时" : ""}
|
||||
toolbar={financeToolbar}
|
||||
onViewChange={setActiveView}
|
||||
>
|
||||
{activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? (
|
||||
{appBar}
|
||||
{activeView === "overview" && session.canViewAppRechargeDetails ? (
|
||||
<FinanceOverview
|
||||
apps={rechargeApps}
|
||||
currency={rechargeDetailFilters.currency}
|
||||
filters={rechargeDetailFilters}
|
||||
loading={rechargeSummaryState.loading || rechargeOverviewState.loading}
|
||||
overview={rechargeOverviewState.data}
|
||||
perAppSummaries={rechargeSummaryState.perApp}
|
||||
prevSummary={rechargeSummaryState.prev}
|
||||
regions={rechargeOverviewState.data.regions}
|
||||
summary={rechargeSummaryState.merged}
|
||||
onFiltersChange={updateRechargeDetailFilters}
|
||||
onGoUnsynced={() => {
|
||||
updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" });
|
||||
setActiveView("rechargeDetails");
|
||||
}}
|
||||
onOpenApp={(appCode) => {
|
||||
updateRechargeDetailFilters({ appCode, regionId: "" });
|
||||
setActiveView("rechargeDetails");
|
||||
}}
|
||||
/>
|
||||
) : activeView === "recon" && session.canViewAppRechargeDetails ? (
|
||||
<FinanceReconciliation
|
||||
loading={reconState.loading}
|
||||
month={reconMonthMeta(reconMonthOffset)}
|
||||
overview={reconState.overview}
|
||||
summary={reconState.summary}
|
||||
onMonthChange={(delta) => setReconMonthOffset((current) => Math.min(0, current + delta))}
|
||||
/>
|
||||
) : activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? (
|
||||
<FinanceRechargeDetailList
|
||||
apps={rechargeApps}
|
||||
bills={rechargeDetailsState.data}
|
||||
currency={rechargeDetailFilters.currency}
|
||||
error={rechargeDetailsState.error}
|
||||
exporting={rechargeExporting}
|
||||
filters={rechargeDetailFilters}
|
||||
googlePaidRefreshing={googlePaidRefreshing}
|
||||
loading={rechargeDetailsState.loading}
|
||||
options={{ ...optionsState.data, apps: rechargeApps }}
|
||||
regions={rechargeRegionsState.items}
|
||||
summary={rechargeSummaryState.data}
|
||||
summaryLoading={rechargeSummaryState.loading}
|
||||
summary={rechargeSummaryState.merged}
|
||||
onExport={exportRechargeBills}
|
||||
onFiltersChange={updateRechargeDetailFilters}
|
||||
onRefreshGooglePaid={refreshGooglePaid}
|
||||
onReload={() => {
|
||||
loadRechargeDetails();
|
||||
loadRechargeSummary();
|
||||
loadRechargeOverview();
|
||||
}}
|
||||
/>
|
||||
) : activeView === "applications" && session.canAuditApplication ? (
|
||||
|
||||
@ -3,9 +3,11 @@ import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/gen
|
||||
import { apiRequest, getAccessToken } from "@/shared/api/request";
|
||||
import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js";
|
||||
import {
|
||||
mergeRechargeOverviews,
|
||||
mergeRechargeSummaries,
|
||||
normalizeApplicationPage,
|
||||
normalizeRechargeBillPage,
|
||||
normalizeRechargeOverview,
|
||||
normalizeRechargeRegions,
|
||||
normalizeRechargeSummary,
|
||||
normalizeWithdrawalApplicationPage
|
||||
@ -137,6 +139,35 @@ export async function listFinanceRechargeRegions(appCode) {
|
||||
return normalizeRechargeRegions(data);
|
||||
}
|
||||
|
||||
// fetchFinanceRechargeSummaries 返回逐 App 的汇总(App 对比表用)和全局合并值。
|
||||
export async function fetchFinanceRechargeSummaries(query = {}, apps = []) {
|
||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
||||
const cleanQuery = cleanRechargeBillQuery(query);
|
||||
const appList = selectedAppCode
|
||||
? normalizeApps(apps).filter((app) => app.appCode === selectedAppCode)
|
||||
: normalizeApps(apps);
|
||||
if (!appList.length && selectedAppCode) {
|
||||
appList.push({ appCode: selectedAppCode, appName: selectedAppCode });
|
||||
}
|
||||
const summaries = await Promise.all(appList.map((app) => getRechargeSummaryForApp(app.appCode, cleanQuery)));
|
||||
const perApp = appList.map((app, index) => ({ ...app, summary: summaries[index] }));
|
||||
return { merged: mergeRechargeSummaries(summaries), perApp };
|
||||
}
|
||||
|
||||
export async function fetchFinanceRechargeOverview(query = {}, apps = []) {
|
||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
||||
const cleanQuery = { ...cleanRechargeBillQuery(query), tz_offset_minutes: 480 };
|
||||
if (selectedAppCode) {
|
||||
return getRechargeOverviewForApp(selectedAppCode, cleanQuery);
|
||||
}
|
||||
const appCodes = normalizeApps(apps).map((app) => app.appCode);
|
||||
if (!appCodes.length) {
|
||||
return normalizeRechargeOverview({});
|
||||
}
|
||||
const overviews = await Promise.all(appCodes.map((appCode) => getRechargeOverviewForApp(appCode, cleanQuery)));
|
||||
return mergeRechargeOverviews(overviews);
|
||||
}
|
||||
|
||||
export async function exportFinanceRechargeBills(appCode, query = {}) {
|
||||
const endpoint = API_ENDPOINTS.exportRechargeBills;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.exportRechargeBills), {
|
||||
@ -213,6 +244,16 @@ async function currentSession() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRechargeOverviewForApp(appCode, query = {}) {
|
||||
const endpoint = API_ENDPOINTS.getRechargeBillOverview;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillOverview), {
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeRechargeOverview(data);
|
||||
}
|
||||
|
||||
async function getRechargeSummaryForApp(appCode, query = {}) {
|
||||
const endpoint = API_ENDPOINTS.getRechargeBillSummary;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillSummary), {
|
||||
|
||||
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 Button from "@mui/material/Button";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useState } from "react";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
EMPTY_RECHARGE_SUMMARY,
|
||||
formatAmount,
|
||||
formatMicroMoney,
|
||||
formatTime,
|
||||
@ -23,22 +14,19 @@ import {
|
||||
rechargeTypeLabel,
|
||||
} from "../format.js";
|
||||
|
||||
const SOURCE_FILTERS = [
|
||||
{ label: "全部来源", value: "" },
|
||||
{ label: "谷歌充值", value: "google_play_recharge" },
|
||||
{ label: "三方充值", value: "third_party" },
|
||||
{ label: "币商充值", value: "coin_seller" },
|
||||
];
|
||||
|
||||
const KPI_CARDS = [
|
||||
{ filter: "", key: "total", label: "充值总和", tone: "total" },
|
||||
{ filter: "google_play_recharge", key: "googlePlay", label: "谷歌充值", tone: "google" },
|
||||
{ filter: "third_party", key: "thirdParty", label: "三方充值", tone: "thirdparty" },
|
||||
{ filter: "coin_seller", key: "coinSeller", label: "币商充值", tone: "coinseller" },
|
||||
// 系统保存视图:财务高频筛选组合一键切换;与全局 rechargeType/paidState 联动。
|
||||
const SAVED_VIEWS = [
|
||||
{ key: "all", label: "全部", paidState: "", rechargeType: "" },
|
||||
{ key: "google", label: "谷歌充值", paidState: "", rechargeType: "google_play_recharge" },
|
||||
{ key: "third", label: "三方充值", paidState: "", rechargeType: "third_party" },
|
||||
{ key: "seller", label: "币商充值", paidState: "", rechargeType: "coin_seller" },
|
||||
{ key: "unsynced", label: "实付未同步", paidState: "unsynced", rechargeType: "" },
|
||||
];
|
||||
|
||||
export function FinanceRechargeDetailList({
|
||||
apps,
|
||||
bills,
|
||||
currency,
|
||||
error,
|
||||
exporting,
|
||||
filters,
|
||||
@ -48,227 +36,234 @@ export function FinanceRechargeDetailList({
|
||||
onFiltersChange,
|
||||
onRefreshGooglePaid,
|
||||
onReload,
|
||||
options,
|
||||
regions,
|
||||
summary,
|
||||
summaryLoading,
|
||||
}) {
|
||||
const [showCoinColumns, setShowCoinColumns] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState("");
|
||||
const items = bills.items || [];
|
||||
const page = Number(bills.page || filters.page || 1);
|
||||
const pageSize = Number(bills.pageSize || 50);
|
||||
const total = Number(bills.total || 0);
|
||||
const hasNextPage = page * pageSize < total;
|
||||
const regionOptions = regions || [];
|
||||
const visibleColumnCount = showCoinColumns ? 9 : 7;
|
||||
const showCoins = currency === "coin";
|
||||
const regionNames = new Map(
|
||||
(regions || []).map((region) => [String(region.regionId), region.name || region.regionCode]),
|
||||
);
|
||||
const activeView =
|
||||
SAVED_VIEWS.find(
|
||||
(view) =>
|
||||
view.rechargeType === (filters.rechargeType || "") && view.paidState === (filters.paidState || ""),
|
||||
)?.key || "all";
|
||||
const pendingGoogleTxIds = items
|
||||
.filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs)
|
||||
.map((item) => item.transactionId)
|
||||
.filter(Boolean);
|
||||
const columnCount = showCoins ? 9 : 8;
|
||||
|
||||
const chips = [
|
||||
filters.appCode
|
||||
? { key: "appCode", label: `App:${appName(filters.appCode, apps)}`, reset: { appCode: "", regionId: "" } }
|
||||
: null,
|
||||
filters.regionId
|
||||
? {
|
||||
key: "regionId",
|
||||
label: `区域:${regionNames.get(String(filters.regionId)) || filters.regionId}`,
|
||||
reset: { regionId: "" },
|
||||
}
|
||||
: null,
|
||||
filters.timeRange?.startMs || filters.timeRange?.endMs
|
||||
? {
|
||||
key: "time",
|
||||
label: `时间:${rangeLabel(filters.timeRange)}`,
|
||||
reset: { timeRange: { endMs: "", startMs: "" } },
|
||||
}
|
||||
: null,
|
||||
filters.keyword ? { key: "keyword", label: `搜索:${filters.keyword}`, reset: { keyword: "" } } : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className="finance-panel finance-list">
|
||||
<RechargeKpiBand
|
||||
activeFilter={filters.rechargeType || ""}
|
||||
loading={summaryLoading}
|
||||
showCoins={showCoinColumns}
|
||||
summary={summary}
|
||||
onSelect={(value) => onFiltersChange({ rechargeType: value })}
|
||||
/>
|
||||
<div className="finance-filterbar">
|
||||
<section className="finance-card finance-list">
|
||||
<div className="finance-views" role="tablist" aria-label="流水视图">
|
||||
{SAVED_VIEWS.map((view) => (
|
||||
<button
|
||||
aria-selected={activeView === view.key}
|
||||
className={activeView === view.key ? "is-on" : ""}
|
||||
key={view.key}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => onFiltersChange({ paidState: view.paidState, rechargeType: view.rechargeType })}
|
||||
>
|
||||
{view.label}
|
||||
{activeView === view.key && !loading ? (
|
||||
<span className="finance-views__cnt num">{formatAmount(total)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="finance-filterrow">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
className="finance-fchip"
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={() => onFiltersChange(chip.reset)}
|
||||
>
|
||||
{chip.label}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
))}
|
||||
<TextField
|
||||
className="finance-filterbar__field"
|
||||
label="APP"
|
||||
select
|
||||
value={filters.appCode}
|
||||
onChange={(event) => onFiltersChange({ appCode: event.target.value, regionId: "" })}
|
||||
>
|
||||
<MenuItem value="">全部 APP</MenuItem>
|
||||
{options.apps.map((app) => (
|
||||
<MenuItem key={app.appCode} value={app.appCode}>
|
||||
{app.appName || app.appCode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className="finance-filterbar__field"
|
||||
className="finance-filterrow__region"
|
||||
disabled={!filters.appCode}
|
||||
label="区域"
|
||||
select
|
||||
title={filters.appCode ? "" : "先选择 APP"}
|
||||
size="small"
|
||||
title={filters.appCode ? "" : "先选择 App"}
|
||||
value={filters.regionId}
|
||||
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
|
||||
>
|
||||
<MenuItem value="">全部区域</MenuItem>
|
||||
{regionOptions.map((region) => (
|
||||
{(regions || []).map((region) => (
|
||||
<MenuItem key={region.regionId} value={String(region.regionId)}>
|
||||
{region.name || region.regionCode || region.regionId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className="finance-filterbar__field"
|
||||
label="充值来源"
|
||||
select
|
||||
value={filters.rechargeType || ""}
|
||||
onChange={(event) => onFiltersChange({ rechargeType: event.target.value })}
|
||||
>
|
||||
{SOURCE_FILTERS.map((source) => (
|
||||
<MenuItem key={source.value} value={source.value}>
|
||||
{source.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TimeRangeFilter
|
||||
label="充值时间 · 中国时区"
|
||||
value={filters.timeRange}
|
||||
onChange={(timeRange) => onFiltersChange({ timeRange })}
|
||||
/>
|
||||
<TextField
|
||||
className="finance-filterbar__field finance-filterbar__field--search"
|
||||
className="finance-filterrow__search"
|
||||
label="订单搜索"
|
||||
placeholder="交易号 / 三方单号 / 凭证号"
|
||||
placeholder="交易号 / GPA单号 / 凭证号"
|
||||
size="small"
|
||||
value={filters.keyword}
|
||||
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||
/>
|
||||
<span className="finance-filterrow__spacer" />
|
||||
<Button
|
||||
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length}
|
||||
size="small"
|
||||
startIcon={<CloudSyncOutlined fontSize="small" />}
|
||||
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 App 后可查询"}
|
||||
variant="outlined"
|
||||
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
|
||||
>
|
||||
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={exporting || !filters.appCode}
|
||||
size="small"
|
||||
startIcon={<FileDownloadOutlined fontSize="small" />}
|
||||
title={filters.appCode ? "按当前筛选导出全部明细(CSV)" : "先选择 App 后可导出"}
|
||||
variant="contained"
|
||||
onClick={onExport}
|
||||
>
|
||||
{exporting ? "导出中…" : "导出报表"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
variant="outlined"
|
||||
onClick={onReload}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="finance-actionbar">
|
||||
<div className="finance-actionbar__presets">
|
||||
{timePresets().map((preset) => (
|
||||
<button
|
||||
className={[
|
||||
"finance-chip",
|
||||
isPresetActive(filters.timeRange, preset) ? "finance-chip--active" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => onFiltersChange({ timeRange: preset.range })}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="finance-actionbar__actions">
|
||||
<label className="finance-switch-field">
|
||||
<AdminSwitch
|
||||
checked={showCoinColumns}
|
||||
label="金币显示"
|
||||
onChange={(event) => setShowCoinColumns(event.target.checked)}
|
||||
/>
|
||||
<span>金币显示</span>
|
||||
</label>
|
||||
<Button
|
||||
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length}
|
||||
size="small"
|
||||
startIcon={<CloudSyncOutlined fontSize="small" />}
|
||||
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 APP 后可查询"}
|
||||
variant="outlined"
|
||||
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
|
||||
>
|
||||
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={exporting || !filters.appCode}
|
||||
size="small"
|
||||
startIcon={<FileDownloadOutlined fontSize="small" />}
|
||||
title={
|
||||
filters.appCode ? "按当前筛选导出全部明细(CSV,可用 Excel 打开)" : "先选择 APP 后可导出"
|
||||
}
|
||||
variant="contained"
|
||||
onClick={onExport}
|
||||
>
|
||||
{exporting ? "导出中…" : "导出报表"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
variant="outlined"
|
||||
onClick={onReload}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="finance-error">{error}</div> : null}
|
||||
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||
{!loading || items.length ? (
|
||||
<TableContainer className="finance-table-wrap">
|
||||
<Table
|
||||
className={[
|
||||
"finance-table",
|
||||
"finance-table--recharge-details",
|
||||
showCoinColumns ? "finance-table--recharge-details-with-coins" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
size="small"
|
||||
stickyHeader
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="finance-time-cell">充值时间</TableCell>
|
||||
<TableCell>APP</TableCell>
|
||||
<TableCell>充值来源</TableCell>
|
||||
<TableCell>充值订单号</TableCell>
|
||||
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
|
||||
{showCoinColumns ? <TableCell>金币换算</TableCell> : null}
|
||||
<TableCell align="right">账单金额</TableCell>
|
||||
<TableCell align="right">用户实付</TableCell>
|
||||
<TableCell align="right">三方税率扣款</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<div className="finance-tablewrap">
|
||||
<table className="finance-flat-table finance-flat-table--bills">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>充值时间</th>
|
||||
<th>APP</th>
|
||||
<th>充值来源</th>
|
||||
<th>充值订单号</th>
|
||||
{showCoins ? <th className="r">充值金币</th> : null}
|
||||
<th className="r">账单金额</th>
|
||||
<th className="r">用户实付</th>
|
||||
<th className="r">三方税率扣款</th>
|
||||
<th>区域</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<TableRow hover key={item.id || item.transactionId}>
|
||||
<TableCell className="finance-time-cell">
|
||||
{formatTime(item.createdAtMs)}
|
||||
</TableCell>
|
||||
<TableCell>{appName(item, options.apps)}</TableCell>
|
||||
<TableCell>
|
||||
<SourceBadge rechargeType={item.rechargeType} />
|
||||
</TableCell>
|
||||
<TableCell className="finance-order-cell">
|
||||
<Stacked
|
||||
primary={item.transactionId || "-"}
|
||||
secondary={secondaryOrderNo(item)}
|
||||
/>
|
||||
</TableCell>
|
||||
{showCoinColumns ? (
|
||||
<TableCell align="right">
|
||||
<AmountText value={item.coinAmount}>
|
||||
{coinText(item.coinAmount)}
|
||||
</AmountText>
|
||||
</TableCell>
|
||||
) : null}
|
||||
{showCoinColumns ? <TableCell>{exchangeText(item)}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<AmountText value={item.usdMinorAmount}>{billAmountText(item)}</AmountText>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<AmountText value={item.userPaidAmountMicro}>
|
||||
{userPaidText(item)}
|
||||
</AmountText>
|
||||
</TableCell>
|
||||
<TableCell align="right">{taxDeductionText(item)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
items.map((item) => {
|
||||
const rowId = item.id || item.transactionId;
|
||||
const expanded = expandedId === rowId;
|
||||
return (
|
||||
<Fragment key={rowId}>
|
||||
<tr
|
||||
className="finance-flat-table__row"
|
||||
onClick={() => setExpandedId(expanded ? "" : rowId)}
|
||||
>
|
||||
<td className="num">{formatTime(item.createdAtMs)}</td>
|
||||
<td>{appName(item.appCode, apps)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`finance-badge finance-badge--${badgeTone(item.rechargeType)}`}
|
||||
>
|
||||
{rechargeTypeLabel(item.rechargeType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="finance-order-cell">
|
||||
<span className="finance-stack">
|
||||
<span>{item.transactionId || "-"}</span>
|
||||
<small>{secondaryOrderNo(item)}</small>
|
||||
</span>
|
||||
</td>
|
||||
{showCoins ? (
|
||||
<td className="r num">
|
||||
<AmountText value={item.coinAmount}>
|
||||
{coinText(item.coinAmount)}
|
||||
</AmountText>
|
||||
</td>
|
||||
) : null}
|
||||
<td className="r num">
|
||||
<AmountText value={item.usdMinorAmount}>
|
||||
{billAmountText(item)}
|
||||
</AmountText>
|
||||
</td>
|
||||
<td className="r num">
|
||||
<AmountText value={item.userPaidAmountMicro}>
|
||||
{userPaidText(item)}
|
||||
</AmountText>
|
||||
</td>
|
||||
<td className="r num">{taxDeductionText(item)}</td>
|
||||
<td>{regionText(item, regionNames)}</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
<tr className="finance-detailrow">
|
||||
<td colSpan={columnCount}>
|
||||
<BillDetail
|
||||
googlePaidRefreshing={googlePaidRefreshing}
|
||||
item={item}
|
||||
onRefreshGooglePaid={onRefreshGooglePaid}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell align="center" colSpan={visibleColumnCount}>
|
||||
<tr>
|
||||
<td colSpan={columnCount} style={{ textAlign: "center" }}>
|
||||
当前筛选条件下没有账单
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="finance-pagination">
|
||||
<span className="finance-pagination__meta">
|
||||
共 {formatAmount(total)} 笔 · 第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页
|
||||
<span className="finance-pagination__meta num">
|
||||
共 {formatAmount(total)} 笔
|
||||
{summary ? ` · 筛选合计 ${formatUsdMinor(summary.total?.usdMinorAmount || 0, "USD")}` : ""} · 第{" "}
|
||||
{page} / {Math.max(1, Math.ceil(total / pageSize))} 页
|
||||
</span>
|
||||
<div className="finance-pagination__buttons">
|
||||
<Button
|
||||
@ -293,55 +288,137 @@ export function FinanceRechargeDetailList({
|
||||
);
|
||||
}
|
||||
|
||||
function RechargeKpiBand({ activeFilter, loading, onSelect, showCoins, summary }) {
|
||||
const data = summary || EMPTY_RECHARGE_SUMMARY;
|
||||
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
|
||||
// BillDetail 是行内展开的单笔明细:金额瀑布 + 单号集合 + 谷歌实付操作。
|
||||
function BillDetail({ googlePaidRefreshing, item, onRefreshGooglePaid }) {
|
||||
const currencyCode = item.userPaidCurrencyCode || item.currencyCode || "USD";
|
||||
const paid = Number(item.userPaidAmountMicro) || 0;
|
||||
const fee = Number(item.providerFeeMicro) || 0;
|
||||
const tax = Number(item.providerTaxMicro) || 0;
|
||||
const net = Number(item.providerNetMicro) || 0;
|
||||
const isGoogle = item.rechargeType === "google_play_recharge";
|
||||
const unsynced = isGoogle && !item.paidSyncedAtMs;
|
||||
return (
|
||||
<div className="finance-kpi-band">
|
||||
{KPI_CARDS.map(({ filter, key, label, tone }) => {
|
||||
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
|
||||
const active = activeFilter === filter;
|
||||
return (
|
||||
<button
|
||||
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={key}
|
||||
title={filter ? `点击只看${label}` : "点击查看全部来源"}
|
||||
type="button"
|
||||
onClick={() => onSelect(active && filter ? "" : filter)}
|
||||
>
|
||||
<span className="finance-kpi__label">{label}</span>
|
||||
<strong className="finance-kpi__value">
|
||||
{loading ? "…" : formatUsdMinor(bucket.usdMinorAmount, "USD")}
|
||||
</strong>
|
||||
<small className="finance-kpi__meta">
|
||||
{loading ? "统计中" : kpiMetaText(bucket, key, totalUsd, showCoins)}
|
||||
</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="finance-detail">
|
||||
<div>
|
||||
<div className="finance-card__title" style={{ marginBottom: 10 }}>
|
||||
金额拆解{paid ? ` · 实付币种 ${currencyCode}` : ""}
|
||||
</div>
|
||||
{paid && (fee || tax || net) ? (
|
||||
<div className="finance-wfall">
|
||||
<DetailWfallRow
|
||||
amount={paid}
|
||||
color="var(--fin-ink)"
|
||||
currency={currencyCode}
|
||||
label="用户实付"
|
||||
max={paid}
|
||||
/>
|
||||
{tax ? (
|
||||
<DetailWfallRow
|
||||
amount={-tax}
|
||||
color="#C86A6A"
|
||||
currency={currencyCode}
|
||||
label="代缴税费"
|
||||
max={paid}
|
||||
/>
|
||||
) : null}
|
||||
{fee ? (
|
||||
<DetailWfallRow
|
||||
amount={-fee}
|
||||
color="#E19B3C"
|
||||
currency={currencyCode}
|
||||
label="渠道服务费"
|
||||
max={paid}
|
||||
/>
|
||||
) : null}
|
||||
{net ? (
|
||||
<DetailWfallRow
|
||||
amount={net}
|
||||
color="var(--fin-pos)"
|
||||
currency={currencyCode}
|
||||
label="开发者净收"
|
||||
max={paid}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : unsynced ? (
|
||||
<p className="finance-hint">
|
||||
实付明细未同步。
|
||||
<Button
|
||||
disabled={googlePaidRefreshing}
|
||||
size="small"
|
||||
style={{ marginLeft: 8 }}
|
||||
variant="outlined"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRefreshGooglePaid([item.transactionId]);
|
||||
}}
|
||||
>
|
||||
同步这一笔
|
||||
</Button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="finance-hint">
|
||||
{paid ? `用户实付 ${formatMicroMoney(paid, currencyCode)} · ` : ""}
|
||||
该渠道未提供扣费明细
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<dl className="finance-kv">
|
||||
<dt>交易号</dt>
|
||||
<dd className="finance-order-cell">{item.transactionId || "-"}</dd>
|
||||
{item.commandId ? (
|
||||
<>
|
||||
<dt>业务单号</dt>
|
||||
<dd className="finance-order-cell">{item.commandId}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{item.externalRef ? (
|
||||
<>
|
||||
<dt>外部订单</dt>
|
||||
<dd className="finance-order-cell">{item.externalRef}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{item.user?.username || item.user?.userId ? (
|
||||
<>
|
||||
<dt>充值用户</dt>
|
||||
<dd>
|
||||
{item.user.username || "-"}
|
||||
{item.user.userId ? ` · ID ${item.user.userId}` : ""}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{item.sellerUserId ? (
|
||||
<>
|
||||
<dt>币商 ID</dt>
|
||||
<dd className="num">{item.sellerUserId}</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>美金换算</dt>
|
||||
<dd className="num">{formatUsdMinor(item.usdMinorAmount, "USD")}</dd>
|
||||
{item.paidSyncedAtMs ? (
|
||||
<>
|
||||
<dt>实付同步于</dt>
|
||||
<dd className="num">{formatTime(item.paidSyncedAtMs)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function kpiMetaText(bucket, key, totalUsd, showCoins) {
|
||||
const parts = [`${formatAmount(bucket.billCount)} 笔`];
|
||||
if (key !== "total" && totalUsd > 0) {
|
||||
const share = ((Number(bucket.usdMinorAmount) || 0) / totalUsd) * 100;
|
||||
parts.push(`占比 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
|
||||
}
|
||||
if (showCoins) {
|
||||
parts.push(`${formatAmount(bucket.coinAmount)} 金币`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function SourceBadge({ rechargeType }) {
|
||||
function DetailWfallRow({ amount, color, currency, label, max }) {
|
||||
const negative = amount < 0;
|
||||
const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0;
|
||||
return (
|
||||
<span className={`finance-badge finance-badge--${rechargeSourceTone(rechargeType)}`}>
|
||||
{rechargeTypeLabel(rechargeType)}
|
||||
</span>
|
||||
<div className="finance-wfall__row">
|
||||
<span className="finance-wfall__label">{label}</span>
|
||||
<span className="finance-wfall__track">
|
||||
<i style={{ background: color, width: `${Math.min(100, width)}%` }} />
|
||||
</span>
|
||||
<span className={negative ? "finance-wfall__val num finance-amount--negative" : "finance-wfall__val num"}>
|
||||
{negative ? `− ${formatMicroMoney(-amount, currency)}` : formatMicroMoney(amount, currency)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -350,46 +427,30 @@ function AmountText({ children, value }) {
|
||||
return <span className={negative ? "finance-amount finance-amount--negative" : "finance-amount"}>{children}</span>;
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const CHINA_OFFSET_MS = 8 * 3_600_000;
|
||||
|
||||
function chinaDayStart(timestamp) {
|
||||
return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS;
|
||||
function badgeTone(rechargeType) {
|
||||
return rechargeSourceTone(rechargeType) === "google"
|
||||
? "google"
|
||||
: rechargeSourceTone(rechargeType) === "coinseller"
|
||||
? "coinseller"
|
||||
: "thirdparty";
|
||||
}
|
||||
|
||||
function timePresets() {
|
||||
const now = Date.now();
|
||||
const todayStart = chinaDayStart(now);
|
||||
const chinaNow = new Date(now + CHINA_OFFSET_MS);
|
||||
const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS;
|
||||
return [
|
||||
{ label: "不限", range: { endMs: "", startMs: "" } },
|
||||
{ label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } },
|
||||
{ label: "昨天", range: { endMs: todayStart, startMs: todayStart - DAY_MS } },
|
||||
{ label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } },
|
||||
{ label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } },
|
||||
];
|
||||
function rangeLabel(timeRange) {
|
||||
const start = timeRange?.startMs ? formatTime(timeRange.startMs) : "…";
|
||||
const end = timeRange?.endMs ? formatTime(timeRange.endMs) : "…";
|
||||
return `${start} ~ ${end}`;
|
||||
}
|
||||
|
||||
function isPresetActive(timeRange, preset) {
|
||||
const current = timeRange || { endMs: "", startMs: "" };
|
||||
return (
|
||||
String(current.startMs || "") === String(preset.range.startMs || "") &&
|
||||
String(current.endMs || "") === String(preset.range.endMs || "")
|
||||
);
|
||||
function appName(appCode, apps) {
|
||||
return (apps || []).find((app) => app.appCode === appCode)?.appName || appCode || "-";
|
||||
}
|
||||
|
||||
function Stacked({ primary, secondary }) {
|
||||
return (
|
||||
<span className="finance-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<small>{secondary || ""}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function appName(item, apps) {
|
||||
return item.appName || apps.find((app) => app.appCode === item.appCode)?.appName || item.appCode || "-";
|
||||
function regionText(item, regionNames) {
|
||||
const regionId = item.targetRegionId || item.sellerRegionId;
|
||||
if (!regionId) {
|
||||
return "—";
|
||||
}
|
||||
return regionNames.get(String(regionId)) || `区域 ${regionId}`;
|
||||
}
|
||||
|
||||
function secondaryOrderNo(item) {
|
||||
@ -401,13 +462,6 @@ function coinText(value) {
|
||||
return text === "-" ? "-" : `${text} 金币`;
|
||||
}
|
||||
|
||||
function exchangeText(item) {
|
||||
if (hasNumber(item.exchangeCoinAmount) || hasNumber(item.exchangeUsdMinorAmount)) {
|
||||
return `${coinText(item.exchangeCoinAmount)} = ${formatUsdMinor(item.exchangeUsdMinorAmount, "USD")}`;
|
||||
}
|
||||
return formatUsdMinor(item.usdMinorAmount, "USD");
|
||||
}
|
||||
|
||||
function billAmountText(item) {
|
||||
if (item.billAmountText) {
|
||||
return item.billAmountText;
|
||||
@ -428,7 +482,7 @@ function userPaidText(item) {
|
||||
return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode);
|
||||
}
|
||||
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
|
||||
return "未同步(点“查询谷歌实付”)";
|
||||
return "未同步";
|
||||
}
|
||||
if (hasPositiveNumber(item.userPaidAmountMinor)) {
|
||||
return formatUsdMinor(item.userPaidAmountMinor, item.userPaidCurrencyCode || item.currencyCode || "USD");
|
||||
@ -447,12 +501,7 @@ function taxDeductionText(item) {
|
||||
const percent = (deduction / item.userPaidAmountMicro) * 100;
|
||||
parts.push(`${percent.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`);
|
||||
}
|
||||
return (
|
||||
<span className="finance-stack">
|
||||
<span>{parts.join(" / ")}</span>
|
||||
<small>{deductionBreakdown(fee, tax, currency)}</small>
|
||||
</span>
|
||||
);
|
||||
return parts.join(" / ");
|
||||
}
|
||||
if (item.taxDeductionText) {
|
||||
return item.taxDeductionText;
|
||||
@ -463,17 +512,6 @@ function taxDeductionText(item) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
function deductionBreakdown(fee, tax, currency) {
|
||||
const parts = [];
|
||||
if (fee > 0) {
|
||||
parts.push(`手续费 ${formatMicroMoney(fee, currency)}`);
|
||||
}
|
||||
if (tax > 0) {
|
||||
parts.push(`税费 ${formatMicroMoney(tax, currency)}`);
|
||||
}
|
||||
return parts.join(" + ");
|
||||
}
|
||||
|
||||
function isGoogleBill(item) {
|
||||
return item.rechargeType === "google_play_recharge" || item.providerCode === "google_play";
|
||||
}
|
||||
|
||||
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 FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import RuleFolderOutlined from "@mui/icons-material/RuleFolderOutlined";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
|
||||
const views = [
|
||||
{ icon: ReceiptLongOutlined, id: "rechargeDetails", label: "APP充值详情" },
|
||||
{ icon: AssignmentOutlined, id: "create", label: "发起申请" },
|
||||
{ icon: FactCheckOutlined, id: "applications", label: "申请审核" },
|
||||
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" }
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
label: "经营",
|
||||
views: [
|
||||
{ icon: InsightsOutlined, id: "overview", label: "财务概览" },
|
||||
{ icon: ReceiptLongOutlined, id: "rechargeDetails", label: "充值流水" },
|
||||
{ icon: RuleFolderOutlined, id: "recon", label: "渠道对账" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "审批",
|
||||
views: [
|
||||
{ icon: AssignmentOutlined, id: "create", label: "发起申请" },
|
||||
{ icon: FactCheckOutlined, id: "applications", label: "申请审核" },
|
||||
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeDetails, canViewWithdrawals, children, loading, onViewChange, session }) {
|
||||
const visibleViews = views.filter((view) => {
|
||||
if (view.id === "rechargeDetails") {
|
||||
return canViewRechargeDetails;
|
||||
}
|
||||
if (view.id === "applications") {
|
||||
return canAudit;
|
||||
}
|
||||
if (view.id === "withdrawals") {
|
||||
return canViewWithdrawals;
|
||||
}
|
||||
return canCreate;
|
||||
});
|
||||
return (
|
||||
<main className="finance-shell">
|
||||
<aside className="finance-sidebar">
|
||||
<div className="finance-brand">
|
||||
<span>HY</span>
|
||||
<strong>财务系统</strong>
|
||||
</div>
|
||||
<nav className="finance-nav" aria-label="财务系统菜单">
|
||||
{visibleViews.map((view) => {
|
||||
const Icon = view.icon;
|
||||
return (
|
||||
<button className={activeView === view.id ? "is-active" : ""} key={view.id} type="button" onClick={() => onViewChange(view.id)}>
|
||||
<Icon fontSize="small" />
|
||||
<span>{view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="finance-user">
|
||||
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
|
||||
<small>
|
||||
{canViewRechargeDetails
|
||||
? "payment-bill:view"
|
||||
: canAudit
|
||||
? "finance-application:audit"
|
||||
: canViewWithdrawals
|
||||
? "finance-withdrawal:view"
|
||||
: "finance-application:create"}
|
||||
</small>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="finance-main">
|
||||
{loading ? <LinearProgress className="finance-progress" /> : null}
|
||||
<div className="finance-content">{children}</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
const VIEW_TITLES = {
|
||||
applications: "申请审核",
|
||||
create: "发起申请",
|
||||
overview: "财务概览",
|
||||
recon: "渠道对账",
|
||||
rechargeDetails: "充值流水",
|
||||
withdrawals: "用户提现申请",
|
||||
};
|
||||
|
||||
export function FinanceShell({
|
||||
activeView,
|
||||
canAudit,
|
||||
canCreate,
|
||||
canViewRechargeDetails,
|
||||
canViewWithdrawals,
|
||||
children,
|
||||
loading,
|
||||
onViewChange,
|
||||
session,
|
||||
subtitle,
|
||||
toolbar,
|
||||
}) {
|
||||
const allows = (viewId) => {
|
||||
if (viewId === "overview" || viewId === "rechargeDetails" || viewId === "recon") {
|
||||
return canViewRechargeDetails;
|
||||
}
|
||||
if (viewId === "applications") {
|
||||
return canAudit;
|
||||
}
|
||||
if (viewId === "withdrawals") {
|
||||
return canViewWithdrawals;
|
||||
}
|
||||
return canCreate;
|
||||
};
|
||||
return (
|
||||
<main className="finance-shell">
|
||||
<aside className="finance-sidebar">
|
||||
<div className="finance-brand">
|
||||
<span>HY</span>
|
||||
<div>
|
||||
<strong>财务中台</strong>
|
||||
<small>Finance Console</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="finance-nav" aria-label="财务系统菜单">
|
||||
{NAV_GROUPS.map((group) => {
|
||||
const visible = group.views.filter((view) => allows(view.id));
|
||||
if (!visible.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="finance-nav__group" key={group.label}>
|
||||
<span className="finance-nav__label">{group.label}</span>
|
||||
{visible.map((view) => {
|
||||
const Icon = view.icon;
|
||||
return (
|
||||
<button
|
||||
className={activeView === view.id ? "is-active" : ""}
|
||||
key={view.id}
|
||||
type="button"
|
||||
onClick={() => onViewChange(view.id)}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<span>{view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="finance-user">
|
||||
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
|
||||
<small>财务系统</small>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="finance-main">
|
||||
{loading ? <LinearProgress className="finance-progress" /> : null}
|
||||
<header className="finance-topbar">
|
||||
<div className="finance-topbar__title">
|
||||
<h1>{VIEW_TITLES[activeView] || "财务系统"}</h1>
|
||||
{subtitle ? <small>{subtitle}</small> : null}
|
||||
</div>
|
||||
{toolbar ? <div className="finance-topbar__tools">{toolbar}</div> : null}
|
||||
</header>
|
||||
<div className="finance-content">{children}</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -145,7 +145,10 @@ export function normalizeRechargeBill(item = {}) {
|
||||
providerNetMicro: numberOrNull(item.providerNetMicro ?? item.provider_net_micro),
|
||||
providerTaxMicro: numberOrNull(item.providerTaxMicro ?? item.provider_tax_micro),
|
||||
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
||||
sellerRegionId: numberOrNull(item.sellerRegionId ?? item.seller_region_id),
|
||||
sellerUserId: numberOrNull(item.sellerUserId ?? item.seller_user_id),
|
||||
status: stringValue(item.status),
|
||||
targetRegionId: numberOrNull(item.targetRegionId ?? item.target_region_id),
|
||||
taxDeductionMinor: numberOrNull(
|
||||
item.taxDeductionMinor ??
|
||||
item.tax_deduction_minor ??
|
||||
@ -161,6 +164,11 @@ export function normalizeRechargeBill(item = {}) {
|
||||
taxRate: numberOrNull(item.taxRate ?? item.tax_rate ?? item.thirdPartyTaxRate ?? item.third_party_tax_rate),
|
||||
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount),
|
||||
user: {
|
||||
displayUserId: stringValue(item.user?.displayUserId ?? item.user?.display_user_id),
|
||||
userId: stringValue(item.user?.userId ?? item.user?.user_id) || stringValue(item.userId ?? item.user_id),
|
||||
username: stringValue(item.user?.username),
|
||||
},
|
||||
userPaidAmountMicro: numberOrNull(
|
||||
item.userPaidAmountMicro ?? item.user_paid_amount_micro ?? item.paidAmountMicro ?? item.paid_amount_micro,
|
||||
),
|
||||
@ -234,6 +242,83 @@ export function mergeRechargeSummaries(summaries = []) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
export const EMPTY_RECHARGE_OVERVIEW = {
|
||||
daily: [],
|
||||
googlePaid: {
|
||||
coveredUsdMinor: 0,
|
||||
estFeeUsdMinor: 0,
|
||||
estNetUsdMinor: 0,
|
||||
estTaxUsdMinor: 0,
|
||||
googleBillCount: 0,
|
||||
syncedCount: 0,
|
||||
unsyncedCount: 0,
|
||||
},
|
||||
regions: [],
|
||||
};
|
||||
|
||||
export function normalizeRechargeOverview(data = {}) {
|
||||
const daily = Array.isArray(data.daily) ? data.daily : [];
|
||||
const regions = Array.isArray(data.regions) ? data.regions : [];
|
||||
const paid = data.googlePaid ?? data.google_paid ?? {};
|
||||
return {
|
||||
daily: daily.map((item) => ({
|
||||
coinSellerCoinAmount: numberValue(item.coinSellerCoinAmount ?? item.coin_seller_coin_amount) || 0,
|
||||
coinSellerUsdMinor: numberValue(item.coinSellerUsdMinor ?? item.coin_seller_usd_minor) || 0,
|
||||
date: stringValue(item.date),
|
||||
googleCoinAmount: numberValue(item.googleCoinAmount ?? item.google_coin_amount) || 0,
|
||||
googleUsdMinor: numberValue(item.googleUsdMinor ?? item.google_usd_minor) || 0,
|
||||
thirdPartyCoinAmount: numberValue(item.thirdPartyCoinAmount ?? item.third_party_coin_amount) || 0,
|
||||
thirdPartyUsdMinor: numberValue(item.thirdPartyUsdMinor ?? item.third_party_usd_minor) || 0,
|
||||
})),
|
||||
googlePaid: {
|
||||
coveredUsdMinor: numberValue(paid.coveredUsdMinor ?? paid.covered_usd_minor) || 0,
|
||||
estFeeUsdMinor: numberValue(paid.estFeeUsdMinor ?? paid.est_fee_usd_minor) || 0,
|
||||
estNetUsdMinor: numberValue(paid.estNetUsdMinor ?? paid.est_net_usd_minor) || 0,
|
||||
estTaxUsdMinor: numberValue(paid.estTaxUsdMinor ?? paid.est_tax_usd_minor) || 0,
|
||||
googleBillCount: numberValue(paid.googleBillCount ?? paid.google_bill_count) || 0,
|
||||
syncedCount: numberValue(paid.syncedCount ?? paid.synced_count) || 0,
|
||||
unsyncedCount: numberValue(paid.unsyncedCount ?? paid.unsynced_count) || 0,
|
||||
},
|
||||
regions: regions.map((item) => ({
|
||||
billCount: numberValue(item.billCount ?? item.bill_count) || 0,
|
||||
name: stringValue(item.name),
|
||||
regionId: numberOrNull(item.regionId ?? item.region_id),
|
||||
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount) || 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// mergeRechargeOverviews 把多 App 概览合并成全局口径:daily 按日期求和,googlePaid 求和;
|
||||
// 区域 ID 属于单 App 语义,跨 App 合并时丢弃区域分布。
|
||||
export function mergeRechargeOverviews(overviews = []) {
|
||||
const dailyByDate = new Map();
|
||||
const merged = normalizeRechargeOverview({});
|
||||
for (const overview of overviews) {
|
||||
for (const bucket of overview.daily) {
|
||||
const existing = dailyByDate.get(bucket.date);
|
||||
if (!existing) {
|
||||
dailyByDate.set(bucket.date, { ...bucket });
|
||||
continue;
|
||||
}
|
||||
for (const key of [
|
||||
"coinSellerCoinAmount",
|
||||
"coinSellerUsdMinor",
|
||||
"googleCoinAmount",
|
||||
"googleUsdMinor",
|
||||
"thirdPartyCoinAmount",
|
||||
"thirdPartyUsdMinor",
|
||||
]) {
|
||||
existing[key] += bucket[key];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(merged.googlePaid)) {
|
||||
merged.googlePaid[key] += overview.googlePaid[key];
|
||||
}
|
||||
}
|
||||
merged.daily = [...dailyByDate.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function normalizeRechargeRegions(data = {}) {
|
||||
const items = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : [];
|
||||
return items
|
||||
|
||||
@ -30,10 +30,10 @@ a {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-5);
|
||||
gap: var(--space-4);
|
||||
border-right: 1px solid #1c2a44;
|
||||
background: #101b2d;
|
||||
padding: var(--space-5) var(--space-3);
|
||||
}
|
||||
|
||||
.finance-brand {
|
||||
@ -54,10 +54,25 @@ a {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.finance-brand {
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.finance-brand strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
display: block;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.finance-brand small {
|
||||
color: #7e8db0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.finance-brand span {
|
||||
background: linear-gradient(135deg, #2557d6, #0e7490);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.finance-nav {
|
||||
@ -65,31 +80,45 @@ a {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-nav__group {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.finance-nav__label {
|
||||
margin: var(--space-3) var(--space-3) var(--space-1);
|
||||
color: #5a6c89;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.finance-nav button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 40px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
color: #93a1b8;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
padding: 0 var(--space-3);
|
||||
text-align: left;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, transform 180ms ease;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.finance-nav button:hover {
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-primary);
|
||||
background: rgb(255 255 255 / 6%);
|
||||
color: #dce4f0;
|
||||
}
|
||||
|
||||
.finance-nav button.is-active {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary);
|
||||
background: #1b2a44;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@ -97,12 +126,16 @@ a {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding-left: var(--space-3);
|
||||
color: #dce4f0;
|
||||
border-top: 1px solid #1c2a44;
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
.finance-user small,
|
||||
.finance-user small {
|
||||
color: #7e8db0;
|
||||
}
|
||||
|
||||
.finance-stack small {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@ -621,3 +654,628 @@ a {
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ===== 财务中台 v2:全局变量与新版组件 ===== */
|
||||
:root {
|
||||
--fin-ink: #17212f;
|
||||
--fin-google: #1b873f;
|
||||
--fin-third: #0e7490;
|
||||
--fin-seller: #b45309;
|
||||
--fin-pos: #16a34a;
|
||||
--fin-neg: #dc2626;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.finance-topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.finance-topbar__title {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.finance-topbar__title h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.finance-topbar__title small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.finance-topbar__tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.finance-seg {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.finance-seg button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
padding: 5px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finance-seg button.is-on {
|
||||
background: var(--fin-ink);
|
||||
color: #fff;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.finance-appbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.finance-appchip {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
padding: 5px 14px;
|
||||
transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.finance-appchip:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.finance-appchip.is-on {
|
||||
border-color: var(--fin-ink);
|
||||
box-shadow: inset 0 0 0 1px var(--fin-ink);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.finance-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.finance-overview__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.finance-alerts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-alert {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
padding: 8px 13px;
|
||||
}
|
||||
|
||||
.finance-alert button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 750;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.finance-alert--warn {
|
||||
background: color-mix(in srgb, #b45309 10%, transparent);
|
||||
border-color: color-mix(in srgb, #b45309 28%, transparent);
|
||||
color: #8a4b06;
|
||||
}
|
||||
|
||||
.finance-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.finance-card__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.finance-card__title small {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-card__tools {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-card__body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.finance-card__divider {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.finance-hint {
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.finance-sparkline {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-chartlegend {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.finance-chartlegend i {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 3px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.finance-trend {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 190px;
|
||||
}
|
||||
|
||||
.finance-trend__col {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column-reverse;
|
||||
gap: 1px;
|
||||
min-width: 4px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finance-trend__col:hover {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
.finance-trend__seg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.finance-trend__seg--googlePlay {
|
||||
background: var(--fin-google);
|
||||
}
|
||||
|
||||
.finance-trend__seg--thirdParty {
|
||||
background: var(--fin-third);
|
||||
}
|
||||
|
||||
.finance-trend__seg--coinSeller {
|
||||
background: var(--fin-seller);
|
||||
}
|
||||
|
||||
.finance-trend__axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10.5px;
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-mixbar {
|
||||
display: flex;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.finance-mixbar span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.finance-mixrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12.5px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.finance-mixrow:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.finance-mixrow i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.finance-mixrow__name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.finance-mixrow__amt {
|
||||
margin-left: auto;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.finance-mixrow__pct {
|
||||
width: 48px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.finance-rankrow {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 96px minmax(0, 1fr) 96px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12.5px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.finance-rankrow__idx {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.finance-rankrow__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finance-rankrow__amt {
|
||||
font-weight: 750;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.finance-rankbar {
|
||||
display: block;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-card-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finance-rankbar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--primary, #2557d6);
|
||||
}
|
||||
|
||||
.finance-tablewrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.finance-flat-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.finance-flat-table th {
|
||||
background: var(--bg-card-strong);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 9px 14px;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finance-flat-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finance-flat-table th.r,
|
||||
.finance-flat-table td.r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.finance-flat-table__row:hover td {
|
||||
background: var(--bg-card-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finance-flat-table--bills {
|
||||
min-width: 1180px;
|
||||
}
|
||||
|
||||
.finance-views {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.finance-views button {
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
margin-bottom: -1px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.finance-views button.is-on {
|
||||
border-bottom-color: var(--primary, #2557d6);
|
||||
color: var(--primary, #2557d6);
|
||||
}
|
||||
|
||||
.finance-views__cnt {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.finance-filterrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.finance-filterrow__region {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.finance-filterrow__search {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.finance-filterrow__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.finance-fchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary, #2557d6) 40%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary, #2557d6) 10%, transparent);
|
||||
color: var(--primary, #2557d6);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.finance-fchip span {
|
||||
opacity: 0.7;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.finance-detailrow td {
|
||||
background: var(--bg-card-strong);
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.finance-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card);
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.finance-kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-content: start;
|
||||
gap: 5px 16px;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.finance-kv dt {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.finance-kv dd {
|
||||
font-weight: 650;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finance-wfall {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.finance-wfall__row {
|
||||
display: grid;
|
||||
grid-template-columns: 104px minmax(0, 1fr) 130px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.finance-wfall__label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.finance-wfall__track {
|
||||
display: block;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.finance-wfall__track i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.finance-wfall__val {
|
||||
font-weight: 750;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.finance-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 750;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.finance-pill i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
.finance-pill--ok {
|
||||
background: color-mix(in srgb, #16a34a 12%, transparent);
|
||||
color: #136b31;
|
||||
}
|
||||
|
||||
.finance-pill--warn {
|
||||
background: color-mix(in srgb, #b45309 14%, transparent);
|
||||
color: #8a4b06;
|
||||
}
|
||||
|
||||
.finance-recon-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.finance-recon-toolbar__month {
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.finance-recon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.finance-recon-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12.5px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.finance-recon-line:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.finance-recon-line--total {
|
||||
border-top: 2px solid var(--border);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.finance-overview__split,
|
||||
.finance-recon-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.finance-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,6 +121,7 @@ export const API_OPERATIONS = {
|
||||
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
||||
getRechargeBillOverview: "getRechargeBillOverview",
|
||||
getRechargeBillSummary: "getRechargeBillSummary",
|
||||
getRedPacket: "getRedPacket",
|
||||
getRedPacketConfig: "getRedPacketConfig",
|
||||
@ -1090,6 +1091,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
getRechargeBillOverview: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getRechargeBillOverview,
|
||||
path: "/v1/admin/payment/recharge-bills/overview",
|
||||
permission: "payment-bill:view",
|
||||
permissions: ["payment-bill:view"]
|
||||
},
|
||||
getRechargeBillSummary: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getRechargeBillSummary,
|
||||
|
||||
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;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/payment/recharge-bills/overview": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getRechargeBillOverview"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/payment/recharge-bills/summary": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -6815,6 +6831,26 @@ export interface operations {
|
||||
200: components["responses"]["RechargeBillPageResponse"];
|
||||
};
|
||||
};
|
||||
getRechargeBillOverview: {
|
||||
parameters: {
|
||||
query?: {
|
||||
keyword?: string;
|
||||
recharge_type?: string;
|
||||
status?: string;
|
||||
region_id?: number;
|
||||
start_at_ms?: number;
|
||||
end_at_ms?: number;
|
||||
tz_offset_minutes?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getRechargeBillSummary: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user