阿斯兰数据
This commit is contained in:
parent
9dd69b2374
commit
9fe67dc8d1
@ -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",
|
||||
|
||||
@ -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() {
|
||||
<FinanceRechargeDetailList
|
||||
bills={rechargeDetailsState.data}
|
||||
error={rechargeDetailsState.error}
|
||||
exporting={rechargeExporting}
|
||||
filters={rechargeDetailFilters}
|
||||
googlePaidRefreshing={googlePaidRefreshing}
|
||||
loading={rechargeDetailsState.loading}
|
||||
@ -431,6 +457,7 @@ export function FinanceApp() {
|
||||
regions={rechargeRegionsState.items}
|
||||
summary={rechargeSummaryState.data}
|
||||
summaryLoading={rechargeSummaryState.loading}
|
||||
onExport={exportRechargeBills}
|
||||
onFiltersChange={updateRechargeDetailFilters}
|
||||
onRefreshGooglePaid={refreshGooglePaid}
|
||||
onReload={() => {
|
||||
|
||||
@ -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), {
|
||||
|
||||
@ -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 (
|
||||
<section className="finance-panel finance-list">
|
||||
<RechargeSummaryCards loading={summaryLoading} showCoins={showCoinColumns} summary={summary} />
|
||||
<div className="finance-toolbar finance-toolbar--recharge-details">
|
||||
<RechargeKpiBand
|
||||
activeFilter={filters.rechargeType || ""}
|
||||
loading={summaryLoading}
|
||||
showCoins={showCoinColumns}
|
||||
summary={summary}
|
||||
onSelect={(value) => onFiltersChange({ rechargeType: value })}
|
||||
/>
|
||||
<div className="finance-filterbar">
|
||||
<TextField
|
||||
className="finance-filterbar__field"
|
||||
label="APP"
|
||||
select
|
||||
value={filters.appCode}
|
||||
@ -66,10 +91,11 @@ export function FinanceRechargeDetailList({
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className="finance-filterbar__field"
|
||||
disabled={!filters.appCode}
|
||||
helperText={filters.appCode ? "" : "先选择 APP"}
|
||||
label="区域"
|
||||
select
|
||||
title={filters.appCode ? "" : "先选择 APP"}
|
||||
value={filters.regionId}
|
||||
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
|
||||
>
|
||||
@ -80,36 +106,90 @@ export function FinanceRechargeDetailList({
|
||||
</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"
|
||||
label="订单搜索"
|
||||
placeholder="交易号 / 三方单号 / 凭证号"
|
||||
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" />}
|
||||
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 APP 后可查询"}
|
||||
variant="outlined"
|
||||
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
|
||||
>
|
||||
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
|
||||
</Button>
|
||||
<Button 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}
|
||||
@ -128,45 +208,57 @@ export function FinanceRechargeDetailList({
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="finance-time-cell">充值时间</TableCell>
|
||||
<TableCell>APP</TableCell>
|
||||
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
|
||||
<TableCell>充值来源</TableCell>
|
||||
<TableCell>充值订单号</TableCell>
|
||||
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
|
||||
{showCoinColumns ? <TableCell>金币换算</TableCell> : null}
|
||||
<TableCell>账单金额</TableCell>
|
||||
<TableCell>用户实付</TableCell>
|
||||
<TableCell>三方税率扣款</TableCell>
|
||||
<TableCell className="finance-time-cell">充值时间</TableCell>
|
||||
<TableCell align="right">账单金额</TableCell>
|
||||
<TableCell align="right">用户实付</TableCell>
|
||||
<TableCell align="right">三方税率扣款</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.length ? (
|
||||
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>
|
||||
{showCoinColumns ? (
|
||||
<TableCell align="right">{coinText(item.coinAmount)}</TableCell>
|
||||
) : null}
|
||||
<TableCell>{rechargeTypeLabel(item.rechargeType)}</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>{billAmountText(item)}</TableCell>
|
||||
<TableCell>{userPaidText(item)}</TableCell>
|
||||
<TableCell>{taxDeductionText(item)}</TableCell>
|
||||
<TableCell className="finance-time-cell">
|
||||
{formatTime(item.createdAtMs)}
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell align="center" colSpan={visibleColumnCount}>
|
||||
当前无数据
|
||||
当前筛选条件下没有账单
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@ -174,63 +266,117 @@ export function FinanceRechargeDetailList({
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : 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
|
||||
disabled={page <= 1 || loading}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => onFiltersChange({ page: page - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
第 {page} 页 / 共 {total} 条
|
||||
</span>
|
||||
<Button
|
||||
disabled={!hasNextPage || loading}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => onFiltersChange({ page: page + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="finance-summary-grid">
|
||||
{cards.map(({ bucket, key, label }) => (
|
||||
<div className="finance-summary-card" key={key}>
|
||||
<span className="finance-summary-card__label">{label}</span>
|
||||
<strong className="finance-summary-card__value">
|
||||
{loading ? "…" : formatUsdMinor(bucket.usdMinorAmount, "USD")}
|
||||
</strong>
|
||||
<small>
|
||||
{loading
|
||||
? "统计中"
|
||||
: summaryMetaText(bucket, showCoins)}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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 }) {
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 || "-";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<ApiOperationId, ApiEndpoint> = {
|
||||
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,
|
||||
|
||||
35
src/shared/api/generated/schema.d.ts
vendored
35
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user