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 || "-";
-}