邀请相关

This commit is contained in:
zhx 2026-06-12 19:11:00 +08:00
parent fd8c5bbd78
commit a8614398a4
15 changed files with 1593 additions and 448 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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>额度案例默认最大下注 500040000/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) {

View File

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

View File

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

View File

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

View File

@ -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) {

View File

@ -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) {

View File

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

View File

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

View File

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

View File

@ -230,6 +230,7 @@
}
.paper {
margin: 12px !important;
width: calc(100vw - 24px) !important;
max-width: calc(100vw - 24px) !important;
}

View File

@ -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 ? (