邀请相关
This commit is contained in:
parent
fd8c5bbd78
commit
a8614398a4
@ -91,12 +91,50 @@ export interface DiceConfigDto {
|
|||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
robotEnabled: boolean;
|
robotEnabled: boolean;
|
||||||
robotMatchWaitMs: number;
|
robotMatchWaitMs: number;
|
||||||
robotUserWinRatePercent: number;
|
|
||||||
poolBalanceCoin: number;
|
poolBalanceCoin: number;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelfGameNewUserPolicyDto {
|
||||||
|
appCode?: string;
|
||||||
|
gameId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
protectionRounds: number;
|
||||||
|
protectionHours: number;
|
||||||
|
maxStakeCoin: number;
|
||||||
|
lifetimeSubsidyQuotaCoin: number;
|
||||||
|
day1SubsidyQuotaCoin: number;
|
||||||
|
singleRoundSubsidyCapCoin: number;
|
||||||
|
strategyLevel: string;
|
||||||
|
loseStreakProtectionEnabled: boolean;
|
||||||
|
loseStreakTrigger: number;
|
||||||
|
normalPhaseTargetWinRatePercent: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfGameStakePoolDto {
|
||||||
|
appCode?: string;
|
||||||
|
gameId: string;
|
||||||
|
stakeCoin: number;
|
||||||
|
balanceCoin: number;
|
||||||
|
reservedCoin: number;
|
||||||
|
availableCoin: number;
|
||||||
|
targetBalanceCoin: number;
|
||||||
|
safeBalanceCoin: number;
|
||||||
|
warningBalanceCoin: number;
|
||||||
|
dangerBalanceCoin: number;
|
||||||
|
status: string;
|
||||||
|
poolLevel: string;
|
||||||
|
requiredPoolCoin: number;
|
||||||
|
humanWinCapacity: number;
|
||||||
|
botMatchEnabled: boolean;
|
||||||
|
blackReason?: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiceRobotDto {
|
export interface DiceRobotDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
@ -179,13 +217,14 @@ export interface RoomRpsChallengePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
|
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
|
||||||
|
export type SelfGameNewUserPolicyPayload = Omit<SelfGameNewUserPolicyDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
||||||
|
|
||||||
export interface RoomRpsConfigPayload {
|
export interface RoomRpsConfigPayload {
|
||||||
status: string;
|
status: string;
|
||||||
challengeTimeoutMs: number;
|
challengeTimeoutMs: number;
|
||||||
revealCountdownMs: number;
|
revealCountdownMs: number;
|
||||||
stakeGifts: Array<{
|
stakeGifts: Array<{
|
||||||
giftId: number;
|
giftId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}>;
|
}>;
|
||||||
@ -197,6 +236,11 @@ export interface DicePoolAdjustPayload {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SelfGameStakePoolPayload = Pick<
|
||||||
|
SelfGameStakePoolDto,
|
||||||
|
"targetBalanceCoin" | "safeBalanceCoin" | "warningBalanceCoin" | "dangerBalanceCoin" | "status"
|
||||||
|
>;
|
||||||
|
|
||||||
export interface DiceGenerateRobotsPayload {
|
export interface DiceGenerateRobotsPayload {
|
||||||
count: number;
|
count: number;
|
||||||
nicknameLanguage?: "english" | "arabic";
|
nicknameLanguage?: "english" | "arabic";
|
||||||
@ -329,14 +373,54 @@ export function updateDiceConfig(gameId: string, payload: DiceConfigPayload): Pr
|
|||||||
).then(normalizeDiceConfig);
|
).then(normalizeDiceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adjustDicePool(
|
export function getSelfGameNewUserPolicy(gameId: string): Promise<SelfGameNewUserPolicyDto> {
|
||||||
|
return apiRequest<SelfGameNewUserPolicyDto>(
|
||||||
|
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/new-user-policy`,
|
||||||
|
{ method: "GET" },
|
||||||
|
).then(normalizeSelfGameNewUserPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSelfGameNewUserPolicy(
|
||||||
gameId: string,
|
gameId: string,
|
||||||
|
payload: SelfGameNewUserPolicyPayload,
|
||||||
|
): Promise<SelfGameNewUserPolicyDto> {
|
||||||
|
return apiRequest<SelfGameNewUserPolicyDto, SelfGameNewUserPolicyPayload>(
|
||||||
|
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/new-user-policy`,
|
||||||
|
{ body: payload, method: "PATCH" },
|
||||||
|
).then(normalizeSelfGameNewUserPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSelfGameStakePools(
|
||||||
|
gameId: string,
|
||||||
|
): Promise<{ items: SelfGameStakePoolDto[]; serverTimeMs?: number }> {
|
||||||
|
return apiRequest<{ items: SelfGameStakePoolDto[]; serverTimeMs?: number }>(
|
||||||
|
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools`,
|
||||||
|
{ method: "GET" },
|
||||||
|
).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeSelfGameStakePool),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSelfGameStakePool(
|
||||||
|
gameId: string,
|
||||||
|
stakeCoin: number,
|
||||||
|
payload: SelfGameStakePoolPayload,
|
||||||
|
): Promise<SelfGameStakePoolDto> {
|
||||||
|
return apiRequest<SelfGameStakePoolDto, SelfGameStakePoolPayload>(
|
||||||
|
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools/${encodeURIComponent(String(stakeCoin))}`,
|
||||||
|
{ body: payload, method: "PATCH" },
|
||||||
|
).then(normalizeSelfGameStakePool);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustSelfGameStakePool(
|
||||||
|
gameId: string,
|
||||||
|
stakeCoin: number,
|
||||||
payload: DicePoolAdjustPayload,
|
payload: DicePoolAdjustPayload,
|
||||||
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
|
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
|
||||||
const endpoint = API_ENDPOINTS.adjustDicePool;
|
|
||||||
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
|
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
|
||||||
apiEndpointPath(API_OPERATIONS.adjustDicePool, { game_id: gameId }),
|
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools/${encodeURIComponent(String(stakeCoin))}/adjust`,
|
||||||
{ body: payload, method: endpoint.method },
|
{ body: payload, method: "POST" },
|
||||||
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
|
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -495,13 +579,55 @@ function normalizeDiceConfig(config: Partial<DiceConfigDto>): DiceConfigDto {
|
|||||||
maxPlayers: numberValue(config.maxPlayers || 2),
|
maxPlayers: numberValue(config.maxPlayers || 2),
|
||||||
robotEnabled: config.robotEnabled !== false,
|
robotEnabled: config.robotEnabled !== false,
|
||||||
robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000),
|
robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000),
|
||||||
robotUserWinRatePercent: numberValue(config.robotUserWinRatePercent ?? 50),
|
|
||||||
poolBalanceCoin: numberValue(config.poolBalanceCoin),
|
poolBalanceCoin: numberValue(config.poolBalanceCoin),
|
||||||
createdAtMs: numberValue(config.createdAtMs),
|
createdAtMs: numberValue(config.createdAtMs),
|
||||||
updatedAtMs: numberValue(config.updatedAtMs),
|
updatedAtMs: numberValue(config.updatedAtMs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSelfGameNewUserPolicy(policy: Partial<SelfGameNewUserPolicyDto>): SelfGameNewUserPolicyDto {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(policy.appCode),
|
||||||
|
gameId: stringValue(policy.gameId || "dice"),
|
||||||
|
enabled: policy.enabled !== false,
|
||||||
|
protectionRounds: numberValue(policy.protectionRounds || 30),
|
||||||
|
protectionHours: numberValue(policy.protectionHours || 168),
|
||||||
|
maxStakeCoin: numberValue(policy.maxStakeCoin || 5000),
|
||||||
|
lifetimeSubsidyQuotaCoin: numberValue(policy.lifetimeSubsidyQuotaCoin || 30000),
|
||||||
|
day1SubsidyQuotaCoin: numberValue(policy.day1SubsidyQuotaCoin || 15000),
|
||||||
|
singleRoundSubsidyCapCoin: numberValue(policy.singleRoundSubsidyCapCoin || 5000),
|
||||||
|
strategyLevel: stringValue(policy.strategyLevel || "soft_boost"),
|
||||||
|
loseStreakProtectionEnabled: policy.loseStreakProtectionEnabled !== false,
|
||||||
|
loseStreakTrigger: numberValue(policy.loseStreakTrigger || 2),
|
||||||
|
normalPhaseTargetWinRatePercent: numberValue(policy.normalPhaseTargetWinRatePercent || 53),
|
||||||
|
createdAtMs: numberValue(policy.createdAtMs),
|
||||||
|
updatedAtMs: numberValue(policy.updatedAtMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelfGameStakePool(pool: Partial<SelfGameStakePoolDto>): SelfGameStakePoolDto {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(pool.appCode),
|
||||||
|
gameId: stringValue(pool.gameId || "dice"),
|
||||||
|
stakeCoin: numberValue(pool.stakeCoin),
|
||||||
|
balanceCoin: numberValue(pool.balanceCoin),
|
||||||
|
reservedCoin: numberValue(pool.reservedCoin),
|
||||||
|
availableCoin: numberValue(pool.availableCoin),
|
||||||
|
targetBalanceCoin: numberValue(pool.targetBalanceCoin),
|
||||||
|
safeBalanceCoin: numberValue(pool.safeBalanceCoin),
|
||||||
|
warningBalanceCoin: numberValue(pool.warningBalanceCoin),
|
||||||
|
dangerBalanceCoin: numberValue(pool.dangerBalanceCoin),
|
||||||
|
status: stringValue(pool.status || "active"),
|
||||||
|
poolLevel: stringValue(pool.poolLevel || "black"),
|
||||||
|
requiredPoolCoin: numberValue(pool.requiredPoolCoin),
|
||||||
|
humanWinCapacity: numberValue(pool.humanWinCapacity),
|
||||||
|
botMatchEnabled: pool.botMatchEnabled === true,
|
||||||
|
blackReason: stringValue(pool.blackReason),
|
||||||
|
createdAtMs: numberValue(pool.createdAtMs),
|
||||||
|
updatedAtMs: numberValue(pool.updatedAtMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
|
function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(robot.appCode),
|
appCode: stringValue(robot.appCode),
|
||||||
|
|||||||
@ -65,26 +65,221 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.giftTierList {
|
.giftTierList {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
gap: 10px;
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.giftTierRow {
|
.giftTierRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(180px, 1fr) minmax(110px, 0.5fr) minmax(96px, auto);
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.giftTierGift {
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 8px;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.giftTierGift:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.giftTierControls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 58px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.giftTierSort {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.giftTierSort :global(.MuiOutlinedInput-root) {
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.giftTierSwitch {
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullField {
|
.fullField {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.strategyLevelLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategyLevelHelpIcon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategyLevelTooltipPaper {
|
||||||
|
max-width: min(560px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategyLevelTooltip {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategyLevelTooltipTitle {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategyLevelTooltipList {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolList {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolRow {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolRow:hover,
|
||||||
|
.stakePoolRowActive {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolMain {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolStake {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolMetrics {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolWarning {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolEmpty {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolHint {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolInlineActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolPrimaryButton {
|
||||||
|
min-height: var(--control-height);
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stakePoolPrimaryButton:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poolLevelBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 42px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poolLevel_green {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poolLevel_yellow {
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poolLevel_red {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poolLevel_black,
|
||||||
|
.poolLevel_unknown {
|
||||||
|
background: rgba(15, 23, 42, 0.12);
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.giftTierRow {
|
.giftTierRow {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.giftTierControls {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.giftTierSort {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformList {
|
.platformList {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { PERMISSIONS } from "@/app/permissions";
|
|||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/api";
|
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/api";
|
||||||
import { listGifts } from "@/features/resources/api";
|
import { listGifts } from "@/features/resources/api";
|
||||||
import { GiftSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
|
import { GiftPreviewSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
|
||||||
import styles from "@/features/games/games.module.css";
|
import styles from "@/features/games/games.module.css";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
@ -154,11 +154,12 @@ export function RoomRpsConfigPage() {
|
|||||||
|
|
||||||
const submit = async (event) => {
|
const submit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const payload = formToPayload(form);
|
const validationError = validateStakeGifts(form.stakeGifts);
|
||||||
if (payload.stakeGifts.length !== 4 || payload.stakeGifts.some((gift) => !gift.giftId)) {
|
if (validationError) {
|
||||||
showToast("房内猜拳必须配置 4 个礼物档位", "error");
|
showToast(validationError, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const payload = formToPayload(form);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await updateRoomRpsConfig(payload);
|
const result = await updateRoomRpsConfig(payload);
|
||||||
@ -214,6 +215,7 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
|||||||
open={open}
|
open={open}
|
||||||
submitLabel="保存配置"
|
submitLabel="保存配置"
|
||||||
title="房内猜拳配置"
|
title="房内猜拳配置"
|
||||||
|
size="large"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
@ -246,28 +248,40 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
|||||||
<div className={styles.giftTierList}>
|
<div className={styles.giftTierList}>
|
||||||
{form.stakeGifts.map((gift, index) => (
|
{form.stakeGifts.map((gift, index) => (
|
||||||
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
|
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
|
||||||
<GiftSelectField
|
<GiftPreviewSelectField
|
||||||
|
badge={`${Number(gift.sortOrder || index + 1)} 档`}
|
||||||
|
className={styles.giftTierGift}
|
||||||
drawerTitle={`选择第 ${index + 1} 档礼物`}
|
drawerTitle={`选择第 ${index + 1} 档礼物`}
|
||||||
|
fallbackGift={gift}
|
||||||
gifts={gifts}
|
gifts={gifts}
|
||||||
label={`第 ${index + 1} 档礼物`}
|
label={`第 ${index + 1} 档礼物`}
|
||||||
loading={giftsLoading}
|
loading={giftsLoading}
|
||||||
placeholder="点击选择礼物"
|
placeholder="点击选择礼物"
|
||||||
value={gift.giftId}
|
value={gift.giftId}
|
||||||
onChange={(value) => setGiftTier(setForm, form, index, { giftId: value })}
|
onChange={(value, selectedGift) =>
|
||||||
/>
|
setGiftTier(setForm, form, index, giftPatchFromSelected(value, selectedGift))
|
||||||
<TextField
|
|
||||||
label="排序"
|
|
||||||
type="number"
|
|
||||||
value={gift.sortOrder}
|
|
||||||
onChange={(event) =>
|
|
||||||
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AdminFormSwitchField
|
<div className={styles.giftTierControls}>
|
||||||
checked={gift.enabled}
|
<TextField
|
||||||
label="启用"
|
className={styles.giftTierSort}
|
||||||
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
|
label="排序"
|
||||||
/>
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={gift.sortOrder}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AdminFormSwitchField
|
||||||
|
checked={gift.enabled}
|
||||||
|
checkedLabel="启用"
|
||||||
|
className={styles.giftTierSwitch}
|
||||||
|
label={`第 ${index + 1} 档启用状态`}
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -288,6 +302,9 @@ function configToForm(config) {
|
|||||||
.map((gift, index) => ({
|
.map((gift, index) => ({
|
||||||
enabled: gift.enabled !== false,
|
enabled: gift.enabled !== false,
|
||||||
giftId: gift.giftId || "",
|
giftId: gift.giftId || "",
|
||||||
|
giftIconUrl: gift.giftIconUrl || "",
|
||||||
|
giftName: gift.giftName || "",
|
||||||
|
giftPriceCoin: Number(gift.giftPriceCoin || 0),
|
||||||
sortOrder: gift.sortOrder || index + 1,
|
sortOrder: gift.sortOrder || index + 1,
|
||||||
}));
|
}));
|
||||||
while (stakeGifts.length < 4) {
|
while (stakeGifts.length < 4) {
|
||||||
@ -301,6 +318,34 @@ function configToForm(config) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function giftPatchFromSelected(giftId, gift) {
|
||||||
|
return {
|
||||||
|
giftId: String(giftId || "").trim(),
|
||||||
|
giftIconUrl: gift?.giftIconUrl || gift?.resource?.previewUrl || gift?.resource?.assetUrl || "",
|
||||||
|
giftName: gift?.name || gift?.giftName || gift?.resource?.name || "",
|
||||||
|
giftPriceCoin: Number(gift?.coinPrice ?? gift?.giftPriceCoin ?? gift?.resource?.coinPrice ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStakeGifts(stakeGifts) {
|
||||||
|
if (!Array.isArray(stakeGifts) || stakeGifts.length !== 4) {
|
||||||
|
return "房内猜拳必须配置 4 个礼物档位";
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
for (let index = 0; index < stakeGifts.length; index += 1) {
|
||||||
|
const gift = stakeGifts[index];
|
||||||
|
const giftId = String(gift?.giftId || "").trim();
|
||||||
|
if (!giftId) {
|
||||||
|
return `第 ${index + 1} 档礼物未选择`;
|
||||||
|
}
|
||||||
|
if (seen.has(giftId)) {
|
||||||
|
return `第 ${index + 1} 档礼物重复,请选择不同礼物`;
|
||||||
|
}
|
||||||
|
seen.add(giftId);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function formToPayload(form) {
|
function formToPayload(form) {
|
||||||
return {
|
return {
|
||||||
status: form.status || "active",
|
status: form.status || "active",
|
||||||
@ -308,7 +353,7 @@ function formToPayload(form) {
|
|||||||
revealCountdownMs: Number(form.revealCountdownMs || 3000),
|
revealCountdownMs: Number(form.revealCountdownMs || 3000),
|
||||||
stakeGifts: form.stakeGifts.map((gift, index) => ({
|
stakeGifts: form.stakeGifts.map((gift, index) => ({
|
||||||
enabled: gift.enabled !== false,
|
enabled: gift.enabled !== false,
|
||||||
giftId: Number(gift.giftId || 0),
|
giftId: String(gift.giftId || "").trim(),
|
||||||
sortOrder: Number(gift.sortOrder || index + 1),
|
sortOrder: Number(gift.sortOrder || index + 1),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,7 +38,7 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("room rps gift tier uses shared gift drawer selection before saving", async () => {
|
test("room rps gift tier uses gift preview card drawer selection before saving", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -54,19 +54,20 @@ test("room rps gift tier uses shared gift drawer selection before saving", async
|
|||||||
await user.click(screen.getByRole("button", { name: "编辑" }));
|
await user.click(screen.getByRole("button", { name: "编辑" }));
|
||||||
expect(await screen.findByRole("dialog", { name: "房内猜拳配置" })).toBeInTheDocument();
|
expect(await screen.findByRole("dialog", { name: "房内猜拳配置" })).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByDisplayValue("Rock Rose"));
|
await user.click(screen.getByRole("button", { name: /第 1 档礼物 Rock Rose/ }));
|
||||||
|
|
||||||
expect(screen.getByRole("dialog", { name: "选择第 1 档礼物" })).toHaveAttribute("data-z-index", "1600");
|
expect(screen.getByRole("dialog", { name: "选择第 1 档礼物" })).toHaveAttribute("data-z-index", "1600");
|
||||||
await user.click(screen.getByText("Lucky Star"));
|
expect(screen.getByText("Invalid Alpha Gift")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText("Invalid Alpha Gift"));
|
||||||
|
|
||||||
expect(screen.getByDisplayValue("Lucky Star")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /第 1 档礼物 Invalid Alpha Gift/ })).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "保存配置" }));
|
await user.click(screen.getByRole("button", { name: "保存配置" }));
|
||||||
|
|
||||||
await waitFor(() => expect(patchBodies).toHaveLength(1));
|
await waitFor(() => expect(patchBodies).toHaveLength(1));
|
||||||
expect(patchBodies[0].stakeGifts[0]).toMatchObject({
|
expect(patchBodies[0].stakeGifts[0]).toMatchObject({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
giftId: 10005,
|
giftId: "Gifi-3",
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -95,8 +96,30 @@ async function mockFetch(input, init = {}) {
|
|||||||
if (url.includes("/v1/admin/gifts")) {
|
if (url.includes("/v1/admin/gifts")) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
items: [
|
items: [
|
||||||
{ coinPrice: 10, giftId: "10001", giftTypeCode: "normal", name: "Rock Rose", resourceId: 11 },
|
{
|
||||||
{ coinPrice: 20, giftId: "10005", giftTypeCode: "normal", name: "Lucky Star", resourceId: 12 },
|
coinPrice: 10,
|
||||||
|
giftId: "10001",
|
||||||
|
giftTypeCode: "normal",
|
||||||
|
name: "Rock Rose",
|
||||||
|
resource: { previewUrl: "https://cdn.example.com/rock.webp", resourceCode: "rock_rose" },
|
||||||
|
resourceId: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
coinPrice: 20,
|
||||||
|
giftId: "10005",
|
||||||
|
giftTypeCode: "normal",
|
||||||
|
name: "Lucky Star",
|
||||||
|
resource: { previewUrl: "https://cdn.example.com/lucky.webp", resourceCode: "lucky_star" },
|
||||||
|
resourceId: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
coinPrice: 1,
|
||||||
|
giftId: "Gifi-3",
|
||||||
|
giftTypeCode: "normal",
|
||||||
|
name: "Invalid Alpha Gift",
|
||||||
|
resource: { previewUrl: "https://cdn.example.com/invalid.webp", resourceCode: "invalid_alpha" },
|
||||||
|
resourceId: 13,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 200,
|
pageSize: 200,
|
||||||
|
|||||||
@ -1,13 +1,24 @@
|
|||||||
|
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||||
|
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
||||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||||
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
|
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
|
import TuneOutlined from "@mui/icons-material/TuneOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { PERMISSIONS } from "@/app/permissions";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { adjustDicePool, listSelfGames, updateDiceConfig } from "@/features/games/api";
|
import {
|
||||||
|
adjustSelfGameStakePool,
|
||||||
|
getSelfGameNewUserPolicy,
|
||||||
|
listSelfGameStakePools,
|
||||||
|
listSelfGames,
|
||||||
|
updateDiceConfig,
|
||||||
|
updateSelfGameNewUserPolicy,
|
||||||
|
updateSelfGameStakePool,
|
||||||
|
} from "@/features/games/api";
|
||||||
import styles from "@/features/games/games.module.css";
|
import styles from "@/features/games/games.module.css";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
@ -36,15 +47,38 @@ const defaultConfigForm = {
|
|||||||
maxPlayers: 2,
|
maxPlayers: 2,
|
||||||
robotEnabled: true,
|
robotEnabled: true,
|
||||||
robotMatchWaitMs: 3000,
|
robotMatchWaitMs: 3000,
|
||||||
robotUserWinRatePercent: 50,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPoolForm = {
|
const defaultStakePoolConfigForm = {
|
||||||
|
stakeCoin: "",
|
||||||
|
targetBalanceCoin: "",
|
||||||
|
safeBalanceCoin: "",
|
||||||
|
warningBalanceCoin: "",
|
||||||
|
dangerBalanceCoin: "",
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultStakePoolAdjustForm = {
|
||||||
|
stakeCoin: "",
|
||||||
amountCoin: "",
|
amountCoin: "",
|
||||||
direction: "in",
|
direction: "in",
|
||||||
reason: "admin_adjust",
|
reason: "admin_adjust",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultPolicyForm = {
|
||||||
|
enabled: true,
|
||||||
|
protectionRounds: 30,
|
||||||
|
protectionHours: 168,
|
||||||
|
maxStakeCoin: 5000,
|
||||||
|
lifetimeSubsidyQuotaCoin: 30000,
|
||||||
|
day1SubsidyQuotaCoin: 15000,
|
||||||
|
singleRoundSubsidyCapCoin: 5000,
|
||||||
|
strategyLevel: "soft_boost",
|
||||||
|
loseStreakProtectionEnabled: true,
|
||||||
|
loseStreakTrigger: 2,
|
||||||
|
normalPhaseTargetWinRatePercent: 53,
|
||||||
|
};
|
||||||
|
|
||||||
export function SelfGamePage() {
|
export function SelfGamePage() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@ -55,7 +89,10 @@ export function SelfGamePage() {
|
|||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [activeGame, setActiveGame] = useState(null);
|
const [activeGame, setActiveGame] = useState(null);
|
||||||
const [configForm, setConfigForm] = useState(defaultConfigForm);
|
const [configForm, setConfigForm] = useState(defaultConfigForm);
|
||||||
const [poolForm, setPoolForm] = useState(defaultPoolForm);
|
const [stakePools, setStakePools] = useState([]);
|
||||||
|
const [stakePoolConfigForm, setStakePoolConfigForm] = useState(defaultStakePoolConfigForm);
|
||||||
|
const [stakePoolAdjustForm, setStakePoolAdjustForm] = useState(defaultStakePoolAdjustForm);
|
||||||
|
const [policyForm, setPolicyForm] = useState(defaultPolicyForm);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -73,6 +110,74 @@ export function SelfGamePage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setActiveAction("");
|
||||||
|
setActiveGame(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openConfig = useCallback((game) => {
|
||||||
|
setActiveGame(game);
|
||||||
|
setConfigForm(configToForm(game));
|
||||||
|
setActiveAction("config");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyStakePools = useCallback((pools, direction = "in") => {
|
||||||
|
const items = pools || [];
|
||||||
|
setStakePools(items);
|
||||||
|
const selected = items[0];
|
||||||
|
if (!selected) {
|
||||||
|
setStakePoolConfigForm(defaultStakePoolConfigForm);
|
||||||
|
setStakePoolAdjustForm({ ...defaultStakePoolAdjustForm, direction });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStakePoolConfigForm(stakePoolToConfigForm(selected));
|
||||||
|
setStakePoolAdjustForm(stakePoolToAdjustForm(selected, direction));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadStakePools = useCallback(
|
||||||
|
async (gameId, direction = stakePoolAdjustForm.direction || "in") => {
|
||||||
|
const result = await listSelfGameStakePools(gameId);
|
||||||
|
applyStakePools(result.items || [], direction);
|
||||||
|
},
|
||||||
|
[applyStakePools, stakePoolAdjustForm.direction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openStakePools = useCallback(
|
||||||
|
async (game, direction = "in") => {
|
||||||
|
setActiveGame(game);
|
||||||
|
setActiveAction("stake-pools");
|
||||||
|
setLoadingAction("stake-pools-load");
|
||||||
|
try {
|
||||||
|
await reloadStakePools(game.gameId, direction);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "加载档位奖池失败", "error");
|
||||||
|
closeDialog();
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[closeDialog, reloadStakePools, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openPolicy = useCallback(
|
||||||
|
async (game) => {
|
||||||
|
setActiveGame(game);
|
||||||
|
setPolicyForm({ ...defaultPolicyForm, gameId: game.gameId });
|
||||||
|
setActiveAction("policy");
|
||||||
|
setLoadingAction("policy-load");
|
||||||
|
try {
|
||||||
|
const policy = await getSelfGameNewUserPolicy(game.gameId);
|
||||||
|
setPolicyForm(policyToForm(policy, game.gameId));
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "加载新手策略失败", "error");
|
||||||
|
closeDialog();
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[closeDialog, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -108,8 +213,7 @@ export function SelfGamePage() {
|
|||||||
key: "robot",
|
key: "robot",
|
||||||
label: "机器人",
|
label: "机器人",
|
||||||
width: "minmax(190px, .9fr)",
|
width: "minmax(190px, .9fr)",
|
||||||
render: (game) =>
|
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms · 策略引擎`,
|
||||||
`${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms · 玩家胜率 ${game.robotUserWinRatePercent ?? 50}%`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "updated",
|
key: "updated",
|
||||||
@ -120,54 +224,36 @@ export function SelfGamePage() {
|
|||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "176px",
|
width: "232px",
|
||||||
render: (game) => (
|
render: (game) => (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
||||||
<SettingsOutlined fontSize="small" />
|
<SettingsOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton disabled={!canUpdate} label="新手策略" onClick={() => openPolicy(game)}>
|
||||||
disabled={!canUpdate}
|
<TuneOutlined fontSize="small" />
|
||||||
label="增加奖池"
|
|
||||||
onClick={() => openPool(game, "in")}
|
|
||||||
>
|
|
||||||
<AddCircleOutlineOutlined fontSize="small" />
|
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
label="扣除奖池"
|
label="档位奖池"
|
||||||
onClick={() => openPool(game, "out")}
|
onClick={() => openStakePools(game, "in")}
|
||||||
>
|
>
|
||||||
<RemoveCircleOutlineOutlined fontSize="small" />
|
<AccountBalanceWalletOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!canUpdate}
|
||||||
|
label="快速加奖池"
|
||||||
|
onClick={() => openStakePools(game, "in")}
|
||||||
|
>
|
||||||
|
<AddCircleOutlineOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[canUpdate],
|
[canUpdate, openConfig, openPolicy, openStakePools],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openConfig = (game) => {
|
|
||||||
setActiveGame(game);
|
|
||||||
setConfigForm(configToForm(game));
|
|
||||||
setActiveAction("config");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPool = (game, direction) => {
|
|
||||||
setActiveGame(game);
|
|
||||||
setPoolForm({
|
|
||||||
...defaultPoolForm,
|
|
||||||
direction,
|
|
||||||
reason: direction === "out" ? "admin_deduct" : "admin_adjust",
|
|
||||||
});
|
|
||||||
setActiveAction("pool");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setActiveAction("");
|
|
||||||
setActiveGame(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitConfig = async (event) => {
|
const submitConfig = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeGame) return;
|
if (!activeGame) return;
|
||||||
@ -184,21 +270,57 @@ export function SelfGamePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitPool = async (event) => {
|
const submitStakePoolConfig = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeGame) return;
|
if (!activeGame) return;
|
||||||
setLoadingAction("pool");
|
setLoadingAction("stake-pool-config");
|
||||||
try {
|
try {
|
||||||
await adjustDicePool(activeGame.gameId, {
|
await updateSelfGameStakePool(
|
||||||
amountCoin: Number(poolForm.amountCoin || 0),
|
activeGame.gameId,
|
||||||
direction: poolForm.direction,
|
Number(stakePoolConfigForm.stakeCoin || 0),
|
||||||
reason: poolForm.reason,
|
stakePoolConfigPayload(stakePoolConfigForm),
|
||||||
|
);
|
||||||
|
await reloadStakePools(activeGame.gameId, stakePoolAdjustForm.direction || "in");
|
||||||
|
showToast("档位奖池配置已保存", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存档位奖池失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitStakePoolAdjust = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeGame) return;
|
||||||
|
setLoadingAction("stake-pool-adjust");
|
||||||
|
try {
|
||||||
|
await adjustSelfGameStakePool(activeGame.gameId, Number(stakePoolAdjustForm.stakeCoin || 0), {
|
||||||
|
amountCoin: Number(stakePoolAdjustForm.amountCoin || 0),
|
||||||
|
direction: stakePoolAdjustForm.direction,
|
||||||
|
reason: stakePoolAdjustForm.reason,
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
showToast(poolForm.direction === "out" ? "奖池已扣除" : "奖池已增加", "success");
|
await reloadStakePools(activeGame.gameId, stakePoolAdjustForm.direction || "in");
|
||||||
|
setStakePoolAdjustForm((current) => ({ ...current, amountCoin: "" }));
|
||||||
|
showToast(stakePoolAdjustForm.direction === "out" ? "档位奖池已扣除" : "档位奖池已增加", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "调整档位奖池失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPolicy = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeGame) return;
|
||||||
|
setLoadingAction("policy");
|
||||||
|
try {
|
||||||
|
await updateSelfGameNewUserPolicy(activeGame.gameId, policyPayload(policyForm, activeGame.gameId));
|
||||||
|
await load();
|
||||||
|
showToast("新手策略已保存", "success");
|
||||||
closeDialog();
|
closeDialog();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "调整奖池失败", "error");
|
showToast(err.message || "保存新手策略失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAction("");
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
@ -228,13 +350,27 @@ export function SelfGamePage() {
|
|||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
onSubmit={submitConfig}
|
onSubmit={submitConfig}
|
||||||
/>
|
/>
|
||||||
<PoolDialog
|
<StakePoolsDialog
|
||||||
form={poolForm}
|
adjustForm={stakePoolAdjustForm}
|
||||||
loading={loadingAction === "pool"}
|
configForm={stakePoolConfigForm}
|
||||||
open={activeAction === "pool"}
|
loading={loadingAction === "stake-pools-load" || loadingAction === "stake-pool-config"}
|
||||||
setForm={setPoolForm}
|
pools={stakePools}
|
||||||
|
setAdjustForm={setStakePoolAdjustForm}
|
||||||
|
setConfigForm={setStakePoolConfigForm}
|
||||||
|
submittingConfig={loadingAction === "stake-pool-config"}
|
||||||
|
submittingAdjust={loadingAction === "stake-pool-adjust"}
|
||||||
|
open={activeAction === "stake-pools"}
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
onSubmit={submitPool}
|
onSubmitAdjust={submitStakePoolAdjust}
|
||||||
|
onSubmitConfig={submitStakePoolConfig}
|
||||||
|
/>
|
||||||
|
<PolicyDialog
|
||||||
|
form={policyForm}
|
||||||
|
loading={loadingAction === "policy" || loadingAction === "policy-load"}
|
||||||
|
open={activeAction === "policy"}
|
||||||
|
setForm={setPolicyForm}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onSubmit={submitPolicy}
|
||||||
/>
|
/>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
@ -300,14 +436,6 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|||||||
value={form.robotMatchWaitMs}
|
value={form.robotMatchWaitMs}
|
||||||
onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })}
|
onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
helperText="机器人局真人玩家胜率,50 为正常规则"
|
|
||||||
label="玩家胜率 %"
|
|
||||||
type="number"
|
|
||||||
value={form.robotUserWinRatePercent}
|
|
||||||
slotProps={{ htmlInput: { max: 100, min: 0 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, robotUserWinRatePercent: event.target.value })}
|
|
||||||
/>
|
|
||||||
<AdminFormSwitchField
|
<AdminFormSwitchField
|
||||||
checked={form.robotEnabled}
|
checked={form.robotEnabled}
|
||||||
label="机器人补位"
|
label="机器人补位"
|
||||||
@ -319,33 +447,322 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
function StakePoolsDialog({
|
||||||
const isDeduct = form.direction === "out";
|
adjustForm,
|
||||||
|
configForm,
|
||||||
|
loading,
|
||||||
|
open,
|
||||||
|
pools,
|
||||||
|
setAdjustForm,
|
||||||
|
setConfigForm,
|
||||||
|
submittingAdjust,
|
||||||
|
submittingConfig,
|
||||||
|
onClose,
|
||||||
|
onSubmitAdjust,
|
||||||
|
onSubmitConfig,
|
||||||
|
}) {
|
||||||
|
const selectedStakeCoin = Number(configForm.stakeCoin || adjustForm.stakeCoin || 0);
|
||||||
|
const selectedPool = pools.find((pool) => pool.stakeCoin === selectedStakeCoin) || pools[0];
|
||||||
|
const isDeduct = adjustForm.direction === "out";
|
||||||
|
const selectPool = (pool, direction = adjustForm.direction || "in") => {
|
||||||
|
setConfigForm(stakePoolToConfigForm(pool));
|
||||||
|
setAdjustForm(stakePoolToAdjustForm(pool, direction));
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={loading}
|
loading={loading}
|
||||||
open={open}
|
open={open}
|
||||||
submitLabel={isDeduct ? "确认扣除" : "确认增加"}
|
size="wide"
|
||||||
title={isDeduct ? "扣除奖池" : "增加奖池"}
|
submitDisabled={loading || submittingConfig || !configForm.stakeCoin}
|
||||||
|
submitLabel="保存档位配置"
|
||||||
|
title="档位奖池"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmitConfig}
|
||||||
|
>
|
||||||
|
<AdminFormSection title="档位水位">
|
||||||
|
<div className={styles.stakePoolList}>
|
||||||
|
{pools.length ? (
|
||||||
|
pools.map((pool) => (
|
||||||
|
<button
|
||||||
|
key={pool.stakeCoin}
|
||||||
|
className={[
|
||||||
|
styles.stakePoolRow,
|
||||||
|
pool.stakeCoin === selectedStakeCoin ? styles.stakePoolRowActive : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectPool(pool)}
|
||||||
|
>
|
||||||
|
<span className={styles.stakePoolMain}>
|
||||||
|
<span className={styles.stakePoolStake}>{formatNumber(pool.stakeCoin)}</span>
|
||||||
|
<span className={poolLevelClass(pool.poolLevel)}>
|
||||||
|
{poolLevelText(pool.poolLevel)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.stakePoolMetrics}>
|
||||||
|
余额 {formatNumber(pool.balanceCoin)} · 可用 {formatNumber(pool.availableCoin)} ·
|
||||||
|
冻结 {formatNumber(pool.reservedCoin)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.stakePoolMetrics}>
|
||||||
|
单局需 {formatNumber(pool.requiredPoolCoin)} · 可承受{" "}
|
||||||
|
{formatNumber(pool.humanWinCapacity)} 次 ·{" "}
|
||||||
|
{pool.botMatchEnabled ? "可补机器人" : "暂停补机器人"}
|
||||||
|
</span>
|
||||||
|
{pool.poolLevel === "black" ? (
|
||||||
|
<span className={styles.stakePoolWarning}>
|
||||||
|
黑色原因:{blackReasonText(pool.blackReason)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={styles.stakePoolEmpty}>暂无档位奖池</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminFormSection>
|
||||||
|
<AdminFormSection title="水位阈值">
|
||||||
|
<AdminFormFieldGrid>
|
||||||
|
<TextField disabled label="当前档位" value={formatNumber(configForm.stakeCoin)} />
|
||||||
|
<TextField
|
||||||
|
label="状态"
|
||||||
|
select
|
||||||
|
value={configForm.status}
|
||||||
|
onChange={(event) => setConfigForm({ ...configForm, status: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="active">启用</MenuItem>
|
||||||
|
<MenuItem value="disabled">停用</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="目标奖池"
|
||||||
|
type="number"
|
||||||
|
value={configForm.targetBalanceCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
onChange={(event) => setConfigForm({ ...configForm, targetBalanceCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="绿色阈值"
|
||||||
|
type="number"
|
||||||
|
value={configForm.safeBalanceCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
onChange={(event) => setConfigForm({ ...configForm, safeBalanceCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="黄色阈值"
|
||||||
|
type="number"
|
||||||
|
value={configForm.warningBalanceCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
onChange={(event) => setConfigForm({ ...configForm, warningBalanceCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="红色阈值"
|
||||||
|
type="number"
|
||||||
|
value={configForm.dangerBalanceCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
onChange={(event) => setConfigForm({ ...configForm, dangerBalanceCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
{selectedPool ? (
|
||||||
|
<div className={styles.stakePoolHint}>
|
||||||
|
黑色水位自动判断:状态停用,或可用余额低于单局需 {formatNumber(selectedPool.requiredPoolCoin)}。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AdminFormSection>
|
||||||
|
<AdminFormSection title="档位奖池加扣">
|
||||||
|
<AdminFormFieldGrid>
|
||||||
|
<TextField disabled label="当前档位" value={formatNumber(adjustForm.stakeCoin)} />
|
||||||
|
<TextField
|
||||||
|
label="方向"
|
||||||
|
select
|
||||||
|
value={adjustForm.direction}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAdjustForm({
|
||||||
|
...adjustForm,
|
||||||
|
direction: event.target.value,
|
||||||
|
reason: event.target.value === "out" ? "admin_deduct" : "admin_adjust",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="in">增加</MenuItem>
|
||||||
|
<MenuItem value="out">扣除</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
helperText={isDeduct ? "不能扣成负数" : ""}
|
||||||
|
label="金币"
|
||||||
|
type="number"
|
||||||
|
value={adjustForm.amountCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setAdjustForm({ ...adjustForm, amountCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="原因"
|
||||||
|
value={adjustForm.reason}
|
||||||
|
onChange={(event) => setAdjustForm({ ...adjustForm, reason: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
<div className={styles.stakePoolInlineActions}>
|
||||||
|
<button
|
||||||
|
className={styles.stakePoolPrimaryButton}
|
||||||
|
disabled={loading || submittingAdjust || !adjustForm.stakeCoin || !adjustForm.amountCoin}
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmitAdjust}
|
||||||
|
>
|
||||||
|
{isDeduct ? "确认扣除" : "确认增加"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AdminFormSection>
|
||||||
|
</AdminFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StrategyLevelLabel() {
|
||||||
|
return (
|
||||||
|
<span className={styles.strategyLevelLabel}>
|
||||||
|
<span>策略等级</span>
|
||||||
|
<HelpOutlineOutlined className={styles.strategyLevelHelpIcon} fontSize="inherit" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StrategyLevelTooltip() {
|
||||||
|
return (
|
||||||
|
<div className={styles.strategyLevelTooltip}>
|
||||||
|
<div className={styles.strategyLevelTooltipTitle}>只影响新手期机器人补位局</div>
|
||||||
|
<div>
|
||||||
|
低风险、绿色水位、下注 1000/5000 且额度足够时:柔性激励按第 1 局 70%、第 2-6 局 60%、第 7-16 局 55%、第
|
||||||
|
17-30 局 50%、第 31 局后正常随机;强激励对应 85%、75%、65%、55%、50%。
|
||||||
|
</div>
|
||||||
|
<ul className={styles.strategyLevelTooltipList}>
|
||||||
|
<li>关闭保护:不强制用户赢,全部走 dice/rock 正常随机。</li>
|
||||||
|
<li>首局不输:第 1 个有效机器人局 100% 正反馈,后续按柔性激励曲线。</li>
|
||||||
|
<li>柔性激励案例:第 3 个有效机器人局命中率 60%;rock 平局不计有效局,也不消耗额度。</li>
|
||||||
|
<li>强激励案例:第 10 个有效机器人局命中率 65%;第 20 个有效机器人局命中率 55%。</li>
|
||||||
|
<li>黄色水位案例:柔性第 1 局 70% 降到 60%,强激励第 1 局 85% 降到 75%。</li>
|
||||||
|
<li>风控案例:risk_score 30-49 降 10%,50-69 降 25%,70 及以上不享受保护。</li>
|
||||||
|
<li>额度案例:默认最大下注 5000,40000/100000 档不触发;首日额度不足时降级正常随机。</li>
|
||||||
|
<li>自然期案例:配置 50% 就完全自然随机;配置 53% 时 dice 约 6% 的机器人局补正为用户赢,rock 约 4.1%。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||||
|
return (
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={loading}
|
||||||
|
open={open}
|
||||||
|
submitLabel="保存策略"
|
||||||
|
title="新手策略"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormFieldGrid>
|
<AdminFormSection title="保护窗口">
|
||||||
<TextField disabled label="方向" value={isDeduct ? "扣除" : "增加"} />
|
<AdminFormFieldGrid>
|
||||||
<TextField
|
<AdminFormSwitchField
|
||||||
helperText={isDeduct ? "不能扣成负数" : ""}
|
checked={form.enabled}
|
||||||
label="金币"
|
label="启用"
|
||||||
type="number"
|
onChange={(event) => setForm({ ...form, enabled: event.target.checked })}
|
||||||
value={form.amountCoin}
|
/>
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
<Tooltip
|
||||||
onChange={(event) => setForm({ ...form, amountCoin: event.target.value })}
|
arrow
|
||||||
/>
|
placement="left"
|
||||||
<TextField
|
title={<StrategyLevelTooltip />}
|
||||||
label="原因"
|
slotProps={{ tooltip: { className: styles.strategyLevelTooltipPaper } }}
|
||||||
value={form.reason}
|
>
|
||||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
<TextField
|
||||||
/>
|
label={<StrategyLevelLabel />}
|
||||||
</AdminFormFieldGrid>
|
select
|
||||||
|
value={form.strategyLevel}
|
||||||
|
onChange={(event) => setForm({ ...form, strategyLevel: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="none">关闭保护</MenuItem>
|
||||||
|
<MenuItem value="first_no_lose">首局不输</MenuItem>
|
||||||
|
<MenuItem value="soft_boost">柔性激励</MenuItem>
|
||||||
|
<MenuItem value="strong_boost">强激励</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Tooltip>
|
||||||
|
<TextField
|
||||||
|
label="保护局数"
|
||||||
|
type="number"
|
||||||
|
value={form.protectionRounds}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, protectionRounds: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="保护小时"
|
||||||
|
type="number"
|
||||||
|
value={form.protectionHours}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, protectionHours: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
|
<AdminFormSection title="补贴额度">
|
||||||
|
<AdminFormFieldGrid>
|
||||||
|
<TextField
|
||||||
|
label="最大下注"
|
||||||
|
type="number"
|
||||||
|
value={form.maxStakeCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, maxStakeCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="生命周期额度"
|
||||||
|
type="number"
|
||||||
|
value={form.lifetimeSubsidyQuotaCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, lifetimeSubsidyQuotaCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="首日额度"
|
||||||
|
type="number"
|
||||||
|
value={form.day1SubsidyQuotaCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, day1SubsidyQuotaCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="单局上限"
|
||||||
|
type="number"
|
||||||
|
value={form.singleRoundSubsidyCapCoin}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, singleRoundSubsidyCapCoin: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
|
<AdminFormSection title="连输保护">
|
||||||
|
<AdminFormFieldGrid>
|
||||||
|
<AdminFormSwitchField
|
||||||
|
checked={form.loseStreakProtectionEnabled}
|
||||||
|
label="启用"
|
||||||
|
onChange={(event) => setForm({ ...form, loseStreakProtectionEnabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="连输阈值"
|
||||||
|
type="number"
|
||||||
|
value={form.loseStreakTrigger}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, loseStreakTrigger: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
|
<AdminFormSection title="成熟期体验">
|
||||||
|
<AdminFormFieldGrid>
|
||||||
|
<Tooltip
|
||||||
|
arrow
|
||||||
|
placement="left"
|
||||||
|
title="只影响已过新手保护候选条件的机器人补位局。50% 表示完全自然随机;高于 50% 会换算成少量用户赢补正,不会强制用户输,也不消耗新手补贴额度。"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
helperText="建议 53,用来抵消 5% 平台费和 1% 入池带来的长期负期望"
|
||||||
|
label="自然期目标胜率 %"
|
||||||
|
type="number"
|
||||||
|
value={form.normalPhaseTargetWinRatePercent}
|
||||||
|
slotProps={{ htmlInput: { min: 50, max: 100, step: 1 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, normalPhaseTargetWinRatePercent: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -360,7 +777,6 @@ function configToForm(game) {
|
|||||||
maxPlayers: game.maxPlayers,
|
maxPlayers: game.maxPlayers,
|
||||||
robotEnabled: game.robotEnabled,
|
robotEnabled: game.robotEnabled,
|
||||||
robotMatchWaitMs: game.robotMatchWaitMs,
|
robotMatchWaitMs: game.robotMatchWaitMs,
|
||||||
robotUserWinRatePercent: game.robotUserWinRatePercent ?? 50,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -375,14 +791,94 @@ function configPayload(form, gameId) {
|
|||||||
maxPlayers: Number(form.maxPlayers || 2),
|
maxPlayers: Number(form.maxPlayers || 2),
|
||||||
robotEnabled: Boolean(form.robotEnabled),
|
robotEnabled: Boolean(form.robotEnabled),
|
||||||
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
|
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
|
||||||
robotUserWinRatePercent: clampPercent(form.robotUserWinRatePercent),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampPercent(value) {
|
function policyToForm(policy, gameId) {
|
||||||
const numeric = Number(value);
|
return {
|
||||||
if (!Number.isFinite(numeric)) return 50;
|
...defaultPolicyForm,
|
||||||
return Math.min(100, Math.max(0, Math.trunc(numeric)));
|
...policy,
|
||||||
|
gameId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyPayload(form, gameId) {
|
||||||
|
return {
|
||||||
|
gameId,
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
protectionRounds: Number(form.protectionRounds || 30),
|
||||||
|
protectionHours: Number(form.protectionHours || 168),
|
||||||
|
maxStakeCoin: Number(form.maxStakeCoin || 5000),
|
||||||
|
lifetimeSubsidyQuotaCoin: Number(form.lifetimeSubsidyQuotaCoin || 30000),
|
||||||
|
day1SubsidyQuotaCoin: Number(form.day1SubsidyQuotaCoin || 15000),
|
||||||
|
singleRoundSubsidyCapCoin: Number(form.singleRoundSubsidyCapCoin || 5000),
|
||||||
|
strategyLevel: form.strategyLevel || "soft_boost",
|
||||||
|
loseStreakProtectionEnabled: Boolean(form.loseStreakProtectionEnabled),
|
||||||
|
loseStreakTrigger: Number(form.loseStreakTrigger || 2),
|
||||||
|
normalPhaseTargetWinRatePercent: Number(form.normalPhaseTargetWinRatePercent || 53),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stakePoolToConfigForm(pool) {
|
||||||
|
if (!pool) return defaultStakePoolConfigForm;
|
||||||
|
return {
|
||||||
|
stakeCoin: pool.stakeCoin,
|
||||||
|
targetBalanceCoin: pool.targetBalanceCoin,
|
||||||
|
safeBalanceCoin: pool.safeBalanceCoin,
|
||||||
|
warningBalanceCoin: pool.warningBalanceCoin,
|
||||||
|
dangerBalanceCoin: pool.dangerBalanceCoin,
|
||||||
|
status: pool.status || "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stakePoolToAdjustForm(pool, direction = "in") {
|
||||||
|
if (!pool) return { ...defaultStakePoolAdjustForm, direction };
|
||||||
|
return {
|
||||||
|
...defaultStakePoolAdjustForm,
|
||||||
|
stakeCoin: pool.stakeCoin,
|
||||||
|
direction,
|
||||||
|
reason: direction === "out" ? "admin_deduct" : "admin_adjust",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stakePoolConfigPayload(form) {
|
||||||
|
return {
|
||||||
|
targetBalanceCoin: Number(form.targetBalanceCoin || 0),
|
||||||
|
safeBalanceCoin: Number(form.safeBalanceCoin || 0),
|
||||||
|
warningBalanceCoin: Number(form.warningBalanceCoin || 0),
|
||||||
|
dangerBalanceCoin: Number(form.dangerBalanceCoin || 0),
|
||||||
|
status: form.status || "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function poolLevelText(level) {
|
||||||
|
switch (level) {
|
||||||
|
case "green":
|
||||||
|
return "绿色";
|
||||||
|
case "yellow":
|
||||||
|
return "黄色";
|
||||||
|
case "red":
|
||||||
|
return "红色";
|
||||||
|
case "black":
|
||||||
|
return "黑色";
|
||||||
|
default:
|
||||||
|
return level || "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function poolLevelClass(level) {
|
||||||
|
return [styles.poolLevelBadge, styles[`poolLevel_${level || "unknown"}`]].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function blackReasonText(reason) {
|
||||||
|
switch (reason) {
|
||||||
|
case "status_disabled":
|
||||||
|
return "档位奖池已停用";
|
||||||
|
case "available_below_required_pool_coin":
|
||||||
|
return "可用余额不足以覆盖真人赢机器人净支出";
|
||||||
|
default:
|
||||||
|
return reason || "可用余额不足";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStakeOptions(text) {
|
function parseStakeOptions(text) {
|
||||||
|
|||||||
@ -75,9 +75,32 @@ export interface InviteActivityRewardClaimDto {
|
|||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteActivityRewardInviteeSummaryDto {
|
||||||
|
userId: string;
|
||||||
|
user?: InviteActivityRewardClaimUserDto;
|
||||||
|
rechargeCoinAmount: number;
|
||||||
|
invitedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteActivityRewardSummaryDto {
|
||||||
|
key: string;
|
||||||
|
appCode?: string;
|
||||||
|
cycleKey?: string;
|
||||||
|
userId: string;
|
||||||
|
user?: InviteActivityRewardClaimUserDto;
|
||||||
|
inviteCount: number;
|
||||||
|
validInviteCount: number;
|
||||||
|
totalRechargeCoinAmount: number;
|
||||||
|
totalRewardCoinAmount: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
invitees: InviteActivityRewardInviteeSummaryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
type RawConfig = InviteActivityRewardConfigDto & Record<string, unknown>;
|
type RawConfig = InviteActivityRewardConfigDto & Record<string, unknown>;
|
||||||
type RawTier = InviteActivityRewardTierDto & Record<string, unknown>;
|
type RawTier = InviteActivityRewardTierDto & Record<string, unknown>;
|
||||||
type RawClaim = InviteActivityRewardClaimDto & Record<string, unknown>;
|
type RawClaim = InviteActivityRewardClaimDto & Record<string, unknown>;
|
||||||
|
type RawInviteeSummary = InviteActivityRewardInviteeSummaryDto & Record<string, unknown>;
|
||||||
|
type RawSummary = InviteActivityRewardSummaryDto & Record<string, unknown>;
|
||||||
|
|
||||||
export function getInviteActivityRewardConfig(): Promise<InviteActivityRewardConfigDto> {
|
export function getInviteActivityRewardConfig(): Promise<InviteActivityRewardConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig;
|
const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig;
|
||||||
@ -110,6 +133,18 @@ export function listInviteActivityRewardClaims(query: PageQuery = {}): Promise<A
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInviteActivityRewardSummaries(
|
||||||
|
query: PageQuery = {},
|
||||||
|
): Promise<ApiPage<InviteActivityRewardSummaryDto>> {
|
||||||
|
return apiRequest<ApiPage<RawSummary>>("/v1/admin/activity/invite-reward/summaries", {
|
||||||
|
method: "GET",
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeSummary),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto {
|
function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto {
|
||||||
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||||
return {
|
return {
|
||||||
@ -176,6 +211,45 @@ function normalizeClaim(item: RawClaim): InviteActivityRewardClaimDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSummary(item: RawSummary): InviteActivityRewardSummaryDto {
|
||||||
|
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||||
|
const rawInvitees = Array.isArray(item.invitees) ? (item.invitees as RawInviteeSummary[]) : [];
|
||||||
|
const cycleKey = stringValue(item.cycleKey ?? item.cycle_key);
|
||||||
|
const userId = stringValue(item.userId ?? item.user_id);
|
||||||
|
return {
|
||||||
|
key: `${cycleKey}:${userId}`,
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
cycleKey,
|
||||||
|
userId,
|
||||||
|
user: normalizeUser(rawUser),
|
||||||
|
inviteCount: numberValue(item.inviteCount ?? item.invite_count),
|
||||||
|
validInviteCount: numberValue(item.validInviteCount ?? item.valid_invite_count),
|
||||||
|
totalRechargeCoinAmount: numberValue(item.totalRechargeCoinAmount ?? item.total_recharge_coin_amount),
|
||||||
|
totalRewardCoinAmount: numberValue(item.totalRewardCoinAmount ?? item.total_reward_coin_amount),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
invitees: rawInvitees.map(normalizeInviteeSummary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInviteeSummary(item: RawInviteeSummary): InviteActivityRewardInviteeSummaryDto {
|
||||||
|
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
userId: stringValue(item.userId ?? item.user_id),
|
||||||
|
user: normalizeUser(rawUser),
|
||||||
|
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
||||||
|
invitedAtMs: numberValue(item.invitedAtMs ?? item.invited_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUser(rawUser: Record<string, unknown>): InviteActivityRewardClaimUserDto {
|
||||||
|
return {
|
||||||
|
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
||||||
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
|
username: stringValue(rawUser.username),
|
||||||
|
avatar: stringValue(rawUser.avatar),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function stringValue(value: unknown) {
|
function stringValue(value: unknown) {
|
||||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getInviteActivityRewardConfig,
|
getInviteActivityRewardConfig,
|
||||||
listInviteActivityRewardClaims,
|
listInviteActivityRewardSummaries,
|
||||||
updateInviteActivityRewardConfig,
|
updateInviteActivityRewardConfig,
|
||||||
} from "@/features/invite-activity-reward/api";
|
} from "@/features/invite-activity-reward/api";
|
||||||
import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js";
|
import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js";
|
||||||
@ -9,7 +9,7 @@ import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
|||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
const emptySummaries = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
perInviteInviterRewardCoinAmount: "",
|
perInviteInviterRewardCoinAmount: "",
|
||||||
@ -27,25 +27,23 @@ export function useInviteActivityRewardPage() {
|
|||||||
const [configSaving, setConfigSaving] = useState(false);
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [cycleKey, setCycleKey] = useState("");
|
const [cycleKey, setCycleKey] = useState("");
|
||||||
const [rewardType, setRewardType] = useState("");
|
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({ keyword: query, cycle_key: cycleKey, reward_type: rewardType, status }),
|
() => ({ keyword: query, cycle_key: cycleKey }),
|
||||||
[cycleKey, query, rewardType, status],
|
[cycleKey, query],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data: claims = emptyClaims,
|
data: summaries = emptySummaries,
|
||||||
error: claimsError,
|
error: summariesError,
|
||||||
loading: claimsLoading,
|
loading: summariesLoading,
|
||||||
reload: reloadClaims,
|
reload: reloadSummaries,
|
||||||
} = usePaginatedQuery({
|
} = usePaginatedQuery({
|
||||||
errorMessage: "加载邀请活动领取记录失败",
|
errorMessage: "加载邀请活动列表失败",
|
||||||
fetcher: listInviteActivityRewardClaims,
|
fetcher: listInviteActivityRewardSummaries,
|
||||||
filters,
|
filters,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
queryKey: ["invite-activity-reward-claims", filters, page],
|
queryKey: ["invite-activity-reward-summaries", filters, page],
|
||||||
});
|
});
|
||||||
|
|
||||||
const reloadConfig = useCallback(async () => {
|
const reloadConfig = useCallback(async () => {
|
||||||
@ -83,15 +81,6 @@ export function useInviteActivityRewardPage() {
|
|||||||
setCycleKey(value);
|
setCycleKey(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
const changeRewardType = (value) => {
|
|
||||||
setRewardType(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const changeStatus = (value) => {
|
|
||||||
setStatus(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitConfig = async (event) => {
|
const submitConfig = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!abilities.canUpdate) {
|
if (!abilities.canUpdate) {
|
||||||
@ -104,7 +93,7 @@ export function useInviteActivityRewardPage() {
|
|||||||
setForm(formFromConfig(saved));
|
setForm(formFromConfig(saved));
|
||||||
setConfigDrawerOpen(false);
|
setConfigDrawerOpen(false);
|
||||||
showToast("邀请活动配置已保存", "success");
|
showToast("邀请活动配置已保存", "success");
|
||||||
await reloadClaims();
|
await reloadSummaries();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存邀请活动配置失败", "error");
|
showToast(err.message || "保存邀请活动配置失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
@ -116,11 +105,6 @@ export function useInviteActivityRewardPage() {
|
|||||||
abilities,
|
abilities,
|
||||||
changeCycleKey,
|
changeCycleKey,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changeRewardType,
|
|
||||||
changeStatus,
|
|
||||||
claims,
|
|
||||||
claimsError,
|
|
||||||
claimsLoading,
|
|
||||||
closeConfigDrawer,
|
closeConfigDrawer,
|
||||||
config,
|
config,
|
||||||
configDrawerOpen,
|
configDrawerOpen,
|
||||||
@ -131,12 +115,13 @@ export function useInviteActivityRewardPage() {
|
|||||||
openConfigDrawer,
|
openConfigDrawer,
|
||||||
page,
|
page,
|
||||||
query,
|
query,
|
||||||
reloadClaims,
|
|
||||||
reloadConfig,
|
reloadConfig,
|
||||||
rewardType,
|
|
||||||
setForm,
|
setForm,
|
||||||
setPage,
|
setPage,
|
||||||
status,
|
reloadSummaries,
|
||||||
|
summaries,
|
||||||
|
summariesError,
|
||||||
|
summariesLoading,
|
||||||
submitConfig,
|
submitConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,6 +83,74 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rowExpanded {
|
||||||
|
background: var(--fill-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandUser {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow {
|
||||||
|
min-height: 0;
|
||||||
|
border-bottom: 1px solid var(--row-border);
|
||||||
|
background: var(--fill-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailCell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 var(--space-3) var(--space-3) calc(var(--space-3) + 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviteeTable {
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviteeHeader,
|
||||||
|
.inviteeRow {
|
||||||
|
display: grid;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: minmax(240px, 1fr) minmax(140px, 0.45fr) minmax(190px, 0.55fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviteeHeader {
|
||||||
|
min-height: 40px;
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviteeRow {
|
||||||
|
border-top: 1px solid var(--row-border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviteeEmpty {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.drawerSectionTitle {
|
.drawerSectionTitle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -116,4 +184,9 @@
|
|||||||
.inlineFields {
|
.inlineFields {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inviteeHeader,
|
||||||
|
.inviteeRow {
|
||||||
|
grid-template-columns: minmax(220px, 1fr) minmax(120px, 0.5fr) minmax(170px, 0.6fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
|
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||||
|
import { useState } from "react";
|
||||||
import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx";
|
import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx";
|
||||||
import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
|
import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
|
||||||
import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js";
|
import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js";
|
||||||
@ -6,143 +9,64 @@ import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
|||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
const claimStatusOptions = [
|
|
||||||
["pending", "发放中"],
|
|
||||||
["granted", "已发放"],
|
|
||||||
["failed", "发放失败"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const rewardTypeOptions = [
|
|
||||||
["recharge", "累计充值"],
|
|
||||||
["valid_invite", "Valid Users"],
|
|
||||||
["invite_inviter", "邀请人即时"],
|
|
||||||
["invite_invitee", "被邀请人即时"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const columnsBase = [
|
|
||||||
{
|
|
||||||
key: "user",
|
|
||||||
label: "用户信息",
|
|
||||||
width: "minmax(260px, 1.1fr)",
|
|
||||||
render: (claim) => <ClaimUser claim={claim} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cycle",
|
|
||||||
label: "周期",
|
|
||||||
width: "minmax(140px, 0.6fr)",
|
|
||||||
render: (claim) => claim.cycleKey || "-",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rewardType",
|
|
||||||
label: "奖励类型",
|
|
||||||
width: "minmax(130px, 0.6fr)",
|
|
||||||
render: (claim) => rewardTypeLabel(claim.rewardType),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tier",
|
|
||||||
label: "档位",
|
|
||||||
width: "minmax(210px, 0.9fr)",
|
|
||||||
render: (claim) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{claim.tierName || claim.tierCode || "-"}</span>
|
|
||||||
<span className={styles.meta}>{thresholdLabel(claim)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "reached",
|
|
||||||
label: "达成值",
|
|
||||||
width: "minmax(150px, 0.65fr)",
|
|
||||||
render: (claim) => reachedLabel(claim),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "reward",
|
|
||||||
label: "奖励金币",
|
|
||||||
width: "minmax(130px, 0.6fr)",
|
|
||||||
render: (claim) => formatNumber(claim.rewardCoinAmount),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
label: "状态",
|
|
||||||
width: "minmax(120px, 0.55fr)",
|
|
||||||
render: (claim) => claimStatusLabel(claim.status),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "createdAt",
|
|
||||||
label: "创建时间",
|
|
||||||
width: "minmax(180px, 0.85fr)",
|
|
||||||
render: (claim) => formatMillis(claim.createdAtMs),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "claimId",
|
|
||||||
label: "记录",
|
|
||||||
width: "minmax(280px, 1fr)",
|
|
||||||
render: (claim) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{claim.claimId}</span>
|
|
||||||
<span className={styles.meta}>{claim.walletTransactionId || claim.walletCommandId || "-"}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "failure",
|
|
||||||
label: "失败原因",
|
|
||||||
width: "minmax(180px, 0.85fr)",
|
|
||||||
render: (claim) => claim.failureReason || "-",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function InviteActivityRewardPage() {
|
export function InviteActivityRewardPage() {
|
||||||
const page = useInviteActivityRewardPage();
|
const page = useInviteActivityRewardPage();
|
||||||
const total = page.claims.total || 0;
|
const total = page.summaries.total || 0;
|
||||||
const columns = columnsBase.map((column) => {
|
const [expandedKey, setExpandedKey] = useState("");
|
||||||
if (column.key === "user") {
|
const toggleSummary = (summary) => {
|
||||||
return {
|
setExpandedKey((current) => (current === summary.key ? "" : summary.key));
|
||||||
...column,
|
};
|
||||||
filter: createTextColumnFilter({
|
const columns = [
|
||||||
placeholder: "搜索用户 ID、短号、名称",
|
{
|
||||||
value: page.query,
|
key: "user",
|
||||||
onChange: page.changeQuery,
|
label: "用户信息",
|
||||||
}),
|
width: "minmax(280px, 1.1fr)",
|
||||||
};
|
render: (summary) => <SummaryUser expanded={expandedKey === summary.key} summary={summary} />,
|
||||||
}
|
filter: createTextColumnFilter({
|
||||||
if (column.key === "cycle") {
|
placeholder: "搜索用户 ID、短号、名称",
|
||||||
return {
|
value: page.query,
|
||||||
...column,
|
onChange: page.changeQuery,
|
||||||
filter: createTextColumnFilter({
|
}),
|
||||||
placeholder: "周期,如 2026-06",
|
},
|
||||||
value: page.cycleKey,
|
{
|
||||||
onChange: page.changeCycleKey,
|
key: "cycle",
|
||||||
}),
|
label: "周期",
|
||||||
};
|
width: "minmax(140px, 0.55fr)",
|
||||||
}
|
render: (summary) => summary.cycleKey || "-",
|
||||||
if (column.key === "rewardType") {
|
filter: createTextColumnFilter({
|
||||||
return {
|
placeholder: "周期,如 2026-06",
|
||||||
...column,
|
value: page.cycleKey,
|
||||||
filter: createOptionsColumnFilter({
|
onChange: page.changeCycleKey,
|
||||||
options: [["", "全部类型"], ...rewardTypeOptions],
|
}),
|
||||||
placeholder: "奖励类型",
|
},
|
||||||
value: page.rewardType,
|
{
|
||||||
onChange: page.changeRewardType,
|
key: "inviteCount",
|
||||||
}),
|
label: "邀请人数",
|
||||||
};
|
width: "minmax(130px, 0.5fr)",
|
||||||
}
|
render: (summary) => `${formatNumber(summary.inviteCount)} 人`,
|
||||||
if (column.key === "status") {
|
},
|
||||||
return {
|
{
|
||||||
...column,
|
key: "validInviteCount",
|
||||||
filter: createOptionsColumnFilter({
|
label: "有效邀请人数",
|
||||||
options: [["", "全部状态"], ...claimStatusOptions],
|
width: "minmax(150px, 0.55fr)",
|
||||||
placeholder: "搜索状态",
|
render: (summary) => `${formatNumber(summary.validInviteCount)} 人`,
|
||||||
value: page.status,
|
},
|
||||||
onChange: page.changeStatus,
|
{
|
||||||
}),
|
key: "totalRechargeCoinAmount",
|
||||||
};
|
label: "邀请人数总充值",
|
||||||
}
|
width: "minmax(170px, 0.65fr)",
|
||||||
return column;
|
render: (summary) => formatNumber(summary.totalRechargeCoinAmount),
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
key: "totalRewardCoinAmount",
|
||||||
|
label: "总奖励金币",
|
||||||
|
width: "minmax(150px, 0.55fr)",
|
||||||
|
render: (summary) => formatNumber(summary.totalRewardCoinAmount),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
@ -153,23 +77,40 @@ export function InviteActivityRewardPage() {
|
|||||||
onEdit={page.openConfigDrawer}
|
onEdit={page.openConfigDrawer}
|
||||||
onRefresh={page.reloadConfig}
|
onRefresh={page.reloadConfig}
|
||||||
/>
|
/>
|
||||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
<DataState error={page.summariesError} loading={page.summariesLoading} onRetry={page.reloadSummaries}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={page.claims.items || []}
|
items={page.summaries.items || []}
|
||||||
minWidth="1580px"
|
minWidth="1180px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
page: page.page,
|
page: page.page,
|
||||||
pageSize: page.claims.pageSize || 50,
|
pageSize: page.summaries.pageSize || 50,
|
||||||
total,
|
total,
|
||||||
onPageChange: page.setPage,
|
onPageChange: page.setPage,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
rowKey={(claim) => claim.claimId}
|
renderRowDetail={(summary) =>
|
||||||
|
expandedKey === summary.key ? <InviteeDetail key={`${summary.key}:detail`} summary={summary} /> : null
|
||||||
|
}
|
||||||
|
rowKey={(summary) => summary.key}
|
||||||
|
getRowProps={(summary) => ({
|
||||||
|
"aria-expanded": expandedKey === summary.key,
|
||||||
|
className: expandedKey === summary.key ? styles.rowExpanded : "",
|
||||||
|
role: "button",
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: () => toggleSummary(summary),
|
||||||
|
onKeyDown: (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSummary(summary);
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
@ -187,35 +128,47 @@ export function InviteActivityRewardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClaimUser({ claim }) {
|
function SummaryUser({ expanded, summary }) {
|
||||||
const user = claim.user || {};
|
const user = summary.user || {};
|
||||||
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
|
return (
|
||||||
|
<div className={styles.expandUser}>
|
||||||
|
<span className={styles.expandIcon} aria-hidden="true">
|
||||||
|
{expanded ? <KeyboardArrowDownOutlined fontSize="small" /> : <KeyboardArrowRightOutlined fontSize="small" />}
|
||||||
|
</span>
|
||||||
|
<AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || summary.userId }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rewardTypeLabel(type) {
|
function InviteeDetail({ summary }) {
|
||||||
return rewardTypeOptions.find(([value]) => value === type)?.[1] || type || "-";
|
const invitees = summary.invitees || [];
|
||||||
}
|
return (
|
||||||
|
<div className={["admin-row", styles.detailRow].join(" ")}>
|
||||||
function claimStatusLabel(status) {
|
<div className={styles.detailCell}>
|
||||||
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
<div className={styles.inviteeTable}>
|
||||||
}
|
<div className={styles.inviteeHeader}>
|
||||||
|
<span>被邀请用户</span>
|
||||||
function thresholdLabel(claim) {
|
<span>充值</span>
|
||||||
if (claim.rewardType === "valid_invite") {
|
<span>邀请时间</span>
|
||||||
return `${formatNumber(claim.thresholdValidInviteCount)} people`;
|
</div>
|
||||||
}
|
{invitees.length ? (
|
||||||
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
invitees.map((invitee) => (
|
||||||
return claim.tierId ? `关联用户 ${claim.tierId}` : "-";
|
<div className={styles.inviteeRow} key={`${summary.key}:${invitee.userId}`}>
|
||||||
}
|
<AdminUserIdentity
|
||||||
return `${formatNumber(claim.thresholdCoinAmount)} 金币`;
|
openInAppUserDetail
|
||||||
}
|
user={{ ...(invitee.user || {}), userId: invitee.user?.userId || invitee.userId }}
|
||||||
|
/>
|
||||||
function reachedLabel(claim) {
|
<span>{formatNumber(invitee.rechargeCoinAmount)}</span>
|
||||||
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
<span>{invitee.invitedAtMs ? formatMillis(invitee.invitedAtMs) : "-"}</span>
|
||||||
return "1 人";
|
</div>
|
||||||
}
|
))
|
||||||
const unit = claim.rewardType === "valid_invite" ? "people" : "金币";
|
) : (
|
||||||
return `${formatNumber(claim.reachedValue)} ${unit}`;
|
<div className={styles.inviteeEmpty}>当前没有被邀请用户明细</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const drawerZIndex = 1600;
|
|||||||
export function GiftSelectField({
|
export function GiftSelectField({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
drawerTitle = "选择礼物",
|
drawerTitle = "选择礼物",
|
||||||
|
fallbackGift = null,
|
||||||
gifts = [],
|
gifts = [],
|
||||||
label = "礼物",
|
label = "礼物",
|
||||||
loading = false,
|
loading = false,
|
||||||
@ -19,11 +20,150 @@ export function GiftSelectField({
|
|||||||
required = true,
|
required = true,
|
||||||
value,
|
value,
|
||||||
}) {
|
}) {
|
||||||
|
const picker = useGiftPicker({ fallbackGift, gifts, loading, onChange, value });
|
||||||
|
const displayValue = picker.selectedGift
|
||||||
|
? giftName(picker.selectedGift)
|
||||||
|
: picker.normalizedValue
|
||||||
|
? `礼物 #${picker.normalizedValue}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
className={styles.field}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
label={label}
|
||||||
|
placeholder={loading ? "礼物加载中" : placeholder}
|
||||||
|
required={required}
|
||||||
|
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
||||||
|
value={displayValue}
|
||||||
|
onClick={picker.openDrawer}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
picker.openDrawer();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<GiftSelectDrawerContent drawerTitle={drawerTitle} picker={picker} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GiftPreviewSelectField({
|
||||||
|
badge = "",
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
drawerTitle = "选择礼物",
|
||||||
|
fallbackGift = null,
|
||||||
|
gifts = [],
|
||||||
|
label = "礼物",
|
||||||
|
loading = false,
|
||||||
|
onChange = () => {},
|
||||||
|
placeholder = "请选择礼物",
|
||||||
|
required = true,
|
||||||
|
value,
|
||||||
|
}) {
|
||||||
|
const picker = useGiftPicker({ fallbackGift, gifts, loading, onChange, value });
|
||||||
|
const selectedGift = picker.selectedGift;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label={`${label} ${selectedGift ? giftName(selectedGift) : placeholder}`}
|
||||||
|
className={[styles.previewField, className].filter(Boolean).join(" ")}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
type="button"
|
||||||
|
onClick={picker.openDrawer}
|
||||||
|
>
|
||||||
|
<span className={styles.previewTop}>
|
||||||
|
<span className={styles.previewLabel}>
|
||||||
|
{label}
|
||||||
|
{required ? <span className={styles.previewRequired}>*</span> : null}
|
||||||
|
</span>
|
||||||
|
{badge ? <span className={styles.previewBadge}>{badge}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className={styles.previewBody}>
|
||||||
|
<GiftThumb className={styles.previewThumb} gift={selectedGift} />
|
||||||
|
<span className={styles.previewCopy}>
|
||||||
|
<span className={styles.previewName}>
|
||||||
|
{selectedGift ? giftName(selectedGift) : loading ? "礼物加载中" : placeholder}
|
||||||
|
</span>
|
||||||
|
<span className={styles.previewMeta}>
|
||||||
|
{selectedGift
|
||||||
|
? `${giftIdentity(selectedGift)} · 金币 ${formatNumber(giftCoinPrice(selectedGift))}`
|
||||||
|
: "点击选择礼物"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<GiftSelectDrawerContent drawerTitle={drawerTitle} picker={picker} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiftSelectDrawerContent({ drawerTitle, picker }) {
|
||||||
|
return (
|
||||||
|
<SideDrawer
|
||||||
|
className={styles.drawer}
|
||||||
|
contentClassName={styles.drawerBody}
|
||||||
|
drawerProps={{ sx: { zIndex: drawerZIndex } }}
|
||||||
|
open={picker.open}
|
||||||
|
title={drawerTitle}
|
||||||
|
width="wide"
|
||||||
|
onClose={picker.closeDrawer}
|
||||||
|
>
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<TextField
|
||||||
|
className={styles.search}
|
||||||
|
placeholder="搜索礼物名称、礼物 ID、资源编码"
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchOutlined fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={picker.query}
|
||||||
|
onChange={(event) => picker.setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{picker.filteredGifts.map((gift) => {
|
||||||
|
const selected = String(gift.giftId) === picker.normalizedValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
|
||||||
|
key={gift.giftId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => picker.selectGift(gift)}
|
||||||
|
>
|
||||||
|
<GiftThumb gift={gift} />
|
||||||
|
<span className={styles.name}>{giftName(gift)}</span>
|
||||||
|
<span className={styles.meta}>{giftIdentity(gift)}</span>
|
||||||
|
<span className={styles.tags}>
|
||||||
|
<span className={styles.tag}>{gift.giftTypeCode || "普通礼物"}</span>
|
||||||
|
<span className={styles.tag}>金币 {formatNumber(giftCoinPrice(gift))}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!picker.filteredGifts.length ? <div className={styles.empty}>当前无可选礼物</div> : null}
|
||||||
|
</div>
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGiftPicker({ fallbackGift, gifts, loading, onChange, value }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalizedValue = String(value || "");
|
const normalizedValue = String(value || "");
|
||||||
const selectedGift = gifts.find((gift) => String(gift.giftId) === normalizedValue);
|
const selectedGift =
|
||||||
const displayValue = selectedGift ? giftName(selectedGift) : normalizedValue ? `礼物 #${normalizedValue}` : "";
|
gifts.find((gift) => String(gift.giftId) === normalizedValue) ||
|
||||||
|
(normalizedValue && fallbackGift && String(fallbackGift.giftId || "") === normalizedValue ? fallbackGift : null);
|
||||||
const filteredGifts = useMemo(() => {
|
const filteredGifts = useMemo(() => {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
@ -46,101 +186,58 @@ export function GiftSelectField({
|
|||||||
}, [gifts, query]);
|
}, [gifts, query]);
|
||||||
|
|
||||||
const openDrawer = () => {
|
const openDrawer = () => {
|
||||||
if (!disabled && !loading) {
|
if (!loading) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeDrawer = () => setOpen(false);
|
||||||
|
|
||||||
const selectGift = (gift) => {
|
const selectGift = (gift) => {
|
||||||
onChange(String(gift.giftId), gift);
|
onChange(String(gift.giftId), gift);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<>
|
closeDrawer,
|
||||||
<TextField
|
filteredGifts,
|
||||||
fullWidth
|
normalizedValue,
|
||||||
className={styles.field}
|
open,
|
||||||
disabled={disabled || loading}
|
openDrawer,
|
||||||
label={label}
|
query,
|
||||||
placeholder={loading ? "礼物加载中" : placeholder}
|
selectGift,
|
||||||
required={required}
|
selectedGift,
|
||||||
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
setQuery,
|
||||||
value={displayValue}
|
};
|
||||||
onClick={openDrawer}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
openDrawer();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<SideDrawer
|
|
||||||
className={styles.drawer}
|
|
||||||
contentClassName={styles.drawerBody}
|
|
||||||
drawerProps={{ sx: { zIndex: drawerZIndex } }}
|
|
||||||
open={open}
|
|
||||||
title={drawerTitle}
|
|
||||||
width="wide"
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<div className={styles.filters}>
|
|
||||||
<TextField
|
|
||||||
className={styles.search}
|
|
||||||
placeholder="搜索礼物名称、礼物 ID、资源编码"
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<SearchOutlined fontSize="small" />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{filteredGifts.map((gift) => {
|
|
||||||
const selected = String(gift.giftId) === normalizedValue;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
|
|
||||||
key={gift.giftId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectGift(gift)}
|
|
||||||
>
|
|
||||||
<GiftThumb gift={gift} />
|
|
||||||
<span className={styles.name}>{giftName(gift)}</span>
|
|
||||||
<span className={styles.meta}>
|
|
||||||
{gift.resource?.resourceCode || gift.giftId || `资源 ${gift.resourceId || "-"}`}
|
|
||||||
</span>
|
|
||||||
<span className={styles.tags}>
|
|
||||||
<span className={styles.tag}>{gift.giftTypeCode || "普通礼物"}</span>
|
|
||||||
<span className={styles.tag}>金币 {formatNumber(gift.coinPrice)}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!filteredGifts.length ? <div className={styles.empty}>当前无可选礼物</div> : null}
|
|
||||||
</div>
|
|
||||||
</SideDrawer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function GiftThumb({ gift }) {
|
function GiftThumb({ className = "", gift }) {
|
||||||
const imageUrl = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl || gift.resource?.animationUrl);
|
const imageUrl = imageURL(
|
||||||
|
gift?.resource?.previewUrl || gift?.resource?.assetUrl || gift?.resource?.animationUrl || gift?.giftIconUrl,
|
||||||
|
);
|
||||||
|
const [failedUrl, setFailedUrl] = useState("");
|
||||||
|
const displayImageUrl = imageUrl && imageUrl !== failedUrl ? imageUrl : "";
|
||||||
return (
|
return (
|
||||||
<span className={styles.thumb}>
|
<span className={[styles.thumb, className].filter(Boolean).join(" ")}>
|
||||||
{imageUrl ? <img alt="" src={imageUrl} /> : <CardGiftcardOutlined fontSize="small" />}
|
{displayImageUrl ? (
|
||||||
|
<img alt="" src={displayImageUrl} onError={() => setFailedUrl(displayImageUrl)} />
|
||||||
|
) : (
|
||||||
|
<CardGiftcardOutlined fontSize="small" />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function giftName(gift) {
|
function giftName(gift) {
|
||||||
return gift?.name || gift?.resource?.name || gift?.giftId || "礼物";
|
return gift?.name || gift?.giftName || gift?.resource?.name || gift?.giftId || "礼物";
|
||||||
|
}
|
||||||
|
|
||||||
|
function giftIdentity(gift) {
|
||||||
|
return gift?.resource?.resourceCode || gift?.giftId || `资源 ${gift?.resourceId || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giftCoinPrice(gift) {
|
||||||
|
return gift?.coinPrice ?? gift?.giftPriceCoin ?? gift?.resource?.coinPrice ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageURL(value) {
|
function imageURL(value) {
|
||||||
|
|||||||
@ -2,6 +2,115 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewField {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast) var(--ease-standard),
|
||||||
|
background var(--motion-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewField:hover {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewField:focus-visible {
|
||||||
|
outline: 3px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewField:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewTop {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewRequired {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
height: var(--admin-tag-height);
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--admin-tag-radius);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewBody {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewThumb {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewCopy {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 750;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewMeta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer.drawer {
|
.drawer.drawer {
|
||||||
width: min(720px, 100vw);
|
width: min(720px, 100vw);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -159,7 +268,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.card {
|
.card,
|
||||||
|
.previewField {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,6 @@ export interface ApiEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const API_OPERATIONS = {
|
export const API_OPERATIONS = {
|
||||||
adjustDicePool: "adjustDicePool",
|
|
||||||
appBanUser: "appBanUser",
|
appBanUser: "appBanUser",
|
||||||
appGetUser: "appGetUser",
|
appGetUser: "appGetUser",
|
||||||
appListBannedUsers: "appListBannedUsers",
|
appListBannedUsers: "appListBannedUsers",
|
||||||
@ -272,13 +271,6 @@ export const API_OPERATIONS = {
|
|||||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||||
|
|
||||||
export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||||
adjustDicePool: {
|
|
||||||
method: "POST",
|
|
||||||
operationId: API_OPERATIONS.adjustDicePool,
|
|
||||||
path: "/v1/admin/game/self-games/{game_id}/pool/adjust",
|
|
||||||
permission: "game:update",
|
|
||||||
permissions: ["game:update"],
|
|
||||||
},
|
|
||||||
appBanUser: {
|
appBanUser: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.appBanUser,
|
operationId: API_OPERATIONS.appBanUser,
|
||||||
|
|||||||
30
src/shared/api/generated/schema.d.ts
vendored
30
src/shared/api/generated/schema.d.ts
vendored
@ -1188,22 +1188,6 @@ export interface paths {
|
|||||||
patch: operations["updateDiceConfig"];
|
patch: operations["updateDiceConfig"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/game/self-games/{game_id}/pool/adjust": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["adjustDicePool"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/admin/game/robots": {
|
"/admin/game/robots": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4793,20 +4777,6 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
adjustDicePool: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
game_id: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
200: components["responses"]["EmptyResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listDiceRobots: {
|
listDiceRobots: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -230,6 +230,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.paper {
|
||||||
|
margin: 12px !important;
|
||||||
width: calc(100vw - 24px) !important;
|
width: calc(100vw - 24px) !important;
|
||||||
max-width: calc(100vw - 24px) !important;
|
max-width: calc(100vw - 24px) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import MenuList from "@mui/material/MenuList";
|
|||||||
import Popover from "@mui/material/Popover";
|
import Popover from "@mui/material/Popover";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
@ -30,6 +30,7 @@ export function DataTable({
|
|||||||
minWidth = "980px",
|
minWidth = "980px",
|
||||||
onColumnWidthsChange,
|
onColumnWidthsChange,
|
||||||
pagination,
|
pagination,
|
||||||
|
renderRowDetail,
|
||||||
resizableColumns = true,
|
resizableColumns = true,
|
||||||
rowKey,
|
rowKey,
|
||||||
tableClassName = "",
|
tableClassName = "",
|
||||||
@ -304,15 +305,19 @@ export function DataTable({
|
|||||||
{safeItems.map((item, index) => {
|
{safeItems.map((item, index) => {
|
||||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||||
|
const key = rowKey(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
<Fragment key={key}>
|
||||||
{preparedColumns.map((column) => (
|
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} {...restRowProps}>
|
||||||
<div className={cellClassName(column)} key={column.key}>
|
{preparedColumns.map((column) => (
|
||||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
<div className={cellClassName(column)} key={column.key}>
|
||||||
</div>
|
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
{renderRowDetail ? renderRowDetail(item, index, renderContext) : null}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
|
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user