Compare commits
9 Commits
90bf486385
...
f2c7ccf7f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c7ccf7f3 | ||
|
|
f97aad449a | ||
|
|
d87cc49797 | ||
|
|
f59c374eae | ||
|
|
d06718cc49 | ||
|
|
1e2b2eea3b | ||
|
|
1330aea332 | ||
|
|
5821154fb1 | ||
|
|
841480d89e |
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
|||||||
import HubOutlined from "@mui/icons-material/HubOutlined";
|
import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
|
import LeaderboardOutlined from "@mui/icons-material/LeaderboardOutlined";
|
||||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||||
import MapOutlined from "@mui/icons-material/MapOutlined";
|
import MapOutlined from "@mui/icons-material/MapOutlined";
|
||||||
@ -25,6 +26,7 @@ import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicatio
|
|||||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
||||||
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
||||||
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||||
|
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
|
||||||
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
||||||
|
|
||||||
const deprecatedMenuCodes = new Set(["services"]);
|
const deprecatedMenuCodes = new Set(["services"]);
|
||||||
@ -40,8 +42,10 @@ const iconMap = {
|
|||||||
category: CategoryOutlined,
|
category: CategoryOutlined,
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
event_available: EventAvailableOutlined,
|
event_available: EventAvailableOutlined,
|
||||||
|
explore: MapOutlined,
|
||||||
gift: CardGiftcardOutlined,
|
gift: CardGiftcardOutlined,
|
||||||
inventory: Inventory2Outlined,
|
inventory: Inventory2Outlined,
|
||||||
|
leaderboard: LeaderboardOutlined,
|
||||||
network: HubOutlined,
|
network: HubOutlined,
|
||||||
team: GroupsOutlined,
|
team: GroupsOutlined,
|
||||||
history: HistoryOutlined,
|
history: HistoryOutlined,
|
||||||
@ -62,6 +66,7 @@ const iconMap = {
|
|||||||
task: TaskAltOutlined,
|
task: TaskAltOutlined,
|
||||||
users: ManageAccountsOutlined,
|
users: ManageAccountsOutlined,
|
||||||
wallet: WalletOutlined,
|
wallet: WalletOutlined,
|
||||||
|
workspace_premium: WorkspacePremiumOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fallbackNavigation = [
|
export const fallbackNavigation = [
|
||||||
@ -110,6 +115,7 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||||
|
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
||||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||||
],
|
],
|
||||||
@ -146,7 +152,12 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
||||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||||
|
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
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 }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const PERMISSIONS = {
|
|||||||
gameCreate: "game:create",
|
gameCreate: "game:create",
|
||||||
gameUpdate: "game:update",
|
gameUpdate: "game:update",
|
||||||
gameStatus: "game:status",
|
gameStatus: "game:status",
|
||||||
|
gameDelete: "game:delete",
|
||||||
roleView: "role:view",
|
roleView: "role:view",
|
||||||
roleCreate: "role:create",
|
roleCreate: "role:create",
|
||||||
roleUpdate: "role:update",
|
roleUpdate: "role:update",
|
||||||
@ -102,12 +103,20 @@ export const PERMISSIONS = {
|
|||||||
achievementUpdate: "achievement:update",
|
achievementUpdate: "achievement:update",
|
||||||
registrationRewardView: "registration-reward:view",
|
registrationRewardView: "registration-reward:view",
|
||||||
registrationRewardUpdate: "registration-reward:update",
|
registrationRewardUpdate: "registration-reward:update",
|
||||||
|
firstRechargeRewardView: "first-recharge-reward:view",
|
||||||
|
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
||||||
sevenDayCheckInView: "seven-day-checkin:view",
|
sevenDayCheckInView: "seven-day-checkin:view",
|
||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
luckyGiftUpdate: "lucky-gift:update",
|
luckyGiftUpdate: "lucky-gift:update",
|
||||||
roomTreasureView: "room-treasure:view",
|
roomTreasureView: "room-treasure:view",
|
||||||
roomTreasureUpdate: "room-treasure:update",
|
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",
|
uploadCreate: "upload:create",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -137,6 +146,7 @@ export const MENU_CODES = {
|
|||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
appConfigH5: "app-config-h5",
|
appConfigH5: "app-config-h5",
|
||||||
appConfigBanners: "app-config-banners",
|
appConfigBanners: "app-config-banners",
|
||||||
|
appConfigExplore: "app-config-explore",
|
||||||
appConfigVersions: "app-config-versions",
|
appConfigVersions: "app-config-versions",
|
||||||
resources: "resources",
|
resources: "resources",
|
||||||
resourceList: "resource-list",
|
resourceList: "resource-list",
|
||||||
@ -151,9 +161,13 @@ export const MENU_CODES = {
|
|||||||
activities: "activities",
|
activities: "activities",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
|
firstRechargeReward: "first-recharge-reward",
|
||||||
achievementConfig: "achievement-config",
|
achievementConfig: "achievement-config",
|
||||||
sevenDayCheckIn: "seven-day-checkin",
|
sevenDayCheckIn: "seven-day-checkin",
|
||||||
roomTreasure: "room-treasure",
|
roomTreasure: "room-treasure",
|
||||||
|
userLeaderboard: "user-leaderboard",
|
||||||
|
redPacket: "red-packet",
|
||||||
|
vipConfig: "vip-config",
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
hostOrg: "host-org",
|
hostOrg: "host-org",
|
||||||
hostOrgCountries: "host-org-countries",
|
hostOrgCountries: "host-org-countries",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
|
|||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||||
|
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||||
import { gameRoutes } from "@/features/games/routes.js";
|
import { gameRoutes } from "@/features/games/routes.js";
|
||||||
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||||
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||||
@ -13,13 +14,16 @@ import { menusRoutes } from "@/features/menus/routes.js";
|
|||||||
import { operationsRoutes } from "@/features/operations/routes.js";
|
import { operationsRoutes } from "@/features/operations/routes.js";
|
||||||
import { paymentRoutes } from "@/features/payment/routes.js";
|
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||||
import { registrationRewardRoutes } from "@/features/registration-reward/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 { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
||||||
import { resourceRoutes } from "@/features/resources/routes.js";
|
import { resourceRoutes } from "@/features/resources/routes.js";
|
||||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
import { roomTreasureRoutes } from "@/features/room-treasure/routes.js";
|
import { roomTreasureRoutes } from "@/features/room-treasure/routes.js";
|
||||||
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/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 { usersRoutes } from "@/features/users/routes.js";
|
||||||
|
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
|
||||||
import type { MenuCode } from "@/app/permissions";
|
import type { MenuCode } from "@/app/permissions";
|
||||||
import type { AdminRoute, PublicRoute } from "./types";
|
import type { AdminRoute, PublicRoute } from "./types";
|
||||||
|
|
||||||
@ -35,8 +39,12 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...dailyTaskRoutes,
|
...dailyTaskRoutes,
|
||||||
...achievementRoutes,
|
...achievementRoutes,
|
||||||
...registrationRewardRoutes,
|
...registrationRewardRoutes,
|
||||||
|
...firstRechargeRewardRoutes,
|
||||||
...sevenDayCheckInRoutes,
|
...sevenDayCheckInRoutes,
|
||||||
...roomTreasureRoutes,
|
...roomTreasureRoutes,
|
||||||
|
...userLeaderboardRoutes,
|
||||||
|
...redPacketRoutes,
|
||||||
|
...vipConfigRoutes,
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
...operationsRoutes,
|
...operationsRoutes,
|
||||||
...luckyGiftRoutes,
|
...luckyGiftRoutes,
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import type {
|
|||||||
AppVersionDto,
|
AppVersionDto,
|
||||||
AppVersionPayload,
|
AppVersionPayload,
|
||||||
EntityId,
|
EntityId,
|
||||||
|
ExploreTabDto,
|
||||||
|
ExploreTabPayload,
|
||||||
H5LinkConfigDto,
|
H5LinkConfigDto,
|
||||||
|
H5LinkConfigPayload,
|
||||||
H5LinkConfigUpdatePayload,
|
H5LinkConfigUpdatePayload,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
@ -27,6 +30,60 @@ export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiLi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createH5Link;
|
||||||
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateH5Link(key: EntityId, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateH5Link;
|
||||||
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteH5Link;
|
||||||
|
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listExploreTabs(query: PageQuery = {}): Promise<ApiList<ExploreTabDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listExploreTabs;
|
||||||
|
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExploreTab(payload: ExploreTabPayload): Promise<ExploreTabDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createExploreTab;
|
||||||
|
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.createExploreTab), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExploreTab(tabId: EntityId, payload: ExploreTabPayload): Promise<ExploreTabDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateExploreTab;
|
||||||
|
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteExploreTab(tabId: EntityId): Promise<{ deleted: boolean }> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteExploreTab;
|
||||||
|
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteExploreTab, { tab_id: tabId }), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listBanners(query: PageQuery = {}): Promise<ApiList<AppBannerDto>> {
|
export function listBanners(query: PageQuery = {}): Promise<ApiList<AppBannerDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listBanners;
|
const endpoint = API_ENDPOINTS.listBanners;
|
||||||
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
||||||
|
|||||||
@ -27,3 +27,8 @@
|
|||||||
.formWideField {
|
.formWideField {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusCell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|||||||
171
src/features/app-config/hooks/useExploreConfigPage.js
Normal file
171
src/features/app-config/hooks/useExploreConfigPage.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { createExploreTab, deleteExploreTab, listExploreTabs, updateExploreTab } from "@/features/app-config/api";
|
||||||
|
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||||
|
import { exploreTabSchema } from "@/features/app-config/schema";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const emptyData = { items: [], total: 0 };
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
enabled: true,
|
||||||
|
h5Url: "",
|
||||||
|
sortOrder: "0",
|
||||||
|
tab: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useExploreConfigPage() {
|
||||||
|
const abilities = useAppConfigAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState("");
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [form, setFormState] = useState(emptyForm);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
|
||||||
|
const filters = useMemo(() => ({ enabled, keyword }), [enabled, keyword]);
|
||||||
|
const queryFn = useCallback(() => listExploreTabs(filters), [filters]);
|
||||||
|
const {
|
||||||
|
data = emptyData,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
} = useAdminQuery(queryFn, {
|
||||||
|
errorMessage: "加载 Explore 配置失败",
|
||||||
|
initialData: emptyData,
|
||||||
|
queryKey: ["app-config", "explore-tabs", filters],
|
||||||
|
});
|
||||||
|
|
||||||
|
const setForm = (patch) => {
|
||||||
|
setFormState((current) => ({ ...current, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormState(emptyForm());
|
||||||
|
setActiveAction("create");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setFormState(formFromExploreTab(item));
|
||||||
|
setActiveAction("edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setActiveAction("");
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormState(emptyForm());
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setKeyword("");
|
||||||
|
setEnabled("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitExploreTab = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = parseForm(exploreTabSchema, normalizeForm(form));
|
||||||
|
const action = editingItem ? "edit" : "create";
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateExploreTab(editingItem.id, payload);
|
||||||
|
showToast("Explore 配置已更新", "success");
|
||||||
|
} else {
|
||||||
|
await createExploreTab(payload);
|
||||||
|
showToast("Explore 配置已新增", "success");
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExploreTab = async (item, nextEnabled) => {
|
||||||
|
setLoadingAction(`toggle:${item.id}`);
|
||||||
|
try {
|
||||||
|
await updateExploreTab(item.id, {
|
||||||
|
enabled: nextEnabled,
|
||||||
|
h5Url: item.h5Url || "",
|
||||||
|
sortOrder: item.sortOrder || 0,
|
||||||
|
tab: item.tab || "",
|
||||||
|
});
|
||||||
|
await reload();
|
||||||
|
showToast(nextEnabled ? "Explore tab 已启用" : "Explore tab 已关闭", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExploreTab = async (item) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "删除",
|
||||||
|
message: item.tab || `Explore ${item.id}`,
|
||||||
|
title: "删除 Explore tab",
|
||||||
|
tone: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction(`delete:${item.id}`);
|
||||||
|
try {
|
||||||
|
await deleteExploreTab(item.id);
|
||||||
|
await reload();
|
||||||
|
showToast("Explore 配置已删除", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "删除失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
activeAction,
|
||||||
|
closeDialog,
|
||||||
|
data,
|
||||||
|
editingItem,
|
||||||
|
enabled,
|
||||||
|
error,
|
||||||
|
form,
|
||||||
|
keyword,
|
||||||
|
loading,
|
||||||
|
loadingAction,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
reload,
|
||||||
|
removeExploreTab,
|
||||||
|
resetFilters,
|
||||||
|
setEnabled,
|
||||||
|
setForm,
|
||||||
|
setKeyword,
|
||||||
|
submitExploreTab,
|
||||||
|
toggleExploreTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForm(form) {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
sortOrder: form.sortOrder || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromExploreTab(item) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(item.enabled),
|
||||||
|
h5Url: item.h5Url || "",
|
||||||
|
sortOrder: String(item.sortOrder || 0),
|
||||||
|
tab: item.tab || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,17 +1,20 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { listH5Links, updateH5Links } from "@/features/app-config/api";
|
import { createH5Link, deleteH5Link, listH5Links, updateH5Link } from "@/features/app-config/api";
|
||||||
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||||
import { h5LinkUpdateSchema } from "@/features/app-config/schema";
|
import { h5LinkUpdateSchema } from "@/features/app-config/schema";
|
||||||
|
|
||||||
const emptyData = { items: [], total: 0 };
|
const emptyData = { items: [], total: 0 };
|
||||||
const emptyForm = () => ({ url: "" });
|
const emptyForm = () => ({ key: "", label: "", url: "" });
|
||||||
|
|
||||||
export function useH5ConfigPage() {
|
export function useH5ConfigPage() {
|
||||||
const abilities = useAppConfigAbilities();
|
const abilities = useAppConfigAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
@ -22,12 +25,20 @@ export function useH5ConfigPage() {
|
|||||||
queryKey: ["app-config", "h5-links"]
|
queryKey: ["app-config", "h5-links"]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setActiveAction("create");
|
||||||
|
};
|
||||||
|
|
||||||
const openEdit = (item) => {
|
const openEdit = (item) => {
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
setForm({ url: item.url || "" });
|
setForm({ key: item.key || "", label: item.label || "", url: item.url || "" });
|
||||||
|
setActiveAction("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEdit = () => {
|
const closeEdit = () => {
|
||||||
|
setActiveAction("");
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setForm(emptyForm());
|
setForm(emptyForm());
|
||||||
};
|
};
|
||||||
@ -35,12 +46,24 @@ export function useH5ConfigPage() {
|
|||||||
const submitEdit = async (event) => {
|
const submitEdit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!editingItem) {
|
if (!editingItem) {
|
||||||
|
const payload = parseForm(h5LinkUpdateSchema, form);
|
||||||
|
setLoadingAction("create");
|
||||||
|
try {
|
||||||
|
await createH5Link(payload);
|
||||||
|
closeEdit();
|
||||||
|
await reload();
|
||||||
|
showToast("H5配置已新增", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = parseForm(h5LinkUpdateSchema, { key: editingItem.key, url: form.url });
|
const payload = parseForm(h5LinkUpdateSchema, { ...form, key: editingItem.key });
|
||||||
setLoadingAction("edit");
|
setLoadingAction("edit");
|
||||||
try {
|
try {
|
||||||
await updateH5Links({ items: [payload] });
|
await updateH5Link(editingItem.key, payload);
|
||||||
closeEdit();
|
closeEdit();
|
||||||
await reload();
|
await reload();
|
||||||
showToast("H5配置已更新", "success");
|
showToast("H5配置已更新", "success");
|
||||||
@ -51,7 +74,30 @@ export function useH5ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeH5Link = async (item) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "删除",
|
||||||
|
message: item.label || item.key,
|
||||||
|
title: "删除H5配置",
|
||||||
|
tone: "danger"
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction(`delete:${item.key}`);
|
||||||
|
try {
|
||||||
|
await deleteH5Link(item.key);
|
||||||
|
await reload();
|
||||||
|
showToast("H5配置已删除", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "删除失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activeAction,
|
||||||
abilities,
|
abilities,
|
||||||
closeEdit,
|
closeEdit,
|
||||||
data,
|
data,
|
||||||
@ -60,8 +106,10 @@ export function useH5ConfigPage() {
|
|||||||
form,
|
form,
|
||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
reload,
|
reload,
|
||||||
|
removeH5Link,
|
||||||
setForm,
|
setForm,
|
||||||
submitEdit
|
submitEdit
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useMemo } from "react";
|
|||||||
import { APP_BANNER_DISPLAY_SCOPE_OPTIONS } from "@/features/app-config/constants";
|
import { APP_BANNER_DISPLAY_SCOPE_OPTIONS } from "@/features/app-config/constants";
|
||||||
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
|
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
|
||||||
import styles from "@/features/app-config/app-config.module.css";
|
import styles from "@/features/app-config/app-config.module.css";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
|
||||||
import {
|
import {
|
||||||
AdminFormDialog,
|
AdminFormDialog,
|
||||||
AdminFormFieldGrid,
|
AdminFormFieldGrid,
|
||||||
@ -59,13 +58,9 @@ export function BannerConfigPage() {
|
|||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canUpdate ? (
|
page.abilities.canUpdate ? (
|
||||||
<Button
|
<AdminActionIconButton label="新增BANNER" primary onClick={page.openCreate}>
|
||||||
startIcon={<AddOutlined fontSize="small" />}
|
<AddOutlined fontSize="small" />
|
||||||
variant="primary"
|
</AdminActionIconButton>
|
||||||
onClick={page.openCreate}
|
|
||||||
>
|
|
||||||
新增BANNER
|
|
||||||
</Button>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -73,14 +68,16 @@ export function BannerConfigPage() {
|
|||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
fixedEdges={false}
|
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1480px"
|
minWidth="1480px"
|
||||||
|
pagination={{
|
||||||
|
itemCount: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: items.length || 1,
|
||||||
|
total: page.data.total ?? items.length,
|
||||||
|
}}
|
||||||
rowKey={(item) => item.id}
|
rowKey={(item) => item.id}
|
||||||
/>
|
/>
|
||||||
<div className="pagination-bar">
|
|
||||||
<span>{items.length} 条</span>
|
|
||||||
</div>
|
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
|
||||||
|
|||||||
216
src/features/app-config/pages/ExploreConfigPage.jsx
Normal file
216
src/features/app-config/pages/ExploreConfigPage.jsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useExploreConfigPage } from "@/features/app-config/hooks/useExploreConfigPage.js";
|
||||||
|
import styles from "@/features/app-config/app-config.module.css";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import {
|
||||||
|
AdminFormDialog,
|
||||||
|
AdminFormFieldGrid,
|
||||||
|
AdminFormSection,
|
||||||
|
AdminFormSwitchField,
|
||||||
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
AdminRowActions,
|
||||||
|
AdminSearchBox,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const enabledOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["true", "启用"],
|
||||||
|
["false", "关闭"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ExploreConfigPage() {
|
||||||
|
const page = useExploreConfigPage();
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const columns = useMemo(() => exploreColumns(page), [page]);
|
||||||
|
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||||
|
新增 Explore tab
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminSearchBox
|
||||||
|
placeholder="搜索 tab / H5 链接"
|
||||||
|
value={page.keyword}
|
||||||
|
onChange={page.setKeyword}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="状态"
|
||||||
|
options={enabledOptions}
|
||||||
|
value={page.enabled}
|
||||||
|
onChange={page.setEnabled}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton disabled={!page.keyword && !page.enabled} onClick={page.resetFilters} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1080px"
|
||||||
|
pagination={{
|
||||||
|
itemCount: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: Math.max(items.length, 1),
|
||||||
|
total: page.data.total ?? items.length,
|
||||||
|
}}
|
||||||
|
rowKey={(item) => item.id}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={saving}
|
||||||
|
open={Boolean(page.activeAction)}
|
||||||
|
submitDisabled={!page.abilities.canUpdate || saving}
|
||||||
|
title={page.editingItem ? "编辑 Explore tab" : "新增 Explore tab"}
|
||||||
|
onClose={page.closeDialog}
|
||||||
|
onSubmit={page.submitExploreTab}
|
||||||
|
>
|
||||||
|
<AdminFormSection title="tab 配置">
|
||||||
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="tab"
|
||||||
|
required
|
||||||
|
value={page.form.tab}
|
||||||
|
onChange={(event) => page.setForm({ tab: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
inputProps={{ step: 1 }}
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={page.form.sortOrder}
|
||||||
|
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.formWideField}
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="H5 链接"
|
||||||
|
required
|
||||||
|
value={page.form.h5Url}
|
||||||
|
onChange={(event) => page.setForm({ h5Url: event.target.value })}
|
||||||
|
/>
|
||||||
|
<AdminFormSwitchField
|
||||||
|
checked={page.form.enabled}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="状态"
|
||||||
|
switchProps={{ inputProps: { "aria-label": "Explore tab 启用状态" } }}
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={(checked) => page.setForm({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
|
</AdminFormDialog>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exploreColumns(page) {
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
key: "tab",
|
||||||
|
label: "tab",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "h5Url",
|
||||||
|
label: "H5链接",
|
||||||
|
render: (item) => <span className={styles.linkText}>{item.h5Url || "-"}</span>,
|
||||||
|
width: "minmax(360px, 1.6fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enabled",
|
||||||
|
label: "状态",
|
||||||
|
render: (item) => <ExploreStatusSwitch item={item} page={page} />,
|
||||||
|
width: "minmax(120px, 0.5fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sortOrder",
|
||||||
|
label: "排序",
|
||||||
|
render: (item) => item.sortOrder ?? 0,
|
||||||
|
width: "minmax(100px, 0.4fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAtMs",
|
||||||
|
label: "更新时间",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs),
|
||||||
|
width: "minmax(170px, 0.7fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!page.abilities.canUpdate) {
|
||||||
|
return baseColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...baseColumns,
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
render: (item) => <ExploreRowActions item={item} page={page} />,
|
||||||
|
width: "minmax(124px, 0.5fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExploreStatusSwitch({ item, page }) {
|
||||||
|
const disabled = !page.abilities.canUpdate || page.loadingAction === `toggle:${item.id}`;
|
||||||
|
return (
|
||||||
|
<div className={styles.statusCell}>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={Boolean(item.enabled)}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ "aria-label": item.enabled ? "关闭 Explore tab" : "启用 Explore tab" }}
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={(event) => page.toggleExploreTab(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExploreRowActions({ item, page }) {
|
||||||
|
return (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
|
||||||
|
{item.h5Url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
label="删除"
|
||||||
|
disabled={page.loadingAction === `delete:${item.id}`}
|
||||||
|
onClick={() => page.removeExploreTab(item)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</AdminRowActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +1,18 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
AdminRowActions
|
AdminRowActions
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { useH5ConfigPage } from "@/features/app-config/hooks/useH5ConfigPage.js";
|
import { useH5ConfigPage } from "@/features/app-config/hooks/useH5ConfigPage.js";
|
||||||
@ -42,6 +46,7 @@ const baseColumns = [
|
|||||||
export function H5ConfigPage() {
|
export function H5ConfigPage() {
|
||||||
const page = useH5ConfigPage();
|
const page = useH5ConfigPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
|
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||||
const columns = page.abilities.canUpdate
|
const columns = page.abilities.canUpdate
|
||||||
? [
|
? [
|
||||||
...baseColumns,
|
...baseColumns,
|
||||||
@ -49,39 +54,73 @@ export function H5ConfigPage() {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item) => <H5LinkActions item={item} page={page} />,
|
render: (item) => <H5LinkActions item={item} page={page} />,
|
||||||
width: "minmax(88px, 0.4fr)"
|
width: "minmax(124px, 0.5fr)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: baseColumns;
|
: baseColumns;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||||
|
新增H5配置
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable columns={columns} items={items} minWidth="980px" rowKey={(item) => item.key} />
|
<DataTable
|
||||||
<div className="pagination-bar">
|
columns={columns}
|
||||||
<span>{items.length} 条</span>
|
items={items}
|
||||||
</div>
|
minWidth="980px"
|
||||||
|
pagination={{
|
||||||
|
itemCount: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: Math.max(items.length, 1),
|
||||||
|
total: page.data.total ?? items.length
|
||||||
|
}}
|
||||||
|
rowKey={(item) => item.key}
|
||||||
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={page.loadingAction === "edit"}
|
loading={saving}
|
||||||
open={Boolean(page.editingItem)}
|
open={Boolean(page.activeAction)}
|
||||||
size="compact"
|
submitDisabled={!page.abilities.canUpdate || saving}
|
||||||
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
|
title={page.editingItem ? "编辑H5配置" : "新增H5配置"}
|
||||||
title="编辑H5链接"
|
|
||||||
onClose={page.closeEdit}
|
onClose={page.closeEdit}
|
||||||
onSubmit={page.submitEdit}
|
onSubmit={page.submitEdit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="链接信息">
|
<AdminFormSection title="链接信息">
|
||||||
<TextField
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
autoFocus
|
<TextField
|
||||||
disabled={!page.abilities.canUpdate}
|
autoFocus
|
||||||
label={page.editingItem?.label || "H5链接"}
|
disabled={!page.abilities.canUpdate}
|
||||||
value={page.form.url}
|
label="配置项"
|
||||||
onChange={(event) => page.setForm({ url: event.target.value })}
|
required
|
||||||
/>
|
value={page.form.label}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, label: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate || Boolean(page.editingItem)}
|
||||||
|
label="key"
|
||||||
|
required
|
||||||
|
value={page.form.key}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, key: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.formWideField}
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="H5链接"
|
||||||
|
required
|
||||||
|
value={page.form.url}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, url: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
@ -94,6 +133,13 @@ function H5LinkActions({ item, page }) {
|
|||||||
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
|
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
|
||||||
{item.url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
|
{item.url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
label="删除"
|
||||||
|
disabled={page.loadingAction === `delete:${item.key}`}
|
||||||
|
onClick={() => page.removeH5Link(item)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,14 @@ export const appConfigRoutes = [
|
|||||||
path: "/app-config/banners",
|
path: "/app-config/banners",
|
||||||
permission: PERMISSIONS.appConfigView
|
permission: PERMISSIONS.appConfigView
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Explore配置",
|
||||||
|
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),
|
||||||
|
menuCode: MENU_CODES.appConfigExplore,
|
||||||
|
pageKey: "app-config-explore",
|
||||||
|
path: "/app-config/explore",
|
||||||
|
permission: PERMISSIONS.appConfigView
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "内购配置",
|
label: "内购配置",
|
||||||
loader: () =>
|
loader: () =>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { appBannerSchema } from "@/features/app-config/schema";
|
import { appBannerSchema, exploreTabSchema, h5LinkUpdateSchema } from "@/features/app-config/schema";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
|
|
||||||
const validBannerForm = {
|
const validBannerForm = {
|
||||||
@ -19,6 +19,27 @@ const validBannerForm = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("app config form schema", () => {
|
describe("app config form schema", () => {
|
||||||
|
test("validates dynamic h5 config item", () => {
|
||||||
|
const payload = parseForm(h5LinkUpdateSchema, {
|
||||||
|
key: "host-center.v2",
|
||||||
|
label: "Host Center",
|
||||||
|
url: "https://h5.example.com/host",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.key).toBe("host-center.v2");
|
||||||
|
expect(payload.label).toBe("Host Center");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects dynamic h5 config key with spaces", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseForm(h5LinkUpdateSchema, {
|
||||||
|
key: "host center",
|
||||||
|
label: "Host Center",
|
||||||
|
url: "https://h5.example.com/host",
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps banner display scope and delivery time fields", () => {
|
test("keeps banner display scope and delivery time fields", () => {
|
||||||
const payload = parseForm(appBannerSchema, validBannerForm);
|
const payload = parseForm(appBannerSchema, validBannerForm);
|
||||||
|
|
||||||
@ -46,4 +67,27 @@ describe("app config form schema", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(FormValidationError);
|
).toThrow(FormValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates explore tab h5 url and sort order", () => {
|
||||||
|
const payload = parseForm(exploreTabSchema, {
|
||||||
|
enabled: true,
|
||||||
|
h5Url: "https://h5.example.com/explore/live",
|
||||||
|
sortOrder: "3",
|
||||||
|
tab: "Live",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.sortOrder).toBe(3);
|
||||||
|
expect(payload.h5Url).toBe("https://h5.example.com/explore/live");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects explore tab url with whitespace", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseForm(exploreTabSchema, {
|
||||||
|
enabled: true,
|
||||||
|
h5Url: "https://h5.example.com/explore live",
|
||||||
|
sortOrder: "0",
|
||||||
|
tab: "Live",
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,20 +1,36 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { H5_LINK_KEYS } from "@/features/app-config/constants";
|
|
||||||
|
|
||||||
const h5LinkURLSchema = z
|
const requiredH5LinkURLSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
|
.min(1, "请填写 H5 链接")
|
||||||
.max(2048, "H5 链接不能超过 2048 个字符")
|
.max(2048, "H5 链接不能超过 2048 个字符")
|
||||||
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
|
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符");
|
||||||
.default("");
|
|
||||||
|
const h5LinkKeySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "请填写 key")
|
||||||
|
.max(80, "key 不能超过 80 个字符")
|
||||||
|
.regex(/^[A-Za-z0-9_.:-]+$/, "key 只能包含字母、数字、下划线、中横线、点和冒号");
|
||||||
|
|
||||||
export const h5LinkUpdateSchema = z.object({
|
export const h5LinkUpdateSchema = z.object({
|
||||||
key: z.enum(H5_LINK_KEYS),
|
key: h5LinkKeySchema,
|
||||||
url: h5LinkURLSchema,
|
label: z.string().trim().min(1, "请填写配置项").max(80, "配置项不能超过 80 个字符"),
|
||||||
|
url: requiredH5LinkURLSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
|
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
|
||||||
|
|
||||||
|
export const exploreTabSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
h5Url: requiredH5LinkURLSchema,
|
||||||
|
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
|
||||||
|
tab: z.string().trim().min(1, "请填写 tab").max(40, "tab 不能超过 40 个字符"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExploreTabForm = z.infer<typeof exploreTabSchema>;
|
||||||
|
|
||||||
export const appBannerSchema = z
|
export const appBannerSchema = z
|
||||||
.object({
|
.object({
|
||||||
bannerType: z.enum(["h5", "app"]),
|
bannerType: z.enum(["h5", "app"]),
|
||||||
|
|||||||
171
src/features/first-recharge-reward/api.ts
Normal file
171
src/features/first-recharge-reward/api.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export interface FirstRechargeRewardTierDto {
|
||||||
|
tierId: number;
|
||||||
|
tierCode: string;
|
||||||
|
tierName: string;
|
||||||
|
minCoinAmount: number;
|
||||||
|
maxCoinAmount: number;
|
||||||
|
resourceGroupId: number;
|
||||||
|
status: string;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstRechargeRewardConfigDto {
|
||||||
|
appCode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
tiers: FirstRechargeRewardTierDto[];
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstRechargeRewardConfigPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
tiers: Array<{
|
||||||
|
tier_id?: number;
|
||||||
|
tier_code: string;
|
||||||
|
tier_name: string;
|
||||||
|
min_coin_amount: number;
|
||||||
|
max_coin_amount: number;
|
||||||
|
resource_group_id: number;
|
||||||
|
status: string;
|
||||||
|
sort_order: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstRechargeRewardClaimUserDto {
|
||||||
|
userId?: number;
|
||||||
|
displayUserId?: string;
|
||||||
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstRechargeRewardClaimDto {
|
||||||
|
claimId: string;
|
||||||
|
eventId?: string;
|
||||||
|
transactionId?: string;
|
||||||
|
commandId?: string;
|
||||||
|
userId: number;
|
||||||
|
user?: FirstRechargeRewardClaimUserDto;
|
||||||
|
tierId?: number;
|
||||||
|
tierCode?: string;
|
||||||
|
resourceGroupId?: number;
|
||||||
|
rechargeCoinAmount?: number;
|
||||||
|
rechargeSequence?: number;
|
||||||
|
rechargeType?: string;
|
||||||
|
status?: string;
|
||||||
|
walletCommandId?: string;
|
||||||
|
walletGrantId?: string;
|
||||||
|
failureReason?: string;
|
||||||
|
rechargedAtMs?: number;
|
||||||
|
grantedAtMs?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawConfig = FirstRechargeRewardConfigDto & Record<string, unknown>;
|
||||||
|
type RawTier = FirstRechargeRewardTierDto & Record<string, unknown>;
|
||||||
|
type RawClaim = FirstRechargeRewardClaimDto & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function getFirstRechargeRewardConfig(): Promise<FirstRechargeRewardConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getFirstRechargeRewardConfig;
|
||||||
|
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getFirstRechargeRewardConfig), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFirstRechargeRewardConfig(
|
||||||
|
payload: FirstRechargeRewardConfigPayload,
|
||||||
|
): Promise<FirstRechargeRewardConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateFirstRechargeRewardConfig;
|
||||||
|
return apiRequest<RawConfig, FirstRechargeRewardConfigPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateFirstRechargeRewardConfig),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFirstRechargeRewardClaims(query: PageQuery = {}): Promise<ApiPage<FirstRechargeRewardClaimDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listFirstRechargeRewardClaims;
|
||||||
|
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listFirstRechargeRewardClaims), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeClaim),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(item: RawConfig): FirstRechargeRewardConfigDto {
|
||||||
|
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
enabled: Boolean(item.enabled),
|
||||||
|
tiers: rawTiers.map(normalizeTier),
|
||||||
|
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 normalizeTier(item: RawTier): FirstRechargeRewardTierDto {
|
||||||
|
return {
|
||||||
|
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||||
|
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||||
|
tierName: stringValue(item.tierName ?? item.tier_name),
|
||||||
|
minCoinAmount: numberValue(item.minCoinAmount ?? item.min_coin_amount),
|
||||||
|
maxCoinAmount: numberValue(item.maxCoinAmount ?? item.max_coin_amount),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
status: stringValue(item.status) || "active",
|
||||||
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
|
||||||
|
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||||
|
eventId: stringValue(item.eventId ?? item.event_id),
|
||||||
|
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||||
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
|
user: {
|
||||||
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
|
username: stringValue(rawUser.username),
|
||||||
|
avatar: stringValue(rawUser.avatar),
|
||||||
|
},
|
||||||
|
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||||
|
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
||||||
|
rechargeSequence: numberValue(item.rechargeSequence ?? item.recharge_sequence),
|
||||||
|
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||||
|
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
|
||||||
|
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||||
|
rechargedAtMs: numberValue(item.rechargedAtMs ?? item.recharged_at_ms),
|
||||||
|
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_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;
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
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 MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
|
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["active", "启用"],
|
||||||
|
["inactive", "停用"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FirstRechargeRewardConfigDrawer({
|
||||||
|
abilities,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
form,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
open,
|
||||||
|
resourceGroups,
|
||||||
|
setForm,
|
||||||
|
}) {
|
||||||
|
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||||
|
|
||||||
|
const addTier = () => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: [
|
||||||
|
...(current.tiers || []),
|
||||||
|
{
|
||||||
|
tierId: 0,
|
||||||
|
tierCode: "",
|
||||||
|
tierName: "",
|
||||||
|
minCoinAmount: "",
|
||||||
|
maxCoinAmount: "",
|
||||||
|
resourceGroupId: "",
|
||||||
|
status: "active",
|
||||||
|
sortOrder: String((current.tiers || []).length),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTier = (index, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: (current.tiers || []).map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTier = (index) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: (current.tiers || []).filter((_, tierIndex) => tierIndex !== 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) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className={styles.drawerSectionTitle}>
|
||||||
|
<span>奖励档位</span>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
type="button"
|
||||||
|
onClick={addTier}
|
||||||
|
>
|
||||||
|
添加档位
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tierEditorList}>
|
||||||
|
{(form.tiers || []).map((tier, index) => (
|
||||||
|
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
||||||
|
<div className={styles.tierEditorHeader}>
|
||||||
|
<span>档位 {index + 1}</span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除档位"
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
onClick={() => removeTier(index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="form-drawer__grid">
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="档位编码"
|
||||||
|
value={tier.tierCode}
|
||||||
|
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="档位名称"
|
||||||
|
value={tier.tierName}
|
||||||
|
onChange={(event) => updateTier(index, { tierName: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
label="最小金币"
|
||||||
|
type="number"
|
||||||
|
value={tier.minCoinAmount}
|
||||||
|
onChange={(event) => updateTier(index, { minCoinAmount: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0 }}
|
||||||
|
label="最大金币"
|
||||||
|
placeholder="0 表示无上限"
|
||||||
|
type="number"
|
||||||
|
value={tier.maxCoinAmount}
|
||||||
|
onChange={(event) => updateTier(index, { maxCoinAmount: event.target.value })}
|
||||||
|
/>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
disabled={disabled}
|
||||||
|
drawerTitle="选择首冲奖励资源组"
|
||||||
|
groups={resourceGroups}
|
||||||
|
label="奖励资源组"
|
||||||
|
value={tier.resourceGroupId}
|
||||||
|
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
disabled={disabled}
|
||||||
|
label="状态"
|
||||||
|
value={tier.status}
|
||||||
|
onChange={(event) => updateTier(index, { status: event.target.value })}
|
||||||
|
>
|
||||||
|
{statusOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0 }}
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={tier.sortOrder}
|
||||||
|
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
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/first-recharge-reward/first-recharge-reward.module.css";
|
||||||
|
|
||||||
|
export function FirstRechargeRewardConfigSummary({
|
||||||
|
canUpdate,
|
||||||
|
config,
|
||||||
|
configLoading,
|
||||||
|
resourceGroups,
|
||||||
|
onEdit,
|
||||||
|
onRefresh,
|
||||||
|
}) {
|
||||||
|
const tiers = config?.tiers || [];
|
||||||
|
const activeCount = tiers.filter((tier) => tier.status === "active").length;
|
||||||
|
|
||||||
|
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="档位">
|
||||||
|
{activeCount}/{tiers.length}
|
||||||
|
</SummaryItem>
|
||||||
|
<SummaryItem label="资源组">{resourceGroupSummary(tiers, resourceGroups)}</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 resourceGroupSummary(tiers, resourceGroups) {
|
||||||
|
if (!tiers.length) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const names = tiers.slice(0, 2).map((tier) =>
|
||||||
|
groupName(
|
||||||
|
resourceGroups.find((group) => group.groupId === tier.resourceGroupId),
|
||||||
|
tier.resourceGroupId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = tiers.length > names.length ? ` +${tiers.length - names.length}` : "";
|
||||||
|
return `${names.join("、")}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupName(group, fallbackId) {
|
||||||
|
return group?.name || group?.groupCode || `资源组 #${fallbackId}`;
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions,
|
||||||
|
.summaryItems {
|
||||||
|
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 {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerSectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditorList {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
min-height: 92px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { listResourceGroups } from "@/features/resources/api";
|
||||||
|
import {
|
||||||
|
getFirstRechargeRewardConfig,
|
||||||
|
listFirstRechargeRewardClaims,
|
||||||
|
updateFirstRechargeRewardConfig,
|
||||||
|
} from "@/features/first-recharge-reward/api";
|
||||||
|
import { useFirstRechargeRewardAbilities } from "@/features/first-recharge-reward/permissions.js";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const pageSize = 10;
|
||||||
|
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
enabled: false,
|
||||||
|
tiers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFirstRechargeRewardPage() {
|
||||||
|
const abilities = useFirstRechargeRewardAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const filters = useMemo(() => ({ keyword: query, status }), [query, status]);
|
||||||
|
const {
|
||||||
|
data: claims = emptyClaims,
|
||||||
|
error: claimsError,
|
||||||
|
loading: claimsLoading,
|
||||||
|
reload: reloadClaims,
|
||||||
|
} = usePaginatedQuery({
|
||||||
|
errorMessage: "加载首冲奖励记录失败",
|
||||||
|
fetcher: listFirstRechargeRewardClaims,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["first-recharge-reward-claims", filters, page],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloadConfig = useCallback(async () => {
|
||||||
|
setConfigLoading(true);
|
||||||
|
try {
|
||||||
|
const [config, groups] = await Promise.all([
|
||||||
|
getFirstRechargeRewardConfig(),
|
||||||
|
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
||||||
|
]);
|
||||||
|
setConfig(config);
|
||||||
|
setForm(formFromConfig(config));
|
||||||
|
setResourceGroups(groups.items || []);
|
||||||
|
} 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 changeQuery = (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeStatus = (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfig = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfigSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await updateFirstRechargeRewardConfig(payloadFromForm(form));
|
||||||
|
setConfig(saved);
|
||||||
|
setForm(formFromConfig(saved));
|
||||||
|
setConfigDrawerOpen(false);
|
||||||
|
showToast("首冲奖励配置已保存", "success");
|
||||||
|
await reloadClaims();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存首冲奖励配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setConfigSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
changeQuery,
|
||||||
|
changeStatus,
|
||||||
|
claims,
|
||||||
|
claimsError,
|
||||||
|
claimsLoading,
|
||||||
|
closeConfigDrawer,
|
||||||
|
config,
|
||||||
|
configDrawerOpen,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
form,
|
||||||
|
openConfigDrawer,
|
||||||
|
page,
|
||||||
|
query,
|
||||||
|
reloadClaims,
|
||||||
|
reloadConfig,
|
||||||
|
resourceGroups,
|
||||||
|
setForm,
|
||||||
|
setPage,
|
||||||
|
status,
|
||||||
|
submitConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromConfig(config) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
tiers: (config.tiers || []).map((tier) => ({
|
||||||
|
tierId: tier.tierId || 0,
|
||||||
|
tierCode: tier.tierCode || "",
|
||||||
|
tierName: tier.tierName || "",
|
||||||
|
minCoinAmount: tier.minCoinAmount ? String(tier.minCoinAmount) : "",
|
||||||
|
maxCoinAmount: tier.maxCoinAmount ? String(tier.maxCoinAmount) : "",
|
||||||
|
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
|
||||||
|
status: tier.status || "active",
|
||||||
|
sortOrder: String(tier.sortOrder || 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFromForm(form) {
|
||||||
|
const tiers = (form.tiers || []).map((tier, index) => {
|
||||||
|
const minCoinAmount = Number(tier.minCoinAmount || 0);
|
||||||
|
const maxCoinAmount = Number(tier.maxCoinAmount || 0);
|
||||||
|
const resourceGroupId = Number(tier.resourceGroupId || 0);
|
||||||
|
if (!tier.tierCode.trim()) {
|
||||||
|
throw new Error("档位编码不能为空");
|
||||||
|
}
|
||||||
|
if (!tier.tierName.trim()) {
|
||||||
|
throw new Error("档位名称不能为空");
|
||||||
|
}
|
||||||
|
if (minCoinAmount <= 0) {
|
||||||
|
throw new Error("最小金币必须大于 0");
|
||||||
|
}
|
||||||
|
if (maxCoinAmount > 0 && maxCoinAmount < minCoinAmount) {
|
||||||
|
throw new Error("最大金币不能小于最小金币");
|
||||||
|
}
|
||||||
|
if (resourceGroupId <= 0) {
|
||||||
|
throw new Error("请选择资源组");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tier_id: Number(tier.tierId || 0),
|
||||||
|
tier_code: tier.tierCode.trim(),
|
||||||
|
tier_name: tier.tierName.trim(),
|
||||||
|
min_coin_amount: minCoinAmount,
|
||||||
|
max_coin_amount: maxCoinAmount,
|
||||||
|
resource_group_id: resourceGroupId,
|
||||||
|
status: tier.status === "inactive" ? "inactive" : "active",
|
||||||
|
sort_order: Number(tier.sortOrder || index),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) {
|
||||||
|
throw new Error("开启后至少需要一个启用档位");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
tiers,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import { FirstRechargeRewardConfigDrawer } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx";
|
||||||
|
import { FirstRechargeRewardConfigSummary } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx";
|
||||||
|
import { useFirstRechargeRewardPage } from "@/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js";
|
||||||
|
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
|
||||||
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const claimStatusOptions = [
|
||||||
|
["pending", "发放中"],
|
||||||
|
["granted", "已发放"],
|
||||||
|
["failed", "发放失败"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnsBase = [
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户信息",
|
||||||
|
width: "minmax(260px, 1.1fr)",
|
||||||
|
render: (claim) => <ClaimUser claim={claim} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tier",
|
||||||
|
label: "命中档位",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (claim) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{claim.tierCode || "-"}</span>
|
||||||
|
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recharge",
|
||||||
|
label: "充值",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (claim) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{formatNumber(claim.rechargeCoinAmount)} 金币</span>
|
||||||
|
<span className={styles.meta}>第 {claim.rechargeSequence || "-"} 笔</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(120px, 0.55fr)",
|
||||||
|
render: (claim) => claimStatusLabel(claim.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rechargedAt",
|
||||||
|
label: "充值时间",
|
||||||
|
width: "minmax(180px, 0.85fr)",
|
||||||
|
render: (claim) => formatMillis(claim.rechargedAtMs || claim.createdAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "claimId",
|
||||||
|
label: "记录",
|
||||||
|
width: "minmax(260px, 1fr)",
|
||||||
|
render: (claim) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{claim.claimId}</span>
|
||||||
|
<span className={styles.meta}>{claim.transactionId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failure",
|
||||||
|
label: "失败原因",
|
||||||
|
width: "minmax(180px, 0.85fr)",
|
||||||
|
render: (claim) => claim.failureReason || "-",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FirstRechargeRewardPage() {
|
||||||
|
const page = useFirstRechargeRewardPage();
|
||||||
|
const total = page.claims.total || 0;
|
||||||
|
const columns = columnsBase.map((column) => {
|
||||||
|
if (column.key === "user") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "搜索用户 ID、短号、名称",
|
||||||
|
value: page.query,
|
||||||
|
onChange: page.changeQuery,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: [["", "全部状态"], ...claimStatusOptions],
|
||||||
|
placeholder: "搜索状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<FirstRechargeRewardConfigSummary
|
||||||
|
canUpdate={page.abilities.canUpdate}
|
||||||
|
config={page.config}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
resourceGroups={page.resourceGroups}
|
||||||
|
onEdit={page.openConfigDrawer}
|
||||||
|
onRefresh={page.reloadConfig}
|
||||||
|
/>
|
||||||
|
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={page.claims.items || []}
|
||||||
|
minWidth="1380px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.claims.pageSize || 10,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(claim) => claim.claimId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<FirstRechargeRewardConfigDrawer
|
||||||
|
abilities={page.abilities}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
configSaving={page.configSaving}
|
||||||
|
form={page.form}
|
||||||
|
open={page.configDrawerOpen}
|
||||||
|
resourceGroups={page.resourceGroups}
|
||||||
|
setForm={page.setForm}
|
||||||
|
onClose={page.closeConfigDrawer}
|
||||||
|
onSubmit={page.submitConfig}
|
||||||
|
/>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClaimUser({ claim }) {
|
||||||
|
const user = claim.user || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<Avatar
|
||||||
|
alt={user.username || String(claim.userId)}
|
||||||
|
src={user.avatar || ""}
|
||||||
|
sx={{ width: 36, height: 36 }}
|
||||||
|
/>
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimStatusLabel(status) {
|
||||||
|
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
|
}
|
||||||
11
src/features/first-recharge-reward/permissions.js
Normal file
11
src/features/first-recharge-reward/permissions.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useFirstRechargeRewardAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUpdate: can(PERMISSIONS.firstRechargeRewardUpdate),
|
||||||
|
canView: can(PERMISSIONS.firstRechargeRewardView),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/first-recharge-reward/routes.js
Normal file
12
src/features/first-recharge-reward/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const firstRechargeRewardRoutes = [
|
||||||
|
{
|
||||||
|
label: "首冲奖励",
|
||||||
|
loader: () => import("./pages/FirstRechargeRewardPage.jsx").then((module) => module.FirstRechargeRewardPage),
|
||||||
|
menuCode: MENU_CODES.firstRechargeReward,
|
||||||
|
pageKey: "first-recharge-reward",
|
||||||
|
path: "/activities/first-recharge-reward",
|
||||||
|
permission: PERMISSIONS.firstRechargeRewardView,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -8,6 +8,14 @@ export interface GamePlatformDto {
|
|||||||
platformName: string;
|
platformName: string;
|
||||||
status: string;
|
status: string;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
|
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1/zeeone_v1/baishun_v1。
|
||||||
|
adapterType: string;
|
||||||
|
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
||||||
|
callbackSecret?: string;
|
||||||
|
callbackSecretSet: boolean;
|
||||||
|
callbackIpWhitelist: string[];
|
||||||
|
// 厂商扩展配置保留 JSON 字符串,后台前端不绑定具体字段结构。
|
||||||
|
adapterConfigJson: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
@ -22,6 +30,7 @@ export interface GameCatalogDto {
|
|||||||
category: string;
|
category: string;
|
||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
|
launchUrl?: string;
|
||||||
launchMode: string;
|
launchMode: string;
|
||||||
orientation: string;
|
orientation: string;
|
||||||
minCoin: number;
|
minCoin: number;
|
||||||
@ -37,6 +46,8 @@ export interface GameCatalogPage {
|
|||||||
nextCursor?: string;
|
nextCursor?: string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
serverTimeMs?: number;
|
serverTimeMs?: number;
|
||||||
|
total?: number;
|
||||||
|
totalPages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GamePlatformPage {
|
export interface GamePlatformPage {
|
||||||
@ -44,7 +55,28 @@ export interface GamePlatformPage {
|
|||||||
serverTimeMs?: number;
|
serverTimeMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GamePlatformPayload = Omit<GamePlatformDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
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"
|
||||||
|
>;
|
||||||
export type GameCatalogPayload = Omit<GameCatalogDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
export type GameCatalogPayload = Omit<GameCatalogDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
||||||
|
|
||||||
export interface GameCatalogQuery {
|
export interface GameCatalogQuery {
|
||||||
@ -111,6 +143,28 @@ export function setGameStatus(gameId: string, status: string): Promise<GameCatal
|
|||||||
).then(normalizeCatalog);
|
).then(normalizeCatalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteGameCatalog(gameId: string): Promise<void> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteCatalog;
|
||||||
|
return apiRequest<void>(apiEndpointPath(API_OPERATIONS.deleteCatalog, { game_id: gameId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto {
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(platform.appCode),
|
appCode: stringValue(platform.appCode),
|
||||||
@ -118,6 +172,13 @@ function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto
|
|||||||
platformName: stringValue(platform.platformName),
|
platformName: stringValue(platform.platformName),
|
||||||
status: stringValue(platform.status || "active"),
|
status: stringValue(platform.status || "active"),
|
||||||
apiBaseUrl: stringValue(platform.apiBaseUrl),
|
apiBaseUrl: stringValue(platform.apiBaseUrl),
|
||||||
|
adapterType: stringValue(platform.adapterType || "demo"),
|
||||||
|
callbackSecret: stringValue(platform.callbackSecret),
|
||||||
|
callbackSecretSet: Boolean(platform.callbackSecretSet),
|
||||||
|
callbackIpWhitelist: Array.isArray(platform.callbackIpWhitelist)
|
||||||
|
? platform.callbackIpWhitelist.map(String).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
adapterConfigJson: stringValue(platform.adapterConfigJson || "{}"),
|
||||||
sortOrder: numberValue(platform.sortOrder),
|
sortOrder: numberValue(platform.sortOrder),
|
||||||
createdAtMs: numberValue(platform.createdAtMs),
|
createdAtMs: numberValue(platform.createdAtMs),
|
||||||
updatedAtMs: numberValue(platform.updatedAtMs),
|
updatedAtMs: numberValue(platform.updatedAtMs),
|
||||||
@ -134,7 +195,8 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
|
|||||||
category: stringValue(game.category),
|
category: stringValue(game.category),
|
||||||
iconUrl: stringValue(game.iconUrl),
|
iconUrl: stringValue(game.iconUrl),
|
||||||
coverUrl: stringValue(game.coverUrl),
|
coverUrl: stringValue(game.coverUrl),
|
||||||
launchMode: stringValue(game.launchMode || "h5_popup"),
|
launchUrl: stringValue(game.launchUrl),
|
||||||
|
launchMode: normalizeLaunchMode(game.launchMode),
|
||||||
orientation: stringValue(game.orientation || "portrait"),
|
orientation: stringValue(game.orientation || "portrait"),
|
||||||
minCoin: numberValue(game.minCoin),
|
minCoin: numberValue(game.minCoin),
|
||||||
status: stringValue(game.status || "active"),
|
status: stringValue(game.status || "active"),
|
||||||
@ -153,3 +215,8 @@ function numberValue(value: unknown) {
|
|||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number : 0;
|
return Number.isFinite(number) ? number : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLaunchMode(value: unknown) {
|
||||||
|
const mode = stringValue(value);
|
||||||
|
return mode === "half_screen" || mode === "three_quarter_screen" || mode === "full_screen" ? mode : "full_screen";
|
||||||
|
}
|
||||||
|
|||||||
@ -31,8 +31,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta,
|
.meta {
|
||||||
.tags {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -40,8 +39,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags {
|
.secretMeta {
|
||||||
color: var(--text-secondary);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switchOnlyField {
|
.switchOnlyField {
|
||||||
@ -55,3 +56,56 @@
|
|||||||
.fullField {
|
.fullField {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platformList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platformRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platformActions {
|
||||||
|
display: inline-flex;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.syncGameURL {
|
||||||
|
max-width: 360px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
deleteGameCatalog,
|
||||||
listGameCatalog,
|
listGameCatalog,
|
||||||
listGamePlatforms,
|
listGamePlatforms,
|
||||||
setGameStatus,
|
setGameStatus,
|
||||||
|
syncGamePlatformCatalog,
|
||||||
upsertGameCatalog,
|
upsertGameCatalog,
|
||||||
upsertGamePlatform,
|
upsertGamePlatform,
|
||||||
} from "@/features/games/api";
|
} from "@/features/games/api";
|
||||||
import { useGameAbilities } from "@/features/games/permissions.js";
|
import { useGameAbilities } from "@/features/games/permissions.js";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
|
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const defaultGameForm = {
|
const defaultGameForm = {
|
||||||
@ -18,7 +22,8 @@ const defaultGameForm = {
|
|||||||
category: "casual",
|
category: "casual",
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
coverUrl: "",
|
coverUrl: "",
|
||||||
launchMode: "h5_popup",
|
gameUrl: "",
|
||||||
|
launchMode: "full_screen",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
minCoin: 0,
|
minCoin: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
@ -31,6 +36,14 @@ const defaultPlatformForm = {
|
|||||||
platformName: "",
|
platformName: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
|
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1。
|
||||||
|
adapterType: "demo",
|
||||||
|
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
||||||
|
callbackSecret: "",
|
||||||
|
callbackSecretSet: false,
|
||||||
|
callbackIpWhitelistText: "",
|
||||||
|
// 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。
|
||||||
|
adapterConfigJson: "{}",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -40,18 +53,26 @@ const defaultPageSize = 50;
|
|||||||
|
|
||||||
export function useGamesPage() {
|
export function useGamesPage() {
|
||||||
const abilities = useGameAbilities();
|
const abilities = useGameAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [platformCode, setPlatformCode] = useState("");
|
const [platformCode, setPlatformCode] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageCursors, setPageCursors] = useState({ 1: "" });
|
|
||||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||||
|
const [catalogItems, setCatalogItems] = useState([]);
|
||||||
|
const [catalogMeta, setCatalogMeta] = useState(emptyCatalog);
|
||||||
|
const [catalogError, setCatalogError] = useState("");
|
||||||
|
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||||
|
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [editingGameId, setEditingGameId] = useState("");
|
const [editingGameId, setEditingGameId] = useState("");
|
||||||
const [editingPlatformCode, setEditingPlatformCode] = useState("");
|
const [editingPlatformCode, setEditingPlatformCode] = useState("");
|
||||||
const [gameForm, setGameForm] = useState(defaultGameForm);
|
const [gameForm, setGameForm] = useState(defaultGameForm);
|
||||||
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
|
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const [syncPlatform, setSyncPlatform] = useState(null);
|
||||||
|
const [syncCandidates, setSyncCandidates] = useState([]);
|
||||||
|
const [selectedSyncGameIds, setSelectedSyncGameIds] = useState([]);
|
||||||
|
const catalogRequestRef = useRef(0);
|
||||||
|
|
||||||
const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
|
const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
|
||||||
const {
|
const {
|
||||||
@ -65,26 +86,52 @@ export function useGamesPage() {
|
|||||||
queryKey: ["games", "platforms"],
|
queryKey: ["games", "platforms"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogQueryFn = useCallback(
|
const loadCatalog = useCallback(
|
||||||
() => listGameCatalog({ cursor: pageCursors[page] || "", platformCode, status, pageSize }),
|
async (cursor = "", append = false) => {
|
||||||
[page, pageCursors, pageSize, platformCode, status],
|
const requestId = catalogRequestRef.current + 1;
|
||||||
|
catalogRequestRef.current = requestId;
|
||||||
|
if (append) {
|
||||||
|
setCatalogLoadingMore(true);
|
||||||
|
} else {
|
||||||
|
setCatalogLoading(true);
|
||||||
|
}
|
||||||
|
setCatalogError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await listGameCatalog({ cursor, platformCode, status, pageSize });
|
||||||
|
if (catalogRequestRef.current !== requestId) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
setCatalogMeta(result);
|
||||||
|
setCatalogItems((current) => (append ? mergeCatalogItems(current, result.items || []) : result.items || []));
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (catalogRequestRef.current === requestId) {
|
||||||
|
setCatalogError(err.message || "加载游戏列表失败");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (catalogRequestRef.current === requestId) {
|
||||||
|
setCatalogLoading(false);
|
||||||
|
setCatalogLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pageSize, platformCode, status],
|
||||||
);
|
);
|
||||||
const {
|
|
||||||
data = emptyCatalog,
|
useEffect(() => {
|
||||||
error,
|
loadCatalog("", false);
|
||||||
loading,
|
}, [loadCatalog]);
|
||||||
reload,
|
|
||||||
} = useAdminQuery(catalogQueryFn, {
|
|
||||||
errorMessage: "加载游戏列表失败",
|
|
||||||
initialData: emptyCatalog,
|
|
||||||
keepPreviousData: true,
|
|
||||||
queryKey: ["games", "catalog", platformCode, status, pageSize, page, pageCursors[page] || ""],
|
|
||||||
});
|
|
||||||
|
|
||||||
const platformOptions = useMemo(
|
const platformOptions = useMemo(
|
||||||
() => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]),
|
() => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]),
|
||||||
[platformData.items],
|
[platformData.items],
|
||||||
);
|
);
|
||||||
|
const resolveGameURL = useCallback(
|
||||||
|
(game) => resolveGameURLFromPlatforms(platformData.items || [], game),
|
||||||
|
[platformData.items],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") {
|
if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") {
|
||||||
@ -93,8 +140,8 @@ export function useGamesPage() {
|
|||||||
}, [activeAction, gameForm.platformCode, platformOptions]);
|
}, [activeAction, gameForm.platformCode, platformOptions]);
|
||||||
|
|
||||||
const resetCatalogPage = () => {
|
const resetCatalogPage = () => {
|
||||||
setPage(1);
|
setCatalogItems([]);
|
||||||
setPageCursors({ 1: "" });
|
setCatalogMeta(emptyCatalog);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePlatformCode = (value) => {
|
const changePlatformCode = (value) => {
|
||||||
@ -112,17 +159,15 @@ export function useGamesPage() {
|
|||||||
resetCatalogPage();
|
resetCatalogPage();
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePage = (nextPage) => {
|
const loadNextPage = () => {
|
||||||
if (nextPage < 1 || nextPage === page) {
|
if (!catalogMeta.nextCursor || catalogLoading || catalogLoadingMore) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if (nextPage === page + 1) {
|
return loadCatalog(catalogMeta.nextCursor, true);
|
||||||
if (!data.nextCursor) {
|
};
|
||||||
return;
|
|
||||||
}
|
const reloadCatalog = () => {
|
||||||
setPageCursors((current) => ({ ...current, [nextPage]: data.nextCursor }));
|
return loadCatalog("", false);
|
||||||
}
|
|
||||||
setPage(nextPage);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@ -140,7 +185,7 @@ export function useGamesPage() {
|
|||||||
|
|
||||||
const openEditGame = (game) => {
|
const openEditGame = (game) => {
|
||||||
setEditingGameId(game.gameId);
|
setEditingGameId(game.gameId);
|
||||||
setGameForm(gameToForm(game));
|
setGameForm(gameToForm(game, platformData.items || []));
|
||||||
setActiveAction("edit-game");
|
setActiveAction("edit-game");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -150,6 +195,10 @@ export function useGamesPage() {
|
|||||||
setActiveAction("create-platform");
|
setActiveAction("create-platform");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openManagePlatforms = () => {
|
||||||
|
setActiveAction("manage-platforms");
|
||||||
|
};
|
||||||
|
|
||||||
const openEditPlatform = (platform) => {
|
const openEditPlatform = (platform) => {
|
||||||
setEditingPlatformCode(platform.platformCode);
|
setEditingPlatformCode(platform.platformCode);
|
||||||
setPlatformForm(platformToForm(platform));
|
setPlatformForm(platformToForm(platform));
|
||||||
@ -157,6 +206,11 @@ export function useGamesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeAction = () => {
|
const closeAction = () => {
|
||||||
|
if (activeAction === "sync-games") {
|
||||||
|
setSyncPlatform(null);
|
||||||
|
setSyncCandidates([]);
|
||||||
|
setSelectedSyncGameIds([]);
|
||||||
|
}
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setEditingGameId("");
|
setEditingGameId("");
|
||||||
setEditingPlatformCode("");
|
setEditingPlatformCode("");
|
||||||
@ -168,7 +222,9 @@ export function useGamesPage() {
|
|||||||
setLoadingAction(activeAction);
|
setLoadingAction(activeAction);
|
||||||
try {
|
try {
|
||||||
await upsertGameCatalog(payload, editingGameId);
|
await upsertGameCatalog(payload, editingGameId);
|
||||||
await reload();
|
await saveGameUrl(platformData.items || [], payload, gameForm.gameUrl);
|
||||||
|
await reloadPlatforms();
|
||||||
|
await reloadCatalog();
|
||||||
showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
|
showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
|
||||||
closeAction();
|
closeAction();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -180,12 +236,18 @@ export function useGamesPage() {
|
|||||||
|
|
||||||
const submitPlatform = async (event) => {
|
const submitPlatform = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const payload = platformPayload(platformForm);
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = platformPayload(platformForm);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "适配器配置 JSON 不正确", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingAction(activeAction);
|
setLoadingAction(activeAction);
|
||||||
try {
|
try {
|
||||||
await upsertGamePlatform(payload, editingPlatformCode);
|
await upsertGamePlatform(payload, editingPlatformCode);
|
||||||
await reloadPlatforms();
|
await reloadPlatforms();
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success");
|
showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success");
|
||||||
closeAction();
|
closeAction();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -200,7 +262,7 @@ export function useGamesPage() {
|
|||||||
setLoadingAction(`status-${game.gameId}`);
|
setLoadingAction(`status-${game.gameId}`);
|
||||||
try {
|
try {
|
||||||
await setGameStatus(game.gameId, nextStatus);
|
await setGameStatus(game.gameId, nextStatus);
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
showToast("游戏状态已更新", "success");
|
showToast("游戏状态已更新", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "状态更新失败", "error");
|
showToast(err.message || "状态更新失败", "error");
|
||||||
@ -209,29 +271,146 @@ export function useGamesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteGame = async (game) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "删除",
|
||||||
|
message: `删除后 App 游戏列表不再展示「${game.gameName || game.gameId}」,历史订单和启动记录会保留用于审计。`,
|
||||||
|
title: "删除游戏",
|
||||||
|
tone: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction(`delete-${game.gameId}`);
|
||||||
|
try {
|
||||||
|
await deleteGameCatalog(game.gameId);
|
||||||
|
await reloadCatalog();
|
||||||
|
showToast("游戏已删除", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "删除游戏失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 reloadCatalog();
|
||||||
|
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 reloadCatalog();
|
||||||
|
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 {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
|
allSyncGamesSelected,
|
||||||
changeGameStatus,
|
changeGameStatus,
|
||||||
|
deleteGame,
|
||||||
closeAction,
|
closeAction,
|
||||||
data,
|
data: {
|
||||||
error: error || platformError,
|
...catalogMeta,
|
||||||
|
items: catalogItems,
|
||||||
|
loadedCount: catalogItems.length,
|
||||||
|
loadedPages: Math.max(1, Math.ceil(catalogItems.length / pageSize)),
|
||||||
|
},
|
||||||
|
error: catalogError || platformError,
|
||||||
gameForm,
|
gameForm,
|
||||||
loading: loading || platformsLoading,
|
loading: (catalogLoading && catalogItems.length === 0) || platformsLoading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
page,
|
loadingMore: catalogLoadingMore || (catalogLoading && catalogItems.length > 0),
|
||||||
|
page: Math.max(1, Math.ceil(catalogItems.length / pageSize)),
|
||||||
|
fetchPlatformGames,
|
||||||
|
importSelectedSyncGames,
|
||||||
|
importSingleSyncGame,
|
||||||
openCreateGame,
|
openCreateGame,
|
||||||
openCreatePlatform,
|
openCreatePlatform,
|
||||||
openEditGame,
|
openEditGame,
|
||||||
openEditPlatform,
|
openEditPlatform,
|
||||||
|
openManagePlatforms,
|
||||||
pageSize,
|
pageSize,
|
||||||
platformCode,
|
platformCode,
|
||||||
platformData,
|
platformData,
|
||||||
platformForm,
|
platformForm,
|
||||||
platformOptions,
|
platformOptions,
|
||||||
reload,
|
loadNextPage,
|
||||||
|
reload: reloadCatalog,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
changePage,
|
resolveGameURL,
|
||||||
changePageSize,
|
changePageSize,
|
||||||
setGameForm,
|
setGameForm,
|
||||||
setPlatformCode: changePlatformCode,
|
setPlatformCode: changePlatformCode,
|
||||||
@ -240,10 +419,71 @@ export function useGamesPage() {
|
|||||||
status,
|
status,
|
||||||
submitGame,
|
submitGame,
|
||||||
submitPlatform,
|
submitPlatform,
|
||||||
|
syncCandidates,
|
||||||
|
syncPlatform,
|
||||||
|
selectedSyncGameIds,
|
||||||
|
toggleAllSyncGames,
|
||||||
|
toggleSyncGame,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function gameToForm(game) {
|
function mergeCatalogItems(current, nextItems) {
|
||||||
|
const seen = new Set();
|
||||||
|
return [...current, ...nextItems].filter((item) => {
|
||||||
|
const key = item.gameId || `${item.platformCode}:${item.providerGameId}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGameUrl(platforms, game, gameUrl) {
|
||||||
|
const platform = platforms.find((item) => item.platformCode === game.platformCode);
|
||||||
|
if (!platform) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextURL = String(gameUrl || "").trim();
|
||||||
|
const currentURL = resolveGameURLFromPlatforms(platforms, game);
|
||||||
|
if (nextURL === currentURL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = parseJsonObject(platform.adapterConfigJson);
|
||||||
|
const gameURLs = config.game_urls && typeof config.game_urls === "object" && !Array.isArray(config.game_urls)
|
||||||
|
? { ...config.game_urls }
|
||||||
|
: {};
|
||||||
|
const keys = gameUrlKeys(game);
|
||||||
|
for (const key of keys) {
|
||||||
|
delete gameURLs[key];
|
||||||
|
}
|
||||||
|
if (nextURL) {
|
||||||
|
gameURLs[keys[0]] = nextURL;
|
||||||
|
}
|
||||||
|
if (Object.keys(gameURLs).length > 0) {
|
||||||
|
config.game_urls = gameURLs;
|
||||||
|
} else {
|
||||||
|
delete config.game_urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertGamePlatform(
|
||||||
|
{
|
||||||
|
platformCode: platform.platformCode,
|
||||||
|
platformName: platform.platformName,
|
||||||
|
status: platform.status,
|
||||||
|
apiBaseUrl: platform.apiBaseUrl,
|
||||||
|
adapterType: platform.adapterType,
|
||||||
|
callbackSecret: "",
|
||||||
|
callbackIpWhitelist: platform.callbackIpWhitelist || [],
|
||||||
|
adapterConfigJson: JSON.stringify(config, null, 2),
|
||||||
|
sortOrder: Number(platform.sortOrder || 0),
|
||||||
|
},
|
||||||
|
platform.platformCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameToForm(game, platforms = []) {
|
||||||
return {
|
return {
|
||||||
gameId: game.gameId || "",
|
gameId: game.gameId || "",
|
||||||
platformCode: game.platformCode || "",
|
platformCode: game.platformCode || "",
|
||||||
@ -252,7 +492,8 @@ function gameToForm(game) {
|
|||||||
category: game.category || "casual",
|
category: game.category || "casual",
|
||||||
iconUrl: game.iconUrl || "",
|
iconUrl: game.iconUrl || "",
|
||||||
coverUrl: game.coverUrl || "",
|
coverUrl: game.coverUrl || "",
|
||||||
launchMode: game.launchMode || "h5_popup",
|
gameUrl: resolveGameURLFromPlatforms(platforms, game),
|
||||||
|
launchMode: normalizeLaunchMode(game.launchMode),
|
||||||
orientation: game.orientation || "portrait",
|
orientation: game.orientation || "portrait",
|
||||||
minCoin: Number(game.minCoin || 0),
|
minCoin: Number(game.minCoin || 0),
|
||||||
status: game.status || "active",
|
status: game.status || "active",
|
||||||
@ -261,12 +502,50 @@ function gameToForm(game) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGameURLFromPlatforms(platforms, game) {
|
||||||
|
const platform = platforms.find((item) => item.platformCode === game.platformCode);
|
||||||
|
if (!platform) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const config = parseJsonObject(platform.adapterConfigJson);
|
||||||
|
const gameURLs = config.game_urls && typeof config.game_urls === "object" ? config.game_urls : {};
|
||||||
|
for (const key of gameUrlKeys(game)) {
|
||||||
|
const value = gameURLs[key];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameUrlKeys(game) {
|
||||||
|
const keys = [game.providerGameId, game.gameId, String(game.gameId || "").toLowerCase()]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return [...new Set(keys)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw) {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw || "{}");
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function platformToForm(platform) {
|
function platformToForm(platform) {
|
||||||
return {
|
return {
|
||||||
platformCode: platform.platformCode || "",
|
platformCode: platform.platformCode || "",
|
||||||
platformName: platform.platformName || "",
|
platformName: platform.platformName || "",
|
||||||
status: platform.status || "active",
|
status: platform.status || "active",
|
||||||
apiBaseUrl: platform.apiBaseUrl || "",
|
apiBaseUrl: platform.apiBaseUrl || "",
|
||||||
|
adapterType: platform.adapterType || "demo",
|
||||||
|
// 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。
|
||||||
|
callbackSecret: platform.callbackSecret || "",
|
||||||
|
callbackSecretSet: Boolean(platform.callbackSecretSet),
|
||||||
|
callbackIpWhitelistText: (platform.callbackIpWhitelist || []).join("\n"),
|
||||||
|
adapterConfigJson: platform.adapterConfigJson || "{}",
|
||||||
sortOrder: Number(platform.sortOrder || 0),
|
sortOrder: Number(platform.sortOrder || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -280,7 +559,7 @@ function gamePayload(form) {
|
|||||||
category: form.category.trim(),
|
category: form.category.trim(),
|
||||||
iconUrl: form.iconUrl.trim(),
|
iconUrl: form.iconUrl.trim(),
|
||||||
coverUrl: form.coverUrl.trim(),
|
coverUrl: form.coverUrl.trim(),
|
||||||
launchMode: form.launchMode.trim() || "h5_popup",
|
launchMode: normalizeLaunchMode(form.launchMode),
|
||||||
orientation: form.orientation.trim() || "portrait",
|
orientation: form.orientation.trim() || "portrait",
|
||||||
minCoin: Number(form.minCoin || 0),
|
minCoin: Number(form.minCoin || 0),
|
||||||
status: form.status.trim() || "active",
|
status: form.status.trim() || "active",
|
||||||
@ -292,12 +571,32 @@ function gamePayload(form) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLaunchMode(value) {
|
||||||
|
const mode = String(value || "").trim();
|
||||||
|
if (mode === "half_screen" || mode === "three_quarter_screen" || mode === "full_screen") {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return "full_screen";
|
||||||
|
}
|
||||||
|
|
||||||
function platformPayload(form) {
|
function platformPayload(form) {
|
||||||
|
const isLeaderCC = form.adapterType.trim() === "leadercc_v1";
|
||||||
return {
|
return {
|
||||||
platformCode: form.platformCode.trim(),
|
platformCode: form.platformCode.trim(),
|
||||||
platformName: form.platformName.trim(),
|
platformName: form.platformName.trim(),
|
||||||
status: form.status.trim() || "active",
|
status: form.status.trim() || "active",
|
||||||
apiBaseUrl: form.apiBaseUrl.trim(),
|
apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(),
|
||||||
|
adapterType: form.adapterType.trim() || "demo",
|
||||||
|
// 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。
|
||||||
|
callbackSecret: form.callbackSecret.trim(),
|
||||||
|
// 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。
|
||||||
|
callbackIpWhitelist: isLeaderCC
|
||||||
|
? []
|
||||||
|
: form.callbackIpWhitelistText
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
adapterConfigJson: normalizeJsonObjectString(form.adapterConfigJson),
|
||||||
sortOrder: Number(form.sortOrder || 0),
|
sortOrder: Number(form.sortOrder || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
||||||
|
import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
@ -31,7 +37,19 @@ const orientationOptions = [
|
|||||||
["portrait", "Portrait"],
|
["portrait", "Portrait"],
|
||||||
["landscape", "Landscape"],
|
["landscape", "Landscape"],
|
||||||
];
|
];
|
||||||
const launchModeOptions = [["h5_popup", "H5 弹窗"]];
|
const launchModeOptions = [
|
||||||
|
["full_screen", "全屏"],
|
||||||
|
["half_screen", "半屏"],
|
||||||
|
["three_quarter_screen", "3/4屏"],
|
||||||
|
];
|
||||||
|
// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。
|
||||||
|
const adapterTypeOptions = [
|
||||||
|
["demo", "Demo"],
|
||||||
|
["yomi_v4", "小游 Yomi V4"],
|
||||||
|
["leadercc_v1", "灵仙 LeaderCC V1"],
|
||||||
|
["zeeone_v1", "ZeeOne V1"],
|
||||||
|
["baishun_v1", "百顺 BAISHUN V1"],
|
||||||
|
];
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{
|
{
|
||||||
@ -57,7 +75,7 @@ const baseColumns = [
|
|||||||
width: "minmax(150px, 0.75fr)",
|
width: "minmax(150px, 0.75fr)",
|
||||||
render: (game) => (
|
render: (game) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{game.launchMode}</span>
|
<span>{launchModeLabel(game.launchMode)}</span>
|
||||||
<span className={styles.meta}>{game.orientation}</span>
|
<span className={styles.meta}>{game.orientation}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -82,7 +100,7 @@ const baseColumns = [
|
|||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "minmax(76px, 0.4fr)",
|
width: "minmax(112px, 0.5fr)",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -128,6 +146,11 @@ export function GameListPage() {
|
|||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
{page.abilities.canUpdate ? (
|
||||||
|
<AdminActionIconButton label="平台配置" onClick={page.openManagePlatforms}>
|
||||||
|
<SettingsOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
{page.abilities.canUpdate ? (
|
{page.abilities.canUpdate ? (
|
||||||
<AdminActionIconButton label="添加平台" onClick={page.openCreatePlatform}>
|
<AdminActionIconButton label="添加平台" onClick={page.openCreatePlatform}>
|
||||||
<SportsEsportsOutlined fontSize="small" />
|
<SportsEsportsOutlined fontSize="small" />
|
||||||
@ -145,16 +168,24 @@ export function GameListPage() {
|
|||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
infiniteScroll={{
|
||||||
|
doneLabel: "",
|
||||||
|
hasMore: Boolean(page.data.nextCursor),
|
||||||
|
loading: page.loadingMore,
|
||||||
|
loadingLabel: "加载中...",
|
||||||
|
onLoadMore: page.loadNextPage,
|
||||||
|
}}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1220px"
|
minWidth="1240px"
|
||||||
pagination={{
|
pagination={{
|
||||||
hasNextPage: Boolean(page.data.nextCursor),
|
hasNextPage: Boolean(page.data.nextCursor),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
onPageChange: page.changePage,
|
|
||||||
onPageSizeChange: page.changePageSize,
|
onPageSizeChange: page.changePageSize,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
pageSize: page.pageSize,
|
pageSize: page.pageSize,
|
||||||
pageSizeOptions: [20, 50, 100],
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
total: page.data.total,
|
||||||
|
totalPages: page.data.totalPages,
|
||||||
}}
|
}}
|
||||||
rowKey={(game) => game.gameId}
|
rowKey={(game) => game.gameId}
|
||||||
/>
|
/>
|
||||||
@ -178,6 +209,12 @@ export function GameListPage() {
|
|||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitPlatform}
|
onSubmit={page.submitPlatform}
|
||||||
/>
|
/>
|
||||||
|
<PlatformManageDialog
|
||||||
|
open={page.activeAction === "manage-platforms"}
|
||||||
|
page={page}
|
||||||
|
onClose={page.closeAction}
|
||||||
|
/>
|
||||||
|
<ProviderGameListDialog open={page.activeAction === "sync-games"} page={page} onClose={page.closeAction} />
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -193,21 +230,21 @@ function GameIdentity({ game }) {
|
|||||||
<div className={styles.identityText}>
|
<div className={styles.identityText}>
|
||||||
<span className={styles.name}>{game.gameName || game.gameId}</span>
|
<span className={styles.name}>{game.gameName || game.gameId}</span>
|
||||||
<span className={styles.meta}>{game.gameId}</span>
|
<span className={styles.meta}>{game.gameId}</span>
|
||||||
<span className={styles.tags}>{(game.tags || []).slice(0, 3).join(" / ")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GameStatusSwitch({ page, game }) {
|
function GameStatusSwitch({ page, game }) {
|
||||||
const disabled =
|
const disabled = !page.abilities.canStatus || page.loadingAction === `status-${game.gameId}`;
|
||||||
!page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`;
|
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={game.status === "active"}
|
checked={game.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label={`${game.gameName || game.gameId} 状态`}
|
label={`${game.gameName || game.gameId} 状态`}
|
||||||
size="small"
|
size="small"
|
||||||
|
uncheckedLabel={game.status === "disabled" ? "下架" : "维护"}
|
||||||
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
|
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -223,6 +260,13 @@ function GameRowActions({ page, game }) {
|
|||||||
>
|
>
|
||||||
<EditOutlined fontSize="small" />
|
<EditOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canDelete || page.loadingAction === `delete-${game.gameId}`}
|
||||||
|
label="删除游戏"
|
||||||
|
onClick={() => page.deleteGame(game)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -307,6 +351,15 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="游戏 URL"
|
||||||
|
className={styles.fullField}
|
||||||
|
minRows={2}
|
||||||
|
multiline
|
||||||
|
placeholder="填写当前游戏的 H5 测试/正式 URL"
|
||||||
|
value={form.gameUrl}
|
||||||
|
onChange={(event) => page.setGameForm({ ...form, gameUrl: event.target.value })}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="最低金币"
|
label="最低金币"
|
||||||
type="number"
|
type="number"
|
||||||
@ -319,15 +372,15 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
|
|||||||
value={form.sortOrder}
|
value={form.sortOrder}
|
||||||
onChange={(event) => page.setGameForm({ ...form, sortOrder: event.target.value })}
|
onChange={(event) => page.setGameForm({ ...form, sortOrder: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<SwitchOnlyField
|
<SwitchOnlyField
|
||||||
checked={form.status === "active"}
|
checked={form.status === "active"}
|
||||||
checkedLabel="启用"
|
checkedLabel="启用"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="游戏启用状态"
|
label="游戏启用状态"
|
||||||
uncheckedLabel="维护"
|
uncheckedLabel="维护"
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
page.setGameForm({
|
page.setGameForm({
|
||||||
...form,
|
...form,
|
||||||
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -361,6 +414,7 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||||
|
const isLeaderCC = form.adapterType === "leadercc_v1";
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
@ -386,15 +440,15 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
|
|||||||
value={form.platformName}
|
value={form.platformName}
|
||||||
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
|
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<SwitchOnlyField
|
<SwitchOnlyField
|
||||||
checked={form.status === "active"}
|
checked={form.status === "active"}
|
||||||
checkedLabel="启用"
|
checkedLabel="启用"
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
label="游戏平台启用状态"
|
label="游戏平台启用状态"
|
||||||
uncheckedLabel="维护"
|
uncheckedLabel="维护"
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
page.setPlatformForm({
|
page.setPlatformForm({
|
||||||
...form,
|
...form,
|
||||||
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -408,13 +462,168 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
|
|||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="接入配置">
|
<AdminFormSection title="接入配置">
|
||||||
<TextField
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
className={styles.fullField}
|
{/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */}
|
||||||
label="H5 Base URL"
|
<TextField
|
||||||
required
|
label="适配器"
|
||||||
value={form.apiBaseUrl}
|
select
|
||||||
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
|
value={form.adapterType}
|
||||||
/>
|
onChange={(event) => page.setPlatformForm({ ...form, adapterType: event.target.value })}
|
||||||
|
>
|
||||||
|
{adapterTypeOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{!isLeaderCC ? (
|
||||||
|
<TextField
|
||||||
|
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 })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{/* 后台直接回显当前厂商 key;清空保存时后端保持旧密钥,输入新值才轮换。 */}
|
||||||
|
<TextField
|
||||||
|
label="回调密钥"
|
||||||
|
value={form.callbackSecret}
|
||||||
|
onChange={(event) => page.setPlatformForm({ ...form, callbackSecret: event.target.value })}
|
||||||
|
/>
|
||||||
|
{!isLeaderCC ? (
|
||||||
|
<TextField
|
||||||
|
label="IP 白名单"
|
||||||
|
minRows={3}
|
||||||
|
multiline
|
||||||
|
value={form.callbackIpWhitelistText}
|
||||||
|
onChange={(event) =>
|
||||||
|
page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
|
||||||
|
<JsonEditorField
|
||||||
|
className={styles.fullField}
|
||||||
|
label="适配器配置 JSON"
|
||||||
|
minRows={5}
|
||||||
|
value={form.adapterConfigJson}
|
||||||
|
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
|
</AdminFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformManageDialog({ open, page, onClose }) {
|
||||||
|
const platforms = page.platformData.items || [];
|
||||||
|
return (
|
||||||
|
<AdminFormDialog
|
||||||
|
disabled={false}
|
||||||
|
loading={false}
|
||||||
|
open={open}
|
||||||
|
submitLabel="关闭"
|
||||||
|
title="平台配置"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.platformList}>
|
||||||
|
{platforms.map((platform) => (
|
||||||
|
<div className={styles.platformRow} key={platform.platformCode}>
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{platform.platformName || platform.platformCode}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{platform.platformCode} / {platform.adapterType || "demo"}
|
||||||
|
</span>
|
||||||
|
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
|
||||||
|
<span className={`${styles.meta} ${styles.secretMeta}`}>
|
||||||
|
密钥:{platform.callbackSecret || "未配置"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.platformActions}>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{platform.callbackSecretSet ? "密钥已配置" : "未配置密钥"}
|
||||||
|
</span>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="编辑平台"
|
||||||
|
onClick={() => page.openEditPlatform(platform)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AdminFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{game.launchUrl ? (
|
||||||
|
<span className={`${styles.meta} ${styles.syncGameURL}`}>{game.launchUrl}</span>
|
||||||
|
) : null}
|
||||||
|
</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>
|
</AdminFormSection>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
@ -438,3 +647,7 @@ function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, unc
|
|||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return Number(value || 0).toLocaleString();
|
return Number(value || 0).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function launchModeLabel(value) {
|
||||||
|
return launchModeOptions.find(([mode]) => mode === value)?.[1] || "全屏";
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export function useGameAbilities() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
canCreate: can(PERMISSIONS.gameCreate),
|
canCreate: can(PERMISSIONS.gameCreate),
|
||||||
|
canDelete: can(PERMISSIONS.gameDelete) || can(PERMISSIONS.gameUpdate),
|
||||||
canStatus: can(PERMISSIONS.gameStatus),
|
canStatus: can(PERMISSIONS.gameStatus),
|
||||||
canUpdate: can(PERMISSIONS.gameUpdate),
|
canUpdate: can(PERMISSIONS.gameUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export const hostOrgRoutes = [
|
|||||||
menuCode: MENU_CODES.hostOrgCountries,
|
menuCode: MENU_CODES.hostOrgCountries,
|
||||||
pageKey: "host-org-countries",
|
pageKey: "host-org-countries",
|
||||||
path: "/host/countries",
|
path: "/host/countries",
|
||||||
permission: PERMISSIONS.countryView
|
permission: PERMISSIONS.countryView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "区域管理",
|
label: "区域管理",
|
||||||
@ -15,15 +15,15 @@ export const hostOrgRoutes = [
|
|||||||
menuCode: MENU_CODES.hostOrgRegions,
|
menuCode: MENU_CODES.hostOrgRegions,
|
||||||
pageKey: "host-org-regions",
|
pageKey: "host-org-regions",
|
||||||
path: "/host/regions",
|
path: "/host/regions",
|
||||||
permission: PERMISSIONS.regionView
|
permission: PERMISSIONS.regionView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "经理列表",
|
label: "Manager列表",
|
||||||
loader: () => import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
|
loader: () => import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
|
||||||
menuCode: MENU_CODES.hostOrgManagers,
|
menuCode: MENU_CODES.hostOrgManagers,
|
||||||
pageKey: "host-org-managers",
|
pageKey: "host-org-managers",
|
||||||
path: "/host/managers",
|
path: "/host/managers",
|
||||||
permission: PERMISSIONS.agencyView
|
permission: PERMISSIONS.agencyView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Agency 列表",
|
label: "Agency 列表",
|
||||||
@ -31,7 +31,7 @@ export const hostOrgRoutes = [
|
|||||||
menuCode: MENU_CODES.hostOrgAgencies,
|
menuCode: MENU_CODES.hostOrgAgencies,
|
||||||
pageKey: "host-org-agencies",
|
pageKey: "host-org-agencies",
|
||||||
path: "/host/agencies",
|
path: "/host/agencies",
|
||||||
permission: PERMISSIONS.agencyView
|
permission: PERMISSIONS.agencyView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "BD Leader 列表",
|
label: "BD Leader 列表",
|
||||||
@ -39,7 +39,7 @@ export const hostOrgRoutes = [
|
|||||||
menuCode: MENU_CODES.hostOrgBdLeaders,
|
menuCode: MENU_CODES.hostOrgBdLeaders,
|
||||||
pageKey: "host-org-bd-leaders",
|
pageKey: "host-org-bd-leaders",
|
||||||
path: "/host/bd-leaders",
|
path: "/host/bd-leaders",
|
||||||
permission: PERMISSIONS.bdView
|
permission: PERMISSIONS.bdView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "BD 列表",
|
label: "BD 列表",
|
||||||
@ -47,22 +47,22 @@ export const hostOrgRoutes = [
|
|||||||
menuCode: MENU_CODES.hostOrgBds,
|
menuCode: MENU_CODES.hostOrgBds,
|
||||||
pageKey: "host-org-bds",
|
pageKey: "host-org-bds",
|
||||||
path: "/host/bds",
|
path: "/host/bds",
|
||||||
permission: PERMISSIONS.bdView
|
permission: PERMISSIONS.bdView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "主播列表",
|
label: "Host 列表",
|
||||||
loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage),
|
loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage),
|
||||||
menuCode: MENU_CODES.hostOrgHosts,
|
menuCode: MENU_CODES.hostOrgHosts,
|
||||||
pageKey: "host-org-hosts",
|
pageKey: "host-org-hosts",
|
||||||
path: "/host/hosts",
|
path: "/host/hosts",
|
||||||
permission: PERMISSIONS.hostView
|
permission: PERMISSIONS.hostView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "币商列表",
|
label: "Coin Saller列表",
|
||||||
loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage),
|
loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage),
|
||||||
menuCode: MENU_CODES.hostOrgCoinSellers,
|
menuCode: MENU_CODES.hostOrgCoinSellers,
|
||||||
pageKey: "host-org-coin-sellers",
|
pageKey: "host-org-coin-sellers",
|
||||||
path: "/host/coin-sellers",
|
path: "/host/coin-sellers",
|
||||||
permission: PERMISSIONS.coinSellerView
|
permission: PERMISSIONS.coinSellerView,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
|
||||||
|
|
||||||
export interface LuckyGiftTierDto {
|
export interface LuckyGiftTierDto {
|
||||||
pool: string;
|
pool: string;
|
||||||
tierId: string;
|
tierId: string;
|
||||||
rewardCoins: number;
|
rewardCoins: number;
|
||||||
|
multiplierPPM: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
highWaterOnly: boolean;
|
highWaterOnly: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -13,6 +13,7 @@ export interface LuckyGiftTierDto {
|
|||||||
|
|
||||||
export interface LuckyGiftConfigDto {
|
export interface LuckyGiftConfigDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
|
poolId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ruleVersion: number;
|
ruleVersion: number;
|
||||||
giftPrice: number;
|
giftPrice: number;
|
||||||
@ -45,6 +46,7 @@ export interface LuckyGiftConfigDto {
|
|||||||
activityBudget: number;
|
activityBudget: number;
|
||||||
activityDailyLimit: number;
|
activityDailyLimit: number;
|
||||||
largeTierEnabled: boolean;
|
largeTierEnabled: boolean;
|
||||||
|
multiplierPPMs: number[];
|
||||||
tiers: LuckyGiftTierDto[];
|
tiers: LuckyGiftTierDto[];
|
||||||
updatedByAdminId?: number;
|
updatedByAdminId?: number;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
@ -52,6 +54,7 @@ export interface LuckyGiftConfigDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LuckyGiftConfigPayload {
|
export interface LuckyGiftConfigPayload {
|
||||||
|
pool_id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
gift_price: number;
|
gift_price: number;
|
||||||
target_rtp_ppm: number;
|
target_rtp_ppm: number;
|
||||||
@ -83,74 +86,75 @@ export interface LuckyGiftConfigPayload {
|
|||||||
activity_budget: number;
|
activity_budget: number;
|
||||||
activity_daily_limit: number;
|
activity_daily_limit: number;
|
||||||
large_tier_enabled: boolean;
|
large_tier_enabled: boolean;
|
||||||
|
multiplier_ppms: number[];
|
||||||
tiers: Array<{
|
tiers: Array<{
|
||||||
pool: string;
|
pool: string;
|
||||||
tier_id: string;
|
tier_id: string;
|
||||||
reward_coins: number;
|
reward_coins: number;
|
||||||
|
multiplier_ppm: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
high_water_only: boolean;
|
high_water_only: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LuckyGiftDrawDto {
|
export interface LuckyGiftDrawSummaryDto {
|
||||||
drawId: string;
|
poolId: string;
|
||||||
commandId?: string;
|
totalDraws: number;
|
||||||
giftId: string;
|
uniqueUsers: number;
|
||||||
ruleVersion: number;
|
uniqueRooms: number;
|
||||||
experiencePool: string;
|
totalSpentCoins: number;
|
||||||
selectedTierId: string;
|
totalRewardCoins: number;
|
||||||
baseRewardCoins: number;
|
baseRewardCoins: number;
|
||||||
roomAtmosphereRewardCoins: number;
|
roomAtmosphereRewardCoins: number;
|
||||||
activitySubsidyCoins: number;
|
activitySubsidyCoins: number;
|
||||||
effectiveRewardCoins: number;
|
actualRTPPPM: number;
|
||||||
budgetSourcesJson?: string;
|
pendingDraws: number;
|
||||||
rewardStatus?: string;
|
grantedDraws: number;
|
||||||
rtpWindowIndex: number;
|
failedDraws: number;
|
||||||
giftRTPWindowIndex: number;
|
|
||||||
globalBaseRTPPPM: number;
|
|
||||||
giftBaseRTPPPM: number;
|
|
||||||
stageFeedback: boolean;
|
|
||||||
highMultiplier: boolean;
|
|
||||||
createdAtMs?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawConfig = LuckyGiftConfigDto & Record<string, unknown>;
|
type RawConfig = LuckyGiftConfigDto & Record<string, unknown>;
|
||||||
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
|
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
|
||||||
type RawDraw = LuckyGiftDrawDto & Record<string, unknown>;
|
type RawSummary = LuckyGiftDrawSummaryDto & Record<string, unknown>;
|
||||||
|
type LuckyGiftDrawSummaryQuery = Record<string, number | string | undefined>;
|
||||||
|
|
||||||
export function getLuckyGiftConfig(): Promise<LuckyGiftConfigDto> {
|
export function getLuckyGiftConfig(poolId: string): Promise<LuckyGiftConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
|
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
|
||||||
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), {
|
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
|
query: { pool_id: poolId },
|
||||||
}).then(normalizeConfig);
|
}).then(normalizeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listLuckyGiftConfigs(): Promise<LuckyGiftConfigDto[]> {
|
||||||
|
const endpoint = API_ENDPOINTS.listLuckyGiftConfigs;
|
||||||
|
return apiRequest<RawConfig[]>(apiEndpointPath(API_OPERATIONS.listLuckyGiftConfigs), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then((items) => (items || []).map(normalizeConfig));
|
||||||
|
}
|
||||||
|
|
||||||
export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise<LuckyGiftConfigDto> {
|
export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise<LuckyGiftConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig;
|
const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig;
|
||||||
return apiRequest<RawConfig, LuckyGiftConfigPayload>(
|
return apiRequest<RawConfig, LuckyGiftConfigPayload>(apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig), {
|
||||||
apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig),
|
body: payload,
|
||||||
{
|
method: endpoint.method,
|
||||||
body: payload,
|
query: { pool_id: payload.pool_id },
|
||||||
method: endpoint.method,
|
}).then(normalizeConfig);
|
||||||
},
|
|
||||||
).then(normalizeConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listLuckyGiftDraws(query: PageQuery = {}): Promise<ApiPage<LuckyGiftDrawDto>> {
|
export function getLuckyGiftDrawSummary(query: LuckyGiftDrawSummaryQuery = {}): Promise<LuckyGiftDrawSummaryDto> {
|
||||||
const endpoint = API_ENDPOINTS.listLuckyGiftDraws;
|
const endpoint = API_ENDPOINTS.getLuckyGiftDrawSummary;
|
||||||
return apiRequest<ApiPage<RawDraw>>(apiEndpointPath(API_OPERATIONS.listLuckyGiftDraws), {
|
return apiRequest<RawSummary>(apiEndpointPath(API_OPERATIONS.getLuckyGiftDrawSummary), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
query,
|
||||||
}).then((page) => ({
|
}).then(normalizeSummary);
|
||||||
...page,
|
|
||||||
items: (page.items || []).map(normalizeDraw),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
|
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
poolId: stringValue(item.poolId ?? item.pool_id ?? item.gift_id),
|
||||||
enabled: booleanValue(item.enabled),
|
enabled: booleanValue(item.enabled),
|
||||||
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
|
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
|
||||||
giftPrice: numberValue(item.giftPrice ?? item.gift_price),
|
giftPrice: numberValue(item.giftPrice ?? item.gift_price),
|
||||||
@ -183,6 +187,7 @@ function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
|
|||||||
activityBudget: numberValue(item.activityBudget ?? item.activity_budget),
|
activityBudget: numberValue(item.activityBudget ?? item.activity_budget),
|
||||||
activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit),
|
activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit),
|
||||||
largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled),
|
largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled),
|
||||||
|
multiplierPPMs: arrayValue(item.multiplierPPMs ?? item.multiplier_ppms).map(numberValue),
|
||||||
tiers: arrayValue(item.tiers).map(normalizeTier),
|
tiers: arrayValue(item.tiers).map(normalizeTier),
|
||||||
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
@ -196,35 +201,28 @@ function normalizeTier(raw: unknown): LuckyGiftTierDto {
|
|||||||
pool: stringValue(item.pool),
|
pool: stringValue(item.pool),
|
||||||
tierId: stringValue(item.tierId ?? item.tier_id),
|
tierId: stringValue(item.tierId ?? item.tier_id),
|
||||||
rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins),
|
rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins),
|
||||||
|
multiplierPPM: numberValue(item.multiplierPPM ?? item.multiplier_ppm),
|
||||||
weight: numberValue(item.weight),
|
weight: numberValue(item.weight),
|
||||||
highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only),
|
highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only),
|
||||||
enabled: booleanValue(item.enabled),
|
enabled: booleanValue(item.enabled),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraw(item: RawDraw): LuckyGiftDrawDto {
|
function normalizeSummary(item: RawSummary): LuckyGiftDrawSummaryDto {
|
||||||
return {
|
return {
|
||||||
drawId: stringValue(item.drawId ?? item.draw_id),
|
poolId: stringValue(item.poolId ?? item.pool_id),
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
totalDraws: numberValue(item.totalDraws ?? item.total_draws),
|
||||||
giftId: stringValue(item.giftId ?? item.gift_id),
|
uniqueUsers: numberValue(item.uniqueUsers ?? item.unique_users),
|
||||||
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
|
uniqueRooms: numberValue(item.uniqueRooms ?? item.unique_rooms),
|
||||||
experiencePool: stringValue(item.experiencePool ?? item.experience_pool),
|
totalSpentCoins: numberValue(item.totalSpentCoins ?? item.total_spent_coins),
|
||||||
selectedTierId: stringValue(item.selectedTierId ?? item.selected_tier_id),
|
totalRewardCoins: numberValue(item.totalRewardCoins ?? item.total_reward_coins),
|
||||||
baseRewardCoins: numberValue(item.baseRewardCoins ?? item.base_reward_coins),
|
baseRewardCoins: numberValue(item.baseRewardCoins ?? item.base_reward_coins),
|
||||||
roomAtmosphereRewardCoins: numberValue(
|
roomAtmosphereRewardCoins: numberValue(item.roomAtmosphereRewardCoins ?? item.room_atmosphere_reward_coins),
|
||||||
item.roomAtmosphereRewardCoins ?? item.room_atmosphere_reward_coins,
|
|
||||||
),
|
|
||||||
activitySubsidyCoins: numberValue(item.activitySubsidyCoins ?? item.activity_subsidy_coins),
|
activitySubsidyCoins: numberValue(item.activitySubsidyCoins ?? item.activity_subsidy_coins),
|
||||||
effectiveRewardCoins: numberValue(item.effectiveRewardCoins ?? item.effective_reward_coins),
|
actualRTPPPM: numberValue(item.actualRTPPPM ?? item.actual_rtp_ppm),
|
||||||
budgetSourcesJson: stringValue(item.budgetSourcesJson ?? item.budget_sources_json),
|
pendingDraws: numberValue(item.pendingDraws ?? item.pending_draws),
|
||||||
rewardStatus: stringValue(item.rewardStatus ?? item.reward_status),
|
grantedDraws: numberValue(item.grantedDraws ?? item.granted_draws),
|
||||||
rtpWindowIndex: numberValue(item.rtpWindowIndex ?? item.rtp_window_index),
|
failedDraws: numberValue(item.failedDraws ?? item.failed_draws),
|
||||||
giftRTPWindowIndex: numberValue(item.giftRTPWindowIndex ?? item.gift_rtp_window_index),
|
|
||||||
globalBaseRTPPPM: numberValue(item.globalBaseRTPPPM ?? item.global_base_rtp_ppm),
|
|
||||||
giftBaseRTPPPM: numberValue(item.giftBaseRTPPPM ?? item.gift_base_rtp_ppm),
|
|
||||||
stageFeedback: booleanValue(item.stageFeedback ?? item.stage_feedback),
|
|
||||||
highMultiplier: booleanValue(item.highMultiplier ?? item.high_multiplier),
|
|
||||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
@ -10,7 +13,6 @@ import {
|
|||||||
controlStrengthOptions,
|
controlStrengthOptions,
|
||||||
jackpotStrategyOptions,
|
jackpotStrategyOptions,
|
||||||
noviceStrengthOptions,
|
noviceStrengthOptions,
|
||||||
payoutStyleOptions,
|
|
||||||
poolModeOptions,
|
poolModeOptions,
|
||||||
riskLevelOptions,
|
riskLevelOptions,
|
||||||
} from "@/features/lucky-gift/constants.js";
|
} from "@/features/lucky-gift/constants.js";
|
||||||
@ -18,11 +20,12 @@ import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
|||||||
|
|
||||||
const helpText = {
|
const helpText = {
|
||||||
enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。",
|
enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。",
|
||||||
|
poolId: "奖池 ID 是请求接口传入的隔离键,不同奖池的 RTP、预算、风控和用户阶段互不影响。",
|
||||||
targetRtpPercent: "基础返奖成本比例,95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。",
|
targetRtpPercent: "基础返奖成本比例,95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。",
|
||||||
controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。",
|
controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。",
|
||||||
poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。",
|
poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。",
|
||||||
noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。",
|
noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。",
|
||||||
payoutStyle: "控制小奖和大奖的权重分布,稳定偏小奖,刺激偏高倍爆点。",
|
multiplierValues: "配置用户可能命中的倍率,例如 0、0.5、1、2、10、100;每个倍率的概率由后端按 RTP 窗口自动生成。",
|
||||||
jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。",
|
jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。",
|
||||||
riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。",
|
riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。",
|
||||||
atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。",
|
atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。",
|
||||||
@ -50,6 +53,12 @@ export function LuckyGiftConfigDrawer({
|
|||||||
<section className="form-drawer__section">
|
<section className="form-drawer__section">
|
||||||
<div className="form-drawer__section-title">基础控制</div>
|
<div className="form-drawer__section-title">基础控制</div>
|
||||||
<div className={styles.configGrid}>
|
<div className={styles.configGrid}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled
|
||||||
|
label={<FieldLabel help={helpText.poolId} text="奖池 ID" />}
|
||||||
|
value={form.poolId}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -97,13 +106,11 @@ export function LuckyGiftConfigDrawer({
|
|||||||
value={form.noviceStrength}
|
value={form.noviceStrength}
|
||||||
onChange={(value) => updateForm(setForm, "noviceStrength", value)}
|
onChange={(value) => updateForm(setForm, "noviceStrength", value)}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<MultiplierField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
help={helpText.payoutStyle}
|
help={helpText.multiplierValues}
|
||||||
label="中奖体感"
|
setForm={setForm}
|
||||||
options={payoutStyleOptions}
|
values={form.multiplierValues || []}
|
||||||
value={form.payoutStyle}
|
|
||||||
onChange={(value) => updateForm(setForm, "payoutStyle", value)}
|
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -181,6 +188,47 @@ export function LuckyGiftConfigDrawer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MultiplierField({ disabled, help, setForm, values }) {
|
||||||
|
const list = values.length > 0 ? values : ["0"];
|
||||||
|
return (
|
||||||
|
<div className={[styles.multiplierBlock, styles.configWide].join(" ")}>
|
||||||
|
<div className={styles.multiplierHeader}>
|
||||||
|
<FieldLabel help={help} text="倍率配置" />
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addMultiplier(setForm)}
|
||||||
|
>
|
||||||
|
添加倍率
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.multiplierList}>
|
||||||
|
{list.map((value, index) => (
|
||||||
|
<div key={index} className={styles.multiplierRow}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 0.01 }}
|
||||||
|
label={`倍率 ${index + 1}`}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => updateMultiplier(setForm, index, event.target.value)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除倍率"
|
||||||
|
disabled={disabled || list.length <= 1}
|
||||||
|
size="small"
|
||||||
|
onClick={() => removeMultiplier(setForm, index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SelectField({ disabled, help, label, onChange, options, value }) {
|
function SelectField({ disabled, help, label, onChange, options, value }) {
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
@ -218,3 +266,25 @@ function SwitchField({ checked, className, disabled, help, label, onChange }) {
|
|||||||
function updateForm(setForm, key, value) {
|
function updateForm(setForm, key, value) {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateMultiplier(setForm, index, value) {
|
||||||
|
setForm((current) => {
|
||||||
|
const nextValues = [...(current.multiplierValues || [])];
|
||||||
|
nextValues[index] = value;
|
||||||
|
return { ...current, multiplierValues: nextValues };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMultiplier(setForm) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
multiplierValues: [...(current.multiplierValues || []), ""],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMultiplier(setForm, index) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
multiplierValues: (current.multiplierValues || []).filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
@ -7,16 +10,24 @@ import {
|
|||||||
atmosphereLevelOptions,
|
atmosphereLevelOptions,
|
||||||
controlStrengthOptions,
|
controlStrengthOptions,
|
||||||
jackpotStrategyOptions,
|
jackpotStrategyOptions,
|
||||||
luckyGiftConfigScopeLabel,
|
|
||||||
noviceStrengthOptions,
|
noviceStrengthOptions,
|
||||||
optionLabel,
|
optionLabel,
|
||||||
payoutStyleOptions,
|
|
||||||
poolModeOptions,
|
poolModeOptions,
|
||||||
} from "@/features/lucky-gift/constants.js";
|
} from "@/features/lucky-gift/constants.js";
|
||||||
import { formFromConfig } from "@/features/lucky-gift/configModel.js";
|
import { formFromConfig } from "@/features/lucky-gift/configModel.js";
|
||||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||||
|
|
||||||
export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
|
export function LuckyGiftConfigSummary({
|
||||||
|
canUpdate,
|
||||||
|
config,
|
||||||
|
configLoading,
|
||||||
|
onAddPool,
|
||||||
|
onEdit,
|
||||||
|
onPoolChange,
|
||||||
|
onRefresh,
|
||||||
|
poolId,
|
||||||
|
poolOptions,
|
||||||
|
}) {
|
||||||
const form = formFromConfig(config);
|
const form = formFromConfig(config);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -25,7 +36,7 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
|
|||||||
<div className={styles.summaryContent}>
|
<div className={styles.summaryContent}>
|
||||||
<div className={styles.summaryHeader}>
|
<div className={styles.summaryHeader}>
|
||||||
<span className={styles.title}>幸运礼物配置</span>
|
<span className={styles.title}>幸运礼物配置</span>
|
||||||
<span className={styles.meta}>{luckyGiftConfigScopeLabel}</span>
|
<span className={styles.meta}>奖池 {poolId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryItems}>
|
<div className={styles.summaryItems}>
|
||||||
<ConfigStatus config={config} loading={configLoading} />
|
<ConfigStatus config={config} loading={configLoading} />
|
||||||
@ -37,7 +48,7 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
|
|||||||
<SummaryItem label="新手保护">
|
<SummaryItem label="新手保护">
|
||||||
{optionLabel(noviceStrengthOptions, form.noviceStrength)}
|
{optionLabel(noviceStrengthOptions, form.noviceStrength)}
|
||||||
</SummaryItem>
|
</SummaryItem>
|
||||||
<SummaryItem label="中奖体感">{optionLabel(payoutStyleOptions, form.payoutStyle)}</SummaryItem>
|
<SummaryItem label="倍率">{formatMultipliers(form.multiplierValues)}</SummaryItem>
|
||||||
<SummaryItem label="大奖策略">
|
<SummaryItem label="大奖策略">
|
||||||
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
|
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
|
||||||
</SummaryItem>
|
</SummaryItem>
|
||||||
@ -51,6 +62,23 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryActions}>
|
<div className={styles.summaryActions}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
className={styles.poolSelector}
|
||||||
|
disabled={configLoading}
|
||||||
|
label="奖池 ID"
|
||||||
|
value={poolId}
|
||||||
|
onChange={(event) => onPoolChange(event.target.value)}
|
||||||
|
>
|
||||||
|
{(poolOptions || [poolId]).map((option) => (
|
||||||
|
<MenuItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<AdminActionIconButton disabled={configLoading || !canUpdate} label="添加奖池" type="button" onClick={onAddPool}>
|
||||||
|
<AddOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
||||||
<RefreshOutlined fontSize="small" />
|
<RefreshOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
@ -68,6 +96,13 @@ export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMultipliers(values) {
|
||||||
|
if (!Array.isArray(values) || values.length === 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return values.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
function SummaryItem({ children, label }) {
|
function SummaryItem({ children, label }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.summaryItem}>
|
<div className={styles.summaryItem}>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||||
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
||||||
|
import ExpandLessOutlined from "@mui/icons-material/ExpandLessOutlined";
|
||||||
|
import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
@ -26,6 +28,7 @@ const defaultInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LuckyGiftSimulationPanel({ config }) {
|
export function LuckyGiftSimulationPanel({ config }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [input, setInput] = useState(defaultInput);
|
const [input, setInput] = useState(defaultInput);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [csvUrl, setCsvUrl] = useState("");
|
const [csvUrl, setCsvUrl] = useState("");
|
||||||
@ -59,61 +62,89 @@ export function LuckyGiftSimulationPanel({ config }) {
|
|||||||
<span className={styles.title}>随机模拟测试</span>
|
<span className={styles.title}>随机模拟测试</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.simulationActions}>
|
<div className={styles.simulationActions}>
|
||||||
<Button startIcon={<PlayArrowOutlined fontSize="small" />} type="button" variant="primary" onClick={runSimulation}>
|
{expanded ? (
|
||||||
测试
|
<>
|
||||||
</Button>
|
<Button
|
||||||
{csvUrl ? (
|
startIcon={<PlayArrowOutlined fontSize="small" />}
|
||||||
<Button component="a" download={csvFileName} href={csvUrl} startIcon={<DownloadOutlined fontSize="small" />}>
|
type="button"
|
||||||
CSV
|
variant="primary"
|
||||||
</Button>
|
onClick={runSimulation}
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
{csvUrl ? (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
download={csvFileName}
|
||||||
|
href={csvUrl}
|
||||||
|
startIcon={<DownloadOutlined fontSize="small" />}
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
aria-expanded={expanded}
|
||||||
|
endIcon={
|
||||||
|
expanded ? <ExpandLessOutlined fontSize="small" /> : <ExpandMoreOutlined fontSize="small" />
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
{expanded ? "收起" : "展开"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.simulationGrid}>
|
{expanded ? (
|
||||||
<NumberField
|
<>
|
||||||
help={simulationHelp.users}
|
<div className={styles.simulationGrid}>
|
||||||
label="用户数量"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.users}
|
||||||
value={input.users}
|
label="用户数量"
|
||||||
onChange={(value) => updateInput(setInput, "users", value)}
|
min={1}
|
||||||
/>
|
value={input.users}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "users", value)}
|
||||||
help={simulationHelp.rooms}
|
/>
|
||||||
label="房间数量"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.rooms}
|
||||||
value={input.rooms}
|
label="房间数量"
|
||||||
onChange={(value) => updateInput(setInput, "rooms", value)}
|
min={1}
|
||||||
/>
|
value={input.rooms}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "rooms", value)}
|
||||||
help={simulationHelp.costMin}
|
/>
|
||||||
label="单抽花费最小值"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.costMin}
|
||||||
value={input.costMin}
|
label="单抽花费最小值"
|
||||||
onChange={(value) => updateInput(setInput, "costMin", value)}
|
min={1}
|
||||||
/>
|
value={input.costMin}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "costMin", value)}
|
||||||
help={simulationHelp.costMax}
|
/>
|
||||||
label="单抽花费最大值"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.costMax}
|
||||||
value={input.costMax}
|
label="单抽花费最大值"
|
||||||
onChange={(value) => updateInput(setInput, "costMax", value)}
|
min={1}
|
||||||
/>
|
value={input.costMax}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "costMax", value)}
|
||||||
help={simulationHelp.drawsMin}
|
/>
|
||||||
label="抽奖次数最小值"
|
<NumberField
|
||||||
min={0}
|
help={simulationHelp.drawsMin}
|
||||||
value={input.drawsMin}
|
label="抽奖次数最小值"
|
||||||
onChange={(value) => updateInput(setInput, "drawsMin", value)}
|
min={0}
|
||||||
/>
|
value={input.drawsMin}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "drawsMin", value)}
|
||||||
help={simulationHelp.drawsMax}
|
/>
|
||||||
label="抽奖次数最大值"
|
<NumberField
|
||||||
min={0}
|
help={simulationHelp.drawsMax}
|
||||||
value={input.drawsMax}
|
label="抽奖次数最大值"
|
||||||
onChange={(value) => updateInput(setInput, "drawsMax", value)}
|
min={0}
|
||||||
/>
|
value={input.drawsMax}
|
||||||
</div>
|
onChange={(value) => updateInput(setInput, "drawsMax", value)}
|
||||||
{result ? <SimulationOverview overview={result.overview} /> : null}
|
/>
|
||||||
|
</div>
|
||||||
|
{result ? <SimulationOverview overview={result.overview} /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,8 +21,9 @@ export function emptyLuckyGiftForm() {
|
|||||||
controlStrength: "standard",
|
controlStrength: "standard",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
jackpotStrategy: "standard",
|
jackpotStrategy: "standard",
|
||||||
|
multiplierValues: ["0", "0.2", "0.5", "1", "2", "5", "10", "20", "50", "100", "500"],
|
||||||
noviceStrength: "standard",
|
noviceStrength: "standard",
|
||||||
payoutStyle: "balanced",
|
poolId: "default",
|
||||||
poolMode: "standard",
|
poolMode: "standard",
|
||||||
riskLevel: "standard",
|
riskLevel: "standard",
|
||||||
targetRtpPercent: "95",
|
targetRtpPercent: "95",
|
||||||
@ -43,8 +44,9 @@ export function formFromConfig(config) {
|
|||||||
controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws),
|
controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws),
|
||||||
enabled: Boolean(config.enabled),
|
enabled: Boolean(config.enabled),
|
||||||
jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple),
|
jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple),
|
||||||
|
multiplierValues: formatMultiplierValues(config.multiplierPPMs, config.tiers || []),
|
||||||
noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit),
|
noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit),
|
||||||
payoutStyle: inferPayoutStyle(config.tiers || []),
|
poolId: config.poolId || "default",
|
||||||
poolMode: inferPoolMode(config),
|
poolMode: inferPoolMode(config),
|
||||||
riskLevel: inferRiskLevel(config),
|
riskLevel: inferRiskLevel(config),
|
||||||
targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM),
|
targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM),
|
||||||
@ -61,6 +63,9 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
|
|||||||
const atmosphere = atmosphereLevelConfig[form.atmosphereLevel] || atmosphereLevelConfig.medium;
|
const atmosphere = atmosphereLevelConfig[form.atmosphereLevel] || atmosphereLevelConfig.medium;
|
||||||
const risk = riskCapsForLevel(form.riskLevel, giftPrice, targetRTPPPM);
|
const risk = riskCapsForLevel(form.riskLevel, giftPrice, targetRTPPPM);
|
||||||
const base = currentConfig || {};
|
const base = currentConfig || {};
|
||||||
|
const multiplierPPMs = parseMultiplierValues(form.multiplierValues);
|
||||||
|
const maxMultiplierPPM = Math.max(...multiplierPPMs);
|
||||||
|
const highMultiplier = positiveNumber(base.highMultiplier, 100);
|
||||||
const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0;
|
const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0;
|
||||||
const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0;
|
const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0;
|
||||||
|
|
||||||
@ -75,7 +80,7 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
|
|||||||
gift_reserve: positiveNumber(base.giftReserve, 1_000_000),
|
gift_reserve: positiveNumber(base.giftReserve, 1_000_000),
|
||||||
global_window_draws: control.globalWindowDraws,
|
global_window_draws: control.globalWindowDraws,
|
||||||
gift_window_draws: control.giftWindowDraws,
|
gift_window_draws: control.giftWindowDraws,
|
||||||
high_multiplier: positiveNumber(base.highMultiplier, 100),
|
high_multiplier: highMultiplier,
|
||||||
high_water_pool_multiple: jackpot.highWaterPoolMultiple,
|
high_water_pool_multiple: jackpot.highWaterPoolMultiple,
|
||||||
initial_gift_pool: positiveNumber(base.initialGiftPool, expectedPayout(giftPrice, targetRTPPPM, control.giftWindowDraws) / 2),
|
initial_gift_pool: positiveNumber(base.initialGiftPool, expectedPayout(giftPrice, targetRTPPPM, control.giftWindowDraws) / 2),
|
||||||
initial_platform_pool: positiveNumber(
|
initial_platform_pool: positiveNumber(
|
||||||
@ -85,10 +90,12 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
|
|||||||
initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000),
|
initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000),
|
||||||
intermediate_draw_limit: novice.intermediateDrawLimit,
|
intermediate_draw_limit: novice.intermediateDrawLimit,
|
||||||
large_tier_enabled: jackpot.largeTierEnabled,
|
large_tier_enabled: jackpot.largeTierEnabled,
|
||||||
max_single_payout: risk.maxSinglePayout,
|
max_single_payout: Math.max(risk.maxSinglePayout, Math.ceil((giftPrice * maxMultiplierPPM) / ppmScale)),
|
||||||
|
multiplier_ppms: multiplierPPMs,
|
||||||
novice_draw_limit: novice.noviceDrawLimit,
|
novice_draw_limit: novice.noviceDrawLimit,
|
||||||
platform_pool_weight_ppm: poolMode.platformPoolWeightPPM,
|
platform_pool_weight_ppm: poolMode.platformPoolWeightPPM,
|
||||||
platform_reserve: positiveNumber(base.platformReserve, 1_000_000),
|
platform_reserve: positiveNumber(base.platformReserve, 1_000_000),
|
||||||
|
pool_id: String(form.poolId || base.poolId || "default").trim() || "default",
|
||||||
pool_rate_ppm: Math.max(targetRTPPPM, positiveNumber(base.poolRatePPM, targetRTPPPM)),
|
pool_rate_ppm: Math.max(targetRTPPPM, positiveNumber(base.poolRatePPM, targetRTPPPM)),
|
||||||
room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200),
|
room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200),
|
||||||
room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM,
|
room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM,
|
||||||
@ -97,7 +104,7 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
|
|||||||
room_pool_weight_ppm: poolMode.roomPoolWeightPPM,
|
room_pool_weight_ppm: poolMode.roomPoolWeightPPM,
|
||||||
room_reserve: positiveNumber(base.roomReserve, giftPrice * 200),
|
room_reserve: positiveNumber(base.roomReserve, giftPrice * 200),
|
||||||
target_rtp_ppm: targetRTPPPM,
|
target_rtp_ppm: targetRTPPPM,
|
||||||
tiers: buildTiers(giftPrice, form.payoutStyle),
|
tiers: buildTiers(giftPrice, multiplierPPMs, highMultiplier),
|
||||||
user_daily_payout_cap: risk.userDailyPayoutCap,
|
user_daily_payout_cap: risk.userDailyPayoutCap,
|
||||||
user_hourly_payout_cap: risk.userHourlyPayoutCap,
|
user_hourly_payout_cap: risk.userHourlyPayoutCap,
|
||||||
};
|
};
|
||||||
@ -121,50 +128,69 @@ export function previewForForm(form) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTiers(cost, style) {
|
function buildTiers(cost, multiplierPPMs, highMultiplier) {
|
||||||
const scale = style === "stable" ? 0 : style === "exciting" ? 2 : 1;
|
return ["novice", "intermediate", "advanced"].flatMap((pool) =>
|
||||||
const noviceHigh = scale === 0 ? 200 : scale === 2 ? 900 : 500;
|
multiplierPPMs.map((multiplierPPM) =>
|
||||||
const intermediateHigh = scale === 0 ? 10_000 : scale === 2 ? 36_000 : 25_000;
|
tier(
|
||||||
const advancedHigh = scale === 0 ? 15_000 : scale === 2 ? 58_000 : 40_000;
|
pool,
|
||||||
|
`${pool}_${multiplierLabel(multiplierPPM)}`,
|
||||||
return [
|
Math.floor((cost * multiplierPPM) / ppmScale),
|
||||||
tier("novice", "none", 0, 720_000),
|
multiplierPPM,
|
||||||
tier("novice", "novice_feedback_0_2x", Math.floor(cost / 5), 90_000),
|
highMultiplier > 0 && multiplierPPM >= highMultiplier * ppmScale,
|
||||||
tier("novice", "novice_rebate_0_5x", Math.floor(cost / 2), 70_000),
|
),
|
||||||
tier("novice", "novice_rebate_1x", cost, 70_000),
|
),
|
||||||
tier("novice", "novice_small_2x", cost * 2, 35_000),
|
);
|
||||||
tier("novice", "novice_small_5x", cost * 5, 12_000),
|
|
||||||
tier("novice", "novice_medium_10x", cost * 10, 2_800),
|
|
||||||
tier("novice", "novice_cap_20x", cost * 20, noviceHigh, true),
|
|
||||||
|
|
||||||
tier("intermediate", "none", 0, 830_000),
|
|
||||||
tier("intermediate", "inter_rebate_0_5x", Math.floor(cost / 2), 25_000),
|
|
||||||
tier("intermediate", "inter_rebate_1x", cost, 40_000),
|
|
||||||
tier("intermediate", "inter_small_2x", cost * 2, 45_000),
|
|
||||||
tier("intermediate", "inter_medium_5x", cost * 5, 35_000),
|
|
||||||
tier("intermediate", "inter_large_20x", cost * 20, Math.floor(intermediateHigh * 0.8), true),
|
|
||||||
tier("intermediate", "inter_large_50x", cost * 50, Math.floor(intermediateHigh * 0.2), true),
|
|
||||||
|
|
||||||
tier("advanced", "none", 0, 900_000),
|
|
||||||
tier("advanced", "adv_small_2x", cost * 2, 30_000),
|
|
||||||
tier("advanced", "adv_medium_5x", cost * 5, 30_000),
|
|
||||||
tier("advanced", "adv_large_20x", cost * 20, Math.floor(advancedHigh * 0.75), true),
|
|
||||||
tier("advanced", "adv_large_100x", cost * 100, Math.floor(advancedHigh * 0.225), true),
|
|
||||||
tier("advanced", "adv_jackpot_500x", cost * 500, Math.max(100, Math.floor(advancedHigh * 0.025)), true),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tier(pool, tierId, rewardCoins, weight, highWaterOnly = false) {
|
function tier(pool, tierId, rewardCoins, multiplierPPM, highWaterOnly = false) {
|
||||||
return {
|
return {
|
||||||
enabled: weight > 0,
|
enabled: true,
|
||||||
high_water_only: Boolean(highWaterOnly),
|
high_water_only: Boolean(highWaterOnly),
|
||||||
|
multiplier_ppm: multiplierPPM,
|
||||||
pool,
|
pool,
|
||||||
reward_coins: rewardCoins,
|
reward_coins: rewardCoins,
|
||||||
tier_id: tierId,
|
tier_id: tierId,
|
||||||
weight,
|
weight: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseMultiplierList(value) {
|
||||||
|
const numbers = String(value || "")
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => Number(item.replace(/x$/i, "")))
|
||||||
|
.filter((item) => Number.isFinite(item) && item >= 0)
|
||||||
|
.map((item) => Math.round(item * ppmScale));
|
||||||
|
const unique = Array.from(new Set([0, ...numbers])).sort((a, b) => a - b);
|
||||||
|
return unique.length > 1 ? unique : [0, 1_000_000];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMultiplierValues(values) {
|
||||||
|
return parseMultiplierList((values || []).join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMultiplierValues(multiplierPPMs, tiers) {
|
||||||
|
const values = Array.isArray(multiplierPPMs) && multiplierPPMs.length > 0 ? multiplierPPMs : multipliersFromTiers(tiers);
|
||||||
|
return values.map(multiplierText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function multipliersFromTiers(tiers) {
|
||||||
|
const values = (tiers || [])
|
||||||
|
.map((tierItem) => Number(tierItem.multiplierPPM || 0))
|
||||||
|
.filter((item) => item >= 0);
|
||||||
|
return values.length > 0 ? Array.from(new Set(values)).sort((a, b) => a - b) : parseMultiplierList("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplierLabel(multiplierPPM) {
|
||||||
|
return multiplierPPM === 0 ? "none" : `${multiplierText(multiplierPPM).replace(".", "_")}x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplierText(multiplierPPM) {
|
||||||
|
const value = multiplierPPM / ppmScale;
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(6).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
function riskCapsForLevel(level, giftPrice, targetRTPPPM) {
|
function riskCapsForLevel(level, giftPrice, targetRTPPPM) {
|
||||||
const expectedHour = expectedPayout(giftPrice, targetRTPPPM, defaultDrawsPerHour);
|
const expectedHour = expectedPayout(giftPrice, targetRTPPPM, defaultDrawsPerHour);
|
||||||
const expectedDay = expectedHour * 24;
|
const expectedDay = expectedHour * 24;
|
||||||
@ -223,19 +249,6 @@ function inferAtmosphereLevel(ratePPM) {
|
|||||||
return matchConfig(atmosphereLevelConfig, (item) => item.roomAtmosphereRatePPM === ratePPM);
|
return matchConfig(atmosphereLevelConfig, (item) => item.roomAtmosphereRatePPM === ratePPM);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPayoutStyle(tiers) {
|
|
||||||
const jackpotWeight = tiers
|
|
||||||
.filter((tierItem) => tierItem.pool === "advanced" && tierItem.highWaterOnly)
|
|
||||||
.reduce((sum, tierItem) => sum + Number(tierItem.weight || 0), 0);
|
|
||||||
if (jackpotWeight <= 20_000) {
|
|
||||||
return "stable";
|
|
||||||
}
|
|
||||||
if (jackpotWeight >= 50_000) {
|
|
||||||
return "exciting";
|
|
||||||
}
|
|
||||||
return "balanced";
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferRiskLevel() {
|
function inferRiskLevel() {
|
||||||
return "standard";
|
return "standard";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { emptyLuckyGiftForm, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
|
import { emptyLuckyGiftForm, parseMultiplierList, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
|
||||||
|
|
||||||
describe("lucky gift config payload", () => {
|
describe("lucky gift config payload", () => {
|
||||||
test("submits both RTP control windows for backend validation", () => {
|
test("submits both RTP control windows for backend validation", () => {
|
||||||
@ -7,5 +7,12 @@ describe("lucky gift config payload", () => {
|
|||||||
|
|
||||||
expect(payload.global_window_draws).toBeGreaterThan(0);
|
expect(payload.global_window_draws).toBeGreaterThan(0);
|
||||||
expect(payload.gift_window_draws).toBeGreaterThan(0);
|
expect(payload.gift_window_draws).toBeGreaterThan(0);
|
||||||
|
expect(payload.pool_id).toBe("default");
|
||||||
|
expect(payload.multiplier_ppms).toContain(500_000_000);
|
||||||
|
expect(payload.tiers.every((tier) => tier.weight === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses custom multipliers without configured probabilities", () => {
|
||||||
|
expect(parseMultiplierList("0, 0.5x, 1, 2")).toEqual([0, 500_000, 1_000_000, 2_000_000]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
export const luckyGiftConfigScopeLabel = "全局配置";
|
|
||||||
|
|
||||||
export const controlStrengthOptions = [
|
export const controlStrengthOptions = [
|
||||||
["precise", "精准"],
|
["precise", "精准"],
|
||||||
["standard", "标准"],
|
["standard", "标准"],
|
||||||
@ -19,12 +17,6 @@ export const noviceStrengthOptions = [
|
|||||||
["strong", "强"],
|
["strong", "强"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const payoutStyleOptions = [
|
|
||||||
["stable", "稳定小奖"],
|
|
||||||
["balanced", "均衡"],
|
|
||||||
["exciting", "刺激大奖"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const jackpotStrategyOptions = [
|
export const jackpotStrategyOptions = [
|
||||||
["off", "关闭"],
|
["off", "关闭"],
|
||||||
["conservative", "保守"],
|
["conservative", "保守"],
|
||||||
|
|||||||
@ -1,51 +1,97 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { getLuckyGiftConfig, listLuckyGiftDraws, updateLuckyGiftConfig } from "@/features/lucky-gift/api";
|
import {
|
||||||
|
getLuckyGiftConfig,
|
||||||
|
getLuckyGiftDrawSummary,
|
||||||
|
listLuckyGiftConfigs,
|
||||||
|
updateLuckyGiftConfig,
|
||||||
|
} from "@/features/lucky-gift/api";
|
||||||
import { emptyLuckyGiftForm, formFromConfig, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
|
import { emptyLuckyGiftForm, formFromConfig, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
|
||||||
import { useLuckyGiftAbilities } from "@/features/lucky-gift/permissions.js";
|
import { useLuckyGiftAbilities } from "@/features/lucky-gift/permissions.js";
|
||||||
import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema";
|
import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema";
|
||||||
|
|
||||||
const pageSize = 10;
|
const defaultPoolId = "default";
|
||||||
const emptyDraws = { items: [], page: 1, pageSize, total: 0 };
|
const emptySummary = {
|
||||||
|
actualRTPPPM: 0,
|
||||||
|
activitySubsidyCoins: 0,
|
||||||
|
baseRewardCoins: 0,
|
||||||
|
failedDraws: 0,
|
||||||
|
grantedDraws: 0,
|
||||||
|
pendingDraws: 0,
|
||||||
|
poolId: defaultPoolId,
|
||||||
|
roomAtmosphereRewardCoins: 0,
|
||||||
|
totalDraws: 0,
|
||||||
|
totalRewardCoins: 0,
|
||||||
|
totalSpentCoins: 0,
|
||||||
|
uniqueRooms: 0,
|
||||||
|
uniqueUsers: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export function useLuckyGiftPage() {
|
export function useLuckyGiftPage() {
|
||||||
const abilities = useLuckyGiftAbilities();
|
const abilities = useLuckyGiftAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [config, setConfig] = useState(null);
|
const [config, setConfig] = useState(null);
|
||||||
|
const [configs, setConfigs] = useState([]);
|
||||||
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||||
|
const [addPoolOpen, setAddPoolOpen] = useState(false);
|
||||||
|
const [addPoolId, setAddPoolId] = useState("");
|
||||||
const [form, setForm] = useState(emptyLuckyGiftForm());
|
const [form, setForm] = useState(emptyLuckyGiftForm());
|
||||||
const [configLoading, setConfigLoading] = useState(false);
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
const [configSaving, setConfigSaving] = useState(false);
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const [poolId, setPoolId] = useState(defaultPoolId);
|
||||||
const [giftId, setGiftId] = useState("");
|
const [giftId, setGiftId] = useState("");
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [roomId, setRoomId] = useState("");
|
const [roomId, setRoomId] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [drawSummary, setDrawSummary] = useState(emptySummary);
|
||||||
|
const [drawSummaryLoading, setDrawSummaryLoading] = useState(false);
|
||||||
|
const [drawSummaryError, setDrawSummaryError] = useState("");
|
||||||
|
|
||||||
const drawFilters = useMemo(
|
const drawFilters = useMemo(
|
||||||
() => ({ gift_id: giftId, room_id: roomId, status, user_id: userId }),
|
() => ({ gift_id: giftId, pool_id: poolId, room_id: roomId, status, user_id: userId }),
|
||||||
[giftId, roomId, status, userId],
|
[giftId, poolId, roomId, status, userId],
|
||||||
);
|
);
|
||||||
const {
|
const poolOptions = useMemo(() => {
|
||||||
data: draws = emptyDraws,
|
const ids = new Set([poolId]);
|
||||||
error: drawsError,
|
for (const item of configs) {
|
||||||
loading: drawsLoading,
|
if (item.poolId) {
|
||||||
reload: reloadDraws,
|
ids.add(item.poolId);
|
||||||
} = usePaginatedQuery({
|
}
|
||||||
errorMessage: "加载幸运礼物记录失败",
|
}
|
||||||
fetcher: listLuckyGiftDraws,
|
return Array.from(ids);
|
||||||
filters: drawFilters,
|
}, [configs, poolId]);
|
||||||
page,
|
|
||||||
pageSize,
|
const reloadDrawSummary = useCallback(async () => {
|
||||||
queryKey: ["lucky-gift-draws", drawFilters, page],
|
setDrawSummaryLoading(true);
|
||||||
});
|
setDrawSummaryError("");
|
||||||
|
try {
|
||||||
|
const summary = await getLuckyGiftDrawSummary(drawFilters);
|
||||||
|
setDrawSummary(summary);
|
||||||
|
} catch (err) {
|
||||||
|
setDrawSummaryError(err.message || "加载幸运礼物抽奖汇总失败");
|
||||||
|
} finally {
|
||||||
|
setDrawSummaryLoading(false);
|
||||||
|
}
|
||||||
|
}, [drawFilters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadDrawSummary();
|
||||||
|
}, [reloadDrawSummary]);
|
||||||
|
|
||||||
|
const reloadConfigs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nextConfigs = await listLuckyGiftConfigs();
|
||||||
|
setConfigs(nextConfigs);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "加载幸运礼物奖池失败", "error");
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
const reloadConfig = useCallback(async () => {
|
const reloadConfig = useCallback(async () => {
|
||||||
setConfigLoading(true);
|
setConfigLoading(true);
|
||||||
try {
|
try {
|
||||||
const nextConfig = await getLuckyGiftConfig();
|
const nextConfig = await getLuckyGiftConfig(poolId);
|
||||||
setConfig(nextConfig);
|
setConfig(nextConfig);
|
||||||
setForm(formFromConfig(nextConfig));
|
setForm(formFromConfig(nextConfig));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -53,21 +99,25 @@ export function useLuckyGiftPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setConfigLoading(false);
|
setConfigLoading(false);
|
||||||
}
|
}
|
||||||
}, [showToast]);
|
}, [poolId, showToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reloadConfig();
|
void reloadConfig();
|
||||||
}, [reloadConfig]);
|
}, [reloadConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadConfigs();
|
||||||
|
}, [reloadConfigs]);
|
||||||
|
|
||||||
const openConfigDrawer = useCallback(() => {
|
const openConfigDrawer = useCallback(() => {
|
||||||
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
|
setForm(config ? formFromConfig(config) : { ...emptyLuckyGiftForm(), poolId });
|
||||||
setConfigDrawerOpen(true);
|
setConfigDrawerOpen(true);
|
||||||
}, [config]);
|
}, [config, poolId]);
|
||||||
|
|
||||||
const closeConfigDrawer = useCallback(() => {
|
const closeConfigDrawer = useCallback(() => {
|
||||||
setConfigDrawerOpen(false);
|
setConfigDrawerOpen(false);
|
||||||
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
|
setForm(config ? formFromConfig(config) : { ...emptyLuckyGiftForm(), poolId });
|
||||||
}, [config]);
|
}, [config, poolId]);
|
||||||
|
|
||||||
const submitConfig = async (event) => {
|
const submitConfig = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -79,10 +129,12 @@ export function useLuckyGiftPage() {
|
|||||||
const parsed = parseForm(luckyGiftConfigFormSchema, form);
|
const parsed = parseForm(luckyGiftConfigFormSchema, form);
|
||||||
const saved = await updateLuckyGiftConfig(payloadFromLuckyGiftForm(parsed, config));
|
const saved = await updateLuckyGiftConfig(payloadFromLuckyGiftForm(parsed, config));
|
||||||
setConfig(saved);
|
setConfig(saved);
|
||||||
|
setConfigs((current) => mergeConfigList(current, saved));
|
||||||
setForm(formFromConfig(saved));
|
setForm(formFromConfig(saved));
|
||||||
|
setPoolId(saved.poolId || poolId);
|
||||||
setConfigDrawerOpen(false);
|
setConfigDrawerOpen(false);
|
||||||
showToast("幸运礼物配置已保存", "success");
|
showToast("幸运礼物配置已保存", "success");
|
||||||
await reloadDraws();
|
await reloadDrawSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存幸运礼物配置失败", "error");
|
showToast(err.message || "保存幸运礼物配置失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
@ -92,54 +144,99 @@ export function useLuckyGiftPage() {
|
|||||||
|
|
||||||
const changeGiftId = (value) => {
|
const changeGiftId = (value) => {
|
||||||
setGiftId(value);
|
setGiftId(value);
|
||||||
setPage(1);
|
};
|
||||||
|
const changePoolId = (value) => {
|
||||||
|
setPoolId(value.trim() || defaultPoolId);
|
||||||
|
};
|
||||||
|
const openAddPool = () => {
|
||||||
|
setAddPoolId("");
|
||||||
|
setAddPoolOpen(true);
|
||||||
|
};
|
||||||
|
const closeAddPool = () => {
|
||||||
|
setAddPoolOpen(false);
|
||||||
|
setAddPoolId("");
|
||||||
|
};
|
||||||
|
const submitAddPool = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextPoolID = addPoolId.trim();
|
||||||
|
if (!nextPoolID) {
|
||||||
|
showToast("奖池 ID 不能为空", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPoolId(nextPoolID);
|
||||||
|
setConfig(null);
|
||||||
|
setForm({ ...emptyLuckyGiftForm(), poolId: nextPoolID });
|
||||||
|
setAddPoolOpen(false);
|
||||||
|
setAddPoolId("");
|
||||||
|
setConfigDrawerOpen(true);
|
||||||
};
|
};
|
||||||
const changeUserId = (value) => {
|
const changeUserId = (value) => {
|
||||||
setUserId(value);
|
setUserId(value);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const changeRoomId = (value) => {
|
const changeRoomId = (value) => {
|
||||||
setRoomId(value);
|
setRoomId(value);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const changeStatus = (value) => {
|
const changeStatus = (value) => {
|
||||||
setStatus(value);
|
setStatus(value);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const resetDrawFilters = () => {
|
const resetDrawFilters = () => {
|
||||||
setGiftId("");
|
setGiftId("");
|
||||||
setUserId("");
|
setUserId("");
|
||||||
setRoomId("");
|
setRoomId("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
|
addPoolId,
|
||||||
|
addPoolOpen,
|
||||||
changeGiftId,
|
changeGiftId,
|
||||||
|
changePoolId,
|
||||||
changeRoomId,
|
changeRoomId,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
changeUserId,
|
changeUserId,
|
||||||
closeConfigDrawer,
|
closeConfigDrawer,
|
||||||
|
closeAddPool,
|
||||||
config,
|
config,
|
||||||
configDrawerOpen,
|
configDrawerOpen,
|
||||||
configLoading,
|
configLoading,
|
||||||
configSaving,
|
configSaving,
|
||||||
draws,
|
drawSummary,
|
||||||
drawsError,
|
drawSummaryError,
|
||||||
drawsLoading,
|
drawSummaryLoading,
|
||||||
form,
|
form,
|
||||||
giftId,
|
giftId,
|
||||||
openConfigDrawer,
|
openConfigDrawer,
|
||||||
page,
|
openAddPool,
|
||||||
|
poolId,
|
||||||
|
poolOptions,
|
||||||
reloadConfig,
|
reloadConfig,
|
||||||
reloadDraws,
|
reloadConfigs,
|
||||||
|
reloadDrawSummary,
|
||||||
resetDrawFilters,
|
resetDrawFilters,
|
||||||
roomId,
|
roomId,
|
||||||
setForm,
|
setForm,
|
||||||
setPage,
|
setAddPoolId,
|
||||||
status,
|
status,
|
||||||
submitConfig,
|
submitConfig,
|
||||||
|
submitAddPool,
|
||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeConfigList(configs, saved) {
|
||||||
|
const out = [];
|
||||||
|
let replaced = false;
|
||||||
|
for (const item of configs) {
|
||||||
|
if (item.poolId === saved.poolId) {
|
||||||
|
out.push(saved);
|
||||||
|
replaced = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
if (!replaced) {
|
||||||
|
out.unshift(saved);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@ -83,6 +83,10 @@
|
|||||||
width: 220px;
|
width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poolSelector {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
.configDrawer {
|
.configDrawer {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: min(520px, 100vw);
|
width: min(520px, 100vw);
|
||||||
@ -166,6 +170,39 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multiplierBlock {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplierHeader {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplierList {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplierRow {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
.simulationPanel {
|
.simulationPanel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
@ -200,6 +237,52 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawSummaryPanel {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawSummaryHeader {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawSummaryFilters {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawSummaryGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryError {
|
||||||
|
display: flex;
|
||||||
|
min-height: 120px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
.metricItem {
|
.metricItem {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -227,6 +310,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metricItem small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -269,8 +361,15 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawSummaryHeader {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.simulationGrid,
|
.simulationGrid,
|
||||||
.simulationOverview {
|
.simulationOverview,
|
||||||
|
.drawSummaryGrid,
|
||||||
|
.multiplierList {
|
||||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -279,7 +378,8 @@
|
|||||||
.configGrid,
|
.configGrid,
|
||||||
.subsidyInputs,
|
.subsidyInputs,
|
||||||
.simulationGrid,
|
.simulationGrid,
|
||||||
.simulationOverview {
|
.simulationOverview,
|
||||||
|
.drawSummaryGrid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
|
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
|
||||||
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
|
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
|
||||||
@ -5,86 +10,11 @@ import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/Lucky
|
|||||||
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
||||||
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
|
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
|
||||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||||
import {
|
import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
AdminFilterResetButton,
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
AdminFilterSelect,
|
|
||||||
AdminListBody,
|
|
||||||
AdminListPage,
|
|
||||||
AdminListToolbar,
|
|
||||||
} 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 columnsBase = [
|
|
||||||
{
|
|
||||||
key: "draw",
|
|
||||||
label: "抽奖记录",
|
|
||||||
width: "minmax(260px, 1.2fr)",
|
|
||||||
render: (draw) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{draw.drawId}</span>
|
|
||||||
<span className={styles.meta}>{draw.giftId || draw.commandId || "-"}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "reward",
|
|
||||||
label: "奖励",
|
|
||||||
width: "minmax(180px, 0.8fr)",
|
|
||||||
render: (draw) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{formatNumber(draw.effectiveRewardCoins)}</span>
|
|
||||||
<span className={styles.meta}>{draw.selectedTierId || "-"}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "pool",
|
|
||||||
label: "体验池",
|
|
||||||
width: "minmax(120px, 0.55fr)",
|
|
||||||
render: (draw) => poolLabel(draw.experiencePool),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
label: "状态",
|
|
||||||
width: "minmax(120px, 0.55fr)",
|
|
||||||
render: (draw) => <DrawStatus status={draw.rewardStatus} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rtp",
|
|
||||||
label: "窗口 RTP",
|
|
||||||
width: "minmax(160px, 0.7fr)",
|
|
||||||
render: (draw) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{formatPPM(draw.globalBaseRTPPPM)}</span>
|
|
||||||
<span className={styles.meta}>整体 {formatPPM(draw.giftBaseRTPPPM)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "source",
|
|
||||||
label: "预算来源",
|
|
||||||
width: "minmax(180px, 0.8fr)",
|
|
||||||
render: (draw) => budgetSourceLabel(draw),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "flags",
|
|
||||||
label: "标记",
|
|
||||||
width: "minmax(150px, 0.65fr)",
|
|
||||||
render: (draw) => flagLabel(draw),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "createdAtMs",
|
|
||||||
label: "时间",
|
|
||||||
width: "minmax(180px, 0.85fr)",
|
|
||||||
render: (draw) => formatMillis(draw.createdAtMs),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LuckyGiftConfigPage() {
|
export function LuckyGiftConfigPage() {
|
||||||
const page = useLuckyGiftPage();
|
const page = useLuckyGiftPage();
|
||||||
const total = page.draws.total || 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
@ -92,64 +22,15 @@ export function LuckyGiftConfigPage() {
|
|||||||
canUpdate={page.abilities.canUpdate}
|
canUpdate={page.abilities.canUpdate}
|
||||||
config={page.config}
|
config={page.config}
|
||||||
configLoading={page.configLoading}
|
configLoading={page.configLoading}
|
||||||
|
poolId={page.poolId}
|
||||||
|
poolOptions={page.poolOptions}
|
||||||
|
onAddPool={page.openAddPool}
|
||||||
onEdit={page.openConfigDrawer}
|
onEdit={page.openConfigDrawer}
|
||||||
|
onPoolChange={page.changePoolId}
|
||||||
onRefresh={page.reloadConfig}
|
onRefresh={page.reloadConfig}
|
||||||
/>
|
/>
|
||||||
<LuckyGiftSimulationPanel config={page.config} />
|
<LuckyGiftSimulationPanel config={page.config} />
|
||||||
<AdminListToolbar
|
<LuckyGiftDrawSummaryPanel page={page} />
|
||||||
filters={
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="礼物 ID"
|
|
||||||
value={page.giftId}
|
|
||||||
onChange={(event) => page.changeGiftId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="用户 ID"
|
|
||||||
value={page.userId}
|
|
||||||
onChange={(event) => page.changeUserId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="房间 ID"
|
|
||||||
value={page.roomId}
|
|
||||||
onChange={(event) => page.changeRoomId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<AdminFilterSelect
|
|
||||||
label="发放状态"
|
|
||||||
options={[["", "全部状态"], ...drawStatusOptions]}
|
|
||||||
value={page.status}
|
|
||||||
onChange={page.changeStatus}
|
|
||||||
/>
|
|
||||||
<AdminFilterResetButton
|
|
||||||
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
|
|
||||||
onClick={page.resetDrawFilters}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataState error={page.drawsError} loading={page.drawsLoading} onRetry={page.reloadDraws}>
|
|
||||||
<AdminListBody>
|
|
||||||
<DataTable
|
|
||||||
columns={columnsBase}
|
|
||||||
items={page.draws.items || []}
|
|
||||||
minWidth="1360px"
|
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.draws.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(draw) => draw.drawId}
|
|
||||||
/>
|
|
||||||
</AdminListBody>
|
|
||||||
</DataState>
|
|
||||||
<LuckyGiftConfigDrawer
|
<LuckyGiftConfigDrawer
|
||||||
abilities={page.abilities}
|
abilities={page.abilities}
|
||||||
configLoading={page.configLoading}
|
configLoading={page.configLoading}
|
||||||
@ -160,55 +41,152 @@ export function LuckyGiftConfigPage() {
|
|||||||
onClose={page.closeConfigDrawer}
|
onClose={page.closeConfigDrawer}
|
||||||
onSubmit={page.submitConfig}
|
onSubmit={page.submitConfig}
|
||||||
/>
|
/>
|
||||||
|
<AddPoolDialog
|
||||||
|
disabled={page.configSaving}
|
||||||
|
open={page.addPoolOpen}
|
||||||
|
value={page.addPoolId}
|
||||||
|
onChange={page.setAddPoolId}
|
||||||
|
onClose={page.closeAddPool}
|
||||||
|
onSubmit={page.submitAddPool}
|
||||||
|
/>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawStatus({ status }) {
|
function LuckyGiftDrawSummaryPanel({ page }) {
|
||||||
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
|
const summary = page.drawSummary || {};
|
||||||
|
const netSpent = Number(summary.totalSpentCoins || 0) - Number(summary.totalRewardCoins || 0);
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
hint: `房间 ${formatNumber(summary.uniqueRooms)}`,
|
||||||
|
label: "参与用户",
|
||||||
|
value: formatNumber(summary.uniqueUsers),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `成功 ${formatNumber(summary.grantedDraws)} / 待发放 ${formatNumber(summary.pendingDraws)} / 失败 ${formatNumber(summary.failedDraws)}`,
|
||||||
|
label: "抽奖次数",
|
||||||
|
value: formatNumber(summary.totalDraws),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `目标 ${formatPPM(page.config?.targetRTPPPM)}`,
|
||||||
|
label: "实际 RTP",
|
||||||
|
value: formatPPM(summary.actualRTPPPM),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `净消耗 ${formatNumber(netSpent)}`,
|
||||||
|
label: "消耗金币",
|
||||||
|
value: formatNumber(summary.totalSpentCoins),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `基础 ${formatNumber(summary.baseRewardCoins)}`,
|
||||||
|
label: "返还金币",
|
||||||
|
value: formatNumber(summary.totalRewardCoins),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `房间 ${formatNumber(summary.roomAtmosphereRewardCoins)} / 活动 ${formatNumber(summary.activitySubsidyCoins)}`,
|
||||||
|
label: "额外预算",
|
||||||
|
value: formatNumber(
|
||||||
|
Number(summary.roomAtmosphereRewardCoins || 0) + Number(summary.activitySubsidyCoins || 0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={["status-badge", `status-badge--${tone}`].join(" ")}>
|
<section className={styles.drawSummaryPanel}>
|
||||||
<span className="status-point" />
|
<header className={styles.drawSummaryHeader}>
|
||||||
{drawStatusOptions.find(([value]) => value === status)?.[1] || status || "-"}
|
<div className={styles.stack}>
|
||||||
</span>
|
<h2 className={styles.sectionTitle}>抽奖数据概览</h2>
|
||||||
|
<span className={styles.meta}>奖池 {summary.poolId || page.poolId}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={page.drawSummaryLoading}
|
||||||
|
startIcon={<RefreshOutlined fontSize="small" />}
|
||||||
|
onClick={page.reloadDrawSummary}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className={styles.drawSummaryFilters}>
|
||||||
|
<TextField className={styles.filterInput} disabled label="当前奖池" value={page.poolId} />
|
||||||
|
<TextField
|
||||||
|
className={styles.filterInput}
|
||||||
|
label="礼物 ID"
|
||||||
|
value={page.giftId}
|
||||||
|
onChange={(event) => page.changeGiftId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.filterInput}
|
||||||
|
label="用户 ID"
|
||||||
|
value={page.userId}
|
||||||
|
onChange={(event) => page.changeUserId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.filterInput}
|
||||||
|
label="房间 ID"
|
||||||
|
value={page.roomId}
|
||||||
|
onChange={(event) => page.changeRoomId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="发放状态"
|
||||||
|
options={[["", "全部状态"], ...drawStatusOptions]}
|
||||||
|
value={page.status}
|
||||||
|
onChange={page.changeStatus}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
|
||||||
|
onClick={page.resetDrawFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{page.drawSummaryError ? (
|
||||||
|
<div className={styles.summaryError}>
|
||||||
|
<span>{page.drawSummaryError}</span>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={page.reloadDrawSummary}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.drawSummaryGrid} aria-busy={page.drawSummaryLoading}>
|
||||||
|
{stats.map((item) => (
|
||||||
|
<div className={styles.metricItem} key={item.label}>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{page.drawSummaryLoading ? "-" : item.value}</strong>
|
||||||
|
<small>{page.drawSummaryLoading ? "" : item.hint}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function poolLabel(value) {
|
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
|
||||||
if (value === "novice") {
|
return (
|
||||||
return "新手池";
|
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
|
||||||
}
|
<form onSubmit={onSubmit}>
|
||||||
if (value === "intermediate") {
|
<DialogTitle>添加奖池</DialogTitle>
|
||||||
return "中级池";
|
<DialogContent>
|
||||||
}
|
<TextField
|
||||||
if (value === "advanced") {
|
autoFocus
|
||||||
return "高级池";
|
fullWidth
|
||||||
}
|
required
|
||||||
return value || "-";
|
disabled={disabled}
|
||||||
}
|
label="奖池 ID"
|
||||||
|
margin="dense"
|
||||||
function flagLabel(draw) {
|
value={value}
|
||||||
const labels = [];
|
onChange={(event) => onChange(event.target.value)}
|
||||||
if (draw.highMultiplier) {
|
/>
|
||||||
labels.push("高倍");
|
</DialogContent>
|
||||||
}
|
<DialogActions>
|
||||||
if (draw.stageFeedback) {
|
<Button disabled={disabled} type="button" onClick={onClose}>
|
||||||
labels.push("阶段反馈");
|
取消
|
||||||
}
|
</Button>
|
||||||
return labels.length ? labels.join(" / ") : "-";
|
<Button disabled={disabled} type="submit" variant="primary">
|
||||||
}
|
添加并编辑
|
||||||
|
</Button>
|
||||||
function budgetSourceLabel(draw) {
|
</DialogActions>
|
||||||
if (draw.activitySubsidyCoins > 0) {
|
</form>
|
||||||
return "活动补贴";
|
</Dialog>
|
||||||
}
|
);
|
||||||
if (draw.roomAtmosphereRewardCoins > 0) {
|
|
||||||
return "房间气氛";
|
|
||||||
}
|
|
||||||
if (draw.baseRewardCoins > 0) {
|
|
||||||
return "基础 RTP";
|
|
||||||
}
|
|
||||||
return "-";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPPM(value) {
|
function formatPPM(value) {
|
||||||
|
|||||||
@ -9,25 +9,34 @@ export const luckyGiftConfigFormSchema = z
|
|||||||
controlStrength: z.enum(["precise", "standard", "natural"]),
|
controlStrength: z.enum(["precise", "standard", "natural"]),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]),
|
jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]),
|
||||||
|
multiplierValues: z.array(z.coerce.number().min(0, "倍率不能小于 0")).min(1, "倍率配置不能为空"),
|
||||||
noviceStrength: z.enum(["weak", "standard", "strong"]),
|
noviceStrength: z.enum(["weak", "standard", "strong"]),
|
||||||
payoutStyle: z.enum(["stable", "balanced", "exciting"]),
|
poolId: z.string().trim().min(1, "奖池 ID 不能为空").max(96, "奖池 ID 不能超过 96 个字符"),
|
||||||
poolMode: z.enum(["platform_guard", "standard", "room_burst", "gift_isolated"]),
|
poolMode: z.enum(["platform_guard", "standard", "room_burst", "gift_isolated"]),
|
||||||
riskLevel: z.enum(["strict", "standard", "loose"]),
|
riskLevel: z.enum(["strict", "standard", "loose"]),
|
||||||
targetRtpPercent: z.coerce.number().min(1, "RTP 必须大于 1%").max(100, "RTP 不能超过 100%"),
|
targetRtpPercent: z.coerce.number().min(1, "RTP 必须大于 1%").max(100, "RTP 不能超过 100%"),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
if (!value.activitySubsidyEnabled) {
|
if (value.activitySubsidyEnabled && value.activityBudget <= 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value.activityBudget <= 0) {
|
|
||||||
context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] });
|
context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] });
|
||||||
}
|
}
|
||||||
if (value.activityBudget > 0 && value.activityDailyLimit <= 0) {
|
if (value.activitySubsidyEnabled && value.activityBudget > 0 && value.activityDailyLimit <= 0) {
|
||||||
context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] });
|
context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] });
|
||||||
}
|
}
|
||||||
if (value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) {
|
if (value.activitySubsidyEnabled && value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) {
|
||||||
context.addIssue({ code: "custom", message: "每日预算不能大于活动总预算", path: ["activityDailyLimit"] });
|
context.addIssue({ code: "custom", message: "每日预算不能大于活动总预算", path: ["activityDailyLimit"] });
|
||||||
}
|
}
|
||||||
|
const multipliers = value.multiplierValues
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((item) => Number.isFinite(item) && item >= 0);
|
||||||
|
if (multipliers.length === 0 || Math.max(...multipliers) <= 0) {
|
||||||
|
context.addIssue({ code: "custom", message: "至少配置一个大于 0 的中奖倍率", path: ["multiplierValues"] });
|
||||||
|
}
|
||||||
|
const maxMultiplier = Math.max(...multipliers, 0);
|
||||||
|
const targetMultiplier = Number(value.targetRtpPercent || 0) / 100;
|
||||||
|
if (maxMultiplier < targetMultiplier) {
|
||||||
|
context.addIssue({ code: "custom", message: "最大倍率不能低于 RTP 对应返还倍数", path: ["multiplierValues"] });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LuckyGiftConfigForm = z.infer<typeof luckyGiftConfigFormSchema>;
|
export type LuckyGiftConfigForm = z.infer<typeof luckyGiftConfigFormSchema>;
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -42,20 +42,26 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
await createResource({
|
await createResource({
|
||||||
amount: 0,
|
amount: 0,
|
||||||
assetUrl: "https://media.haiyihy.com/resource/material.png",
|
assetUrl: "https://media.haiyihy.com/resource/material.png",
|
||||||
name: "Rose",
|
badgeForm: "strip",
|
||||||
|
coinPrice: 10,
|
||||||
|
name: "VIP Badge",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/cover.png",
|
previewUrl: "https://media.haiyihy.com/resource/cover.png",
|
||||||
resourceCode: "gift_rose_test",
|
priceType: "coin",
|
||||||
resourceType: "gift",
|
resourceCode: "badge_vip_test",
|
||||||
|
resourceType: "badge",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await updateResource(11, {
|
await updateResource(11, {
|
||||||
amount: 0,
|
amount: 0,
|
||||||
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
|
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
|
||||||
name: "Rose Updated",
|
badgeForm: "tile",
|
||||||
|
coinPrice: 20,
|
||||||
|
name: "VIP Badge Updated",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
|
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
|
||||||
resourceCode: "gift_rose_test",
|
priceType: "coin",
|
||||||
resourceType: "gift",
|
resourceCode: "badge_vip_test",
|
||||||
|
resourceType: "badge",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
@ -173,9 +179,15 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
expect(listInit?.method).toBe("GET");
|
expect(listInit?.method).toBe("GET");
|
||||||
expect(String(createUrl)).toContain("/api/v1/admin/resources");
|
expect(String(createUrl)).toContain("/api/v1/admin/resources");
|
||||||
expect(createInit?.method).toBe("POST");
|
expect(createInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(createInit?.body))).toMatchObject({ resourceCode: "gift_rose_test", status: "active" });
|
expect(JSON.parse(String(createInit?.body))).toMatchObject({
|
||||||
|
badgeForm: "strip",
|
||||||
|
resourceCode: "badge_vip_test",
|
||||||
|
resourceType: "badge",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
expect(String(updateUrl)).toContain("/api/v1/admin/resources/11");
|
expect(String(updateUrl)).toContain("/api/v1/admin/resources/11");
|
||||||
expect(updateInit?.method).toBe("PUT");
|
expect(updateInit?.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(String(updateInit?.body))).toMatchObject({ badgeForm: "tile" });
|
||||||
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
||||||
expect(String(giftListUrl)).toContain("region_id=1001");
|
expect(String(giftListUrl)).toContain("region_id=1001");
|
||||||
expect(giftListInit?.method).toBe("GET");
|
expect(giftListInit?.method).toBe("GET");
|
||||||
|
|||||||
@ -13,6 +13,10 @@ export interface ResourceDto {
|
|||||||
grantStrategy?: string;
|
grantStrategy?: string;
|
||||||
walletAssetType?: string;
|
walletAssetType?: string;
|
||||||
walletAssetAmount?: number;
|
walletAssetAmount?: number;
|
||||||
|
priceType?: string;
|
||||||
|
coinPrice?: number;
|
||||||
|
giftPointAmount?: number;
|
||||||
|
badgeForm?: string;
|
||||||
usageScopes?: string[];
|
usageScopes?: string[];
|
||||||
assetUrl?: string;
|
assetUrl?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
@ -27,8 +31,11 @@ export interface ResourcePayload {
|
|||||||
amount: number;
|
amount: number;
|
||||||
animationUrl?: string;
|
animationUrl?: string;
|
||||||
assetUrl: string;
|
assetUrl: string;
|
||||||
|
badgeForm?: string;
|
||||||
|
coinPrice: number;
|
||||||
name: string;
|
name: string;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
priceType: string;
|
||||||
resourceCode: string;
|
resourceCode: string;
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
|||||||
195
src/features/resources/components/ResourceSelectDrawer.jsx
Normal file
195
src/features/resources/components/ResourceSelectDrawer.jsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
|
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { resourcePriceTypeLabels, resourceTypeLabels } from "@/features/resources/constants.js";
|
||||||
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
|
import styles from "@/features/resources/components/ResourceSelectDrawer.module.css";
|
||||||
|
|
||||||
|
export function ResourceSelectField({
|
||||||
|
disabled = false,
|
||||||
|
drawerTitle = "选择资源",
|
||||||
|
label = "资源",
|
||||||
|
loading = false,
|
||||||
|
onChange = () => {},
|
||||||
|
placeholder = "请选择资源",
|
||||||
|
resources = [],
|
||||||
|
value,
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [resourceType, setResourceType] = useState("");
|
||||||
|
const normalizedValue = String(value || "");
|
||||||
|
const selectedResource = resources.find((resource) => String(resource.resourceId) === normalizedValue);
|
||||||
|
const displayValue = selectedResource
|
||||||
|
? resourceName(selectedResource)
|
||||||
|
: normalizedValue
|
||||||
|
? `资源 #${normalizedValue}`
|
||||||
|
: "";
|
||||||
|
const resourceTypeOptions = useMemo(() => {
|
||||||
|
const seen = new Set();
|
||||||
|
return resources
|
||||||
|
.map((resource) => resource.resourceType)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((type) => {
|
||||||
|
if (seen.has(type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(type);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((type) => [type, resourceTypeLabels[type] || type])
|
||||||
|
.sort(([, labelA], [, labelB]) => labelA.localeCompare(labelB, "zh-Hans-CN"));
|
||||||
|
}, [resources]);
|
||||||
|
const filteredResources = useMemo(() => {
|
||||||
|
const keyword = query.trim().toLowerCase();
|
||||||
|
return resources.filter((resource) => {
|
||||||
|
if (resourceType && resource.resourceType !== resourceType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const haystack = [resource.name, resource.resourceCode, resource.resourceId, resource.resourceType]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(keyword);
|
||||||
|
});
|
||||||
|
}, [query, resourceType, resources]);
|
||||||
|
|
||||||
|
const openDrawer = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectResource = (resource) => {
|
||||||
|
onChange(String(resource.resourceId), resource);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
className={styles.field}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
label={label}
|
||||||
|
placeholder={loading ? "资源加载中" : placeholder}
|
||||||
|
required
|
||||||
|
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
||||||
|
value={displayValue}
|
||||||
|
onClick={openDrawer}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openDrawer();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SideDrawer
|
||||||
|
className={styles.drawer}
|
||||||
|
contentClassName={styles.drawerBody}
|
||||||
|
drawerProps={{ sx: { zIndex: 1600 } }}
|
||||||
|
open={open}
|
||||||
|
title={drawerTitle}
|
||||||
|
width="wide"
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<TextField
|
||||||
|
className={styles.search}
|
||||||
|
placeholder="搜索资源名称、编码"
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchOutlined fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
className={styles.typeFilter}
|
||||||
|
label="分类"
|
||||||
|
value={resourceType}
|
||||||
|
onChange={(event) => setResourceType(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部分类</MenuItem>
|
||||||
|
{resourceTypeOptions.map(([type, label]) => (
|
||||||
|
<MenuItem key={type} value={type}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{filteredResources.map((resource) => {
|
||||||
|
const selected = String(resource.resourceId) === normalizedValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
|
||||||
|
key={resource.resourceId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectResource(resource)}
|
||||||
|
>
|
||||||
|
<ResourceThumb resource={resource} />
|
||||||
|
<span className={styles.name}>{resourceName(resource)}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{resource.resourceCode || `ID ${resource.resourceId}`}
|
||||||
|
</span>
|
||||||
|
<span className={styles.tags}>
|
||||||
|
<span className={styles.tag}>
|
||||||
|
{resourceTypeLabels[resource.resourceType] || resource.resourceType || "资源"}
|
||||||
|
</span>
|
||||||
|
<span className={styles.tag}>{resourcePriceLabel(resource)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!filteredResources.length ? <div className={styles.empty}>当前无可选资源</div> : null}
|
||||||
|
</div>
|
||||||
|
</SideDrawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceThumb({ resource }) {
|
||||||
|
const imageUrl = imageURL(resource.previewUrl || resource.assetUrl || resource.animationUrl);
|
||||||
|
return (
|
||||||
|
<span className={styles.thumb}>
|
||||||
|
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceName(resource) {
|
||||||
|
return resource?.name || resource?.resourceCode || `资源 #${resource?.resourceId || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourcePriceLabel(resource) {
|
||||||
|
const priceType = resource?.priceType;
|
||||||
|
if (priceType === "coin") {
|
||||||
|
return `金币 ${formatNumber(resource.coinPrice)}`;
|
||||||
|
}
|
||||||
|
if (priceType === "free") {
|
||||||
|
return resourcePriceTypeLabels.free;
|
||||||
|
}
|
||||||
|
return "未配置价格";
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageURL(value) {
|
||||||
|
const url = String(value || "").trim();
|
||||||
|
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
.field :global(.MuiInputBase-input:not(:disabled)) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer.drawer {
|
||||||
|
width: min(780px, 100vw);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerBody {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(140px, 180px);
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search,
|
||||||
|
.typeFilter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(156px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 164px;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast) var(--ease-standard),
|
||||||
|
background var(--motion-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-fast) var(--ease-standard),
|
||||||
|
transform var(--motion-base) var(--ease-emphasized);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:focus-visible {
|
||||||
|
outline: 3px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardSelected,
|
||||||
|
.cardSelected:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-surface-strong);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--primary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name,
|
||||||
|
.meta,
|
||||||
|
.tag {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
height: var(--admin-tag-height);
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid var(--admin-tag-border);
|
||||||
|
border-radius: var(--admin-tag-radius);
|
||||||
|
background: var(--admin-tag-bg);
|
||||||
|
color: var(--admin-tag-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
min-height: 180px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { ResourceSelectField } from "./ResourceSelectDrawer.jsx";
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/SideDrawer.jsx", () => ({
|
||||||
|
SideDrawer({ children, drawerProps, onClose, open, title }) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<aside aria-label={title} data-z-index={drawerProps?.sx?.zIndex} role="dialog">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resources = [
|
||||||
|
{
|
||||||
|
coinPrice: 10,
|
||||||
|
name: "Rose Gift",
|
||||||
|
priceType: "coin",
|
||||||
|
resourceCode: "rose_gift",
|
||||||
|
resourceId: 1,
|
||||||
|
resourceType: "gift",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
coinPrice: 0,
|
||||||
|
name: "VIP Vehicle",
|
||||||
|
priceType: "free",
|
||||||
|
resourceCode: "vip_vehicle",
|
||||||
|
resourceId: 2,
|
||||||
|
resourceType: "vehicle",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("resource select drawer stays above dialogs and filters by category", () => {
|
||||||
|
render(<ResourceSelectField resources={resources} value="" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByPlaceholderText("请选择资源"));
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: "选择资源" })).toHaveAttribute("data-z-index", "1600");
|
||||||
|
expect(screen.getByText("Rose Gift")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("VIP Vehicle")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByRole("combobox", { name: "分类" }));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "座驾" }));
|
||||||
|
|
||||||
|
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("VIP Vehicle")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@ -41,6 +41,20 @@ export const emojiPackPricingLabels = {
|
|||||||
paid: "付费",
|
paid: "付费",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resourcePriceTypeOptions = [
|
||||||
|
["coin", "金币"],
|
||||||
|
["free", "免费"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const resourcePriceTypeLabels = Object.fromEntries(resourcePriceTypeOptions);
|
||||||
|
|
||||||
|
export const badgeFormOptions = [
|
||||||
|
["strip", "长徽章"],
|
||||||
|
["tile", "短徽章"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const badgeFormLabels = Object.fromEntries(badgeFormOptions);
|
||||||
|
|
||||||
export const resourceTypeFilters = [
|
export const resourceTypeFilters = [
|
||||||
["", "全部类型"],
|
["", "全部类型"],
|
||||||
["avatar_frame", "头像框"],
|
["avatar_frame", "头像框"],
|
||||||
|
|||||||
@ -44,9 +44,12 @@ const dayMillis = 24 * 60 * 60 * 1000;
|
|||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyResourceForm = (resource = {}) => ({
|
const emptyResourceForm = (resource = {}) => ({
|
||||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||||
|
badgeForm: resource.resourceType === "badge" ? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile" : "tile",
|
||||||
|
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
||||||
enabled: resource.status ? resource.status === "active" : true,
|
enabled: resource.status ? resource.status === "active" : true,
|
||||||
name: resource.name || "",
|
name: resource.name || "",
|
||||||
previewUrl: resource.previewUrl || "",
|
previewUrl: resource.previewUrl || "",
|
||||||
|
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
|
||||||
resourceCode: resource.resourceCode || "",
|
resourceCode: resource.resourceCode || "",
|
||||||
resourceType: resource.resourceType || "avatar_frame",
|
resourceType: resource.resourceType || "avatar_frame",
|
||||||
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
||||||
@ -103,14 +106,17 @@ const emptyGiftForm = (gift = {}) => ({
|
|||||||
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
export function applyGiftResourceSelection(form, resource) {
|
export function applyGiftResourceSelection(form, resource, fillIdentity = true) {
|
||||||
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
|
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
|
||||||
return {
|
const nextForm = {
|
||||||
...form,
|
...form,
|
||||||
giftId: resourceId,
|
|
||||||
name: resource?.name || resource?.resourceCode || resourceId,
|
|
||||||
resourceId,
|
resourceId,
|
||||||
};
|
};
|
||||||
|
if (fillIdentity) {
|
||||||
|
nextForm.giftId = resourceId;
|
||||||
|
nextForm.name = resource?.name || resource?.resourceCode || resourceId;
|
||||||
|
}
|
||||||
|
return applyGiftResourcePrice(nextForm, resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyGiftPriceDefaults(form, coinPrice) {
|
export function applyGiftPriceDefaults(form, coinPrice) {
|
||||||
@ -124,6 +130,17 @@ export function applyGiftPriceDefaults(form, coinPrice) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyGiftResourcePrice(form, resource) {
|
||||||
|
const priceType = resource?.priceType || (resource?.coinPrice > 0 ? "coin" : "");
|
||||||
|
if (priceType === "free") {
|
||||||
|
return applyGiftPriceDefaults({ ...form, chargeAssetType: "COIN" }, 0);
|
||||||
|
}
|
||||||
|
if (priceType === "coin") {
|
||||||
|
return applyGiftPriceDefaults({ ...form, chargeAssetType: "COIN" }, resource?.coinPrice || 0);
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
const emptyGrantForm = () => ({
|
const emptyGrantForm = () => ({
|
||||||
durationDays: "0",
|
durationDays: "0",
|
||||||
groupId: "",
|
groupId: "",
|
||||||
@ -593,10 +610,11 @@ export function useGiftListPage() {
|
|||||||
const selectGiftResource = (resourceId) => {
|
const selectGiftResource = (resourceId) => {
|
||||||
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
|
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
|
||||||
setForm((currentForm) => {
|
setForm((currentForm) => {
|
||||||
if (activeAction !== "create") {
|
return applyGiftResourceSelection(
|
||||||
return { ...currentForm, resourceId };
|
currentForm,
|
||||||
}
|
selectedResource || { resourceId },
|
||||||
return applyGiftResourceSelection(currentForm, selectedResource || { resourceId });
|
activeAction === "create",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1005,8 +1023,11 @@ function buildResourcePayload(form) {
|
|||||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||||
animationUrl,
|
animationUrl,
|
||||||
assetUrl: animationUrl,
|
assetUrl: animationUrl,
|
||||||
|
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
|
||||||
|
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
previewUrl: form.previewUrl.trim(),
|
previewUrl: form.previewUrl.trim(),
|
||||||
|
priceType: form.priceType,
|
||||||
resourceCode: form.resourceCode.trim(),
|
resourceCode: form.resourceCode.trim(),
|
||||||
resourceType,
|
resourceType,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
@ -1014,6 +1035,22 @@ function buildResourcePayload(form) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badgeFormFromMetadata(metadataJson) {
|
||||||
|
if (!metadataJson) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(metadataJson);
|
||||||
|
const badgeForm = String(metadata?.badge_form || "").trim().toLowerCase();
|
||||||
|
if (badgeForm === "strip" || badgeForm === "tile") {
|
||||||
|
return badgeForm;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function buildEmojiPackPayload(form) {
|
function buildEmojiPackPayload(form) {
|
||||||
const regionIds = (form.regionIds || [])
|
const regionIds = (form.regionIds || [])
|
||||||
.map(Number)
|
.map(Number)
|
||||||
@ -1080,7 +1117,7 @@ function buildGiftPayload(form) {
|
|||||||
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
|
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
|
||||||
effectTypes: form.effectTypes || [],
|
effectTypes: form.effectTypes || [],
|
||||||
giftId: form.giftId.trim(),
|
giftId: form.giftId.trim(),
|
||||||
giftPointAmount: Number(form.giftPointAmount || 0),
|
giftPointAmount: Number(form.coinPrice || 0),
|
||||||
giftTypeCode: form.giftTypeCode,
|
giftTypeCode: form.giftTypeCode,
|
||||||
heatValue: Number(form.heatValue || 0),
|
heatValue: Number(form.heatValue || 0),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
|||||||
@ -10,7 +10,9 @@ test("fills gift name and id from selected gift resource", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextForm = applyGiftResourceSelection(form, {
|
const nextForm = applyGiftResourceSelection(form, {
|
||||||
|
coinPrice: 10,
|
||||||
name: "Rose",
|
name: "Rose",
|
||||||
|
priceType: "coin",
|
||||||
resourceCode: "gift_rose",
|
resourceCode: "gift_rose",
|
||||||
resourceId: 11,
|
resourceId: 11,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,13 +34,10 @@ import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/t
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
|
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
|
||||||
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
||||||
|
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
||||||
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
|
|
||||||
const chargeAssetOptions = [
|
|
||||||
["COIN", "金币"],
|
|
||||||
["DIAMOND", "钻石"],
|
|
||||||
];
|
|
||||||
const effectOptions = [
|
const effectOptions = [
|
||||||
["animation", "动画"],
|
["animation", "动画"],
|
||||||
["music", "音乐"],
|
["music", "音乐"],
|
||||||
@ -69,9 +66,7 @@ const baseColumns = (giftTypeOptions) => [
|
|||||||
render: (gift) => (
|
render: (gift) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
|
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
|
||||||
<span className={styles.meta}>
|
<span className={styles.meta}>{giftPriceLabel(gift)}</span>
|
||||||
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -277,20 +272,15 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
>
|
>
|
||||||
<AdminFormSection title="资源信息">
|
<AdminFormSection title="资源信息">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<TextField
|
<ResourceSelectField
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
disabled={disabled || page.resourceOptionsLoading}
|
||||||
|
drawerTitle="选择礼物资源"
|
||||||
label="礼物资源"
|
label="礼物资源"
|
||||||
required
|
loading={page.resourceOptionsLoading}
|
||||||
select
|
resources={page.resourceOptions}
|
||||||
value={form.resourceId}
|
value={form.resourceId}
|
||||||
onChange={(event) => page.selectGiftResource(event.target.value)}
|
onChange={(resourceId) => page.selectGiftResource(resourceId)}
|
||||||
>
|
/>
|
||||||
{page.resourceOptions.map((resource) => (
|
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
{showGiftIdentityFields ? (
|
{showGiftIdentityFields ? (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
@ -327,35 +317,8 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="价格与排序">
|
<AdminFormSection title="价格与排序">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<TextField
|
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
|
||||||
disabled={disabled}
|
<TextField disabled label="积分" type="number" value={form.giftPointAmount} />
|
||||||
label="收费类型"
|
|
||||||
required
|
|
||||||
select
|
|
||||||
value={form.chargeAssetType}
|
|
||||||
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
|
||||||
>
|
|
||||||
{chargeAssetOptions.map(([value, label]) => (
|
|
||||||
<MenuItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="价格"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
value={form.coinPrice}
|
|
||||||
onChange={(event) => page.changeGiftPrice(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
label="积分"
|
|
||||||
type="number"
|
|
||||||
value={form.giftPointAmount}
|
|
||||||
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="热度"
|
label="热度"
|
||||||
@ -658,8 +621,12 @@ function giftTypeLabel(value, options = defaultGiftTypeOptions) {
|
|||||||
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
|
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
|
||||||
}
|
}
|
||||||
|
|
||||||
function chargeAssetLabel(value) {
|
function giftPriceLabel(gift) {
|
||||||
return chargeAssetOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "金币";
|
if (Number(gift.coinPrice || 0) === 0) {
|
||||||
|
return "免费";
|
||||||
|
}
|
||||||
|
const chargeAsset = gift.chargeAssetType === "DIAMOND" ? "钻石" : "金币";
|
||||||
|
return `${chargeAsset} · ${formatNumber(gift.coinPrice)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectTypeLabel(value) {
|
function effectTypeLabel(value) {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
|||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import {
|
||||||
@ -29,6 +28,7 @@ import {
|
|||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
||||||
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
||||||
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
@ -87,22 +87,22 @@ export function ResourceGroupListPage() {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: column.key === "status"
|
: column.key === "status"
|
||||||
? {
|
|
||||||
...column,
|
|
||||||
filter: createOptionsColumnFilter({
|
|
||||||
options: resourceStatusFilters,
|
|
||||||
placeholder: "搜索状态",
|
|
||||||
value: page.status,
|
|
||||||
onChange: page.changeStatus,
|
|
||||||
}),
|
|
||||||
render: (group) => <ResourceGroupStatusSwitch group={group} page={page} />,
|
|
||||||
}
|
|
||||||
: column.key === "actions"
|
|
||||||
? {
|
? {
|
||||||
...column,
|
...column,
|
||||||
render: (group) => <ResourceGroupRowActions group={group} page={page} />,
|
filter: createOptionsColumnFilter({
|
||||||
|
options: resourceStatusFilters,
|
||||||
|
placeholder: "搜索状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
render: (group) => <ResourceGroupStatusSwitch group={group} page={page} />,
|
||||||
}
|
}
|
||||||
: column,
|
: column.key === "actions"
|
||||||
|
? {
|
||||||
|
...column,
|
||||||
|
render: (group) => <ResourceGroupRowActions group={group} page={page} />,
|
||||||
|
}
|
||||||
|
: column,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -309,20 +309,15 @@ function ResourceGroupItemEditor({ disabled, index, item, page }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminFormListRow>
|
<AdminFormListRow>
|
||||||
<TextField
|
<ResourceSelectField
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
disabled={disabled || page.resourceOptionsLoading}
|
||||||
|
drawerTitle="选择资源"
|
||||||
label="资源"
|
label="资源"
|
||||||
required
|
loading={page.resourceOptionsLoading}
|
||||||
select
|
resources={page.resourceOptions}
|
||||||
value={item.resourceId}
|
value={item.resourceId}
|
||||||
onChange={(event) => page.updateGroupItem(index, { resourceId: event.target.value })}
|
onChange={(resourceId) => page.updateGroupItem(index, { resourceId })}
|
||||||
>
|
/>
|
||||||
{page.resourceOptions.map((resource) => (
|
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="有效天数"
|
label="有效天数"
|
||||||
|
|||||||
@ -23,7 +23,11 @@ import {
|
|||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
|
badgeFormLabels,
|
||||||
|
badgeFormOptions,
|
||||||
resourceStatusFilters,
|
resourceStatusFilters,
|
||||||
|
resourcePriceTypeLabels,
|
||||||
|
resourcePriceTypeOptions,
|
||||||
resourceTypeFilters,
|
resourceTypeFilters,
|
||||||
resourceTypeLabels,
|
resourceTypeLabels,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
@ -45,6 +49,18 @@ const baseColumns = [
|
|||||||
width: "minmax(120px, 0.7fr)",
|
width: "minmax(120px, 0.7fr)",
|
||||||
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
|
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "price",
|
||||||
|
label: "价格",
|
||||||
|
width: "minmax(120px, 0.65fr)",
|
||||||
|
render: (resource) => resourcePriceLabel(resource),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "badgeForm",
|
||||||
|
label: "徽章属性",
|
||||||
|
width: "minmax(120px, 0.65fr)",
|
||||||
|
render: (resource) => badgeFormLabel(resource),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
@ -123,7 +139,7 @@ export function ResourceListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="820px"
|
minWidth="940px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -204,9 +220,15 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
required
|
required
|
||||||
select
|
select
|
||||||
value={form.resourceType}
|
value={form.resourceType}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })
|
const resourceType = event.target.value;
|
||||||
}
|
setForm({
|
||||||
|
...form,
|
||||||
|
badgeForm: resourceType === "badge" ? form.badgeForm || "tile" : form.badgeForm,
|
||||||
|
resourceType,
|
||||||
|
walletAssetAmount: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{resourceTypeOptions.map(([value, label]) => (
|
{resourceTypeOptions.map(([value, label]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
@ -224,6 +246,54 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
|
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{form.resourceType === "badge" ? (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="徽章属性"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.badgeForm}
|
||||||
|
onChange={(event) => setForm({ ...form, badgeForm: event.target.value })}
|
||||||
|
>
|
||||||
|
{badgeFormOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
|
<AdminFormSection title="价格">
|
||||||
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="价格类型"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.priceType}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
coinPrice: event.target.value === "free" ? "0" : form.coinPrice,
|
||||||
|
priceType: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{resourcePriceTypeOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || form.priceType === "free"}
|
||||||
|
label="价格"
|
||||||
|
required={form.priceType === "coin"}
|
||||||
|
type="number"
|
||||||
|
value={form.priceType === "free" ? "0" : form.coinPrice}
|
||||||
|
onChange={(event) => setForm({ ...form, coinPrice: event.target.value })}
|
||||||
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="素材">
|
<AdminFormSection title="素材">
|
||||||
@ -285,6 +355,36 @@ function ResourceStatusSwitch({ page, resource }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourcePriceLabel(resource) {
|
||||||
|
if (resource.priceType === "free") {
|
||||||
|
return resourcePriceTypeLabels.free;
|
||||||
|
}
|
||||||
|
if (resource.priceType === "coin") {
|
||||||
|
return `金币 ${formatNumber(resource.coinPrice)}`;
|
||||||
|
}
|
||||||
|
return "未配置";
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeFormLabel(resource) {
|
||||||
|
if (resource.resourceType !== "badge") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return badgeFormLabels[resource.badgeForm || badgeFormFromMetadata(resource.metadataJson)] || "未配置";
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeFormFromMetadata(metadataJson) {
|
||||||
|
if (!metadataJson) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(metadataJson);
|
||||||
|
const badgeForm = String(metadata?.badge_form || "").trim().toLowerCase();
|
||||||
|
return badgeForm === "strip" || badgeForm === "tile" ? badgeForm : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function imageURL(value) {
|
function imageURL(value) {
|
||||||
const url = String(value || "").trim();
|
const url = String(value || "").trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@ -292,3 +392,7 @@ function imageURL(value) {
|
|||||||
}
|
}
|
||||||
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ const resourceTypes = [
|
|||||||
"emoji_pack",
|
"emoji_pack",
|
||||||
];
|
];
|
||||||
const walletAssetTypes = ["COIN", "DIAMOND"];
|
const walletAssetTypes = ["COIN", "DIAMOND"];
|
||||||
|
const resourcePriceTypes = ["coin", "free"];
|
||||||
|
const badgeForms = ["strip", "tile"];
|
||||||
const optionalWalletAssetTypeSchema = z
|
const optionalWalletAssetTypeSchema = z
|
||||||
.preprocess((value) => {
|
.preprocess((value) => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@ -27,24 +29,44 @@ const optionalWalletAssetTypeSchema = z
|
|||||||
export const resourceCreateFormSchema = z
|
export const resourceCreateFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
||||||
|
badgeForm: z.enum(badgeForms).optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
||||||
|
coinPrice: z.union([z.string(), z.number()]).optional(),
|
||||||
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
||||||
|
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
|
||||||
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
|
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
|
||||||
resourceType: z.enum(resourceTypes, "请选择资源类型"),
|
resourceType: z.enum(resourceTypes, "请选择资源类型"),
|
||||||
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
if (value.resourceType !== "coin") {
|
if (value.resourceType !== "coin") {
|
||||||
return;
|
if (value.resourceType === "badge" && !value.badgeForm) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请选择徽章属性",
|
||||||
|
path: ["badgeForm"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const amount = Number(value.walletAssetAmount);
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请输入金币数量",
|
||||||
|
path: ["walletAssetAmount"],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const amount = Number(value.walletAssetAmount);
|
if (value.priceType === "coin") {
|
||||||
if (!Number.isInteger(amount) || amount <= 0) {
|
const coinPrice = Number(value.coinPrice);
|
||||||
context.addIssue({
|
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
|
||||||
code: "custom",
|
context.addIssue({
|
||||||
message: "请输入金币数量",
|
code: "custom",
|
||||||
path: ["walletAssetAmount"],
|
message: "请输入资源价格",
|
||||||
});
|
path: ["coinPrice"],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -170,7 +192,7 @@ export const giftFormSchema = z
|
|||||||
path: ["resourceId"],
|
path: ["resourceId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
|
if (!Number.isInteger(coinPrice) || coinPrice < 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请输入价格",
|
message: "请输入价格",
|
||||||
@ -184,6 +206,13 @@ export const giftFormSchema = z
|
|||||||
path: ["giftPointAmount"],
|
path: ["giftPointAmount"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (Number.isInteger(coinPrice) && Number.isInteger(giftPointAmount) && giftPointAmount !== coinPrice) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "积分必须同步价格",
|
||||||
|
path: ["giftPointAmount"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!Number.isInteger(heatValue) || heatValue < 0) {
|
if (!Number.isInteger(heatValue) || heatValue < 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
@ -1,120 +1,150 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
import {
|
import {
|
||||||
emojiPackFormSchema,
|
emojiPackFormSchema,
|
||||||
giftFormSchema,
|
giftFormSchema,
|
||||||
resourceFormSchema,
|
resourceFormSchema,
|
||||||
resourceGrantFormSchema,
|
resourceGrantFormSchema,
|
||||||
resourceGroupCreateFormSchema
|
resourceGroupCreateFormSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
describe("resource form schema", () => {
|
describe("resource form schema", () => {
|
||||||
test("allows profile card resources", () => {
|
test("allows profile card resources", () => {
|
||||||
const payload = parseForm(resourceFormSchema, {
|
const payload = parseForm(resourceFormSchema, {
|
||||||
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
||||||
enabled: true,
|
badgeForm: "tile",
|
||||||
name: "Profile Card",
|
enabled: true,
|
||||||
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
name: "Profile Card",
|
||||||
resourceCode: "profile_card_gold",
|
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
||||||
resourceType: "profile_card",
|
priceType: "free",
|
||||||
walletAssetAmount: ""
|
resourceCode: "profile_card_gold",
|
||||||
|
resourceType: "profile_card",
|
||||||
|
walletAssetAmount: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.resourceType).toBe("profile_card");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.resourceType).toBe("profile_card");
|
test("requires badge form for badge resources", () => {
|
||||||
});
|
const payload = parseForm(resourceFormSchema, {
|
||||||
|
animationUrl: "https://media.haiyihy.com/resource/badge.pag",
|
||||||
|
badgeForm: "strip",
|
||||||
|
enabled: true,
|
||||||
|
name: "VIP Badge",
|
||||||
|
previewUrl: "https://media.haiyihy.com/resource/badge.png",
|
||||||
|
priceType: "free",
|
||||||
|
resourceCode: "badge_vip",
|
||||||
|
resourceType: "badge",
|
||||||
|
walletAssetAmount: "",
|
||||||
|
});
|
||||||
|
|
||||||
test("allows resource group resource items without wallet asset type", () => {
|
expect(payload.badgeForm).toBe("strip");
|
||||||
const payload = parseForm(resourceGroupCreateFormSchema, {
|
expect(() =>
|
||||||
description: "",
|
parseForm(resourceFormSchema, {
|
||||||
enabled: true,
|
animationUrl: "https://media.haiyihy.com/resource/badge.pag",
|
||||||
groupCode: "starter_pack",
|
enabled: true,
|
||||||
items: [
|
name: "VIP Badge",
|
||||||
{
|
previewUrl: "https://media.haiyihy.com/resource/badge.png",
|
||||||
durationDays: "7",
|
priceType: "free",
|
||||||
itemType: "resource",
|
resourceCode: "badge_vip",
|
||||||
resourceId: "11",
|
resourceType: "badge",
|
||||||
sortOrder: "0",
|
walletAssetAmount: "",
|
||||||
walletAssetAmount: "",
|
}),
|
||||||
walletAssetType: ""
|
).toThrow(FormValidationError);
|
||||||
}
|
|
||||||
],
|
|
||||||
name: "Starter Pack",
|
|
||||||
sortOrder: "0"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.items[0].walletAssetType).toBeUndefined();
|
test("allows resource group resource items without wallet asset type", () => {
|
||||||
});
|
const payload = parseForm(resourceGroupCreateFormSchema, {
|
||||||
|
description: "",
|
||||||
test("rejects invalid wallet asset type with Chinese message", () => {
|
enabled: true,
|
||||||
expect(() =>
|
groupCode: "starter_pack",
|
||||||
parseForm(resourceGroupCreateFormSchema, {
|
items: [
|
||||||
description: "",
|
{
|
||||||
enabled: true,
|
durationDays: "7",
|
||||||
groupCode: "starter_pack",
|
itemType: "resource",
|
||||||
items: [
|
resourceId: "11",
|
||||||
{
|
sortOrder: "0",
|
||||||
durationDays: "",
|
walletAssetAmount: "",
|
||||||
itemType: "wallet_asset",
|
walletAssetType: "",
|
||||||
resourceId: "",
|
},
|
||||||
|
],
|
||||||
|
name: "Starter Pack",
|
||||||
sortOrder: "0",
|
sortOrder: "0",
|
||||||
walletAssetAmount: "100",
|
});
|
||||||
walletAssetType: "gold"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
name: "Starter Pack",
|
|
||||||
sortOrder: "0"
|
|
||||||
})
|
|
||||||
).toThrow(FormValidationError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validates editable gift form fields", () => {
|
expect(payload.items[0].walletAssetType).toBeUndefined();
|
||||||
const payload = parseForm(giftFormSchema, {
|
|
||||||
chargeAssetType: "COIN",
|
|
||||||
coinPrice: "10",
|
|
||||||
effectTypes: ["animation"],
|
|
||||||
effectiveFrom: "",
|
|
||||||
effectiveTo: "",
|
|
||||||
enabled: true,
|
|
||||||
giftId: "rose",
|
|
||||||
giftPointAmount: "1",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
heatValue: "2",
|
|
||||||
name: "Rose",
|
|
||||||
presentationJson: "{}",
|
|
||||||
priceVersion: "default",
|
|
||||||
regionIds: ["0", "1001"],
|
|
||||||
resourceId: "11",
|
|
||||||
sortOrder: "0"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.regionIds).toEqual(["0", "1001"]);
|
test("rejects invalid wallet asset type with Chinese message", () => {
|
||||||
});
|
expect(() =>
|
||||||
|
parseForm(resourceGroupCreateFormSchema, {
|
||||||
test("validates emoji pack category and pricing type", () => {
|
description: "",
|
||||||
const payload = parseForm(emojiPackFormSchema, {
|
enabled: true,
|
||||||
animationUrl: "https://media.haiyihy.com/emoji/wave.svga",
|
groupCode: "starter_pack",
|
||||||
category: "热门",
|
items: [
|
||||||
coverUrl: "https://media.haiyihy.com/emoji/wave.png",
|
{
|
||||||
name: "Wave",
|
durationDays: "",
|
||||||
pricingType: "paid",
|
itemType: "wallet_asset",
|
||||||
regionIds: ["0"],
|
resourceId: "",
|
||||||
sortOrder: "0"
|
sortOrder: "0",
|
||||||
|
walletAssetAmount: "100",
|
||||||
|
walletAssetType: "gold",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: "Starter Pack",
|
||||||
|
sortOrder: "0",
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.category).toBe("热门");
|
test("validates editable gift form fields", () => {
|
||||||
expect(payload.pricingType).toBe("paid");
|
const payload = parseForm(giftFormSchema, {
|
||||||
});
|
chargeAssetType: "COIN",
|
||||||
|
coinPrice: "10",
|
||||||
|
effectTypes: ["animation"],
|
||||||
|
effectiveFrom: "",
|
||||||
|
effectiveTo: "",
|
||||||
|
enabled: true,
|
||||||
|
giftId: "rose",
|
||||||
|
giftPointAmount: "10",
|
||||||
|
giftTypeCode: "normal",
|
||||||
|
heatValue: "2",
|
||||||
|
name: "Rose",
|
||||||
|
presentationJson: "{}",
|
||||||
|
priceVersion: "default",
|
||||||
|
regionIds: ["0", "1001"],
|
||||||
|
resourceId: "11",
|
||||||
|
sortOrder: "0",
|
||||||
|
});
|
||||||
|
|
||||||
test("validates resource grant form fields", () => {
|
expect(payload.regionIds).toEqual(["0", "1001"]);
|
||||||
const payload = parseForm(resourceGrantFormSchema, {
|
|
||||||
durationDays: "7",
|
|
||||||
quantity: "1",
|
|
||||||
reason: "manual",
|
|
||||||
resourceId: "11",
|
|
||||||
subjectType: "resource",
|
|
||||||
targetUserId: "1001"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.subjectType).toBe("resource");
|
test("validates emoji pack category and pricing type", () => {
|
||||||
});
|
const payload = parseForm(emojiPackFormSchema, {
|
||||||
|
animationUrl: "https://media.haiyihy.com/emoji/wave.svga",
|
||||||
|
category: "热门",
|
||||||
|
coverUrl: "https://media.haiyihy.com/emoji/wave.png",
|
||||||
|
name: "Wave",
|
||||||
|
pricingType: "paid",
|
||||||
|
regionIds: ["0"],
|
||||||
|
sortOrder: "0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.category).toBe("热门");
|
||||||
|
expect(payload.pricingType).toBe("paid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates resource grant form fields", () => {
|
||||||
|
const payload = parseForm(resourceGrantFormSchema, {
|
||||||
|
durationDays: "7",
|
||||||
|
quantity: "1",
|
||||||
|
reason: "manual",
|
||||||
|
resourceId: "11",
|
||||||
|
subjectType: "resource",
|
||||||
|
targetUserId: "1001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.subjectType).toBe("resource");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -27,8 +27,8 @@ const permissionGroupDefinitions = [
|
|||||||
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
||||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
|
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
|
||||||
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
|
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
|
||||||
{ key: "hosts", prefixes: ["host"], title: "主播列表" },
|
{ key: "hosts", prefixes: ["host"], title: "Host 列表" },
|
||||||
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" },
|
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "Coin Saller列表" },
|
||||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||||
];
|
];
|
||||||
|
|||||||
155
src/features/user-leaderboard/api.ts
Normal file
155
src/features/user-leaderboard/api.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import type { PageQuery } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export type UserLeaderboardBoardType = "sent" | "received" | "room";
|
||||||
|
export type UserLeaderboardPeriod = "today" | "week" | "month";
|
||||||
|
|
||||||
|
export interface UserLeaderboardUserDto {
|
||||||
|
userId: string;
|
||||||
|
displayUserId?: string;
|
||||||
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserLeaderboardRoomDto {
|
||||||
|
roomId: string;
|
||||||
|
roomShortId?: string;
|
||||||
|
title?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
ownerUserId?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserLeaderboardItemDto {
|
||||||
|
rank: number;
|
||||||
|
userId?: string;
|
||||||
|
roomId?: string;
|
||||||
|
giftValue: number;
|
||||||
|
giftCount: number;
|
||||||
|
transactionCount: number;
|
||||||
|
lastGiftAtMs: number;
|
||||||
|
user?: UserLeaderboardUserDto;
|
||||||
|
room?: UserLeaderboardRoomDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserLeaderboardResponseDto {
|
||||||
|
items: UserLeaderboardItemDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
boardType: UserLeaderboardBoardType;
|
||||||
|
period: UserLeaderboardPeriod;
|
||||||
|
startAtMs: number;
|
||||||
|
endAtMs: number;
|
||||||
|
serverTimeMs: number;
|
||||||
|
myRank?: UserLeaderboardItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserLeaderboardQuery extends PageQuery {
|
||||||
|
board_type?: UserLeaderboardBoardType;
|
||||||
|
period?: UserLeaderboardPeriod;
|
||||||
|
viewer_user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawUser = UserLeaderboardUserDto & Record<string, unknown>;
|
||||||
|
type RawRoom = UserLeaderboardRoomDto & Record<string, unknown>;
|
||||||
|
type RawItem = Omit<UserLeaderboardItemDto, "room" | "user"> & {
|
||||||
|
last_gift_at_ms?: number | string;
|
||||||
|
transaction_count?: number | string;
|
||||||
|
gift_count?: number | string;
|
||||||
|
gift_value?: number | string;
|
||||||
|
user_id?: string | number;
|
||||||
|
room_id?: string;
|
||||||
|
user?: RawUser;
|
||||||
|
room?: RawRoom;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
type RawResponse = Omit<UserLeaderboardResponseDto, "items" | "myRank"> & {
|
||||||
|
board_type?: UserLeaderboardBoardType;
|
||||||
|
end_at_ms?: number | string;
|
||||||
|
items?: RawItem[];
|
||||||
|
my_rank?: RawItem | null;
|
||||||
|
myRank?: RawItem | null;
|
||||||
|
page_size?: number | string;
|
||||||
|
server_time_ms?: number | string;
|
||||||
|
start_at_ms?: number | string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function listUserLeaderboards(query: UserLeaderboardQuery = {}): Promise<UserLeaderboardResponseDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.listUserLeaderboards;
|
||||||
|
return apiRequest<RawResponse>(apiEndpointPath(API_OPERATIONS.listUserLeaderboards), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then(normalizeResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponse(response: RawResponse): UserLeaderboardResponseDto {
|
||||||
|
return {
|
||||||
|
items: (response.items || []).map(normalizeItem),
|
||||||
|
total: numberValue(response.total),
|
||||||
|
page: numberValue(response.page) || 1,
|
||||||
|
pageSize: numberValue(response.pageSize ?? response.page_size) || 20,
|
||||||
|
boardType: normalizeBoardType(response.boardType ?? response.board_type),
|
||||||
|
period: normalizePeriod(response.period),
|
||||||
|
startAtMs: numberValue(response.startAtMs ?? response.start_at_ms),
|
||||||
|
endAtMs: numberValue(response.endAtMs ?? response.end_at_ms),
|
||||||
|
serverTimeMs: numberValue(response.serverTimeMs ?? response.server_time_ms),
|
||||||
|
myRank: response.myRank || response.my_rank ? normalizeItem((response.myRank || response.my_rank) as RawItem) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItem(item: RawItem): UserLeaderboardItemDto {
|
||||||
|
return {
|
||||||
|
rank: numberValue(item.rank),
|
||||||
|
userId: optionalString(item.userId ?? item.user_id),
|
||||||
|
roomId: optionalString(item.roomId ?? item.room_id),
|
||||||
|
giftValue: numberValue(item.giftValue ?? item.gift_value),
|
||||||
|
giftCount: numberValue(item.giftCount ?? item.gift_count),
|
||||||
|
transactionCount: numberValue(item.transactionCount ?? item.transaction_count),
|
||||||
|
lastGiftAtMs: numberValue(item.lastGiftAtMs ?? item.last_gift_at_ms),
|
||||||
|
user: item.user ? normalizeUser(item.user) : undefined,
|
||||||
|
room: item.room ? normalizeRoom(item.room) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUser(user: RawUser): UserLeaderboardUserDto {
|
||||||
|
return {
|
||||||
|
userId: stringValue(user.userId ?? user.user_id),
|
||||||
|
displayUserId: optionalString(user.displayUserId ?? user.display_user_id),
|
||||||
|
username: optionalString(user.username),
|
||||||
|
avatar: optionalString(user.avatar),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoom(room: RawRoom): UserLeaderboardRoomDto {
|
||||||
|
return {
|
||||||
|
roomId: stringValue(room.roomId ?? room.room_id),
|
||||||
|
roomShortId: optionalString(room.roomShortId ?? room.room_short_id),
|
||||||
|
title: optionalString(room.title),
|
||||||
|
coverUrl: optionalString(room.coverUrl ?? room.cover_url),
|
||||||
|
ownerUserId: optionalString(room.ownerUserId ?? room.owner_user_id),
|
||||||
|
status: optionalString(room.status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoardType(value: unknown): UserLeaderboardBoardType {
|
||||||
|
return value === "received" || value === "room" ? value : "sent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePeriod(value: unknown): UserLeaderboardPeriod {
|
||||||
|
return value === "week" || value === "month" ? value : "today";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return value === null || value === undefined ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown) {
|
||||||
|
const text = stringValue(value).trim();
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const next = Number(value ?? 0);
|
||||||
|
return Number.isFinite(next) ? next : 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { listUserLeaderboards } from "@/features/user-leaderboard/api";
|
||||||
|
import { useUserLeaderboardAbilities } from "@/features/user-leaderboard/permissions.js";
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
export function useUserLeaderboardPage() {
|
||||||
|
const abilities = useUserLeaderboardAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [boardType, setBoardType] = useState("sent");
|
||||||
|
const [period, setPeriod] = useState("today");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [data, setData] = useState(emptyData());
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const nextData = await listUserLeaderboards({
|
||||||
|
board_type: boardType,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
period,
|
||||||
|
});
|
||||||
|
setData(nextData);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.message || "加载用户榜单失败";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [boardType, page, period, showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const changeBoardType = useCallback((nextValue) => {
|
||||||
|
setBoardType(nextValue || "sent");
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changePeriod = useCallback((nextValue) => {
|
||||||
|
setPeriod(nextValue || "today");
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setBoardType("sent");
|
||||||
|
setPeriod("today");
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
boardType,
|
||||||
|
changeBoardType,
|
||||||
|
changePeriod,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
page,
|
||||||
|
period,
|
||||||
|
reload,
|
||||||
|
resetFilters,
|
||||||
|
setPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyData() {
|
||||||
|
return {
|
||||||
|
boardType: "sent",
|
||||||
|
endAtMs: 0,
|
||||||
|
items: [],
|
||||||
|
myRank: null,
|
||||||
|
page: 1,
|
||||||
|
pageSize,
|
||||||
|
period: "today",
|
||||||
|
serverTimeMs: 0,
|
||||||
|
startAtMs: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
162
src/features/user-leaderboard/pages/UserLeaderboardPage.jsx
Normal file
162
src/features/user-leaderboard/pages/UserLeaderboardPage.jsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import {
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { useUserLeaderboardPage } from "@/features/user-leaderboard/hooks/useUserLeaderboardPage.js";
|
||||||
|
import styles from "@/features/user-leaderboard/user-leaderboard.module.css";
|
||||||
|
|
||||||
|
const boardTypeOptions = [
|
||||||
|
["sent", "用户送礼榜"],
|
||||||
|
["received", "收礼榜"],
|
||||||
|
["room", "房间礼物值榜"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
["today", "今天"],
|
||||||
|
["week", "一周"],
|
||||||
|
["month", "一月"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "rank",
|
||||||
|
label: "排名",
|
||||||
|
width: "minmax(96px, 0.45fr)",
|
||||||
|
render: (item) => <span className={styles.rank}>#{item.rank}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "subject",
|
||||||
|
label: "对象",
|
||||||
|
width: "minmax(280px, 1.25fr)",
|
||||||
|
render: (item, _index, context) => <LeaderboardSubject item={item} type={context.boardType} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "giftValue",
|
||||||
|
label: "礼物值",
|
||||||
|
width: "minmax(150px, 0.75fr)",
|
||||||
|
render: (item) => formatNumber(item.giftValue),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "giftCount",
|
||||||
|
label: "礼物数量",
|
||||||
|
width: "minmax(130px, 0.65fr)",
|
||||||
|
render: (item) => formatNumber(item.giftCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "transactionCount",
|
||||||
|
label: "交易数",
|
||||||
|
width: "minmax(120px, 0.6fr)",
|
||||||
|
render: (item) => formatNumber(item.transactionCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lastGiftAtMs",
|
||||||
|
label: "最后送礼时间",
|
||||||
|
width: "minmax(180px, 0.85fr)",
|
||||||
|
render: (item) => `${formatMillis(item.lastGiftAtMs, "UTC")} UTC`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function UserLeaderboardPage() {
|
||||||
|
const page = useUserLeaderboardPage();
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const total = page.data.total || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="榜单"
|
||||||
|
options={boardTypeOptions}
|
||||||
|
value={page.boardType}
|
||||||
|
onChange={page.changeBoardType}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="时间维度"
|
||||||
|
options={periodOptions}
|
||||||
|
value={page.period}
|
||||||
|
onChange={page.changePeriod}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={page.boardType === "sent" && page.period === "today"}
|
||||||
|
onClick={page.resetFilters}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={styles.summary}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.summaryLabel}>UTC 时间范围</span>
|
||||||
|
<span className={styles.summaryValue}>
|
||||||
|
{formatMillis(page.data.startAtMs, "UTC")} - {formatMillis(page.data.endAtMs, "UTC")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{page.data.myRank ? (
|
||||||
|
<div>
|
||||||
|
<span className={styles.summaryLabel}>当前用户排名</span>
|
||||||
|
<span className={styles.summaryValue}>
|
||||||
|
#{page.data.myRank.rank} · {formatNumber(page.data.myRank.giftValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
context={{ boardType: page.boardType }}
|
||||||
|
items={items}
|
||||||
|
minWidth="960px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.data.pageSize || 20,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => `${page.boardType}-${item.userId || item.roomId || item.rank}`}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderboardSubject({ item, type }) {
|
||||||
|
if (type === "room") {
|
||||||
|
const room = item.room || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<span className={styles.name}>{room.title || item.roomId || "-"}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{room.roomShortId ? `短号 ${room.roomShortId}` : item.roomId || "-"}
|
||||||
|
{room.ownerUserId ? ` · 房主 ${room.ownerUserId}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const user = item.user || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<span className={styles.name}>{user.username || user.displayUserId || item.userId || "-"}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{user.displayUserId ? `短号 ${user.displayUserId}` : ""}
|
||||||
|
{item.userId ? `${user.displayUserId ? " · " : ""}ID ${item.userId}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
9
src/features/user-leaderboard/permissions.js
Normal file
9
src/features/user-leaderboard/permissions.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useUserLeaderboardAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
return {
|
||||||
|
canView: can(PERMISSIONS.userLeaderboardView),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/user-leaderboard/routes.js
Normal file
12
src/features/user-leaderboard/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const userLeaderboardRoutes = [
|
||||||
|
{
|
||||||
|
label: "用户榜单",
|
||||||
|
loader: () => import("./pages/UserLeaderboardPage.jsx").then((module) => module.UserLeaderboardPage),
|
||||||
|
menuCode: MENU_CODES.userLeaderboard,
|
||||||
|
pageKey: "user-leaderboard",
|
||||||
|
path: "/activities/user-leaderboards",
|
||||||
|
permission: PERMISSIONS.userLeaderboardView,
|
||||||
|
},
|
||||||
|
];
|
||||||
50
src/features/user-leaderboard/user-leaderboard.module.css
Normal file
50
src/features/user-leaderboard/user-leaderboard.module.css
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryValue {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
408
src/shared/api/generated/schema.d.ts
vendored
408
src/shared/api/generated/schema.d.ts
vendored
@ -84,6 +84,70 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/user-leaderboards": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listUserLeaderboards"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
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": {
|
"/admin/activity/lucky-gifts/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -100,6 +164,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/lucky-gifts/configs": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listLuckyGiftConfigs"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/lucky-gifts/draws": {
|
"/admin/activity/lucky-gifts/draws": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -116,6 +196,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/lucky-gifts/draw-summary": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getLuckyGiftDrawSummary"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/first-recharge-reward/claims": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listFirstRechargeRewardClaims"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/registration-reward/claims": {
|
"/admin/activity/registration-reward/claims": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -132,6 +244,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/first-recharge-reward/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getFirstRechargeRewardConfig"];
|
||||||
|
put: operations["updateFirstRechargeRewardConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/registration-reward/config": {
|
"/admin/activity/registration-reward/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -285,13 +413,61 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get: operations["listH5Links"];
|
get: operations["listH5Links"];
|
||||||
put: operations["updateH5Links"];
|
put: operations["updateH5Links"];
|
||||||
post?: never;
|
post: operations["createH5Link"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/app-config/h5-links/{key}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateH5Link"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteH5Link"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/app-config/explore-tabs": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listExploreTabs"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createExploreTab"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/app-config/explore-tabs/{tab_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateExploreTab"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteExploreTab"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/app-config/versions": {
|
"/admin/app-config/versions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -622,7 +798,7 @@ export interface paths {
|
|||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete: operations["deleteCatalog"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch: operations["updateCatalog"];
|
patch: operations["updateCatalog"];
|
||||||
@ -2432,6 +2608,68 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listUserLeaderboards: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
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: {
|
getLuckyGiftConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2456,6 +2694,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listLuckyGiftConfigs: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listLuckyGiftDraws: {
|
listLuckyGiftDraws: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2468,6 +2718,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getLuckyGiftDrawSummary: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listFirstRechargeRewardClaims: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listRegistrationRewardClaims: {
|
listRegistrationRewardClaims: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2480,6 +2754,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getFirstRechargeRewardConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateFirstRechargeRewardConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getRegistrationRewardConfig: {
|
getRegistrationRewardConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2699,6 +2997,98 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
createH5Link: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateH5Link: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteH5Link: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listExploreTabs: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createExploreTab: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateExploreTab: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tab_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteExploreTab: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tab_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listAppVersions: {
|
listAppVersions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3096,6 +3486,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteCatalog: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
game_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
updateCatalog: {
|
updateCatalog: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -618,13 +618,32 @@ export interface H5LinkConfigDto {
|
|||||||
|
|
||||||
export interface H5LinkConfigPayload {
|
export interface H5LinkConfigPayload {
|
||||||
key: string;
|
key: string;
|
||||||
url?: string;
|
label: string;
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface H5LinkConfigUpdatePayload {
|
export interface H5LinkConfigUpdatePayload {
|
||||||
items: H5LinkConfigPayload[];
|
items: H5LinkConfigPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExploreTabDto {
|
||||||
|
appCode?: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
enabled: boolean;
|
||||||
|
h5Url: string;
|
||||||
|
id: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
tab: string;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExploreTabPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
h5Url: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
tab: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppBannerDto {
|
export interface AppBannerDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
bannerType: "h5" | "app" | string;
|
bannerType: "h5" | "app" | string;
|
||||||
|
|||||||
@ -1,87 +1,126 @@
|
|||||||
import Switch from "@mui/material/Switch";
|
import Switch from "@mui/material/Switch";
|
||||||
|
|
||||||
const baseSwitchSx = {
|
const baseSwitchSx = {
|
||||||
"--switch-width": "40px",
|
"--switch-width": "40px",
|
||||||
"--switch-height": "24px",
|
"--switch-height": "24px",
|
||||||
"--switch-thumb-size": "18px",
|
"--switch-thumb-size": "18px",
|
||||||
"--switch-padding": "3px",
|
"--switch-padding": "3px",
|
||||||
"--switch-translate-x": "16px",
|
"--switch-translate-x": "16px",
|
||||||
margin: 0
|
margin: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function cssContent(value) {
|
function cssContent(value) {
|
||||||
return `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
return `"${String(value ?? "")
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function labeledSwitchSx(checkedLabel, uncheckedLabel) {
|
function labeledSwitchSx(checkedLabel, uncheckedLabel) {
|
||||||
const labelLength = Math.max(Array.from(String(checkedLabel || "")).length, Array.from(String(uncheckedLabel || "")).length);
|
const labelLength = Math.max(
|
||||||
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
|
Array.from(String(checkedLabel || "")).length,
|
||||||
|
Array.from(String(uncheckedLabel || "")).length,
|
||||||
|
);
|
||||||
|
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"--switch-width": `${width}px`,
|
"--switch-width": `${width}px`,
|
||||||
"--switch-height": "26px",
|
"--switch-height": "26px",
|
||||||
"--switch-thumb-size": "20px",
|
"--switch-thumb-size": "20px",
|
||||||
"--switch-padding": "3px",
|
"--switch-padding": "3px",
|
||||||
"--switch-translate-x": `${width - 26}px`,
|
"--switch-translate-x": `${width - 26}px`,
|
||||||
"& .MuiSwitch-track": {
|
width: "var(--switch-width)",
|
||||||
overflow: "hidden",
|
height: "var(--switch-height)",
|
||||||
position: "relative",
|
padding: 0,
|
||||||
"&::before, &::after": {
|
overflow: "visible",
|
||||||
position: "absolute",
|
"& .MuiSwitch-switchBase": {
|
||||||
top: 0,
|
padding: "var(--switch-padding)",
|
||||||
bottom: 0,
|
"&.Mui-checked": {
|
||||||
display: "flex",
|
transform: "translateX(var(--switch-translate-x))",
|
||||||
alignItems: "center",
|
},
|
||||||
fontSize: 10.5,
|
},
|
||||||
fontWeight: 760,
|
"& .MuiSwitch-thumb": {
|
||||||
lineHeight: 1,
|
width: "var(--switch-thumb-size)",
|
||||||
pointerEvents: "none",
|
height: "var(--switch-thumb-size)",
|
||||||
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)"
|
},
|
||||||
},
|
"& .MuiSwitch-track": {
|
||||||
"&::before": {
|
overflow: "hidden",
|
||||||
right: 26,
|
position: "relative",
|
||||||
left: 7,
|
"&::before, &::after": {
|
||||||
justifyContent: "flex-start",
|
position: "absolute",
|
||||||
color: "var(--active-contrast)",
|
top: 0,
|
||||||
content: cssContent(checkedLabel),
|
bottom: 0,
|
||||||
opacity: 0
|
display: "flex",
|
||||||
},
|
alignItems: "center",
|
||||||
"&::after": {
|
fontSize: "10px",
|
||||||
right: 7,
|
fontWeight: 720,
|
||||||
left: 26,
|
letterSpacing: 0,
|
||||||
justifyContent: "flex-end",
|
lineHeight: 1,
|
||||||
color: "var(--text-tertiary)",
|
whiteSpace: "nowrap",
|
||||||
content: cssContent(uncheckedLabel),
|
pointerEvents: "none",
|
||||||
opacity: 1
|
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)",
|
||||||
}
|
},
|
||||||
},
|
"&::before": {
|
||||||
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::before": {
|
left: 5,
|
||||||
opacity: 1
|
right: 20,
|
||||||
},
|
justifyContent: "flex-start",
|
||||||
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::after": {
|
color: "var(--active-contrast)",
|
||||||
opacity: 0
|
content: cssContent(checkedLabel),
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
"&::after": {
|
||||||
|
right: 5,
|
||||||
|
left: 20,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
content: cssContent(uncheckedLabel),
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::before": {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::after": {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInputSlotProps(slotInputProps, fallbackInputProps) {
|
||||||
|
if (!fallbackInputProps) {
|
||||||
|
return slotInputProps;
|
||||||
}
|
}
|
||||||
};
|
if (typeof slotInputProps === "function") {
|
||||||
|
return (ownerState) => ({
|
||||||
|
...fallbackInputProps,
|
||||||
|
...slotInputProps(ownerState),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...fallbackInputProps,
|
||||||
|
...slotInputProps,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSwitch({ checkedLabel, inputProps, label, sx, uncheckedLabel, ...props }) {
|
export function AdminSwitch({ checkedLabel, inputProps, label, slotProps, sx, uncheckedLabel, ...props }) {
|
||||||
const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel);
|
const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel);
|
||||||
const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null;
|
const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null;
|
||||||
const mergedSx = Array.isArray(sx)
|
const mergedSx = Array.isArray(sx)
|
||||||
? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean)
|
? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean)
|
||||||
: [baseSwitchSx, switchLabelSx, sx].filter(Boolean);
|
: [baseSwitchSx, switchLabelSx, sx].filter(Boolean);
|
||||||
const mergedInputProps = label
|
const fallbackInputProps = label || inputProps || slotProps?.input
|
||||||
? {
|
? {
|
||||||
"aria-label": label,
|
role: "switch",
|
||||||
...inputProps
|
...(label ? { "aria-label": label } : null),
|
||||||
}
|
...inputProps,
|
||||||
: inputProps;
|
}
|
||||||
|
: undefined;
|
||||||
|
const mergedSlotProps =
|
||||||
|
fallbackInputProps || slotProps
|
||||||
|
? {
|
||||||
|
...slotProps,
|
||||||
|
input: mergeInputSlotProps(slotProps?.input, fallbackInputProps),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return <Switch {...props} slotProps={mergedSlotProps} sx={mergedSx} />;
|
||||||
<Switch
|
|
||||||
{...props}
|
|
||||||
inputProps={mergedInputProps}
|
|
||||||
sx={mergedSx}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export function DataTable({
|
|||||||
emptyLabel = "当前无数据",
|
emptyLabel = "当前无数据",
|
||||||
fixedEdges = true,
|
fixedEdges = true,
|
||||||
getRowProps,
|
getRowProps,
|
||||||
|
infiniteScroll,
|
||||||
items,
|
items,
|
||||||
minWidth = "980px",
|
minWidth = "980px",
|
||||||
onColumnWidthsChange,
|
onColumnWidthsChange,
|
||||||
@ -38,8 +39,11 @@ export function DataTable({
|
|||||||
const [filterQuery, setFilterQuery] = useState("");
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
|
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
|
||||||
const [resizeSession, setResizeSession] = useState(null);
|
const [resizeSession, setResizeSession] = useState(null);
|
||||||
|
const loadMoreRef = useRef(null);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const safeItems = Array.isArray(items) ? items : [];
|
const safeItems = Array.isArray(items) ? items : [];
|
||||||
|
const infiniteLoading = Boolean(infiniteScroll?.loading);
|
||||||
|
const infiniteHasMore = Boolean(infiniteScroll?.hasMore);
|
||||||
const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]);
|
const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]);
|
||||||
|
|
||||||
const preparedColumns = useMemo(
|
const preparedColumns = useMemo(
|
||||||
@ -132,6 +136,28 @@ export function DataTable({
|
|||||||
};
|
};
|
||||||
}, [onColumnWidthsChange, resizeSession]);
|
}, [onColumnWidthsChange, resizeSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scrollRef.current;
|
||||||
|
const target = loadMoreRef.current;
|
||||||
|
if (!root || !target || !infiniteScroll?.onLoadMore || !infiniteHasMore || infiniteLoading) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
infiniteScroll.onLoadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root, rootMargin: "0px 0px 96px", threshold: 0.01 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(target);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [infiniteHasMore, infiniteLoading, infiniteScroll, safeItems.length]);
|
||||||
|
|
||||||
const startResize = (event, column) => {
|
const startResize = (event, column) => {
|
||||||
if (!resizableColumns || column.resizable === false) {
|
if (!resizableColumns || column.resizable === false) {
|
||||||
return;
|
return;
|
||||||
@ -247,25 +273,48 @@ export function DataTable({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{safeItems.length ? (
|
{safeItems.length ? (
|
||||||
safeItems.map((item, index) => {
|
<>
|
||||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
{safeItems.map((item, index) => {
|
||||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||||
|
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||||
{preparedColumns.map((column) => (
|
{preparedColumns.map((column) => (
|
||||||
<div className={cellClassName(column)} key={column.key}>
|
<div className={cellClassName(column)} key={column.key}>
|
||||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{infiniteScroll ? (
|
||||||
|
<div
|
||||||
|
className={["admin-row", "admin-row--load-more", !infiniteHasMore && !infiniteLoading ? "admin-row--load-more-done" : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
ref={loadMoreRef}
|
||||||
|
>
|
||||||
|
<div className="admin-cell admin-cell--load-more">
|
||||||
|
{infiniteLoading ? infiniteScroll.loadingLabel || "加载中..." : infiniteHasMore ? "" : infiniteScroll.doneLabel || ""}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null}
|
||||||
})
|
</>
|
||||||
|
) : infiniteLoading ? (
|
||||||
|
<div className="admin-row admin-row--load-more" ref={loadMoreRef}>
|
||||||
|
<div className="admin-cell admin-cell--load-more">
|
||||||
|
{infiniteScroll?.loadingLabel || "加载中..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state empty-state--table">
|
<div className="empty-state empty-state--table">
|
||||||
<div className="empty-state__title">{emptyLabel}</div>
|
<div className="empty-state__title">{emptyLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{safeItems.length === 0 && infiniteScroll && !infiniteLoading ? (
|
||||||
|
<div className="admin-row--load-more-sentinel" ref={loadMoreRef} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{pagination ? <PaginationBar {...pagination} /> : null}
|
{pagination ? <PaginationBar {...pagination} /> : null}
|
||||||
|
|||||||
114
src/shared/ui/JsonEditorField.jsx
Normal file
114
src/shared/ui/JsonEditorField.jsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import styles from "@/shared/ui/JsonEditorField.module.css";
|
||||||
|
|
||||||
|
export function JsonEditorField({
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
minRows = 6,
|
||||||
|
onChange,
|
||||||
|
required = false,
|
||||||
|
value,
|
||||||
|
}) {
|
||||||
|
const [validated, setValidated] = useState(false);
|
||||||
|
const validation = useMemo(() => validateJsonObject(value), [value]);
|
||||||
|
const showError = validated && !validation.valid;
|
||||||
|
const helperText = showError
|
||||||
|
? validation.message
|
||||||
|
: validated
|
||||||
|
? "JSON 格式正确"
|
||||||
|
: "支持格式化和校验,内容必须是 JSON 对象";
|
||||||
|
const lineCount = Math.max(1, String(value || "").split("\n").length);
|
||||||
|
|
||||||
|
const formatJson = () => {
|
||||||
|
const result = parseJsonObject(value);
|
||||||
|
setValidated(true);
|
||||||
|
if (!result.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(JSON.stringify(result.value, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.root, className].filter(Boolean).join(" ")}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{required ? <span className={styles.required}>*</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button disabled={disabled} onClick={() => setValidated(true)}>
|
||||||
|
校验 JSON
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled} onClick={formatJson}>
|
||||||
|
格式化 JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={[styles.editor, showError ? styles.editorInvalid : ""].filter(Boolean).join(" ")}>
|
||||||
|
<pre className={styles.gutter} aria-hidden="true">
|
||||||
|
{Array.from({ length: lineCount }, (_, index) => index + 1).join("\n")}
|
||||||
|
</pre>
|
||||||
|
<TextField
|
||||||
|
className={styles.editorField}
|
||||||
|
disabled={disabled}
|
||||||
|
minRows={minRows}
|
||||||
|
multiline
|
||||||
|
slotProps={{
|
||||||
|
input: { disableUnderline: true },
|
||||||
|
htmlInput: {
|
||||||
|
"aria-label": label,
|
||||||
|
autoCapitalize: "off",
|
||||||
|
autoCorrect: "off",
|
||||||
|
spellCheck: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={value}
|
||||||
|
variant="standard"
|
||||||
|
onBlur={() => setValidated(true)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValidated(false);
|
||||||
|
onChange(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[styles.helper, showError ? styles.helperInvalid : validated ? styles.helperValid : ""].join(
|
||||||
|
" ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{helperText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJsonObjectString(raw) {
|
||||||
|
const result = parseJsonObject(raw);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
return JSON.stringify(result.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateJsonObject(raw) {
|
||||||
|
return parseJsonObject(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw) {
|
||||||
|
const value = String(raw || "").trim();
|
||||||
|
if (!value) {
|
||||||
|
return { valid: true, value: {} };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return { valid: false, message: '配置 JSON 必须是对象,例如 {"app_id": 123}' };
|
||||||
|
}
|
||||||
|
return { valid: true, value: parsed };
|
||||||
|
} catch (err) {
|
||||||
|
return { valid: false, message: `JSON 格式错误:${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/shared/ui/JsonEditorField.module.css
Normal file
105
src/shared/ui/JsonEditorField.module.css
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(90deg, var(--bg-card-strong) 0 44px, transparent 44px), var(--bg-card);
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor:focus-within {
|
||||||
|
border-color: var(--primary-border-strong);
|
||||||
|
box-shadow: 0 0 0 3px var(--primary-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorInvalid {
|
||||||
|
border-color: var(--danger-border-strong);
|
||||||
|
box-shadow: 0 0 0 3px var(--danger-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 10px 14px 0;
|
||||||
|
border-right: 1px solid var(--border-soft);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField :global(.MuiInputBase-root) {
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField :global(textarea) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px 14px 14px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField :global(textarea::placeholder) {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helperInvalid {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helperValid {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
12
src/shared/ui/JsonEditorField.test.jsx
Normal file
12
src/shared/ui/JsonEditorField.test.jsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
|
|
||||||
|
test("normalizes valid json object strings", () => {
|
||||||
|
expect(normalizeJsonObjectString(`{"app_id":307715}`)).toBe(`{"app_id":307715}`);
|
||||||
|
expect(normalizeJsonObjectString("")).toBe("{}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid json and non-object json", () => {
|
||||||
|
expect(() => normalizeJsonObjectString("{")).toThrow("JSON 格式错误");
|
||||||
|
expect(() => normalizeJsonObjectString("[]")).toThrow("配置 JSON 必须是对象");
|
||||||
|
});
|
||||||
@ -12,46 +12,57 @@ export function PaginationBar({
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
pageSizeOptions = [],
|
pageSizeOptions = [],
|
||||||
total
|
total,
|
||||||
|
totalPages
|
||||||
}) {
|
}) {
|
||||||
const hasKnownTotal = Number.isFinite(Number(total));
|
const hasKnownTotal = Number.isFinite(Number(total));
|
||||||
const totalPages = hasKnownTotal ? Math.max(1, Math.ceil(total / pageSize)) : Math.max(1, page + (hasNextPage ? 1 : 0));
|
const resolvedTotalPages = Number.isFinite(Number(totalPages))
|
||||||
|
? Math.max(1, Number(totalPages))
|
||||||
|
: hasKnownTotal
|
||||||
|
? Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
: Math.max(1, page + (hasNextPage ? 1 : 0));
|
||||||
const start = hasKnownTotal ? (total === 0 ? 0 : (page - 1) * pageSize + 1) : 0;
|
const start = hasKnownTotal ? (total === 0 ? 0 : (page - 1) * pageSize + 1) : 0;
|
||||||
const end = hasKnownTotal ? Math.min(total, page * pageSize) : Number(itemCount || 0);
|
const end = hasKnownTotal ? Math.min(total, page * pageSize) : Number(itemCount || 0);
|
||||||
const nextDisabled = hasKnownTotal ? page >= totalPages : !hasNextPage;
|
const nextDisabled = hasKnownTotal ? page >= resolvedTotalPages : !hasNextPage;
|
||||||
|
const showPageActions = typeof onPageChange === "function";
|
||||||
|
const showPageSize = showPageActions && pageSizeOptions.length && onPageSizeChange;
|
||||||
|
const pageSummary = hasKnownTotal ? `共 ${resolvedTotalPages} 页 · 共 ${total} 条` : `已加载 ${Math.max(1, page)} 页 · ${end} 条`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pagination-bar">
|
<div className="pagination-bar">
|
||||||
<div className="pagination-bar__meta">
|
<div className="pagination-bar__meta">
|
||||||
{pageSizeOptions.length && onPageSizeChange ? (
|
{showPageSize ? (
|
||||||
<TextField
|
<span className="pagination-bar__page-size-wrap">
|
||||||
className="pagination-bar__page-size"
|
<span>每页</span>
|
||||||
label="每页"
|
<TextField
|
||||||
select
|
className="pagination-bar__page-size"
|
||||||
size="small"
|
inputProps={{ "aria-label": "每页条数" }}
|
||||||
value={pageSize}
|
select
|
||||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
size="small"
|
||||||
>
|
value={pageSize}
|
||||||
{pageSizeOptions.map((option) => (
|
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||||
<MenuItem key={option} value={option}>
|
>
|
||||||
{option}
|
{pageSizeOptions.map((option) => (
|
||||||
</MenuItem>
|
<MenuItem key={option} value={option}>
|
||||||
))}
|
{option}
|
||||||
</TextField>
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span>
|
<span>{showPageActions && hasKnownTotal ? `${start}-${end} / ${total}` : pageSummary}</span>
|
||||||
{hasKnownTotal ? `${start}-${end} / 共 ${total} 条` : `第 ${page} 页 · 本页 ${end} 条`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pagination-bar__actions">
|
|
||||||
<IconButton disabled={page <= 1} label="上一页" onClick={() => onPageChange(page - 1)}>
|
|
||||||
<KeyboardArrowLeft fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<span>{page} / {totalPages}</span>
|
|
||||||
<IconButton disabled={nextDisabled} label="下一页" onClick={() => onPageChange(page + 1)}>
|
|
||||||
<KeyboardArrowRight fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
{showPageActions ? (
|
||||||
|
<div className="pagination-bar__actions">
|
||||||
|
<IconButton disabled={page <= 1} label="上一页" onClick={() => onPageChange(page - 1)}>
|
||||||
|
<KeyboardArrowLeft fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<span>{page} / {resolvedTotalPages}</span>
|
||||||
|
<IconButton disabled={nextDisabled} label="下一页" onClick={() => onPageChange(page + 1)}>
|
||||||
|
<KeyboardArrowRight fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export function ResourceGroupSelectField({
|
|||||||
<SideDrawer
|
<SideDrawer
|
||||||
className={styles.drawer}
|
className={styles.drawer}
|
||||||
contentClassName={styles.drawerBody}
|
contentClassName={styles.drawerBody}
|
||||||
|
drawerProps={{ sx: { zIndex: 1600 } }}
|
||||||
open={open}
|
open={open}
|
||||||
title={drawerTitle}
|
title={drawerTitle}
|
||||||
width="wide"
|
width="wide"
|
||||||
|
|||||||
@ -3,33 +3,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer.drawer {
|
.drawer.drawer {
|
||||||
width: min(760px, 100vw);
|
width: min(640px, 100vw);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawerBody {
|
.drawerBody {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupGrid {
|
.groupGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
align-content: start;
|
||||||
gap: var(--space-3);
|
align-items: start;
|
||||||
|
grid-auto-rows: min-content;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: var(--space-1);
|
padding-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupCard {
|
.groupCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 136px;
|
min-height: 76px;
|
||||||
align-content: start;
|
align-items: start;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1) var(--space-3);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -45,8 +50,8 @@
|
|||||||
.groupCard:hover {
|
.groupCard:hover {
|
||||||
border-color: var(--primary-border);
|
border-color: var(--primary-border);
|
||||||
background: var(--primary-surface);
|
background: var(--primary-surface);
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: none;
|
||||||
transform: translateY(-1px);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupCard:focus-visible {
|
.groupCard:focus-visible {
|
||||||
@ -62,18 +67,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.emptyCard {
|
.emptyCard {
|
||||||
min-height: 104px;
|
min-height: 64px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupIcon {
|
.groupIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 44px;
|
grid-row: 1 / 4;
|
||||||
height: 44px;
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@ -105,6 +112,7 @@
|
|||||||
|
|
||||||
.groupItems {
|
.groupItems {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
grid-column: 2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@ -136,7 +144,7 @@
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.groupGrid {
|
.groupGrid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
src/shared/ui/ResourceGroupSelectDrawer.test.jsx
Normal file
38
src/shared/ui/ResourceGroupSelectDrawer.test.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/SideDrawer.jsx", () => ({
|
||||||
|
SideDrawer({ children, drawerProps, onClose, open, title }) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<aside aria-label={title} data-z-index={drawerProps?.sx?.zIndex} role="dialog">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
groupCode: "vip1_rewards",
|
||||||
|
groupId: 1,
|
||||||
|
items: [{ itemType: "resource", resource: { name: "VIP1 Avatar Frame" }, resourceId: 101 }],
|
||||||
|
name: "VIP1 Rewards",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("resource group drawer stays above dialogs", () => {
|
||||||
|
render(<ResourceGroupSelectField allowEmpty groups={groups} value="" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByPlaceholderText("请选择资源组"));
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: "选择资源组" })).toHaveAttribute("data-z-index", "1600");
|
||||||
|
expect(screen.getByText("不配置")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("VIP1 Rewards")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@ -3,39 +3,36 @@ import Drawer from "@mui/material/Drawer";
|
|||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
|
||||||
export function SideDrawer({
|
export function SideDrawer({
|
||||||
actions,
|
actions,
|
||||||
as: Component = "section",
|
as: Component = "section",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
contentClassName = "",
|
contentClassName = "",
|
||||||
onClose,
|
drawerProps = {},
|
||||||
open,
|
onClose,
|
||||||
title,
|
open,
|
||||||
width = "default",
|
title,
|
||||||
...props
|
width = "default",
|
||||||
|
...props
|
||||||
}) {
|
}) {
|
||||||
const drawerClassName = [
|
const drawerClassName = ["side-drawer", width === "wide" ? "side-drawer--wide" : "", className]
|
||||||
"side-drawer",
|
.filter(Boolean)
|
||||||
width === "wide" ? "side-drawer--wide" : "",
|
.join(" ");
|
||||||
className
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
<Drawer anchor="right" open={open} onClose={onClose} {...drawerProps}>
|
||||||
<Component className={drawerClassName} {...props}>
|
<Component className={drawerClassName} {...props}>
|
||||||
<div className="side-drawer__header">
|
<div className="side-drawer__header">
|
||||||
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
|
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
|
||||||
{onClose ? (
|
{onClose ? (
|
||||||
<IconButton label="关闭" onClick={onClose}>
|
<IconButton label="关闭" onClick={onClose}>
|
||||||
<CloseOutlined fontSize="small" />
|
<CloseOutlined fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={["side-drawer__body", contentClassName].filter(Boolean).join(" ")}>
|
<div className={["side-drawer__body", contentClassName].filter(Boolean).join(" ")}>{children}</div>
|
||||||
{children}
|
{actions ? <div className="side-drawer__actions">{actions}</div> : null}
|
||||||
</div>
|
</Component>
|
||||||
{actions ? <div className="side-drawer__actions">{actions}</div> : null}
|
</Drawer>
|
||||||
</Component>
|
);
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -994,27 +994,45 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-3);
|
min-height: 32px;
|
||||||
|
margin-top: var(--space-2);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-font-size);
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-bar__meta {
|
.pagination-bar__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__page-size-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-bar__page-size {
|
.pagination-bar__page-size {
|
||||||
width: 96px;
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__page-size .MuiInputBase-root {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__page-size .MuiSelect-select.MuiInputBase-input {
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-bar__actions {
|
.pagination-bar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-state {
|
.data-state {
|
||||||
@ -1125,7 +1143,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
--admin-table-cell-x: var(--space-4);
|
--admin-table-cell-x: var(--space-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: max(100%, var(--admin-table-min-width, 900px));
|
width: max(100%, var(--admin-table-min-width, 900px));
|
||||||
@ -1160,10 +1178,30 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-row--load-more {
|
||||||
|
min-height: 36px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row--load-more-done {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell--load-more {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row--load-more-sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-cell {
|
.admin-cell {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 var(--admin-table-cell-x);
|
padding: 0 var(--admin-table-cell-x);
|
||||||
|
overflow: hidden;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1180,11 +1218,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-height: var(--table-head-height);
|
min-height: var(--table-head-height);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-right: calc(var(--admin-table-cell-x) + 12px);
|
padding-right: calc(var(--admin-table-cell-x) + 10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-cell--head:last-child {
|
.admin-cell--head:last-child {
|
||||||
padding-right: calc(var(--admin-table-cell-x) + 12px);
|
padding-right: calc(var(--admin-table-cell-x) + 10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-cell__head-content {
|
.admin-cell__head-content {
|
||||||
|
|||||||
@ -9,8 +9,8 @@
|
|||||||
--admin-tag-gap: var(--space-1);
|
--admin-tag-gap: var(--space-1);
|
||||||
--admin-tag-font-size: var(--admin-font-size);
|
--admin-tag-font-size: var(--admin-font-size);
|
||||||
--admin-tag-font-weight: 650;
|
--admin-tag-font-weight: 650;
|
||||||
--table-head-height: 44px;
|
--table-head-height: 38px;
|
||||||
--table-row-height: 58px;
|
--table-row-height: 48px;
|
||||||
--switch-width: 46px;
|
--switch-width: 46px;
|
||||||
--switch-height: 26px;
|
--switch-height: 26px;
|
||||||
--switch-thumb-size: 20px;
|
--switch-thumb-size: 20px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user