From ec252cec2c8e09e680c0c7023a68b9401cc1a6a1 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 18 Jun 2026 17:13:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=BA=E5=99=A8=E4=BA=BA?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=92=8C=E8=BD=AC=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 46 ++ src/app/navigation/menu.js | 3 + src/app/permissions.ts | 3 + src/features/operations/api.test.ts | 83 +++ src/features/operations/api.ts | 238 ++++++++ .../operations/components/WheelPrizeTable.jsx | 127 +++++ src/features/operations/hooks/useWheelPage.js | 537 ++++++++++++++++++ .../operations/hooks/useWheelPage.test.js | 69 +++ src/features/operations/operations.module.css | 304 ++++++++++ src/features/operations/pages/WheelPage.jsx | 369 ++++++++++++ src/features/operations/permissions.js | 9 + src/features/operations/routes.js | 8 + src/features/operations/schema.ts | 107 ++++ src/shared/api/generated/endpoints.ts | 34 +- src/shared/api/generated/schema.d.ts | 96 ++++ 15 files changed, 2032 insertions(+), 1 deletion(-) create mode 100644 src/features/operations/components/WheelPrizeTable.jsx create mode 100644 src/features/operations/hooks/useWheelPage.js create mode 100644 src/features/operations/hooks/useWheelPage.test.js create mode 100644 src/features/operations/pages/WheelPage.jsx create mode 100644 src/features/operations/schema.ts diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 9b1bcac..9a7283b 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -280,6 +280,52 @@ "x-permissions": ["lucky-gift:view"] } }, + "/admin/activity/wheel/config": { + "get": { + "operationId": "getWheelConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "wheel:view", + "x-permissions": ["wheel:view"] + }, + "put": { + "operationId": "upsertWheelConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "wheel:update", + "x-permissions": ["wheel:update"] + } + }, + "/admin/activity/wheel/draws": { + "get": { + "operationId": "listWheelDraws", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "wheel:view", + "x-permissions": ["wheel:view"] + } + }, + "/admin/activity/wheel/draw-summary": { + "get": { + "operationId": "getWheelDrawSummary", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "wheel:view", + "x-permissions": ["wheel:view"] + } + }, "/admin/activity/first-recharge-reward/claims": { "get": { "operationId": "listFirstRechargeRewardClaims", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 48e50db..165722a 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -4,6 +4,7 @@ import BlockOutlined from "@mui/icons-material/BlockOutlined"; import CampaignOutlined from "@mui/icons-material/CampaignOutlined"; import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; import CategoryOutlined from "@mui/icons-material/CategoryOutlined"; +import CasinoOutlined from "@mui/icons-material/CasinoOutlined"; import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined"; import FavoriteBorderOutlined from "@mui/icons-material/FavoriteBorderOutlined"; @@ -47,6 +48,7 @@ const iconMap = { apartment: ApartmentOutlined, block: BlockOutlined, campaign: CampaignOutlined, + casino: CasinoOutlined, category: CategoryOutlined, dashboard: DashboardOutlined, event_available: EventAvailableOutlined, @@ -213,6 +215,7 @@ export const fallbackNavigation = [ routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }), routeNavItem("room-rocket", { icon: RedeemOutlined }), routeNavItem("room-turnover-reward", { icon: PaidOutlined }), + routeNavItem("wheel", { icon: CasinoOutlined }), routeNavItem("weekly-star", { icon: StarBorderOutlined }), routeNavItem("agency-opening", { icon: StorefrontOutlined }), routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }), diff --git a/src/app/permissions.ts b/src/app/permissions.ts index c41d14c..be16c1f 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -146,6 +146,8 @@ export const PERMISSIONS = { sevenDayCheckInUpdate: "seven-day-checkin:update", luckyGiftView: "lucky-gift:view", luckyGiftUpdate: "lucky-gift:update", + wheelView: "wheel:view", + wheelUpdate: "wheel:update", roomRocketView: "room-rocket:view", roomRocketUpdate: "room-rocket:update", roomTurnoverRewardView: "room-turnover-reward:view", @@ -217,6 +219,7 @@ export const MENU_CODES = { operationGiftDiamond: "operation-gift-diamond", operationFullServerNotice: "operation-full-server-notice", luckyGift: "lucky-gift", + wheel: "wheel", activities: "activities", dailyTaskList: "daily-task-list", registrationReward: "registration-reward", diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts index 463943e..202b36e 100644 --- a/src/features/operations/api.test.ts +++ b/src/features/operations/api.test.ts @@ -5,10 +5,14 @@ import { createFullServerNoticeFanout, exportCoinSellerLedger, getGiftDiamondRatios, + getWheelConfig, + getWheelDrawSummary, listCoinAdjustments, listCoinSellerLedger, + listWheelDraws, lookupCoinAdjustmentTarget, updateGiftDiamondRatios, + updateWheelConfig, } from "./api"; afterEach(() => { @@ -165,3 +169,82 @@ test("coin seller ledger export API sends current filters without pagination", a expect(String(exportUrl)).not.toContain("page="); expect(exportInit?.method).toBe("GET"); }); + +test("wheel APIs use activity paths and generated methods", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } }))), + ); + + await getWheelConfig("default"); + await updateWheelConfig({ + draw_price_coins: 10000, + effective_from_ms: 0, + enabled: true, + initial_pool_coins: 0, + max_single_rtp_payout: 0, + pool_rate_percent: 90, + pool_reserve_coins: 0, + settlement_window_draws: 1000, + target_rtp_percent: 90, + tiers: [ + { + display_name: "金币", + enabled: true, + metadata_json: "{}", + probability_percent: 70, + reward_coins: 10000, + reward_count: 1, + reward_id: "", + reward_type: "coin", + rtp_value_coins: 10000, + tier_id: "coin", + }, + { + display_name: "礼物", + enabled: true, + metadata_json: "{}", + probability_percent: 20, + reward_coins: 0, + reward_count: 1, + reward_id: "gift_1001", + reward_type: "gift", + rtp_value_coins: 5000, + tier_id: "gift", + }, + { + display_name: "装扮", + enabled: true, + metadata_json: "{}", + probability_percent: 10, + reward_coins: 0, + reward_count: 1, + reward_id: "dress_1001", + reward_type: "dress", + rtp_value_coins: 0, + tier_id: "dress", + }, + ], + wheel_id: "default", + }); + await listWheelDraws({ page: 1, page_size: 20, status: "granted", user_id: "1001", wheel_id: "default" }); + await getWheelDrawSummary({ status: "granted", user_id: "1001", wheel_id: "default" }); + + const [getUrl, getInit] = vi.mocked(fetch).mock.calls[0]; + const [putUrl, putInit] = vi.mocked(fetch).mock.calls[1]; + const [drawsUrl, drawsInit] = vi.mocked(fetch).mock.calls[2]; + const [summaryUrl, summaryInit] = vi.mocked(fetch).mock.calls[3]; + + expect(String(getUrl)).toContain("/api/v1/admin/activity/wheel/config?"); + expect(String(getUrl)).toContain("wheel_id=default"); + expect(getInit?.method).toBe("GET"); + expect(String(putUrl)).toContain("/api/v1/admin/activity/wheel/config?"); + expect(putInit?.method).toBe("PUT"); + expect(JSON.parse(String(putInit?.body))).toMatchObject({ enabled: true, wheel_id: "default" }); + expect(String(drawsUrl)).toContain("/api/v1/admin/activity/wheel/draws?"); + expect(String(drawsUrl)).toContain("user_id=1001"); + expect(String(drawsUrl)).toContain("status=granted"); + expect(drawsInit?.method).toBe("GET"); + expect(String(summaryUrl)).toContain("/api/v1/admin/activity/wheel/draw-summary?"); + expect(summaryInit?.method).toBe("GET"); +}); diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts index ac114f1..ca7ed84 100644 --- a/src/features/operations/api.ts +++ b/src/features/operations/api.ts @@ -60,6 +60,103 @@ export interface FullServerNoticeFanoutResponse { target_scope: string; } +export type WheelRewardType = "coin" | "dress" | "gift" | "prop"; + +export interface WheelTierDto { + tierId: string; + displayName: string; + rewardType: WheelRewardType; + rewardId: string; + rewardCount: number; + rewardCoins: number; + rtpValueCoins: number; + probabilityPercent: number; + enabled: boolean; + metadataJson: string; +} + +export interface WheelConfigDto { + appCode?: string; + wheelId: string; + enabled: boolean; + ruleVersion: number; + drawPriceCoins: number; + targetRTPPercent: number; + poolRatePercent: number; + settlementWindowDraws: number; + initialPoolCoins: number; + poolReserveCoins: number; + maxSingleRTPPayout: number; + effectiveFromMs: number; + createdByAdminId?: number; + createdAtMs?: number; + tiers: WheelTierDto[]; +} + +export interface WheelConfigPayload { + wheel_id: string; + enabled: boolean; + draw_price_coins: number; + target_rtp_percent: number; + pool_rate_percent: number; + settlement_window_draws: number; + initial_pool_coins: number; + pool_reserve_coins: number; + max_single_rtp_payout: number; + effective_from_ms: number; + tiers: Array<{ + tier_id: string; + display_name: string; + reward_type: WheelRewardType; + reward_id: string; + reward_count: number; + reward_coins: number; + rtp_value_coins: number; + probability_percent: number; + enabled: boolean; + metadata_json: string; + }>; +} + +export interface WheelDrawDto { + drawId: string; + drawIds: string[]; + commandId: string; + wheelId: string; + ruleVersion: number; + selectedTierId: string; + rewardType: string; + rewardId: string; + rewardCount: number; + rewardCoins: number; + rtpValueCoins: number; + rewardStatus: string; + rtpWindowIndex: number; + actualRTPPPM: number; + createdAtMs: number; + walletTransactionId: string; + coinBalanceAfter: number; + metadataJson: string; +} + +export interface WheelDrawSummaryDto { + wheelId: string; + totalDraws: number; + uniqueUsers: number; + totalSpentCoins: number; + totalRTPValueCoins: number; + actualRTPPPM: number; + pendingDraws: number; + grantedDraws: number; + failedDraws: number; +} + +type RawWheelConfig = WheelConfigDto & Record; +type RawWheelTier = WheelTierDto & Record; +type RawWheelDraw = WheelDrawDto & Record; +type RawWheelSummary = WheelDrawSummaryDto & Record; +type WheelSummaryQuery = Record; + export function listCoinLedger(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listCoinLedger; return apiRequest>(apiEndpointPath(API_OPERATIONS.listCoinLedger), { @@ -148,3 +245,144 @@ export function createFullServerNoticeFanout( }, ); } + +export function getWheelConfig(wheelId = "default"): Promise { + const endpoint = API_ENDPOINTS.getWheelConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getWheelConfig), { + method: endpoint.method, + query: { wheel_id: wheelId }, + }).then(normalizeWheelConfig); +} + +export function updateWheelConfig(payload: WheelConfigPayload): Promise { + const endpoint = API_ENDPOINTS.upsertWheelConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.upsertWheelConfig), { + body: payload, + method: endpoint.method, + query: { wheel_id: payload.wheel_id }, + }).then(normalizeWheelConfig); +} + +export function listWheelDraws(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listWheelDraws; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listWheelDraws), { + method: endpoint.method, + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizeWheelDraw), + })); +} + +export function getWheelDrawSummary(query: WheelSummaryQuery = {}): Promise { + const endpoint = API_ENDPOINTS.getWheelDrawSummary; + return apiRequest(apiEndpointPath(API_OPERATIONS.getWheelDrawSummary), { + method: endpoint.method, + query, + }).then(normalizeWheelSummary); +} + +function normalizeWheelConfig(item: RawWheelConfig): WheelConfigDto { + // 后端为了兼容 Go DTO 使用 snake_case;页面统一消费 camelCase, + // 避免表单、表格、保存 payload 混用两套字段名导致配置保存缺字段。 + return { + appCode: stringValue(item.appCode ?? item.app_code), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id), + drawPriceCoins: numberValue(item.drawPriceCoins ?? item.draw_price_coins), + effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms), + enabled: booleanValue(item.enabled), + initialPoolCoins: numberValue(item.initialPoolCoins ?? item.initial_pool_coins), + maxSingleRTPPayout: numberValue(item.maxSingleRTPPayout ?? item.max_single_rtp_payout), + poolRatePercent: numberValue(item.poolRatePercent ?? item.pool_rate_percent), + poolReserveCoins: numberValue(item.poolReserveCoins ?? item.pool_reserve_coins), + ruleVersion: numberValue(item.ruleVersion ?? item.rule_version), + settlementWindowDraws: numberValue(item.settlementWindowDraws ?? item.settlement_window_draws), + targetRTPPercent: numberValue(item.targetRTPPercent ?? item.target_rtp_percent), + tiers: arrayValue(item.tiers).map(normalizeWheelTier), + wheelId: stringValue(item.wheelId ?? item.wheel_id), + }; +} + +function normalizeWheelTier(raw: unknown): WheelTierDto { + const item = asRecord(raw) as RawWheelTier; + const rewardType = normalizeRewardType(item.rewardType ?? item.reward_type); + return { + displayName: stringValue(item.displayName ?? item.display_name), + enabled: booleanValue(item.enabled), + metadataJson: stringValue(item.metadataJson ?? item.metadata_json) || "{}", + probabilityPercent: numberValue(item.probabilityPercent ?? item.probability_percent), + rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins), + rewardCount: numberValue(item.rewardCount ?? item.reward_count), + rewardId: stringValue(item.rewardId ?? item.reward_id), + rewardType, + rtpValueCoins: rewardType === "prop" || rewardType === "dress" ? 0 : numberValue(item.rtpValueCoins ?? item.rtp_value_coins), + tierId: stringValue(item.tierId ?? item.tier_id) || rewardType, + }; +} + +function normalizeWheelDraw(item: RawWheelDraw): WheelDrawDto { + return { + actualRTPPPM: numberValue(item.actualRTPPPM ?? item.actual_rtp_ppm), + coinBalanceAfter: numberValue(item.coinBalanceAfter ?? item.coin_balance_after), + commandId: stringValue(item.commandId ?? item.command_id), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + drawId: stringValue(item.drawId ?? item.draw_id), + drawIds: arrayValue(item.drawIds ?? item.draw_ids).map(stringValue), + metadataJson: stringValue(item.metadataJson ?? item.metadata_json) || "{}", + rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins), + rewardCount: numberValue(item.rewardCount ?? item.reward_count), + rewardId: stringValue(item.rewardId ?? item.reward_id), + rewardStatus: stringValue(item.rewardStatus ?? item.reward_status), + rewardType: stringValue(item.rewardType ?? item.reward_type), + rtpValueCoins: numberValue(item.rtpValueCoins ?? item.rtp_value_coins), + rtpWindowIndex: numberValue(item.rtpWindowIndex ?? item.rtp_window_index), + ruleVersion: numberValue(item.ruleVersion ?? item.rule_version), + selectedTierId: stringValue(item.selectedTierId ?? item.selected_tier_id), + walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id), + wheelId: stringValue(item.wheelId ?? item.wheel_id), + }; +} + +function normalizeWheelSummary(item: RawWheelSummary): WheelDrawSummaryDto { + return { + actualRTPPPM: numberValue(item.actualRTPPPM ?? item.actual_rtp_ppm), + failedDraws: numberValue(item.failedDraws ?? item.failed_draws), + grantedDraws: numberValue(item.grantedDraws ?? item.granted_draws), + pendingDraws: numberValue(item.pendingDraws ?? item.pending_draws), + totalDraws: numberValue(item.totalDraws ?? item.total_draws), + totalRTPValueCoins: numberValue(item.totalRTPValueCoins ?? item.total_rtp_value_coins), + totalSpentCoins: numberValue(item.totalSpentCoins ?? item.total_spent_coins), + uniqueUsers: numberValue(item.uniqueUsers ?? item.unique_users), + wheelId: stringValue(item.wheelId ?? item.wheel_id), + }; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function booleanValue(value: unknown) { + return value === true || value === "true" || value === 1 || value === "1"; +} + +function normalizeRewardType(value: unknown): WheelRewardType { + const normalized = stringValue(value).trim().toLowerCase(); + if (normalized === "dress" || normalized === "gift" || normalized === "prop") { + return normalized; + } + return "coin"; +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function numberValue(value: unknown) { + const number = Number(value || 0); + return Number.isFinite(number) ? number : 0; +} diff --git a/src/features/operations/components/WheelPrizeTable.jsx b/src/features/operations/components/WheelPrizeTable.jsx new file mode 100644 index 0000000..a9eacf5 --- /dev/null +++ b/src/features/operations/components/WheelPrizeTable.jsx @@ -0,0 +1,127 @@ +import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useState } from "react"; +import styles from "@/features/operations/operations.module.css"; + +const rewardTypeOptions = [ + { label: "金币", value: "coin" }, + { label: "礼物", value: "gift" }, + { label: "道具", value: "prop" }, + { label: "装扮", value: "dress" }, +]; + +export function WheelPrizeTable({ canUpdate, disabled, expectedCount, onChange, onMove, resourceGroupPicker, tiers }) { + const [dragIndex, setDragIndex] = useState(null); + + return ( +
+
+

奖品档位

+ {expectedCount} 个档位,选择资源组后可拖动排序 +
+ {resourceGroupPicker ?
{resourceGroupPicker}
: null} + {tiers.length ? ( +
+
+ {tiers.map((tier, index) => ( +
event.preventDefault()} + onDragStart={() => setDragIndex(index)} + onDrop={() => { + if (dragIndex !== null) { + onMove?.(dragIndex, index); + } + setDragIndex(null); + }} + > + #{index + 1} +
+ onChange(index, "displayName", event.target.value)} + /> + onChange(index, "rewardType", event.target.value)} + > + {rewardTypeOptions.map((option) => ( + + {option.label} + + ))} + + onChange(index, "rewardId", event.target.value)} + /> + onChange(index, "rewardCount", event.target.value)} + /> + onChange(index, "rewardCoins", event.target.value)} + /> + onChange(index, "rtpValueCoins", event.target.value)} + /> + %, + readOnly: true, + }} + disabled={disabled} + /> + onChange(index, "metadataJson", event.target.value)} + /> +
+
+ ))} +
+
+ ) : ( +
请选择资源组生成奖品档位
+ )} +
+ ); +} diff --git a/src/features/operations/hooks/useWheelPage.js b/src/features/operations/hooks/useWheelPage.js new file mode 100644 index 0000000..db8309c --- /dev/null +++ b/src/features/operations/hooks/useWheelPage.js @@ -0,0 +1,537 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getWheelConfig, getWheelDrawSummary, listWheelDraws, updateWheelConfig } from "@/features/operations/api"; +import { listResourceGroups } from "@/features/resources/api"; +import { wheelConfigFormSchema } from "@/features/operations/schema"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const pageSize = 50; +const probabilityScale = 1_000_000; +const probabilityPercentScale = 10_000; +const minimumTierProbabilityPPM = 100; +const randomRewardTypes = new Set(["prop", "dress"]); +const probabilityAffectingFields = new Set([ + "drawPriceCoins", + "targetRTPPercent", + "rewardType", + "rewardCoins", + "rtpValueCoins", +]); +export const wheelPools = [ + { key: "classic", label: "Classic", tierCount: 9 }, + { key: "luxury", label: "Luxury", tierCount: 8 }, + { key: "advanced", label: "Advanced", tierCount: 12 }, +]; + +const defaultForm = defaultFormFor("classic"); + +function defaultFormFor(wheelId) { + const pool = wheelPools.find((item) => item.key === wheelId) || wheelPools[0]; + return { + drawPriceCoins: 10000, + effectiveFromMs: 0, + enabled: true, + initialPoolCoins: 0, + maxSingleRTPPayout: 0, + poolRatePercent: 90, + poolReserveCoins: 0, + settlementWindowDraws: 1000, + targetRTPPercent: 90, + tiers: [], + wheelId: pool.key, + }; +} + +export function useWheelPage() { + const { showToast } = useToast(); + const [wheelId, setWheelId] = useState("classic"); + const [userId, setUserId] = useState(""); + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + const [form, setForm] = useState(defaultForm); + const [saving, setSaving] = useState(false); + + const configQueryFn = useCallback(() => getWheelConfig(wheelId), [wheelId]); + const configQuery = useAdminQuery(configQueryFn, { + errorMessage: "获取转盘配置失败", + queryKey: ["wheel-config", wheelId], + }); + + const summaryFilters = useMemo( + () => ({ + status, + user_id: userId, + wheel_id: wheelId, + }), + [status, userId, wheelId], + ); + const summaryQueryFn = useCallback(() => getWheelDrawSummary(summaryFilters), [summaryFilters]); + const summaryQuery = useAdminQuery(summaryQueryFn, { + errorMessage: "获取转盘汇总失败", + initialData: null, + queryKey: ["wheel-draw-summary", summaryFilters], + }); + + const drawQuery = usePaginatedQuery({ + errorMessage: "获取转盘抽奖记录失败", + fetcher: listWheelDraws, + filters: summaryFilters, + page, + pageSize, + queryKey: ["wheel-draws", summaryFilters, page], + }); + + const resourceGroupQueryFn = useCallback(() => listResourceGroups({ page: 1, page_size: 300, status: "active" }), []); + const resourceGroupQuery = useAdminQuery(resourceGroupQueryFn, { + errorMessage: "获取资源组失败", + initialData: { items: [] }, + queryKey: ["wheel-resource-groups"], + }); + + useEffect(() => { + if (configQuery.data) { + setForm(configToForm(configQuery.data)); + return; + } + setForm(defaultFormFor(wheelId)); + }, [configQuery.data, wheelId]); + + const filtersDirty = Boolean(userId || status || wheelId !== "classic"); + + const changeWheelId = (value) => { + setWheelId(value.trim() || "classic"); + setPage(1); + }; + + const selectPool = (value) => { + const nextWheelId = wheelPools.some((pool) => pool.key === value) ? value : "classic"; + setWheelId(nextWheelId); + setPage(1); + }; + + const changeUserId = (value) => { + setUserId(value); + setPage(1); + }; + + const changeStatus = (value) => { + setStatus(value); + setPage(1); + }; + + const resetFilters = () => { + setWheelId("classic"); + setUserId(""); + setStatus(""); + setPage(1); + }; + + const changeFormField = (field, value) => { + setForm((current) => { + const next = { ...current, [field]: value }; + return probabilityAffectingFields.has(field) ? withGeneratedTierProbabilities(next) : next; + }); + }; + + const changeTierField = (index, field, value) => { + setForm((current) => { + const tiers = current.tiers.map((tier, tierIndex) => { + if (tierIndex !== index) { + return tier; + } + const next = { ...tier, [field]: value }; + // 道具和装扮只影响展示/背包发放,不进入 RTP 统计;前端在切换类型时立即清零, + // 这样运营保存前看到的数值和后端实际入库规则一致。 + if (field === "rewardType" && (value === "prop" || value === "dress")) { + next.rtpValueCoins = 0; + next.rewardCoins = 0; + next.tierId = `${current.wheelId}-${tierIndex + 1}`; + } + if (field === "rewardType" && value === "coin") { + next.rewardId = ""; + next.rtpValueCoins = Number(next.rewardCoins || 0); + next.tierId = `${current.wheelId}-${tierIndex + 1}`; + } + if (field === "rewardType" && value === "gift") { + next.rewardCoins = 0; + next.tierId = `${current.wheelId}-${tierIndex + 1}`; + } + if (field === "rewardCoins" && tier.rewardType === "coin") { + next.rtpValueCoins = value; + } + return next; + }); + const next = { ...current, tiers }; + return probabilityAffectingFields.has(field) ? withGeneratedTierProbabilities(next) : next; + }); + }; + + const importResourceGroup = (group) => { + const items = group?.items || []; + const expectedCount = expectedTierCount(wheelId); + if (items.length !== expectedCount) { + showToast(`${poolLabel(wheelId)} 需要 ${expectedCount} 个奖品,当前资源组有 ${items.length} 个`, "error"); + return; + } + setForm((current) => + withGeneratedTierProbabilities({ ...current, tiers: items.map((item, index) => groupItemToTier(item, wheelId, index)) }), + ); + showToast("资源组已导入,可拖动排序", "success"); + }; + + const moveTier = (fromIndex, toIndex) => { + setForm((current) => { + if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= current.tiers.length || toIndex >= current.tiers.length) { + return current; + } + const tiers = [...current.tiers]; + const [item] = tiers.splice(fromIndex, 1); + tiers.splice(toIndex, 0, item); + return withGeneratedTierProbabilities({ + ...current, + tiers: tiers.map((tier, index) => ({ ...tier, tierId: `${current.wheelId}-${index + 1}` })), + }); + }); + }; + + const submit = async () => { + const generatedForm = withGeneratedTierProbabilities(form); + setForm(generatedForm); + const parsed = wheelConfigFormSchema.safeParse(generatedForm); + if (!parsed.success) { + showToast(parsed.error.issues[0]?.message || "转盘配置参数不正确", "error"); + return; + } + + setSaving(true); + try { + await updateWheelConfig(formToPayload(parsed.data)); + showToast("转盘配置已保存", "success"); + setWheelId(parsed.data.wheelId); + await Promise.all([configQuery.reload(), summaryQuery.reload(), drawQuery.reload()]); + } catch (err) { + showToast(err.message || "保存转盘配置失败", "error"); + } finally { + setSaving(false); + } + }; + + return { + changeFormField, + changeStatus, + changeTierField, + changeUserId, + changeWheelId, + configQuery, + drawQuery, + drawError: drawQuery.error, + draws: drawQuery.data, + expectedTierCount: expectedTierCount(wheelId), + filtersDirty, + form, + importResourceGroup, + moveTier, + page, + pageSize, + resetFilters, + resourceGroups: resourceGroupQuery.data?.items || [], + resourceGroupQuery, + saving, + selectPool, + setPage, + status, + submit, + summary: summaryQuery.data, + summaryQuery, + userId, + wheelPools, + wheelId, + configError: configQuery.error, + }; +} + +function configToForm(config) { + const wheelId = config.wheelId || "classic"; + const rawTiers = config.tiers || []; + const tiers = isGeneratedPlaceholderTiers(rawTiers, wheelId) ? [] : normalizeTiers(rawTiers, wheelId); + return { + drawPriceCoins: config.drawPriceCoins || defaultForm.drawPriceCoins, + effectiveFromMs: config.effectiveFromMs || 0, + enabled: Boolean(config.enabled), + initialPoolCoins: config.initialPoolCoins || 0, + maxSingleRTPPayout: config.maxSingleRTPPayout || 0, + poolRatePercent: config.poolRatePercent || defaultForm.poolRatePercent, + poolReserveCoins: config.poolReserveCoins || 0, + settlementWindowDraws: config.settlementWindowDraws || defaultForm.settlementWindowDraws, + targetRTPPercent: config.targetRTPPercent ?? defaultForm.targetRTPPercent, + tiers: generateWheelTierProbabilities({ + drawPriceCoins: config.drawPriceCoins || defaultForm.drawPriceCoins, + targetRTPPercent: config.targetRTPPercent ?? defaultForm.targetRTPPercent, + tiers, + }), + wheelId, + }; +} + +function normalizeTiers(tiers, wheelId) { + return (tiers || []).map( + (tier, index) => ({ + displayName: tier.displayName || "", + enabled: true, + metadataJson: tier.metadataJson || "{}", + probabilityPercent: tier.probabilityPercent || 0, + rewardCoins: tier.rewardCoins || 0, + rewardCount: tier.rewardCount || 0, + rewardId: tier.rewardId || "", + rewardType: tier.rewardType || "coin", + rtpValueCoins: tier.rewardType === "prop" || tier.rewardType === "dress" ? 0 : tier.rtpValueCoins || 0, + tierId: tier.tierId || `${wheelId}-${index + 1}`, + }), + ); +} + +function isGeneratedPlaceholderTiers(tiers, wheelId) { + const expectedCount = expectedTierCount(wheelId); + if (!Array.isArray(tiers) || tiers.length !== expectedCount) { + return false; + } + return tiers.every((tier, index) => { + const expectedName = `奖品 ${index + 1}`; + const expectedTierId = `${wheelId}-${index + 1}`; + return ( + tier.displayName === expectedName && + tier.tierId === expectedTierId && + tier.rewardType === "coin" && + !tier.rewardId && + Number(tier.rewardCoins || 0) === 1 && + Number(tier.rtpValueCoins || 0) === 1 && + Number(tier.rewardCount || 0) === 1 + ); + }); +} + +function formToPayload(form) { + return { + draw_price_coins: Number(form.drawPriceCoins), + effective_from_ms: Number(form.effectiveFromMs || 0), + enabled: Boolean(form.enabled), + initial_pool_coins: Number(form.initialPoolCoins || 0), + max_single_rtp_payout: Number(form.maxSingleRTPPayout || 0), + pool_rate_percent: Number(form.poolRatePercent), + pool_reserve_coins: Number(form.poolReserveCoins || 0), + settlement_window_draws: Number(form.settlementWindowDraws), + target_rtp_percent: Number(form.targetRTPPercent), + tiers: form.tiers.map((tier) => { + const rewardType = tier.rewardType; + return { + display_name: tier.displayName.trim(), + enabled: true, + metadata_json: tier.metadataJson.trim() || "{}", + probability_percent: Number(tier.probabilityPercent), + reward_coins: Number(rewardType === "coin" ? tier.rewardCoins : 0), + reward_count: Number(tier.rewardCount || 0), + reward_id: tier.rewardId.trim(), + reward_type: rewardType, + rtp_value_coins: rewardType === "prop" || rewardType === "dress" ? 0 : Number(tier.rtpValueCoins || 0), + tier_id: tier.tierId.trim(), + }; + }), + wheel_id: form.wheelId.trim(), + }; +} + +function expectedTierCount(wheelId) { + return (wheelPools.find((pool) => pool.key === wheelId) || wheelPools[0]).tierCount; +} + +function poolLabel(wheelId) { + return (wheelPools.find((pool) => pool.key === wheelId) || wheelPools[0]).label; +} + +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); + const coinValue = isCoin ? Number(item.walletAssetAmount || 0) : Number(resource.coinPrice || 0); + const metadata = { + animation_url: isCoin ? "" : resource.animationUrl || "", + asset_url: resource.assetUrl || "", + preview_url: resource.previewUrl || resource.assetUrl || "", + resource_code: resource.resourceCode || "", + resource_id: resource.resourceId || 0, + resource_type: resource.resourceType || "", + }; + return { + displayName: isCoin ? "金币" : resource.name || resource.resourceCode || `资源 ${resource.resourceId || index + 1}`, + enabled: true, + metadataJson: JSON.stringify(metadata), + probabilityPercent: 0, + rewardCoins: isCoin ? coinValue : 0, + rewardCount: Number(item.quantity || 1), + rewardId: isCoin ? "" : String(resource.resourceId || item.resourceId || ""), + rewardType, + rtpValueCoins: rewardType === "prop" || rewardType === "dress" ? 0 : coinValue, + tierId: `${wheelId}-${index + 1}`, + }; +} + +function withGeneratedTierProbabilities(form) { + return { ...form, tiers: generateWheelTierProbabilities(form) }; +} + +export function generateWheelTierProbabilities(form) { + const tiers = form.tiers || []; + if (!tiers.length) { + return tiers; + } + + const ppm = tiers.map(() => minimumTierProbabilityPPM); + const randomIndexes = []; + const rtpIndexes = []; + const rtpValues = new Map(); + + tiers.forEach((tier, index) => { + const rewardType = tier.rewardType; + if (randomRewardTypes.has(rewardType)) { + randomIndexes.push(index); + return; + } + const rtpValue = rtpTierValue(tier); + if (rtpValue > 0) { + rtpIndexes.push(index); + rtpValues.set(index, rtpValue); + } else { + randomIndexes.push(index); + } + }); + + const availablePPM = Math.max(0, probabilityScale - minimumTierProbabilityPPM * tiers.length); + if (!rtpIndexes.length) { + distributeEqual(ppm, randomIndexes.length ? randomIndexes : tiers.map((_, index) => index), availablePPM); + return formatTierProbabilities(tiers, ppm); + } + + const targetExpectedCoins = (numberValue(form.drawPriceCoins) * numberValue(form.targetRTPPercent)) / 100; + const baselineExpectedCoins = rtpIndexes.reduce( + (sum, index) => sum + (minimumTierProbabilityPPM / probabilityScale) * (rtpValues.get(index) || 0), + 0, + ); + const remainingTargetCoins = Math.max(0, targetExpectedCoins - baselineExpectedCoins); + const sortedRtpIndexes = [...rtpIndexes].sort((left, right) => (rtpValues.get(left) || 0) - (rtpValues.get(right) || 0)); + const minRtpValue = rtpValues.get(sortedRtpIndexes[0]) || 0; + const maxRtpValue = rtpValues.get(sortedRtpIndexes[sortedRtpIndexes.length - 1]) || minRtpValue; + + // 非 RTP 奖励没有金币期望值,默认只拿一个小的随机池;如果目标 RTP 需要更多金币/礼物概率, + // 这里会自动压缩随机池,保证提交前概率合计和期望 RTP 都尽量贴近基础配置。 + const desiredRandomPPM = randomIndexes.length ? Math.min(300_000, randomIndexes.length * 20_000) : 0; + const minRtpExtraPPM = maxRtpValue > 0 ? Math.ceil((remainingTargetCoins * probabilityScale) / maxRtpValue) : availablePPM; + const maxRtpExtraPPM = minRtpValue > 0 ? Math.floor((remainingTargetCoins * probabilityScale) / minRtpValue) : availablePPM; + const desiredRtpExtraPPM = randomIndexes.length ? availablePPM - desiredRandomPPM : availablePPM; + const rtpExtraPPM = randomIndexes.length ? clamp(desiredRtpExtraPPM, 0, availablePPM, minRtpExtraPPM, maxRtpExtraPPM) : availablePPM; + const randomExtraPPM = availablePPM - rtpExtraPPM; + + distributeEqual(ppm, randomIndexes, randomExtraPPM); + distributeRtpProbability(ppm, sortedRtpIndexes, rtpValues, rtpExtraPPM, remainingTargetCoins); + normalizeProbabilityTotal(ppm); + return formatTierProbabilities(tiers, ppm); +} + +function distributeRtpProbability(ppm, sortedIndexes, rtpValues, availablePPM, targetCoins) { + if (availablePPM <= 0 || !sortedIndexes.length) { + return; + } + if (sortedIndexes.length === 1) { + ppm[sortedIndexes[0]] += availablePPM; + return; + } + const averageTarget = targetCoins / (availablePPM / probabilityScale); + let lowerIndex = sortedIndexes[0]; + let upperIndex = sortedIndexes[sortedIndexes.length - 1]; + + for (let index = 0; index < sortedIndexes.length - 1; index += 1) { + const currentIndex = sortedIndexes[index]; + const nextIndex = sortedIndexes[index + 1]; + const currentValue = rtpValues.get(currentIndex) || 0; + const nextValue = rtpValues.get(nextIndex) || currentValue; + if (averageTarget >= currentValue && averageTarget <= nextValue) { + lowerIndex = currentIndex; + upperIndex = nextIndex; + break; + } + } + + const lowerValue = rtpValues.get(lowerIndex) || 0; + const upperValue = rtpValues.get(upperIndex) || lowerValue; + if (upperValue <= lowerValue) { + ppm[lowerIndex] += availablePPM; + return; + } + + // 在两个相邻 RTP 价值档之间做线性分配,可以同时满足“概率合计 100”和“期望返奖贴近目标 RTP”。 + const upperRatio = Math.max(0, Math.min(1, (averageTarget - lowerValue) / (upperValue - lowerValue))); + const upperPPM = Math.round(availablePPM * upperRatio); + ppm[upperIndex] += upperPPM; + ppm[lowerIndex] += availablePPM - upperPPM; +} + +function distributeEqual(ppm, indexes, totalPPM) { + if (!indexes.length || totalPPM <= 0) { + return; + } + const base = Math.floor(totalPPM / indexes.length); + let remainder = totalPPM - base * indexes.length; + indexes.forEach((index) => { + ppm[index] += base + (remainder > 0 ? 1 : 0); + remainder -= 1; + }); +} + +function normalizeProbabilityTotal(ppm) { + const delta = probabilityScale - ppm.reduce((sum, value) => sum + value, 0); + if (delta !== 0 && ppm.length) { + ppm[ppm.length - 1] += delta; + } +} + +function formatTierProbabilities(tiers, ppm) { + return tiers.map((tier, index) => ({ + ...tier, + probabilityPercent: Number((ppm[index] / probabilityPercentScale).toFixed(4)), + })); +} + +function rtpTierValue(tier) { + if (tier.rewardType === "coin") { + return numberValue(tier.rewardCoins || tier.rtpValueCoins); + } + if (tier.rewardType === "gift") { + return numberValue(tier.rtpValueCoins); + } + return 0; +} + +function numberValue(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function clamp(value, min, max, feasibleMin, feasibleMax) { + const lower = Math.max(min, Number.isFinite(feasibleMin) ? feasibleMin : min); + const upper = Math.min(max, Number.isFinite(feasibleMax) ? feasibleMax : max); + if (lower > upper) { + return Math.max(min, Math.min(max, value < lower ? lower : upper)); + } + return Math.max(lower, Math.min(upper, value)); +} + +function resourceTypeToRewardType(resourceType) { + const value = String(resourceType || "").toLowerCase(); + if (value === "gift" || value.includes("gift")) { + return "gift"; + } + if (value.includes("dress") || value.includes("frame") || value.includes("card") || value.includes("entrance")) { + return "dress"; + } + return "prop"; +} diff --git a/src/features/operations/hooks/useWheelPage.test.js b/src/features/operations/hooks/useWheelPage.test.js new file mode 100644 index 0000000..8102fce --- /dev/null +++ b/src/features/operations/hooks/useWheelPage.test.js @@ -0,0 +1,69 @@ +import { describe, expect, test } from "vitest"; +import { generateWheelTierProbabilities } from "@/features/operations/hooks/useWheelPage.js"; + +function tier(overrides) { + return { + displayName: "奖品", + enabled: true, + metadataJson: "{}", + probabilityPercent: 0, + rewardCoins: 0, + rewardCount: 1, + rewardId: "1", + rewardType: "gift", + rtpValueCoins: 0, + tierId: "classic-1", + ...overrides, + }; +} + +function probabilityPPM(tiers) { + return tiers.reduce((sum, item) => sum + Math.round(item.probabilityPercent * 10_000), 0); +} + +function expectedPayoutCoins(tiers) { + return tiers.reduce((sum, item) => { + if (item.rewardType === "coin") { + return sum + (item.probabilityPercent / 100) * Number(item.rewardCoins || 0); + } + if (item.rewardType === "gift") { + return sum + (item.probabilityPercent / 100) * Number(item.rtpValueCoins || 0); + } + return sum; + }, 0); +} + +describe("wheel prize probability generation", () => { + test("uses the base RTP to generate coin and gift probabilities while keeping dress and prop random", () => { + const result = generateWheelTierProbabilities({ + drawPriceCoins: 10000, + targetRTPPercent: 90, + tiers: [ + tier({ rewardCoins: 1000, rewardId: "", rewardType: "coin", rtpValueCoins: 1000, tierId: "classic-1" }), + tier({ rewardId: "200", rewardType: "gift", rtpValueCoins: 20000, tierId: "classic-2" }), + tier({ rewardId: "300", rewardType: "dress", rtpValueCoins: 0, tierId: "classic-3" }), + tier({ rewardId: "400", rewardType: "prop", rtpValueCoins: 0, tierId: "classic-4" }), + ], + }); + + expect(probabilityPPM(result)).toBe(1_000_000); + expect(expectedPayoutCoins(result)).toBeCloseTo(9000, 0); + expect(result[2].probabilityPercent).toBe(result[3].probabilityPercent); + expect(result[0].probabilityPercent + result[1].probabilityPercent).toBeGreaterThan(95); + }); + + test("falls back to pure random probabilities when a prize set has no RTP value", () => { + const result = generateWheelTierProbabilities({ + drawPriceCoins: 10000, + targetRTPPercent: 90, + tiers: [ + tier({ rewardId: "300", rewardType: "dress", rtpValueCoins: 0, tierId: "classic-1" }), + tier({ rewardId: "400", rewardType: "prop", rtpValueCoins: 0, tierId: "classic-2" }), + ], + }); + + expect(probabilityPPM(result)).toBe(1_000_000); + expect(result[0].probabilityPercent).toBe(50); + expect(result[1].probabilityPercent).toBe(50); + }); +}); diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css index a2d7c1f..7b4b836 100644 --- a/src/features/operations/operations.module.css +++ b/src/features/operations/operations.module.css @@ -235,6 +235,307 @@ gap: var(--space-4); } +.wheelBody { + display: grid; + min-width: 0; + gap: var(--space-5); + padding: var(--space-5); + background: var(--bg-card); +} + +.wheelTopBar { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); + background: var(--bg-card); +} + +.wheelPoolTabs { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: var(--space-3); +} + +.wheelActionBar { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--space-3); +} + +.wheelPoolTab { + display: inline-grid; + min-width: 148px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); + cursor: pointer; + font: inherit; + transition: + border-color 160ms ease, + background-color 160ms ease, + color 160ms ease; +} + +.wheelPoolTab:hover, +.wheelPoolTabActive { + border-color: var(--primary-border-strong); + background: var(--primary-hover); + color: var(--primary); +} + +.wheelPoolTab span { + min-width: 0; + overflow: hidden; + font-weight: 800; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wheelPoolTab strong { + font-size: 12px; + font-weight: 800; +} + +.wheelConfigColumn { + display: grid; + min-width: 0; + align-content: start; + gap: var(--space-5); +} + +.wheelPanel { + display: grid; + min-width: 0; + gap: var(--space-4); +} + +.wheelPanelHeader { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.wheelPanelHeader h2 { + margin: 0; + color: var(--text-primary); + font-size: 16px; + font-weight: 800; + line-height: 1.4; +} + +.wheelPanelHeader span { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 650; +} + +.wheelPanelActions { + display: inline-flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-3); +} + +.wheelCollapseButton { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + gap: var(--space-1); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 800; +} + +.wheelCollapseButton:hover { + border-color: var(--primary-border-strong); + color: var(--primary); +} + +.wheelCollapseIcon { + font-size: 18px; + transition: transform 160ms ease; +} + +.wheelCollapseIconOpen { + transform: rotate(180deg); +} + +.wheelConfigGrid { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)); + gap: var(--space-4); +} + +.wheelPrizeScroller { + width: 100%; + max-width: 100%; + max-height: min(520px, 55vh); + min-width: 0; + overflow: auto; + overscroll-behavior: contain; + padding-bottom: var(--space-2); + scrollbar-gutter: stable both-edges; + -webkit-overflow-scrolling: touch; +} + +.wheelPrizeList { + display: grid; + width: 1500px; + min-width: 1500px; + border-top: 1px solid var(--border); +} + +.wheelGroupPicker { + max-width: 360px; +} + +.wheelPrizeEmpty { + display: flex; + min-height: 96px; + align-items: center; + justify-content: center; + border: 1px dashed var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-tertiary); + font-size: 13px; + font-weight: 700; +} + +.wheelPrizeRow { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + width: 1500px; + min-width: 1500px; + gap: var(--space-4); + align-items: center; + padding: var(--space-3) 0; + border-bottom: 1px solid var(--border); +} + +.wheelPrizeRow[draggable="true"] { + cursor: grab; +} + +.wheelPrizeIndex { + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card); + color: var(--text-secondary); + font-size: 12px; + font-weight: 800; +} + +.wheelPrizeFields { + display: grid; + grid-template-columns: + minmax(130px, 1.1fr) + minmax(120px, 0.8fr) + minmax(120px, 0.8fr) + minmax(100px, 0.7fr) + minmax(100px, 0.7fr) + minmax(100px, 0.7fr) + minmax(100px, 0.7fr) + minmax(130px, 1fr); + gap: var(--space-3); +} + +.wheelSummaryGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.wheelSummaryItem { + display: grid; + min-width: 0; + gap: 4px; + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card-strong); +} + +.wheelSummaryItem span { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 650; +} + +.wheelSummaryItem strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--text-primary); + font-size: 18px; + font-weight: 800; +} + +.wheelStatsDialog :global(.MuiDialog-paper) { + border-radius: var(--radius-card); +} + +.wheelStatsTable { + display: grid; + min-width: 0; + gap: var(--space-3); + margin-top: var(--space-5); +} + +.wheelStatsTable h3 { + margin: 0; + color: var(--text-primary); + font-size: 15px; + font-weight: 800; +} + +.wheelRewardCell { + display: grid; + min-width: 0; + gap: 4px; +} + +.wheelRewardCell strong { + color: var(--text-primary); + font-weight: 800; +} + +.wheelRewardCell span { + min-width: 0; + overflow-wrap: anywhere; + color: var(--text-tertiary); + font-size: 12px; +} + .noticeBody { display: grid; min-height: 0; @@ -329,6 +630,9 @@ grid-template-columns: minmax(0, 1fr); } + .wheelBody, + .wheelConfigGrid, + .wheelSummaryGrid, .noticeBody, .noticeGrid, .noticeContentGrid { diff --git a/src/features/operations/pages/WheelPage.jsx b/src/features/operations/pages/WheelPage.jsx new file mode 100644 index 0000000..33a9a8f --- /dev/null +++ b/src/features/operations/pages/WheelPage.jsx @@ -0,0 +1,369 @@ +import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined"; +import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; +import SaveOutlined from "@mui/icons-material/SaveOutlined"; +import QueryStatsOutlined from "@mui/icons-material/QueryStatsOutlined"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import InputAdornment from "@mui/material/InputAdornment"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import { useMemo, useState } from "react"; +import { WheelPrizeTable } from "@/features/operations/components/WheelPrizeTable.jsx"; +import { useWheelPage } from "@/features/operations/hooks/useWheelPage.js"; +import { useWheelAbilities } from "@/features/operations/permissions.js"; +import styles from "@/features/operations/operations.module.css"; +import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx"; +import { TimeText } from "@/shared/ui/TimeText.jsx"; + +const drawStatusOptions = [ + ["", "全部状态"], + ["pending", "待发放"], + ["granted", "已发放"], + ["failed", "发放失败"], +]; + +const drawColumns = [ + { + key: "drawId", + label: "抽奖 ID", + width: "minmax(200px, 1fr)", + render: (draw) => draw.drawId || "-", + }, + { + key: "reward", + label: "奖品", + width: "minmax(180px, 0.9fr)", + render: (draw) => ( +
+ {rewardTypeLabel(draw.rewardType)} + {rewardValue(draw)} +
+ ), + }, + { + key: "rtp", + label: "RTP价值", + width: "minmax(120px, 0.65fr)", + render: (draw) => formatNumber(draw.rtpValueCoins), + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.65fr)", + render: (draw) => drawStatusLabel(draw.rewardStatus), + }, + { + key: "actualRtp", + label: "窗口 RTP", + width: "minmax(120px, 0.65fr)", + render: (draw) => ppmToPercent(draw.actualRTPPPM), + }, + { + key: "createdAtMs", + label: "时间", + width: "minmax(170px, 0.85fr)", + render: (draw) => , + }, +]; + +export function WheelPage() { + const abilities = useWheelAbilities(); + const state = useWheelPage(); + const draws = state.draws || { items: [], total: 0, page: state.page, pageSize: state.pageSize }; + const summary = useMemo(() => state.summary || {}, [state.summary]); + const disableForm = state.saving || state.configQuery.loading; + const [statsOpen, setStatsOpen] = useState(false); + const [configOpen, setConfigOpen] = useState(false); + + const summaryItems = useMemo( + () => [ + ["总抽数", formatNumber(summary.totalDraws)], + ["参与用户", formatNumber(summary.uniqueUsers)], + ["消耗金币", formatNumber(summary.totalSpentCoins)], + ["RTP奖值", formatNumber(summary.totalRTPValueCoins)], + ["实际 RTP", ppmToPercent(summary.actualRTPPPM)], + ["已发放", formatNumber(summary.grantedDraws)], + ["待发放", formatNumber(summary.pendingDraws)], + ["失败", formatNumber(summary.failedDraws)], + ], + [summary], + ); + + return ( + +
+
+ {state.wheelPools.map((pool) => ( + + ))} +
+
+ + + {abilities.canUpdate ? ( + + ) : null} +
+
+ +
+
+
+
+

基础配置

+
+ state.changeFormField("enabled", event.target.checked)} + /> + } + label={state.form.enabled ? "已启用" : "已关闭"} + /> + +
+
+ {configOpen ? ( +
+ + state.changeFormField("drawPriceCoins", value)} + /> + state.changeFormField("targetRTPPercent", value)} + /> + state.changeFormField("poolRatePercent", value)} + /> + state.changeFormField("settlementWindowDraws", value)} + /> + state.changeFormField("initialPoolCoins", value)} + /> + state.changeFormField("poolReserveCoins", value)} + /> + state.changeFormField("maxSingleRTPPayout", value)} + /> +
+ ) : null} +
+ + state.importResourceGroup(group)} + /> + } + tiers={state.form.tiers} + onChange={state.changeTierField} + onMove={state.moveTier} + /> +
+
+
+ + setStatsOpen(false)} + > + 抽奖统计 + +
+ {summaryItems.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+

中奖记录

+ + + 0 + ? { + page: state.page, + pageSize: draws.pageSize || state.pageSize, + total: draws.total, + onPageChange: state.setPage, + } + : undefined + } + rowKey={(draw) => draw.drawId || `${draw.commandId}-${draw.createdAtMs}`} + /> + + +
+
+ + + +
+
+ ); +} + +function NumberField({ disabled, label, onChange, value }) { + return ( + onChange(event.target.value)} + /> + ); +} + +function PercentField({ disabled, label, onChange, value }) { + return ( + % }} + disabled={disabled} + onChange={(event) => onChange(event.target.value)} + /> + ); +} + +function rewardTypeLabel(value) { + if (value === "coin") { + return "金币"; + } + if (value === "gift") { + return "礼物"; + } + if (value === "dress") { + return "装扮"; + } + if (value === "prop") { + return "道具"; + } + return value || "-"; +} + +function rewardValue(draw) { + if (draw.rewardType === "coin") { + return `${formatNumber(draw.rewardCoins)} 金币`; + } + const count = draw.rewardCount ? ` x${draw.rewardCount}` : ""; + return `${draw.rewardId || "-"}${count}`; +} + +function drawStatusLabel(value) { + return drawStatusOptions.find(([status]) => status === value)?.[1] || value || "-"; +} + +function ppmToPercent(value) { + const ppm = Number(value || 0); + return `${(ppm / 10000).toFixed(2)}%`; +} + +function formatNumber(value) { + const number = Number(value || 0); + return Number.isFinite(number) ? number.toLocaleString("en-US") : "0"; +} diff --git a/src/features/operations/permissions.js b/src/features/operations/permissions.js index ce28298..e41b7a6 100644 --- a/src/features/operations/permissions.js +++ b/src/features/operations/permissions.js @@ -27,3 +27,12 @@ export function useFullServerNoticeAbilities() { canView: can(PERMISSIONS.fullServerNoticeView), }; } + +export function useWheelAbilities() { + const { can } = useAuth(); + + return { + canUpdate: can(PERMISSIONS.wheelUpdate), + canView: can(PERMISSIONS.wheelView), + }; +} diff --git a/src/features/operations/routes.js b/src/features/operations/routes.js index a9b29e4..e9e2cf8 100644 --- a/src/features/operations/routes.js +++ b/src/features/operations/routes.js @@ -25,6 +25,14 @@ export const operationsRoutes = [ path: "/operations/coin-adjustments", permission: PERMISSIONS.coinAdjustmentView, }, + { + label: "转盘抽奖", + loader: () => import("./pages/WheelPage.jsx").then((module) => module.WheelPage), + menuCode: MENU_CODES.wheel, + pageKey: "wheel", + path: "/activities/wheel", + permission: PERMISSIONS.wheelView, + }, { label: "举报列表", loader: () => import("./pages/ReportListPage.jsx").then((module) => module.ReportListPage), diff --git a/src/features/operations/schema.ts b/src/features/operations/schema.ts new file mode 100644 index 0000000..8459d2f --- /dev/null +++ b/src/features/operations/schema.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +const rewardTypes = ["coin", "gift", "prop", "dress"] as const; +const propRewardTypes = new Set(["prop", "dress"]); +const wheelTierCounts: Record = { + advanced: 12, + classic: 9, + luxury: 8, +}; + +const wheelTierSchema = z.object({ + displayName: z.string().trim().min(1, "奖品名称不能为空").max(80, "奖品名称不能超过 80 个字符"), + enabled: z.boolean(), + metadataJson: z.string().trim().default("{}"), + probabilityPercent: z.coerce.number().min(0, "概率不能小于 0").max(100, "概率不能超过 100%"), + rewardCoins: z.coerce.number().int("金币数量必须是整数").min(0, "金币数量不能小于 0"), + rewardCount: z.coerce.number().int("奖励数量必须是整数").min(0, "奖励数量不能小于 0"), + rewardId: z.string().trim().default(""), + rewardType: z.enum(rewardTypes), + rtpValueCoins: z.coerce.number().int("RTP 价值必须是整数").min(0, "RTP 价值不能小于 0"), + tierId: z.string().trim().min(1, "档位 ID 不能为空").max(64, "档位 ID 不能超过 64 个字符"), +}); + +export const wheelConfigFormSchema = z + .object({ + drawPriceCoins: z.coerce.number().int("单抽价格必须是整数").gt(0, "单抽价格必须大于 0"), + effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"), + enabled: z.boolean(), + initialPoolCoins: z.coerce.number().int("初始奖池必须是整数").min(0, "初始奖池不能小于 0"), + maxSingleRTPPayout: z.coerce.number().int("单次 RTP 上限必须是整数").min(0, "单次 RTP 上限不能小于 0"), + poolRatePercent: z.coerce.number().min(0, "入池比例不能小于 0").max(100, "入池比例不能超过 100%"), + poolReserveCoins: z.coerce.number().int("奖池保底必须是整数").min(0, "奖池保底不能小于 0"), + settlementWindowDraws: z.coerce.number().int("结算窗口必须是整数").gt(0, "结算窗口必须大于 0"), + targetRTPPercent: z.coerce.number().min(0, "RTP 不能小于 0").max(100, "RTP 不能超过 100%"), + tiers: z.array(wheelTierSchema).min(1, "必须配置奖品档位"), + wheelId: z.string().trim().min(1, "转盘 ID 不能为空").max(64, "转盘 ID 不能超过 64 个字符"), + }) + .superRefine((value, context) => { + const expectedCount = wheelTierCounts[value.wheelId] || wheelTierCounts.classic; + if (value.tiers.length !== expectedCount) { + context.addIssue({ + code: "custom", + message: `${value.wheelId} 转盘必须配置 ${expectedCount} 个奖品档位`, + path: ["tiers"], + }); + } + + const totalPPM = value.tiers.reduce((sum, tier) => sum + Math.round(tier.probabilityPercent * 10_000), 0); + if (totalPPM !== 1_000_000) { + context.addIssue({ + code: "custom", + message: "奖品档位概率合计必须等于 100%", + path: ["tiers"], + }); + } + + value.tiers.forEach((tier, index) => { + if (tier.enabled && tier.probabilityPercent <= 0) { + context.addIssue({ + code: "custom", + message: "启用奖品的概率必须大于 0", + path: ["tiers", index, "probabilityPercent"], + }); + } + if (tier.rewardType === "coin" && tier.rewardCoins <= 0) { + context.addIssue({ + code: "custom", + message: "金币档 reward_coins 必须大于 0", + path: ["tiers", index, "rewardCoins"], + }); + } + if (tier.rewardType === "gift" && !tier.rewardId) { + context.addIssue({ + code: "custom", + message: "礼物档 reward_id 不能为空", + path: ["tiers", index, "rewardId"], + }); + } + if (propRewardTypes.has(tier.rewardType) && !tier.rewardId) { + context.addIssue({ + code: "custom", + message: "道具或装扮档 reward_id 不能为空", + path: ["tiers", index, "rewardId"], + }); + } + if (propRewardTypes.has(tier.rewardType) && tier.rtpValueCoins !== 0) { + context.addIssue({ + code: "custom", + message: "道具或装扮不计入 RTP,RTP 价值必须为 0", + path: ["tiers", index, "rtpValueCoins"], + }); + } + if (tier.metadataJson) { + try { + JSON.parse(tier.metadataJson); + } catch { + context.addIssue({ + code: "custom", + message: "扩展 JSON 格式不正确", + path: ["tiers", index, "metadataJson"], + }); + } + } + }); + }); + +export type WheelConfigForm = z.infer; diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 1613da7..41a164b 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -126,6 +126,8 @@ export const API_OPERATIONS = { getSevenDayCheckInConfig: "getSevenDayCheckInConfig", getUser: "getUser", getWeeklyStarCycle: "getWeeklyStarCycle", + getWheelConfig: "getWheelConfig", + getWheelDrawSummary: "getWheelDrawSummary", grantPrettyId: "grantPrettyId", grantResource: "grantResource", grantResourceGroup: "grantResourceGroup", @@ -196,6 +198,7 @@ export const API_OPERATIONS = { listWeeklyStarCycles: "listWeeklyStarCycles", listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard", listWeeklyStarSettlements: "listWeeklyStarSettlements", + listWheelDraws: "listWheelDraws", login: "login", logout: "logout", lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget", @@ -281,7 +284,8 @@ export const API_OPERATIONS = { uploadImage: "uploadImage", upsertLuckyGiftConfig: "upsertLuckyGiftConfig", upsertResourceShopItems: "upsertResourceShopItems", - upsertRule: "upsertRule" + upsertRule: "upsertRule", + upsertWheelConfig: "upsertWheelConfig" } as const; export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS]; @@ -1076,6 +1080,20 @@ export const API_ENDPOINTS: Record = { permission: "weekly-star:view", permissions: ["weekly-star:view"] }, + getWheelConfig: { + method: "GET", + operationId: API_OPERATIONS.getWheelConfig, + path: "/v1/admin/activity/wheel/config", + permission: "wheel:view", + permissions: ["wheel:view"] + }, + getWheelDrawSummary: { + method: "GET", + operationId: API_OPERATIONS.getWheelDrawSummary, + path: "/v1/admin/activity/wheel/draw-summary", + permission: "wheel:view", + permissions: ["wheel:view"] + }, grantPrettyId: { method: "POST", operationId: API_OPERATIONS.grantPrettyId, @@ -1564,6 +1582,13 @@ export const API_ENDPOINTS: Record = { permission: "weekly-star:settle", permissions: ["weekly-star:settle"] }, + listWheelDraws: { + method: "GET", + operationId: API_OPERATIONS.listWheelDraws, + path: "/v1/admin/activity/wheel/draws", + permission: "wheel:view", + permissions: ["wheel:view"] + }, login: { method: "POST", operationId: API_OPERATIONS.login, @@ -2153,6 +2178,13 @@ export const API_ENDPOINTS: Record = { path: "/v1/admin/users/level-config/rules/{track}/{level}", permission: "level-config:update", permissions: ["level-config:update"] + }, + upsertWheelConfig: { + method: "PUT", + operationId: API_OPERATIONS.upsertWheelConfig, + path: "/v1/admin/activity/wheel/config", + permission: "wheel:update", + permissions: ["wheel:update"] } }; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 0c4a1fb..7baa4df 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -244,6 +244,54 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/activity/wheel/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getWheelConfig"]; + put: operations["upsertWheelConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activity/wheel/draws": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listWheelDraws"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activity/wheel/draw-summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getWheelDrawSummary"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/first-recharge-reward/claims": { parameters: { query?: never; @@ -3807,6 +3855,54 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + getWheelConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + upsertWheelConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listWheelDraws: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + getWheelDrawSummary: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listFirstRechargeRewardClaims: { parameters: { query?: never;