送礼记录
This commit is contained in:
parent
59901a3cab
commit
da6d2dfdb9
@ -3206,6 +3206,71 @@
|
||||
"x-permissions": ["coin-adjustment:create"]
|
||||
}
|
||||
},
|
||||
"/admin/operations/gift-records": {
|
||||
"get": {
|
||||
"operationId": "listGiftRecords",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/GiftRecordPageResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Page"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/PageSize"
|
||||
},
|
||||
{
|
||||
"name": "scene",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["room", "direct"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sender_display_user_id",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sender_user_id",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sender_username",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start_at_ms",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "end_at_ms",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "gift-record:view",
|
||||
"x-permissions": ["gift-record:view"]
|
||||
}
|
||||
},
|
||||
"/admin/operations/coin-ledger": {
|
||||
"get": {
|
||||
"operationId": "listCoinLedger",
|
||||
@ -7810,6 +7875,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GiftRecordPageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseGiftRecordPage"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BDProfilePageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
@ -9289,6 +9364,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiPageGiftRecord": {
|
||||
"type": "object",
|
||||
"required": ["items", "page", "pageSize", "total"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GiftRecord"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiPageBDProfile": {
|
||||
"type": "object",
|
||||
"required": ["items", "page", "pageSize", "total"],
|
||||
@ -9669,6 +9765,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseGiftRecordPage": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Envelope"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ApiPageGiftRecord"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseBDProfilePage": {
|
||||
"allOf": [
|
||||
{
|
||||
@ -10878,6 +10989,99 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GiftRecordUser": {
|
||||
"type": "object",
|
||||
"required": ["userId"],
|
||||
"properties": {
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"defaultDisplayUserId": {
|
||||
"type": "string"
|
||||
},
|
||||
"displayUserId": {
|
||||
"type": "string"
|
||||
},
|
||||
"prettyDisplayUserId": {
|
||||
"type": "string"
|
||||
},
|
||||
"prettyId": {
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GiftRecordGift": {
|
||||
"type": "object",
|
||||
"required": ["giftId", "name", "coverUrl"],
|
||||
"properties": {
|
||||
"coverUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"giftId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GiftRecord": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"transactionId",
|
||||
"scene",
|
||||
"sender",
|
||||
"receiver",
|
||||
"gift",
|
||||
"giftCount",
|
||||
"unitValue",
|
||||
"giftValue",
|
||||
"createdAtMs"
|
||||
],
|
||||
"properties": {
|
||||
"createdAtMs": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"gift": {
|
||||
"$ref": "#/components/schemas/GiftRecordGift"
|
||||
},
|
||||
"giftCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"giftValue": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"receiver": {
|
||||
"$ref": "#/components/schemas/GiftRecordUser"
|
||||
},
|
||||
"roomId": {
|
||||
"type": "string"
|
||||
},
|
||||
"scene": {
|
||||
"type": "string",
|
||||
"enum": ["room", "direct"]
|
||||
},
|
||||
"sender": {
|
||||
"$ref": "#/components/schemas/GiftRecordUser"
|
||||
},
|
||||
"transactionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"unitValue": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CoinLedgerUser": {
|
||||
"type": "object",
|
||||
"required": ["userId"],
|
||||
|
||||
@ -186,6 +186,7 @@ export const fallbackNavigation = [
|
||||
id: "operations",
|
||||
label: "运营管理",
|
||||
children: [
|
||||
routeNavItem("operation-gift-records", { icon: CardGiftcardOutlined }),
|
||||
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
|
||||
routeNavItem("operation-coin-seller-ledger", { icon: ReceiptLongOutlined }),
|
||||
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
|
||||
|
||||
@ -57,6 +57,7 @@ export const PERMISSIONS = {
|
||||
financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant",
|
||||
coinLedgerView: "coin-ledger:view",
|
||||
coinSellerLedgerView: "coin-seller-ledger:view",
|
||||
giftRecordView: "gift-record:view",
|
||||
coinAdjustmentView: "coin-adjustment:view",
|
||||
coinAdjustmentCreate: "coin-adjustment:create",
|
||||
reportView: "report:view",
|
||||
@ -250,6 +251,7 @@ export const MENU_CODES = {
|
||||
operations: "operations",
|
||||
operationCoinLedger: "operation-coin-ledger",
|
||||
operationCoinSellerLedger: "operation-coin-seller-ledger",
|
||||
operationGiftRecords: "operation-gift-records",
|
||||
operationCoinAdjustment: "operation-coin-adjustment",
|
||||
operationReports: "operation-reports",
|
||||
operationGiftDiamond: "operation-gift-diamond",
|
||||
|
||||
@ -8,7 +8,7 @@ export interface GamePlatformDto {
|
||||
platformName: string;
|
||||
status: string;
|
||||
apiBaseUrl: string;
|
||||
// 服务端按 adapterType 选择真实厂商协议:yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1/reyou_v1。
|
||||
// 服务端按 adapterType 选择真实厂商协议;AMG 使用 amg_v1,RSA 私钥仍通过 callbackSecret 单独提交。
|
||||
adapterType: string;
|
||||
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
||||
callbackSecret?: string;
|
||||
|
||||
@ -65,6 +65,7 @@ const writableBridgeAdapterTypes = new Set([
|
||||
"vivagames_v1",
|
||||
"reyou_v1",
|
||||
"zgame_v1",
|
||||
"amg_v1",
|
||||
]);
|
||||
|
||||
const emptyCatalog = { items: [], pageSize: 50 };
|
||||
|
||||
@ -54,6 +54,7 @@ const adapterTypeOptions = [
|
||||
["vivagames_v1", "VIVAGAMES V1"],
|
||||
["reyou_v1", "热游 Reyou V1"],
|
||||
["zgame_v1", "ZGame V1"],
|
||||
["amg_v1", "AMG V1"],
|
||||
];
|
||||
|
||||
const baseColumns = [
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
listCoinLedger,
|
||||
listCoinAdjustments,
|
||||
listCoinSellerLedger,
|
||||
listGiftRecords,
|
||||
listWheelDraws,
|
||||
lookupCoinAdjustmentTarget,
|
||||
updateGiftDiamondRatios,
|
||||
@ -110,6 +111,36 @@ test("coin ledger API sends type filter", async () => {
|
||||
expect(listInit?.method).toBe("GET");
|
||||
});
|
||||
|
||||
test("gift record API uses generated path with sender, scene and UTC range filters", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 30, total: 0 } })),
|
||||
),
|
||||
);
|
||||
|
||||
await listGiftRecords({
|
||||
end_at_ms: 2000,
|
||||
page: 1,
|
||||
page_size: 30,
|
||||
scene: "room",
|
||||
sender_display_user_id: "888888",
|
||||
sender_user_id: "1001",
|
||||
sender_username: "送礼人",
|
||||
start_at_ms: 1000,
|
||||
});
|
||||
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||
expect(String(listUrl)).toContain("/api/v1/admin/operations/gift-records?");
|
||||
expect(String(listUrl)).toContain("scene=room");
|
||||
expect(String(listUrl)).toContain("sender_display_user_id=888888");
|
||||
expect(String(listUrl)).toContain("sender_user_id=1001");
|
||||
expect(String(listUrl)).toContain("sender_username=%E9%80%81%E7%A4%BC%E4%BA%BA");
|
||||
expect(String(listUrl)).toContain("start_at_ms=1000");
|
||||
expect(String(listUrl)).toContain("end_at_ms=2000");
|
||||
expect(listInit?.method).toBe("GET");
|
||||
});
|
||||
|
||||
test("coin seller ledger API uses generated admin path and filters", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@ -22,6 +22,33 @@ export interface GiftDiamondRatioItem {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface GiftRecordUserDto {
|
||||
userId: string;
|
||||
displayUserId?: string;
|
||||
defaultDisplayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface GiftRecordDto {
|
||||
transactionId: string;
|
||||
scene: "room" | "direct";
|
||||
roomId?: string;
|
||||
sender: GiftRecordUserDto;
|
||||
receiver: GiftRecordUserDto;
|
||||
gift: {
|
||||
giftId: string;
|
||||
name: string;
|
||||
coverUrl?: string;
|
||||
};
|
||||
giftCount: number;
|
||||
unitValue: number;
|
||||
giftValue: number;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface GiftDiamondRatioResponse {
|
||||
regionId: number;
|
||||
items: GiftDiamondRatioItem[];
|
||||
@ -132,6 +159,14 @@ export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedge
|
||||
});
|
||||
}
|
||||
|
||||
export function listGiftRecords(query: PageQuery = {}): Promise<ApiPage<GiftRecordDto>> {
|
||||
const endpoint = API_ENDPOINTS.listGiftRecords;
|
||||
return apiRequest<ApiPage<GiftRecordDto>>(apiEndpointPath(API_OPERATIONS.listGiftRecords), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function listCoinSellerLedger(query: PageQuery = {}): Promise<ApiPage<CoinSellerLedgerDto>> {
|
||||
const endpoint = API_ENDPOINTS.listCoinSellerLedger;
|
||||
return apiRequest<ApiPage<CoinSellerLedgerDto>>(apiEndpointPath(API_OPERATIONS.listCoinSellerLedger), {
|
||||
|
||||
220
src/features/operations/components/GiftRecordTable.jsx
Normal file
220
src/features/operations/components/GiftRecordTable.jsx
Normal file
@ -0,0 +1,220 @@
|
||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import { useMemo, useState } from "react";
|
||||
import { listGiftRecords } from "@/features/operations/api";
|
||||
import styles from "@/features/operations/operations.module.css";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
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 { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { createOptionsColumnFilter, createUserIdentityColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import {
|
||||
emptyUserIdentityFilter,
|
||||
isUserIdentityFilterEmpty,
|
||||
userIdentityFilterParams,
|
||||
} from "@/shared/ui/userIdentityFilter.js";
|
||||
|
||||
const pageSize = 30;
|
||||
|
||||
const sceneOptions = [
|
||||
{ label: "房间送礼", value: "room" },
|
||||
{ label: "私聊送礼", value: "direct" },
|
||||
];
|
||||
|
||||
export function GiftRecordTable() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [scene, setScene] = useState("");
|
||||
const [senderFilter, setSenderFilter] = useState(emptyUserIdentityFilter);
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
scene,
|
||||
...userIdentityFilterParams(senderFilter, {
|
||||
displayUserId: "sender_display_user_id",
|
||||
userId: "sender_user_id",
|
||||
username: "sender_username",
|
||||
}),
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
}),
|
||||
[scene, senderFilter, timeRange.endMs, timeRange.startMs],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取送礼记录失败",
|
||||
fetcher: listGiftRecords,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["gift-records", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const hasActiveFilters = Boolean(
|
||||
!isUserIdentityFilterEmpty(senderFilter) || scene || timeRange.startMs || timeRange.endMs,
|
||||
);
|
||||
|
||||
const changeScene = (value) => {
|
||||
setScene(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeSenderFilter = (value) => {
|
||||
setSenderFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeTimeRange = (value) => {
|
||||
setTimeRange(value);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setScene("");
|
||||
setSenderFilter(emptyUserIdentityFilter);
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "index",
|
||||
label: "序号",
|
||||
render: (_entry, index) => <span className={styles.giftRecordIndex}>{index + 1}</span>,
|
||||
resizable: false,
|
||||
width: "72px",
|
||||
},
|
||||
{
|
||||
filter: createUserIdentityColumnFilter({
|
||||
value: senderFilter,
|
||||
onChange: changeSenderFilter,
|
||||
}),
|
||||
key: "sender",
|
||||
label: "送礼人",
|
||||
render: (entry) => <AdminUserIdentity openInAppUserDetail user={entry.sender} />,
|
||||
width: "minmax(210px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "receiver",
|
||||
label: "收礼人",
|
||||
render: (entry) => <AdminUserIdentity openInAppUserDetail user={entry.receiver} />,
|
||||
width: "minmax(210px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "gift",
|
||||
label: "礼物",
|
||||
render: (entry) => <GiftCell gift={entry.gift} />,
|
||||
width: "minmax(220px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "giftValue",
|
||||
label: "数量与价值",
|
||||
render: (entry) => <GiftValueCell entry={entry} />,
|
||||
width: "minmax(190px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
filter: createOptionsColumnFilter({
|
||||
emptyLabel: "全部场景",
|
||||
options: sceneOptions,
|
||||
placeholder: "送礼场景",
|
||||
value: scene,
|
||||
onChange: changeScene,
|
||||
}),
|
||||
key: "scene",
|
||||
label: "场景",
|
||||
render: (entry) => <SceneCell entry={entry} />,
|
||||
width: "minmax(132px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "送礼时间",
|
||||
render: (entry) => <TimeText value={entry.createdAtMs} />,
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
},
|
||||
],
|
||||
[scene, senderFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.ledgerTableShell}>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
emptyLabel="当前无送礼记录"
|
||||
items={items}
|
||||
minWidth="1230px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(entry) => entry.transactionId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GiftCell({ gift = {} }) {
|
||||
return (
|
||||
<div className={styles.giftRecordGift}>
|
||||
<span className={styles.giftRecordCover}>
|
||||
{gift.coverUrl ? <img src={gift.coverUrl} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
|
||||
</span>
|
||||
<span className={styles.giftRecordGiftText}>
|
||||
<span className={styles.giftRecordGiftName}>{gift.name || "-"}</span>
|
||||
{gift.giftId ? <span className={styles.giftRecordGiftId}>{gift.giftId}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GiftValueCell({ entry }) {
|
||||
return (
|
||||
<div className={styles.giftRecordMetrics}>
|
||||
<span className={styles.giftRecordMetric}>
|
||||
<span>数量</span>
|
||||
<strong>× {formatNumber(entry.giftCount)}</strong>
|
||||
</span>
|
||||
<span className={styles.giftRecordMetric}>
|
||||
<span>单价</span>
|
||||
<strong>{formatNumber(entry.unitValue)}</strong>
|
||||
</span>
|
||||
<span className={`${styles.giftRecordMetric} ${styles.giftRecordTotal}`}>
|
||||
<span>总价值</span>
|
||||
<strong>{formatNumber(entry.giftValue)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneCell({ entry }) {
|
||||
const direct = entry.scene === "direct";
|
||||
return (
|
||||
<span className={styles.giftRecordScene}>
|
||||
<span className={`${styles.giftRecordSceneBadge} ${direct ? styles.giftRecordSceneDirect : styles.giftRecordSceneRoom}`}>
|
||||
{direct ? "私聊" : "房间"}
|
||||
</span>
|
||||
{!direct && entry.roomId ? <span className={styles.giftRecordRoomId}>{entry.roomId}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
69
src/features/operations/components/GiftRecordTable.test.jsx
Normal file
69
src/features/operations/components/GiftRecordTable.test.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { GiftRecordTable } from "@/features/operations/components/GiftRecordTable.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
|
||||
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
|
||||
usePaginatedQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("gift record table uses public user identities and keeps gift facts compact", () => {
|
||||
vi.mocked(usePaginatedQuery).mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
createdAtMs: 1770000000000,
|
||||
gift: { coverUrl: "https://cdn.example/rose.webp", giftId: "rose", name: "玫瑰" },
|
||||
giftCount: 3,
|
||||
giftValue: 300,
|
||||
receiver: { displayUserId: "202", userId: "2002", username: "收礼人" },
|
||||
roomId: "room-9",
|
||||
scene: "room",
|
||||
sender: {
|
||||
defaultDisplayUserId: "101",
|
||||
displayUserId: "888888",
|
||||
prettyDisplayUserId: "888888",
|
||||
prettyId: "pretty-1",
|
||||
userId: "1001",
|
||||
username: "送礼人",
|
||||
},
|
||||
transactionId: "wallet-gift-1",
|
||||
unitValue: 100,
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
total: 1,
|
||||
},
|
||||
error: null,
|
||||
loading: false,
|
||||
reload: vi.fn(),
|
||||
});
|
||||
|
||||
render(<GiftRecordTable />);
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("送礼人").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("收礼人").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText("玫瑰")).toBeInTheDocument();
|
||||
expect(screen.getByText("× 3")).toBeInTheDocument();
|
||||
expect(screen.getByText("300")).toBeInTheDocument();
|
||||
expect(screen.getByText("房间")).toBeInTheDocument();
|
||||
expect(screen.getByText("room-9")).toBeInTheDocument();
|
||||
expect(screen.getByText("已加载 1 条 · 共 1 条")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "送礼人" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("输入短 ID"), { target: { value: "888888" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
|
||||
expect(usePaginatedQuery).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({ sender_display_user_id: "888888" }),
|
||||
page: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -36,6 +36,140 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.giftRecordIndex {
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.giftRecordGift {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.giftRecordCover {
|
||||
display: inline-flex;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, var(--bg-card-strong), var(--primary-hover));
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.giftRecordCover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.giftRecordGiftText,
|
||||
.giftRecordScene {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.giftRecordGiftName {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 780;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.giftRecordGiftId,
|
||||
.giftRecordRoomId {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.giftRecordMetrics {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(3, minmax(0, auto));
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.giftRecordMetric {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.giftRecordMetric span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.giftRecordMetric strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 760;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.giftRecordTotal {
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.giftRecordTotal strong {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.giftRecordSceneBadge {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.giftRecordSceneRoom {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.giftRecordSceneDirect {
|
||||
border-color: rgba(124, 58, 237, 0.2);
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.giftRecordCover,
|
||||
.giftRecordSceneBadge {
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.giftRecordGift:hover .giftRecordCover {
|
||||
border-color: var(--primary-border-strong);
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.adjustmentAmount {
|
||||
font-size: calc(var(--admin-font-size) + 4px);
|
||||
}
|
||||
|
||||
10
src/features/operations/pages/GiftRecordPage.jsx
Normal file
10
src/features/operations/pages/GiftRecordPage.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { GiftRecordTable } from "@/features/operations/components/GiftRecordTable.jsx";
|
||||
import { AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
|
||||
export function GiftRecordPage() {
|
||||
return (
|
||||
<AdminListPage>
|
||||
<GiftRecordTable />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,14 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const operationsRoutes = [
|
||||
{
|
||||
label: "送礼记录",
|
||||
loader: () => import("./pages/GiftRecordPage.jsx").then((module) => module.GiftRecordPage),
|
||||
menuCode: MENU_CODES.operationGiftRecords,
|
||||
pageKey: "operation-gift-records",
|
||||
path: "/operations/gift-records",
|
||||
permission: PERMISSIONS.giftRecordView,
|
||||
},
|
||||
{
|
||||
label: "金币流水",
|
||||
loader: () => import("./pages/CoinLedgerPage.jsx").then((module) => module.CoinLedgerPage),
|
||||
|
||||
@ -203,6 +203,7 @@ export const API_OPERATIONS = {
|
||||
listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications",
|
||||
listFirstRechargeRewardClaims: "listFirstRechargeRewardClaims",
|
||||
listGameWhitelistUsers: "listGameWhitelistUsers",
|
||||
listGiftRecords: "listGiftRecords",
|
||||
listGifts: "listGifts",
|
||||
listGiftTypes: "listGiftTypes",
|
||||
listH5Links: "listH5Links",
|
||||
@ -1710,6 +1711,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:view",
|
||||
permissions: ["game:view"]
|
||||
},
|
||||
listGiftRecords: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listGiftRecords,
|
||||
path: "/v1/admin/operations/gift-records",
|
||||
permission: "gift-record:view",
|
||||
permissions: ["gift-record:view"]
|
||||
},
|
||||
listGifts: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listGifts,
|
||||
|
||||
86
src/shared/api/generated/schema.d.ts
vendored
86
src/shared/api/generated/schema.d.ts
vendored
@ -2060,6 +2060,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/operations/gift-records": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listGiftRecords"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/operations/coin-ledger": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4887,6 +4903,12 @@ export interface components {
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
ApiPageGiftRecord: {
|
||||
items: components["schemas"]["GiftRecord"][];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
ApiPageBDProfile: {
|
||||
items: components["schemas"]["BDProfile"][];
|
||||
page: number;
|
||||
@ -5043,6 +5065,9 @@ export interface components {
|
||||
ApiResponseCoinSellerLedgerPage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageCoinSellerLedger"];
|
||||
};
|
||||
ApiResponseGiftRecordPage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageGiftRecord"];
|
||||
};
|
||||
ApiResponseBDProfilePage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageBDProfile"];
|
||||
};
|
||||
@ -5403,6 +5428,37 @@ export interface components {
|
||||
child?: components["schemas"]["CoinSellerSubApplicationReviewChild"];
|
||||
relation?: components["schemas"]["CoinSellerSubRelation"];
|
||||
};
|
||||
GiftRecordUser: {
|
||||
avatar?: string;
|
||||
defaultDisplayUserId?: string;
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
};
|
||||
GiftRecordGift: {
|
||||
coverUrl: string;
|
||||
giftId: string;
|
||||
name: string;
|
||||
};
|
||||
GiftRecord: {
|
||||
/** Format: int64 */
|
||||
createdAtMs: number;
|
||||
gift: components["schemas"]["GiftRecordGift"];
|
||||
/** Format: int32 */
|
||||
giftCount: number;
|
||||
/** Format: int64 */
|
||||
giftValue: number;
|
||||
receiver: components["schemas"]["GiftRecordUser"];
|
||||
roomId?: string;
|
||||
/** @enum {string} */
|
||||
scene: "room" | "direct";
|
||||
sender: components["schemas"]["GiftRecordUser"];
|
||||
transactionId: string;
|
||||
/** Format: int64 */
|
||||
unitValue: number;
|
||||
};
|
||||
CoinLedgerUser: {
|
||||
avatar?: string;
|
||||
displayUserId?: string;
|
||||
@ -6147,6 +6203,15 @@ export interface components {
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
GiftRecordPageResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseGiftRecordPage"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
BDProfilePageResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@ -9193,6 +9258,27 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listGiftRecords: {
|
||||
parameters: {
|
||||
query?: {
|
||||
page?: components["parameters"]["Page"];
|
||||
page_size?: components["parameters"]["PageSize"];
|
||||
scene?: "room" | "direct";
|
||||
sender_display_user_id?: string;
|
||||
sender_user_id?: string;
|
||||
sender_username?: string;
|
||||
start_at_ms?: number;
|
||||
end_at_ms?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["GiftRecordPageResponse"];
|
||||
};
|
||||
};
|
||||
listCoinLedger: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user