diff --git a/src/features/games/api.ts b/src/features/games/api.ts index 7b94c57..e470861 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -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; +export type SelfGameNewUserPolicyPayload = Omit; 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 { + return apiRequest( + `/v1/admin/game/self-games/${encodeURIComponent(gameId)}/new-user-policy`, + { method: "GET" }, + ).then(normalizeSelfGameNewUserPolicy); +} + +export function updateSelfGameNewUserPolicy( gameId: string, + payload: SelfGameNewUserPolicyPayload, +): Promise { + return apiRequest( + `/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 { + return apiRequest( + `/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 { 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 { + 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 { + 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 { return { appCode: stringValue(robot.appCode), diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index 6d50c33..55e6107 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -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 { diff --git a/src/features/games/pages/RoomRpsConfigPage.jsx b/src/features/games/pages/RoomRpsConfigPage.jsx index 439a159..d85e800 100644 --- a/src/features/games/pages/RoomRpsConfigPage.jsx +++ b/src/features/games/pages/RoomRpsConfigPage.jsx @@ -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
{form.stakeGifts.map((gift, index) => (
- setGiftTier(setForm, form, index, { giftId: value })} - /> - - setGiftTier(setForm, form, index, { sortOrder: event.target.value }) + onChange={(value, selectedGift) => + setGiftTier(setForm, form, index, giftPatchFromSelected(value, selectedGift)) } /> - setGiftTier(setForm, form, index, { enabled: checked })} - /> +
+ + setGiftTier(setForm, form, index, { sortOrder: event.target.value }) + } + /> + setGiftTier(setForm, form, index, { enabled: checked })} + /> +
))}
@@ -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), })), }; diff --git a/src/features/games/pages/RoomRpsConfigPage.test.jsx b/src/features/games/pages/RoomRpsConfigPage.test.jsx index c29ab1c..2725045 100644 --- a/src/features/games/pages/RoomRpsConfigPage.test.jsx +++ b/src/features/games/pages/RoomRpsConfigPage.test.jsx @@ -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, diff --git a/src/features/games/pages/SelfGamePage.jsx b/src/features/games/pages/SelfGamePage.jsx index 90a7b61..f73e339 100644 --- a/src/features/games/pages/SelfGamePage.jsx +++ b/src/features/games/pages/SelfGamePage.jsx @@ -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) => ( openConfig(game)}> - openPool(game, "in")} - > - + openPolicy(game)}> + openPool(game, "out")} + label="档位奖池" + onClick={() => openStakePools(game, "in")} > - + + + openStakePools(game, "in")} + > + ), }, ], - [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} /> - + ); @@ -300,14 +436,6 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) { value={form.robotMatchWaitMs} onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })} /> - setForm({ ...form, robotUserWinRatePercent: event.target.value })} - /> 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 ( + +
+ {pools.length ? ( + pools.map((pool) => ( + + )) + ) : ( +
暂无档位奖池
+ )} +
+
+ + + + setConfigForm({ ...configForm, status: event.target.value })} + > + 启用 + 停用 + + setConfigForm({ ...configForm, targetBalanceCoin: event.target.value })} + /> + setConfigForm({ ...configForm, safeBalanceCoin: event.target.value })} + /> + setConfigForm({ ...configForm, warningBalanceCoin: event.target.value })} + /> + setConfigForm({ ...configForm, dangerBalanceCoin: event.target.value })} + /> + + {selectedPool ? ( +
+ 黑色水位自动判断:状态停用,或可用余额低于单局需 {formatNumber(selectedPool.requiredPoolCoin)}。 +
+ ) : null} +
+ + + + + setAdjustForm({ + ...adjustForm, + direction: event.target.value, + reason: event.target.value === "out" ? "admin_deduct" : "admin_adjust", + }) + } + > + 增加 + 扣除 + + setAdjustForm({ ...adjustForm, amountCoin: event.target.value })} + /> + setAdjustForm({ ...adjustForm, reason: event.target.value })} + /> + +
+ +
+
+
+ ); +} + +function StrategyLevelLabel() { + return ( + + 策略等级 + + + ); +} + +function StrategyLevelTooltip() { + return ( +
+
只影响新手期机器人补位局
+
+ 低风险、绿色水位、下注 1000/5000 且额度足够时:柔性激励按第 1 局 70%、第 2-6 局 60%、第 7-16 局 55%、第 + 17-30 局 50%、第 31 局后正常随机;强激励对应 85%、75%、65%、55%、50%。 +
+
    +
  • 关闭保护:不强制用户赢,全部走 dice/rock 正常随机。
  • +
  • 首局不输:第 1 个有效机器人局 100% 正反馈,后续按柔性激励曲线。
  • +
  • 柔性激励案例:第 3 个有效机器人局命中率 60%;rock 平局不计有效局,也不消耗额度。
  • +
  • 强激励案例:第 10 个有效机器人局命中率 65%;第 20 个有效机器人局命中率 55%。
  • +
  • 黄色水位案例:柔性第 1 局 70% 降到 60%,强激励第 1 局 85% 降到 75%。
  • +
  • 风控案例:risk_score 30-49 降 10%,50-69 降 25%,70 及以上不享受保护。
  • +
  • 额度案例:默认最大下注 5000,40000/100000 档不触发;首日额度不足时降级正常随机。
  • +
  • 自然期案例:配置 50% 就完全自然随机;配置 53% 时 dice 约 6% 的机器人局补正为用户赢,rock 约 4.1%。
  • +
+
+ ); +} + +function PolicyDialog({ form, loading, open, setForm, onClose, onSubmit }) { + return ( + - - - setForm({ ...form, amountCoin: event.target.value })} - /> - setForm({ ...form, reason: event.target.value })} - /> - + + + setForm({ ...form, enabled: event.target.checked })} + /> + } + slotProps={{ tooltip: { className: styles.strategyLevelTooltipPaper } }} + > + } + select + value={form.strategyLevel} + onChange={(event) => setForm({ ...form, strategyLevel: event.target.value })} + > + 关闭保护 + 首局不输 + 柔性激励 + 强激励 + + + setForm({ ...form, protectionRounds: event.target.value })} + /> + setForm({ ...form, protectionHours: event.target.value })} + /> + + + + + setForm({ ...form, maxStakeCoin: event.target.value })} + /> + setForm({ ...form, lifetimeSubsidyQuotaCoin: event.target.value })} + /> + setForm({ ...form, day1SubsidyQuotaCoin: event.target.value })} + /> + setForm({ ...form, singleRoundSubsidyCapCoin: event.target.value })} + /> + + + + + setForm({ ...form, loseStreakProtectionEnabled: event.target.checked })} + /> + setForm({ ...form, loseStreakTrigger: event.target.value })} + /> + + + + + + setForm({ ...form, normalPhaseTargetWinRatePercent: event.target.value })} + /> + + + ); } @@ -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) { diff --git a/src/features/invite-activity-reward/api.ts b/src/features/invite-activity-reward/api.ts index 0e8ac8c..61ff2e2 100644 --- a/src/features/invite-activity-reward/api.ts +++ b/src/features/invite-activity-reward/api.ts @@ -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; type RawTier = InviteActivityRewardTierDto & Record; type RawClaim = InviteActivityRewardClaimDto & Record; +type RawInviteeSummary = InviteActivityRewardInviteeSummaryDto & Record; +type RawSummary = InviteActivityRewardSummaryDto & Record; export function getInviteActivityRewardConfig(): Promise { const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig; @@ -110,6 +133,18 @@ export function listInviteActivityRewardClaims(query: PageQuery = {}): Promise> { + return apiRequest>("/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; + 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; + 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): 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); } diff --git a/src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js b/src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js index 19338ee..02b041e 100644 --- a/src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js +++ b/src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js @@ -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, }; } diff --git a/src/features/invite-activity-reward/invite-activity-reward.module.css b/src/features/invite-activity-reward/invite-activity-reward.module.css index 9fd2c28..b0d50db 100644 --- a/src/features/invite-activity-reward/invite-activity-reward.module.css +++ b/src/features/invite-activity-reward/invite-activity-reward.module.css @@ -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); + } } diff --git a/src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx b/src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx index 088d37b..c78d5a4 100644 --- a/src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx +++ b/src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx @@ -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) => , - }, - { - 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) => ( -
- {claim.tierName || claim.tierCode || "-"} - {thresholdLabel(claim)} -
- ), - }, - { - 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) => ( -
- {claim.claimId} - {claim.walletTransactionId || claim.walletCommandId || "-"} -
- ), - }, - { - 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) => , + 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 ( @@ -153,23 +77,40 @@ export function InviteActivityRewardPage() { onEdit={page.openConfigDrawer} onRefresh={page.reloadConfig} /> - + 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 ? : 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); + }, + })} /> @@ -187,35 +128,47 @@ export function InviteActivityRewardPage() { ); } -function ClaimUser({ claim }) { - const user = claim.user || {}; - return ; +function SummaryUser({ expanded, summary }) { + const user = summary.user || {}; + return ( +
+ + +
+ ); } -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 ( +
+
+
+
+ 被邀请用户 + 充值 + 邀请时间 +
+ {invitees.length ? ( + invitees.map((invitee) => ( +
+ + {formatNumber(invitee.rechargeCoinAmount)} + {invitee.invitedAtMs ? formatMillis(invitee.invitedAtMs) : "-"} +
+ )) + ) : ( +
当前没有被邀请用户明细
+ )} +
+
+
+ ); } function formatNumber(value) { diff --git a/src/features/resources/components/GiftSelectDrawer.jsx b/src/features/resources/components/GiftSelectDrawer.jsx index 074896d..22f09d4 100644 --- a/src/features/resources/components/GiftSelectDrawer.jsx +++ b/src/features/resources/components/GiftSelectDrawer.jsx @@ -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 ( + <> + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + picker.openDrawer(); + } + }} + /> + + + ); +} + +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 ( + <> + + + + ); +} + +function GiftSelectDrawerContent({ drawerTitle, picker }) { + return ( + +
+ + + + ), + }, + }} + value={picker.query} + onChange={(event) => picker.setQuery(event.target.value)} + /> +
+
+ {picker.filteredGifts.map((gift) => { + const selected = String(gift.giftId) === picker.normalizedValue; + return ( + + ); + })} + {!picker.filteredGifts.length ?
当前无可选礼物
: null} +
+
+ ); +} + +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 ( - <> - { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - openDrawer(); - } - }} - /> - setOpen(false)} - > -
- - - - ), - }, - }} - value={query} - onChange={(event) => setQuery(event.target.value)} - /> -
-
- {filteredGifts.map((gift) => { - const selected = String(gift.giftId) === normalizedValue; - return ( - - ); - })} - {!filteredGifts.length ?
当前无可选礼物
: null} -
-
- - ); + 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 ( - - {imageUrl ? : } + + {displayImageUrl ? ( + setFailedUrl(displayImageUrl)} /> + ) : ( + + )} ); } 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) { diff --git a/src/features/resources/components/ResourceSelectDrawer.module.css b/src/features/resources/components/ResourceSelectDrawer.module.css index 4a38586..f1261e5 100644 --- a/src/features/resources/components/ResourceSelectDrawer.module.css +++ b/src/features/resources/components/ResourceSelectDrawer.module.css @@ -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; } diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 2f58170..3b2da2f 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { - 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 9f0c122..2d12e07 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/ui/AdminFormDialog.module.css b/src/shared/ui/AdminFormDialog.module.css index 3b732ad..91f86a6 100644 --- a/src/shared/ui/AdminFormDialog.module.css +++ b/src/shared/ui/AdminFormDialog.module.css @@ -230,6 +230,7 @@ } .paper { + margin: 12px !important; width: calc(100vw - 24px) !important; max-width: calc(100vw - 24px) !important; } diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx index 5f1c85c..850187d 100644 --- a/src/shared/ui/DataTable.jsx +++ b/src/shared/ui/DataTable.jsx @@ -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 ( -
- {preparedColumns.map((column) => ( -
- {column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])} -
- ))} -
+ +
+ {preparedColumns.map((column) => ( +
+ {column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])} +
+ ))} +
+ {renderRowDetail ? renderRowDetail(item, index, renderContext) : null} +
); })} {infiniteScroll || paginationHasMore || paginationLoadingMore ? (