zeeone
This commit is contained in:
parent
1330aea332
commit
1e2b2eea3b
@ -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": {
|
"/admin/activity/registration-reward/claims": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRegistrationRewardClaims",
|
"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": {
|
"/admin/activity/registration-reward/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getRegistrationRewardConfig",
|
"operationId": "getRegistrationRewardConfig",
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicatio
|
|||||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
||||||
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
||||||
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||||
|
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
|
||||||
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
||||||
|
|
||||||
const deprecatedMenuCodes = new Set(["services"]);
|
const deprecatedMenuCodes = new Set(["services"]);
|
||||||
@ -64,6 +65,7 @@ const iconMap = {
|
|||||||
task: TaskAltOutlined,
|
task: TaskAltOutlined,
|
||||||
users: ManageAccountsOutlined,
|
users: ManageAccountsOutlined,
|
||||||
wallet: WalletOutlined,
|
wallet: WalletOutlined,
|
||||||
|
workspace_premium: WorkspacePremiumOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fallbackNavigation = [
|
export const fallbackNavigation = [
|
||||||
@ -148,6 +150,7 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
||||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||||
|
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
||||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -102,6 +102,8 @@ export const PERMISSIONS = {
|
|||||||
achievementUpdate: "achievement:update",
|
achievementUpdate: "achievement:update",
|
||||||
registrationRewardView: "registration-reward:view",
|
registrationRewardView: "registration-reward:view",
|
||||||
registrationRewardUpdate: "registration-reward:update",
|
registrationRewardUpdate: "registration-reward:update",
|
||||||
|
firstRechargeRewardView: "first-recharge-reward:view",
|
||||||
|
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
||||||
sevenDayCheckInView: "seven-day-checkin:view",
|
sevenDayCheckInView: "seven-day-checkin:view",
|
||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
@ -152,6 +154,7 @@ export const MENU_CODES = {
|
|||||||
activities: "activities",
|
activities: "activities",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
|
firstRechargeReward: "first-recharge-reward",
|
||||||
achievementConfig: "achievement-config",
|
achievementConfig: "achievement-config",
|
||||||
sevenDayCheckIn: "seven-day-checkin",
|
sevenDayCheckIn: "seven-day-checkin",
|
||||||
roomTreasure: "room-treasure",
|
roomTreasure: "room-treasure",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
|
|||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/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 { gameRoutes } from "@/features/games/routes.js";
|
||||||
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||||
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||||
@ -36,6 +37,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...dailyTaskRoutes,
|
...dailyTaskRoutes,
|
||||||
...achievementRoutes,
|
...achievementRoutes,
|
||||||
...registrationRewardRoutes,
|
...registrationRewardRoutes,
|
||||||
|
...firstRechargeRewardRoutes,
|
||||||
...sevenDayCheckInRoutes,
|
...sevenDayCheckInRoutes,
|
||||||
...roomTreasureRoutes,
|
...roomTreasureRoutes,
|
||||||
...userLeaderboardRoutes,
|
...userLeaderboardRoutes,
|
||||||
|
|||||||
171
src/features/first-recharge-reward/api.ts
Normal file
171
src/features/first-recharge-reward/api.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}`;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
11
src/features/first-recharge-reward/permissions.js
Normal file
11
src/features/first-recharge-reward/permissions.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/first-recharge-reward/routes.js
Normal file
12
src/features/first-recharge-reward/routes.js
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -8,7 +8,7 @@ export interface GamePlatformDto {
|
|||||||
platformName: string;
|
platformName: string;
|
||||||
status: string;
|
status: string;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1。
|
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1/zeeone_v1。
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
||||||
callbackSecret?: string;
|
callbackSecret?: string;
|
||||||
|
|||||||
@ -31,7 +31,7 @@ const defaultPlatformForm = {
|
|||||||
platformName: "",
|
platformName: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1。
|
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1。
|
||||||
adapterType: "demo",
|
adapterType: "demo",
|
||||||
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
||||||
callbackSecret: "",
|
callbackSecret: "",
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const adapterTypeOptions = [
|
|||||||
["demo", "Demo"],
|
["demo", "Demo"],
|
||||||
["yomi_v4", "小游 Yomi V4"],
|
["yomi_v4", "小游 Yomi V4"],
|
||||||
["leadercc_v1", "灵仙 LeaderCC V1"],
|
["leadercc_v1", "灵仙 LeaderCC V1"],
|
||||||
|
["zeeone_v1", "ZeeOne V1"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
|
|||||||
@ -42,8 +42,10 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
await createResource({
|
await createResource({
|
||||||
amount: 0,
|
amount: 0,
|
||||||
assetUrl: "https://media.haiyihy.com/resource/material.png",
|
assetUrl: "https://media.haiyihy.com/resource/material.png",
|
||||||
|
coinPrice: 10,
|
||||||
name: "Rose",
|
name: "Rose",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/cover.png",
|
previewUrl: "https://media.haiyihy.com/resource/cover.png",
|
||||||
|
priceType: "coin",
|
||||||
resourceCode: "gift_rose_test",
|
resourceCode: "gift_rose_test",
|
||||||
resourceType: "gift",
|
resourceType: "gift",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
@ -52,8 +54,10 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
await updateResource(11, {
|
await updateResource(11, {
|
||||||
amount: 0,
|
amount: 0,
|
||||||
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
|
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
|
||||||
|
coinPrice: 20,
|
||||||
name: "Rose Updated",
|
name: "Rose Updated",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
|
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
|
||||||
|
priceType: "coin",
|
||||||
resourceCode: "gift_rose_test",
|
resourceCode: "gift_rose_test",
|
||||||
resourceType: "gift",
|
resourceType: "gift",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
|||||||
@ -13,6 +13,9 @@ export interface ResourceDto {
|
|||||||
grantStrategy?: string;
|
grantStrategy?: string;
|
||||||
walletAssetType?: string;
|
walletAssetType?: string;
|
||||||
walletAssetAmount?: number;
|
walletAssetAmount?: number;
|
||||||
|
priceType?: string;
|
||||||
|
coinPrice?: number;
|
||||||
|
giftPointAmount?: number;
|
||||||
usageScopes?: string[];
|
usageScopes?: string[];
|
||||||
assetUrl?: string;
|
assetUrl?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
@ -27,8 +30,10 @@ export interface ResourcePayload {
|
|||||||
amount: number;
|
amount: number;
|
||||||
animationUrl?: string;
|
animationUrl?: string;
|
||||||
assetUrl: string;
|
assetUrl: string;
|
||||||
|
coinPrice: number;
|
||||||
name: string;
|
name: string;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
priceType: string;
|
||||||
resourceCode: string;
|
resourceCode: string;
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
|||||||
195
src/features/resources/components/ResourceSelectDrawer.jsx
Normal file
195
src/features/resources/components/ResourceSelectDrawer.jsx
Normal 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");
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
@ -41,6 +41,13 @@ export const emojiPackPricingLabels = {
|
|||||||
paid: "付费",
|
paid: "付费",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resourcePriceTypeOptions = [
|
||||||
|
["coin", "金币"],
|
||||||
|
["free", "免费"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const resourcePriceTypeLabels = Object.fromEntries(resourcePriceTypeOptions);
|
||||||
|
|
||||||
export const resourceTypeFilters = [
|
export const resourceTypeFilters = [
|
||||||
["", "全部类型"],
|
["", "全部类型"],
|
||||||
["avatar_frame", "头像框"],
|
["avatar_frame", "头像框"],
|
||||||
|
|||||||
@ -44,9 +44,11 @@ const dayMillis = 24 * 60 * 60 * 1000;
|
|||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyResourceForm = (resource = {}) => ({
|
const emptyResourceForm = (resource = {}) => ({
|
||||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||||
|
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
||||||
enabled: resource.status ? resource.status === "active" : true,
|
enabled: resource.status ? resource.status === "active" : true,
|
||||||
name: resource.name || "",
|
name: resource.name || "",
|
||||||
previewUrl: resource.previewUrl || "",
|
previewUrl: resource.previewUrl || "",
|
||||||
|
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
|
||||||
resourceCode: resource.resourceCode || "",
|
resourceCode: resource.resourceCode || "",
|
||||||
resourceType: resource.resourceType || "avatar_frame",
|
resourceType: resource.resourceType || "avatar_frame",
|
||||||
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
||||||
@ -103,14 +105,17 @@ const emptyGiftForm = (gift = {}) => ({
|
|||||||
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
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) : "";
|
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
|
||||||
return {
|
const nextForm = {
|
||||||
...form,
|
...form,
|
||||||
giftId: resourceId,
|
|
||||||
name: resource?.name || resource?.resourceCode || resourceId,
|
|
||||||
resourceId,
|
resourceId,
|
||||||
};
|
};
|
||||||
|
if (fillIdentity) {
|
||||||
|
nextForm.giftId = resourceId;
|
||||||
|
nextForm.name = resource?.name || resource?.resourceCode || resourceId;
|
||||||
|
}
|
||||||
|
return applyGiftResourcePrice(nextForm, resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyGiftPriceDefaults(form, coinPrice) {
|
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 = () => ({
|
const emptyGrantForm = () => ({
|
||||||
durationDays: "0",
|
durationDays: "0",
|
||||||
groupId: "",
|
groupId: "",
|
||||||
@ -593,10 +609,11 @@ export function useGiftListPage() {
|
|||||||
const selectGiftResource = (resourceId) => {
|
const selectGiftResource = (resourceId) => {
|
||||||
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
|
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
|
||||||
setForm((currentForm) => {
|
setForm((currentForm) => {
|
||||||
if (activeAction !== "create") {
|
return applyGiftResourceSelection(
|
||||||
return { ...currentForm, resourceId };
|
currentForm,
|
||||||
}
|
selectedResource || { resourceId },
|
||||||
return applyGiftResourceSelection(currentForm, selectedResource || { resourceId });
|
activeAction === "create",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1005,8 +1022,10 @@ function buildResourcePayload(form) {
|
|||||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||||
animationUrl,
|
animationUrl,
|
||||||
assetUrl: animationUrl,
|
assetUrl: animationUrl,
|
||||||
|
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
previewUrl: form.previewUrl.trim(),
|
previewUrl: form.previewUrl.trim(),
|
||||||
|
priceType: form.priceType,
|
||||||
resourceCode: form.resourceCode.trim(),
|
resourceCode: form.resourceCode.trim(),
|
||||||
resourceType,
|
resourceType,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
@ -1080,7 +1099,7 @@ function buildGiftPayload(form) {
|
|||||||
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
|
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
|
||||||
effectTypes: form.effectTypes || [],
|
effectTypes: form.effectTypes || [],
|
||||||
giftId: form.giftId.trim(),
|
giftId: form.giftId.trim(),
|
||||||
giftPointAmount: Number(form.giftPointAmount || 0),
|
giftPointAmount: Number(form.coinPrice || 0),
|
||||||
giftTypeCode: form.giftTypeCode,
|
giftTypeCode: form.giftTypeCode,
|
||||||
heatValue: Number(form.heatValue || 0),
|
heatValue: Number(form.heatValue || 0),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
|||||||
@ -10,7 +10,9 @@ test("fills gift name and id from selected gift resource", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextForm = applyGiftResourceSelection(form, {
|
const nextForm = applyGiftResourceSelection(form, {
|
||||||
|
coinPrice: 10,
|
||||||
name: "Rose",
|
name: "Rose",
|
||||||
|
priceType: "coin",
|
||||||
resourceCode: "gift_rose",
|
resourceCode: "gift_rose",
|
||||||
resourceId: 11,
|
resourceId: 11,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,13 +34,10 @@ import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/t
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
|
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
|
||||||
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
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 { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
|
|
||||||
const chargeAssetOptions = [
|
|
||||||
["COIN", "金币"],
|
|
||||||
["DIAMOND", "钻石"],
|
|
||||||
];
|
|
||||||
const effectOptions = [
|
const effectOptions = [
|
||||||
["animation", "动画"],
|
["animation", "动画"],
|
||||||
["music", "音乐"],
|
["music", "音乐"],
|
||||||
@ -69,9 +66,7 @@ const baseColumns = (giftTypeOptions) => [
|
|||||||
render: (gift) => (
|
render: (gift) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
|
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
|
||||||
<span className={styles.meta}>
|
<span className={styles.meta}>{giftPriceLabel(gift)}</span>
|
||||||
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -277,20 +272,15 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
>
|
>
|
||||||
<AdminFormSection title="资源信息">
|
<AdminFormSection title="资源信息">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<TextField
|
<ResourceSelectField
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
disabled={disabled || page.resourceOptionsLoading}
|
||||||
|
drawerTitle="选择礼物资源"
|
||||||
label="礼物资源"
|
label="礼物资源"
|
||||||
required
|
loading={page.resourceOptionsLoading}
|
||||||
select
|
resources={page.resourceOptions}
|
||||||
value={form.resourceId}
|
value={form.resourceId}
|
||||||
onChange={(event) => page.selectGiftResource(event.target.value)}
|
onChange={(resourceId) => page.selectGiftResource(resourceId)}
|
||||||
>
|
/>
|
||||||
{page.resourceOptions.map((resource) => (
|
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
{showGiftIdentityFields ? (
|
{showGiftIdentityFields ? (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
@ -327,35 +317,8 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="价格与排序">
|
<AdminFormSection title="价格与排序">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<TextField
|
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
|
||||||
disabled={disabled}
|
<TextField disabled label="积分" type="number" value={form.giftPointAmount} />
|
||||||
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
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="热度"
|
label="热度"
|
||||||
@ -658,8 +621,12 @@ function giftTypeLabel(value, options = defaultGiftTypeOptions) {
|
|||||||
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
|
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
|
||||||
}
|
}
|
||||||
|
|
||||||
function chargeAssetLabel(value) {
|
function giftPriceLabel(gift) {
|
||||||
return chargeAssetOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "金币";
|
if (Number(gift.coinPrice || 0) === 0) {
|
||||||
|
return "免费";
|
||||||
|
}
|
||||||
|
const chargeAsset = gift.chargeAssetType === "DIAMOND" ? "钻石" : "金币";
|
||||||
|
return `${chargeAsset} · ${formatNumber(gift.coinPrice)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectTypeLabel(value) {
|
function effectTypeLabel(value) {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
|||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import {
|
||||||
@ -29,6 +28,7 @@ import {
|
|||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.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 { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
||||||
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
@ -87,22 +87,22 @@ export function ResourceGroupListPage() {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: column.key === "status"
|
: 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,
|
...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 (
|
return (
|
||||||
@ -309,20 +309,15 @@ function ResourceGroupItemEditor({ disabled, index, item, page }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminFormListRow>
|
<AdminFormListRow>
|
||||||
<TextField
|
<ResourceSelectField
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
disabled={disabled || page.resourceOptionsLoading}
|
||||||
|
drawerTitle="选择资源"
|
||||||
label="资源"
|
label="资源"
|
||||||
required
|
loading={page.resourceOptionsLoading}
|
||||||
select
|
resources={page.resourceOptions}
|
||||||
value={item.resourceId}
|
value={item.resourceId}
|
||||||
onChange={(event) => page.updateGroupItem(index, { resourceId: event.target.value })}
|
onChange={(resourceId) => page.updateGroupItem(index, { resourceId })}
|
||||||
>
|
/>
|
||||||
{page.resourceOptions.map((resource) => (
|
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="有效天数"
|
label="有效天数"
|
||||||
|
|||||||
@ -24,6 +24,8 @@ import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/t
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
resourceStatusFilters,
|
resourceStatusFilters,
|
||||||
|
resourcePriceTypeLabels,
|
||||||
|
resourcePriceTypeOptions,
|
||||||
resourceTypeFilters,
|
resourceTypeFilters,
|
||||||
resourceTypeLabels,
|
resourceTypeLabels,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
@ -45,6 +47,12 @@ const baseColumns = [
|
|||||||
width: "minmax(120px, 0.7fr)",
|
width: "minmax(120px, 0.7fr)",
|
||||||
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
|
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "price",
|
||||||
|
label: "价格",
|
||||||
|
width: "minmax(120px, 0.65fr)",
|
||||||
|
render: (resource) => resourcePriceLabel(resource),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
@ -226,6 +234,38 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
) : null}
|
) : null}
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</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="素材">
|
<AdminFormSection title="素材">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<UploadField
|
<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) {
|
function imageURL(value) {
|
||||||
const url = String(value || "").trim();
|
const url = String(value || "").trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@ -292,3 +342,7 @@ function imageURL(value) {
|
|||||||
}
|
}
|
||||||
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const resourceTypes = [
|
|||||||
"emoji_pack",
|
"emoji_pack",
|
||||||
];
|
];
|
||||||
const walletAssetTypes = ["COIN", "DIAMOND"];
|
const walletAssetTypes = ["COIN", "DIAMOND"];
|
||||||
|
const resourcePriceTypes = ["coin", "free"];
|
||||||
const optionalWalletAssetTypeSchema = z
|
const optionalWalletAssetTypeSchema = z
|
||||||
.preprocess((value) => {
|
.preprocess((value) => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@ -29,7 +30,9 @@ export const resourceCreateFormSchema = z
|
|||||||
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
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 个字符"),
|
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
||||||
|
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
|
||||||
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
|
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
|
||||||
resourceType: z.enum(resourceTypes, "请选择资源类型"),
|
resourceType: z.enum(resourceTypes, "请选择资源类型"),
|
||||||
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
||||||
@ -46,6 +49,16 @@ export const resourceCreateFormSchema = z
|
|||||||
path: ["walletAssetAmount"],
|
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;
|
export const resourceFormSchema = resourceCreateFormSchema;
|
||||||
@ -170,7 +183,7 @@ export const giftFormSchema = z
|
|||||||
path: ["resourceId"],
|
path: ["resourceId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
|
if (!Number.isInteger(coinPrice) || coinPrice < 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请输入价格",
|
message: "请输入价格",
|
||||||
@ -184,6 +197,13 @@ export const giftFormSchema = z
|
|||||||
path: ["giftPointAmount"],
|
path: ["giftPointAmount"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (Number.isInteger(coinPrice) && Number.isInteger(giftPointAmount) && giftPointAmount !== coinPrice) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "积分必须同步价格",
|
||||||
|
path: ["giftPointAmount"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!Number.isInteger(heatValue) || heatValue < 0) {
|
if (!Number.isInteger(heatValue) || heatValue < 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
@ -1,120 +1,121 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
import {
|
import {
|
||||||
emojiPackFormSchema,
|
emojiPackFormSchema,
|
||||||
giftFormSchema,
|
giftFormSchema,
|
||||||
resourceFormSchema,
|
resourceFormSchema,
|
||||||
resourceGrantFormSchema,
|
resourceGrantFormSchema,
|
||||||
resourceGroupCreateFormSchema
|
resourceGroupCreateFormSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
describe("resource form schema", () => {
|
describe("resource form schema", () => {
|
||||||
test("allows profile card resources", () => {
|
test("allows profile card resources", () => {
|
||||||
const payload = parseForm(resourceFormSchema, {
|
const payload = parseForm(resourceFormSchema, {
|
||||||
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: "Profile Card",
|
name: "Profile Card",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
||||||
resourceCode: "profile_card_gold",
|
priceType: "free",
|
||||||
resourceType: "profile_card",
|
resourceCode: "profile_card_gold",
|
||||||
walletAssetAmount: ""
|
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: "",
|
||||||
test("allows resource group resource items without wallet asset type", () => {
|
enabled: true,
|
||||||
const payload = parseForm(resourceGroupCreateFormSchema, {
|
groupCode: "starter_pack",
|
||||||
description: "",
|
items: [
|
||||||
enabled: true,
|
{
|
||||||
groupCode: "starter_pack",
|
durationDays: "7",
|
||||||
items: [
|
itemType: "resource",
|
||||||
{
|
resourceId: "11",
|
||||||
durationDays: "7",
|
sortOrder: "0",
|
||||||
itemType: "resource",
|
walletAssetAmount: "",
|
||||||
resourceId: "11",
|
walletAssetType: "",
|
||||||
sortOrder: "0",
|
},
|
||||||
walletAssetAmount: "",
|
],
|
||||||
walletAssetType: ""
|
name: "Starter Pack",
|
||||||
}
|
|
||||||
],
|
|
||||||
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: "",
|
|
||||||
sortOrder: "0",
|
sortOrder: "0",
|
||||||
walletAssetAmount: "100",
|
});
|
||||||
walletAssetType: "gold"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
name: "Starter Pack",
|
|
||||||
sortOrder: "0"
|
|
||||||
})
|
|
||||||
).toThrow(FormValidationError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validates editable gift form fields", () => {
|
expect(payload.items[0].walletAssetType).toBeUndefined();
|
||||||
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.regionIds).toEqual(["0", "1001"]);
|
test("rejects invalid wallet asset type with Chinese message", () => {
|
||||||
});
|
expect(() =>
|
||||||
|
parseForm(resourceGroupCreateFormSchema, {
|
||||||
test("validates emoji pack category and pricing type", () => {
|
description: "",
|
||||||
const payload = parseForm(emojiPackFormSchema, {
|
enabled: true,
|
||||||
animationUrl: "https://media.haiyihy.com/emoji/wave.svga",
|
groupCode: "starter_pack",
|
||||||
category: "热门",
|
items: [
|
||||||
coverUrl: "https://media.haiyihy.com/emoji/wave.png",
|
{
|
||||||
name: "Wave",
|
durationDays: "",
|
||||||
pricingType: "paid",
|
itemType: "wallet_asset",
|
||||||
regionIds: ["0"],
|
resourceId: "",
|
||||||
sortOrder: "0"
|
sortOrder: "0",
|
||||||
|
walletAssetAmount: "100",
|
||||||
|
walletAssetType: "gold",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: "Starter Pack",
|
||||||
|
sortOrder: "0",
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.category).toBe("热门");
|
test("validates editable gift form fields", () => {
|
||||||
expect(payload.pricingType).toBe("paid");
|
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", () => {
|
expect(payload.regionIds).toEqual(["0", "1001"]);
|
||||||
const payload = parseForm(resourceGrantFormSchema, {
|
|
||||||
durationDays: "7",
|
|
||||||
quantity: "1",
|
|
||||||
reason: "manual",
|
|
||||||
resourceId: "11",
|
|
||||||
subjectType: "resource",
|
|
||||||
targetUserId: "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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -75,6 +75,7 @@ export const API_OPERATIONS = {
|
|||||||
exportOperationLogs: "exportOperationLogs",
|
exportOperationLogs: "exportOperationLogs",
|
||||||
exportUsers: "exportUsers",
|
exportUsers: "exportUsers",
|
||||||
getCountry: "getCountry",
|
getCountry: "getCountry",
|
||||||
|
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
||||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||||
getRegion: "getRegion",
|
getRegion: "getRegion",
|
||||||
getRegistrationRewardConfig: "getRegistrationRewardConfig",
|
getRegistrationRewardConfig: "getRegistrationRewardConfig",
|
||||||
@ -101,6 +102,7 @@ export const API_OPERATIONS = {
|
|||||||
listCountries: "listCountries",
|
listCountries: "listCountries",
|
||||||
listEmojiPackCategories: "listEmojiPackCategories",
|
listEmojiPackCategories: "listEmojiPackCategories",
|
||||||
listEmojiPacks: "listEmojiPacks",
|
listEmojiPacks: "listEmojiPacks",
|
||||||
|
listFirstRechargeRewardClaims: "listFirstRechargeRewardClaims",
|
||||||
listGifts: "listGifts",
|
listGifts: "listGifts",
|
||||||
listGiftTypes: "listGiftTypes",
|
listGiftTypes: "listGiftTypes",
|
||||||
listH5Links: "listH5Links",
|
listH5Links: "listH5Links",
|
||||||
@ -153,6 +155,7 @@ export const API_OPERATIONS = {
|
|||||||
updateBanner: "updateBanner",
|
updateBanner: "updateBanner",
|
||||||
updateCatalog: "updateCatalog",
|
updateCatalog: "updateCatalog",
|
||||||
updateCountry: "updateCountry",
|
updateCountry: "updateCountry",
|
||||||
|
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||||
updateGift: "updateGift",
|
updateGift: "updateGift",
|
||||||
updateGiftType: "updateGiftType",
|
updateGiftType: "updateGiftType",
|
||||||
updateH5Links: "updateH5Links",
|
updateH5Links: "updateH5Links",
|
||||||
@ -617,6 +620,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "country:view",
|
permission: "country:view",
|
||||||
permissions: ["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: {
|
getLuckyGiftConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getLuckyGiftConfig,
|
operationId: API_OPERATIONS.getLuckyGiftConfig,
|
||||||
@ -797,6 +807,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "emoji-pack:view",
|
permission: "emoji-pack:view",
|
||||||
permissions: ["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: {
|
listGifts: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listGifts,
|
operationId: API_OPERATIONS.listGifts,
|
||||||
@ -1149,6 +1166,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "country:update",
|
permission: "country:update",
|
||||||
permissions: ["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: {
|
updateGift: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateGift,
|
operationId: API_OPERATIONS.updateGift,
|
||||||
|
|||||||
68
src/shared/api/generated/schema.d.ts
vendored
68
src/shared/api/generated/schema.d.ts
vendored
@ -148,6 +148,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/activity/registration-reward/claims": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -164,6 +180,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/activity/registration-reward/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2524,6 +2556,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listFirstRechargeRewardClaims: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listRegistrationRewardClaims: {
|
listRegistrationRewardClaims: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2536,6 +2580,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
getRegistrationRewardConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -3,39 +3,36 @@ import Drawer from "@mui/material/Drawer";
|
|||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
|
||||||
export function SideDrawer({
|
export function SideDrawer({
|
||||||
actions,
|
actions,
|
||||||
as: Component = "section",
|
as: Component = "section",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
contentClassName = "",
|
contentClassName = "",
|
||||||
onClose,
|
drawerProps = {},
|
||||||
open,
|
onClose,
|
||||||
title,
|
open,
|
||||||
width = "default",
|
title,
|
||||||
...props
|
width = "default",
|
||||||
|
...props
|
||||||
}) {
|
}) {
|
||||||
const drawerClassName = [
|
const drawerClassName = ["side-drawer", width === "wide" ? "side-drawer--wide" : "", className]
|
||||||
"side-drawer",
|
.filter(Boolean)
|
||||||
width === "wide" ? "side-drawer--wide" : "",
|
.join(" ");
|
||||||
className
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
<Drawer anchor="right" open={open} onClose={onClose} {...drawerProps}>
|
||||||
<Component className={drawerClassName} {...props}>
|
<Component className={drawerClassName} {...props}>
|
||||||
<div className="side-drawer__header">
|
<div className="side-drawer__header">
|
||||||
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
|
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
|
||||||
{onClose ? (
|
{onClose ? (
|
||||||
<IconButton label="关闭" onClick={onClose}>
|
<IconButton label="关闭" onClick={onClose}>
|
||||||
<CloseOutlined fontSize="small" />
|
<CloseOutlined fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={["side-drawer__body", contentClassName].filter(Boolean).join(" ")}>
|
<div className={["side-drawer__body", contentClassName].filter(Boolean).join(" ")}>{children}</div>
|
||||||
{children}
|
{actions ? <div className="side-drawer__actions">{actions}</div> : null}
|
||||||
</div>
|
</Component>
|
||||||
{actions ? <div className="side-drawer__actions">{actions}</div> : null}
|
</Drawer>
|
||||||
</Component>
|
);
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user