From 24e9bfc439a212d257efb8a851a9ea4a018a6082 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 6 Jul 2026 12:43:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E6=92=A4=E5=9B=9E=EF=BC=8C?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 16 +- src/app/navigation/menu.js | 53 ++- src/app/navigation/menu.test.jsx | 42 +- src/features/app-config/api.test.ts | 91 ++++ src/features/app-config/api.ts | 34 ++ src/features/app-config/app-config.module.css | 293 ++++++++----- .../pages/SystemMessagePushPage.jsx | 220 ++++++++++ .../pages/SystemMessagePushPage.test.jsx | 120 ++++++ src/features/app-config/permissions.js | 29 +- src/features/app-config/routes.js | 8 + src/features/app-config/schema.test.ts | 38 ++ src/features/app-config/schema.ts | 66 +++ src/features/operations/api.test.ts | 47 --- src/features/operations/api.ts | 52 +-- src/features/operations/operations.module.css | 94 +---- .../operations/pages/FullServerNoticePage.jsx | 398 ------------------ src/features/operations/permissions.js | 9 - src/features/operations/routes.js | 8 - .../resources/hooks/useResourcePages.js | 13 +- .../resources/pages/ResourceGrantListPage.jsx | 10 +- .../pages/ResourceGrantListPage.test.jsx | 25 +- src/shared/api/generated/schema.d.ts | 4 +- 22 files changed, 936 insertions(+), 734 deletions(-) create mode 100644 src/features/app-config/api.test.ts create mode 100644 src/features/app-config/pages/SystemMessagePushPage.jsx create mode 100644 src/features/app-config/pages/SystemMessagePushPage.test.jsx delete mode 100644 src/features/operations/pages/FullServerNoticePage.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 75ccf57..c0ab697 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2828,13 +2828,25 @@ "summary": { "type": "string" }, "target_scope": { "type": "string", - "enum": ["all_active_users", "single_user", "user_ids", "region", "country"] + "enum": [ + "all_registered_users", + "all_active_users", + "single_user", + "user_ids", + "region", + "country" + ] }, "target_user_id": { "type": "integer", "format": "int64" }, "title": { "type": "string" }, "user_ids": { "type": "array", - "items": { "type": "integer", "format": "int64" } + "items": { + "oneOf": [ + { "type": "integer", "format": "int64" }, + { "type": "string", "pattern": "^[1-9][0-9]*$" } + ] + } } } } diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 709de0a..10a8740 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -162,6 +162,7 @@ export const fallbackNavigation = [ routeNavItem("app-config-popups", { icon: ImageOutlined }), routeNavItem("app-config-explore", { icon: MapOutlined }), routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }), + routeNavItem("operation-full-server-notice", { icon: CampaignOutlined }), ], }, { @@ -190,7 +191,6 @@ export const fallbackNavigation = [ routeNavItem("lucky-gift", { icon: RedeemOutlined }), routeNavItem("operation-reports", { icon: FlagOutlined }), routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }), - routeNavItem("operation-full-server-notice", { icon: CampaignOutlined }), ], }, { @@ -268,15 +268,16 @@ export const fallbackNavigation = [ }, ]; -export function mapBackendMenus(menus = []) { - return orderTopLevelMenus(relocateBackendMenus(menus)) +export function mapBackendMenus(menus = [], options = { relocate: true }) { + const sourceMenus = options.relocate ? orderTopLevelMenus(relocateBackendMenus(menus)) : menus; + return sourceMenus .map((item) => { if (deprecatedMenuCodes.has(item.code)) { return null; } const route = item.code ? getRouteByMenuCode(item.code) : undefined; - const children = item.children?.length ? mapBackendMenus(item.children) : undefined; + const children = item.children?.length ? mapBackendMenus(item.children, { relocate: false }) : undefined; return { children: children?.length ? children : undefined, @@ -292,7 +293,49 @@ export function mapBackendMenus(menus = []) { } function relocateBackendMenus(menus = []) { - return relocateLogMenusToSystem(menus); + return relocateFullServerNoticeToAppConfig(relocateLogMenusToSystem(menus)); +} + +function relocateFullServerNoticeToAppConfig(menus = []) { + let movedItem = null; + + function strip(items = []) { + return items + .map((item) => { + if (item.code === "operation-full-server-notice") { + if (!movedItem) { + movedItem = item; + } + return null; + } + const children = item.children?.length ? strip(item.children) : item.children; + return children === item.children ? item : { ...item, children }; + }) + .filter(Boolean); + } + + const strippedMenus = strip(menus); + if (!movedItem) { + return strippedMenus; + } + + return strippedMenus.map((item) => + item.code === "app-config" + ? { + ...item, + children: [ + ...(item.children || []), + { + ...movedItem, + icon: movedItem.icon || "campaign", + label: "系统消息推送", + path: "/app-config/system-message-push", + permissionCode: movedItem.permissionCode || "full-server-notice:view", + }, + ], + } + : item, + ); } function orderTopLevelMenus(menus = []) { diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx index 342c3ac..99165eb 100644 --- a/src/app/navigation/menu.test.jsx +++ b/src/app/navigation/menu.test.jsx @@ -162,7 +162,10 @@ describe("navigation menu helpers", () => { label: "APP配置", }, ]; - const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "app-config:view"); + const fallbackMenus = filterNavigationByPermission( + fallbackNavigation, + (code) => code === "app-config:view" || code === "full-server-notice:view", + ); const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus); const appConfig = merged.find((item) => item.code === "app-config"); @@ -173,9 +176,46 @@ describe("navigation menu helpers", () => { "app-config-popups", "app-config-explore", "app-config-versions", + "operation-full-server-notice", ]); }); + test("moves stale full server notice backend menu under app config", () => { + const mapped = mapBackendMenus([ + { + children: [{ code: "app-config-h5", id: "app-config-h5", label: "H5配置", path: "/app-config/h5" }], + code: "app-config", + icon: "settings", + id: "app-config", + label: "APP配置", + }, + { + children: [ + { + code: "operation-full-server-notice", + icon: "campaign", + id: "operation-full-server-notice", + label: "全服通知", + path: "/operations/full-server-notices", + permissionCode: "full-server-notice:view", + }, + ], + code: "operations", + icon: "receipt", + id: "operations", + label: "运营管理", + }, + ]); + const appConfig = mapped.find((item) => item.code === "app-config"); + const operations = mapped.find((item) => item.code === "operations"); + + expect(appConfig.children.map((item) => item.code)).toEqual(["app-config-h5", "operation-full-server-notice"]); + expect(appConfig.children.find((item) => item.code === "operation-full-server-notice").path).toBe( + "/app-config/system-message-push", + ); + expect((operations.children || []).map((item) => item.code)).not.toContain("operation-full-server-notice"); + }); + test("adds team settings under system menu when backend menu is stale", () => { const backendMenus = [ { diff --git a/src/features/app-config/api.test.ts b/src/features/app-config/api.test.ts new file mode 100644 index 0000000..e1bd8a7 --- /dev/null +++ b/src/features/app-config/api.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setAccessToken } from "@/shared/api/request"; +import { createSystemMessagePushFanout } from "./api"; + +afterEach(() => { + setAccessToken(""); + vi.unstubAllGlobals(); +}); + +test("system message push fanout API keeps existing operations path", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + command_id: "admin_notice_test", + created: true, + job_id: "mfan_test", + message_type: "system", + status: "pending", + target_scope: "all_registered_users", + }, + }), + ), + ), + ); + + await createSystemMessagePushFanout({ + action_param: "https://example.com", + action_type: "app_h5", + batch_size: 500, + message_type: "system", + producer_event_type: "admin_full_server_notice", + summary: "今晚 23:00 维护", + target_scope: "all_registered_users", + title: "维护通知", + }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(String(url)).toContain("/api/v1/admin/operations/full-server-notices/fanout"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + action_param: "https://example.com", + action_type: "app_h5", + message_type: "system", + producer_event_type: "admin_full_server_notice", + target_scope: "all_registered_users", + title: "维护通知", + }); +}); + +test("system message push fanout API preserves string user IDs", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + command_id: "admin_notice_users", + created: true, + job_id: "mfan_users", + message_type: "system", + status: "pending", + target_scope: "user_ids", + }, + }), + ), + ), + ); + + await createSystemMessagePushFanout({ + message_type: "system", + summary: "指定用户维护", + target_scope: "user_ids", + title: "维护通知", + user_ids: ["325379237278126080", "42"], + }); + + const [, init] = vi.mocked(fetch).mock.calls[0]; + + expect(JSON.parse(String(init?.body))).toMatchObject({ + target_scope: "user_ids", + user_ids: ["325379237278126080", "42"], + }); +}); diff --git a/src/features/app-config/api.ts b/src/features/app-config/api.ts index cf2a9a3..d89c9e9 100644 --- a/src/features/app-config/api.ts +++ b/src/features/app-config/api.ts @@ -19,6 +19,40 @@ import type { PageQuery, } from "@/shared/api/types"; +export interface SystemMessagePushPayload { + action_param?: string; + action_type?: string; + aggregate_type?: string; + batch_size?: number; + body?: string; + message_type: "system"; + producer_event_type?: string; + summary: string; + target_scope: "all_registered_users" | "user_ids"; + title: string; + user_ids?: string[]; +} + +export interface SystemMessagePushResponse { + command_id: string; + created: boolean; + job_id: string; + message_type: string; + status: string; + target_scope: string; +} + +export function createSystemMessagePushFanout(payload: SystemMessagePushPayload): Promise { + const endpoint = API_ENDPOINTS.createFullServerNoticeFanout; + return apiRequest( + apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout), + { + body: payload, + method: endpoint.method, + }, + ); +} + export function listH5Links(): Promise> { const endpoint = API_ENDPOINTS.listH5Links; return apiRequest>(apiEndpointPath(API_OPERATIONS.listH5Links), { diff --git a/src/features/app-config/app-config.module.css b/src/features/app-config/app-config.module.css index c34f94f..b39cef6 100644 --- a/src/features/app-config/app-config.module.css +++ b/src/features/app-config/app-config.module.css @@ -1,176 +1,273 @@ .linkText { - overflow: hidden; - max-width: 100%; - color: var(--text-secondary); - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + max-width: 100%; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; } .bannerCover, .bannerCoverEmpty { - display: inline-flex; - width: 88px; - height: 50px; - 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); + display: inline-flex; + width: 88px; + height: 50px; + 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); } .bannerCover { - object-fit: cover; + 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); + 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; + object-fit: cover; } .splashCover, .splashCoverEmpty { - display: inline-flex; - width: 54px; - height: 96px; - 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); + display: inline-flex; + width: 54px; + height: 96px; + 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); } .splashCover { - object-fit: cover; + object-fit: cover; } .formWideField { - grid-column: 1 / -1; + grid-column: 1 / -1; +} + +.noticeBody { + display: grid; + min-height: 0; + grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); + gap: var(--space-5); + padding: var(--space-5); + background: var(--bg-card); +} + +.noticePanel { + display: grid; + min-width: 0; + gap: var(--space-5); +} + +.noticeHeader { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); + color: var(--text-primary); +} + +.noticeHeader h2, +.noticePreview h3 { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-size: 16px; + font-weight: 800; + line-height: 1.4; +} + +.noticeGrid, +.noticeContentGrid { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: var(--space-4); +} + +.noticeContentGrid { + grid-template-columns: repeat(2, minmax(260px, 1fr)); +} + +.noticeWideField { + grid-column: 1 / -1; +} + +.noticePreview { + display: grid; + align-content: start; + min-width: 0; + gap: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card-strong); +} + +.noticePreview p { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--text-secondary); + line-height: 1.6; +} + +.noticePreviewMeta { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: var(--space-2); +} + +.noticePreviewMeta span { + display: inline-flex; + max-width: 100%; + min-height: 28px; + align-items: center; + padding: 0 var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + overflow-wrap: anywhere; } .splashFormLayout { - display: grid; - width: 100%; - grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); - align-items: start; - gap: var(--space-5); + display: grid; + width: 100%; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + align-items: start; + gap: var(--space-5); } .splashAssetPane, .splashFieldPane { - display: grid; - min-width: 0; - gap: var(--space-4); + display: grid; + min-width: 0; + gap: var(--space-4); } .splashAssetPane { - padding-right: var(--space-1); + padding-right: var(--space-1); } .splashFieldPane { - padding-left: var(--space-1); + padding-left: var(--space-1); } .splashUploadField { - width: min(100%, 260px); - justify-self: center; + width: min(100%, 260px); + justify-self: center; } .splashUploadField .splashUploadPreview { - height: auto; - aspect-ratio: 9 / 16; - max-height: min(52vh, 420px); + height: auto; + aspect-ratio: 9 / 16; + max-height: min(52vh, 420px); } .splashStatusField { - align-self: center; + align-self: center; } .popupFormLayout { - display: grid; - width: 100%; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - align-items: start; - gap: var(--space-5); + 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); + display: grid; + min-width: 0; + gap: var(--space-4); } .popupAssetPane { - padding-right: var(--space-1); + padding-right: var(--space-1); } .popupFieldPane { - padding-left: var(--space-1); + padding-left: var(--space-1); } .popupUploadField { - width: min(100%, 280px); - justify-self: center; + width: min(100%, 280px); + justify-self: center; } .popupUploadField .popupUploadPreview { - aspect-ratio: 1 / 1; - height: auto; - max-height: min(42vh, 300px); + aspect-ratio: 1 / 1; + height: auto; + max-height: min(42vh, 300px); } .statusCell { - display: inline-flex; - align-items: center; + display: inline-flex; + align-items: center; } @media (max-width: 760px) { - .splashFormLayout { - grid-template-columns: 1fr; - gap: var(--space-4); - } + .splashFormLayout { + grid-template-columns: 1fr; + gap: var(--space-4); + } - .splashAssetPane, - .splashFieldPane { - padding-right: 0; - padding-left: 0; - } + .splashAssetPane, + .splashFieldPane { + padding-right: 0; + padding-left: 0; + } - .splashUploadField { - justify-self: stretch; - width: min(100%, 260px); - } + .splashUploadField { + justify-self: stretch; + width: min(100%, 260px); + } - .popupFormLayout { - grid-template-columns: 1fr; - gap: var(--space-4); - } + .popupFormLayout { + grid-template-columns: 1fr; + gap: var(--space-4); + } - .popupAssetPane, - .popupFieldPane { - padding-right: 0; - padding-left: 0; - } + .popupAssetPane, + .popupFieldPane { + padding-right: 0; + padding-left: 0; + } - .popupUploadField { - justify-self: stretch; - width: min(100%, 280px); - } + .popupUploadField { + justify-self: stretch; + width: min(100%, 280px); + } +} + +@media (max-width: 720px) { + .noticeBody, + .noticeGrid, + .noticeContentGrid { + grid-template-columns: minmax(0, 1fr); + } } diff --git a/src/features/app-config/pages/SystemMessagePushPage.jsx b/src/features/app-config/pages/SystemMessagePushPage.jsx new file mode 100644 index 0000000..3b9ba66 --- /dev/null +++ b/src/features/app-config/pages/SystemMessagePushPage.jsx @@ -0,0 +1,220 @@ +import CampaignOutlined from "@mui/icons-material/CampaignOutlined"; +import SendOutlined from "@mui/icons-material/SendOutlined"; +import Alert from "@mui/material/Alert"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useMemo, useState } from "react"; +import { createSystemMessagePushFanout } from "@/features/app-config/api"; +import { useSystemMessagePushAbilities } from "@/features/app-config/permissions.js"; +import { parseSystemMessagePushUserIds, systemMessagePushSchema } from "@/features/app-config/schema"; +import styles from "@/features/app-config/app-config.module.css"; +import { FormValidationError, parseForm } from "@/shared/forms/validation"; +import { AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const targetScopes = [ + { label: "所有注册用户", value: "all_registered_users" }, + { label: "指定用户", value: "user_ids" }, +]; + +const actionTypes = [ + { label: "无跳转", value: "" }, + { label: "活动详情", value: "activity_detail" }, + { label: "H5 页面", value: "app_h5" }, + { label: "房间详情", value: "room_detail" }, + { label: "用户主页", value: "user_profile" }, + { label: "提现详情", value: "wallet_withdraw_detail" }, + { label: "主播申请", value: "host_application_detail" }, + { label: "币商转账", value: "coin_seller_transfer_detail" }, +]; + +const initialForm = { + actionParam: "", + actionType: "", + body: "", + summary: "", + targetScope: "all_registered_users", + title: "", + userIdsText: "", +}; + +export function SystemMessagePushPage() { + const abilities = useSystemMessagePushAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + const [form, setForm] = useState(initialForm); + const [result, setResult] = useState(null); + const [saving, setSaving] = useState(false); + + const targetLabel = useMemo( + () => targetScopes.find((item) => item.value === form.targetScope)?.label || form.targetScope, + [form.targetScope], + ); + + const updateField = (field) => (event) => { + setForm((current) => ({ ...current, [field]: event.target.value })); + }; + + const submit = async () => { + let parsed; + try { + parsed = parseForm(systemMessagePushSchema, form); + } catch (error) { + if (error instanceof FormValidationError) { + showToast(error.message, "error"); + return; + } + throw error; + } + + const userIds = parsed.targetScope === "user_ids" ? parseSystemMessagePushUserIds(parsed.userIdsText) : []; + const targetText = + parsed.targetScope === "all_registered_users" ? "所有注册用户" : `${userIds.length} 个指定用户`; + const accepted = await confirm({ + confirmText: "发送", + message: `将向${targetText}发送「${parsed.title}」。`, + title: "发送系统消息", + }); + if (!accepted) { + return; + } + + setSaving(true); + try { + const next = await createSystemMessagePushFanout(buildPayload(parsed, userIds)); + setResult(next); + showToast(next.created ? "系统消息推送任务已创建" : "已返回现有系统消息推送任务", "success"); + } catch (error) { + showToast(error.message || "创建系统消息推送失败", "error"); + } finally { + setSaving(false); + } + }; + + return ( + + } variant="primary" onClick={submit}> + 发送 + + ) : null + } + filters={null} + /> +
+
+
+ +

