修复机器人创建和转盘

This commit is contained in:
zhx 2026-06-18 17:13:59 +08:00
parent ee0a14d5b7
commit ec252cec2c
15 changed files with 2032 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View 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";
}

View 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);
});
});

View File

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

View 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";
}

View File

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

View File

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

View 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: "道具或装扮不计入 RTPRTP 价值必须为 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>;

View File

@ -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"]
}
};

View File

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