完善游戏平台
This commit is contained in:
parent
1e2b2eea3b
commit
d06718cc49
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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}>
|
||||
|
||||
172
src/features/red-packets/api.ts
Normal file
172
src/features/red-packets/api.ts
Normal 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;
|
||||
}
|
||||
162
src/features/red-packets/components/RedPacketConfigDrawer.jsx
Normal file
162
src/features/red-packets/components/RedPacketConfigDrawer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
103
src/features/red-packets/components/RedPacketDetailDrawer.jsx
Normal file
103
src/features/red-packets/components/RedPacketDetailDrawer.jsx
Normal 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));
|
||||
}
|
||||
239
src/features/red-packets/hooks/useRedPacketPage.js
Normal file
239
src/features/red-packets/hooks/useRedPacketPage.js
Normal 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);
|
||||
}
|
||||
222
src/features/red-packets/pages/RedPacketPage.jsx
Normal file
222
src/features/red-packets/pages/RedPacketPage.jsx
Normal 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));
|
||||
}
|
||||
11
src/features/red-packets/permissions.js
Normal file
11
src/features/red-packets/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
125
src/features/red-packets/red-packets.module.css
Normal file
125
src/features/red-packets/red-packets.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/features/red-packets/routes.js
Normal file
12
src/features/red-packets/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
160
src/features/vip-config/api.ts
Normal file
160
src/features/vip-config/api.ts
Normal 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;
|
||||
}
|
||||
260
src/features/vip-config/hooks/useVipConfigPage.js
Normal file
260
src/features/vip-config/hooks/useVipConfigPage.js
Normal 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) || {}),
|
||||
}));
|
||||
}
|
||||
340
src/features/vip-config/pages/VipConfigPage.jsx
Normal file
340
src/features/vip-config/pages/VipConfigPage.jsx
Normal 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") : "-";
|
||||
}
|
||||
11
src/features/vip-config/permissions.js
Normal file
11
src/features/vip-config/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
12
src/features/vip-config/routes.js
Normal file
12
src/features/vip-config/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
103
src/features/vip-config/vip-config.module.css
Normal file
103
src/features/vip-config/vip-config.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
98
src/shared/api/generated/schema.d.ts
vendored
98
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user