diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index 95a4824..9b1bcac 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -3169,6 +3169,28 @@
"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": {
"get": {
"operationId": "listRoomPins",
@@ -3247,6 +3269,28 @@
"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": {
"post": {
"operationId": "startRobotRoom",
diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx
index 37495f7..114d0a8 100644
--- a/databi/src/DatabiApp.jsx
+++ b/databi/src/DatabiApp.jsx
@@ -242,6 +242,12 @@ export function DatabiApp() {
))}
+
+ {model.robotGiftKpis.map((item) => (
+ setTrendModalItem(item)} />
+ ))}
+
+
diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx
index 4ac6ba4..8766113 100644
--- a/databi/src/DatabiApp.test.jsx
+++ b/databi/src/DatabiApp.test.jsx
@@ -1,4 +1,4 @@
-import { act, render, screen } from "@testing-library/react";
+import { act, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { DatabiApp } from "./DatabiApp.jsx";
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js";
@@ -74,6 +74,29 @@ test("loads self game statistics from the big screen switch", async () => {
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();
+
+ 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() {
await act(async () => {
for (let index = 0; index < 10; index += 1) {
diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js
index 5b71136..5c252dc 100644
--- a/databi/src/data/createDashboardModel.js
+++ b/databi/src/data/createDashboardModel.js
@@ -19,6 +19,12 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
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 luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
@@ -100,6 +106,12 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
luckyGiftPools,
payoutDistribution,
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: [
{ 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) }
diff --git a/databi/src/data/createDashboardModel.test.js b/databi/src/data/createDashboardModel.test.js
index 02bf059..9687afa 100644
--- a/databi/src/data/createDashboardModel.test.js
+++ b/databi/src/data/createDashboardModel.test.js
@@ -67,6 +67,22 @@ test("exposes new user KPI and removes visitor stage from funnel", () => {
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", () => {
const model = createDashboardModel({
active_users: 80,
@@ -92,5 +108,5 @@ test("uses active users as payer and recharge conversion denominator", () => {
});
function metricValue(model, label) {
- return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
+ return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
}
diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js
index 9a0d594..48e50db 100644
--- a/src/app/navigation/menu.js
+++ b/src/app/navigation/menu.js
@@ -141,6 +141,7 @@ export const fallbackNavigation = [
routeNavItem("room-list", { icon: BedroomParentOutlined }),
routeNavItem("room-pins", { icon: PushPinOutlined }),
routeNavItem("room-config", { icon: SettingsApplicationsOutlined }),
+ routeNavItem("room-whitelist", { icon: ShieldOutlined }),
routeNavItem("room-robots", { icon: GroupsOutlined }),
],
},
diff --git a/src/app/permissions.ts b/src/app/permissions.ts
index 4a0bcab..c41d14c 100644
--- a/src/app/permissions.ts
+++ b/src/app/permissions.ts
@@ -99,6 +99,8 @@ export const PERMISSIONS = {
roomPinCancel: "room-pin:cancel",
roomConfigView: "room-config:view",
roomConfigUpdate: "room-config:update",
+ roomWhitelistView: "room-whitelist:view",
+ roomWhitelistUpdate: "room-whitelist:update",
roomRobotView: "room-robot:view",
roomRobotCreate: "room-robot:create",
roomRobotUpdate: "room-robot:update",
@@ -191,6 +193,7 @@ export const MENU_CODES = {
roomList: "room-list",
roomPins: "room-pins",
roomConfig: "room-config",
+ roomWhitelist: "room-whitelist",
roomRobots: "room-robots",
appConfig: "app-config",
appConfigH5: "app-config-h5",
diff --git a/src/features/app-config/constants.ts b/src/features/app-config/constants.ts
index 5eacfb7..63c9ec9 100644
--- a/src/features/app-config/constants.ts
+++ b/src/features/app-config/constants.ts
@@ -6,6 +6,7 @@ export const APP_BANNER_DISPLAY_SCOPE_OPTIONS = [
{ label: "首页", value: "home" },
{ label: "房间内", value: "room" },
{ label: "充值页", value: "recharge" },
+ { label: "我的页", value: "me" },
] as const;
export type AppBannerDisplayScope = (typeof APP_BANNER_DISPLAY_SCOPE_OPTIONS)[number]["value"];
diff --git a/src/features/app-config/hooks/useBannerConfigPage.js b/src/features/app-config/hooks/useBannerConfigPage.js
index a4bfd02..7a4c92f 100644
--- a/src/features/app-config/hooks/useBannerConfigPage.js
+++ b/src/features/app-config/hooks/useBannerConfigPage.js
@@ -213,7 +213,7 @@ function formFromBanner(item) {
function normalizeDisplayScopes(value) {
const values = Array.isArray(value) ? value : String(value || "").split(",");
- const known = ["home", "room", "recharge"];
+ const known = ["home", "room", "recharge", "me"];
const seen = new Set(values.map((item) => String(item || "").trim()).filter(Boolean));
const normalized = known.filter((scope) => seen.has(scope));
return normalized.length ? normalized : ["home"];
diff --git a/src/features/app-config/schema.test.ts b/src/features/app-config/schema.test.ts
index af560aa..a85addb 100644
--- a/src/features/app-config/schema.test.ts
+++ b/src/features/app-config/schema.test.ts
@@ -107,11 +107,11 @@ describe("app config form schema", () => {
test("accepts multiple banner display scopes", () => {
const payload = parseForm(appBannerSchema, {
...validBannerForm,
- displayScopes: ["home", "room", "recharge"],
+ displayScopes: ["home", "room", "recharge", "me"],
roomSmallImageUrl: "https://media.haiyihy.com/banner/room-small.png",
});
- expect(payload.displayScopes).toEqual(["home", "room", "recharge"]);
+ expect(payload.displayScopes).toEqual(["home", "room", "recharge", "me"]);
});
test("requires room small image for room banner", () => {
diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts
index ae4aeff..69e6b04 100644
--- a/src/features/app-config/schema.ts
+++ b/src/features/app-config/schema.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
-const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
+const appBannerDisplayScopes = ["home", "room", "recharge", "me"] as const;
const requiredH5LinkURLSchema = z
.string()
diff --git a/src/features/rooms/api.ts b/src/features/rooms/api.ts
index a4793e2..b64920e 100644
--- a/src/features/rooms/api.ts
+++ b/src/features/rooms/api.ts
@@ -6,10 +6,14 @@ import type {
PageQuery,
RoomConfigDto,
RoomConfigPayload,
+ RoomWhitelistConfigDto,
+ RoomWhitelistPayload,
RoomDto,
RoomPinDto,
RoomPinPayload,
AvailableRoomRobotDto,
+ HumanRoomRobotConfigDto,
+ HumanRoomRobotConfigPayload,
RobotRoomDto,
RobotRoomPayload,
RoomUpdatePayload,
@@ -35,7 +39,7 @@ export function listRoomPins(query: PageQuery = {}): Promise
const endpoint = API_ENDPOINTS.listRoomPins;
return apiRequest>(apiEndpointPath(API_OPERATIONS.listRoomPins), {
method: endpoint.method,
- query
+ query,
});
}
@@ -43,14 +47,14 @@ export function createRoomPin(payload: RoomPinPayload): Promise {
const endpoint = API_ENDPOINTS.createRoomPin;
return apiRequest(apiEndpointPath(API_OPERATIONS.createRoomPin), {
body: payload,
- method: endpoint.method
+ method: endpoint.method,
});
}
export function cancelRoomPin(pinId: EntityId): Promise {
const endpoint = API_ENDPOINTS.cancelRoomPin;
return apiRequest(apiEndpointPath(API_OPERATIONS.cancelRoomPin, { pin_id: pinId }), {
- method: endpoint.method
+ method: endpoint.method,
});
}
@@ -76,6 +80,24 @@ export function updateRoomConfig(payload: RoomConfigPayload): Promise {
+ const endpoint = API_ENDPOINTS.getRoomWhitelistConfig;
+ return apiRequest(apiEndpointPath(API_OPERATIONS.getRoomWhitelistConfig), {
+ method: endpoint.method,
+ });
+}
+
+export function updateRoomWhitelistConfig(payload: RoomWhitelistPayload): Promise {
+ const endpoint = API_ENDPOINTS.updateRoomWhitelistConfig;
+ return apiRequest(
+ apiEndpointPath(API_OPERATIONS.updateRoomWhitelistConfig),
+ {
+ body: payload,
+ method: endpoint.method,
+ },
+ );
+}
+
export function listRobotRooms(query: PageQuery = {}): Promise> {
const endpoint = API_ENDPOINTS.listRobotRooms;
return apiRequest>(apiEndpointPath(API_OPERATIONS.listRobotRooms), {
@@ -84,6 +106,24 @@ export function listRobotRooms(query: PageQuery = {}): Promise {
+ const endpoint = API_ENDPOINTS.getHumanRoomRobotConfig;
+ return apiRequest(apiEndpointPath(API_OPERATIONS.getHumanRoomRobotConfig), {
+ method: endpoint.method,
+ });
+}
+
+export function updateHumanRoomRobotConfig(payload: HumanRoomRobotConfigPayload): Promise {
+ const endpoint = API_ENDPOINTS.updateHumanRoomRobotConfig;
+ return apiRequest(
+ apiEndpointPath(API_OPERATIONS.updateHumanRoomRobotConfig),
+ {
+ body: payload,
+ method: endpoint.method,
+ },
+ );
+}
+
export function listAvailableRoomRobots(): Promise {
const endpoint = API_ENDPOINTS.listAvailableRoomRobots;
return apiRequest(apiEndpointPath(API_OPERATIONS.listAvailableRoomRobots), {
diff --git a/src/features/rooms/hooks/useRobotRoomsPage.js b/src/features/rooms/hooks/useRobotRoomsPage.js
index 1024c54..0cdb2c2 100644
--- a/src/features/rooms/hooks/useRobotRoomsPage.js
+++ b/src/features/rooms/hooks/useRobotRoomsPage.js
@@ -1,15 +1,19 @@
import { useEffect, useMemo, useState } from "react";
+import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
import { listGifts } from "@/features/resources/api";
import {
createRobotRoom,
+ getHumanRoomRobotConfig,
listAvailableRoomRobots,
listRobotRooms,
startRobotRoom,
stopRobotRoom,
+ updateHumanRoomRobotConfig,
} from "@/features/rooms/api";
import { useRoomAbilities } from "@/features/rooms/permissions.js";
-import { robotRoomCreateSchema } from "@/features/rooms/schema";
+import { humanRoomRobotConfigSchema, robotRoomCreateSchema } from "@/features/rooms/schema";
import { parseForm } from "@/shared/forms/validation";
+import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@@ -25,22 +29,55 @@ function emptyForm() {
luckyGiftIds: [],
luckyPauseMaxMs: "20000",
luckyPauseMinMs: "5000",
+ maxGiftSenders: "1",
maxRobotCount: "8",
minRobotCount: "6",
normalGiftIntervalMs: "10000",
ownerRobotUserId: "",
+ robotReplaceMaxMs: "60000",
+ robotReplaceMinMs: "0",
+ robotStayMaxMs: "600000",
+ robotStayMinMs: "180000",
+ seatCount: "10",
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() {
const abilities = useRoomAbilities();
+ const { countryOptions, loadingCountries } = useCountryOptions();
+ const { loadingRegions, regionOptions } = useRegionOptions();
const { showToast } = useToast();
const [activeAction, setActiveAction] = useState("");
const [availableRobotsState, setAvailableRobotsState] = useState({ error: "", loading: false, options: [] });
const [candidatesTouched, setCandidatesTouched] = useState(false);
const [form, setForm] = useState(emptyForm);
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 [page, setPage] = useState(1);
const [status, setStatus] = useState("active");
@@ -79,12 +116,29 @@ export function useRobotRoomsPage() {
}
};
- useEffect(() => {
- if (activeAction !== "create") {
- return;
+ 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(() => {
+ loadHumanConfig();
+ }, []);
+
+ useEffect(() => {
+ if (activeAction === "create") {
+ loadAvailableRobots();
+ loadGiftOptions();
+ }
+ if (activeAction === "human-config") {
+ loadGiftOptions();
}
- loadAvailableRobots();
- loadGiftOptions();
}, [activeAction]);
useEffect(() => {
@@ -115,6 +169,11 @@ export function useRobotRoomsPage() {
setGiftOptionsState({ error: "", loading: false, options: [] });
};
+ const openHumanConfig = () => {
+ setActiveAction("human-config");
+ setHumanConfigForm(humanConfigToForm(humanConfigState.config));
+ };
+
const closeAction = () => {
setActiveAction("");
};
@@ -149,7 +208,9 @@ export function useRobotRoomsPage() {
setForm((current) => ({
...current,
ownerRobotUserId,
- candidateRobotUserIds: (current.candidateRobotUserIds || []).filter((userId) => String(userId) !== String(ownerRobotUserId)),
+ candidateRobotUserIds: (current.candidateRobotUserIds || []).filter(
+ (userId) => String(userId) !== String(ownerRobotUserId),
+ ),
}));
};
@@ -167,6 +228,21 @@ export function useRobotRoomsPage() {
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) => {
setLoadingAction(action);
try {
@@ -191,6 +267,14 @@ export function useRobotRoomsPage() {
() => 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 selected = new Set((form.giftIds || []).map(String));
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
@@ -199,6 +283,25 @@ export function useRobotRoomsPage() {
const selected = new Set((form.luckyGiftIds || []).map(String));
return giftOptionsState.options.filter((item) => selected.has(giftId(item)));
}, [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 {
abilities,
@@ -209,30 +312,79 @@ export function useRobotRoomsPage() {
changeStatus,
closeAction,
candidateRobotOptions,
+ countryOptions,
data,
error,
form,
giftOptions: giftOptionsState.options,
giftOptionsError: giftOptionsState.error,
giftOptionsLoading: giftOptionsState.loading,
+ humanConfig: humanConfigState.config,
+ humanConfigError: humanConfigState.error,
+ humanConfigForm,
+ humanConfigLoading: humanConfigState.loading,
loading,
loadingAction,
+ loadingRobotLocationOptions: loadingCountries || loadingRegions,
openCreate,
+ openHumanConfig,
page,
reload,
resetFilters,
selectCandidateRobots,
selectGiftIds,
+ selectHumanGiftIds,
selectOwnerRobot,
selectedCandidateRobots,
selectedGiftOptions,
selectedLuckyGiftOptions,
+ selectedHumanGiftOptions,
selectedOwnerRobot,
+ robotCountryLabels: countryLabels,
+ robotRegionLabels: regionLabels,
setForm,
+ setHumanConfigForm,
setPage,
setRoomStatus,
status,
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),
};
}
@@ -244,6 +396,18 @@ export function robotOptionLabel(robot) {
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) {
return String(gift?.giftId || "");
}
diff --git a/src/features/rooms/hooks/useRobotRoomsPage.test.js b/src/features/rooms/hooks/useRobotRoomsPage.test.js
new file mode 100644
index 0000000..68aa3a1
--- /dev/null
+++ b/src/features/rooms/hooks/useRobotRoomsPage.test.js
@@ -0,0 +1,25 @@
+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");
+ });
+});
diff --git a/src/features/rooms/hooks/useRoomWhitelistPage.js b/src/features/rooms/hooks/useRoomWhitelistPage.js
new file mode 100644
index 0000000..fbfa9ec
--- /dev/null
+++ b/src/features/rooms/hooks/useRoomWhitelistPage.js
@@ -0,0 +1,76 @@
+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))] : [];
+}
diff --git a/src/features/rooms/pages/RoomRobotPage.jsx b/src/features/rooms/pages/RoomRobotPage.jsx
index b076797..41655d3 100644
--- a/src/features/rooms/pages/RoomRobotPage.jsx
+++ b/src/features/rooms/pages/RoomRobotPage.jsx
@@ -1,5 +1,6 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
+import TuneOutlined from "@mui/icons-material/TuneOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
@@ -15,6 +16,7 @@ import { formatMillis } from "@/shared/utils/time.js";
import {
giftId,
giftOptionLabel,
+ robotLocationLabel,
robotOptionLabel,
useRobotRoomsPage,
} from "@/features/rooms/hooks/useRobotRoomsPage.js";
@@ -41,9 +43,16 @@ const columns = [
},
{
key: "robots",
- label: "进房机器人",
- width: "minmax(120px, 0.5fr)",
- render: (room) => Number(room.robotUserIds?.length || room.robots?.length || 0).toLocaleString("zh-CN"),
+ label: "机器人",
+ width: "minmax(150px, 0.6fr)",
+ render: (room) => (
+
+ ),
},
{
key: "normalRule",
@@ -72,6 +81,20 @@ const columns = [
/>
),
},
+ {
+ key: "behaviorRule",
+ label: "行为",
+ width: "minmax(220px, 0.9fr)",
+ render: (room) => (
+
+ ),
+ },
{
key: "createdAt",
label: "创建时间",
@@ -115,12 +138,24 @@ export function RoomRobotPage() {
{page.abilities.canRobotCreate ? (
- } variant="primary" onClick={page.openCreate}>
+ }
+ variant="secondary"
+ onClick={page.openHumanConfig}
+ >
+ 真人房间机器人配置
+
+ }
+ variant="primary"
+ onClick={page.openCreate}
+ >
增加机器人房间
) : null}
+
@@ -170,7 +205,12 @@ export function RoomRobotPage() {
)}
renderOption={(props, option) => (
-
+
)}
size="small"
@@ -245,6 +285,43 @@ export function RoomRobotPage() {
value={page.form.normalGiftIntervalMs}
onChange={(event) => page.setForm({ ...page.form, normalGiftIntervalMs: event.target.value })}
/>
+
+ page.setForm({ ...page.form, robotStayMinMs: event.target.value })}
+ />
+ page.setForm({ ...page.form, robotStayMaxMs: event.target.value })}
+ />
+ page.setForm({ ...page.form, robotReplaceMinMs: event.target.value })}
+ />
+ page.setForm({ ...page.form, robotReplaceMaxMs: event.target.value })}
+ />
+ page.setForm({ ...page.form, maxGiftSenders: event.target.value })}
+ />
+
+
);
}
+function HumanConfigPanel({ page }) {
+ const config = page.humanConfig || {};
+ return (
+
+
+
真人房间机器人
+
+ {page.humanConfigLoading
+ ? "配置加载中"
+ : page.humanConfigError || (config.enabled ? "已启用" : "未启用")}
+
+
+
+
+ {page.abilities.canRobotUpdate ? (
+ }
+ variant="secondary"
+ onClick={page.openHumanConfig}
+ >
+ 配置
+
+ ) : null}
+
+ );
+}
+
+function HumanConfigDialog({ page }) {
+ return (
+
+
+
+
+ page.setHumanConfigForm({
+ ...page.humanConfigForm,
+ candidateRoomMaxOnline: event.target.value,
+ })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMinOnline: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, roomTargetMaxOnline: event.target.value })
+ }
+ />
+
+
+
+ page.setHumanConfigForm({
+ ...page.humanConfigForm,
+ normalGiftIntervalMinMs: event.target.value,
+ })
+ }
+ />
+
+ page.setHumanConfigForm({
+ ...page.humanConfigForm,
+ normalGiftIntervalMaxMs: event.target.value,
+ })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMinMs: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, robotStayMaxMs: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMinMs: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, robotReplaceMaxMs: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, maxGiftSenders: event.target.value })
+ }
+ />
+
+ page.selectHumanGiftIds("giftIds", options)}
+ />
+
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMin: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, luckyComboMax: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMinMs: event.target.value })
+ }
+ />
+
+ page.setHumanConfigForm({ ...page.humanConfigForm, luckyPauseMaxMs: event.target.value })
+ }
+ />
+
+ page.selectHumanGiftIds("luckyGiftIds", options)}
+ />
+ page.selectHumanGiftIds("superLuckyGiftIds", options)}
+ />
+
+ 机器人池自动获取
+
+ 系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。
+
+
+
+ );
+}
+
+function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) {
+ return (
+ giftId(option) === giftId(selected)}
+ loading={loading}
+ noOptionsText="当前无数据"
+ options={options}
+ renderInput={(params) => (
+ {label} : label} />
+ )}
+ size="small"
+ value={value}
+ onChange={(_, selected) => onChange(selected)}
+ />
+ );
+}
+
function RobotRoomIdentity({ room }) {
return (
@@ -325,6 +663,17 @@ function RobotIdentity({ fallbackId = "", openInAppUserDetail = true, user = {}
);
}
+function OwnerRobotOption({ countryLabels, loadingLocation, option, regionLabels }) {
+ return (
+
+
+
+ {loadingLocation ? "国家/区域加载中" : robotLocationLabel(option, countryLabels, regionLabels)}
+
+
+ );
+}
+
function RequiredLabel({ children }) {
return (
@@ -369,3 +718,7 @@ function formatSeconds(ms) {
const seconds = Math.round(Number(ms || 0) / 1000);
return `${seconds.toLocaleString("zh-CN")}秒`;
}
+
+function formatDurationRange(minMs, maxMs) {
+ return `${formatSeconds(minMs)}-${formatSeconds(maxMs)}`;
+}
diff --git a/src/features/rooms/pages/RoomWhitelistPage.jsx b/src/features/rooms/pages/RoomWhitelistPage.jsx
new file mode 100644
index 0000000..9ac3e1a
--- /dev/null
+++ b/src/features/rooms/pages/RoomWhitelistPage.jsx
@@ -0,0 +1,62 @@
+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 (
+
+
+
+
+ ) : null
+ }
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/rooms/permissions.js b/src/features/rooms/permissions.js
index 52f57ff..2392256 100644
--- a/src/features/rooms/permissions.js
+++ b/src/features/rooms/permissions.js
@@ -7,6 +7,8 @@ export function useRoomAbilities() {
return {
canUpdateConfig: can(PERMISSIONS.roomConfigUpdate),
canViewConfig: can(PERMISSIONS.roomConfigView),
+ canUpdateWhitelist: can(PERMISSIONS.roomWhitelistUpdate),
+ canViewWhitelist: can(PERMISSIONS.roomWhitelistView),
canDelete: can(PERMISSIONS.roomDelete),
canPinCancel: can(PERMISSIONS.roomPinCancel),
canPinCreate: can(PERMISSIONS.roomPinCreate),
diff --git a/src/features/rooms/rooms.module.css b/src/features/rooms/rooms.module.css
index 7cfb07f..188e14e 100644
--- a/src/features/rooms/rooms.module.css
+++ b/src/features/rooms/rooms.module.css
@@ -48,10 +48,20 @@
}
.toolbarActions {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
- gap: var(--space-2);
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ 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 {
@@ -64,6 +74,11 @@
.formGrid {
grid-template-columns: 1fr;
}
+
+ .robotConfigPanel {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
}
.search {
@@ -182,6 +197,41 @@
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 {
display: inline-flex;
min-width: 0;
@@ -223,6 +273,21 @@
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 {
display: inline-flex;
align-items: center;
diff --git a/src/features/rooms/routes.js b/src/features/rooms/routes.js
index 75f4fc1..76737c0 100644
--- a/src/features/rooms/routes.js
+++ b/src/features/rooms/routes.js
@@ -25,6 +25,14 @@ export const roomRoutes = [
path: "/rooms/config",
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: "机器人房间",
loader: () => import("./pages/RoomRobotPage.jsx").then((module) => module.RoomRobotPage),
diff --git a/src/features/rooms/schema.test.ts b/src/features/rooms/schema.test.ts
index 956225b..b52cee0 100644
--- a/src/features/rooms/schema.test.ts
+++ b/src/features/rooms/schema.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { parseForm } from "@/shared/forms/validation";
-import { robotRoomCreateSchema } from "./schema";
+import { humanRoomRobotConfigSchema, robotRoomCreateSchema, roomWhitelistSchema } from "./schema";
describe("robot room form schema", () => {
test("preserves int64 robot user ids as strings", () => {
@@ -13,14 +13,92 @@ describe("robot room form schema", () => {
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"]);
});
});
diff --git a/src/features/rooms/schema.ts b/src/features/rooms/schema.ts
index cb33d5c..f967906 100644
--- a/src/features/rooms/schema.ts
+++ b/src/features/rooms/schema.ts
@@ -2,10 +2,11 @@ import { z } from "zod";
const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
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 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
.string()
.trim()
@@ -61,6 +62,20 @@ export const roomConfigSchema = z
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
.object({
expiresAtMs: dateTimeMsSchema,
@@ -83,10 +98,19 @@ export const robotRoomCreateSchema = z
luckyGiftIds: z.array(z.string().trim().min(1)).min(1, "请选择幸运礼物合集"),
luckyPauseMaxMs: 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, "机器人数量范围不正确"),
minRobotCount: z.coerce.number().int().min(1, "机器人数量范围不正确"),
normalGiftIntervalMs: z.coerce.number().int().min(1000, "普通礼物频次不正确"),
ownerRobotUserId: robotUserIdSchema("请选择房主机器人"),
+ 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),
})
.refine((value) => value.maxRobotCount >= value.minRobotCount, {
@@ -100,10 +124,81 @@ export const robotRoomCreateSchema = z
.refine((value) => value.luckyPauseMaxMs >= value.luckyPauseMinMs, {
message: "幸运礼物间隔范围不正确",
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;
export type RoomStatusForm = z.infer;
export type RoomConfigForm = z.infer;
+export type RoomWhitelistForm = z.infer;
export type RoomPinCreateForm = z.infer;
export type RobotRoomCreateForm = z.infer;
+export type HumanRoomRobotConfigForm = z.infer;
diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts
index f0f7803..1613da7 100644
--- a/src/shared/api/generated/endpoints.ts
+++ b/src/shared/api/generated/endpoints.ts
@@ -106,6 +106,7 @@ export const API_OPERATIONS = {
getCPConfig: "getCPConfig",
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
+ getHumanRoomRobotConfig: "getHumanRoomRobotConfig",
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
getLuckyGiftConfig: "getLuckyGiftConfig",
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
@@ -121,6 +122,7 @@ export const API_OPERATIONS = {
getRoomRpsChallenge: "getRoomRpsChallenge",
getRoomRpsConfig: "getRoomRpsConfig",
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
+ getRoomWhitelistConfig: "getRoomWhitelistConfig",
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
getUser: "getUser",
getWeeklyStarCycle: "getWeeklyStarCycle",
@@ -243,6 +245,7 @@ export const API_OPERATIONS = {
updateH5Link: "updateH5Link",
updateH5Links: "updateH5Links",
updateHostAgencyPolicy: "updateHostAgencyPolicy",
+ updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
updateMenu: "updateMenu",
updateMenuVisible: "updateMenuVisible",
@@ -263,6 +266,7 @@ export const API_OPERATIONS = {
updateRoomRocketConfig: "updateRoomRocketConfig",
updateRoomRpsConfig: "updateRoomRpsConfig",
updateRoomTurnoverRewardConfig: "updateRoomTurnoverRewardConfig",
+ updateRoomWhitelistConfig: "updateRoomWhitelistConfig",
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
updateSplashScreen: "updateSplashScreen",
updateTaskDefinition: "updateTaskDefinition",
@@ -932,6 +936,13 @@ export const API_ENDPOINTS: Record = {
permission: "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: {
method: "GET",
operationId: API_OPERATIONS.getInviteActivityRewardConfig,
@@ -1037,6 +1048,13 @@ export const API_ENDPOINTS: Record = {
permission: "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: {
method: "GET",
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
@@ -1877,6 +1895,13 @@ export const API_ENDPOINTS: Record = {
permission: "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: {
method: "PUT",
operationId: API_OPERATIONS.updateInviteActivityRewardConfig,
@@ -2017,6 +2042,13 @@ export const API_ENDPOINTS: Record = {
permission: "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: {
method: "PUT",
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index 558713d..0c4a1fb 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -2164,6 +2164,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -2228,6 +2244,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -6104,6 +6136,30 @@ export interface operations {
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: {
parameters: {
query?: never;
@@ -6178,6 +6234,30 @@ export interface operations {
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: {
parameters: {
query?: never;
diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts
index 7457ecb..0f031ca 100644
--- a/src/shared/api/types.ts
+++ b/src/shared/api/types.ts
@@ -945,7 +945,14 @@ export interface RobotGiftRuleDto {
luckyComboMin?: number;
luckyPauseMaxMs?: number;
luckyPauseMinMs?: number;
+ maxGiftSenders?: number;
normalGiftIntervalMs?: number;
+ normalGiftIntervalMinMs?: number;
+ normalGiftIntervalMaxMs?: number;
+ robotReplaceMaxMs?: number;
+ robotReplaceMinMs?: number;
+ robotStayMaxMs?: number;
+ robotStayMinMs?: number;
}
export interface RobotRoomDto {
@@ -959,6 +966,8 @@ export interface RobotRoomDto {
regionName?: string;
robots?: RoomOwnerDto[];
robotUserIds?: string[];
+ activeRobotCount?: number;
+ seatCount?: number;
roomId: string;
roomShortId?: string;
status?: string;
@@ -984,22 +993,78 @@ export interface RobotRoomPayload {
luckyGiftIds: string[];
luckyPauseMaxMs: number;
luckyPauseMinMs: number;
+ maxGiftSenders: number;
maxRobotCount: number;
minRobotCount: number;
normalGiftIntervalMs: number;
ownerRobotUserId: EntityId;
+ robotReplaceMaxMs: number;
+ robotReplaceMinMs: number;
+ robotStayMaxMs: number;
+ robotStayMinMs: number;
+ seatCount: number;
roomAvatar?: string;
roomName?: string;
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 {
avatar?: string;
+ countryCode?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId?: string;
username?: string;
+ visibleRegionId?: number;
}
export interface RoomUpdatePayload {
@@ -1024,6 +1089,15 @@ export interface RoomConfigPayload {
defaultSeatCount: number;
}
+export interface RoomWhitelistConfigDto {
+ updatedAtMs?: number;
+ userIds: EntityId[];
+}
+
+export interface RoomWhitelistPayload {
+ userIds: EntityId[];
+}
+
export interface UploadResultDto {
content_type?: string;
contentType?: string;
@@ -1087,8 +1161,8 @@ export interface AppBannerDto {
coverUrl: string;
createdAtMs?: number;
description?: string;
- displayScope?: "home" | "room" | "recharge" | string;
- displayScopes?: Array<"home" | "room" | "recharge" | string>;
+ displayScope?: "home" | "room" | "recharge" | "me" | string;
+ displayScopes?: Array<"home" | "room" | "recharge" | "me" | string>;
endsAtMs?: number;
id: number;
param?: string;
@@ -1106,8 +1180,8 @@ export interface AppBannerPayload {
countryCode?: string;
coverUrl: string;
description?: string;
- displayScope?: "home" | "room" | "recharge";
- displayScopes?: Array<"home" | "room" | "recharge">;
+ displayScope?: "home" | "room" | "recharge" | "me";
+ displayScopes?: Array<"home" | "room" | "recharge" | "me">;
endsAtMs?: number;
param?: string;
platform: "android" | "ios";