完善后台功能

This commit is contained in:
zhx 2026-05-19 18:04:08 +08:00
parent bdeb3cd15a
commit 7c527ee791
53 changed files with 4501 additions and 165 deletions

View File

@ -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 预览。

View File

@ -65,6 +65,7 @@ pnpm build
- `FilterChip`
- `KpiCard`
- `SegmentControl`
- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段使用 `ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,选择后自动回填。
- 布局组件放在 `src/components/layout/`
- `Header`
- `Sidebar`

View File

@ -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",

View File

@ -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 }),
],
},
{

View File

@ -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",

View File

@ -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,

View File

@ -24,6 +24,7 @@ import {
AdminRowActions,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
achievementStatusLabels,
@ -437,12 +438,13 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
value={form.primaryBadgeResourceId}
onChange={changeField(page.setForm, "primaryBadgeResourceId")}
/>
<ResourceGroupSelect
<ResourceGroupSelectField
allowEmpty
disabled={disabled}
groups={page.resourceGroups}
label="达成奖励资源组"
value={form.rewardResourceGroupId}
onChange={changeField(page.setForm, "rewardResourceGroupId")}
onChange={(value) => page.setForm((current) => ({ ...current, rewardResourceGroupId: value }))}
/>
<TextField
disabled={disabled}
@ -518,20 +520,6 @@ function ResourceSelect({ disabled, label, onChange, resources, value }) {
);
}
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
const options = withSelectedGroup(groups, value);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
);
}
function ResourceLabel({ id, resources }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
@ -573,14 +561,6 @@ function withSelectedResource(resources, value) {
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
}
function withSelectedGroup(groups, value) {
const selectedId = Number(value || 0);
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
return groups;
}
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
}
function achievementStatusLabel(status) {
return achievementStatusLabels[status] || status || "-";
}

View File

@ -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"];

View File

@ -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",
};
}

View File

@ -5,6 +5,7 @@ import ImageOutlined from "@mui/icons-material/ImageOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { APP_BANNER_DISPLAY_SCOPE_OPTIONS } from "@/features/app-config/constants";
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
import styles from "@/features/app-config/app-config.module.css";
import { Button } from "@/shared/ui/Button.jsx";
@ -26,6 +27,7 @@ import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
@ -33,6 +35,12 @@ const statusOptions = [
["", "全部状态"],
["active", "启用"],
["disabled", "关闭"],
["expired", "过期"],
];
const displayScopeFilterOptions = [
["", "全部范围"],
...APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => [option.value, option.label]),
];
const platformOptions = [
@ -63,7 +71,13 @@ export function BannerConfigPage() {
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1240px" rowKey={(item) => item.id} />
<DataTable
columns={columns}
fixedEdges={false}
items={items}
minWidth="1480px"
rowKey={(item) => item.id}
/>
<div className="pagination-bar">
<span>{items.length} </span>
</div>
@ -82,12 +96,24 @@ export function BannerConfigPage() {
onSubmit={page.submitBanner}
>
<AdminFormSection title="素材">
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="封面图"
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })}
/>
<AdminFormFieldGrid
columns={page.form.displayScope === "room" ? "repeat(2, minmax(0, 1fr))" : "1fr"}
>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="封面图"
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })}
/>
{page.form.displayScope === "room" ? (
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="房间内小屏图"
value={page.form.roomSmallImageUrl}
onChange={(roomSmallImageUrl) => page.setForm({ roomSmallImageUrl })}
/>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="跳转信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
@ -125,6 +151,26 @@ export function BannerConfigPage() {
</AdminFormSection>
<AdminFormSection title="投放设置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!page.abilities.canUpdate}
label="显示范围"
required
select
value={page.form.displayScope}
onChange={(event) => {
const displayScope = event.target.value;
page.setForm({
displayScope,
roomSmallImageUrl: displayScope === "room" ? page.form.roomSmallImageUrl : "",
});
}}
>
{APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<RegionSelect
disabled={!page.abilities.canUpdate}
emptyLabel="全部区域"
@ -149,13 +195,20 @@ export function BannerConfigPage() {
value={page.form.sortOrder}
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
/>
<TimeRangeFilter
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
label="投放时间区间"
value={{ endMs: page.form.endsAtMs, startMs: page.form.startsAtMs }}
onChange={(range) => page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs })}
/>
<AdminFormSwitchField
checked={page.form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="BANNER 状态"
switchProps={{ inputProps: { "aria-label": "BANNER 启用状态" } }}
uncheckedLabel="关闭"
uncheckedLabel={page.form.status === "expired" ? "过期" : "关闭"}
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
/>
</AdminFormFieldGrid>
@ -206,6 +259,18 @@ function bannerColumns(page) {
}),
width: "minmax(110px, 0.6fr)",
},
{
key: "displayScope",
label: "显示范围",
render: (item) => displayScopeLabel(item.displayScope),
filter: createOptionsColumnFilter({
options: displayScopeFilterOptions,
placeholder: "搜索显示范围",
value: page.displayScope,
onChange: page.setDisplayScope,
}),
width: "minmax(120px, 0.65fr)",
},
{
key: "status",
label: "状态",
@ -249,6 +314,12 @@ function bannerColumns(page) {
label: "排序",
width: "minmax(80px, 0.4fr)",
},
{
key: "deliveryTime",
label: "投放时间",
render: (item) => deliveryTimeLabel(item.startsAtMs, item.endsAtMs),
width: "minmax(220px, 1fr)",
},
{
key: "description",
label: "描述",
@ -342,11 +413,11 @@ function Stack({ primary, secondary }) {
}
function StatusBadge({ status }) {
const tone = status === "active" ? "succeeded" : "stopped";
const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{status === "active" ? "启用" : "关闭"}
{statusLabel(status)}
</span>
);
}
@ -365,6 +436,26 @@ function platformLabel(value) {
return value || "-";
}
function displayScopeLabel(value) {
return APP_BANNER_DISPLAY_SCOPE_OPTIONS.find((option) => option.value === value)?.label || value || "-";
}
function deliveryTimeLabel(startsAtMs, endsAtMs) {
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
return `${start} - ${end}`;
}
function statusLabel(status) {
if (status === "active") {
return "启用";
}
if (status === "expired") {
return "过期";
}
return "关闭";
}
function regionLabel(options, value) {
if (!value) {
return "全部区域";

View File

@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { appBannerSchema } from "@/features/app-config/schema";
import { FormValidationError, parseForm } from "@/shared/forms/validation";
const validBannerForm = {
bannerType: "h5",
countryCode: "",
coverUrl: "https://media.haiyihy.com/banner/home.png",
description: "",
displayScope: "home",
endsAtMs: "1800000000000",
param: "https://h5.example.com/activity",
platform: "android",
regionId: "",
roomSmallImageUrl: "",
sortOrder: "0",
startsAtMs: "1700000000000",
status: "active",
};
describe("app config form schema", () => {
test("keeps banner display scope and delivery time fields", () => {
const payload = parseForm(appBannerSchema, validBannerForm);
expect(payload.displayScope).toBe("home");
expect(payload.startsAtMs).toBe(1700000000000);
expect(payload.endsAtMs).toBe(1800000000000);
});
test("requires room small image for room banner", () => {
expect(() =>
parseForm(appBannerSchema, {
...validBannerForm,
displayScope: "room",
roomSmallImageUrl: "",
}),
).toThrow(FormValidationError);
});
test("rejects inverted banner delivery time range", () => {
expect(() =>
parseForm(appBannerSchema, {
...validBannerForm,
endsAtMs: "1700000000000",
startsAtMs: "1800000000000",
}),
).toThrow(FormValidationError);
});
});

View File

@ -1,52 +1,82 @@
import { z } from "zod";
import { H5_LINK_KEYS } from "@/features/app-config/constants";
const h5LinkURLSchema = z.string()
.trim()
.max(2048, "H5 链接不能超过 2048 个字符")
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
.default("");
const h5LinkURLSchema = z
.string()
.trim()
.max(2048, "H5 链接不能超过 2048 个字符")
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
.default("");
export const h5LinkUpdateSchema = z.object({
key: z.enum(H5_LINK_KEYS),
url: h5LinkURLSchema
key: z.enum(H5_LINK_KEYS),
url: h5LinkURLSchema,
});
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
export const appBannerSchema = z.object({
bannerType: z.enum(["h5", "app"]),
countryCode: z.string().trim().transform((value) => value.toUpperCase())
.refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"),
coverUrl: z.string().trim().min(1, "请上传封面图").max(1024, "封面图地址不能超过 1024 个字符"),
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
platform: z.enum(["android", "ios"]),
regionId: z.coerce.number().int().min(0, "区域不正确").default(0),
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
status: z.enum(["active", "disabled"])
}).superRefine((value, ctx) => {
if (value.bannerType !== "h5") {
return;
}
if (!value.param) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] });
}
if (/\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
}
});
export const appBannerSchema = z
.object({
bannerType: z.enum(["h5", "app"]),
countryCode: z
.string()
.trim()
.transform((value) => value.toUpperCase())
.refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"),
coverUrl: z
.string()
.trim()
.min(1, "请上传封面图")
.max(1024, "封面图地址不能超过 1024 个字符")
.refine((value) => !/\s/.test(value), "封面图地址不能包含空白字符"),
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
displayScope: z.enum(["home", "room", "recharge"]),
endsAtMs: z.coerce.number().int().min(0, "投放结束时间不正确").default(0),
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
platform: z.enum(["android", "ios"]),
regionId: z.coerce.number().int().min(0, "区域不正确").default(0),
roomSmallImageUrl: z
.string()
.trim()
.max(1024, "房间内小屏图地址不能超过 1024 个字符")
.refine((value) => !/\s/.test(value), "房间内小屏图地址不能包含空白字符"),
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
startsAtMs: z.coerce.number().int().min(0, "投放开始时间不正确").default(0),
status: z.enum(["active", "disabled", "expired"]),
})
.superRefine((value, ctx) => {
if (value.bannerType === "h5" && !value.param) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] });
}
if (value.bannerType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
}
if (value.displayScope === "room" && !value.roomSmallImageUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "房间内显示需要上传房间内小屏图",
path: ["roomSmallImageUrl"],
});
}
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["endsAtMs"] });
}
});
export type AppBannerForm = z.infer<typeof appBannerSchema>;
export const appVersionSchema = z.object({
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
downloadUrl: z.string().trim().min(1, "请填写下载链接").max(2048, "下载链接不能超过 2048 个字符")
.refine((value) => !/\s/.test(value), "下载链接不能包含空白字符"),
forceUpdate: z.boolean(),
platform: z.enum(["android", "ios"]),
version: z.string().trim().min(1, "请填写版本号").max(40, "版本号不能超过 40 个字符")
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
downloadUrl: z
.string()
.trim()
.min(1, "请填写下载链接")
.max(2048, "下载链接不能超过 2048 个字符")
.refine((value) => !/\s/.test(value), "下载链接不能包含空白字符"),
forceUpdate: z.boolean(),
platform: z.enum(["android", "ios"]),
version: z.string().trim().min(1, "请填写版本号").max(40, "版本号不能超过 40 个字符"),
});
export type AppVersionForm = z.infer<typeof appVersionSchema>;

View File

@ -15,18 +15,10 @@ import {
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
levelStatusLabels,
levelTrackLabels,
levelTrackOptions,
} from "@/features/level-config/constants.js";
import { levelStatusLabels, levelTrackLabels, levelTrackOptions } from "@/features/level-config/constants.js";
import { useLevelConfigPage } from "@/features/level-config/hooks/useLevelConfigPage.js";
import styles from "@/features/level-config/level-config.module.css";
@ -124,7 +116,9 @@ function LevelTrackSection({ page, track }) {
<div className={styles.trackHeader}>
<div className={styles.trackTitleGroup}>
<div className={styles.trackTitleRow}>
<span className={styles.trackTitle}>{track.name || levelTrackLabels[track.track] || track.track}</span>
<span className={styles.trackTitle}>
{track.name || levelTrackLabels[track.track] || track.track}
</span>
</div>
<div className={styles.trackMetaList}>
<span>{track.track}</span>
@ -272,7 +266,9 @@ function buildTierColumns(page) {
key: "frame",
label: "头像框",
width: "minmax(200px, 0.9fr)",
render: (tier) => <ResourceLabel id={tier.displayAvatarFrameResourceId} resources={page.avatarFrameResources} />,
render: (tier) => (
<ResourceLabel id={tier.displayAvatarFrameResourceId} resources={page.avatarFrameResources} />
),
},
{
key: "badge",
@ -457,12 +453,15 @@ function RuleFormDialog({ page }) {
value={form.longBadgeResourceId}
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
/>
<ResourceGroupSelect
<ResourceGroupSelectField
allowEmpty
disabled={!page.abilities.canUpdate}
groups={page.resourceGroups}
label="升级礼物"
value={form.rewardResourceGroupId}
onChange={changeField(page.setRuleForm, "rewardResourceGroupId")}
onChange={(value) =>
page.setRuleForm((current) => ({ ...current, rewardResourceGroupId: value }))
}
/>
<TextField
disabled={!page.abilities.canUpdate}
@ -558,12 +557,15 @@ function TierFormDialog({ page }) {
value={form.displayBadgeResourceId}
onChange={changeField(page.setTierForm, "displayBadgeResourceId")}
/>
<ResourceGroupSelect
<ResourceGroupSelectField
allowEmpty
disabled={!page.abilities.canUpdate}
groups={page.resourceGroups}
label="升级礼物"
value={form.rewardResourceGroupId}
onChange={changeField(page.setTierForm, "rewardResourceGroupId")}
onChange={(value) =>
page.setTierForm((current) => ({ ...current, rewardResourceGroupId: value }))
}
/>
<AdminFormSwitchField
checked={form.status === "active"}
@ -639,20 +641,6 @@ function ResourceSelect({ disabled, label, onChange, resources, value }) {
);
}
function ResourceGroupSelect({ disabled, groups, label, onChange, value }) {
const options = withSelectedGroup(groups, value);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
);
}
function ResourceLabel({ id, resources }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
@ -694,14 +682,6 @@ function withSelectedResource(resources, value) {
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
}
function withSelectedGroup(groups, value) {
const selectedId = Number(value || 0);
if (!selectedId || groups.some((group) => Number(group.groupId) === selectedId)) {
return groups;
}
return [{ groupId: selectedId, name: `资源组 #${selectedId}` }, ...groups];
}
function levelStatusLabel(status) {
return levelStatusLabels[status] || status || "-";
}

