2026-05-14 09:57:41 +08:00

510 lines
18 KiB
JavaScript

import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { listCoinLedger } from "@/features/operations/api";
import {
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/operations/operations.module.css";
const pageSize = 20;
const columns = [
{
key: "user",
label: "用户",
width: "minmax(260px, 1.35fr)",
render: (entry) => <UserCell entry={entry} />,
},
{
key: "amount",
label: "数量",
width: "minmax(120px, 0.65fr)",
render: (entry) => <AmountValue entry={entry} />,
},
{
key: "bizType",
label: "类型",
width: "minmax(150px, 0.8fr)",
render: (entry) => bizTypeLabel(entry.bizType),
},
{
key: "balance",
label: "余额",
width: "minmax(120px, 0.65fr)",
render: (entry) => formatNumber(entry.availableAfter),
},
{
key: "createdAtMs",
label: "时间",
width: "minmax(170px, 0.85fr)",
render: (entry) => formatMillis(entry.createdAtMs),
},
];
const hiddenMetadataKeys = new Set(["presentation_json", "resource_snapshot_json"]);
const metadataLabels = {
amount: "数量",
app_code: "App",
applied_at_ms: "入账时间",
asset_type: "资产类型",
available_delta: "可用变化",
balance_after: "余额",
billing_receipt_id: "账单 ID",
charge_amount: "扣费数量",
charge_asset_type: "扣费资产",
coin_amount: "金币数量",
coin_price: "单价",
coin_spent: "消耗金币",
counts_as_seller_recharge: "计入币商充值",
cycle_key: "周期",
evidence_ref: "凭证",
game_id: "游戏 ID",
gift_count: "礼物数量",
gift_id: "礼物 ID",
gift_name: "礼物名称",
gift_point_added: "礼物积分",
heat_value: "热度",
operator_user_id: "操作人",
op_type: "操作类型",
paid_amount_micro: "支付金额",
paid_currency_code: "支付币种",
payment_ref: "支付凭证",
platform_code: "游戏平台",
price_version: "价格版本",
provider_order_id: "游戏订单",
provider_round_id: "游戏局号",
reason: "原因",
recharge_currency_code: "充值币种",
recharge_policy_coin_amount: "策略金币",
recharge_policy_id: "充值策略 ID",
recharge_policy_usd_minor_amount: "策略 USD 金额",
recharge_policy_version: "充值策略版本",
recharge_usd_minor: "充值 USD 金额",
room_id: "房间 ID",
seller_asset_type: "币商资产",
seller_balance_after: "币商余额",
seller_region_id: "币商区域",
seller_user_id: "币商用户",
sender_user_id: "赠送人",
sort_order: "排序",
stock_type: "进货类型",
target_asset_type: "目标资产",
target_balance_after: "接收方余额",
target_region_id: "接收方区域",
target_user_id: "接收用户",
task_id: "任务 ID",
task_type: "任务类型",
user_id: "用户 ID",
};
export function CoinLedgerPage() {
const [page, setPage] = useState(1);
const [selectedEntry, setSelectedEntry] = useState(null);
const [userKeyword, setUserKeyword] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs || "",
start_at_ms: timeRange.startMs || "",
user_keyword: userKeyword,
}),
[timeRange.endMs, timeRange.startMs, userKeyword],
);
const query = usePaginatedQuery({
errorMessage: "获取金币流水失败",
fetcher: listCoinLedger,
filters,
page,
pageSize,
queryKey: ["coin-ledger", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const changeFilter = (setter) => (value) => {
setter(value);
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const resetFilters = () => {
setUserKeyword("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
const closeDetail = () => {
setSelectedEntry(null);
};
const tableColumns = columns.map((column) =>
column.key === "user"
? {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: userKeyword,
onChange: changeFilter(setUserKeyword),
}),
}
: column,
);
return (
<AdminListPage>
<AdminListToolbar
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!userKeyword && !timeRange.startMs && !timeRange.endMs}
onClick={resetFilters}
/>
</>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
items={items}
minWidth="900px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(entry) => entry.entryId}
/>
</AdminListBody>
</DataState>
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
</AdminListPage>
);
}
function coinLedgerRowProps(entry, onOpen) {
const user = entry.user || {};
const userLabel = user.username || user.displayUserId || user.userId || entry.userId || "用户";
return {
"aria-label": `查看 ${userLabel} 的金币流水详情`,
className: styles.clickableRow,
role: "button",
tabIndex: 0,
onClick: () => onOpen(entry),
onKeyDown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(entry);
}
},
};
}
function CoinLedgerDetailDrawer({ entry, onClose, open }) {
const metadata = metadataObject(entry?.metadata);
const contextRows = entry ? businessContextRows(entry, metadata) : [];
const usedMetadataKeys = new Set(contextRows.flatMap((row) => row.metadataKeys || []));
const metadataRows = metadataDetailRows(metadata, usedMetadataKeys);
const user = entry?.user || {};
return (
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
{entry ? (
<>
<header className={styles.detailHero}>
<span className={styles.detailAvatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="medium" />}
</span>
<div className={styles.detailHeroText}>
<div className={styles.detailTitleRow}>
<h3>{user.username || "-"}</h3>
<AmountValue entry={entry} />
</div>
<span className={styles.detailMeta}>{userIdText(entry)}</span>
</div>
</header>
<DetailSection
items={[
detailRow("交易 ID", entry.transactionId),
detailRow("命令 ID", entry.commandId),
detailRow("外部单号", entry.externalRef),
detailRow("流水 ID", entry.entryId),
detailRow("业务类型", bizTypeLabel(entry.bizType)),
detailRow("方向", directionLabel(entry.direction)),
detailRow("发生时间", formatMillis(entry.createdAtMs)),
]}
title="交易信息"
/>
<DetailSection
items={[
detailRow("流水数量", formatSignedAmount(entry)),
detailRow("可用变化", formatSignedDelta(entry.availableDelta)),
detailRow("可用余额", formatNumber(entry.availableAfter)),
]}
title="金额变化"
/>
<DetailSection items={contextRows} title="业务上下文" />
<DetailSection items={metadataRows} title="扩展明细" />
</>
) : null}
</SideDrawer>
);
}
function UserCell({ entry }) {
const user = entry.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>{userIdText(entry)}</span>
</div>
</div>
);
}
function AmountValue({ entry }) {
const expense = entry.direction === "expense";
return (
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
{formatSignedAmount(entry)}
</span>
);
}
function DetailSection({ items, title }) {
const visibleItems = items.filter((item) => hasDetailValue(item.value));
if (!visibleItems.length) {
return null;
}
return (
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>{title}</h3>
<div className={styles.detailGrid}>
{visibleItems.map((item) => (
<DetailItem key={item.label} label={item.label} value={item.value} />
))}
</div>
</section>
);
}
function DetailItem({ label, value }) {
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<div className={styles.detailValue}>{displayDetailValue(value)}</div>
</div>
);
}
function businessContextRows(entry, metadata) {
if (entry.bizType === "gift_debit") {
return compactRows([
metadataRow("礼物名称", metadata, ["gift_name", "giftName"]),
metadataRow("礼物 ID", metadata, ["gift_id", "giftId"]),
metadataRow("礼物数量", metadata, ["gift_count", "giftCount"]),
metadataRow("赠送给", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
metadataRow("礼物积分", metadata, ["gift_point_added", "giftPointAdded"]),
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),
]);
}
if (String(entry.bizType || "").startsWith("game_")) {
return compactRows([
metadataRow("游戏 ID", metadata, ["game_id", "gameId"]),
metadataRow("游戏平台", metadata, ["platform_code", "platformCode"]),
metadataRow("游戏订单", metadata, ["provider_order_id", "providerOrderId"], entry.externalRef),
metadataRow("游戏局号", metadata, ["provider_round_id", "providerRoundId"]),
metadataRow("游戏操作", metadata, ["op_type", "opType"]),
metadataRow("游戏房间", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("游戏金币", metadata, ["coin_amount", "coinAmount"]),
]);
}
if (entry.bizType === "coin_seller_transfer") {
return compactRows([
metadataRow("币商用户", metadata, ["seller_user_id", "sellerUserId"]),
metadataRow("接收用户", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
metadataRow("转账金额", metadata, ["amount"]),
metadataRow("原因", metadata, ["reason"]),
metadataRow("币商余额", metadata, ["seller_balance_after", "sellerBalanceAfter"]),
metadataRow("接收方余额", metadata, ["target_balance_after", "targetBalanceAfter"]),
metadataRow("充值策略 ID", metadata, ["recharge_policy_id", "rechargePolicyId"]),
metadataRow("充值币种", metadata, ["recharge_currency_code", "rechargeCurrencyCode"]),
]);
}
return compactRows([
metadataRow("关联用户", metadata, ["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"], entry.counterpartyUserId),
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
metadataRow("周期", metadata, ["cycle_key", "cycleKey"]),
metadataRow("原因", metadata, ["reason"]),
metadataRow("操作人", metadata, ["operator_user_id", "operatorUserId"]),
metadataRow("凭证", metadata, ["evidence_ref", "evidenceRef"]),
]);
}
function metadataDetailRows(metadata, usedMetadataKeys) {
return Object.entries(metadata)
.filter(([key, value]) => !usedMetadataKeys.has(key) && !hiddenMetadataKeys.has(key) && hasDetailValue(value))
.map(([key, value]) => detailRow(metadataLabel(key), normalizeMetadataValue(value), [key]));
}
function metadataRow(label, metadata, keys, fallback) {
return detailRow(label, firstValue(valueFromMetadata(metadata, keys), fallback), keys);
}
function detailRow(label, value, metadataKeys = []) {
return { label, metadataKeys, value };
}
function compactRows(rows) {
return rows.filter((row) => hasDetailValue(row.value));
}
function valueFromMetadata(metadata, keys) {
for (const key of keys) {
if (hasDetailValue(metadata[key])) {
return metadata[key];
}
}
return undefined;
}
function firstValue(...values) {
return values.find((value) => hasDetailValue(value));
}
function metadataObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value;
}
function normalizeMetadataValue(value) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function displayDetailValue(value) {
if (!hasDetailValue(value)) {
return "-";
}
if (typeof value === "object") {
return <pre className={styles.detailCode}>{JSON.stringify(value, null, 2)}</pre>;
}
return String(value);
}
function userIdText(entry) {
const user = entry.user || {};
const longId = user.userId || entry.userId || "";
const shortId = user.displayUserId || "";
if (longId && shortId && longId !== shortId) {
return `${longId}(${shortId})`;
}
return longId || shortId || "-";
}
function hasDetailValue(value) {
return value === 0 || value === false || Boolean(value);
}
function formatSignedAmount(entry) {
const sign = entry.direction === "expense" ? "-" : "+";
const amount = hasDetailValue(entry.amount) ? entry.amount : Math.abs(Number(entry.availableDelta || 0));
return `${sign}${formatNumber(amount)}`;
}
function formatSignedDelta(value) {
if (!hasDetailValue(value)) {
return "-";
}
const numberValue = Number(value);
if (Number.isNaN(numberValue)) {
return String(value);
}
return `${numberValue > 0 ? "+" : ""}${formatNumber(numberValue)}`;
}
function formatNumber(value) {
if (value === 0 || value) {
return Number(value).toLocaleString("zh-CN");
}
return "-";
}
function directionLabel(value) {
const labels = {
expense: "支出",
income: "收入",
};
return labels[value] || value || "-";
}
function metadataLabel(key) {
return metadataLabels[key] || key;
}
function bizTypeLabel(value) {
const labels = {
coin_seller_transfer: "币商转账",
game_credit: "游戏入账",
game_debit: "游戏扣费",
game_refund: "游戏退款",
game_reverse: "游戏冲正",
gift_debit: "礼物扣费",
manual_credit: "人工入账",
resource_grant: "资源发放",
task_reward: "任务奖励",
vip_purchase: "VIP 购买",
};
return labels[value] || value || "-";
}