diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 184c83d..f2aac4e 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2881,6 +2881,62 @@ "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": { "post": { "operationId": "refreshGoogleRechargePaid", diff --git a/finance/src/FinanceApp.jsx b/finance/src/FinanceApp.jsx index 18b5e10..6cdeb0e 100644 --- a/finance/src/FinanceApp.jsx +++ b/finance/src/FinanceApp.jsx @@ -6,6 +6,7 @@ import { approveFinanceApplication, approveFinanceWithdrawalApplication, createFinanceApplication, + exportFinanceRechargeBills, fetchFinanceApplicationOptions, fetchFinanceRechargeApps, fetchFinanceRechargeSummary, @@ -29,6 +30,7 @@ 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 { downloadCsv } from "@/shared/api/download"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; 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 [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, 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 [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" }); const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 }); @@ -96,6 +99,7 @@ export function FinanceApp() { 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, stat_tz: "Asia/Shanghai", @@ -244,6 +248,27 @@ export function FinanceApp() { [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 () => { if (!session?.canAuditApplication) { return; @@ -424,6 +449,7 @@ export function FinanceApp() { { diff --git a/finance/src/api.js b/finance/src/api.js index f4af0e6..d1bb44d 100644 --- a/finance/src/api.js +++ b/finance/src/api.js @@ -137,6 +137,16 @@ export async function listFinanceRechargeRegions(appCode) { 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) { const endpoint = API_ENDPOINTS.refreshGoogleRechargePaid; const data = await apiRequest(apiEndpointPath(API_OPERATIONS.refreshGoogleRechargePaid), { diff --git a/finance/src/components/FinanceRechargeDetailList.jsx b/finance/src/components/FinanceRechargeDetailList.jsx index a6cf6ec..8558949 100644 --- a/finance/src/components/FinanceRechargeDetailList.jsx +++ b/finance/src/components/FinanceRechargeDetailList.jsx @@ -1,4 +1,5 @@ import CloudSyncOutlined from "@mui/icons-material/CloudSyncOutlined"; +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"; @@ -18,15 +19,32 @@ import { formatMicroMoney, formatTime, formatUsdMinor, + rechargeSourceTone, 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" }, +]; + export function FinanceRechargeDetailList({ bills, error, + exporting, filters, googlePaidRefreshing, loading, + onExport, onFiltersChange, onRefreshGooglePaid, onReload, @@ -50,9 +68,16 @@ export function FinanceRechargeDetailList({ return (
- -
+ onFiltersChange({ rechargeType: value })} + /> +
onFiltersChange({ regionId: event.target.value })} > @@ -80,36 +106,90 @@ export function FinanceRechargeDetailList({ ))} + onFiltersChange({ rechargeType: event.target.value })} + > + {SOURCE_FILTERS.map((source) => ( + + {source.label} + + ))} + onFiltersChange({ timeRange })} /> onFiltersChange({ keyword: event.target.value })} /> - - - +
+
+
+ {timePresets().map((preset) => ( + + ))} +
+
+ + + + +
{error ?
{error}
: null} {loading && !items.length ?
: null} @@ -128,45 +208,57 @@ export function FinanceRechargeDetailList({ > + 充值时间 APP - {showCoinColumns ? 充值金币 : null} 充值来源 充值订单号 + {showCoinColumns ? 充值金币 : null} {showCoinColumns ? 金币换算 : null} - 账单金额 - 用户实付 - 三方税率扣款 - 充值时间 + 账单金额 + 用户实付 + 三方税率扣款 {items.length ? ( items.map((item) => ( - + + + {formatTime(item.createdAtMs)} + {appName(item, options.apps)} - {showCoinColumns ? ( - {coinText(item.coinAmount)} - ) : null} - {rechargeTypeLabel(item.rechargeType)} + + + + {showCoinColumns ? ( + + + {coinText(item.coinAmount)} + + + ) : null} {showCoinColumns ? {exchangeText(item)} : null} - {billAmountText(item)} - {userPaidText(item)} - {taxDeductionText(item)} - - {formatTime(item.createdAtMs)} + + {billAmountText(item)} + + + {userPaidText(item)} + + + {taxDeductionText(item)} )) ) : ( - 当前无数据 + 当前筛选条件下没有账单 )} @@ -174,63 +266,117 @@ export function FinanceRechargeDetailList({ ) : null} - {total > pageSize ? ( -
+
+ + 共 {formatAmount(total)} 笔 · 第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页 + +
- - 第 {page} 页 / 共 {total} 条 -
- ) : null} +
); } -function RechargeSummaryCards({ loading, showCoins, summary }) { +function RechargeKpiBand({ activeFilter, loading, onSelect, showCoins, summary }) { const data = summary || EMPTY_RECHARGE_SUMMARY; - const cards = [ - { bucket: data.total, key: "total", label: "充值总和" }, - { bucket: data.thirdParty, key: "thirdParty", label: "三方充值" }, - { bucket: data.googlePlay, key: "googlePlay", label: "谷歌充值" }, - ]; + const totalUsd = Number(data.total?.usdMinorAmount) || 0; return ( -
- {cards.map(({ bucket, key, label }) => ( -
- {label} - - {loading ? "…" : formatUsdMinor(bucket.usdMinorAmount, "USD")} - - - {loading - ? "统计中" - : summaryMetaText(bucket, showCoins)} - -
- ))} +
+ {KPI_CARDS.map(({ filter, key, label, tone }) => { + const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 }; + const active = activeFilter === filter; + return ( + + ); + })}
); } -function summaryMetaText(bucket, showCoins) { - const billCount = `${formatAmount(bucket.billCount)} 笔`; - if (!showCoins) { - return billCount; +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 })}%`); } - return `${billCount} · ${formatAmount(bucket.coinAmount)} 金币`; + if (showCoins) { + parts.push(`${formatAmount(bucket.coinAmount)} 金币`); + } + return parts.join(" · "); +} + +function SourceBadge({ rechargeType }) { + return ( + + {rechargeTypeLabel(rechargeType)} + + ); +} + +function AmountText({ children, value }) { + const negative = hasNumber(value) && Number(value) < 0; + return {children}; +} + +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 }) { @@ -268,7 +414,7 @@ function billAmountText(item) { } const amount = hasNumber(item.billAmountMinor) ? item.billAmountMinor - : hasNumber(item.providerAmountMinor) + : hasPositiveNumber(item.providerAmountMinor) ? item.providerAmountMinor : item.usdMinorAmount; return formatUsdMinor(amount, item.currencyCode || "USD"); @@ -278,7 +424,7 @@ function userPaidText(item) { if (item.userPaidText) { return item.userPaidText; } - if (hasPositiveNumber(item.userPaidAmountMicro) && item.userPaidCurrencyCode) { + if (hasNonZeroNumber(item.userPaidAmountMicro) && item.userPaidCurrencyCode) { return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode); } if (isGoogleBill(item) && !item.paidSyncedAtMs) { @@ -342,3 +488,7 @@ function hasNumber(value) { function hasPositiveNumber(value) { return hasNumber(value) && Number(value) > 0; } + +function hasNonZeroNumber(value) { + return hasNumber(value) && Number(value) !== 0; +} diff --git a/finance/src/format.js b/finance/src/format.js index ecd308b..dca5b51 100644 --- a/finance/src/format.js +++ b/finance/src/format.js @@ -197,6 +197,7 @@ export function normalizeRechargeBillPage(data = {}) { const EMPTY_RECHARGE_SUMMARY_BUCKET = { billCount: 0, coinAmount: 0, usdMinorAmount: 0 }; export const EMPTY_RECHARGE_SUMMARY = { + coinSeller: EMPTY_RECHARGE_SUMMARY_BUCKET, googlePlay: EMPTY_RECHARGE_SUMMARY_BUCKET, thirdParty: EMPTY_RECHARGE_SUMMARY_BUCKET, total: EMPTY_RECHARGE_SUMMARY_BUCKET, @@ -212,6 +213,7 @@ function normalizeRechargeSummaryBucket(bucket = {}) { export function normalizeRechargeSummary(data = {}) { return { + coinSeller: normalizeRechargeSummaryBucket(data.coinSeller ?? data.coin_seller), googlePlay: normalizeRechargeSummaryBucket(data.googlePlay ?? data.google_play), thirdParty: normalizeRechargeSummaryBucket(data.thirdParty ?? data.third_party), total: normalizeRechargeSummaryBucket(data.total), @@ -221,7 +223,7 @@ export function normalizeRechargeSummary(data = {}) { export function mergeRechargeSummaries(summaries = []) { const merged = normalizeRechargeSummary({}); for (const summary of summaries) { - for (const key of ["googlePlay", "thirdParty", "total"]) { + for (const key of ["coinSeller", "googlePlay", "thirdParty", "total"]) { merged[key] = { billCount: merged[key].billCount + summary[key].billCount, coinAmount: merged[key].coinAmount + summary[key].coinAmount, @@ -257,6 +259,12 @@ export function rechargeTypeLabel(value) { if (value === "coin_seller_transfer") { return "币商充值"; } + if (value === "coin_seller_stock_purchase") { + return "币商进货"; + } + if (value === "coin_seller_stock_deduction") { + return "币商冲回"; + } if (value === "google_play_recharge") { return "谷歌充值"; } @@ -284,6 +292,21 @@ export function rechargeTypeLabel(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) { return APPLICATION_STATUS.find(([status]) => status === value)?.[1] || value || "-"; } diff --git a/finance/src/styles/index.css b/finance/src/styles/index.css index 08e8da5..d86f003 100644 --- a/finance/src/styles/index.css +++ b/finance/src/styles/index.css @@ -177,35 +177,182 @@ a { white-space: nowrap; } -.finance-summary-grid { +.finance-kpi-band { display: grid; - grid-template-columns: repeat(3, minmax(200px, 1fr)); + grid-template-columns: repeat(4, minmax(180px, 1fr)); gap: var(--space-4); } -.finance-summary-card { +.finance-kpi { display: grid; gap: 4px; 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); 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); font-size: 13px; + font-weight: 650; } -.finance-summary-card__value { - font-size: 22px; +.finance-kpi__value { + font-size: 24px; line-height: 1.2; + font-variant-numeric: tabular-nums; } -.finance-summary-card small { +.finance-kpi__meta { 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 { border: 1px solid color-mix(in srgb, var(--danger) 24%, transparent); border-radius: var(--radius-control); @@ -312,7 +459,7 @@ a { .finance-pagination { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: var(--space-3); color: var(--text-secondary); } @@ -445,11 +592,14 @@ a { .finance-form-grid, .finance-evidence-grid, - .finance-toolbar, - .finance-summary-grid { + .finance-toolbar { grid-template-columns: 1fr; } + .finance-kpi-band { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .finance-create-dialog-paper { width: calc(100vw - 32px); max-width: calc(100vw - 32px) !important; diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index f472da2..86b151a 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -106,6 +106,7 @@ export const API_OPERATIONS = { expireRoomRpsChallenge: "expireRoomRpsChallenge", exportLoginLogs: "exportLoginLogs", exportOperationLogs: "exportOperationLogs", + exportRechargeBills: "exportRechargeBills", exportUsers: "exportUsers", generateDiceRobots: "generateDiceRobots", generatePrettyIds: "generatePrettyIds", @@ -984,6 +985,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "GET", operationId: API_OPERATIONS.exportUsers, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 1e985f0..4de9c42 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1892,6 +1892,22 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -6818,6 +6834,25 @@ export interface operations { 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: { parameters: { query?: never;