From 9d930da859a6567fb2681e4fc8cd825b081730b7 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 17 Jun 2026 23:13:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 44 +++ databi/src/DatabiApp.jsx | 6 + databi/src/DatabiApp.test.jsx | 25 +- databi/src/data/createDashboardModel.js | 12 + databi/src/data/createDashboardModel.test.js | 18 +- src/app/navigation/menu.js | 1 + src/app/permissions.ts | 3 + src/features/app-config/constants.ts | 1 + .../app-config/hooks/useBannerConfigPage.js | 2 +- src/features/app-config/schema.test.ts | 4 +- src/features/app-config/schema.ts | 2 +- src/features/rooms/api.ts | 46 ++- src/features/rooms/hooks/useRobotRoomsPage.js | 178 ++++++++- .../rooms/hooks/useRobotRoomsPage.test.js | 25 ++ .../rooms/hooks/useRoomWhitelistPage.js | 76 ++++ src/features/rooms/pages/RoomRobotPage.jsx | 363 +++++++++++++++++- .../rooms/pages/RoomWhitelistPage.jsx | 62 +++ src/features/rooms/permissions.js | 2 + src/features/rooms/rooms.module.css | 73 +++- src/features/rooms/routes.js | 8 + src/features/rooms/schema.test.ts | 80 +++- src/features/rooms/schema.ts | 103 ++++- src/shared/api/generated/endpoints.ts | 32 ++ src/shared/api/generated/schema.d.ts | 80 ++++ src/shared/api/types.ts | 82 +++- 25 files changed, 1294 insertions(+), 34 deletions(-) create mode 100644 src/features/rooms/hooks/useRobotRoomsPage.test.js create mode 100644 src/features/rooms/hooks/useRoomWhitelistPage.js create mode 100644 src/features/rooms/pages/RoomWhitelistPage.jsx 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 ? (
- +
) : 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 ? ( + + ) : 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 + } + /> + + +
    +
    +
    + + + +
    +

    用户 ID

    + + {page.data?.updatedAtMs ? : "未配置"} + +
    +
    + + page.setText(event.target.value)} + /> +
    +
    +
    +
    +
    + ); +} 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";