抽奖相关
This commit is contained in:
parent
3b21bd1133
commit
0789e02c6a
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 "-";
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
28
src/shared/api/generated/schema.d.ts
vendored
28
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user