View File

@ -0,0 +1,250 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export interface LuckyGiftTierDto {
pool: string;
tierId: string;
rewardCoins: number;
weight: number;
highWaterOnly: boolean;
enabled: boolean;
}
export interface LuckyGiftConfigDto {
appCode?: string;
enabled: boolean;
ruleVersion: number;
giftPrice: number;
targetRTPPPM: number;
poolRatePPM: number;
globalWindowDraws: number;
giftWindowDraws: number;
noviceDrawLimit: number;
intermediateDrawLimit: number;
highMultiplier: number;
highWaterPoolMultiple: number;
platformPoolWeightPPM: number;
roomPoolWeightPPM: number;
giftPoolWeightPPM: number;
initialPlatformPool: number;
initialGiftPool: number;
initialRoomPool: number;
platformReserve: number;
giftReserve: number;
roomReserve: number;
maxSinglePayout: number;
userHourlyPayoutCap: number;
userDailyPayoutCap: number;
deviceDailyPayoutCap: number;
roomHourlyPayoutCap: number;
anchorDailyPayoutCap: number;
roomAtmosphereRatePPM: number;
roomAtmosphereInitial: number;
roomAtmosphereReserve: number;
activityBudget: number;
activityDailyLimit: number;
largeTierEnabled: boolean;
tiers: LuckyGiftTierDto[];
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface LuckyGiftConfigPayload {
enabled: boolean;
gift_price: number;
target_rtp_ppm: number;
pool_rate_ppm: number;
global_window_draws: number;
gift_window_draws: number;
novice_draw_limit: number;
intermediate_draw_limit: number;
high_multiplier: number;
high_water_pool_multiple: number;
platform_pool_weight_ppm: number;
room_pool_weight_ppm: number;
gift_pool_weight_ppm: number;
initial_platform_pool: number;
initial_gift_pool: number;
initial_room_pool: number;
platform_reserve: number;
gift_reserve: number;
room_reserve: number;
max_single_payout: number;
user_hourly_payout_cap: number;
user_daily_payout_cap: number;
device_daily_payout_cap: number;
room_hourly_payout_cap: number;
anchor_daily_payout_cap: number;
room_atmosphere_rate_ppm: number;
room_atmosphere_initial: number;
room_atmosphere_reserve: number;
activity_budget: number;
activity_daily_limit: number;
large_tier_enabled: boolean;
tiers: Array<{
pool: string;
tier_id: string;
reward_coins: number;
weight: number;
high_water_only: boolean;
enabled: boolean;
}>;
}
export interface LuckyGiftDrawDto {
drawId: string;
commandId?: string;
giftId: string;
ruleVersion: number;
experiencePool: string;
selectedTierId: string;
baseRewardCoins: number;
roomAtmosphereRewardCoins: number;
activitySubsidyCoins: number;
effectiveRewardCoins: number;
budgetSourcesJson?: string;
rewardStatus?: string;
rtpWindowIndex: number;
giftRTPWindowIndex: number;
globalBaseRTPPPM: number;
giftBaseRTPPPM: number;
stageFeedback: boolean;
highMultiplier: boolean;
createdAtMs?: number;
}
type RawConfig = LuckyGiftConfigDto & Record<string, unknown>;
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
type RawDraw = LuckyGiftDrawDto & Record<string, unknown>;
export function getLuckyGiftConfig(): Promise<LuckyGiftConfigDto> {
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getLuckyGiftConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise<LuckyGiftConfigDto> {
const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig;
return apiRequest<RawConfig, LuckyGiftConfigPayload>(
apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig),
{
body: payload,
method: endpoint.method,
},
).then(normalizeConfig);
}
export function listLuckyGiftDraws(query: PageQuery = {}): Promise<ApiPage<LuckyGiftDrawDto>> {
const endpoint = API_ENDPOINTS.listLuckyGiftDraws;
return apiRequest<ApiPage<RawDraw>>(apiEndpointPath(API_OPERATIONS.listLuckyGiftDraws), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeDraw),
}));
}
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
enabled: booleanValue(item.enabled),
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
giftPrice: numberValue(item.giftPrice ?? item.gift_price),
targetRTPPPM: numberValue(item.targetRTPPPM ?? item.target_rtp_ppm),
poolRatePPM: numberValue(item.poolRatePPM ?? item.pool_rate_ppm),
globalWindowDraws: numberValue(item.globalWindowDraws ?? item.global_window_draws),
giftWindowDraws: numberValue(item.giftWindowDraws ?? item.gift_window_draws),
noviceDrawLimit: numberValue(item.noviceDrawLimit ?? item.novice_draw_limit),
intermediateDrawLimit: numberValue(item.intermediateDrawLimit ?? item.intermediate_draw_limit),
highMultiplier: numberValue(item.highMultiplier ?? item.high_multiplier),
highWaterPoolMultiple: numberValue(item.highWaterPoolMultiple ?? item.high_water_pool_multiple),
platformPoolWeightPPM: numberValue(item.platformPoolWeightPPM ?? item.platform_pool_weight_ppm),
roomPoolWeightPPM: numberValue(item.roomPoolWeightPPM ?? item.room_pool_weight_ppm),
giftPoolWeightPPM: numberValue(item.giftPoolWeightPPM ?? item.gift_pool_weight_ppm),
initialPlatformPool: numberValue(item.initialPlatformPool ?? item.initial_platform_pool),
initialGiftPool: numberValue(item.initialGiftPool ?? item.initial_gift_pool),
initialRoomPool: numberValue(item.initialRoomPool ?? item.initial_room_pool),
platformReserve: numberValue(item.platformReserve ?? item.platform_reserve),
giftReserve: numberValue(item.giftReserve ?? item.gift_reserve),
roomReserve: numberValue(item.roomReserve ?? item.room_reserve),
maxSinglePayout: numberValue(item.maxSinglePayout ?? item.max_single_payout),
userHourlyPayoutCap: numberValue(item.userHourlyPayoutCap ?? item.user_hourly_payout_cap),
userDailyPayoutCap: numberValue(item.userDailyPayoutCap ?? item.user_daily_payout_cap),
deviceDailyPayoutCap: numberValue(item.deviceDailyPayoutCap ?? item.device_daily_payout_cap),
roomHourlyPayoutCap: numberValue(item.roomHourlyPayoutCap ?? item.room_hourly_payout_cap),
anchorDailyPayoutCap: numberValue(item.anchorDailyPayoutCap ?? item.anchor_daily_payout_cap),
roomAtmosphereRatePPM: numberValue(item.roomAtmosphereRatePPM ?? item.room_atmosphere_rate_ppm),
roomAtmosphereInitial: numberValue(item.roomAtmosphereInitial ?? item.room_atmosphere_initial),
roomAtmosphereReserve: numberValue(item.roomAtmosphereReserve ?? item.room_atmosphere_reserve),
activityBudget: numberValue(item.activityBudget ?? item.activity_budget),
activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit),
largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled),
tiers: arrayValue(item.tiers).map(normalizeTier),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeTier(raw: unknown): LuckyGiftTierDto {
const item = asRecord(raw) as RawTier;
return {
pool: stringValue(item.pool),
tierId: stringValue(item.tierId ?? item.tier_id),
rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins),
weight: numberValue(item.weight),
highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only),
enabled: booleanValue(item.enabled),
};
}
function normalizeDraw(item: RawDraw): LuckyGiftDrawDto {
return {
drawId: stringValue(item.drawId ?? item.draw_id),
commandId: stringValue(item.commandId ?? item.command_id),
giftId: stringValue(item.giftId ?? item.gift_id),
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
experiencePool: stringValue(item.experiencePool ?? item.experience_pool),
selectedTierId: stringValue(item.selectedTierId ?? item.selected_tier_id),
baseRewardCoins: numberValue(item.baseRewardCoins ?? item.base_reward_coins),
roomAtmosphereRewardCoins: numberValue(
item.roomAtmosphereRewardCoins ?? item.room_atmosphere_reward_coins,
),
activitySubsidyCoins: numberValue(item.activitySubsidyCoins ?? item.activity_subsidy_coins),
effectiveRewardCoins: numberValue(item.effectiveRewardCoins ?? item.effective_reward_coins),
budgetSourcesJson: stringValue(item.budgetSourcesJson ?? item.budget_sources_json),
rewardStatus: stringValue(item.rewardStatus ?? item.reward_status),
rtpWindowIndex: numberValue(item.rtpWindowIndex ?? item.rtp_window_index),
giftRTPWindowIndex: numberValue(item.giftRTPWindowIndex ?? item.gift_rtp_window_index),
globalBaseRTPPPM: numberValue(item.globalBaseRTPPPM ?? item.global_base_rtp_ppm),
giftBaseRTPPPM: numberValue(item.giftBaseRTPPPM ?? item.gift_base_rtp_ppm),
stageFeedback: booleanValue(item.stageFeedback ?? item.stage_feedback),
highMultiplier: booleanValue(item.highMultiplier ?? item.high_multiplier),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
};
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function booleanValue(value: unknown) {
return value === true || value === "true" || value === 1 || value === "1";
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,16 @@
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
import Tooltip from "@mui/material/Tooltip";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
export function FieldLabel({ help, text }) {
return (
<span className={styles.fieldLabel}>
<span>{text}</span>
{help ? (
<Tooltip arrow title={help}>
<HelpOutlineOutlined className={styles.helpIcon} fontSize="inherit" />
</Tooltip>
) : null}
</span>
);
}

View File

@ -0,0 +1,220 @@
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx";
import {
atmosphereLevelOptions,
controlStrengthOptions,
jackpotStrategyOptions,
noviceStrengthOptions,
payoutStyleOptions,
poolModeOptions,
riskLevelOptions,
} from "@/features/lucky-gift/constants.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
const helpText = {
enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。",
targetRtpPercent: "基础返奖成本比例95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。",
controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。",
poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。",
noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。",
payoutStyle: "控制小奖和大奖的权重分布,稳定偏小奖,刺激偏高倍爆点。",
jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。",
riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。",
atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。",
activitySubsidyEnabled: "活动补贴是独立预算,开启后会提高用户可见奖励,但不污染基础 RTP。",
activityBudget: "活动期最多可额外补贴的金币总额。",
activityDailyLimit: "活动补贴每天最多释放的金币额度。",
};
export function LuckyGiftConfigDrawer({
abilities,
configLoading,
configSaving,
form,
onClose,
onSubmit,
open,
setForm,
}) {
const disabled = !abilities.canUpdate || configLoading || configSaving;
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className={["form-drawer", styles.configDrawer].join(" ")} onSubmit={onSubmit}>
<h2>幸运礼物配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">基础控制</div>
<div className={styles.configGrid}>
<TextField
required
disabled={disabled}
inputProps={{ max: 100, min: 1, step: 0.01 }}
label={<FieldLabel help={helpText.targetRtpPercent} text="RTP" />}
type="number"
value={form.targetRtpPercent}
onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)}
/>
<SelectField
disabled={disabled}
help={helpText.controlStrength}
label="RTP 控制强度"
options={controlStrengthOptions}
value={form.controlStrength}
onChange={(value) => updateForm(setForm, "controlStrength", value)}
/>
<SwitchField
checked={form.enabled}
className={styles.configWide}
disabled={disabled}
help={helpText.enabled}
label="幸运礼物状态"
onChange={(event) => updateForm(setForm, "enabled", event.target.checked)}
/>
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">体验策略</div>
<div className={styles.configGrid}>
<SelectField
disabled={disabled}
help={helpText.poolMode}
label="奖池模式"
options={poolModeOptions}
value={form.poolMode}
onChange={(value) => updateForm(setForm, "poolMode", value)}
/>
<SelectField
disabled={disabled}
help={helpText.noviceStrength}
label="新手保护"
options={noviceStrengthOptions}
value={form.noviceStrength}
onChange={(value) => updateForm(setForm, "noviceStrength", value)}
/>
<SelectField
disabled={disabled}
help={helpText.payoutStyle}
label="中奖体感"
options={payoutStyleOptions}
value={form.payoutStyle}
onChange={(value) => updateForm(setForm, "payoutStyle", value)}
/>
<SelectField
disabled={disabled}
help={helpText.jackpotStrategy}
label="大奖策略"
options={jackpotStrategyOptions}
value={form.jackpotStrategy}
onChange={(value) => updateForm(setForm, "jackpotStrategy", value)}
/>
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">保护和补贴</div>
<div className={styles.configGrid}>
<SelectField
disabled={disabled}
help={helpText.riskLevel}
label="风控等级"
options={riskLevelOptions}
value={form.riskLevel}
onChange={(value) => updateForm(setForm, "riskLevel", value)}
/>
<SelectField
disabled={disabled}
help={helpText.atmosphereLevel}
label="房间爆点强度"
options={atmosphereLevelOptions}
value={form.atmosphereLevel}
onChange={(value) => updateForm(setForm, "atmosphereLevel", value)}
/>
<div className={styles.subsidyBlock}>
<SwitchField
checked={form.activitySubsidyEnabled}
className={styles.subsidyToggle}
disabled={disabled}
help={helpText.activitySubsidyEnabled}
label="活动补贴"
onChange={(event) => updateForm(setForm, "activitySubsidyEnabled", event.target.checked)}
/>
{form.activitySubsidyEnabled ? (
<div className={styles.subsidyInputs}>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label={<FieldLabel help={helpText.activityBudget} text="活动总预算" />}
type="number"
value={form.activityBudget}
onChange={(event) => updateForm(setForm, "activityBudget", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label={<FieldLabel help={helpText.activityDailyLimit} text="活动每日预算" />}
type="number"
value={form.activityDailyLimit}
onChange={(event) => updateForm(setForm, "activityDailyLimit", event.target.value)}
/>
</div>
) : null}
</div>
</div>
</section>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
保存
</Button>
</div>
</form>
</Drawer>
);
}
function SelectField({ disabled, help, label, onChange, options, value }) {
return (
<TextField
select
disabled={disabled}
label={<FieldLabel help={help} text={label} />}
value={value}
onChange={(event) => onChange(event.target.value)}
>
{options.map(([optionValue, optionLabel]) => (
<MenuItem key={optionValue} value={optionValue}>
{optionLabel}
</MenuItem>
))}
</TextField>
);
}
function SwitchField({ checked, className, disabled, help, label, onChange }) {
return (
<div className={[styles.switchField, className].filter(Boolean).join(" ")}>
<FieldLabel help={help} text={label} />
<AdminSwitch
checked={checked}
checkedLabel="开启"
disabled={disabled}
label={label}
uncheckedLabel="关闭"
onChange={onChange}
/>
</div>
);
}
function updateForm(setForm, key, value) {
setForm((current) => ({ ...current, [key]: value }));
}

View File

@ -0,0 +1,97 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
atmosphereLevelOptions,
controlStrengthOptions,
jackpotStrategyOptions,
luckyGiftConfigScopeLabel,
noviceStrengthOptions,
optionLabel,
payoutStyleOptions,
poolModeOptions,
} from "@/features/lucky-gift/constants.js";
import { formFromConfig } from "@/features/lucky-gift/configModel.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
export function LuckyGiftConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
const form = formFromConfig(config);
return (
<section className={styles.summaryPanel}>
<div className={styles.summaryScrollArea}>
<div className={styles.summaryContent}>
<div className={styles.summaryHeader}>
<span className={styles.title}>幸运礼物配置</span>
<span className={styles.meta}>{luckyGiftConfigScopeLabel}</span>
</div>
<div className={styles.summaryItems}>
<ConfigStatus config={config} loading={configLoading} />
<SummaryItem label="RTP">{formatPercent(config?.targetRTPPPM)}</SummaryItem>
<SummaryItem label="控制强度">
{optionLabel(controlStrengthOptions, form.controlStrength)}
</SummaryItem>
<SummaryItem label="奖池模式">{optionLabel(poolModeOptions, form.poolMode)}</SummaryItem>
<SummaryItem label="新手保护">
{optionLabel(noviceStrengthOptions, form.noviceStrength)}
</SummaryItem>
<SummaryItem label="中奖体感">{optionLabel(payoutStyleOptions, form.payoutStyle)}</SummaryItem>
<SummaryItem label="大奖策略">
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
</SummaryItem>
<SummaryItem label="房间爆点">
{optionLabel(atmosphereLevelOptions, form.atmosphereLevel)}
</SummaryItem>
<SummaryItem label="更新时间">
{config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"}
</SummaryItem>
</div>
</div>
</div>
<div className={styles.summaryActions}>
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<Button
disabled={!canUpdate || configLoading}
startIcon={<EditOutlined fontSize="small" />}
type="button"
variant="primary"
onClick={onEdit}
>
编辑配置
</Button>
</div>
</section>
);
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function ConfigStatus({ config, loading }) {
return (
<span
className={[
"status-badge",
config?.enabled ? "status-badge--running" : loading ? "status-badge--warning" : "status-badge--stopped",
].join(" ")}
>
<span className="status-point" />
{config?.enabled ? "已开启" : loading ? "加载中" : "已关闭"}
</span>
);
}
function formatPercent(ppm) {
const value = Number(ppm || 0) / 10_000;
return `${value.toFixed(2).replace(/\.?0+$/, "")}%`;
}

View File

@ -0,0 +1,176 @@
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { Button } from "@/shared/ui/Button.jsx";
import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx";
import { runLuckyGiftSimulation } from "@/features/lucky-gift/simulation.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
const simulationHelp = {
costMax: "单次抽奖消耗的最高金币数,用于模拟不同价格幸运礼物混合抽奖。",
costMin: "单次抽奖消耗的最低金币数,用于模拟低价幸运礼物。",
drawsMax: "单个用户本次模拟最多抽奖次数。",
drawsMin: "单个用户本次模拟最少抽奖次数,可以填 0 表示有用户未参与。",
rooms: "模拟用户随机分散进入的房间数量,用于观察房间维度分布。",
users: "参与模拟的用户数量,每个用户都会生成独立抽奖次数和 RTP。",
};
const defaultInput = {
costMax: "500000",
costMin: "100",
drawsMax: "100000",
drawsMin: "0",
rooms: "50",
users: "1000",
};
export function LuckyGiftSimulationPanel({ config }) {
const [input, setInput] = useState(defaultInput);
const [result, setResult] = useState(null);
const [csvUrl, setCsvUrl] = useState("");
useEffect(() => {
return () => {
if (csvUrl) {
URL.revokeObjectURL(csvUrl);
}
};
}, [csvUrl]);
const csvFileName = `lucky-gift-simulation-${new Date().toISOString().slice(0, 10)}.csv`;
const runSimulation = () => {
const seed = nextSimulationSeed();
const nextResult = runLuckyGiftSimulation(config, { ...input, seed });
const blob = new Blob([`\ufeff${nextResult.csv}`], { type: "text/csv;charset=utf-8" });
const nextUrl = URL.createObjectURL(blob);
if (csvUrl) {
URL.revokeObjectURL(csvUrl);
}
setResult(nextResult);
setCsvUrl(nextUrl);
};
return (
<section className={styles.simulationPanel}>
<div className={styles.simulationHeader}>
<div>
<span className={styles.title}>随机模拟测试</span>
</div>
<div className={styles.simulationActions}>
<Button startIcon={<PlayArrowOutlined fontSize="small" />} type="button" variant="primary" onClick={runSimulation}>
测试
</Button>
{csvUrl ? (
<Button component="a" download={csvFileName} href={csvUrl} startIcon={<DownloadOutlined fontSize="small" />}>
CSV
</Button>
) : null}
</div>
</div>
<div className={styles.simulationGrid}>
<NumberField
help={simulationHelp.users}
label="用户数量"
min={1}
value={input.users}
onChange={(value) => updateInput(setInput, "users", value)}
/>
<NumberField
help={simulationHelp.rooms}
label="房间数量"
min={1}
value={input.rooms}
onChange={(value) => updateInput(setInput, "rooms", value)}
/>
<NumberField
help={simulationHelp.costMin}
label="单抽花费最小值"
min={1}
value={input.costMin}
onChange={(value) => updateInput(setInput, "costMin", value)}
/>
<NumberField
help={simulationHelp.costMax}
label="单抽花费最大值"
min={1}
value={input.costMax}
onChange={(value) => updateInput(setInput, "costMax", value)}
/>
<NumberField
help={simulationHelp.drawsMin}
label="抽奖次数最小值"
min={0}
value={input.drawsMin}
onChange={(value) => updateInput(setInput, "drawsMin", value)}
/>
<NumberField
help={simulationHelp.drawsMax}
label="抽奖次数最大值"
min={0}
value={input.drawsMax}
onChange={(value) => updateInput(setInput, "drawsMax", value)}
/>
</div>
{result ? <SimulationOverview overview={result.overview} /> : null}
</section>
);
}
function NumberField({ help, label, min, onChange, value }) {
return (
<TextField
inputProps={{ min, step: 1 }}
label={<FieldLabel help={help} text={label} />}
type="number"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}
function SimulationOverview({ overview }) {
return (
<div className={styles.simulationOverview}>
<Metric label="用户" value={formatNumber(overview.users)} />
<Metric label="房间" value={formatNumber(overview.rooms)} />
<Metric label="总抽数" value={formatNumber(overview.totalDraws)} />
<Metric label="平台收入" value={formatNumber(overview.totalSpend)} />
<Metric label="平台支出" value={formatNumber(overview.totalReward)} />
<Metric label="总 RTP" value={`${formatRatio(overview.totalRTP)}%`} />
<Metric label="人均 RTP" value={`${formatRatio(overview.averageUserRTP)}%`} />
<Metric label="用户净收益" value={formatNumber(overview.totalNetIncome)} />
</div>
);
}
function Metric({ label, value }) {
return (
<div className={styles.metricItem}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function updateInput(setInput, key, value) {
setInput((current) => ({ ...current, [key]: value }));
}
function nextSimulationSeed() {
if (globalThis.crypto?.getRandomValues) {
const values = new Uint32Array(1);
globalThis.crypto.getRandomValues(values);
return values[0];
}
return Math.round(Date.now() + Math.random() * 0xffffffff);
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Math.round(Number(value || 0)));
}
function formatRatio(value) {
return Number(value || 0).toFixed(2);
}

View File

@ -0,0 +1,272 @@
import {
atmosphereLevelConfig,
controlStrengthConfig,
jackpotStrategyConfig,
noviceStrengthConfig,
poolModeConfig,
} from "@/features/lucky-gift/constants.js";
const ppmScale = 1_000_000;
export const luckyGiftReferenceCost = 10_000;
const defaultTargetRTPPPM = 950_000;
const defaultDrawsPerHour = 3_600;
const defaultUsersPerRoom = 20;
export function emptyLuckyGiftForm() {
return {
activityBudget: "0",
activityDailyLimit: "0",
activitySubsidyEnabled: false,
atmosphereLevel: "medium",
controlStrength: "standard",
enabled: false,
jackpotStrategy: "standard",
noviceStrength: "standard",
payoutStyle: "balanced",
poolMode: "standard",
riskLevel: "standard",
targetRtpPercent: "95",
};
}
export function formFromConfig(config) {
if (!config) {
return emptyLuckyGiftForm();
}
const activityBudget = Number(config.activityBudget || 0);
const activityDailyLimit = Number(config.activityDailyLimit || 0);
return {
activityBudget: String(activityBudget),
activityDailyLimit: String(activityDailyLimit),
activitySubsidyEnabled: activityBudget > 0 || activityDailyLimit > 0,
atmosphereLevel: inferAtmosphereLevel(config.roomAtmosphereRatePPM),
controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws),
enabled: Boolean(config.enabled),
jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple),
noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit),
payoutStyle: inferPayoutStyle(config.tiers || []),
poolMode: inferPoolMode(config),
riskLevel: inferRiskLevel(config),
targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM),
};
}
export function payloadFromLuckyGiftForm(form, currentConfig) {
const giftPrice = luckyGiftReferenceCost;
const targetRTPPPM = percentToPPM(form.targetRtpPercent);
const control = controlStrengthConfig[form.controlStrength] || controlStrengthConfig.standard;
const poolMode = poolModeConfig[form.poolMode] || poolModeConfig.standard;
const novice = noviceStrengthConfig[form.noviceStrength] || noviceStrengthConfig.standard;
const jackpot = jackpotStrategyConfig[form.jackpotStrategy] || jackpotStrategyConfig.standard;
const atmosphere = atmosphereLevelConfig[form.atmosphereLevel] || atmosphereLevelConfig.medium;
const risk = riskCapsForLevel(form.riskLevel, giftPrice, targetRTPPPM);
const base = currentConfig || {};
const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0;
const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0;
return {
activity_budget: activityBudget,
activity_daily_limit: activityDailyLimit,
anchor_daily_payout_cap: risk.anchorDailyPayoutCap,
device_daily_payout_cap: risk.deviceDailyPayoutCap,
enabled: Boolean(form.enabled),
gift_pool_weight_ppm: poolMode.giftPoolWeightPPM,
gift_price: giftPrice,
gift_reserve: positiveNumber(base.giftReserve, 1_000_000),
global_window_draws: control.globalWindowDraws,
gift_window_draws: control.giftWindowDraws,
high_multiplier: positiveNumber(base.highMultiplier, 100),
high_water_pool_multiple: jackpot.highWaterPoolMultiple,
initial_gift_pool: positiveNumber(base.initialGiftPool, expectedPayout(giftPrice, targetRTPPPM, control.giftWindowDraws) / 2),
initial_platform_pool: positiveNumber(
base.initialPlatformPool,
weighted(expectedPayout(giftPrice, targetRTPPPM, control.globalWindowDraws), poolMode.platformPoolWeightPPM),
),
initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000),
intermediate_draw_limit: novice.intermediateDrawLimit,
large_tier_enabled: jackpot.largeTierEnabled,
max_single_payout: risk.maxSinglePayout,
novice_draw_limit: novice.noviceDrawLimit,
platform_pool_weight_ppm: poolMode.platformPoolWeightPPM,
platform_reserve: positiveNumber(base.platformReserve, 1_000_000),
pool_rate_ppm: Math.max(targetRTPPPM, positiveNumber(base.poolRatePPM, targetRTPPPM)),
room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200),
room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM,
room_atmosphere_reserve: positiveNumber(base.roomAtmosphereReserve, giftPrice * 20),
room_hourly_payout_cap: risk.roomHourlyPayoutCap,
room_pool_weight_ppm: poolMode.roomPoolWeightPPM,
room_reserve: positiveNumber(base.roomReserve, giftPrice * 200),
target_rtp_ppm: targetRTPPPM,
tiers: buildTiers(giftPrice, form.payoutStyle),
user_daily_payout_cap: risk.userDailyPayoutCap,
user_hourly_payout_cap: risk.userHourlyPayoutCap,
};
}
export function previewForForm(form) {
const giftPrice = luckyGiftReferenceCost;
const targetRTPPPM = percentToPPM(form.targetRtpPercent);
const income = giftPrice * 100_000;
const basePayout = Math.round((income * targetRTPPPM) / ppmScale);
const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0;
const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0;
return {
activityBudget,
activityDailyLimit,
basePayout,
income,
netBeforeSubsidy: income - basePayout,
targetRTPPPM,
visibleRTPPercent: ((basePayout + Math.min(activityBudget, activityDailyLimit || activityBudget)) * 100) / income,
};
}
function buildTiers(cost, style) {
const scale = style === "stable" ? 0 : style === "exciting" ? 2 : 1;
const noviceHigh = scale === 0 ? 200 : scale === 2 ? 900 : 500;
const intermediateHigh = scale === 0 ? 10_000 : scale === 2 ? 36_000 : 25_000;
const advancedHigh = scale === 0 ? 15_000 : scale === 2 ? 58_000 : 40_000;
return [
tier("novice", "none", 0, 720_000),
tier("novice", "novice_feedback_0_2x", Math.floor(cost / 5), 90_000),
tier("novice", "novice_rebate_0_5x", Math.floor(cost / 2), 70_000),
tier("novice", "novice_rebate_1x", cost, 70_000),
tier("novice", "novice_small_2x", cost * 2, 35_000),
tier("novice", "novice_small_5x", cost * 5, 12_000),
tier("novice", "novice_medium_10x", cost * 10, 2_800),
tier("novice", "novice_cap_20x", cost * 20, noviceHigh, true),
tier("intermediate", "none", 0, 830_000),
tier("intermediate", "inter_rebate_0_5x", Math.floor(cost / 2), 25_000),
tier("intermediate", "inter_rebate_1x", cost, 40_000),
tier("intermediate", "inter_small_2x", cost * 2, 45_000),
tier("intermediate", "inter_medium_5x", cost * 5, 35_000),
tier("intermediate", "inter_large_20x", cost * 20, Math.floor(intermediateHigh * 0.8), true),
tier("intermediate", "inter_large_50x", cost * 50, Math.floor(intermediateHigh * 0.2), true),
tier("advanced", "none", 0, 900_000),
tier("advanced", "adv_small_2x", cost * 2, 30_000),
tier("advanced", "adv_medium_5x", cost * 5, 30_000),
tier("advanced", "adv_large_20x", cost * 20, Math.floor(advancedHigh * 0.75), true),
tier("advanced", "adv_large_100x", cost * 100, Math.floor(advancedHigh * 0.225), true),
tier("advanced", "adv_jackpot_500x", cost * 500, Math.max(100, Math.floor(advancedHigh * 0.025)), true),
];
}
function tier(pool, tierId, rewardCoins, weight, highWaterOnly = false) {
return {
enabled: weight > 0,
high_water_only: Boolean(highWaterOnly),
pool,
reward_coins: rewardCoins,
tier_id: tierId,
weight,
};
}
function riskCapsForLevel(level, giftPrice, targetRTPPPM) {
const expectedHour = expectedPayout(giftPrice, targetRTPPPM, defaultDrawsPerHour);
const expectedDay = expectedHour * 24;
const profile =
level === "strict"
? { anchorDay: 1.2, deviceDay: 1.5, roomHour: 1.2, single: 200, userDay: 1.2, userHour: 1.25 }
: level === "loose"
? { anchorDay: 2, deviceDay: 4, roomHour: 3, single: 500, userDay: 2, userHour: 3 }
: { anchorDay: 1.5, deviceDay: 2.5, roomHour: 2, single: 500, userDay: 1.5, userHour: 2 };
return {
anchorDailyPayoutCap: Math.round(expectedDay * defaultUsersPerRoom * profile.anchorDay),
deviceDailyPayoutCap: Math.round(expectedDay * profile.deviceDay),
maxSinglePayout: giftPrice * profile.single,
roomHourlyPayoutCap: Math.round(expectedHour * defaultUsersPerRoom * profile.roomHour),
userDailyPayoutCap: Math.round(expectedDay * profile.userDay),
userHourlyPayoutCap: Math.round(expectedHour * profile.userHour),
};
}
function inferControlStrength(globalWindowDraws, giftWindowDraws) {
return matchConfig(controlStrengthConfig, (item) => {
return item.globalWindowDraws === globalWindowDraws && item.giftWindowDraws === giftWindowDraws;
});
}
function inferPoolMode(config) {
return matchConfig(poolModeConfig, (item) => {
return (
item.platformPoolWeightPPM === config.platformPoolWeightPPM &&
item.roomPoolWeightPPM === config.roomPoolWeightPPM &&
item.giftPoolWeightPPM === config.giftPoolWeightPPM
);
});
}
function inferNoviceStrength(noviceDrawLimit, intermediateDrawLimit) {
return matchConfig(noviceStrengthConfig, (item) => {
return item.noviceDrawLimit === noviceDrawLimit && item.intermediateDrawLimit === intermediateDrawLimit;
});
}
function inferJackpotStrategy(largeTierEnabled, highWaterPoolMultiple) {
if (!largeTierEnabled) {
return "off";
}
if (highWaterPoolMultiple >= 3) {
return "conservative";
}
if (highWaterPoolMultiple <= 1) {
return "aggressive";
}
return "standard";
}
function inferAtmosphereLevel(ratePPM) {
return matchConfig(atmosphereLevelConfig, (item) => item.roomAtmosphereRatePPM === ratePPM);
}
function inferPayoutStyle(tiers) {
const jackpotWeight = tiers
.filter((tierItem) => tierItem.pool === "advanced" && tierItem.highWaterOnly)
.reduce((sum, tierItem) => sum + Number(tierItem.weight || 0), 0);
if (jackpotWeight <= 20_000) {
return "stable";
}
if (jackpotWeight >= 50_000) {
return "exciting";
}
return "balanced";
}
function inferRiskLevel() {
return "standard";
}
function matchConfig(configMap, matcher) {
return Object.entries(configMap).find(([, item]) => matcher(item))?.[0] || "standard";
}
function formatPercentInput(ppm) {
const percent = Number(ppm || 0) / 10_000;
return Number.isInteger(percent) ? String(percent) : percent.toFixed(2).replace(/\.?0+$/, "");
}
function percentToPPM(value) {
return Math.round(Number(value || 0) * 10_000);
}
function expectedPayout(cost, targetRTPPPM, draws) {
return Math.round((cost * targetRTPPPM * draws) / ppmScale);
}
function weighted(amount, weightPPM) {
return Math.round((amount * weightPPM) / ppmScale);
}
function positiveNumber(value, fallback) {
const number = Number(value || 0);
return Number.isFinite(number) && number > 0 ? Math.round(number) : Math.round(fallback);
}
function nonNegativeNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) && number >= 0 ? Math.round(number) : 0;
}

