完善游戏平台

This commit is contained in:
zhx 2026-05-23 11:21:17 +08:00
parent 1e2b2eea3b
commit d06718cc49
25 changed files with 2433 additions and 4 deletions

View File

@ -148,6 +148,70 @@
]
}
},
"/admin/activity/red-packets": {
"get": {
"operationId": "listRedPackets",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "red-packet:view",
"x-permissions": [
"red-packet:view"
]
}
},
"/admin/activity/red-packets/{packet_id}": {
"get": {
"operationId": "getRedPacket",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "packet_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "red-packet:view",
"x-permissions": [
"red-packet:view"
]
}
},
"/admin/activity/red-packets/config": {
"get": {
"operationId": "getRedPacketConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "red-packet:view",
"x-permissions": [
"red-packet:view"
]
},
"put": {
"operationId": "updateRedPacketConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "red-packet:update",
"x-permissions": [
"red-packet:update"
]
}
},
"/admin/activity/lucky-gifts/config": {
"get": {
"operationId": "getLuckyGiftConfig",

View File

@ -152,7 +152,10 @@ export const fallbackNavigation = [
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
routeNavItem("room-treasure", { icon: RedeemOutlined }),
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
routeNavItem("red-packet", { icon: RedeemOutlined }),
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
],
},
{

View File

@ -111,6 +111,11 @@ export const PERMISSIONS = {
roomTreasureView: "room-treasure:view",
roomTreasureUpdate: "room-treasure:update",
userLeaderboardView: "user-leaderboard:view",
redPacketView: "red-packet:view",
redPacketUpdate: "red-packet:update",
vipConfigView: "vip-config:view",
vipConfigUpdate: "vip-config:update",
vipConfigGrant: "vip-config:grant",
uploadCreate: "upload:create",
} as const;
@ -159,6 +164,8 @@ export const MENU_CODES = {
sevenDayCheckIn: "seven-day-checkin",
roomTreasure: "room-treasure",
userLeaderboard: "user-leaderboard",
redPacket: "red-packet",
vipConfig: "vip-config",
geo: "geo",
hostOrg: "host-org",
hostOrgCountries: "host-org-countries",

View File

@ -14,6 +14,7 @@ import { menusRoutes } from "@/features/menus/routes.js";
import { operationsRoutes } from "@/features/operations/routes.js";
import { paymentRoutes } from "@/features/payment/routes.js";
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
import { redPacketRoutes } from "@/features/red-packets/routes.js";
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
import { resourceRoutes } from "@/features/resources/routes.js";
import { rolesRoutes } from "@/features/roles/routes.js";
@ -22,6 +23,7 @@ import { roomTreasureRoutes } from "@/features/room-treasure/routes.js";
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
import { usersRoutes } from "@/features/users/routes.js";
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
import type { MenuCode } from "@/app/permissions";
import type { AdminRoute, PublicRoute } from "./types";
@ -41,6 +43,8 @@ export const adminRoutes: AdminRoute[] = [
...sevenDayCheckInRoutes,
...roomTreasureRoutes,
...userLeaderboardRoutes,
...redPacketRoutes,
...vipConfigRoutes,
...resourceRoutes,
...operationsRoutes,
...luckyGiftRoutes,

View File

@ -8,7 +8,7 @@ export interface GamePlatformDto {
platformName: string;
status: string;
apiBaseUrl: string;
// 服务端按 adapterType 选择厂商协议demo/yomi_v4/leadercc_v1/zeeone_v1
// 服务端按 adapterType 选择厂商协议demo/yomi_v4/leadercc_v1/zeeone_v1/baishun_v1
adapterType: string;
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
callbackSecret?: string;
@ -52,6 +52,24 @@ export interface GamePlatformPage {
serverTimeMs?: number;
}
export interface GameSyncPayload {
dryRun?: boolean;
status?: string;
category?: string;
launchMode?: string;
minCoin?: number;
tags?: string[];
gameListType?: number;
providerGameIds?: string[];
}
export interface GameSyncResult {
platformCode: string;
synced: number;
dryRun: boolean;
items: GameCatalogDto[];
}
export type GamePlatformPayload = Omit<
GamePlatformDto,
"appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet"
@ -122,6 +140,21 @@ export function setGameStatus(gameId: string, status: string): Promise<GameCatal
).then(normalizeCatalog);
}
export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncPayload = {}): Promise<GameSyncResult> {
return apiRequest<GameSyncResult, GameSyncPayload>(
`/v1/admin/game/platforms/${encodeURIComponent(platformCode)}/sync-games`,
{
body: payload,
method: "POST",
},
).then((result) => ({
...result,
items: (result.items || []).map(normalizeCatalog),
synced: numberValue(result.synced),
dryRun: Boolean(result.dryRun),
}));
}
function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto {
return {
appCode: stringValue(platform.appCode),

View File

@ -83,3 +83,29 @@
align-items: center;
gap: 8px;
}
.syncGameList {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
max-height: 520px;
overflow: auto;
}
.syncGameRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(120px, auto) auto;
gap: 12px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.syncGameMeta {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
gap: 3px;
}

View File

@ -3,6 +3,7 @@ import {
listGameCatalog,
listGamePlatforms,
setGameStatus,
syncGamePlatformCatalog,
upsertGameCatalog,
upsertGamePlatform,
} from "@/features/games/api";
@ -31,7 +32,7 @@ const defaultPlatformForm = {
platformName: "",
status: "active",
apiBaseUrl: "",
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1
adapterType: "demo",
// 后台现在会回显厂商 key编辑弹窗直接显示当前值提交空值仍代表“不覆盖旧值”。
callbackSecret: "",
@ -60,6 +61,9 @@ export function useGamesPage() {
const [gameForm, setGameForm] = useState(defaultGameForm);
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
const [loadingAction, setLoadingAction] = useState("");
const [syncPlatform, setSyncPlatform] = useState(null);
const [syncCandidates, setSyncCandidates] = useState([]);
const [selectedSyncGameIds, setSelectedSyncGameIds] = useState([]);
const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
const {
@ -169,6 +173,11 @@ export function useGamesPage() {
};
const closeAction = () => {
if (activeAction === "sync-games") {
setSyncPlatform(null);
setSyncCandidates([]);
setSelectedSyncGameIds([]);
}
setActiveAction("");
setEditingGameId("");
setEditingPlatformCode("");
@ -221,9 +230,92 @@ export function useGamesPage() {
}
};
const fetchPlatformGames = async (platform) => {
setLoadingAction(`fetch-games-${platform.platformCode}`);
try {
const result = await syncGamePlatformCatalog(platform.platformCode, {
dryRun: true,
status: "disabled",
});
setSyncPlatform(platform);
setSyncCandidates(result.items || []);
setSelectedSyncGameIds([]);
setActiveAction("sync-games");
showToast(`已获取 ${result.items?.length || 0} 个游戏`, "success");
} catch (err) {
showToast(err.message || "获取厂商游戏列表失败", "error");
} finally {
setLoadingAction("");
}
};
const syncCandidateIds = useMemo(
() => syncCandidates.map((game) => game.providerGameId).filter(Boolean),
[syncCandidates],
);
const allSyncGamesSelected = syncCandidateIds.length > 0 && selectedSyncGameIds.length === syncCandidateIds.length;
const toggleAllSyncGames = () => {
setSelectedSyncGameIds(allSyncGamesSelected ? [] : syncCandidateIds);
};
const toggleSyncGame = (providerGameId) => {
setSelectedSyncGameIds((current) =>
current.includes(providerGameId)
? current.filter((item) => item !== providerGameId)
: [...current, providerGameId],
);
};
const importSelectedSyncGames = async (event) => {
event.preventDefault();
if (!syncPlatform || selectedSyncGameIds.length === 0) {
showToast("请选择要添加的游戏", "error");
return;
}
setLoadingAction("import-sync-games");
try {
const result = await syncGamePlatformCatalog(syncPlatform.platformCode, {
providerGameIds: selectedSyncGameIds,
status: "disabled",
});
await reloadPlatforms();
await reload();
showToast(`已添加 ${result.synced} 个游戏,默认下架`, "success");
closeAction();
} catch (err) {
showToast(err.message || "添加游戏失败", "error");
} finally {
setLoadingAction("");
}
};
const importSingleSyncGame = async (game) => {
if (!syncPlatform || !game.providerGameId) {
return;
}
setLoadingAction(`import-sync-game-${game.providerGameId}`);
try {
await syncGamePlatformCatalog(syncPlatform.platformCode, {
providerGameIds: [game.providerGameId],
status: "disabled",
});
await reloadPlatforms();
await reload();
setSelectedSyncGameIds((current) => current.filter((item) => item !== game.providerGameId));
setSyncCandidates((current) => current.filter((item) => item.providerGameId !== game.providerGameId));
showToast("游戏已添加,默认下架", "success");
} catch (err) {
showToast(err.message || "添加游戏失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
allSyncGamesSelected,
changeGameStatus,
closeAction,
data,
@ -232,6 +324,9 @@ export function useGamesPage() {
loading: loading || platformsLoading,
loadingAction,
page,
fetchPlatformGames,
importSelectedSyncGames,
importSingleSyncGame,
openCreateGame,
openCreatePlatform,
openEditGame,
@ -253,6 +348,11 @@ export function useGamesPage() {
status,
submitGame,
submitPlatform,
syncCandidates,
syncPlatform,
selectedSyncGameIds,
toggleAllSyncGames,
toggleSyncGame,
};
}

View File

@ -2,10 +2,13 @@ import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import SyncOutlined from "@mui/icons-material/SyncOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@ -39,6 +42,7 @@ const adapterTypeOptions = [
["yomi_v4", "小游 Yomi V4"],
["leadercc_v1", "灵仙 LeaderCC V1"],
["zeeone_v1", "ZeeOne V1"],
["baishun_v1", "百顺 BAISHUN V1"],
];
const baseColumns = [
@ -196,6 +200,7 @@ export function GameListPage() {
page={page}
onClose={page.closeAction}
/>
<ProviderGameListDialog open={page.activeAction === "sync-games"} page={page} onClose={page.closeAction} />
</AdminListPage>
);
}
@ -443,8 +448,8 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
</TextField>
{!isLeaderCC ? (
<TextField
label="H5/Auth URL"
required={form.adapterType === "yomi_v4"}
label="H5/Auth/API URL"
required={form.adapterType === "yomi_v4" || form.adapterType === "baishun_v1"}
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
@ -520,6 +525,16 @@ function PlatformManageDialog({ open, page, onClose }) {
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={
!page.abilities.canUpdate ||
page.loadingAction === `fetch-games-${platform.platformCode}`
}
label="获取游戏列表"
onClick={() => page.fetchPlatformGames(platform)}
>
<SyncOutlined fontSize="small" />
</AdminActionIconButton>
</div>
</div>
))}
@ -528,6 +543,60 @@ function PlatformManageDialog({ open, page, onClose }) {
);
}
function ProviderGameListDialog({ open, page, onClose }) {
const games = page.syncCandidates || [];
const selected = new Set(page.selectedSyncGameIds || []);
return (
<AdminFormDialog
loading={page.loadingAction === "import-sync-games"}
open={open}
size="wide"
submitDisabled={selected.size === 0 || page.loadingAction === "import-sync-games"}
submitLabel={`添加选中 (${selected.size})`}
title={`厂商游戏列表 ${page.syncPlatform?.platformName || page.syncPlatform?.platformCode || ""}`}
onClose={onClose}
onSubmit={page.importSelectedSyncGames}
>
<AdminFormSection
title="选择要添加的游戏"
actions={
<Button disabled={games.length === 0} onClick={page.toggleAllSyncGames}>
{page.allSyncGamesSelected ? "取消全选" : "全选"}
</Button>
}
>
<div className={styles.syncGameList}>
{games.length > 0 ? (
games.map((game) => (
<div className={styles.syncGameRow} key={game.providerGameId || game.gameId}>
<Checkbox
checked={selected.has(game.providerGameId)}
inputProps={{ "aria-label": `${game.gameName || game.gameId} 选择状态` }}
onChange={() => page.toggleSyncGame(game.providerGameId)}
/>
<GameIdentity game={game} />
<div className={styles.syncGameMeta}>
<span className={styles.meta}>{game.providerGameId}</span>
<span className={styles.meta}>默认下架</span>
</div>
<AdminActionIconButton
disabled={page.loadingAction === `import-sync-game-${game.providerGameId}`}
label="添加单个游戏"
onClick={() => page.importSingleSyncGame(game)}
>
<Add fontSize="small" />
</AdminActionIconButton>
</div>
))
) : (
<span className={styles.meta}>暂无可添加游戏</span>
)}
</div>
</AdminFormSection>
</AdminFormDialog>
);
}
function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) {
return (
<span className={styles.switchOnlyField}>

View File

@ -0,0 +1,172 @@
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export interface RedPacketConfigDto {
appCode?: string;
enabled: boolean;
countTiers: number[];
amountTiers: number[];
delayedOpenSeconds: number;
expireSeconds: number;
dailySendLimit: number;
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface RedPacketConfigPayload {
enabled: boolean;
count_tiers: number[];
amount_tiers: number[];
delayed_open_seconds: number;
expire_seconds: number;
daily_send_limit: number;
}
export interface RedPacketClaimDto {
claimId: string;
commandId?: string;
packetId: string;
userId: number;
amount: number;
walletTransactionId?: string;
status?: string;
failureReason?: string;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface RedPacketDto {
appCode?: string;
packetId: string;
commandId?: string;
senderUserId: number;
roomId: string;
regionId: number;
packetType: string;
totalAmount: number;
packetCount: number;
remainingAmount: number;
remainingCount: number;
status: string;
openAtMs: number;
expiresAtMs: number;
createdAtMs: number;
updatedAtMs?: number;
claims?: RedPacketClaimDto[];
}
export interface RedPacketQuery extends PageQuery {
room_id?: string;
sender_user_id?: string;
region_id?: string;
packet_type?: string;
status?: string;
}
type RawConfig = RedPacketConfigDto & Record<string, unknown>;
type RawPacket = RedPacketDto & Record<string, unknown>;
type RawClaim = RedPacketClaimDto & Record<string, unknown>;
export function getRedPacketConfig(): Promise<RedPacketConfigDto> {
const endpoint = API_ENDPOINTS.getRedPacketConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getRedPacketConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateRedPacketConfig(payload: RedPacketConfigPayload): Promise<RedPacketConfigDto> {
const endpoint = API_ENDPOINTS.updateRedPacketConfig;
return apiRequest<RawConfig, RedPacketConfigPayload>(apiEndpointPath(API_OPERATIONS.updateRedPacketConfig), {
body: payload,
method: endpoint.method,
}).then(normalizeConfig);
}
export function listRedPackets(query: RedPacketQuery = {}): Promise<ApiPage<RedPacketDto>> {
const endpoint = API_ENDPOINTS.listRedPackets;
return apiRequest<ApiPage<RawPacket>>(apiEndpointPath(API_OPERATIONS.listRedPackets), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizePacket),
}));
}
export function getRedPacket(packetId: string): Promise<RedPacketDto> {
const endpoint = API_ENDPOINTS.getRedPacket;
return apiRequest<RawPacket>(apiEndpointPath(API_OPERATIONS.getRedPacket, { packet_id: packetId }), {
method: endpoint.method,
}).then(normalizePacket);
}
function normalizeConfig(item: RawConfig): RedPacketConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
enabled: Boolean(item.enabled),
countTiers: numberArray(item.countTiers ?? item.count_tiers),
amountTiers: numberArray(item.amountTiers ?? item.amount_tiers),
delayedOpenSeconds: numberValue(item.delayedOpenSeconds ?? item.delayed_open_seconds),
expireSeconds: numberValue(item.expireSeconds ?? item.expire_seconds),
dailySendLimit: numberValue(item.dailySendLimit ?? item.daily_send_limit),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizePacket(item: RawPacket): RedPacketDto {
const rawClaims = Array.isArray(item.claims) ? (item.claims as RawClaim[]) : [];
return {
appCode: stringValue(item.appCode ?? item.app_code),
packetId: stringValue(item.packetId ?? item.packet_id),
commandId: stringValue(item.commandId ?? item.command_id),
senderUserId: numberValue(item.senderUserId ?? item.sender_user_id),
roomId: stringValue(item.roomId ?? item.room_id),
regionId: numberValue(item.regionId ?? item.region_id),
packetType: stringValue(item.packetType ?? item.packet_type),
totalAmount: numberValue(item.totalAmount ?? item.total_amount),
packetCount: numberValue(item.packetCount ?? item.packet_count),
remainingAmount: numberValue(item.remainingAmount ?? item.remaining_amount),
remainingCount: numberValue(item.remainingCount ?? item.remaining_count),
status: stringValue(item.status),
openAtMs: numberValue(item.openAtMs ?? item.open_at_ms),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
claims: rawClaims.map(normalizeClaim),
};
}
function normalizeClaim(item: RawClaim): RedPacketClaimDto {
return {
claimId: stringValue(item.claimId ?? item.claim_id),
commandId: stringValue(item.commandId ?? item.command_id),
packetId: stringValue(item.packetId ?? item.packet_id),
userId: numberValue(item.userId ?? item.user_id),
amount: numberValue(item.amount),
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
status: stringValue(item.status),
failureReason: stringValue(item.failureReason ?? item.failure_reason),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function numberArray(value: unknown): number[] {
if (!Array.isArray(value)) {
return [];
}
return value.map(numberValue).filter((item) => item > 0);
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,162 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import styles from "@/features/red-packets/red-packets.module.css";
export function RedPacketConfigDrawer({
abilities,
configLoading,
configSaving,
form,
onClose,
onSubmit,
open,
setForm,
}) {
const disabled = !abilities.canUpdate || configLoading || configSaving;
const updateValue = (key, value) => {
setForm((current) => ({ ...current, [key]: value }));
};
const updateTier = (key, index, value) => {
setForm((current) => ({
...current,
[key]: (current[key] || []).map((item, itemIndex) => (itemIndex === index ? value : item)),
}));
};
const addTier = (key) => {
setForm((current) => ({ ...current, [key]: [...(current[key] || []), ""] }));
};
const removeTier = (key, index) => {
setForm((current) => ({
...current,
[key]: (current[key] || []).filter((_, itemIndex) => itemIndex !== index),
}));
};
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>红包配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="红包状态"
uncheckedLabel="关闭"
onChange={(event) => updateValue("enabled", event.target.checked)}
/>
</section>
<section className="form-drawer__section">
<div className="form-drawer__grid">
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="延迟红包秒数"
type="number"
value={form.delayedOpenSeconds}
onChange={(event) => updateValue("delayedOpenSeconds", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="过期退款秒数"
type="number"
value={form.expireSeconds}
onChange={(event) => updateValue("expireSeconds", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="每用户每日最多发送"
type="number"
value={form.dailySendLimit}
onChange={(event) => updateValue("dailySendLimit", event.target.value)}
/>
</div>
</section>
<TierSection
disabled={disabled}
label="红包数量档位"
tierKey="countTiers"
values={form.countTiers}
onAdd={addTier}
onRemove={removeTier}
onUpdate={updateTier}
/>
<TierSection
disabled={disabled}
label="红包金额档位"
tierKey="amountTiers"
values={form.amountTiers}
onAdd={addTier}
onRemove={removeTier}
onUpdate={updateTier}
/>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</form>
</Drawer>
);
}
function TierSection({ disabled, label, onAdd, onRemove, onUpdate, tierKey, values }) {
return (
<section className="form-drawer__section">
<div className="form-drawer__section-title">
<span>{label}</span>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={() => onAdd(tierKey)}
>
添加
</Button>
</div>
<div className={styles.tierEditor}>
{(values || []).map((value, index) => (
<div className={styles.tierGrid} key={`${tierKey}-${index}`}>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label={`${label} ${index + 1}`}
type="number"
value={value}
onChange={(event) => onUpdate(tierKey, index, event.target.value)}
/>
<IconButton
aria-label="删除档位"
disabled={disabled || values.length <= 1}
size="small"
onClick={() => onRemove(tierKey, index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
))}
</div>
</section>
);
}

View File

@ -0,0 +1,61 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import styles from "@/features/red-packets/red-packets.module.css";
export function RedPacketConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
return (
<div className={styles.summaryPanel}>
<div className={styles.summaryItems}>
<SummaryItem label="状态">
<span
className={[
styles.statusBadge,
config?.enabled ? styles.statusActive : styles.statusInactive,
].join(" ")}
>
{config?.enabled ? "启用" : "停用"}
</span>
</SummaryItem>
<SummaryItem label="数量档位">{formatTiers(config?.countTiers)}</SummaryItem>
<SummaryItem label="金额档位">{formatTiers(config?.amountTiers)}</SummaryItem>
<SummaryItem label="延迟/过期">
{Number(config?.delayedOpenSeconds || 0)}s / {Number(config?.expireSeconds || 0)}s
</SummaryItem>
<SummaryItem label="每日上限">{Number(config?.dailySendLimit || 0)} </SummaryItem>
</div>
<div className={styles.summaryActions}>
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
刷新
</Button>
{canUpdate ? (
<Button
disabled={configLoading}
startIcon={<EditOutlined fontSize="small" />}
variant="primary"
onClick={onEdit}
>
编辑配置
</Button>
) : null}
</div>
</div>
);
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function formatTiers(values) {
if (!values?.length) {
return "-";
}
const visible = values.slice(0, 4).join("、");
return values.length > 4 ? `${visible} +${values.length - 4}` : visible;
}

View File

@ -0,0 +1,103 @@
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/red-packets/red-packets.module.css";
const claimColumns = [
{
key: "userId",
label: "用户",
width: "minmax(120px, 0.7fr)",
render: (claim) => claim.userId || "-",
},
{
key: "amount",
label: "金额",
width: "minmax(120px, 0.7fr)",
render: (claim) => `${formatNumber(claim.amount)} 金币`,
},
{
key: "status",
label: "状态",
width: "minmax(100px, 0.55fr)",
render: (claim) => claimStatusLabel(claim.status),
},
{
key: "createdAt",
label: "领取时间",
width: "minmax(180px, 0.9fr)",
render: (claim) => formatMillis(claim.createdAtMs),
},
];
export function RedPacketDetailDrawer({ loading, onClose, open, packet }) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<div className="form-drawer">
<div className="form-drawer__section-title">
<h2>红包详情</h2>
<IconButton aria-label="关闭" size="small" onClick={onClose}>
<CloseOutlined fontSize="small" />
</IconButton>
</div>
<DataState loading={loading} reportPageLoading={false}>
{packet ? (
<div className={styles.detailPanel}>
<InfoRow label="红包 ID" value={packet.packetId} />
<InfoRow label="房间" value={packet.roomId || "-"} />
<InfoRow label="发送人" value={packet.senderUserId || "-"} />
<InfoRow label="类型" value={packetTypeLabel(packet.packetType)} />
<InfoRow label="状态" value={packetStatusLabel(packet.status)} />
<InfoRow label="金额" value={`${formatNumber(packet.totalAmount)} 金币`} />
<InfoRow label="剩余" value={`${formatNumber(packet.remainingAmount)} / ${packet.remainingCount}`} />
<InfoRow label="开启时间" value={formatMillis(packet.openAtMs)} />
<InfoRow label="过期时间" value={formatMillis(packet.expiresAtMs)} />
<DataTable
columns={claimColumns}
emptyLabel="暂无领取记录"
fixedEdges={false}
items={packet.claims || []}
minWidth="620px"
rowKey={(claim) => claim.claimId}
/>
</div>
) : null}
</DataState>
</div>
</Drawer>
);
}
function InfoRow({ label, value }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{value}</span>
</div>
);
}
function packetTypeLabel(type) {
return type === "delayed" ? "延迟红包" : "普通红包";
}
function packetStatusLabel(status) {
const labels = {
active: "进行中",
finished: "已抢完",
refunded: "已退款",
waiting_open: "待开启",
};
return labels[status] || status || "-";
}
function claimStatusLabel(status) {
return status === "claimed" ? "已领取" : status || "-";
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,239 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getRedPacket,
getRedPacketConfig,
listRedPackets,
updateRedPacketConfig,
} from "@/features/red-packets/api";
import { useRedPacketAbilities } from "@/features/red-packets/permissions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 10;
const emptyPackets = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = {
enabled: false,
countTiers: [""],
amountTiers: [""],
delayedOpenSeconds: "0",
expireSeconds: "300",
dailySendLimit: "5",
};
export function useRedPacketPage() {
const abilities = useRedPacketAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(null);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [form, setForm] = useState(emptyForm);
const [roomId, setRoomId] = useState("");
const [senderUserId, setSenderUserId] = useState("");
const [packetType, setPacketType] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedPacket, setSelectedPacket] = useState(null);
const filters = useMemo(
() => ({
room_id: roomId.trim() || undefined,
sender_user_id: senderUserId.trim() || undefined,
packet_type: packetType || undefined,
status: status || undefined,
}),
[packetType, roomId, senderUserId, status],
);
const {
data: packets = emptyPackets,
error: packetsError,
loading: packetsLoading,
reload: reloadPackets,
} = usePaginatedQuery({
errorMessage: "加载红包列表失败",
fetcher: listRedPackets,
filters,
page,
pageSize,
queryKey: ["red-packets", filters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const config = await getRedPacketConfig();
setConfig(config);
setForm(formFromConfig(config));
} catch (err) {
showToast(err.message || "加载红包配置失败", "error");
} finally {
setConfigLoading(false);
}
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
const openConfigDrawer = useCallback(() => {
setForm(config ? formFromConfig(config) : emptyForm);
setConfigDrawerOpen(true);
}, [config]);
const closeConfigDrawer = useCallback(() => {
setConfigDrawerOpen(false);
setForm(config ? formFromConfig(config) : emptyForm);
}, [config]);
const submitConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
setConfigSaving(true);
try {
const saved = await updateRedPacketConfig(payloadFromForm(form));
setConfig(saved);
setForm(formFromConfig(saved));
setConfigDrawerOpen(false);
showToast("红包配置已保存", "success");
} catch (err) {
showToast(err.message || "保存红包配置失败", "error");
} finally {
setConfigSaving(false);
}
};
const openPacketDetail = async (packetId) => {
if (!packetId) {
return;
}
setDetailOpen(true);
setDetailLoading(true);
try {
const packet = await getRedPacket(packetId);
setSelectedPacket(packet);
} catch (err) {
showToast(err.message || "加载红包详情失败", "error");
} finally {
setDetailLoading(false);
}
};
const closePacketDetail = () => {
setDetailOpen(false);
setSelectedPacket(null);
};
const changeFilter = (setter) => (value) => {
setter(value);
setPage(1);
};
const resetFilters = () => {
setRoomId("");
setSenderUserId("");
setPacketType("");
setStatus("");
setPage(1);
};
return {
abilities,
closeConfigDrawer,
closePacketDetail,
config,
configDrawerOpen,
configLoading,
configSaving,
detailLoading,
detailOpen,
form,
openConfigDrawer,
openPacketDetail,
packetType,
packets,
packetsError,
packetsLoading,
page,
reloadConfig,
reloadPackets,
resetFilters,
roomId,
selectedPacket,
senderUserId,
setForm,
setPacketType: changeFilter(setPacketType),
setPage,
setRoomId: changeFilter(setRoomId),
setSenderUserId: changeFilter(setSenderUserId),
setStatus: changeFilter(setStatus),
status,
submitConfig,
};
}
function formFromConfig(config) {
return {
enabled: Boolean(config.enabled),
countTiers: valuesToForm(config.countTiers),
amountTiers: valuesToForm(config.amountTiers),
delayedOpenSeconds: String(config.delayedOpenSeconds || 0),
expireSeconds: String(config.expireSeconds || 300),
dailySendLimit: String(config.dailySendLimit || 0),
};
}
function valuesToForm(values) {
return values?.length ? values.map((value) => String(value)) : [""];
}
function payloadFromForm(form) {
const countTiers = normalizePositiveIntegers(form.countTiers);
const amountTiers = normalizePositiveIntegers(form.amountTiers);
const delayedOpenSeconds = Number(form.delayedOpenSeconds || 0);
const expireSeconds = Number(form.expireSeconds || 0);
const dailySendLimit = Number(form.dailySendLimit || 0);
if (!countTiers.length) {
throw new Error("红包数量档位不能为空");
}
if (!amountTiers.length) {
throw new Error("红包金额档位不能为空");
}
if (!Number.isFinite(delayedOpenSeconds) || delayedOpenSeconds < 0) {
throw new Error("延迟秒数不能小于 0");
}
if (!Number.isFinite(expireSeconds) || expireSeconds <= delayedOpenSeconds) {
throw new Error("过期秒数必须大于延迟秒数");
}
if (!Number.isFinite(dailySendLimit) || dailySendLimit <= 0) {
throw new Error("每日发送次数必须大于 0");
}
return {
enabled: Boolean(form.enabled),
count_tiers: countTiers,
amount_tiers: amountTiers,
delayed_open_seconds: delayedOpenSeconds,
expire_seconds: expireSeconds,
daily_send_limit: dailySendLimit,
};
}
function normalizePositiveIntegers(values) {
const seen = new Set();
return (values || [])
.map((value) => Number(value || 0))
.filter((value) => Number.isInteger(value) && value > 0)
.filter((value) => {
if (seen.has(value)) {
return false;
}
seen.add(value);
return true;
})
.sort((a, b) => a - b);
}

View File

@ -0,0 +1,222 @@
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { RedPacketConfigDrawer } from "@/features/red-packets/components/RedPacketConfigDrawer.jsx";
import { RedPacketConfigSummary } from "@/features/red-packets/components/RedPacketConfigSummary.jsx";
import { RedPacketDetailDrawer } from "@/features/red-packets/components/RedPacketDetailDrawer.jsx";
import { useRedPacketPage } from "@/features/red-packets/hooks/useRedPacketPage.js";
import styles from "@/features/red-packets/red-packets.module.css";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const packetTypeOptions = [
["", "全部类型"],
["normal", "普通红包"],
["delayed", "延迟红包"],
];
const statusOptions = [
["", "全部状态"],
["waiting_open", "待开启"],
["active", "进行中"],
["finished", "已抢完"],
["refunded", "已退款"],
];
export function RedPacketPage() {
const page = useRedPacketPage();
const total = page.packets.total || 0;
const columns = [
{
key: "packet",
label: "红包",
width: "minmax(280px, 1.2fr)",
render: (packet) => (
<div className={styles.stack}>
<span className={styles.name}>{packet.packetId}</span>
<span className={styles.meta}>{packet.commandId || "-"}</span>
</div>
),
},
{
key: "room",
label: "房间/区域",
width: "minmax(180px, 0.75fr)",
render: (packet) => (
<div className={styles.stack}>
<span>{packet.roomId || "-"}</span>
<span className={styles.meta}>区域 {packet.regionId || "-"}</span>
</div>
),
},
{
key: "sender",
label: "发送人",
width: "minmax(130px, 0.55fr)",
render: (packet) => packet.senderUserId || "-",
},
{
key: "type",
label: "类型",
width: "minmax(110px, 0.5fr)",
render: (packet) => packetTypeLabel(packet.packetType),
},
{
key: "amount",
label: "金额/个数",
width: "minmax(150px, 0.65fr)",
render: (packet) => (
<div className={styles.stack}>
<span>{formatNumber(packet.totalAmount)} 金币</span>
<span className={styles.meta}>
剩余 {formatNumber(packet.remainingAmount)} / {packet.remainingCount}
</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(110px, 0.5fr)",
render: (packet) => packetStatusLabel(packet.status),
},
{
key: "openAt",
label: "开启时间",
width: "minmax(180px, 0.75fr)",
render: (packet) => formatMillis(packet.openAtMs),
},
{
key: "expiresAt",
label: "过期时间",
width: "minmax(180px, 0.75fr)",
render: (packet) => formatMillis(packet.expiresAtMs),
},
{
key: "actions",
label: "操作",
width: "88px",
render: (packet) => (
<AdminRowActions>
<AdminActionIconButton label="查看详情" onClick={() => page.openPacketDetail(packet.packetId)}>
<VisibilityOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
},
];
return (
<AdminListPage>
<RedPacketConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<AdminListToolbar
actions={<AdminFilterResetButton label="重置筛选" onClick={page.resetFilters} />}
filters={
<>
<TextField
label="房间 ID"
size="small"
value={page.roomId}
onChange={(event) => page.setRoomId(event.target.value)}
/>
<TextField
label="发送人 ID"
size="small"
value={page.senderUserId}
onChange={(event) => page.setSenderUserId(event.target.value)}
/>
<TextField
select
label="类型"
size="small"
value={page.packetType}
onChange={(event) => page.setPacketType(event.target.value)}
>
{packetTypeOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
select
label="状态"
size="small"
value={page.status}
onChange={(event) => page.setStatus(event.target.value)}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
</>
}
/>
<DataState error={page.packetsError} loading={page.packetsLoading} onRetry={page.reloadPackets}>
<AdminListBody>
<DataTable
columns={columns}
items={page.packets.items || []}
minWidth="1500px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.packets.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(packet) => packet.packetId}
/>
</AdminListBody>
</DataState>
<RedPacketConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
<RedPacketDetailDrawer
loading={page.detailLoading}
open={page.detailOpen}
packet={page.selectedPacket}
onClose={page.closePacketDetail}
/>
</AdminListPage>
);
}
function packetTypeLabel(type) {
return type === "delayed" ? "延迟红包" : "普通红包";
}
function packetStatusLabel(status) {
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useRedPacketAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.redPacketUpdate),
canView: can(PERMISSIONS.redPacketView),
};
}

View File

@ -0,0 +1,125 @@
.summaryPanel {
display: flex;
flex: 0 0 auto;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.summaryItems,
.summaryActions {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.summaryItems {
flex: 1;
gap: var(--space-7);
}
.summaryItem {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.summaryLabel {
flex: 0 0 auto;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.summaryValue {
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 26px;
padding: 0 var(--space-2);
border-radius: 999px;
font-size: 12px;
font-weight: 750;
}
.statusActive {
background: var(--success-surface);
color: var(--success);
}
.statusInactive {
background: var(--fill-secondary);
color: var(--text-tertiary);
}
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
.tierEditor {
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--fill-secondary);
}
.tierGrid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
}
.detailPanel {
display: grid;
gap: var(--space-3);
min-width: 420px;
}
@media (max-width: 900px) {
.summaryPanel {
align-items: flex-start;
flex-direction: column;
}
.summaryItems {
width: 100%;
align-items: flex-start;
flex-direction: column;
gap: var(--space-3);
}
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const redPacketRoutes = [
{
label: "红包配置",
loader: () => import("./pages/RedPacketPage.jsx").then((module) => module.RedPacketPage),
menuCode: MENU_CODES.redPacket,
pageKey: "red-packet",
path: "/activities/red-packets",
permission: PERMISSIONS.redPacketView,
},
];

View File

@ -0,0 +1,160 @@
import { apiRequest } from "@/shared/api/request";
export interface VipLevelDto {
level: number;
name: string;
status: string;
priceCoin: number;
durationMs: number;
rewardResourceGroupId: number;
sortOrder: number;
rechargeGateRequired: boolean;
requiredRechargeCoinAmount: number;
userRechargeCoinAmount?: number;
purchaseLockedReason?: string;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface VipConfigDto {
levels: VipLevelDto[];
serverTimeMs?: number;
}
export interface VipConfigPayload {
levels: Array<{
level: number;
name: string;
status: string;
priceCoin: number;
durationMs: number;
rewardResourceGroupId: number;
sortOrder: number;
requiredRechargeCoinAmount: number;
}>;
}
export interface UserVipDto {
userId: string;
level: number;
name: string;
active: boolean;
startedAtMs: number;
expiresAtMs: number;
updatedAtMs: number;
}
export interface VipRewardItemDto {
resourceId: string;
resourceCode: string;
resourceType: string;
name: string;
quantity: number;
expiresAtMs: number;
}
export interface VipGrantDto {
transactionId: string;
vip: UserVipDto;
rewardItems: VipRewardItemDto[];
serverTimeMs?: number;
}
export interface VipGrantPayload {
commandId?: string;
targetUserId: string;
level: number;
reason: string;
}
const path = "/v1/admin/activity/vip-levels";
const grantPath = "/v1/admin/activity/vip-grants";
export function getVipConfig(): Promise<VipConfigDto> {
return apiRequest<RawConfig>(path).then(normalizeConfig);
}
export function updateVipConfig(payload: VipConfigPayload): Promise<VipConfigDto> {
return apiRequest<RawConfig, VipConfigPayload>(path, { body: payload, method: "PUT" }).then(normalizeConfig);
}
export function grantVip(payload: VipGrantPayload): Promise<VipGrantDto> {
return apiRequest<RawVipGrant, VipGrantPayload>(grantPath, { body: payload, method: "POST" }).then(
normalizeVipGrant,
);
}
type RawConfig = VipConfigDto & Record<string, unknown>;
type RawLevel = VipLevelDto & Record<string, unknown>;
type RawVipGrant = VipGrantDto & Record<string, unknown>;
type RawUserVip = UserVipDto & Record<string, unknown>;
type RawReward = VipRewardItemDto & Record<string, unknown>;
function normalizeConfig(item: RawConfig): VipConfigDto {
const rawLevels = Array.isArray(item.levels) ? (item.levels as RawLevel[]) : [];
return {
levels: rawLevels.map(normalizeLevel),
serverTimeMs: numberValue(item.serverTimeMs ?? item.server_time_ms),
};
}
function normalizeLevel(item: RawLevel): VipLevelDto {
const level = numberValue(item.level);
return {
level,
name: stringValue(item.name) || `VIP${level}`,
status: stringValue(item.status) || "disabled",
priceCoin: numberValue(item.priceCoin ?? item.price_coin),
durationMs: numberValue(item.durationMs ?? item.duration_ms),
rewardResourceGroupId: numberValue(item.rewardResourceGroupId ?? item.reward_resource_group_id),
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
rechargeGateRequired: Boolean(item.rechargeGateRequired ?? item.recharge_gate_required ?? level >= 6),
requiredRechargeCoinAmount: numberValue(item.requiredRechargeCoinAmount ?? item.required_recharge_coin_amount),
userRechargeCoinAmount: numberValue(item.userRechargeCoinAmount ?? item.user_recharge_coin_amount),
purchaseLockedReason: stringValue(item.purchaseLockedReason ?? item.purchase_locked_reason),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeVipGrant(item: RawVipGrant): VipGrantDto {
const rewardItems = Array.isArray(item.rewardItems) ? (item.rewardItems as RawReward[]) : [];
return {
transactionId: stringValue(item.transactionId ?? item.transaction_id),
vip: normalizeUserVip((item.vip || {}) as RawUserVip),
rewardItems: rewardItems.map(normalizeRewardItem),
serverTimeMs: numberValue(item.serverTimeMs ?? item.server_time_ms),
};
}
function normalizeUserVip(item: RawUserVip): UserVipDto {
return {
userId: stringValue(item.userId ?? item.user_id),
level: numberValue(item.level),
name: stringValue(item.name),
active: Boolean(item.active),
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeRewardItem(item: RawReward): VipRewardItemDto {
return {
resourceId: stringValue(item.resourceId ?? item.resource_id),
resourceCode: stringValue(item.resourceCode ?? item.resource_code),
resourceType: stringValue(item.resourceType ?? item.resource_type),
name: stringValue(item.name),
quantity: numberValue(item.quantity),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,260 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getVipConfig, grantVip, updateVipConfig } from "@/features/vip-config/api";
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
import { listResourceGroups } from "@/features/resources/api";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const defaultDurationMs = 30 * 24 * 60 * 60 * 1000;
export function useVipConfigPage() {
const abilities = useVipConfigAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState({ levels: defaultLevels() });
const [form, setForm] = useState({ levels: defaultLevels().map(levelToForm) });
const [resourceGroups, setResourceGroups] = useState([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [grantDrawerOpen, setGrantDrawerOpen] = useState(false);
const [grantForm, setGrantForm] = useState(defaultGrantForm());
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [grantSaving, setGrantSaving] = useState(false);
const activeCount = useMemo(
() => (config.levels || []).filter((level) => level.status === "active").length,
[config.levels],
);
const reload = useCallback(async () => {
setLoading(true);
try {
const [remoteConfig, groups] = await Promise.all([
getVipConfig(),
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
]);
const levels = completeLevels(remoteConfig.levels || []);
setConfig({ ...remoteConfig, levels });
setForm({ levels: levels.map(levelToForm) });
setResourceGroups(groups.items || []);
} catch (err) {
showToast(err.message || "加载 VIP 配置失败", "error");
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
void reload();
}, [reload]);
const openDrawer = () => {
setForm({ levels: completeLevels(config.levels || []).map(levelToForm) });
setDrawerOpen(true);
};
const closeDrawer = () => {
if (saving) {
return;
}
setForm({ levels: completeLevels(config.levels || []).map(levelToForm) });
setDrawerOpen(false);
};
const openGrantDrawer = () => {
setGrantForm((current) => ({
...defaultGrantForm(),
level: firstActiveLevel(config.levels || []) || current.level || "1",
}));
setGrantDrawerOpen(true);
};
const closeGrantDrawer = () => {
if (grantSaving) {
return;
}
setGrantDrawerOpen(false);
setGrantForm(defaultGrantForm());
};
const updateLevel = (levelNumber, patch) => {
setForm((current) => ({
...current,
levels: (current.levels || []).map((item) => (item.level === levelNumber ? { ...item, ...patch } : item)),
}));
};
const updateGrantForm = (patch) => {
setGrantForm((current) => ({ ...current, ...patch }));
};
const submit = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
let payload;
try {
payload = payloadFromForm(form);
} catch (err) {
showToast(err.message || "VIP 配置参数不正确", "error");
return;
}
setSaving(true);
try {
const saved = await updateVipConfig(payload);
const levels = completeLevels(saved.levels || []);
setConfig({ ...saved, levels });
setForm({ levels: levels.map(levelToForm) });
setDrawerOpen(false);
showToast("VIP 配置已保存", "success");
} catch (err) {
showToast(err.message || "保存 VIP 配置失败", "error");
} finally {
setSaving(false);
}
};
const submitGrant = async (event) => {
event.preventDefault();
if (!abilities.canGrant) {
return;
}
const targetUserId = String(grantForm.targetUserId || "").trim();
const reason = String(grantForm.reason || "").trim();
const level = Number(grantForm.level || 0);
if (!targetUserId || !Number.isInteger(level) || level <= 0 || !reason) {
showToast("VIP 赠送参数不正确", "error");
return;
}
setGrantSaving(true);
try {
const result = await grantVip({ targetUserId, level, reason });
setGrantDrawerOpen(false);
setGrantForm(defaultGrantForm());
showToast(`已赠送 ${result.vip?.name || `VIP${level}`}`, "success");
await reload();
} catch (err) {
showToast(err.message || "赠送 VIP 失败", "error");
} finally {
setGrantSaving(false);
}
};
return {
abilities,
activeCount,
closeDrawer,
closeGrantDrawer,
config,
drawerOpen,
form,
grantDrawerOpen,
grantForm,
grantSaving,
loading,
openDrawer,
openGrantDrawer,
reload,
resourceGroups,
saving,
submit,
submitGrant,
updateLevel,
updateGrantForm,
};
}
function defaultLevels() {
return Array.from({ length: 10 }, (_, index) => {
const level = index + 1;
return {
level,
name: `VIP${level}`,
status: "disabled",
priceCoin: 0,
durationMs: defaultDurationMs,
rewardResourceGroupId: 0,
requiredRechargeCoinAmount: 0,
rechargeGateRequired: level >= 6,
sortOrder: level * 10,
};
});
}
function completeLevels(levels) {
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
return defaultLevels().map((fallback) => ({ ...fallback, ...(byLevel.get(fallback.level) || {}) }));
}
function defaultGrantForm() {
return {
targetUserId: "",
level: "1",
reason: "",
};
}
function firstActiveLevel(levels) {
const active = (levels || []).find((level) => level.status === "active");
return active ? String(active.level) : "";
}
function levelToForm(level) {
return {
level: Number(level.level || 0),
name: level.name || `VIP${level.level}`,
status: level.status || "disabled",
priceCoin: level.priceCoin ? String(level.priceCoin) : "",
durationDays: level.durationMs ? String(Math.max(1, Math.round(Number(level.durationMs) / 86400000))) : "30",
rewardResourceGroupId: level.rewardResourceGroupId ? String(level.rewardResourceGroupId) : "",
requiredRechargeCoinAmount: level.requiredRechargeCoinAmount ? String(level.requiredRechargeCoinAmount) : "",
sortOrder: String(level.sortOrder || Number(level.level || 0) * 10),
};
}
function payloadFromForm(form) {
const levels = completeFormLevels(form.levels || []).map((level) => {
const priceCoin = Number(level.priceCoin || 0);
const durationDays = Number(level.durationDays || 0);
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
const requiredRechargeCoinAmount = level.level >= 6 ? Number(level.requiredRechargeCoinAmount || 0) : 0;
const sortOrder = Number(level.sortOrder || level.level * 10);
const status = level.status === "active" ? "active" : "disabled";
if (!String(level.name || "").trim()) {
throw new Error(`VIP${level.level} 名称不能为空`);
}
if (!Number.isInteger(priceCoin) || priceCoin < 0) {
throw new Error(`VIP${level.level} 购买金币数不正确`);
}
if (!Number.isInteger(durationDays) || durationDays <= 0) {
throw new Error(`VIP${level.level} 有效天数必须大于 0`);
}
if (status === "active" && priceCoin <= 0) {
throw new Error(`VIP${level.level} 启用时购买金币数必须大于 0`);
}
if (status === "active" && rewardResourceGroupId <= 0) {
throw new Error(`VIP${level.level} 启用时必须选择奖励资源组`);
}
if (status === "active" && level.level >= 6 && requiredRechargeCoinAmount <= 0) {
throw new Error(`VIP${level.level} 启用时必须配置累计充值门槛`);
}
return {
level: Number(level.level),
name: String(level.name || "").trim(),
status,
priceCoin,
durationMs: durationDays * 86400000,
rewardResourceGroupId,
requiredRechargeCoinAmount,
sortOrder,
};
});
return { levels };
}
function completeFormLevels(levels) {
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
return defaultLevels().map((fallback) => ({
...levelToForm(fallback),
...(byLevel.get(fallback.level) || {}),
}));
}

View File

@ -0,0 +1,340 @@
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { useVipConfigPage } from "@/features/vip-config/hooks/useVipConfigPage.js";
import styles from "@/features/vip-config/vip-config.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js";
export function VipConfigPage() {
const page = useVipConfigPage();
const levels = page.config.levels || [];
const resourceLabels = useMemo(() => groupLabelMap(page.resourceGroups), [page.resourceGroups]);
const columns = useMemo(() => vipColumns(resourceLabels), [resourceLabels]);
return (
<AdminListPage>
<div className={styles.summaryPanel}>
<div className={styles.summaryItems}>
<SummaryItem label="总等级">{levels.length || 10}</SummaryItem>
<SummaryItem label="启用等级">{page.activeCount}</SummaryItem>
<SummaryItem label="高阶门槛">
{levels.filter((level) => level.level >= 6 && level.status === "active").length}
</SummaryItem>
</div>
<div className={styles.summaryActions}>
<Button
disabled={page.loading}
startIcon={<RefreshOutlined fontSize="small" />}
onClick={page.reload}
>
刷新
</Button>
{page.abilities.canUpdate ? (
<Button
disabled={page.loading}
startIcon={<EditOutlined fontSize="small" />}
variant="primary"
onClick={page.openDrawer}
>
编辑配置
</Button>
) : null}
{page.abilities.canGrant ? (
<Button
disabled={page.loading}
startIcon={<CardGiftcardOutlined fontSize="small" />}
onClick={page.openGrantDrawer}
>
赠送VIP
</Button>
) : null}
</div>
</div>
<DataState loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={levels} minWidth="1120px" rowKey={(item) => item.level} />
</AdminListBody>
</DataState>
<VipConfigDrawer page={page} />
<VipGrantDrawer page={page} />
</AdminListPage>
);
}
function VipGrantDrawer({ page }) {
const disabled = !page.abilities.canGrant || page.grantSaving || page.loading;
const activeLevels = (page.config.levels || []).filter((level) => level.status === "active");
return (
<Drawer
anchor="right"
open={page.grantDrawerOpen}
onClose={page.grantSaving ? undefined : page.closeGrantDrawer}
>
<form className="form-drawer" onSubmit={page.submitGrant}>
<h2>赠送VIP</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">赠送信息</div>
<TextField
disabled={disabled}
inputProps={{ inputMode: "numeric" }}
label="用户ID"
value={page.grantForm.targetUserId}
onChange={(event) => page.updateGrantForm({ targetUserId: event.target.value })}
/>
<TextField
select
disabled={disabled || activeLevels.length === 0}
label="VIP等级"
value={page.grantForm.level}
onChange={(event) => page.updateGrantForm({ level: event.target.value })}
>
{activeLevels.length === 0 ? (
<MenuItem value={page.grantForm.level || ""}>暂无启用等级</MenuItem>
) : (
activeLevels.map((level) => (
<MenuItem key={level.level} value={String(level.level)}>
VIP{level.level} · {level.name}
</MenuItem>
))
)}
</TextField>
<TextField
multiline
disabled={disabled}
inputProps={{ maxLength: 512 }}
label="原因"
minRows={3}
value={page.grantForm.reason}
onChange={(event) => page.updateGrantForm({ reason: event.target.value })}
/>
</section>
<div className="form-drawer__actions">
<Button disabled={page.grantSaving} type="button" onClick={page.closeGrantDrawer}>
取消
</Button>
<Button
disabled={disabled || activeLevels.length === 0}
startIcon={<CardGiftcardOutlined fontSize="small" />}
type="submit"
variant="primary"
>
赠送
</Button>
</div>
</form>
</Drawer>
);
}
function VipConfigDrawer({ page }) {
const disabled = !page.abilities.canUpdate || page.saving || page.loading;
return (
<Drawer anchor="right" open={page.drawerOpen} onClose={page.saving ? undefined : page.closeDrawer}>
<form className="form-drawer form-drawer--wide" onSubmit={page.submit}>
<h2>VIP配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">等级配置</div>
<div className={styles.levelEditorList}>
{(page.form.levels || []).map((level) => (
<div className={styles.levelEditor} key={level.level}>
<div className={styles.levelEditorHeader}>
<span>VIP{level.level}</span>
<AdminSwitch
checked={level.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="状态"
uncheckedLabel="停用"
onChange={(event) =>
page.updateLevel(level.level, {
status: event.target.checked ? "active" : "disabled",
})
}
/>
</div>
<div className="form-drawer__grid">
<TextField
disabled={disabled}
label="名称"
value={level.name}
onChange={(event) =>
page.updateLevel(level.level, { name: event.target.value })
}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label="购买金币"
type="number"
value={level.priceCoin}
onChange={(event) =>
page.updateLevel(level.level, { priceCoin: event.target.value })
}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1, step: 1 }}
label="有效天数"
type="number"
value={level.durationDays}
onChange={(event) =>
page.updateLevel(level.level, { durationDays: event.target.value })
}
/>
<ResourceGroupSelectField
disabled={disabled}
drawerTitle={`选择 VIP${level.level} 奖励资源组`}
groups={page.resourceGroups}
label="奖励资源组"
value={level.rewardResourceGroupId}
onChange={(value) =>
page.updateLevel(level.level, { rewardResourceGroupId: value })
}
/>
{level.level >= 6 ? (
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label="累计充值门槛"
type="number"
value={level.requiredRechargeCoinAmount}
onChange={(event) =>
page.updateLevel(level.level, {
requiredRechargeCoinAmount: event.target.value,
})
}
/>
) : (
<TextField disabled label="购买门槛" value="直接购买" />
)}
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label="排序"
type="number"
value={level.sortOrder}
onChange={(event) =>
page.updateLevel(level.level, { sortOrder: event.target.value })
}
/>
</div>
</div>
))}
</div>
</section>
<div className="form-drawer__actions">
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
取消
</Button>
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</form>
</Drawer>
);
}
function vipColumns(resourceLabels) {
return [
{
key: "level",
label: "等级",
render: (item) => <Stack primary={`VIP${item.level}`} secondary={item.name} />,
width: "minmax(120px, 0.5fr)",
},
{
key: "status",
label: "状态",
render: (item) => (
<span
className={[
styles.statusBadge,
item.status === "active" ? styles.statusActive : styles.statusDisabled,
].join(" ")}
>
{item.status === "active" ? "启用" : "停用"}
</span>
),
width: "minmax(100px, 0.45fr)",
},
{
key: "price",
label: "购买金币",
render: (item) => formatNumber(item.priceCoin),
width: "minmax(140px, 0.6fr)",
},
{
key: "gate",
label: "购买门槛",
render: (item) =>
item.level >= 6 ? `${formatNumber(item.requiredRechargeCoinAmount)} 累充金币` : "直接购买",
width: "minmax(180px, 0.8fr)",
},
{
key: "reward",
label: "奖励资源组",
render: (item) =>
resourceLabels[String(item.rewardResourceGroupId)] || `资源组 #${item.rewardResourceGroupId || "-"}`,
width: "minmax(220px, 1fr)",
},
{
key: "duration",
label: "有效期",
render: (item) => `${Math.round(Number(item.durationMs || 0) / 86400000)}`,
width: "minmax(110px, 0.45fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 0.75fr)",
},
];
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function Stack({ primary, secondary }) {
return (
<div className={styles.stack}>
<span>{primary || "-"}</span>
<span className={styles.meta}>{secondary || "-"}</span>
</div>
);
}
function groupLabelMap(groups) {
return (groups || []).reduce((labels, group) => {
labels[String(group.groupId)] = group.name || group.groupCode || `资源组 #${group.groupId}`;
return labels;
}, {});
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString("zh-CN") : "-";
}

View File

@ -0,0 +1,11 @@
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
export function useVipConfigAbilities() {
const { can } = useAuth();
return {
canView: can(PERMISSIONS.vipConfigView),
canUpdate: can(PERMISSIONS.vipConfigUpdate),
canGrant: can(PERMISSIONS.vipConfigGrant),
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const vipConfigRoutes = [
{
label: "VIP配置",
loader: () => import("./pages/VipConfigPage.jsx").then((module) => module.VipConfigPage),
menuCode: MENU_CODES.vipConfig,
pageKey: "vip-config",
path: "/activities/vip-config",
permission: PERMISSIONS.vipConfigView,
},
];

View File

@ -0,0 +1,103 @@
.summaryPanel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface);
}
.summaryItems {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.summaryItem {
min-width: 140px;
}
.summaryLabel,
.meta {
display: block;
color: var(--color-text-muted);
font-size: 12px;
}
.summaryValue {
display: block;
margin-top: 4px;
color: var(--color-text);
font-size: 18px;
font-weight: 700;
}
.summaryActions {
display: flex;
gap: 10px;
}
.statusBadge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.statusActive {
color: #12603a;
background: #dcfce7;
}
.statusDisabled {
color: #475569;
background: #e2e8f0;
}
.stack {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.levelEditorList {
display: grid;
gap: 12px;
}
.levelEditor {
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
padding: 14px;
background: var(--color-surface-subtle);
}
.levelEditorHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
font-weight: 700;
}
.directLabel {
color: var(--color-text-muted);
font-size: 12px;
}
@media (max-width: 760px) {
.summaryPanel {
align-items: stretch;
flex-direction: column;
}
.summaryActions {
justify-content: flex-end;
}
}

View File

@ -77,6 +77,8 @@ export const API_OPERATIONS = {
getCountry: "getCountry",
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
getLuckyGiftConfig: "getLuckyGiftConfig",
getRedPacket: "getRedPacket",
getRedPacketConfig: "getRedPacketConfig",
getRegion: "getRegion",
getRegistrationRewardConfig: "getRegistrationRewardConfig",
getResource: "getResource",
@ -116,6 +118,7 @@ export const API_OPERATIONS = {
listPlatforms: "listPlatforms",
listRechargeBills: "listRechargeBills",
listRechargeProducts: "listRechargeProducts",
listRedPackets: "listRedPackets",
listRegionBlocks: "listRegionBlocks",
listRegions: "listRegions",
listRegistrationRewardClaims: "listRegistrationRewardClaims",
@ -164,6 +167,7 @@ export const API_OPERATIONS = {
updatePermission: "updatePermission",
updatePlatform: "updatePlatform",
updateRechargeProduct: "updateRechargeProduct",
updateRedPacketConfig: "updateRedPacketConfig",
updateRegion: "updateRegion",
updateRegistrationRewardConfig: "updateRegistrationRewardConfig",
updateResource: "updateResource",
@ -634,6 +638,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
getRedPacket: {
method: "GET",
operationId: API_OPERATIONS.getRedPacket,
path: "/v1/admin/activity/red-packets/{packet_id}",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
getRedPacketConfig: {
method: "GET",
operationId: API_OPERATIONS.getRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
getRegion: {
method: "GET",
operationId: API_OPERATIONS.getRegion,
@ -905,6 +923,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "payment-product:view",
permissions: ["payment-product:view"]
},
listRedPackets: {
method: "GET",
operationId: API_OPERATIONS.listRedPackets,
path: "/v1/admin/activity/red-packets",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
listRegionBlocks: {
method: "GET",
operationId: API_OPERATIONS.listRegionBlocks,
@ -1229,6 +1254,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "payment-product:update",
permissions: ["payment-product:update"]
},
updateRedPacketConfig: {
method: "PUT",
operationId: API_OPERATIONS.updateRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
updateRegion: {
method: "PATCH",
operationId: API_OPERATIONS.updateRegion,

View File

@ -100,6 +100,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listRedPackets"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/{packet_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRedPacket"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRedPacketConfig"];
put: operations["updateRedPacketConfig"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/lucky-gifts/config": {
parameters: {
query?: never;
@ -2508,6 +2556,56 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listRedPackets: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getRedPacket: {
parameters: {
query?: never;
header?: never;
path: {
packet_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getRedPacketConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateRedPacketConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getLuckyGiftConfig: {
parameters: {
query?: never;