diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 1b6b419..3bfabdc 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2881,6 +2881,18 @@ "x-permissions": ["resource-shop:update"] } }, + "/admin/resource-shop/purchase-orders": { + "get": { + "operationId": "listResourceShopPurchaseOrders", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-shop:view", + "x-permissions": ["resource-shop:view"] + } + }, "/admin/resource-shop/items/{shop_item_id}/disable": { "post": { "operationId": "disableResourceShopItem", diff --git a/src/features/operations/hooks/useWheelPage.js b/src/features/operations/hooks/useWheelPage.js index e5d3be9..c29f40d 100644 --- a/src/features/operations/hooks/useWheelPage.js +++ b/src/features/operations/hooks/useWheelPage.js @@ -356,7 +356,7 @@ function poolLabel(wheelId) { return (wheelPools.find((pool) => pool.key === wheelId) || wheelPools[0]).label; } -function groupItemToTier(item, wheelId, index) { +export function groupItemToTier(item, wheelId, index) { const resource = item.resource || {}; const isCoin = item.itemType === "wallet_asset" && item.walletAssetType === "COIN"; const rewardType = isCoin ? "coin" : resourceTypeToRewardType(resource.resourceType); @@ -364,10 +364,15 @@ function groupItemToTier(item, wheelId, index) { const metadata = { animation_url: isCoin ? "" : resource.animationUrl || "", asset_url: resource.assetUrl || "", + coinPrice: coinValue, + coin_price: coinValue, + displayValue: coinValue, + display_value: coinValue, preview_url: resource.previewUrl || resource.assetUrl || "", resource_code: resource.resourceCode || "", resource_id: resource.resourceId || 0, resource_type: resource.resourceType || "", + value: coinValue, }; return { displayName: isCoin diff --git a/src/features/operations/hooks/useWheelPage.test.js b/src/features/operations/hooks/useWheelPage.test.js index 4d32692..a9d13d7 100644 --- a/src/features/operations/hooks/useWheelPage.test.js +++ b/src/features/operations/hooks/useWheelPage.test.js @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; import { wheelConfigFormSchema } from "@/features/operations/schema"; -import { generateWheelTierProbabilities, wheelPools } from "@/features/operations/hooks/useWheelPage.js"; +import { + generateWheelTierProbabilities, + groupItemToTier, + wheelPools, +} from "@/features/operations/hooks/useWheelPage.js"; function tier(overrides) { return { @@ -35,6 +39,33 @@ function wheelExpectedRTP(tiers, drawPriceCoins) { } describe("wheel prize probability generation", () => { + test("stores resource coin price in metadata for prop and dress display", () => { + const result = groupItemToTier( + { + itemType: "resource", + quantity: 1, + resource: { + assetUrl: "https://example.com/a.png", + coinPrice: 1314, + name: "道具", + previewUrl: "https://example.com/p.png", + resourceCode: "prop_a", + resourceId: 326, + resourceType: "prop", + }, + }, + "classic", + 0, + ); + const metadata = JSON.parse(result.metadataJson); + + expect(result.rewardType).toBe("prop"); + expect(result.rtpValueCoins).toBe(0); + expect(metadata.coin_price).toBe(1314); + expect(metadata.display_value).toBe(1314); + expect(metadata.value).toBe(1314); + }); + test("uses eight prize slots for the classic wheel", () => { const classicPool = wheelPools.find((pool) => pool.key === "classic"); const tiers = Array.from({ length: 8 }, (_, index) => diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 83afc34..d2a05aa 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -22,6 +22,7 @@ import { listResourceGrants, listGifts, listResourceShopItems, + listResourceShopPurchaseOrders, listResources, lookupResourceGrantTarget, updateGift, @@ -327,6 +328,12 @@ test("resource shop APIs use generated admin paths", async () => { ), ); + await listResourceShopPurchaseOrders({ + page: 1, + page_size: 10, + resource_type: "avatar_frame", + user_keyword: "163000", + }); await listResourceShopItems({ page: 1, page_size: 10, resource_type: "avatar_frame", status: "active" }); await upsertResourceShopItems({ items: [ @@ -343,11 +350,16 @@ test("resource shop APIs use generated admin paths", async () => { await enableResourceShopItem(99); await disableResourceShopItem(99); - const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0]; - const [upsertUrl, upsertInit] = vi.mocked(fetch).mock.calls[1]; - const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[2]; - const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[3]; + const [purchaseUrl, purchaseInit] = vi.mocked(fetch).mock.calls[0]; + const [listUrl, listInit] = vi.mocked(fetch).mock.calls[1]; + const [upsertUrl, upsertInit] = vi.mocked(fetch).mock.calls[2]; + const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[3]; + const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[4]; + expect(String(purchaseUrl)).toContain("/api/v1/admin/resource-shop/purchase-orders?"); + expect(String(purchaseUrl)).toContain("resource_type=avatar_frame"); + expect(String(purchaseUrl)).toContain("user_keyword=163000"); + expect(purchaseInit?.method).toBe("GET"); expect(String(listUrl)).toContain("/api/v1/admin/resource-shop/items?"); expect(String(listUrl)).toContain("resource_type=avatar_frame"); expect(String(listUrl)).toContain("status=active"); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 4999b29..21c38fc 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -124,6 +124,25 @@ export interface ResourceShopItemDto { updatedAtMs?: number; } +export interface ResourceShopPurchaseOrderDto { + appCode?: string; + orderId: string; + commandId?: string; + userId?: string; + user?: ResourceGrantUserDto; + shopItemId?: number; + resourceId: number; + resource?: ResourceDto; + durationDays?: number; + priceCoin?: number; + status?: string; + walletTransactionId?: string; + resourceGrantId?: string; + entitlementId?: string; + createdAtMs?: number; + updatedAtMs?: number; +} + export interface GiftDto { appCode?: string; giftId: string; @@ -352,6 +371,17 @@ export function listResourceShopItems(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listResourceShopPurchaseOrders; + return apiRequest>( + apiEndpointPath(API_OPERATIONS.listResourceShopPurchaseOrders), + { + method: endpoint.method, + query, + }, + ); +} + export function upsertResourceShopItems(payload: ResourceShopItemsPayload): Promise { const endpoint = API_ENDPOINTS.upsertResourceShopItems; return apiRequest( diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 8b1b936..6728027 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { parseForm } from "@/shared/forms/validation"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; @@ -27,6 +27,7 @@ import { listResourceGrants, listResourceGroups, listResourceShopItems, + listResourceShopPurchaseOrders, listResources, lookupResourceGrantTarget, updateGift, @@ -482,6 +483,11 @@ export function useResourceShopPage() { const [resourceType, setResourceType] = useState(""); const [page, setPage] = useState(1); const [drawerOpen, setDrawerOpen] = useState(false); + const [purchaseDrawerOpen, setPurchaseDrawerOpen] = useState(false); + const [purchasePage, setPurchasePage] = useState(1); + const [purchaseUserKeyword, setPurchaseUserKeyword] = useState(""); + const [purchaseResourceType, setPurchaseResourceType] = useState(""); + const [purchaseKeyword, setPurchaseKeyword] = useState(""); const [drawerQuery, setDrawerQuery] = useState(""); const [drawerResourceType, setDrawerResourceType] = useState(""); const [batchDurationDays, setBatchDurationDays] = useState("1"); @@ -505,6 +511,31 @@ export function useResourceShopPage() { pageSize, queryKey: ["resource-shop", filters, page], }); + const purchaseFilters = useMemo( + () => ({ + keyword: purchaseKeyword, + resource_type: purchaseResourceType, + user_keyword: purchaseUserKeyword, + }), + [purchaseKeyword, purchaseResourceType, purchaseUserKeyword], + ); + const fetchPurchaseOrders = useCallback( + (requestQuery) => { + if (!purchaseDrawerOpen) { + return Promise.resolve(emptyData); + } + return listResourceShopPurchaseOrders(requestQuery); + }, + [purchaseDrawerOpen], + ); + const purchaseResult = usePaginatedQuery({ + errorMessage: "加载售卖记录失败", + fetcher: fetchPurchaseOrders, + filters: purchaseFilters, + page: purchasePage, + pageSize, + queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage], + }); const shopItemsByResourceId = useMemo(() => { const items = new Map(); (result.data?.items || []).forEach((item) => { @@ -579,6 +610,15 @@ export function useResourceShopPage() { setDraftByResourceId({}); }; + const openPurchaseDrawer = () => { + setPurchaseDrawerOpen(true); + setPurchasePage(1); + }; + + const closePurchaseDrawer = () => { + setPurchaseDrawerOpen(false); + }; + const toggleDraftResource = (resource) => { if (!resource?.resourceId) { return; @@ -692,12 +732,24 @@ export function useResourceShopPage() { changeResourceType: resetSetter(setResourceType, setPage), changeStatus: resetSetter(setStatus, setPage), closeShopDrawer, + closePurchaseDrawer, drawerOpen, drawerQuery, drawerResourceType, filteredResourceOptions, loadingAction, openShopDrawer, + openPurchaseDrawer, + purchaseData: purchaseResult.data, + purchaseDrawerOpen, + purchaseError: purchaseResult.error, + purchaseKeyword, + purchaseLoading: purchaseResult.loading, + purchasePage, + purchaseResourceType, + purchaseUserKeyword, + reloadPurchaseOrders: purchaseResult.reload, + setPurchasePage, resourceOptionsLoading, resourceType, resetFilters, @@ -707,6 +759,9 @@ export function useResourceShopPage() { submitShopItems, toggleDraftResource, toggleShopItem, + changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage), + changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage), + changePurchaseUserKeyword: resetSetter(setPurchaseUserKeyword, setPurchasePage), updateDraftItem, }; } diff --git a/src/features/resources/pages/ResourceShopPage.jsx b/src/features/resources/pages/ResourceShopPage.jsx index 10ace64..93d1415 100644 --- a/src/features/resources/pages/ResourceShopPage.jsx +++ b/src/features/resources/pages/ResourceShopPage.jsx @@ -1,8 +1,10 @@ import Add from "@mui/icons-material/Add"; +import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined"; import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined"; import Checkbox from "@mui/material/Checkbox"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; @@ -70,6 +72,39 @@ const shopColumns = [ }, ]; +const purchaseColumns = [ + { + key: "user", + label: "用户信息", + width: "minmax(210px, 1fr)", + render: (item) => , + }, + { + key: "resourceType", + label: "道具类型", + width: "minmax(120px, 0.55fr)", + render: (item) => resourceTypeLabels[item.resource?.resourceType] || item.resource?.resourceType || "-", + }, + { + key: "resource", + label: "购买素材", + width: "minmax(260px, 1.1fr)", + render: (item) => , + }, + { + key: "price", + label: "购买价格", + width: "minmax(130px, 0.55fr)", + render: (item) => coinPriceLabel(item.priceCoin), + }, + { + key: "time", + label: "购买时间", + width: "minmax(170px, 0.7fr)", + render: (item) => formatMillis(item.createdAtMs), + }, +]; + export function ResourceShopPage() { const page = useResourceShopPage(); const items = page.data.items || []; @@ -115,11 +150,18 @@ export function ResourceShopPage() { - - - ) : null + <> + {page.abilities.canViewShop ? ( + + + + ) : null} + {page.abilities.canUpdateShop ? ( + + + + ) : null} + } /> @@ -143,6 +185,7 @@ export function ResourceShopPage() { + ); } @@ -227,6 +270,69 @@ function ResourceShopDrawer({ page }) { ); } +function ResourceShopPurchaseDrawer({ page }) { + const data = page.purchaseData || {}; + const items = data.items || []; + const total = data.total || 0; + const columns = purchaseColumns.map((column) => { + if (column.key === "user") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索用户ID、短号、靓号、昵称", + value: page.purchaseUserKeyword, + onChange: page.changePurchaseUserKeyword, + }), + }; + } + if (column.key === "resourceType") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: resourceShopTypeFilters, + placeholder: "搜索道具类型", + value: page.purchaseResourceType, + onChange: page.changePurchaseResourceType, + }), + }; + } + if (column.key === "resource") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索素材名称、素材编码、订单号", + value: page.purchaseKeyword, + onChange: page.changePurchaseKeyword, + }), + }; + } + return column; + }); + + return ( + + + 0 + ? { + page: page.purchasePage, + pageSize: data.pageSize || 50, + total, + onPageChange: page.setPurchasePage, + } + : undefined + } + rowKey={(item) => item.orderId} + /> + + + ); +} + function ResourceSelectionRow({ disabled, page, resource }) { const resourceId = String(resource.resourceId); const selected = page.selectedResourceIds.has(resourceId); @@ -316,6 +422,14 @@ function ResourceIdentity({ resource = {} }) { ); } +function PurchaseUser({ order }) { + const user = order.user || { userId: order.userId }; + if (!user?.userId && !user?.displayUserId && !user?.username) { + return "-"; + } + return ; +} + function SaleSpec({ item }) { return (
@@ -342,3 +456,10 @@ function resourcePriceLabel(item = {}) { } return resourcePriceTypeLabels[item.priceType] || "-"; } + +function coinPriceLabel(value) { + if (value === 0 || value) { + return `${Number(value).toLocaleString("zh-CN")}金币`; + } + return "-"; +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 7a7a9eb..cb25c99 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -182,6 +182,7 @@ export const API_OPERATIONS = { listResourceGroups: "listResourceGroups", listResources: "listResources", listResourceShopItems: "listResourceShopItems", + listResourceShopPurchaseOrders: "listResourceShopPurchaseOrders", listRobotRooms: "listRobotRooms", listRoles: "listRoles", listRoomPins: "listRoomPins", @@ -1472,6 +1473,13 @@ export const API_ENDPOINTS: Record = { permission: "resource-shop:view", permissions: ["resource-shop:view"] }, + listResourceShopPurchaseOrders: { + method: "GET", + operationId: API_OPERATIONS.listResourceShopPurchaseOrders, + path: "/v1/admin/resource-shop/purchase-orders", + permission: "resource-shop:view", + permissions: ["resource-shop:view"] + }, listRobotRooms: { method: "GET", operationId: API_OPERATIONS.listRobotRooms, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 58e9093..af25283 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -2020,6 +2020,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/resource-shop/purchase-orders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listResourceShopPurchaseOrders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/resource-shop/items/{shop_item_id}/disable": { parameters: { query?: never; @@ -6064,6 +6080,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listResourceShopPurchaseOrders: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; disableResourceShopItem: { parameters: { query?: never;