Compare commits

..

No commits in common. "f2c7ccf7f3b3da34f5a63bd47a67d0e74bb4faf5" and "90bf48638519c42f9bfd3970d8990cf06f394809" have entirely different histories.

89 changed files with 6272 additions and 12791 deletions

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@ import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import HubOutlined from "@mui/icons-material/HubOutlined";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import LeaderboardOutlined from "@mui/icons-material/LeaderboardOutlined";
import LoginOutlined from "@mui/icons-material/LoginOutlined";
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
import MapOutlined from "@mui/icons-material/MapOutlined";
@ -26,7 +25,6 @@ import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicatio
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
import WalletOutlined from "@mui/icons-material/WalletOutlined";
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
import { getRouteByMenuCode } from "@/app/router/routeConfig";
const deprecatedMenuCodes = new Set(["services"]);
@ -42,10 +40,8 @@ const iconMap = {
category: CategoryOutlined,
dashboard: DashboardOutlined,
event_available: EventAvailableOutlined,
explore: MapOutlined,
gift: CardGiftcardOutlined,
inventory: Inventory2Outlined,
leaderboard: LeaderboardOutlined,
network: HubOutlined,
team: GroupsOutlined,
history: HistoryOutlined,
@ -66,7 +62,6 @@ const iconMap = {
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined,
workspace_premium: WorkspacePremiumOutlined,
};
export const fallbackNavigation = [
@ -115,7 +110,6 @@ export const fallbackNavigation = [
children: [
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
routeNavItem("app-config-banners", { icon: ImageOutlined }),
routeNavItem("app-config-explore", { icon: MapOutlined }),
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
],
@ -152,12 +146,7 @@ export const fallbackNavigation = [
children: [
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
routeNavItem("room-treasure", { icon: RedeemOutlined }),
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
routeNavItem("red-packet", { icon: RedeemOutlined }),
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
],
},
{

View File

@ -37,7 +37,6 @@ export const PERMISSIONS = {
gameCreate: "game:create",
gameUpdate: "game:update",
gameStatus: "game:status",
gameDelete: "game:delete",
roleView: "role:view",
roleCreate: "role:create",
roleUpdate: "role:update",
@ -103,20 +102,12 @@ export const PERMISSIONS = {
achievementUpdate: "achievement:update",
registrationRewardView: "registration-reward:view",
registrationRewardUpdate: "registration-reward:update",
firstRechargeRewardView: "first-recharge-reward:view",
firstRechargeRewardUpdate: "first-recharge-reward:update",
sevenDayCheckInView: "seven-day-checkin:view",
sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view",
luckyGiftUpdate: "lucky-gift:update",
roomTreasureView: "room-treasure:view",
roomTreasureUpdate: "room-treasure:update",
userLeaderboardView: "user-leaderboard:view",
redPacketView: "red-packet:view",
redPacketUpdate: "red-packet:update",
vipConfigView: "vip-config:view",
vipConfigUpdate: "vip-config:update",
vipConfigGrant: "vip-config:grant",
uploadCreate: "upload:create",
} as const;
@ -146,7 +137,6 @@ export const MENU_CODES = {
appConfig: "app-config",
appConfigH5: "app-config-h5",
appConfigBanners: "app-config-banners",
appConfigExplore: "app-config-explore",
appConfigVersions: "app-config-versions",
resources: "resources",
resourceList: "resource-list",
@ -161,13 +151,9 @@ export const MENU_CODES = {
activities: "activities",
dailyTaskList: "daily-task-list",
registrationReward: "registration-reward",
firstRechargeReward: "first-recharge-reward",
achievementConfig: "achievement-config",
sevenDayCheckIn: "seven-day-checkin",
roomTreasure: "room-treasure",
userLeaderboard: "user-leaderboard",
redPacket: "red-packet",
vipConfig: "vip-config",
geo: "geo",
hostOrg: "host-org",
hostOrgCountries: "host-org-countries",

View File

@ -4,7 +4,6 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
import { appUserRoutes } from "@/features/app-users/routes.js";
import { dashboardRoutes } from "@/features/dashboard/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 { hostOrgRoutes } from "@/features/host-org/routes.js";
import { levelConfigRoutes } from "@/features/level-config/routes.js";
@ -14,16 +13,13 @@ import { menusRoutes } from "@/features/menus/routes.js";
import { operationsRoutes } from "@/features/operations/routes.js";
import { paymentRoutes } from "@/features/payment/routes.js";
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
import { redPacketRoutes } from "@/features/red-packets/routes.js";
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
import { resourceRoutes } from "@/features/resources/routes.js";
import { rolesRoutes } from "@/features/roles/routes.js";
import { roomRoutes } from "@/features/rooms/routes.js";
import { roomTreasureRoutes } from "@/features/room-treasure/routes.js";
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
import { usersRoutes } from "@/features/users/routes.js";
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
import type { MenuCode } from "@/app/permissions";
import type { AdminRoute, PublicRoute } from "./types";
@ -39,12 +35,8 @@ export const adminRoutes: AdminRoute[] = [
...dailyTaskRoutes,
...achievementRoutes,
...registrationRewardRoutes,
...firstRechargeRewardRoutes,
...sevenDayCheckInRoutes,
...roomTreasureRoutes,
...userLeaderboardRoutes,
...redPacketRoutes,
...vipConfigRoutes,
...resourceRoutes,
...operationsRoutes,
...luckyGiftRoutes,

View File

@ -7,10 +7,7 @@ import type {
AppVersionDto,
AppVersionPayload,
EntityId,
ExploreTabDto,
ExploreTabPayload,
H5LinkConfigDto,
H5LinkConfigPayload,
H5LinkConfigUpdatePayload,
PageQuery,
} from "@/shared/api/types";
@ -30,60 +27,6 @@ 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>> {
const endpoint = API_ENDPOINTS.listBanners;
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {

View File

@ -27,8 +27,3 @@
.formWideField {
grid-column: 1 / -1;
}
.statusCell {
display: inline-flex;
align-items: center;
}

View File

@ -1,171 +0,0 @@
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 || "",
};
}

View File

@ -1,20 +1,17 @@
import { useCallback, useState } from "react";
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";
import { createH5Link, deleteH5Link, listH5Links, updateH5Link } from "@/features/app-config/api";
import { listH5Links, updateH5Links } from "@/features/app-config/api";
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
import { h5LinkUpdateSchema } from "@/features/app-config/schema";
const emptyData = { items: [], total: 0 };
const emptyForm = () => ({ key: "", label: "", url: "" });
const emptyForm = () => ({ url: "" });
export function useH5ConfigPage() {
const abilities = useAppConfigAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [activeAction, setActiveAction] = useState("");
const [editingItem, setEditingItem] = useState(null);
const [form, setForm] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
@ -25,20 +22,12 @@ export function useH5ConfigPage() {
queryKey: ["app-config", "h5-links"]
});
const openCreate = () => {
setEditingItem(null);
setForm(emptyForm());
setActiveAction("create");
};
const openEdit = (item) => {
setEditingItem(item);
setForm({ key: item.key || "", label: item.label || "", url: item.url || "" });
setActiveAction("edit");
setForm({ url: item.url || "" });
};
const closeEdit = () => {
setActiveAction("");
setEditingItem(null);
setForm(emptyForm());
};
@ -46,24 +35,12 @@ export function useH5ConfigPage() {
const submitEdit = async (event) => {
event.preventDefault();
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;
}
const payload = parseForm(h5LinkUpdateSchema, { ...form, key: editingItem.key });
const payload = parseForm(h5LinkUpdateSchema, { key: editingItem.key, url: form.url });
setLoadingAction("edit");
try {
await updateH5Link(editingItem.key, payload);
await updateH5Links({ items: [payload] });
closeEdit();
await reload();
showToast("H5配置已更新", "success");
@ -74,30 +51,7 @@ 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 {
activeAction,
abilities,
closeEdit,
data,
@ -106,10 +60,8 @@ export function useH5ConfigPage() {
form,
loading,
loadingAction,
openCreate,
openEdit,
reload,
removeH5Link,
setForm,
submitEdit
};

View File

@ -8,6 +8,7 @@ import { useMemo } from "react";
import { APP_BANNER_DISPLAY_SCOPE_OPTIONS } from "@/features/app-config/constants";
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
import styles from "@/features/app-config/app-config.module.css";
import { Button } from "@/shared/ui/Button.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
@ -58,9 +59,13 @@ export function BannerConfigPage() {
<AdminListToolbar
actions={
page.abilities.canUpdate ? (
<AdminActionIconButton label="新增BANNER" primary onClick={page.openCreate}>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
<Button
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={page.openCreate}
>
新增BANNER
</Button>
) : null
}
/>
@ -68,16 +73,14 @@ export function BannerConfigPage() {
<AdminListBody>
<DataTable
columns={columns}
fixedEdges={false}
items={items}
minWidth="1480px"
pagination={{
itemCount: items.length,
page: 1,
pageSize: items.length || 1,
total: page.data.total ?? items.length,
}}
rowKey={(item) => item.id}
/>
<div className="pagination-bar">
<span>{items.length} </span>
</div>
</AdminListBody>
</DataState>

View File

@ -1,216 +0,0 @@
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>
);
}

View File

@ -1,18 +1,14 @@
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 { formatMillis } from "@/shared/utils/time.js";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions
} from "@/shared/ui/AdminListLayout.jsx";
import { useH5ConfigPage } from "@/features/app-config/hooks/useH5ConfigPage.js";
@ -46,7 +42,6 @@ const baseColumns = [
export function H5ConfigPage() {
const page = useH5ConfigPage();
const items = page.data.items || [];
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
const columns = page.abilities.canUpdate
? [
...baseColumns,
@ -54,73 +49,39 @@ export function H5ConfigPage() {
key: "actions",
label: "操作",
render: (item) => <H5LinkActions item={item} page={page} />,
width: "minmax(124px, 0.5fr)"
width: "minmax(88px, 0.4fr)"
}
]
: baseColumns;
return (
<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}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="980px"
pagination={{
itemCount: items.length,
page: 1,
pageSize: Math.max(items.length, 1),
total: page.data.total ?? items.length
}}
rowKey={(item) => item.key}
/>
<DataTable columns={columns} items={items} minWidth="980px" rowKey={(item) => item.key} />
<div className="pagination-bar">
<span>{items.length} </span>
</div>
</AdminListBody>
</DataState>
<AdminFormDialog
loading={saving}
open={Boolean(page.activeAction)}
submitDisabled={!page.abilities.canUpdate || saving}
title={page.editingItem ? "编辑H5配置" : "新增H5配置"}
loading={page.loadingAction === "edit"}
open={Boolean(page.editingItem)}
size="compact"
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
title="编辑H5链接"
onClose={page.closeEdit}
onSubmit={page.submitEdit}
>
<AdminFormSection title="链接信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
autoFocus
disabled={!page.abilities.canUpdate}
label="配置项"
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>
<TextField
autoFocus
disabled={!page.abilities.canUpdate}
label={page.editingItem?.label || "H5链接"}
value={page.form.url}
onChange={(event) => page.setForm({ url: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage>
@ -133,13 +94,6 @@ function H5LinkActions({ item, page }) {
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
{item.url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
</AdminActionIconButton>
<AdminActionIconButton
label="删除"
disabled={page.loadingAction === `delete:${item.key}`}
onClick={() => page.removeH5Link(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}

View File

@ -17,14 +17,6 @@ export const appConfigRoutes = [
path: "/app-config/banners",
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: "内购配置",
loader: () =>

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { appBannerSchema, exploreTabSchema, h5LinkUpdateSchema } from "@/features/app-config/schema";
import { appBannerSchema } from "@/features/app-config/schema";
import { FormValidationError, parseForm } from "@/shared/forms/validation";
const validBannerForm = {
@ -19,27 +19,6 @@ const validBannerForm = {
};
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", () => {
const payload = parseForm(appBannerSchema, validBannerForm);
@ -67,27 +46,4 @@ describe("app config form schema", () => {
}),
).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);
});
});

View File

@ -1,36 +1,20 @@
import { z } from "zod";
import { H5_LINK_KEYS } from "@/features/app-config/constants";
const requiredH5LinkURLSchema = z
const h5LinkURLSchema = z
.string()
.trim()
.min(1, "请填写 H5 链接")
.max(2048, "H5 链接不能超过 2048 个字符")
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符");
const h5LinkKeySchema = z
.string()
.trim()
.min(1, "请填写 key")
.max(80, "key 不能超过 80 个字符")
.regex(/^[A-Za-z0-9_.:-]+$/, "key 只能包含字母、数字、下划线、中横线、点和冒号");
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
.default("");
export const h5LinkUpdateSchema = z.object({
key: h5LinkKeySchema,
label: z.string().trim().min(1, "请填写配置项").max(80, "配置项不能超过 80 个字符"),
url: requiredH5LinkURLSchema,
key: z.enum(H5_LINK_KEYS),
url: h5LinkURLSchema,
});
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
.object({
bannerType: z.enum(["h5", "app"]),

View File

@ -1,171 +0,0 @@
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;
}

View File

@ -1,186 +0,0 @@
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>
);
}

View File

@ -1,79 +0,0 @@
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}`;
}

View File

@ -1,159 +0,0 @@
.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;
}
}

View File

@ -1,188 +0,0 @@
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,
};
}

View File

@ -1,176 +0,0 @@
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));
}

View File

@ -1,11 +0,0 @@
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),
};
}

View File

@ -1,12 +0,0 @@
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,
},
];

View File

@ -8,14 +8,6 @@ export interface GamePlatformDto {
platformName: string;
status: 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;
createdAtMs?: number;
updatedAtMs?: number;
@ -30,7 +22,6 @@ export interface GameCatalogDto {
category: string;
iconUrl: string;
coverUrl: string;
launchUrl?: string;
launchMode: string;
orientation: string;
minCoin: number;
@ -46,8 +37,6 @@ export interface GameCatalogPage {
nextCursor?: string;
pageSize?: number;
serverTimeMs?: number;
total?: number;
totalPages?: number;
}
export interface GamePlatformPage {
@ -55,28 +44,7 @@ export interface GamePlatformPage {
serverTimeMs?: number;
}
export interface GameSyncPayload {
dryRun?: boolean;
status?: string;
category?: string;
launchMode?: string;
minCoin?: number;
tags?: string[];
gameListType?: number;
providerGameIds?: string[];
}
export interface GameSyncResult {
platformCode: string;
synced: number;
dryRun: boolean;
items: GameCatalogDto[];
}
export type GamePlatformPayload = Omit<
GamePlatformDto,
"appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet"
>;
export type GamePlatformPayload = Omit<GamePlatformDto, "appCode" | "createdAtMs" | "updatedAtMs">;
export type GameCatalogPayload = Omit<GameCatalogDto, "appCode" | "createdAtMs" | "updatedAtMs">;
export interface GameCatalogQuery {
@ -143,28 +111,6 @@ export function setGameStatus(gameId: string, status: string): Promise<GameCatal
).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 {
return {
appCode: stringValue(platform.appCode),
@ -172,13 +118,6 @@ function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto
platformName: stringValue(platform.platformName),
status: stringValue(platform.status || "active"),
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),
createdAtMs: numberValue(platform.createdAtMs),
updatedAtMs: numberValue(platform.updatedAtMs),
@ -195,8 +134,7 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
category: stringValue(game.category),
iconUrl: stringValue(game.iconUrl),
coverUrl: stringValue(game.coverUrl),
launchUrl: stringValue(game.launchUrl),
launchMode: normalizeLaunchMode(game.launchMode),
launchMode: stringValue(game.launchMode || "h5_popup"),
orientation: stringValue(game.orientation || "portrait"),
minCoin: numberValue(game.minCoin),
status: stringValue(game.status || "active"),
@ -215,8 +153,3 @@ function numberValue(value: unknown) {
const number = Number(value || 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";
}

View File

@ -31,7 +31,8 @@
white-space: nowrap;
}
.meta {
.meta,
.tags {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
@ -39,10 +40,8 @@
white-space: nowrap;
}
.secretMeta {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
overflow-wrap: anywhere;
white-space: normal;
.tags {
color: var(--text-secondary);
}
.switchOnlyField {
@ -56,56 +55,3 @@
.fullField {
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;
}

View File

@ -1,17 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
deleteGameCatalog,
listGameCatalog,
listGamePlatforms,
setGameStatus,
syncGamePlatformCatalog,
upsertGameCatalog,
upsertGamePlatform,
} from "@/features/games/api";
import { useGameAbilities } from "@/features/games/permissions.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";
const defaultGameForm = {
@ -22,8 +18,7 @@ const defaultGameForm = {
category: "casual",
iconUrl: "",
coverUrl: "",
gameUrl: "",
launchMode: "full_screen",
launchMode: "h5_popup",
orientation: "portrait",
minCoin: 0,
status: "active",
@ -36,14 +31,6 @@ const defaultPlatformForm = {
platformName: "",
status: "active",
apiBaseUrl: "",
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1。
adapterType: "demo",
// 后台现在会回显厂商 key编辑弹窗直接显示当前值提交空值仍代表“不覆盖旧值”。
callbackSecret: "",
callbackSecretSet: false,
callbackIpWhitelistText: "",
// 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。
adapterConfigJson: "{}",
sortOrder: 0,
};
@ -53,26 +40,18 @@ const defaultPageSize = 50;
export function useGamesPage() {
const abilities = useGameAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [platformCode, setPlatformCode] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [pageCursors, setPageCursors] = useState({ 1: "" });
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 [editingGameId, setEditingGameId] = useState("");
const [editingPlatformCode, setEditingPlatformCode] = useState("");
const [gameForm, setGameForm] = useState(defaultGameForm);
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
const [loadingAction, setLoadingAction] = useState("");
const [syncPlatform, setSyncPlatform] = useState(null);
const [syncCandidates, setSyncCandidates] = useState([]);
const [selectedSyncGameIds, setSelectedSyncGameIds] = useState([]);
const catalogRequestRef = useRef(0);
const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
const {
@ -86,52 +65,26 @@ export function useGamesPage() {
queryKey: ["games", "platforms"],
});
const loadCatalog = useCallback(
async (cursor = "", append = false) => {
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 catalogQueryFn = useCallback(
() => listGameCatalog({ cursor: pageCursors[page] || "", platformCode, status, pageSize }),
[page, pageCursors, pageSize, platformCode, status],
);
useEffect(() => {
loadCatalog("", false);
}, [loadCatalog]);
const {
data = emptyCatalog,
error,
loading,
reload,
} = useAdminQuery(catalogQueryFn, {
errorMessage: "加载游戏列表失败",
initialData: emptyCatalog,
keepPreviousData: true,
queryKey: ["games", "catalog", platformCode, status, pageSize, page, pageCursors[page] || ""],
});
const platformOptions = useMemo(
() => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]),
[platformData.items],
);
const resolveGameURL = useCallback(
(game) => resolveGameURLFromPlatforms(platformData.items || [], game),
[platformData.items],
);
useEffect(() => {
if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") {
@ -140,8 +93,8 @@ export function useGamesPage() {
}, [activeAction, gameForm.platformCode, platformOptions]);
const resetCatalogPage = () => {
setCatalogItems([]);
setCatalogMeta(emptyCatalog);
setPage(1);
setPageCursors({ 1: "" });
};
const changePlatformCode = (value) => {
@ -159,15 +112,17 @@ export function useGamesPage() {
resetCatalogPage();
};
const loadNextPage = () => {
if (!catalogMeta.nextCursor || catalogLoading || catalogLoadingMore) {
return null;
const changePage = (nextPage) => {
if (nextPage < 1 || nextPage === page) {
return;
}
return loadCatalog(catalogMeta.nextCursor, true);
};
const reloadCatalog = () => {
return loadCatalog("", false);
if (nextPage === page + 1) {
if (!data.nextCursor) {
return;
}
setPageCursors((current) => ({ ...current, [nextPage]: data.nextCursor }));
}
setPage(nextPage);
};
const resetFilters = () => {
@ -185,7 +140,7 @@ export function useGamesPage() {
const openEditGame = (game) => {
setEditingGameId(game.gameId);
setGameForm(gameToForm(game, platformData.items || []));
setGameForm(gameToForm(game));
setActiveAction("edit-game");
};
@ -195,10 +150,6 @@ export function useGamesPage() {
setActiveAction("create-platform");
};
const openManagePlatforms = () => {
setActiveAction("manage-platforms");
};
const openEditPlatform = (platform) => {
setEditingPlatformCode(platform.platformCode);
setPlatformForm(platformToForm(platform));
@ -206,11 +157,6 @@ export function useGamesPage() {
};
const closeAction = () => {
if (activeAction === "sync-games") {
setSyncPlatform(null);
setSyncCandidates([]);
setSelectedSyncGameIds([]);
}
setActiveAction("");
setEditingGameId("");
setEditingPlatformCode("");
@ -222,9 +168,7 @@ export function useGamesPage() {
setLoadingAction(activeAction);
try {
await upsertGameCatalog(payload, editingGameId);
await saveGameUrl(platformData.items || [], payload, gameForm.gameUrl);
await reloadPlatforms();
await reloadCatalog();
await reload();
showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
closeAction();
} catch (err) {
@ -236,18 +180,12 @@ export function useGamesPage() {
const submitPlatform = async (event) => {
event.preventDefault();
let payload;
try {
payload = platformPayload(platformForm);
} catch (err) {
showToast(err.message || "适配器配置 JSON 不正确", "error");
return;
}
const payload = platformPayload(platformForm);
setLoadingAction(activeAction);
try {
await upsertGamePlatform(payload, editingPlatformCode);
await reloadPlatforms();
await reloadCatalog();
await reload();
showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success");
closeAction();
} catch (err) {
@ -262,7 +200,7 @@ export function useGamesPage() {
setLoadingAction(`status-${game.gameId}`);
try {
await setGameStatus(game.gameId, nextStatus);
await reloadCatalog();
await reload();
showToast("游戏状态已更新", "success");
} catch (err) {
showToast(err.message || "状态更新失败", "error");
@ -271,146 +209,29 @@ 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 {
abilities,
activeAction,
allSyncGamesSelected,
changeGameStatus,
deleteGame,
closeAction,
data: {
...catalogMeta,
items: catalogItems,
loadedCount: catalogItems.length,
loadedPages: Math.max(1, Math.ceil(catalogItems.length / pageSize)),
},
error: catalogError || platformError,
data,
error: error || platformError,
gameForm,
loading: (catalogLoading && catalogItems.length === 0) || platformsLoading,
loading: loading || platformsLoading,
loadingAction,
loadingMore: catalogLoadingMore || (catalogLoading && catalogItems.length > 0),
page: Math.max(1, Math.ceil(catalogItems.length / pageSize)),
fetchPlatformGames,
importSelectedSyncGames,
importSingleSyncGame,
page,
openCreateGame,
openCreatePlatform,
openEditGame,
openEditPlatform,
openManagePlatforms,
pageSize,
platformCode,
platformData,
platformForm,
platformOptions,
loadNextPage,
reload: reloadCatalog,
reload,
resetFilters,
resolveGameURL,
changePage,
changePageSize,
setGameForm,
setPlatformCode: changePlatformCode,
@ -419,71 +240,10 @@ export function useGamesPage() {
status,
submitGame,
submitPlatform,
syncCandidates,
syncPlatform,
selectedSyncGameIds,
toggleAllSyncGames,
toggleSyncGame,
};
}
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 = []) {
function gameToForm(game) {
return {
gameId: game.gameId || "",
platformCode: game.platformCode || "",
@ -492,8 +252,7 @@ function gameToForm(game, platforms = []) {
category: game.category || "casual",
iconUrl: game.iconUrl || "",
coverUrl: game.coverUrl || "",
gameUrl: resolveGameURLFromPlatforms(platforms, game),
launchMode: normalizeLaunchMode(game.launchMode),
launchMode: game.launchMode || "h5_popup",
orientation: game.orientation || "portrait",
minCoin: Number(game.minCoin || 0),
status: game.status || "active",
@ -502,50 +261,12 @@ function gameToForm(game, platforms = []) {
};
}
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) {
return {
platformCode: platform.platformCode || "",
platformName: platform.platformName || "",
status: platform.status || "active",
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),
};
}
@ -559,7 +280,7 @@ function gamePayload(form) {
category: form.category.trim(),
iconUrl: form.iconUrl.trim(),
coverUrl: form.coverUrl.trim(),
launchMode: normalizeLaunchMode(form.launchMode),
launchMode: form.launchMode.trim() || "h5_popup",
orientation: form.orientation.trim() || "portrait",
minCoin: Number(form.minCoin || 0),
status: form.status.trim() || "active",
@ -571,32 +292,12 @@ 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) {
const isLeaderCC = form.adapterType.trim() === "leadercc_v1";
return {
platformCode: form.platformCode.trim(),
platformName: form.platformName.trim(),
status: form.status.trim() || "active",
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),
apiBaseUrl: form.apiBaseUrl.trim(),
sortOrder: Number(form.sortOrder || 0),
};
}

View File

@ -1,18 +1,12 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import SyncOutlined from "@mui/icons-material/SyncOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import {
AdminActionIconButton,
AdminListBody,
@ -37,19 +31,7 @@ const orientationOptions = [
["portrait", "Portrait"],
["landscape", "Landscape"],
];
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 launchModeOptions = [["h5_popup", "H5 弹窗"]];
const baseColumns = [
{
@ -75,7 +57,7 @@ const baseColumns = [
width: "minmax(150px, 0.75fr)",
render: (game) => (
<div className={styles.stack}>
<span>{launchModeLabel(game.launchMode)}</span>
<span>{game.launchMode}</span>
<span className={styles.meta}>{game.orientation}</span>
</div>
),
@ -100,7 +82,7 @@ const baseColumns = [
{
key: "actions",
label: "操作",
width: "minmax(112px, 0.5fr)",
width: "minmax(76px, 0.4fr)",
},
];
@ -146,11 +128,6 @@ export function GameListPage() {
<AdminListToolbar
actions={
<>
{page.abilities.canUpdate ? (
<AdminActionIconButton label="平台配置" onClick={page.openManagePlatforms}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canUpdate ? (
<AdminActionIconButton label="添加平台" onClick={page.openCreatePlatform}>
<SportsEsportsOutlined fontSize="small" />
@ -168,24 +145,16 @@ export function GameListPage() {
<AdminListBody>
<DataTable
columns={columns}
infiniteScroll={{
doneLabel: "",
hasMore: Boolean(page.data.nextCursor),
loading: page.loadingMore,
loadingLabel: "加载中...",
onLoadMore: page.loadNextPage,
}}
items={items}
minWidth="1240px"
minWidth="1220px"
pagination={{
hasNextPage: Boolean(page.data.nextCursor),
itemCount: items.length,
onPageChange: page.changePage,
onPageSizeChange: page.changePageSize,
page: page.page,
pageSize: page.pageSize,
pageSizeOptions: [20, 50, 100],
total: page.data.total,
totalPages: page.data.totalPages,
}}
rowKey={(game) => game.gameId}
/>
@ -209,12 +178,6 @@ export function GameListPage() {
onClose={page.closeAction}
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>
);
}
@ -230,21 +193,21 @@ function GameIdentity({ game }) {
<div className={styles.identityText}>
<span className={styles.name}>{game.gameName || game.gameId}</span>
<span className={styles.meta}>{game.gameId}</span>
<span className={styles.tags}>{(game.tags || []).slice(0, 3).join(" / ")}</span>
</div>
</div>
);
}
function GameStatusSwitch({ page, game }) {
const disabled = !page.abilities.canStatus || page.loadingAction === `status-${game.gameId}`;
const disabled =
!page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`;
return (
<AdminSwitch
checked={game.status === "active"}
checkedLabel="启用"
disabled={disabled}
label={`${game.gameName || game.gameId} 状态`}
size="small"
uncheckedLabel={game.status === "disabled" ? "下架" : "维护"}
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
/>
);
@ -260,13 +223,6 @@ function GameRowActions({ page, game }) {
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canDelete || page.loadingAction === `delete-${game.gameId}`}
label="删除游戏"
onClick={() => page.deleteGame(game)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
@ -351,15 +307,6 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
</MenuItem>
))}
</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
label="最低金币"
type="number"
@ -372,15 +319,15 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
value={form.sortOrder}
onChange={(event) => page.setGameForm({ ...form, sortOrder: event.target.value })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="游戏启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setGameForm({
...form,
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="游戏启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setGameForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
@ -414,7 +361,6 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
}
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const isLeaderCC = form.adapterType === "leadercc_v1";
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
@ -440,15 +386,15 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
value={form.platformName}
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
@ -462,168 +408,13 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="接入配置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
{/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */}
<TextField
label="适配器"
select
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>
<TextField
className={styles.fullField}
label="H5 Base URL"
required
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
);
@ -647,7 +438,3 @@ function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, unc
function formatNumber(value) {
return Number(value || 0).toLocaleString();
}
function launchModeLabel(value) {
return launchModeOptions.find(([mode]) => mode === value)?.[1] || "全屏";
}

View File

@ -6,7 +6,6 @@ export function useGameAbilities() {
return {
canCreate: can(PERMISSIONS.gameCreate),
canDelete: can(PERMISSIONS.gameDelete) || can(PERMISSIONS.gameUpdate),
canStatus: can(PERMISSIONS.gameStatus),
canUpdate: can(PERMISSIONS.gameUpdate),
canUpload: can(PERMISSIONS.uploadCreate),

View File

@ -7,7 +7,7 @@ export const hostOrgRoutes = [
menuCode: MENU_CODES.hostOrgCountries,
pageKey: "host-org-countries",
path: "/host/countries",
permission: PERMISSIONS.countryView,
permission: PERMISSIONS.countryView
},
{
label: "区域管理",
@ -15,15 +15,15 @@ export const hostOrgRoutes = [
menuCode: MENU_CODES.hostOrgRegions,
pageKey: "host-org-regions",
path: "/host/regions",
permission: PERMISSIONS.regionView,
permission: PERMISSIONS.regionView
},
{
label: "Manager列表",
label: "经理列表",
loader: () => import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
menuCode: MENU_CODES.hostOrgManagers,
pageKey: "host-org-managers",
path: "/host/managers",
permission: PERMISSIONS.agencyView,
permission: PERMISSIONS.agencyView
},
{
label: "Agency 列表",
@ -31,7 +31,7 @@ export const hostOrgRoutes = [
menuCode: MENU_CODES.hostOrgAgencies,
pageKey: "host-org-agencies",
path: "/host/agencies",
permission: PERMISSIONS.agencyView,
permission: PERMISSIONS.agencyView
},
{
label: "BD Leader 列表",
@ -39,7 +39,7 @@ export const hostOrgRoutes = [
menuCode: MENU_CODES.hostOrgBdLeaders,
pageKey: "host-org-bd-leaders",
path: "/host/bd-leaders",
permission: PERMISSIONS.bdView,
permission: PERMISSIONS.bdView
},
{
label: "BD 列表",
@ -47,22 +47,22 @@ export const hostOrgRoutes = [
menuCode: MENU_CODES.hostOrgBds,
pageKey: "host-org-bds",
path: "/host/bds",
permission: PERMISSIONS.bdView,
permission: PERMISSIONS.bdView
},
{
label: "Host 列表",
label: "主播列表",
loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage),
menuCode: MENU_CODES.hostOrgHosts,
pageKey: "host-org-hosts",
path: "/host/hosts",
permission: PERMISSIONS.hostView,
permission: PERMISSIONS.hostView
},
{
label: "Coin Saller列表",
label: "币商列表",
loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage),
menuCode: MENU_CODES.hostOrgCoinSellers,
pageKey: "host-org-coin-sellers",
path: "/host/coin-sellers",
permission: PERMISSIONS.coinSellerView,
},
permission: PERMISSIONS.coinSellerView
}
];

View File

@ -1,11 +1,11 @@
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 LuckyGiftTierDto {
pool: string;
tierId: string;
rewardCoins: number;
multiplierPPM: number;
weight: number;
highWaterOnly: boolean;
enabled: boolean;
@ -13,7 +13,6 @@ export interface LuckyGiftTierDto {
export interface LuckyGiftConfigDto {
appCode?: string;
poolId: string;
enabled: boolean;
ruleVersion: number;
giftPrice: number;
@ -46,7 +45,6 @@ export interface LuckyGiftConfigDto {
activityBudget: number;
activityDailyLimit: number;
largeTierEnabled: boolean;
multiplierPPMs: number[];
tiers: LuckyGiftTierDto[];
updatedByAdminId?: number;
createdAtMs?: number;
@ -54,7 +52,6 @@ export interface LuckyGiftConfigDto {
}
export interface LuckyGiftConfigPayload {
pool_id: string;
enabled: boolean;
gift_price: number;
target_rtp_ppm: number;
@ -86,75 +83,74 @@ export interface LuckyGiftConfigPayload {
activity_budget: number;
activity_daily_limit: number;
large_tier_enabled: boolean;
multiplier_ppms: number[];
tiers: Array<{
pool: string;
tier_id: string;
reward_coins: number;
multiplier_ppm: number;
weight: number;
high_water_only: boolean;
enabled: boolean;
}>;
}
export interface LuckyGiftDrawSummaryDto {
poolId: string;
totalDraws: number;
uniqueUsers: number;
uniqueRooms: number;
totalSpentCoins: number;
totalRewardCoins: number;
export interface LuckyGiftDrawDto {
drawId: string;
commandId?: string;
giftId: string;
ruleVersion: number;
experiencePool: string;
selectedTierId: string;
baseRewardCoins: number;
roomAtmosphereRewardCoins: number;
activitySubsidyCoins: number;
actualRTPPPM: number;
pendingDraws: number;
grantedDraws: number;
failedDraws: number;
effectiveRewardCoins: number;
budgetSourcesJson?: string;
rewardStatus?: string;
rtpWindowIndex: number;
giftRTPWindowIndex: number;
globalBaseRTPPPM: number;
giftBaseRTPPPM: number;
stageFeedback: boolean;
highMultiplier: boolean;
createdAtMs?: number;
}
type RawConfig = LuckyGiftConfigDto & Record<string, unknown>;
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
type RawSummary = LuckyGiftDrawSummaryDto & Record<string, unknown>;
type LuckyGiftDrawSummaryQuery = Record<string, number | string | undefined>;
type RawDraw = LuckyGiftDrawDto & Record<string, unknown>;
export function getLuckyGiftConfig(poolId: string): Promise<LuckyGiftConfigDto> {
export function getLuckyGiftConfig(): Promise<LuckyGiftConfigDto> {
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), {
method: endpoint.method,
query: { pool_id: poolId },
}).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> {
const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig;
return apiRequest<RawConfig, LuckyGiftConfigPayload>(apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig), {
body: payload,
method: endpoint.method,
query: { pool_id: payload.pool_id },
}).then(normalizeConfig);
return apiRequest<RawConfig, LuckyGiftConfigPayload>(
apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig),
{
body: payload,
method: endpoint.method,
},
).then(normalizeConfig);
}
export function getLuckyGiftDrawSummary(query: LuckyGiftDrawSummaryQuery = {}): Promise<LuckyGiftDrawSummaryDto> {
const endpoint = API_ENDPOINTS.getLuckyGiftDrawSummary;
return apiRequest<RawSummary>(apiEndpointPath(API_OPERATIONS.getLuckyGiftDrawSummary), {
export function listLuckyGiftDraws(query: PageQuery = {}): Promise<ApiPage<LuckyGiftDrawDto>> {
const endpoint = API_ENDPOINTS.listLuckyGiftDraws;
return apiRequest<ApiPage<RawDraw>>(apiEndpointPath(API_OPERATIONS.listLuckyGiftDraws), {
method: endpoint.method,
query,
}).then(normalizeSummary);
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeDraw),
}));
}
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
poolId: stringValue(item.poolId ?? item.pool_id ?? item.gift_id),
enabled: booleanValue(item.enabled),
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
giftPrice: numberValue(item.giftPrice ?? item.gift_price),
@ -187,7 +183,6 @@ function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
activityBudget: numberValue(item.activityBudget ?? item.activity_budget),
activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit),
largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled),
multiplierPPMs: arrayValue(item.multiplierPPMs ?? item.multiplier_ppms).map(numberValue),
tiers: arrayValue(item.tiers).map(normalizeTier),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
@ -201,28 +196,35 @@ function normalizeTier(raw: unknown): LuckyGiftTierDto {
pool: stringValue(item.pool),
tierId: stringValue(item.tierId ?? item.tier_id),
rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins),
multiplierPPM: numberValue(item.multiplierPPM ?? item.multiplier_ppm),
weight: numberValue(item.weight),
highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only),
enabled: booleanValue(item.enabled),
};
}
function normalizeSummary(item: RawSummary): LuckyGiftDrawSummaryDto {
function normalizeDraw(item: RawDraw): LuckyGiftDrawDto {
return {
poolId: stringValue(item.poolId ?? item.pool_id),
totalDraws: numberValue(item.totalDraws ?? item.total_draws),
uniqueUsers: numberValue(item.uniqueUsers ?? item.unique_users),
uniqueRooms: numberValue(item.uniqueRooms ?? item.unique_rooms),
totalSpentCoins: numberValue(item.totalSpentCoins ?? item.total_spent_coins),
totalRewardCoins: numberValue(item.totalRewardCoins ?? item.total_reward_coins),
drawId: stringValue(item.drawId ?? item.draw_id),
commandId: stringValue(item.commandId ?? item.command_id),
giftId: stringValue(item.giftId ?? item.gift_id),
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
experiencePool: stringValue(item.experiencePool ?? item.experience_pool),
selectedTierId: stringValue(item.selectedTierId ?? item.selected_tier_id),
baseRewardCoins: numberValue(item.baseRewardCoins ?? item.base_reward_coins),
roomAtmosphereRewardCoins: numberValue(item.roomAtmosphereRewardCoins ?? item.room_atmosphere_reward_coins),
roomAtmosphereRewardCoins: numberValue(
item.roomAtmosphereRewardCoins ?? item.room_atmosphere_reward_coins,
),
activitySubsidyCoins: numberValue(item.activitySubsidyCoins ?? item.activity_subsidy_coins),
actualRTPPPM: numberValue(item.actualRTPPPM ?? item.actual_rtp_ppm),
pendingDraws: numberValue(item.pendingDraws ?? item.pending_draws),
grantedDraws: numberValue(item.grantedDraws ?? item.granted_draws),
failedDraws: numberValue(item.failedDraws ?? item.failed_draws),
effectiveRewardCoins: numberValue(item.effectiveRewardCoins ?? item.effective_reward_coins),
budgetSourcesJson: stringValue(item.budgetSourcesJson ?? item.budget_sources_json),
rewardStatus: stringValue(item.rewardStatus ?? item.reward_status),
rtpWindowIndex: numberValue(item.rtpWindowIndex ?? item.rtp_window_index),
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),
};
}

View File

@ -1,8 +1,5 @@
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";
@ -13,6 +10,7 @@ import {
controlStrengthOptions,
jackpotStrategyOptions,
noviceStrengthOptions,
payoutStyleOptions,
poolModeOptions,
riskLevelOptions,
} from "@/features/lucky-gift/constants.js";
@ -20,12 +18,11 @@ import styles from "@/features/lucky-gift/lucky-gift.module.css";
const helpText = {
enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。",
poolId: "奖池 ID 是请求接口传入的隔离键,不同奖池的 RTP、预算、风控和用户阶段互不影响。",
targetRtpPercent: "基础返奖成本比例95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。",
controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。",
poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。",
noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。",
multiplierValues: "配置用户可能命中的倍率,例如 0、0.5、1、2、10、100每个倍率的概率由后端按 RTP 窗口自动生成。",
payoutStyle: "控制小奖和大奖的权重分布,稳定偏小奖,刺激偏高倍爆点。",
jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。",
riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。",
atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。",
@ -53,12 +50,6 @@ export function LuckyGiftConfigDrawer({
<section className="form-drawer__section">
<div className="form-drawer__section-title">基础控制</div>
<div className={styles.configGrid}>
<TextField
required
disabled
label={<FieldLabel help={helpText.poolId} text="奖池 ID" />}
value={form.poolId}
/>
<TextField
required
disabled={disabled}
@ -106,11 +97,13 @@ export function LuckyGiftConfigDrawer({
value={form.noviceStrength}
onChange={(value) => updateForm(setForm, "noviceStrength", value)}
/>
<MultiplierField
<SelectField
disabled={disabled}
help={helpText.multiplierValues}
setForm={setForm}
values={form.multiplierValues || []}
help={helpText.payoutStyle}
label="中奖体感"
options={payoutStyleOptions}
value={form.payoutStyle}
onChange={(value) => updateForm(setForm, "payoutStyle", value)}
/>
<SelectField
disabled={disabled}
@ -188,47 +181,6 @@ 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 }) {
return (
<TextField
@ -266,25 +218,3 @@ function SwitchField({ checked, className, disabled, help, label, onChange }) {
function updateForm(setForm, 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),
}));
}

View File

@ -1,8 +1,5 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
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 { Button } from "@/shared/ui/Button.jsx";
import { formatMillis } from "@/shared/utils/time.js";
@ -10,24 +7,16 @@ import {
atmosphereLevelOptions,
controlStrengthOptions,
jackpotStrategyOptions,
luckyGiftConfigScopeLabel,
noviceStrengthOptions,
optionLabel,
payoutStyleOptions,
poolModeOptions,
} from "@/features/lucky-gift/constants.js";
import { formFromConfig } from "@/features/lucky-gift/configModel.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
export function LuckyGiftConfigSummary({
canUpdate,
config,
configLoading,
onAddPool,
onEdit,
onPoolChange,
onRefresh,
poolId,
poolOptions,
}) {
export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
const form = formFromConfig(config);
return (
@ -36,7 +25,7 @@ export function LuckyGiftConfigSummary({
<div className={styles.summaryContent}>
<div className={styles.summaryHeader}>
<span className={styles.title}>幸运礼物配置</span>
<span className={styles.meta}>奖池 {poolId}</span>
<span className={styles.meta}>{luckyGiftConfigScopeLabel}</span>
</div>
<div className={styles.summaryItems}>
<ConfigStatus config={config} loading={configLoading} />
@ -48,7 +37,7 @@ export function LuckyGiftConfigSummary({
<SummaryItem label="新手保护">
{optionLabel(noviceStrengthOptions, form.noviceStrength)}
</SummaryItem>
<SummaryItem label="倍率">{formatMultipliers(form.multiplierValues)}</SummaryItem>
<SummaryItem label="中奖体感">{optionLabel(payoutStyleOptions, form.payoutStyle)}</SummaryItem>
<SummaryItem label="大奖策略">
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
</SummaryItem>
@ -62,23 +51,6 @@ export function LuckyGiftConfigSummary({
</div>
</div>
<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}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
@ -96,13 +68,6 @@ export function LuckyGiftConfigSummary({
);
}
function formatMultipliers(values) {
if (!Array.isArray(values) || values.length === 0) {
return "-";
}
return values.join(", ");
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>

View File

@ -1,7 +1,5 @@
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
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 { useEffect, useState } from "react";
import { Button } from "@/shared/ui/Button.jsx";
@ -28,7 +26,6 @@ const defaultInput = {
};
export function LuckyGiftSimulationPanel({ config }) {
const [expanded, setExpanded] = useState(false);
const [input, setInput] = useState(defaultInput);
const [result, setResult] = useState(null);
const [csvUrl, setCsvUrl] = useState("");
@ -62,89 +59,61 @@ export function LuckyGiftSimulationPanel({ config }) {
<span className={styles.title}>随机模拟测试</span>
</div>
<div className={styles.simulationActions}>
{expanded ? (
<>
<Button
startIcon={<PlayArrowOutlined fontSize="small" />}
type="button"
variant="primary"
onClick={runSimulation}
>
测试
</Button>
{csvUrl ? (
<Button
component="a"
download={csvFileName}
href={csvUrl}
startIcon={<DownloadOutlined fontSize="small" />}
>
CSV
</Button>
) : null}
</>
) : null}
<Button
aria-expanded={expanded}
endIcon={
expanded ? <ExpandLessOutlined fontSize="small" /> : <ExpandMoreOutlined fontSize="small" />
}
type="button"
onClick={() => setExpanded((current) => !current)}
>
{expanded ? "收起" : "展开"}
<Button startIcon={<PlayArrowOutlined fontSize="small" />} type="button" variant="primary" onClick={runSimulation}>
测试
</Button>
{csvUrl ? (
<Button component="a" download={csvFileName} href={csvUrl} startIcon={<DownloadOutlined fontSize="small" />}>
CSV
</Button>
) : null}
</div>
</div>
{expanded ? (
<>
<div className={styles.simulationGrid}>
<NumberField
help={simulationHelp.users}
label="用户数量"
min={1}
value={input.users}
onChange={(value) => updateInput(setInput, "users", value)}
/>
<NumberField
help={simulationHelp.rooms}
label="房间数量"
min={1}
value={input.rooms}
onChange={(value) => updateInput(setInput, "rooms", value)}
/>
<NumberField
help={simulationHelp.costMin}
label="单抽花费最小值"
min={1}
value={input.costMin}
onChange={(value) => updateInput(setInput, "costMin", value)}
/>
<NumberField
help={simulationHelp.costMax}
label="单抽花费最大值"
min={1}
value={input.costMax}
onChange={(value) => updateInput(setInput, "costMax", value)}
/>
<NumberField
help={simulationHelp.drawsMin}
label="抽奖次数最小值"
min={0}
value={input.drawsMin}
onChange={(value) => updateInput(setInput, "drawsMin", value)}
/>
<NumberField
help={simulationHelp.drawsMax}
label="抽奖次数最大值"
min={0}
value={input.drawsMax}
onChange={(value) => updateInput(setInput, "drawsMax", value)}
/>
</div>
{result ? <SimulationOverview overview={result.overview} /> : null}
</>
) : null}
<div className={styles.simulationGrid}>
<NumberField
help={simulationHelp.users}
label="用户数量"
min={1}
value={input.users}
onChange={(value) => updateInput(setInput, "users", value)}
/>
<NumberField
help={simulationHelp.rooms}
label="房间数量"
min={1}
value={input.rooms}
onChange={(value) => updateInput(setInput, "rooms", value)}
/>
<NumberField
help={simulationHelp.costMin}
label="单抽花费最小值"
min={1}
value={input.costMin}
onChange={(value) => updateInput(setInput, "costMin", value)}
/>
<NumberField
help={simulationHelp.costMax}
label="单抽花费最大值"
min={1}
value={input.costMax}
onChange={(value) => updateInput(setInput, "costMax", value)}
/>
<NumberField
help={simulationHelp.drawsMin}
label="抽奖次数最小值"
min={0}
value={input.drawsMin}
onChange={(value) => updateInput(setInput, "drawsMin", value)}
/>
<NumberField
help={simulationHelp.drawsMax}
label="抽奖次数最大值"
min={0}
value={input.drawsMax}
onChange={(value) => updateInput(setInput, "drawsMax", value)}
/>
</div>
{result ? <SimulationOverview overview={result.overview} /> : null}
</section>
);
}

View File

@ -21,9 +21,8 @@ export function emptyLuckyGiftForm() {
controlStrength: "standard",
enabled: false,
jackpotStrategy: "standard",
multiplierValues: ["0", "0.2", "0.5", "1", "2", "5", "10", "20", "50", "100", "500"],
noviceStrength: "standard",
poolId: "default",
payoutStyle: "balanced",
poolMode: "standard",
riskLevel: "standard",
targetRtpPercent: "95",
@ -44,9 +43,8 @@ export function formFromConfig(config) {
controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws),
enabled: Boolean(config.enabled),
jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple),
multiplierValues: formatMultiplierValues(config.multiplierPPMs, config.tiers || []),
noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit),
poolId: config.poolId || "default",
payoutStyle: inferPayoutStyle(config.tiers || []),
poolMode: inferPoolMode(config),
riskLevel: inferRiskLevel(config),
targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM),
@ -63,9 +61,6 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
const atmosphere = atmosphereLevelConfig[form.atmosphereLevel] || atmosphereLevelConfig.medium;
const risk = riskCapsForLevel(form.riskLevel, giftPrice, targetRTPPPM);
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 activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0;
@ -80,7 +75,7 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
gift_reserve: positiveNumber(base.giftReserve, 1_000_000),
global_window_draws: control.globalWindowDraws,
gift_window_draws: control.giftWindowDraws,
high_multiplier: highMultiplier,
high_multiplier: positiveNumber(base.highMultiplier, 100),
high_water_pool_multiple: jackpot.highWaterPoolMultiple,
initial_gift_pool: positiveNumber(base.initialGiftPool, expectedPayout(giftPrice, targetRTPPPM, control.giftWindowDraws) / 2),
initial_platform_pool: positiveNumber(
@ -90,12 +85,10 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000),
intermediate_draw_limit: novice.intermediateDrawLimit,
large_tier_enabled: jackpot.largeTierEnabled,
max_single_payout: Math.max(risk.maxSinglePayout, Math.ceil((giftPrice * maxMultiplierPPM) / ppmScale)),
multiplier_ppms: multiplierPPMs,
max_single_payout: risk.maxSinglePayout,
novice_draw_limit: novice.noviceDrawLimit,
platform_pool_weight_ppm: poolMode.platformPoolWeightPPM,
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)),
room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200),
room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM,
@ -104,7 +97,7 @@ export function payloadFromLuckyGiftForm(form, currentConfig) {
room_pool_weight_ppm: poolMode.roomPoolWeightPPM,
room_reserve: positiveNumber(base.roomReserve, giftPrice * 200),
target_rtp_ppm: targetRTPPPM,
tiers: buildTiers(giftPrice, multiplierPPMs, highMultiplier),
tiers: buildTiers(giftPrice, form.payoutStyle),
user_daily_payout_cap: risk.userDailyPayoutCap,
user_hourly_payout_cap: risk.userHourlyPayoutCap,
};
@ -128,69 +121,50 @@ export function previewForForm(form) {
};
}
function buildTiers(cost, multiplierPPMs, highMultiplier) {
return ["novice", "intermediate", "advanced"].flatMap((pool) =>
multiplierPPMs.map((multiplierPPM) =>
tier(
pool,
`${pool}_${multiplierLabel(multiplierPPM)}`,
Math.floor((cost * multiplierPPM) / ppmScale),
multiplierPPM,
highMultiplier > 0 && multiplierPPM >= highMultiplier * ppmScale,
),
),
);
function buildTiers(cost, style) {
const scale = style === "stable" ? 0 : style === "exciting" ? 2 : 1;
const noviceHigh = scale === 0 ? 200 : scale === 2 ? 900 : 500;
const intermediateHigh = scale === 0 ? 10_000 : scale === 2 ? 36_000 : 25_000;
const advancedHigh = scale === 0 ? 15_000 : scale === 2 ? 58_000 : 40_000;
return [
tier("novice", "none", 0, 720_000),
tier("novice", "novice_feedback_0_2x", Math.floor(cost / 5), 90_000),
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, multiplierPPM, highWaterOnly = false) {
function tier(pool, tierId, rewardCoins, weight, highWaterOnly = false) {
return {
enabled: true,
enabled: weight > 0,
high_water_only: Boolean(highWaterOnly),
multiplier_ppm: multiplierPPM,
pool,
reward_coins: rewardCoins,
tier_id: tierId,
weight: 0,
weight,
};
}
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) {
const expectedHour = expectedPayout(giftPrice, targetRTPPPM, defaultDrawsPerHour);
const expectedDay = expectedHour * 24;
@ -249,6 +223,19 @@ function inferAtmosphereLevel(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() {
return "standard";
}

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { emptyLuckyGiftForm, parseMultiplierList, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
import { emptyLuckyGiftForm, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
describe("lucky gift config payload", () => {
test("submits both RTP control windows for backend validation", () => {
@ -7,12 +7,5 @@ describe("lucky gift config payload", () => {
expect(payload.global_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]);
});
});

View File

@ -1,3 +1,5 @@
export const luckyGiftConfigScopeLabel = "全局配置";
export const controlStrengthOptions = [
["precise", "精准"],
["standard", "标准"],
@ -17,6 +19,12 @@ export const noviceStrengthOptions = [
["strong", "强"],
];
export const payoutStyleOptions = [
["stable", "稳定小奖"],
["balanced", "均衡"],
["exciting", "刺激大奖"],
];
export const jackpotStrategyOptions = [
["off", "关闭"],
["conservative", "保守"],

View File

@ -1,97 +1,51 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
getLuckyGiftConfig,
getLuckyGiftDrawSummary,
listLuckyGiftConfigs,
updateLuckyGiftConfig,
} from "@/features/lucky-gift/api";
import { getLuckyGiftConfig, listLuckyGiftDraws, updateLuckyGiftConfig } from "@/features/lucky-gift/api";
import { emptyLuckyGiftForm, formFromConfig, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
import { useLuckyGiftAbilities } from "@/features/lucky-gift/permissions.js";
import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema";
const defaultPoolId = "default";
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,
};
const pageSize = 10;
const emptyDraws = { items: [], page: 1, pageSize, total: 0 };
export function useLuckyGiftPage() {
const abilities = useLuckyGiftAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(null);
const [configs, setConfigs] = useState([]);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [addPoolOpen, setAddPoolOpen] = useState(false);
const [addPoolId, setAddPoolId] = useState("");
const [form, setForm] = useState(emptyLuckyGiftForm());
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [poolId, setPoolId] = useState(defaultPoolId);
const [giftId, setGiftId] = useState("");
const [userId, setUserId] = useState("");
const [roomId, setRoomId] = useState("");
const [status, setStatus] = useState("");
const [drawSummary, setDrawSummary] = useState(emptySummary);
const [drawSummaryLoading, setDrawSummaryLoading] = useState(false);
const [drawSummaryError, setDrawSummaryError] = useState("");
const [page, setPage] = useState(1);
const drawFilters = useMemo(
() => ({ gift_id: giftId, pool_id: poolId, room_id: roomId, status, user_id: userId }),
[giftId, poolId, roomId, status, userId],
() => ({ gift_id: giftId, room_id: roomId, status, user_id: userId }),
[giftId, roomId, status, userId],
);
const poolOptions = useMemo(() => {
const ids = new Set([poolId]);
for (const item of configs) {
if (item.poolId) {
ids.add(item.poolId);
}
}
return Array.from(ids);
}, [configs, poolId]);
const reloadDrawSummary = useCallback(async () => {
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 {
data: draws = emptyDraws,
error: drawsError,
loading: drawsLoading,
reload: reloadDraws,
} = usePaginatedQuery({
errorMessage: "加载幸运礼物记录失败",
fetcher: listLuckyGiftDraws,
filters: drawFilters,
page,
pageSize,
queryKey: ["lucky-gift-draws", drawFilters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const nextConfig = await getLuckyGiftConfig(poolId);
const nextConfig = await getLuckyGiftConfig();
setConfig(nextConfig);
setForm(formFromConfig(nextConfig));
} catch (err) {
@ -99,25 +53,21 @@ export function useLuckyGiftPage() {
} finally {
setConfigLoading(false);
}
}, [poolId, showToast]);
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
useEffect(() => {
void reloadConfigs();
}, [reloadConfigs]);
const openConfigDrawer = useCallback(() => {
setForm(config ? formFromConfig(config) : { ...emptyLuckyGiftForm(), poolId });
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
setConfigDrawerOpen(true);
}, [config, poolId]);
}, [config]);
const closeConfigDrawer = useCallback(() => {
setConfigDrawerOpen(false);
setForm(config ? formFromConfig(config) : { ...emptyLuckyGiftForm(), poolId });
}, [config, poolId]);
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
}, [config]);
const submitConfig = async (event) => {
event.preventDefault();
@ -129,12 +79,10 @@ export function useLuckyGiftPage() {
const parsed = parseForm(luckyGiftConfigFormSchema, form);
const saved = await updateLuckyGiftConfig(payloadFromLuckyGiftForm(parsed, config));
setConfig(saved);
setConfigs((current) => mergeConfigList(current, saved));
setForm(formFromConfig(saved));
setPoolId(saved.poolId || poolId);
setConfigDrawerOpen(false);
showToast("幸运礼物配置已保存", "success");
await reloadDrawSummary();
await reloadDraws();
} catch (err) {
showToast(err.message || "保存幸运礼物配置失败", "error");
} finally {
@ -144,99 +92,54 @@ export function useLuckyGiftPage() {
const changeGiftId = (value) => {
setGiftId(value);
};
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);
setPage(1);
};
const changeUserId = (value) => {
setUserId(value);
setPage(1);
};
const changeRoomId = (value) => {
setRoomId(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const resetDrawFilters = () => {
setGiftId("");
setUserId("");
setRoomId("");
setStatus("");
setPage(1);
};
return {
abilities,
addPoolId,
addPoolOpen,
changeGiftId,
changePoolId,
changeRoomId,
changeStatus,
changeUserId,
closeConfigDrawer,
closeAddPool,
config,
configDrawerOpen,
configLoading,
configSaving,
drawSummary,
drawSummaryError,
drawSummaryLoading,
draws,
drawsError,
drawsLoading,
form,
giftId,
openConfigDrawer,
openAddPool,
poolId,
poolOptions,
page,
reloadConfig,
reloadConfigs,
reloadDrawSummary,
reloadDraws,
resetDrawFilters,
roomId,
setForm,
setAddPoolId,
setPage,
status,
submitConfig,
submitAddPool,
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;
}

View File

@ -83,10 +83,6 @@
width: 220px;
}
.poolSelector {
width: 160px;
}
.configDrawer {
box-sizing: border-box;
width: min(520px, 100vw);
@ -170,39 +166,6 @@
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 {
display: grid;
gap: var(--space-4);
@ -237,52 +200,6 @@
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 {
display: grid;
min-width: 0;
@ -310,15 +227,6 @@
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 {
display: grid;
min-width: 0;
@ -361,15 +269,8 @@
flex-direction: column;
}
.drawSummaryHeader {
align-items: flex-start;
flex-direction: column;
}
.simulationGrid,
.simulationOverview,
.drawSummaryGrid,
.multiplierList {
.simulationOverview {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
@ -378,8 +279,7 @@
.configGrid,
.subsidyInputs,
.simulationGrid,
.simulationOverview,
.drawSummaryGrid {
.simulationOverview {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -1,8 +1,3 @@
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 { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
@ -10,11 +5,86 @@ import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/Lucky
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import {
AdminFilterResetButton,
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() {
const page = useLuckyGiftPage();
const total = page.draws.total || 0;
return (
<AdminListPage>
@ -22,15 +92,64 @@ export function LuckyGiftConfigPage() {
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
poolId={page.poolId}
poolOptions={page.poolOptions}
onAddPool={page.openAddPool}
onEdit={page.openConfigDrawer}
onPoolChange={page.changePoolId}
onRefresh={page.reloadConfig}
/>
<LuckyGiftSimulationPanel config={page.config} />
<LuckyGiftDrawSummaryPanel page={page} />
<AdminListToolbar
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
abilities={page.abilities}
configLoading={page.configLoading}
@ -41,152 +160,55 @@ export function LuckyGiftConfigPage() {
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
<AddPoolDialog
disabled={page.configSaving}
open={page.addPoolOpen}
value={page.addPoolId}
onChange={page.setAddPoolId}
onClose={page.closeAddPool}
onSubmit={page.submitAddPool}
/>
</AdminListPage>
);
}
function LuckyGiftDrawSummaryPanel({ page }) {
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),
),
},
];
function DrawStatus({ status }) {
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
return (
<section className={styles.drawSummaryPanel}>
<header className={styles.drawSummaryHeader}>
<div className={styles.stack}>
<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>
<span className={["status-badge", `status-badge--${tone}`].join(" ")}>
<span className="status-point" />
{drawStatusOptions.find(([value]) => value === status)?.[1] || status || "-"}
</span>
);
}
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
return (
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
<form onSubmit={onSubmit}>
<DialogTitle>添加奖池</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
required
disabled={disabled}
label="奖池 ID"
margin="dense"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button disabled={disabled} type="button" onClick={onClose}>
取消
</Button>
<Button disabled={disabled} type="submit" variant="primary">
添加并编辑
</Button>
</DialogActions>
</form>
</Dialog>
);
function poolLabel(value) {
if (value === "novice") {
return "新手池";
}
if (value === "intermediate") {
return "中级池";
}
if (value === "advanced") {
return "高级池";
}
return value || "-";
}
function flagLabel(draw) {
const labels = [];
if (draw.highMultiplier) {
labels.push("高倍");
}
if (draw.stageFeedback) {
labels.push("阶段反馈");
}
return labels.length ? labels.join(" / ") : "-";
}
function budgetSourceLabel(draw) {
if (draw.activitySubsidyCoins > 0) {
return "活动补贴";
}
if (draw.roomAtmosphereRewardCoins > 0) {
return "房间气氛";
}
if (draw.baseRewardCoins > 0) {
return "基础 RTP";
}
return "-";
}
function formatPPM(value) {

View File

@ -9,34 +9,25 @@ export const luckyGiftConfigFormSchema = z
controlStrength: z.enum(["precise", "standard", "natural"]),
enabled: z.boolean(),
jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]),
multiplierValues: z.array(z.coerce.number().min(0, "倍率不能小于 0")).min(1, "倍率配置不能为空"),
noviceStrength: z.enum(["weak", "standard", "strong"]),
poolId: z.string().trim().min(1, "奖池 ID 不能为空").max(96, "奖池 ID 不能超过 96 个字符"),
payoutStyle: z.enum(["stable", "balanced", "exciting"]),
poolMode: z.enum(["platform_guard", "standard", "room_burst", "gift_isolated"]),
riskLevel: z.enum(["strict", "standard", "loose"]),
targetRtpPercent: z.coerce.number().min(1, "RTP 必须大于 1%").max(100, "RTP 不能超过 100%"),
})
.superRefine((value, context) => {
if (value.activitySubsidyEnabled && value.activityBudget <= 0) {
if (!value.activitySubsidyEnabled) {
return;
}
if (value.activityBudget <= 0) {
context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] });
}
if (value.activitySubsidyEnabled && value.activityBudget > 0 && value.activityDailyLimit <= 0) {
if (value.activityBudget > 0 && value.activityDailyLimit <= 0) {
context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] });
}
if (value.activitySubsidyEnabled && value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) {
if (value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) {
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>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,26 +42,20 @@ test("resource APIs use generated admin paths", async () => {
await createResource({
amount: 0,
assetUrl: "https://media.haiyihy.com/resource/material.png",
badgeForm: "strip",
coinPrice: 10,
name: "VIP Badge",
name: "Rose",
previewUrl: "https://media.haiyihy.com/resource/cover.png",
priceType: "coin",
resourceCode: "badge_vip_test",
resourceType: "badge",
resourceCode: "gift_rose_test",
resourceType: "gift",
sortOrder: 0,
status: "active",
});
await updateResource(11, {
amount: 0,
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
badgeForm: "tile",
coinPrice: 20,
name: "VIP Badge Updated",
name: "Rose Updated",
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
priceType: "coin",
resourceCode: "badge_vip_test",
resourceType: "badge",
resourceCode: "gift_rose_test",
resourceType: "gift",
sortOrder: 0,
status: "active",
});
@ -179,15 +173,9 @@ test("resource APIs use generated admin paths", async () => {
expect(listInit?.method).toBe("GET");
expect(String(createUrl)).toContain("/api/v1/admin/resources");
expect(createInit?.method).toBe("POST");
expect(JSON.parse(String(createInit?.body))).toMatchObject({
badgeForm: "strip",
resourceCode: "badge_vip_test",
resourceType: "badge",
status: "active",
});
expect(JSON.parse(String(createInit?.body))).toMatchObject({ resourceCode: "gift_rose_test", status: "active" });
expect(String(updateUrl)).toContain("/api/v1/admin/resources/11");
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("region_id=1001");
expect(giftListInit?.method).toBe("GET");

View File

@ -13,10 +13,6 @@ export interface ResourceDto {
grantStrategy?: string;
walletAssetType?: string;
walletAssetAmount?: number;
priceType?: string;
coinPrice?: number;
giftPointAmount?: number;
badgeForm?: string;
usageScopes?: string[];
assetUrl?: string;
previewUrl?: string;
@ -31,11 +27,8 @@ export interface ResourcePayload {
amount: number;
animationUrl?: string;
assetUrl: string;
badgeForm?: string;
coinPrice: number;
name: string;
previewUrl: string;
priceType: string;
resourceCode: string;
resourceType: string;
sortOrder: number;

View File

@ -1,195 +0,0 @@
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");
}

View File

@ -1,165 +0,0 @@
.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;
}
}

View File

@ -1,54 +0,0 @@
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();
});

View File

@ -41,20 +41,6 @@ export const emojiPackPricingLabels = {
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 = [
["", "全部类型"],
["avatar_frame", "头像框"],

View File

@ -44,12 +44,9 @@ const dayMillis = 24 * 60 * 60 * 1000;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyResourceForm = (resource = {}) => ({
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,
name: resource.name || "",
previewUrl: resource.previewUrl || "",
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
resourceCode: resource.resourceCode || "",
resourceType: resource.resourceType || "avatar_frame",
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
@ -106,17 +103,14 @@ const emptyGiftForm = (gift = {}) => ({
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
});
export function applyGiftResourceSelection(form, resource, fillIdentity = true) {
export function applyGiftResourceSelection(form, resource) {
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
const nextForm = {
return {
...form,
giftId: resourceId,
name: resource?.name || resource?.resourceCode || resourceId,
resourceId,
};
if (fillIdentity) {
nextForm.giftId = resourceId;
nextForm.name = resource?.name || resource?.resourceCode || resourceId;
}
return applyGiftResourcePrice(nextForm, resource);
}
export function applyGiftPriceDefaults(form, coinPrice) {
@ -130,17 +124,6 @@ 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 = () => ({
durationDays: "0",
groupId: "",
@ -610,11 +593,10 @@ export function useGiftListPage() {
const selectGiftResource = (resourceId) => {
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
setForm((currentForm) => {
return applyGiftResourceSelection(
currentForm,
selectedResource || { resourceId },
activeAction === "create",
);
if (activeAction !== "create") {
return { ...currentForm, resourceId };
}
return applyGiftResourceSelection(currentForm, selectedResource || { resourceId });
});
};
@ -1023,11 +1005,8 @@ function buildResourcePayload(form) {
amount: isCoin ? Number(form.walletAssetAmount) : 0,
animationUrl,
assetUrl: animationUrl,
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
name: form.name.trim(),
previewUrl: form.previewUrl.trim(),
priceType: form.priceType,
resourceCode: form.resourceCode.trim(),
resourceType,
sortOrder: 0,
@ -1035,22 +1014,6 @@ 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) {
const regionIds = (form.regionIds || [])
.map(Number)
@ -1117,7 +1080,7 @@ function buildGiftPayload(form) {
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
effectTypes: form.effectTypes || [],
giftId: form.giftId.trim(),
giftPointAmount: Number(form.coinPrice || 0),
giftPointAmount: Number(form.giftPointAmount || 0),
giftTypeCode: form.giftTypeCode,
heatValue: Number(form.heatValue || 0),
name: form.name.trim(),

View File

@ -10,9 +10,7 @@ test("fills gift name and id from selected gift resource", () => {
};
const nextForm = applyGiftResourceSelection(form, {
coinPrice: 10,
name: "Rose",
priceType: "coin",
resourceCode: "gift_rose",
resourceId: 11,
});

View File

@ -34,10 +34,13 @@ import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/t
import { formatMillis } from "@/shared/utils/time.js";
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
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 styles from "@/features/resources/resources.module.css";
const chargeAssetOptions = [
["COIN", "金币"],
["DIAMOND", "钻石"],
];
const effectOptions = [
["animation", "动画"],
["music", "音乐"],
@ -66,7 +69,9 @@ const baseColumns = (giftTypeOptions) => [
render: (gift) => (
<div className={styles.stack}>
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
<span className={styles.meta}>{giftPriceLabel(gift)}</span>
<span className={styles.meta}>
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
</span>
</div>
),
},
@ -272,15 +277,20 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
>
<AdminFormSection title="资源信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<ResourceSelectField
<TextField
disabled={disabled || page.resourceOptionsLoading}
drawerTitle="选择礼物资源"
label="礼物资源"
loading={page.resourceOptionsLoading}
resources={page.resourceOptions}
required
select
value={form.resourceId}
onChange={(resourceId) => page.selectGiftResource(resourceId)}
/>
onChange={(event) => page.selectGiftResource(event.target.value)}
>
{page.resourceOptions.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
{showGiftIdentityFields ? (
<>
<TextField
@ -317,8 +327,35 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
</AdminFormSection>
<AdminFormSection title="价格与排序">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
<TextField disabled label="积分" type="number" value={form.giftPointAmount} />
<TextField
disabled={disabled}
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
disabled={disabled}
label="热度"
@ -621,12 +658,8 @@ function giftTypeLabel(value, options = defaultGiftTypeOptions) {
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
}
function giftPriceLabel(gift) {
if (Number(gift.coinPrice || 0) === 0) {
return "免费";
}
const chargeAsset = gift.chargeAssetType === "DIAMOND" ? "钻石" : "金币";
return `${chargeAsset} · ${formatNumber(gift.coinPrice)}`;
function chargeAssetLabel(value) {
return chargeAssetOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "金币";
}
function effectTypeLabel(value) {

View File

@ -5,6 +5,7 @@ import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import {
@ -28,7 +29,6 @@ import {
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.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 { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
@ -87,22 +87,22 @@ export function ResourceGroupListPage() {
}),
}
: 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,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (group) => <ResourceGroupStatusSwitch group={group} page={page} />,
render: (group) => <ResourceGroupRowActions group={group} page={page} />,
}
: column.key === "actions"
? {
...column,
render: (group) => <ResourceGroupRowActions group={group} page={page} />,
}
: column,
: column,
);
return (
@ -309,15 +309,20 @@ function ResourceGroupItemEditor({ disabled, index, item, page }) {
return (
<AdminFormListRow>
<ResourceSelectField
<TextField
disabled={disabled || page.resourceOptionsLoading}
drawerTitle="选择资源"
label="资源"
loading={page.resourceOptionsLoading}
resources={page.resourceOptions}
required
select
value={item.resourceId}
onChange={(resourceId) => page.updateGroupItem(index, { resourceId })}
/>
onChange={(event) => page.updateGroupItem(index, { resourceId: event.target.value })}
>
{page.resourceOptions.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="有效天数"

View File

@ -23,11 +23,7 @@ import {
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
badgeFormLabels,
badgeFormOptions,
resourceStatusFilters,
resourcePriceTypeLabels,
resourcePriceTypeOptions,
resourceTypeFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
@ -49,18 +45,6 @@ const baseColumns = [
width: "minmax(120px, 0.7fr)",
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",
label: "状态",
@ -139,7 +123,7 @@ export function ResourceListPage() {
<DataTable
columns={columns}
items={items}
minWidth="940px"
minWidth="820px"
pagination={
total > 0
? {
@ -220,15 +204,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
required
select
value={form.resourceType}
onChange={(event) => {
const resourceType = event.target.value;
setForm({
...form,
badgeForm: resourceType === "badge" ? form.badgeForm || "tile" : form.badgeForm,
resourceType,
walletAssetAmount: "",
});
}}
onChange={(event) =>
setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })
}
>
{resourceTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
@ -246,54 +224,6 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
/>
) : 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>
</AdminFormSection>
<AdminFormSection title="素材">
@ -355,36 +285,6 @@ 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) {
const url = String(value || "").trim();
if (!url) {
@ -392,7 +292,3 @@ function imageURL(value) {
}
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -14,8 +14,6 @@ const resourceTypes = [
"emoji_pack",
];
const walletAssetTypes = ["COIN", "DIAMOND"];
const resourcePriceTypes = ["coin", "free"];
const badgeForms = ["strip", "tile"];
const optionalWalletAssetTypeSchema = z
.preprocess((value) => {
if (typeof value !== "string") {
@ -29,44 +27,24 @@ const optionalWalletAssetTypeSchema = z
export const resourceCreateFormSchema = z
.object({
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
badgeForm: z.enum(badgeForms).optional(),
enabled: z.boolean(),
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 个字符"),
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
resourceType: z.enum(resourceTypes, "请选择资源类型"),
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
})
.superRefine((value, context) => {
if (value.resourceType !== "coin") {
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"],
});
}
return;
}
if (value.priceType === "coin") {
const coinPrice = Number(value.coinPrice);
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
context.addIssue({
code: "custom",
message: "请输入资源价格",
path: ["coinPrice"],
});
}
const amount = Number(value.walletAssetAmount);
if (!Number.isInteger(amount) || amount <= 0) {
context.addIssue({
code: "custom",
message: "请输入金币数量",
path: ["walletAssetAmount"],
});
}
});
@ -192,7 +170,7 @@ export const giftFormSchema = z
path: ["resourceId"],
});
}
if (!Number.isInteger(coinPrice) || coinPrice < 0) {
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
context.addIssue({
code: "custom",
message: "请输入价格",
@ -206,13 +184,6 @@ export const giftFormSchema = z
path: ["giftPointAmount"],
});
}
if (Number.isInteger(coinPrice) && Number.isInteger(giftPointAmount) && giftPointAmount !== coinPrice) {
context.addIssue({
code: "custom",
message: "积分必须同步价格",
path: ["giftPointAmount"],
});
}
if (!Number.isInteger(heatValue) || heatValue < 0) {
context.addIssue({
code: "custom",

View File

@ -1,150 +1,120 @@
import { describe, expect, test } from "vitest";
import { FormValidationError, parseForm } from "@/shared/forms/validation";
import {
emojiPackFormSchema,
giftFormSchema,
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema,
emojiPackFormSchema,
giftFormSchema,
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema
} from "./schema.js";
describe("resource form schema", () => {
test("allows profile card resources", () => {
const payload = parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
badgeForm: "tile",
enabled: true,
name: "Profile Card",
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
priceType: "free",
resourceCode: "profile_card_gold",
resourceType: "profile_card",
walletAssetAmount: "",
});
expect(payload.resourceType).toBe("profile_card");
test("allows profile card resources", () => {
const payload = parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
enabled: true,
name: "Profile Card",
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
resourceCode: "profile_card_gold",
resourceType: "profile_card",
walletAssetAmount: ""
});
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: "",
});
expect(payload.resourceType).toBe("profile_card");
});
expect(payload.badgeForm).toBe("strip");
expect(() =>
parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/badge.pag",
enabled: true,
name: "VIP Badge",
previewUrl: "https://media.haiyihy.com/resource/badge.png",
priceType: "free",
resourceCode: "badge_vip",
resourceType: "badge",
walletAssetAmount: "",
}),
).toThrow(FormValidationError);
test("allows resource group resource items without wallet asset type", () => {
const payload = parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "7",
itemType: "resource",
resourceId: "11",
sortOrder: "0",
walletAssetAmount: "",
walletAssetType: ""
}
],
name: "Starter Pack",
sortOrder: "0"
});
test("allows resource group resource items without wallet asset type", () => {
const payload = parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "7",
itemType: "resource",
resourceId: "11",
sortOrder: "0",
walletAssetAmount: "",
walletAssetType: "",
},
],
name: "Starter Pack",
expect(payload.items[0].walletAssetType).toBeUndefined();
});
test("rejects invalid wallet asset type with Chinese message", () => {
expect(() =>
parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "",
itemType: "wallet_asset",
resourceId: "",
sortOrder: "0",
});
walletAssetAmount: "100",
walletAssetType: "gold"
}
],
name: "Starter Pack",
sortOrder: "0"
})
).toThrow(FormValidationError);
});
expect(payload.items[0].walletAssetType).toBeUndefined();
test("validates editable gift form fields", () => {
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"
});
test("rejects invalid wallet asset type with Chinese message", () => {
expect(() =>
parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "",
itemType: "wallet_asset",
resourceId: "",
sortOrder: "0",
walletAssetAmount: "100",
walletAssetType: "gold",
},
],
name: "Starter Pack",
sortOrder: "0",
}),
).toThrow(FormValidationError);
expect(payload.regionIds).toEqual(["0", "1001"]);
});
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"
});
test("validates editable gift form fields", () => {
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",
});
expect(payload.category).toBe("热门");
expect(payload.pricingType).toBe("paid");
});
expect(payload.regionIds).toEqual(["0", "1001"]);
test("validates resource grant form fields", () => {
const payload = parseForm(resourceGrantFormSchema, {
durationDays: "7",
quantity: "1",
reason: "manual",
resourceId: "11",
subjectType: "resource",
targetUserId: "1001"
});
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");
});
expect(payload.subjectType).toBe("resource");
});
});

View File

@ -27,8 +27,8 @@ const permissionGroupDefinitions = [
{ key: "regions", prefixes: ["region"], title: "区域管理" },
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
{ key: "hosts", prefixes: ["host"], title: "Host 列表" },
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "Coin Saller列表" },
{ key: "hosts", prefixes: ["host"], title: "主播列表" },
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" },
{ key: "logs", prefixes: ["log"], title: "日志审计" },
{ key: "common", prefixes: ["upload"], title: "通用能力" },
];

View File

@ -1,155 +0,0 @@
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;
}

View File

@ -1,87 +0,0 @@
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,
};
}

View File

@ -1,162 +0,0 @@
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");
}

View File

@ -1,9 +0,0 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useUserLeaderboardAbilities() {
const { can } = useAuth();
return {
canView: can(PERMISSIONS.userLeaderboardView),
};
}

View File

@ -1,12 +0,0 @@
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,
},
];

View File

@ -1,50 +0,0 @@
.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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,103 +0,0 @@
.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

View File

@ -84,70 +84,6 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@ -164,22 +100,6 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@ -196,38 +116,6 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@ -244,22 +132,6 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@ -413,61 +285,13 @@ export interface paths {
};
get: operations["listH5Links"];
put: operations["updateH5Links"];
post: operations["createH5Link"];
delete?: never;
options?: never;
head?: never;
patch?: 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": {
parameters: {
query?: never;
@ -798,7 +622,7 @@ export interface paths {
get?: never;
put?: never;
post?: never;
delete: operations["deleteCatalog"];
delete?: never;
options?: never;
head?: never;
patch: operations["updateCatalog"];
@ -2608,68 +2432,6 @@ export interface operations {
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: {
parameters: {
query?: never;
@ -2694,18 +2456,6 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listLuckyGiftConfigs: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listLuckyGiftDraws: {
parameters: {
query?: never;
@ -2718,30 +2468,6 @@ export interface operations {
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: {
parameters: {
query?: never;
@ -2754,30 +2480,6 @@ export interface operations {
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: {
parameters: {
query?: never;
@ -2997,98 +2699,6 @@ export interface operations {
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: {
parameters: {
query?: never;
@ -3486,20 +3096,6 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
deleteCatalog: {
parameters: {
query?: never;
header?: never;
path: {
game_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateCatalog: {
parameters: {
query?: never;

View File

@ -618,32 +618,13 @@ export interface H5LinkConfigDto {
export interface H5LinkConfigPayload {
key: string;
label: string;
url: string;
url?: string;
}
export interface H5LinkConfigUpdatePayload {
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 {
appCode?: string;
bannerType: "h5" | "app" | string;

View File

@ -1,126 +1,87 @@
import Switch from "@mui/material/Switch";
const baseSwitchSx = {
"--switch-width": "40px",
"--switch-height": "24px",
"--switch-thumb-size": "18px",
"--switch-padding": "3px",
"--switch-translate-x": "16px",
margin: 0,
"--switch-width": "40px",
"--switch-height": "24px",
"--switch-thumb-size": "18px",
"--switch-padding": "3px",
"--switch-translate-x": "16px",
margin: 0
};
function cssContent(value) {
return `"${String(value ?? "")
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')}"`;
return `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function labeledSwitchSx(checkedLabel, uncheckedLabel) {
const labelLength = Math.max(
Array.from(String(checkedLabel || "")).length,
Array.from(String(uncheckedLabel || "")).length,
);
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
const labelLength = Math.max(Array.from(String(checkedLabel || "")).length, Array.from(String(uncheckedLabel || "")).length);
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
return {
"--switch-width": `${width}px`,
"--switch-height": "26px",
"--switch-thumb-size": "20px",
"--switch-padding": "3px",
"--switch-translate-x": `${width - 26}px`,
width: "var(--switch-width)",
height: "var(--switch-height)",
padding: 0,
overflow: "visible",
"& .MuiSwitch-switchBase": {
padding: "var(--switch-padding)",
"&.Mui-checked": {
transform: "translateX(var(--switch-translate-x))",
},
},
"& .MuiSwitch-thumb": {
width: "var(--switch-thumb-size)",
height: "var(--switch-thumb-size)",
},
"& .MuiSwitch-track": {
overflow: "hidden",
position: "relative",
"&::before, &::after": {
position: "absolute",
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
fontSize: "10px",
fontWeight: 720,
letterSpacing: 0,
lineHeight: 1,
whiteSpace: "nowrap",
pointerEvents: "none",
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)",
},
"&::before": {
left: 5,
right: 20,
justifyContent: "flex-start",
color: "var(--active-contrast)",
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;
return {
"--switch-width": `${width}px`,
"--switch-height": "26px",
"--switch-thumb-size": "20px",
"--switch-padding": "3px",
"--switch-translate-x": `${width - 26}px`,
"& .MuiSwitch-track": {
overflow: "hidden",
position: "relative",
"&::before, &::after": {
position: "absolute",
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
fontSize: 10.5,
fontWeight: 760,
lineHeight: 1,
pointerEvents: "none",
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)"
},
"&::before": {
right: 26,
left: 7,
justifyContent: "flex-start",
color: "var(--active-contrast)",
content: cssContent(checkedLabel),
opacity: 0
},
"&::after": {
right: 7,
left: 26,
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
}
if (typeof slotInputProps === "function") {
return (ownerState) => ({
...fallbackInputProps,
...slotInputProps(ownerState),
});
}
return {
...fallbackInputProps,
...slotInputProps,
};
};
}
export function AdminSwitch({ checkedLabel, inputProps, label, slotProps, sx, uncheckedLabel, ...props }) {
const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel);
const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null;
const mergedSx = Array.isArray(sx)
? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean)
: [baseSwitchSx, switchLabelSx, sx].filter(Boolean);
const fallbackInputProps = label || inputProps || slotProps?.input
? {
role: "switch",
...(label ? { "aria-label": label } : null),
...inputProps,
}
: undefined;
const mergedSlotProps =
fallbackInputProps || slotProps
? {
...slotProps,
input: mergeInputSlotProps(slotProps?.input, fallbackInputProps),
}
: undefined;
export function AdminSwitch({ checkedLabel, inputProps, label, sx, uncheckedLabel, ...props }) {
const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel);
const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null;
const mergedSx = Array.isArray(sx)
? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean)
: [baseSwitchSx, switchLabelSx, sx].filter(Boolean);
const mergedInputProps = label
? {
"aria-label": label,
...inputProps
}
: inputProps;
return <Switch {...props} slotProps={mergedSlotProps} sx={mergedSx} />;
return (
<Switch
{...props}
inputProps={mergedInputProps}
sx={mergedSx}
/>
);
}

View File

@ -22,7 +22,6 @@ export function DataTable({
emptyLabel = "当前无数据",
fixedEdges = true,
getRowProps,
infiniteScroll,
items,
minWidth = "980px",
onColumnWidthsChange,
@ -39,11 +38,8 @@ export function DataTable({
const [filterQuery, setFilterQuery] = useState("");
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const [resizeSession, setResizeSession] = useState(null);
const loadMoreRef = useRef(null);
const scrollRef = useRef(null);
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 preparedColumns = useMemo(
@ -136,28 +132,6 @@ export function DataTable({
};
}, [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) => {
if (!resizableColumns || column.resizable === false) {
return;
@ -273,48 +247,25 @@ export function DataTable({
})}
</div>
{safeItems.length ? (
<>
{safeItems.map((item, index) => {
const rowProps = getRowProps ? getRowProps(item, index) : {};
const { className: rowClassName = "", ...restRowProps } = rowProps;
safeItems.map((item, index) => {
const rowProps = getRowProps ? getRowProps(item, index) : {};
const { className: rowClassName = "", ...restRowProps } = rowProps;
return (
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
{preparedColumns.map((column) => (
<div className={cellClassName(column)} key={column.key}>
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
</div>
))}
</div>
);
})}
{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>
return (
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
{preparedColumns.map((column) => (
<div className={cellClassName(column)} key={column.key}>
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
</div>
))}
</div>
) : 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__title">{emptyLabel}</div>
</div>
)}
{safeItems.length === 0 && infiniteScroll && !infiniteLoading ? (
<div className="admin-row--load-more-sentinel" ref={loadMoreRef} />
) : null}
</div>
</div>
{pagination ? <PaginationBar {...pagination} /> : null}

View File

@ -1,114 +0,0 @@
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}` };
}
}

View File

@ -1,105 +0,0 @@
.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);
}

View File

@ -1,12 +0,0 @@
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 必须是对象");
});

View File

@ -12,57 +12,46 @@ export function PaginationBar({
page,
pageSize,
pageSizeOptions = [],
total,
totalPages
total
}) {
const hasKnownTotal = Number.isFinite(Number(total));
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 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 end = hasKnownTotal ? Math.min(total, page * pageSize) : Number(itemCount || 0);
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}`;
const nextDisabled = hasKnownTotal ? page >= totalPages : !hasNextPage;
return (
<div className="pagination-bar">
<div className="pagination-bar__meta">
{showPageSize ? (
<span className="pagination-bar__page-size-wrap">
<span>每页</span>
<TextField
className="pagination-bar__page-size"
inputProps={{ "aria-label": "每页条数" }}
select
size="small"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{pageSizeOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
</span>
{pageSizeOptions.length && onPageSizeChange ? (
<TextField
className="pagination-bar__page-size"
label="每页"
select
size="small"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{pageSizeOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
) : null}
<span>{showPageActions && hasKnownTotal ? `${start}-${end} / ${total}` : pageSummary}</span>
<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>
{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>
);
}

View File

@ -59,7 +59,6 @@ export function ResourceGroupSelectField({
<SideDrawer
className={styles.drawer}
contentClassName={styles.drawerBody}
drawerProps={{ sx: { zIndex: 1600 } }}
open={open}
title={drawerTitle}
width="wide"

View File

@ -3,38 +3,33 @@
}
.drawer.drawer {
width: min(640px, 100vw);
width: min(760px, 100vw);
height: 100%;
}
.drawerBody {
flex: 1;
min-height: 0;
overflow: hidden;
}
.groupGrid {
display: grid;
min-height: 0;
align-content: start;
align-items: start;
grid-auto-rows: min-content;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-2);
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
gap: var(--space-3);
overflow-y: auto;
padding-right: var(--space-1);
}
.groupCard {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
min-width: 0;
min-height: 76px;
align-items: start;
gap: var(--space-1) var(--space-3);
min-height: 136px;
align-content: start;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
border-radius: var(--radius-card);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
@ -50,8 +45,8 @@
.groupCard:hover {
border-color: var(--primary-border);
background: var(--primary-surface);
box-shadow: none;
transform: none;
box-shadow: var(--shadow-soft);
transform: translateY(-1px);
}
.groupCard:focus-visible {
@ -67,20 +62,18 @@
}
.emptyCard {
min-height: 64px;
align-items: center;
min-height: 104px;
}
.groupIcon {
display: inline-flex;
grid-row: 1 / 4;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
@ -112,7 +105,6 @@
.groupItems {
display: flex;
grid-column: 2;
min-width: 0;
flex-wrap: wrap;
gap: var(--space-1);
@ -144,7 +136,7 @@
@media (max-width: 640px) {
.groupGrid {
grid-template-columns: 1fr;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
}
}

View File

@ -1,38 +0,0 @@
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();
});

View File

@ -3,36 +3,39 @@ import Drawer from "@mui/material/Drawer";
import { IconButton } from "@/shared/ui/IconButton.jsx";
export function SideDrawer({
actions,
as: Component = "section",
children,
className = "",
contentClassName = "",
drawerProps = {},
onClose,
open,
title,
width = "default",
...props
actions,
as: Component = "section",
children,
className = "",
contentClassName = "",
onClose,
open,
title,
width = "default",
...props
}) {
const drawerClassName = ["side-drawer", width === "wide" ? "side-drawer--wide" : "", className]
.filter(Boolean)
.join(" ");
const drawerClassName = [
"side-drawer",
width === "wide" ? "side-drawer--wide" : "",
className
].filter(Boolean).join(" ");
return (
<Drawer anchor="right" open={open} onClose={onClose} {...drawerProps}>
<Component className={drawerClassName} {...props}>
<div className="side-drawer__header">
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
{onClose ? (
<IconButton label="关闭" onClick={onClose}>
<CloseOutlined fontSize="small" />
</IconButton>
) : null}
</div>
<div className={["side-drawer__body", contentClassName].filter(Boolean).join(" ")}>{children}</div>
{actions ? <div className="side-drawer__actions">{actions}</div> : null}
</Component>
</Drawer>
);
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<Component className={drawerClassName} {...props}>
<div className="side-drawer__header">
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
{onClose ? (
<IconButton label="关闭" onClick={onClose}>
<CloseOutlined fontSize="small" />
</IconButton>
) : null}
</div>
<div className={["side-drawer__body", contentClassName].filter(Boolean).join(" ")}>
{children}
</div>
{actions ? <div className="side-drawer__actions">{actions}</div> : null}
</Component>
</Drawer>
);
}

View File

@ -994,45 +994,27 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: 32px;
margin-top: var(--space-2);
gap: var(--space-3);
margin-top: var(--space-3);
color: var(--text-tertiary);
font-size: 12px;
font-size: var(--admin-font-size);
}
.pagination-bar__meta {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.pagination-bar__page-size-wrap {
display: inline-flex;
align-items: center;
gap: var(--space-1);
gap: var(--space-3);
}
.pagination-bar__page-size {
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;
width: 96px;
}
.pagination-bar__actions {
display: flex;
align-items: center;
gap: var(--space-1);
gap: var(--space-2);
}
.data-state {
@ -1143,7 +1125,7 @@
}
.admin-table {
--admin-table-cell-x: var(--space-3);
--admin-table-cell-x: var(--space-4);
display: flex;
flex-direction: column;
width: max(100%, var(--admin-table-min-width, 900px));
@ -1178,30 +1160,10 @@
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 {
box-sizing: border-box;
min-width: 0;
padding: 0 var(--admin-table-cell-x);
overflow: hidden;
overflow-wrap: anywhere;
}
@ -1218,11 +1180,11 @@
display: flex;
min-height: var(--table-head-height);
align-items: center;
padding-right: calc(var(--admin-table-cell-x) + 10px);
padding-right: calc(var(--admin-table-cell-x) + 12px);
}
.admin-cell--head:last-child {
padding-right: calc(var(--admin-table-cell-x) + 10px);
padding-right: calc(var(--admin-table-cell-x) + 12px);
}
.admin-cell__head-content {

View File

@ -9,8 +9,8 @@
--admin-tag-gap: var(--space-1);
--admin-tag-font-size: var(--admin-font-size);
--admin-tag-font-weight: 650;
--table-head-height: 38px;
--table-row-height: 48px;
--table-head-height: 44px;
--table-row-height: 58px;
--switch-width: 46px;
--switch-height: 26px;
--switch-thumb-size: 20px;