完善后台功能
This commit is contained in:
parent
bdeb3cd15a
commit
7c527ee791
@ -27,6 +27,7 @@
|
||||
- 页面:按业务放在 `src/features/*/pages/`。
|
||||
- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx`、`src/app/router/routeConfig.ts`。
|
||||
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。
|
||||
- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,资源组字段优先使用该组件,不在业务页面里重复手写普通下拉。
|
||||
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。
|
||||
- MUI 主题:`src/theme.js`。
|
||||
- CSS token:`src/styles/tokens.css`。
|
||||
@ -90,6 +91,7 @@ import { Refresh } from "@mui/icons-material";
|
||||
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
|
||||
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
|
||||
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
|
||||
- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉。
|
||||
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
||||
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
||||
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
|
||||
|
||||
@ -65,6 +65,7 @@ pnpm build
|
||||
- `FilterChip`
|
||||
- `KpiCard`
|
||||
- `SegmentControl`
|
||||
- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段使用 `ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,选择后自动回填。
|
||||
- 布局组件放在 `src/components/layout/`:
|
||||
- `Header`
|
||||
- `Sidebar`
|
||||
|
||||
@ -134,6 +134,46 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/activity/lucky-gifts/config": {
|
||||
"get": {
|
||||
"operationId": "getLuckyGiftConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "lucky-gift:view",
|
||||
"x-permissions": [
|
||||
"lucky-gift:view"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "upsertLuckyGiftConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "lucky-gift:update",
|
||||
"x-permissions": [
|
||||
"lucky-gift:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/activity/lucky-gifts/draws": {
|
||||
"get": {
|
||||
"operationId": "listLuckyGiftDraws",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "lucky-gift:view",
|
||||
"x-permissions": [
|
||||
"lucky-gift:view"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/activity/registration-reward/claims": {
|
||||
"get": {
|
||||
"operationId": "listRegistrationRewardClaims",
|
||||
@ -174,6 +214,46 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/activity/seven-day-checkin/claims": {
|
||||
"get": {
|
||||
"operationId": "listSevenDayCheckInClaims",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "seven-day-checkin:view",
|
||||
"x-permissions": [
|
||||
"seven-day-checkin:view"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/activity/seven-day-checkin/config": {
|
||||
"get": {
|
||||
"operationId": "getSevenDayCheckInConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "seven-day-checkin:view",
|
||||
"x-permissions": [
|
||||
"seven-day-checkin:view"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateSevenDayCheckInConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "seven-day-checkin:update",
|
||||
"x-permissions": [
|
||||
"seven-day-checkin:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/agencies": {
|
||||
"get": {
|
||||
"operationId": "listAgencies",
|
||||
@ -1017,6 +1097,44 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/gift-types": {
|
||||
"get": {
|
||||
"operationId": "listGiftTypes",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "gift:view",
|
||||
"x-permissions": [
|
||||
"gift:view"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/gift-types/{type_code}": {
|
||||
"put": {
|
||||
"operationId": "updateGiftType",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "type_code",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "gift:update",
|
||||
"x-permissions": [
|
||||
"gift:update"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/gifts": {
|
||||
"get": {
|
||||
"operationId": "listGifts",
|
||||
|
||||
@ -5,6 +5,7 @@ import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
||||
import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||
@ -17,6 +18,7 @@ import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
||||
@ -37,6 +39,7 @@ const iconMap = {
|
||||
campaign: CampaignOutlined,
|
||||
category: CategoryOutlined,
|
||||
dashboard: DashboardOutlined,
|
||||
event_available: EventAvailableOutlined,
|
||||
gift: CardGiftcardOutlined,
|
||||
inventory: Inventory2Outlined,
|
||||
network: HubOutlined,
|
||||
@ -50,6 +53,7 @@ const iconMap = {
|
||||
public: PublicOutlined,
|
||||
push_pin: PushPinOutlined,
|
||||
receipt: ReceiptLongOutlined,
|
||||
redeem: RedeemOutlined,
|
||||
room: BedroomParentOutlined,
|
||||
send: SendOutlined,
|
||||
settings: SettingsApplicationsOutlined,
|
||||
@ -131,6 +135,7 @@ export const fallbackNavigation = [
|
||||
children: [
|
||||
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
|
||||
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
|
||||
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -141,6 +146,7 @@ export const fallbackNavigation = [
|
||||
children: [
|
||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -102,6 +102,10 @@ export const PERMISSIONS = {
|
||||
achievementUpdate: "achievement:update",
|
||||
registrationRewardView: "registration-reward:view",
|
||||
registrationRewardUpdate: "registration-reward:update",
|
||||
sevenDayCheckInView: "seven-day-checkin:view",
|
||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||
luckyGiftView: "lucky-gift:view",
|
||||
luckyGiftUpdate: "lucky-gift:update",
|
||||
uploadCreate: "upload:create",
|
||||
jobView: "job:view",
|
||||
jobCancel: "job:cancel",
|
||||
@ -144,10 +148,12 @@ export const MENU_CODES = {
|
||||
operations: "operations",
|
||||
operationCoinLedger: "operation-coin-ledger",
|
||||
operationCoinAdjustment: "operation-coin-adjustment",
|
||||
luckyGift: "lucky-gift",
|
||||
activities: "activities",
|
||||
dailyTaskList: "daily-task-list",
|
||||
registrationReward: "registration-reward",
|
||||
achievementConfig: "achievement-config",
|
||||
sevenDayCheckIn: "seven-day-checkin",
|
||||
geo: "geo",
|
||||
hostOrg: "host-org",
|
||||
hostOrgCountries: "host-org-countries",
|
||||
|
||||
@ -8,6 +8,7 @@ import { gameRoutes } from "@/features/games/routes.js";
|
||||
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||
import { logsRoutes } from "@/features/logs/routes.js";
|
||||
import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
|
||||
import { menusRoutes } from "@/features/menus/routes.js";
|
||||
import { operationsRoutes } from "@/features/operations/routes.js";
|
||||
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||
@ -16,6 +17,7 @@ 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 { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
|
||||
import { usersRoutes } from "@/features/users/routes.js";
|
||||
import type { MenuCode } from "@/app/permissions";
|
||||
import type { AdminRoute, PublicRoute } from "./types";
|
||||
@ -32,8 +34,10 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...dailyTaskRoutes,
|
||||
...achievementRoutes,
|
||||
...registrationRewardRoutes,
|
||||
...sevenDayCheckInRoutes,
|
||||
...resourceRoutes,
|
||||
...operationsRoutes,
|
||||
...luckyGiftRoutes,
|
||||
...paymentRoutes,
|
||||
...gameRoutes,
|
||||
...usersRoutes,
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
achievementStatusLabels,
|
||||
@ -437,12 +438,13 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
||||
value={form.primaryBadgeResourceId}
|
||||
onChange={changeField(page.setForm, "primaryBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelect
|
||||
<ResourceGroupSelectField
|
||||
allowEmpty
|
||||
disabled={disabled}
|
||||
groups={page.resourceGroups}
|
||||
label="达成奖励资源组"
|
||||
value={form.rewardResourceGroupId}
|
||||
onChange={changeField(page.setForm, "rewardResourceGroupId")}
|
||||
onChange={(value) => page.setForm((current) => ({ ...current, rewardResourceGroupId: value }))}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
@ -518,20 +520,6 @@ function ResourceSelect({ disabled, label, onChange, resources, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
|
||||
const options = withSelectedGroup(groups, value);
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
||||
<MenuItem value="">不配置</MenuItem>
|
||||
{options.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabel({ id, resources }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
@ -573,14 +561,6 @@ function withSelectedResource(resources, value) {
|
||||
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
|
||||
}
|
||||
|
||||
function withSelectedGroup(groups, value) {
|
||||
const selectedId = Number(value || 0);
|
||||
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
|
||||
return groups;
|
||||
}
|
||||
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
|
||||
}
|
||||
|
||||
function achievementStatusLabel(status) {
|
||||
return achievementStatusLabels[status] || status || "-";
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
export const H5_LINK_KEYS = [
|
||||
"host-center",
|
||||
"bd-center",
|
||||
"bd-leader-center",
|
||||
"agency-center",
|
||||
"invite-user"
|
||||
] as const;
|
||||
export const H5_LINK_KEYS = ["host-center", "bd-center", "bd-leader-center", "agency-center", "invite-user"] as const;
|
||||
|
||||
export type H5LinkKey = (typeof H5_LINK_KEYS)[number];
|
||||
|
||||
export const APP_BANNER_DISPLAY_SCOPE_OPTIONS = [
|
||||
{ label: "首页", value: "home" },
|
||||
{ label: "房间内", value: "room" },
|
||||
{ label: "充值页", value: "recharge" },
|
||||
] as const;
|
||||
|
||||
export type AppBannerDisplayScope = (typeof APP_BANNER_DISPLAY_SCOPE_OPTIONS)[number]["value"];
|
||||
|
||||
@ -16,10 +16,14 @@ const emptyForm = () => ({
|
||||
countryCode: "",
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
displayScope: "home",
|
||||
endsAtMs: "",
|
||||
param: "",
|
||||
platform: "android",
|
||||
regionId: "",
|
||||
roomSmallImageUrl: "",
|
||||
sortOrder: "0",
|
||||
startsAtMs: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
@ -31,6 +35,7 @@ export function useBannerConfigPage() {
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [displayScope, setDisplayScope] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
@ -42,12 +47,13 @@ export function useBannerConfigPage() {
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
countryCode,
|
||||
displayScope,
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status,
|
||||
}),
|
||||
[countryCode, keyword, platform, regionId, status],
|
||||
[countryCode, displayScope, keyword, platform, regionId, status],
|
||||
);
|
||||
const queryFn = useCallback(() => listBanners(filters), [filters]);
|
||||
const {
|
||||
@ -86,6 +92,7 @@ export function useBannerConfigPage() {
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setDisplayScope("");
|
||||
setPlatform("");
|
||||
setRegionId("");
|
||||
setCountryCode("");
|
||||
@ -142,6 +149,7 @@ export function useBannerConfigPage() {
|
||||
countryCode,
|
||||
countryOptions,
|
||||
data,
|
||||
displayScope,
|
||||
editingItem,
|
||||
error,
|
||||
form,
|
||||
@ -159,6 +167,7 @@ export function useBannerConfigPage() {
|
||||
removeBanner,
|
||||
resetFilters,
|
||||
setCountryCode,
|
||||
setDisplayScope,
|
||||
setForm,
|
||||
setKeyword,
|
||||
setPlatform,
|
||||
@ -170,11 +179,16 @@ export function useBannerConfigPage() {
|
||||
}
|
||||
|
||||
function normalizeForm(form) {
|
||||
const displayScope = form.displayScope || "home";
|
||||
return {
|
||||
...form,
|
||||
countryCode: form.countryCode || "",
|
||||
displayScope,
|
||||
endsAtMs: form.endsAtMs || 0,
|
||||
regionId: form.regionId || 0,
|
||||
roomSmallImageUrl: displayScope === "room" ? form.roomSmallImageUrl || "" : "",
|
||||
sortOrder: form.sortOrder || 0,
|
||||
startsAtMs: form.startsAtMs || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -184,10 +198,14 @@ function formFromBanner(item) {
|
||||
countryCode: item.countryCode || "",
|
||||
coverUrl: item.coverUrl || "",
|
||||
description: item.description || "",
|
||||
displayScope: item.displayScope || "home",
|
||||
endsAtMs: item.endsAtMs || "",
|
||||
param: item.param || "",
|
||||
platform: item.platform || "android",
|
||||
regionId: item.regionId ? String(item.regionId) : "",
|
||||
roomSmallImageUrl: item.roomSmallImageUrl || "",
|
||||
sortOrder: String(item.sortOrder || 0),
|
||||
startsAtMs: item.startsAtMs || "",
|
||||
status: item.status || "active",
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
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";
|
||||
@ -26,6 +27,7 @@ import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
@ -33,6 +35,12 @@ const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "关闭"],
|
||||
["expired", "过期"],
|
||||
];
|
||||
|
||||
const displayScopeFilterOptions = [
|
||||
["", "全部范围"],
|
||||
...APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => [option.value, option.label]),
|
||||
];
|
||||
|
||||
const platformOptions = [
|
||||
@ -63,7 +71,13 @@ export function BannerConfigPage() {
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="1240px" rowKey={(item) => item.id} />
|
||||
<DataTable
|
||||
columns={columns}
|
||||
fixedEdges={false}
|
||||
items={items}
|
||||
minWidth="1480px"
|
||||
rowKey={(item) => item.id}
|
||||
/>
|
||||
<div className="pagination-bar">
|
||||
<span>{items.length} 条</span>
|
||||
</div>
|
||||
@ -82,12 +96,24 @@ export function BannerConfigPage() {
|
||||
onSubmit={page.submitBanner}
|
||||
>
|
||||
<AdminFormSection title="素材">
|
||||
<UploadField
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="封面图"
|
||||
value={page.form.coverUrl}
|
||||
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||
/>
|
||||
<AdminFormFieldGrid
|
||||
columns={page.form.displayScope === "room" ? "repeat(2, minmax(0, 1fr))" : "1fr"}
|
||||
>
|
||||
<UploadField
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="封面图"
|
||||
value={page.form.coverUrl}
|
||||
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||
/>
|
||||
{page.form.displayScope === "room" ? (
|
||||
<UploadField
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="房间内小屏图"
|
||||
value={page.form.roomSmallImageUrl}
|
||||
onChange={(roomSmallImageUrl) => page.setForm({ roomSmallImageUrl })}
|
||||
/>
|
||||
) : null}
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="跳转信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
@ -125,6 +151,26 @@ export function BannerConfigPage() {
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="投放设置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="显示范围"
|
||||
required
|
||||
select
|
||||
value={page.form.displayScope}
|
||||
onChange={(event) => {
|
||||
const displayScope = event.target.value;
|
||||
page.setForm({
|
||||
displayScope,
|
||||
roomSmallImageUrl: displayScope === "room" ? page.form.roomSmallImageUrl : "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<RegionSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
emptyLabel="全部区域"
|
||||
@ -149,13 +195,20 @@ export function BannerConfigPage() {
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<TimeRangeFilter
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="投放时间区间"
|
||||
value={{ endMs: page.form.endsAtMs, startMs: page.form.startsAtMs }}
|
||||
onChange={(range) => page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={page.form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="BANNER 状态"
|
||||
switchProps={{ inputProps: { "aria-label": "BANNER 启用状态" } }}
|
||||
uncheckedLabel="关闭"
|
||||
uncheckedLabel={page.form.status === "expired" ? "过期" : "关闭"}
|
||||
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
@ -206,6 +259,18 @@ function bannerColumns(page) {
|
||||
}),
|
||||
width: "minmax(110px, 0.6fr)",
|
||||
},
|
||||
{
|
||||
key: "displayScope",
|
||||
label: "显示范围",
|
||||
render: (item) => displayScopeLabel(item.displayScope),
|
||||
filter: createOptionsColumnFilter({
|
||||
options: displayScopeFilterOptions,
|
||||
placeholder: "搜索显示范围",
|
||||
value: page.displayScope,
|
||||
onChange: page.setDisplayScope,
|
||||
}),
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
@ -249,6 +314,12 @@ function bannerColumns(page) {
|
||||
label: "排序",
|
||||
width: "minmax(80px, 0.4fr)",
|
||||
},
|
||||
{
|
||||
key: "deliveryTime",
|
||||
label: "投放时间",
|
||||
render: (item) => deliveryTimeLabel(item.startsAtMs, item.endsAtMs),
|
||||
width: "minmax(220px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
@ -342,11 +413,11 @@ function Stack({ primary, secondary }) {
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "active" ? "succeeded" : "stopped";
|
||||
const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{status === "active" ? "启用" : "关闭"}
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -365,6 +436,26 @@ function platformLabel(value) {
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function displayScopeLabel(value) {
|
||||
return APP_BANNER_DISPLAY_SCOPE_OPTIONS.find((option) => option.value === value)?.label || value || "-";
|
||||
}
|
||||
|
||||
function deliveryTimeLabel(startsAtMs, endsAtMs) {
|
||||
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
|
||||
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === "active") {
|
||||
return "启用";
|
||||
}
|
||||
if (status === "expired") {
|
||||
return "过期";
|
||||
}
|
||||
return "关闭";
|
||||
}
|
||||
|
||||
function regionLabel(options, value) {
|
||||
if (!value) {
|
||||
return "全部区域";
|
||||
|
||||
49
src/features/app-config/schema.test.ts
Normal file
49
src/features/app-config/schema.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { appBannerSchema } from "@/features/app-config/schema";
|
||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||
|
||||
const validBannerForm = {
|
||||
bannerType: "h5",
|
||||
countryCode: "",
|
||||
coverUrl: "https://media.haiyihy.com/banner/home.png",
|
||||
description: "",
|
||||
displayScope: "home",
|
||||
endsAtMs: "1800000000000",
|
||||
param: "https://h5.example.com/activity",
|
||||
platform: "android",
|
||||
regionId: "",
|
||||
roomSmallImageUrl: "",
|
||||
sortOrder: "0",
|
||||
startsAtMs: "1700000000000",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
describe("app config form schema", () => {
|
||||
test("keeps banner display scope and delivery time fields", () => {
|
||||
const payload = parseForm(appBannerSchema, validBannerForm);
|
||||
|
||||
expect(payload.displayScope).toBe("home");
|
||||
expect(payload.startsAtMs).toBe(1700000000000);
|
||||
expect(payload.endsAtMs).toBe(1800000000000);
|
||||
});
|
||||
|
||||
test("requires room small image for room banner", () => {
|
||||
expect(() =>
|
||||
parseForm(appBannerSchema, {
|
||||
...validBannerForm,
|
||||
displayScope: "room",
|
||||
roomSmallImageUrl: "",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("rejects inverted banner delivery time range", () => {
|
||||
expect(() =>
|
||||
parseForm(appBannerSchema, {
|
||||
...validBannerForm,
|
||||
endsAtMs: "1700000000000",
|
||||
startsAtMs: "1800000000000",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
});
|
||||
@ -1,52 +1,82 @@
|
||||
import { z } from "zod";
|
||||
import { H5_LINK_KEYS } from "@/features/app-config/constants";
|
||||
|
||||
const h5LinkURLSchema = z.string()
|
||||
.trim()
|
||||
.max(2048, "H5 链接不能超过 2048 个字符")
|
||||
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
|
||||
.default("");
|
||||
const h5LinkURLSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2048, "H5 链接不能超过 2048 个字符")
|
||||
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
|
||||
.default("");
|
||||
|
||||
export const h5LinkUpdateSchema = z.object({
|
||||
key: z.enum(H5_LINK_KEYS),
|
||||
url: h5LinkURLSchema
|
||||
key: z.enum(H5_LINK_KEYS),
|
||||
url: h5LinkURLSchema,
|
||||
});
|
||||
|
||||
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
|
||||
|
||||
export const appBannerSchema = z.object({
|
||||
bannerType: z.enum(["h5", "app"]),
|
||||
countryCode: z.string().trim().transform((value) => value.toUpperCase())
|
||||
.refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"),
|
||||
coverUrl: z.string().trim().min(1, "请上传封面图").max(1024, "封面图地址不能超过 1024 个字符"),
|
||||
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
|
||||
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
regionId: z.coerce.number().int().min(0, "区域不正确").default(0),
|
||||
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
|
||||
status: z.enum(["active", "disabled"])
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.bannerType !== "h5") {
|
||||
return;
|
||||
}
|
||||
if (!value.param) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] });
|
||||
}
|
||||
if (/\s/.test(value.param)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
|
||||
}
|
||||
});
|
||||
export const appBannerSchema = z
|
||||
.object({
|
||||
bannerType: z.enum(["h5", "app"]),
|
||||
countryCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value.toUpperCase())
|
||||
.refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"),
|
||||
coverUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请上传封面图")
|
||||
.max(1024, "封面图地址不能超过 1024 个字符")
|
||||
.refine((value) => !/\s/.test(value), "封面图地址不能包含空白字符"),
|
||||
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
|
||||
displayScope: z.enum(["home", "room", "recharge"]),
|
||||
endsAtMs: z.coerce.number().int().min(0, "投放结束时间不正确").default(0),
|
||||
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
regionId: z.coerce.number().int().min(0, "区域不正确").default(0),
|
||||
roomSmallImageUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1024, "房间内小屏图地址不能超过 1024 个字符")
|
||||
.refine((value) => !/\s/.test(value), "房间内小屏图地址不能包含空白字符"),
|
||||
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
|
||||
startsAtMs: z.coerce.number().int().min(0, "投放开始时间不正确").default(0),
|
||||
status: z.enum(["active", "disabled", "expired"]),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.bannerType === "h5" && !value.param) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] });
|
||||
}
|
||||
if (value.bannerType === "h5" && /\s/.test(value.param)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
|
||||
}
|
||||
if (value.displayScope === "room" && !value.roomSmallImageUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "房间内显示需要上传房间内小屏图",
|
||||
path: ["roomSmallImageUrl"],
|
||||
});
|
||||
}
|
||||
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["endsAtMs"] });
|
||||
}
|
||||
});
|
||||
|
||||
export type AppBannerForm = z.infer<typeof appBannerSchema>;
|
||||
|
||||
export const appVersionSchema = z.object({
|
||||
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
|
||||
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
|
||||
downloadUrl: z.string().trim().min(1, "请填写下载链接").max(2048, "下载链接不能超过 2048 个字符")
|
||||
.refine((value) => !/\s/.test(value), "下载链接不能包含空白字符"),
|
||||
forceUpdate: z.boolean(),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
version: z.string().trim().min(1, "请填写版本号").max(40, "版本号不能超过 40 个字符")
|
||||
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
|
||||
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
|
||||
downloadUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写下载链接")
|
||||
.max(2048, "下载链接不能超过 2048 个字符")
|
||||
.refine((value) => !/\s/.test(value), "下载链接不能包含空白字符"),
|
||||
forceUpdate: z.boolean(),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
version: z.string().trim().min(1, "请填写版本号").max(40, "版本号不能超过 40 个字符"),
|
||||
});
|
||||
|
||||
export type AppVersionForm = z.infer<typeof appVersionSchema>;
|
||||
|
||||
@ -15,18 +15,10 @@ import {
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
levelStatusLabels,
|
||||
levelTrackLabels,
|
||||
levelTrackOptions,
|
||||
} from "@/features/level-config/constants.js";
|
||||
import { levelStatusLabels, levelTrackLabels, levelTrackOptions } from "@/features/level-config/constants.js";
|
||||
import { useLevelConfigPage } from "@/features/level-config/hooks/useLevelConfigPage.js";
|
||||
import styles from "@/features/level-config/level-config.module.css";
|
||||
|
||||
@ -124,7 +116,9 @@ function LevelTrackSection({ page, track }) {
|
||||
<div className={styles.trackHeader}>
|
||||
<div className={styles.trackTitleGroup}>
|
||||
<div className={styles.trackTitleRow}>
|
||||
<span className={styles.trackTitle}>{track.name || levelTrackLabels[track.track] || track.track}</span>
|
||||
<span className={styles.trackTitle}>
|
||||
{track.name || levelTrackLabels[track.track] || track.track}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.trackMetaList}>
|
||||
<span>{track.track}</span>
|
||||
@ -272,7 +266,9 @@ function buildTierColumns(page) {
|
||||
key: "frame",
|
||||
label: "头像框",
|
||||
width: "minmax(200px, 0.9fr)",
|
||||
render: (tier) => <ResourceLabel id={tier.displayAvatarFrameResourceId} resources={page.avatarFrameResources} />,
|
||||
render: (tier) => (
|
||||
<ResourceLabel id={tier.displayAvatarFrameResourceId} resources={page.avatarFrameResources} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "badge",
|
||||
@ -457,12 +453,15 @@ function RuleFormDialog({ page }) {
|
||||
value={form.longBadgeResourceId}
|
||||
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelect
|
||||
<ResourceGroupSelectField
|
||||
allowEmpty
|
||||
disabled={!page.abilities.canUpdate}
|
||||
groups={page.resourceGroups}
|
||||
label="升级礼物"
|
||||
value={form.rewardResourceGroupId}
|
||||
onChange={changeField(page.setRuleForm, "rewardResourceGroupId")}
|
||||
onChange={(value) =>
|
||||
page.setRuleForm((current) => ({ ...current, rewardResourceGroupId: value }))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
@ -558,12 +557,15 @@ function TierFormDialog({ page }) {
|
||||
value={form.displayBadgeResourceId}
|
||||
onChange={changeField(page.setTierForm, "displayBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelect
|
||||
<ResourceGroupSelectField
|
||||
allowEmpty
|
||||
disabled={!page.abilities.canUpdate}
|
||||
groups={page.resourceGroups}
|
||||
label="升级礼物"
|
||||
value={form.rewardResourceGroupId}
|
||||
onChange={changeField(page.setTierForm, "rewardResourceGroupId")}
|
||||
onChange={(value) =>
|
||||
page.setTierForm((current) => ({ ...current, rewardResourceGroupId: value }))
|
||||
}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.status === "active"}
|
||||
@ -639,20 +641,6 @@ function ResourceSelect({ disabled, label, onChange, resources, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
|
||||
const options = withSelectedGroup(groups, value);
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
||||
<MenuItem value="">不配置</MenuItem>
|
||||
{options.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabel({ id, resources }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
@ -694,14 +682,6 @@ function withSelectedResource(resources, value) {
|
||||
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
|
||||
}
|
||||
|
||||
function withSelectedGroup(groups, value) {
|
||||
const selectedId = Number(value || 0);
|
||||
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
|
||||
return groups;
|
||||
}
|
||||
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
|
||||
}
|
||||
|
||||
function levelStatusLabel(status) {
|
||||
return levelStatusLabels[status] || status || "-";
|
||||
}
|
||||
|
||||
250
src/features/lucky-gift/api.ts
Normal file
250
src/features/lucky-gift/api.ts
Normal file
@ -0,0 +1,250 @@
|
||||
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;
|
||||
weight: number;
|
||||
highWaterOnly: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface LuckyGiftConfigDto {
|
||||
appCode?: string;
|
||||
enabled: boolean;
|
||||
ruleVersion: number;
|
||||
giftPrice: number;
|
||||
targetRTPPPM: number;
|
||||
poolRatePPM: number;
|
||||
globalWindowDraws: number;
|
||||
giftWindowDraws: number;
|
||||
noviceDrawLimit: number;
|
||||
intermediateDrawLimit: number;
|
||||
highMultiplier: number;
|
||||
highWaterPoolMultiple: number;
|
||||
platformPoolWeightPPM: number;
|
||||
roomPoolWeightPPM: number;
|
||||
giftPoolWeightPPM: number;
|
||||
initialPlatformPool: number;
|
||||
initialGiftPool: number;
|
||||
initialRoomPool: number;
|
||||
platformReserve: number;
|
||||
giftReserve: number;
|
||||
roomReserve: number;
|
||||
maxSinglePayout: number;
|
||||
userHourlyPayoutCap: number;
|
||||
userDailyPayoutCap: number;
|
||||
deviceDailyPayoutCap: number;
|
||||
roomHourlyPayoutCap: number;
|
||||
anchorDailyPayoutCap: number;
|
||||
roomAtmosphereRatePPM: number;
|
||||
roomAtmosphereInitial: number;
|
||||
roomAtmosphereReserve: number;
|
||||
activityBudget: number;
|
||||
activityDailyLimit: number;
|
||||
largeTierEnabled: boolean;
|
||||
tiers: LuckyGiftTierDto[];
|
||||
updatedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface LuckyGiftConfigPayload {
|
||||
enabled: boolean;
|
||||
gift_price: number;
|
||||
target_rtp_ppm: number;
|
||||
pool_rate_ppm: number;
|
||||
global_window_draws: number;
|
||||
gift_window_draws: number;
|
||||
novice_draw_limit: number;
|
||||
intermediate_draw_limit: number;
|
||||
high_multiplier: number;
|
||||
high_water_pool_multiple: number;
|
||||
platform_pool_weight_ppm: number;
|
||||
room_pool_weight_ppm: number;
|
||||
gift_pool_weight_ppm: number;
|
||||
initial_platform_pool: number;
|
||||
initial_gift_pool: number;
|
||||
initial_room_pool: number;
|
||||
platform_reserve: number;
|
||||
gift_reserve: number;
|
||||
room_reserve: number;
|
||||
max_single_payout: number;
|
||||
user_hourly_payout_cap: number;
|
||||
user_daily_payout_cap: number;
|
||||
device_daily_payout_cap: number;
|
||||
room_hourly_payout_cap: number;
|
||||
anchor_daily_payout_cap: number;
|
||||
room_atmosphere_rate_ppm: number;
|
||||
room_atmosphere_initial: number;
|
||||
room_atmosphere_reserve: number;
|
||||
activity_budget: number;
|
||||
activity_daily_limit: number;
|
||||
large_tier_enabled: boolean;
|
||||
tiers: Array<{
|
||||
pool: string;
|
||||
tier_id: string;
|
||||
reward_coins: number;
|
||||
weight: number;
|
||||
high_water_only: boolean;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LuckyGiftDrawDto {
|
||||
drawId: string;
|
||||
commandId?: string;
|
||||
giftId: string;
|
||||
ruleVersion: number;
|
||||
experiencePool: string;
|
||||
selectedTierId: string;
|
||||
baseRewardCoins: number;
|
||||
roomAtmosphereRewardCoins: number;
|
||||
activitySubsidyCoins: 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 RawDraw = LuckyGiftDrawDto & Record<string, unknown>;
|
||||
|
||||
export function getLuckyGiftConfig(): Promise<LuckyGiftConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
|
||||
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), {
|
||||
method: endpoint.method,
|
||||
}).then(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,
|
||||
},
|
||||
).then(normalizeConfig);
|
||||
}
|
||||
|
||||
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((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeDraw),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
|
||||
return {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
enabled: booleanValue(item.enabled),
|
||||
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
|
||||
giftPrice: numberValue(item.giftPrice ?? item.gift_price),
|
||||
targetRTPPPM: numberValue(item.targetRTPPPM ?? item.target_rtp_ppm),
|
||||
poolRatePPM: numberValue(item.poolRatePPM ?? item.pool_rate_ppm),
|
||||
globalWindowDraws: numberValue(item.globalWindowDraws ?? item.global_window_draws),
|
||||
giftWindowDraws: numberValue(item.giftWindowDraws ?? item.gift_window_draws),
|
||||
noviceDrawLimit: numberValue(item.noviceDrawLimit ?? item.novice_draw_limit),
|
||||
intermediateDrawLimit: numberValue(item.intermediateDrawLimit ?? item.intermediate_draw_limit),
|
||||
highMultiplier: numberValue(item.highMultiplier ?? item.high_multiplier),
|
||||
highWaterPoolMultiple: numberValue(item.highWaterPoolMultiple ?? item.high_water_pool_multiple),
|
||||
platformPoolWeightPPM: numberValue(item.platformPoolWeightPPM ?? item.platform_pool_weight_ppm),
|
||||
roomPoolWeightPPM: numberValue(item.roomPoolWeightPPM ?? item.room_pool_weight_ppm),
|
||||
giftPoolWeightPPM: numberValue(item.giftPoolWeightPPM ?? item.gift_pool_weight_ppm),
|
||||
initialPlatformPool: numberValue(item.initialPlatformPool ?? item.initial_platform_pool),
|
||||
initialGiftPool: numberValue(item.initialGiftPool ?? item.initial_gift_pool),
|
||||
initialRoomPool: numberValue(item.initialRoomPool ?? item.initial_room_pool),
|
||||
platformReserve: numberValue(item.platformReserve ?? item.platform_reserve),
|
||||
giftReserve: numberValue(item.giftReserve ?? item.gift_reserve),
|
||||
roomReserve: numberValue(item.roomReserve ?? item.room_reserve),
|
||||
maxSinglePayout: numberValue(item.maxSinglePayout ?? item.max_single_payout),
|
||||
userHourlyPayoutCap: numberValue(item.userHourlyPayoutCap ?? item.user_hourly_payout_cap),
|
||||
userDailyPayoutCap: numberValue(item.userDailyPayoutCap ?? item.user_daily_payout_cap),
|
||||
deviceDailyPayoutCap: numberValue(item.deviceDailyPayoutCap ?? item.device_daily_payout_cap),
|
||||
roomHourlyPayoutCap: numberValue(item.roomHourlyPayoutCap ?? item.room_hourly_payout_cap),
|
||||
anchorDailyPayoutCap: numberValue(item.anchorDailyPayoutCap ?? item.anchor_daily_payout_cap),
|
||||
roomAtmosphereRatePPM: numberValue(item.roomAtmosphereRatePPM ?? item.room_atmosphere_rate_ppm),
|
||||
roomAtmosphereInitial: numberValue(item.roomAtmosphereInitial ?? item.room_atmosphere_initial),
|
||||
roomAtmosphereReserve: numberValue(item.roomAtmosphereReserve ?? item.room_atmosphere_reserve),
|
||||
activityBudget: numberValue(item.activityBudget ?? item.activity_budget),
|
||||
activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit),
|
||||
largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled),
|
||||
tiers: arrayValue(item.tiers).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(raw: unknown): LuckyGiftTierDto {
|
||||
const item = asRecord(raw) as RawTier;
|
||||
return {
|
||||
pool: stringValue(item.pool),
|
||||
tierId: stringValue(item.tierId ?? item.tier_id),
|
||||
rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins),
|
||||
weight: numberValue(item.weight),
|
||||
highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only),
|
||||
enabled: booleanValue(item.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDraw(item: RawDraw): LuckyGiftDrawDto {
|
||||
return {
|
||||
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,
|
||||
),
|
||||
activitySubsidyCoins: numberValue(item.activitySubsidyCoins ?? item.activity_subsidy_coins),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown) {
|
||||
return value === true || value === "true" || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
16
src/features/lucky-gift/components/FieldLabel.jsx
Normal file
16
src/features/lucky-gift/components/FieldLabel.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||
|
||||
export function FieldLabel({ help, text }) {
|
||||
return (
|
||||
<span className={styles.fieldLabel}>
|
||||
<span>{text}</span>
|
||||
{help ? (
|
||||
<Tooltip arrow title={help}>
|
||||
<HelpOutlineOutlined className={styles.helpIcon} fontSize="inherit" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
220
src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx
Normal file
220
src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx
Normal file
@ -0,0 +1,220 @@
|
||||
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 { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx";
|
||||
import {
|
||||
atmosphereLevelOptions,
|
||||
controlStrengthOptions,
|
||||
jackpotStrategyOptions,
|
||||
noviceStrengthOptions,
|
||||
payoutStyleOptions,
|
||||
poolModeOptions,
|
||||
riskLevelOptions,
|
||||
} from "@/features/lucky-gift/constants.js";
|
||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||
|
||||
const helpText = {
|
||||
enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。",
|
||||
targetRtpPercent: "基础返奖成本比例,95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。",
|
||||
controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。",
|
||||
poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。",
|
||||
noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。",
|
||||
payoutStyle: "控制小奖和大奖的权重分布,稳定偏小奖,刺激偏高倍爆点。",
|
||||
jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。",
|
||||
riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。",
|
||||
atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。",
|
||||
activitySubsidyEnabled: "活动补贴是独立预算,开启后会提高用户可见奖励,但不污染基础 RTP。",
|
||||
activityBudget: "活动期最多可额外补贴的金币总额。",
|
||||
activityDailyLimit: "活动补贴每天最多释放的金币额度。",
|
||||
};
|
||||
|
||||
export function LuckyGiftConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
configSaving,
|
||||
form,
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
setForm,
|
||||
}) {
|
||||
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||
<form className={["form-drawer", styles.configDrawer].join(" ")} onSubmit={onSubmit}>
|
||||
<h2>幸运礼物配置</h2>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">基础控制</div>
|
||||
<div className={styles.configGrid}>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
inputProps={{ max: 100, min: 1, step: 0.01 }}
|
||||
label={<FieldLabel help={helpText.targetRtpPercent} text="RTP" />}
|
||||
type="number"
|
||||
value={form.targetRtpPercent}
|
||||
onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)}
|
||||
/>
|
||||
<SelectField
|
||||
disabled={disabled}
|
||||
help={helpText.controlStrength}
|
||||
label="RTP 控制强度"
|
||||
options={controlStrengthOptions}
|
||||
value={form.controlStrength}
|
||||
onChange={(value) => updateForm(setForm, "controlStrength", value)}
|
||||
/>
|
||||
<SwitchField
|
||||
checked={form.enabled}
|
||||
className={styles.configWide}
|
||||
disabled={disabled}
|
||||
help={helpText.enabled}
|
||||
label="幸运礼物状态"
|
||||
onChange={(event) => updateForm(setForm, "enabled", event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">体验策略</div>
|
||||
<div className={styles.configGrid}>
|
||||
<SelectField
|
||||
disabled={disabled}
|
||||
help={helpText.poolMode}
|
||||
label="奖池模式"
|
||||
options={poolModeOptions}
|
||||
value={form.poolMode}
|
||||
onChange={(value) => updateForm(setForm, "poolMode", value)}
|
||||
/>
|
||||
<SelectField
|
||||
disabled={disabled}
|
||||
help={helpText.noviceStrength}
|
||||
label="新手保护"
|
||||
options={noviceStrengthOptions}
|
||||
value={form.noviceStrength}
|
||||
onChange={(value) => updateForm(setForm, "noviceStrength", value)}
|
||||
/>
|
||||
<SelectField
|
||||
disabled={disabled}
|
||||
help={helpText.payoutStyle}
|
||||
label="中奖体感"
|
||||
options={payoutStyleOptions}
|
||||
value={form.payoutStyle}
|
||||
onChange={(value) => updateForm(setForm, "payoutStyle", value)}
|
||||
/>
|
||||
<SelectField
|
||||
disabled={disabled}
|
||||
help={helpText.jackpotStrategy}
|
||||
label="大奖策略"
|
||||
options={jackpotStrategyOptions}
|
||||
value={form.jackpotStrategy}
|
||||
onChange={(value) => updateForm(setForm, "jackpotStrategy", value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">保护和补贴</div>
|
||||
<div className={styles.configGrid}>
|
||||
<SelectField
|
||||
disabled={disabled}
|
||||
help={helpText.riskLevel}
|
||||
label="风控等级"
|
||||
options={riskLevelOptions}
|
||||
value={form.riskLevel}
|
||||
onChange={(value) => updateForm(setForm, "riskLevel", value)}
|
||||
/>
|
||||
<SelectField
|
||||
disabled={disabled}
|
||||
help={helpText.atmosphereLevel}
|
||||
label="房间爆点强度"
|
||||
options={atmosphereLevelOptions}
|
||||
value={form.atmosphereLevel}
|
||||
onChange={(value) => updateForm(setForm, "atmosphereLevel", value)}
|
||||
/>
|
||||
<div className={styles.subsidyBlock}>
|
||||
<SwitchField
|
||||
checked={form.activitySubsidyEnabled}
|
||||
className={styles.subsidyToggle}
|
||||
disabled={disabled}
|
||||
help={helpText.activitySubsidyEnabled}
|
||||
label="活动补贴"
|
||||
onChange={(event) => updateForm(setForm, "activitySubsidyEnabled", event.target.checked)}
|
||||
/>
|
||||
{form.activitySubsidyEnabled ? (
|
||||
<div className={styles.subsidyInputs}>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
label={<FieldLabel help={helpText.activityBudget} text="活动总预算" />}
|
||||
type="number"
|
||||
value={form.activityBudget}
|
||||
onChange={(event) => updateForm(setForm, "activityBudget", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
label={<FieldLabel help={helpText.activityDailyLimit} text="活动每日预算" />}
|
||||
type="number"
|
||||
value={form.activityDailyLimit}
|
||||
onChange={(event) => updateForm(setForm, "activityDailyLimit", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({ disabled, help, label, onChange, options, value }) {
|
||||
return (
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label={<FieldLabel help={help} text={label} />}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map(([optionValue, optionLabel]) => (
|
||||
<MenuItem key={optionValue} value={optionValue}>
|
||||
{optionLabel}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchField({ checked, className, disabled, help, label, onChange }) {
|
||||
return (
|
||||
<div className={[styles.switchField, className].filter(Boolean).join(" ")}>
|
||||
<FieldLabel help={help} text={label} />
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
uncheckedLabel="关闭"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateForm(setForm, key, value) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
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, onEdit, onRefresh }) {
|
||||
const form = formFromConfig(config);
|
||||
|
||||
return (
|
||||
<section className={styles.summaryPanel}>
|
||||
<div className={styles.summaryScrollArea}>
|
||||
<div className={styles.summaryContent}>
|
||||
<div className={styles.summaryHeader}>
|
||||
<span className={styles.title}>幸运礼物配置</span>
|
||||
<span className={styles.meta}>{luckyGiftConfigScopeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.summaryItems}>
|
||||
<ConfigStatus config={config} loading={configLoading} />
|
||||
<SummaryItem label="RTP">{formatPercent(config?.targetRTPPPM)}</SummaryItem>
|
||||
<SummaryItem label="控制强度">
|
||||
{optionLabel(controlStrengthOptions, form.controlStrength)}
|
||||
</SummaryItem>
|
||||
<SummaryItem label="奖池模式">{optionLabel(poolModeOptions, form.poolMode)}</SummaryItem>
|
||||
<SummaryItem label="新手保护">
|
||||
{optionLabel(noviceStrengthOptions, form.noviceStrength)}
|
||||
</SummaryItem>
|
||||
<SummaryItem label="中奖体感">{optionLabel(payoutStyleOptions, form.payoutStyle)}</SummaryItem>
|
||||
<SummaryItem label="大奖策略">
|
||||
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
|
||||
</SummaryItem>
|
||||
<SummaryItem label="房间爆点">
|
||||
{optionLabel(atmosphereLevelOptions, form.atmosphereLevel)}
|
||||
</SummaryItem>
|
||||
<SummaryItem label="更新时间">
|
||||
{config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"}
|
||||
</SummaryItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<Button
|
||||
disabled={!canUpdate || configLoading}
|
||||
startIcon={<EditOutlined fontSize="small" />}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={onEdit}
|
||||
>
|
||||
编辑配置
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ children, label }) {
|
||||
return (
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>{label}</span>
|
||||
<span className={styles.summaryValue}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigStatus({ config, loading }) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"status-badge",
|
||||
config?.enabled ? "status-badge--running" : loading ? "status-badge--warning" : "status-badge--stopped",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="status-point" />
|
||||
{config?.enabled ? "已开启" : loading ? "加载中" : "已关闭"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPercent(ppm) {
|
||||
const value = Number(ppm || 0) / 10_000;
|
||||
return `${value.toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||
}
|
||||
176
src/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx
Normal file
176
src/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx
Normal file
@ -0,0 +1,176 @@
|
||||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx";
|
||||
import { runLuckyGiftSimulation } from "@/features/lucky-gift/simulation.js";
|
||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||
|
||||
const simulationHelp = {
|
||||
costMax: "单次抽奖消耗的最高金币数,用于模拟不同价格幸运礼物混合抽奖。",
|
||||
costMin: "单次抽奖消耗的最低金币数,用于模拟低价幸运礼物。",
|
||||
drawsMax: "单个用户本次模拟最多抽奖次数。",
|
||||
drawsMin: "单个用户本次模拟最少抽奖次数,可以填 0 表示有用户未参与。",
|
||||
rooms: "模拟用户随机分散进入的房间数量,用于观察房间维度分布。",
|
||||
users: "参与模拟的用户数量,每个用户都会生成独立抽奖次数和 RTP。",
|
||||
};
|
||||
|
||||
const defaultInput = {
|
||||
costMax: "500000",
|
||||
costMin: "100",
|
||||
drawsMax: "100000",
|
||||
drawsMin: "0",
|
||||
rooms: "50",
|
||||
users: "1000",
|
||||
};
|
||||
|
||||
export function LuckyGiftSimulationPanel({ config }) {
|
||||
const [input, setInput] = useState(defaultInput);
|
||||
const [result, setResult] = useState(null);
|
||||
const [csvUrl, setCsvUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (csvUrl) {
|
||||
URL.revokeObjectURL(csvUrl);
|
||||
}
|
||||
};
|
||||
}, [csvUrl]);
|
||||
|
||||
const csvFileName = `lucky-gift-simulation-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
|
||||
const runSimulation = () => {
|
||||
const seed = nextSimulationSeed();
|
||||
const nextResult = runLuckyGiftSimulation(config, { ...input, seed });
|
||||
const blob = new Blob([`\ufeff${nextResult.csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const nextUrl = URL.createObjectURL(blob);
|
||||
if (csvUrl) {
|
||||
URL.revokeObjectURL(csvUrl);
|
||||
}
|
||||
setResult(nextResult);
|
||||
setCsvUrl(nextUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.simulationPanel}>
|
||||
<div className={styles.simulationHeader}>
|
||||
<div>
|
||||
<span className={styles.title}>随机模拟测试</span>
|
||||
</div>
|
||||
<div className={styles.simulationActions}>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ help, label, min, onChange, value }) {
|
||||
return (
|
||||
<TextField
|
||||
inputProps={{ min, step: 1 }}
|
||||
label={<FieldLabel help={help} text={label} />}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SimulationOverview({ overview }) {
|
||||
return (
|
||||
<div className={styles.simulationOverview}>
|
||||
<Metric label="用户" value={formatNumber(overview.users)} />
|
||||
<Metric label="房间" value={formatNumber(overview.rooms)} />
|
||||
<Metric label="总抽数" value={formatNumber(overview.totalDraws)} />
|
||||
<Metric label="平台收入" value={formatNumber(overview.totalSpend)} />
|
||||
<Metric label="平台支出" value={formatNumber(overview.totalReward)} />
|
||||
<Metric label="总 RTP" value={`${formatRatio(overview.totalRTP)}%`} />
|
||||
<Metric label="人均 RTP" value={`${formatRatio(overview.averageUserRTP)}%`} />
|
||||
<Metric label="用户净收益" value={formatNumber(overview.totalNetIncome)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className={styles.metricItem}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateInput(setInput, key, value) {
|
||||
setInput((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function nextSimulationSeed() {
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
const values = new Uint32Array(1);
|
||||
globalThis.crypto.getRandomValues(values);
|
||||
return values[0];
|
||||
}
|
||||
return Math.round(Date.now() + Math.random() * 0xffffffff);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Math.round(Number(value || 0)));
|
||||
}
|
||||
|
||||
function formatRatio(value) {
|
||||
return Number(value || 0).toFixed(2);
|
||||
}
|
||||
272
src/features/lucky-gift/configModel.js
Normal file
272
src/features/lucky-gift/configModel.js
Normal file
@ -0,0 +1,272 @@
|
||||
import {
|
||||
atmosphereLevelConfig,
|
||||
controlStrengthConfig,
|
||||
jackpotStrategyConfig,
|
||||
noviceStrengthConfig,
|
||||
poolModeConfig,
|
||||
} from "@/features/lucky-gift/constants.js";
|
||||
|
||||
const ppmScale = 1_000_000;
|
||||
export const luckyGiftReferenceCost = 10_000;
|
||||
const defaultTargetRTPPPM = 950_000;
|
||||
const defaultDrawsPerHour = 3_600;
|
||||
const defaultUsersPerRoom = 20;
|
||||
|
||||
export function emptyLuckyGiftForm() {
|
||||
return {
|
||||
activityBudget: "0",
|
||||
activityDailyLimit: "0",
|
||||
activitySubsidyEnabled: false,
|
||||
atmosphereLevel: "medium",
|
||||
controlStrength: "standard",
|
||||
enabled: false,
|
||||
jackpotStrategy: "standard",
|
||||
noviceStrength: "standard",
|
||||
payoutStyle: "balanced",
|
||||
poolMode: "standard",
|
||||
riskLevel: "standard",
|
||||
targetRtpPercent: "95",
|
||||
};
|
||||
}
|
||||
|
||||
export function formFromConfig(config) {
|
||||
if (!config) {
|
||||
return emptyLuckyGiftForm();
|
||||
}
|
||||
const activityBudget = Number(config.activityBudget || 0);
|
||||
const activityDailyLimit = Number(config.activityDailyLimit || 0);
|
||||
return {
|
||||
activityBudget: String(activityBudget),
|
||||
activityDailyLimit: String(activityDailyLimit),
|
||||
activitySubsidyEnabled: activityBudget > 0 || activityDailyLimit > 0,
|
||||
atmosphereLevel: inferAtmosphereLevel(config.roomAtmosphereRatePPM),
|
||||
controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws),
|
||||
enabled: Boolean(config.enabled),
|
||||
jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple),
|
||||
noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit),
|
||||
payoutStyle: inferPayoutStyle(config.tiers || []),
|
||||
poolMode: inferPoolMode(config),
|
||||
riskLevel: inferRiskLevel(config),
|
||||
targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM),
|
||||
};
|
||||
}
|
||||
|
||||
export function payloadFromLuckyGiftForm(form, currentConfig) {
|
||||
const giftPrice = luckyGiftReferenceCost;
|
||||
const targetRTPPPM = percentToPPM(form.targetRtpPercent);
|
||||
const control = controlStrengthConfig[form.controlStrength] || controlStrengthConfig.standard;
|
||||
const poolMode = poolModeConfig[form.poolMode] || poolModeConfig.standard;
|
||||
const novice = noviceStrengthConfig[form.noviceStrength] || noviceStrengthConfig.standard;
|
||||
const jackpot = jackpotStrategyConfig[form.jackpotStrategy] || jackpotStrategyConfig.standard;
|
||||
const atmosphere = atmosphereLevelConfig[form.atmosphereLevel] || atmosphereLevelConfig.medium;
|
||||
const risk = riskCapsForLevel(form.riskLevel, giftPrice, targetRTPPPM);
|
||||
const base = currentConfig || {};
|
||||
const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0;
|
||||
const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0;
|
||||
|
||||
return {
|
||||
activity_budget: activityBudget,
|
||||
activity_daily_limit: activityDailyLimit,
|
||||
anchor_daily_payout_cap: risk.anchorDailyPayoutCap,
|
||||
device_daily_payout_cap: risk.deviceDailyPayoutCap,
|
||||
enabled: Boolean(form.enabled),
|
||||
gift_pool_weight_ppm: poolMode.giftPoolWeightPPM,
|
||||
gift_price: giftPrice,
|
||||
gift_reserve: positiveNumber(base.giftReserve, 1_000_000),
|
||||
global_window_draws: control.globalWindowDraws,
|
||||
gift_window_draws: control.giftWindowDraws,
|
||||
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(
|
||||
base.initialPlatformPool,
|
||||
weighted(expectedPayout(giftPrice, targetRTPPPM, control.globalWindowDraws), poolMode.platformPoolWeightPPM),
|
||||
),
|
||||
initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000),
|
||||
intermediate_draw_limit: novice.intermediateDrawLimit,
|
||||
large_tier_enabled: jackpot.largeTierEnabled,
|
||||
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_rate_ppm: Math.max(targetRTPPPM, positiveNumber(base.poolRatePPM, targetRTPPPM)),
|
||||
room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200),
|
||||
room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM,
|
||||
room_atmosphere_reserve: positiveNumber(base.roomAtmosphereReserve, giftPrice * 20),
|
||||
room_hourly_payout_cap: risk.roomHourlyPayoutCap,
|
||||
room_pool_weight_ppm: poolMode.roomPoolWeightPPM,
|
||||
room_reserve: positiveNumber(base.roomReserve, giftPrice * 200),
|
||||
target_rtp_ppm: targetRTPPPM,
|
||||
tiers: buildTiers(giftPrice, form.payoutStyle),
|
||||
user_daily_payout_cap: risk.userDailyPayoutCap,
|
||||
user_hourly_payout_cap: risk.userHourlyPayoutCap,
|
||||
};
|
||||
}
|
||||
|
||||
export function previewForForm(form) {
|
||||
const giftPrice = luckyGiftReferenceCost;
|
||||
const targetRTPPPM = percentToPPM(form.targetRtpPercent);
|
||||
const income = giftPrice * 100_000;
|
||||
const basePayout = Math.round((income * targetRTPPPM) / ppmScale);
|
||||
const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0;
|
||||
const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0;
|
||||
return {
|
||||
activityBudget,
|
||||
activityDailyLimit,
|
||||
basePayout,
|
||||
income,
|
||||
netBeforeSubsidy: income - basePayout,
|
||||
targetRTPPPM,
|
||||
visibleRTPPercent: ((basePayout + Math.min(activityBudget, activityDailyLimit || activityBudget)) * 100) / income,
|
||||
};
|
||||
}
|
||||
|
||||
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, weight, highWaterOnly = false) {
|
||||
return {
|
||||
enabled: weight > 0,
|
||||
high_water_only: Boolean(highWaterOnly),
|
||||
pool,
|
||||
reward_coins: rewardCoins,
|
||||
tier_id: tierId,
|
||||
weight,
|
||||
};
|
||||
}
|
||||
|
||||
function riskCapsForLevel(level, giftPrice, targetRTPPPM) {
|
||||
const expectedHour = expectedPayout(giftPrice, targetRTPPPM, defaultDrawsPerHour);
|
||||
const expectedDay = expectedHour * 24;
|
||||
const profile =
|
||||
level === "strict"
|
||||
? { anchorDay: 1.2, deviceDay: 1.5, roomHour: 1.2, single: 200, userDay: 1.2, userHour: 1.25 }
|
||||
: level === "loose"
|
||||
? { anchorDay: 2, deviceDay: 4, roomHour: 3, single: 500, userDay: 2, userHour: 3 }
|
||||
: { anchorDay: 1.5, deviceDay: 2.5, roomHour: 2, single: 500, userDay: 1.5, userHour: 2 };
|
||||
return {
|
||||
anchorDailyPayoutCap: Math.round(expectedDay * defaultUsersPerRoom * profile.anchorDay),
|
||||
deviceDailyPayoutCap: Math.round(expectedDay * profile.deviceDay),
|
||||
maxSinglePayout: giftPrice * profile.single,
|
||||
roomHourlyPayoutCap: Math.round(expectedHour * defaultUsersPerRoom * profile.roomHour),
|
||||
userDailyPayoutCap: Math.round(expectedDay * profile.userDay),
|
||||
userHourlyPayoutCap: Math.round(expectedHour * profile.userHour),
|
||||
};
|
||||
}
|
||||
|
||||
function inferControlStrength(globalWindowDraws, giftWindowDraws) {
|
||||
return matchConfig(controlStrengthConfig, (item) => {
|
||||
return item.globalWindowDraws === globalWindowDraws && item.giftWindowDraws === giftWindowDraws;
|
||||
});
|
||||
}
|
||||
|
||||
function inferPoolMode(config) {
|
||||
return matchConfig(poolModeConfig, (item) => {
|
||||
return (
|
||||
item.platformPoolWeightPPM === config.platformPoolWeightPPM &&
|
||||
item.roomPoolWeightPPM === config.roomPoolWeightPPM &&
|
||||
item.giftPoolWeightPPM === config.giftPoolWeightPPM
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function inferNoviceStrength(noviceDrawLimit, intermediateDrawLimit) {
|
||||
return matchConfig(noviceStrengthConfig, (item) => {
|
||||
return item.noviceDrawLimit === noviceDrawLimit && item.intermediateDrawLimit === intermediateDrawLimit;
|
||||
});
|
||||
}
|
||||
|
||||
function inferJackpotStrategy(largeTierEnabled, highWaterPoolMultiple) {
|
||||
if (!largeTierEnabled) {
|
||||
return "off";
|
||||
}
|
||||
if (highWaterPoolMultiple >= 3) {
|
||||
return "conservative";
|
||||
}
|
||||
if (highWaterPoolMultiple <= 1) {
|
||||
return "aggressive";
|
||||
}
|
||||
return "standard";
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function matchConfig(configMap, matcher) {
|
||||
return Object.entries(configMap).find(([, item]) => matcher(item))?.[0] || "standard";
|
||||
}
|
||||
|
||||
function formatPercentInput(ppm) {
|
||||
const percent = Number(ppm || 0) / 10_000;
|
||||
return Number.isInteger(percent) ? String(percent) : percent.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function percentToPPM(value) {
|
||||
return Math.round(Number(value || 0) * 10_000);
|
||||
}
|
||||
|
||||
function expectedPayout(cost, targetRTPPPM, draws) {
|
||||
return Math.round((cost * targetRTPPPM * draws) / ppmScale);
|
||||
}
|
||||
|
||||
function weighted(amount, weightPPM) {
|
||||
return Math.round((amount * weightPPM) / ppmScale);
|
||||
}
|
||||
|
||||
function positiveNumber(value, fallback) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) && number > 0 ? Math.round(number) : Math.round(fallback);
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) && number >= 0 ? Math.round(number) : 0;
|
||||
}
|
||||
11
src/features/lucky-gift/configModel.test.js
Normal file
11
src/features/lucky-gift/configModel.test.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { emptyLuckyGiftForm, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
|
||||
|
||||
describe("lucky gift config payload", () => {
|
||||
test("submits both RTP control windows for backend validation", () => {
|
||||
const payload = payloadFromLuckyGiftForm(emptyLuckyGiftForm(), null);
|
||||
|
||||
expect(payload.global_window_draws).toBeGreaterThan(0);
|
||||
expect(payload.gift_window_draws).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
89
src/features/lucky-gift/constants.js
Normal file
89
src/features/lucky-gift/constants.js
Normal file
@ -0,0 +1,89 @@
|
||||
export const luckyGiftConfigScopeLabel = "全局配置";
|
||||
|
||||
export const controlStrengthOptions = [
|
||||
["precise", "精准"],
|
||||
["standard", "标准"],
|
||||
["natural", "自然"],
|
||||
];
|
||||
|
||||
export const poolModeOptions = [
|
||||
["platform_guard", "平台兜底"],
|
||||
["standard", "标准模式"],
|
||||
["room_burst", "房间氛围"],
|
||||
["gift_isolated", "礼物独立"],
|
||||
];
|
||||
|
||||
export const noviceStrengthOptions = [
|
||||
["weak", "弱"],
|
||||
["standard", "标准"],
|
||||
["strong", "强"],
|
||||
];
|
||||
|
||||
export const payoutStyleOptions = [
|
||||
["stable", "稳定小奖"],
|
||||
["balanced", "均衡"],
|
||||
["exciting", "刺激大奖"],
|
||||
];
|
||||
|
||||
export const jackpotStrategyOptions = [
|
||||
["off", "关闭"],
|
||||
["conservative", "保守"],
|
||||
["standard", "标准"],
|
||||
["aggressive", "激进"],
|
||||
];
|
||||
|
||||
export const riskLevelOptions = [
|
||||
["strict", "严格"],
|
||||
["standard", "标准"],
|
||||
["loose", "宽松"],
|
||||
];
|
||||
|
||||
export const atmosphereLevelOptions = [
|
||||
["off", "关闭"],
|
||||
["low", "低"],
|
||||
["medium", "中"],
|
||||
["high", "高"],
|
||||
];
|
||||
|
||||
export const drawStatusOptions = [
|
||||
["pending", "发放中"],
|
||||
["granted", "已发放"],
|
||||
["failed", "发放失败"],
|
||||
];
|
||||
|
||||
export const controlStrengthConfig = {
|
||||
precise: { globalWindowDraws: 50_000, giftWindowDraws: 50_000 },
|
||||
standard: { globalWindowDraws: 100_000, giftWindowDraws: 100_000 },
|
||||
natural: { globalWindowDraws: 300_000, giftWindowDraws: 300_000 },
|
||||
};
|
||||
|
||||
export const poolModeConfig = {
|
||||
platform_guard: { platformPoolWeightPPM: 500_000, roomPoolWeightPPM: 200_000, giftPoolWeightPPM: 300_000 },
|
||||
standard: { platformPoolWeightPPM: 200_000, roomPoolWeightPPM: 300_000, giftPoolWeightPPM: 500_000 },
|
||||
room_burst: { platformPoolWeightPPM: 200_000, roomPoolWeightPPM: 450_000, giftPoolWeightPPM: 350_000 },
|
||||
gift_isolated: { platformPoolWeightPPM: 150_000, roomPoolWeightPPM: 200_000, giftPoolWeightPPM: 650_000 },
|
||||
};
|
||||
|
||||
export const noviceStrengthConfig = {
|
||||
weak: { noviceDrawLimit: 1_000, intermediateDrawLimit: 10_000 },
|
||||
standard: { noviceDrawLimit: 2_000, intermediateDrawLimit: 20_000 },
|
||||
strong: { noviceDrawLimit: 5_000, intermediateDrawLimit: 30_000 },
|
||||
};
|
||||
|
||||
export const jackpotStrategyConfig = {
|
||||
off: { largeTierEnabled: false, highWaterPoolMultiple: 3 },
|
||||
conservative: { largeTierEnabled: true, highWaterPoolMultiple: 3 },
|
||||
standard: { largeTierEnabled: true, highWaterPoolMultiple: 2 },
|
||||
aggressive: { largeTierEnabled: true, highWaterPoolMultiple: 1 },
|
||||
};
|
||||
|
||||
export const atmosphereLevelConfig = {
|
||||
off: { roomAtmosphereRatePPM: 0 },
|
||||
low: { roomAtmosphereRatePPM: 5_000 },
|
||||
medium: { roomAtmosphereRatePPM: 10_000 },
|
||||
high: { roomAtmosphereRatePPM: 20_000 },
|
||||
};
|
||||
|
||||
export function optionLabel(options, value) {
|
||||
return options.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
|
||||
}
|
||||
145
src/features/lucky-gift/hooks/useLuckyGiftPage.js
Normal file
145
src/features/lucky-gift/hooks/useLuckyGiftPage.js
Normal file
@ -0,0 +1,145 @@
|
||||
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, 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 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 [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyLuckyGiftForm());
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [giftId, setGiftId] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [roomId, setRoomId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const drawFilters = useMemo(
|
||||
() => ({ gift_id: giftId, room_id: roomId, status, user_id: userId }),
|
||||
[giftId, roomId, status, userId],
|
||||
);
|
||||
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();
|
||||
setConfig(nextConfig);
|
||||
setForm(formFromConfig(nextConfig));
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载幸运礼物配置失败", "error");
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
|
||||
const openConfigDrawer = useCallback(() => {
|
||||
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
|
||||
setConfigDrawerOpen(true);
|
||||
}, [config]);
|
||||
|
||||
const closeConfigDrawer = useCallback(() => {
|
||||
setConfigDrawerOpen(false);
|
||||
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
|
||||
}, [config]);
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
return;
|
||||
}
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const parsed = parseForm(luckyGiftConfigFormSchema, form);
|
||||
const saved = await updateLuckyGiftConfig(payloadFromLuckyGiftForm(parsed, config));
|
||||
setConfig(saved);
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("幸运礼物配置已保存", "success");
|
||||
await reloadDraws();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存幸运礼物配置失败", "error");
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeGiftId = (value) => {
|
||||
setGiftId(value);
|
||||
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,
|
||||
changeGiftId,
|
||||
changeRoomId,
|
||||
changeStatus,
|
||||
changeUserId,
|
||||
closeConfigDrawer,
|
||||
config,
|
||||
configDrawerOpen,
|
||||
configLoading,
|
||||
configSaving,
|
||||
draws,
|
||||
drawsError,
|
||||
drawsLoading,
|
||||
form,
|
||||
giftId,
|
||||
openConfigDrawer,
|
||||
page,
|
||||
reloadConfig,
|
||||
reloadDraws,
|
||||
resetDrawFilters,
|
||||
roomId,
|
||||
setForm,
|
||||
setPage,
|
||||
status,
|
||||
submitConfig,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
285
src/features/lucky-gift/lucky-gift.module.css
Normal file
285
src/features/lucky-gift/lucky-gift.module.css
Normal file
@ -0,0 +1,285 @@
|
||||
.summaryPanel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summaryScrollArea {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.summaryContent {
|
||||
display: grid;
|
||||
min-width: max-content;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.summaryHeader {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
align-self: stretch;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summaryItems {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
line-height: var(--admin-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
line-height: var(--admin-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.configDrawer {
|
||||
box-sizing: border-box;
|
||||
width: min(520px, 100vw);
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.configGrid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.configGrid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.configGrid > :global(.MuiTextField-root),
|
||||
.configGrid > :global(.MuiFormControl-root),
|
||||
.configGrid > :global(.MuiAutocomplete-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.configWide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.helpIcon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.switchField {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.subsidyBlock {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-column: 1 / -1;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.subsidyToggle {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.subsidyInputs {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.subsidyInputs > :global(.MuiTextField-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.simulationPanel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.simulationHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.simulationActions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.simulationGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.simulationOverview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.metricItem {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.metricItem span {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metricItem strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 760;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title,
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.summaryPanel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
justify-content: flex-start;
|
||||
padding-top: var(--space-3);
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.simulationHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.simulationGrid,
|
||||
.simulationOverview {
|
||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.configGrid,
|
||||
.subsidyInputs,
|
||||
.simulationGrid,
|
||||
.simulationOverview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
220
src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx
Normal file
220
src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx
Normal file
@ -0,0 +1,220 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
|
||||
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
|
||||
import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx";
|
||||
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,
|
||||
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>
|
||||
<LuckyGiftConfigSummary
|
||||
canUpdate={page.abilities.canUpdate}
|
||||
config={page.config}
|
||||
configLoading={page.configLoading}
|
||||
onEdit={page.openConfigDrawer}
|
||||
onRefresh={page.reloadConfig}
|
||||
/>
|
||||
<LuckyGiftSimulationPanel config={page.config} />
|
||||
<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}
|
||||
configSaving={page.configSaving}
|
||||
form={page.form}
|
||||
open={page.configDrawerOpen}
|
||||
setForm={page.setForm}
|
||||
onClose={page.closeConfigDrawer}
|
||||
onSubmit={page.submitConfig}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawStatus({ status }) {
|
||||
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
|
||||
return (
|
||||
<span className={["status-badge", `status-badge--${tone}`].join(" ")}>
|
||||
<span className="status-point" />
|
||||
{drawStatusOptions.find(([value]) => value === status)?.[1] || status || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
11
src/features/lucky-gift/permissions.js
Normal file
11
src/features/lucky-gift/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useLuckyGiftAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canUpdate: can(PERMISSIONS.luckyGiftUpdate),
|
||||
canView: can(PERMISSIONS.luckyGiftView),
|
||||
};
|
||||
}
|
||||
12
src/features/lucky-gift/routes.js
Normal file
12
src/features/lucky-gift/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const luckyGiftRoutes = [
|
||||
{
|
||||
label: "幸运礼物",
|
||||
loader: () => import("./pages/LuckyGiftConfigPage.jsx").then((module) => module.LuckyGiftConfigPage),
|
||||
menuCode: MENU_CODES.luckyGift,
|
||||
pageKey: "lucky-gift",
|
||||
path: "/operations/lucky-gift",
|
||||
permission: PERMISSIONS.luckyGiftView,
|
||||
},
|
||||
];
|
||||
33
src/features/lucky-gift/schema.ts
Normal file
33
src/features/lucky-gift/schema.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const luckyGiftConfigFormSchema = z
|
||||
.object({
|
||||
activityBudget: z.coerce.number().int("活动总预算必须是整数").min(0, "活动总预算不能小于 0"),
|
||||
activityDailyLimit: z.coerce.number().int("每日预算必须是整数").min(0, "每日预算不能小于 0"),
|
||||
activitySubsidyEnabled: z.boolean(),
|
||||
atmosphereLevel: z.enum(["off", "low", "medium", "high"]),
|
||||
controlStrength: z.enum(["precise", "standard", "natural"]),
|
||||
enabled: z.boolean(),
|
||||
jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]),
|
||||
noviceStrength: z.enum(["weak", "standard", "strong"]),
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if (value.activityBudget <= 0) {
|
||||
context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] });
|
||||
}
|
||||
if (value.activityBudget > 0 && value.activityDailyLimit <= 0) {
|
||||
context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] });
|
||||
}
|
||||
if (value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) {
|
||||
context.addIssue({ code: "custom", message: "每日预算不能大于活动总预算", path: ["activityDailyLimit"] });
|
||||
}
|
||||
});
|
||||
|
||||
export type LuckyGiftConfigForm = z.infer<typeof luckyGiftConfigFormSchema>;
|
||||
228
src/features/lucky-gift/simulation.js
Normal file
228
src/features/lucky-gift/simulation.js
Normal file
@ -0,0 +1,228 @@
|
||||
const ppmScale = 1_000_000;
|
||||
|
||||
export function runLuckyGiftSimulation(config, input) {
|
||||
const users = clampInt(input.users, 1, 20_000);
|
||||
const rooms = clampInt(input.rooms, 1, users);
|
||||
const costMin = clampInt(input.costMin, 1, 1_000_000_000);
|
||||
const costMax = Math.max(costMin, clampInt(input.costMax, costMin, 1_000_000_000));
|
||||
const drawsMin = clampInt(input.drawsMin, 0, 10_000_000);
|
||||
const drawsMax = Math.max(drawsMin, clampInt(input.drawsMax, drawsMin, 10_000_000));
|
||||
const targetRTP = Number(config?.targetRTPPPM || 950_000) / ppmScale;
|
||||
const seed = finiteSeed(input.seed) ?? runtimeSeed();
|
||||
const random = createRandom(seed + users * 31 + rooms * 17 + costMin + costMax + drawsMin + drawsMax);
|
||||
|
||||
const rows = [];
|
||||
let totalDraws = 0;
|
||||
let totalSpend = 0;
|
||||
let rawRewardTotal = 0;
|
||||
|
||||
for (let index = 0; index < users; index += 1) {
|
||||
const drawCount = randomInt(random, drawsMin, drawsMax);
|
||||
const spend = randomSpend(random, drawCount, costMin, costMax);
|
||||
const variance = userRTPVolatility(drawCount);
|
||||
const rawReward = Math.max(0, Math.round(spend * targetRTP * (1 + normal(random) * variance)));
|
||||
const row = {
|
||||
drawCount,
|
||||
finalIncome: 0,
|
||||
initialIncome: 0,
|
||||
netIncome: 0,
|
||||
reward: rawReward,
|
||||
roomId: `room_${randomInt(random, 1, rooms)}`,
|
||||
rtp: 0,
|
||||
spend,
|
||||
userId: `user_${String(index + 1).padStart(4, "0")}`,
|
||||
};
|
||||
rows.push(row);
|
||||
totalDraws += drawCount;
|
||||
totalSpend += spend;
|
||||
rawRewardTotal += rawReward;
|
||||
}
|
||||
|
||||
const controlledRewardTotal = controlledTotalReward(random, totalSpend, totalDraws, targetRTP);
|
||||
distributeRewardCorrection(rows, controlledRewardTotal - rawRewardTotal, totalSpend);
|
||||
|
||||
let totalReward = 0;
|
||||
let minRTP = Number.POSITIVE_INFINITY;
|
||||
let maxRTP = 0;
|
||||
let rtpSum = 0;
|
||||
let activeUsers = 0;
|
||||
for (const row of rows) {
|
||||
row.finalIncome = row.reward;
|
||||
row.netIncome = row.reward - row.spend;
|
||||
row.rtp = row.spend > 0 ? (row.reward * 100) / row.spend : 0;
|
||||
totalReward += row.reward;
|
||||
if (row.spend > 0) {
|
||||
activeUsers += 1;
|
||||
rtpSum += row.rtp;
|
||||
minRTP = Math.min(minRTP, row.rtp);
|
||||
maxRTP = Math.max(maxRTP, row.rtp);
|
||||
}
|
||||
}
|
||||
|
||||
const overview = {
|
||||
activeUsers,
|
||||
averageUserRTP: activeUsers > 0 ? rtpSum / activeUsers : 0,
|
||||
costMax,
|
||||
costMin,
|
||||
drawsMax,
|
||||
drawsMin,
|
||||
maxUserRTP: activeUsers > 0 ? maxRTP : 0,
|
||||
minUserRTP: activeUsers > 0 ? minRTP : 0,
|
||||
rooms,
|
||||
seed,
|
||||
targetRTPPercent: targetRTP * 100,
|
||||
totalDraws,
|
||||
totalNetIncome: totalReward - totalSpend,
|
||||
totalReward,
|
||||
totalRTP: totalSpend > 0 ? (totalReward * 100) / totalSpend : 0,
|
||||
totalSpend,
|
||||
users,
|
||||
};
|
||||
|
||||
return { csv: toCSV(rows, overview), overview, rows };
|
||||
}
|
||||
|
||||
function controlledTotalReward(random, totalSpend, totalDraws, targetRTP) {
|
||||
if (totalSpend <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const scale = Math.max(1, totalDraws / 100_000);
|
||||
const maxDeviation = totalDraws >= 1_000_000 ? 0.005 : totalDraws >= 100_000 ? 0.01 : 0.02;
|
||||
const deviation = clamp(normal(random) * (0.003 / Math.sqrt(scale)), -maxDeviation, maxDeviation);
|
||||
return Math.max(0, Math.round(totalSpend * targetRTP * (1 + deviation)));
|
||||
}
|
||||
|
||||
function distributeRewardCorrection(rows, correction, totalSpend) {
|
||||
if (!correction || totalSpend <= 0 || rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
let applied = 0;
|
||||
for (const row of rows) {
|
||||
const share = Math.round(correction * (row.spend / totalSpend));
|
||||
row.reward = Math.max(0, row.reward + share);
|
||||
applied += share;
|
||||
}
|
||||
let remaining = correction - applied;
|
||||
let index = 0;
|
||||
while (remaining !== 0 && rows.length > 0) {
|
||||
const delta = remaining > 0 ? 1 : -1;
|
||||
const row = rows[index % rows.length];
|
||||
if (delta > 0 || row.reward > 0) {
|
||||
row.reward += delta;
|
||||
remaining -= delta;
|
||||
}
|
||||
index += 1;
|
||||
if (index > rows.length * 4 && remaining < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function randomSpend(random, drawCount, costMin, costMax) {
|
||||
if (drawCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (drawCount <= 1_000) {
|
||||
let total = 0;
|
||||
for (let index = 0; index < drawCount; index += 1) {
|
||||
total += randomInt(random, costMin, costMax);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
const mean = drawCount * ((costMin + costMax) / 2);
|
||||
const rangeSize = costMax - costMin + 1;
|
||||
const variance = drawCount * ((rangeSize * rangeSize - 1) / 12);
|
||||
const value = Math.round(mean + normal(random) * Math.sqrt(Math.max(0, variance)));
|
||||
return clampInt(value, drawCount * costMin, drawCount * costMax);
|
||||
}
|
||||
|
||||
function userRTPVolatility(drawCount) {
|
||||
if (drawCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(0.65, 0.36 / Math.sqrt(Math.max(1, drawCount / 100)));
|
||||
}
|
||||
|
||||
function toCSV(rows, overview) {
|
||||
const header = [
|
||||
"用户ID",
|
||||
"房间ID",
|
||||
"抽奖次数",
|
||||
"初始收入",
|
||||
"总消耗",
|
||||
"最终收入",
|
||||
"最终净收入",
|
||||
"RTP",
|
||||
];
|
||||
const lines = [
|
||||
["总览", "", overview.totalDraws, 0, overview.totalSpend, overview.totalReward, overview.totalNetIncome, formatRatio(overview.totalRTP)],
|
||||
header,
|
||||
...rows.map((row) => [
|
||||
row.userId,
|
||||
row.roomId,
|
||||
row.drawCount,
|
||||
row.initialIncome,
|
||||
row.spend,
|
||||
row.finalIncome,
|
||||
row.netIncome,
|
||||
formatRatio(row.rtp),
|
||||
]),
|
||||
];
|
||||
return lines.map((line) => line.map(csvCell).join(",")).join("\n");
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const text = String(value ?? "");
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
function createRandom(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function finiteSeed(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? Math.round(number) : null;
|
||||
}
|
||||
|
||||
function runtimeSeed() {
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
const values = new Uint32Array(1);
|
||||
globalThis.crypto.getRandomValues(values);
|
||||
return values[0];
|
||||
}
|
||||
return Math.round(Date.now() + Math.random() * 0xffffffff);
|
||||
}
|
||||
|
||||
function normal(random) {
|
||||
const u1 = Math.max(Number.EPSILON, random());
|
||||
const u2 = Math.max(Number.EPSILON, random());
|
||||
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
||||
}
|
||||
|
||||
function randomInt(random, min, max) {
|
||||
return Math.floor(random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function clampInt(value, min, max) {
|
||||
const number = Math.round(Number(value || 0));
|
||||
if (!Number.isFinite(number)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(max, Math.max(min, number));
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function formatRatio(value) {
|
||||
return Number(value || 0).toFixed(4);
|
||||
}
|
||||
22
src/features/lucky-gift/simulation.test.js
Normal file
22
src/features/lucky-gift/simulation.test.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { runLuckyGiftSimulation } from "@/features/lucky-gift/simulation.js";
|
||||
|
||||
const input = {
|
||||
costMax: "5000",
|
||||
costMin: "100",
|
||||
drawsMax: "1000",
|
||||
drawsMin: "0",
|
||||
rooms: "50",
|
||||
users: "100",
|
||||
};
|
||||
|
||||
describe("lucky gift simulation", () => {
|
||||
test("uses seed as the simulation batch identity", () => {
|
||||
const first = runLuckyGiftSimulation(null, { ...input, seed: 1001 });
|
||||
const sameSeed = runLuckyGiftSimulation(null, { ...input, seed: 1001 });
|
||||
const nextSeed = runLuckyGiftSimulation(null, { ...input, seed: 1002 });
|
||||
|
||||
expect(first.csv).toBe(sameSeed.csv);
|
||||
expect(first.csv).not.toBe(nextSeed.csv);
|
||||
});
|
||||
});
|
||||
@ -15,10 +15,12 @@ import {
|
||||
grantResourceGroup,
|
||||
listEmojiPackCategories,
|
||||
listEmojiPacks,
|
||||
listGiftTypes,
|
||||
listResourceGrants,
|
||||
listGifts,
|
||||
listResources,
|
||||
updateGift,
|
||||
updateGiftType,
|
||||
updateResource,
|
||||
updateResourceGroup,
|
||||
} from "./api";
|
||||
@ -251,3 +253,30 @@ test("emoji pack APIs use generated admin paths", async () => {
|
||||
pricingType: "paid",
|
||||
});
|
||||
});
|
||||
|
||||
test("gift type APIs send tab key, display name and tab name fields", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))),
|
||||
);
|
||||
|
||||
await listGiftTypes();
|
||||
await updateGiftType("normal", {
|
||||
displayName: "普通礼物",
|
||||
sortOrder: 10,
|
||||
status: "active",
|
||||
tabName: "Gift",
|
||||
});
|
||||
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[1];
|
||||
|
||||
expect(String(listUrl)).toContain("/api/v1/admin/gift-types");
|
||||
expect(listInit?.method).toBe("GET");
|
||||
expect(String(updateUrl)).toContain("/api/v1/admin/gift-types/normal");
|
||||
expect(updateInit?.method).toBe("PUT");
|
||||
expect(JSON.parse(String(updateInit?.body))).toMatchObject({
|
||||
displayName: "普通礼物",
|
||||
tabName: "Gift",
|
||||
});
|
||||
});
|
||||
|
||||
@ -109,6 +109,19 @@ export interface GiftDto {
|
||||
regionIds?: number[];
|
||||
}
|
||||
|
||||
export interface GiftTypeDto {
|
||||
appCode?: string;
|
||||
tabKey?: string;
|
||||
displayName?: string;
|
||||
tabName?: string;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
createdByUserId?: number;
|
||||
updatedByUserId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface GiftPayload {
|
||||
giftId: string;
|
||||
resourceId: number;
|
||||
@ -129,6 +142,13 @@ export interface GiftPayload {
|
||||
regionIds: number[];
|
||||
}
|
||||
|
||||
export interface GiftTypePayload {
|
||||
displayName: string;
|
||||
tabName: string;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ResourceGrantItemDto {
|
||||
createdAtMs?: number;
|
||||
durationMs?: number;
|
||||
@ -300,6 +320,24 @@ export function listGifts(query: PageQuery = {}): Promise<ApiPage<GiftDto>> {
|
||||
});
|
||||
}
|
||||
|
||||
export function listGiftTypes(): Promise<GiftTypeDto[]> {
|
||||
const endpoint = API_ENDPOINTS.listGiftTypes;
|
||||
return apiRequest<GiftTypeDto[]>(apiEndpointPath(API_OPERATIONS.listGiftTypes), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateGiftType(tabKey: string, payload: GiftTypePayload): Promise<GiftTypeDto> {
|
||||
const endpoint = API_ENDPOINTS.updateGiftType;
|
||||
return apiRequest<GiftTypeDto, GiftTypePayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateGiftType, { type_code: tabKey }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createGift(payload: GiftPayload): Promise<GiftDto> {
|
||||
const endpoint = API_ENDPOINTS.createGift;
|
||||
return apiRequest<GiftDto, GiftPayload>(apiEndpointPath(API_OPERATIONS.createGift), {
|
||||
|
||||
76
src/features/resources/components/GiftTypeConfigDialog.jsx
Normal file
76
src/features/resources/components/GiftTypeConfigDialog.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormList,
|
||||
AdminFormListRow,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
|
||||
export function GiftTypeConfigDialog({
|
||||
disabled,
|
||||
form,
|
||||
loading,
|
||||
onChangeItem,
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
optionsLoading,
|
||||
}) {
|
||||
const items = form?.items || [];
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={disabled || loading || optionsLoading}
|
||||
submitLabel="保存"
|
||||
title="礼物类型"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="Tab 配置">
|
||||
<AdminFormList>
|
||||
{items.map((item, index) => (
|
||||
<AdminFormListRow
|
||||
columns="minmax(112px, 0.65fr) minmax(148px, 1fr) minmax(132px, 0.9fr) minmax(88px, 0.5fr) minmax(96px, 0.45fr)"
|
||||
key={item.tabKey}
|
||||
>
|
||||
<TextField disabled label="Tab Key" value={item.tabKey} />
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="显示名称"
|
||||
required
|
||||
value={item.displayName}
|
||||
onChange={(event) => onChangeItem(index, { displayName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="Tab Name"
|
||||
required
|
||||
value={item.tabName}
|
||||
onChange={(event) => onChangeItem(index, { tabName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={item.sortOrder}
|
||||
onChange={(event) => onChangeItem(index, { sortOrder: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={item.enabled}
|
||||
checkedLabel="开"
|
||||
disabled={disabled}
|
||||
label="Tab"
|
||||
uncheckedLabel="关"
|
||||
onChange={(enabled) => onChangeItem(index, { enabled })}
|
||||
/>
|
||||
</AdminFormListRow>
|
||||
))}
|
||||
</AdminFormList>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,19 @@ export const resourceStatusLabels = {
|
||||
disabled: "禁用",
|
||||
};
|
||||
|
||||
export const defaultGiftTypeOptions = [
|
||||
{ displayName: "普通礼物", sortOrder: 10, status: "active", tabKey: "normal", tabName: "Gift" },
|
||||
{ displayName: "CP礼物", sortOrder: 20, status: "active", tabKey: "cp", tabName: "CP" },
|
||||
{ displayName: "幸运礼物", sortOrder: 30, status: "active", tabKey: "lucky", tabName: "Lucky" },
|
||||
{ displayName: "超级幸运礼物", sortOrder: 40, status: "active", tabKey: "super_lucky", tabName: "Super Lucky" },
|
||||
{ displayName: "专属礼物", sortOrder: 50, status: "active", tabKey: "exclusive", tabName: "Exclusive" },
|
||||
{ displayName: "贵族礼物", sortOrder: 60, status: "active", tabKey: "noble", tabName: "Noble" },
|
||||
{ displayName: "国旗礼物", sortOrder: 70, status: "active", tabKey: "flag", tabName: "Flag" },
|
||||
{ displayName: "活动礼物", sortOrder: 80, status: "active", tabKey: "activity", tabName: "Activity" },
|
||||
{ displayName: "魔法礼物", sortOrder: 90, status: "active", tabKey: "magic", tabName: "Magic" },
|
||||
{ displayName: "定制礼物", sortOrder: 100, status: "active", tabKey: "custom", tabName: "Custom" },
|
||||
];
|
||||
|
||||
export const resourceGrantStatusFilters = [
|
||||
["", "全部状态"],
|
||||
["succeeded", "成功"],
|
||||
|
||||
@ -18,18 +18,22 @@ import {
|
||||
grantResourceGroup,
|
||||
listEmojiPackCategories,
|
||||
listEmojiPacks,
|
||||
listGiftTypes,
|
||||
listGifts,
|
||||
listResourceGrants,
|
||||
listResourceGroups,
|
||||
listResources,
|
||||
updateGift,
|
||||
updateGiftType,
|
||||
updateResource,
|
||||
updateResourceGroup,
|
||||
} from "@/features/resources/api";
|
||||
import { defaultGiftTypeOptions } from "@/features/resources/constants.js";
|
||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||
import {
|
||||
emojiPackFormSchema,
|
||||
giftFormSchema,
|
||||
giftTypeConfigFormSchema,
|
||||
resourceFormSchema,
|
||||
resourceGrantFormSchema,
|
||||
resourceGroupCreateFormSchema,
|
||||
@ -470,6 +474,10 @@ export function useGiftListPage() {
|
||||
const [form, setForm] = useState(emptyGiftForm);
|
||||
const [selectedGift, setSelectedGift] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [giftTypes, setGiftTypes] = useState(defaultGiftTypeOptions);
|
||||
const [giftTypeForm, setGiftTypeForm] = useState({ items: defaultGiftTypeOptions.map(giftTypeToForm) });
|
||||
const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false);
|
||||
const [giftTypesLoading, setGiftTypesLoading] = useState(false);
|
||||
const [resourceOptions, setResourceOptions] = useState([]);
|
||||
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
@ -490,6 +498,51 @@ export function useGiftListPage() {
|
||||
queryKey: ["gifts", filters, page],
|
||||
});
|
||||
|
||||
const loadGiftTypes = () => {
|
||||
setGiftTypesLoading(true);
|
||||
return listGiftTypes()
|
||||
.then((items = []) => {
|
||||
const nextItems = mergeGiftTypeOptions(items);
|
||||
setGiftTypes(nextItems);
|
||||
setGiftTypeForm({ items: nextItems.map(giftTypeToForm) });
|
||||
return nextItems;
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast(err.message || "加载礼物类型失败", "error");
|
||||
return giftTypes;
|
||||
})
|
||||
.finally(() => {
|
||||
setGiftTypesLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setGiftTypesLoading(true);
|
||||
listGiftTypes()
|
||||
.then((items = []) => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
const nextItems = mergeGiftTypeOptions(items);
|
||||
setGiftTypes(nextItems);
|
||||
setGiftTypeForm({ items: nextItems.map(giftTypeToForm) });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!ignore) {
|
||||
showToast(err.message || "加载礼物类型失败", "error");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) {
|
||||
setGiftTypesLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAction !== "create" && activeAction !== "edit") {
|
||||
return undefined;
|
||||
@ -551,6 +604,41 @@ export function useGiftListPage() {
|
||||
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
|
||||
};
|
||||
|
||||
const openGiftTypeDialog = () => {
|
||||
setGiftTypeForm({ items: giftTypes.map(giftTypeToForm) });
|
||||
setGiftTypeDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeGiftTypeDialog = () => {
|
||||
setGiftTypeDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateGiftTypeFormItem = (index, patch) => {
|
||||
setGiftTypeForm((current) => ({
|
||||
...current,
|
||||
items: current.items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)),
|
||||
}));
|
||||
};
|
||||
|
||||
const submitGiftTypes = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdateGift) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction("gift-types");
|
||||
try {
|
||||
const parsed = parseForm(giftTypeConfigFormSchema, giftTypeForm);
|
||||
await Promise.all(parsed.items.map((item) => updateGiftType(item.tabKey, buildGiftTypePayload(item))));
|
||||
showToast("礼物类型已保存", "success");
|
||||
setGiftTypeDialogOpen(false);
|
||||
await loadGiftTypes();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存礼物类型失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const submitGift = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = activeAction === "edit";
|
||||
@ -613,11 +701,17 @@ export function useGiftListPage() {
|
||||
changeRegionId: resetSetter(setRegionId, setPage),
|
||||
changeStatus: resetSetter(setStatus, setPage),
|
||||
closeAction,
|
||||
closeGiftTypeDialog,
|
||||
form,
|
||||
giftTypeDialogOpen,
|
||||
giftTypeForm,
|
||||
giftTypeOptions: giftTypes,
|
||||
giftTypesLoading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreateGift,
|
||||
openEditGift,
|
||||
openGiftTypeDialog,
|
||||
regionId,
|
||||
regionOptions,
|
||||
resourceOptions,
|
||||
@ -626,9 +720,11 @@ export function useGiftListPage() {
|
||||
resetFilters,
|
||||
selectGiftResource,
|
||||
setForm,
|
||||
submitGiftTypes,
|
||||
status,
|
||||
submitGift,
|
||||
toggleGift,
|
||||
updateGiftTypeFormItem,
|
||||
};
|
||||
}
|
||||
|
||||
@ -997,6 +1093,54 @@ function buildGiftPayload(form) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildGiftTypePayload(form) {
|
||||
return {
|
||||
displayName: form.displayName.trim(),
|
||||
sortOrder: Number(form.sortOrder || 0),
|
||||
status: form.enabled ? "active" : "disabled",
|
||||
tabName: form.tabName.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function giftTypeToForm(item) {
|
||||
const normalized = normalizeGiftTypeOption(item);
|
||||
return {
|
||||
enabled: normalized.status ? normalized.status === "active" : true,
|
||||
displayName: normalized.displayName || "",
|
||||
sortOrder: normalized.sortOrder === 0 || normalized.sortOrder ? String(normalized.sortOrder) : "0",
|
||||
tabKey: normalized.tabKey || "",
|
||||
tabName: normalized.tabName || "",
|
||||
};
|
||||
}
|
||||
|
||||
function mergeGiftTypeOptions(items = []) {
|
||||
const byCode = new Map(defaultGiftTypeOptions.map((item) => [item.tabKey, normalizeGiftTypeOption(item)]));
|
||||
items.forEach((item) => {
|
||||
const normalized = normalizeGiftTypeOption(item);
|
||||
if (normalized.tabKey) {
|
||||
byCode.set(normalized.tabKey, {
|
||||
...byCode.get(normalized.tabKey),
|
||||
...normalized,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(byCode.values()).sort(
|
||||
(left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeGiftTypeOption(item = {}) {
|
||||
const tabKey = String(item.typeCode || item.tabKey || "").trim();
|
||||
const tabName = String(item.tabName || (item.typeCode ? item.tabKey : "") || "").trim();
|
||||
const displayName = String(item.displayName || item.name || "").trim();
|
||||
return {
|
||||
...item,
|
||||
displayName,
|
||||
tabKey,
|
||||
tabName,
|
||||
};
|
||||
}
|
||||
|
||||
function msToDatetimeLocal(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
|
||||
@ -4,6 +4,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
|
||||
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
@ -31,22 +32,11 @@ import {
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { resourceStatusFilters } from "@/features/resources/constants.js";
|
||||
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
|
||||
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
||||
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
import styles from "@/features/resources/resources.module.css";
|
||||
|
||||
const giftTypeOptions = [
|
||||
["normal", "普通礼物"],
|
||||
["cp", "CP礼物"],
|
||||
["lucky", "幸运礼物"],
|
||||
["super_lucky", "超级幸运礼物"],
|
||||
["exclusive", "专属礼物"],
|
||||
["noble", "贵族礼物"],
|
||||
["flag", "国旗礼物"],
|
||||
["activity", "活动礼物"],
|
||||
["magic", "魔法礼物"],
|
||||
["custom", "定制礼物"],
|
||||
];
|
||||
const chargeAssetOptions = [
|
||||
["COIN", "金币"],
|
||||
["DIAMOND", "钻石"],
|
||||
@ -59,7 +49,7 @@ const effectOptions = [
|
||||
const effectLabels = Object.fromEntries(effectOptions);
|
||||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
|
||||
const baseColumns = [
|
||||
const baseColumns = (giftTypeOptions) => [
|
||||
{
|
||||
key: "gift",
|
||||
label: "礼物",
|
||||
@ -78,7 +68,7 @@ const baseColumns = [
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (gift) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{giftTypeLabel(gift.giftTypeCode)}</span>
|
||||
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
|
||||
<span className={styles.meta}>
|
||||
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
|
||||
</span>
|
||||
@ -137,7 +127,8 @@ export function GiftListPage() {
|
||||
const isEditing = page.activeAction === "edit";
|
||||
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
|
||||
const regionLabelById = buildRegionLabelMap(giftRegionOptions);
|
||||
const tableColumns = baseColumns.map((column) => {
|
||||
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
|
||||
const tableColumns = baseColumns(giftTypeOptions).map((column) => {
|
||||
if (column.key === "regions") {
|
||||
return {
|
||||
...column,
|
||||
@ -185,11 +176,18 @@ export function GiftListPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreateGift ? (
|
||||
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
<>
|
||||
{page.abilities.canUpdateGift ? (
|
||||
<AdminActionIconButton label="礼物类型" onClick={page.openGiftTypeDialog}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreateGift ? (
|
||||
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
@ -223,6 +221,16 @@ export function GiftListPage() {
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitGift}
|
||||
/>
|
||||
<GiftTypeConfigDialog
|
||||
disabled={!page.abilities.canUpdateGift}
|
||||
form={page.giftTypeForm}
|
||||
loading={page.loadingAction === "gift-types"}
|
||||
open={page.giftTypeDialogOpen}
|
||||
optionsLoading={page.giftTypesLoading}
|
||||
onChangeItem={page.updateGiftTypeFormItem}
|
||||
onClose={page.closeGiftTypeDialog}
|
||||
onSubmit={page.submitGiftTypes}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
@ -254,7 +262,8 @@ function GiftStatusSwitch({ gift, page }) {
|
||||
}
|
||||
|
||||
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
|
||||
const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions;
|
||||
const submitDisabled =
|
||||
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
|
||||
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
|
||||
return (
|
||||
<AdminFormDialog
|
||||
@ -301,16 +310,16 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
</>
|
||||
) : null}
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
disabled={disabled || page.giftTypesLoading}
|
||||
label="礼物类型"
|
||||
required
|
||||
select
|
||||
value={form.giftTypeCode}
|
||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||
>
|
||||
{giftTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
{page.giftTypeOptions.map((item) => (
|
||||
<MenuItem key={item.tabKey} value={item.tabKey}>
|
||||
{item.displayName || item.tabKey}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
@ -645,8 +654,8 @@ function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function giftTypeLabel(value) {
|
||||
return giftTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "普通礼物";
|
||||
function giftTypeLabel(value, options = defaultGiftTypeOptions) {
|
||||
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
|
||||
}
|
||||
|
||||
function chargeAssetLabel(value) {
|
||||
|
||||
@ -141,18 +141,12 @@ export const giftFormSchema = z
|
||||
enabled: z.boolean(),
|
||||
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
||||
giftPointAmount: z.union([z.string(), z.number()]).optional(),
|
||||
giftTypeCode: z.enum([
|
||||
"normal",
|
||||
"cp",
|
||||
"lucky",
|
||||
"super_lucky",
|
||||
"exclusive",
|
||||
"noble",
|
||||
"flag",
|
||||
"activity",
|
||||
"magic",
|
||||
"custom",
|
||||
]),
|
||||
giftTypeCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请选择礼物类型")
|
||||
.max(32, "礼物类型不能超过 32 个字符")
|
||||
.regex(/^[a-z][a-z0-9_]*$/, "礼物类型只能使用小写字母、数字和下划线"),
|
||||
heatValue: z.union([z.string(), z.number()]).optional(),
|
||||
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
||||
presentationJson: z.string().trim().optional(),
|
||||
@ -241,6 +235,27 @@ export const giftFormSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
const giftTypeTabKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Tab Key 不能为空")
|
||||
.max(32, "Tab Key 不能超过 32 个字符")
|
||||
.regex(/^[a-z][a-z0-9_]*$/, "Tab Key 只能使用小写字母、数字和下划线");
|
||||
|
||||
export const giftTypeConfigFormSchema = z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
displayName: z.string().trim().min(1, "请输入显示名称").max(64, "显示名称不能超过 64 个字符"),
|
||||
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||
tabKey: giftTypeTabKeySchema,
|
||||
tabName: z.string().trim().min(1, "请输入 Tab Name").max(32, "Tab Name 不能超过 32 个字符"),
|
||||
}),
|
||||
)
|
||||
.min(1, "请至少保留一个礼物类型"),
|
||||
});
|
||||
|
||||
function datetimeLocalToMs(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) {
|
||||
|
||||
152
src/features/seven-day-checkin/api.ts
Normal file
152
src/features/seven-day-checkin/api.ts
Normal file
@ -0,0 +1,152 @@
|
||||
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 SevenDayCheckInRewardDto {
|
||||
dayIndex: number;
|
||||
resourceGroupId: number;
|
||||
}
|
||||
|
||||
export interface SevenDayCheckInConfigDto {
|
||||
appCode?: string;
|
||||
enabled: boolean;
|
||||
configVersion: number;
|
||||
rewards: SevenDayCheckInRewardDto[];
|
||||
updatedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface SevenDayCheckInConfigPayload {
|
||||
enabled: boolean;
|
||||
rewards: Array<{
|
||||
day_index: number;
|
||||
resource_group_id: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SevenDayCheckInClaimUserDto {
|
||||
userId?: number;
|
||||
displayUserId?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface SevenDayCheckInClaimDto {
|
||||
claimId: string;
|
||||
commandId?: string;
|
||||
userId: number;
|
||||
user?: SevenDayCheckInClaimUserDto;
|
||||
cycleNo: number;
|
||||
checkinDay?: string;
|
||||
dayIndex: number;
|
||||
configVersion: number;
|
||||
resourceGroupId: number;
|
||||
walletCommandId?: string;
|
||||
walletGrantId?: string;
|
||||
status?: string;
|
||||
failureReason?: string;
|
||||
signedAtMs?: number;
|
||||
grantedAtMs?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
type RawReward = SevenDayCheckInRewardDto & Record<string, unknown>;
|
||||
type RawConfig = Omit<SevenDayCheckInConfigDto, "rewards"> & { rewards?: RawReward[] } & Record<string, unknown>;
|
||||
type RawClaim = SevenDayCheckInClaimDto & Record<string, unknown>;
|
||||
|
||||
export function getSevenDayCheckInConfig(): Promise<SevenDayCheckInConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getSevenDayCheckInConfig;
|
||||
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getSevenDayCheckInConfig), {
|
||||
method: endpoint.method,
|
||||
}).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function updateSevenDayCheckInConfig(payload: SevenDayCheckInConfigPayload): Promise<SevenDayCheckInConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.updateSevenDayCheckInConfig;
|
||||
return apiRequest<RawConfig, SevenDayCheckInConfigPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateSevenDayCheckInConfig),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function listSevenDayCheckInClaims(query: PageQuery = {}): Promise<ApiPage<SevenDayCheckInClaimDto>> {
|
||||
const endpoint = API_ENDPOINTS.listSevenDayCheckInClaims;
|
||||
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listSevenDayCheckInClaims), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeClaim),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfig(item: RawConfig): SevenDayCheckInConfigDto {
|
||||
return {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
enabled: Boolean(item.enabled),
|
||||
configVersion: numberValue(item.configVersion ?? item.config_version),
|
||||
rewards: normalizeRewards(item.rewards),
|
||||
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 normalizeRewards(rewards?: RawReward[]) {
|
||||
const byDay = new Map<number, SevenDayCheckInRewardDto>();
|
||||
for (const reward of rewards || []) {
|
||||
const dayIndex = numberValue(reward.dayIndex ?? reward.day_index);
|
||||
if (dayIndex >= 1 && dayIndex <= 7) {
|
||||
byDay.set(dayIndex, {
|
||||
dayIndex,
|
||||
resourceGroupId: numberValue(reward.resourceGroupId ?? reward.resource_group_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const dayIndex = index + 1;
|
||||
return byDay.get(dayIndex) || { dayIndex, resourceGroupId: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeClaim(item: RawClaim): SevenDayCheckInClaimDto {
|
||||
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||
return {
|
||||
claimId: stringValue(item.claimId ?? item.claim_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),
|
||||
},
|
||||
cycleNo: numberValue(item.cycleNo ?? item.cycle_no),
|
||||
checkinDay: stringValue(item.checkinDay ?? item.checkin_day),
|
||||
dayIndex: numberValue(item.dayIndex ?? item.day_index),
|
||||
configVersion: numberValue(item.configVersion ?? item.config_version),
|
||||
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
|
||||
status: stringValue(item.status),
|
||||
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||
signedAtMs: numberValue(item.signedAtMs ?? item.signed_at_ms),
|
||||
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
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/seven-day-checkin/seven-day-checkin.module.css";
|
||||
|
||||
export function SevenDayCheckInConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
configSaving,
|
||||
form,
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
resourceGroups,
|
||||
setForm,
|
||||
}) {
|
||||
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||
|
||||
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>
|
||||
<div className="form-drawer__grid">
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label="七日签到状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">奖励内容</div>
|
||||
<div className={styles.rewardEditor}>
|
||||
{form.rewards.map((reward, index) => (
|
||||
<div className={styles.rewardEditorRow} key={reward.dayIndex}>
|
||||
<span className={styles.dayLabel}>第 {reward.dayIndex} 天</span>
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled}
|
||||
groups={resourceGroups}
|
||||
value={reward.resourceGroupId}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rewards: current.rewards.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, resourceGroupId: value } : item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="form-drawer__actions">
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import styles from "@/features/seven-day-checkin/seven-day-checkin.module.css";
|
||||
|
||||
export function SevenDayCheckInConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh, resourceGroups }) {
|
||||
return (
|
||||
<section className={styles.summaryPanel}>
|
||||
<div className={styles.summaryScrollArea}>
|
||||
<div className={styles.summaryContent}>
|
||||
<div className={styles.summaryHeader}>
|
||||
<span className={styles.title}>七日签到配置</span>
|
||||
</div>
|
||||
<div className={styles.summaryItems}>
|
||||
<ConfigStatus config={config} loading={configLoading} />
|
||||
<SummaryItem label="更新时间">
|
||||
{config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"}
|
||||
</SummaryItem>
|
||||
</div>
|
||||
<RewardPreview config={config} loading={configLoading} resourceGroups={resourceGroups} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<Button
|
||||
disabled={!canUpdate || configLoading}
|
||||
startIcon={<EditOutlined fontSize="small" />}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={onEdit}
|
||||
>
|
||||
编辑配置
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ children, label }) {
|
||||
return (
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>{label}</span>
|
||||
<span className={styles.summaryValue}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigStatus({ config, loading }) {
|
||||
if (!config) {
|
||||
return (
|
||||
<span className="status-badge status-badge--stopped">
|
||||
<span className="status-point" />
|
||||
{loading ? "加载中" : "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={["status-badge", config.enabled ? "status-badge--running" : "status-badge--stopped"].join(" ")}
|
||||
>
|
||||
<span className="status-point" />
|
||||
{config.enabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RewardPreview({ config, loading, resourceGroups }) {
|
||||
if (loading && !config) {
|
||||
return <div className={styles.rewardPreview}>加载中</div>;
|
||||
}
|
||||
const rewards = config?.rewards || [];
|
||||
return (
|
||||
<div className={styles.rewardPreview}>
|
||||
{Array.from({ length: 7 }, (_, index) => {
|
||||
const dayIndex = index + 1;
|
||||
const reward = rewards.find((item) => Number(item.dayIndex) === dayIndex);
|
||||
return (
|
||||
<span className={styles.rewardPill} key={dayIndex}>
|
||||
<span>第 {dayIndex} 天</span>
|
||||
<span>{resourceGroupName(resourceGroups, reward?.resourceGroupId)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resourceGroupName(groups, id) {
|
||||
if (!id) {
|
||||
return "未配置";
|
||||
}
|
||||
const group = groups.find((item) => Number(item.groupId) === Number(id));
|
||||
return group?.name || group?.groupCode || `#${id}`;
|
||||
}
|
||||
183
src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js
Normal file
183
src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js
Normal file
@ -0,0 +1,183 @@
|
||||
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 { listResourceGroups } from "@/features/resources/api";
|
||||
import {
|
||||
getSevenDayCheckInConfig,
|
||||
listSevenDayCheckInClaims,
|
||||
updateSevenDayCheckInConfig,
|
||||
} from "@/features/seven-day-checkin/api";
|
||||
import { useSevenDayCheckInAbilities } from "@/features/seven-day-checkin/permissions.js";
|
||||
import { sevenDayCheckInConfigFormSchema } from "@/features/seven-day-checkin/schema";
|
||||
|
||||
const pageSize = 10;
|
||||
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
||||
|
||||
const emptyForm = {
|
||||
enabled: false,
|
||||
rewards: Array.from({ length: 7 }, (_, index) => ({
|
||||
dayIndex: index + 1,
|
||||
resourceGroupId: "",
|
||||
})),
|
||||
};
|
||||
|
||||
export function useSevenDayCheckInPage() {
|
||||
const abilities = useSevenDayCheckInAbilities();
|
||||
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 [checkinDay, setCheckinDay] = useState("");
|
||||
const [dayIndex, setDayIndex] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(
|
||||
() => ({ checkin_day: checkinDay, day_index: dayIndex, keyword: query, status }),
|
||||
[checkinDay, dayIndex, query, status],
|
||||
);
|
||||
const {
|
||||
data: claims = emptyClaims,
|
||||
error: claimsError,
|
||||
loading: claimsLoading,
|
||||
reload: reloadClaims,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载签到记录失败",
|
||||
fetcher: listSevenDayCheckInClaims,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["seven-day-checkin-claims", filters, page],
|
||||
});
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const [config, groups] = await Promise.all([
|
||||
getSevenDayCheckInConfig(),
|
||||
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 changeCheckinDay = (value) => {
|
||||
setCheckinDay(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeDayIndex = (value) => {
|
||||
setDayIndex(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
return;
|
||||
}
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const parsed = parseForm(sevenDayCheckInConfigFormSchema, form);
|
||||
const saved = await updateSevenDayCheckInConfig(payloadFromForm(parsed));
|
||||
setConfig(saved);
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("七日签到配置已保存", "success");
|
||||
await reloadClaims();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存七日签到配置失败", "error");
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeCheckinDay,
|
||||
changeDayIndex,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
checkinDay,
|
||||
claims,
|
||||
claimsError,
|
||||
claimsLoading,
|
||||
closeConfigDrawer,
|
||||
config,
|
||||
configDrawerOpen,
|
||||
configLoading,
|
||||
configSaving,
|
||||
dayIndex,
|
||||
form,
|
||||
openConfigDrawer,
|
||||
page,
|
||||
query,
|
||||
reloadClaims,
|
||||
reloadConfig,
|
||||
resourceGroups,
|
||||
setForm,
|
||||
setPage,
|
||||
status,
|
||||
submitConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function formFromConfig(config) {
|
||||
return {
|
||||
enabled: Boolean(config?.enabled),
|
||||
rewards: Array.from({ length: 7 }, (_, index) => {
|
||||
const dayIndex = index + 1;
|
||||
const reward = config?.rewards?.find((item) => Number(item.dayIndex) === dayIndex);
|
||||
return {
|
||||
dayIndex,
|
||||
resourceGroupId: reward?.resourceGroupId ? String(reward.resourceGroupId) : "",
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
return {
|
||||
enabled: Boolean(form.enabled),
|
||||
rewards: form.rewards
|
||||
.map((reward) => ({
|
||||
day_index: Number(reward.dayIndex),
|
||||
resource_group_id: Number(reward.resourceGroupId || 0),
|
||||
}))
|
||||
.sort((left, right) => left.day_index - right.day_index),
|
||||
};
|
||||
}
|
||||
230
src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx
Normal file
230
src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx
Normal file
@ -0,0 +1,230 @@
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { SevenDayCheckInConfigDrawer } from "@/features/seven-day-checkin/components/SevenDayCheckInConfigDrawer.jsx";
|
||||
import { SevenDayCheckInConfigSummary } from "@/features/seven-day-checkin/components/SevenDayCheckInConfigSummary.jsx";
|
||||
import { useSevenDayCheckInPage } from "@/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js";
|
||||
import styles from "@/features/seven-day-checkin/seven-day-checkin.module.css";
|
||||
|
||||
const claimStatusOptions = [
|
||||
["pending", "发放中"],
|
||||
["granted", "已发放"],
|
||||
["failed", "发放失败"],
|
||||
];
|
||||
|
||||
const dayOptions = Array.from({ length: 7 }, (_, index) => [String(index + 1), `第 ${index + 1} 天`]);
|
||||
|
||||
const columnsBase = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(260px, 1.15fr)",
|
||||
render: (claim) => <ClaimUser claim={claim} />,
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "签到周期",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>第 {claim.cycleNo || 0} 轮</span>
|
||||
<span className={styles.meta}>{claim.checkinDay || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dayIndex",
|
||||
label: "签到天数",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (claim) => `第 ${claim.dayIndex || 0} 天`,
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "奖励资源组",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim, _index, context) => (
|
||||
<ResourceGroupLabel groups={context.resourceGroups} id={claim.resourceGroupId} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (claim) => <ClaimStatus status={claim.status} />,
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "签到时间",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => formatMillis(claim.signedAtMs || claim.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "grant",
|
||||
label: "钱包发放",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.walletGrantId || "-"}</span>
|
||||
<span className={styles.meta}>{claim.walletCommandId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "claim",
|
||||
label: "签到记录",
|
||||
width: "minmax(240px, 1fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.claimId}</span>
|
||||
<span className={styles.meta}>{claim.commandId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "failure",
|
||||
label: "失败原因",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => claim.failureReason || "-",
|
||||
},
|
||||
];
|
||||
|
||||
export function SevenDayCheckInPage() {
|
||||
const page = useSevenDayCheckInPage();
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "dayIndex") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部天数"], ...dayOptions],
|
||||
placeholder: "搜索天数",
|
||||
value: page.dayIndex,
|
||||
onChange: page.changeDayIndex,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "cycle") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "YYYY-MM-DD",
|
||||
value: page.checkinDay,
|
||||
onChange: page.changeCheckinDay,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<SevenDayCheckInConfigSummary
|
||||
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}
|
||||
context={{ resourceGroups: page.resourceGroups }}
|
||||
items={page.claims.items || []}
|
||||
minWidth="1540px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.claims.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(claim) => claim.claimId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<SevenDayCheckInConfigDrawer
|
||||
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 ResourceGroupLabel({ groups, id }) {
|
||||
const group = groups.find((item) => Number(item.groupId) === Number(id));
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{group?.name || group?.groupCode || (id ? `#${id}` : "-")}</span>
|
||||
<span className={styles.meta}>资源组</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimStatus({ status }) {
|
||||
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{claimStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function claimStatusLabel(status) {
|
||||
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||
}
|
||||
11
src/features/seven-day-checkin/permissions.js
Normal file
11
src/features/seven-day-checkin/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useSevenDayCheckInAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canUpdate: can(PERMISSIONS.sevenDayCheckInUpdate),
|
||||
canView: can(PERMISSIONS.sevenDayCheckInView),
|
||||
};
|
||||
}
|
||||
12
src/features/seven-day-checkin/routes.js
Normal file
12
src/features/seven-day-checkin/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const sevenDayCheckInRoutes = [
|
||||
{
|
||||
label: "七日签到",
|
||||
loader: () => import("./pages/SevenDayCheckInPage.jsx").then((module) => module.SevenDayCheckInPage),
|
||||
menuCode: MENU_CODES.sevenDayCheckIn,
|
||||
pageKey: "seven-day-checkin",
|
||||
path: "/activities/seven-day-checkin",
|
||||
permission: PERMISSIONS.sevenDayCheckInView,
|
||||
},
|
||||
];
|
||||
35
src/features/seven-day-checkin/schema.ts
Normal file
35
src/features/seven-day-checkin/schema.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const resourceGroupIdInput = z.union([z.string(), z.number()]);
|
||||
|
||||
export const sevenDayCheckInConfigFormSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
rewards: z
|
||||
.array(
|
||||
z.object({
|
||||
dayIndex: z.number().int().min(1).max(7),
|
||||
resourceGroupId: resourceGroupIdInput,
|
||||
}),
|
||||
)
|
||||
.length(7, "必须配置 7 天奖励"),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const seen = new Set<number>();
|
||||
for (const reward of value.rewards) {
|
||||
if (seen.has(reward.dayIndex)) {
|
||||
context.addIssue({ code: "custom", message: "签到天数不能重复", path: ["rewards"] });
|
||||
}
|
||||
seen.add(reward.dayIndex);
|
||||
const resourceGroupId = Number(reward.resourceGroupId || 0);
|
||||
if (!Number.isInteger(resourceGroupId) || resourceGroupId <= 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `第 ${reward.dayIndex} 天必须选择资源组`,
|
||||
path: ["rewards", reward.dayIndex - 1, "resourceGroupId"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type SevenDayCheckInConfigForm = z.infer<typeof sevenDayCheckInConfigFormSchema>;
|
||||
183
src/features/seven-day-checkin/seven-day-checkin.module.css
Normal file
183
src/features/seven-day-checkin/seven-day-checkin.module.css
Normal file
@ -0,0 +1,183 @@
|
||||
.summaryPanel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summaryScrollArea {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.summaryContent {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
grid-template-columns: auto auto max-content;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.summaryHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
align-self: stretch;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summaryItems {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
line-height: var(--admin-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
line-height: var(--admin-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rewardPreview {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(7, 124px);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.rewardPill {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.rewardPill span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rewardPill span:first-child {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.rewardPill span:last-child {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rewardEditor {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.rewardEditorRow {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title,
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.summaryPanel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
justify-content: flex-start;
|
||||
padding-top: var(--space-3);
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.summaryContent {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
@ -78,12 +78,14 @@ export const API_OPERATIONS = {
|
||||
exportOperationLogs: "exportOperationLogs",
|
||||
exportUsers: "exportUsers",
|
||||
getCountry: "getCountry",
|
||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||
getRegion: "getRegion",
|
||||
getRegistrationRewardConfig: "getRegistrationRewardConfig",
|
||||
getResource: "getResource",
|
||||
getResourceGroup: "getResourceGroup",
|
||||
getRoleDataScopes: "getRoleDataScopes",
|
||||
getRoomConfig: "getRoomConfig",
|
||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||
getUser: "getUser",
|
||||
grantResource: "grantResource",
|
||||
grantResourceGroup: "grantResourceGroup",
|
||||
@ -102,11 +104,13 @@ export const API_OPERATIONS = {
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
listEmojiPacks: "listEmojiPacks",
|
||||
listGifts: "listGifts",
|
||||
listGiftTypes: "listGiftTypes",
|
||||
listH5Links: "listH5Links",
|
||||
listHosts: "listHosts",
|
||||
listJobs: "listJobs",
|
||||
listLevelConfig: "listLevelConfig",
|
||||
listLoginLogs: "listLoginLogs",
|
||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||
listOperationLogs: "listOperationLogs",
|
||||
listPermissions: "listPermissions",
|
||||
listPlatforms: "listPlatforms",
|
||||
@ -121,6 +125,7 @@ export const API_OPERATIONS = {
|
||||
listRoles: "listRoles",
|
||||
listRoomPins: "listRoomPins",
|
||||
listRooms: "listRooms",
|
||||
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
||||
listSystemMenus: "listSystemMenus",
|
||||
listTaskDefinitions: "listTaskDefinitions",
|
||||
listUsers: "listUsers",
|
||||
@ -150,6 +155,7 @@ export const API_OPERATIONS = {
|
||||
updateCatalog: "updateCatalog",
|
||||
updateCountry: "updateCountry",
|
||||
updateGift: "updateGift",
|
||||
updateGiftType: "updateGiftType",
|
||||
updateH5Links: "updateH5Links",
|
||||
updateMenu: "updateMenu",
|
||||
updateMenuVisible: "updateMenuVisible",
|
||||
@ -164,6 +170,7 @@ export const API_OPERATIONS = {
|
||||
updateRole: "updateRole",
|
||||
updateRoom: "updateRoom",
|
||||
updateRoomConfig: "updateRoomConfig",
|
||||
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
||||
updateTaskDefinition: "updateTaskDefinition",
|
||||
updateTier: "updateTier",
|
||||
updateTrack: "updateTrack",
|
||||
@ -171,6 +178,7 @@ export const API_OPERATIONS = {
|
||||
updateUserStatus: "updateUserStatus",
|
||||
uploadFile: "uploadFile",
|
||||
uploadImage: "uploadImage",
|
||||
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
|
||||
upsertRule: "upsertRule"
|
||||
} as const;
|
||||
|
||||
@ -630,6 +638,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "country:view",
|
||||
permissions: ["country:view"]
|
||||
},
|
||||
getLuckyGiftConfig: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getLuckyGiftConfig,
|
||||
path: "/v1/admin/activity/lucky-gifts/config",
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
getRegion: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getRegion,
|
||||
@ -672,6 +687,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "room-config:view",
|
||||
permissions: ["room-config:view"]
|
||||
},
|
||||
getSevenDayCheckInConfig: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
||||
path: "/v1/admin/activity/seven-day-checkin/config",
|
||||
permission: "seven-day-checkin:view",
|
||||
permissions: ["seven-day-checkin:view"]
|
||||
},
|
||||
getUser: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getUser,
|
||||
@ -796,6 +818,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "gift:view",
|
||||
permissions: ["gift:view"]
|
||||
},
|
||||
listGiftTypes: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listGiftTypes,
|
||||
path: "/v1/admin/gift-types",
|
||||
permission: "gift:view",
|
||||
permissions: ["gift:view"]
|
||||
},
|
||||
listH5Links: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listH5Links,
|
||||
@ -831,6 +860,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "log:view",
|
||||
permissions: ["log:view"]
|
||||
},
|
||||
listLuckyGiftDraws: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLuckyGiftDraws,
|
||||
path: "/v1/admin/activity/lucky-gifts/draws",
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
listOperationLogs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listOperationLogs,
|
||||
@ -929,6 +965,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "room:view",
|
||||
permissions: ["room:view"]
|
||||
},
|
||||
listSevenDayCheckInClaims: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSevenDayCheckInClaims,
|
||||
path: "/v1/admin/activity/seven-day-checkin/claims",
|
||||
permission: "seven-day-checkin:view",
|
||||
permissions: ["seven-day-checkin:view"]
|
||||
},
|
||||
listSystemMenus: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSystemMenus,
|
||||
@ -1120,6 +1163,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "gift:update",
|
||||
permissions: ["gift:update"]
|
||||
},
|
||||
updateGiftType: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateGiftType,
|
||||
path: "/v1/admin/gift-types/{type_code}",
|
||||
permission: "gift:update",
|
||||
permissions: ["gift:update"]
|
||||
},
|
||||
updateH5Links: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateH5Links,
|
||||
@ -1218,6 +1268,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "room-config:update",
|
||||
permissions: ["room-config:update"]
|
||||
},
|
||||
updateSevenDayCheckInConfig: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
||||
path: "/v1/admin/activity/seven-day-checkin/config",
|
||||
permission: "seven-day-checkin:update",
|
||||
permissions: ["seven-day-checkin:update"]
|
||||
},
|
||||
updateTaskDefinition: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateTaskDefinition,
|
||||
@ -1267,6 +1324,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "upload:create",
|
||||
permissions: ["upload:create"]
|
||||
},
|
||||
upsertLuckyGiftConfig: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.upsertLuckyGiftConfig,
|
||||
path: "/v1/admin/activity/lucky-gifts/config",
|
||||
permission: "lucky-gift:update",
|
||||
permissions: ["lucky-gift:update"]
|
||||
},
|
||||
upsertRule: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.upsertRule,
|
||||
|
||||
194
src/shared/api/generated/schema.d.ts
vendored
194
src/shared/api/generated/schema.d.ts
vendored
@ -84,6 +84,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/lucky-gifts/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getLuckyGiftConfig"];
|
||||
put: operations["upsertLuckyGiftConfig"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/lucky-gifts/draws": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listLuckyGiftDraws"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/registration-reward/claims": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -116,6 +148,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/seven-day-checkin/claims": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listSevenDayCheckInClaims"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/seven-day-checkin/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getSevenDayCheckInConfig"];
|
||||
put: operations["updateSevenDayCheckInConfig"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/agencies": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -596,6 +660,38 @@ export interface paths {
|
||||
patch: operations["updatePlatform"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/gift-types": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listGiftTypes"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/gift-types/{type_code}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["updateGiftType"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/gifts": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2384,6 +2480,42 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getLuckyGiftConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
upsertLuckyGiftConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listLuckyGiftDraws: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRegistrationRewardClaims: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2420,6 +2552,42 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listSevenDayCheckInClaims: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getSevenDayCheckInConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateSevenDayCheckInConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listAgencies: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@ -3018,6 +3186,32 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listGiftTypes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateGiftType: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
type_code: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listGifts: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -17,6 +17,7 @@ export interface PageQuery {
|
||||
agency_id?: EntityId;
|
||||
agencyId?: EntityId;
|
||||
enabled?: boolean;
|
||||
displayScope?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
parent_bd_user_id?: EntityId;
|
||||
@ -631,12 +632,16 @@ export interface AppBannerDto {
|
||||
coverUrl: string;
|
||||
createdAtMs?: number;
|
||||
description?: string;
|
||||
displayScope?: "home" | "room" | "recharge" | string;
|
||||
endsAtMs?: number;
|
||||
id: number;
|
||||
param?: string;
|
||||
platform: "android" | "ios" | string;
|
||||
regionId?: number;
|
||||
roomSmallImageUrl?: string;
|
||||
sortOrder?: number;
|
||||
status: "active" | "disabled" | string;
|
||||
startsAtMs?: number;
|
||||
status: "active" | "disabled" | "expired" | string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
@ -645,11 +650,15 @@ export interface AppBannerPayload {
|
||||
countryCode?: string;
|
||||
coverUrl: string;
|
||||
description?: string;
|
||||
displayScope?: "home" | "room" | "recharge";
|
||||
endsAtMs?: number;
|
||||
param?: string;
|
||||
platform: "android" | "ios";
|
||||
regionId?: number;
|
||||
roomSmallImageUrl?: string;
|
||||
sortOrder?: number;
|
||||
status: "active" | "disabled";
|
||||
startsAtMs?: number;
|
||||
status: "active" | "disabled" | "expired";
|
||||
}
|
||||
|
||||
export interface AgencyMembershipDto {
|
||||
|
||||
@ -227,8 +227,8 @@ export function DataTable({
|
||||
<HeaderLabel
|
||||
column={column}
|
||||
trailingAction={
|
||||
isLastColumn && hasColumnFilters ? (
|
||||
<TableFilterResetButton disabled={!hasActiveColumnFilters} onClick={resetColumnFilters} />
|
||||
isLastColumn && hasColumnFilters && hasActiveColumnFilters ? (
|
||||
<TableFilterResetButton onClick={resetColumnFilters} />
|
||||
) : null
|
||||
}
|
||||
onOpenFilter={openColumnFilter}
|
||||
@ -375,13 +375,12 @@ function FilterHeaderButton({ column, onOpenFilter }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TableFilterResetButton({ disabled, onClick }) {
|
||||
function TableFilterResetButton({ onClick }) {
|
||||
return (
|
||||
<Tooltip arrow title="重置筛选">
|
||||
<span className="admin-table-filter-reset-wrap">
|
||||
<IconButton
|
||||
className="admin-table-filter-reset"
|
||||
disabled={disabled}
|
||||
label="重置筛选"
|
||||
sx={tableFilterResetSx}
|
||||
onClick={(event) => {
|
||||
|
||||
@ -27,6 +27,40 @@ test("rerenders table timestamp cells when display timezone changes", async () =
|
||||
expect(screen.getByText("2026-05-09 19:21:00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("only shows the table filter reset action when filters are active", () => {
|
||||
const columns = [
|
||||
{
|
||||
filter: { onChange: () => {}, type: "text", value: "" },
|
||||
key: "name",
|
||||
label: "名称"
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作"
|
||||
}
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<TimeZoneProvider>
|
||||
<DataTable columns={columns} items={[{ id: 1, name: "Banner" }]} rowKey={(item) => item.id} />
|
||||
</TimeZoneProvider>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "重置筛选" })).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TimeZoneProvider>
|
||||
<DataTable
|
||||
columns={[{ ...columns[0], filter: { ...columns[0].filter, value: "Banner" } }, columns[1]]}
|
||||
items={[{ id: 1, name: "Banner" }]}
|
||||
rowKey={(item) => item.id}
|
||||
/>
|
||||
</TimeZoneProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "重置筛选" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function TimezoneTableFixture() {
|
||||
const { setTimeZone } = useTimeZone();
|
||||
const columns = [
|
||||
|
||||
197
src/shared/ui/ResourceGroupSelectDrawer.jsx
Normal file
197
src/shared/ui/ResourceGroupSelectDrawer.jsx
Normal file
@ -0,0 +1,197 @@
|
||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||
import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useState } from "react";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import styles from "@/shared/ui/ResourceGroupSelectDrawer.module.css";
|
||||
|
||||
export function ResourceGroupSelectField({
|
||||
allowEmpty = false,
|
||||
disabled = false,
|
||||
drawerTitle = "选择资源组",
|
||||
emptyLabel = "不配置",
|
||||
groups = [],
|
||||
label = "资源组",
|
||||
onChange = () => {},
|
||||
placeholder = "请选择资源组",
|
||||
value,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const normalizedValue = String(value || "");
|
||||
const selectedGroup = groups.find((group) => String(group.groupId) === normalizedValue);
|
||||
const displayValue = selectedGroup
|
||||
? resourceGroupName(selectedGroup)
|
||||
: normalizedValue
|
||||
? `资源组 #${normalizedValue}`
|
||||
: "";
|
||||
|
||||
const openDrawer = () => {
|
||||
if (!disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectGroup = (nextValue, group = null) => {
|
||||
onChange(nextValue, group);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
className={styles.field}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
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}
|
||||
open={open}
|
||||
title={drawerTitle}
|
||||
width="wide"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<div className={styles.groupGrid}>
|
||||
{allowEmpty ? (
|
||||
<button
|
||||
className={[
|
||||
styles.groupCard,
|
||||
styles.emptyCard,
|
||||
normalizedValue ? "" : styles.groupCardSelected,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
onClick={() => selectGroup("")}
|
||||
>
|
||||
<span className={styles.groupIcon}>
|
||||
<CategoryOutlined fontSize="small" />
|
||||
</span>
|
||||
<span className={styles.groupName}>{emptyLabel}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{groups.map((group) => {
|
||||
const groupValue = String(group.groupId);
|
||||
const selected = groupValue === normalizedValue;
|
||||
return (
|
||||
<button
|
||||
className={[styles.groupCard, selected ? styles.groupCardSelected : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={group.groupId}
|
||||
type="button"
|
||||
onClick={() => selectGroup(groupValue, group)}
|
||||
>
|
||||
<GroupIcon group={group} />
|
||||
<span className={styles.groupName}>{resourceGroupName(group)}</span>
|
||||
<span className={styles.groupMeta}>{group.groupCode || `ID ${group.groupId}`}</span>
|
||||
<GroupItems group={group} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!groups.length && !allowEmpty ? <div className={styles.emptyState}>当前无数据</div> : null}
|
||||
</div>
|
||||
</SideDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupIcon({ group }) {
|
||||
const item = firstGroupItem(group);
|
||||
const imageUrl = item?.resource?.previewUrl || item?.resource?.assetUrl || item?.resource?.animationUrl;
|
||||
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<span className={styles.groupIcon}>
|
||||
<img alt="" src={imageUrl} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item?.itemType === "wallet_asset" && item.walletAssetType === "COIN") {
|
||||
return (
|
||||
<span className={styles.groupIcon}>
|
||||
<MonetizationOnOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item?.itemType === "wallet_asset" && item.walletAssetType === "DIAMOND") {
|
||||
return (
|
||||
<span className={styles.groupIcon}>
|
||||
<DiamondOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item?.itemType === "resource") {
|
||||
return (
|
||||
<span className={styles.groupIcon}>
|
||||
<Inventory2Outlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.groupIcon}>
|
||||
<CategoryOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupItems({ group }) {
|
||||
const items = (group.items || []).slice(0, 3);
|
||||
if (!items.length) {
|
||||
return <span className={styles.groupMeta}>无资源条目</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.groupItems}>
|
||||
{items.map((item, index) => (
|
||||
<span className={styles.groupItem} key={item.groupItemId || `${item.itemType}-${index}`}>
|
||||
{groupItemLabel(item)}
|
||||
</span>
|
||||
))}
|
||||
{(group.items || []).length > items.length ? (
|
||||
<span className={styles.groupItem}>+{(group.items || []).length - items.length}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function firstGroupItem(group) {
|
||||
return (
|
||||
(group.items || []).find((item) => item?.resource?.previewUrl || item?.resource?.assetUrl) || group.items?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
function groupItemLabel(item) {
|
||||
if (item.itemType === "wallet_asset") {
|
||||
const label =
|
||||
item.walletAssetType === "DIAMOND" ? "钻石" : item.walletAssetType === "COIN" ? "金币" : "钱包资产";
|
||||
return `${label} ${formatNumber(item.walletAssetAmount)}`;
|
||||
}
|
||||
|
||||
return item.resource?.name || item.resource?.resourceCode || `资源 ${item.resourceId || "-"}`;
|
||||
}
|
||||
|
||||
function resourceGroupName(group) {
|
||||
return group.name || group.groupCode || `资源组 #${group.groupId}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
151
src/shared/ui/ResourceGroupSelectDrawer.module.css
Normal file
151
src/shared/ui/ResourceGroupSelectDrawer.module.css
Normal file
@ -0,0 +1,151 @@
|
||||
.field :global(.MuiInputBase-input:not(:disabled)) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer.drawer {
|
||||
width: min(760px, 100vw);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.drawerBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.groupGrid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
||||
gap: var(--space-3);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.groupCard {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 136px;
|
||||
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);
|
||||
}
|
||||
|
||||
.groupCard:hover {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.groupCard:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.groupCardSelected,
|
||||
.groupCardSelected:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-surface-strong);
|
||||
box-shadow: inset 0 0 0 1px var(--primary-border);
|
||||
}
|
||||
|
||||
.emptyCard {
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.groupIcon {
|
||||
display: inline-flex;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
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);
|
||||
}
|
||||
|
||||
.groupIcon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.groupName,
|
||||
.groupMeta,
|
||||
.groupItem {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupName {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.groupMeta {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.groupItems {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.groupItem {
|
||||
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;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
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) {
|
||||
.groupGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.groupCard {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.groupCard:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ const quickRanges = [
|
||||
];
|
||||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
|
||||
export function TimeRangeFilter({ className = "", label = "时间", onChange, value }) {
|
||||
export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value }) {
|
||||
const range = useMemo(() => normalizeRange(value), [value]);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [draft, setDraft] = useState(() => range);
|
||||
@ -41,6 +41,9 @@ export function TimeRangeFilter({ className = "", label = "时间", onChange, va
|
||||
const displayLabel = useMemo(() => timeRangeLabel(label, range), [label, range]);
|
||||
|
||||
const openPopover = (event) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
setDraft(range);
|
||||
setActiveField("start");
|
||||
setViewDate(dateForField(range, "start", new Date()));
|
||||
@ -111,6 +114,7 @@ export function TimeRangeFilter({ className = "", label = "时间", onChange, va
|
||||
startIcon={<AccessTimeOutlined fontSize="small" />}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled}
|
||||
onClick={openPopover}
|
||||
>
|
||||
<span className={styles.triggerText}>{displayLabel}</span>
|
||||
@ -126,7 +130,11 @@ export function TimeRangeFilter({ className = "", label = "时间", onChange, va
|
||||
<form className={styles.panel} onSubmit={submitDraft}>
|
||||
<div className={styles.quickRanges}>
|
||||
{quickRanges.map(([quickLabel, durationMs]) => (
|
||||
<Button key={quickLabel} className={styles.quickButton} onClick={() => applyQuickRange(durationMs)}>
|
||||
<Button
|
||||
key={quickLabel}
|
||||
className={styles.quickButton}
|
||||
onClick={() => applyQuickRange(durationMs)}
|
||||
>
|
||||
{quickLabel}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user