游戏和幸运礼物配置

This commit is contained in:
zhx 2026-05-21 10:43:36 +08:00
parent 90bf486385
commit 841480d89e
26 changed files with 1192 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<GamePlatformDto, "appCode" | "createdAtMs" | "updatedAtMs">;
export type GamePlatformPayload = Omit<
GamePlatformDto,
"appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet"
>;
export type GameCatalogPayload = Omit<GameCatalogDto, "appCode" | "createdAtMs" | "updatedAtMs">;
export interface GameCatalogQuery {
@ -118,6 +129,12 @@ function normalizePlatform(platform: Partial<GamePlatformDto>): 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),

View File

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

View File

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

View File

@ -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() {
<AdminListToolbar
actions={
<>
{page.abilities.canUpdate ? (
<AdminActionIconButton label="平台配置" onClick={page.openManagePlatforms}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canUpdate ? (
<AdminActionIconButton label="添加平台" onClick={page.openCreatePlatform}>
<SportsEsportsOutlined fontSize="small" />
@ -178,6 +190,11 @@ export function GameListPage() {
onClose={page.closeAction}
onSubmit={page.submitPlatform}
/>
<PlatformManageDialog
open={page.activeAction === "manage-platforms"}
page={page}
onClose={page.closeAction}
/>
</AdminListPage>
);
}
@ -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 })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="游戏启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setGameForm({
...form,
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="游戏启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
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 })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
@ -408,18 +425,101 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="接入配置">
<TextField
className={styles.fullField}
label="H5 Base URL"
required
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
{/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */}
<TextField
label="适配器"
select
value={form.adapterType}
onChange={(event) => page.setPlatformForm({ ...form, adapterType: event.target.value })}
>
{adapterTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="H5/Auth URL"
required
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
{/* 密钥不回显;编辑平台时留空表示沿用旧密钥,输入新值才覆盖。 */}
<TextField
label={form.callbackSecretSet ? "回调密钥(已配置)" : "回调密钥"}
type="password"
value={form.callbackSecret}
onChange={(event) => page.setPlatformForm({ ...form, callbackSecret: event.target.value })}
/>
<TextField
label="IP 白名单"
minRows={3}
multiline
value={form.callbackIpWhitelistText}
onChange={(event) =>
page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value })
}
/>
{/* 厂商差异字段放 JSON如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
<TextField
className={styles.fullField}
label="适配器配置 JSON"
minRows={5}
multiline
value={form.adapterConfigJson}
onChange={(event) => page.setPlatformForm({ ...form, adapterConfigJson: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function PlatformManageDialog({ open, page, onClose }) {
const platforms = page.platformData.items || [];
return (
<AdminFormDialog
disabled={false}
loading={false}
open={open}
submitLabel="关闭"
title="平台配置"
onClose={onClose}
onSubmit={(event) => {
event.preventDefault();
onClose();
}}
>
<div className={styles.platformList}>
{platforms.map((platform) => (
<div className={styles.platformRow} key={platform.platformCode}>
<div className={styles.stack}>
<span className={styles.name}>{platform.platformName || platform.platformCode}</span>
<span className={styles.meta}>
{platform.platformCode} / {platform.adapterType || "demo"}
</span>
<span className={styles.meta}>{platform.apiBaseUrl}</span>
</div>
<div className={styles.platformActions}>
<span className={styles.meta}>
{platform.callbackSecretSet ? "密钥已配置" : "未配置密钥"}
</span>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑平台"
onClick={() => page.openEditPlatform(platform)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</div>
</div>
))}
</div>
</AdminFormDialog>
);
}
function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) {
return (
<span className={styles.switchOnlyField}>

View File

@ -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<string, unknown>;
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
type RawDraw = LuckyGiftDrawDto & Record<string, unknown>;
export function getLuckyGiftConfig(): Promise<LuckyGiftConfigDto> {
export function getLuckyGiftConfig(poolId: string): Promise<LuckyGiftConfigDto> {
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), {
method: endpoint.method,
query: { pool_id: poolId },
}).then(normalizeConfig);
}
export function listLuckyGiftConfigs(): Promise<LuckyGiftConfigDto[]> {
const endpoint = API_ENDPOINTS.listLuckyGiftConfigs;
return apiRequest<RawConfig[]>(apiEndpointPath(API_OPERATIONS.listLuckyGiftConfigs), {
method: endpoint.method,
}).then((items) => (items || []).map(normalizeConfig));
}
export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise<LuckyGiftConfigDto> {
const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig;
return apiRequest<RawConfig, LuckyGiftConfigPayload>(
@ -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<ApiPage<Lucky
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
poolId: stringValue(item.poolId ?? item.pool_id ?? item.gift_id),
enabled: booleanValue(item.enabled),
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
giftPrice: numberValue(item.giftPrice ?? item.gift_price),
@ -183,6 +200,7 @@ function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
activityBudget: numberValue(item.activityBudget ?? item.activity_budget),
activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit),
largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled),
multiplierPPMs: arrayValue(item.multiplierPPMs ?? item.multiplier_ppms).map(numberValue),
tiers: arrayValue(item.tiers).map(normalizeTier),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
@ -196,6 +214,7 @@ function normalizeTier(raw: unknown): LuckyGiftTierDto {
pool: stringValue(item.pool),
tierId: stringValue(item.tierId ?? item.tier_id),
rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins),
multiplierPPM: numberValue(item.multiplierPPM ?? item.multiplier_ppm),
weight: numberValue(item.weight),
highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only),
enabled: booleanValue(item.enabled),
@ -206,6 +225,7 @@ function normalizeDraw(item: RawDraw): LuckyGiftDrawDto {
return {
drawId: stringValue(item.drawId ?? item.draw_id),
commandId: stringValue(item.commandId ?? item.command_id),
poolId: stringValue(item.poolId ?? item.pool_id),
giftId: stringValue(item.giftId ?? item.gift_id),
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
experiencePool: stringValue(item.experiencePool ?? item.experience_pool),

View File

@ -1,5 +1,8 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
@ -10,7 +13,6 @@ import {
controlStrengthOptions,
jackpotStrategyOptions,
noviceStrengthOptions,
payoutStyleOptions,
poolModeOptions,
riskLevelOptions,
} from "@/features/lucky-gift/constants.js";
@ -18,11 +20,12 @@ import styles from "@/features/lucky-gift/lucky-gift.module.css";
const helpText = {
enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。",
poolId: "奖池 ID 是请求接口传入的隔离键,不同奖池的 RTP、预算、风控和用户阶段互不影响。",
targetRtpPercent: "基础返奖成本比例95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。",
controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。",
poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。",
noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。",
payoutStyle: "控制小奖和大奖的权重分布,稳定偏小奖,刺激偏高倍爆点。",
multiplierValues: "配置用户可能命中的倍率,例如 0、0.5、1、2、10、100每个倍率的概率由后端按 RTP 窗口自动生成。",
jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。",
riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。",
atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。",
@ -50,6 +53,12 @@ export function LuckyGiftConfigDrawer({
<section className="form-drawer__section">
<div className="form-drawer__section-title">基础控制</div>
<div className={styles.configGrid}>
<TextField
required
disabled
label={<FieldLabel help={helpText.poolId} text="奖池 ID" />}
value={form.poolId}
/>
<TextField
required
disabled={disabled}
@ -97,13 +106,11 @@ export function LuckyGiftConfigDrawer({
value={form.noviceStrength}
onChange={(value) => updateForm(setForm, "noviceStrength", value)}
/>
<SelectField
<MultiplierField
disabled={disabled}
help={helpText.payoutStyle}
label="中奖体感"
options={payoutStyleOptions}
value={form.payoutStyle}
onChange={(value) => updateForm(setForm, "payoutStyle", value)}
help={helpText.multiplierValues}
setForm={setForm}
values={form.multiplierValues || []}
/>
<SelectField
disabled={disabled}
@ -181,6 +188,47 @@ export function LuckyGiftConfigDrawer({
);
}
function MultiplierField({ disabled, help, setForm, values }) {
const list = values.length > 0 ? values : ["0"];
return (
<div className={[styles.multiplierBlock, styles.configWide].join(" ")}>
<div className={styles.multiplierHeader}>
<FieldLabel help={help} text="倍率配置" />
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={() => addMultiplier(setForm)}
>
添加倍率
</Button>
</div>
<div className={styles.multiplierList}>
{list.map((value, index) => (
<div key={index} className={styles.multiplierRow}>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 0.01 }}
label={`倍率 ${index + 1}`}
type="number"
value={value}
onChange={(event) => updateMultiplier(setForm, index, event.target.value)}
/>
<IconButton
aria-label="删除倍率"
disabled={disabled || list.length <= 1}
size="small"
onClick={() => removeMultiplier(setForm, index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
))}
</div>
</div>
);
}
function SelectField({ disabled, help, label, onChange, options, value }) {
return (
<TextField
@ -218,3 +266,25 @@ function SwitchField({ checked, className, disabled, help, label, onChange }) {
function updateForm(setForm, key, value) {
setForm((current) => ({ ...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),
}));
}

View File

@ -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
<div className={styles.summaryContent}>
<div className={styles.summaryHeader}>
<span className={styles.title}>幸运礼物配置</span>
<span className={styles.meta}>{luckyGiftConfigScopeLabel}</span>
<span className={styles.meta}>奖池 {poolId}</span>
</div>
<div className={styles.summaryItems}>
<ConfigStatus config={config} loading={configLoading} />
@ -37,7 +48,7 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
<SummaryItem label="新手保护">
{optionLabel(noviceStrengthOptions, form.noviceStrength)}
</SummaryItem>
<SummaryItem label="中奖体感">{optionLabel(payoutStyleOptions, form.payoutStyle)}</SummaryItem>
<SummaryItem label="倍率">{formatMultipliers(form.multiplierValues)}</SummaryItem>
<SummaryItem label="大奖策略">
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
</SummaryItem>
@ -51,6 +62,23 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
</div>
</div>
<div className={styles.summaryActions}>
<TextField
select
className={styles.poolSelector}
disabled={configLoading}
label="奖池 ID"
value={poolId}
onChange={(event) => onPoolChange(event.target.value)}
>
{(poolOptions || [poolId]).map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
<AdminActionIconButton disabled={configLoading || !canUpdate} label="添加奖池" type="button" onClick={onAddPool}>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
@ -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 (
<div className={styles.summaryItem}>

View File

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

View File

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

View File

@ -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", "保守"],

View File

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

View File

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

View File

@ -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) => (
<div className={styles.stack}>
<span>{draw.drawId}</span>
<span className={styles.meta}>{draw.giftId || draw.commandId || "-"}</span>
<span className={styles.meta}>
{draw.poolId ? `${draw.poolId} / ` : ""}
{draw.giftId || draw.commandId || "-"}
</span>
</div>
),
},
@ -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}
/>
<LuckyGiftSimulationPanel config={page.config} />
<AdminListToolbar
filters={
<>
<TextField
className={styles.filterInput}
disabled
label="当前奖池"
value={page.poolId}
/>
<TextField
className={styles.filterInput}
label="礼物 ID"
@ -160,10 +178,48 @@ export function LuckyGiftConfigPage() {
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
<AddPoolDialog
disabled={page.configSaving}
open={page.addPoolOpen}
value={page.addPoolId}
onChange={page.setAddPoolId}
onClose={page.closeAddPool}
onSubmit={page.submitAddPool}
/>
</AdminListPage>
);
}
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
return (
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
<form onSubmit={onSubmit}>
<DialogTitle>添加奖池</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
required
disabled={disabled}
label="奖池 ID"
margin="dense"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button disabled={disabled} type="button" onClick={onClose}>
取消
</Button>
<Button disabled={disabled} type="submit" variant="primary">
添加并编辑
</Button>
</DialogActions>
</form>
</Dialog>
);
}
function DrawStatus({ status }) {
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
return (

View File

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

View File

@ -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<string, unknown>;
type RawRoom = UserLeaderboardRoomDto & Record<string, unknown>;
type RawItem = Omit<UserLeaderboardItemDto, "room" | "user"> & {
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<string, unknown>;
type RawResponse = Omit<UserLeaderboardResponseDto, "items" | "myRank"> & {
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<string, unknown>;
export function listUserLeaderboards(query: UserLeaderboardQuery = {}): Promise<UserLeaderboardResponseDto> {
const endpoint = API_ENDPOINTS.listUserLeaderboards;
return apiRequest<RawResponse>(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;
}

View File

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

View File

@ -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) => <span className={styles.rank}>#{item.rank}</span>,
},
{
key: "subject",
label: "对象",
width: "minmax(280px, 1.25fr)",
render: (item, _index, context) => <LeaderboardSubject item={item} type={context.boardType} />,
},
{
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 (
<AdminListPage>
<AdminListToolbar
filters={
<>
<AdminFilterSelect
label="榜单"
options={boardTypeOptions}
value={page.boardType}
onChange={page.changeBoardType}
/>
<AdminFilterSelect
label="时间维度"
options={periodOptions}
value={page.period}
onChange={page.changePeriod}
/>
<AdminFilterResetButton
disabled={page.boardType === "sent" && page.period === "today"}
onClick={page.resetFilters}
/>
</>
}
/>
<div className={styles.summary}>
<div>
<span className={styles.summaryLabel}>UTC 时间范围</span>
<span className={styles.summaryValue}>
{formatMillis(page.data.startAtMs, "UTC")} - {formatMillis(page.data.endAtMs, "UTC")}
</span>
</div>
{page.data.myRank ? (
<div>
<span className={styles.summaryLabel}>当前用户排名</span>
<span className={styles.summaryValue}>
#{page.data.myRank.rank} · {formatNumber(page.data.myRank.giftValue)}
</span>
</div>
) : null}
</div>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
context={{ boardType: page.boardType }}
items={items}
minWidth="960px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 20,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => `${page.boardType}-${item.userId || item.roomId || item.rank}`}
/>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function LeaderboardSubject({ item, type }) {
if (type === "room") {
const room = item.room || {};
return (
<div className={styles.identity}>
<span className={styles.name}>{room.title || item.roomId || "-"}</span>
<span className={styles.meta}>
{room.roomShortId ? `短号 ${room.roomShortId}` : item.roomId || "-"}
{room.ownerUserId ? ` · 房主 ${room.ownerUserId}` : ""}
</span>
</div>
);
}
const user = item.user || {};
return (
<div className={styles.identity}>
<span className={styles.name}>{user.username || user.displayUserId || item.userId || "-"}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : ""}
{item.userId ? `${user.displayUserId ? " · " : ""}ID ${item.userId}` : ""}
</span>
</div>
);
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

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

View File

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

View File

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

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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,

View File

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