游戏和幸运礼物配置
This commit is contained in:
parent
90bf486385
commit
841480d89e
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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", "保守"],
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>;
|
||||
|
||||
155
src/features/user-leaderboard/api.ts
Normal file
155
src/features/user-leaderboard/api.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
162
src/features/user-leaderboard/pages/UserLeaderboardPage.jsx
Normal file
162
src/features/user-leaderboard/pages/UserLeaderboardPage.jsx
Normal 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");
|
||||
}
|
||||
9
src/features/user-leaderboard/permissions.js
Normal file
9
src/features/user-leaderboard/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
12
src/features/user-leaderboard/routes.js
Normal file
12
src/features/user-leaderboard/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
50
src/features/user-leaderboard/user-leaderboard.module.css
Normal file
50
src/features/user-leaderboard/user-leaderboard.module.css
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
56
src/shared/api/generated/schema.d.ts
vendored
56
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user