用户列表流水
This commit is contained in:
parent
1beca72cb1
commit
001eb0d4dc
@ -211,6 +211,46 @@
|
|||||||
color: var(--primary);
|
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 {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -165,6 +165,11 @@ export function useAppUsersPage() {
|
|||||||
setActiveAction("country");
|
setActiveAction("country");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCoinLedger = (user) => {
|
||||||
|
setActiveUser(user);
|
||||||
|
setActiveAction("coin-ledger");
|
||||||
|
};
|
||||||
|
|
||||||
const closeAction = () => {
|
const closeAction = () => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setActiveUser(null);
|
setActiveUser(null);
|
||||||
@ -310,6 +315,7 @@ export function useAppUsersPage() {
|
|||||||
locationFilterOptions,
|
locationFilterOptions,
|
||||||
locationFilterValue,
|
locationFilterValue,
|
||||||
openCountry,
|
openCountry,
|
||||||
|
openCoinLedger,
|
||||||
openEdit,
|
openEdit,
|
||||||
openPassword,
|
openPassword,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@ -16,12 +16,14 @@ import { Button } from "@/shared/ui/Button.jsx";
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
||||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.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";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
export function AppUserListPage() {
|
export function AppUserListPage() {
|
||||||
@ -115,7 +117,7 @@ export function AppUserListPage() {
|
|||||||
),
|
),
|
||||||
label: "金币",
|
label: "金币",
|
||||||
width: "minmax(90px, 0.6fr)",
|
width: "minmax(90px, 0.6fr)",
|
||||||
render: (user) => formatNumber(user.coin),
|
render: (user) => <CoinValue page={page} user={user} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
@ -280,10 +282,40 @@ export function AppUserListPage() {
|
|||||||
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
|
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</ActionModal>
|
</ActionModal>
|
||||||
|
|
||||||
|
<SideDrawer
|
||||||
|
className={styles.coinLedgerDrawer}
|
||||||
|
contentClassName={styles.coinLedgerDrawerBody}
|
||||||
|
open={page.activeAction === "coin-ledger"}
|
||||||
|
title={`金币流水 - ${userLedgerTitle(page.activeUser)}`}
|
||||||
|
width="wide"
|
||||||
|
onClose={page.closeAction}
|
||||||
|
>
|
||||||
|
{page.activeUser ? (
|
||||||
|
<CoinLedgerTable lockedUser={page.activeUser} userId={page.activeUser.userId} />
|
||||||
|
) : null}
|
||||||
|
</SideDrawer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CoinValue({ page, user }) {
|
||||||
|
const value = formatNumber(user.coin);
|
||||||
|
if (!page.abilities.canCoinLedger) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={`查看 ${userLedgerTitle(user)} 的金币流水`}
|
||||||
|
className={styles.coinButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => page.openCoinLedger(user)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UserLocation({ page, user }) {
|
function UserLocation({ page, user }) {
|
||||||
const countryLabel = formatCountry(user);
|
const countryLabel = formatCountry(user);
|
||||||
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
||||||
@ -392,6 +424,13 @@ function formatNumber(value) {
|
|||||||
return Number(value || 0).toLocaleString("zh-CN");
|
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) {
|
function formatCountry(user) {
|
||||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/features/app-users/pages/AppUserListPage.test.jsx
Normal file
125
src/features/app-users/pages/AppUserListPage.test.jsx
Normal file
@ -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 <div data-testid="coin-ledger-table">{props.userId}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking an app user's coin opens the coin ledger drawer", () => {
|
||||||
|
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
|
||||||
|
|
||||||
|
render(<AppUserListPage />);
|
||||||
|
|
||||||
|
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(<AppUserListPage />);
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ export function useAppUserAbilities() {
|
|||||||
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
||||||
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
||||||
canPrettyView: can(PERMISSIONS.prettyIdView),
|
canPrettyView: can(PERMISSIONS.prettyIdView),
|
||||||
|
canCoinLedger: can(PERMISSIONS.coinLedgerView),
|
||||||
canStatus: can(PERMISSIONS.appUserStatus),
|
canStatus: can(PERMISSIONS.appUserStatus),
|
||||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
|
|||||||
492
src/features/operations/components/CoinLedgerTable.jsx
Normal file
492
src/features/operations/components/CoinLedgerTable.jsx
Normal file
@ -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) => <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) => <TimeText value={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: "礼物名称",
|
||||||
|
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 (
|
||||||
|
<div className={styles.ledgerTableShell}>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||||
|
<AdminFilterResetButton disabled={!hasMutableFilters} onClick={resetFilters} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
|
||||||
|
items={items}
|
||||||
|
minWidth={fixedUserId ? "620px" : "900px"}
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page,
|
||||||
|
pageSize: data.pageSize || pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={coinLedgerRowKey}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
|
||||||
|
{entry ? (
|
||||||
|
<>
|
||||||
|
<header className={styles.detailHero}>
|
||||||
|
<AdminUserIdentity
|
||||||
|
openInAppUserDetail
|
||||||
|
user={{ ...user, userId: user.userId || entry.userId }}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<AmountValue entry={entry} />
|
||||||
|
</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("发生时间", <TimeText value={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 <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ["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 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 || "-";
|
||||||
|
}
|
||||||
@ -1,488 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
||||||
import { listCoinLedger } from "@/features/operations/api";
|
import { AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
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) => <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) => <TimeText value={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: "礼物名称",
|
|
||||||
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() {
|
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 (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<CoinLedgerTable />
|
||||||
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>
|
</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}>
|
|
||||||
<AdminUserIdentity
|
|
||||||
openInAppUserDetail
|
|
||||||
user={{ ...user, userId: user.userId || entry.userId }}
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
<AmountValue entry={entry} />
|
|
||||||
</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("发生时间", <TimeText value={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 <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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, ["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 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 || "-";
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user