diff --git a/AGENTS.md b/AGENTS.md index 9cf84e4..0944fb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 预览。 diff --git a/README.md b/README.md index d8e4603..a08aec5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ pnpm build - `FilterChip` - `KpiCard` - `SegmentControl` +- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段使用 `ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,选择后自动回填。 - 布局组件放在 `src/components/layout/`: - `Header` - `Sidebar` diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 69d4e2a..6fa36a6 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 291b75e..dcb7777 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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 }), ], }, { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index b56a5c9..4b84f6f 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index bcf26be..1606ade 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -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, diff --git a/src/features/achievements/pages/AchievementConfigPage.jsx b/src/features/achievements/pages/AchievementConfigPage.jsx index 3034725..0b5b5cb 100644 --- a/src/features/achievements/pages/AchievementConfigPage.jsx +++ b/src/features/achievements/pages/AchievementConfigPage.jsx @@ -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")} /> - page.setForm((current) => ({ ...current, rewardResourceGroupId: value }))} /> - 不配置 - {options.map((group) => ( - - {group.name || group.groupCode || group.groupId} - - ))} - - ); -} - function ResourceLabel({ id, resources }) { if (!id) { return 未配置; @@ -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 || "-"; } diff --git a/src/features/app-config/constants.ts b/src/features/app-config/constants.ts index 8fc3a99..5eacfb7 100644 --- a/src/features/app-config/constants.ts +++ b/src/features/app-config/constants.ts @@ -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"]; diff --git a/src/features/app-config/hooks/useBannerConfigPage.js b/src/features/app-config/hooks/useBannerConfigPage.js index ac6ffce..cdcbca2 100644 --- a/src/features/app-config/hooks/useBannerConfigPage.js +++ b/src/features/app-config/hooks/useBannerConfigPage.js @@ -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", }; } diff --git a/src/features/app-config/pages/BannerConfigPage.jsx b/src/features/app-config/pages/BannerConfigPage.jsx index c6335b9..65ea4aa 100644 --- a/src/features/app-config/pages/BannerConfigPage.jsx +++ b/src/features/app-config/pages/BannerConfigPage.jsx @@ -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() { /> - item.id} /> + item.id} + />
{items.length} 条
@@ -82,12 +96,24 @@ export function BannerConfigPage() { onSubmit={page.submitBanner} > - page.setForm({ coverUrl })} - /> + + page.setForm({ coverUrl })} + /> + {page.form.displayScope === "room" ? ( + page.setForm({ roomSmallImageUrl })} + /> + ) : null} + @@ -125,6 +151,26 @@ export function BannerConfigPage() { + { + const displayScope = event.target.value; + page.setForm({ + displayScope, + roomSmallImageUrl: displayScope === "room" ? page.form.roomSmallImageUrl : "", + }); + }} + > + {APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => ( + + {option.label} + + ))} + page.setForm({ sortOrder: event.target.value })} /> + page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs })} + /> page.setForm({ status: checked ? "active" : "disabled" })} /> @@ -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 ( - {status === "active" ? "启用" : "关闭"} + {statusLabel(status)} ); } @@ -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 "全部区域"; diff --git a/src/features/app-config/schema.test.ts b/src/features/app-config/schema.test.ts new file mode 100644 index 0000000..977de83 --- /dev/null +++ b/src/features/app-config/schema.test.ts @@ -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); + }); +}); diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts index 5385c47..73e1d91 100644 --- a/src/features/app-config/schema.ts +++ b/src/features/app-config/schema.ts @@ -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; -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; 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; diff --git a/src/features/level-config/pages/LevelConfigPage.jsx b/src/features/level-config/pages/LevelConfigPage.jsx index ef4a83a..c56afaa 100644 --- a/src/features/level-config/pages/LevelConfigPage.jsx +++ b/src/features/level-config/pages/LevelConfigPage.jsx @@ -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 }) {
- {track.name || levelTrackLabels[track.track] || track.track} + + {track.name || levelTrackLabels[track.track] || track.track} +
{track.track} @@ -272,7 +266,9 @@ function buildTierColumns(page) { key: "frame", label: "头像框", width: "minmax(200px, 0.9fr)", - render: (tier) => , + render: (tier) => ( + + ), }, { key: "badge", @@ -457,12 +453,15 @@ function RuleFormDialog({ page }) { value={form.longBadgeResourceId} onChange={changeField(page.setRuleForm, "longBadgeResourceId")} /> - + page.setRuleForm((current) => ({ ...current, rewardResourceGroupId: value })) + } /> - + page.setTierForm((current) => ({ ...current, rewardResourceGroupId: value })) + } /> - 不配置 - {options.map((group) => ( - - {group.name || group.groupCode || group.groupId} - - ))} - - ); -} - function ResourceLabel({ id, resources }) { if (!id) { return 未配置; @@ -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 || "-"; } diff --git a/src/features/lucky-gift/api.ts b/src/features/lucky-gift/api.ts new file mode 100644 index 0000000..4240173 --- /dev/null +++ b/src/features/lucky-gift/api.ts @@ -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; +type RawTier = LuckyGiftTierDto & Record; +type RawDraw = LuckyGiftDrawDto & Record; + +export function getLuckyGiftConfig(): Promise { + const endpoint = API_ENDPOINTS.getLuckyGiftConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise { + const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeConfig); +} + +export function listLuckyGiftDraws(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listLuckyGiftDraws; + return apiRequest>(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 { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +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; +} diff --git a/src/features/lucky-gift/components/FieldLabel.jsx b/src/features/lucky-gift/components/FieldLabel.jsx new file mode 100644 index 0000000..6696f43 --- /dev/null +++ b/src/features/lucky-gift/components/FieldLabel.jsx @@ -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 ( + + {text} + {help ? ( + + + + ) : null} + + ); +} diff --git a/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx b/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx new file mode 100644 index 0000000..5f37bbb --- /dev/null +++ b/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx @@ -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 ( + +
+

幸运礼物配置

+
+
基础控制
+
+ } + type="number" + value={form.targetRtpPercent} + onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)} + /> + updateForm(setForm, "controlStrength", value)} + /> + updateForm(setForm, "enabled", event.target.checked)} + /> +
+
+ +
+
体验策略
+
+ updateForm(setForm, "poolMode", value)} + /> + updateForm(setForm, "noviceStrength", value)} + /> + updateForm(setForm, "payoutStyle", value)} + /> + updateForm(setForm, "jackpotStrategy", value)} + /> +
+
+ +
+
保护和补贴
+
+ updateForm(setForm, "riskLevel", value)} + /> + updateForm(setForm, "atmosphereLevel", value)} + /> +
+ updateForm(setForm, "activitySubsidyEnabled", event.target.checked)} + /> + {form.activitySubsidyEnabled ? ( +
+ } + type="number" + value={form.activityBudget} + onChange={(event) => updateForm(setForm, "activityBudget", event.target.value)} + /> + } + type="number" + value={form.activityDailyLimit} + onChange={(event) => updateForm(setForm, "activityDailyLimit", event.target.value)} + /> +
+ ) : null} +
+
+
+ +
+ + +
+
+
+ ); +} + +function SelectField({ disabled, help, label, onChange, options, value }) { + return ( + } + value={value} + onChange={(event) => onChange(event.target.value)} + > + {options.map(([optionValue, optionLabel]) => ( + + {optionLabel} + + ))} + + ); +} + +function SwitchField({ checked, className, disabled, help, label, onChange }) { + return ( +
+ + +
+ ); +} + +function updateForm(setForm, key, value) { + setForm((current) => ({ ...current, [key]: value })); +} diff --git a/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx b/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx new file mode 100644 index 0000000..134ef46 --- /dev/null +++ b/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx @@ -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 ( +
+
+
+
+ 幸运礼物配置 + {luckyGiftConfigScopeLabel} +
+
+ + {formatPercent(config?.targetRTPPPM)} + + {optionLabel(controlStrengthOptions, form.controlStrength)} + + {optionLabel(poolModeOptions, form.poolMode)} + + {optionLabel(noviceStrengthOptions, form.noviceStrength)} + + {optionLabel(payoutStyleOptions, form.payoutStyle)} + + {optionLabel(jackpotStrategyOptions, form.jackpotStrategy)} + + + {optionLabel(atmosphereLevelOptions, form.atmosphereLevel)} + + + {config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"} + +
+
+
+
+ + + + +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +function ConfigStatus({ config, loading }) { + return ( + + + {config?.enabled ? "已开启" : loading ? "加载中" : "已关闭"} + + ); +} + +function formatPercent(ppm) { + const value = Number(ppm || 0) / 10_000; + return `${value.toFixed(2).replace(/\.?0+$/, "")}%`; +} diff --git a/src/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx b/src/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx new file mode 100644 index 0000000..57204ac --- /dev/null +++ b/src/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx @@ -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 ( +
+
+
+ 随机模拟测试 +
+
+ + {csvUrl ? ( + + ) : null} +
+
+
+ updateInput(setInput, "users", value)} + /> + updateInput(setInput, "rooms", value)} + /> + updateInput(setInput, "costMin", value)} + /> + updateInput(setInput, "costMax", value)} + /> + updateInput(setInput, "drawsMin", value)} + /> + updateInput(setInput, "drawsMax", value)} + /> +
+ {result ? : null} +
+ ); +} + +function NumberField({ help, label, min, onChange, value }) { + return ( + } + type="number" + value={value} + onChange={(event) => onChange(event.target.value)} + /> + ); +} + +function SimulationOverview({ overview }) { + return ( +
+ + + + + + + + +
+ ); +} + +function Metric({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +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); +} diff --git a/src/features/lucky-gift/configModel.js b/src/features/lucky-gift/configModel.js new file mode 100644 index 0000000..e3bf170 --- /dev/null +++ b/src/features/lucky-gift/configModel.js @@ -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; +} diff --git a/src/features/lucky-gift/configModel.test.js b/src/features/lucky-gift/configModel.test.js new file mode 100644 index 0000000..8e3cbc8 --- /dev/null +++ b/src/features/lucky-gift/configModel.test.js @@ -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); + }); +}); diff --git a/src/features/lucky-gift/constants.js b/src/features/lucky-gift/constants.js new file mode 100644 index 0000000..7ea0410 --- /dev/null +++ b/src/features/lucky-gift/constants.js @@ -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 || "-"; +} diff --git a/src/features/lucky-gift/hooks/useLuckyGiftPage.js b/src/features/lucky-gift/hooks/useLuckyGiftPage.js new file mode 100644 index 0000000..69bc32b --- /dev/null +++ b/src/features/lucky-gift/hooks/useLuckyGiftPage.js @@ -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, + }; +} diff --git a/src/features/lucky-gift/lucky-gift.module.css b/src/features/lucky-gift/lucky-gift.module.css new file mode 100644 index 0000000..cb5de86 --- /dev/null +++ b/src/features/lucky-gift/lucky-gift.module.css @@ -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); + } +} diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx new file mode 100644 index 0000000..a3a96f6 --- /dev/null +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx @@ -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) => ( +
+ {draw.drawId} + {draw.giftId || draw.commandId || "-"} +
+ ), + }, + { + key: "reward", + label: "奖励", + width: "minmax(180px, 0.8fr)", + render: (draw) => ( +
+ {formatNumber(draw.effectiveRewardCoins)} + {draw.selectedTierId || "-"} +
+ ), + }, + { + key: "pool", + label: "体验池", + width: "minmax(120px, 0.55fr)", + render: (draw) => poolLabel(draw.experiencePool), + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.55fr)", + render: (draw) => , + }, + { + key: "rtp", + label: "窗口 RTP", + width: "minmax(160px, 0.7fr)", + render: (draw) => ( +
+ {formatPPM(draw.globalBaseRTPPPM)} + 整体 {formatPPM(draw.giftBaseRTPPPM)} +
+ ), + }, + { + 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 ( + + + + + page.changeGiftId(event.target.value)} + /> + page.changeUserId(event.target.value)} + /> + page.changeRoomId(event.target.value)} + /> + + + + } + /> + + + 0 + ? { + page: page.page, + pageSize: page.draws.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(draw) => draw.drawId} + /> + + + + + ); +} + +function DrawStatus({ status }) { + const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning"; + return ( + + + {drawStatusOptions.find(([value]) => value === status)?.[1] || status || "-"} + + ); +} + +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)); +} diff --git a/src/features/lucky-gift/permissions.js b/src/features/lucky-gift/permissions.js new file mode 100644 index 0000000..8e58784 --- /dev/null +++ b/src/features/lucky-gift/permissions.js @@ -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), + }; +} diff --git a/src/features/lucky-gift/routes.js b/src/features/lucky-gift/routes.js new file mode 100644 index 0000000..c6ab713 --- /dev/null +++ b/src/features/lucky-gift/routes.js @@ -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, + }, +]; diff --git a/src/features/lucky-gift/schema.ts b/src/features/lucky-gift/schema.ts new file mode 100644 index 0000000..024a2e6 --- /dev/null +++ b/src/features/lucky-gift/schema.ts @@ -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; diff --git a/src/features/lucky-gift/simulation.js b/src/features/lucky-gift/simulation.js new file mode 100644 index 0000000..fb15e87 --- /dev/null +++ b/src/features/lucky-gift/simulation.js @@ -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); +} diff --git a/src/features/lucky-gift/simulation.test.js b/src/features/lucky-gift/simulation.test.js new file mode 100644 index 0000000..f73bd31 --- /dev/null +++ b/src/features/lucky-gift/simulation.test.js @@ -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); + }); +}); diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index e1f3bee..4bf2a29 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -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", + }); +}); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index c60702e..9a2c316 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -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> { }); } +export function listGiftTypes(): Promise { + const endpoint = API_ENDPOINTS.listGiftTypes; + return apiRequest(apiEndpointPath(API_OPERATIONS.listGiftTypes), { + method: endpoint.method, + }); +} + +export function updateGiftType(tabKey: string, payload: GiftTypePayload): Promise { + const endpoint = API_ENDPOINTS.updateGiftType; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateGiftType, { type_code: tabKey }), + { + body: payload, + method: endpoint.method, + }, + ); +} + export function createGift(payload: GiftPayload): Promise { const endpoint = API_ENDPOINTS.createGift; return apiRequest(apiEndpointPath(API_OPERATIONS.createGift), { diff --git a/src/features/resources/components/GiftTypeConfigDialog.jsx b/src/features/resources/components/GiftTypeConfigDialog.jsx new file mode 100644 index 0000000..c3f4dbc --- /dev/null +++ b/src/features/resources/components/GiftTypeConfigDialog.jsx @@ -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 ( + + + + {items.map((item, index) => ( + + + onChangeItem(index, { displayName: event.target.value })} + /> + onChangeItem(index, { tabName: event.target.value })} + /> + onChangeItem(index, { sortOrder: event.target.value })} + /> + onChangeItem(index, { enabled })} + /> + + ))} + + + + ); +} diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 1cdaa08..31cbe8c 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -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", "成功"], diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 71f7988..4af8d92 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -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 ""; diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index 2144ecf..1817c60 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -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) => (
- {giftTypeLabel(gift.giftTypeCode)} + {giftTypeLabel(gift.giftTypeCode, giftTypeOptions)} {chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)} @@ -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() { - - - ) : null + <> + {page.abilities.canUpdateGift ? ( + + + + ) : null} + {page.abilities.canCreateGift ? ( + + + + ) : null} + } /> @@ -223,6 +221,16 @@ export function GiftListPage() { onClose={page.closeAction} onSubmit={page.submitGift} /> + ); } @@ -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 ( ) : null} page.setForm({ ...form, giftTypeCode: event.target.value })} > - {giftTypeOptions.map(([value, label]) => ( - - {label} + {page.giftTypeOptions.map((item) => ( + + {item.displayName || item.tabKey} ))} @@ -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) { diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index e02341b..2956f67 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -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) { diff --git a/src/features/seven-day-checkin/api.ts b/src/features/seven-day-checkin/api.ts new file mode 100644 index 0000000..841e923 --- /dev/null +++ b/src/features/seven-day-checkin/api.ts @@ -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; +type RawConfig = Omit & { rewards?: RawReward[] } & Record; +type RawClaim = SevenDayCheckInClaimDto & Record; + +export function getSevenDayCheckInConfig(): Promise { + const endpoint = API_ENDPOINTS.getSevenDayCheckInConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getSevenDayCheckInConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateSevenDayCheckInConfig(payload: SevenDayCheckInConfigPayload): Promise { + const endpoint = API_ENDPOINTS.updateSevenDayCheckInConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateSevenDayCheckInConfig), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeConfig); +} + +export function listSevenDayCheckInClaims(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listSevenDayCheckInClaims; + return apiRequest>(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(); + 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; + 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; +} diff --git a/src/features/seven-day-checkin/components/SevenDayCheckInConfigDrawer.jsx b/src/features/seven-day-checkin/components/SevenDayCheckInConfigDrawer.jsx new file mode 100644 index 0000000..1e15293 --- /dev/null +++ b/src/features/seven-day-checkin/components/SevenDayCheckInConfigDrawer.jsx @@ -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 ( + +
+

七日签到配置

+
+
活动状态
+
+ setForm((current) => ({ ...current, enabled: event.target.checked }))} + /> +
+
+
+
奖励内容
+
+ {form.rewards.map((reward, index) => ( +
+ 第 {reward.dayIndex} 天 + + setForm((current) => ({ + ...current, + rewards: current.rewards.map((item, itemIndex) => + itemIndex === index ? { ...item, resourceGroupId: value } : item, + ), + })) + } + /> +
+ ))} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/features/seven-day-checkin/components/SevenDayCheckInConfigSummary.jsx b/src/features/seven-day-checkin/components/SevenDayCheckInConfigSummary.jsx new file mode 100644 index 0000000..b4eb9f6 --- /dev/null +++ b/src/features/seven-day-checkin/components/SevenDayCheckInConfigSummary.jsx @@ -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 ( +
+
+
+
+ 七日签到配置 +
+
+ + + {config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"} + +
+ +
+
+
+ + + + +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +function ConfigStatus({ config, loading }) { + if (!config) { + return ( + + + {loading ? "加载中" : "-"} + + ); + } + + return ( + + + {config.enabled ? "已开启" : "已关闭"} + + ); +} + +function RewardPreview({ config, loading, resourceGroups }) { + if (loading && !config) { + return
加载中
; + } + const rewards = config?.rewards || []; + return ( +
+ {Array.from({ length: 7 }, (_, index) => { + const dayIndex = index + 1; + const reward = rewards.find((item) => Number(item.dayIndex) === dayIndex); + return ( + + 第 {dayIndex} 天 + {resourceGroupName(resourceGroups, reward?.resourceGroupId)} + + ); + })} +
+ ); +} + +function resourceGroupName(groups, id) { + if (!id) { + return "未配置"; + } + const group = groups.find((item) => Number(item.groupId) === Number(id)); + return group?.name || group?.groupCode || `#${id}`; +} diff --git a/src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js b/src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js new file mode 100644 index 0000000..33b1872 --- /dev/null +++ b/src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js @@ -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), + }; +} diff --git a/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx b/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx new file mode 100644 index 0000000..04bcb7f --- /dev/null +++ b/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx @@ -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) => , + }, + { + key: "cycle", + label: "签到周期", + width: "minmax(180px, 0.8fr)", + render: (claim) => ( +
+ 第 {claim.cycleNo || 0} 轮 + {claim.checkinDay || "-"} +
+ ), + }, + { + 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) => ( + + ), + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.55fr)", + render: (claim) => , + }, + { + key: "time", + label: "签到时间", + width: "minmax(180px, 0.85fr)", + render: (claim) => formatMillis(claim.signedAtMs || claim.createdAtMs), + }, + { + key: "grant", + label: "钱包发放", + width: "minmax(220px, 1fr)", + render: (claim) => ( +
+ {claim.walletGrantId || "-"} + {claim.walletCommandId || "-"} +
+ ), + }, + { + key: "claim", + label: "签到记录", + width: "minmax(240px, 1fr)", + render: (claim) => ( +
+ {claim.claimId} + {claim.commandId || "-"} +
+ ), + }, + { + 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 ( + + + + + 0 + ? { + page: page.page, + pageSize: page.claims.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(claim) => claim.claimId} + /> + + + + + ); +} + +function ClaimUser({ claim }) { + const user = claim.user || {}; + return ( +
+ +
+ {user.username || `用户 ${claim.userId}`} + + {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`} + +
+
+ ); +} + +function ResourceGroupLabel({ groups, id }) { + const group = groups.find((item) => Number(item.groupId) === Number(id)); + return ( +
+ {group?.name || group?.groupCode || (id ? `#${id}` : "-")} + 资源组 +
+ ); +} + +function ClaimStatus({ status }) { + const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning"; + return ( + + + {claimStatusLabel(status)} + + ); +} + +function claimStatusLabel(status) { + return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-"; +} diff --git a/src/features/seven-day-checkin/permissions.js b/src/features/seven-day-checkin/permissions.js new file mode 100644 index 0000000..c1ac383 --- /dev/null +++ b/src/features/seven-day-checkin/permissions.js @@ -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), + }; +} diff --git a/src/features/seven-day-checkin/routes.js b/src/features/seven-day-checkin/routes.js new file mode 100644 index 0000000..30bad3f --- /dev/null +++ b/src/features/seven-day-checkin/routes.js @@ -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, + }, +]; diff --git a/src/features/seven-day-checkin/schema.ts b/src/features/seven-day-checkin/schema.ts new file mode 100644 index 0000000..97a0ee1 --- /dev/null +++ b/src/features/seven-day-checkin/schema.ts @@ -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(); + 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; diff --git a/src/features/seven-day-checkin/seven-day-checkin.module.css b/src/features/seven-day-checkin/seven-day-checkin.module.css new file mode 100644 index 0000000..895ab25 --- /dev/null +++ b/src/features/seven-day-checkin/seven-day-checkin.module.css @@ -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); + } +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index e27b109..10bdbf7 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 6c4399f..32f1309 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 790e60a..5d49bca 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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 { diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx index 129eda2..cbe3f73 100644 --- a/src/shared/ui/DataTable.jsx +++ b/src/shared/ui/DataTable.jsx @@ -227,8 +227,8 @@ export function DataTable({ + isLastColumn && hasColumnFilters && hasActiveColumnFilters ? ( + ) : null } onOpenFilter={openColumnFilter} @@ -375,13 +375,12 @@ function FilterHeaderButton({ column, onOpenFilter }) { ); } -function TableFilterResetButton({ disabled, onClick }) { +function TableFilterResetButton({ onClick }) { return ( { diff --git a/src/shared/ui/DataTable.timezone.test.jsx b/src/shared/ui/DataTable.timezone.test.jsx index 8af4fe2..dfe7c31 100644 --- a/src/shared/ui/DataTable.timezone.test.jsx +++ b/src/shared/ui/DataTable.timezone.test.jsx @@ -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( + + item.id} /> + + ); + + expect(screen.queryByRole("button", { name: "重置筛选" })).not.toBeInTheDocument(); + + rerender( + + item.id} + /> + + ); + + expect(screen.getByRole("button", { name: "重置筛选" })).toBeInTheDocument(); +}); + function TimezoneTableFixture() { const { setTimeZone } = useTimeZone(); const columns = [ diff --git a/src/shared/ui/ResourceGroupSelectDrawer.jsx b/src/shared/ui/ResourceGroupSelectDrawer.jsx new file mode 100644 index 0000000..ee0a771 --- /dev/null +++ b/src/shared/ui/ResourceGroupSelectDrawer.jsx @@ -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 ( + <> + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDrawer(); + } + }} + /> + setOpen(false)} + > +
+ {allowEmpty ? ( + + ) : null} + {groups.map((group) => { + const groupValue = String(group.groupId); + const selected = groupValue === normalizedValue; + return ( + + ); + })} + {!groups.length && !allowEmpty ?
当前无数据
: null} +
+
+ + ); +} + +function GroupIcon({ group }) { + const item = firstGroupItem(group); + const imageUrl = item?.resource?.previewUrl || item?.resource?.assetUrl || item?.resource?.animationUrl; + + if (imageUrl) { + return ( + + + + ); + } + + if (item?.itemType === "wallet_asset" && item.walletAssetType === "COIN") { + return ( + + + + ); + } + + if (item?.itemType === "wallet_asset" && item.walletAssetType === "DIAMOND") { + return ( + + + + ); + } + + if (item?.itemType === "resource") { + return ( + + + + ); + } + + return ( + + + + ); +} + +function GroupItems({ group }) { + const items = (group.items || []).slice(0, 3); + if (!items.length) { + return 无资源条目; + } + + return ( + + {items.map((item, index) => ( + + {groupItemLabel(item)} + + ))} + {(group.items || []).length > items.length ? ( + +{(group.items || []).length - items.length} + ) : null} + + ); +} + +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"); +} diff --git a/src/shared/ui/ResourceGroupSelectDrawer.module.css b/src/shared/ui/ResourceGroupSelectDrawer.module.css new file mode 100644 index 0000000..bcc84d2 --- /dev/null +++ b/src/shared/ui/ResourceGroupSelectDrawer.module.css @@ -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; + } +} diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index f1857b5..e681ce2 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -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={} aria-expanded={open} aria-haspopup="dialog" + disabled={disabled} onClick={openPopover} > {displayLabel} @@ -126,7 +130,11 @@ export function TimeRangeFilter({ className = "", label = "时间", onChange, va
{quickRanges.map(([quickLabel, durationMs]) => ( - ))}