diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index b5371ff..3e07e7a 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -211,6 +211,46 @@ color: var(--primary); } +.coinButton { + display: inline-flex; + max-width: 100%; + min-height: 28px; + align-items: center; + padding: 0; + border: 0; + background: transparent; + color: var(--text-primary); + cursor: pointer; + font: inherit; + font-weight: 700; + text-align: left; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 3px; + white-space: nowrap; +} + +.coinButton:hover, +.coinButton:focus-visible { + color: var(--primary); + text-decoration-color: currentColor; +} + +.coinButton:focus-visible { + border-radius: var(--radius-sm); + outline: 2px solid var(--primary-border-strong); + outline-offset: 2px; +} + +.coinLedgerDrawer.coinLedgerDrawer { + width: min(960px, calc(100vw - 32px)); +} + +.coinLedgerDrawerBody.coinLedgerDrawerBody { + display: flex; + overflow: hidden; +} + .filters { display: flex; min-width: 0; diff --git a/src/features/app-users/hooks/useAppUsersPage.js b/src/features/app-users/hooks/useAppUsersPage.js index 2990afd..a58ed3f 100644 --- a/src/features/app-users/hooks/useAppUsersPage.js +++ b/src/features/app-users/hooks/useAppUsersPage.js @@ -165,6 +165,11 @@ export function useAppUsersPage() { setActiveAction("country"); }; + const openCoinLedger = (user) => { + setActiveUser(user); + setActiveAction("coin-ledger"); + }; + const closeAction = () => { setActiveAction(""); setActiveUser(null); @@ -310,6 +315,7 @@ export function useAppUsersPage() { locationFilterOptions, locationFilterValue, openCountry, + openCoinLedger, openEdit, openPassword, page, diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index eb74f96..361ad6d 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -16,12 +16,14 @@ import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js"; import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js"; +import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx"; import styles from "@/features/app-users/app-users.module.css"; export function AppUserListPage() { @@ -115,7 +117,7 @@ export function AppUserListPage() { ), label: "金币", width: "minmax(90px, 0.6fr)", - render: (user) => formatNumber(user.coin), + render: (user) => , }, { key: "status", @@ -280,10 +282,40 @@ export function AppUserListPage() { onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })} /> + + + {page.activeUser ? ( + + ) : null} + ); } +function CoinValue({ page, user }) { + const value = formatNumber(user.coin); + if (!page.abilities.canCoinLedger) { + return value; + } + return ( + + ); +} + function UserLocation({ page, user }) { const countryLabel = formatCountry(user); const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-"); @@ -392,6 +424,13 @@ function formatNumber(value) { return Number(value || 0).toLocaleString("zh-CN"); } +function userLedgerTitle(user) { + if (!user) { + return "用户"; + } + return user.username || user.displayUserId || user.prettyDisplayUserId || user.userId || "用户"; +} + function formatCountry(user) { return user.countryDisplayName || user.countryName || user.country || "-"; } diff --git a/src/features/app-users/pages/AppUserListPage.test.jsx b/src/features/app-users/pages/AppUserListPage.test.jsx new file mode 100644 index 0000000..c29aee4 --- /dev/null +++ b/src/features/app-users/pages/AppUserListPage.test.jsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { AppUserListPage } from "./AppUserListPage.jsx"; +import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js"; + +const mocks = vi.hoisted(() => ({ + ledgerTable: vi.fn(), + openCoinLedger: vi.fn(), +})); + +vi.mock("@/features/app-users/hooks/useAppUsersPage.js", () => ({ + useAppUsersPage: vi.fn(), +})); + +vi.mock("@/features/operations/components/CoinLedgerTable.jsx", () => ({ + CoinLedgerTable: (props) => { + mocks.ledgerTable(props); + return
{props.userId}
; + }, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("clicking an app user's coin opens the coin ledger drawer", () => { + vi.mocked(useAppUsersPage).mockReturnValue(pageFixture()); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" })); + + expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" })); +}); + +test("coin ledger drawer passes the active app user to the shared ledger table", () => { + const activeUser = userFixture(); + vi.mocked(useAppUsersPage).mockReturnValue( + pageFixture({ + activeAction: "coin-ledger", + activeUser, + }), + ); + + render(); + + expect(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001"); + expect(mocks.ledgerTable).toHaveBeenCalledWith( + expect.objectContaining({ lockedUser: activeUser, userId: "10001" }), + ); +}); + +function pageFixture(patch = {}) { + return { + abilities: { + canCoinLedger: true, + canPassword: true, + canStatus: true, + canUpdate: true, + canUpload: true, + canView: true, + }, + activeAction: "", + activeUser: null, + batchBan: vi.fn(), + changeLocationFilter: vi.fn(), + changeQuery: vi.fn(), + changeSort: vi.fn(), + changeStatus: vi.fn(), + changeTimeRange: vi.fn(), + closeAction: vi.fn(), + countryForm: { country: "" }, + countryOptions: [], + data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 }, + editForm: { avatar: "", gender: "", username: "" }, + error: null, + loading: false, + loadingAction: "", + loadingCountries: false, + loadingRegions: false, + locationFilterOptions: [], + locationFilterValue: "", + openCoinLedger: mocks.openCoinLedger, + openCountry: vi.fn(), + openEdit: vi.fn(), + openPassword: vi.fn(), + page: 1, + passwordForm: { password: "" }, + query: "", + regionOptions: [], + reload: vi.fn(), + selectedUserIds: [], + setCountryForm: vi.fn(), + setEditForm: vi.fn(), + setPage: vi.fn(), + setPasswordForm: vi.fn(), + setSelectedUserIds: vi.fn(), + sortBy: "created_at", + sortDirection: "desc", + status: "", + submitCountry: vi.fn(), + submitEdit: vi.fn(), + submitPassword: vi.fn(), + timeRange: { endMs: "", startMs: "" }, + toggleBan: vi.fn(), + ...patch, + }; +} + +function userFixture() { + return { + avatar: "", + coin: 223437, + country: "AE", + countryDisplayName: "阿拉伯联合酋长国", + createdAtMs: 1779322523000, + displayUserId: "163337", + lastActiveAtMs: 1781325798000, + regionId: 2, + regionName: "中东区", + status: "active", + userId: "10001", + username: "d", + }; +} diff --git a/src/features/app-users/permissions.js b/src/features/app-users/permissions.js index 504073a..ce7f0c7 100644 --- a/src/features/app-users/permissions.js +++ b/src/features/app-users/permissions.js @@ -10,6 +10,7 @@ export function useAppUserAbilities() { canPrettyGrant: can(PERMISSIONS.prettyIdGrant), canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate), canPrettyView: can(PERMISSIONS.prettyIdView), + canCoinLedger: can(PERMISSIONS.coinLedgerView), canStatus: can(PERMISSIONS.appUserStatus), canUpdate: can(PERMISSIONS.appUserUpdate), canUpload: can(PERMISSIONS.uploadCreate), diff --git a/src/features/operations/components/CoinLedgerTable.jsx b/src/features/operations/components/CoinLedgerTable.jsx new file mode 100644 index 0000000..a270008 --- /dev/null +++ b/src/features/operations/components/CoinLedgerTable.jsx @@ -0,0 +1,492 @@ +import { useMemo, useState } from "react"; +import { listCoinLedger } from "@/features/operations/api"; +import { + AdminFilterResetButton, + AdminListBody, + AdminListToolbar, +} from "@/shared/ui/AdminListLayout.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.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 { TimeText } from "@/shared/ui/TimeText.jsx"; +import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; +import styles from "@/features/operations/operations.module.css"; + +const pageSize = 50; + +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) => , + }, +]; + +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: "礼物名称", + 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 CoinLedgerTable({ lockedUser = null, userId = "" }) { + const fixedUserId = String(userId || lockedUser?.userId || "").trim(); + 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: fixedUserId || userKeyword, + }), + [fixedUserId, 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 hasMutableFilters = Boolean(userKeyword || timeRange.startMs || timeRange.endMs); + + const changeUserKeyword = (value) => { + setUserKeyword(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 + .filter((column) => !(column.key === "user" && fixedUserId)) + .map((column) => + column.key === "user" + ? { + ...column, + filter: createTextColumnFilter({ + placeholder: "长 ID / 短 ID / 昵称", + value: userKeyword, + onChange: changeUserKeyword, + }), + } + : column, + ); + + return ( +
+ + + + + } + /> + + + coinLedgerRowProps(entry, setSelectedEntry)} + items={items} + minWidth={fixedUserId ? "620px" : "900px"} + pagination={ + total > 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total, + onPageChange: setPage, + } + : undefined + } + rowKey={coinLedgerRowKey} + /> + + + +
+ ); +} + +function coinLedgerRowKey(entry) { + return entry.entryId || entry.transactionId || `${entry.userId || "user"}-${entry.createdAtMs || "time"}`; +} + +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 ? ( + <> +
+ + +
+ + ), + ]} + title="交易信息" + /> + + + + + + + ) : null} +
+ ); +} + +function UserCell({ entry }) { + const user = entry.user || {}; + return ; +} + +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, ["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 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 || "-"; +} diff --git a/src/features/operations/pages/CoinLedgerPage.jsx b/src/features/operations/pages/CoinLedgerPage.jsx index a36a961..4c337df 100644 --- a/src/features/operations/pages/CoinLedgerPage.jsx +++ b/src/features/operations/pages/CoinLedgerPage.jsx @@ -1,488 +1,10 @@ -import { useMemo, useState } from "react"; -import { listCoinLedger } from "@/features/operations/api"; -import { - AdminFilterResetButton, - AdminListBody, - AdminListPage, - AdminListToolbar, -} from "@/shared/ui/AdminListLayout.jsx"; -import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.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 { TimeText } from "@/shared/ui/TimeText.jsx"; -import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; -import styles from "@/features/operations/operations.module.css"; - -const pageSize = 50; - -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) => , - }, -]; - -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: "礼物名称", - 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", -}; +import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx"; +import { AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; 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 ? ( - <> -
- - -
- - ), - ]} - title="交易信息" - /> - - - - - - - ) : null} -
- ); -} - -function UserCell({ entry }) { - const user = entry.user || {}; - return ; -} - -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, ["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 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 || "-"; -}