修复机器人创建和转盘
This commit is contained in:
parent
ee0a14d5b7
commit
ec252cec2c
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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<string, unknown>;
|
||||
type RawWheelTier = WheelTierDto & Record<string, unknown>;
|
||||
type RawWheelDraw = WheelDrawDto & Record<string, unknown>;
|
||||
type RawWheelSummary = WheelDrawSummaryDto & Record<string, unknown>;
|
||||
type WheelSummaryQuery = Record<string, number | string | undefined>;
|
||||
|
||||
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||
const endpoint = API_ENDPOINTS.listCoinLedger;
|
||||
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
|
||||
@ -148,3 +245,144 @@ export function createFullServerNoticeFanout(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getWheelConfig(wheelId = "default"): Promise<WheelConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getWheelConfig;
|
||||
return apiRequest<RawWheelConfig>(apiEndpointPath(API_OPERATIONS.getWheelConfig), {
|
||||
method: endpoint.method,
|
||||
query: { wheel_id: wheelId },
|
||||
}).then(normalizeWheelConfig);
|
||||
}
|
||||
|
||||
export function updateWheelConfig(payload: WheelConfigPayload): Promise<WheelConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.upsertWheelConfig;
|
||||
return apiRequest<RawWheelConfig, WheelConfigPayload>(apiEndpointPath(API_OPERATIONS.upsertWheelConfig), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
query: { wheel_id: payload.wheel_id },
|
||||
}).then(normalizeWheelConfig);
|
||||
}
|
||||
|
||||
export function listWheelDraws(query: PageQuery = {}): Promise<ApiPage<WheelDrawDto>> {
|
||||
const endpoint = API_ENDPOINTS.listWheelDraws;
|
||||
return apiRequest<ApiPage<RawWheelDraw>>(apiEndpointPath(API_OPERATIONS.listWheelDraws), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeWheelDraw),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getWheelDrawSummary(query: WheelSummaryQuery = {}): Promise<WheelDrawSummaryDto> {
|
||||
const endpoint = API_ENDPOINTS.getWheelDrawSummary;
|
||||
return apiRequest<RawWheelSummary>(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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
127
src/features/operations/components/WheelPrizeTable.jsx
Normal file
127
src/features/operations/components/WheelPrizeTable.jsx
Normal file
@ -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 (
|
||||
<section className={styles.wheelPanel}>
|
||||
<div className={styles.wheelPanelHeader}>
|
||||
<h2>奖品档位</h2>
|
||||
<span>{expectedCount} 个档位,选择资源组后可拖动排序</span>
|
||||
</div>
|
||||
{resourceGroupPicker ? <div className={styles.wheelGroupPicker}>{resourceGroupPicker}</div> : null}
|
||||
{tiers.length ? (
|
||||
<div className={styles.wheelPrizeScroller}>
|
||||
<div className={styles.wheelPrizeList}>
|
||||
{tiers.map((tier, index) => (
|
||||
<article
|
||||
className={styles.wheelPrizeRow}
|
||||
draggable={canUpdate && !disabled}
|
||||
key={`${tier.tierId}-${index}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDrop={() => {
|
||||
if (dragIndex !== null) {
|
||||
onMove?.(dragIndex, index);
|
||||
}
|
||||
setDragIndex(null);
|
||||
}}
|
||||
>
|
||||
<span className={styles.wheelPrizeIndex}>#{index + 1}</span>
|
||||
<div className={styles.wheelPrizeFields}>
|
||||
<TextField
|
||||
label="奖品名称"
|
||||
size="small"
|
||||
value={tier.displayName}
|
||||
disabled={!canUpdate || disabled}
|
||||
onChange={(event) => onChange(index, "displayName", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="奖品类型"
|
||||
size="small"
|
||||
value={tier.rewardType}
|
||||
disabled={!canUpdate || disabled}
|
||||
onChange={(event) => onChange(index, "rewardType", event.target.value)}
|
||||
>
|
||||
{rewardTypeOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="资源 ID"
|
||||
size="small"
|
||||
value={tier.rewardId}
|
||||
disabled={!canUpdate || disabled || tier.rewardType === "coin"}
|
||||
onChange={(event) => onChange(index, "rewardId", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="奖励数量"
|
||||
type="number"
|
||||
size="small"
|
||||
value={tier.rewardCount}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
disabled={!canUpdate || disabled}
|
||||
onChange={(event) => onChange(index, "rewardCount", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="金币数量"
|
||||
type="number"
|
||||
size="small"
|
||||
value={tier.rewardCoins}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
disabled={!canUpdate || disabled || tier.rewardType !== "coin"}
|
||||
onChange={(event) => onChange(index, "rewardCoins", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="RTP 价值"
|
||||
type="number"
|
||||
size="small"
|
||||
value={tier.rtpValueCoins}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
disabled={!canUpdate || disabled || tier.rewardType === "prop" || tier.rewardType === "dress"}
|
||||
onChange={(event) => onChange(index, "rtpValueCoins", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="概率"
|
||||
type="number"
|
||||
size="small"
|
||||
value={tier.probabilityPercent}
|
||||
inputProps={{ min: 0, max: 100, readOnly: true, step: "0.0001" }}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||
readOnly: true,
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextField
|
||||
label="扩展 JSON"
|
||||
size="small"
|
||||
value={tier.metadataJson}
|
||||
disabled={!canUpdate || disabled}
|
||||
onChange={(event) => onChange(index, "metadataJson", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.wheelPrizeEmpty}>请选择资源组生成奖品档位</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
537
src/features/operations/hooks/useWheelPage.js
Normal file
537
src/features/operations/hooks/useWheelPage.js
Normal file
@ -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";
|
||||
}
|
||||
69
src/features/operations/hooks/useWheelPage.test.js
Normal file
69
src/features/operations/hooks/useWheelPage.test.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
369
src/features/operations/pages/WheelPage.jsx
Normal file
369
src/features/operations/pages/WheelPage.jsx
Normal file
@ -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) => (
|
||||
<div className={styles.wheelRewardCell}>
|
||||
<strong>{rewardTypeLabel(draw.rewardType)}</strong>
|
||||
<span>{rewardValue(draw)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => <TimeText value={draw.createdAtMs} />,
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<AdminListPage>
|
||||
<div className={styles.wheelTopBar}>
|
||||
<div className={styles.wheelPoolTabs}>
|
||||
{state.wheelPools.map((pool) => (
|
||||
<button
|
||||
className={[styles.wheelPoolTab, state.wheelId === pool.key ? styles.wheelPoolTabActive : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={pool.key}
|
||||
type="button"
|
||||
onClick={() => state.selectPool(pool.key)}
|
||||
>
|
||||
<span>{pool.label}</span>
|
||||
<strong>{pool.tierCount} 档</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.wheelActionBar}>
|
||||
<Button startIcon={<QueryStatsOutlined />} onClick={() => setStatsOpen(true)}>
|
||||
抽奖统计
|
||||
</Button>
|
||||
<Button
|
||||
disabled={state.configQuery.loading || state.drawQuery.loading}
|
||||
startIcon={<RefreshOutlined />}
|
||||
onClick={() => {
|
||||
state.configQuery.reload();
|
||||
state.summaryQuery.reload();
|
||||
state.drawQuery.reload();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{abilities.canUpdate ? (
|
||||
<Button
|
||||
disabled={state.saving || state.configQuery.loading}
|
||||
startIcon={<SaveOutlined />}
|
||||
variant="primary"
|
||||
onClick={state.submit}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DataState error={state.configError} loading={state.configQuery.loading} onRetry={state.configQuery.reload}>
|
||||
<div className={styles.wheelBody}>
|
||||
<section className={styles.wheelConfigColumn}>
|
||||
<section className={styles.wheelPanel}>
|
||||
<div className={styles.wheelPanelHeader}>
|
||||
<h2>基础配置</h2>
|
||||
<div className={styles.wheelPanelActions}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(state.form.enabled)}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(event) => state.changeFormField("enabled", event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={state.form.enabled ? "已启用" : "已关闭"}
|
||||
/>
|
||||
<button
|
||||
className={styles.wheelCollapseButton}
|
||||
type="button"
|
||||
onClick={() => setConfigOpen((value) => !value)}
|
||||
>
|
||||
<ExpandMoreOutlined
|
||||
className={[
|
||||
styles.wheelCollapseIcon,
|
||||
configOpen ? styles.wheelCollapseIconOpen : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
/>
|
||||
{configOpen ? "收起" : "展开"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{configOpen ? (
|
||||
<div className={styles.wheelConfigGrid}>
|
||||
<TextField
|
||||
label="转盘 ID"
|
||||
size="small"
|
||||
value={state.form.wheelId}
|
||||
disabled
|
||||
/>
|
||||
<NumberField
|
||||
label="单抽价格"
|
||||
value={state.form.drawPriceCoins}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(value) => state.changeFormField("drawPriceCoins", value)}
|
||||
/>
|
||||
<PercentField
|
||||
label="目标 RTP"
|
||||
value={state.form.targetRTPPercent}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(value) => state.changeFormField("targetRTPPercent", value)}
|
||||
/>
|
||||
<PercentField
|
||||
label="入池比例"
|
||||
value={state.form.poolRatePercent}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(value) => state.changeFormField("poolRatePercent", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="结算窗口抽数"
|
||||
value={state.form.settlementWindowDraws}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(value) => state.changeFormField("settlementWindowDraws", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="初始奖池"
|
||||
value={state.form.initialPoolCoins}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(value) => state.changeFormField("initialPoolCoins", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="奖池保底"
|
||||
value={state.form.poolReserveCoins}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(value) => state.changeFormField("poolReserveCoins", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="单次 RTP 上限"
|
||||
value={state.form.maxSingleRTPPayout}
|
||||
disabled={!abilities.canUpdate || disableForm}
|
||||
onChange={(value) => state.changeFormField("maxSingleRTPPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<WheelPrizeTable
|
||||
canUpdate={abilities.canUpdate}
|
||||
disabled={disableForm}
|
||||
expectedCount={state.expectedTierCount}
|
||||
resourceGroupPicker={
|
||||
<ResourceGroupSelectField
|
||||
disabled={!abilities.canUpdate || disableForm || state.resourceGroupQuery.loading}
|
||||
drawerTitle="添加奖池"
|
||||
groups={state.resourceGroups}
|
||||
label="添加奖池"
|
||||
placeholder="选择资源组"
|
||||
value=""
|
||||
onChange={(_, group) => state.importResourceGroup(group)}
|
||||
/>
|
||||
}
|
||||
tiers={state.form.tiers}
|
||||
onChange={state.changeTierField}
|
||||
onMove={state.moveTier}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</DataState>
|
||||
|
||||
<Dialog
|
||||
className={styles.wheelStatsDialog}
|
||||
fullWidth
|
||||
maxWidth="lg"
|
||||
open={statsOpen}
|
||||
onClose={() => setStatsOpen(false)}
|
||||
>
|
||||
<DialogTitle>抽奖统计</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className={styles.wheelSummaryGrid}>
|
||||
{summaryItems.map(([label, value]) => (
|
||||
<div className={styles.wheelSummaryItem} key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.wheelStatsTable}>
|
||||
<h3>中奖记录</h3>
|
||||
<DataState error={state.drawError} loading={state.drawQuery.loading} onRetry={state.drawQuery.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={drawColumns}
|
||||
items={draws.items || []}
|
||||
minWidth="920px"
|
||||
pagination={
|
||||
draws.total > 0
|
||||
? {
|
||||
page: state.page,
|
||||
pageSize: draws.pageSize || state.pageSize,
|
||||
total: draws.total,
|
||||
onPageChange: state.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(draw) => draw.drawId || `${draw.commandId}-${draw.createdAtMs}`}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setStatsOpen(false)}>关闭</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ disabled, label, onChange, value }) {
|
||||
return (
|
||||
<TextField
|
||||
label={label}
|
||||
type="number"
|
||||
size="small"
|
||||
value={value}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PercentField({ disabled, label, onChange, value }) {
|
||||
return (
|
||||
<TextField
|
||||
label={label}
|
||||
type="number"
|
||||
size="small"
|
||||
value={value}
|
||||
inputProps={{ min: 0, max: 100, step: "0.01" }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
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";
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
107
src/features/operations/schema.ts
Normal file
107
src/features/operations/schema.ts
Normal file
@ -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<string, number> = {
|
||||
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<typeof wheelConfigFormSchema>;
|
||||
@ -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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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"]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
96
src/shared/api/generated/schema.d.ts
vendored
96
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user