View File

@ -0,0 +1,11 @@
import { describe, expect, test } from "vitest";
import { emptyLuckyGiftForm, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
describe("lucky gift config payload", () => {
test("submits both RTP control windows for backend validation", () => {
const payload = payloadFromLuckyGiftForm(emptyLuckyGiftForm(), null);
expect(payload.global_window_draws).toBeGreaterThan(0);
expect(payload.gift_window_draws).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,89 @@
export const luckyGiftConfigScopeLabel = "全局配置";
export const controlStrengthOptions = [
["precise", "精准"],
["standard", "标准"],
["natural", "自然"],
];
export const poolModeOptions = [
["platform_guard", "平台兜底"],
["standard", "标准模式"],
["room_burst", "房间氛围"],
["gift_isolated", "礼物独立"],
];
export const noviceStrengthOptions = [
["weak", "弱"],
["standard", "标准"],
["strong", "强"],
];
export const payoutStyleOptions = [
["stable", "稳定小奖"],
["balanced", "均衡"],
["exciting", "刺激大奖"],
];
export const jackpotStrategyOptions = [
["off", "关闭"],
["conservative", "保守"],
["standard", "标准"],
["aggressive", "激进"],
];
export const riskLevelOptions = [
["strict", "严格"],
["standard", "标准"],
["loose", "宽松"],
];
export const atmosphereLevelOptions = [
["off", "关闭"],
["low", "低"],
["medium", "中"],
["high", "高"],
];
export const drawStatusOptions = [
["pending", "发放中"],
["granted", "已发放"],
["failed", "发放失败"],
];
export const controlStrengthConfig = {
precise: { globalWindowDraws: 50_000, giftWindowDraws: 50_000 },
standard: { globalWindowDraws: 100_000, giftWindowDraws: 100_000 },
natural: { globalWindowDraws: 300_000, giftWindowDraws: 300_000 },
};
export const poolModeConfig = {
platform_guard: { platformPoolWeightPPM: 500_000, roomPoolWeightPPM: 200_000, giftPoolWeightPPM: 300_000 },
standard: { platformPoolWeightPPM: 200_000, roomPoolWeightPPM: 300_000, giftPoolWeightPPM: 500_000 },
room_burst: { platformPoolWeightPPM: 200_000, roomPoolWeightPPM: 450_000, giftPoolWeightPPM: 350_000 },
gift_isolated: { platformPoolWeightPPM: 150_000, roomPoolWeightPPM: 200_000, giftPoolWeightPPM: 650_000 },
};
export const noviceStrengthConfig = {
weak: { noviceDrawLimit: 1_000, intermediateDrawLimit: 10_000 },
standard: { noviceDrawLimit: 2_000, intermediateDrawLimit: 20_000 },
strong: { noviceDrawLimit: 5_000, intermediateDrawLimit: 30_000 },
};
export const jackpotStrategyConfig = {
off: { largeTierEnabled: false, highWaterPoolMultiple: 3 },
conservative: { largeTierEnabled: true, highWaterPoolMultiple: 3 },
standard: { largeTierEnabled: true, highWaterPoolMultiple: 2 },
aggressive: { largeTierEnabled: true, highWaterPoolMultiple: 1 },
};
export const atmosphereLevelConfig = {
off: { roomAtmosphereRatePPM: 0 },
low: { roomAtmosphereRatePPM: 5_000 },
medium: { roomAtmosphereRatePPM: 10_000 },
high: { roomAtmosphereRatePPM: 20_000 },
};
export function optionLabel(options, value) {
return options.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
}

View File

@ -0,0 +1,145 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { getLuckyGiftConfig, listLuckyGiftDraws, updateLuckyGiftConfig } from "@/features/lucky-gift/api";
import { emptyLuckyGiftForm, formFromConfig, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
import { useLuckyGiftAbilities } from "@/features/lucky-gift/permissions.js";
import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema";
const pageSize = 10;
const emptyDraws = { items: [], page: 1, pageSize, total: 0 };
export function useLuckyGiftPage() {
const abilities = useLuckyGiftAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(null);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [form, setForm] = useState(emptyLuckyGiftForm());
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [giftId, setGiftId] = useState("");
const [userId, setUserId] = useState("");
const [roomId, setRoomId] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const drawFilters = useMemo(
() => ({ gift_id: giftId, room_id: roomId, status, user_id: userId }),
[giftId, roomId, status, userId],
);
const {
data: draws = emptyDraws,
error: drawsError,
loading: drawsLoading,
reload: reloadDraws,
} = usePaginatedQuery({
errorMessage: "加载幸运礼物记录失败",
fetcher: listLuckyGiftDraws,
filters: drawFilters,
page,
pageSize,
queryKey: ["lucky-gift-draws", drawFilters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const nextConfig = await getLuckyGiftConfig();
setConfig(nextConfig);
setForm(formFromConfig(nextConfig));
} catch (err) {
showToast(err.message || "加载幸运礼物配置失败", "error");
} finally {
setConfigLoading(false);
}
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
const openConfigDrawer = useCallback(() => {
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
setConfigDrawerOpen(true);
}, [config]);
const closeConfigDrawer = useCallback(() => {
setConfigDrawerOpen(false);
setForm(config ? formFromConfig(config) : emptyLuckyGiftForm());
}, [config]);
const submitConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
setConfigSaving(true);
try {
const parsed = parseForm(luckyGiftConfigFormSchema, form);
const saved = await updateLuckyGiftConfig(payloadFromLuckyGiftForm(parsed, config));
setConfig(saved);
setForm(formFromConfig(saved));
setConfigDrawerOpen(false);
showToast("幸运礼物配置已保存", "success");
await reloadDraws();
} catch (err) {
showToast(err.message || "保存幸运礼物配置失败", "error");
} finally {
setConfigSaving(false);
}
};
const changeGiftId = (value) => {
setGiftId(value);
setPage(1);
};
const changeUserId = (value) => {
setUserId(value);
setPage(1);
};
const changeRoomId = (value) => {
setRoomId(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const resetDrawFilters = () => {
setGiftId("");
setUserId("");
setRoomId("");
setStatus("");
setPage(1);
};
return {
abilities,
changeGiftId,
changeRoomId,
changeStatus,
changeUserId,
closeConfigDrawer,
config,
configDrawerOpen,
configLoading,
configSaving,
draws,
drawsError,
drawsLoading,
form,
giftId,
openConfigDrawer,
page,
reloadConfig,
reloadDraws,
resetDrawFilters,
roomId,
setForm,
setPage,
status,
submitConfig,
userId,
};
}

View File

@ -0,0 +1,285 @@
.summaryPanel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.summaryScrollArea {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
}
.summaryContent {
display: grid;
min-width: max-content;
gap: var(--space-3);
}
.summaryHeader {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.summaryActions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
align-self: stretch;
padding-left: var(--space-3);
border-left: 1px solid var(--border);
background: var(--bg-card);
white-space: nowrap;
}
.summaryItems {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-5);
}
.summaryItem {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.summaryLabel {
flex: 0 0 auto;
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
line-height: var(--admin-line-height);
text-overflow: ellipsis;
white-space: nowrap;
}
.summaryValue {
display: inline-flex;
min-width: 0;
align-items: center;
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
line-height: var(--admin-line-height);
text-overflow: ellipsis;
white-space: nowrap;
}
.filterInput {
width: 220px;
}
.configDrawer {
box-sizing: border-box;
width: min(520px, 100vw);
max-width: 100vw;
overflow-x: hidden;
}
.configGrid {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.configGrid > * {
min-width: 0;
}
.configGrid > :global(.MuiTextField-root),
.configGrid > :global(.MuiFormControl-root),
.configGrid > :global(.MuiAutocomplete-root) {
width: 100%;
}
.configWide {
grid-column: 1 / -1;
}
.fieldLabel {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 4px;
overflow: hidden;
vertical-align: middle;
}
.helpIcon {
flex: 0 0 auto;
color: var(--text-tertiary);
font-size: 15px;
}
.switchField {
display: flex;
min-height: var(--control-height);
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card);
}
.subsidyBlock {
display: grid;
min-width: 0;
grid-column: 1 / -1;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-soft);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
}
.subsidyToggle {
padding: 0;
border: 0;
background: transparent;
}
.subsidyInputs {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.subsidyInputs > :global(.MuiTextField-root) {
width: 100%;
}
.simulationPanel {
display: grid;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.simulationHeader {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.simulationActions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
}
.simulationGrid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: var(--space-3);
}
.simulationOverview {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: var(--space-3);
}
.metricItem {
display: grid;
min-width: 0;
gap: 2px;
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
}
.metricItem span {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.metricItem strong {
overflow: hidden;
color: var(--text-primary);
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.title,
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.summaryPanel {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
.summaryActions {
justify-content: flex-start;
padding-top: var(--space-3);
padding-left: 0;
border-top: 1px solid var(--border);
border-left: 0;
}
.simulationHeader {
align-items: flex-start;
flex-direction: column;
}
.simulationGrid,
.simulationOverview {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
@media (max-width: 560px) {
.configGrid,
.subsidyInputs,
.simulationGrid,
.simulationOverview {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -0,0 +1,220 @@
import TextField from "@mui/material/TextField";
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx";
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
import {
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const columnsBase = [
{
key: "draw",
label: "抽奖记录",
width: "minmax(260px, 1.2fr)",
render: (draw) => (
<div className={styles.stack}>
<span>{draw.drawId}</span>
<span className={styles.meta}>{draw.giftId || draw.commandId || "-"}</span>
</div>
),
},
{
key: "reward",
label: "奖励",
width: "minmax(180px, 0.8fr)",
render: (draw) => (
<div className={styles.stack}>
<span>{formatNumber(draw.effectiveRewardCoins)}</span>
<span className={styles.meta}>{draw.selectedTierId || "-"}</span>
</div>
),
},
{
key: "pool",
label: "体验池",
width: "minmax(120px, 0.55fr)",
render: (draw) => poolLabel(draw.experiencePool),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (draw) => <DrawStatus status={draw.rewardStatus} />,
},
{
key: "rtp",
label: "窗口 RTP",
width: "minmax(160px, 0.7fr)",
render: (draw) => (
<div className={styles.stack}>
<span>{formatPPM(draw.globalBaseRTPPPM)}</span>
<span className={styles.meta}>整体 {formatPPM(draw.giftBaseRTPPPM)}</span>
</div>
),
},
{
key: "source",
label: "预算来源",
width: "minmax(180px, 0.8fr)",
render: (draw) => budgetSourceLabel(draw),
},
{
key: "flags",
label: "标记",
width: "minmax(150px, 0.65fr)",
render: (draw) => flagLabel(draw),
},
{
key: "createdAtMs",
label: "时间",
width: "minmax(180px, 0.85fr)",
render: (draw) => formatMillis(draw.createdAtMs),
},
];
export function LuckyGiftConfigPage() {
const page = useLuckyGiftPage();
const total = page.draws.total || 0;
return (
<AdminListPage>
<LuckyGiftConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<LuckyGiftSimulationPanel config={page.config} />
<AdminListToolbar
filters={
<>
<TextField
className={styles.filterInput}
label="礼物 ID"
value={page.giftId}
onChange={(event) => page.changeGiftId(event.target.value)}
/>
<TextField
className={styles.filterInput}
label="用户 ID"
value={page.userId}
onChange={(event) => page.changeUserId(event.target.value)}
/>
<TextField
className={styles.filterInput}
label="房间 ID"
value={page.roomId}
onChange={(event) => page.changeRoomId(event.target.value)}
/>
<AdminFilterSelect
label="发放状态"
options={[["", "全部状态"], ...drawStatusOptions]}
value={page.status}
onChange={page.changeStatus}
/>
<AdminFilterResetButton
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
onClick={page.resetDrawFilters}
/>
</>
}
/>
<DataState error={page.drawsError} loading={page.drawsLoading} onRetry={page.reloadDraws}>
<AdminListBody>
<DataTable
columns={columnsBase}
items={page.draws.items || []}
minWidth="1360px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.draws.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(draw) => draw.drawId}
/>
</AdminListBody>
</DataState>
<LuckyGiftConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
</AdminListPage>
);
}
function DrawStatus({ status }) {
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
return (
<span className={["status-badge", `status-badge--${tone}`].join(" ")}>
<span className="status-point" />
{drawStatusOptions.find(([value]) => value === status)?.[1] || status || "-"}
</span>
);
}
function poolLabel(value) {
if (value === "novice") {
return "新手池";
}
if (value === "intermediate") {
return "中级池";
}
if (value === "advanced") {
return "高级池";
}
return value || "-";
}
function flagLabel(draw) {
const labels = [];
if (draw.highMultiplier) {
labels.push("高倍");
}
if (draw.stageFeedback) {
labels.push("阶段反馈");
}
return labels.length ? labels.join(" / ") : "-";
}
function budgetSourceLabel(draw) {
if (draw.activitySubsidyCoins > 0) {
return "活动补贴";
}
if (draw.roomAtmosphereRewardCoins > 0) {
return "房间气氛";
}
if (draw.baseRewardCoins > 0) {
return "基础 RTP";
}
return "-";
}
function formatPPM(value) {
return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`;
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

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

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const luckyGiftRoutes = [
{
label: "幸运礼物",
loader: () => import("./pages/LuckyGiftConfigPage.jsx").then((module) => module.LuckyGiftConfigPage),
menuCode: MENU_CODES.luckyGift,
pageKey: "lucky-gift",
path: "/operations/lucky-gift",
permission: PERMISSIONS.luckyGiftView,
},
];

View File

@ -0,0 +1,33 @@
import { z } from "zod";
export const luckyGiftConfigFormSchema = z
.object({
activityBudget: z.coerce.number().int("活动总预算必须是整数").min(0, "活动总预算不能小于 0"),
activityDailyLimit: z.coerce.number().int("每日预算必须是整数").min(0, "每日预算不能小于 0"),
activitySubsidyEnabled: z.boolean(),
atmosphereLevel: z.enum(["off", "low", "medium", "high"]),
controlStrength: z.enum(["precise", "standard", "natural"]),
enabled: z.boolean(),
jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]),
noviceStrength: z.enum(["weak", "standard", "strong"]),
payoutStyle: z.enum(["stable", "balanced", "exciting"]),
poolMode: z.enum(["platform_guard", "standard", "room_burst", "gift_isolated"]),
riskLevel: z.enum(["strict", "standard", "loose"]),
targetRtpPercent: z.coerce.number().min(1, "RTP 必须大于 1%").max(100, "RTP 不能超过 100%"),
})
.superRefine((value, context) => {
if (!value.activitySubsidyEnabled) {
return;
}
if (value.activityBudget <= 0) {
context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] });
}
if (value.activityBudget > 0 && value.activityDailyLimit <= 0) {
context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] });
}
if (value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) {
context.addIssue({ code: "custom", message: "每日预算不能大于活动总预算", path: ["activityDailyLimit"] });
}
});
export type LuckyGiftConfigForm = z.infer<typeof luckyGiftConfigFormSchema>;

View File

@ -0,0 +1,228 @@
const ppmScale = 1_000_000;
export function runLuckyGiftSimulation(config, input) {
const users = clampInt(input.users, 1, 20_000);
const rooms = clampInt(input.rooms, 1, users);
const costMin = clampInt(input.costMin, 1, 1_000_000_000);
const costMax = Math.max(costMin, clampInt(input.costMax, costMin, 1_000_000_000));
const drawsMin = clampInt(input.drawsMin, 0, 10_000_000);
const drawsMax = Math.max(drawsMin, clampInt(input.drawsMax, drawsMin, 10_000_000));
const targetRTP = Number(config?.targetRTPPPM || 950_000) / ppmScale;
const seed = finiteSeed(input.seed) ?? runtimeSeed();
const random = createRandom(seed + users * 31 + rooms * 17 + costMin + costMax + drawsMin + drawsMax);
const rows = [];
let totalDraws = 0;
let totalSpend = 0;
let rawRewardTotal = 0;
for (let index = 0; index < users; index += 1) {
const drawCount = randomInt(random, drawsMin, drawsMax);
const spend = randomSpend(random, drawCount, costMin, costMax);
const variance = userRTPVolatility(drawCount);
const rawReward = Math.max(0, Math.round(spend * targetRTP * (1 + normal(random) * variance)));
const row = {
drawCount,
finalIncome: 0,
initialIncome: 0,
netIncome: 0,
reward: rawReward,
roomId: `room_${randomInt(random, 1, rooms)}`,
rtp: 0,
spend,
userId: `user_${String(index + 1).padStart(4, "0")}`,
};
rows.push(row);
totalDraws += drawCount;
totalSpend += spend;
rawRewardTotal += rawReward;
}
const controlledRewardTotal = controlledTotalReward(random, totalSpend, totalDraws, targetRTP);
distributeRewardCorrection(rows, controlledRewardTotal - rawRewardTotal, totalSpend);
let totalReward = 0;
let minRTP = Number.POSITIVE_INFINITY;
let maxRTP = 0;
let rtpSum = 0;
let activeUsers = 0;
for (const row of rows) {
row.finalIncome = row.reward;
row.netIncome = row.reward - row.spend;
row.rtp = row.spend > 0 ? (row.reward * 100) / row.spend : 0;
totalReward += row.reward;
if (row.spend > 0) {
activeUsers += 1;
rtpSum += row.rtp;
minRTP = Math.min(minRTP, row.rtp);
maxRTP = Math.max(maxRTP, row.rtp);
}
}
const overview = {
activeUsers,
averageUserRTP: activeUsers > 0 ? rtpSum / activeUsers : 0,
costMax,
costMin,
drawsMax,
drawsMin,
maxUserRTP: activeUsers > 0 ? maxRTP : 0,
minUserRTP: activeUsers > 0 ? minRTP : 0,
rooms,
seed,
targetRTPPercent: targetRTP * 100,
totalDraws,
totalNetIncome: totalReward - totalSpend,
totalReward,
totalRTP: totalSpend > 0 ? (totalReward * 100) / totalSpend : 0,
totalSpend,
users,
};
return { csv: toCSV(rows, overview), overview, rows };
}
function controlledTotalReward(random, totalSpend, totalDraws, targetRTP) {
if (totalSpend <= 0) {
return 0;
}
const scale = Math.max(1, totalDraws / 100_000);
const maxDeviation = totalDraws >= 1_000_000 ? 0.005 : totalDraws >= 100_000 ? 0.01 : 0.02;
const deviation = clamp(normal(random) * (0.003 / Math.sqrt(scale)), -maxDeviation, maxDeviation);
return Math.max(0, Math.round(totalSpend * targetRTP * (1 + deviation)));
}
function distributeRewardCorrection(rows, correction, totalSpend) {
if (!correction || totalSpend <= 0 || rows.length === 0) {
return;
}
let applied = 0;
for (const row of rows) {
const share = Math.round(correction * (row.spend / totalSpend));
row.reward = Math.max(0, row.reward + share);
applied += share;
}
let remaining = correction - applied;
let index = 0;
while (remaining !== 0 && rows.length > 0) {
const delta = remaining > 0 ? 1 : -1;
const row = rows[index % rows.length];
if (delta > 0 || row.reward > 0) {
row.reward += delta;
remaining -= delta;
}
index += 1;
if (index > rows.length * 4 && remaining < 0) {
break;
}
}
}
function randomSpend(random, drawCount, costMin, costMax) {
if (drawCount <= 0) {
return 0;
}
if (drawCount <= 1_000) {
let total = 0;
for (let index = 0; index < drawCount; index += 1) {
total += randomInt(random, costMin, costMax);
}
return total;
}
const mean = drawCount * ((costMin + costMax) / 2);
const rangeSize = costMax - costMin + 1;
const variance = drawCount * ((rangeSize * rangeSize - 1) / 12);
const value = Math.round(mean + normal(random) * Math.sqrt(Math.max(0, variance)));
return clampInt(value, drawCount * costMin, drawCount * costMax);
}
function userRTPVolatility(drawCount) {
if (drawCount <= 0) {
return 0;
}
return Math.min(0.65, 0.36 / Math.sqrt(Math.max(1, drawCount / 100)));
}
function toCSV(rows, overview) {
const header = [
"用户ID",
"房间ID",
"抽奖次数",
"初始收入",
"总消耗",
"最终收入",
"最终净收入",
"RTP",
];
const lines = [
["总览", "", overview.totalDraws, 0, overview.totalSpend, overview.totalReward, overview.totalNetIncome, formatRatio(overview.totalRTP)],
header,
...rows.map((row) => [
row.userId,
row.roomId,
row.drawCount,
row.initialIncome,
row.spend,
row.finalIncome,
row.netIncome,
formatRatio(row.rtp),
]),
];
return lines.map((line) => line.map(csvCell).join(",")).join("\n");
}
function csvCell(value) {
const text = String(value ?? "");
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function createRandom(seed) {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function finiteSeed(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.round(number) : null;
}
function runtimeSeed() {
if (globalThis.crypto?.getRandomValues) {
const values = new Uint32Array(1);
globalThis.crypto.getRandomValues(values);
return values[0];
}
return Math.round(Date.now() + Math.random() * 0xffffffff);
}
function normal(random) {
const u1 = Math.max(Number.EPSILON, random());
const u2 = Math.max(Number.EPSILON, random());
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}
function randomInt(random, min, max) {
return Math.floor(random() * (max - min + 1)) + min;
}
function clampInt(value, min, max) {
const number = Math.round(Number(value || 0));
if (!Number.isFinite(number)) {
return min;
}
return Math.min(max, Math.max(min, number));
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function formatRatio(value) {
return Number(value || 0).toFixed(4);
}

View File

@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { runLuckyGiftSimulation } from "@/features/lucky-gift/simulation.js";
const input = {
costMax: "5000",
costMin: "100",
drawsMax: "1000",
drawsMin: "0",
rooms: "50",
users: "100",
};
describe("lucky gift simulation", () => {
test("uses seed as the simulation batch identity", () => {
const first = runLuckyGiftSimulation(null, { ...input, seed: 1001 });
const sameSeed = runLuckyGiftSimulation(null, { ...input, seed: 1001 });
const nextSeed = runLuckyGiftSimulation(null, { ...input, seed: 1002 });
expect(first.csv).toBe(sameSeed.csv);
expect(first.csv).not.toBe(nextSeed.csv);
});
});

View File

@ -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",
});
});

View File

@ -109,6 +109,19 @@ export interface GiftDto {
regionIds?: number[];
}
export interface GiftTypeDto {
appCode?: string;
tabKey?: string;
displayName?: string;
tabName?: string;
status?: string;
sortOrder?: number;
createdByUserId?: number;
updatedByUserId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface GiftPayload {
giftId: string;
resourceId: number;
@ -129,6 +142,13 @@ export interface GiftPayload {
regionIds: number[];
}
export interface GiftTypePayload {
displayName: string;
tabName: string;
status: string;
sortOrder: number;
}
export interface ResourceGrantItemDto {
createdAtMs?: number;
durationMs?: number;
@ -300,6 +320,24 @@ export function listGifts(query: PageQuery = {}): Promise<ApiPage<GiftDto>> {
});
}
export function listGiftTypes(): Promise<GiftTypeDto[]> {
const endpoint = API_ENDPOINTS.listGiftTypes;
return apiRequest<GiftTypeDto[]>(apiEndpointPath(API_OPERATIONS.listGiftTypes), {
method: endpoint.method,
});
}
export function updateGiftType(tabKey: string, payload: GiftTypePayload): Promise<GiftTypeDto> {
const endpoint = API_ENDPOINTS.updateGiftType;
return apiRequest<GiftTypeDto, GiftTypePayload>(
apiEndpointPath(API_OPERATIONS.updateGiftType, { type_code: tabKey }),
{
body: payload,
method: endpoint.method,
},
);
}
export function createGift(payload: GiftPayload): Promise<GiftDto> {
const endpoint = API_ENDPOINTS.createGift;
return apiRequest<GiftDto, GiftPayload>(apiEndpointPath(API_OPERATIONS.createGift), {

View File

@ -0,0 +1,76 @@
import TextField from "@mui/material/TextField";
import {
AdminFormDialog,
AdminFormList,
AdminFormListRow,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
export function GiftTypeConfigDialog({
disabled,
form,
loading,
onChangeItem,
onClose,
onSubmit,
open,
optionsLoading,
}) {
const items = form?.items || [];
return (
<AdminFormDialog
disabled={disabled}
loading={loading}
open={open}
size="large"
submitDisabled={disabled || loading || optionsLoading}
submitLabel="保存"
title="礼物类型"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="Tab 配置">
<AdminFormList>
{items.map((item, index) => (
<AdminFormListRow
columns="minmax(112px, 0.65fr) minmax(148px, 1fr) minmax(132px, 0.9fr) minmax(88px, 0.5fr) minmax(96px, 0.45fr)"
key={item.tabKey}
>
<TextField disabled label="Tab Key" value={item.tabKey} />
<TextField
disabled={disabled}
label="显示名称"
required
value={item.displayName}
onChange={(event) => onChangeItem(index, { displayName: event.target.value })}
/>
<TextField
disabled={disabled}
label="Tab Name"
required
value={item.tabName}
onChange={(event) => onChangeItem(index, { tabName: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={item.sortOrder}
onChange={(event) => onChangeItem(index, { sortOrder: event.target.value })}
/>
<AdminFormSwitchField
checked={item.enabled}
checkedLabel="开"
disabled={disabled}
label="Tab"
uncheckedLabel="关"
onChange={(enabled) => onChangeItem(index, { enabled })}
/>
</AdminFormListRow>
))}
</AdminFormList>
</AdminFormSection>
</AdminFormDialog>
);
}

View File

@ -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", "成功"],

View File

@ -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 "";

View File

@ -4,6 +4,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
@ -31,22 +32,11 @@ import {
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { resourceStatusFilters } from "@/features/resources/constants.js";
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
const giftTypeOptions = [
["normal", "普通礼物"],
["cp", "CP礼物"],
["lucky", "幸运礼物"],
["super_lucky", "超级幸运礼物"],
["exclusive", "专属礼物"],
["noble", "贵族礼物"],
["flag", "国旗礼物"],
["activity", "活动礼物"],
["magic", "魔法礼物"],
["custom", "定制礼物"],
];
const chargeAssetOptions = [
["COIN", "金币"],
["DIAMOND", "钻石"],
@ -59,7 +49,7 @@ const effectOptions = [
const effectLabels = Object.fromEntries(effectOptions);
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
const baseColumns = [
const baseColumns = (giftTypeOptions) => [
{
key: "gift",
label: "礼物",
@ -78,7 +68,7 @@ const baseColumns = [
width: "minmax(150px, 0.75fr)",
render: (gift) => (
<div className={styles.stack}>
<span>{giftTypeLabel(gift.giftTypeCode)}</span>
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
<span className={styles.meta}>
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
</span>
@ -137,7 +127,8 @@ export function GiftListPage() {
const isEditing = page.activeAction === "edit";
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
const regionLabelById = buildRegionLabelMap(giftRegionOptions);
const tableColumns = baseColumns.map((column) => {
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
const tableColumns = baseColumns(giftTypeOptions).map((column) => {
if (column.key === "regions") {
return {
...column,
@ -185,11 +176,18 @@ export function GiftListPage() {
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreateGift ? (
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
<>
{page.abilities.canUpdateGift ? (
<AdminActionIconButton label="礼物类型" onClick={page.openGiftTypeDialog}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canCreateGift ? (
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null}
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -223,6 +221,16 @@ export function GiftListPage() {
onClose={page.closeAction}
onSubmit={page.submitGift}
/>
<GiftTypeConfigDialog
disabled={!page.abilities.canUpdateGift}
form={page.giftTypeForm}
loading={page.loadingAction === "gift-types"}
open={page.giftTypeDialogOpen}
optionsLoading={page.giftTypesLoading}
onChangeItem={page.updateGiftTypeFormItem}
onClose={page.closeGiftTypeDialog}
onSubmit={page.submitGiftTypes}
/>
</AdminListPage>
);
}
@ -254,7 +262,8 @@ function GiftStatusSwitch({ gift, page }) {
}
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions;
const submitDisabled =
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
return (
<AdminFormDialog
@ -301,16 +310,16 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
</>
) : null}
<TextField
disabled={disabled}
disabled={disabled || page.giftTypesLoading}
label="礼物类型"
required
select
value={form.giftTypeCode}
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
>
{giftTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
{page.giftTypeOptions.map((item) => (
<MenuItem key={item.tabKey} value={item.tabKey}>
{item.displayName || item.tabKey}
</MenuItem>
))}
</TextField>
@ -645,8 +654,8 @@ function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function giftTypeLabel(value) {
return giftTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "普通礼物";
function giftTypeLabel(value, options = defaultGiftTypeOptions) {
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
}
function chargeAssetLabel(value) {

View File

@ -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) {

View File

@ -0,0 +1,152 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export interface SevenDayCheckInRewardDto {
dayIndex: number;
resourceGroupId: number;
}
export interface SevenDayCheckInConfigDto {
appCode?: string;
enabled: boolean;
configVersion: number;
rewards: SevenDayCheckInRewardDto[];
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface SevenDayCheckInConfigPayload {
enabled: boolean;
rewards: Array<{
day_index: number;
resource_group_id: number;
}>;
}
export interface SevenDayCheckInClaimUserDto {
userId?: number;
displayUserId?: string;
username?: string;
avatar?: string;
}
export interface SevenDayCheckInClaimDto {
claimId: string;
commandId?: string;
userId: number;
user?: SevenDayCheckInClaimUserDto;
cycleNo: number;
checkinDay?: string;
dayIndex: number;
configVersion: number;
resourceGroupId: number;
walletCommandId?: string;
walletGrantId?: string;
status?: string;
failureReason?: string;
signedAtMs?: number;
grantedAtMs?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
type RawReward = SevenDayCheckInRewardDto & Record<string, unknown>;
type RawConfig = Omit<SevenDayCheckInConfigDto, "rewards"> & { rewards?: RawReward[] } & Record<string, unknown>;
type RawClaim = SevenDayCheckInClaimDto & Record<string, unknown>;
export function getSevenDayCheckInConfig(): Promise<SevenDayCheckInConfigDto> {
const endpoint = API_ENDPOINTS.getSevenDayCheckInConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getSevenDayCheckInConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateSevenDayCheckInConfig(payload: SevenDayCheckInConfigPayload): Promise<SevenDayCheckInConfigDto> {
const endpoint = API_ENDPOINTS.updateSevenDayCheckInConfig;
return apiRequest<RawConfig, SevenDayCheckInConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateSevenDayCheckInConfig),
{
body: payload,
method: endpoint.method,
},
).then(normalizeConfig);
}
export function listSevenDayCheckInClaims(query: PageQuery = {}): Promise<ApiPage<SevenDayCheckInClaimDto>> {
const endpoint = API_ENDPOINTS.listSevenDayCheckInClaims;
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listSevenDayCheckInClaims), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeClaim),
}));
}
function normalizeConfig(item: RawConfig): SevenDayCheckInConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
enabled: Boolean(item.enabled),
configVersion: numberValue(item.configVersion ?? item.config_version),
rewards: normalizeRewards(item.rewards),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeRewards(rewards?: RawReward[]) {
const byDay = new Map<number, SevenDayCheckInRewardDto>();
for (const reward of rewards || []) {
const dayIndex = numberValue(reward.dayIndex ?? reward.day_index);
if (dayIndex >= 1 && dayIndex <= 7) {
byDay.set(dayIndex, {
dayIndex,
resourceGroupId: numberValue(reward.resourceGroupId ?? reward.resource_group_id),
});
}
}
return Array.from({ length: 7 }, (_, index) => {
const dayIndex = index + 1;
return byDay.get(dayIndex) || { dayIndex, resourceGroupId: 0 };
});
}
function normalizeClaim(item: RawClaim): SevenDayCheckInClaimDto {
const rawUser = (item.user || {}) as Record<string, unknown>;
return {
claimId: stringValue(item.claimId ?? item.claim_id),
commandId: stringValue(item.commandId ?? item.command_id),
userId: numberValue(item.userId ?? item.user_id),
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},
cycleNo: numberValue(item.cycleNo ?? item.cycle_no),
checkinDay: stringValue(item.checkinDay ?? item.checkin_day),
dayIndex: numberValue(item.dayIndex ?? item.day_index),
configVersion: numberValue(item.configVersion ?? item.config_version),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
status: stringValue(item.status),
failureReason: stringValue(item.failureReason ?? item.failure_reason),
signedAtMs: numberValue(item.signedAtMs ?? item.signed_at_ms),
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,77 @@
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import styles from "@/features/seven-day-checkin/seven-day-checkin.module.css";
export function SevenDayCheckInConfigDrawer({
abilities,
configLoading,
configSaving,
form,
onClose,
onSubmit,
open,
resourceGroups,
setForm,
}) {
const disabled = !abilities.canUpdate || configLoading || configSaving;
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>七日签到配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">活动状态</div>
<div className="form-drawer__grid">
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="七日签到状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">奖励内容</div>
<div className={styles.rewardEditor}>
{form.rewards.map((reward, index) => (
<div className={styles.rewardEditorRow} key={reward.dayIndex}>
<span className={styles.dayLabel}> {reward.dayIndex} </span>
<ResourceGroupSelectField
disabled={disabled}
groups={resourceGroups}
value={reward.resourceGroupId}
onChange={(value) =>
setForm((current) => ({
...current,
rewards: current.rewards.map((item, itemIndex) =>
itemIndex === index ? { ...item, resourceGroupId: value } : item,
),
}))
}
/>
</div>
))}
</div>
</section>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</form>
</Drawer>
);
}

View File

@ -0,0 +1,99 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/seven-day-checkin/seven-day-checkin.module.css";
export function SevenDayCheckInConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh, resourceGroups }) {
return (
<section className={styles.summaryPanel}>
<div className={styles.summaryScrollArea}>
<div className={styles.summaryContent}>
<div className={styles.summaryHeader}>
<span className={styles.title}>七日签到配置</span>
</div>
<div className={styles.summaryItems}>
<ConfigStatus config={config} loading={configLoading} />
<SummaryItem label="更新时间">
{config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"}
</SummaryItem>
</div>
<RewardPreview config={config} loading={configLoading} resourceGroups={resourceGroups} />
</div>
</div>
<div className={styles.summaryActions}>
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<Button
disabled={!canUpdate || configLoading}
startIcon={<EditOutlined fontSize="small" />}
type="button"
variant="primary"
onClick={onEdit}
>
编辑配置
</Button>
</div>
</section>
);
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function ConfigStatus({ config, loading }) {
if (!config) {
return (
<span className="status-badge status-badge--stopped">
<span className="status-point" />
{loading ? "加载中" : "-"}
</span>
);
}
return (
<span
className={["status-badge", config.enabled ? "status-badge--running" : "status-badge--stopped"].join(" ")}
>
<span className="status-point" />
{config.enabled ? "已开启" : "已关闭"}
</span>
);
}
function RewardPreview({ config, loading, resourceGroups }) {
if (loading && !config) {
return <div className={styles.rewardPreview}>加载中</div>;
}
const rewards = config?.rewards || [];
return (
<div className={styles.rewardPreview}>
{Array.from({ length: 7 }, (_, index) => {
const dayIndex = index + 1;
const reward = rewards.find((item) => Number(item.dayIndex) === dayIndex);
return (
<span className={styles.rewardPill} key={dayIndex}>
<span> {dayIndex} </span>
<span>{resourceGroupName(resourceGroups, reward?.resourceGroupId)}</span>
</span>
);
})}
</div>
);
}
function resourceGroupName(groups, id) {
if (!id) {
return "未配置";
}
const group = groups.find((item) => Number(item.groupId) === Number(id));
return group?.name || group?.groupCode || `#${id}`;
}

View File

@ -0,0 +1,183 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listResourceGroups } from "@/features/resources/api";
import {
getSevenDayCheckInConfig,
listSevenDayCheckInClaims,
updateSevenDayCheckInConfig,
} from "@/features/seven-day-checkin/api";
import { useSevenDayCheckInAbilities } from "@/features/seven-day-checkin/permissions.js";
import { sevenDayCheckInConfigFormSchema } from "@/features/seven-day-checkin/schema";
const pageSize = 10;
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = {
enabled: false,
rewards: Array.from({ length: 7 }, (_, index) => ({
dayIndex: index + 1,
resourceGroupId: "",
})),
};
export function useSevenDayCheckInPage() {
const abilities = useSevenDayCheckInAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(null);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [resourceGroups, setResourceGroups] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [checkinDay, setCheckinDay] = useState("");
const [dayIndex, setDayIndex] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(
() => ({ checkin_day: checkinDay, day_index: dayIndex, keyword: query, status }),
[checkinDay, dayIndex, query, status],
);
const {
data: claims = emptyClaims,
error: claimsError,
loading: claimsLoading,
reload: reloadClaims,
} = usePaginatedQuery({
errorMessage: "加载签到记录失败",
fetcher: listSevenDayCheckInClaims,
filters,
page,
pageSize,
queryKey: ["seven-day-checkin-claims", filters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const [config, groups] = await Promise.all([
getSevenDayCheckInConfig(),
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
]);
setConfig(config);
setForm(formFromConfig(config));
setResourceGroups(groups.items || []);
} catch (err) {
showToast(err.message || "加载七日签到配置失败", "error");
} finally {
setConfigLoading(false);
}
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
const openConfigDrawer = useCallback(() => {
setForm(config ? formFromConfig(config) : emptyForm);
setConfigDrawerOpen(true);
}, [config]);
const closeConfigDrawer = useCallback(() => {
setConfigDrawerOpen(false);
setForm(config ? formFromConfig(config) : emptyForm);
}, [config]);
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeCheckinDay = (value) => {
setCheckinDay(value);
setPage(1);
};
const changeDayIndex = (value) => {
setDayIndex(value);
setPage(1);
};
const submitConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
setConfigSaving(true);
try {
const parsed = parseForm(sevenDayCheckInConfigFormSchema, form);
const saved = await updateSevenDayCheckInConfig(payloadFromForm(parsed));
setConfig(saved);
setForm(formFromConfig(saved));
setConfigDrawerOpen(false);
showToast("七日签到配置已保存", "success");
await reloadClaims();
} catch (err) {
showToast(err.message || "保存七日签到配置失败", "error");
} finally {
setConfigSaving(false);
}
};
return {
abilities,
changeCheckinDay,
changeDayIndex,
changeQuery,
changeStatus,
checkinDay,
claims,
claimsError,
claimsLoading,
closeConfigDrawer,
config,
configDrawerOpen,
configLoading,
configSaving,
dayIndex,
form,
openConfigDrawer,
page,
query,
reloadClaims,
reloadConfig,
resourceGroups,
setForm,
setPage,
status,
submitConfig,
};
}
function formFromConfig(config) {
return {
enabled: Boolean(config?.enabled),
rewards: Array.from({ length: 7 }, (_, index) => {
const dayIndex = index + 1;
const reward = config?.rewards?.find((item) => Number(item.dayIndex) === dayIndex);
return {
dayIndex,
resourceGroupId: reward?.resourceGroupId ? String(reward.resourceGroupId) : "",
};
}),
};
}
function payloadFromForm(form) {
return {
enabled: Boolean(form.enabled),
rewards: form.rewards
.map((reward) => ({
day_index: Number(reward.dayIndex),
resource_group_id: Number(reward.resourceGroupId || 0),
}))
.sort((left, right) => left.day_index - right.day_index),
};
}

View File

@ -0,0 +1,230 @@
import Avatar from "@mui/material/Avatar";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { SevenDayCheckInConfigDrawer } from "@/features/seven-day-checkin/components/SevenDayCheckInConfigDrawer.jsx";
import { SevenDayCheckInConfigSummary } from "@/features/seven-day-checkin/components/SevenDayCheckInConfigSummary.jsx";
import { useSevenDayCheckInPage } from "@/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js";
import styles from "@/features/seven-day-checkin/seven-day-checkin.module.css";
const claimStatusOptions = [
["pending", "发放中"],
["granted", "已发放"],
["failed", "发放失败"],
];
const dayOptions = Array.from({ length: 7 }, (_, index) => [String(index + 1), `${index + 1}`]);
const columnsBase = [
{
key: "user",
label: "用户信息",
width: "minmax(260px, 1.15fr)",
render: (claim) => <ClaimUser claim={claim} />,
},
{
key: "cycle",
label: "签到周期",
width: "minmax(180px, 0.8fr)",
render: (claim) => (
<div className={styles.stack}>
<span> {claim.cycleNo || 0} </span>
<span className={styles.meta}>{claim.checkinDay || "-"}</span>
</div>
),
},
{
key: "dayIndex",
label: "签到天数",
width: "minmax(120px, 0.55fr)",
render: (claim) => `${claim.dayIndex || 0}`,
},
{
key: "reward",
label: "奖励资源组",
width: "minmax(180px, 0.85fr)",
render: (claim, _index, context) => (
<ResourceGroupLabel groups={context.resourceGroups} id={claim.resourceGroupId} />
),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (claim) => <ClaimStatus status={claim.status} />,
},
{
key: "time",
label: "签到时间",
width: "minmax(180px, 0.85fr)",
render: (claim) => formatMillis(claim.signedAtMs || claim.createdAtMs),
},
{
key: "grant",
label: "钱包发放",
width: "minmax(220px, 1fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.walletGrantId || "-"}</span>
<span className={styles.meta}>{claim.walletCommandId || "-"}</span>
</div>
),
},
{
key: "claim",
label: "签到记录",
width: "minmax(240px, 1fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.claimId}</span>
<span className={styles.meta}>{claim.commandId || "-"}</span>
</div>
),
},
{
key: "failure",
label: "失败原因",
width: "minmax(180px, 0.85fr)",
render: (claim) => claim.failureReason || "-",
},
];
export function SevenDayCheckInPage() {
const page = useSevenDayCheckInPage();
const total = page.claims.total || 0;
const columns = columnsBase.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID、短号、名称",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: [["", "全部状态"], ...claimStatusOptions],
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
if (column.key === "dayIndex") {
return {
...column,
filter: createOptionsColumnFilter({
options: [["", "全部天数"], ...dayOptions],
placeholder: "搜索天数",
value: page.dayIndex,
onChange: page.changeDayIndex,
}),
};
}
if (column.key === "cycle") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "YYYY-MM-DD",
value: page.checkinDay,
onChange: page.changeCheckinDay,
}),
};
}
return column;
});
return (
<AdminListPage>
<SevenDayCheckInConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
resourceGroups={page.resourceGroups}
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
<AdminListBody>
<DataTable
columns={columns}
context={{ resourceGroups: page.resourceGroups }}
items={page.claims.items || []}
minWidth="1540px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.claims.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(claim) => claim.claimId}
/>
</AdminListBody>
</DataState>
<SevenDayCheckInConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
resourceGroups={page.resourceGroups}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
</AdminListPage>
);
}
function ClaimUser({ claim }) {
const user = claim.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(claim.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}
</span>
</div>
</div>
);
}
function ResourceGroupLabel({ groups, id }) {
const group = groups.find((item) => Number(item.groupId) === Number(id));
return (
<div className={styles.stack}>
<span>{group?.name || group?.groupCode || (id ? `#${id}` : "-")}</span>
<span className={styles.meta}>资源组</span>
</div>
);
}
function ClaimStatus({ status }) {
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{claimStatusLabel(status)}
</span>
);
}
function claimStatusLabel(status) {
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
}

View File

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

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const sevenDayCheckInRoutes = [
{
label: "七日签到",
loader: () => import("./pages/SevenDayCheckInPage.jsx").then((module) => module.SevenDayCheckInPage),
menuCode: MENU_CODES.sevenDayCheckIn,
pageKey: "seven-day-checkin",
path: "/activities/seven-day-checkin",
permission: PERMISSIONS.sevenDayCheckInView,
},
];

View File

@ -0,0 +1,35 @@
import { z } from "zod";
const resourceGroupIdInput = z.union([z.string(), z.number()]);
export const sevenDayCheckInConfigFormSchema = z
.object({
enabled: z.boolean(),
rewards: z
.array(
z.object({
dayIndex: z.number().int().min(1).max(7),
resourceGroupId: resourceGroupIdInput,
}),
)
.length(7, "必须配置 7 天奖励"),
})
.superRefine((value, context) => {
const seen = new Set<number>();
for (const reward of value.rewards) {
if (seen.has(reward.dayIndex)) {
context.addIssue({ code: "custom", message: "签到天数不能重复", path: ["rewards"] });
}
seen.add(reward.dayIndex);
const resourceGroupId = Number(reward.resourceGroupId || 0);
if (!Number.isInteger(resourceGroupId) || resourceGroupId <= 0) {
context.addIssue({
code: "custom",
message: `${reward.dayIndex} 天必须选择资源组`,
path: ["rewards", reward.dayIndex - 1, "resourceGroupId"],
});
}
}
});
export type SevenDayCheckInConfigForm = z.infer<typeof sevenDayCheckInConfigFormSchema>;

View File

@ -0,0 +1,183 @@
.summaryPanel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.summaryScrollArea {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
}
.summaryContent {
display: grid;
width: 100%;
min-width: max-content;
grid-template-columns: auto auto max-content;
align-items: center;
gap: var(--space-5);
}
.summaryHeader {
display: flex;
min-width: 0;
align-items: center;
}
.summaryActions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
align-self: stretch;
padding-left: var(--space-3);
border-left: 1px solid var(--border);
background: var(--bg-card);
white-space: nowrap;
}
.summaryItems {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-5);
}
.summaryItem {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.summaryLabel {
flex: 0 0 auto;
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
line-height: var(--admin-line-height);
text-overflow: ellipsis;
white-space: nowrap;
}
.summaryValue {
display: inline-flex;
min-width: 0;
align-items: center;
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
line-height: var(--admin-line-height);
text-overflow: ellipsis;
white-space: nowrap;
}
.rewardPreview {
display: grid;
min-width: 0;
grid-template-columns: repeat(7, 124px);
gap: var(--space-2);
}
.rewardPill {
display: grid;
min-width: 0;
gap: 2px;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-muted);
}
.rewardPill span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rewardPill span:first-child {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.rewardPill span:last-child {
color: var(--text-primary);
font-weight: 700;
}
.rewardEditor {
display: grid;
gap: var(--space-3);
}
.rewardEditorRow {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
}
.dayLabel {
color: var(--text-secondary);
font-weight: 700;
white-space: nowrap;
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.title,
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.summaryPanel {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
.summaryActions {
justify-content: flex-start;
padding-top: var(--space-3);
padding-left: 0;
border-top: 1px solid var(--border);
border-left: 0;
}
.summaryContent {
gap: var(--space-4);
}
}

View File

@ -78,12 +78,14 @@ export const API_OPERATIONS = {
exportOperationLogs: "exportOperationLogs",
exportUsers: "exportUsers",
getCountry: "getCountry",
getLuckyGiftConfig: "getLuckyGiftConfig",
getRegion: "getRegion",
getRegistrationRewardConfig: "getRegistrationRewardConfig",
getResource: "getResource",
getResourceGroup: "getResourceGroup",
getRoleDataScopes: "getRoleDataScopes",
getRoomConfig: "getRoomConfig",
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
getUser: "getUser",
grantResource: "grantResource",
grantResourceGroup: "grantResourceGroup",
@ -102,11 +104,13 @@ export const API_OPERATIONS = {
listEmojiPackCategories: "listEmojiPackCategories",
listEmojiPacks: "listEmojiPacks",
listGifts: "listGifts",
listGiftTypes: "listGiftTypes",
listH5Links: "listH5Links",
listHosts: "listHosts",
listJobs: "listJobs",
listLevelConfig: "listLevelConfig",
listLoginLogs: "listLoginLogs",
listLuckyGiftDraws: "listLuckyGiftDraws",
listOperationLogs: "listOperationLogs",
listPermissions: "listPermissions",
listPlatforms: "listPlatforms",
@ -121,6 +125,7 @@ export const API_OPERATIONS = {
listRoles: "listRoles",
listRoomPins: "listRoomPins",
listRooms: "listRooms",
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
listSystemMenus: "listSystemMenus",
listTaskDefinitions: "listTaskDefinitions",
listUsers: "listUsers",
@ -150,6 +155,7 @@ export const API_OPERATIONS = {
updateCatalog: "updateCatalog",
updateCountry: "updateCountry",
updateGift: "updateGift",
updateGiftType: "updateGiftType",
updateH5Links: "updateH5Links",
updateMenu: "updateMenu",
updateMenuVisible: "updateMenuVisible",
@ -164,6 +170,7 @@ export const API_OPERATIONS = {
updateRole: "updateRole",
updateRoom: "updateRoom",
updateRoomConfig: "updateRoomConfig",
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
updateTaskDefinition: "updateTaskDefinition",
updateTier: "updateTier",
updateTrack: "updateTrack",
@ -171,6 +178,7 @@ export const API_OPERATIONS = {
updateUserStatus: "updateUserStatus",
uploadFile: "uploadFile",
uploadImage: "uploadImage",
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
upsertRule: "upsertRule"
} as const;
@ -630,6 +638,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "country:view",
permissions: ["country:view"]
},
getLuckyGiftConfig: {
method: "GET",
operationId: API_OPERATIONS.getLuckyGiftConfig,
path: "/v1/admin/activity/lucky-gifts/config",
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
getRegion: {
method: "GET",
operationId: API_OPERATIONS.getRegion,
@ -672,6 +687,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room-config:view",
permissions: ["room-config:view"]
},
getSevenDayCheckInConfig: {
method: "GET",
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
path: "/v1/admin/activity/seven-day-checkin/config",
permission: "seven-day-checkin:view",
permissions: ["seven-day-checkin:view"]
},
getUser: {
method: "GET",
operationId: API_OPERATIONS.getUser,
@ -796,6 +818,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "gift:view",
permissions: ["gift:view"]
},
listGiftTypes: {
method: "GET",
operationId: API_OPERATIONS.listGiftTypes,
path: "/v1/admin/gift-types",
permission: "gift:view",
permissions: ["gift:view"]
},
listH5Links: {
method: "GET",
operationId: API_OPERATIONS.listH5Links,
@ -831,6 +860,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "log:view",
permissions: ["log:view"]
},
listLuckyGiftDraws: {
method: "GET",
operationId: API_OPERATIONS.listLuckyGiftDraws,
path: "/v1/admin/activity/lucky-gifts/draws",
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
listOperationLogs: {
method: "GET",
operationId: API_OPERATIONS.listOperationLogs,
@ -929,6 +965,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room:view",
permissions: ["room:view"]
},
listSevenDayCheckInClaims: {
method: "GET",
operationId: API_OPERATIONS.listSevenDayCheckInClaims,
path: "/v1/admin/activity/seven-day-checkin/claims",
permission: "seven-day-checkin:view",
permissions: ["seven-day-checkin:view"]
},
listSystemMenus: {
method: "GET",
operationId: API_OPERATIONS.listSystemMenus,
@ -1120,6 +1163,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "gift:update",
permissions: ["gift:update"]
},
updateGiftType: {
method: "PUT",
operationId: API_OPERATIONS.updateGiftType,
path: "/v1/admin/gift-types/{type_code}",
permission: "gift:update",
permissions: ["gift:update"]
},
updateH5Links: {
method: "PUT",
operationId: API_OPERATIONS.updateH5Links,
@ -1218,6 +1268,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room-config:update",
permissions: ["room-config:update"]
},
updateSevenDayCheckInConfig: {
method: "PUT",
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
path: "/v1/admin/activity/seven-day-checkin/config",
permission: "seven-day-checkin:update",
permissions: ["seven-day-checkin:update"]
},
updateTaskDefinition: {
method: "PUT",
operationId: API_OPERATIONS.updateTaskDefinition,
@ -1267,6 +1324,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "upload:create",
permissions: ["upload:create"]
},
upsertLuckyGiftConfig: {
method: "PUT",
operationId: API_OPERATIONS.upsertLuckyGiftConfig,
path: "/v1/admin/activity/lucky-gifts/config",
permission: "lucky-gift:update",
permissions: ["lucky-gift:update"]
},
upsertRule: {
method: "PUT",
operationId: API_OPERATIONS.upsertRule,

View File

@ -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;

View File

@ -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 {

View File

@ -227,8 +227,8 @@ export function DataTable({
<HeaderLabel
column={column}
trailingAction={
isLastColumn && hasColumnFilters ? (
<TableFilterResetButton disabled={!hasActiveColumnFilters} onClick={resetColumnFilters} />
isLastColumn && hasColumnFilters && hasActiveColumnFilters ? (
<TableFilterResetButton onClick={resetColumnFilters} />
) : null
}
onOpenFilter={openColumnFilter}
@ -375,13 +375,12 @@ function FilterHeaderButton({ column, onOpenFilter }) {
);
}
function TableFilterResetButton({ disabled, onClick }) {
function TableFilterResetButton({ onClick }) {
return (
<Tooltip arrow title="重置筛选">
<span className="admin-table-filter-reset-wrap">
<IconButton
className="admin-table-filter-reset"
disabled={disabled}
label="重置筛选"
sx={tableFilterResetSx}
onClick={(event) => {

View File

@ -27,6 +27,40 @@ test("rerenders table timestamp cells when display timezone changes", async () =
expect(screen.getByText("2026-05-09 19:21:00")).toBeInTheDocument();
});
test("only shows the table filter reset action when filters are active", () => {
const columns = [
{
filter: { onChange: () => {}, type: "text", value: "" },
key: "name",
label: "名称"
},
{
key: "actions",
label: "操作"
}
];
const { rerender } = render(
<TimeZoneProvider>
<DataTable columns={columns} items={[{ id: 1, name: "Banner" }]} rowKey={(item) => item.id} />
</TimeZoneProvider>
);
expect(screen.queryByRole("button", { name: "重置筛选" })).not.toBeInTheDocument();
rerender(
<TimeZoneProvider>
<DataTable
columns={[{ ...columns[0], filter: { ...columns[0].filter, value: "Banner" } }, columns[1]]}
items={[{ id: 1, name: "Banner" }]}
rowKey={(item) => item.id}
/>
</TimeZoneProvider>
);
expect(screen.getByRole("button", { name: "重置筛选" })).toBeInTheDocument();
});
function TimezoneTableFixture() {
const { setTimeZone } = useTimeZone();
const columns = [

View File

@ -0,0 +1,197 @@
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import TextField from "@mui/material/TextField";
import { useState } from "react";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import styles from "@/shared/ui/ResourceGroupSelectDrawer.module.css";
export function ResourceGroupSelectField({
allowEmpty = false,
disabled = false,
drawerTitle = "选择资源组",
emptyLabel = "不配置",
groups = [],
label = "资源组",
onChange = () => {},
placeholder = "请选择资源组",
value,
}) {
const [open, setOpen] = useState(false);
const normalizedValue = String(value || "");
const selectedGroup = groups.find((group) => String(group.groupId) === normalizedValue);
const displayValue = selectedGroup
? resourceGroupName(selectedGroup)
: normalizedValue
? `资源组 #${normalizedValue}`
: "";
const openDrawer = () => {
if (!disabled) {
setOpen(true);
}
};
const selectGroup = (nextValue, group = null) => {
onChange(nextValue, group);
setOpen(false);
};
return (
<>
<TextField
fullWidth
className={styles.field}
disabled={disabled}
label={label}
placeholder={placeholder}
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
value={displayValue}
onClick={openDrawer}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer();
}
}}
/>
<SideDrawer
className={styles.drawer}
contentClassName={styles.drawerBody}
open={open}
title={drawerTitle}
width="wide"
onClose={() => setOpen(false)}
>
<div className={styles.groupGrid}>
{allowEmpty ? (
<button
className={[
styles.groupCard,
styles.emptyCard,
normalizedValue ? "" : styles.groupCardSelected,
]
.filter(Boolean)
.join(" ")}
type="button"
onClick={() => selectGroup("")}
>
<span className={styles.groupIcon}>
<CategoryOutlined fontSize="small" />
</span>
<span className={styles.groupName}>{emptyLabel}</span>
</button>
) : null}
{groups.map((group) => {
const groupValue = String(group.groupId);
const selected = groupValue === normalizedValue;
return (
<button
className={[styles.groupCard, selected ? styles.groupCardSelected : ""]
.filter(Boolean)
.join(" ")}
key={group.groupId}
type="button"
onClick={() => selectGroup(groupValue, group)}
>
<GroupIcon group={group} />
<span className={styles.groupName}>{resourceGroupName(group)}</span>
<span className={styles.groupMeta}>{group.groupCode || `ID ${group.groupId}`}</span>
<GroupItems group={group} />
</button>
);
})}
{!groups.length && !allowEmpty ? <div className={styles.emptyState}>当前无数据</div> : null}
</div>
</SideDrawer>
</>
);
}
function GroupIcon({ group }) {
const item = firstGroupItem(group);
const imageUrl = item?.resource?.previewUrl || item?.resource?.assetUrl || item?.resource?.animationUrl;
if (imageUrl) {
return (
<span className={styles.groupIcon}>
<img alt="" src={imageUrl} />
</span>
);
}
if (item?.itemType === "wallet_asset" && item.walletAssetType === "COIN") {
return (
<span className={styles.groupIcon}>
<MonetizationOnOutlined fontSize="small" />
</span>
);
}
if (item?.itemType === "wallet_asset" && item.walletAssetType === "DIAMOND") {
return (
<span className={styles.groupIcon}>
<DiamondOutlined fontSize="small" />
</span>
);
}
if (item?.itemType === "resource") {
return (
<span className={styles.groupIcon}>
<Inventory2Outlined fontSize="small" />
</span>
);
}
return (
<span className={styles.groupIcon}>
<CategoryOutlined fontSize="small" />
</span>
);
}
function GroupItems({ group }) {
const items = (group.items || []).slice(0, 3);
if (!items.length) {
return <span className={styles.groupMeta}>无资源条目</span>;
}
return (
<span className={styles.groupItems}>
{items.map((item, index) => (
<span className={styles.groupItem} key={item.groupItemId || `${item.itemType}-${index}`}>
{groupItemLabel(item)}
</span>
))}
{(group.items || []).length > items.length ? (
<span className={styles.groupItem}>+{(group.items || []).length - items.length}</span>
) : null}
</span>
);
}
function firstGroupItem(group) {
return (
(group.items || []).find((item) => item?.resource?.previewUrl || item?.resource?.assetUrl) || group.items?.[0]
);
}
function groupItemLabel(item) {
if (item.itemType === "wallet_asset") {
const label =
item.walletAssetType === "DIAMOND" ? "钻石" : item.walletAssetType === "COIN" ? "金币" : "钱包资产";
return `${label} ${formatNumber(item.walletAssetAmount)}`;
}
return item.resource?.name || item.resource?.resourceCode || `资源 ${item.resourceId || "-"}`;
}
function resourceGroupName(group) {
return group.name || group.groupCode || `资源组 #${group.groupId}`;
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -0,0 +1,151 @@
.field :global(.MuiInputBase-input:not(:disabled)) {
cursor: pointer;
}
.drawer.drawer {
width: min(760px, 100vw);
height: 100%;
}
.drawerBody {
flex: 1;
min-height: 0;
}
.groupGrid {
display: grid;
min-height: 0;
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
gap: var(--space-3);
overflow-y: auto;
padding-right: var(--space-1);
}
.groupCard {
display: grid;
min-width: 0;
min-height: 136px;
align-content: start;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.groupCard:hover {
border-color: var(--primary-border);
background: var(--primary-surface);
box-shadow: var(--shadow-soft);
transform: translateY(-1px);
}
.groupCard:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.groupCardSelected,
.groupCardSelected:hover {
border-color: var(--primary);
background: var(--primary-surface-strong);
box-shadow: inset 0 0 0 1px var(--primary-border);
}
.emptyCard {
min-height: 104px;
}
.groupIcon {
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.groupIcon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.groupName,
.groupMeta,
.groupItem {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupName {
color: var(--text-primary);
font-weight: 700;
}
.groupMeta {
color: var(--text-tertiary);
font-size: 13px;
}
.groupItems {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: var(--space-1);
}
.groupItem {
display: inline-flex;
max-width: 100%;
height: var(--admin-tag-height);
align-items: center;
padding: 0 var(--space-2);
border: 1px solid var(--admin-tag-border);
border-radius: var(--admin-tag-radius);
background: var(--admin-tag-bg);
color: var(--admin-tag-color);
font-size: 12px;
font-weight: 650;
}
.emptyState {
display: flex;
min-height: 180px;
grid-column: 1 / -1;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-weight: 650;
}
@media (max-width: 640px) {
.groupGrid {
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.groupCard {
transition: none;
}
.groupCard:hover {
transform: none;
}
}

View File

@ -18,7 +18,7 @@ const quickRanges = [
];
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
export function TimeRangeFilter({ className = "", label = "时间", onChange, value }) {
export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value }) {
const range = useMemo(() => normalizeRange(value), [value]);
const [anchorEl, setAnchorEl] = useState(null);
const [draft, setDraft] = useState(() => range);
@ -41,6 +41,9 @@ export function TimeRangeFilter({ className = "", label = "时间", onChange, va
const displayLabel = useMemo(() => timeRangeLabel(label, range), [label, range]);
const openPopover = (event) => {
if (disabled) {
return;
}
setDraft(range);
setActiveField("start");
setViewDate(dateForField(range, "start", new Date()));
@ -111,6 +114,7 @@ export function TimeRangeFilter({ className = "", label = "时间", onChange, va
startIcon={<AccessTimeOutlined fontSize="small" />}
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={openPopover}
>
<span className={styles.triggerText}>{displayLabel}</span>
@ -126,7 +130,11 @@ export function TimeRangeFilter({ className = "", label = "时间", onChange, va
<form className={styles.panel} onSubmit={submitDraft}>
<div className={styles.quickRanges}>
{quickRanges.map(([quickLabel, durationMs]) => (
<Button key={quickLabel} className={styles.quickButton} onClick={() => applyQuickRange(durationMs)}>
<Button
key={quickLabel}
className={styles.quickButton}
onClick={() => applyQuickRange(durationMs)}
>
{quickLabel}
</Button>
))}