This commit is contained in:
zhx 2026-05-22 11:58:51 +08:00
parent 1330aea332
commit 1e2b2eea3b
31 changed files with 1829 additions and 221 deletions

View File

@ -202,6 +202,20 @@
]
}
},
"/admin/activity/first-recharge-reward/claims": {
"get": {
"operationId": "listFirstRechargeRewardClaims",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "first-recharge-reward:view",
"x-permissions": [
"first-recharge-reward:view"
]
}
},
"/admin/activity/registration-reward/claims": {
"get": {
"operationId": "listRegistrationRewardClaims",
@ -216,6 +230,32 @@
]
}
},
"/admin/activity/first-recharge-reward/config": {
"get": {
"operationId": "getFirstRechargeRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "first-recharge-reward:view",
"x-permissions": [
"first-recharge-reward:view"
]
},
"put": {
"operationId": "updateFirstRechargeRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "first-recharge-reward:update",
"x-permissions": [
"first-recharge-reward:update"
]
}
},
"/admin/activity/registration-reward/config": {
"get": {
"operationId": "getRegistrationRewardConfig",

View File

@ -26,6 +26,7 @@ import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicatio
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
import WalletOutlined from "@mui/icons-material/WalletOutlined";
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
import { getRouteByMenuCode } from "@/app/router/routeConfig";
const deprecatedMenuCodes = new Set(["services"]);
@ -64,6 +65,7 @@ const iconMap = {
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined,
workspace_premium: WorkspacePremiumOutlined,
};
export const fallbackNavigation = [
@ -148,6 +150,7 @@ export const fallbackNavigation = [
children: [
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
],

View File

@ -102,6 +102,8 @@ export const PERMISSIONS = {
achievementUpdate: "achievement:update",
registrationRewardView: "registration-reward:view",
registrationRewardUpdate: "registration-reward:update",
firstRechargeRewardView: "first-recharge-reward:view",
firstRechargeRewardUpdate: "first-recharge-reward:update",
sevenDayCheckInView: "seven-day-checkin:view",
sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view",
@ -152,6 +154,7 @@ export const MENU_CODES = {
activities: "activities",
dailyTaskList: "daily-task-list",
registrationReward: "registration-reward",
firstRechargeReward: "first-recharge-reward",
achievementConfig: "achievement-config",
sevenDayCheckIn: "seven-day-checkin",
roomTreasure: "room-treasure",

View File

@ -4,6 +4,7 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
import { appUserRoutes } from "@/features/app-users/routes.js";
import { dashboardRoutes } from "@/features/dashboard/routes.js";
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
import { gameRoutes } from "@/features/games/routes.js";
import { hostOrgRoutes } from "@/features/host-org/routes.js";
import { levelConfigRoutes } from "@/features/level-config/routes.js";
@ -36,6 +37,7 @@ export const adminRoutes: AdminRoute[] = [
...dailyTaskRoutes,
...achievementRoutes,
...registrationRewardRoutes,
...firstRechargeRewardRoutes,
...sevenDayCheckInRoutes,
...roomTreasureRoutes,
...userLeaderboardRoutes,

View File

@ -0,0 +1,171 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export interface FirstRechargeRewardTierDto {
tierId: number;
tierCode: string;
tierName: string;
minCoinAmount: number;
maxCoinAmount: number;
resourceGroupId: number;
status: string;
sortOrder: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface FirstRechargeRewardConfigDto {
appCode?: string;
enabled: boolean;
tiers: FirstRechargeRewardTierDto[];
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface FirstRechargeRewardConfigPayload {
enabled: boolean;
tiers: Array<{
tier_id?: number;
tier_code: string;
tier_name: string;
min_coin_amount: number;
max_coin_amount: number;
resource_group_id: number;
status: string;
sort_order: number;
}>;
}
export interface FirstRechargeRewardClaimUserDto {
userId?: number;
displayUserId?: string;
username?: string;
avatar?: string;
}
export interface FirstRechargeRewardClaimDto {
claimId: string;
eventId?: string;
transactionId?: string;
commandId?: string;
userId: number;
user?: FirstRechargeRewardClaimUserDto;
tierId?: number;
tierCode?: string;
resourceGroupId?: number;
rechargeCoinAmount?: number;
rechargeSequence?: number;
rechargeType?: string;
status?: string;
walletCommandId?: string;
walletGrantId?: string;
failureReason?: string;
rechargedAtMs?: number;
grantedAtMs?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
type RawConfig = FirstRechargeRewardConfigDto & Record<string, unknown>;
type RawTier = FirstRechargeRewardTierDto & Record<string, unknown>;
type RawClaim = FirstRechargeRewardClaimDto & Record<string, unknown>;
export function getFirstRechargeRewardConfig(): Promise<FirstRechargeRewardConfigDto> {
const endpoint = API_ENDPOINTS.getFirstRechargeRewardConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getFirstRechargeRewardConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateFirstRechargeRewardConfig(
payload: FirstRechargeRewardConfigPayload,
): Promise<FirstRechargeRewardConfigDto> {
const endpoint = API_ENDPOINTS.updateFirstRechargeRewardConfig;
return apiRequest<RawConfig, FirstRechargeRewardConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateFirstRechargeRewardConfig),
{
body: payload,
method: endpoint.method,
},
).then(normalizeConfig);
}
export function listFirstRechargeRewardClaims(query: PageQuery = {}): Promise<ApiPage<FirstRechargeRewardClaimDto>> {
const endpoint = API_ENDPOINTS.listFirstRechargeRewardClaims;
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listFirstRechargeRewardClaims), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeClaim),
}));
}
function normalizeConfig(item: RawConfig): FirstRechargeRewardConfigDto {
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
return {
appCode: stringValue(item.appCode ?? item.app_code),
enabled: Boolean(item.enabled),
tiers: rawTiers.map(normalizeTier),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeTier(item: RawTier): FirstRechargeRewardTierDto {
return {
tierId: numberValue(item.tierId ?? item.tier_id),
tierCode: stringValue(item.tierCode ?? item.tier_code),
tierName: stringValue(item.tierName ?? item.tier_name),
minCoinAmount: numberValue(item.minCoinAmount ?? item.min_coin_amount),
maxCoinAmount: numberValue(item.maxCoinAmount ?? item.max_coin_amount),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
status: stringValue(item.status) || "active",
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
const rawUser = (item.user || {}) as Record<string, unknown>;
return {
claimId: stringValue(item.claimId ?? item.claim_id),
eventId: stringValue(item.eventId ?? item.event_id),
transactionId: stringValue(item.transactionId ?? item.transaction_id),
commandId: stringValue(item.commandId ?? item.command_id),
userId: numberValue(item.userId ?? item.user_id),
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},
tierId: numberValue(item.tierId ?? item.tier_id),
tierCode: stringValue(item.tierCode ?? item.tier_code),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
rechargeSequence: numberValue(item.rechargeSequence ?? item.recharge_sequence),
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
status: stringValue(item.status),
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
failureReason: stringValue(item.failureReason ?? item.failure_reason),
rechargedAtMs: numberValue(item.rechargedAtMs ?? item.recharged_at_ms),
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,186 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
const statusOptions = [
["active", "启用"],
["inactive", "停用"],
];
export function FirstRechargeRewardConfigDrawer({
abilities,
configLoading,
configSaving,
form,
onClose,
onSubmit,
open,
resourceGroups,
setForm,
}) {
const disabled = !abilities.canUpdate || configLoading || configSaving;
const addTier = () => {
setForm((current) => ({
...current,
tiers: [
...(current.tiers || []),
{
tierId: 0,
tierCode: "",
tierName: "",
minCoinAmount: "",
maxCoinAmount: "",
resourceGroupId: "",
status: "active",
sortOrder: String((current.tiers || []).length),
},
],
}));
};
const updateTier = (index, patch) => {
setForm((current) => ({
...current,
tiers: (current.tiers || []).map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier)),
}));
};
const removeTier = (index) => {
setForm((current) => ({
...current,
tiers: (current.tiers || []).filter((_, tierIndex) => tierIndex !== index),
}));
};
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>首冲奖励配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="首冲奖励状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<div className={styles.tierEditorList}>
{(form.tiers || []).map((tier, index) => (
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
<div className={styles.tierEditorHeader}>
<span>档位 {index + 1}</span>
<IconButton
aria-label="删除档位"
disabled={disabled}
size="small"
onClick={() => removeTier(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
<div className="form-drawer__grid">
<TextField
disabled={disabled}
label="档位编码"
value={tier.tierCode}
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="档位名称"
value={tier.tierName}
onChange={(event) => updateTier(index, { tierName: event.target.value })}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="最小金币"
type="number"
value={tier.minCoinAmount}
onChange={(event) => updateTier(index, { minCoinAmount: event.target.value })}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="最大金币"
placeholder="0 表示无上限"
type="number"
value={tier.maxCoinAmount}
onChange={(event) => updateTier(index, { maxCoinAmount: event.target.value })}
/>
<ResourceGroupSelectField
disabled={disabled}
drawerTitle="选择首冲奖励资源组"
groups={resourceGroups}
label="奖励资源组"
value={tier.resourceGroupId}
onChange={(value) => updateTier(index, { resourceGroupId: value })}
/>
<TextField
select
disabled={disabled}
label="状态"
value={tier.status}
onChange={(event) => updateTier(index, { status: event.target.value })}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="排序"
type="number"
value={tier.sortOrder}
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
/>
</div>
</div>
))}
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
</div>
</section>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</form>
</Drawer>
);
}

View File

@ -0,0 +1,79 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
export function FirstRechargeRewardConfigSummary({
canUpdate,
config,
configLoading,
resourceGroups,
onEdit,
onRefresh,
}) {
const tiers = config?.tiers || [];
const activeCount = tiers.filter((tier) => tier.status === "active").length;
return (
<div className={styles.summaryPanel}>
<div className={styles.summaryItems}>
<SummaryItem label="状态">
<span
className={[
styles.statusBadge,
config?.enabled ? styles.statusActive : styles.statusInactive,
].join(" ")}
>
{config?.enabled ? "启用" : "停用"}
</span>
</SummaryItem>
<SummaryItem label="档位">
{activeCount}/{tiers.length}
</SummaryItem>
<SummaryItem label="资源组">{resourceGroupSummary(tiers, resourceGroups)}</SummaryItem>
</div>
<div className={styles.summaryActions}>
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
刷新
</Button>
{canUpdate ? (
<Button
disabled={configLoading}
startIcon={<EditOutlined fontSize="small" />}
variant="primary"
onClick={onEdit}
>
编辑配置
</Button>
) : null}
</div>
</div>
);
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function resourceGroupSummary(tiers, resourceGroups) {
if (!tiers.length) {
return "-";
}
const names = tiers.slice(0, 2).map((tier) =>
groupName(
resourceGroups.find((group) => group.groupId === tier.resourceGroupId),
tier.resourceGroupId,
),
);
const suffix = tiers.length > names.length ? ` +${tiers.length - names.length}` : "";
return `${names.join("、")}${suffix}`;
}
function groupName(group, fallbackId) {
return group?.name || group?.groupCode || `资源组 #${fallbackId}`;
}

View File

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

View File

@ -0,0 +1,188 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listResourceGroups } from "@/features/resources/api";
import {
getFirstRechargeRewardConfig,
listFirstRechargeRewardClaims,
updateFirstRechargeRewardConfig,
} from "@/features/first-recharge-reward/api";
import { useFirstRechargeRewardAbilities } from "@/features/first-recharge-reward/permissions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 10;
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = {
enabled: false,
tiers: [],
};
export function useFirstRechargeRewardPage() {
const abilities = useFirstRechargeRewardAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(null);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [resourceGroups, setResourceGroups] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(() => ({ keyword: query, status }), [query, status]);
const {
data: claims = emptyClaims,
error: claimsError,
loading: claimsLoading,
reload: reloadClaims,
} = usePaginatedQuery({
errorMessage: "加载首冲奖励记录失败",
fetcher: listFirstRechargeRewardClaims,
filters,
page,
pageSize,
queryKey: ["first-recharge-reward-claims", filters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const [config, groups] = await Promise.all([
getFirstRechargeRewardConfig(),
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
]);
setConfig(config);
setForm(formFromConfig(config));
setResourceGroups(groups.items || []);
} catch (err) {
showToast(err.message || "加载首冲奖励配置失败", "error");
} finally {
setConfigLoading(false);
}
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
const openConfigDrawer = useCallback(() => {
setForm(config ? formFromConfig(config) : emptyForm);
setConfigDrawerOpen(true);
}, [config]);
const closeConfigDrawer = useCallback(() => {
setConfigDrawerOpen(false);
setForm(config ? formFromConfig(config) : emptyForm);
}, [config]);
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const submitConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
setConfigSaving(true);
try {
const saved = await updateFirstRechargeRewardConfig(payloadFromForm(form));
setConfig(saved);
setForm(formFromConfig(saved));
setConfigDrawerOpen(false);
showToast("首冲奖励配置已保存", "success");
await reloadClaims();
} catch (err) {
showToast(err.message || "保存首冲奖励配置失败", "error");
} finally {
setConfigSaving(false);
}
};
return {
abilities,
changeQuery,
changeStatus,
claims,
claimsError,
claimsLoading,
closeConfigDrawer,
config,
configDrawerOpen,
configLoading,
configSaving,
form,
openConfigDrawer,
page,
query,
reloadClaims,
reloadConfig,
resourceGroups,
setForm,
setPage,
status,
submitConfig,
};
}
function formFromConfig(config) {
return {
enabled: Boolean(config.enabled),
tiers: (config.tiers || []).map((tier) => ({
tierId: tier.tierId || 0,
tierCode: tier.tierCode || "",
tierName: tier.tierName || "",
minCoinAmount: tier.minCoinAmount ? String(tier.minCoinAmount) : "",
maxCoinAmount: tier.maxCoinAmount ? String(tier.maxCoinAmount) : "",
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
status: tier.status || "active",
sortOrder: String(tier.sortOrder || 0),
})),
};
}
function payloadFromForm(form) {
const tiers = (form.tiers || []).map((tier, index) => {
const minCoinAmount = Number(tier.minCoinAmount || 0);
const maxCoinAmount = Number(tier.maxCoinAmount || 0);
const resourceGroupId = Number(tier.resourceGroupId || 0);
if (!tier.tierCode.trim()) {
throw new Error("档位编码不能为空");
}
if (!tier.tierName.trim()) {
throw new Error("档位名称不能为空");
}
if (minCoinAmount <= 0) {
throw new Error("最小金币必须大于 0");
}
if (maxCoinAmount > 0 && maxCoinAmount < minCoinAmount) {
throw new Error("最大金币不能小于最小金币");
}
if (resourceGroupId <= 0) {
throw new Error("请选择资源组");
}
return {
tier_id: Number(tier.tierId || 0),
tier_code: tier.tierCode.trim(),
tier_name: tier.tierName.trim(),
min_coin_amount: minCoinAmount,
max_coin_amount: maxCoinAmount,
resource_group_id: resourceGroupId,
status: tier.status === "inactive" ? "inactive" : "active",
sort_order: Number(tier.sortOrder || index),
};
});
if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) {
throw new Error("开启后至少需要一个启用档位");
}
return {
enabled: Boolean(form.enabled),
tiers,
};
}

View File

@ -0,0 +1,176 @@
import Avatar from "@mui/material/Avatar";
import { FirstRechargeRewardConfigDrawer } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx";
import { FirstRechargeRewardConfigSummary } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx";
import { useFirstRechargeRewardPage } from "@/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js";
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const claimStatusOptions = [
["pending", "发放中"],
["granted", "已发放"],
["failed", "发放失败"],
];
const columnsBase = [
{
key: "user",
label: "用户信息",
width: "minmax(260px, 1.1fr)",
render: (claim) => <ClaimUser claim={claim} />,
},
{
key: "tier",
label: "命中档位",
width: "minmax(180px, 0.8fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.tierCode || "-"}</span>
<span className={styles.meta}>资源组 #{claim.resourceGroupId || "-"}</span>
</div>
),
},
{
key: "recharge",
label: "充值",
width: "minmax(180px, 0.8fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{formatNumber(claim.rechargeCoinAmount)} 金币</span>
<span className={styles.meta}> {claim.rechargeSequence || "-"} </span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (claim) => claimStatusLabel(claim.status),
},
{
key: "rechargedAt",
label: "充值时间",
width: "minmax(180px, 0.85fr)",
render: (claim) => formatMillis(claim.rechargedAtMs || claim.createdAtMs),
},
{
key: "claimId",
label: "记录",
width: "minmax(260px, 1fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.claimId}</span>
<span className={styles.meta}>{claim.transactionId || "-"}</span>
</div>
),
},
{
key: "failure",
label: "失败原因",
width: "minmax(180px, 0.85fr)",
render: (claim) => claim.failureReason || "-",
},
];
export function FirstRechargeRewardPage() {
const page = useFirstRechargeRewardPage();
const total = page.claims.total || 0;
const columns = columnsBase.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID、短号、名称",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: [["", "全部状态"], ...claimStatusOptions],
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return (
<AdminListPage>
<FirstRechargeRewardConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
resourceGroups={page.resourceGroups}
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
<AdminListBody>
<DataTable
columns={columns}
items={page.claims.items || []}
minWidth="1380px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.claims.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(claim) => claim.claimId}
/>
</AdminListBody>
</DataState>
<FirstRechargeRewardConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
resourceGroups={page.resourceGroups}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
</AdminListPage>
);
}
function ClaimUser({ claim }) {
const user = claim.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(claim.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}
</span>
</div>
</div>
);
}
function claimStatusLabel(status) {
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

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

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const firstRechargeRewardRoutes = [
{
label: "首冲奖励",
loader: () => import("./pages/FirstRechargeRewardPage.jsx").then((module) => module.FirstRechargeRewardPage),
menuCode: MENU_CODES.firstRechargeReward,
pageKey: "first-recharge-reward",
path: "/activities/first-recharge-reward",
permission: PERMISSIONS.firstRechargeRewardView,
},
];

View File

@ -8,7 +8,7 @@ export interface GamePlatformDto {
platformName: string;
status: string;
apiBaseUrl: string;
// 服务端按 adapterType 选择厂商协议demo/yomi_v4/leadercc_v1
// 服务端按 adapterType 选择厂商协议demo/yomi_v4/leadercc_v1/zeeone_v1
adapterType: string;
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
callbackSecret?: string;

View File

@ -31,7 +31,7 @@ const defaultPlatformForm = {
platformName: "",
status: "active",
apiBaseUrl: "",
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1
adapterType: "demo",
// 后台现在会回显厂商 key编辑弹窗直接显示当前值提交空值仍代表“不覆盖旧值”。
callbackSecret: "",

View File

@ -38,6 +38,7 @@ const adapterTypeOptions = [
["demo", "Demo"],
["yomi_v4", "小游 Yomi V4"],
["leadercc_v1", "灵仙 LeaderCC V1"],
["zeeone_v1", "ZeeOne V1"],
];
const baseColumns = [

View File

@ -42,8 +42,10 @@ test("resource APIs use generated admin paths", async () => {
await createResource({
amount: 0,
assetUrl: "https://media.haiyihy.com/resource/material.png",
coinPrice: 10,
name: "Rose",
previewUrl: "https://media.haiyihy.com/resource/cover.png",
priceType: "coin",
resourceCode: "gift_rose_test",
resourceType: "gift",
sortOrder: 0,
@ -52,8 +54,10 @@ test("resource APIs use generated admin paths", async () => {
await updateResource(11, {
amount: 0,
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
coinPrice: 20,
name: "Rose Updated",
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
priceType: "coin",
resourceCode: "gift_rose_test",
resourceType: "gift",
sortOrder: 0,

View File

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

View File

@ -0,0 +1,195 @@
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { resourcePriceTypeLabels, resourceTypeLabels } from "@/features/resources/constants.js";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import styles from "@/features/resources/components/ResourceSelectDrawer.module.css";
export function ResourceSelectField({
disabled = false,
drawerTitle = "选择资源",
label = "资源",
loading = false,
onChange = () => {},
placeholder = "请选择资源",
resources = [],
value,
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [resourceType, setResourceType] = useState("");
const normalizedValue = String(value || "");
const selectedResource = resources.find((resource) => String(resource.resourceId) === normalizedValue);
const displayValue = selectedResource
? resourceName(selectedResource)
: normalizedValue
? `资源 #${normalizedValue}`
: "";
const resourceTypeOptions = useMemo(() => {
const seen = new Set();
return resources
.map((resource) => resource.resourceType)
.filter(Boolean)
.filter((type) => {
if (seen.has(type)) {
return false;
}
seen.add(type);
return true;
})
.map((type) => [type, resourceTypeLabels[type] || type])
.sort(([, labelA], [, labelB]) => labelA.localeCompare(labelB, "zh-Hans-CN"));
}, [resources]);
const filteredResources = useMemo(() => {
const keyword = query.trim().toLowerCase();
return resources.filter((resource) => {
if (resourceType && resource.resourceType !== resourceType) {
return false;
}
if (!keyword) {
return true;
}
const haystack = [resource.name, resource.resourceCode, resource.resourceId, resource.resourceType]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(keyword);
});
}, [query, resourceType, resources]);
const openDrawer = () => {
if (!disabled) {
setOpen(true);
}
};
const selectResource = (resource) => {
onChange(String(resource.resourceId), resource);
setOpen(false);
};
return (
<>
<TextField
fullWidth
className={styles.field}
disabled={disabled || loading}
label={label}
placeholder={loading ? "资源加载中" : placeholder}
required
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
value={displayValue}
onClick={openDrawer}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer();
}
}}
/>
<SideDrawer
className={styles.drawer}
contentClassName={styles.drawerBody}
drawerProps={{ sx: { zIndex: 1600 } }}
open={open}
title={drawerTitle}
width="wide"
onClose={() => setOpen(false)}
>
<div className={styles.filters}>
<TextField
className={styles.search}
placeholder="搜索资源名称、编码"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchOutlined fontSize="small" />
</InputAdornment>
),
},
}}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<TextField
select
className={styles.typeFilter}
label="分类"
value={resourceType}
onChange={(event) => setResourceType(event.target.value)}
>
<MenuItem value="">全部分类</MenuItem>
{resourceTypeOptions.map(([type, label]) => (
<MenuItem key={type} value={type}>
{label}
</MenuItem>
))}
</TextField>
</div>
<div className={styles.grid}>
{filteredResources.map((resource) => {
const selected = String(resource.resourceId) === normalizedValue;
return (
<button
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
key={resource.resourceId}
type="button"
onClick={() => selectResource(resource)}
>
<ResourceThumb resource={resource} />
<span className={styles.name}>{resourceName(resource)}</span>
<span className={styles.meta}>
{resource.resourceCode || `ID ${resource.resourceId}`}
</span>
<span className={styles.tags}>
<span className={styles.tag}>
{resourceTypeLabels[resource.resourceType] || resource.resourceType || "资源"}
</span>
<span className={styles.tag}>{resourcePriceLabel(resource)}</span>
</span>
</button>
);
})}
{!filteredResources.length ? <div className={styles.empty}>当前无可选资源</div> : null}
</div>
</SideDrawer>
</>
);
}
function ResourceThumb({ resource }) {
const imageUrl = imageURL(resource.previewUrl || resource.assetUrl || resource.animationUrl);
return (
<span className={styles.thumb}>
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
</span>
);
}
function resourceName(resource) {
return resource?.name || resource?.resourceCode || `资源 #${resource?.resourceId || "-"}`;
}
function resourcePriceLabel(resource) {
const priceType = resource?.priceType;
if (priceType === "coin") {
return `金币 ${formatNumber(resource.coinPrice)}`;
}
if (priceType === "free") {
return resourcePriceTypeLabels.free;
}
return "未配置价格";
}
function imageURL(value) {
const url = String(value || "").trim();
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -0,0 +1,165 @@
.field :global(.MuiInputBase-input:not(:disabled)) {
cursor: pointer;
}
.drawer.drawer {
width: min(780px, 100vw);
height: 100%;
}
.drawerBody {
display: grid;
min-height: 0;
grid-template-rows: auto 1fr;
gap: var(--space-3);
}
.filters {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 180px);
gap: var(--space-2);
align-items: center;
}
.search,
.typeFilter {
width: 100%;
}
.grid {
display: grid;
min-height: 0;
grid-template-columns: repeat(auto-fill, minmax(156px, 1fr));
gap: var(--space-3);
overflow-y: auto;
padding-right: var(--space-1);
}
.card {
display: grid;
min-width: 0;
min-height: 164px;
align-content: start;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.card:hover {
border-color: var(--primary-border);
background: var(--primary-surface);
box-shadow: var(--shadow-soft);
transform: translateY(-1px);
}
.card:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.cardSelected,
.cardSelected:hover {
border-color: var(--primary);
background: var(--primary-surface-strong);
box-shadow: inset 0 0 0 1px var(--primary-border);
}
.thumb {
display: inline-flex;
width: 54px;
height: 54px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.name,
.meta,
.tag {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name {
color: var(--text-primary);
font-weight: 700;
}
.meta {
color: var(--text-tertiary);
font-size: 13px;
}
.tags {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: var(--space-1);
}
.tag {
display: inline-flex;
max-width: 100%;
height: var(--admin-tag-height);
align-items: center;
padding: 0 var(--space-2);
border: 1px solid var(--admin-tag-border);
border-radius: var(--admin-tag-radius);
background: var(--admin-tag-bg);
color: var(--admin-tag-color);
font-size: 12px;
font-weight: 650;
}
.empty {
display: flex;
min-height: 180px;
grid-column: 1 / -1;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-weight: 650;
}
@media (max-width: 640px) {
.filters {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}

View File

@ -0,0 +1,54 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { ResourceSelectField } from "./ResourceSelectDrawer.jsx";
vi.mock("@/shared/ui/SideDrawer.jsx", () => ({
SideDrawer({ children, drawerProps, onClose, open, title }) {
if (!open) {
return null;
}
return (
<aside aria-label={title} data-z-index={drawerProps?.sx?.zIndex} role="dialog">
<button type="button" onClick={onClose}>
关闭
</button>
{children}
</aside>
);
},
}));
const resources = [
{
coinPrice: 10,
name: "Rose Gift",
priceType: "coin",
resourceCode: "rose_gift",
resourceId: 1,
resourceType: "gift",
},
{
coinPrice: 0,
name: "VIP Vehicle",
priceType: "free",
resourceCode: "vip_vehicle",
resourceId: 2,
resourceType: "vehicle",
},
];
test("resource select drawer stays above dialogs and filters by category", () => {
render(<ResourceSelectField resources={resources} value="" />);
fireEvent.click(screen.getByPlaceholderText("请选择资源"));
expect(screen.getByRole("dialog", { name: "选择资源" })).toHaveAttribute("data-z-index", "1600");
expect(screen.getByText("Rose Gift")).toBeInTheDocument();
expect(screen.getByText("VIP Vehicle")).toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole("combobox", { name: "分类" }));
fireEvent.click(screen.getByRole("option", { name: "座驾" }));
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
expect(screen.getByText("VIP Vehicle")).toBeInTheDocument();
});

View File

@ -41,6 +41,13 @@ export const emojiPackPricingLabels = {
paid: "付费",
};
export const resourcePriceTypeOptions = [
["coin", "金币"],
["free", "免费"],
];
export const resourcePriceTypeLabels = Object.fromEntries(resourcePriceTypeOptions);
export const resourceTypeFilters = [
["", "全部类型"],
["avatar_frame", "头像框"],

View File

@ -44,9 +44,11 @@ const dayMillis = 24 * 60 * 60 * 1000;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyResourceForm = (resource = {}) => ({
animationUrl: resource.animationUrl || resource.assetUrl || "",
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
enabled: resource.status ? resource.status === "active" : true,
name: resource.name || "",
previewUrl: resource.previewUrl || "",
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
resourceCode: resource.resourceCode || "",
resourceType: resource.resourceType || "avatar_frame",
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
@ -103,14 +105,17 @@ const emptyGiftForm = (gift = {}) => ({
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
});
export function applyGiftResourceSelection(form, resource) {
export function applyGiftResourceSelection(form, resource, fillIdentity = true) {
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
return {
const nextForm = {
...form,
giftId: resourceId,
name: resource?.name || resource?.resourceCode || resourceId,
resourceId,
};
if (fillIdentity) {
nextForm.giftId = resourceId;
nextForm.name = resource?.name || resource?.resourceCode || resourceId;
}
return applyGiftResourcePrice(nextForm, resource);
}
export function applyGiftPriceDefaults(form, coinPrice) {
@ -124,6 +129,17 @@ export function applyGiftPriceDefaults(form, coinPrice) {
};
}
function applyGiftResourcePrice(form, resource) {
const priceType = resource?.priceType || (resource?.coinPrice > 0 ? "coin" : "");
if (priceType === "free") {
return applyGiftPriceDefaults({ ...form, chargeAssetType: "COIN" }, 0);
}
if (priceType === "coin") {
return applyGiftPriceDefaults({ ...form, chargeAssetType: "COIN" }, resource?.coinPrice || 0);
}
return form;
}
const emptyGrantForm = () => ({
durationDays: "0",
groupId: "",
@ -593,10 +609,11 @@ export function useGiftListPage() {
const selectGiftResource = (resourceId) => {
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
setForm((currentForm) => {
if (activeAction !== "create") {
return { ...currentForm, resourceId };
}
return applyGiftResourceSelection(currentForm, selectedResource || { resourceId });
return applyGiftResourceSelection(
currentForm,
selectedResource || { resourceId },
activeAction === "create",
);
});
};
@ -1005,8 +1022,10 @@ function buildResourcePayload(form) {
amount: isCoin ? Number(form.walletAssetAmount) : 0,
animationUrl,
assetUrl: animationUrl,
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
name: form.name.trim(),
previewUrl: form.previewUrl.trim(),
priceType: form.priceType,
resourceCode: form.resourceCode.trim(),
resourceType,
sortOrder: 0,
@ -1080,7 +1099,7 @@ function buildGiftPayload(form) {
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
effectTypes: form.effectTypes || [],
giftId: form.giftId.trim(),
giftPointAmount: Number(form.giftPointAmount || 0),
giftPointAmount: Number(form.coinPrice || 0),
giftTypeCode: form.giftTypeCode,
heatValue: Number(form.heatValue || 0),
name: form.name.trim(),

View File

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

View File

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

View File

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

View File

@ -24,6 +24,8 @@ import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/t
import { formatMillis } from "@/shared/utils/time.js";
import {
resourceStatusFilters,
resourcePriceTypeLabels,
resourcePriceTypeOptions,
resourceTypeFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
@ -45,6 +47,12 @@ const baseColumns = [
width: "minmax(120px, 0.7fr)",
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
},
{
key: "price",
label: "价格",
width: "minmax(120px, 0.65fr)",
render: (resource) => resourcePriceLabel(resource),
},
{
key: "status",
label: "状态",
@ -226,6 +234,38 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="价格类型"
required
select
value={form.priceType}
onChange={(event) =>
setForm({
...form,
coinPrice: event.target.value === "free" ? "0" : form.coinPrice,
priceType: event.target.value,
})
}
>
{resourcePriceTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled || form.priceType === "free"}
label="价格"
required={form.priceType === "coin"}
type="number"
value={form.priceType === "free" ? "0" : form.coinPrice}
onChange={(event) => setForm({ ...form, coinPrice: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="素材">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
@ -285,6 +325,16 @@ function ResourceStatusSwitch({ page, resource }) {
);
}
function resourcePriceLabel(resource) {
if (resource.priceType === "free") {
return resourcePriceTypeLabels.free;
}
if (resource.priceType === "coin") {
return `金币 ${formatNumber(resource.coinPrice)}`;
}
return "未配置";
}
function imageURL(value) {
const url = String(value || "").trim();
if (!url) {
@ -292,3 +342,7 @@ function imageURL(value) {
}
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -14,6 +14,7 @@ const resourceTypes = [
"emoji_pack",
];
const walletAssetTypes = ["COIN", "DIAMOND"];
const resourcePriceTypes = ["coin", "free"];
const optionalWalletAssetTypeSchema = z
.preprocess((value) => {
if (typeof value !== "string") {
@ -29,7 +30,9 @@ export const resourceCreateFormSchema = z
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
enabled: z.boolean(),
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
coinPrice: z.union([z.string(), z.number()]).optional(),
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
resourceType: z.enum(resourceTypes, "请选择资源类型"),
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
@ -46,6 +49,16 @@ export const resourceCreateFormSchema = z
path: ["walletAssetAmount"],
});
}
if (value.priceType === "coin") {
const coinPrice = Number(value.coinPrice);
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
context.addIssue({
code: "custom",
message: "请输入资源价格",
path: ["coinPrice"],
});
}
}
});
export const resourceFormSchema = resourceCreateFormSchema;
@ -170,7 +183,7 @@ export const giftFormSchema = z
path: ["resourceId"],
});
}
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
if (!Number.isInteger(coinPrice) || coinPrice < 0) {
context.addIssue({
code: "custom",
message: "请输入价格",
@ -184,6 +197,13 @@ export const giftFormSchema = z
path: ["giftPointAmount"],
});
}
if (Number.isInteger(coinPrice) && Number.isInteger(giftPointAmount) && giftPointAmount !== coinPrice) {
context.addIssue({
code: "custom",
message: "积分必须同步价格",
path: ["giftPointAmount"],
});
}
if (!Number.isInteger(heatValue) || heatValue < 0) {
context.addIssue({
code: "custom",

View File

@ -1,120 +1,121 @@
import { describe, expect, test } from "vitest";
import { FormValidationError, parseForm } from "@/shared/forms/validation";
import {
emojiPackFormSchema,
giftFormSchema,
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema
emojiPackFormSchema,
giftFormSchema,
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema,
} from "./schema.js";
describe("resource form schema", () => {
test("allows profile card resources", () => {
const payload = parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
enabled: true,
name: "Profile Card",
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
resourceCode: "profile_card_gold",
resourceType: "profile_card",
walletAssetAmount: ""
test("allows profile card resources", () => {
const payload = parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
enabled: true,
name: "Profile Card",
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
priceType: "free",
resourceCode: "profile_card_gold",
resourceType: "profile_card",
walletAssetAmount: "",
});
expect(payload.resourceType).toBe("profile_card");
});
expect(payload.resourceType).toBe("profile_card");
});
test("allows resource group resource items without wallet asset type", () => {
const payload = parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "7",
itemType: "resource",
resourceId: "11",
sortOrder: "0",
walletAssetAmount: "",
walletAssetType: ""
}
],
name: "Starter Pack",
sortOrder: "0"
});
expect(payload.items[0].walletAssetType).toBeUndefined();
});
test("rejects invalid wallet asset type with Chinese message", () => {
expect(() =>
parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "",
itemType: "wallet_asset",
resourceId: "",
test("allows resource group resource items without wallet asset type", () => {
const payload = parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "7",
itemType: "resource",
resourceId: "11",
sortOrder: "0",
walletAssetAmount: "",
walletAssetType: "",
},
],
name: "Starter Pack",
sortOrder: "0",
walletAssetAmount: "100",
walletAssetType: "gold"
}
],
name: "Starter Pack",
sortOrder: "0"
})
).toThrow(FormValidationError);
});
});
test("validates editable gift form fields", () => {
const payload = parseForm(giftFormSchema, {
chargeAssetType: "COIN",
coinPrice: "10",
effectTypes: ["animation"],
effectiveFrom: "",
effectiveTo: "",
enabled: true,
giftId: "rose",
giftPointAmount: "1",
giftTypeCode: "normal",
heatValue: "2",
name: "Rose",
presentationJson: "{}",
priceVersion: "default",
regionIds: ["0", "1001"],
resourceId: "11",
sortOrder: "0"
expect(payload.items[0].walletAssetType).toBeUndefined();
});
expect(payload.regionIds).toEqual(["0", "1001"]);
});
test("validates emoji pack category and pricing type", () => {
const payload = parseForm(emojiPackFormSchema, {
animationUrl: "https://media.haiyihy.com/emoji/wave.svga",
category: "热门",
coverUrl: "https://media.haiyihy.com/emoji/wave.png",
name: "Wave",
pricingType: "paid",
regionIds: ["0"],
sortOrder: "0"
test("rejects invalid wallet asset type with Chinese message", () => {
expect(() =>
parseForm(resourceGroupCreateFormSchema, {
description: "",
enabled: true,
groupCode: "starter_pack",
items: [
{
durationDays: "",
itemType: "wallet_asset",
resourceId: "",
sortOrder: "0",
walletAssetAmount: "100",
walletAssetType: "gold",
},
],
name: "Starter Pack",
sortOrder: "0",
}),
).toThrow(FormValidationError);
});
expect(payload.category).toBe("热门");
expect(payload.pricingType).toBe("paid");
});
test("validates editable gift form fields", () => {
const payload = parseForm(giftFormSchema, {
chargeAssetType: "COIN",
coinPrice: "10",
effectTypes: ["animation"],
effectiveFrom: "",
effectiveTo: "",
enabled: true,
giftId: "rose",
giftPointAmount: "10",
giftTypeCode: "normal",
heatValue: "2",
name: "Rose",
presentationJson: "{}",
priceVersion: "default",
regionIds: ["0", "1001"],
resourceId: "11",
sortOrder: "0",
});
test("validates resource grant form fields", () => {
const payload = parseForm(resourceGrantFormSchema, {
durationDays: "7",
quantity: "1",
reason: "manual",
resourceId: "11",
subjectType: "resource",
targetUserId: "1001"
expect(payload.regionIds).toEqual(["0", "1001"]);
});
expect(payload.subjectType).toBe("resource");
});
test("validates emoji pack category and pricing type", () => {
const payload = parseForm(emojiPackFormSchema, {
animationUrl: "https://media.haiyihy.com/emoji/wave.svga",
category: "热门",
coverUrl: "https://media.haiyihy.com/emoji/wave.png",
name: "Wave",
pricingType: "paid",
regionIds: ["0"],
sortOrder: "0",
});
expect(payload.category).toBe("热门");
expect(payload.pricingType).toBe("paid");
});
test("validates resource grant form fields", () => {
const payload = parseForm(resourceGrantFormSchema, {
durationDays: "7",
quantity: "1",
reason: "manual",
resourceId: "11",
subjectType: "resource",
targetUserId: "1001",
});
expect(payload.subjectType).toBe("resource");
});
});

View File

@ -75,6 +75,7 @@ export const API_OPERATIONS = {
exportOperationLogs: "exportOperationLogs",
exportUsers: "exportUsers",
getCountry: "getCountry",
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
getLuckyGiftConfig: "getLuckyGiftConfig",
getRegion: "getRegion",
getRegistrationRewardConfig: "getRegistrationRewardConfig",
@ -101,6 +102,7 @@ export const API_OPERATIONS = {
listCountries: "listCountries",
listEmojiPackCategories: "listEmojiPackCategories",
listEmojiPacks: "listEmojiPacks",
listFirstRechargeRewardClaims: "listFirstRechargeRewardClaims",
listGifts: "listGifts",
listGiftTypes: "listGiftTypes",
listH5Links: "listH5Links",
@ -153,6 +155,7 @@ export const API_OPERATIONS = {
updateBanner: "updateBanner",
updateCatalog: "updateCatalog",
updateCountry: "updateCountry",
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
updateGift: "updateGift",
updateGiftType: "updateGiftType",
updateH5Links: "updateH5Links",
@ -617,6 +620,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "country:view",
permissions: ["country:view"]
},
getFirstRechargeRewardConfig: {
method: "GET",
operationId: API_OPERATIONS.getFirstRechargeRewardConfig,
path: "/v1/admin/activity/first-recharge-reward/config",
permission: "first-recharge-reward:view",
permissions: ["first-recharge-reward:view"]
},
getLuckyGiftConfig: {
method: "GET",
operationId: API_OPERATIONS.getLuckyGiftConfig,
@ -797,6 +807,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "emoji-pack:view",
permissions: ["emoji-pack:view"]
},
listFirstRechargeRewardClaims: {
method: "GET",
operationId: API_OPERATIONS.listFirstRechargeRewardClaims,
path: "/v1/admin/activity/first-recharge-reward/claims",
permission: "first-recharge-reward:view",
permissions: ["first-recharge-reward:view"]
},
listGifts: {
method: "GET",
operationId: API_OPERATIONS.listGifts,
@ -1149,6 +1166,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "country:update",
permissions: ["country:update"]
},
updateFirstRechargeRewardConfig: {
method: "PUT",
operationId: API_OPERATIONS.updateFirstRechargeRewardConfig,
path: "/v1/admin/activity/first-recharge-reward/config",
permission: "first-recharge-reward:update",
permissions: ["first-recharge-reward:update"]
},
updateGift: {
method: "PUT",
operationId: API_OPERATIONS.updateGift,

View File

@ -148,6 +148,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/first-recharge-reward/claims": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listFirstRechargeRewardClaims"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/registration-reward/claims": {
parameters: {
query?: never;
@ -164,6 +180,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/first-recharge-reward/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getFirstRechargeRewardConfig"];
put: operations["updateFirstRechargeRewardConfig"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/registration-reward/config": {
parameters: {
query?: never;
@ -2524,6 +2556,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listFirstRechargeRewardClaims: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listRegistrationRewardClaims: {
parameters: {
query?: never;
@ -2536,6 +2580,30 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
getFirstRechargeRewardConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateFirstRechargeRewardConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getRegistrationRewardConfig: {
parameters: {
query?: never;

View File

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