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