邀请相关
This commit is contained in:
parent
fd8c5bbd78
commit
a8614398a4
@ -91,12 +91,50 @@ export interface DiceConfigDto {
|
||||
maxPlayers: number;
|
||||
robotEnabled: boolean;
|
||||
robotMatchWaitMs: number;
|
||||
robotUserWinRatePercent: number;
|
||||
poolBalanceCoin: number;
|
||||
createdAtMs?: 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 {
|
||||
appCode?: string;
|
||||
gameId: string;
|
||||
@ -179,13 +217,14 @@ export interface RoomRpsChallengePage {
|
||||
}
|
||||
|
||||
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
|
||||
export type SelfGameNewUserPolicyPayload = Omit<SelfGameNewUserPolicyDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
||||
|
||||
export interface RoomRpsConfigPayload {
|
||||
status: string;
|
||||
challengeTimeoutMs: number;
|
||||
revealCountdownMs: number;
|
||||
stakeGifts: Array<{
|
||||
giftId: number;
|
||||
giftId: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
@ -197,6 +236,11 @@ export interface DicePoolAdjustPayload {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type SelfGameStakePoolPayload = Pick<
|
||||
SelfGameStakePoolDto,
|
||||
"targetBalanceCoin" | "safeBalanceCoin" | "warningBalanceCoin" | "dangerBalanceCoin" | "status"
|
||||
>;
|
||||
|
||||
export interface DiceGenerateRobotsPayload {
|
||||
count: number;
|
||||
nicknameLanguage?: "english" | "arabic";
|
||||
@ -329,14 +373,54 @@ export function updateDiceConfig(gameId: string, payload: DiceConfigPayload): Pr
|
||||
).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,
|
||||
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,
|
||||
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
|
||||
const endpoint = API_ENDPOINTS.adjustDicePool;
|
||||
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.adjustDicePool, { game_id: gameId }),
|
||||
{ body: payload, method: endpoint.method },
|
||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools/${encodeURIComponent(String(stakeCoin))}/adjust`,
|
||||
{ body: payload, method: "POST" },
|
||||
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
|
||||
}
|
||||
|
||||
@ -495,13 +579,55 @@ function normalizeDiceConfig(config: Partial<DiceConfigDto>): DiceConfigDto {
|
||||
maxPlayers: numberValue(config.maxPlayers || 2),
|
||||
robotEnabled: config.robotEnabled !== false,
|
||||
robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000),
|
||||
robotUserWinRatePercent: numberValue(config.robotUserWinRatePercent ?? 50),
|
||||
poolBalanceCoin: numberValue(config.poolBalanceCoin),
|
||||
createdAtMs: numberValue(config.createdAtMs),
|
||||
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 {
|
||||
return {
|
||||
appCode: stringValue(robot.appCode),
|
||||
|
||||
@ -65,26 +65,221 @@
|
||||
}
|
||||
|
||||
.giftTierList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.giftTierRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(110px, 0.5fr) minmax(96px, auto);
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
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 {
|
||||
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) {
|
||||
.giftTierRow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.giftTierControls {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.giftTierSort {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.platformList {
|
||||
|
||||
@ -7,7 +7,7 @@ import { PERMISSIONS } from "@/app/permissions";
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/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 {
|
||||
AdminActionIconButton,
|
||||
@ -154,11 +154,12 @@ export function RoomRpsConfigPage() {
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = formToPayload(form);
|
||||
if (payload.stakeGifts.length !== 4 || payload.stakeGifts.some((gift) => !gift.giftId)) {
|
||||
showToast("房内猜拳必须配置 4 个礼物档位", "error");
|
||||
const validationError = validateStakeGifts(form.stakeGifts);
|
||||
if (validationError) {
|
||||
showToast(validationError, "error");
|
||||
return;
|
||||
}
|
||||
const payload = formToPayload(form);
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateRoomRpsConfig(payload);
|
||||
@ -214,6 +215,7 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
||||
open={open}
|
||||
submitLabel="保存配置"
|
||||
title="房内猜拳配置"
|
||||
size="large"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
@ -246,28 +248,40 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
||||
<div className={styles.giftTierList}>
|
||||
{form.stakeGifts.map((gift, index) => (
|
||||
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
|
||||
<GiftSelectField
|
||||
<GiftPreviewSelectField
|
||||
badge={`${Number(gift.sortOrder || index + 1)} 档`}
|
||||
className={styles.giftTierGift}
|
||||
drawerTitle={`选择第 ${index + 1} 档礼物`}
|
||||
fallbackGift={gift}
|
||||
gifts={gifts}
|
||||
label={`第 ${index + 1} 档礼物`}
|
||||
loading={giftsLoading}
|
||||
placeholder="点击选择礼物"
|
||||
value={gift.giftId}
|
||||
onChange={(value) => setGiftTier(setForm, form, index, { giftId: value })}
|
||||
/>
|
||||
<TextField
|
||||
label="排序"
|
||||
type="number"
|
||||
value={gift.sortOrder}
|
||||
onChange={(event) =>
|
||||
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
|
||||
onChange={(value, selectedGift) =>
|
||||
setGiftTier(setForm, form, index, giftPatchFromSelected(value, selectedGift))
|
||||
}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={gift.enabled}
|
||||
label="启用"
|
||||
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
|
||||
/>
|
||||
<div className={styles.giftTierControls}>
|
||||
<TextField
|
||||
className={styles.giftTierSort}
|
||||
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>
|
||||
@ -288,6 +302,9 @@ function configToForm(config) {
|
||||
.map((gift, index) => ({
|
||||
enabled: gift.enabled !== false,
|
||||
giftId: gift.giftId || "",
|
||||
giftIconUrl: gift.giftIconUrl || "",
|
||||
giftName: gift.giftName || "",
|
||||
giftPriceCoin: Number(gift.giftPriceCoin || 0),
|
||||
sortOrder: gift.sortOrder || index + 1,
|
||||
}));
|
||||
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) {
|
||||
return {
|
||||
status: form.status || "active",
|
||||
@ -308,7 +353,7 @@ function formToPayload(form) {
|
||||
revealCountdownMs: Number(form.revealCountdownMs || 3000),
|
||||
stakeGifts: form.stakeGifts.map((gift, index) => ({
|
||||
enabled: gift.enabled !== false,
|
||||
giftId: Number(gift.giftId || 0),
|
||||
giftId: String(gift.giftId || "").trim(),
|
||||
sortOrder: Number(gift.sortOrder || index + 1),
|
||||
})),
|
||||
};
|
||||
|
||||
@ -38,7 +38,7 @@ afterEach(() => {
|
||||
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();
|
||||
|
||||
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: "编辑" }));
|
||||
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");
|
||||
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 waitFor(() => expect(patchBodies).toHaveLength(1));
|
||||
expect(patchBodies[0].stakeGifts[0]).toMatchObject({
|
||||
enabled: true,
|
||||
giftId: 10005,
|
||||
giftId: "Gifi-3",
|
||||
sortOrder: 1,
|
||||
});
|
||||
});
|
||||
@ -95,8 +96,30 @@ async function mockFetch(input, init = {}) {
|
||||
if (url.includes("/v1/admin/gifts")) {
|
||||
return jsonResponse({
|
||||
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,
|
||||
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 RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import TuneOutlined from "@mui/icons-material/TuneOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
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 {
|
||||
AdminActionIconButton,
|
||||
@ -36,15 +47,38 @@ const defaultConfigForm = {
|
||||
maxPlayers: 2,
|
||||
robotEnabled: true,
|
||||
robotMatchWaitMs: 3000,
|
||||
robotUserWinRatePercent: 50,
|
||||
};
|
||||
|
||||
const defaultPoolForm = {
|
||||
const defaultStakePoolConfigForm = {
|
||||
stakeCoin: "",
|
||||
targetBalanceCoin: "",
|
||||
safeBalanceCoin: "",
|
||||
warningBalanceCoin: "",
|
||||
dangerBalanceCoin: "",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const defaultStakePoolAdjustForm = {
|
||||
stakeCoin: "",
|
||||
amountCoin: "",
|
||||
direction: "in",
|
||||
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() {
|
||||
const { can } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
@ -55,7 +89,10 @@ export function SelfGamePage() {
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeGame, setActiveGame] = useState(null);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@ -73,6 +110,74 @@ export function SelfGamePage() {
|
||||
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(
|
||||
() => [
|
||||
{
|
||||
@ -108,8 +213,7 @@ export function SelfGamePage() {
|
||||
key: "robot",
|
||||
label: "机器人",
|
||||
width: "minmax(190px, .9fr)",
|
||||
render: (game) =>
|
||||
`${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms · 玩家胜率 ${game.robotUserWinRatePercent ?? 50}%`,
|
||||
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms · 策略引擎`,
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
@ -120,54 +224,36 @@ export function SelfGamePage() {
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "176px",
|
||||
width: "232px",
|
||||
render: (game) => (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!canUpdate}
|
||||
label="增加奖池"
|
||||
onClick={() => openPool(game, "in")}
|
||||
>
|
||||
<AddCircleOutlineOutlined fontSize="small" />
|
||||
<AdminActionIconButton disabled={!canUpdate} label="新手策略" onClick={() => openPolicy(game)}>
|
||||
<TuneOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!canUpdate}
|
||||
label="扣除奖池"
|
||||
onClick={() => openPool(game, "out")}
|
||||
label="档位奖池"
|
||||
onClick={() => openStakePools(game, "in")}
|
||||
>
|
||||
<RemoveCircleOutlineOutlined fontSize="small" />
|
||||
<AccountBalanceWalletOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!canUpdate}
|
||||
label="快速加奖池"
|
||||
onClick={() => openStakePools(game, "in")}
|
||||
>
|
||||
<AddCircleOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</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) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
@ -184,21 +270,57 @@ export function SelfGamePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitPool = async (event) => {
|
||||
const submitStakePoolConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("pool");
|
||||
setLoadingAction("stake-pool-config");
|
||||
try {
|
||||
await adjustDicePool(activeGame.gameId, {
|
||||
amountCoin: Number(poolForm.amountCoin || 0),
|
||||
direction: poolForm.direction,
|
||||
reason: poolForm.reason,
|
||||
await updateSelfGameStakePool(
|
||||
activeGame.gameId,
|
||||
Number(stakePoolConfigForm.stakeCoin || 0),
|
||||
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();
|
||||
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();
|
||||
} catch (err) {
|
||||
showToast(err.message || "调整奖池失败", "error");
|
||||
showToast(err.message || "保存新手策略失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
@ -228,13 +350,27 @@ export function SelfGamePage() {
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitConfig}
|
||||
/>
|
||||
<PoolDialog
|
||||
form={poolForm}
|
||||
loading={loadingAction === "pool"}
|
||||
open={activeAction === "pool"}
|
||||
setForm={setPoolForm}
|
||||
<StakePoolsDialog
|
||||
adjustForm={stakePoolAdjustForm}
|
||||
configForm={stakePoolConfigForm}
|
||||
loading={loadingAction === "stake-pools-load" || loadingAction === "stake-pool-config"}
|
||||
pools={stakePools}
|
||||
setAdjustForm={setStakePoolAdjustForm}
|
||||
setConfigForm={setStakePoolConfigForm}
|
||||
submittingConfig={loadingAction === "stake-pool-config"}
|
||||
submittingAdjust={loadingAction === "stake-pool-adjust"}
|
||||
open={activeAction === "stake-pools"}
|
||||
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>
|
||||
);
|
||||
@ -300,14 +436,6 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
value={form.robotMatchWaitMs}
|
||||
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
|
||||
checked={form.robotEnabled}
|
||||
label="机器人补位"
|
||||
@ -319,33 +447,322 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
const isDeduct = form.direction === "out";
|
||||
function StakePoolsDialog({
|
||||
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 (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
submitLabel={isDeduct ? "确认扣除" : "确认增加"}
|
||||
title={isDeduct ? "扣除奖池" : "增加奖池"}
|
||||
size="wide"
|
||||
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}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField disabled label="方向" value={isDeduct ? "扣除" : "增加"} />
|
||||
<TextField
|
||||
helperText={isDeduct ? "不能扣成负数" : ""}
|
||||
label="金币"
|
||||
type="number"
|
||||
value={form.amountCoin}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, amountCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="原因"
|
||||
value={form.reason}
|
||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
<AdminFormSection title="保护窗口">
|
||||
<AdminFormFieldGrid>
|
||||
<AdminFormSwitchField
|
||||
checked={form.enabled}
|
||||
label="启用"
|
||||
onChange={(event) => setForm({ ...form, enabled: event.target.checked })}
|
||||
/>
|
||||
<Tooltip
|
||||
arrow
|
||||
placement="left"
|
||||
title={<StrategyLevelTooltip />}
|
||||
slotProps={{ tooltip: { className: styles.strategyLevelTooltipPaper } }}
|
||||
>
|
||||
<TextField
|
||||
label={<StrategyLevelLabel />}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -360,7 +777,6 @@ function configToForm(game) {
|
||||
maxPlayers: game.maxPlayers,
|
||||
robotEnabled: game.robotEnabled,
|
||||
robotMatchWaitMs: game.robotMatchWaitMs,
|
||||
robotUserWinRatePercent: game.robotUserWinRatePercent ?? 50,
|
||||
};
|
||||
}
|
||||
|
||||
@ -375,14 +791,94 @@ function configPayload(form, gameId) {
|
||||
maxPlayers: Number(form.maxPlayers || 2),
|
||||
robotEnabled: Boolean(form.robotEnabled),
|
||||
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
|
||||
robotUserWinRatePercent: clampPercent(form.robotUserWinRatePercent),
|
||||
};
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 50;
|
||||
return Math.min(100, Math.max(0, Math.trunc(numeric)));
|
||||
function policyToForm(policy, gameId) {
|
||||
return {
|
||||
...defaultPolicyForm,
|
||||
...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) {
|
||||
|
||||
@ -75,9 +75,32 @@ export interface InviteActivityRewardClaimDto {
|
||||
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 RawTier = InviteActivityRewardTierDto & 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> {
|
||||
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 {
|
||||
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||
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) {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getInviteActivityRewardConfig,
|
||||
listInviteActivityRewardClaims,
|
||||
listInviteActivityRewardSummaries,
|
||||
updateInviteActivityRewardConfig,
|
||||
} from "@/features/invite-activity-reward/api";
|
||||
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";
|
||||
|
||||
const pageSize = 50;
|
||||
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptySummaries = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyForm = {
|
||||
enabled: true,
|
||||
perInviteInviterRewardCoinAmount: "",
|
||||
@ -27,25 +27,23 @@ export function useInviteActivityRewardPage() {
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [cycleKey, setCycleKey] = useState("");
|
||||
const [rewardType, setRewardType] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(
|
||||
() => ({ keyword: query, cycle_key: cycleKey, reward_type: rewardType, status }),
|
||||
[cycleKey, query, rewardType, status],
|
||||
() => ({ keyword: query, cycle_key: cycleKey }),
|
||||
[cycleKey, query],
|
||||
);
|
||||
const {
|
||||
data: claims = emptyClaims,
|
||||
error: claimsError,
|
||||
loading: claimsLoading,
|
||||
reload: reloadClaims,
|
||||
data: summaries = emptySummaries,
|
||||
error: summariesError,
|
||||
loading: summariesLoading,
|
||||
reload: reloadSummaries,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载邀请活动领取记录失败",
|
||||
fetcher: listInviteActivityRewardClaims,
|
||||
errorMessage: "加载邀请活动列表失败",
|
||||
fetcher: listInviteActivityRewardSummaries,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["invite-activity-reward-claims", filters, page],
|
||||
queryKey: ["invite-activity-reward-summaries", filters, page],
|
||||
});
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
@ -83,15 +81,6 @@ export function useInviteActivityRewardPage() {
|
||||
setCycleKey(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeRewardType = (value) => {
|
||||
setRewardType(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
@ -104,7 +93,7 @@ export function useInviteActivityRewardPage() {
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("邀请活动配置已保存", "success");
|
||||
await reloadClaims();
|
||||
await reloadSummaries();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存邀请活动配置失败", "error");
|
||||
} finally {
|
||||
@ -116,11 +105,6 @@ export function useInviteActivityRewardPage() {
|
||||
abilities,
|
||||
changeCycleKey,
|
||||
changeQuery,
|
||||
changeRewardType,
|
||||
changeStatus,
|
||||
claims,
|
||||
claimsError,
|
||||
claimsLoading,
|
||||
closeConfigDrawer,
|
||||
config,
|
||||
configDrawerOpen,
|
||||
@ -131,12 +115,13 @@ export function useInviteActivityRewardPage() {
|
||||
openConfigDrawer,
|
||||
page,
|
||||
query,
|
||||
reloadClaims,
|
||||
reloadConfig,
|
||||
rewardType,
|
||||
setForm,
|
||||
setPage,
|
||||
status,
|
||||
reloadSummaries,
|
||||
summaries,
|
||||
summariesError,
|
||||
summariesLoading,
|
||||
submitConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@ -83,6 +83,74 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -116,4 +184,9 @@
|
||||
.inlineFields {
|
||||
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 { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
|
||||
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 { DataState } from "@/shared/ui/DataState.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";
|
||||
|
||||
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() {
|
||||
const page = useInviteActivityRewardPage();
|
||||
const total = page.claims.total || 0;
|
||||
const columns = columnsBase.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、短号、名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "cycle") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "周期,如 2026-06",
|
||||
value: page.cycleKey,
|
||||
onChange: page.changeCycleKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "rewardType") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部类型"], ...rewardTypeOptions],
|
||||
placeholder: "奖励类型",
|
||||
value: page.rewardType,
|
||||
onChange: page.changeRewardType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部状态"], ...claimStatusOptions],
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const total = page.summaries.total || 0;
|
||||
const [expandedKey, setExpandedKey] = useState("");
|
||||
const toggleSummary = (summary) => {
|
||||
setExpandedKey((current) => (current === summary.key ? "" : summary.key));
|
||||
};
|
||||
const columns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(280px, 1.1fr)",
|
||||
render: (summary) => <SummaryUser expanded={expandedKey === summary.key} summary={summary} />,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、短号、名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期",
|
||||
width: "minmax(140px, 0.55fr)",
|
||||
render: (summary) => summary.cycleKey || "-",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "周期,如 2026-06",
|
||||
value: page.cycleKey,
|
||||
onChange: page.changeCycleKey,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "inviteCount",
|
||||
label: "邀请人数",
|
||||
width: "minmax(130px, 0.5fr)",
|
||||
render: (summary) => `${formatNumber(summary.inviteCount)} 人`,
|
||||
},
|
||||
{
|
||||
key: "validInviteCount",
|
||||
label: "有效邀请人数",
|
||||
width: "minmax(150px, 0.55fr)",
|
||||
render: (summary) => `${formatNumber(summary.validInviteCount)} 人`,
|
||||
},
|
||||
{
|
||||
key: "totalRechargeCoinAmount",
|
||||
label: "邀请人数总充值",
|
||||
width: "minmax(170px, 0.65fr)",
|
||||
render: (summary) => formatNumber(summary.totalRechargeCoinAmount),
|
||||
},
|
||||
{
|
||||
key: "totalRewardCoinAmount",
|
||||
label: "总奖励金币",
|
||||
width: "minmax(150px, 0.55fr)",
|
||||
render: (summary) => formatNumber(summary.totalRewardCoinAmount),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
@ -153,23 +77,40 @@ export function InviteActivityRewardPage() {
|
||||
onEdit={page.openConfigDrawer}
|
||||
onRefresh={page.reloadConfig}
|
||||
/>
|
||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||
<DataState error={page.summariesError} loading={page.summariesLoading} onRetry={page.reloadSummaries}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={page.claims.items || []}
|
||||
minWidth="1580px"
|
||||
items={page.summaries.items || []}
|
||||
minWidth="1180px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.claims.pageSize || 50,
|
||||
pageSize: page.summaries.pageSize || 50,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: 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>
|
||||
</DataState>
|
||||
@ -187,35 +128,47 @@ export function InviteActivityRewardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimUser({ claim }) {
|
||||
const user = claim.user || {};
|
||||
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
|
||||
function SummaryUser({ expanded, summary }) {
|
||||
const user = summary.user || {};
|
||||
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) {
|
||||
return rewardTypeOptions.find(([value]) => value === type)?.[1] || type || "-";
|
||||
}
|
||||
|
||||
function claimStatusLabel(status) {
|
||||
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||
}
|
||||
|
||||
function thresholdLabel(claim) {
|
||||
if (claim.rewardType === "valid_invite") {
|
||||
return `${formatNumber(claim.thresholdValidInviteCount)} people`;
|
||||
}
|
||||
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
||||
return claim.tierId ? `关联用户 ${claim.tierId}` : "-";
|
||||
}
|
||||
return `${formatNumber(claim.thresholdCoinAmount)} 金币`;
|
||||
}
|
||||
|
||||
function reachedLabel(claim) {
|
||||
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
||||
return "1 人";
|
||||
}
|
||||
const unit = claim.rewardType === "valid_invite" ? "people" : "金币";
|
||||
return `${formatNumber(claim.reachedValue)} ${unit}`;
|
||||
function InviteeDetail({ summary }) {
|
||||
const invitees = summary.invitees || [];
|
||||
return (
|
||||
<div className={["admin-row", styles.detailRow].join(" ")}>
|
||||
<div className={styles.detailCell}>
|
||||
<div className={styles.inviteeTable}>
|
||||
<div className={styles.inviteeHeader}>
|
||||
<span>被邀请用户</span>
|
||||
<span>充值</span>
|
||||
<span>邀请时间</span>
|
||||
</div>
|
||||
{invitees.length ? (
|
||||
invitees.map((invitee) => (
|
||||
<div className={styles.inviteeRow} key={`${summary.key}:${invitee.userId}`}>
|
||||
<AdminUserIdentity
|
||||
openInAppUserDetail
|
||||
user={{ ...(invitee.user || {}), userId: invitee.user?.userId || invitee.userId }}
|
||||
/>
|
||||
<span>{formatNumber(invitee.rechargeCoinAmount)}</span>
|
||||
<span>{invitee.invitedAtMs ? formatMillis(invitee.invitedAtMs) : "-"}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.inviteeEmpty}>当前没有被邀请用户明细</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
|
||||
@ -11,6 +11,7 @@ const drawerZIndex = 1600;
|
||||
export function GiftSelectField({
|
||||
disabled = false,
|
||||
drawerTitle = "选择礼物",
|
||||
fallbackGift = null,
|
||||
gifts = [],
|
||||
label = "礼物",
|
||||
loading = false,
|
||||
@ -19,11 +20,150 @@ export function GiftSelectField({
|
||||
required = true,
|
||||
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 [query, setQuery] = useState("");
|
||||
const normalizedValue = String(value || "");
|
||||
const selectedGift = gifts.find((gift) => String(gift.giftId) === normalizedValue);
|
||||
const displayValue = selectedGift ? giftName(selectedGift) : normalizedValue ? `礼物 #${normalizedValue}` : "";
|
||||
const selectedGift =
|
||||
gifts.find((gift) => String(gift.giftId) === normalizedValue) ||
|
||||
(normalizedValue && fallbackGift && String(fallbackGift.giftId || "") === normalizedValue ? fallbackGift : null);
|
||||
const filteredGifts = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
@ -46,101 +186,58 @@ export function GiftSelectField({
|
||||
}, [gifts, query]);
|
||||
|
||||
const openDrawer = () => {
|
||||
if (!disabled && !loading) {
|
||||
if (!loading) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDrawer = () => setOpen(false);
|
||||
|
||||
const selectGift = (gift) => {
|
||||
onChange(String(gift.giftId), gift);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
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={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>
|
||||
</>
|
||||
);
|
||||
return {
|
||||
closeDrawer,
|
||||
filteredGifts,
|
||||
normalizedValue,
|
||||
open,
|
||||
openDrawer,
|
||||
query,
|
||||
selectGift,
|
||||
selectedGift,
|
||||
setQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function GiftThumb({ gift }) {
|
||||
const imageUrl = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl || gift.resource?.animationUrl);
|
||||
function GiftThumb({ className = "", gift }) {
|
||||
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 (
|
||||
<span className={styles.thumb}>
|
||||
{imageUrl ? <img alt="" src={imageUrl} /> : <CardGiftcardOutlined fontSize="small" />}
|
||||
<span className={[styles.thumb, className].filter(Boolean).join(" ")}>
|
||||
{displayImageUrl ? (
|
||||
<img alt="" src={displayImageUrl} onError={() => setFailedUrl(displayImageUrl)} />
|
||||
) : (
|
||||
<CardGiftcardOutlined fontSize="small" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@ -2,6 +2,115 @@
|
||||
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 {
|
||||
width: min(720px, 100vw);
|
||||
height: 100%;
|
||||
@ -159,7 +268,8 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
.card,
|
||||
.previewField {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ export interface ApiEndpoint {
|
||||
}
|
||||
|
||||
export const API_OPERATIONS = {
|
||||
adjustDicePool: "adjustDicePool",
|
||||
appBanUser: "appBanUser",
|
||||
appGetUser: "appGetUser",
|
||||
appListBannedUsers: "appListBannedUsers",
|
||||
@ -272,13 +271,6 @@ export const API_OPERATIONS = {
|
||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||
|
||||
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: {
|
||||
method: "POST",
|
||||
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"];
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4793,20 +4777,6 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
adjustDicePool: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
game_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listDiceRobots: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -230,6 +230,7 @@
|
||||
}
|
||||
|
||||
.paper {
|
||||
margin: 12px !important;
|
||||
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 TextField from "@mui/material/TextField";
|
||||
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 { Button } from "@/shared/ui/Button.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
@ -30,6 +30,7 @@ export function DataTable({
|
||||
minWidth = "980px",
|
||||
onColumnWidthsChange,
|
||||
pagination,
|
||||
renderRowDetail,
|
||||
resizableColumns = true,
|
||||
rowKey,
|
||||
tableClassName = "",
|
||||
@ -304,15 +305,19 @@ export function DataTable({
|
||||
{safeItems.map((item, index) => {
|
||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||
const key = rowKey(item);
|
||||
|
||||
return (
|
||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||
{preparedColumns.map((column) => (
|
||||
<div className={cellClassName(column)} key={column.key}>
|
||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Fragment key={key}>
|
||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} {...restRowProps}>
|
||||
{preparedColumns.map((column) => (
|
||||
<div className={cellClassName(column)} key={column.key}>
|
||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderRowDetail ? renderRowDetail(item, index, renderContext) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user