阿斯兰数据

This commit is contained in:
zhx 2026-07-03 15:53:40 +08:00
parent 9dd69b2374
commit 9fe67dc8d1
8 changed files with 544 additions and 85 deletions

View File

@ -2881,6 +2881,62 @@
"x-permissions": ["payment-bill:view"] "x-permissions": ["payment-bill:view"]
} }
}, },
"/admin/payment/recharge-bills/export": {
"get": {
"operationId": "exportRechargeBills",
"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"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-bill:view",
"x-permissions": ["payment-bill:view"]
}
},
"/admin/payment/recharge-bills/google-paid/refresh": { "/admin/payment/recharge-bills/google-paid/refresh": {
"post": { "post": {
"operationId": "refreshGoogleRechargePaid", "operationId": "refreshGoogleRechargePaid",

View File

@ -6,6 +6,7 @@ import {
approveFinanceApplication, approveFinanceApplication,
approveFinanceWithdrawalApplication, approveFinanceWithdrawalApplication,
createFinanceApplication, createFinanceApplication,
exportFinanceRechargeBills,
fetchFinanceApplicationOptions, fetchFinanceApplicationOptions,
fetchFinanceRechargeApps, fetchFinanceRechargeApps,
fetchFinanceRechargeSummary, fetchFinanceRechargeSummary,
@ -29,6 +30,7 @@ import { FinanceShell } from "./components/FinanceShell.jsx";
import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx"; import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx";
import { DEFAULT_APPLICATION_FORM } from "./constants.js"; import { DEFAULT_APPLICATION_FORM } from "./constants.js";
import { EMPTY_RECHARGE_SUMMARY, buildApplicationPayload, validateApplicationPayload } from "./format.js"; import { EMPTY_RECHARGE_SUMMARY, buildApplicationPayload, validateApplicationPayload } from "./format.js";
import { downloadCsv } from "@/shared/api/download";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
const emptyOptions = { apps: [], operations: [], walletIdentities: [] }; const emptyOptions = { apps: [], operations: [], walletIdentities: [] };
@ -47,7 +49,8 @@ export function FinanceApp() {
const [applicationsState, setApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false }); const [applicationsState, setApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
const [withdrawalsState, setWithdrawalsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false }); const [withdrawalsState, setWithdrawalsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
const [activeView, setActiveView] = useState("create"); const [activeView, setActiveView] = useState("create");
const [rechargeDetailFilters, setRechargeDetailFilters] = useState({ appCode: "", keyword: "", page: 1, regionId: "", timeRange: { endMs: "", startMs: "" } }); const [rechargeDetailFilters, setRechargeDetailFilters] = useState({ appCode: "", keyword: "", page: 1, rechargeType: "", regionId: "", timeRange: { endMs: "", startMs: "" } });
const [rechargeExporting, setRechargeExporting] = useState(false);
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" }); const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" }); const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" });
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 }); const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
@ -96,6 +99,7 @@ export function FinanceApp() {
keyword: rechargeDetailFilters.keyword, keyword: rechargeDetailFilters.keyword,
page: rechargeDetailFilters.page, page: rechargeDetailFilters.page,
page_size: pageSize, page_size: pageSize,
recharge_type: rechargeDetailFilters.rechargeType,
region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "", region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "",
start_at_ms: rechargeDetailFilters.timeRange.startMs, start_at_ms: rechargeDetailFilters.timeRange.startMs,
stat_tz: "Asia/Shanghai", stat_tz: "Asia/Shanghai",
@ -244,6 +248,27 @@ export function FinanceApp() {
[googlePaidRefreshing, loadRechargeDetails, rechargeAppCode, showToast] [googlePaidRefreshing, loadRechargeDetails, rechargeAppCode, showToast]
); );
const exportRechargeBills = useCallback(async () => {
if (!rechargeAppCode || rechargeExporting) {
return;
}
setRechargeExporting(true);
try {
const timestamp = new Date()
.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" })
.replaceAll(/[: ]/g, "-");
await downloadCsv(
() => exportFinanceRechargeBills(rechargeAppCode, rechargeDetailQuery),
`充值明细-${rechargeAppCode}-${timestamp}.csv`
);
showToast("充值明细已导出", "success");
} catch (err) {
showToast(err.message || "导出充值明细失败", "error");
} finally {
setRechargeExporting(false);
}
}, [rechargeAppCode, rechargeDetailQuery, rechargeExporting, showToast]);
const loadApplications = useCallback(async () => { const loadApplications = useCallback(async () => {
if (!session?.canAuditApplication) { if (!session?.canAuditApplication) {
return; return;
@ -424,6 +449,7 @@ export function FinanceApp() {
<FinanceRechargeDetailList <FinanceRechargeDetailList
bills={rechargeDetailsState.data} bills={rechargeDetailsState.data}
error={rechargeDetailsState.error} error={rechargeDetailsState.error}
exporting={rechargeExporting}
filters={rechargeDetailFilters} filters={rechargeDetailFilters}
googlePaidRefreshing={googlePaidRefreshing} googlePaidRefreshing={googlePaidRefreshing}
loading={rechargeDetailsState.loading} loading={rechargeDetailsState.loading}
@ -431,6 +457,7 @@ export function FinanceApp() {
regions={rechargeRegionsState.items} regions={rechargeRegionsState.items}
summary={rechargeSummaryState.data} summary={rechargeSummaryState.data}
summaryLoading={rechargeSummaryState.loading} summaryLoading={rechargeSummaryState.loading}
onExport={exportRechargeBills}
onFiltersChange={updateRechargeDetailFilters} onFiltersChange={updateRechargeDetailFilters}
onRefreshGooglePaid={refreshGooglePaid} onRefreshGooglePaid={refreshGooglePaid}
onReload={() => { onReload={() => {

View File

@ -137,6 +137,16 @@ export async function listFinanceRechargeRegions(appCode) {
return normalizeRechargeRegions(data); return normalizeRechargeRegions(data);
} }
export async function exportFinanceRechargeBills(appCode, query = {}) {
const endpoint = API_ENDPOINTS.exportRechargeBills;
return apiRequest(apiEndpointPath(API_OPERATIONS.exportRechargeBills), {
headers: { [appCodeHeader]: appCode },
method: endpoint.method,
query: cleanRechargeBillQuery(query),
raw: true
});
}
export async function refreshFinanceGoogleRechargePaid(appCode, transactionIds) { export async function refreshFinanceGoogleRechargePaid(appCode, transactionIds) {
const endpoint = API_ENDPOINTS.refreshGoogleRechargePaid; const endpoint = API_ENDPOINTS.refreshGoogleRechargePaid;
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.refreshGoogleRechargePaid), { const data = await apiRequest(apiEndpointPath(API_OPERATIONS.refreshGoogleRechargePaid), {

View File

@ -1,4 +1,5 @@
import CloudSyncOutlined from "@mui/icons-material/CloudSyncOutlined"; import CloudSyncOutlined from "@mui/icons-material/CloudSyncOutlined";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
@ -18,15 +19,32 @@ import {
formatMicroMoney, formatMicroMoney,
formatTime, formatTime,
formatUsdMinor, formatUsdMinor,
rechargeSourceTone,
rechargeTypeLabel, rechargeTypeLabel,
} from "../format.js"; } 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" },
];
export function FinanceRechargeDetailList({ export function FinanceRechargeDetailList({
bills, bills,
error, error,
exporting,
filters, filters,
googlePaidRefreshing, googlePaidRefreshing,
loading, loading,
onExport,
onFiltersChange, onFiltersChange,
onRefreshGooglePaid, onRefreshGooglePaid,
onReload, onReload,
@ -50,9 +68,16 @@ export function FinanceRechargeDetailList({
return ( return (
<section className="finance-panel finance-list"> <section className="finance-panel finance-list">
<RechargeSummaryCards loading={summaryLoading} showCoins={showCoinColumns} summary={summary} /> <RechargeKpiBand
<div className="finance-toolbar finance-toolbar--recharge-details"> activeFilter={filters.rechargeType || ""}
loading={summaryLoading}
showCoins={showCoinColumns}
summary={summary}
onSelect={(value) => onFiltersChange({ rechargeType: value })}
/>
<div className="finance-filterbar">
<TextField <TextField
className="finance-filterbar__field"
label="APP" label="APP"
select select
value={filters.appCode} value={filters.appCode}
@ -66,10 +91,11 @@ export function FinanceRechargeDetailList({
))} ))}
</TextField> </TextField>
<TextField <TextField
className="finance-filterbar__field"
disabled={!filters.appCode} disabled={!filters.appCode}
helperText={filters.appCode ? "" : "先选择 APP"}
label="区域" label="区域"
select select
title={filters.appCode ? "" : "先选择 APP"}
value={filters.regionId} value={filters.regionId}
onChange={(event) => onFiltersChange({ regionId: event.target.value })} onChange={(event) => onFiltersChange({ regionId: event.target.value })}
> >
@ -80,36 +106,90 @@ export function FinanceRechargeDetailList({
</MenuItem> </MenuItem>
))} ))}
</TextField> </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 <TimeRangeFilter
label="充值时间 · 中国时区" label="充值时间 · 中国时区"
value={filters.timeRange} value={filters.timeRange}
onChange={(timeRange) => onFiltersChange({ timeRange })} onChange={(timeRange) => onFiltersChange({ timeRange })}
/> />
<TextField <TextField
className="finance-filterbar__field finance-filterbar__field--search"
label="订单搜索" label="订单搜索"
placeholder="交易号 / 三方单号 / 凭证号"
value={filters.keyword} value={filters.keyword}
onChange={(event) => onFiltersChange({ keyword: event.target.value })} onChange={(event) => onFiltersChange({ keyword: event.target.value })}
/> />
<label className="finance-switch-field"> </div>
<AdminSwitch <div className="finance-actionbar">
checked={showCoinColumns} <div className="finance-actionbar__presets">
label="金币显示" {timePresets().map((preset) => (
onChange={(event) => setShowCoinColumns(event.target.checked)} <button
/> className={[
<span>金币显示</span> "finance-chip",
</label> isPresetActive(filters.timeRange, preset) ? "finance-chip--active" : "",
<Button ]
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length} .filter(Boolean)
startIcon={<CloudSyncOutlined fontSize="small" />} .join(" ")}
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 APP 后可查询"} key={preset.label}
variant="outlined" type="button"
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)} onClick={() => onFiltersChange({ timeRange: preset.range })}
> >
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"} {preset.label}
</Button> </button>
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}> ))}
刷新列表 </div>
</Button> <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> </div>
{error ? <div className="finance-error">{error}</div> : null} {error ? <div className="finance-error">{error}</div> : null}
{loading && !items.length ? <div className="finance-skeleton" /> : null} {loading && !items.length ? <div className="finance-skeleton" /> : null}
@ -128,45 +208,57 @@ export function FinanceRechargeDetailList({
> >
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell className="finance-time-cell">充值时间</TableCell>
<TableCell>APP</TableCell> <TableCell>APP</TableCell>
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
<TableCell>充值来源</TableCell> <TableCell>充值来源</TableCell>
<TableCell>充值订单号</TableCell> <TableCell>充值订单号</TableCell>
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
{showCoinColumns ? <TableCell>金币换算</TableCell> : null} {showCoinColumns ? <TableCell>金币换算</TableCell> : null}
<TableCell>账单金额</TableCell> <TableCell align="right">账单金额</TableCell>
<TableCell>用户实付</TableCell> <TableCell align="right">用户实付</TableCell>
<TableCell>三方税率扣款</TableCell> <TableCell align="right">三方税率扣款</TableCell>
<TableCell className="finance-time-cell">充值时间</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{items.length ? ( {items.length ? (
items.map((item) => ( items.map((item) => (
<TableRow key={item.id || item.transactionId}> <TableRow hover key={item.id || item.transactionId}>
<TableCell className="finance-time-cell">
{formatTime(item.createdAtMs)}
</TableCell>
<TableCell>{appName(item, options.apps)}</TableCell> <TableCell>{appName(item, options.apps)}</TableCell>
{showCoinColumns ? (
<TableCell align="right">{coinText(item.coinAmount)}</TableCell>
) : null}
<TableCell>{rechargeTypeLabel(item.rechargeType)}</TableCell>
<TableCell> <TableCell>
<SourceBadge rechargeType={item.rechargeType} />
</TableCell>
<TableCell className="finance-order-cell">
<Stacked <Stacked
primary={item.transactionId || "-"} primary={item.transactionId || "-"}
secondary={secondaryOrderNo(item)} secondary={secondaryOrderNo(item)}
/> />
</TableCell> </TableCell>
{showCoinColumns ? (
<TableCell align="right">
<AmountText value={item.coinAmount}>
{coinText(item.coinAmount)}
</AmountText>
</TableCell>
) : null}
{showCoinColumns ? <TableCell>{exchangeText(item)}</TableCell> : null} {showCoinColumns ? <TableCell>{exchangeText(item)}</TableCell> : null}
<TableCell>{billAmountText(item)}</TableCell> <TableCell align="right">
<TableCell>{userPaidText(item)}</TableCell> <AmountText value={item.usdMinorAmount}>{billAmountText(item)}</AmountText>
<TableCell>{taxDeductionText(item)}</TableCell>
<TableCell className="finance-time-cell">
{formatTime(item.createdAtMs)}
</TableCell> </TableCell>
<TableCell align="right">
<AmountText value={item.userPaidAmountMicro}>
{userPaidText(item)}
</AmountText>
</TableCell>
<TableCell align="right">{taxDeductionText(item)}</TableCell>
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell align="center" colSpan={visibleColumnCount}> <TableCell align="center" colSpan={visibleColumnCount}>
当前无数据 当前筛选条件下没有账单
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@ -174,63 +266,117 @@ export function FinanceRechargeDetailList({
</Table> </Table>
</TableContainer> </TableContainer>
) : null} ) : null}
{total > pageSize ? ( <div className="finance-pagination">
<div className="finance-pagination"> <span className="finance-pagination__meta">
{formatAmount(total)} · {page} / {Math.max(1, Math.ceil(total / pageSize))}
</span>
<div className="finance-pagination__buttons">
<Button <Button
disabled={page <= 1 || loading} disabled={page <= 1 || loading}
size="small"
variant="outlined" variant="outlined"
onClick={() => onFiltersChange({ page: page - 1 })} onClick={() => onFiltersChange({ page: page - 1 })}
> >
上一页 上一页
</Button> </Button>
<span>
{page} / {total}
</span>
<Button <Button
disabled={!hasNextPage || loading} disabled={!hasNextPage || loading}
size="small"
variant="outlined" variant="outlined"
onClick={() => onFiltersChange({ page: page + 1 })} onClick={() => onFiltersChange({ page: page + 1 })}
> >
下一页 下一页
</Button> </Button>
</div> </div>
) : null} </div>
</section> </section>
); );
} }
function RechargeSummaryCards({ loading, showCoins, summary }) { function RechargeKpiBand({ activeFilter, loading, onSelect, showCoins, summary }) {
const data = summary || EMPTY_RECHARGE_SUMMARY; const data = summary || EMPTY_RECHARGE_SUMMARY;
const cards = [ const totalUsd = Number(data.total?.usdMinorAmount) || 0;
{ bucket: data.total, key: "total", label: "充值总和" },
{ bucket: data.thirdParty, key: "thirdParty", label: "三方充值" },
{ bucket: data.googlePlay, key: "googlePlay", label: "谷歌充值" },
];
return ( return (
<div className="finance-summary-grid"> <div className="finance-kpi-band">
{cards.map(({ bucket, key, label }) => ( {KPI_CARDS.map(({ filter, key, label, tone }) => {
<div className="finance-summary-card" key={key}> const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
<span className="finance-summary-card__label">{label}</span> const active = activeFilter === filter;
<strong className="finance-summary-card__value"> return (
{loading ? "…" : formatUsdMinor(bucket.usdMinorAmount, "USD")} <button
</strong> className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
<small> .filter(Boolean)
{loading .join(" ")}
? "统计中" key={key}
: summaryMetaText(bucket, showCoins)} title={filter ? `点击只看${label}` : "点击查看全部来源"}
</small> type="button"
</div> 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> </div>
); );
} }
function summaryMetaText(bucket, showCoins) { function kpiMetaText(bucket, key, totalUsd, showCoins) {
const billCount = `${formatAmount(bucket.billCount)}`; const parts = [`${formatAmount(bucket.billCount)}`];
if (!showCoins) { if (key !== "total" && totalUsd > 0) {
return billCount; const share = ((Number(bucket.usdMinorAmount) || 0) / totalUsd) * 100;
parts.push(`占比 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
} }
return `${billCount} · ${formatAmount(bucket.coinAmount)} 金币`; if (showCoins) {
parts.push(`${formatAmount(bucket.coinAmount)} 金币`);
}
return parts.join(" · ");
}
function SourceBadge({ rechargeType }) {
return (
<span className={`finance-badge finance-badge--${rechargeSourceTone(rechargeType)}`}>
{rechargeTypeLabel(rechargeType)}
</span>
);
}
function AmountText({ children, value }) {
const negative = hasNumber(value) && Number(value) < 0;
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 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 isPresetActive(timeRange, preset) {
const current = timeRange || { endMs: "", startMs: "" };
return (
String(current.startMs || "") === String(preset.range.startMs || "") &&
String(current.endMs || "") === String(preset.range.endMs || "")
);
} }
function Stacked({ primary, secondary }) { function Stacked({ primary, secondary }) {
@ -268,7 +414,7 @@ function billAmountText(item) {
} }
const amount = hasNumber(item.billAmountMinor) const amount = hasNumber(item.billAmountMinor)
? item.billAmountMinor ? item.billAmountMinor
: hasNumber(item.providerAmountMinor) : hasPositiveNumber(item.providerAmountMinor)
? item.providerAmountMinor ? item.providerAmountMinor
: item.usdMinorAmount; : item.usdMinorAmount;
return formatUsdMinor(amount, item.currencyCode || "USD"); return formatUsdMinor(amount, item.currencyCode || "USD");
@ -278,7 +424,7 @@ function userPaidText(item) {
if (item.userPaidText) { if (item.userPaidText) {
return item.userPaidText; return item.userPaidText;
} }
if (hasPositiveNumber(item.userPaidAmountMicro) && item.userPaidCurrencyCode) { if (hasNonZeroNumber(item.userPaidAmountMicro) && item.userPaidCurrencyCode) {
return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode); return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode);
} }
if (isGoogleBill(item) && !item.paidSyncedAtMs) { if (isGoogleBill(item) && !item.paidSyncedAtMs) {
@ -342,3 +488,7 @@ function hasNumber(value) {
function hasPositiveNumber(value) { function hasPositiveNumber(value) {
return hasNumber(value) && Number(value) > 0; return hasNumber(value) && Number(value) > 0;
} }
function hasNonZeroNumber(value) {
return hasNumber(value) && Number(value) !== 0;
}

View File

@ -197,6 +197,7 @@ export function normalizeRechargeBillPage(data = {}) {
const EMPTY_RECHARGE_SUMMARY_BUCKET = { billCount: 0, coinAmount: 0, usdMinorAmount: 0 }; const EMPTY_RECHARGE_SUMMARY_BUCKET = { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
export const EMPTY_RECHARGE_SUMMARY = { export const EMPTY_RECHARGE_SUMMARY = {
coinSeller: EMPTY_RECHARGE_SUMMARY_BUCKET,
googlePlay: EMPTY_RECHARGE_SUMMARY_BUCKET, googlePlay: EMPTY_RECHARGE_SUMMARY_BUCKET,
thirdParty: EMPTY_RECHARGE_SUMMARY_BUCKET, thirdParty: EMPTY_RECHARGE_SUMMARY_BUCKET,
total: EMPTY_RECHARGE_SUMMARY_BUCKET, total: EMPTY_RECHARGE_SUMMARY_BUCKET,
@ -212,6 +213,7 @@ function normalizeRechargeSummaryBucket(bucket = {}) {
export function normalizeRechargeSummary(data = {}) { export function normalizeRechargeSummary(data = {}) {
return { return {
coinSeller: normalizeRechargeSummaryBucket(data.coinSeller ?? data.coin_seller),
googlePlay: normalizeRechargeSummaryBucket(data.googlePlay ?? data.google_play), googlePlay: normalizeRechargeSummaryBucket(data.googlePlay ?? data.google_play),
thirdParty: normalizeRechargeSummaryBucket(data.thirdParty ?? data.third_party), thirdParty: normalizeRechargeSummaryBucket(data.thirdParty ?? data.third_party),
total: normalizeRechargeSummaryBucket(data.total), total: normalizeRechargeSummaryBucket(data.total),
@ -221,7 +223,7 @@ export function normalizeRechargeSummary(data = {}) {
export function mergeRechargeSummaries(summaries = []) { export function mergeRechargeSummaries(summaries = []) {
const merged = normalizeRechargeSummary({}); const merged = normalizeRechargeSummary({});
for (const summary of summaries) { for (const summary of summaries) {
for (const key of ["googlePlay", "thirdParty", "total"]) { for (const key of ["coinSeller", "googlePlay", "thirdParty", "total"]) {
merged[key] = { merged[key] = {
billCount: merged[key].billCount + summary[key].billCount, billCount: merged[key].billCount + summary[key].billCount,
coinAmount: merged[key].coinAmount + summary[key].coinAmount, coinAmount: merged[key].coinAmount + summary[key].coinAmount,
@ -257,6 +259,12 @@ export function rechargeTypeLabel(value) {
if (value === "coin_seller_transfer") { if (value === "coin_seller_transfer") {
return "币商充值"; return "币商充值";
} }
if (value === "coin_seller_stock_purchase") {
return "币商进货";
}
if (value === "coin_seller_stock_deduction") {
return "币商冲回";
}
if (value === "google_play_recharge") { if (value === "google_play_recharge") {
return "谷歌充值"; return "谷歌充值";
} }
@ -284,6 +292,21 @@ export function rechargeTypeLabel(value) {
return value || "-"; return value || "-";
} }
// rechargeSourceTone 决定来源徽章与 KPI 卡片的配色分组google/thirdparty/coinseller。
export function rechargeSourceTone(rechargeType) {
if (rechargeType === "google_play_recharge") {
return "google";
}
if (
rechargeType === "coin_seller_stock_purchase" ||
rechargeType === "coin_seller_stock_deduction" ||
rechargeType === "coin_seller_transfer"
) {
return "coinseller";
}
return "thirdparty";
}
export function statusLabel(value) { export function statusLabel(value) {
return APPLICATION_STATUS.find(([status]) => status === value)?.[1] || value || "-"; return APPLICATION_STATUS.find(([status]) => status === value)?.[1] || value || "-";
} }

View File

@ -177,35 +177,182 @@ a {
white-space: nowrap; white-space: nowrap;
} }
.finance-summary-grid { .finance-kpi-band {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(200px, 1fr)); grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: var(--space-4); gap: var(--space-4);
} }
.finance-summary-card { .finance-kpi {
display: grid; display: grid;
gap: 4px; gap: 4px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-control); border-radius: var(--radius-card);
border-left: 4px solid var(--border);
background: var(--bg-card-strong); background: var(--bg-card-strong);
padding: var(--space-4); padding: var(--space-4);
text-align: left;
font: inherit;
color: inherit;
cursor: pointer;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
} }
.finance-summary-card__label { .finance-kpi:hover {
box-shadow: var(--shadow-soft);
transform: translateY(-1px);
}
.finance-kpi--total {
border-left-color: var(--text-secondary);
}
.finance-kpi--google {
border-left-color: #34a853;
}
.finance-kpi--thirdparty {
border-left-color: #4285f4;
}
.finance-kpi--coinseller {
border-left-color: #f9ab00;
}
.finance-kpi--active {
border-color: var(--primary, #4285f4);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary, #4285f4) 24%, transparent);
}
.finance-kpi__label {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 13px; font-size: 13px;
font-weight: 650;
} }
.finance-summary-card__value { .finance-kpi__value {
font-size: 22px; font-size: 24px;
line-height: 1.2; line-height: 1.2;
font-variant-numeric: tabular-nums;
} }
.finance-summary-card small { .finance-kpi__meta {
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.finance-filterbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
.finance-filterbar__field {
min-width: 150px;
}
.finance-filterbar__field--search {
min-width: 240px;
flex: 1;
}
.finance-actionbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
justify-content: space-between;
}
.finance-actionbar__presets {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.finance-actionbar__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
.finance-chip {
border: 1px solid var(--border);
border-radius: 999px;
background: transparent;
color: var(--text-secondary);
padding: 4px 12px;
font: inherit;
font-size: 13px;
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease,
color 0.15s ease;
}
.finance-chip:hover {
border-color: var(--primary, #4285f4);
color: var(--primary, #4285f4);
}
.finance-chip--active {
border-color: var(--primary, #4285f4);
background: color-mix(in srgb, var(--primary, #4285f4) 12%, transparent);
color: var(--primary, #4285f4);
font-weight: 650;
}
.finance-badge {
display: inline-block;
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.finance-badge--google {
background: color-mix(in srgb, #34a853 14%, transparent);
color: #1e7e34;
}
.finance-badge--thirdparty {
background: color-mix(in srgb, #4285f4 14%, transparent);
color: #2b6cd4;
}
.finance-badge--coinseller {
background: color-mix(in srgb, #f9ab00 18%, transparent);
color: #b07503;
}
.finance-amount {
font-variant-numeric: tabular-nums;
}
.finance-amount--negative {
color: var(--danger);
}
.finance-order-cell {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.finance-pagination__meta {
color: var(--text-secondary);
}
.finance-pagination__buttons {
display: flex;
gap: var(--space-2);
}
.finance-error { .finance-error {
border: 1px solid color-mix(in srgb, var(--danger) 24%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 24%, transparent);
border-radius: var(--radius-control); border-radius: var(--radius-control);
@ -312,7 +459,7 @@ a {
.finance-pagination { .finance-pagination {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
color: var(--text-secondary); color: var(--text-secondary);
} }
@ -445,11 +592,14 @@ a {
.finance-form-grid, .finance-form-grid,
.finance-evidence-grid, .finance-evidence-grid,
.finance-toolbar, .finance-toolbar {
.finance-summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.finance-kpi-band {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.finance-create-dialog-paper { .finance-create-dialog-paper {
width: calc(100vw - 32px); width: calc(100vw - 32px);
max-width: calc(100vw - 32px) !important; max-width: calc(100vw - 32px) !important;

View File

@ -106,6 +106,7 @@ export const API_OPERATIONS = {
expireRoomRpsChallenge: "expireRoomRpsChallenge", expireRoomRpsChallenge: "expireRoomRpsChallenge",
exportLoginLogs: "exportLoginLogs", exportLoginLogs: "exportLoginLogs",
exportOperationLogs: "exportOperationLogs", exportOperationLogs: "exportOperationLogs",
exportRechargeBills: "exportRechargeBills",
exportUsers: "exportUsers", exportUsers: "exportUsers",
generateDiceRobots: "generateDiceRobots", generateDiceRobots: "generateDiceRobots",
generatePrettyIds: "generatePrettyIds", generatePrettyIds: "generatePrettyIds",
@ -984,6 +985,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "log:export", permission: "log:export",
permissions: ["log:export"] permissions: ["log:export"]
}, },
exportRechargeBills: {
method: "GET",
operationId: API_OPERATIONS.exportRechargeBills,
path: "/v1/admin/payment/recharge-bills/export",
permission: "payment-bill:view",
permissions: ["payment-bill:view"]
},
exportUsers: { exportUsers: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.exportUsers, operationId: API_OPERATIONS.exportUsers,

View File

@ -1892,6 +1892,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/payment/recharge-bills/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["exportRechargeBills"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/recharge-bills/google-paid/refresh": { "/admin/payment/recharge-bills/google-paid/refresh": {
parameters: { parameters: {
query?: never; query?: never;
@ -6818,6 +6834,25 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
exportRechargeBills: {
parameters: {
query?: {
keyword?: string;
recharge_type?: string;
status?: string;
region_id?: number;
start_at_ms?: number;
end_at_ms?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
refreshGoogleRechargePaid: { refreshGoogleRechargePaid: {
parameters: { parameters: {
query?: never; query?: never;