Compare commits
No commits in common. "ec252cec2c8e09e680c0c7023a68b9401cc1a6a1" and "723258a3aa0ce9517736eca6fd0384af915b5a36" have entirely different histories.
ec252cec2c
...
723258a3aa
@ -280,52 +280,6 @@
|
|||||||
"x-permissions": ["lucky-gift:view"]
|
"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": {
|
"/admin/activity/first-recharge-reward/claims": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listFirstRechargeRewardClaims",
|
"operationId": "listFirstRechargeRewardClaims",
|
||||||
@ -3215,28 +3169,6 @@
|
|||||||
"x-permissions": ["room-config:update"]
|
"x-permissions": ["room-config:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/rooms/whitelist": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getRoomWhitelistConfig",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "room-whitelist:view",
|
|
||||||
"x-permissions": ["room-whitelist:view"]
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"operationId": "updateRoomWhitelistConfig",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "room-whitelist:update",
|
|
||||||
"x-permissions": ["room-whitelist:update"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/rooms/pins": {
|
"/admin/rooms/pins": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRoomPins",
|
"operationId": "listRoomPins",
|
||||||
@ -3315,28 +3247,6 @@
|
|||||||
"x-permissions": ["room-robot:view"]
|
"x-permissions": ["room-robot:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/rooms/robot-rooms/human-config": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getHumanRoomRobotConfig",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "room-robot:view",
|
|
||||||
"x-permissions": ["room-robot:view"]
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"operationId": "updateHumanRoomRobotConfig",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-permission": "room-robot:update",
|
|
||||||
"x-permissions": ["room-robot:update"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/rooms/robot-rooms/{room_id}/start": {
|
"/admin/rooms/robot-rooms/{room_id}/start": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "startRobotRoom",
|
"operationId": "startRobotRoom",
|
||||||
|
|||||||
@ -242,12 +242,6 @@ export function DatabiApp() {
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="business-metric-grid robot-gift-metric-grid" aria-label="真人房机器人送礼指标">
|
|
||||||
{model.robotGiftKpis.map((item) => (
|
|
||||||
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="databi-grid databi-grid--top">
|
<section className="databi-grid databi-grid--top">
|
||||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
||||||
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { act, render, screen, within } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { DatabiApp } from "./DatabiApp.jsx";
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js";
|
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js";
|
||||||
@ -74,29 +74,6 @@ test("loads self game statistics from the big screen switch", async () => {
|
|||||||
expect(screen.getByText("创建局")).toBeTruthy();
|
expect(screen.getByText("创建局")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders real-room robot gift cards in a separate row", async () => {
|
|
||||||
fetchStatisticsOverview.mockResolvedValue({
|
|
||||||
real_room_robot_avg_room_gift_coin: 300,
|
|
||||||
real_room_robot_gift_room_count: 2,
|
|
||||||
real_room_robot_lucky_gift_coin: 200,
|
|
||||||
real_room_robot_normal_gift_coin: 100,
|
|
||||||
real_room_robot_super_gift_coin: 300,
|
|
||||||
updated_at_ms: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DatabiApp />);
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
const businessSection = screen.getByLabelText("业务指标");
|
|
||||||
const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标");
|
|
||||||
expect(within(businessSection).queryByText("真人房机器人普通礼物")).toBeNull();
|
|
||||||
expect(within(robotGiftSection).getByText("真人房机器人普通礼物")).toBeTruthy();
|
|
||||||
expect(within(robotGiftSection).getByText("真人房机器人幸运礼物")).toBeTruthy();
|
|
||||||
expect(within(robotGiftSection).getByText("真人房机器人超级幸运礼物")).toBeTruthy();
|
|
||||||
expect(within(robotGiftSection).getByText("真人房机器人房均礼物")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function flushEffects() {
|
async function flushEffects() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
for (let index = 0; index < 10; index += 1) {
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
|||||||
@ -19,12 +19,6 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
||||||
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
||||||
const coinSellerTransferCoin = readNumber(source, "coin_seller_transfer_coin", "coinSellerTransferCoin");
|
const coinSellerTransferCoin = readNumber(source, "coin_seller_transfer_coin", "coinSellerTransferCoin");
|
||||||
const realRoomRobotNormalGiftCoin = readNumber(source, "real_room_robot_normal_gift_coin", "realRoomRobotNormalGiftCoin");
|
|
||||||
const realRoomRobotLuckyGiftCoin = readNumber(source, "real_room_robot_lucky_gift_coin", "realRoomRobotLuckyGiftCoin");
|
|
||||||
const realRoomRobotSuperGiftCoin = readNumber(source, "real_room_robot_super_gift_coin", "realRoomRobotSuperGiftCoin");
|
|
||||||
const realRoomRobotTotalGiftCoin = readNumber(source, "real_room_robot_total_gift_coin", "realRoomRobotTotalGiftCoin") || realRoomRobotNormalGiftCoin + realRoomRobotLuckyGiftCoin + realRoomRobotSuperGiftCoin;
|
|
||||||
const realRoomRobotGiftRoomCount = readNumber(source, "real_room_robot_gift_room_count", "realRoomRobotGiftRoomCount");
|
|
||||||
const realRoomRobotAvgRoomGiftCoin = readNumber(source, "real_room_robot_avg_room_gift_coin", "realRoomRobotAvgRoomGiftCoin") || Math.round(ratio(realRoomRobotTotalGiftCoin, realRoomRobotGiftRoomCount));
|
|
||||||
const luckyGiftPools = normalizeLuckyGiftPools(source);
|
const luckyGiftPools = normalizeLuckyGiftPools(source);
|
||||||
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
|
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
|
||||||
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
|
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
|
||||||
@ -106,12 +100,6 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
luckyGiftPools,
|
luckyGiftPools,
|
||||||
payoutDistribution,
|
payoutDistribution,
|
||||||
revenueSeries,
|
revenueSeries,
|
||||||
robotGiftKpis: [
|
|
||||||
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin") }),
|
|
||||||
coinMetric("真人房机器人幸运礼物", realRoomRobotLuckyGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_lucky_gift_coin", "真人房机器人幸运礼物", "coin") }),
|
|
||||||
coinMetric("真人房机器人超级幸运礼物", realRoomRobotSuperGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_super_gift_coin", "真人房机器人超级幸运礼物", "coin") }),
|
|
||||||
coinMetric("真人房机器人房均礼物", realRoomRobotAvgRoomGiftCoin, "", `${formatNumber(realRoomRobotGiftRoomCount)} 个房间`, { trend: metricTrend(dailySeries, "real_room_robot_avg_room_gift_coin", "真人房机器人房均礼物", "coin") })
|
|
||||||
],
|
|
||||||
roomMetrics: [
|
roomMetrics: [
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate")), label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate")), label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
|
||||||
|
|||||||
@ -67,22 +67,6 @@ test("exposes new user KPI and removes visitor stage from funnel", () => {
|
|||||||
expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
|
expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adds real-room robot gift metrics to a separate card row", () => {
|
|
||||||
const model = createDashboardModel({
|
|
||||||
real_room_robot_gift_room_count: 2,
|
|
||||||
real_room_robot_lucky_gift_coin: 200,
|
|
||||||
real_room_robot_normal_gift_coin: 100,
|
|
||||||
real_room_robot_super_gift_coin: 300
|
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
|
||||||
|
|
||||||
expect(metricValue(model, "真人房机器人普通礼物")).toBe("100");
|
|
||||||
expect(metricValue(model, "真人房机器人幸运礼物")).toBe("200");
|
|
||||||
expect(metricValue(model, "真人房机器人超级幸运礼物")).toBe("300");
|
|
||||||
expect(metricValue(model, "真人房机器人房均礼物")).toBe("300");
|
|
||||||
expect(model.businessKpis.map((item) => item.label)).not.toContain("真人房机器人房均礼物");
|
|
||||||
expect(model.robotGiftKpis.find((item) => item.label === "真人房机器人房均礼物")?.caption).toBe("2 个房间");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses active users as payer and recharge conversion denominator", () => {
|
test("uses active users as payer and recharge conversion denominator", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel({
|
||||||
active_users: 80,
|
active_users: 80,
|
||||||
@ -108,5 +92,5 @@ test("uses active users as payer and recharge conversion denominator", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function metricValue(model, label) {
|
function metricValue(model, label) {
|
||||||
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
|
return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,9 +8,8 @@
|
|||||||
.databi-screen {
|
.databi-screen {
|
||||||
--analysis-row-height: 320px;
|
--analysis-row-height: 320px;
|
||||||
--business-metric-row-height: 104px;
|
--business-metric-row-height: 104px;
|
||||||
--robot-gift-metric-row-height: 104px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 72px 140px var(--business-metric-row-height) var(--robot-gift-metric-row-height) var(--analysis-row-height);
|
grid-template-rows: 72px 140px var(--business-metric-row-height) var(--analysis-row-height);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@ -752,14 +751,6 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.robot-gift-metric-grid {
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.robot-gift-metric-grid .metric-card {
|
|
||||||
height: var(--robot-gift-metric-row-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.business-metric-grid .metric-content {
|
.business-metric-grid .metric-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(max-content, 1fr) max-content;
|
grid-template-columns: minmax(max-content, 1fr) max-content;
|
||||||
@ -768,10 +759,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.robot-gift-metric-grid .metric-content {
|
|
||||||
grid-template-columns: minmax(0, 1fr) max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.business-metric-grid .metric-card.is-loading .metric-content {
|
.business-metric-grid .metric-card.is-loading .metric-content {
|
||||||
grid-template-columns: minmax(0, 1fr) 96px;
|
grid-template-columns: minmax(0, 1fr) 96px;
|
||||||
grid-template-rows: 16px 28px;
|
grid-template-rows: 16px 28px;
|
||||||
@ -812,12 +799,6 @@
|
|||||||
text-overflow: clip;
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.robot-gift-metric-grid .metric-label-row > span {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.business-metric-grid .metric-content strong {
|
.business-metric-grid .metric-content strong {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
|||||||
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||||
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
|
||||||
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||||
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
||||||
import FavoriteBorderOutlined from "@mui/icons-material/FavoriteBorderOutlined";
|
import FavoriteBorderOutlined from "@mui/icons-material/FavoriteBorderOutlined";
|
||||||
@ -48,7 +47,6 @@ const iconMap = {
|
|||||||
apartment: ApartmentOutlined,
|
apartment: ApartmentOutlined,
|
||||||
block: BlockOutlined,
|
block: BlockOutlined,
|
||||||
campaign: CampaignOutlined,
|
campaign: CampaignOutlined,
|
||||||
casino: CasinoOutlined,
|
|
||||||
category: CategoryOutlined,
|
category: CategoryOutlined,
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
event_available: EventAvailableOutlined,
|
event_available: EventAvailableOutlined,
|
||||||
@ -143,7 +141,6 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("room-list", { icon: BedroomParentOutlined }),
|
routeNavItem("room-list", { icon: BedroomParentOutlined }),
|
||||||
routeNavItem("room-pins", { icon: PushPinOutlined }),
|
routeNavItem("room-pins", { icon: PushPinOutlined }),
|
||||||
routeNavItem("room-config", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("room-config", { icon: SettingsApplicationsOutlined }),
|
||||||
routeNavItem("room-whitelist", { icon: ShieldOutlined }),
|
|
||||||
routeNavItem("room-robots", { icon: GroupsOutlined }),
|
routeNavItem("room-robots", { icon: GroupsOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -215,7 +212,6 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
||||||
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
||||||
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
||||||
routeNavItem("wheel", { icon: CasinoOutlined }),
|
|
||||||
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
||||||
routeNavItem("agency-opening", { icon: StorefrontOutlined }),
|
routeNavItem("agency-opening", { icon: StorefrontOutlined }),
|
||||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||||
|
|||||||
@ -99,8 +99,6 @@ export const PERMISSIONS = {
|
|||||||
roomPinCancel: "room-pin:cancel",
|
roomPinCancel: "room-pin:cancel",
|
||||||
roomConfigView: "room-config:view",
|
roomConfigView: "room-config:view",
|
||||||
roomConfigUpdate: "room-config:update",
|
roomConfigUpdate: "room-config:update",
|
||||||
roomWhitelistView: "room-whitelist:view",
|
|
||||||
roomWhitelistUpdate: "room-whitelist:update",
|
|
||||||
roomRobotView: "room-robot:view",
|
roomRobotView: "room-robot:view",
|
||||||
roomRobotCreate: "room-robot:create",
|
roomRobotCreate: "room-robot:create",
|
||||||
roomRobotUpdate: "room-robot:update",
|
roomRobotUpdate: "room-robot:update",
|
||||||
@ -146,8 +144,6 @@ export const PERMISSIONS = {
|
|||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
luckyGiftUpdate: "lucky-gift:update",
|
luckyGiftUpdate: "lucky-gift:update",
|
||||||
wheelView: "wheel:view",
|
|
||||||
wheelUpdate: "wheel:update",
|
|
||||||
roomRocketView: "room-rocket:view",
|
roomRocketView: "room-rocket:view",
|
||||||
roomRocketUpdate: "room-rocket:update",
|
roomRocketUpdate: "room-rocket:update",
|
||||||
roomTurnoverRewardView: "room-turnover-reward:view",
|
roomTurnoverRewardView: "room-turnover-reward:view",
|
||||||
@ -195,7 +191,6 @@ export const MENU_CODES = {
|
|||||||
roomList: "room-list",
|
roomList: "room-list",
|
||||||
roomPins: "room-pins",
|
roomPins: "room-pins",
|
||||||
roomConfig: "room-config",
|
roomConfig: "room-config",
|
||||||
roomWhitelist: "room-whitelist",
|
|
||||||
roomRobots: "room-robots",
|
roomRobots: "room-robots",
|
||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
appConfigH5: "app-config-h5",
|
appConfigH5: "app-config-h5",
|
||||||
@ -219,7 +214,6 @@ export const MENU_CODES = {
|
|||||||
operationGiftDiamond: "operation-gift-diamond",
|
operationGiftDiamond: "operation-gift-diamond",
|
||||||
operationFullServerNotice: "operation-full-server-notice",
|
operationFullServerNotice: "operation-full-server-notice",
|
||||||
luckyGift: "lucky-gift",
|
luckyGift: "lucky-gift",
|
||||||
wheel: "wheel",
|
|
||||||
activities: "activities",
|
activities: "activities",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
|
|||||||
@ -52,10 +52,6 @@ export interface AgencyOpeningApplicationDto {
|
|||||||
walletTransactionId: string;
|
walletTransactionId: string;
|
||||||
failureReason: string;
|
failureReason: string;
|
||||||
appliedAtMs: number;
|
appliedAtMs: number;
|
||||||
approvedAtMs: number;
|
|
||||||
scoreStartMs: number;
|
|
||||||
scoreEndMs: number;
|
|
||||||
reviewedByAdminId: number;
|
|
||||||
grantedAtMs: number;
|
grantedAtMs: number;
|
||||||
updatedAtMs: number;
|
updatedAtMs: number;
|
||||||
}
|
}
|
||||||
@ -98,13 +94,6 @@ export function listAgencyOpeningApplications(query: QueryParams = {}) {
|
|||||||
).then((page) => ({ ...page, items: (page.items || []).map(normalizeApplication) }));
|
).then((page) => ({ ...page, items: (page.items || []).map(normalizeApplication) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function approveAgencyOpeningApplication(applicationId: string) {
|
|
||||||
return apiRequest<Raw, undefined>(
|
|
||||||
`/v1/admin/activity/agency-opening/applications/${encodeURIComponent(applicationId)}/approve`,
|
|
||||||
{ method: "POST" },
|
|
||||||
).then(normalizeApplication);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCycle(item: Raw): AgencyOpeningCycleDto {
|
function normalizeCycle(item: Raw): AgencyOpeningCycleDto {
|
||||||
return {
|
return {
|
||||||
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||||
@ -149,10 +138,6 @@ function normalizeApplication(item: Raw): AgencyOpeningApplicationDto {
|
|||||||
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||||
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||||
appliedAtMs: numberValue(item.appliedAtMs ?? item.applied_at_ms),
|
appliedAtMs: numberValue(item.appliedAtMs ?? item.applied_at_ms),
|
||||||
approvedAtMs: numberValue(item.approvedAtMs ?? item.approved_at_ms),
|
|
||||||
scoreStartMs: numberValue(item.scoreStartMs ?? item.score_start_ms),
|
|
||||||
scoreEndMs: numberValue(item.scoreEndMs ?? item.score_end_ms),
|
|
||||||
reviewedByAdminId: numberValue(item.reviewedByAdminId ?? item.reviewed_by_admin_id),
|
|
||||||
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
|
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
@ -13,7 +12,6 @@ import TextField from "@mui/material/TextField";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
approveAgencyOpeningApplication,
|
|
||||||
createAgencyOpeningCycle,
|
createAgencyOpeningCycle,
|
||||||
listAgencyOpeningApplications,
|
listAgencyOpeningApplications,
|
||||||
listAgencyOpeningCycles,
|
listAgencyOpeningCycles,
|
||||||
@ -184,17 +182,6 @@ export function AgencyOpeningPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveApplication(item) {
|
|
||||||
if (!abilities.canUpdate || item.status !== "applied") return;
|
|
||||||
try {
|
|
||||||
await approveAgencyOpeningApplication(item.applicationId);
|
|
||||||
setToast("已同意申请,计分窗口从当前时间开始");
|
|
||||||
await reloadApplications(selectedCycleId);
|
|
||||||
} catch (err) {
|
|
||||||
setToast(err instanceof Error ? err.message : "同意申请失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateReward(index, field, value) {
|
function updateReward(index, field, value) {
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -401,54 +388,16 @@ export function AgencyOpeningPage() {
|
|||||||
width: "180px",
|
width: "180px",
|
||||||
render: (item) => <TimeText value={item.appliedAtMs} />,
|
render: (item) => <TimeText value={item.appliedAtMs} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "score-window",
|
|
||||||
label: "计分窗口",
|
|
||||||
width: "240px",
|
|
||||||
render: (item) =>
|
|
||||||
item.scoreStartMs ? (
|
|
||||||
<div className="weekly-star-list-stack">
|
|
||||||
<TimeText value={item.scoreStartMs} />
|
|
||||||
<span className="weekly-star-list-meta">
|
|
||||||
至 <TimeText value={item.scoreEndMs} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "granted",
|
key: "granted",
|
||||||
label: "发奖时间",
|
label: "发奖时间",
|
||||||
width: "180px",
|
width: "180px",
|
||||||
render: (item) => (item.grantedAtMs ? <TimeText value={item.grantedAtMs} /> : "-"),
|
render: (item) => (item.grantedAtMs ? <TimeText value={item.grantedAtMs} /> : "-"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
label: "操作",
|
|
||||||
width: "112px",
|
|
||||||
fixed: "right",
|
|
||||||
resizable: false,
|
|
||||||
render: (item) =>
|
|
||||||
item.status === "applied" && abilities.canUpdate ? (
|
|
||||||
<AdminRowActions>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<CheckCircleOutlineOutlined fontSize="small" />}
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => approveApplication(item)}
|
|
||||||
>
|
|
||||||
同意
|
|
||||||
</Button>
|
|
||||||
</AdminRowActions>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
emptyLabel={applicationsLoading ? "加载中..." : "暂无申请记录"}
|
emptyLabel={applicationsLoading ? "加载中..." : "暂无申请记录"}
|
||||||
items={applications}
|
items={applications}
|
||||||
minWidth="1560px"
|
minWidth="1210px"
|
||||||
rowKey={(item) => item.applicationId}
|
rowKey={(item) => item.applicationId}
|
||||||
/>
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
@ -592,12 +541,10 @@ function statusColor(status) {
|
|||||||
function applicationStatusLabel(status) {
|
function applicationStatusLabel(status) {
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
applied: "待审核",
|
applied: "已申请",
|
||||||
approved: "计分中",
|
|
||||||
pending: "待发奖",
|
pending: "待发奖",
|
||||||
granted: "已发奖",
|
granted: "已发奖",
|
||||||
failed: "发奖失败",
|
failed: "发奖失败",
|
||||||
expired: "已过期",
|
|
||||||
}[status] ||
|
}[status] ||
|
||||||
status ||
|
status ||
|
||||||
"-"
|
"-"
|
||||||
|
|||||||
@ -6,7 +6,6 @@ export const APP_BANNER_DISPLAY_SCOPE_OPTIONS = [
|
|||||||
{ label: "首页", value: "home" },
|
{ label: "首页", value: "home" },
|
||||||
{ label: "房间内", value: "room" },
|
{ label: "房间内", value: "room" },
|
||||||
{ label: "充值页", value: "recharge" },
|
{ label: "充值页", value: "recharge" },
|
||||||
{ label: "我的页", value: "me" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AppBannerDisplayScope = (typeof APP_BANNER_DISPLAY_SCOPE_OPTIONS)[number]["value"];
|
export type AppBannerDisplayScope = (typeof APP_BANNER_DISPLAY_SCOPE_OPTIONS)[number]["value"];
|
||||||
|
|||||||
@ -213,7 +213,7 @@ function formFromBanner(item) {
|
|||||||
|
|
||||||
function normalizeDisplayScopes(value) {
|
function normalizeDisplayScopes(value) {
|
||||||
const values = Array.isArray(value) ? value : String(value || "").split(",");
|
const values = Array.isArray(value) ? value : String(value || "").split(",");
|
||||||
const known = ["home", "room", "recharge", "me"];
|
const known = ["home", "room", "recharge"];
|
||||||
const seen = new Set(values.map((item) => String(item || "").trim()).filter(Boolean));
|
const seen = new Set(values.map((item) => String(item || "").trim()).filter(Boolean));
|
||||||
const normalized = known.filter((scope) => seen.has(scope));
|
const normalized = known.filter((scope) => seen.has(scope));
|
||||||
return normalized.length ? normalized : ["home"];
|
return normalized.length ? normalized : ["home"];
|
||||||
|
|||||||
@ -107,11 +107,11 @@ describe("app config form schema", () => {
|
|||||||
test("accepts multiple banner display scopes", () => {
|
test("accepts multiple banner display scopes", () => {
|
||||||
const payload = parseForm(appBannerSchema, {
|
const payload = parseForm(appBannerSchema, {
|
||||||
...validBannerForm,
|
...validBannerForm,
|
||||||
displayScopes: ["home", "room", "recharge", "me"],
|
displayScopes: ["home", "room", "recharge"],
|
||||||
roomSmallImageUrl: "https://media.haiyihy.com/banner/room-small.png",
|
roomSmallImageUrl: "https://media.haiyihy.com/banner/room-small.png",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.displayScopes).toEqual(["home", "room", "recharge", "me"]);
|
expect(payload.displayScopes).toEqual(["home", "room", "recharge"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("requires room small image for room banner", () => {
|
test("requires room small image for room banner", () => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||||
|
|
||||||
const appBannerDisplayScopes = ["home", "room", "recharge", "me"] as const;
|
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
|
||||||
|
|
||||||
const requiredH5LinkURLSchema = z
|
const requiredH5LinkURLSchema = z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@ -5,14 +5,10 @@ import {
|
|||||||
createFullServerNoticeFanout,
|
createFullServerNoticeFanout,
|
||||||
exportCoinSellerLedger,
|
exportCoinSellerLedger,
|
||||||
getGiftDiamondRatios,
|
getGiftDiamondRatios,
|
||||||
getWheelConfig,
|
|
||||||
getWheelDrawSummary,
|
|
||||||
listCoinAdjustments,
|
listCoinAdjustments,
|
||||||
listCoinSellerLedger,
|
listCoinSellerLedger,
|
||||||
listWheelDraws,
|
|
||||||
lookupCoinAdjustmentTarget,
|
lookupCoinAdjustmentTarget,
|
||||||
updateGiftDiamondRatios,
|
updateGiftDiamondRatios,
|
||||||
updateWheelConfig,
|
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -169,82 +165,3 @@ test("coin seller ledger export API sends current filters without pagination", a
|
|||||||
expect(String(exportUrl)).not.toContain("page=");
|
expect(String(exportUrl)).not.toContain("page=");
|
||||||
expect(exportInit?.method).toBe("GET");
|
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,103 +60,6 @@ export interface FullServerNoticeFanoutResponse {
|
|||||||
target_scope: string;
|
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>> {
|
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listCoinLedger;
|
const endpoint = API_ENDPOINTS.listCoinLedger;
|
||||||
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
|
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
|
||||||
@ -245,144 +148,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,537 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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,307 +235,6 @@
|
|||||||
gap: var(--space-4);
|
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 {
|
.noticeBody {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@ -630,9 +329,6 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wheelBody,
|
|
||||||
.wheelConfigGrid,
|
|
||||||
.wheelSummaryGrid,
|
|
||||||
.noticeBody,
|
.noticeBody,
|
||||||
.noticeGrid,
|
.noticeGrid,
|
||||||
.noticeContentGrid {
|
.noticeContentGrid {
|
||||||
|
|||||||
@ -1,369 +0,0 @@
|
|||||||
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,12 +27,3 @@ export function useFullServerNoticeAbilities() {
|
|||||||
canView: can(PERMISSIONS.fullServerNoticeView),
|
canView: can(PERMISSIONS.fullServerNoticeView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWheelAbilities() {
|
|
||||||
const { can } = useAuth();
|
|
||||||
|
|
||||||
return {
|
|
||||||
canUpdate: can(PERMISSIONS.wheelUpdate),
|
|
||||||
canView: can(PERMISSIONS.wheelView),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -25,14 +25,6 @@ export const operationsRoutes = [
|
|||||||
path: "/operations/coin-adjustments",
|
path: "/operations/coin-adjustments",
|
||||||
permission: PERMISSIONS.coinAdjustmentView,
|
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: "举报列表",
|
label: "举报列表",
|
||||||
loader: () => import("./pages/ReportListPage.jsx").then((module) => module.ReportListPage),
|
loader: () => import("./pages/ReportListPage.jsx").then((module) => module.ReportListPage),
|
||||||
|
|||||||
@ -1,107 +0,0 @@
|
|||||||
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>;
|
|
||||||
@ -6,14 +6,10 @@ import type {
|
|||||||
PageQuery,
|
PageQuery,
|
||||||
RoomConfigDto,
|
RoomConfigDto,
|
||||||
RoomConfigPayload,
|
RoomConfigPayload,
|
||||||
RoomWhitelistConfigDto,
|
|
||||||
RoomWhitelistPayload,
|
|
||||||
RoomDto,
|
RoomDto,
|
||||||
RoomPinDto,
|
RoomPinDto,
|
||||||
RoomPinPayload,
|
RoomPinPayload,
|
||||||
AvailableRoomRobotDto,
|
AvailableRoomRobotDto,
|
||||||
HumanRoomRobotConfigDto,
|
|
||||||
HumanRoomRobotConfigPayload,
|
|
||||||
RobotRoomDto,
|
RobotRoomDto,
|
||||||
RobotRoomPayload,
|
RobotRoomPayload,
|
||||||
RoomUpdatePayload,
|
RoomUpdatePayload,
|
||||||
@ -39,7 +35,7 @@ export function listRoomPins(query: PageQuery = {}): Promise<ApiPage<RoomPinDto>
|
|||||||
const endpoint = API_ENDPOINTS.listRoomPins;
|
const endpoint = API_ENDPOINTS.listRoomPins;
|
||||||
return apiRequest<ApiPage<RoomPinDto>>(apiEndpointPath(API_OPERATIONS.listRoomPins), {
|
return apiRequest<ApiPage<RoomPinDto>>(apiEndpointPath(API_OPERATIONS.listRoomPins), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
query
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,14 +43,14 @@ export function createRoomPin(payload: RoomPinPayload): Promise<RoomPinDto> {
|
|||||||
const endpoint = API_ENDPOINTS.createRoomPin;
|
const endpoint = API_ENDPOINTS.createRoomPin;
|
||||||
return apiRequest<RoomPinDto, RoomPinPayload>(apiEndpointPath(API_OPERATIONS.createRoomPin), {
|
return apiRequest<RoomPinDto, RoomPinPayload>(apiEndpointPath(API_OPERATIONS.createRoomPin), {
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method,
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelRoomPin(pinId: EntityId): Promise<RoomPinDto> {
|
export function cancelRoomPin(pinId: EntityId): Promise<RoomPinDto> {
|
||||||
const endpoint = API_ENDPOINTS.cancelRoomPin;
|
const endpoint = API_ENDPOINTS.cancelRoomPin;
|
||||||
return apiRequest<RoomPinDto>(apiEndpointPath(API_OPERATIONS.cancelRoomPin, { pin_id: pinId }), {
|
return apiRequest<RoomPinDto>(apiEndpointPath(API_OPERATIONS.cancelRoomPin, { pin_id: pinId }), {
|
||||||
method: endpoint.method,
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,24 +76,6 @@ export function updateRoomConfig(payload: RoomConfigPayload): Promise<RoomConfig
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoomWhitelistConfig(): Promise<RoomWhitelistConfigDto> {
|
|
||||||
const endpoint = API_ENDPOINTS.getRoomWhitelistConfig;
|
|
||||||
return apiRequest<RoomWhitelistConfigDto>(apiEndpointPath(API_OPERATIONS.getRoomWhitelistConfig), {
|
|
||||||
method: endpoint.method,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateRoomWhitelistConfig(payload: RoomWhitelistPayload): Promise<RoomWhitelistConfigDto> {
|
|
||||||
const endpoint = API_ENDPOINTS.updateRoomWhitelistConfig;
|
|
||||||
return apiRequest<RoomWhitelistConfigDto, RoomWhitelistPayload>(
|
|
||||||
apiEndpointPath(API_OPERATIONS.updateRoomWhitelistConfig),
|
|
||||||
{
|
|
||||||
body: payload,
|
|
||||||
method: endpoint.method,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listRobotRooms(query: PageQuery = {}): Promise<ApiPage<RobotRoomDto>> {
|
export function listRobotRooms(query: PageQuery = {}): Promise<ApiPage<RobotRoomDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listRobotRooms;
|
const endpoint = API_ENDPOINTS.listRobotRooms;
|
||||||
return apiRequest<ApiPage<RobotRoomDto>>(apiEndpointPath(API_OPERATIONS.listRobotRooms), {
|
return apiRequest<ApiPage<RobotRoomDto>>(apiEndpointPath(API_OPERATIONS.listRobotRooms), {
|
||||||
@ -106,24 +84,6 @@ export function listRobotRooms(query: PageQuery = {}): Promise<ApiPage<RobotRoom
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHumanRoomRobotConfig(): Promise<HumanRoomRobotConfigDto> {
|
|
||||||
const endpoint = API_ENDPOINTS.getHumanRoomRobotConfig;
|
|
||||||
return apiRequest<HumanRoomRobotConfigDto>(apiEndpointPath(API_OPERATIONS.getHumanRoomRobotConfig), {
|
|
||||||
method: endpoint.method,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateHumanRoomRobotConfig(payload: HumanRoomRobotConfigPayload): Promise<HumanRoomRobotConfigDto> {
|
|
||||||
const endpoint = API_ENDPOINTS.updateHumanRoomRobotConfig;
|
|
||||||
return apiRequest<HumanRoomRobotConfigDto, HumanRoomRobotConfigPayload>(
|
|
||||||
apiEndpointPath(API_OPERATIONS.updateHumanRoomRobotConfig),
|
|
||||||
{
|
|
||||||
body: payload,
|
|
||||||
method: endpoint.method,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listAvailableRoomRobots(): Promise<AvailableRoomRobotDto[]> {
|
export function listAvailableRoomRobots(): Promise<AvailableRoomRobotDto[]> {
|
||||||
const endpoint = API_ENDPOINTS.listAvailableRoomRobots;
|
const endpoint = API_ENDPOINTS.listAvailableRoomRobots;
|
||||||
return apiRequest<AvailableRoomRobotDto[]>(apiEndpointPath(API_OPERATIONS.listAvailableRoomRobots), {
|
return apiRequest<AvailableRoomRobotDto[]>(apiEndpointPath(API_OPERATIONS.listAvailableRoomRobots), {
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
|
||||||
import { listGifts } from "@/features/resources/api";
|
import { listGifts } from "@/features/resources/api";
|
||||||
import {
|
import {
|
||||||
createRobotRoom,
|
createRobotRoom,
|
||||||
getHumanRoomRobotConfig,
|
|
||||||
listAvailableRoomRobots,
|
listAvailableRoomRobots,
|
||||||
listRobotRooms,
|
listRobotRooms,
|
||||||
startRobotRoom,
|
startRobotRoom,
|
||||||
stopRobotRoom,
|
stopRobotRoom,
|
||||||
updateHumanRoomRobotConfig,
|
|
||||||
} from "@/features/rooms/api";
|
} from "@/features/rooms/api";
|
||||||
import { useRoomAbilities } from "@/features/rooms/permissions.js";
|
import { useRoomAbilities } from "@/features/rooms/permissions.js";
|
||||||
import { humanRoomRobotConfigSchema, robotRoomCreateSchema } from "@/features/rooms/schema";
|
import { robotRoomCreateSchema } from "@/features/rooms/schema";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
@ -29,55 +25,22 @@ function emptyForm() {
|
|||||||
luckyGiftIds: [],
|
luckyGiftIds: [],
|
||||||
luckyPauseMaxMs: "20000",
|
luckyPauseMaxMs: "20000",
|
||||||
luckyPauseMinMs: "5000",
|
luckyPauseMinMs: "5000",
|
||||||
maxGiftSenders: "1",
|
|
||||||
maxRobotCount: "8",
|
maxRobotCount: "8",
|
||||||
minRobotCount: "6",
|
minRobotCount: "6",
|
||||||
normalGiftIntervalMs: "10000",
|
normalGiftIntervalMs: "10000",
|
||||||
ownerRobotUserId: "",
|
ownerRobotUserId: "",
|
||||||
robotReplaceMaxMs: "60000",
|
|
||||||
robotReplaceMinMs: "0",
|
|
||||||
robotStayMaxMs: "600000",
|
|
||||||
robotStayMinMs: "180000",
|
|
||||||
seatCount: "10",
|
|
||||||
visibleRegionId: 0,
|
visibleRegionId: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyHumanConfigForm() {
|
|
||||||
return {
|
|
||||||
candidateRoomMaxOnline: "5",
|
|
||||||
enabled: false,
|
|
||||||
giftIds: [],
|
|
||||||
luckyComboMax: "3",
|
|
||||||
luckyComboMin: "1",
|
|
||||||
luckyGiftIds: [],
|
|
||||||
luckyPauseMaxMs: "20000",
|
|
||||||
luckyPauseMinMs: "5000",
|
|
||||||
maxGiftSenders: "1",
|
|
||||||
normalGiftIntervalMaxMs: "20000",
|
|
||||||
normalGiftIntervalMinMs: "1000",
|
|
||||||
robotReplaceMaxMs: "180000",
|
|
||||||
robotReplaceMinMs: "60000",
|
|
||||||
robotStayMaxMs: "600000",
|
|
||||||
robotStayMinMs: "60000",
|
|
||||||
roomTargetMaxOnline: "10",
|
|
||||||
roomTargetMinOnline: "8",
|
|
||||||
superLuckyGiftIds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRobotRoomsPage() {
|
export function useRobotRoomsPage() {
|
||||||
const abilities = useRoomAbilities();
|
const abilities = useRoomAbilities();
|
||||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
|
||||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [availableRobotsState, setAvailableRobotsState] = useState({ error: "", loading: false, options: [] });
|
const [availableRobotsState, setAvailableRobotsState] = useState({ error: "", loading: false, options: [] });
|
||||||
const [candidatesTouched, setCandidatesTouched] = useState(false);
|
const [candidatesTouched, setCandidatesTouched] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [giftOptionsState, setGiftOptionsState] = useState({ error: "", loading: false, options: [] });
|
const [giftOptionsState, setGiftOptionsState] = useState({ error: "", loading: false, options: [] });
|
||||||
const [humanConfigState, setHumanConfigState] = useState({ config: null, error: "", loading: false });
|
|
||||||
const [humanConfigForm, setHumanConfigForm] = useState(emptyHumanConfigForm);
|
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState("active");
|
const [status, setStatus] = useState("active");
|
||||||
@ -116,29 +79,12 @@ export function useRobotRoomsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadHumanConfig = async () => {
|
|
||||||
setHumanConfigState((current) => ({ ...current, error: "", loading: true }));
|
|
||||||
try {
|
|
||||||
const config = await getHumanRoomRobotConfig();
|
|
||||||
setHumanConfigState({ config, error: "", loading: false });
|
|
||||||
setHumanConfigForm(humanConfigToForm(config));
|
|
||||||
} catch (err) {
|
|
||||||
setHumanConfigState({ config: null, error: err.message || "加载真人房间机器人配置失败", loading: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHumanConfig();
|
if (activeAction !== "create") {
|
||||||
}, []);
|
return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeAction === "create") {
|
|
||||||
loadAvailableRobots();
|
|
||||||
loadGiftOptions();
|
|
||||||
}
|
|
||||||
if (activeAction === "human-config") {
|
|
||||||
loadGiftOptions();
|
|
||||||
}
|
}
|
||||||
|
loadAvailableRobots();
|
||||||
|
loadGiftOptions();
|
||||||
}, [activeAction]);
|
}, [activeAction]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -169,11 +115,6 @@ export function useRobotRoomsPage() {
|
|||||||
setGiftOptionsState({ error: "", loading: false, options: [] });
|
setGiftOptionsState({ error: "", loading: false, options: [] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openHumanConfig = () => {
|
|
||||||
setActiveAction("human-config");
|
|
||||||
setHumanConfigForm(humanConfigToForm(humanConfigState.config));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeAction = () => {
|
const closeAction = () => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
};
|
};
|
||||||
@ -204,45 +145,18 @@ export function useRobotRoomsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectOwnerRobot = (robot) => {
|
const selectOwnerRobot = (robot) => {
|
||||||
const ownerRobotUserId = robot?.userId || "";
|
setForm((current) => ({ ...current, ownerRobotUserId: robot?.userId || "" }));
|
||||||
setForm((current) => ({
|
|
||||||
...current,
|
|
||||||
ownerRobotUserId,
|
|
||||||
candidateRobotUserIds: (current.candidateRobotUserIds || []).filter(
|
|
||||||
(userId) => String(userId) !== String(ownerRobotUserId),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectCandidateRobots = (robots) => {
|
const selectCandidateRobots = (robots) => {
|
||||||
setCandidatesTouched(true);
|
setCandidatesTouched(true);
|
||||||
setForm((current) => ({
|
setForm((current) => ({ ...current, candidateRobotUserIds: robots.map((robot) => robot.userId).filter(Boolean) }));
|
||||||
...current,
|
|
||||||
candidateRobotUserIds: robots
|
|
||||||
.map((robot) => robot.userId)
|
|
||||||
.filter((userId) => userId && String(userId) !== String(current.ownerRobotUserId)),
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectGiftIds = (key, gifts) => {
|
const selectGiftIds = (key, gifts) => {
|
||||||
setForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
|
setForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectHumanGiftIds = (key, gifts) => {
|
|
||||||
setHumanConfigForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitHumanConfig = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
await runAction("human-config", "真人房间机器人配置已保存", async () => {
|
|
||||||
const payload = parseForm(humanRoomRobotConfigSchema, humanConfigForm);
|
|
||||||
const config = await updateHumanRoomRobotConfig(payload);
|
|
||||||
setHumanConfigState({ config, error: "", loading: false });
|
|
||||||
setHumanConfigForm(humanConfigToForm(config));
|
|
||||||
closeAction();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAction = async (action, successMessage, submitter) => {
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
setLoadingAction(action);
|
setLoadingAction(action);
|
||||||
try {
|
try {
|
||||||
@ -263,18 +177,6 @@ export function useRobotRoomsPage() {
|
|||||||
const selected = new Set((form.candidateRobotUserIds || []).map(String));
|
const selected = new Set((form.candidateRobotUserIds || []).map(String));
|
||||||
return availableRobotsState.options.filter((item) => selected.has(String(item.userId)));
|
return availableRobotsState.options.filter((item) => selected.has(String(item.userId)));
|
||||||
}, [availableRobotsState.options, form.candidateRobotUserIds]);
|
}, [availableRobotsState.options, form.candidateRobotUserIds]);
|
||||||
const candidateRobotOptions = useMemo(
|
|
||||||
() => availableRobotsState.options.filter((item) => String(item.userId) !== String(form.ownerRobotUserId)),
|
|
||||||
[availableRobotsState.options, form.ownerRobotUserId],
|
|
||||||
);
|
|
||||||
const countryLabels = useMemo(
|
|
||||||
() => Object.fromEntries(countryOptions.map((option) => [option.value, option.label])),
|
|
||||||
[countryOptions],
|
|
||||||
);
|
|
||||||
const regionLabels = useMemo(
|
|
||||||
() => Object.fromEntries(regionOptions.map((option) => [option.value, option.label])),
|
|
||||||
[regionOptions],
|
|
||||||
);
|
|
||||||
const selectedGiftOptions = useMemo(() => {
|
const selectedGiftOptions = useMemo(() => {
|
||||||
const selected = new Set((form.giftIds || []).map(String));
|
const selected = new Set((form.giftIds || []).map(String));
|
||||||
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
|
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
|
||||||
@ -283,25 +185,6 @@ export function useRobotRoomsPage() {
|
|||||||
const selected = new Set((form.luckyGiftIds || []).map(String));
|
const selected = new Set((form.luckyGiftIds || []).map(String));
|
||||||
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
|
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
|
||||||
}, [form.luckyGiftIds, giftOptionsState.options]);
|
}, [form.luckyGiftIds, giftOptionsState.options]);
|
||||||
const selectedHumanGiftOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
giftIds: giftOptionsState.options.filter((item) =>
|
|
||||||
new Set((humanConfigForm.giftIds || []).map(String)).has(giftId(item)),
|
|
||||||
),
|
|
||||||
luckyGiftIds: giftOptionsState.options.filter((item) =>
|
|
||||||
new Set((humanConfigForm.luckyGiftIds || []).map(String)).has(giftId(item)),
|
|
||||||
),
|
|
||||||
superLuckyGiftIds: giftOptionsState.options.filter((item) =>
|
|
||||||
new Set((humanConfigForm.superLuckyGiftIds || []).map(String)).has(giftId(item)),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
giftOptionsState.options,
|
|
||||||
humanConfigForm.giftIds,
|
|
||||||
humanConfigForm.luckyGiftIds,
|
|
||||||
humanConfigForm.superLuckyGiftIds,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
@ -311,80 +194,30 @@ export function useRobotRoomsPage() {
|
|||||||
availableRobots: availableRobotsState.options,
|
availableRobots: availableRobotsState.options,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
closeAction,
|
closeAction,
|
||||||
candidateRobotOptions,
|
|
||||||
countryOptions,
|
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
form,
|
form,
|
||||||
giftOptions: giftOptionsState.options,
|
giftOptions: giftOptionsState.options,
|
||||||
giftOptionsError: giftOptionsState.error,
|
giftOptionsError: giftOptionsState.error,
|
||||||
giftOptionsLoading: giftOptionsState.loading,
|
giftOptionsLoading: giftOptionsState.loading,
|
||||||
humanConfig: humanConfigState.config,
|
|
||||||
humanConfigError: humanConfigState.error,
|
|
||||||
humanConfigForm,
|
|
||||||
humanConfigLoading: humanConfigState.loading,
|
|
||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
loadingRobotLocationOptions: loadingCountries || loadingRegions,
|
|
||||||
openCreate,
|
openCreate,
|
||||||
openHumanConfig,
|
|
||||||
page,
|
page,
|
||||||
reload,
|
reload,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
selectCandidateRobots,
|
selectCandidateRobots,
|
||||||
selectGiftIds,
|
selectGiftIds,
|
||||||
selectHumanGiftIds,
|
|
||||||
selectOwnerRobot,
|
selectOwnerRobot,
|
||||||
selectedCandidateRobots,
|
selectedCandidateRobots,
|
||||||
selectedGiftOptions,
|
selectedGiftOptions,
|
||||||
selectedLuckyGiftOptions,
|
selectedLuckyGiftOptions,
|
||||||
selectedHumanGiftOptions,
|
|
||||||
selectedOwnerRobot,
|
selectedOwnerRobot,
|
||||||
robotCountryLabels: countryLabels,
|
|
||||||
robotRegionLabels: regionLabels,
|
|
||||||
setForm,
|
setForm,
|
||||||
setHumanConfigForm,
|
|
||||||
setPage,
|
setPage,
|
||||||
setRoomStatus,
|
setRoomStatus,
|
||||||
status,
|
status,
|
||||||
submitCreate,
|
submitCreate,
|
||||||
submitHumanConfig,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanConfigToForm(config = null) {
|
|
||||||
const defaults = emptyHumanConfigForm();
|
|
||||||
if (!config) {
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...defaults,
|
|
||||||
candidateRoomMaxOnline: String(config.candidateRoomMaxOnline ?? defaults.candidateRoomMaxOnline),
|
|
||||||
enabled: Boolean(config.enabled),
|
|
||||||
giftIds: (config.giftIds || []).map(String),
|
|
||||||
luckyComboMax: String(config.luckyComboMax ?? defaults.luckyComboMax),
|
|
||||||
luckyComboMin: String(config.luckyComboMin ?? defaults.luckyComboMin),
|
|
||||||
luckyGiftIds: (config.luckyGiftIds || []).map(String),
|
|
||||||
luckyPauseMaxMs: String(config.luckyPauseMaxMs ?? defaults.luckyPauseMaxMs),
|
|
||||||
luckyPauseMinMs: String(config.luckyPauseMinMs ?? defaults.luckyPauseMinMs),
|
|
||||||
maxGiftSenders: String(config.maxGiftSenders ?? defaults.maxGiftSenders),
|
|
||||||
normalGiftIntervalMaxMs: String(
|
|
||||||
config.normalGiftIntervalMaxMs ?? config.normalGiftIntervalMs ?? defaults.normalGiftIntervalMaxMs,
|
|
||||||
),
|
|
||||||
normalGiftIntervalMinMs: String(
|
|
||||||
config.normalGiftIntervalMinMs ?? config.normalGiftIntervalMs ?? defaults.normalGiftIntervalMinMs,
|
|
||||||
),
|
|
||||||
robotReplaceMaxMs: String(config.robotReplaceMaxMs ?? defaults.robotReplaceMaxMs),
|
|
||||||
robotReplaceMinMs: String(config.robotReplaceMinMs ?? defaults.robotReplaceMinMs),
|
|
||||||
robotStayMaxMs: String(config.robotStayMaxMs ?? defaults.robotStayMaxMs),
|
|
||||||
robotStayMinMs: String(config.robotStayMinMs ?? defaults.robotStayMinMs),
|
|
||||||
roomTargetMaxOnline: String(
|
|
||||||
config.roomTargetMaxOnline ?? config.roomFullStopOnline ?? defaults.roomTargetMaxOnline,
|
|
||||||
),
|
|
||||||
roomTargetMinOnline: String(
|
|
||||||
config.roomTargetMinOnline ?? config.roomFullStopOnline ?? defaults.roomTargetMinOnline,
|
|
||||||
),
|
|
||||||
superLuckyGiftIds: (config.superLuckyGiftIds || []).map(String),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,18 +229,6 @@ export function robotOptionLabel(robot) {
|
|||||||
return [user.username, user.displayUserId, robot.userId].filter(Boolean).join(" / ");
|
return [user.username, user.displayUserId, robot.userId].filter(Boolean).join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function robotLocationLabel(robot, countryLabels = {}, regionLabels = {}) {
|
|
||||||
const user = robot?.user || {};
|
|
||||||
const countryCode = String(user.countryCode || "")
|
|
||||||
.trim()
|
|
||||||
.toUpperCase();
|
|
||||||
const regionId = user.visibleRegionId || "";
|
|
||||||
const countryLabel = countryCode ? countryLabels[countryCode] || countryCode : "国家 -";
|
|
||||||
const regionKey = regionId ? String(regionId) : "";
|
|
||||||
const regionLabel = regionKey ? regionLabels[regionKey] || `区域 ${regionKey}` : "区域 -";
|
|
||||||
return `${countryLabel} / ${regionLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function giftId(gift) {
|
export function giftId(gift) {
|
||||||
return String(gift?.giftId || "");
|
return String(gift?.giftId || "");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { robotLocationLabel } from "@/features/rooms/hooks/useRobotRoomsPage.js";
|
|
||||||
|
|
||||||
describe("robotLocationLabel", () => {
|
|
||||||
it("shows robot country and region labels", () => {
|
|
||||||
const label = robotLocationLabel(
|
|
||||||
{
|
|
||||||
user: {
|
|
||||||
countryCode: "ae",
|
|
||||||
visibleRegionId: 686,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ AE: "🇦🇪 · 阿联酋 · AE" },
|
|
||||||
{ 686: "中东 · ME · 686" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(label).toBe("🇦🇪 · 阿联酋 · AE / 中东 · ME · 686");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to raw country and region ids", () => {
|
|
||||||
const label = robotLocationLabel({ user: { countryCode: "sa", visibleRegionId: 7 } }, {}, {});
|
|
||||||
|
|
||||||
expect(label).toBe("SA / 区域 7");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { getRoomWhitelistConfig, updateRoomWhitelistConfig } from "@/features/rooms/api";
|
|
||||||
import { useRoomAbilities } from "@/features/rooms/permissions.js";
|
|
||||||
import { roomWhitelistSchema } from "@/features/rooms/schema";
|
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|
||||||
|
|
||||||
const emptyData = {
|
|
||||||
updatedAtMs: 0,
|
|
||||||
userIds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useRoomWhitelistPage() {
|
|
||||||
const abilities = useRoomAbilities();
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
|
||||||
const queryFn = useCallback(() => getRoomWhitelistConfig(), []);
|
|
||||||
const {
|
|
||||||
data = emptyData,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
reload,
|
|
||||||
} = useAdminQuery(queryFn, {
|
|
||||||
errorMessage: "加载房间白名单失败",
|
|
||||||
initialData: emptyData,
|
|
||||||
queryKey: ["rooms", "whitelist"],
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setText(normalizeUserIds(data?.userIds).join("\n"));
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const userIds = useMemo(() => normalizeUserIds(splitUserIds(text)), [text]);
|
|
||||||
|
|
||||||
const submitWhitelist = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setLoadingAction("save");
|
|
||||||
try {
|
|
||||||
const payload = parseForm(roomWhitelistSchema, { userIds });
|
|
||||||
const saved = await updateRoomWhitelistConfig(payload);
|
|
||||||
setText(normalizeUserIds(saved.userIds).join("\n"));
|
|
||||||
await reload();
|
|
||||||
showToast("房间白名单已保存", "success");
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "保存失败", "error");
|
|
||||||
} finally {
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
abilities,
|
|
||||||
data,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
loadingAction,
|
|
||||||
reload,
|
|
||||||
setText,
|
|
||||||
submitWhitelist,
|
|
||||||
text,
|
|
||||||
userIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitUserIds(value) {
|
|
||||||
return String(value || "")
|
|
||||||
.split(/[\s,,;;]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUserIds(values) {
|
|
||||||
return Array.isArray(values) ? [...new Set(values.map((value) => String(value).trim()).filter(Boolean))] : [];
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
||||||
import TuneOutlined from "@mui/icons-material/TuneOutlined";
|
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
@ -16,7 +15,6 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import {
|
import {
|
||||||
giftId,
|
giftId,
|
||||||
giftOptionLabel,
|
giftOptionLabel,
|
||||||
robotLocationLabel,
|
|
||||||
robotOptionLabel,
|
robotOptionLabel,
|
||||||
useRobotRoomsPage,
|
useRobotRoomsPage,
|
||||||
} from "@/features/rooms/hooks/useRobotRoomsPage.js";
|
} from "@/features/rooms/hooks/useRobotRoomsPage.js";
|
||||||
@ -43,16 +41,9 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "robots",
|
key: "robots",
|
||||||
label: "机器人",
|
label: "进房机器人",
|
||||||
width: "minmax(150px, 0.6fr)",
|
width: "minmax(120px, 0.5fr)",
|
||||||
render: (room) => (
|
render: (room) => Number(room.robotUserIds?.length || room.robots?.length || 0).toLocaleString("zh-CN"),
|
||||||
<RuleStack
|
|
||||||
rows={[
|
|
||||||
`活跃 ${Number(room.activeRobotCount || 0).toLocaleString("zh-CN")}`,
|
|
||||||
`候选 ${Number(room.robotUserIds?.length || room.robots?.length || 0).toLocaleString("zh-CN")}`,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "normalRule",
|
key: "normalRule",
|
||||||
@ -81,20 +72,6 @@ const columns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "behaviorRule",
|
|
||||||
label: "行为",
|
|
||||||
width: "minmax(220px, 0.9fr)",
|
|
||||||
render: (room) => (
|
|
||||||
<RuleStack
|
|
||||||
rows={[
|
|
||||||
`${formatDurationRange(room.giftRule?.robotStayMinMs, room.giftRule?.robotStayMaxMs)} 停留`,
|
|
||||||
`${formatDurationRange(room.giftRule?.robotReplaceMinMs, room.giftRule?.robotReplaceMaxMs)} 补位`,
|
|
||||||
`最多 ${Number(room.giftRule?.maxGiftSenders || 0).toLocaleString("zh-CN")} 个送礼`,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "createdAt",
|
key: "createdAt",
|
||||||
label: "创建时间",
|
label: "创建时间",
|
||||||
@ -138,24 +115,12 @@ export function RoomRobotPage() {
|
|||||||
</section>
|
</section>
|
||||||
{page.abilities.canRobotCreate ? (
|
{page.abilities.canRobotCreate ? (
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarActions}>
|
||||||
<Button
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||||
startIcon={<TuneOutlined fontSize="small" />}
|
|
||||||
variant="secondary"
|
|
||||||
onClick={page.openHumanConfig}
|
|
||||||
>
|
|
||||||
真人房间机器人配置
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<AddOutlined fontSize="small" />}
|
|
||||||
variant="primary"
|
|
||||||
onClick={page.openCreate}
|
|
||||||
>
|
|
||||||
增加机器人房间
|
增加机器人房间
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<HumanConfigPanel page={page} />
|
|
||||||
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
@ -205,12 +170,7 @@ export function RoomRobotPage() {
|
|||||||
)}
|
)}
|
||||||
renderOption={(props, option) => (
|
renderOption={(props, option) => (
|
||||||
<li {...props} key={option.userId}>
|
<li {...props} key={option.userId}>
|
||||||
<OwnerRobotOption
|
<RobotIdentity user={option.user} fallbackId={option.userId} />
|
||||||
countryLabels={page.robotCountryLabels}
|
|
||||||
loadingLocation={page.loadingRobotLocationOptions}
|
|
||||||
option={option}
|
|
||||||
regionLabels={page.robotRegionLabels}
|
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
@ -224,18 +184,19 @@ export function RoomRobotPage() {
|
|||||||
isOptionEqualToValue={(option, value) => option.userId === value.userId}
|
isOptionEqualToValue={(option, value) => option.userId === value.userId}
|
||||||
loading={page.availableRobotsLoading}
|
loading={page.availableRobotsLoading}
|
||||||
noOptionsText="当前无数据"
|
noOptionsText="当前无数据"
|
||||||
options={page.candidateRobotOptions}
|
options={page.availableRobots}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
error={Boolean(page.availableRobotsError)}
|
error={Boolean(page.availableRobotsError)}
|
||||||
helperText={page.availableRobotsError}
|
helperText={page.availableRobotsError}
|
||||||
label={<RequiredLabel>候选机器人</RequiredLabel>}
|
label="候选机器人"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderOption={(props, option) => (
|
renderOption={(props, option) => (
|
||||||
<li {...props} key={option.userId}>
|
<li {...props} key={option.userId}>
|
||||||
<RobotIdentity openInAppUserDetail={false} user={option.user} fallbackId={option.userId} />
|
<RobotIdentity user={option.user} fallbackId={option.userId} />
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
@ -271,7 +232,8 @@ export function RoomRobotPage() {
|
|||||||
{...params}
|
{...params}
|
||||||
error={Boolean(page.giftOptionsError)}
|
error={Boolean(page.giftOptionsError)}
|
||||||
helperText={page.giftOptionsError}
|
helperText={page.giftOptionsError}
|
||||||
label={<RequiredLabel>礼物合集</RequiredLabel>}
|
label="礼物合集"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
@ -285,43 +247,6 @@ export function RoomRobotPage() {
|
|||||||
value={page.form.normalGiftIntervalMs}
|
value={page.form.normalGiftIntervalMs}
|
||||||
onChange={(event) => page.setForm({ ...page.form, normalGiftIntervalMs: event.target.value })}
|
onChange={(event) => page.setForm({ ...page.form, normalGiftIntervalMs: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className={styles.formGrid}>
|
|
||||||
<TextField
|
|
||||||
label="最短停留(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.form.robotStayMinMs}
|
|
||||||
onChange={(event) => page.setForm({ ...page.form, robotStayMinMs: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最长停留(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.form.robotStayMaxMs}
|
|
||||||
onChange={(event) => page.setForm({ ...page.form, robotStayMaxMs: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最短补位(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.form.robotReplaceMinMs}
|
|
||||||
onChange={(event) => page.setForm({ ...page.form, robotReplaceMinMs: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最长补位(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.form.robotReplaceMaxMs}
|
|
||||||
onChange={(event) => page.setForm({ ...page.form, robotReplaceMaxMs: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="同时送礼上限"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.form.maxGiftSenders}
|
|
||||||
onChange={(event) => page.setForm({ ...page.form, maxGiftSenders: event.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
multiple
|
multiple
|
||||||
disableCloseOnSelect
|
disableCloseOnSelect
|
||||||
@ -335,7 +260,8 @@ export function RoomRobotPage() {
|
|||||||
{...params}
|
{...params}
|
||||||
error={Boolean(page.giftOptionsError)}
|
error={Boolean(page.giftOptionsError)}
|
||||||
helperText={page.giftOptionsError}
|
helperText={page.giftOptionsError}
|
||||||
label={<RequiredLabel>幸运礼物合集</RequiredLabel>}
|
label="幸运礼物合集"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
@ -373,271 +299,10 @@ export function RoomRobotPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
<HumanConfigDialog page={page} />
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HumanConfigPanel({ page }) {
|
|
||||||
const config = page.humanConfig || {};
|
|
||||||
return (
|
|
||||||
<section className={styles.robotConfigPanel}>
|
|
||||||
<div>
|
|
||||||
<div className={styles.primaryText}>真人房间机器人</div>
|
|
||||||
<div className={styles.meta}>
|
|
||||||
{page.humanConfigLoading
|
|
||||||
? "配置加载中"
|
|
||||||
: page.humanConfigError || (config.enabled ? "已启用" : "未启用")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<RuleStack
|
|
||||||
rows={[
|
|
||||||
`低于 ${Number(config.candidateRoomMaxOnline || 5).toLocaleString("zh-CN")} 人进房`,
|
|
||||||
`目标 ${Number(config.roomTargetMinOnline || config.roomFullStopOnline || 8).toLocaleString("zh-CN")}-${Number(config.roomTargetMaxOnline || config.roomFullStopOnline || 10).toLocaleString("zh-CN")} 人`,
|
|
||||||
`${formatDurationRange(config.robotStayMinMs || 60000, config.robotStayMaxMs || 600000)} 停留`,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<RuleStack
|
|
||||||
rows={[
|
|
||||||
"自动使用全站机器人",
|
|
||||||
`${formatDurationRange(
|
|
||||||
config.normalGiftIntervalMinMs || config.normalGiftIntervalMs || 10000,
|
|
||||||
config.normalGiftIntervalMaxMs || config.normalGiftIntervalMs || 10000,
|
|
||||||
)} / 次`,
|
|
||||||
`${Number(config.maxGiftSenders || 1).toLocaleString("zh-CN")} 个机器人送礼`,
|
|
||||||
`${config.giftIds?.length || 0} 个普通礼物`,
|
|
||||||
`${(config.luckyGiftIds?.length || 0) + (config.superLuckyGiftIds?.length || 0)} 个幸运礼物`,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{page.abilities.canRobotUpdate ? (
|
|
||||||
<Button
|
|
||||||
loading={page.humanConfigLoading}
|
|
||||||
startIcon={<TuneOutlined fontSize="small" />}
|
|
||||||
variant="secondary"
|
|
||||||
onClick={page.openHumanConfig}
|
|
||||||
>
|
|
||||||
配置
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HumanConfigDialog({ page }) {
|
|
||||||
return (
|
|
||||||
<AdminFormDialog
|
|
||||||
loading={page.loadingAction === "human-config"}
|
|
||||||
open={page.activeAction === "human-config"}
|
|
||||||
size="wide"
|
|
||||||
submitDisabled={!page.abilities.canRobotUpdate || page.loadingAction === "human-config"}
|
|
||||||
title="真人房间机器人配置"
|
|
||||||
onClose={page.closeAction}
|
|
||||||
onSubmit={page.submitHumanConfig}
|
|
||||||
>
|
|
||||||
<div className={styles.formGrid}>
|
|
||||||
<label className={styles.switchField}>
|
|
||||||
<AdminSwitch
|
|
||||||
checked={Boolean(page.humanConfigForm.enabled)}
|
|
||||||
checkedLabel="启用"
|
|
||||||
label="启用真人房间机器人"
|
|
||||||
uncheckedLabel="关闭"
|
|
||||||
onChange={(_, checked) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, enabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<TextField
|
|
||||||
label="真人房间低于人数"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.candidateRoomMaxOnline}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({
|
|
||||||
...page.humanConfigForm,
|
|
||||||
candidateRoomMaxOnline: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="目标人数下限"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.roomTargetMinOnline}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMinOnline: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="目标人数上限"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.roomTargetMaxOnline}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMaxOnline: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.formGrid}>
|
|
||||||
<TextField
|
|
||||||
label="最短送礼间隔(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.normalGiftIntervalMinMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({
|
|
||||||
...page.humanConfigForm,
|
|
||||||
normalGiftIntervalMinMs: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最长送礼间隔(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.normalGiftIntervalMaxMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({
|
|
||||||
...page.humanConfigForm,
|
|
||||||
normalGiftIntervalMaxMs: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最短停留(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.robotStayMinMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMinMs: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最长停留(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.robotStayMaxMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMaxMs: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最短补位(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.robotReplaceMinMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMinMs: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最长补位(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.robotReplaceMaxMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMaxMs: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="房间送礼机器人数"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.maxGiftSenders}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, maxGiftSenders: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<GiftAutocomplete
|
|
||||||
label="普通礼物合集"
|
|
||||||
loading={page.giftOptionsLoading}
|
|
||||||
options={page.giftOptions}
|
|
||||||
required
|
|
||||||
value={page.selectedHumanGiftOptions.giftIds}
|
|
||||||
onChange={(options) => page.selectHumanGiftIds("giftIds", options)}
|
|
||||||
/>
|
|
||||||
<div className={styles.formGrid}>
|
|
||||||
<TextField
|
|
||||||
label="最少幸运连击"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.luckyComboMin}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMin: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最多幸运连击"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.luckyComboMax}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMax: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最短幸运间隔(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.luckyPauseMinMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMinMs: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="最长幸运间隔(毫秒)"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={page.humanConfigForm.luckyPauseMaxMs}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMaxMs: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<GiftAutocomplete
|
|
||||||
label="幸运礼物合集"
|
|
||||||
loading={page.giftOptionsLoading}
|
|
||||||
options={page.giftOptions}
|
|
||||||
value={page.selectedHumanGiftOptions.luckyGiftIds}
|
|
||||||
onChange={(options) => page.selectHumanGiftIds("luckyGiftIds", options)}
|
|
||||||
/>
|
|
||||||
<GiftAutocomplete
|
|
||||||
label="超级幸运礼物合集"
|
|
||||||
loading={page.giftOptionsLoading}
|
|
||||||
options={page.giftOptions}
|
|
||||||
value={page.selectedHumanGiftOptions.superLuckyGiftIds}
|
|
||||||
onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)}
|
|
||||||
/>
|
|
||||||
<section className={styles.robotAutoPoolNotice}>
|
|
||||||
<div className={styles.primaryText}>机器人池自动获取</div>
|
|
||||||
<div className={styles.meta}>
|
|
||||||
系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminFormDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) {
|
|
||||||
return (
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
disableCloseOnSelect
|
|
||||||
getOptionLabel={giftOptionLabel}
|
|
||||||
isOptionEqualToValue={(option, selected) => giftId(option) === giftId(selected)}
|
|
||||||
loading={loading}
|
|
||||||
noOptionsText="当前无数据"
|
|
||||||
options={options}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label={required ? <RequiredLabel>{label}</RequiredLabel> : label} />
|
|
||||||
)}
|
|
||||||
size="small"
|
|
||||||
value={value}
|
|
||||||
onChange={(_, selected) => onChange(selected)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RobotRoomIdentity({ room }) {
|
function RobotRoomIdentity({ room }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.identity}>
|
<div className={styles.identity}>
|
||||||
@ -653,33 +318,8 @@ function RobotRoomIdentity({ room }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RobotIdentity({ fallbackId = "", openInAppUserDetail = true, user = {} }) {
|
function RobotIdentity({ user = {}, fallbackId = "" }) {
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail rows={[user.displayUserId, user.userId || fallbackId]} user={user} />;
|
||||||
<AdminUserIdentity
|
|
||||||
openInAppUserDetail={openInAppUserDetail}
|
|
||||||
rows={[user.displayUserId, user.userId || fallbackId]}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OwnerRobotOption({ countryLabels, loadingLocation, option, regionLabels }) {
|
|
||||||
return (
|
|
||||||
<div className={styles.ownerRobotOption}>
|
|
||||||
<RobotIdentity openInAppUserDetail={false} user={option.user} fallbackId={option.userId} />
|
|
||||||
<span className={styles.ownerRobotLocation}>
|
|
||||||
{loadingLocation ? "国家/区域加载中" : robotLocationLabel(option, countryLabels, regionLabels)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequiredLabel({ children }) {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{children} <span aria-hidden="true">*</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RobotRoomStatus({ page, room }) {
|
function RobotRoomStatus({ page, room }) {
|
||||||
@ -718,7 +358,3 @@ function formatSeconds(ms) {
|
|||||||
const seconds = Math.round(Number(ms || 0) / 1000);
|
const seconds = Math.round(Number(ms || 0) / 1000);
|
||||||
return `${seconds.toLocaleString("zh-CN")}秒`;
|
return `${seconds.toLocaleString("zh-CN")}秒`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDurationRange(minMs, maxMs) {
|
|
||||||
return `${formatSeconds(minMs)}-${formatSeconds(maxMs)}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
|
||||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import { useRoomWhitelistPage } from "@/features/rooms/hooks/useRoomWhitelistPage.js";
|
|
||||||
import styles from "@/features/rooms/rooms.module.css";
|
|
||||||
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
|
||||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
|
||||||
|
|
||||||
export function RoomWhitelistPage() {
|
|
||||||
const page = useRoomWhitelistPage();
|
|
||||||
const canSave = page.abilities.canUpdateWhitelist && page.loadingAction !== "save";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminListPage>
|
|
||||||
<AdminListToolbar
|
|
||||||
actions={
|
|
||||||
page.abilities.canUpdateWhitelist ? (
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={!canSave}
|
|
||||||
form="room-whitelist-form"
|
|
||||||
label="保存"
|
|
||||||
primary
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
<SaveOutlined fontSize="small" />
|
|
||||||
</AdminActionIconButton>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
|
||||||
<AdminListBody>
|
|
||||||
<form id="room-whitelist-form" className={styles.configForm} onSubmit={page.submitWhitelist}>
|
|
||||||
<section className={styles.configSection}>
|
|
||||||
<div className={styles.configHeader}>
|
|
||||||
<span className={styles.configIcon}>
|
|
||||||
<GroupsOutlined fontSize="small" />
|
|
||||||
</span>
|
|
||||||
<div className={styles.configTitle}>
|
|
||||||
<h2>用户 ID</h2>
|
|
||||||
<span>
|
|
||||||
{page.data?.updatedAtMs ? <TimeText value={page.data.updatedAtMs} /> : "未配置"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
disabled={!page.abilities.canUpdateWhitelist}
|
|
||||||
fullWidth
|
|
||||||
label="用户 ID"
|
|
||||||
minRows={12}
|
|
||||||
multiline
|
|
||||||
value={page.text}
|
|
||||||
onChange={(event) => page.setText(event.target.value)}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</AdminListBody>
|
|
||||||
</DataState>
|
|
||||||
</AdminListPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -7,8 +7,6 @@ export function useRoomAbilities() {
|
|||||||
return {
|
return {
|
||||||
canUpdateConfig: can(PERMISSIONS.roomConfigUpdate),
|
canUpdateConfig: can(PERMISSIONS.roomConfigUpdate),
|
||||||
canViewConfig: can(PERMISSIONS.roomConfigView),
|
canViewConfig: can(PERMISSIONS.roomConfigView),
|
||||||
canUpdateWhitelist: can(PERMISSIONS.roomWhitelistUpdate),
|
|
||||||
canViewWhitelist: can(PERMISSIONS.roomWhitelistView),
|
|
||||||
canDelete: can(PERMISSIONS.roomDelete),
|
canDelete: can(PERMISSIONS.roomDelete),
|
||||||
canPinCancel: can(PERMISSIONS.roomPinCancel),
|
canPinCancel: can(PERMISSIONS.roomPinCancel),
|
||||||
canPinCreate: can(PERMISSIONS.roomPinCreate),
|
canPinCreate: can(PERMISSIONS.roomPinCreate),
|
||||||
|
|||||||
@ -48,20 +48,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbarActions {
|
.toolbarActions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
|
||||||
|
|
||||||
.robotConfigPanel {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 1fr) minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-3) var(--space-5);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-card-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.formGrid {
|
.formGrid {
|
||||||
@ -74,11 +64,6 @@
|
|||||||
.formGrid {
|
.formGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.robotConfigPanel {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
@ -197,41 +182,6 @@
|
|||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ownerRobotOption {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ownerRobotOption > :first-child {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ownerRobotLocation {
|
|
||||||
flex: 0 1 320px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--admin-font-size);
|
|
||||||
line-height: 1.35;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.ownerRobotOption {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ownerRobotLocation {
|
|
||||||
flex-basis: auto;
|
|
||||||
padding-left: 48px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortHeader {
|
.sortHeader {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -273,21 +223,6 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switchField {
|
|
||||||
display: inline-flex;
|
|
||||||
min-height: var(--control-height);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.robotAutoPoolNotice {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
background: var(--bg-card-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowActions {
|
.rowActions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -25,14 +25,6 @@ export const roomRoutes = [
|
|||||||
path: "/rooms/config",
|
path: "/rooms/config",
|
||||||
permission: PERMISSIONS.roomConfigView,
|
permission: PERMISSIONS.roomConfigView,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "房间白名单",
|
|
||||||
loader: () => import("./pages/RoomWhitelistPage.jsx").then((module) => module.RoomWhitelistPage),
|
|
||||||
menuCode: MENU_CODES.roomWhitelist,
|
|
||||||
pageKey: "room-whitelist",
|
|
||||||
path: "/rooms/whitelist",
|
|
||||||
permission: PERMISSIONS.roomWhitelistView,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "机器人房间",
|
label: "机器人房间",
|
||||||
loader: () => import("./pages/RoomRobotPage.jsx").then((module) => module.RoomRobotPage),
|
loader: () => import("./pages/RoomRobotPage.jsx").then((module) => module.RoomRobotPage),
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
|
||||||
import { humanRoomRobotConfigSchema, robotRoomCreateSchema, roomWhitelistSchema } from "./schema";
|
|
||||||
|
|
||||||
describe("robot room form schema", () => {
|
|
||||||
test("preserves int64 robot user ids as strings", () => {
|
|
||||||
const payload = parseForm(robotRoomCreateSchema, {
|
|
||||||
candidateRobotUserIds: ["325455441691676672"],
|
|
||||||
giftIds: ["84"],
|
|
||||||
luckyComboMax: "10000",
|
|
||||||
luckyComboMin: "100",
|
|
||||||
luckyGiftIds: ["28"],
|
|
||||||
luckyPauseMaxMs: "20000",
|
|
||||||
luckyPauseMinMs: "5000",
|
|
||||||
maxGiftSenders: "2",
|
|
||||||
maxRobotCount: "8",
|
|
||||||
minRobotCount: "2",
|
|
||||||
normalGiftIntervalMs: "10000",
|
|
||||||
ownerRobotUserId: "325379237278126080",
|
|
||||||
robotReplaceMaxMs: "60000",
|
|
||||||
robotReplaceMinMs: "0",
|
|
||||||
robotStayMaxMs: "600000",
|
|
||||||
robotStayMinMs: "180000",
|
|
||||||
seatCount: "10",
|
|
||||||
visibleRegionId: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload.ownerRobotUserId).toBe("325379237278126080");
|
|
||||||
expect(payload.candidateRobotUserIds).toEqual(["325455441691676672"]);
|
|
||||||
expect(payload.robotStayMinMs).toBe(180000);
|
|
||||||
expect(payload.robotStayMaxMs).toBe(600000);
|
|
||||||
expect(payload.robotReplaceMinMs).toBe(0);
|
|
||||||
expect(payload.robotReplaceMaxMs).toBe(60000);
|
|
||||||
expect(payload.maxGiftSenders).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("human room robot config schema", () => {
|
|
||||||
test("preserves robot ids and parses enabled config", () => {
|
|
||||||
const payload = parseForm(humanRoomRobotConfigSchema, {
|
|
||||||
candidateRoomMaxOnline: "5",
|
|
||||||
enabled: true,
|
|
||||||
giftIds: ["84"],
|
|
||||||
luckyComboMax: "3",
|
|
||||||
luckyComboMin: "1",
|
|
||||||
luckyGiftIds: ["28"],
|
|
||||||
luckyPauseMaxMs: "20000",
|
|
||||||
luckyPauseMinMs: "5000",
|
|
||||||
maxGiftSenders: "1",
|
|
||||||
normalGiftIntervalMaxMs: "20000",
|
|
||||||
normalGiftIntervalMinMs: "1000",
|
|
||||||
robotReplaceMaxMs: "180000",
|
|
||||||
robotReplaceMinMs: "60000",
|
|
||||||
robotStayMaxMs: "600000",
|
|
||||||
robotStayMinMs: "60000",
|
|
||||||
roomTargetMaxOnline: "10",
|
|
||||||
roomTargetMinOnline: "8",
|
|
||||||
superLuckyGiftIds: ["99"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload.roomTargetMinOnline).toBe(8);
|
|
||||||
expect(payload.roomTargetMaxOnline).toBe(10);
|
|
||||||
expect(payload.roomFullStopOnline).toBe(10);
|
|
||||||
expect(payload.normalGiftIntervalMinMs).toBe(1000);
|
|
||||||
expect(payload.normalGiftIntervalMaxMs).toBe(20000);
|
|
||||||
expect(payload.giftIds).toEqual(["84"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("requires gift pools when enabled", () => {
|
|
||||||
expect(() =>
|
|
||||||
parseForm(humanRoomRobotConfigSchema, {
|
|
||||||
candidateRoomMaxOnline: "5",
|
|
||||||
enabled: true,
|
|
||||||
giftIds: [],
|
|
||||||
luckyComboMax: "3",
|
|
||||||
luckyComboMin: "1",
|
|
||||||
luckyGiftIds: [],
|
|
||||||
luckyPauseMaxMs: "20000",
|
|
||||||
luckyPauseMinMs: "5000",
|
|
||||||
maxGiftSenders: "1",
|
|
||||||
normalGiftIntervalMaxMs: "20000",
|
|
||||||
normalGiftIntervalMinMs: "1000",
|
|
||||||
robotReplaceMaxMs: "180000",
|
|
||||||
robotReplaceMinMs: "60000",
|
|
||||||
robotStayMaxMs: "600000",
|
|
||||||
robotStayMinMs: "60000",
|
|
||||||
roomTargetMaxOnline: "10",
|
|
||||||
roomTargetMinOnline: "8",
|
|
||||||
superLuckyGiftIds: [],
|
|
||||||
}),
|
|
||||||
).toThrow("启用后请选择普通礼物合集");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("room whitelist schema", () => {
|
|
||||||
test("keeps long user ids as strings and removes duplicates", () => {
|
|
||||||
const payload = parseForm(roomWhitelistSchema, {
|
|
||||||
userIds: ["325379237278126080", "325379237278126080", "1001"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload.userIds).toEqual(["1001", "325379237278126080"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,11 +2,6 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
|
const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
|
||||||
const roomSeatCandidates = [10, 15, 20, 25, 30] as const;
|
const roomSeatCandidates = [10, 15, 20, 25, 30] as const;
|
||||||
const robotUserIdSchema = (message: string) =>
|
|
||||||
z
|
|
||||||
.union([z.string(), z.number()])
|
|
||||||
.transform((value) => String(value).trim())
|
|
||||||
.refine((value) => /^[1-9]\d*$/.test(value), message);
|
|
||||||
const pinTypeSchema = z
|
const pinTypeSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@ -62,20 +57,6 @@ export const roomConfigSchema = z
|
|||||||
path: ["defaultSeatCount"],
|
path: ["defaultSeatCount"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roomWhitelistSchema = z.object({
|
|
||||||
userIds: z
|
|
||||||
.array(
|
|
||||||
z
|
|
||||||
.union([z.string(), z.number()])
|
|
||||||
.transform((value) => String(value).trim())
|
|
||||||
.refine((value) => /^[1-9]\d*$/.test(value), "用户 ID 不正确"),
|
|
||||||
)
|
|
||||||
.default([])
|
|
||||||
.transform((values) =>
|
|
||||||
[...new Set(values)].sort((left, right) => left.localeCompare(right, "en", { numeric: true })),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const roomPinCreateSchema = z
|
export const roomPinCreateSchema = z
|
||||||
.object({
|
.object({
|
||||||
expiresAtMs: dateTimeMsSchema,
|
expiresAtMs: dateTimeMsSchema,
|
||||||
@ -91,26 +72,17 @@ export const roomPinCreateSchema = z
|
|||||||
|
|
||||||
export const robotRoomCreateSchema = z
|
export const robotRoomCreateSchema = z
|
||||||
.object({
|
.object({
|
||||||
candidateRobotUserIds: z.array(robotUserIdSchema("请选择候选机器人")).min(1, "请选择候选机器人"),
|
candidateRobotUserIds: z.array(z.coerce.number().int().positive()).min(1, "请选择候选机器人"),
|
||||||
giftIds: z.array(z.string().trim().min(1)).min(1, "请选择普通礼物合集"),
|
giftIds: z.array(z.string().trim().min(1)).min(1, "请选择普通礼物合集"),
|
||||||
luckyComboMax: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
luckyComboMax: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
||||||
luckyComboMin: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
luckyComboMin: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
||||||
luckyGiftIds: z.array(z.string().trim().min(1)).min(1, "请选择幸运礼物合集"),
|
luckyGiftIds: z.array(z.string().trim().min(1)).min(1, "请选择幸运礼物合集"),
|
||||||
luckyPauseMaxMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
luckyPauseMaxMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
||||||
luckyPauseMinMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
luckyPauseMinMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
||||||
maxGiftSenders: z.coerce.number().int().min(1, "同时送礼机器人数量不正确"),
|
|
||||||
maxRobotCount: z.coerce.number().int().min(1, "机器人数量范围不正确"),
|
maxRobotCount: z.coerce.number().int().min(1, "机器人数量范围不正确"),
|
||||||
minRobotCount: z.coerce.number().int().min(1, "机器人数量范围不正确"),
|
minRobotCount: z.coerce.number().int().min(1, "机器人数量范围不正确"),
|
||||||
normalGiftIntervalMs: z.coerce.number().int().min(1000, "普通礼物频次不正确"),
|
normalGiftIntervalMs: z.coerce.number().int().min(1000, "普通礼物频次不正确"),
|
||||||
ownerRobotUserId: robotUserIdSchema("请选择房主机器人"),
|
ownerRobotUserId: z.coerce.number().int().positive("请选择房主机器人"),
|
||||||
robotReplaceMaxMs: z.coerce.number().int().min(0, "机器人补位时间范围不正确"),
|
|
||||||
robotReplaceMinMs: z.coerce.number().int().min(0, "机器人补位时间范围不正确"),
|
|
||||||
robotStayMaxMs: z.coerce.number().int().min(1000, "机器人停留时间范围不正确"),
|
|
||||||
robotStayMinMs: z.coerce.number().int().min(1000, "机器人停留时间范围不正确"),
|
|
||||||
seatCount: z.coerce
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.refine((value) => value === 10 || value === 15 || value === 20, "请选择麦位数"),
|
|
||||||
visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0),
|
visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0),
|
||||||
})
|
})
|
||||||
.refine((value) => value.maxRobotCount >= value.minRobotCount, {
|
.refine((value) => value.maxRobotCount >= value.minRobotCount, {
|
||||||
@ -124,81 +96,10 @@ export const robotRoomCreateSchema = z
|
|||||||
.refine((value) => value.luckyPauseMaxMs >= value.luckyPauseMinMs, {
|
.refine((value) => value.luckyPauseMaxMs >= value.luckyPauseMinMs, {
|
||||||
message: "幸运礼物间隔范围不正确",
|
message: "幸运礼物间隔范围不正确",
|
||||||
path: ["luckyPauseMaxMs"],
|
path: ["luckyPauseMaxMs"],
|
||||||
})
|
|
||||||
.refine((value) => value.robotStayMaxMs >= value.robotStayMinMs, {
|
|
||||||
message: "机器人停留时间范围不正确",
|
|
||||||
path: ["robotStayMaxMs"],
|
|
||||||
})
|
|
||||||
.refine((value) => value.robotReplaceMaxMs >= value.robotReplaceMinMs, {
|
|
||||||
message: "机器人补位时间范围不正确",
|
|
||||||
path: ["robotReplaceMaxMs"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const humanRoomRobotConfigSchema = z
|
|
||||||
.object({
|
|
||||||
candidateRoomMaxOnline: z.coerce.number().int().min(1, "真人房间筛选人数不正确"),
|
|
||||||
enabled: z.coerce.boolean().default(false),
|
|
||||||
giftIds: z.array(z.string().trim().min(1)).default([]),
|
|
||||||
luckyComboMax: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
|
||||||
luckyComboMin: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
|
|
||||||
luckyGiftIds: z.array(z.string().trim().min(1)).default([]),
|
|
||||||
luckyPauseMaxMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
|
||||||
luckyPauseMinMs: z.coerce.number().int().min(1000, "幸运礼物间隔范围不正确"),
|
|
||||||
maxGiftSenders: z.coerce.number().int().min(1, "房间送礼机器人数量不正确"),
|
|
||||||
normalGiftIntervalMs: z.coerce.number().int().min(1000, "普通礼物频次不正确").optional(),
|
|
||||||
normalGiftIntervalMaxMs: z.coerce.number().int().min(1000, "普通礼物间隔范围不正确"),
|
|
||||||
normalGiftIntervalMinMs: z.coerce.number().int().min(1000, "普通礼物间隔范围不正确"),
|
|
||||||
robotReplaceMaxMs: z.coerce.number().int().min(0, "机器人补位时间范围不正确"),
|
|
||||||
robotReplaceMinMs: z.coerce.number().int().min(0, "机器人补位时间范围不正确"),
|
|
||||||
robotStayMaxMs: z.coerce.number().int().min(1000, "机器人停留时间范围不正确"),
|
|
||||||
robotStayMinMs: z.coerce.number().int().min(1000, "机器人停留时间范围不正确"),
|
|
||||||
roomFullStopOnline: z.coerce.number().int().min(1, "真人房间停止人数不正确").optional(),
|
|
||||||
roomTargetMaxOnline: z.coerce.number().int().min(1, "房间目标人数范围不正确"),
|
|
||||||
roomTargetMinOnline: z.coerce.number().int().min(1, "房间目标人数范围不正确"),
|
|
||||||
superLuckyGiftIds: z.array(z.string().trim().min(1)).default([]),
|
|
||||||
})
|
|
||||||
.transform((value) => ({ ...value, roomFullStopOnline: value.roomTargetMaxOnline }))
|
|
||||||
.refine((value) => value.roomTargetMaxOnline >= value.roomTargetMinOnline, {
|
|
||||||
message: "房间目标人数范围不正确",
|
|
||||||
path: ["roomTargetMaxOnline"],
|
|
||||||
})
|
|
||||||
.refine((value) => value.roomTargetMaxOnline >= value.candidateRoomMaxOnline, {
|
|
||||||
message: "目标人数上限不能小于筛选人数",
|
|
||||||
path: ["roomTargetMaxOnline"],
|
|
||||||
})
|
|
||||||
.refine((value) => value.luckyComboMax >= value.luckyComboMin, {
|
|
||||||
message: "幸运礼物连击范围不正确",
|
|
||||||
path: ["luckyComboMax"],
|
|
||||||
})
|
|
||||||
.refine((value) => value.luckyPauseMaxMs >= value.luckyPauseMinMs, {
|
|
||||||
message: "幸运礼物间隔范围不正确",
|
|
||||||
path: ["luckyPauseMaxMs"],
|
|
||||||
})
|
|
||||||
.refine((value) => value.normalGiftIntervalMaxMs >= value.normalGiftIntervalMinMs, {
|
|
||||||
message: "普通礼物间隔范围不正确",
|
|
||||||
path: ["normalGiftIntervalMaxMs"],
|
|
||||||
})
|
|
||||||
.refine((value) => value.robotStayMaxMs >= value.robotStayMinMs, {
|
|
||||||
message: "机器人停留时间范围不正确",
|
|
||||||
path: ["robotStayMaxMs"],
|
|
||||||
})
|
|
||||||
.refine((value) => value.robotReplaceMaxMs >= value.robotReplaceMinMs, {
|
|
||||||
message: "机器人补位时间范围不正确",
|
|
||||||
path: ["robotReplaceMaxMs"],
|
|
||||||
})
|
|
||||||
.refine((value) => !value.enabled || value.giftIds.length > 0, {
|
|
||||||
message: "启用后请选择普通礼物合集",
|
|
||||||
path: ["giftIds"],
|
|
||||||
})
|
|
||||||
.refine((value) => !value.enabled || value.luckyGiftIds.length + value.superLuckyGiftIds.length > 0, {
|
|
||||||
message: "启用后请选择幸运礼物或超级幸运礼物合集",
|
|
||||||
path: ["luckyGiftIds"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;
|
export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;
|
||||||
export type RoomStatusForm = z.infer<typeof roomStatusSchema>;
|
export type RoomStatusForm = z.infer<typeof roomStatusSchema>;
|
||||||
export type RoomConfigForm = z.infer<typeof roomConfigSchema>;
|
export type RoomConfigForm = z.infer<typeof roomConfigSchema>;
|
||||||
export type RoomWhitelistForm = z.infer<typeof roomWhitelistSchema>;
|
|
||||||
export type RoomPinCreateForm = z.infer<typeof roomPinCreateSchema>;
|
export type RoomPinCreateForm = z.infer<typeof roomPinCreateSchema>;
|
||||||
export type RobotRoomCreateForm = z.infer<typeof robotRoomCreateSchema>;
|
export type RobotRoomCreateForm = z.infer<typeof robotRoomCreateSchema>;
|
||||||
export type HumanRoomRobotConfigForm = z.infer<typeof humanRoomRobotConfigSchema>;
|
|
||||||
|
|||||||
@ -106,7 +106,6 @@ export const API_OPERATIONS = {
|
|||||||
getCPConfig: "getCPConfig",
|
getCPConfig: "getCPConfig",
|
||||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||||
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
||||||
getHumanRoomRobotConfig: "getHumanRoomRobotConfig",
|
|
||||||
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
||||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||||
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
||||||
@ -122,12 +121,9 @@ export const API_OPERATIONS = {
|
|||||||
getRoomRpsChallenge: "getRoomRpsChallenge",
|
getRoomRpsChallenge: "getRoomRpsChallenge",
|
||||||
getRoomRpsConfig: "getRoomRpsConfig",
|
getRoomRpsConfig: "getRoomRpsConfig",
|
||||||
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
||||||
getRoomWhitelistConfig: "getRoomWhitelistConfig",
|
|
||||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||||
getUser: "getUser",
|
getUser: "getUser",
|
||||||
getWeeklyStarCycle: "getWeeklyStarCycle",
|
getWeeklyStarCycle: "getWeeklyStarCycle",
|
||||||
getWheelConfig: "getWheelConfig",
|
|
||||||
getWheelDrawSummary: "getWheelDrawSummary",
|
|
||||||
grantPrettyId: "grantPrettyId",
|
grantPrettyId: "grantPrettyId",
|
||||||
grantResource: "grantResource",
|
grantResource: "grantResource",
|
||||||
grantResourceGroup: "grantResourceGroup",
|
grantResourceGroup: "grantResourceGroup",
|
||||||
@ -198,7 +194,6 @@ export const API_OPERATIONS = {
|
|||||||
listWeeklyStarCycles: "listWeeklyStarCycles",
|
listWeeklyStarCycles: "listWeeklyStarCycles",
|
||||||
listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard",
|
listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard",
|
||||||
listWeeklyStarSettlements: "listWeeklyStarSettlements",
|
listWeeklyStarSettlements: "listWeeklyStarSettlements",
|
||||||
listWheelDraws: "listWheelDraws",
|
|
||||||
login: "login",
|
login: "login",
|
||||||
logout: "logout",
|
logout: "logout",
|
||||||
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
||||||
@ -248,7 +243,6 @@ export const API_OPERATIONS = {
|
|||||||
updateH5Link: "updateH5Link",
|
updateH5Link: "updateH5Link",
|
||||||
updateH5Links: "updateH5Links",
|
updateH5Links: "updateH5Links",
|
||||||
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
||||||
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
|
|
||||||
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
|
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
|
||||||
updateMenu: "updateMenu",
|
updateMenu: "updateMenu",
|
||||||
updateMenuVisible: "updateMenuVisible",
|
updateMenuVisible: "updateMenuVisible",
|
||||||
@ -269,7 +263,6 @@ export const API_OPERATIONS = {
|
|||||||
updateRoomRocketConfig: "updateRoomRocketConfig",
|
updateRoomRocketConfig: "updateRoomRocketConfig",
|
||||||
updateRoomRpsConfig: "updateRoomRpsConfig",
|
updateRoomRpsConfig: "updateRoomRpsConfig",
|
||||||
updateRoomTurnoverRewardConfig: "updateRoomTurnoverRewardConfig",
|
updateRoomTurnoverRewardConfig: "updateRoomTurnoverRewardConfig",
|
||||||
updateRoomWhitelistConfig: "updateRoomWhitelistConfig",
|
|
||||||
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
||||||
updateSplashScreen: "updateSplashScreen",
|
updateSplashScreen: "updateSplashScreen",
|
||||||
updateTaskDefinition: "updateTaskDefinition",
|
updateTaskDefinition: "updateTaskDefinition",
|
||||||
@ -284,8 +277,7 @@ export const API_OPERATIONS = {
|
|||||||
uploadImage: "uploadImage",
|
uploadImage: "uploadImage",
|
||||||
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
|
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
|
||||||
upsertResourceShopItems: "upsertResourceShopItems",
|
upsertResourceShopItems: "upsertResourceShopItems",
|
||||||
upsertRule: "upsertRule",
|
upsertRule: "upsertRule"
|
||||||
upsertWheelConfig: "upsertWheelConfig"
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||||
@ -940,13 +932,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "first-recharge-reward:view",
|
permission: "first-recharge-reward:view",
|
||||||
permissions: ["first-recharge-reward:view"]
|
permissions: ["first-recharge-reward:view"]
|
||||||
},
|
},
|
||||||
getHumanRoomRobotConfig: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getHumanRoomRobotConfig,
|
|
||||||
path: "/v1/admin/rooms/robot-rooms/human-config",
|
|
||||||
permission: "room-robot:view",
|
|
||||||
permissions: ["room-robot:view"]
|
|
||||||
},
|
|
||||||
getInviteActivityRewardConfig: {
|
getInviteActivityRewardConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getInviteActivityRewardConfig,
|
operationId: API_OPERATIONS.getInviteActivityRewardConfig,
|
||||||
@ -1052,13 +1037,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room-turnover-reward:view",
|
permission: "room-turnover-reward:view",
|
||||||
permissions: ["room-turnover-reward:view"]
|
permissions: ["room-turnover-reward:view"]
|
||||||
},
|
},
|
||||||
getRoomWhitelistConfig: {
|
|
||||||
method: "GET",
|
|
||||||
operationId: API_OPERATIONS.getRoomWhitelistConfig,
|
|
||||||
path: "/v1/admin/rooms/whitelist",
|
|
||||||
permission: "room-whitelist:view",
|
|
||||||
permissions: ["room-whitelist:view"]
|
|
||||||
},
|
|
||||||
getSevenDayCheckInConfig: {
|
getSevenDayCheckInConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
||||||
@ -1080,20 +1058,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "weekly-star:view",
|
permission: "weekly-star:view",
|
||||||
permissions: ["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: {
|
grantPrettyId: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.grantPrettyId,
|
operationId: API_OPERATIONS.grantPrettyId,
|
||||||
@ -1582,13 +1546,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "weekly-star:settle",
|
permission: "weekly-star:settle",
|
||||||
permissions: ["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: {
|
login: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.login,
|
operationId: API_OPERATIONS.login,
|
||||||
@ -1920,13 +1877,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "host-agency-policy:update",
|
permission: "host-agency-policy:update",
|
||||||
permissions: ["host-agency-policy:update"]
|
permissions: ["host-agency-policy:update"]
|
||||||
},
|
},
|
||||||
updateHumanRoomRobotConfig: {
|
|
||||||
method: "PUT",
|
|
||||||
operationId: API_OPERATIONS.updateHumanRoomRobotConfig,
|
|
||||||
path: "/v1/admin/rooms/robot-rooms/human-config",
|
|
||||||
permission: "room-robot:update",
|
|
||||||
permissions: ["room-robot:update"]
|
|
||||||
},
|
|
||||||
updateInviteActivityRewardConfig: {
|
updateInviteActivityRewardConfig: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateInviteActivityRewardConfig,
|
operationId: API_OPERATIONS.updateInviteActivityRewardConfig,
|
||||||
@ -2067,13 +2017,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room-turnover-reward:update",
|
permission: "room-turnover-reward:update",
|
||||||
permissions: ["room-turnover-reward:update"]
|
permissions: ["room-turnover-reward:update"]
|
||||||
},
|
},
|
||||||
updateRoomWhitelistConfig: {
|
|
||||||
method: "PUT",
|
|
||||||
operationId: API_OPERATIONS.updateRoomWhitelistConfig,
|
|
||||||
path: "/v1/admin/rooms/whitelist",
|
|
||||||
permission: "room-whitelist:update",
|
|
||||||
permissions: ["room-whitelist:update"]
|
|
||||||
},
|
|
||||||
updateSevenDayCheckInConfig: {
|
updateSevenDayCheckInConfig: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
||||||
@ -2178,13 +2121,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
path: "/v1/admin/users/level-config/rules/{track}/{level}",
|
path: "/v1/admin/users/level-config/rules/{track}/{level}",
|
||||||
permission: "level-config:update",
|
permission: "level-config:update",
|
||||||
permissions: ["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"]
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
176
src/shared/api/generated/schema.d.ts
vendored
176
src/shared/api/generated/schema.d.ts
vendored
@ -244,54 +244,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/activity/first-recharge-reward/claims": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2212,22 +2164,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/rooms/whitelist": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getRoomWhitelistConfig"];
|
|
||||||
put: operations["updateRoomWhitelistConfig"];
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/rooms/pins": {
|
"/admin/rooms/pins": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2292,22 +2228,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/rooms/robot-rooms/human-config": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getHumanRoomRobotConfig"];
|
|
||||||
put: operations["updateHumanRoomRobotConfig"];
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/rooms/robot-rooms/{room_id}/start": {
|
"/admin/rooms/robot-rooms/{room_id}/start": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3855,54 +3775,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
listFirstRechargeRewardClaims: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -6232,30 +6104,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getRoomWhitelistConfig: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
updateRoomWhitelistConfig: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listRoomPins: {
|
listRoomPins: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -6330,30 +6178,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getHumanRoomRobotConfig: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
updateHumanRoomRobotConfig: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
startRobotRoom: {
|
startRobotRoom: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -945,14 +945,7 @@ export interface RobotGiftRuleDto {
|
|||||||
luckyComboMin?: number;
|
luckyComboMin?: number;
|
||||||
luckyPauseMaxMs?: number;
|
luckyPauseMaxMs?: number;
|
||||||
luckyPauseMinMs?: number;
|
luckyPauseMinMs?: number;
|
||||||
maxGiftSenders?: number;
|
|
||||||
normalGiftIntervalMs?: number;
|
normalGiftIntervalMs?: number;
|
||||||
normalGiftIntervalMinMs?: number;
|
|
||||||
normalGiftIntervalMaxMs?: number;
|
|
||||||
robotReplaceMaxMs?: number;
|
|
||||||
robotReplaceMinMs?: number;
|
|
||||||
robotStayMaxMs?: number;
|
|
||||||
robotStayMinMs?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RobotRoomDto {
|
export interface RobotRoomDto {
|
||||||
@ -966,8 +959,6 @@ export interface RobotRoomDto {
|
|||||||
regionName?: string;
|
regionName?: string;
|
||||||
robots?: RoomOwnerDto[];
|
robots?: RoomOwnerDto[];
|
||||||
robotUserIds?: string[];
|
robotUserIds?: string[];
|
||||||
activeRobotCount?: number;
|
|
||||||
seatCount?: number;
|
|
||||||
roomId: string;
|
roomId: string;
|
||||||
roomShortId?: string;
|
roomShortId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@ -993,78 +984,22 @@ export interface RobotRoomPayload {
|
|||||||
luckyGiftIds: string[];
|
luckyGiftIds: string[];
|
||||||
luckyPauseMaxMs: number;
|
luckyPauseMaxMs: number;
|
||||||
luckyPauseMinMs: number;
|
luckyPauseMinMs: number;
|
||||||
maxGiftSenders: number;
|
|
||||||
maxRobotCount: number;
|
maxRobotCount: number;
|
||||||
minRobotCount: number;
|
minRobotCount: number;
|
||||||
normalGiftIntervalMs: number;
|
normalGiftIntervalMs: number;
|
||||||
ownerRobotUserId: EntityId;
|
ownerRobotUserId: EntityId;
|
||||||
robotReplaceMaxMs: number;
|
|
||||||
robotReplaceMinMs: number;
|
|
||||||
robotStayMaxMs: number;
|
|
||||||
robotStayMinMs: number;
|
|
||||||
seatCount: number;
|
|
||||||
roomAvatar?: string;
|
roomAvatar?: string;
|
||||||
roomName?: string;
|
roomName?: string;
|
||||||
visibleRegionId?: EntityId;
|
visibleRegionId?: EntityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HumanRoomRobotConfigDto {
|
|
||||||
candidateRoomMaxOnline?: number;
|
|
||||||
enabled?: boolean;
|
|
||||||
giftIds?: string[];
|
|
||||||
luckyComboMax?: number;
|
|
||||||
luckyComboMin?: number;
|
|
||||||
luckyGiftIds?: string[];
|
|
||||||
luckyPauseMaxMs?: number;
|
|
||||||
luckyPauseMinMs?: number;
|
|
||||||
maxGiftSenders?: number;
|
|
||||||
normalGiftIntervalMs?: number;
|
|
||||||
normalGiftIntervalMaxMs?: number;
|
|
||||||
normalGiftIntervalMinMs?: number;
|
|
||||||
robotReplaceMaxMs?: number;
|
|
||||||
robotReplaceMinMs?: number;
|
|
||||||
robotStayMaxMs?: number;
|
|
||||||
robotStayMinMs?: number;
|
|
||||||
roomFullStopOnline?: number;
|
|
||||||
roomTargetMaxOnline?: number;
|
|
||||||
roomTargetMinOnline?: number;
|
|
||||||
superLuckyGiftIds?: string[];
|
|
||||||
updatedAtMs?: number;
|
|
||||||
updatedByAdminId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HumanRoomRobotConfigPayload {
|
|
||||||
candidateRoomMaxOnline: number;
|
|
||||||
enabled: boolean;
|
|
||||||
giftIds: string[];
|
|
||||||
luckyComboMax: number;
|
|
||||||
luckyComboMin: number;
|
|
||||||
luckyGiftIds: string[];
|
|
||||||
luckyPauseMaxMs: number;
|
|
||||||
luckyPauseMinMs: number;
|
|
||||||
maxGiftSenders: number;
|
|
||||||
normalGiftIntervalMs: number;
|
|
||||||
normalGiftIntervalMinMs: number;
|
|
||||||
normalGiftIntervalMaxMs: number;
|
|
||||||
roomTargetMaxOnline: number;
|
|
||||||
roomTargetMinOnline: number;
|
|
||||||
robotReplaceMaxMs: number;
|
|
||||||
robotReplaceMinMs: number;
|
|
||||||
robotStayMaxMs: number;
|
|
||||||
robotStayMinMs: number;
|
|
||||||
roomFullStopOnline: number;
|
|
||||||
superLuckyGiftIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomOwnerDto {
|
export interface RoomOwnerDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
countryCode?: string;
|
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
visibleRegionId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomUpdatePayload {
|
export interface RoomUpdatePayload {
|
||||||
@ -1089,15 +1024,6 @@ export interface RoomConfigPayload {
|
|||||||
defaultSeatCount: number;
|
defaultSeatCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomWhitelistConfigDto {
|
|
||||||
updatedAtMs?: number;
|
|
||||||
userIds: EntityId[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomWhitelistPayload {
|
|
||||||
userIds: EntityId[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadResultDto {
|
export interface UploadResultDto {
|
||||||
content_type?: string;
|
content_type?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@ -1161,8 +1087,8 @@ export interface AppBannerDto {
|
|||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
displayScope?: "home" | "room" | "recharge" | "me" | string;
|
displayScope?: "home" | "room" | "recharge" | string;
|
||||||
displayScopes?: Array<"home" | "room" | "recharge" | "me" | string>;
|
displayScopes?: Array<"home" | "room" | "recharge" | string>;
|
||||||
endsAtMs?: number;
|
endsAtMs?: number;
|
||||||
id: number;
|
id: number;
|
||||||
param?: string;
|
param?: string;
|
||||||
@ -1180,8 +1106,8 @@ export interface AppBannerPayload {
|
|||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
displayScope?: "home" | "room" | "recharge" | "me";
|
displayScope?: "home" | "room" | "recharge";
|
||||||
displayScopes?: Array<"home" | "room" | "recharge" | "me">;
|
displayScopes?: Array<"home" | "room" | "recharge">;
|
||||||
endsAtMs?: number;
|
endsAtMs?: number;
|
||||||
param?: string;
|
param?: string;
|
||||||
platform: "android" | "ios";
|
platform: "android" | "ios";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user