diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 06035bf..809477c 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -134,6 +134,20 @@ ] } }, + "/admin/activity/user-leaderboards": { + "get": { + "operationId": "listUserLeaderboards", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "user-leaderboard:view", + "x-permissions": [ + "user-leaderboard:view" + ] + } + }, "/admin/activity/lucky-gifts/config": { "get": { "operationId": "getLuckyGiftConfig", @@ -160,6 +174,20 @@ ] } }, + "/admin/activity/lucky-gifts/configs": { + "get": { + "operationId": "listLuckyGiftConfigs", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "lucky-gift:view", + "x-permissions": [ + "lucky-gift:view" + ] + } + }, "/admin/activity/lucky-gifts/draws": { "get": { "operationId": "listLuckyGiftDraws", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index dcb7777..624400c 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -11,6 +11,7 @@ import HistoryOutlined from "@mui/icons-material/HistoryOutlined"; import HubOutlined from "@mui/icons-material/HubOutlined"; import ImageOutlined from "@mui/icons-material/ImageOutlined"; import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; +import LeaderboardOutlined from "@mui/icons-material/LeaderboardOutlined"; import LoginOutlined from "@mui/icons-material/LoginOutlined"; import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined"; import MapOutlined from "@mui/icons-material/MapOutlined"; @@ -42,6 +43,7 @@ const iconMap = { event_available: EventAvailableOutlined, gift: CardGiftcardOutlined, inventory: Inventory2Outlined, + leaderboard: LeaderboardOutlined, network: HubOutlined, team: GroupsOutlined, history: HistoryOutlined, @@ -147,6 +149,7 @@ export const fallbackNavigation = [ routeNavItem("daily-task-list", { icon: TaskAltOutlined }), routeNavItem("registration-reward", { icon: CardGiftcardOutlined }), routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }), + routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }), ], }, { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 86176a6..c19818c 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -108,6 +108,7 @@ export const PERMISSIONS = { luckyGiftUpdate: "lucky-gift:update", roomTreasureView: "room-treasure:view", roomTreasureUpdate: "room-treasure:update", + userLeaderboardView: "user-leaderboard:view", uploadCreate: "upload:create", } as const; @@ -154,6 +155,7 @@ export const MENU_CODES = { achievementConfig: "achievement-config", sevenDayCheckIn: "seven-day-checkin", roomTreasure: "room-treasure", + userLeaderboard: "user-leaderboard", geo: "geo", hostOrg: "host-org", hostOrgCountries: "host-org-countries", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index b3cef8f..1605d7d 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -19,6 +19,7 @@ import { rolesRoutes } from "@/features/roles/routes.js"; import { roomRoutes } from "@/features/rooms/routes.js"; import { roomTreasureRoutes } from "@/features/room-treasure/routes.js"; import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js"; +import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js"; import { usersRoutes } from "@/features/users/routes.js"; import type { MenuCode } from "@/app/permissions"; import type { AdminRoute, PublicRoute } from "./types"; @@ -37,6 +38,7 @@ export const adminRoutes: AdminRoute[] = [ ...registrationRewardRoutes, ...sevenDayCheckInRoutes, ...roomTreasureRoutes, + ...userLeaderboardRoutes, ...resourceRoutes, ...operationsRoutes, ...luckyGiftRoutes, diff --git a/src/features/games/api.ts b/src/features/games/api.ts index 4bcdaa9..562b3ab 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -8,6 +8,14 @@ export interface GamePlatformDto { platformName: string; status: string; apiBaseUrl: string; + // 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1。 + adapterType: string; + // callbackSecret 只用于提交更新;列表返回不会携带明文。 + callbackSecret?: string; + callbackSecretSet: boolean; + callbackIpWhitelist: string[]; + // 厂商扩展配置保留 JSON 字符串,后台前端不绑定具体字段结构。 + adapterConfigJson: string; sortOrder: number; createdAtMs?: number; updatedAtMs?: number; @@ -44,7 +52,10 @@ export interface GamePlatformPage { serverTimeMs?: number; } -export type GamePlatformPayload = Omit; +export type GamePlatformPayload = Omit< + GamePlatformDto, + "appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet" +>; export type GameCatalogPayload = Omit; export interface GameCatalogQuery { @@ -118,6 +129,12 @@ function normalizePlatform(platform: Partial): GamePlatformDto platformName: stringValue(platform.platformName), status: stringValue(platform.status || "active"), apiBaseUrl: stringValue(platform.apiBaseUrl), + adapterType: stringValue(platform.adapterType || "demo"), + callbackSecretSet: Boolean(platform.callbackSecretSet), + callbackIpWhitelist: Array.isArray(platform.callbackIpWhitelist) + ? platform.callbackIpWhitelist.map(String).filter(Boolean) + : [], + adapterConfigJson: stringValue(platform.adapterConfigJson || "{}"), sortOrder: numberValue(platform.sortOrder), createdAtMs: numberValue(platform.createdAtMs), updatedAtMs: numberValue(platform.updatedAtMs), diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index b6bce6c..5ba53cb 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -55,3 +55,25 @@ .fullField { grid-column: 1 / -1; } + +.platformList { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.platformRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.platformActions { + display: inline-flex; + align-items: center; + gap: 8px; +} diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index c86192c..9d69f2f 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -31,6 +31,14 @@ const defaultPlatformForm = { platformName: "", status: "active", apiBaseUrl: "", + // 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1。 + adapterType: "demo", + // 密钥只写不读,打开编辑弹窗时始终为空,避免明文停留在浏览器状态里。 + callbackSecret: "", + callbackSecretSet: false, + callbackIpWhitelistText: "", + // 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。 + adapterConfigJson: "{}", sortOrder: 0, }; @@ -150,6 +158,10 @@ export function useGamesPage() { setActiveAction("create-platform"); }; + const openManagePlatforms = () => { + setActiveAction("manage-platforms"); + }; + const openEditPlatform = (platform) => { setEditingPlatformCode(platform.platformCode); setPlatformForm(platformToForm(platform)); @@ -224,6 +236,7 @@ export function useGamesPage() { openCreatePlatform, openEditGame, openEditPlatform, + openManagePlatforms, pageSize, platformCode, platformData, @@ -267,6 +280,12 @@ function platformToForm(platform) { platformName: platform.platformName || "", status: platform.status || "active", apiBaseUrl: platform.apiBaseUrl || "", + adapterType: platform.adapterType || "demo", + // 后端不会返回 callbackSecret 明文;这里保留空字符串代表“不更新密钥”。 + callbackSecret: "", + callbackSecretSet: Boolean(platform.callbackSecretSet), + callbackIpWhitelistText: (platform.callbackIpWhitelist || []).join("\n"), + adapterConfigJson: platform.adapterConfigJson || "{}", sortOrder: Number(platform.sortOrder || 0), }; } @@ -298,6 +317,16 @@ function platformPayload(form) { platformName: form.platformName.trim(), status: form.status.trim() || "active", apiBaseUrl: form.apiBaseUrl.trim(), + adapterType: form.adapterType.trim() || "demo", + // 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。 + callbackSecret: form.callbackSecret.trim(), + // 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。 + callbackIpWhitelist: form.callbackIpWhitelistText + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean), + // JSON 不在前端强解析,避免前端版本限制后端新增厂商字段。 + adapterConfigJson: form.adapterConfigJson.trim() || "{}", sortOrder: Number(form.sortOrder || 0), }; } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 8159c52..c251552 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -1,5 +1,6 @@ import Add from "@mui/icons-material/Add"; import EditOutlined from "@mui/icons-material/EditOutlined"; +import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import MenuItem from "@mui/material/MenuItem"; @@ -32,6 +33,12 @@ const orientationOptions = [ ["landscape", "Landscape"], ]; const launchModeOptions = [["h5_popup", "H5 弹窗"]]; +// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。 +const adapterTypeOptions = [ + ["demo", "Demo"], + ["yomi_v4", "小游 Yomi V4"], + ["leadercc_v1", "灵仙 LeaderCC V1"], +]; const baseColumns = [ { @@ -128,6 +135,11 @@ export function GameListPage() { + {page.abilities.canUpdate ? ( + + + + ) : null} {page.abilities.canUpdate ? ( @@ -178,6 +190,11 @@ export function GameListPage() { onClose={page.closeAction} onSubmit={page.submitPlatform} /> + ); } @@ -319,15 +336,15 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) value={form.sortOrder} onChange={(event) => page.setGameForm({ ...form, sortOrder: event.target.value })} /> - - page.setGameForm({ - ...form, + + page.setGameForm({ + ...form, status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", }) } @@ -386,15 +403,15 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit value={form.platformName} onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })} /> - - page.setPlatformForm({ - ...form, + + page.setPlatformForm({ + ...form, status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", }) } @@ -408,18 +425,101 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit - page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} - /> + + {/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */} + page.setPlatformForm({ ...form, adapterType: event.target.value })} + > + {adapterTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} + /> + {/* 密钥不回显;编辑平台时留空表示沿用旧密钥,输入新值才覆盖。 */} + page.setPlatformForm({ ...form, callbackSecret: event.target.value })} + /> + + page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value }) + } + /> + {/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */} + page.setPlatformForm({ ...form, adapterConfigJson: event.target.value })} + /> + ); } +function PlatformManageDialog({ open, page, onClose }) { + const platforms = page.platformData.items || []; + return ( + { + event.preventDefault(); + onClose(); + }} + > +
+ {platforms.map((platform) => ( +
+
+ {platform.platformName || platform.platformCode} + + {platform.platformCode} / {platform.adapterType || "demo"} + + {platform.apiBaseUrl} +
+
+ + {platform.callbackSecretSet ? "密钥已配置" : "未配置密钥"} + + page.openEditPlatform(platform)} + > + + +
+
+ ))} +
+
+ ); +} + function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) { return ( diff --git a/src/features/lucky-gift/api.ts b/src/features/lucky-gift/api.ts index 4240173..56fad1c 100644 --- a/src/features/lucky-gift/api.ts +++ b/src/features/lucky-gift/api.ts @@ -6,6 +6,7 @@ export interface LuckyGiftTierDto { pool: string; tierId: string; rewardCoins: number; + multiplierPPM: number; weight: number; highWaterOnly: boolean; enabled: boolean; @@ -13,6 +14,7 @@ export interface LuckyGiftTierDto { export interface LuckyGiftConfigDto { appCode?: string; + poolId: string; enabled: boolean; ruleVersion: number; giftPrice: number; @@ -45,6 +47,7 @@ export interface LuckyGiftConfigDto { activityBudget: number; activityDailyLimit: number; largeTierEnabled: boolean; + multiplierPPMs: number[]; tiers: LuckyGiftTierDto[]; updatedByAdminId?: number; createdAtMs?: number; @@ -52,6 +55,7 @@ export interface LuckyGiftConfigDto { } export interface LuckyGiftConfigPayload { + pool_id: string; enabled: boolean; gift_price: number; target_rtp_ppm: number; @@ -83,10 +87,12 @@ export interface LuckyGiftConfigPayload { activity_budget: number; activity_daily_limit: number; large_tier_enabled: boolean; + multiplier_ppms: number[]; tiers: Array<{ pool: string; tier_id: string; reward_coins: number; + multiplier_ppm: number; weight: number; high_water_only: boolean; enabled: boolean; @@ -96,6 +102,7 @@ export interface LuckyGiftConfigPayload { export interface LuckyGiftDrawDto { drawId: string; commandId?: string; + poolId: string; giftId: string; ruleVersion: number; experiencePool: string; @@ -119,13 +126,21 @@ type RawConfig = LuckyGiftConfigDto & Record; type RawTier = LuckyGiftTierDto & Record; type RawDraw = LuckyGiftDrawDto & Record; -export function getLuckyGiftConfig(): Promise { +export function getLuckyGiftConfig(poolId: string): Promise { const endpoint = API_ENDPOINTS.getLuckyGiftConfig; return apiRequest(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), { method: endpoint.method, + query: { pool_id: poolId }, }).then(normalizeConfig); } +export function listLuckyGiftConfigs(): Promise { + const endpoint = API_ENDPOINTS.listLuckyGiftConfigs; + return apiRequest(apiEndpointPath(API_OPERATIONS.listLuckyGiftConfigs), { + method: endpoint.method, + }).then((items) => (items || []).map(normalizeConfig)); +} + export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise { const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig; return apiRequest( @@ -133,6 +148,7 @@ export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise< { body: payload, method: endpoint.method, + query: { pool_id: payload.pool_id }, }, ).then(normalizeConfig); } @@ -151,6 +167,7 @@ export function listLuckyGiftDraws(query: PageQuery = {}): Promise
基础控制
+ } + value={form.poolId} + /> updateForm(setForm, "noviceStrength", value)} /> - updateForm(setForm, "payoutStyle", value)} + help={helpText.multiplierValues} + setForm={setForm} + values={form.multiplierValues || []} /> 0 ? values : ["0"]; + return ( +
+
+ + +
+
+ {list.map((value, index) => ( +
+ updateMultiplier(setForm, index, event.target.value)} + /> + removeMultiplier(setForm, index)} + > + + +
+ ))} +
+
+ ); +} + function SelectField({ disabled, help, label, onChange, options, value }) { return ( ({ ...current, [key]: value })); } + +function updateMultiplier(setForm, index, value) { + setForm((current) => { + const nextValues = [...(current.multiplierValues || [])]; + nextValues[index] = value; + return { ...current, multiplierValues: nextValues }; + }); +} + +function addMultiplier(setForm) { + setForm((current) => ({ + ...current, + multiplierValues: [...(current.multiplierValues || []), ""], + })); +} + +function removeMultiplier(setForm, index) { + setForm((current) => ({ + ...current, + multiplierValues: (current.multiplierValues || []).filter((_, itemIndex) => itemIndex !== index), + })); +} diff --git a/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx b/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx index 134ef46..b767435 100644 --- a/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx +++ b/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx @@ -1,5 +1,8 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { formatMillis } from "@/shared/utils/time.js"; @@ -7,16 +10,24 @@ import { atmosphereLevelOptions, controlStrengthOptions, jackpotStrategyOptions, - luckyGiftConfigScopeLabel, noviceStrengthOptions, optionLabel, - payoutStyleOptions, poolModeOptions, } from "@/features/lucky-gift/constants.js"; import { formFromConfig } from "@/features/lucky-gift/configModel.js"; import styles from "@/features/lucky-gift/lucky-gift.module.css"; -export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) { +export function LuckyGiftConfigSummary({ + canUpdate, + config, + configLoading, + onAddPool, + onEdit, + onPoolChange, + onRefresh, + poolId, + poolOptions, +}) { const form = formFromConfig(config); return ( @@ -25,7 +36,7 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
幸运礼物配置 - {luckyGiftConfigScopeLabel} + 奖池 {poolId}
@@ -37,7 +48,7 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi {optionLabel(noviceStrengthOptions, form.noviceStrength)} - {optionLabel(payoutStyleOptions, form.payoutStyle)} + {formatMultipliers(form.multiplierValues)} {optionLabel(jackpotStrategyOptions, form.jackpotStrategy)} @@ -51,6 +62,23 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
+ onPoolChange(event.target.value)} + > + {(poolOptions || [poolId]).map((option) => ( + + {option} + + ))} + + + + @@ -68,6 +96,13 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi ); } +function formatMultipliers(values) { + if (!Array.isArray(values) || values.length === 0) { + return "-"; + } + return values.join(", "); +} + function SummaryItem({ children, label }) { return (
diff --git a/src/features/lucky-gift/configModel.js b/src/features/lucky-gift/configModel.js index e3bf170..86217ab 100644 --- a/src/features/lucky-gift/configModel.js +++ b/src/features/lucky-gift/configModel.js @@ -21,8 +21,9 @@ export function emptyLuckyGiftForm() { controlStrength: "standard", enabled: false, jackpotStrategy: "standard", + multiplierValues: ["0", "0.2", "0.5", "1", "2", "5", "10", "20", "50", "100", "500"], noviceStrength: "standard", - payoutStyle: "balanced", + poolId: "default", poolMode: "standard", riskLevel: "standard", targetRtpPercent: "95", @@ -43,8 +44,9 @@ export function formFromConfig(config) { controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws), enabled: Boolean(config.enabled), jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple), + multiplierValues: formatMultiplierValues(config.multiplierPPMs, config.tiers || []), noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit), - payoutStyle: inferPayoutStyle(config.tiers || []), + poolId: config.poolId || "default", poolMode: inferPoolMode(config), riskLevel: inferRiskLevel(config), targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM), @@ -61,6 +63,9 @@ export function payloadFromLuckyGiftForm(form, currentConfig) { const atmosphere = atmosphereLevelConfig[form.atmosphereLevel] || atmosphereLevelConfig.medium; const risk = riskCapsForLevel(form.riskLevel, giftPrice, targetRTPPPM); const base = currentConfig || {}; + const multiplierPPMs = parseMultiplierValues(form.multiplierValues); + const maxMultiplierPPM = Math.max(...multiplierPPMs); + const highMultiplier = positiveNumber(base.highMultiplier, 100); const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0; const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0; @@ -75,7 +80,7 @@ export function payloadFromLuckyGiftForm(form, currentConfig) { gift_reserve: positiveNumber(base.giftReserve, 1_000_000), global_window_draws: control.globalWindowDraws, gift_window_draws: control.giftWindowDraws, - high_multiplier: positiveNumber(base.highMultiplier, 100), + high_multiplier: highMultiplier, high_water_pool_multiple: jackpot.highWaterPoolMultiple, initial_gift_pool: positiveNumber(base.initialGiftPool, expectedPayout(giftPrice, targetRTPPPM, control.giftWindowDraws) / 2), initial_platform_pool: positiveNumber( @@ -85,10 +90,12 @@ export function payloadFromLuckyGiftForm(form, currentConfig) { initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000), intermediate_draw_limit: novice.intermediateDrawLimit, large_tier_enabled: jackpot.largeTierEnabled, - max_single_payout: risk.maxSinglePayout, + max_single_payout: Math.max(risk.maxSinglePayout, Math.ceil((giftPrice * maxMultiplierPPM) / ppmScale)), + multiplier_ppms: multiplierPPMs, novice_draw_limit: novice.noviceDrawLimit, platform_pool_weight_ppm: poolMode.platformPoolWeightPPM, platform_reserve: positiveNumber(base.platformReserve, 1_000_000), + pool_id: String(form.poolId || base.poolId || "default").trim() || "default", pool_rate_ppm: Math.max(targetRTPPPM, positiveNumber(base.poolRatePPM, targetRTPPPM)), room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200), room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM, @@ -97,7 +104,7 @@ export function payloadFromLuckyGiftForm(form, currentConfig) { room_pool_weight_ppm: poolMode.roomPoolWeightPPM, room_reserve: positiveNumber(base.roomReserve, giftPrice * 200), target_rtp_ppm: targetRTPPPM, - tiers: buildTiers(giftPrice, form.payoutStyle), + tiers: buildTiers(giftPrice, multiplierPPMs, highMultiplier), user_daily_payout_cap: risk.userDailyPayoutCap, user_hourly_payout_cap: risk.userHourlyPayoutCap, }; @@ -121,50 +128,69 @@ export function previewForForm(form) { }; } -function buildTiers(cost, style) { - const scale = style === "stable" ? 0 : style === "exciting" ? 2 : 1; - const noviceHigh = scale === 0 ? 200 : scale === 2 ? 900 : 500; - const intermediateHigh = scale === 0 ? 10_000 : scale === 2 ? 36_000 : 25_000; - const advancedHigh = scale === 0 ? 15_000 : scale === 2 ? 58_000 : 40_000; - - return [ - tier("novice", "none", 0, 720_000), - tier("novice", "novice_feedback_0_2x", Math.floor(cost / 5), 90_000), - tier("novice", "novice_rebate_0_5x", Math.floor(cost / 2), 70_000), - tier("novice", "novice_rebate_1x", cost, 70_000), - tier("novice", "novice_small_2x", cost * 2, 35_000), - tier("novice", "novice_small_5x", cost * 5, 12_000), - tier("novice", "novice_medium_10x", cost * 10, 2_800), - tier("novice", "novice_cap_20x", cost * 20, noviceHigh, true), - - tier("intermediate", "none", 0, 830_000), - tier("intermediate", "inter_rebate_0_5x", Math.floor(cost / 2), 25_000), - tier("intermediate", "inter_rebate_1x", cost, 40_000), - tier("intermediate", "inter_small_2x", cost * 2, 45_000), - tier("intermediate", "inter_medium_5x", cost * 5, 35_000), - tier("intermediate", "inter_large_20x", cost * 20, Math.floor(intermediateHigh * 0.8), true), - tier("intermediate", "inter_large_50x", cost * 50, Math.floor(intermediateHigh * 0.2), true), - - tier("advanced", "none", 0, 900_000), - tier("advanced", "adv_small_2x", cost * 2, 30_000), - tier("advanced", "adv_medium_5x", cost * 5, 30_000), - tier("advanced", "adv_large_20x", cost * 20, Math.floor(advancedHigh * 0.75), true), - tier("advanced", "adv_large_100x", cost * 100, Math.floor(advancedHigh * 0.225), true), - tier("advanced", "adv_jackpot_500x", cost * 500, Math.max(100, Math.floor(advancedHigh * 0.025)), true), - ]; +function buildTiers(cost, multiplierPPMs, highMultiplier) { + return ["novice", "intermediate", "advanced"].flatMap((pool) => + multiplierPPMs.map((multiplierPPM) => + tier( + pool, + `${pool}_${multiplierLabel(multiplierPPM)}`, + Math.floor((cost * multiplierPPM) / ppmScale), + multiplierPPM, + highMultiplier > 0 && multiplierPPM >= highMultiplier * ppmScale, + ), + ), + ); } -function tier(pool, tierId, rewardCoins, weight, highWaterOnly = false) { +function tier(pool, tierId, rewardCoins, multiplierPPM, highWaterOnly = false) { return { - enabled: weight > 0, + enabled: true, high_water_only: Boolean(highWaterOnly), + multiplier_ppm: multiplierPPM, pool, reward_coins: rewardCoins, tier_id: tierId, - weight, + weight: 0, }; } +export function parseMultiplierList(value) { + const numbers = String(value || "") + .split(/[,,\s]+/) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => Number(item.replace(/x$/i, ""))) + .filter((item) => Number.isFinite(item) && item >= 0) + .map((item) => Math.round(item * ppmScale)); + const unique = Array.from(new Set([0, ...numbers])).sort((a, b) => a - b); + return unique.length > 1 ? unique : [0, 1_000_000]; +} + +export function parseMultiplierValues(values) { + return parseMultiplierList((values || []).join(",")); +} + +function formatMultiplierValues(multiplierPPMs, tiers) { + const values = Array.isArray(multiplierPPMs) && multiplierPPMs.length > 0 ? multiplierPPMs : multipliersFromTiers(tiers); + return values.map(multiplierText); +} + +function multipliersFromTiers(tiers) { + const values = (tiers || []) + .map((tierItem) => Number(tierItem.multiplierPPM || 0)) + .filter((item) => item >= 0); + return values.length > 0 ? Array.from(new Set(values)).sort((a, b) => a - b) : parseMultiplierList(""); +} + +function multiplierLabel(multiplierPPM) { + return multiplierPPM === 0 ? "none" : `${multiplierText(multiplierPPM).replace(".", "_")}x`; +} + +function multiplierText(multiplierPPM) { + const value = multiplierPPM / ppmScale; + return Number.isInteger(value) ? String(value) : value.toFixed(6).replace(/\.?0+$/, ""); +} + function riskCapsForLevel(level, giftPrice, targetRTPPPM) { const expectedHour = expectedPayout(giftPrice, targetRTPPPM, defaultDrawsPerHour); const expectedDay = expectedHour * 24; @@ -223,19 +249,6 @@ function inferAtmosphereLevel(ratePPM) { return matchConfig(atmosphereLevelConfig, (item) => item.roomAtmosphereRatePPM === ratePPM); } -function inferPayoutStyle(tiers) { - const jackpotWeight = tiers - .filter((tierItem) => tierItem.pool === "advanced" && tierItem.highWaterOnly) - .reduce((sum, tierItem) => sum + Number(tierItem.weight || 0), 0); - if (jackpotWeight <= 20_000) { - return "stable"; - } - if (jackpotWeight >= 50_000) { - return "exciting"; - } - return "balanced"; -} - function inferRiskLevel() { return "standard"; } diff --git a/src/features/lucky-gift/configModel.test.js b/src/features/lucky-gift/configModel.test.js index 8e3cbc8..8500a65 100644 --- a/src/features/lucky-gift/configModel.test.js +++ b/src/features/lucky-gift/configModel.test.js @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { emptyLuckyGiftForm, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js"; +import { emptyLuckyGiftForm, parseMultiplierList, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js"; describe("lucky gift config payload", () => { test("submits both RTP control windows for backend validation", () => { @@ -7,5 +7,12 @@ describe("lucky gift config payload", () => { expect(payload.global_window_draws).toBeGreaterThan(0); expect(payload.gift_window_draws).toBeGreaterThan(0); + expect(payload.pool_id).toBe("default"); + expect(payload.multiplier_ppms).toContain(500_000_000); + expect(payload.tiers.every((tier) => tier.weight === 0)).toBe(true); + }); + + test("parses custom multipliers without configured probabilities", () => { + expect(parseMultiplierList("0, 0.5x, 1, 2")).toEqual([0, 500_000, 1_000_000, 2_000_000]); }); }); diff --git a/src/features/lucky-gift/constants.js b/src/features/lucky-gift/constants.js index 7ea0410..b41e4d2 100644 --- a/src/features/lucky-gift/constants.js +++ b/src/features/lucky-gift/constants.js @@ -1,5 +1,3 @@ -export const luckyGiftConfigScopeLabel = "全局配置"; - export const controlStrengthOptions = [ ["precise", "精准"], ["standard", "标准"], @@ -19,12 +17,6 @@ export const noviceStrengthOptions = [ ["strong", "强"], ]; -export const payoutStyleOptions = [ - ["stable", "稳定小奖"], - ["balanced", "均衡"], - ["exciting", "刺激大奖"], -]; - export const jackpotStrategyOptions = [ ["off", "关闭"], ["conservative", "保守"], diff --git a/src/features/lucky-gift/hooks/useLuckyGiftPage.js b/src/features/lucky-gift/hooks/useLuckyGiftPage.js index 69bc32b..7957663 100644 --- a/src/features/lucky-gift/hooks/useLuckyGiftPage.js +++ b/src/features/lucky-gift/hooks/useLuckyGiftPage.js @@ -2,22 +2,27 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { parseForm } from "@/shared/forms/validation"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; -import { getLuckyGiftConfig, listLuckyGiftDraws, updateLuckyGiftConfig } from "@/features/lucky-gift/api"; +import { getLuckyGiftConfig, listLuckyGiftConfigs, listLuckyGiftDraws, updateLuckyGiftConfig } from "@/features/lucky-gift/api"; import { emptyLuckyGiftForm, formFromConfig, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js"; import { useLuckyGiftAbilities } from "@/features/lucky-gift/permissions.js"; import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema"; const pageSize = 10; const emptyDraws = { items: [], page: 1, pageSize, total: 0 }; +const defaultPoolId = "default"; export function useLuckyGiftPage() { const abilities = useLuckyGiftAbilities(); const { showToast } = useToast(); const [config, setConfig] = useState(null); + const [configs, setConfigs] = useState([]); const [configDrawerOpen, setConfigDrawerOpen] = useState(false); + const [addPoolOpen, setAddPoolOpen] = useState(false); + const [addPoolId, setAddPoolId] = useState(""); const [form, setForm] = useState(emptyLuckyGiftForm()); const [configLoading, setConfigLoading] = useState(false); const [configSaving, setConfigSaving] = useState(false); + const [poolId, setPoolId] = useState(defaultPoolId); const [giftId, setGiftId] = useState(""); const [userId, setUserId] = useState(""); const [roomId, setRoomId] = useState(""); @@ -25,8 +30,8 @@ export function useLuckyGiftPage() { const [page, setPage] = useState(1); const drawFilters = useMemo( - () => ({ gift_id: giftId, room_id: roomId, status, user_id: userId }), - [giftId, roomId, status, userId], + () => ({ gift_id: giftId, pool_id: poolId, room_id: roomId, status, user_id: userId }), + [giftId, poolId, roomId, status, userId], ); const { data: draws = emptyDraws, @@ -41,11 +46,29 @@ export function useLuckyGiftPage() { pageSize, queryKey: ["lucky-gift-draws", drawFilters, page], }); + const poolOptions = useMemo(() => { + const ids = new Set([poolId]); + for (const item of configs) { + if (item.poolId) { + ids.add(item.poolId); + } + } + return Array.from(ids); + }, [configs, poolId]); + + const reloadConfigs = useCallback(async () => { + try { + const nextConfigs = await listLuckyGiftConfigs(); + setConfigs(nextConfigs); + } catch (err) { + showToast(err.message || "加载幸运礼物奖池失败", "error"); + } + }, [showToast]); const reloadConfig = useCallback(async () => { setConfigLoading(true); try { - const nextConfig = await getLuckyGiftConfig(); + const nextConfig = await getLuckyGiftConfig(poolId); setConfig(nextConfig); setForm(formFromConfig(nextConfig)); } catch (err) { @@ -53,21 +76,25 @@ export function useLuckyGiftPage() { } finally { setConfigLoading(false); } - }, [showToast]); + }, [poolId, showToast]); useEffect(() => { void reloadConfig(); }, [reloadConfig]); + useEffect(() => { + void reloadConfigs(); + }, [reloadConfigs]); + const openConfigDrawer = useCallback(() => { - setForm(config ? formFromConfig(config) : emptyLuckyGiftForm()); + setForm(config ? formFromConfig(config) : { ...emptyLuckyGiftForm(), poolId }); setConfigDrawerOpen(true); - }, [config]); + }, [config, poolId]); const closeConfigDrawer = useCallback(() => { setConfigDrawerOpen(false); - setForm(config ? formFromConfig(config) : emptyLuckyGiftForm()); - }, [config]); + setForm(config ? formFromConfig(config) : { ...emptyLuckyGiftForm(), poolId }); + }, [config, poolId]); const submitConfig = async (event) => { event.preventDefault(); @@ -79,7 +106,9 @@ export function useLuckyGiftPage() { const parsed = parseForm(luckyGiftConfigFormSchema, form); const saved = await updateLuckyGiftConfig(payloadFromLuckyGiftForm(parsed, config)); setConfig(saved); + setConfigs((current) => mergeConfigList(current, saved)); setForm(formFromConfig(saved)); + setPoolId(saved.poolId || poolId); setConfigDrawerOpen(false); showToast("幸运礼物配置已保存", "success"); await reloadDraws(); @@ -94,6 +123,33 @@ export function useLuckyGiftPage() { setGiftId(value); setPage(1); }; + const changePoolId = (value) => { + setPoolId(value.trim() || defaultPoolId); + setPage(1); + }; + const openAddPool = () => { + setAddPoolId(""); + setAddPoolOpen(true); + }; + const closeAddPool = () => { + setAddPoolOpen(false); + setAddPoolId(""); + }; + const submitAddPool = (event) => { + event.preventDefault(); + const nextPoolID = addPoolId.trim(); + if (!nextPoolID) { + showToast("奖池 ID 不能为空", "error"); + return; + } + setPoolId(nextPoolID); + setConfig(null); + setForm({ ...emptyLuckyGiftForm(), poolId: nextPoolID }); + setAddPoolOpen(false); + setAddPoolId(""); + setConfigDrawerOpen(true); + setPage(1); + }; const changeUserId = (value) => { setUserId(value); setPage(1); @@ -116,11 +172,15 @@ export function useLuckyGiftPage() { return { abilities, + addPoolId, + addPoolOpen, changeGiftId, + changePoolId, changeRoomId, changeStatus, changeUserId, closeConfigDrawer, + closeAddPool, config, configDrawerOpen, configLoading, @@ -131,15 +191,38 @@ export function useLuckyGiftPage() { form, giftId, openConfigDrawer, + openAddPool, page, + poolId, + poolOptions, reloadConfig, + reloadConfigs, reloadDraws, resetDrawFilters, roomId, setForm, + setAddPoolId, setPage, status, submitConfig, + submitAddPool, userId, }; } + +function mergeConfigList(configs, saved) { + const out = []; + let replaced = false; + for (const item of configs) { + if (item.poolId === saved.poolId) { + out.push(saved); + replaced = true; + continue; + } + out.push(item); + } + if (!replaced) { + out.unshift(saved); + } + return out; +} diff --git a/src/features/lucky-gift/lucky-gift.module.css b/src/features/lucky-gift/lucky-gift.module.css index cb5de86..782ae8a 100644 --- a/src/features/lucky-gift/lucky-gift.module.css +++ b/src/features/lucky-gift/lucky-gift.module.css @@ -83,6 +83,10 @@ width: 220px; } +.poolSelector { + width: 160px; +} + .configDrawer { box-sizing: border-box; width: min(520px, 100vw); @@ -166,6 +170,39 @@ width: 100%; } +.multiplierBlock { + display: grid; + min-width: 0; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card-strong); +} + +.multiplierHeader { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.multiplierList { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +.multiplierRow { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-1); +} + .simulationPanel { display: grid; gap: var(--space-4); @@ -270,7 +307,8 @@ } .simulationGrid, - .simulationOverview { + .simulationOverview, + .multiplierList { grid-template-columns: repeat(2, minmax(140px, 1fr)); } } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx index a3a96f6..7d84685 100644 --- a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx @@ -1,3 +1,7 @@ +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; import TextField from "@mui/material/TextField"; import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx"; import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx"; @@ -12,6 +16,7 @@ import { AdminListPage, AdminListToolbar, } from "@/shared/ui/AdminListLayout.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { formatMillis } from "@/shared/utils/time.js"; @@ -24,7 +29,10 @@ const columnsBase = [ render: (draw) => (
{draw.drawId} - {draw.giftId || draw.commandId || "-"} + + {draw.poolId ? `${draw.poolId} / ` : ""} + {draw.giftId || draw.commandId || "-"} +
), }, @@ -92,13 +100,23 @@ export function LuckyGiftConfigPage() { canUpdate={page.abilities.canUpdate} config={page.config} configLoading={page.configLoading} + poolId={page.poolId} + poolOptions={page.poolOptions} + onAddPool={page.openAddPool} onEdit={page.openConfigDrawer} + onPoolChange={page.changePoolId} onRefresh={page.reloadConfig} /> + + ); } +function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) { + return ( + +
+ 添加奖池 + + onChange(event.target.value)} + /> + + + + + +
+
+ ); +} + function DrawStatus({ status }) { const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning"; return ( diff --git a/src/features/lucky-gift/schema.ts b/src/features/lucky-gift/schema.ts index 024a2e6..d94de72 100644 --- a/src/features/lucky-gift/schema.ts +++ b/src/features/lucky-gift/schema.ts @@ -9,25 +9,34 @@ export const luckyGiftConfigFormSchema = z controlStrength: z.enum(["precise", "standard", "natural"]), enabled: z.boolean(), jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]), + multiplierValues: z.array(z.coerce.number().min(0, "倍率不能小于 0")).min(1, "倍率配置不能为空"), noviceStrength: z.enum(["weak", "standard", "strong"]), - payoutStyle: z.enum(["stable", "balanced", "exciting"]), + poolId: z.string().trim().min(1, "奖池 ID 不能为空").max(96, "奖池 ID 不能超过 96 个字符"), poolMode: z.enum(["platform_guard", "standard", "room_burst", "gift_isolated"]), riskLevel: z.enum(["strict", "standard", "loose"]), targetRtpPercent: z.coerce.number().min(1, "RTP 必须大于 1%").max(100, "RTP 不能超过 100%"), }) .superRefine((value, context) => { - if (!value.activitySubsidyEnabled) { - return; - } - if (value.activityBudget <= 0) { + if (value.activitySubsidyEnabled && value.activityBudget <= 0) { context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] }); } - if (value.activityBudget > 0 && value.activityDailyLimit <= 0) { + if (value.activitySubsidyEnabled && value.activityBudget > 0 && value.activityDailyLimit <= 0) { context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] }); } - if (value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) { + if (value.activitySubsidyEnabled && value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) { context.addIssue({ code: "custom", message: "每日预算不能大于活动总预算", path: ["activityDailyLimit"] }); } + const multipliers = value.multiplierValues + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item) && item >= 0); + if (multipliers.length === 0 || Math.max(...multipliers) <= 0) { + context.addIssue({ code: "custom", message: "至少配置一个大于 0 的中奖倍率", path: ["multiplierValues"] }); + } + const maxMultiplier = Math.max(...multipliers, 0); + const targetMultiplier = Number(value.targetRtpPercent || 0) / 100; + if (maxMultiplier < targetMultiplier) { + context.addIssue({ code: "custom", message: "最大倍率不能低于 RTP 对应返还倍数", path: ["multiplierValues"] }); + } }); export type LuckyGiftConfigForm = z.infer; diff --git a/src/features/user-leaderboard/api.ts b/src/features/user-leaderboard/api.ts new file mode 100644 index 0000000..304fedc --- /dev/null +++ b/src/features/user-leaderboard/api.ts @@ -0,0 +1,155 @@ +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import { apiRequest } from "@/shared/api/request"; +import type { PageQuery } from "@/shared/api/types"; + +export type UserLeaderboardBoardType = "sent" | "received" | "room"; +export type UserLeaderboardPeriod = "today" | "week" | "month"; + +export interface UserLeaderboardUserDto { + userId: string; + displayUserId?: string; + username?: string; + avatar?: string; +} + +export interface UserLeaderboardRoomDto { + roomId: string; + roomShortId?: string; + title?: string; + coverUrl?: string; + ownerUserId?: string; + status?: string; +} + +export interface UserLeaderboardItemDto { + rank: number; + userId?: string; + roomId?: string; + giftValue: number; + giftCount: number; + transactionCount: number; + lastGiftAtMs: number; + user?: UserLeaderboardUserDto; + room?: UserLeaderboardRoomDto; +} + +export interface UserLeaderboardResponseDto { + items: UserLeaderboardItemDto[]; + total: number; + page: number; + pageSize: number; + boardType: UserLeaderboardBoardType; + period: UserLeaderboardPeriod; + startAtMs: number; + endAtMs: number; + serverTimeMs: number; + myRank?: UserLeaderboardItemDto | null; +} + +export interface UserLeaderboardQuery extends PageQuery { + board_type?: UserLeaderboardBoardType; + period?: UserLeaderboardPeriod; + viewer_user_id?: string; +} + +type RawUser = UserLeaderboardUserDto & Record; +type RawRoom = UserLeaderboardRoomDto & Record; +type RawItem = Omit & { + last_gift_at_ms?: number | string; + transaction_count?: number | string; + gift_count?: number | string; + gift_value?: number | string; + user_id?: string | number; + room_id?: string; + user?: RawUser; + room?: RawRoom; +} & Record; +type RawResponse = Omit & { + board_type?: UserLeaderboardBoardType; + end_at_ms?: number | string; + items?: RawItem[]; + my_rank?: RawItem | null; + myRank?: RawItem | null; + page_size?: number | string; + server_time_ms?: number | string; + start_at_ms?: number | string; +} & Record; + +export function listUserLeaderboards(query: UserLeaderboardQuery = {}): Promise { + const endpoint = API_ENDPOINTS.listUserLeaderboards; + return apiRequest(apiEndpointPath(API_OPERATIONS.listUserLeaderboards), { + method: endpoint.method, + query, + }).then(normalizeResponse); +} + +function normalizeResponse(response: RawResponse): UserLeaderboardResponseDto { + return { + items: (response.items || []).map(normalizeItem), + total: numberValue(response.total), + page: numberValue(response.page) || 1, + pageSize: numberValue(response.pageSize ?? response.page_size) || 20, + boardType: normalizeBoardType(response.boardType ?? response.board_type), + period: normalizePeriod(response.period), + startAtMs: numberValue(response.startAtMs ?? response.start_at_ms), + endAtMs: numberValue(response.endAtMs ?? response.end_at_ms), + serverTimeMs: numberValue(response.serverTimeMs ?? response.server_time_ms), + myRank: response.myRank || response.my_rank ? normalizeItem((response.myRank || response.my_rank) as RawItem) : null, + }; +} + +function normalizeItem(item: RawItem): UserLeaderboardItemDto { + return { + rank: numberValue(item.rank), + userId: optionalString(item.userId ?? item.user_id), + roomId: optionalString(item.roomId ?? item.room_id), + giftValue: numberValue(item.giftValue ?? item.gift_value), + giftCount: numberValue(item.giftCount ?? item.gift_count), + transactionCount: numberValue(item.transactionCount ?? item.transaction_count), + lastGiftAtMs: numberValue(item.lastGiftAtMs ?? item.last_gift_at_ms), + user: item.user ? normalizeUser(item.user) : undefined, + room: item.room ? normalizeRoom(item.room) : undefined, + }; +} + +function normalizeUser(user: RawUser): UserLeaderboardUserDto { + return { + userId: stringValue(user.userId ?? user.user_id), + displayUserId: optionalString(user.displayUserId ?? user.display_user_id), + username: optionalString(user.username), + avatar: optionalString(user.avatar), + }; +} + +function normalizeRoom(room: RawRoom): UserLeaderboardRoomDto { + return { + roomId: stringValue(room.roomId ?? room.room_id), + roomShortId: optionalString(room.roomShortId ?? room.room_short_id), + title: optionalString(room.title), + coverUrl: optionalString(room.coverUrl ?? room.cover_url), + ownerUserId: optionalString(room.ownerUserId ?? room.owner_user_id), + status: optionalString(room.status), + }; +} + +function normalizeBoardType(value: unknown): UserLeaderboardBoardType { + return value === "received" || value === "room" ? value : "sent"; +} + +function normalizePeriod(value: unknown): UserLeaderboardPeriod { + return value === "week" || value === "month" ? value : "today"; +} + +function stringValue(value: unknown) { + return value === null || value === undefined ? "" : String(value); +} + +function optionalString(value: unknown) { + const text = stringValue(value).trim(); + return text || undefined; +} + +function numberValue(value: unknown) { + const next = Number(value ?? 0); + return Number.isFinite(next) ? next : 0; +} diff --git a/src/features/user-leaderboard/hooks/useUserLeaderboardPage.js b/src/features/user-leaderboard/hooks/useUserLeaderboardPage.js new file mode 100644 index 0000000..8c19ef3 --- /dev/null +++ b/src/features/user-leaderboard/hooks/useUserLeaderboardPage.js @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { listUserLeaderboards } from "@/features/user-leaderboard/api"; +import { useUserLeaderboardAbilities } from "@/features/user-leaderboard/permissions.js"; + +const pageSize = 20; + +export function useUserLeaderboardPage() { + const abilities = useUserLeaderboardAbilities(); + const { showToast } = useToast(); + const [boardType, setBoardType] = useState("sent"); + const [period, setPeriod] = useState("today"); + const [page, setPage] = useState(1); + const [data, setData] = useState(emptyData()); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const reload = useCallback(async () => { + setLoading(true); + setError(""); + try { + const nextData = await listUserLeaderboards({ + board_type: boardType, + page, + page_size: pageSize, + period, + }); + setData(nextData); + } catch (err) { + const message = err.message || "加载用户榜单失败"; + setError(message); + showToast(message, "error"); + } finally { + setLoading(false); + } + }, [boardType, page, period, showToast]); + + useEffect(() => { + void reload(); + }, [reload]); + + const changeBoardType = useCallback((nextValue) => { + setBoardType(nextValue || "sent"); + setPage(1); + }, []); + + const changePeriod = useCallback((nextValue) => { + setPeriod(nextValue || "today"); + setPage(1); + }, []); + + const resetFilters = useCallback(() => { + setBoardType("sent"); + setPeriod("today"); + setPage(1); + }, []); + + return { + abilities, + boardType, + changeBoardType, + changePeriod, + data, + error, + loading, + page, + period, + reload, + resetFilters, + setPage, + }; +} + +function emptyData() { + return { + boardType: "sent", + endAtMs: 0, + items: [], + myRank: null, + page: 1, + pageSize, + period: "today", + serverTimeMs: 0, + startAtMs: 0, + total: 0, + }; +} diff --git a/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx b/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx new file mode 100644 index 0000000..c650da3 --- /dev/null +++ b/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx @@ -0,0 +1,162 @@ +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { + AdminFilterResetButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, +} from "@/shared/ui/AdminListLayout.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { useUserLeaderboardPage } from "@/features/user-leaderboard/hooks/useUserLeaderboardPage.js"; +import styles from "@/features/user-leaderboard/user-leaderboard.module.css"; + +const boardTypeOptions = [ + ["sent", "用户送礼榜"], + ["received", "收礼榜"], + ["room", "房间礼物值榜"], +]; + +const periodOptions = [ + ["today", "今天"], + ["week", "一周"], + ["month", "一月"], +]; + +const columns = [ + { + key: "rank", + label: "排名", + width: "minmax(96px, 0.45fr)", + render: (item) => #{item.rank}, + }, + { + key: "subject", + label: "对象", + width: "minmax(280px, 1.25fr)", + render: (item, _index, context) => , + }, + { + key: "giftValue", + label: "礼物值", + width: "minmax(150px, 0.75fr)", + render: (item) => formatNumber(item.giftValue), + }, + { + key: "giftCount", + label: "礼物数量", + width: "minmax(130px, 0.65fr)", + render: (item) => formatNumber(item.giftCount), + }, + { + key: "transactionCount", + label: "交易数", + width: "minmax(120px, 0.6fr)", + render: (item) => formatNumber(item.transactionCount), + }, + { + key: "lastGiftAtMs", + label: "最后送礼时间", + width: "minmax(180px, 0.85fr)", + render: (item) => `${formatMillis(item.lastGiftAtMs, "UTC")} UTC`, + }, +]; + +export function UserLeaderboardPage() { + const page = useUserLeaderboardPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + + return ( + + + + + + + } + /> +
+
+ UTC 时间范围 + + {formatMillis(page.data.startAtMs, "UTC")} - {formatMillis(page.data.endAtMs, "UTC")} + +
+ {page.data.myRank ? ( +
+ 当前用户排名 + + #{page.data.myRank.rank} · {formatNumber(page.data.myRank.giftValue)} + +
+ ) : null} +
+ + + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 20, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(item) => `${page.boardType}-${item.userId || item.roomId || item.rank}`} + /> + + +
+ ); +} + +function LeaderboardSubject({ item, type }) { + if (type === "room") { + const room = item.room || {}; + return ( +
+ {room.title || item.roomId || "-"} + + {room.roomShortId ? `短号 ${room.roomShortId}` : item.roomId || "-"} + {room.ownerUserId ? ` · 房主 ${room.ownerUserId}` : ""} + +
+ ); + } + const user = item.user || {}; + return ( +
+ {user.username || user.displayUserId || item.userId || "-"} + + {user.displayUserId ? `短号 ${user.displayUserId}` : ""} + {item.userId ? `${user.displayUserId ? " · " : ""}ID ${item.userId}` : ""} + +
+ ); +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} diff --git a/src/features/user-leaderboard/permissions.js b/src/features/user-leaderboard/permissions.js new file mode 100644 index 0000000..dfee8ce --- /dev/null +++ b/src/features/user-leaderboard/permissions.js @@ -0,0 +1,9 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useUserLeaderboardAbilities() { + const { can } = useAuth(); + return { + canView: can(PERMISSIONS.userLeaderboardView), + }; +} diff --git a/src/features/user-leaderboard/routes.js b/src/features/user-leaderboard/routes.js new file mode 100644 index 0000000..fab5db2 --- /dev/null +++ b/src/features/user-leaderboard/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const userLeaderboardRoutes = [ + { + label: "用户榜单", + loader: () => import("./pages/UserLeaderboardPage.jsx").then((module) => module.UserLeaderboardPage), + menuCode: MENU_CODES.userLeaderboard, + pageKey: "user-leaderboard", + path: "/activities/user-leaderboards", + permission: PERMISSIONS.userLeaderboardView, + }, +]; diff --git a/src/features/user-leaderboard/user-leaderboard.module.css b/src/features/user-leaderboard/user-leaderboard.module.css new file mode 100644 index 0000000..2277ecc --- /dev/null +++ b/src/features/user-leaderboard/user-leaderboard.module.css @@ -0,0 +1,50 @@ +.summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); +} + +.summaryLabel { + display: block; + color: var(--text-tertiary); + font-size: 12px; +} + +.summaryValue { + display: block; + margin-top: 4px; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; +} + +.identity { + display: grid; + min-width: 0; + gap: 4px; +} + +.name { + overflow: hidden; + color: var(--text-primary); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rank { + font-weight: 700; +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index fa0f432..95fa524 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -107,6 +107,7 @@ export const API_OPERATIONS = { listHosts: "listHosts", listLevelConfig: "listLevelConfig", listLoginLogs: "listLoginLogs", + listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftDraws: "listLuckyGiftDraws", listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", @@ -125,6 +126,7 @@ export const API_OPERATIONS = { listSevenDayCheckInClaims: "listSevenDayCheckInClaims", listSystemMenus: "listSystemMenus", listTaskDefinitions: "listTaskDefinitions", + listUserLeaderboards: "listUserLeaderboards", listUsers: "listUsers", login: "login", logout: "logout", @@ -837,6 +839,13 @@ export const API_ENDPOINTS: Record = { permission: "log:view", permissions: ["log:view"] }, + listLuckyGiftConfigs: { + method: "GET", + operationId: API_OPERATIONS.listLuckyGiftConfigs, + path: "/v1/admin/activity/lucky-gifts/configs", + permission: "lucky-gift:view", + permissions: ["lucky-gift:view"] + }, listLuckyGiftDraws: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftDraws, @@ -963,6 +972,13 @@ export const API_ENDPOINTS: Record = { permission: "daily-task:view", permissions: ["daily-task:view"] }, + listUserLeaderboards: { + method: "GET", + operationId: API_OPERATIONS.listUserLeaderboards, + path: "/v1/admin/activity/user-leaderboards", + permission: "user-leaderboard:view", + permissions: ["user-leaderboard:view"] + }, listUsers: { method: "GET", operationId: API_OPERATIONS.listUsers, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 6c05cb8..54cd765 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -84,6 +84,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/activity/user-leaderboards": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listUserLeaderboards"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/lucky-gifts/config": { parameters: { query?: never; @@ -100,6 +116,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/activity/lucky-gifts/configs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLuckyGiftConfigs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/lucky-gifts/draws": { parameters: { query?: never; @@ -2432,6 +2464,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listUserLeaderboards: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; getLuckyGiftConfig: { parameters: { query?: never; @@ -2456,6 +2500,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listLuckyGiftConfigs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listLuckyGiftDraws: { parameters: { query?: never;