系统消息推送

+
+
+ + + {targetScopes.map((item) => ( + + {item.label} + + ))} + + {form.targetScope === "user_ids" ? ( + + ) : null} +
+
+ + + +
+
+ + {actionTypes.map((item) => ( + + {item.label} + + ))} + + +
+
+
+

{form.title || "标题"}

+

{form.summary || "摘要"}

+
+ 系统消息 + {targetLabel} + {form.actionType || "无跳转"} +
+ {result ? ( + + {result.status} · {result.job_id} · {result.command_id} + + ) : null} +
+
+
+ ); +} + +function buildPayload(form, userIds) { + return { + action_param: form.actionParam || undefined, + action_type: form.actionType || undefined, + aggregate_type: "admin_notice", + batch_size: 500, + body: form.body || undefined, + message_type: "system", + producer_event_type: "admin_full_server_notice", + summary: form.summary, + target_scope: form.targetScope, + title: form.title, + user_ids: form.targetScope === "user_ids" ? userIds : undefined, + }; +} diff --git a/src/features/app-config/pages/SystemMessagePushPage.test.jsx b/src/features/app-config/pages/SystemMessagePushPage.test.jsx new file mode 100644 index 0000000..5744e84 --- /dev/null +++ b/src/features/app-config/pages/SystemMessagePushPage.test.jsx @@ -0,0 +1,120 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, test, vi } from "vitest"; +import { AppProviders } from "@/app/providers.jsx"; +import { SystemMessagePushPage } from "./SystemMessagePushPage.jsx"; + +vi.mock("@/app/auth/AuthProvider.jsx", () => ({ + AuthProvider: ({ children }) => children, + useAuth: () => ({ can: () => true }), +})); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("submits all registered users payload with fixed system message type", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", createFanoutFetch({ target_scope: "all_registered_users" })); + + renderPage(); + await user.type(screen.getByRole("textbox", { name: "标题" }), "维护通知"); + await user.type(screen.getByRole("textbox", { name: "摘要" }), "今晚 23:00 维护"); + await user.click(screen.getByRole("button", { name: "发送" })); + await confirmSend(user); + + await waitFor(() => + expect(fanoutPostBodies()).toEqual([ + expect.objectContaining({ + message_type: "system", + producer_event_type: "admin_full_server_notice", + target_scope: "all_registered_users", + title: "维护通知", + }), + ]), + ); +}); + +test("submits specified users after parsing and deduping separators", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", createFanoutFetch({ target_scope: "user_ids" })); + + renderPage(); + await user.click(screen.getByRole("combobox", { name: "发送范围" })); + await user.click(screen.getByRole("option", { name: "指定用户" })); + await user.type(screen.getByRole("textbox", { name: "用户 ID 列表" }), "325379237278126080, 42,42"); + await user.type(screen.getByRole("textbox", { name: "标题" }), "指定用户通知"); + await user.type(screen.getByRole("textbox", { name: "摘要" }), "只发给指定用户"); + await user.click(screen.getByRole("button", { name: "发送" })); + await confirmSend(user); + + await waitFor(() => + expect(fanoutPostBodies()).toEqual([ + expect.objectContaining({ + message_type: "system", + target_scope: "user_ids", + user_ids: ["325379237278126080", "42"], + }), + ]), + ); +}); + +test("blocks specified users submit when user ID list is empty", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", createFanoutFetch({ target_scope: "user_ids" })); + + renderPage(); + await user.click(screen.getByRole("combobox", { name: "发送范围" })); + await user.click(screen.getByRole("option", { name: "指定用户" })); + await user.type(screen.getByRole("textbox", { name: "标题" }), "指定用户通知"); + await user.type(screen.getByRole("textbox", { name: "摘要" }), "只发给指定用户"); + await user.click(screen.getByRole("button", { name: "发送" })); + + await waitFor(() => expect(screen.getByText("用户 ID 列表不能为空")).toBeInTheDocument()); + expect(fanoutPostBodies()).toHaveLength(0); +}); + +function renderPage() { + return render( + + + + + , + ); +} + +async function confirmSend(user) { + const dialog = await screen.findByRole("dialog", { name: "发送系统消息" }); + await user.click(within(dialog).getByRole("button", { name: "发送" })); +} + +function fanoutPostBodies() { + return vi + .mocked(fetch) + .mock.calls.filter( + ([url, init]) => + String(url).includes("/api/v1/admin/operations/full-server-notices/fanout") && init?.method === "POST", + ) + .map(([, init]) => JSON.parse(String(init?.body))); +} + +function createFanoutFetch({ target_scope }) { + return vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + command_id: "admin_notice_test", + created: true, + job_id: "mfan_test", + message_type: "system", + status: "pending", + target_scope, + }, + }), + ), + ); +} diff --git a/src/features/app-config/permissions.js b/src/features/app-config/permissions.js index 8af2af4..f26f042 100644 --- a/src/features/app-config/permissions.js +++ b/src/features/app-config/permissions.js @@ -2,15 +2,24 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { PERMISSIONS } from "@/app/permissions"; export function useAppConfigAbilities() { - const { can } = useAuth(); + const { can } = useAuth(); - return { - canCreateVersion: can(PERMISSIONS.appVersionCreate), - canDeleteVersion: can(PERMISSIONS.appVersionDelete), - canUpdate: can(PERMISSIONS.appConfigUpdate), - canUpdateVersion: can(PERMISSIONS.appVersionUpdate), - canUpload: can(PERMISSIONS.uploadCreate), - canView: can(PERMISSIONS.appConfigView), - canViewVersion: can(PERMISSIONS.appVersionView) - }; + return { + canCreateVersion: can(PERMISSIONS.appVersionCreate), + canDeleteVersion: can(PERMISSIONS.appVersionDelete), + canUpdate: can(PERMISSIONS.appConfigUpdate), + canUpdateVersion: can(PERMISSIONS.appVersionUpdate), + canUpload: can(PERMISSIONS.uploadCreate), + canView: can(PERMISSIONS.appConfigView), + canViewVersion: can(PERMISSIONS.appVersionView), + }; +} + +export function useSystemMessagePushAbilities() { + const { can } = useAuth(); + + return { + canSend: can(PERMISSIONS.fullServerNoticeSend), + canView: can(PERMISSIONS.fullServerNoticeView), + }; } diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js index c3dba2b..bc027f9 100644 --- a/src/features/app-config/routes.js +++ b/src/features/app-config/routes.js @@ -49,4 +49,12 @@ export const appConfigRoutes = [ path: "/app-config/versions", permission: PERMISSIONS.appVersionView, }, + { + label: "系统消息推送", + loader: () => import("./pages/SystemMessagePushPage.jsx").then((module) => module.SystemMessagePushPage), + menuCode: MENU_CODES.operationFullServerNotice, + pageKey: "app-config-system-message-push", + path: "/app-config/system-message-push", + permission: PERMISSIONS.fullServerNoticeView, + }, ]; diff --git a/src/features/app-config/schema.test.ts b/src/features/app-config/schema.test.ts index a85addb..faabdba 100644 --- a/src/features/app-config/schema.test.ts +++ b/src/features/app-config/schema.test.ts @@ -5,6 +5,8 @@ import { appSplashScreenSchema, exploreTabSchema, h5LinkUpdateSchema, + parseSystemMessagePushUserIds, + systemMessagePushSchema, } from "@/features/app-config/schema"; import { FormValidationError, parseForm } from "@/shared/forms/validation"; @@ -75,6 +77,42 @@ describe("app config form schema", () => { ).toThrow(FormValidationError); }); + test("parses and dedupes system message push user IDs as strings", () => { + expect(parseSystemMessagePushUserIds("325379237278126080, 42,42 1001")).toEqual([ + "325379237278126080", + "42", + "1001", + ]); + }); + + test("keeps system message push payload constrained to supported scopes", () => { + const payload = parseForm(systemMessagePushSchema, { + actionParam: "", + actionType: "", + body: "正文", + summary: "摘要", + targetScope: "all_registered_users", + title: "标题", + userIdsText: "", + }); + + expect(payload.targetScope).toBe("all_registered_users"); + }); + + test("requires system message push user IDs for specified users", () => { + expect(() => + parseForm(systemMessagePushSchema, { + actionParam: "", + actionType: "", + body: "", + summary: "摘要", + targetScope: "user_ids", + title: "标题", + userIdsText: "", + }), + ).toThrow(FormValidationError); + }); + test("keeps banner display scope and delivery time fields", () => { const payload = parseForm(appBannerSchema, validBannerForm); diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts index 69e6b04..02d7a4a 100644 --- a/src/features/app-config/schema.ts +++ b/src/features/app-config/schema.ts @@ -2,6 +2,17 @@ import { z } from "zod"; import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; const appBannerDisplayScopes = ["home", "room", "recharge", "me"] as const; +const systemMessagePushTargetScopes = ["all_registered_users", "user_ids"] as const; +const systemMessagePushActionTypes = [ + "", + "activity_detail", + "app_h5", + "room_detail", + "user_profile", + "wallet_withdraw_detail", + "host_application_detail", + "coin_seller_transfer_detail", +] as const; const requiredH5LinkURLSchema = z .string() @@ -34,6 +45,61 @@ export const exploreTabSchema = z.object({ export type ExploreTabForm = z.infer; +export function parseSystemMessagePushUserIds(value: string): string[] { + const tokens = String(value || "") + .split(/[\s,,]+/) + .map((item) => item.trim()) + .filter(Boolean); + const invalid = tokens.find((item) => !/^[1-9]\d*$/.test(item)); + if (invalid) { + throw new Error("用户 ID 必须是正整数"); + } + return Array.from(new Set(tokens)); +} + +export const systemMessagePushSchema = z + .object({ + actionParam: z.string().trim().max(2048, "跳转参数不能超过 2048 个字符").default(""), + actionType: z.enum(systemMessagePushActionTypes).default(""), + body: z.string().trim().max(4000, "正文不能超过 4000 个字符").default(""), + summary: z.string().trim().min(1, "请填写摘要").max(240, "摘要不能超过 240 个字符"), + targetScope: z.enum(systemMessagePushTargetScopes), + title: z.string().trim().min(1, "请填写标题").max(80, "标题不能超过 80 个字符"), + userIdsText: z.string().trim().default(""), + }) + .superRefine((value, ctx) => { + if (value.targetScope === "user_ids") { + let userIds: string[]; + try { + userIds = parseSystemMessagePushUserIds(value.userIdsText); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error instanceof Error ? error.message : "用户 ID 必须是正整数", + path: ["userIdsText"], + }); + return; + } + if (!userIds.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "用户 ID 列表不能为空", + path: ["userIdsText"], + }); + } + } + + if (value.actionParam && !value.actionType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "请选择跳转类型", + path: ["actionType"], + }); + } + }); + +export type SystemMessagePushForm = z.infer; + export const appBannerSchema = z .object({ bannerType: z.enum(["h5", "app"]), diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts index 7826f4d..2bc5a7c 100644 --- a/src/features/operations/api.test.ts +++ b/src/features/operations/api.test.ts @@ -2,7 +2,6 @@ import { afterEach, expect, test, vi } from "vitest"; import { setAccessToken } from "@/shared/api/request"; import { createCoinAdjustment, - createFullServerNoticeFanout, exportCoinSellerLedger, getGiftDiamondRatios, getWheelConfig, @@ -49,52 +48,6 @@ test("gift diamond ratio APIs use operations paths", async () => { }); }); -test("full server notice fanout API uses operations path", async () => { - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response( - JSON.stringify({ - code: 0, - data: { - command_id: "admin_notice_test", - created: true, - job_id: "mfan_test", - message_type: "system", - status: "pending", - target_scope: "all_active_users", - }, - }), - ), - ), - ); - - await createFullServerNoticeFanout({ - action_type: "app_h5", - action_param: "https://example.com", - batch_size: 500, - command_id: "admin_notice_test", - message_type: "system", - summary: "今晚 23:00 维护", - target_scope: "all_active_users", - title: "维护通知", - }); - - const [url, init] = vi.mocked(fetch).mock.calls[0]; - - expect(String(url)).toContain("/api/v1/admin/operations/full-server-notices/fanout"); - expect(init?.method).toBe("POST"); - expect(JSON.parse(String(init?.body))).toMatchObject({ - action_param: "https://example.com", - action_type: "app_h5", - command_id: "admin_notice_test", - message_type: "system", - target_scope: "all_active_users", - title: "维护通知", - }); -}); - test("coin adjustment APIs use generated admin paths", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts index 275d524..b9ac0d5 100644 --- a/src/features/operations/api.ts +++ b/src/features/operations/api.ts @@ -27,40 +27,6 @@ export interface GiftDiamondRatioResponse { items: GiftDiamondRatioItem[]; } -export interface FullServerNoticeFanoutPayload { - action_param?: string; - action_type?: string; - aggregate_id?: string; - aggregate_type?: string; - batch_size?: number; - body?: string; - command_id?: string; - country?: string; - expire_at_ms?: number; - icon_url?: string; - image_url?: string; - message_type: "system" | "activity"; - metadata_json?: string; - priority?: number; - producer_event_type?: string; - region_id?: number; - sent_at_ms?: number; - summary: string; - target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country"; - target_user_id?: number; - title: string; - user_ids?: number[]; -} - -export interface FullServerNoticeFanoutResponse { - command_id: string; - created: boolean; - job_id: string; - message_type: string; - status: string; - target_scope: string; -} - export type WheelRewardType = "coin" | "dress" | "gift" | "prop"; export interface WheelTierDto { @@ -235,19 +201,6 @@ export function updateGiftDiamondRatios(payload: { }); } -export function createFullServerNoticeFanout( - payload: FullServerNoticeFanoutPayload, -): Promise { - const endpoint = API_ENDPOINTS.createFullServerNoticeFanout; - return apiRequest( - apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout), - { - body: payload, - method: endpoint.method, - }, - ); -} - export function getWheelConfig(wheelId = "default"): Promise { const endpoint = API_ENDPOINTS.getWheelConfig; return apiRequest(apiEndpointPath(API_OPERATIONS.getWheelConfig), { @@ -318,7 +271,10 @@ function normalizeWheelTier(raw: unknown): WheelTierDto { rewardCount: numberValue(item.rewardCount ?? item.reward_count), rewardId: stringValue(item.rewardId ?? item.reward_id), rewardType, - rtpValueCoins: rewardType === "prop" || rewardType === "dress" ? 0 : numberValue(item.rtpValueCoins ?? item.rtp_value_coins), + rtpValueCoins: + rewardType === "prop" || rewardType === "dress" + ? 0 + : numberValue(item.rtpValueCoins ?? item.rtp_value_coins), tierId: stringValue(item.tierId ?? item.tier_id) || rewardType, }; } diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css index 2146bb0..1bcf017 100644 --- a/src/features/operations/operations.module.css +++ b/src/features/operations/operations.module.css @@ -559,95 +559,6 @@ font-size: 12px; } -.noticeBody { - display: grid; - min-height: 0; - grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); - gap: var(--space-5); - padding: var(--space-5); - background: var(--bg-card); -} - -.noticePanel { - display: grid; - min-width: 0; - gap: var(--space-5); -} - -.noticeHeader { - display: flex; - min-width: 0; - align-items: center; - gap: var(--space-2); - color: var(--text-primary); -} - -.noticeHeader h2, -.noticePreview h3 { - min-width: 0; - margin: 0; - overflow-wrap: anywhere; - font-size: 16px; - font-weight: 800; - line-height: 1.4; -} - -.noticeGrid, -.noticeContentGrid { - display: grid; - min-width: 0; - grid-template-columns: repeat(2, minmax(220px, 1fr)); - gap: var(--space-4); -} - -.noticeContentGrid { - grid-template-columns: repeat(2, minmax(260px, 1fr)); -} - -.noticeWideField { - grid-column: 1 / -1; -} - -.noticePreview { - display: grid; - align-content: start; - min-width: 0; - gap: var(--space-4); - padding: var(--space-4); - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card-strong); -} - -.noticePreview p { - min-width: 0; - margin: 0; - overflow-wrap: anywhere; - color: var(--text-secondary); - line-height: 1.6; -} - -.noticePreviewMeta { - display: flex; - min-width: 0; - flex-wrap: wrap; - gap: var(--space-2); -} - -.noticePreviewMeta span { - display: inline-flex; - max-width: 100%; - min-height: 28px; - align-items: center; - padding: 0 var(--space-3); - border: 1px solid var(--border-soft); - border-radius: var(--radius-control); - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - overflow-wrap: anywhere; -} - @media (max-width: 720px) { .ratioGrid, .ratioTable { @@ -667,10 +578,7 @@ .wheelBody, .wheelConfigGrid, - .wheelSummaryGrid, - .noticeBody, - .noticeGrid, - .noticeContentGrid { + .wheelSummaryGrid { grid-template-columns: minmax(0, 1fr); } } diff --git a/src/features/operations/pages/FullServerNoticePage.jsx b/src/features/operations/pages/FullServerNoticePage.jsx deleted file mode 100644 index 649691a..0000000 --- a/src/features/operations/pages/FullServerNoticePage.jsx +++ /dev/null @@ -1,398 +0,0 @@ -import CampaignOutlined from "@mui/icons-material/CampaignOutlined"; -import SendOutlined from "@mui/icons-material/SendOutlined"; -import Alert from "@mui/material/Alert"; -import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; -import { useMemo, useState } from "react"; -import { createFullServerNoticeFanout } from "@/features/operations/api"; -import { useFullServerNoticeAbilities } from "@/features/operations/permissions.js"; -import styles from "@/features/operations/operations.module.css"; -import { AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx"; -import { Button } from "@/shared/ui/Button.jsx"; -import { useToast } from "@/shared/ui/ToastProvider.jsx"; - -const messageTypes = [ - { label: "系统消息", value: "system" }, - { label: "活动消息", value: "activity" }, -]; - -const targetScopes = [ - { label: "全量活跃用户", value: "all_active_users" }, - { label: "指定用户", value: "single_user" }, - { label: "用户列表", value: "user_ids" }, - { label: "指定区域", value: "region" }, - { label: "指定国家", value: "country" }, -]; - -const actionTypes = [ - { label: "无跳转", value: "" }, - { label: "活动详情", value: "activity_detail" }, - { label: "H5 页面", value: "app_h5" }, - { label: "房间详情", value: "room_detail" }, - { label: "用户主页", value: "user_profile" }, - { label: "提现详情", value: "wallet_withdraw_detail" }, - { label: "主播申请", value: "host_application_detail" }, - { label: "币商转账", value: "coin_seller_transfer_detail" }, -]; - -const initialForm = { - actionParam: "", - actionType: "", - aggregateId: "", - aggregateType: "admin_notice", - batchSize: "500", - body: "", - commandId: "", - country: "", - expireAt: "", - messageType: "system", - metadataJson: "", - priority: "0", - producerEventType: "admin_full_server_notice", - regionId: "", - summary: "", - targetScope: "all_active_users", - targetUserId: "", - title: "", - userIds: "", -}; - -export function FullServerNoticePage() { - const abilities = useFullServerNoticeAbilities(); - const { showToast } = useToast(); - const [form, setForm] = useState(initialForm); - const [result, setResult] = useState(null); - const [saving, setSaving] = useState(false); - - const targetLabel = useMemo( - () => targetScopes.find((item) => item.value === form.targetScope)?.label || form.targetScope, - [form.targetScope], - ); - - const updateField = (field) => (event) => { - setForm((current) => ({ ...current, [field]: event.target.value })); - }; - - const submit = async () => { - const payload = buildPayload(form); - if (!payload.ok) { - showToast(payload.error, "error"); - return; - } - setSaving(true); - try { - const next = await createFullServerNoticeFanout(payload.value); - setResult(next); - showToast(next.created ? "全服通知任务已创建" : "已返回现有通知任务", "success"); - } catch (err) { - showToast(err.message || "创建全服通知失败", "error"); - } finally { - setSaving(false); - } - }; - - return ( - - } variant="primary" onClick={submit}> - 发送 - - ) : null - } - filters={null} - /> -
-
-
- -

全服通知

-
-
- - {messageTypes.map((item) => ( - - {item.label} - - ))} - - - {targetScopes.map((item) => ( - - {item.label} - - ))} - - {form.targetScope === "single_user" ? ( - - ) : null} - {form.targetScope === "user_ids" ? ( - - ) : null} - {form.targetScope === "region" ? ( - - ) : null} - {form.targetScope === "country" ? ( - - ) : null} - - -
-
- - - -
-
- - {actionTypes.map((item) => ( - - {item.label} - - ))} - - - - - - - - -
-
-
-

{form.title || "标题"}

-

{form.summary || "摘要"}

-
- {messageTypes.find((item) => item.value === form.messageType)?.label} - {targetLabel} - {form.actionType || "无跳转"} -
- {result ? ( - - {result.status} · {result.job_id} · {result.command_id} - - ) : null} -
-
-
- ); -} - -function buildPayload(form) { - const title = form.title.trim(); - const summary = form.summary.trim(); - if (!title || !summary) { - return { error: "标题和摘要不能为空", ok: false }; - } - const batchSize = parsePositiveNumber(form.batchSize, "批量大小"); - if (!batchSize.ok) { - return batchSize; - } - const priority = parseOptionalNumber(form.priority, "优先级"); - if (!priority.ok) { - return priority; - } - const expireAtMS = datetimeLocalToMS(form.expireAt); - if (!expireAtMS.ok) { - return expireAtMS; - } - const target = buildTargetPayload(form); - if (!target.ok) { - return target; - } - const metadataJson = form.metadataJson.trim(); - if (metadataJson) { - try { - JSON.parse(metadataJson); - } catch { - return { error: "Metadata JSON 不正确", ok: false }; - } - } - return { - ok: true, - value: { - ...target.value, - action_param: form.actionParam.trim() || undefined, - action_type: form.actionType, - aggregate_id: form.aggregateId.trim() || undefined, - aggregate_type: form.aggregateType.trim() || undefined, - batch_size: batchSize.value, - body: form.body.trim() || undefined, - command_id: form.commandId.trim() || undefined, - expire_at_ms: expireAtMS.value || undefined, - message_type: form.messageType, - metadata_json: metadataJson || undefined, - priority: priority.value, - producer_event_type: form.producerEventType.trim() || undefined, - summary, - target_scope: form.targetScope, - title, - }, - }; -} - -function buildTargetPayload(form) { - if (form.targetScope === "single_user") { - const targetUserID = parsePositiveNumber(form.targetUserId, "用户 ID"); - return targetUserID.ok ? { ok: true, value: { target_user_id: targetUserID.value } } : targetUserID; - } - if (form.targetScope === "user_ids") { - const ids = form.userIds - .split(/[\s,,]+/) - .map((item) => Number(item.trim())) - .filter((value) => Number.isInteger(value) && value > 0); - if (!ids.length) { - return { error: "用户 ID 列表不能为空", ok: false }; - } - return { ok: true, value: { user_ids: Array.from(new Set(ids)) } }; - } - if (form.targetScope === "region") { - const regionID = parsePositiveNumber(form.regionId, "区域 ID"); - return regionID.ok ? { ok: true, value: { region_id: regionID.value } } : regionID; - } - if (form.targetScope === "country") { - const country = form.country.trim(); - return country ? { ok: true, value: { country } } : { error: "国家不能为空", ok: false }; - } - return { ok: true, value: {} }; -} - -function parsePositiveNumber(value, label) { - const number = Number(String(value || "").trim()); - if (!Number.isInteger(number) || number <= 0) { - return { error: `${label} 必须是正整数`, ok: false }; - } - return { ok: true, value: number }; -} - -function parseOptionalNumber(value, label) { - const raw = String(value ?? "").trim(); - if (!raw) { - return { ok: true, value: 0 }; - } - const number = Number(raw); - if (!Number.isInteger(number)) { - return { error: `${label} 必须是整数`, ok: false }; - } - return { ok: true, value: number }; -} - -function datetimeLocalToMS(value) { - if (!value) { - return { ok: true, value: 0 }; - } - const ms = new Date(value).getTime(); - if (!Number.isFinite(ms)) { - return { error: "过期时间不正确", ok: false }; - } - return { ok: true, value: ms }; -} diff --git a/src/features/operations/permissions.js b/src/features/operations/permissions.js index e41b7a6..5fd5582 100644 --- a/src/features/operations/permissions.js +++ b/src/features/operations/permissions.js @@ -19,15 +19,6 @@ export function useGiftDiamondAbilities() { }; } -export function useFullServerNoticeAbilities() { - const { can } = useAuth(); - - return { - canSend: can(PERMISSIONS.fullServerNoticeSend), - canView: can(PERMISSIONS.fullServerNoticeView), - }; -} - export function useWheelAbilities() { const { can } = useAuth(); diff --git a/src/features/operations/routes.js b/src/features/operations/routes.js index e9e2cf8..3d3c8b4 100644 --- a/src/features/operations/routes.js +++ b/src/features/operations/routes.js @@ -49,12 +49,4 @@ export const operationsRoutes = [ path: "/operations/gift-diamonds", permission: PERMISSIONS.giftDiamondView, }, - { - label: "全服通知", - loader: () => import("./pages/FullServerNoticePage.jsx").then((module) => module.FullServerNoticePage), - menuCode: MENU_CODES.operationFullServerNotice, - pageKey: "operation-full-server-notice", - path: "/operations/full-server-notices", - permission: PERMISSIONS.fullServerNoticeView, - }, ]; diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 18ed122..f9bba97 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -1944,13 +1944,14 @@ export function useResourceGrantListPage() { !abilities.canRevokeGrant || !grant?.grantId || grant.status !== "succeeded" || - grant.grantSubjectType !== "resource_group" + !isRevocableGrantSubject(grant.grantSubjectType) ) { return; } const accepted = await confirm({ - title: "撤销资源组赠送", - message: "撤销后会扣回金币并移除资源组内装扮。该操作不可自动恢复。", + title: "撤销资源赠送", + message: + "撤销后会按原发放内容扣回资源:素材扣减对应有效天数,金币扣减对应数量,最低扣至 0。该操作不可自动恢复。", confirmText: "撤销", tone: "danger", }); @@ -1960,7 +1961,7 @@ export function useResourceGrantListPage() { setLoadingAction(`grant-revoke-${grant.grantId}`); try { await revokeResourceGrant(grant.grantId); - showToast("资源组赠送已撤销", "success"); + showToast("资源赠送已撤销", "success"); await result.reload(); } catch (err) { showToast(err.message || "撤销失败", "error"); @@ -2365,6 +2366,10 @@ function grantResourceIds(form) { return form.resourceId ? [form.resourceId] : []; } +function isRevocableGrantSubject(subjectType) { + return subjectType === "resource" || subjectType === "resource_group"; +} + function makeCommandId(prefix) { return `${prefix}-${Date.now()}`; } diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index c7392f9..536fe07 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -174,7 +174,9 @@ export function ResourceGrantListPage() { function ResourceGrantActions({ grant, page }) { const canRevoke = - page.abilities.canRevokeGrant && grant?.status === "succeeded" && grant?.grantSubjectType === "resource_group"; + page.abilities.canRevokeGrant && + grant?.status === "succeeded" && + isRevocableGrantSubject(grant?.grantSubjectType); if (!canRevoke) { return -; } @@ -185,7 +187,7 @@ function ResourceGrantActions({ grant, page }) { disabled={page.loadingAction === `grant-revoke-${grant.grantId}`} label="撤销" sx={dangerActionSx} - tooltip="撤销资源组赠送" + tooltip="撤销资源赠送" onClick={() => page.revokeGrant(grant)} > @@ -461,6 +463,10 @@ function subjectMeta(grant) { return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-"; } +function isRevocableGrantSubject(subjectType) { + return subjectType === "resource" || subjectType === "resource_group"; +} + function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) { const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType"); if (walletAssetType) { diff --git a/src/features/resources/pages/ResourceGrantListPage.test.jsx b/src/features/resources/pages/ResourceGrantListPage.test.jsx index f5142e8..2bfb54e 100644 --- a/src/features/resources/pages/ResourceGrantListPage.test.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.test.jsx @@ -15,19 +15,28 @@ afterEach(() => { vi.clearAllMocks(); }); -test("resource grant list shows revoke only for succeeded resource group grants", () => { - const eligible = grantFixture({ +test("resource grant list shows revoke for succeeded resource and resource group grants", () => { + const eligibleGroup = grantFixture({ grantId: "rgr_group_ok", grantSubjectType: "resource_group", status: "succeeded", }); + const eligibleResource = grantFixture({ + grantId: "rgr_resource_ok", + grantSubjectType: "resource", + status: "succeeded", + }); vi.mocked(useResourceGrantListPage).mockReturnValue( pageFixture({ data: { items: [ - eligible, - grantFixture({ grantId: "rgr_resource_ok", grantSubjectType: "resource", status: "succeeded" }), - grantFixture({ grantId: "rgr_group_revoked", grantSubjectType: "resource_group", status: "revoked" }), + eligibleGroup, + eligibleResource, + grantFixture({ + grantId: "rgr_group_revoked", + grantSubjectType: "resource_group", + status: "revoked", + }), ], page: 1, pageSize: 50, @@ -40,11 +49,13 @@ test("resource grant list shows revoke only for succeeded resource group grants" expect(screen.getByText("已撤销")).toBeInTheDocument(); const revokeButtons = screen.getAllByLabelText("撤销"); - expect(revokeButtons).toHaveLength(1); + expect(revokeButtons).toHaveLength(2); fireEvent.click(revokeButtons[0]); + fireEvent.click(revokeButtons[1]); - expect(mocks.revokeGrant).toHaveBeenCalledWith(eligible); + expect(mocks.revokeGrant).toHaveBeenNthCalledWith(1, eligibleGroup); + expect(mocks.revokeGrant).toHaveBeenNthCalledWith(2, eligibleResource); }); test("resource grant list hides revoke without permission", () => { diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index f66045b..a16f17e 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -7005,11 +7005,11 @@ export interface operations { sent_at_ms?: number; summary: string; /** @enum {string} */ - target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country"; + target_scope: "all_registered_users" | "all_active_users" | "single_user" | "user_ids" | "region" | "country"; /** Format: int64 */ target_user_id?: number; title: string; - user_ids?: number[]; + user_ids?: (number | string)[]; }; }; };