From d06718cc4963502d0140f59ee4c9361bc9ca1a39 Mon Sep 17 00:00:00 2001 From: zhx Date: Sat, 23 May 2026 11:21:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=B8=B8=E6=88=8F=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 64 ++++ src/app/navigation/menu.js | 3 + src/app/permissions.ts | 7 + src/app/router/routeConfig.ts | 4 + src/features/games/api.ts | 35 +- src/features/games/games.module.css | 26 ++ src/features/games/hooks/useGamesPage.js | 102 +++++- src/features/games/pages/GameListPage.jsx | 73 +++- src/features/red-packets/api.ts | 172 +++++++++ .../components/RedPacketConfigDrawer.jsx | 162 +++++++++ .../components/RedPacketConfigSummary.jsx | 61 ++++ .../components/RedPacketDetailDrawer.jsx | 103 ++++++ .../red-packets/hooks/useRedPacketPage.js | 239 ++++++++++++ .../red-packets/pages/RedPacketPage.jsx | 222 ++++++++++++ src/features/red-packets/permissions.js | 11 + .../red-packets/red-packets.module.css | 125 +++++++ src/features/red-packets/routes.js | 12 + src/features/vip-config/api.ts | 160 +++++++++ .../vip-config/hooks/useVipConfigPage.js | 260 ++++++++++++++ .../vip-config/pages/VipConfigPage.jsx | 340 ++++++++++++++++++ src/features/vip-config/permissions.js | 11 + src/features/vip-config/routes.js | 12 + src/features/vip-config/vip-config.module.css | 103 ++++++ src/shared/api/generated/endpoints.ts | 32 ++ src/shared/api/generated/schema.d.ts | 98 +++++ 25 files changed, 2433 insertions(+), 4 deletions(-) create mode 100644 src/features/red-packets/api.ts create mode 100644 src/features/red-packets/components/RedPacketConfigDrawer.jsx create mode 100644 src/features/red-packets/components/RedPacketConfigSummary.jsx create mode 100644 src/features/red-packets/components/RedPacketDetailDrawer.jsx create mode 100644 src/features/red-packets/hooks/useRedPacketPage.js create mode 100644 src/features/red-packets/pages/RedPacketPage.jsx create mode 100644 src/features/red-packets/permissions.js create mode 100644 src/features/red-packets/red-packets.module.css create mode 100644 src/features/red-packets/routes.js create mode 100644 src/features/vip-config/api.ts create mode 100644 src/features/vip-config/hooks/useVipConfigPage.js create mode 100644 src/features/vip-config/pages/VipConfigPage.jsx create mode 100644 src/features/vip-config/permissions.js create mode 100644 src/features/vip-config/routes.js create mode 100644 src/features/vip-config/vip-config.module.css diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index d8963c9..df8fdbe 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index b20ddfe..c0bc04f 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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 }), ], }, { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 0545f91..11ab304 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 4f3e812..754978b 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -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, diff --git a/src/features/games/api.ts b/src/features/games/api.ts index 3d74506..d34d833 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -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 { + return apiRequest( + `/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 { return { appCode: stringValue(platform.appCode), diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index 113c24c..97165e2 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -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; +} diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index 43c1846..812d018 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -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, }; } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 46d9d81..b868328 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -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} /> + ); } @@ -443,8 +448,8 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit {!isLeaderCC ? ( page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} /> @@ -520,6 +525,16 @@ function PlatformManageDialog({ open, page, onClose }) { > + page.fetchPlatformGames(platform)} + > + + ))} @@ -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 ( + + + {page.allSyncGamesSelected ? "取消全选" : "全选"} + + } + > +
+ {games.length > 0 ? ( + games.map((game) => ( +
+ page.toggleSyncGame(game.providerGameId)} + /> + +
+ {game.providerGameId} + 默认下架 +
+ page.importSingleSyncGame(game)} + > + + +
+ )) + ) : ( + 暂无可添加游戏 + )} +
+
+
+ ); +} + function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) { return ( diff --git a/src/features/red-packets/api.ts b/src/features/red-packets/api.ts new file mode 100644 index 0000000..1a5a3d6 --- /dev/null +++ b/src/features/red-packets/api.ts @@ -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; +type RawPacket = RedPacketDto & Record; +type RawClaim = RedPacketClaimDto & Record; + +export function getRedPacketConfig(): Promise { + const endpoint = API_ENDPOINTS.getRedPacketConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getRedPacketConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateRedPacketConfig(payload: RedPacketConfigPayload): Promise { + const endpoint = API_ENDPOINTS.updateRedPacketConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateRedPacketConfig), { + body: payload, + method: endpoint.method, + }).then(normalizeConfig); +} + +export function listRedPackets(query: RedPacketQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listRedPackets; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listRedPackets), { + method: endpoint.method, + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizePacket), + })); +} + +export function getRedPacket(packetId: string): Promise { + const endpoint = API_ENDPOINTS.getRedPacket; + return apiRequest(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; +} diff --git a/src/features/red-packets/components/RedPacketConfigDrawer.jsx b/src/features/red-packets/components/RedPacketConfigDrawer.jsx new file mode 100644 index 0000000..caa01d4 --- /dev/null +++ b/src/features/red-packets/components/RedPacketConfigDrawer.jsx @@ -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 ( + +
+

红包配置

+
+
发放状态
+ updateValue("enabled", event.target.checked)} + /> +
+
+
+ updateValue("delayedOpenSeconds", event.target.value)} + /> + updateValue("expireSeconds", event.target.value)} + /> + updateValue("dailySendLimit", event.target.value)} + /> +
+
+ + +
+ + +
+ +
+ ); +} + +function TierSection({ disabled, label, onAdd, onRemove, onUpdate, tierKey, values }) { + return ( +
+
+ {label} + +
+
+ {(values || []).map((value, index) => ( +
+ onUpdate(tierKey, index, event.target.value)} + /> + onRemove(tierKey, index)} + > + + +
+ ))} +
+
+ ); +} diff --git a/src/features/red-packets/components/RedPacketConfigSummary.jsx b/src/features/red-packets/components/RedPacketConfigSummary.jsx new file mode 100644 index 0000000..e23d1df --- /dev/null +++ b/src/features/red-packets/components/RedPacketConfigSummary.jsx @@ -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 ( +
+
+ + + {config?.enabled ? "启用" : "停用"} + + + {formatTiers(config?.countTiers)} + {formatTiers(config?.amountTiers)} + + {Number(config?.delayedOpenSeconds || 0)}s / {Number(config?.expireSeconds || 0)}s + + {Number(config?.dailySendLimit || 0)} 次 +
+
+ + {canUpdate ? ( + + ) : null} +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +function formatTiers(values) { + if (!values?.length) { + return "-"; + } + const visible = values.slice(0, 4).join("、"); + return values.length > 4 ? `${visible} +${values.length - 4}` : visible; +} diff --git a/src/features/red-packets/components/RedPacketDetailDrawer.jsx b/src/features/red-packets/components/RedPacketDetailDrawer.jsx new file mode 100644 index 0000000..5dd9c84 --- /dev/null +++ b/src/features/red-packets/components/RedPacketDetailDrawer.jsx @@ -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 ( + +
+
+

红包详情

+ + + +
+ + {packet ? ( +
+ + + + + + + + + + claim.claimId} + /> +
+ ) : null} +
+
+
+ ); +} + +function InfoRow({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +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)); +} diff --git a/src/features/red-packets/hooks/useRedPacketPage.js b/src/features/red-packets/hooks/useRedPacketPage.js new file mode 100644 index 0000000..013b9d8 --- /dev/null +++ b/src/features/red-packets/hooks/useRedPacketPage.js @@ -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); +} diff --git a/src/features/red-packets/pages/RedPacketPage.jsx b/src/features/red-packets/pages/RedPacketPage.jsx new file mode 100644 index 0000000..ff6bfd5 --- /dev/null +++ b/src/features/red-packets/pages/RedPacketPage.jsx @@ -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) => ( +
+ {packet.packetId} + {packet.commandId || "-"} +
+ ), + }, + { + key: "room", + label: "房间/区域", + width: "minmax(180px, 0.75fr)", + render: (packet) => ( +
+ {packet.roomId || "-"} + 区域 {packet.regionId || "-"} +
+ ), + }, + { + 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) => ( +
+ {formatNumber(packet.totalAmount)} 金币 + + 剩余 {formatNumber(packet.remainingAmount)} / {packet.remainingCount} 个 + +
+ ), + }, + { + 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) => ( + + page.openPacketDetail(packet.packetId)}> + + + + ), + }, + ]; + + return ( + + + } + filters={ + <> + page.setRoomId(event.target.value)} + /> + page.setSenderUserId(event.target.value)} + /> + page.setPacketType(event.target.value)} + > + {packetTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setStatus(event.target.value)} + > + {statusOptions.map(([value, label]) => ( + + {label} + + ))} + + + } + /> + + + 0 + ? { + page: page.page, + pageSize: page.packets.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(packet) => packet.packetId} + /> + + + + + + ); +} + +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)); +} diff --git a/src/features/red-packets/permissions.js b/src/features/red-packets/permissions.js new file mode 100644 index 0000000..9531e6d --- /dev/null +++ b/src/features/red-packets/permissions.js @@ -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), + }; +} diff --git a/src/features/red-packets/red-packets.module.css b/src/features/red-packets/red-packets.module.css new file mode 100644 index 0000000..1ce7384 --- /dev/null +++ b/src/features/red-packets/red-packets.module.css @@ -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); + } +} diff --git a/src/features/red-packets/routes.js b/src/features/red-packets/routes.js new file mode 100644 index 0000000..c0439fe --- /dev/null +++ b/src/features/red-packets/routes.js @@ -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, + }, +]; diff --git a/src/features/vip-config/api.ts b/src/features/vip-config/api.ts new file mode 100644 index 0000000..fac7f0a --- /dev/null +++ b/src/features/vip-config/api.ts @@ -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 { + return apiRequest(path).then(normalizeConfig); +} + +export function updateVipConfig(payload: VipConfigPayload): Promise { + return apiRequest(path, { body: payload, method: "PUT" }).then(normalizeConfig); +} + +export function grantVip(payload: VipGrantPayload): Promise { + return apiRequest(grantPath, { body: payload, method: "POST" }).then( + normalizeVipGrant, + ); +} + +type RawConfig = VipConfigDto & Record; +type RawLevel = VipLevelDto & Record; +type RawVipGrant = VipGrantDto & Record; +type RawUserVip = UserVipDto & Record; +type RawReward = VipRewardItemDto & Record; + +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; +} diff --git a/src/features/vip-config/hooks/useVipConfigPage.js b/src/features/vip-config/hooks/useVipConfigPage.js new file mode 100644 index 0000000..d4817b4 --- /dev/null +++ b/src/features/vip-config/hooks/useVipConfigPage.js @@ -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) || {}), + })); +} diff --git a/src/features/vip-config/pages/VipConfigPage.jsx b/src/features/vip-config/pages/VipConfigPage.jsx new file mode 100644 index 0000000..5baf9d3 --- /dev/null +++ b/src/features/vip-config/pages/VipConfigPage.jsx @@ -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 ( + +
+
+ {levels.length || 10} + {page.activeCount} + + {levels.filter((level) => level.level >= 6 && level.status === "active").length} + +
+
+ + {page.abilities.canUpdate ? ( + + ) : null} + {page.abilities.canGrant ? ( + + ) : null} +
+
+ + + item.level} /> + + + + +
+ ); +} + +function VipGrantDrawer({ page }) { + const disabled = !page.abilities.canGrant || page.grantSaving || page.loading; + const activeLevels = (page.config.levels || []).filter((level) => level.status === "active"); + return ( + +
+

赠送VIP

+
+
赠送信息
+ page.updateGrantForm({ targetUserId: event.target.value })} + /> + page.updateGrantForm({ level: event.target.value })} + > + {activeLevels.length === 0 ? ( + 暂无启用等级 + ) : ( + activeLevels.map((level) => ( + + VIP{level.level} · {level.name} + + )) + )} + + page.updateGrantForm({ reason: event.target.value })} + /> +
+
+ + +
+
+
+ ); +} + +function VipConfigDrawer({ page }) { + const disabled = !page.abilities.canUpdate || page.saving || page.loading; + return ( + +
+

VIP配置

+
+
等级配置
+
+ {(page.form.levels || []).map((level) => ( +
+
+ VIP{level.level} + + page.updateLevel(level.level, { + status: event.target.checked ? "active" : "disabled", + }) + } + /> +
+
+ + page.updateLevel(level.level, { name: event.target.value }) + } + /> + + page.updateLevel(level.level, { priceCoin: event.target.value }) + } + /> + + page.updateLevel(level.level, { durationDays: event.target.value }) + } + /> + + page.updateLevel(level.level, { rewardResourceGroupId: value }) + } + /> + {level.level >= 6 ? ( + + page.updateLevel(level.level, { + requiredRechargeCoinAmount: event.target.value, + }) + } + /> + ) : ( + + )} + + page.updateLevel(level.level, { sortOrder: event.target.value }) + } + /> +
+
+ ))} +
+
+
+ + +
+
+
+ ); +} + +function vipColumns(resourceLabels) { + return [ + { + key: "level", + label: "等级", + render: (item) => , + width: "minmax(120px, 0.5fr)", + }, + { + key: "status", + label: "状态", + render: (item) => ( + + {item.status === "active" ? "启用" : "停用"} + + ), + 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 ( +
+ {label} + {children} +
+ ); +} + +function Stack({ primary, secondary }) { + return ( +
+ {primary || "-"} + {secondary || "-"} +
+ ); +} + +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") : "-"; +} diff --git a/src/features/vip-config/permissions.js b/src/features/vip-config/permissions.js new file mode 100644 index 0000000..544dc37 --- /dev/null +++ b/src/features/vip-config/permissions.js @@ -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), + }; +} diff --git a/src/features/vip-config/routes.js b/src/features/vip-config/routes.js new file mode 100644 index 0000000..cc81ce4 --- /dev/null +++ b/src/features/vip-config/routes.js @@ -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, + }, +]; diff --git a/src/features/vip-config/vip-config.module.css b/src/features/vip-config/vip-config.module.css new file mode 100644 index 0000000..1862900 --- /dev/null +++ b/src/features/vip-config/vip-config.module.css @@ -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; + } +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 9d5f33d..24f19c3 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 9b1a8ce..2369b76 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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;