This commit is contained in:
zhx 2026-07-03 13:27:03 +08:00
parent 88eae83dbe
commit b8b5182c67
6 changed files with 137 additions and 77 deletions

View File

@ -394,14 +394,6 @@ export function FinanceApp() {
canViewWithdrawals={session.canViewWithdrawalApplications}
loading={optionsState.loading || rechargeDetailsState.loading || myApplicationsState.loading || applicationsState.loading || withdrawalsState.loading}
session={session}
onRefresh={() => {
loadOptions();
loadRechargeDetails();
loadRechargeSummary();
loadMyApplications();
loadApplications();
loadWithdrawals();
}}
onViewChange={setActiveView}
>
{activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? (

View File

@ -41,7 +41,10 @@ test("keeps create application dialog open when backdrop is clicked", async () =
</ToastProvider>
);
fireEvent.click(await screen.findByRole("button", { name: "添加" }));
await screen.findByRole("button", { name: "添加" });
expect(document.querySelector(".finance-header")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "添加" }));
expect(within(screen.getByRole("dialog")).getByRole("heading", { name: "发起申请" })).toBeInTheDocument();
const backdrop = document.querySelector(".MuiBackdrop-root");

View File

@ -9,6 +9,8 @@ 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 {
EMPTY_RECHARGE_SUMMARY,
@ -33,12 +35,14 @@ export function FinanceRechargeDetailList({
summary,
summaryLoading,
}) {
const [showCoinColumns, setShowCoinColumns] = useState(false);
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 pendingGoogleTxIds = items
.filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs)
.map((item) => item.transactionId)
@ -46,7 +50,7 @@ export function FinanceRechargeDetailList({
return (
<section className="finance-panel finance-list">
<RechargeSummaryCards loading={summaryLoading} summary={summary} />
<RechargeSummaryCards loading={summaryLoading} showCoins={showCoinColumns} summary={summary} />
<div className="finance-toolbar finance-toolbar--recharge-details">
<TextField
label="APP"
@ -86,6 +90,14 @@ export function FinanceRechargeDetailList({
value={filters.keyword}
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
/>
<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}
startIcon={<CloudSyncOutlined fontSize="small" />}
@ -103,14 +115,24 @@ export function FinanceRechargeDetailList({
{loading && !items.length ? <div className="finance-skeleton" /> : null}
{!loading || items.length ? (
<TableContainer className="finance-table-wrap">
<Table className="finance-table finance-table--recharge-details" size="small" stickyHeader>
<Table
className={[
"finance-table",
"finance-table--recharge-details",
showCoinColumns ? "finance-table--recharge-details-with-coins" : "",
]
.filter(Boolean)
.join(" ")}
size="small"
stickyHeader
>
<TableHead>
<TableRow>
<TableCell>APP</TableCell>
<TableCell align="right">充值总额</TableCell>
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
<TableCell>充值来源</TableCell>
<TableCell>充值订单号</TableCell>
<TableCell>美金换算</TableCell>
{showCoinColumns ? <TableCell>换算</TableCell> : null}
<TableCell>账单金额</TableCell>
<TableCell>用户实付</TableCell>
<TableCell>三方税率扣款</TableCell>
@ -122,7 +144,9 @@ export function FinanceRechargeDetailList({
items.map((item) => (
<TableRow key={item.id || item.transactionId}>
<TableCell>{appName(item, options.apps)}</TableCell>
<TableCell align="right">{coinText(item.coinAmount)}</TableCell>
{showCoinColumns ? (
<TableCell align="right">{coinText(item.coinAmount)}</TableCell>
) : null}
<TableCell>{rechargeTypeLabel(item.rechargeType)}</TableCell>
<TableCell>
<Stacked
@ -130,7 +154,7 @@ export function FinanceRechargeDetailList({
secondary={secondaryOrderNo(item)}
/>
</TableCell>
<TableCell>{exchangeText(item)}</TableCell>
{showCoinColumns ? <TableCell>{exchangeText(item)}</TableCell> : null}
<TableCell>{billAmountText(item)}</TableCell>
<TableCell>{userPaidText(item)}</TableCell>
<TableCell>{taxDeductionText(item)}</TableCell>
@ -141,7 +165,7 @@ export function FinanceRechargeDetailList({
))
) : (
<TableRow>
<TableCell align="center" colSpan={9}>
<TableCell align="center" colSpan={visibleColumnCount}>
当前无数据
</TableCell>
</TableRow>
@ -175,7 +199,7 @@ export function FinanceRechargeDetailList({
);
}
function RechargeSummaryCards({ loading, summary }) {
function RechargeSummaryCards({ loading, showCoins, summary }) {
const data = summary || EMPTY_RECHARGE_SUMMARY;
const cards = [
{ bucket: data.total, key: "total", label: "充值总和" },
@ -193,7 +217,7 @@ function RechargeSummaryCards({ loading, summary }) {
<small>
{loading
? "统计中"
: `${formatAmount(bucket.billCount)} 笔 · ${formatAmount(bucket.coinAmount)} 金币`}
: summaryMetaText(bucket, showCoins)}
</small>
</div>
))}
@ -201,6 +225,14 @@ function RechargeSummaryCards({ loading, summary }) {
);
}
function summaryMetaText(bucket, showCoins) {
const billCount = `${formatAmount(bucket.billCount)}`;
if (!showCoins) {
return billCount;
}
return `${billCount} · ${formatAmount(bucket.coinAmount)} 金币`;
}
function Stacked({ primary, secondary }) {
return (
<span className="finance-stack">

View File

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { FinanceRechargeDetailList } from "./FinanceRechargeDetailList.jsx";
@ -14,6 +14,7 @@ const baseProps = {
appCode: "",
keyword: "",
page: 1,
regionId: "",
timeRange: { endMs: "", startMs: "" },
},
loading: false,
@ -25,49 +26,63 @@ const baseProps = {
{ appCode: "aslan", appName: "Aslan" },
],
},
summary: {
googlePlay: { billCount: 1, coinAmount: 90000, usdMinorAmount: 999 },
thirdParty: { billCount: 0, coinAmount: 0, usdMinorAmount: 0 },
total: { billCount: 1, coinAmount: 90000, usdMinorAmount: 999 },
},
summaryLoading: false,
};
test("renders app recharge detail columns and values", () => {
test("hides coin recharge columns by default", () => {
render(
<FinanceRechargeDetailList
{...baseProps}
bills={{
...baseProps.bills,
items: [
{
appCode: "aslan",
coinAmount: 90000,
commandId: "cmd-1",
createdAtMs: 1760000000000,
currencyCode: "SAR",
exchangeCoinAmount: 90000,
exchangeUsdMinorAmount: 999,
externalRef: "GPA.1",
id: "tx-google",
providerAmountMinor: 1299,
rechargeType: "google_play_recharge",
taxDeductionMinor: 195,
taxRate: 0.15,
transactionId: "tx-google",
usdMinorAmount: 999,
},
],
items: [rechargeBillFixture()],
total: 1,
}}
/>,
);
expect(screen.getByRole("switch", { name: "金币显示" })).not.toBeChecked();
expect(screen.queryByRole("columnheader", { name: "充值金币" })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "金币换算" })).not.toBeInTheDocument();
expect(screen.queryByText("90,000 金币")).not.toBeInTheDocument();
expect(screen.queryByText("90,000 金币 = USD 9.99")).not.toBeInTheDocument();
expect(screen.queryByText("1 笔 · 90,000 金币")).not.toBeInTheDocument();
expect(screen.getAllByText("1 笔")).toHaveLength(2);
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
});
test("renders app recharge coin columns and values when switch is enabled", () => {
render(
<FinanceRechargeDetailList
{...baseProps}
bills={{
...baseProps.bills,
items: [rechargeBillFixture()],
total: 1,
}}
/>,
);
fireEvent.click(screen.getByRole("switch", { name: "金币显示" }));
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "充值总额" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "充值金币" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "充值来源" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "充值订单号" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "美金换算" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "换算" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "三方税率扣款" })).toBeInTheDocument();
expect(screen.getByText("Aslan")).toBeInTheDocument();
expect(screen.getByText("90,000 金币")).toBeInTheDocument();
expect(screen.getByText("谷歌充值")).toBeInTheDocument();
expect(screen.getAllByText("1 笔 · 90,000 金币")).toHaveLength(2);
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("tx-google")).toBeInTheDocument();
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
expect(screen.getByText("90,000 金币 = USD 9.99")).toBeInTheDocument();
@ -79,6 +94,32 @@ test("renders app recharge detail columns and values", () => {
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
expect(container.querySelector("tbody td")?.colSpan).toBe(7);
expect(screen.getByText("当前无数据")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "金币显示" }));
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
});
function rechargeBillFixture() {
return {
appCode: "aslan",
coinAmount: 90000,
commandId: "cmd-1",
createdAtMs: 1760000000000,
currencyCode: "SAR",
exchangeCoinAmount: 90000,
exchangeUsdMinorAmount: 999,
externalRef: "GPA.1",
id: "tx-google",
paidSyncedAtMs: 1760000100000,
providerAmountMinor: 1299,
providerTaxMicro: 1950000,
rechargeType: "google_play_recharge",
transactionId: "tx-google",
usdMinorAmount: 999,
userPaidAmountMicro: 13000000,
userPaidCurrencyCode: "SAR",
userPaidText: "SAR 12.99",
};
}

View File

@ -2,8 +2,6 @@ import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined";
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import Button from "@mui/material/Button";
import LinearProgress from "@mui/material/LinearProgress";
const views = [
@ -13,7 +11,7 @@ const views = [
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" }
];
export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeDetails, canViewWithdrawals, children, loading, onRefresh, onViewChange, session }) {
export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeDetails, canViewWithdrawals, children, loading, onViewChange, session }) {
const visibleViews = views.filter((view) => {
if (view.id === "rechargeDetails") {
return canViewRechargeDetails;
@ -26,8 +24,6 @@ export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeD
}
return canCreate;
});
const activeLabel = visibleViews.find((view) => view.id === activeView)?.label || visibleViews[0]?.label || "财务系统";
return (
<main className="finance-shell">
<aside className="finance-sidebar">
@ -60,15 +56,6 @@ export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeD
</div>
</aside>
<section className="finance-main">
<header className="finance-header">
<div>
<span>HYApp</span>
<h1>{activeLabel}</h1>
</div>
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onRefresh}>
刷新
</Button>
</header>
{loading ? <LinearProgress className="finance-progress" /> : null}
<div className="finance-content">{children}</div>
</section>

View File

@ -54,8 +54,7 @@ a {
font-weight: 800;
}
.finance-brand strong,
.finance-header h1 {
.finance-brand strong {
color: var(--text-primary);
font-size: 18px;
line-height: 1.3;
@ -104,36 +103,25 @@ a {
}
.finance-user small,
.finance-header span,
.finance-stack small {
color: var(--text-tertiary);
}
.finance-main {
display: grid;
min-width: 0;
grid-template-rows: auto auto minmax(0, 1fr);
}
.finance-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
padding: 0 var(--space-6);
}
.finance-header h1 {
margin: 2px 0 0;
flex-direction: column;
min-width: 0;
min-height: 100vh;
}
.finance-progress {
flex: 0 0 auto;
height: 2px;
}
.finance-content {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
padding: var(--space-6);
}
@ -173,7 +161,20 @@ a {
}
.finance-toolbar--recharge-details {
grid-template-columns: minmax(150px, 1fr) minmax(130px, 0.85fr) minmax(250px, 1.3fr) minmax(160px, 1fr) auto auto;
grid-template-columns: minmax(150px, 1fr) minmax(130px, 0.85fr) minmax(250px, 1.3fr) minmax(160px, 1fr) auto auto auto;
}
.finance-switch-field {
display: inline-flex;
height: var(--control-height);
align-items: center;
justify-content: center;
gap: var(--space-1);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--control-font-size);
font-weight: 650;
white-space: nowrap;
}
.finance-summary-grid {
@ -224,6 +225,10 @@ a {
}
.finance-table--recharge-details {
min-width: 1240px;
}
.finance-table--recharge-details-with-coins {
min-width: 1480px;
}