From 7d6418e43c6d49096e7629ccfbd6c3fd8f55964f Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 11 Jun 2026 17:11:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 84 +++++ src/app/navigation/menu.js | 4 + src/app/navigation/menu.test.jsx | 7 + src/app/permissions.ts | 5 + src/app/router/routeConfig.ts | 2 + src/features/app-config/api.ts | 36 ++ src/features/app-config/app-config.module.css | 68 ++++ .../app-config/hooks/usePopupConfigPage.js | 174 ++++++++++ .../app-config/pages/BannerConfigPage.jsx | 25 +- .../app-config/pages/PopupConfigPage.jsx | 326 ++++++++++++++++++ .../pages/SplashScreenConfigPage.jsx | 25 +- src/features/app-config/routes.js | 8 + src/features/app-config/schema.test.ts | 97 ++++++ src/features/app-config/schema.ts | 52 +++ .../daily-tasks/daily-tasks.module.css | 33 ++ .../daily-tasks/hooks/useDailyTasksPage.js | 139 ++++++-- .../daily-tasks/pages/DailyTaskListPage.jsx | 127 +++---- src/features/daily-tasks/permissions.js | 1 + src/features/daily-tasks/schema.ts | 20 +- src/features/daily-tasks/taskPayload.test.js | 38 ++ src/features/invite-activity-reward/api.ts | 178 ++++++++++ .../InviteActivityRewardConfigDrawer.jsx | 200 +++++++++++ .../InviteActivityRewardConfigSummary.jsx | 74 ++++ .../hooks/useInviteActivityRewardPage.js | 197 +++++++++++ .../invite-activity-reward.module.css | 107 ++++++ .../pages/InviteActivityRewardPage.jsx | 215 ++++++++++++ .../invite-activity-reward/permissions.js | 11 + src/features/invite-activity-reward/routes.js | 12 + src/features/resources/api.test.ts | 18 +- src/features/resources/api.ts | 7 + .../resources/hooks/useResourcePages.js | 39 +++ src/features/resources/pages/GiftListPage.jsx | 235 +++++++++---- src/features/resources/permissions.js | 1 + src/features/roles/hooks/useRolesPage.js | 1 + src/shared/api/generated/endpoints.ts | 40 +++ src/shared/api/generated/schema.d.ts | 100 +++++- src/shared/api/types.ts | 30 ++ src/shared/app-jump/appJumpConfig.js | 175 ++++++++++ src/shared/app-jump/appJumpConfig.test.js | 50 +++ src/shared/ui/AdminUserIdentity.jsx | 17 +- src/shared/ui/AdminUserIdentity.test.jsx | 20 ++ src/shared/ui/AppJumpConfigField.jsx | 203 +++++++++++ src/shared/ui/AppJumpConfigField.module.css | 16 + 43 files changed, 2977 insertions(+), 240 deletions(-) create mode 100644 src/features/app-config/hooks/usePopupConfigPage.js create mode 100644 src/features/app-config/pages/PopupConfigPage.jsx create mode 100644 src/features/daily-tasks/taskPayload.test.js create mode 100644 src/features/invite-activity-reward/api.ts create mode 100644 src/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx create mode 100644 src/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx create mode 100644 src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js create mode 100644 src/features/invite-activity-reward/invite-activity-reward.module.css create mode 100644 src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx create mode 100644 src/features/invite-activity-reward/permissions.js create mode 100644 src/features/invite-activity-reward/routes.js create mode 100644 src/shared/app-jump/appJumpConfig.js create mode 100644 src/shared/app-jump/appJumpConfig.test.js create mode 100644 src/shared/ui/AppJumpConfigField.jsx create mode 100644 src/shared/ui/AppJumpConfigField.module.css diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 28aafe6..f1a5adf 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -824,6 +824,70 @@ "x-permissions": ["app-config:update"] } }, + "/admin/app-config/popups": { + "get": { + "operationId": "listPopups", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-config:view", + "x-permissions": ["app-config:view"] + }, + "post": { + "operationId": "createPopup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-config:update", + "x-permissions": ["app-config:update"] + } + }, + "/admin/app-config/popups/{popup_id}": { + "put": { + "operationId": "updatePopup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "popup_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "app-config:update", + "x-permissions": ["app-config:update"] + }, + "delete": { + "operationId": "deletePopup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "popup_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "app-config:update", + "x-permissions": ["app-config:update"] + } + }, "/admin/app-config/h5-links": { "get": { "operationId": "listH5Links", @@ -1917,6 +1981,26 @@ ], "x-permission": "gift:update", "x-permissions": ["gift:update"] + }, + "delete": { + "operationId": "deleteGift", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "gift_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "x-permission": "gift:delete", + "x-permissions": ["gift:delete"] } }, "/admin/gifts/{gift_id}/disable": { diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index a55d3e0..bbcbd99 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -23,6 +23,7 @@ import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined"; import PublicOutlined from "@mui/icons-material/PublicOutlined"; import PushPinOutlined from "@mui/icons-material/PushPinOutlined"; import PaidOutlined from "@mui/icons-material/PaidOutlined"; +import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined"; import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined"; import RedeemOutlined from "@mui/icons-material/RedeemOutlined"; import SendOutlined from "@mui/icons-material/SendOutlined"; @@ -64,6 +65,7 @@ const iconMap = { menu: MenuOpenOutlined, operations: ReceiptLongOutlined, paid: PaidOutlined, + person_add: PersonAddAltOutlined, public: PublicOutlined, push_pin: PushPinOutlined, receipt: ReceiptLongOutlined, @@ -150,6 +152,7 @@ export const fallbackNavigation = [ routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }), routeNavItem("app-config-banners", { icon: ImageOutlined }), routeNavItem("app-config-splash-screens", { icon: ImageOutlined }), + routeNavItem("app-config-popups", { icon: ImageOutlined }), routeNavItem("app-config-explore", { icon: MapOutlined }), routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }), ], @@ -203,6 +206,7 @@ export const fallbackNavigation = [ routeNavItem("registration-reward", { icon: CardGiftcardOutlined }), routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }), routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }), + routeNavItem("invite-activity-reward", { icon: PersonAddAltOutlined }), routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }), routeNavItem("room-rocket", { icon: RedeemOutlined }), routeNavItem("room-turnover-reward", { icon: PaidOutlined }), diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx index e6b279f..6fb759b 100644 --- a/src/app/navigation/menu.test.jsx +++ b/src/app/navigation/menu.test.jsx @@ -139,6 +139,12 @@ describe("navigation menu helpers", () => { label: "Explore配置", path: "/app-config/explore", }, + { + code: "app-config-popups", + id: "app-config-popups", + label: "弹窗配置", + path: "/app-config/popups", + }, { code: "app-config-versions", id: "app-config-versions", @@ -160,6 +166,7 @@ describe("navigation menu helpers", () => { "app-config-h5", "app-config-banners", "app-config-splash-screens", + "app-config-popups", "app-config-explore", "app-config-versions", ]); diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 555036a..1f6f0bd 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -117,6 +117,7 @@ export const PERMISSIONS = { giftCreate: "gift:create", giftUpdate: "gift:update", giftStatus: "gift:status", + giftDelete: "gift:delete", emojiPackView: "emoji-pack:view", emojiPackCreate: "emoji-pack:create", dailyTaskView: "daily-task:view", @@ -132,6 +133,8 @@ export const PERMISSIONS = { firstRechargeRewardUpdate: "first-recharge-reward:update", cumulativeRechargeRewardView: "cumulative-recharge-reward:view", cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update", + inviteActivityRewardView: "invite-activity-reward:view", + inviteActivityRewardUpdate: "invite-activity-reward:update", sevenDayCheckInView: "seven-day-checkin:view", sevenDayCheckInUpdate: "seven-day-checkin:update", luckyGiftView: "lucky-gift:view", @@ -184,6 +187,7 @@ export const MENU_CODES = { appConfigH5: "app-config-h5", appConfigBanners: "app-config-banners", appConfigSplashScreens: "app-config-splash-screens", + appConfigPopups: "app-config-popups", appConfigExplore: "app-config-explore", appConfigVersions: "app-config-versions", resources: "resources", @@ -205,6 +209,7 @@ export const MENU_CODES = { registrationReward: "registration-reward", firstRechargeReward: "first-recharge-reward", cumulativeRechargeReward: "cumulative-recharge-reward", + inviteActivityReward: "invite-activity-reward", achievementConfig: "achievement-config", sevenDayCheckIn: "seven-day-checkin", roomRocket: "room-rocket", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 34ec1c3..c626e99 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -10,6 +10,7 @@ import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/rout import { gameRoutes } from "@/features/games/routes.js"; import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js"; import { hostOrgRoutes } from "@/features/host-org/routes.js"; +import { inviteActivityRewardRoutes } from "@/features/invite-activity-reward/routes.js"; import { levelConfigRoutes } from "@/features/level-config/routes.js"; import { logsRoutes } from "@/features/logs/routes.js"; import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js"; @@ -46,6 +47,7 @@ export const adminRoutes: AdminRoute[] = [ ...registrationRewardRoutes, ...firstRechargeRewardRoutes, ...cumulativeRechargeRewardRoutes, + ...inviteActivityRewardRoutes, ...sevenDayCheckInRoutes, ...roomRocketRoutes, ...roomTurnoverRewardRoutes, diff --git a/src/features/app-config/api.ts b/src/features/app-config/api.ts index c38e88d..cf2a9a3 100644 --- a/src/features/app-config/api.ts +++ b/src/features/app-config/api.ts @@ -4,6 +4,8 @@ import type { ApiList, AppBannerDto, AppBannerPayload, + AppPopupDto, + AppPopupPayload, AppSplashScreenDto, AppSplashScreenPayload, AppVersionDto, @@ -163,6 +165,40 @@ export function deleteSplashScreen(splashId: EntityId): Promise<{ deleted: boole ); } +export function listPopups(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listPopups; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listPopups), { + method: endpoint.method, + query, + }); +} + +export function createPopup(payload: AppPopupPayload): Promise { + const endpoint = API_ENDPOINTS.createPopup; + return apiRequest(apiEndpointPath(API_OPERATIONS.createPopup), { + body: payload, + method: endpoint.method, + }); +} + +export function updatePopup(popupId: EntityId, payload: AppPopupPayload): Promise { + const endpoint = API_ENDPOINTS.updatePopup; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updatePopup, { popup_id: popupId }), + { + body: payload, + method: endpoint.method, + }, + ); +} + +export function deletePopup(popupId: EntityId): Promise<{ deleted: boolean }> { + const endpoint = API_ENDPOINTS.deletePopup; + return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deletePopup, { popup_id: popupId }), { + method: endpoint.method, + }); +} + export function listAppVersions(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listAppVersions; return apiRequest>(apiEndpointPath(API_OPERATIONS.listAppVersions), { diff --git a/src/features/app-config/app-config.module.css b/src/features/app-config/app-config.module.css index ffe65dd..c34f94f 100644 --- a/src/features/app-config/app-config.module.css +++ b/src/features/app-config/app-config.module.css @@ -24,6 +24,24 @@ object-fit: cover; } +.popupCover, +.popupCoverEmpty { + display: inline-flex; + width: 72px; + height: 72px; + 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-tertiary); +} + +.popupCover { + object-fit: cover; +} + .splashCover, .splashCoverEmpty { display: inline-flex; @@ -84,6 +102,40 @@ align-self: center; } +.popupFormLayout { + display: grid; + width: 100%; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + align-items: start; + gap: var(--space-5); +} + +.popupAssetPane, +.popupFieldPane { + display: grid; + min-width: 0; + gap: var(--space-4); +} + +.popupAssetPane { + padding-right: var(--space-1); +} + +.popupFieldPane { + padding-left: var(--space-1); +} + +.popupUploadField { + width: min(100%, 280px); + justify-self: center; +} + +.popupUploadField .popupUploadPreview { + aspect-ratio: 1 / 1; + height: auto; + max-height: min(42vh, 300px); +} + .statusCell { display: inline-flex; align-items: center; @@ -105,4 +157,20 @@ justify-self: stretch; width: min(100%, 260px); } + + .popupFormLayout { + grid-template-columns: 1fr; + gap: var(--space-4); + } + + .popupAssetPane, + .popupFieldPane { + padding-right: 0; + padding-left: 0; + } + + .popupUploadField { + justify-self: stretch; + width: min(100%, 280px); + } } diff --git a/src/features/app-config/hooks/usePopupConfigPage.js b/src/features/app-config/hooks/usePopupConfigPage.js new file mode 100644 index 0000000..2a4081a --- /dev/null +++ b/src/features/app-config/hooks/usePopupConfigPage.js @@ -0,0 +1,174 @@ +import { useCallback, useMemo, useState } from "react"; +import { createPopup, deletePopup, listPopups, updatePopup } from "@/features/app-config/api"; +import { useAppConfigAbilities } from "@/features/app-config/permissions.js"; +import { appPopupSchema } from "@/features/app-config/schema"; +import { parseForm } from "@/shared/forms/validation"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const emptyData = { items: [], total: 0 }; + +const emptyForm = () => ({ + code: "", + displayPeriodDays: "0", + endsAtMs: "", + imageUrl: "", + jumpType: "h5", + jumpUrl: "", + name: "", + sortOrder: "0", + startsAtMs: "", + status: "active", +}); + +export function usePopupConfigPage() { + const abilities = useAppConfigAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + const [keyword, setKeyword] = useState(""); + const [status, setStatus] = useState(""); + const [activeAction, setActiveAction] = useState(""); + const [editingItem, setEditingItem] = useState(null); + const [form, setFormState] = useState(emptyForm); + const [loadingAction, setLoadingAction] = useState(""); + + const filters = useMemo( + () => ({ + keyword, + status, + }), + [keyword, status], + ); + const queryFn = useCallback(() => listPopups(filters), [filters]); + const { + data = emptyData, + error, + loading, + reload, + } = useAdminQuery(queryFn, { + errorMessage: "加载弹窗配置失败", + initialData: emptyData, + queryKey: ["app-config", "popups", filters], + }); + + const setForm = (patch) => { + setFormState((current) => ({ ...current, ...patch })); + }; + + const openCreate = () => { + setEditingItem(null); + setFormState(emptyForm()); + setActiveAction("create"); + }; + + const openEdit = (item) => { + setEditingItem(item); + setFormState(formFromPopup(item)); + setActiveAction("edit"); + }; + + const closeDialog = () => { + setActiveAction(""); + setEditingItem(null); + setFormState(emptyForm()); + }; + + const resetFilters = () => { + setKeyword(""); + setStatus(""); + }; + + const submitPopup = async (event) => { + event.preventDefault(); + const payload = parseForm(appPopupSchema, normalizeForm(form)); + const action = editingItem ? "edit" : "create"; + setLoadingAction(action); + try { + if (editingItem) { + await updatePopup(editingItem.id, payload); + showToast("弹窗配置已更新", "success"); + } else { + await createPopup(payload); + showToast("弹窗配置已新增", "success"); + } + closeDialog(); + await reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const removePopup = async (item) => { + const ok = await confirm({ + confirmText: "删除", + message: item.name || item.jumpUrl || `弹窗配置 ${item.id}`, + title: "删除弹窗配置", + tone: "danger", + }); + if (!ok) { + return; + } + setLoadingAction(`delete:${item.id}`); + try { + await deletePopup(item.id); + await reload(); + showToast("弹窗配置已删除", "success"); + } catch (err) { + showToast(err.message || "删除失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + abilities, + activeAction, + closeDialog, + data, + editingItem, + error, + form, + keyword, + loading, + loadingAction, + openCreate, + openEdit, + reload, + removePopup, + resetFilters, + setForm, + setKeyword, + setStatus, + status, + submitPopup, + }; +} + +function normalizeForm(form) { + return { + ...form, + displayPeriodDays: form.displayPeriodDays || 0, + endsAtMs: form.endsAtMs || 0, + imageUrl: form.imageUrl || "", + sortOrder: form.sortOrder || 0, + startsAtMs: form.startsAtMs || 0, + }; +} + +function formFromPopup(item) { + return { + code: item.code || "", + displayPeriodDays: String(item.displayPeriodDays ?? 0), + endsAtMs: item.endsAtMs || "", + imageUrl: item.imageUrl || "", + jumpType: item.jumpType || "h5", + jumpUrl: item.jumpUrl || "", + name: item.name || "", + sortOrder: String(item.sortOrder || 0), + startsAtMs: item.startsAtMs || "", + status: item.status || "active", + }; +} diff --git a/src/features/app-config/pages/BannerConfigPage.jsx b/src/features/app-config/pages/BannerConfigPage.jsx index 9c0dc7c..0c75a8c 100644 --- a/src/features/app-config/pages/BannerConfigPage.jsx +++ b/src/features/app-config/pages/BannerConfigPage.jsx @@ -27,6 +27,7 @@ import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; +import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx"; @@ -116,17 +117,6 @@ export function BannerConfigPage() { - page.setForm({ bannerType: event.target.value })} - > - H5 - APP - 安卓 iOS - page.setForm({ param: event.target.value })} + h5Label="参数(H5链接)" + paramValue={page.form.param} + required + typeLabel="类型" + typeValue={page.form.bannerType} + onChange={({ param, type }) => page.setForm({ bannerType: type, param })} /> diff --git a/src/features/app-config/pages/PopupConfigPage.jsx b/src/features/app-config/pages/PopupConfigPage.jsx new file mode 100644 index 0000000..8ff0841 --- /dev/null +++ b/src/features/app-config/pages/PopupConfigPage.jsx @@ -0,0 +1,326 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import TextField from "@mui/material/TextField"; +import { useMemo } from "react"; +import styles from "@/features/app-config/app-config.module.css"; +import { usePopupConfigPage } from "@/features/app-config/hooks/usePopupConfigPage.js"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormSection, + AdminFormSwitchField, +} from "@/shared/ui/AdminFormDialog.jsx"; +import { + AdminActionIconButton, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, +} 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 { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import { UploadField } from "@/shared/ui/UploadField.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; + +const statusOptions = [ + ["", "全部状态"], + ["active", "启用"], + ["disabled", "关闭"], + ["expired", "过期"], +]; + +export function PopupConfigPage() { + const page = usePopupConfigPage(); + const items = page.data.items || []; + const columns = useMemo(() => popupColumns(page), [page]); + + return ( + + + + + ) : null + } + /> + + + item.id} + /> + + + + +
+
+ + page.setForm({ imageUrl })} + /> + +
+
+ + + page.setForm({ code: event.target.value })} + /> + page.setForm({ name: event.target.value })} + /> + page.setForm({ displayPeriodDays: event.target.value })} + /> + page.setForm({ jumpType: type, jumpUrl: param })} + /> + + + + + page.setForm({ sortOrder: event.target.value })} + /> + page.setForm({ status: checked ? "active" : "disabled" })} + /> + + page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs }) + } + /> + + +
+
+
+
+ ); +} + +function popupColumns(page) { + const columns = [ + { + key: "image", + label: "图片", + render: (item) => , + width: "minmax(96px, 0.45fr)", + }, + { + key: "name", + label: "弹窗名字", + render: (item) => , + filter: createTextColumnFilter({ + placeholder: "搜索code、名字、跳转地址", + value: page.keyword, + onChange: page.setKeyword, + }), + width: "minmax(200px, 0.9fr)", + }, + { + key: "jump", + label: "跳转", + render: (item) => , + width: "minmax(260px, 1.25fr)", + }, + { + key: "displayPeriodDays", + label: "展示周期", + render: (item) => periodLabel(item.displayPeriodDays), + width: "minmax(110px, 0.55fr)", + }, + { + key: "status", + label: "状态", + render: (item) => , + filter: createOptionsColumnFilter({ + options: statusOptions, + placeholder: "搜索状态", + value: page.status, + onChange: page.setStatus, + }), + width: "minmax(100px, 0.5fr)", + }, + { + key: "sortOrder", + label: "排序", + width: "minmax(80px, 0.4fr)", + }, + { + key: "effectiveTime", + label: "生效时间", + render: (item) => effectiveTimeLabel(item.startsAtMs, item.endsAtMs), + width: "minmax(220px, 1fr)", + }, + { + key: "updatedAtMs", + label: "更新时间", + render: (item) => formatMillis(item.updatedAtMs), + width: "minmax(160px, 0.75fr)", + }, + ]; + + if (!page.abilities.canUpdate) { + return columns; + } + + return [ + ...columns, + { + key: "actions", + label: "操作", + render: (item) => , + width: "minmax(96px, 0.45fr)", + }, + ]; +} + +function PopupImage({ src }) { + if (!src) { + return ( + + + + ); + } + return ; +} + +function PopupActions({ item, page }) { + const deleting = page.loadingAction === `delete:${item.id}`; + return ( + + page.openEdit(item)} + > + + + page.removePopup(item)} + > + + + + ); +} + +function Stack({ primary, secondary }) { + return ( +
+ {primary || "-"} + {secondary || "-"} +
+ ); +} + +function StatusBadge({ status }) { + const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped"; + return ( + + + {statusLabel(status)} + + ); +} + +function typeLabel(value) { + return value === "app" ? "APP" : "H5"; +} + +function periodLabel(value) { + const days = Number(value || 0); + if (!Number.isFinite(days) || days <= 0) { + return "每次打开"; + } + return `${days} 天一次`; +} + +function effectiveTimeLabel(startsAtMs, endsAtMs) { + const start = startsAtMs ? formatMillis(startsAtMs) : "不限"; + const end = endsAtMs ? formatMillis(endsAtMs) : "不限"; + return `${start} - ${end}`; +} + +function statusLabel(status) { + if (status === "active") { + return "启用"; + } + if (status === "expired") { + return "过期"; + } + return "关闭"; +} diff --git a/src/features/app-config/pages/SplashScreenConfigPage.jsx b/src/features/app-config/pages/SplashScreenConfigPage.jsx index a360d18..1019437 100644 --- a/src/features/app-config/pages/SplashScreenConfigPage.jsx +++ b/src/features/app-config/pages/SplashScreenConfigPage.jsx @@ -24,6 +24,7 @@ import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; +import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx"; @@ -102,17 +103,6 @@ export function SplashScreenConfigPage() {
- page.setForm({ splashType: event.target.value })} - > - H5 - APP - 安卓 iOS - page.setForm({ param: event.target.value })} + h5Label="参数(H5链接)" + paramValue={page.form.param} + required + typeLabel="类型" + typeValue={page.form.splashType} + onChange={({ param, type }) => page.setForm({ param, splashType: type })} /> diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js index 7b54225..c3dba2b 100644 --- a/src/features/app-config/routes.js +++ b/src/features/app-config/routes.js @@ -25,6 +25,14 @@ export const appConfigRoutes = [ path: "/app-config/splash-screens", permission: PERMISSIONS.appConfigView, }, + { + label: "弹窗配置", + loader: () => import("./pages/PopupConfigPage.jsx").then((module) => module.PopupConfigPage), + menuCode: MENU_CODES.appConfigPopups, + pageKey: "app-config-popups", + path: "/app-config/popups", + permission: PERMISSIONS.appConfigView, + }, { label: "Explore配置", loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage), diff --git a/src/features/app-config/schema.test.ts b/src/features/app-config/schema.test.ts index 059459d..af560aa 100644 --- a/src/features/app-config/schema.test.ts +++ b/src/features/app-config/schema.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { appBannerSchema, + appPopupSchema, appSplashScreenSchema, exploreTabSchema, h5LinkUpdateSchema, @@ -39,6 +40,19 @@ const validSplashScreenForm = { status: "active", }; +const validPopupForm = { + code: "new-user-popup", + displayPeriodDays: "2", + endsAtMs: "1800000000000", + imageUrl: "", + jumpType: "h5", + jumpUrl: "https://h5.example.com/popup", + name: "新人活动", + sortOrder: "0", + startsAtMs: "1700000000000", + status: "active", +}; + describe("app config form schema", () => { test("validates dynamic h5 config item", () => { const payload = parseForm(h5LinkUpdateSchema, { @@ -69,6 +83,27 @@ describe("app config form schema", () => { expect(payload.endsAtMs).toBe(1800000000000); }); + test("accepts public app jump json for banner", () => { + const payload = parseForm(appBannerSchema, { + ...validBannerForm, + bannerType: "app", + param: '{"type":"room_random_game","game_id":"dice"}', + }); + + expect(payload.bannerType).toBe("app"); + expect(payload.param).toBe('{"type":"room_random_game","game_id":"dice"}'); + }); + + test("rejects incomplete public app jump json for banner", () => { + expect(() => + parseForm(appBannerSchema, { + ...validBannerForm, + bannerType: "app", + param: '{"type":"room_game","room_id":"room_1"}', + }), + ).toThrow(FormValidationError); + }); + test("accepts multiple banner display scopes", () => { const payload = parseForm(appBannerSchema, { ...validBannerForm, @@ -109,6 +144,17 @@ describe("app config form schema", () => { expect("displayScopes" in payload).toBe(false); }); + test("accepts public app jump json for splash screen", () => { + const payload = parseForm(appSplashScreenSchema, { + ...validSplashScreenForm, + param: '{"type":"wallet"}', + splashType: "app", + }); + + expect(payload.splashType).toBe("app"); + expect(payload.param).toBe('{"type":"wallet"}'); + }); + test("requires h5 link for splash screen h5 type", () => { expect(() => parseForm(appSplashScreenSchema, { @@ -137,6 +183,57 @@ describe("app config form schema", () => { ).toThrow(FormValidationError); }); + test("keeps popup optional image and display period", () => { + const payload = parseForm(appPopupSchema, validPopupForm); + + expect(payload.code).toBe("new-user-popup"); + expect(payload.imageUrl).toBe(""); + expect(payload.displayPeriodDays).toBe(2); + expect(payload.startsAtMs).toBe(1700000000000); + expect(payload.endsAtMs).toBe(1800000000000); + }); + + test("accepts public app jump json for popup", () => { + const payload = parseForm(appPopupSchema, { + ...validPopupForm, + jumpType: "app", + jumpUrl: '{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}', + }); + + expect(payload.jumpType).toBe("app"); + expect(payload.jumpUrl).toBe( + '{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}', + ); + }); + + test("rejects popup code with spaces", () => { + expect(() => + parseForm(appPopupSchema, { + ...validPopupForm, + code: "new user popup", + }), + ).toThrow(FormValidationError); + }); + + test("requires popup jump address", () => { + expect(() => + parseForm(appPopupSchema, { + ...validPopupForm, + jumpUrl: "", + }), + ).toThrow(FormValidationError); + }); + + test("rejects inverted popup effective time range", () => { + expect(() => + parseForm(appPopupSchema, { + ...validPopupForm, + endsAtMs: "1700000000000", + startsAtMs: "1800000000000", + }), + ).toThrow(FormValidationError); + }); + test("validates explore tab h5 url and sort order", () => { const payload = parseForm(exploreTabSchema, { enabled: true, diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts index 9f80625..ae4aeff 100644 --- a/src/features/app-config/schema.ts +++ b/src/features/app-config/schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; const appBannerDisplayScopes = ["home", "room", "recharge"] as const; @@ -70,6 +71,12 @@ export const appBannerSchema = z if (value.bannerType === "h5" && /\s/.test(value.param)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] }); } + if (value.bannerType === "app") { + const validation = validatePublicAppJumpParam(value.param); + if (!validation.valid) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] }); + } + } if (value.displayScopes.includes("room") && !value.roomSmallImageUrl) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -115,6 +122,12 @@ export const appSplashScreenSchema = z if (value.splashType === "h5" && /\s/.test(value.param)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] }); } + if (value.splashType === "app") { + const validation = validatePublicAppJumpParam(value.param); + if (!validation.valid) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] }); + } + } if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["endsAtMs"] }); } @@ -122,6 +135,45 @@ export const appSplashScreenSchema = z export type AppSplashScreenForm = z.infer; +export const appPopupSchema = z + .object({ + code: z + .string() + .trim() + .min(1, "请填写弹窗code") + .max(80, "弹窗code不能超过 80 个字符") + .regex(/^[A-Za-z0-9_.:-]+$/, "弹窗code只能包含字母、数字、下划线、中横线、点和冒号"), + displayPeriodDays: z.coerce.number().int("展示周期必须是整数天").min(0, "展示周期不能小于 0").default(0), + endsAtMs: z.coerce.number().int().min(0, "生效结束时间不正确").default(0), + imageUrl: z + .string() + .trim() + .max(1024, "图片地址不能超过 1024 个字符") + .refine((value) => !/\s/.test(value), "图片地址不能包含空白字符"), + jumpType: z.enum(["h5", "app"]), + jumpUrl: z.string().trim().min(1, "请填写跳转地址").max(2048, "跳转地址不能超过 2048 个字符"), + name: z.string().trim().min(1, "请填写弹窗名字").max(80, "弹窗名字不能超过 80 个字符"), + sortOrder: z.coerce.number().int("排序必须是整数").default(0), + startsAtMs: z.coerce.number().int().min(0, "生效开始时间不正确").default(0), + status: z.enum(["active", "disabled", "expired"]), + }) + .superRefine((value, ctx) => { + if (value.jumpType === "h5" && /\s/.test(value.jumpUrl)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["jumpUrl"] }); + } + if (value.jumpType === "app") { + const validation = validatePublicAppJumpParam(value.jumpUrl); + if (!validation.valid) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["jumpUrl"] }); + } + } + if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "生效结束时间必须晚于开始时间", path: ["endsAtMs"] }); + } + }); + +export type AppPopupForm = z.infer; + export const appVersionSchema = z.object({ buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"), description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""), diff --git a/src/features/daily-tasks/daily-tasks.module.css b/src/features/daily-tasks/daily-tasks.module.css index eef791b..8620acd 100644 --- a/src/features/daily-tasks/daily-tasks.module.css +++ b/src/features/daily-tasks/daily-tasks.module.css @@ -71,3 +71,36 @@ align-items: center; gap: var(--space-1); } + +.jumpConfigGrid { + display: grid; + grid-template-columns: minmax(220px, 0.72fr) minmax(320px, 1.28fr); + align-items: start; + gap: var(--space-3); + width: 100%; +} + +.jumpConfigSide { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.iconUpload { + min-width: 0; +} + +.flutterJumpField { + min-width: 0; +} + +.flutterJumpField :global(.MuiInputBase-root) { + min-height: 162px; + align-items: flex-start; +} + +@media (max-width: 760px) { + .jumpConfigGrid { + grid-template-columns: 1fr; + } +} diff --git a/src/features/daily-tasks/hooks/useDailyTasksPage.js b/src/features/daily-tasks/hooks/useDailyTasksPage.js index 0f7f939..b7c36c2 100644 --- a/src/features/daily-tasks/hooks/useDailyTasksPage.js +++ b/src/features/daily-tasks/hooks/useDailyTasksPage.js @@ -13,27 +13,29 @@ import { dailyTaskFormSchema } from "@/features/daily-tasks/schema"; const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; -const emptyForm = (task = {}) => ({ - actionParam: task.actionParam || "", - actionPayloadJson: task.actionPayloadJson || "{}", - actionType: task.actionType || "none", - audienceType: task.audienceType || "all", - category: task.category || "", - description: task.description || "", - dimensionFilterJson: task.dimensionFilterJson || "{}", - effectiveFrom: msToDatetimeLocal(task.effectiveFromMs), - effectiveTo: msToDatetimeLocal(task.effectiveToMs), - iconKey: task.iconKey || "", - iconUrl: task.iconUrl || "", - metricType: task.metricType || "", - rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "", - sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0", - status: task.status || "draft", - targetUnit: task.targetUnit || "count", - targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "", - taskType: task.taskType || "daily", - title: task.title || "", -}); +const emptyForm = (task = {}) => { + const actionType = task.actionType || "none"; + const flutterJumpParam = taskFlutterJumpParam(actionType, task.actionParam, task.actionPayloadJson); + + return { + actionParam: task.actionParam || "", + actionType, + category: task.category || "", + description: task.description || "", + effectiveFrom: msToDatetimeLocal(task.effectiveFromMs), + effectiveTo: msToDatetimeLocal(task.effectiveToMs), + flutterJumpParam, + iconUrl: task.iconUrl || "", + metricType: task.metricType || "", + rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "", + sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0", + status: task.status || "draft", + targetUnit: task.targetUnit || "count", + targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "", + taskType: task.taskType || "daily", + title: task.title || "", + }; +}; export function useDailyTasksPage() { const abilities = useDailyTaskAbilities(); @@ -41,7 +43,6 @@ export function useDailyTasksPage() { const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); const [taskType, setTaskType] = useState(""); - const [category, setCategory] = useState(""); const [page, setPage] = useState(1); const [activeAction, setActiveAction] = useState(""); const [selectedTask, setSelectedTask] = useState(null); @@ -49,12 +50,11 @@ export function useDailyTasksPage() { const [loadingAction, setLoadingAction] = useState(""); const filters = useMemo( () => ({ - category, keyword: query, status, task_type: taskType, }), - [category, query, status, taskType], + [query, status, taskType], ); const { data = emptyData, @@ -73,13 +73,11 @@ export function useDailyTasksPage() { const changeQuery = resetSetter(setQuery, setPage); const changeStatus = resetSetter(setStatus, setPage); const changeTaskType = resetSetter(setTaskType, setPage); - const changeCategory = resetSetter(setCategory, setPage); const resetFilters = () => { setQuery(""); setStatus(""); setTaskType(""); - setCategory(""); setPage(1); }; @@ -103,6 +101,15 @@ export function useDailyTasksPage() { setSelectedTask(null); }; + const changeFormActionType = (actionType) => { + setForm((current) => ({ + ...current, + actionParam: "", + actionType, + flutterJumpParam: defaultFlutterJumpParam(actionType), + })); + }; + const submitTask = async (event) => { event.preventDefault(); const editing = activeAction === "edit"; @@ -151,8 +158,7 @@ export function useDailyTasksPage() { return { abilities, activeAction, - category, - changeCategory, + changeFormActionType, changeQuery, changeStatus, changeTaskType, @@ -178,18 +184,25 @@ export function useDailyTasksPage() { }; } -function buildTaskPayload(form) { +export function buildTaskPayload(form) { + // 表单不再暴露“跳转扩展 JSON”,但后端和 App 仍读取 action_payload_json。 + // 这里把可编辑的 Flutter 跳转参数压缩成对象 JSON,既保留后台配置能力,也避免 UI 暴露底层字段名。 + const actionPayloadJSON = normalizedJSONObject(form.flutterJumpParam || defaultFlutterJumpParam(form.actionType)); + return { - action_param: form.actionParam.trim(), - action_payload_json: normalizedJSONObject(form.actionPayloadJson), + // room_random_game / gift_panel_specific 的旧 App 合约仍从 action_param 读取 game_id/gift_id。 + // 新表单只让运营维护一份 Flutter 参数,所以提交前从对象里反抽标量,保证新旧字段保持同一含义。 + action_param: actionParamFromFlutterJump(form.actionType, actionPayloadJSON, form.actionParam), + action_payload_json: actionPayloadJSON, action_type: form.actionType, - audience_type: form.audienceType, - category: form.category.trim(), + // 人群、维度过滤、图标键已从表单移除:任务默认全量用户可见,不做事件维度限制,图标只使用上传 URL。 + audience_type: "all", + category: hiddenTaskCategory(form), description: form.description.trim(), - dimension_filter_json: normalizedJSONObject(form.dimensionFilterJson), + dimension_filter_json: "{}", effective_from_ms: datetimeLocalToMs(form.effectiveFrom), effective_to_ms: datetimeLocalToMs(form.effectiveTo), - icon_key: form.iconKey.trim(), + icon_key: "", icon_url: form.iconUrl.trim(), metric_type: form.metricType.trim(), reward_coin_amount: Number(form.rewardCoinAmount), @@ -202,6 +215,52 @@ function buildTaskPayload(form) { }; } +export function defaultFlutterJumpParam(actionType) { + const template = + { + gift_panel: { route: "gift_panel" }, + gift_panel_specific: { gift_id: "", route: "gift_panel" }, + none: {}, + room_random: { route: "room_random" }, + room_random_game: { game_id: "", route: "room_random", show_game: true }, + wallet: { route: "wallet" }, + }[actionType] || {}; + + return JSON.stringify(template, null, 2); +} + +function taskFlutterJumpParam(actionType, actionParam, actionPayloadJSON) { + const parsed = parseObjectJSON(actionPayloadJSON); + const payload = Object.keys(parsed).length > 0 ? parsed : parseObjectJSON(defaultFlutterJumpParam(actionType)); + const scalarParam = String(actionParam || "").trim(); + + if (actionType === "room_random_game" && scalarParam && !payload.game_id) { + payload.game_id = scalarParam; + } + if (actionType === "gift_panel_specific" && scalarParam && !payload.gift_id) { + payload.gift_id = scalarParam; + } + + return JSON.stringify(payload, null, 2); +} + +function actionParamFromFlutterJump(actionType, actionPayloadJSON, fallback = "") { + const payload = parseObjectJSON(actionPayloadJSON); + + if (actionType === "room_random_game") { + return String(payload.game_id || payload.gameId || fallback || "").trim(); + } + if (actionType === "gift_panel_specific") { + return String(payload.gift_id || payload.giftId || fallback || "").trim(); + } + return String(fallback || "").trim(); +} + +function hiddenTaskCategory(form) { + const value = String(form.category || form.metricType || form.title || "task").trim(); + return value.slice(0, 32) || "task"; +} + function normalizedJSONObject(value) { const trimmed = String(value || "").trim(); if (!trimmed) { @@ -218,6 +277,16 @@ function normalizedJSONObject(value) { } } +function parseObjectJSON(value) { + const normalized = normalizedJSONObject(value); + try { + const parsed = JSON.parse(normalized); + return parsed && !Array.isArray(parsed) && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + function resetSetter(setter, setPage) { return (value) => { setter(value); diff --git a/src/features/daily-tasks/pages/DailyTaskListPage.jsx b/src/features/daily-tasks/pages/DailyTaskListPage.jsx index 12eacb8..f35e5a3 100644 --- a/src/features/daily-tasks/pages/DailyTaskListPage.jsx +++ b/src/features/daily-tasks/pages/DailyTaskListPage.jsx @@ -21,12 +21,11 @@ import { AdminRowActions, } from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; +import { UploadField } from "@/shared/ui/UploadField.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { taskActionLabels, taskActionOptions, - taskAudienceLabels, - taskAudienceOptions, taskMetricOptions, taskStatusLabels, taskStatusOptions, @@ -51,7 +50,9 @@ const baseColumns = [ render: (task) => (
{task.metricType} - {task.category} + + {formatNumber(task.targetValue)} {taskUnitLabel(task.targetUnit)} +
), }, @@ -61,11 +62,8 @@ const baseColumns = [ width: "minmax(220px, 1fr)", render: (task) => (
- {taskAudienceLabel(task.audienceType)} - - {taskActionLabel(task.actionType)} - {task.actionParam ? `:${task.actionParam}` : ""} - + {taskActionLabel(task.actionType)} + {task.actionParam || task.actionPayloadJson || "-"}
), }, @@ -141,14 +139,7 @@ export function DailyTaskListPage() { }; } if (column.key === "metric") { - return { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索分类", - value: page.category, - onChange: page.changeCategory, - }), - }; + return column; } if (column.key === "status") { return { @@ -183,7 +174,7 @@ export function DailyTaskListPage() { onChange={page.changeTaskType} /> @@ -328,13 +319,6 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open value={form.title} onChange={(event) => page.setForm({ ...form, title: event.target.value })} /> - page.setForm({ ...form, category: event.target.value })} - /> @@ -393,70 +377,41 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open - +
+
+ page.changeFormActionType(event.target.value)} + > + {taskActionOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setForm({ ...form, iconUrl })} + /> +
page.setForm({ ...form, audienceType: event.target.value })} - > - {taskAudienceOptions.map(([value, label]) => ( - - {label} - - ))} - - page.setForm({ ...form, actionType: event.target.value })} - > - {taskActionOptions.map(([value, label]) => ( - - {label} - - ))} - - page.setForm({ ...form, actionParam: event.target.value })} - /> - page.setForm({ ...form, iconKey: event.target.value })} - /> - page.setForm({ ...form, iconUrl: event.target.value })} - /> - page.setForm({ ...form, dimensionFilterJson: event.target.value })} + minRows={5} + label="Flutter 跳转参数" + value={form.flutterJumpParam} + onChange={(event) => page.setForm({ ...form, flutterJumpParam: event.target.value })} /> - page.setForm({ ...form, actionPayloadJson: event.target.value })} - /> - +
@@ -500,10 +455,6 @@ function taskStatusLabel(value) { return taskStatusLabels[value] || value || "-"; } -function taskAudienceLabel(value) { - return taskAudienceLabels[value] || value || "-"; -} - function taskActionLabel(value) { return taskActionLabels[value] || value || "-"; } diff --git a/src/features/daily-tasks/permissions.js b/src/features/daily-tasks/permissions.js index 0926dbd..33ffff2 100644 --- a/src/features/daily-tasks/permissions.js +++ b/src/features/daily-tasks/permissions.js @@ -8,6 +8,7 @@ export function useDailyTaskAbilities() { canCreate: can(PERMISSIONS.dailyTaskCreate), canStatus: can(PERMISSIONS.dailyTaskStatus), canUpdate: can(PERMISSIONS.dailyTaskUpdate), + canUpload: can(PERMISSIONS.uploadCreate), canView: can(PERMISSIONS.dailyTaskView), }; } diff --git a/src/features/daily-tasks/schema.ts b/src/features/daily-tasks/schema.ts index 841e006..95908f4 100644 --- a/src/features/daily-tasks/schema.ts +++ b/src/features/daily-tasks/schema.ts @@ -2,16 +2,13 @@ import { z } from "zod"; export const dailyTaskFormSchema = z .object({ - category: z.string().trim().min(1, "请输入任务分类").max(32, "任务分类不能超过 32 个字符"), - actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符"), - actionPayloadJson: z.string().trim().optional(), + actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符").optional(), actionType: z.enum(["none", "room_random", "room_random_game", "wallet", "gift_panel", "gift_panel_specific"]), - audienceType: z.enum(["all", "newbie"]), + category: z.string().trim().max(32, "任务分类不能超过 32 个字符").optional(), description: z.string().trim().max(512, "描述不能超过 512 个字符"), - dimensionFilterJson: z.string().trim().optional(), effectiveFrom: z.string().optional(), effectiveTo: z.string().optional(), - iconKey: z.string().trim().max(64, "图标键不能超过 64 个字符"), + flutterJumpParam: z.string().trim().optional(), iconUrl: z.string().trim().max(512, "图标 URL 不能超过 512 个字符"), metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"), rewardCoinAmount: z.union([z.string(), z.number()]), @@ -47,11 +44,12 @@ export const dailyTaskFormSchema = z if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) { context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] }); } - if (!isJSONObject(value.actionPayloadJson)) { - context.addIssue({ code: "custom", message: "跳转扩展必须是 JSON 对象", path: ["actionPayloadJson"] }); - } - if (!isJSONObject(value.dimensionFilterJson)) { - context.addIssue({ code: "custom", message: "维度过滤必须是 JSON 对象", path: ["dimensionFilterJson"] }); + if (!isJSONObject(value.flutterJumpParam)) { + context.addIssue({ + code: "custom", + message: "Flutter 跳转参数必须是对象格式", + path: ["flutterJumpParam"], + }); } }); diff --git a/src/features/daily-tasks/taskPayload.test.js b/src/features/daily-tasks/taskPayload.test.js new file mode 100644 index 0000000..2676615 --- /dev/null +++ b/src/features/daily-tasks/taskPayload.test.js @@ -0,0 +1,38 @@ +import { expect, test } from "vitest"; +import { parseForm } from "@/shared/forms/validation"; +import { buildTaskPayload, defaultFlutterJumpParam } from "./hooks/useDailyTasksPage.js"; +import { dailyTaskFormSchema } from "./schema"; + +test("daily task payload hides removed admin fields and keeps Flutter jump params editable", () => { + const form = parseForm(dailyTaskFormSchema, { + actionType: "room_random_game", + description: "", + effectiveFrom: "", + effectiveTo: "", + flutterJumpParam: JSON.stringify({ game_id: "dice", route: "room_random", show_game: true }, null, 2), + iconUrl: "https://media.haiyihy.com/admin/images/task-game.png", + metricType: "game_spend_coin", + rewardCoinAmount: "20", + sortOrder: "3", + status: "active", + targetUnit: "coin", + targetValue: "100", + taskType: "daily", + title: "玩游戏消耗金币", + }); + + const payload = buildTaskPayload(form); + + expect(payload.category).toBe("game_spend_coin"); + expect(payload.audience_type).toBe("all"); + expect(payload.dimension_filter_json).toBe("{}"); + expect(payload.icon_key).toBe(""); + expect(payload.icon_url).toBe("https://media.haiyihy.com/admin/images/task-game.png"); + expect(payload.action_param).toBe("dice"); + expect(payload.action_payload_json).toBe('{"game_id":"dice","route":"room_random","show_game":true}'); +}); + +test("daily task action type selection provides concrete Flutter jump template", () => { + expect(defaultFlutterJumpParam("gift_panel_specific")).toContain('"gift_id": ""'); + expect(defaultFlutterJumpParam("wallet")).toContain('"route": "wallet"'); +}); diff --git a/src/features/invite-activity-reward/api.ts b/src/features/invite-activity-reward/api.ts new file mode 100644 index 0000000..707a7de --- /dev/null +++ b/src/features/invite-activity-reward/api.ts @@ -0,0 +1,178 @@ +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 InviteActivityRewardTierDto { + tierId: number; + rewardType: string; + tierCode: string; + tierName: string; + thresholdCoinAmount: number; + thresholdValidInviteCount: number; + rewardCoinAmount: number; + status: string; + sortOrder: number; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface InviteActivityRewardConfigDto { + appCode?: string; + enabled: boolean; + tiers: InviteActivityRewardTierDto[]; + updatedByAdminId?: number; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface InviteActivityRewardConfigPayload { + enabled: boolean; + tiers: Array<{ + tier_id?: number; + reward_type: string; + tier_code: string; + tier_name: string; + threshold_coin_amount: number; + threshold_valid_invite_count: number; + reward_coin_amount: number; + status: string; + sort_order: number; + }>; +} + +export interface InviteActivityRewardClaimUserDto { + userId?: number; + displayUserId?: string; + username?: string; + avatar?: string; +} + +export interface InviteActivityRewardClaimDto { + claimId: string; + appCode?: string; + cycleKey?: string; + userId: number; + user?: InviteActivityRewardClaimUserDto; + rewardType?: string; + commandId?: string; + tierId?: number; + tierCode?: string; + tierName?: string; + thresholdCoinAmount?: number; + thresholdValidInviteCount?: number; + reachedValue?: number; + rewardCoinAmount?: number; + status?: string; + walletCommandId?: string; + walletTransactionId?: string; + failureReason?: string; + grantedAtMs?: number; + createdAtMs?: number; + updatedAtMs?: number; +} + +type RawConfig = InviteActivityRewardConfigDto & Record; +type RawTier = InviteActivityRewardTierDto & Record; +type RawClaim = InviteActivityRewardClaimDto & Record; + +export function getInviteActivityRewardConfig(): Promise { + const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getInviteActivityRewardConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateInviteActivityRewardConfig( + payload: InviteActivityRewardConfigPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateInviteActivityRewardConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateInviteActivityRewardConfig), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeConfig); +} + +export function listInviteActivityRewardClaims( + query: PageQuery = {}, +): Promise> { + const endpoint = API_ENDPOINTS.listInviteActivityRewardClaims; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listInviteActivityRewardClaims), { + method: endpoint.method, + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizeClaim), + })); +} + +function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto { + 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): InviteActivityRewardTierDto { + return { + tierId: numberValue(item.tierId ?? item.tier_id), + rewardType: stringValue(item.rewardType ?? item.reward_type), + tierCode: stringValue(item.tierCode ?? item.tier_code), + tierName: stringValue(item.tierName ?? item.tier_name), + thresholdCoinAmount: numberValue(item.thresholdCoinAmount ?? item.threshold_coin_amount), + thresholdValidInviteCount: numberValue(item.thresholdValidInviteCount ?? item.threshold_valid_invite_count), + rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount), + 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): InviteActivityRewardClaimDto { + const rawUser = (item.user || {}) as Record; + return { + claimId: stringValue(item.claimId ?? item.claim_id), + appCode: stringValue(item.appCode ?? item.app_code), + cycleKey: stringValue(item.cycleKey ?? item.cycle_key), + 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), + }, + rewardType: stringValue(item.rewardType ?? item.reward_type), + commandId: stringValue(item.commandId ?? item.command_id), + tierId: numberValue(item.tierId ?? item.tier_id), + tierCode: stringValue(item.tierCode ?? item.tier_code), + tierName: stringValue(item.tierName ?? item.tier_name), + thresholdCoinAmount: numberValue(item.thresholdCoinAmount ?? item.threshold_coin_amount), + thresholdValidInviteCount: numberValue(item.thresholdValidInviteCount ?? item.threshold_valid_invite_count), + reachedValue: numberValue(item.reachedValue ?? item.reached_value), + rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount), + status: stringValue(item.status), + walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id), + walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id), + failureReason: stringValue(item.failureReason ?? item.failure_reason), + 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/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx b/src/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx new file mode 100644 index 0000000..8da51ef --- /dev/null +++ b/src/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx @@ -0,0 +1,200 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import SaveOutlined from "@mui/icons-material/SaveOutlined"; +import Drawer from "@mui/material/Drawer"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx"; +import styles from "@/features/invite-activity-reward/invite-activity-reward.module.css"; + +const rewardTypeOptions = [ + ["recharge", "累计充值"], + ["valid_invite", "有效人数"], +]; + +export function InviteActivityRewardConfigDrawer({ + abilities, + configLoading, + configSaving, + form, + onClose, + onSubmit, + open, + setForm, +}) { + const disabled = !abilities.canUpdate || configLoading || configSaving; + + const addTier = () => { + setForm((current) => ({ + ...current, + tiers: [ + ...(current.tiers || []), + { + tierId: 0, + rewardType: "recharge", + tierCode: `invite_reward_${Date.now()}_${(current.tiers || []).length + 1}`, + tierName: "", + thresholdCoinAmount: "", + thresholdValidInviteCount: "", + rewardCoinAmount: "", + 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 }))} + /> +
+
+
+ 奖励档位 + +
+ ( + 档位 {index + 1} + ), + }, + { + key: "rewardType", + label: "类型", + render: (tier, index) => ( + updateTier(index, { rewardType: event.target.value })} + > + {rewardTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + ), + }, + { + key: "threshold", + label: "门槛", + render: (tier, index) => { + const isRecharge = (tier.rewardType || "recharge") === "recharge"; + return ( + + updateTier( + index, + isRecharge + ? { thresholdCoinAmount: event.target.value } + : { thresholdValidInviteCount: event.target.value }, + ) + } + /> + ); + }, + }, + { + key: "rewardCoinAmount", + label: "奖励金币", + render: (tier, index) => ( + updateTier(index, { rewardCoinAmount: event.target.value })} + /> + ), + }, + { + key: "status", + label: "状态", + render: (tier, index) => ( + + updateTier(index, { status: event.target.checked ? "active" : "inactive" }) + } + /> + ), + }, + ]} + disabled={disabled} + emptyText="暂无档位" + gridTemplateColumns="72px minmax(130px, 0.7fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) 120px 52px" + rows={form.tiers || []} + onRemoveTier={removeTier} + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/src/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx b/src/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx new file mode 100644 index 0000000..9f351f1 --- /dev/null +++ b/src/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx @@ -0,0 +1,74 @@ +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/invite-activity-reward/invite-activity-reward.module.css"; + +export function InviteActivityRewardConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) { + const tiers = config?.tiers || []; + const activeTiers = tiers.filter((tier) => tier.status === "active"); + const rechargeTiers = activeTiers.filter((tier) => tier.rewardType === "recharge"); + const validInviteTiers = activeTiers.filter((tier) => tier.rewardType === "valid_invite"); + + return ( +
+
+ + + {config?.enabled ? "启用" : "停用"} + + + {thresholdSummary(rechargeTiers, "thresholdCoinAmount", "金币")} + + {thresholdSummary(validInviteTiers, "thresholdValidInviteCount", "人")} + + {thresholdSummary(activeTiers, "rewardCoinAmount", "金币")} +
+
+ + {canUpdate ? ( + + ) : null} +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +function thresholdSummary(tiers, field, unit) { + if (!tiers.length) { + return "-"; + } + const values = tiers.map((tier) => Number(tier[field] || 0)).filter((value) => value > 0); + if (!values.length) { + return "-"; + } + const min = Math.min(...values); + const max = Math.max(...values); + return min === max ? `${formatNumber(min)} ${unit}` : `${formatNumber(min)} - ${formatNumber(max)} ${unit}`; +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); +} diff --git a/src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js b/src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js new file mode 100644 index 0000000..ae475e2 --- /dev/null +++ b/src/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + getInviteActivityRewardConfig, + listInviteActivityRewardClaims, + updateInviteActivityRewardConfig, +} from "@/features/invite-activity-reward/api"; +import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const pageSize = 50; +const emptyClaims = { items: [], page: 1, pageSize, total: 0 }; +const emptyForm = { enabled: true, tiers: [] }; + +export function useInviteActivityRewardPage() { + const abilities = useInviteActivityRewardAbilities(); + 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 [query, setQuery] = useState(""); + const [cycleKey, setCycleKey] = useState(""); + const [rewardType, setRewardType] = useState(""); + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + const filters = useMemo( + () => ({ keyword: query, cycle_key: cycleKey, reward_type: rewardType, status }), + [cycleKey, query, rewardType, status], + ); + const { + data: claims = emptyClaims, + error: claimsError, + loading: claimsLoading, + reload: reloadClaims, + } = usePaginatedQuery({ + errorMessage: "加载邀请活动领取记录失败", + fetcher: listInviteActivityRewardClaims, + filters, + page, + pageSize, + queryKey: ["invite-activity-reward-claims", filters, page], + }); + + const reloadConfig = useCallback(async () => { + setConfigLoading(true); + try { + const loaded = await getInviteActivityRewardConfig(); + setConfig(loaded); + setForm(formFromConfig(loaded)); + } 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 changeCycleKey = (value) => { + setCycleKey(value); + setPage(1); + }; + const changeRewardType = (value) => { + setRewardType(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 updateInviteActivityRewardConfig(payloadFromForm(form)); + setConfig(saved); + setForm(formFromConfig(saved)); + setConfigDrawerOpen(false); + showToast("邀请活动配置已保存", "success"); + await reloadClaims(); + } catch (err) { + showToast(err.message || "保存邀请活动配置失败", "error"); + } finally { + setConfigSaving(false); + } + }; + + return { + abilities, + changeCycleKey, + changeQuery, + changeRewardType, + changeStatus, + claims, + claimsError, + claimsLoading, + closeConfigDrawer, + config, + configDrawerOpen, + configLoading, + configSaving, + cycleKey, + form, + openConfigDrawer, + page, + query, + reloadClaims, + reloadConfig, + rewardType, + setForm, + setPage, + status, + submitConfig, + }; +} + +function formFromConfig(config) { + return { + enabled: Boolean(config.enabled), + tiers: (config.tiers || []).map((tier) => ({ + tierId: tier.tierId || 0, + rewardType: tier.rewardType || "recharge", + tierCode: tier.tierCode || "", + tierName: tier.tierName || "", + thresholdCoinAmount: tier.thresholdCoinAmount ? String(tier.thresholdCoinAmount) : "", + thresholdValidInviteCount: tier.thresholdValidInviteCount ? String(tier.thresholdValidInviteCount) : "", + rewardCoinAmount: tier.rewardCoinAmount ? String(tier.rewardCoinAmount) : "", + status: tier.status || "active", + sortOrder: String(tier.sortOrder || 0), + })), + }; +} + +function payloadFromForm(form) { + const tiers = (form.tiers || []).map((tier, index) => { + const rewardType = tier.rewardType === "valid_invite" ? "valid_invite" : "recharge"; + const thresholdCoinAmount = rewardType === "recharge" ? Number(tier.thresholdCoinAmount || 0) : 0; + const thresholdValidInviteCount = rewardType === "valid_invite" ? Number(tier.thresholdValidInviteCount || 0) : 0; + const rewardCoinAmount = Number(tier.rewardCoinAmount || 0); + if (rewardType === "recharge" && thresholdCoinAmount <= 0) { + throw new Error("累计充值金币门槛必须大于 0"); + } + if (rewardType === "valid_invite" && thresholdValidInviteCount <= 0) { + throw new Error("有效人数门槛必须大于 0"); + } + if (rewardCoinAmount <= 0) { + throw new Error("奖励金币必须大于 0"); + } + const tierCode = String(tier.tierCode || "").trim() || `invite_${rewardType}_${Date.now()}_${index + 1}`; + const tierName = + String(tier.tierName || "").trim() || + (rewardType === "recharge" + ? `累计充值 ${formatNumber(thresholdCoinAmount)} 金币` + : `有效邀请 ${formatNumber(thresholdValidInviteCount)} 人`); + return { + tier_id: Number(tier.tierId || 0), + reward_type: rewardType, + tier_code: tierCode, + tier_name: tierName, + threshold_coin_amount: thresholdCoinAmount, + threshold_valid_invite_count: thresholdValidInviteCount, + reward_coin_amount: rewardCoinAmount, + status: tier.status === "inactive" ? "inactive" : "active", + sort_order: index, + }; + }); + if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) { + throw new Error("开启后至少需要一个启用档位"); + } + return { enabled: Boolean(form.enabled), tiers }; +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); +} diff --git a/src/features/invite-activity-reward/invite-activity-reward.module.css b/src/features/invite-activity-reward/invite-activity-reward.module.css new file mode 100644 index 0000000..938656f --- /dev/null +++ b/src/features/invite-activity-reward/invite-activity-reward.module.css @@ -0,0 +1,107 @@ +.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); +} + +.stack { + display: grid; + min-width: 0; + gap: 2px; +} + +.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; +} + +@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); + } +} diff --git a/src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx b/src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx new file mode 100644 index 0000000..7bbc7df --- /dev/null +++ b/src/features/invite-activity-reward/pages/InviteActivityRewardPage.jsx @@ -0,0 +1,215 @@ +import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx"; +import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx"; +import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js"; +import styles from "@/features/invite-activity-reward/invite-activity-reward.module.css"; +import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.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 rewardTypeOptions = [ + ["recharge", "累计充值"], + ["valid_invite", "有效人数"], +]; + +const columnsBase = [ + { + key: "user", + label: "用户信息", + width: "minmax(260px, 1.1fr)", + render: (claim) => , + }, + { + key: "cycle", + label: "周期", + width: "minmax(140px, 0.6fr)", + render: (claim) => claim.cycleKey || "-", + }, + { + key: "rewardType", + label: "奖励类型", + width: "minmax(130px, 0.6fr)", + render: (claim) => rewardTypeLabel(claim.rewardType), + }, + { + key: "tier", + label: "档位", + width: "minmax(210px, 0.9fr)", + render: (claim) => ( +
+ {claim.tierName || claim.tierCode || "-"} + {thresholdLabel(claim)} +
+ ), + }, + { + key: "reached", + label: "达成值", + width: "minmax(150px, 0.65fr)", + render: (claim) => reachedLabel(claim), + }, + { + key: "reward", + label: "奖励金币", + width: "minmax(130px, 0.6fr)", + render: (claim) => formatNumber(claim.rewardCoinAmount), + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.55fr)", + render: (claim) => claimStatusLabel(claim.status), + }, + { + key: "createdAt", + label: "创建时间", + width: "minmax(180px, 0.85fr)", + render: (claim) => formatMillis(claim.createdAtMs), + }, + { + key: "claimId", + label: "记录", + width: "minmax(280px, 1fr)", + render: (claim) => ( +
+ {claim.claimId} + {claim.walletTransactionId || claim.walletCommandId || "-"} +
+ ), + }, + { + key: "failure", + label: "失败原因", + width: "minmax(180px, 0.85fr)", + render: (claim) => claim.failureReason || "-", + }, +]; + +export function InviteActivityRewardPage() { + const page = useInviteActivityRewardPage(); + 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 === "cycle") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "周期,如 2026-06", + value: page.cycleKey, + onChange: page.changeCycleKey, + }), + }; + } + if (column.key === "rewardType") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: [["", "全部类型"], ...rewardTypeOptions], + placeholder: "奖励类型", + value: page.rewardType, + onChange: page.changeRewardType, + }), + }; + } + 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 || 50, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(claim) => claim.claimId} + /> + + + + + ); +} + +function ClaimUser({ claim }) { + const user = claim.user || {}; + return ; +} + +function rewardTypeLabel(type) { + return rewardTypeOptions.find(([value]) => value === type)?.[1] || type || "-"; +} + +function claimStatusLabel(status) { + return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-"; +} + +function thresholdLabel(claim) { + if (claim.rewardType === "valid_invite") { + return `${formatNumber(claim.thresholdValidInviteCount)} 人`; + } + return `${formatNumber(claim.thresholdCoinAmount)} 金币`; +} + +function reachedLabel(claim) { + const unit = claim.rewardType === "valid_invite" ? "人" : "金币"; + return `${formatNumber(claim.reachedValue)} ${unit}`; +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); +} diff --git a/src/features/invite-activity-reward/permissions.js b/src/features/invite-activity-reward/permissions.js new file mode 100644 index 0000000..00be71d --- /dev/null +++ b/src/features/invite-activity-reward/permissions.js @@ -0,0 +1,11 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useInviteActivityRewardAbilities() { + const { can } = useAuth(); + + return { + canUpdate: can(PERMISSIONS.inviteActivityRewardUpdate), + canView: can(PERMISSIONS.inviteActivityRewardView), + }; +} diff --git a/src/features/invite-activity-reward/routes.js b/src/features/invite-activity-reward/routes.js new file mode 100644 index 0000000..9bdce66 --- /dev/null +++ b/src/features/invite-activity-reward/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const inviteActivityRewardRoutes = [ + { + label: "邀请活动", + loader: () => import("./pages/InviteActivityRewardPage.jsx").then((module) => module.InviteActivityRewardPage), + menuCode: MENU_CODES.inviteActivityReward, + pageKey: "invite-activity-reward", + path: "/activities/invite-reward", + permission: PERMISSIONS.inviteActivityRewardView, + }, +]; diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index f78c428..34960b6 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -9,6 +9,7 @@ import { disableGift, disableResource, disableResourceGroup, + deleteGift, enableResourceShopItem, enableGift, enableResource, @@ -150,6 +151,7 @@ test("resource APIs use generated admin paths", async () => { }); await enableGift("rose"); await disableGift("rose"); + await deleteGift("rose"); await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" }); await lookupResourceGrantTarget("163000"); await grantResource({ @@ -181,10 +183,11 @@ test("resource APIs use generated admin paths", async () => { const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11]; const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12]; const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13]; - const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[14]; - const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[15]; - const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[16]; - const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[17]; + const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14]; + const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15]; + const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16]; + const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17]; + const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18]; expect(String(listUrl)).toContain("/api/v1/admin/resources?"); expect(String(listUrl)).toContain("resource_type=gift"); @@ -239,6 +242,8 @@ test("resource APIs use generated admin paths", async () => { expect(enableGiftInit?.method).toBe("POST"); expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable"); expect(disableGiftInit?.method).toBe("POST"); + expect(String(deleteGiftUrl)).toContain("/api/v1/admin/gifts/rose"); + expect(deleteGiftInit?.method).toBe("DELETE"); expect(String(grantListUrl)).toContain("/api/v1/admin/resource-grants?"); expect(String(grantListUrl)).toContain("target_user_id=1001"); expect(grantListInit?.method).toBe("GET"); @@ -257,7 +262,10 @@ test("resource APIs use generated admin paths", async () => { }); test("resource mp4 layout batch API uses narrow update endpoint", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] })))); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))), + ); await updateResourceMp4Layouts([ { diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 6f86628..40b4c0b 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -466,6 +466,13 @@ export function updateGift(giftId: string, payload: GiftPayload): Promise { + const endpoint = API_ENDPOINTS.deleteGift; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteGift, { gift_id: giftId }), { + method: endpoint.method, + }); +} + export function enableGift(giftId: string): Promise { const endpoint = API_ENDPOINTS.enableGift; return apiRequest(apiEndpointPath(API_OPERATIONS.enableGift, { gift_id: giftId }), { diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index c7f3ee2..8b1b936 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react"; import { parseForm } from "@/shared/forms/validation"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createEmojiPack, createGift, createResource, createResourceGroup, + deleteGift as requestDeleteGift, disableResourceShopItem, disableGift, disableResource, @@ -886,6 +888,7 @@ export function useResourceGroupListPage() { export function useGiftListPage() { const abilities = useResourceAbilities(); + const confirm = useConfirm(); const { showToast } = useToast(); const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); @@ -1143,6 +1146,41 @@ export function useGiftListPage() { } }; + const deleteGifts = async (gifts = []) => { + const targetGifts = gifts.filter((gift) => gift?.giftId); + if (!abilities.canDeleteGift || !targetGifts.length) { + return false; + } + const title = targetGifts.length > 1 ? "批量删除礼物" : "删除礼物"; + const message = + targetGifts.length > 1 + ? `确认删除选中的 ${targetGifts.length} 个礼物?只删除礼物列表数据,不删除资源列表数据。` + : `确认删除礼物 ${targetGifts[0].name || targetGifts[0].giftId}?只删除礼物列表数据,不删除资源列表数据。`; + const ok = await confirm({ + confirmText: "删除", + message, + title, + tone: "danger", + }); + if (!ok) { + return false; + } + setLoadingAction(targetGifts.length > 1 ? "gift-delete-batch" : `gift-delete-${targetGifts[0].giftId}`); + try { + for (const gift of targetGifts) { + await requestDeleteGift(gift.giftId); + } + showToast(targetGifts.length > 1 ? `已删除 ${targetGifts.length} 个礼物` : "礼物已删除", "success"); + await result.reload(); + return true; + } catch (err) { + showToast(err.message || "删除礼物失败", "error"); + return false; + } finally { + setLoadingAction(""); + } + }; + const saveGiftSortOrder = async (gift, rawSortOrder) => { if (!abilities.canUpdateGift || !gift?.giftId) { return false; @@ -1200,6 +1238,7 @@ export function useGiftListPage() { changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage), closeAction, closeGiftTypeDialog, + deleteGifts, form, giftSortSavingIds, giftTypeCode, diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index 7e08d28..2136ae3 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -3,16 +3,19 @@ import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined"; import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined"; import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import PublicOutlined from "@mui/icons-material/PublicOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; +import Checkbox from "@mui/material/Checkbox"; +import CircularProgress from "@mui/material/CircularProgress"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; import MenuItem from "@mui/material/MenuItem"; import Popover from "@mui/material/Popover"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AdminFormDialog, AdminFormFieldGrid, @@ -128,7 +131,7 @@ const baseColumns = (giftTypeOptions) => [ { key: "actions", label: "操作", - width: "minmax(76px, 0.4fr)", + width: "minmax(112px, 0.5fr)", }, ]; @@ -136,75 +139,148 @@ export function GiftListPage() { const page = useGiftListPage(); const items = page.data.items || []; const total = page.data.total || 0; + const [selectedGiftIds, setSelectedGiftIds] = useState([]); const isEditing = page.activeAction === "edit"; const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions]; const regionLabelById = buildRegionLabelMap(giftRegionOptions); const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions; - const tableColumns = baseColumns(giftTypeOptions).map((column) => { - if (column.key === "regions") { - return { - ...column, - filter: createRegionColumnFilter({ - loading: page.loadingRegions, - options: giftRegionOptions, - value: page.regionId, - onChange: page.changeRegionId, - }), - render: (gift) => , - }; + const selectedGiftIdSet = useMemo(() => new Set(selectedGiftIds), [selectedGiftIds]); + const selectedGifts = useMemo( + () => items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))), + [items, selectedGiftIdSet], + ); + const allChecked = items.length > 0 && items.every((gift) => selectedGiftIdSet.has(String(gift.giftId))); + const selectedOnPageCount = items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))).length; + const someChecked = selectedOnPageCount > 0 && !allChecked; + const deletingBatch = page.loadingAction === "gift-delete-batch"; + + useEffect(() => { + const itemIds = new Set(items.map((gift) => String(gift.giftId))); + setSelectedGiftIds((current) => { + const next = current.filter((giftId) => itemIds.has(giftId)); + return next.length === current.length ? current : next; + }); + }, [items]); + + const toggleAllGifts = (checked) => { + const pageIds = items.map((gift) => String(gift.giftId)); + setSelectedGiftIds((current) => { + if (!checked) { + return current.filter((giftId) => !pageIds.includes(giftId)); + } + return Array.from(new Set([...current, ...pageIds])); + }); + }; + + const toggleGiftSelection = (gift, checked) => { + const giftId = String(gift.giftId); + setSelectedGiftIds((current) => + checked ? Array.from(new Set([...current, giftId])) : current.filter((selectedId) => selectedId !== giftId), + ); + }; + + const deleteSelectedGifts = async () => { + const deleted = await page.deleteGifts(selectedGifts); + if (deleted) { + setSelectedGiftIds([]); } - if (column.key === "status") { - return { - ...column, - filter: createOptionsColumnFilter({ - options: resourceStatusFilters, - placeholder: "搜索状态", - value: page.status, - onChange: page.changeStatus, - }), - render: (gift) => , - }; - } - if (column.key === "type") { - return { - ...column, - filter: createOptionsColumnFilter({ - emptyLabel: "全部类型", - loading: page.giftTypesLoading, - options: giftTypeOptions.map((item) => ({ - label: item.displayName || item.tabName || item.tabKey, - value: item.tabKey, - })), - placeholder: "搜索类型", - value: page.giftTypeCode, - onChange: page.changeGiftTypeCode, - }), - }; - } - if (column.key === "sortOrder") { - return { - ...column, - render: (gift) => , - }; - } - if (column.key === "gift") { - return { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索礼物名称、礼物 ID", - value: page.query, - onChange: page.changeQuery, - }), - }; - } - if (column.key === "actions") { - return { - ...column, - render: (gift) => , - }; - } - return column; - }); + }; + + const selectionColumn = { + key: "select", + className: styles.selectColumn, + header: ( + toggleAllGifts(event.target.checked)} + /> + ), + resizable: false, + width: "56px", + render: (gift) => { + const giftId = String(gift.giftId); + return ( + toggleGiftSelection(gift, event.target.checked)} + /> + ); + }, + }; + const tableColumns = [ + selectionColumn, + ...baseColumns(giftTypeOptions).map((column) => { + if (column.key === "regions") { + return { + ...column, + filter: createRegionColumnFilter({ + loading: page.loadingRegions, + options: giftRegionOptions, + value: page.regionId, + onChange: page.changeRegionId, + }), + render: (gift) => , + }; + } + if (column.key === "status") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: resourceStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + render: (gift) => , + }; + } + if (column.key === "type") { + return { + ...column, + filter: createOptionsColumnFilter({ + emptyLabel: "全部类型", + loading: page.giftTypesLoading, + options: giftTypeOptions.map((item) => ({ + label: item.displayName || item.tabName || item.tabKey, + value: item.tabKey, + })), + placeholder: "搜索类型", + value: page.giftTypeCode, + onChange: page.changeGiftTypeCode, + }), + }; + } + if (column.key === "sortOrder") { + return { + ...column, + render: (gift) => , + }; + } + if (column.key === "gift") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索礼物名称、礼物 ID", + value: page.query, + onChange: page.changeQuery, + }), + }; + } + if (column.key === "actions") { + return { + ...column, + render: (gift) => , + }; + } + return column; + }), + ]; return ( @@ -216,6 +292,19 @@ export function GiftListPage() { ) : null} + {page.abilities.canDeleteGift ? ( + + {deletingBatch ? ( + + ) : ( + + )} + + ) : null} {page.abilities.canCreateGift ? ( @@ -229,7 +318,7 @@ export function GiftListPage() { 0 ? { @@ -270,6 +359,9 @@ export function GiftListPage() { } function GiftRowActions({ gift, page }) { + const deleting = page.loadingAction === `gift-delete-${gift.giftId}`; + const deletingBatch = page.loadingAction === "gift-delete-batch"; + return ( + {page.abilities.canDeleteGift ? ( + page.deleteGifts([gift])} + > + {deleting ? : } + + ) : null} ); } diff --git a/src/features/resources/permissions.js b/src/features/resources/permissions.js index fb37c19..3416eec 100644 --- a/src/features/resources/permissions.js +++ b/src/features/resources/permissions.js @@ -10,6 +10,7 @@ export function useResourceAbilities() { canCreateGrant: can(PERMISSIONS.resourceGrantCreate), canCreateGroup: can(PERMISSIONS.resourceGroupCreate), canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate), + canDeleteGift: can(PERMISSIONS.giftDelete), canStatusGift: can(PERMISSIONS.giftStatus), canUpdateShop: can(PERMISSIONS.resourceShopUpdate), canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate), diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js index d9e06a8..064f70c 100644 --- a/src/features/roles/hooks/useRolesPage.js +++ b/src/features/roles/hooks/useRolesPage.js @@ -43,6 +43,7 @@ const permissionGroupDefinitions = [ "cumulative-recharge-reward", "daily-task", "first-recharge-reward", + "invite-activity-reward", "lucky-gift", "red-packet", "registration-reward", diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index b813915..98e484f 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -45,6 +45,7 @@ export const API_OPERATIONS = { createMenu: "createMenu", createPermission: "createPermission", createPlatform: "createPlatform", + createPopup: "createPopup", createPrettyIdPool: "createPrettyIdPool", createRechargeProduct: "createRechargeProduct", createRegion: "createRegion", @@ -68,10 +69,12 @@ export const API_OPERATIONS = { deleteCountry: "deleteCountry", deleteDiceRobot: "deleteDiceRobot", deleteExploreTab: "deleteExploreTab", + deleteGift: "deleteGift", deleteH5Link: "deleteH5Link", deleteHostAgencyPolicy: "deleteHostAgencyPolicy", deleteMenu: "deleteMenu", deletePermission: "deletePermission", + deletePopup: "deletePopup", deleteRechargeProduct: "deleteRechargeProduct", deleteRole: "deleteRole", deleteRoom: "deleteRoom", @@ -151,6 +154,7 @@ export const API_OPERATIONS = { listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", listPlatforms: "listPlatforms", + listPopups: "listPopups", listPrettyIdPools: "listPrettyIdPools", listPrettyIds: "listPrettyIds", listRechargeBills: "listRechargeBills", @@ -230,6 +234,7 @@ export const API_OPERATIONS = { updateMenuVisible: "updateMenuVisible", updatePermission: "updatePermission", updatePlatform: "updatePlatform", + updatePopup: "updatePopup", updatePrettyIdPool: "updatePrettyIdPool", updateRechargeProduct: "updateRechargeProduct", updateRedPacketConfig: "updateRedPacketConfig", @@ -486,6 +491,13 @@ export const API_ENDPOINTS: Record = { permission: "game:update", permissions: ["game:update"] }, + createPopup: { + method: "POST", + operationId: API_OPERATIONS.createPopup, + path: "/v1/admin/app-config/popups", + permission: "app-config:update", + permissions: ["app-config:update"] + }, createPrettyIdPool: { method: "POST", operationId: API_OPERATIONS.createPrettyIdPool, @@ -647,6 +659,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + deleteGift: { + method: "DELETE", + operationId: API_OPERATIONS.deleteGift, + path: "/v1/admin/gifts/{gift_id}", + permission: "gift:delete", + permissions: ["gift:delete"] + }, deleteH5Link: { method: "DELETE", operationId: API_OPERATIONS.deleteH5Link, @@ -675,6 +694,13 @@ export const API_ENDPOINTS: Record = { permission: "permission:delete", permissions: ["permission:delete"] }, + deletePopup: { + method: "DELETE", + operationId: API_OPERATIONS.deletePopup, + path: "/v1/admin/app-config/popups/{popup_id}", + permission: "app-config:update", + permissions: ["app-config:update"] + }, deleteRechargeProduct: { method: "DELETE", operationId: API_OPERATIONS.deleteRechargeProduct, @@ -1226,6 +1252,13 @@ export const API_ENDPOINTS: Record = { permission: "game:view", permissions: ["game:view"] }, + listPopups: { + method: "GET", + operationId: API_OPERATIONS.listPopups, + path: "/v1/admin/app-config/popups", + permission: "app-config:view", + permissions: ["app-config:view"] + }, listPrettyIdPools: { method: "GET", operationId: API_OPERATIONS.listPrettyIdPools, @@ -1767,6 +1800,13 @@ export const API_ENDPOINTS: Record = { permission: "game:update", permissions: ["game:update"] }, + updatePopup: { + method: "PUT", + operationId: API_OPERATIONS.updatePopup, + path: "/v1/admin/app-config/popups/{popup_id}", + permission: "app-config:update", + permissions: ["app-config:update"] + }, updatePrettyIdPool: { method: "PUT", operationId: API_OPERATIONS.updatePrettyIdPool, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index bccf508..8d1a80c 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -628,6 +628,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/app-config/popups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPopups"]; + put?: never; + post: operations["createPopup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/app-config/popups/{popup_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updatePopup"]; + post?: never; + delete: operations["deletePopup"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/app-config/h5-links": { parameters: { query?: never; @@ -1342,7 +1374,7 @@ export interface paths { get?: never; put: operations["updateGift"]; post?: never; - delete?: never; + delete: operations["deleteGift"]; options?: never; head?: never; patch?: never; @@ -3994,6 +4026,58 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listPopups: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createPopup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updatePopup: { + parameters: { + query?: never; + header?: never; + path: { + popup_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + deletePopup: { + parameters: { + query?: never; + header?: never; + path: { + popup_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listH5Links: { parameters: { query?: never; @@ -4861,6 +4945,20 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + deleteGift: { + parameters: { + query?: never; + header?: never; + path: { + gift_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; disableGift: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 53de7e6..ddcceb0 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -980,6 +980,36 @@ export interface AppSplashScreenPayload { status: "active" | "disabled" | "expired"; } +export interface AppPopupDto { + appCode?: string; + code: string; + createdAtMs?: number; + displayPeriodDays?: number; + endsAtMs?: number; + id: number; + imageUrl?: string; + jumpType: "h5" | "app" | string; + jumpUrl: string; + name: string; + sortOrder?: number; + startsAtMs?: number; + status: "active" | "disabled" | "expired" | string; + updatedAtMs?: number; +} + +export interface AppPopupPayload { + code: string; + displayPeriodDays?: number; + endsAtMs?: number; + imageUrl?: string; + jumpType: "h5" | "app"; + jumpUrl: string; + name: string; + sortOrder?: number; + startsAtMs?: number; + status: "active" | "disabled" | "expired"; +} + export interface AgencyMembershipDto { agencyId?: number; createdAtMs?: number; diff --git a/src/shared/app-jump/appJumpConfig.js b/src/shared/app-jump/appJumpConfig.js new file mode 100644 index 0000000..bdceb15 --- /dev/null +++ b/src/shared/app-jump/appJumpConfig.js @@ -0,0 +1,175 @@ +export const appJumpTargetOptions = [ + ["wallet", "钱包"], + ["room", "指定房间"], + ["room_random", "随机房间"], + ["room_window", "指定房内拉起窗口"], + ["room_random_window", "随机房内拉起窗口"], + ["room_game", "指定房内拉起游戏"], + ["room_random_game", "随机房内拉起游戏"], + ["explore_game", "Explore 游戏 H5"], + ["custom", "自定义 JSON"], +]; + +export const appJumpWindowOptions = [ + ["game_window", "游戏窗口"], + ["gift_panel", "礼物面板"], + ["red_packet_claim", "领红包弹窗"], + ["wheel", "转盘窗口"], +]; + +export const appJumpExploreGameCodeOptions = [ + ["game_dice", "Dice Battle"], + ["game_rock", "Rock Paper Scissors"], +]; + +const knownTargets = new Set(appJumpTargetOptions.map(([value]) => value).filter((value) => value !== "custom")); +const knownWindows = new Set(appJumpWindowOptions.map(([value]) => value)); +const knownExploreGameCodes = new Set(appJumpExploreGameCodeOptions.map(([value]) => value)); + +export function normalizeJumpConfigType(value) { + return String(value || "") + .trim() + .toLowerCase() === "app" + ? "app" + : "h5"; +} + +export function parseAppJumpParam(rawParam) { + const raw = String(rawParam || "").trim(); + const data = parseObject(raw); + if (!data) { + return defaultParsedJump(raw); + } + + const type = readString(data.type); + const target = knownTargets.has(type) ? type : type ? "custom" : "wallet"; + const window = normalizeWindow(data.window); + + return { + gameCode: normalizeExploreGameCode(data.game_code ?? data.gameCode), + gameId: readString(data.game_id ?? data.gameId), + giftId: readString(data.gift_id ?? data.giftId), + raw, + redPacketNo: readString(data.red_packet_no ?? data.redPacketNo), + roomId: readString(data.room_id ?? data.roomId), + target, + window, + }; +} + +export function buildAppJumpParam(values) { + const target = knownTargets.has(values?.target) ? values.target : "wallet"; + const payload = { type: target }; + const roomId = readString(values?.roomId); + const gameId = readString(values?.gameId); + const gameCode = normalizeExploreGameCode(values?.gameCode); + const giftId = readString(values?.giftId); + const redPacketNo = readString(values?.redPacketNo); + const window = normalizeWindow(values?.window); + + if (target === "room" || target === "room_window" || target === "room_game") { + payload.room_id = roomId; + } + if (target === "room_window" || target === "room_random_window") { + payload.window = window; + appendWindowParam(payload, window, { gameId, giftId, redPacketNo }); + } + if (target === "room_game" || target === "room_random_game") { + payload.game_id = gameId; + } + if (target === "explore_game") { + payload.game_code = gameCode; + } + + return JSON.stringify(payload); +} + +export function validatePublicAppJumpParam(rawParam) { + const raw = String(rawParam || "").trim(); + if (!raw) { + return { message: "请填写 APP 跳转参数", valid: false }; + } + const data = parseObject(raw); + if (!data) { + return { message: "APP 跳转参数必须是 JSON 对象", valid: false }; + } + + const target = readString(data.type); + if (!knownTargets.has(target)) { + return { message: "APP 跳转 type 不在公共跳转方案内", valid: false }; + } + + const roomId = readString(data.room_id ?? data.roomId); + const gameId = readString(data.game_id ?? data.gameId); + const gameCode = readString(data.game_code ?? data.gameCode); + const window = readString(data.window); + + if ((target === "room" || target === "room_window" || target === "room_game") && !roomId) { + return { message: "该 APP 跳转需要填写 room_id", valid: false }; + } + if ((target === "room_game" || target === "room_random_game") && !gameId) { + return { message: "该 APP 跳转需要填写 game_id", valid: false }; + } + if ((target === "room_window" || target === "room_random_window") && !knownWindows.has(window)) { + return { message: "该 APP 跳转需要填写有效 window", valid: false }; + } + if (target === "explore_game" && !knownExploreGameCodes.has(gameCode)) { + return { message: "Explore 游戏 H5 需要选择有效 game_code", valid: false }; + } + + return { message: "", valid: true }; +} + +function defaultParsedJump(raw = "") { + return { + gameCode: "game_dice", + gameId: "", + giftId: "", + raw, + redPacketNo: "", + roomId: "", + target: raw ? "custom" : "wallet", + window: "gift_panel", + }; +} + +function appendWindowParam(payload, window, values) { + if (window === "game_window" && values.gameId) { + payload.game_id = values.gameId; + } + if (window === "gift_panel" && values.giftId) { + payload.gift_id = values.giftId; + } + if (window === "red_packet_claim" && values.redPacketNo) { + payload.red_packet_no = values.redPacketNo; + } +} + +function normalizeWindow(value) { + const window = readString(value); + return knownWindows.has(window) ? window : "gift_panel"; +} + +function normalizeExploreGameCode(value) { + const gameCode = readString(value); + return knownExploreGameCodes.has(gameCode) ? gameCode : "game_dice"; +} + +function parseObject(raw) { + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw); + if (parsed && !Array.isArray(parsed) && typeof parsed === "object") { + return parsed; + } + } catch { + return null; + } + return null; +} + +function readString(value) { + return String(value ?? "").trim(); +} diff --git a/src/shared/app-jump/appJumpConfig.test.js b/src/shared/app-jump/appJumpConfig.test.js new file mode 100644 index 0000000..bde5f06 --- /dev/null +++ b/src/shared/app-jump/appJumpConfig.test.js @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { buildAppJumpParam, parseAppJumpParam, validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; + +describe("public app jump config", () => { + test("builds and parses room window jump", () => { + const param = buildAppJumpParam({ + giftId: "gift_9", + roomId: "room_1", + target: "room_window", + window: "gift_panel", + }); + + expect(param).toBe('{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_9"}'); + expect(parseAppJumpParam(param)).toMatchObject({ + giftId: "gift_9", + roomId: "room_1", + target: "room_window", + window: "gift_panel", + }); + }); + + test("validates required fields for public app jumps", () => { + expect(validatePublicAppJumpParam('{"type":"wallet"}').valid).toBe(true); + expect(validatePublicAppJumpParam('{"type":"room_game","room_id":"room_1","game_id":"dice"}').valid).toBe(true); + expect(validatePublicAppJumpParam('{"type":"explore_game","game_code":"game_rock"}').valid).toBe(true); + expect(validatePublicAppJumpParam('{"type":"room_game","room_id":"room_1"}')).toMatchObject({ + valid: false, + }); + expect(validatePublicAppJumpParam('{"type":"room_window","room_id":"room_1"}')).toMatchObject({ + valid: false, + }); + expect(validatePublicAppJumpParam('{"type":"explore_game","game_code":"unknown"}')).toMatchObject({ + valid: false, + }); + expect(validatePublicAppJumpParam("not json")).toMatchObject({ valid: false }); + }); + + test("builds and parses explore game jump", () => { + const param = buildAppJumpParam({ + gameCode: "game_dice", + target: "explore_game", + }); + + expect(param).toBe('{"type":"explore_game","game_code":"game_dice"}'); + expect(parseAppJumpParam(param)).toMatchObject({ + gameCode: "game_dice", + target: "explore_game", + }); + }); +}); diff --git a/src/shared/ui/AdminUserIdentity.jsx b/src/shared/ui/AdminUserIdentity.jsx index c67217b..f61a93c 100644 --- a/src/shared/ui/AdminUserIdentity.jsx +++ b/src/shared/ui/AdminUserIdentity.jsx @@ -38,7 +38,7 @@ export function AdminUserIdentity({ user: normalized.detailUser, }); const visibleRows = Array.isArray(rows) - ? rows.map((row) => String(row || "").trim()).filter(Boolean) + ? resolveVisibleRows(rows, normalized) : [normalized.shortId].filter(Boolean); return ( @@ -175,6 +175,7 @@ function normalizeUserIdentity({ userId: resolvedUserId, username: firstNonEmpty(name, source.username, source.name, source.account), }, + displayUserId: resolvedDisplayUserId, displayName, hasPretty, prettyDisplayUserId: resolvedPrettyDisplayUserId, @@ -184,6 +185,20 @@ function normalizeUserIdentity({ }; } +function resolveVisibleRows(rows, normalized) { + const visibleRows = rows.map((row) => String(row || "").trim()).filter(Boolean); + if ( + normalized.hasPretty && + normalized.shortId && + normalized.displayUserId && + visibleRows[0] === normalized.displayUserId && + normalized.shortId !== normalized.displayUserId + ) { + return [normalized.shortId, ...visibleRows.slice(1)]; + } + return visibleRows; +} + function resolveRootProps({ displayName, onClick, openAppUserDetail, shouldOpenAppUserDetail, user }) { if (onClick) { return { diff --git a/src/shared/ui/AdminUserIdentity.test.jsx b/src/shared/ui/AdminUserIdentity.test.jsx index 3be1e1f..cf65c56 100644 --- a/src/shared/ui/AdminUserIdentity.test.jsx +++ b/src/shared/ui/AdminUserIdentity.test.jsx @@ -44,6 +44,26 @@ test("clickable identity opens detail handler", async () => { expect(onClick).toHaveBeenCalledTimes(1); }); +test("uses original id before pretty id when rows pass current display id", () => { + render( + , + ); + + expect(screen.getByText("123456")).toBeInTheDocument(); + expect(screen.getByText("VIP2026")).toBeInTheDocument(); + expect(screen.getByText("10001")).toBeInTheDocument(); +}); + test("open detail uses provider instead of app user list link", async () => { const user = userEvent.setup(); const openAppUserDetail = vi.fn(); diff --git a/src/shared/ui/AppJumpConfigField.jsx b/src/shared/ui/AppJumpConfigField.jsx new file mode 100644 index 0000000..7b7458e --- /dev/null +++ b/src/shared/ui/AppJumpConfigField.jsx @@ -0,0 +1,203 @@ +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useMemo } from "react"; +import { + appJumpExploreGameCodeOptions, + appJumpTargetOptions, + appJumpWindowOptions, + buildAppJumpParam, + normalizeJumpConfigType, + parseAppJumpParam, +} from "@/shared/app-jump/appJumpConfig.js"; +import { AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx"; +import styles from "@/shared/ui/AppJumpConfigField.module.css"; + +export function AppJumpConfigField({ + appParamLabel = "APP 参数 JSON", + className = "", + disabled = false, + h5Label = "H5 链接", + onChange, + paramValue, + required = false, + typeLabel = "跳转类型", + typeValue, +}) { + const type = normalizeJumpConfigType(typeValue); + const appJump = useMemo(() => parseAppJumpParam(paramValue), [paramValue]); + const setType = (nextType) => { + if (nextType === "app") { + onChange({ param: buildAppJumpParam({ target: "wallet" }), type: "app" }); + return; + } + onChange({ param: "", type: "h5" }); + }; + const setAppJump = (patch) => { + const next = { ...appJump, ...patch }; + if (next.target === "custom") { + onChange({ param: next.raw, type: "app" }); + return; + } + onChange({ param: buildAppJumpParam(next), type: "app" }); + }; + + return ( +
+ + setType(event.target.value)} + > + H5 + APP + + {type === "h5" ? ( + onChange({ param: event.target.value, type: "h5" })} + /> + ) : ( + <> + setAppJump({ target: event.target.value })} + > + {appJumpTargetOptions.map(([value, label]) => ( + + {label} + + ))} + + + onChange({ param: event.target.value, type: "app" })} + /> + + )} + + {type === "app" ? ( +

APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。

+ ) : null} +
+ ); +} + +function AppJumpStructuredFields({ appJump, disabled, onChange }) { + if (appJump.target === "custom" || appJump.target === "wallet" || appJump.target === "room_random") { + return null; + } + + return ( + <> + {needsRoomId(appJump.target) ? ( + onChange({ roomId: event.target.value })} + /> + ) : null} + {needsWindow(appJump.target) ? ( + onChange({ window: event.target.value })} + > + {appJumpWindowOptions.map(([value, label]) => ( + + {label} + + ))} + + ) : null} + {needsGameId(appJump.target, appJump.window) ? ( + onChange({ gameId: event.target.value })} + /> + ) : null} + {needsExploreGameCode(appJump.target) ? ( + onChange({ gameCode: event.target.value })} + > + {appJumpExploreGameCodeOptions.map(([value, label]) => ( + + {label} + + ))} + + ) : null} + {needsGiftId(appJump.target, appJump.window) ? ( + onChange({ giftId: event.target.value })} + /> + ) : null} + {needsRedPacketNo(appJump.target, appJump.window) ? ( + onChange({ redPacketNo: event.target.value })} + /> + ) : null} + + ); +} + +function needsRoomId(target) { + return target === "room" || target === "room_window" || target === "room_game"; +} + +function needsWindow(target) { + return target === "room_window" || target === "room_random_window"; +} + +function needsGameId(target, window) { + return target === "room_game" || target === "room_random_game" || window === "game_window"; +} + +function needsExploreGameCode(target) { + return target === "explore_game"; +} + +function needsGiftId(target, window) { + return needsWindow(target) && window === "gift_panel"; +} + +function needsRedPacketNo(target, window) { + return needsWindow(target) && window === "red_packet_claim"; +} diff --git a/src/shared/ui/AppJumpConfigField.module.css b/src/shared/ui/AppJumpConfigField.module.css new file mode 100644 index 0000000..41cff60 --- /dev/null +++ b/src/shared/ui/AppJumpConfigField.module.css @@ -0,0 +1,16 @@ +.root { + display: grid; + width: 100%; + gap: var(--space-3); +} + +.wide { + grid-column: 1 / -1; +} + +.hint { + margin: 0; + color: var(--text-tertiary); + font-size: var(--font-size-xs); + line-height: 1.5; +}