From 1e2b2eea3bb30745f28ad22dc241e55ab4183e75 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 22 May 2026 11:58:51 +0800 Subject: [PATCH] zeeone --- contracts/admin-openapi.json | 40 ++++ src/app/navigation/menu.js | 3 + src/app/permissions.ts | 3 + src/app/router/routeConfig.ts | 2 + src/features/first-recharge-reward/api.ts | 171 +++++++++++++++ .../FirstRechargeRewardConfigDrawer.jsx | 186 ++++++++++++++++ .../FirstRechargeRewardConfigSummary.jsx | 79 +++++++ .../first-recharge-reward.module.css | 159 ++++++++++++++ .../hooks/useFirstRechargeRewardPage.js | 188 ++++++++++++++++ .../pages/FirstRechargeRewardPage.jsx | 176 +++++++++++++++ .../first-recharge-reward/permissions.js | 11 + src/features/first-recharge-reward/routes.js | 12 + src/features/games/api.ts | 2 +- src/features/games/hooks/useGamesPage.js | 2 +- src/features/games/pages/GameListPage.jsx | 1 + src/features/resources/api.test.ts | 4 + src/features/resources/api.ts | 5 + .../components/ResourceSelectDrawer.jsx | 195 +++++++++++++++++ .../ResourceSelectDrawer.module.css | 165 ++++++++++++++ .../components/ResourceSelectDrawer.test.jsx | 54 +++++ src/features/resources/constants.js | 7 + .../resources/hooks/useResourcePages.js | 37 +++- .../resources/hooks/useResourcePages.test.js | 2 + src/features/resources/pages/GiftListPage.jsx | 65 ++---- .../resources/pages/ResourceGroupListPage.jsx | 45 ++-- .../resources/pages/ResourceListPage.jsx | 54 +++++ src/features/resources/schema.js | 22 +- src/features/resources/schema.test.ts | 205 +++++++++--------- src/shared/api/generated/endpoints.ts | 24 ++ src/shared/api/generated/schema.d.ts | 68 ++++++ src/shared/ui/SideDrawer.jsx | 63 +++--- 31 files changed, 1829 insertions(+), 221 deletions(-) create mode 100644 src/features/first-recharge-reward/api.ts create mode 100644 src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx create mode 100644 src/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx create mode 100644 src/features/first-recharge-reward/first-recharge-reward.module.css create mode 100644 src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js create mode 100644 src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx create mode 100644 src/features/first-recharge-reward/permissions.js create mode 100644 src/features/first-recharge-reward/routes.js create mode 100644 src/features/resources/components/ResourceSelectDrawer.jsx create mode 100644 src/features/resources/components/ResourceSelectDrawer.module.css create mode 100644 src/features/resources/components/ResourceSelectDrawer.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 809477c..d8963c9 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 624400c..b20ddfe 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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 }), ], diff --git a/src/app/permissions.ts b/src/app/permissions.ts index c19818c..0545f91 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 1605d7d..4f3e812 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -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, diff --git a/src/features/first-recharge-reward/api.ts b/src/features/first-recharge-reward/api.ts new file mode 100644 index 0000000..7be6640 --- /dev/null +++ b/src/features/first-recharge-reward/api.ts @@ -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; +type RawTier = FirstRechargeRewardTierDto & Record; +type RawClaim = FirstRechargeRewardClaimDto & Record; + +export function getFirstRechargeRewardConfig(): Promise { + const endpoint = API_ENDPOINTS.getFirstRechargeRewardConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getFirstRechargeRewardConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateFirstRechargeRewardConfig( + payload: FirstRechargeRewardConfigPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateFirstRechargeRewardConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateFirstRechargeRewardConfig), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeConfig); +} + +export function listFirstRechargeRewardClaims(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listFirstRechargeRewardClaims; + return apiRequest>(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; + 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; +} diff --git a/src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx b/src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx new file mode 100644 index 0000000..0d525e0 --- /dev/null +++ b/src/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx @@ -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 ( + +
+

首冲奖励配置

+
+
发放状态
+ setForm((current) => ({ ...current, enabled: event.target.checked }))} + /> +
+
+
+ 奖励档位 + +
+
+ {(form.tiers || []).map((tier, index) => ( +
+
+ 档位 {index + 1} + removeTier(index)} + > + + +
+
+ updateTier(index, { tierCode: event.target.value })} + /> + updateTier(index, { tierName: event.target.value })} + /> + updateTier(index, { minCoinAmount: event.target.value })} + /> + updateTier(index, { maxCoinAmount: event.target.value })} + /> + updateTier(index, { resourceGroupId: value })} + /> + updateTier(index, { status: event.target.value })} + > + {statusOptions.map(([value, label]) => ( + + {label} + + ))} + + updateTier(index, { sortOrder: event.target.value })} + /> +
+
+ ))} + {!form.tiers?.length ?
暂无档位
: null} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx b/src/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx new file mode 100644 index 0000000..e3d40a1 --- /dev/null +++ b/src/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx @@ -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 ( +
+
+ + + {config?.enabled ? "启用" : "停用"} + + + + {activeCount}/{tiers.length} + + {resourceGroupSummary(tiers, resourceGroups)} +
+
+ + {canUpdate ? ( + + ) : null} +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +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}`; +} diff --git a/src/features/first-recharge-reward/first-recharge-reward.module.css b/src/features/first-recharge-reward/first-recharge-reward.module.css new file mode 100644 index 0000000..39dfc81 --- /dev/null +++ b/src/features/first-recharge-reward/first-recharge-reward.module.css @@ -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; + } +} diff --git a/src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js b/src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js new file mode 100644 index 0000000..ba67d80 --- /dev/null +++ b/src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js @@ -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, + }; +} diff --git a/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx b/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx new file mode 100644 index 0000000..60b81d6 --- /dev/null +++ b/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx @@ -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) => , + }, + { + key: "tier", + label: "命中档位", + width: "minmax(180px, 0.8fr)", + render: (claim) => ( +
+ {claim.tierCode || "-"} + 资源组 #{claim.resourceGroupId || "-"} +
+ ), + }, + { + key: "recharge", + label: "充值", + width: "minmax(180px, 0.8fr)", + render: (claim) => ( +
+ {formatNumber(claim.rechargeCoinAmount)} 金币 + 第 {claim.rechargeSequence || "-"} 笔 +
+ ), + }, + { + 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) => ( +
+ {claim.claimId} + {claim.transactionId || "-"} +
+ ), + }, + { + 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 ( + + + + + 0 + ? { + page: page.page, + pageSize: page.claims.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(claim) => claim.claimId} + /> + + + + + ); +} + +function ClaimUser({ claim }) { + const user = claim.user || {}; + return ( +
+ +
+ {user.username || `用户 ${claim.userId}`} + + {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`} + +
+
+ ); +} + +function claimStatusLabel(status) { + return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-"; +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); +} diff --git a/src/features/first-recharge-reward/permissions.js b/src/features/first-recharge-reward/permissions.js new file mode 100644 index 0000000..70a988d --- /dev/null +++ b/src/features/first-recharge-reward/permissions.js @@ -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), + }; +} diff --git a/src/features/first-recharge-reward/routes.js b/src/features/first-recharge-reward/routes.js new file mode 100644 index 0000000..7f9a1e6 --- /dev/null +++ b/src/features/first-recharge-reward/routes.js @@ -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, + }, +]; diff --git a/src/features/games/api.ts b/src/features/games/api.ts index af090bd..3d74506 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -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; diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index e50edb6..43c1846 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -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: "", diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 7cf2988..46d9d81 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -38,6 +38,7 @@ const adapterTypeOptions = [ ["demo", "Demo"], ["yomi_v4", "小游 Yomi V4"], ["leadercc_v1", "灵仙 LeaderCC V1"], + ["zeeone_v1", "ZeeOne V1"], ]; const baseColumns = [ diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 4bf2a29..83569b6 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -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, diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 9a2c316..b987f00 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -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; diff --git a/src/features/resources/components/ResourceSelectDrawer.jsx b/src/features/resources/components/ResourceSelectDrawer.jsx new file mode 100644 index 0000000..61afbf1 --- /dev/null +++ b/src/features/resources/components/ResourceSelectDrawer.jsx @@ -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 ( + <> + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDrawer(); + } + }} + /> + setOpen(false)} + > +
+ + + + ), + }, + }} + value={query} + onChange={(event) => setQuery(event.target.value)} + /> + setResourceType(event.target.value)} + > + 全部分类 + {resourceTypeOptions.map(([type, label]) => ( + + {label} + + ))} + +
+
+ {filteredResources.map((resource) => { + const selected = String(resource.resourceId) === normalizedValue; + return ( + + ); + })} + {!filteredResources.length ?
当前无可选资源
: null} +
+
+ + ); +} + +function ResourceThumb({ resource }) { + const imageUrl = imageURL(resource.previewUrl || resource.assetUrl || resource.animationUrl); + return ( + + {imageUrl ? : } + + ); +} + +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"); +} diff --git a/src/features/resources/components/ResourceSelectDrawer.module.css b/src/features/resources/components/ResourceSelectDrawer.module.css new file mode 100644 index 0000000..31dfbdc --- /dev/null +++ b/src/features/resources/components/ResourceSelectDrawer.module.css @@ -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; + } +} diff --git a/src/features/resources/components/ResourceSelectDrawer.test.jsx b/src/features/resources/components/ResourceSelectDrawer.test.jsx new file mode 100644 index 0000000..5b3c775 --- /dev/null +++ b/src/features/resources/components/ResourceSelectDrawer.test.jsx @@ -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 ( + + ); + }, +})); + +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(); + + 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(); +}); diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 31cbe8c..02afcbf 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -41,6 +41,13 @@ export const emojiPackPricingLabels = { paid: "付费", }; +export const resourcePriceTypeOptions = [ + ["coin", "金币"], + ["free", "免费"], +]; + +export const resourcePriceTypeLabels = Object.fromEntries(resourcePriceTypeOptions); + export const resourceTypeFilters = [ ["", "全部类型"], ["avatar_frame", "头像框"], diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 4af8d92..5e1b74e 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -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(), diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js index b4b44ab..b30abda 100644 --- a/src/features/resources/hooks/useResourcePages.test.js +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -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, }); diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index 1817c60..e2955bb 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -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) => (
{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)} - - {chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)} - + {giftPriceLabel(gift)}
), }, @@ -277,20 +272,15 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open > - page.selectGiftResource(event.target.value)} - > - {page.resourceOptions.map((resource) => ( - - {resource.name || resource.resourceCode || resource.resourceId} - - ))} - + onChange={(resourceId) => page.selectGiftResource(resourceId)} + /> {showGiftIdentityFields ? ( <> - page.setForm({ ...form, chargeAssetType: event.target.value })} - > - {chargeAssetOptions.map(([value, label]) => ( - - {label} - - ))} - - page.changeGiftPrice(event.target.value)} - /> - page.setForm({ ...form, giftPointAmount: event.target.value })} - /> + + 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) { diff --git a/src/features/resources/pages/ResourceGroupListPage.jsx b/src/features/resources/pages/ResourceGroupListPage.jsx index e269b18..45b84c3 100644 --- a/src/features/resources/pages/ResourceGroupListPage.jsx +++ b/src/features/resources/pages/ResourceGroupListPage.jsx @@ -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) => , - } - : column.key === "actions" ? { ...column, - render: (group) => , + filter: createOptionsColumnFilter({ + options: resourceStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + render: (group) => , } - : column, + : column.key === "actions" + ? { + ...column, + render: (group) => , + } + : column, ); return ( @@ -309,20 +309,15 @@ function ResourceGroupItemEditor({ disabled, index, item, page }) { return ( - page.updateGroupItem(index, { resourceId: event.target.value })} - > - {page.resourceOptions.map((resource) => ( - - {resource.name || resource.resourceCode || resource.resourceId} - - ))} - + onChange={(resourceId) => page.updateGroupItem(index, { resourceId })} + /> 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} + + + + setForm({ + ...form, + coinPrice: event.target.value === "free" ? "0" : form.coinPrice, + priceType: event.target.value, + }) + } + > + {resourcePriceTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + setForm({ ...form, coinPrice: event.target.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", diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index e7fb6ff..72465e8 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -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"); + }); }); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 95fa524..9d5f33d 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 54cd765..9b1a8ce 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/ui/SideDrawer.jsx b/src/shared/ui/SideDrawer.jsx index eaa7aa2..5e082c2 100644 --- a/src/shared/ui/SideDrawer.jsx +++ b/src/shared/ui/SideDrawer.jsx @@ -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 ( - - -
- {title ?

{title}

: } - {onClose ? ( - - - - ) : null} -
-
- {children} -
- {actions ?
{actions}
: null} -
-
- ); + return ( + + +
+ {title ?

{title}

: } + {onClose ? ( + + + + ) : null} +
+
{children}
+ {actions ?
{actions}
: null} +
+
+ ); }