抽奖相关

This commit is contained in:
zhx 2026-06-18 22:41:59 +08:00
parent 3b21bd1133
commit 0789e02c6a
9 changed files with 314 additions and 12 deletions

View File

@ -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",

View File

@ -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

View File

@ -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) =>

View File

@ -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");

View File

@ -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<ApiPage<Re
});
}
export function listResourceShopPurchaseOrders(query: PageQuery = {}): Promise<ApiPage<ResourceShopPurchaseOrderDto>> {
const endpoint = API_ENDPOINTS.listResourceShopPurchaseOrders;
return apiRequest<ApiPage<ResourceShopPurchaseOrderDto>>(
apiEndpointPath(API_OPERATIONS.listResourceShopPurchaseOrders),
{
method: endpoint.method,
query,
},
);
}
export function upsertResourceShopItems(payload: ResourceShopItemsPayload): Promise<ResourceShopItemDto[]> {
const endpoint = API_ENDPOINTS.upsertResourceShopItems;
return apiRequest<ResourceShopItemDto[], ResourceShopItemsPayload>(

View File

@ -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,
};
}

View File

@ -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) => <PurchaseUser order={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) => <ResourceIdentity resource={item.resource} />,
},
{
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() {
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canUpdateShop ? (
<AdminActionIconButton label="添加售卖资源" primary onClick={page.openShopDrawer}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
<>
{page.abilities.canViewShop ? (
<AdminActionIconButton label="售卖记录" onClick={page.openPurchaseDrawer}>
<ReceiptLongOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canUpdateShop ? (
<AdminActionIconButton label="添加售卖资源" primary onClick={page.openShopDrawer}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null}
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -143,6 +185,7 @@ export function ResourceShopPage() {
</AdminListBody>
</DataState>
<ResourceShopDrawer page={page} />
<ResourceShopPurchaseDrawer page={page} />
</AdminListPage>
);
}
@ -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 (
<SideDrawer open={page.purchaseDrawerOpen} title="售卖记录" width="wide" onClose={page.closePurchaseDrawer}>
<DataState error={page.purchaseError} loading={page.purchaseLoading} onRetry={page.reloadPurchaseOrders}>
<DataTable
columns={columns}
items={items}
minWidth="980px"
pagination={
total > 0
? {
page: page.purchasePage,
pageSize: data.pageSize || 50,
total,
onPageChange: page.setPurchasePage,
}
: undefined
}
rowKey={(item) => item.orderId}
/>
</DataState>
</SideDrawer>
);
}
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 <AdminUserIdentity openInAppUserDetail user={user} />;
}
function SaleSpec({ item }) {
return (
<div className={styles.stack}>
@ -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 "-";
}

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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;