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) => , }, { key: "amount", label: "数量", width: "minmax(120px, 0.65fr)", render: (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 ( } /> coinLedgerRowProps(entry, setSelectedEntry)} items={items} minWidth="900px" pagination={ total > 0 ? { page, pageSize: data.pageSize || pageSize, total, onPageChange: setPage, } : undefined } rowKey={(entry) => entry.entryId} /> ); } 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 ( {entry ? ( <>
{user.avatar ? : }

{user.username || "-"}

{userIdText(entry)}
) : null}
); } function UserCell({ entry }) { const user = entry.user || {}; return (
{user.avatar ? : }
{user.username || "-"} {userIdText(entry)}
); } function AmountValue({ entry }) { const expense = entry.direction === "expense"; return ( {formatSignedAmount(entry)} ); } function DetailSection({ items, title }) { const visibleItems = items.filter((item) => hasDetailValue(item.value)); if (!visibleItems.length) { return null; } return (

{title}

{visibleItems.map((item) => ( ))}
); } function DetailItem({ label, value }) { return (
{label}
{displayDetailValue(value)}
); } 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
{JSON.stringify(value, null, 2)}
; } 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 || "-"; }