资源撤回,系统消息
This commit is contained in:
parent
46654ac23f
commit
24e9bfc439
@ -2828,13 +2828,25 @@
|
|||||||
"summary": { "type": "string" },
|
"summary": { "type": "string" },
|
||||||
"target_scope": {
|
"target_scope": {
|
||||||
"type": "string",
|
"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" },
|
"target_user_id": { "type": "integer", "format": "int64" },
|
||||||
"title": { "type": "string" },
|
"title": { "type": "string" },
|
||||||
"user_ids": {
|
"user_ids": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "integer", "format": "int64" }
|
"items": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "type": "integer", "format": "int64" },
|
||||||
|
{ "type": "string", "pattern": "^[1-9][0-9]*$" }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -162,6 +162,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("app-config-popups", { icon: ImageOutlined }),
|
routeNavItem("app-config-popups", { icon: ImageOutlined }),
|
||||||
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
||||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
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("lucky-gift", { icon: RedeemOutlined }),
|
||||||
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
||||||
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
||||||
routeNavItem("operation-full-server-notice", { icon: CampaignOutlined }),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -268,15 +268,16 @@ export const fallbackNavigation = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function mapBackendMenus(menus = []) {
|
export function mapBackendMenus(menus = [], options = { relocate: true }) {
|
||||||
return orderTopLevelMenus(relocateBackendMenus(menus))
|
const sourceMenus = options.relocate ? orderTopLevelMenus(relocateBackendMenus(menus)) : menus;
|
||||||
|
return sourceMenus
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (deprecatedMenuCodes.has(item.code)) {
|
if (deprecatedMenuCodes.has(item.code)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
|
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 {
|
return {
|
||||||
children: children?.length ? children : undefined,
|
children: children?.length ? children : undefined,
|
||||||
@ -292,7 +293,49 @@ export function mapBackendMenus(menus = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function relocateBackendMenus(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 = []) {
|
function orderTopLevelMenus(menus = []) {
|
||||||
|
|||||||
@ -162,7 +162,10 @@ describe("navigation menu helpers", () => {
|
|||||||
label: "APP配置",
|
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 merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
|
||||||
const appConfig = merged.find((item) => item.code === "app-config");
|
const appConfig = merged.find((item) => item.code === "app-config");
|
||||||
|
|
||||||
@ -173,9 +176,46 @@ describe("navigation menu helpers", () => {
|
|||||||
"app-config-popups",
|
"app-config-popups",
|
||||||
"app-config-explore",
|
"app-config-explore",
|
||||||
"app-config-versions",
|
"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", () => {
|
test("adds team settings under system menu when backend menu is stale", () => {
|
||||||
const backendMenus = [
|
const backendMenus = [
|
||||||
{
|
{
|
||||||
|
|||||||
91
src/features/app-config/api.test.ts
Normal file
91
src/features/app-config/api.test.ts
Normal file
@ -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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -19,6 +19,40 @@ import type {
|
|||||||
PageQuery,
|
PageQuery,
|
||||||
} from "@/shared/api/types";
|
} 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<SystemMessagePushResponse> {
|
||||||
|
const endpoint = API_ENDPOINTS.createFullServerNoticeFanout;
|
||||||
|
return apiRequest<SystemMessagePushResponse, SystemMessagePushPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listH5Links;
|
const endpoint = API_ENDPOINTS.listH5Links;
|
||||||
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
||||||
|
|||||||
@ -1,176 +1,273 @@
|
|||||||
.linkText {
|
.linkText {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bannerCover,
|
.bannerCover,
|
||||||
.bannerCoverEmpty {
|
.bannerCoverEmpty {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 88px;
|
width: 88px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bannerCover {
|
.bannerCover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupCover,
|
.popupCover,
|
||||||
.popupCoverEmpty {
|
.popupCoverEmpty {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupCover {
|
.popupCover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashCover,
|
.splashCover,
|
||||||
.splashCoverEmpty {
|
.splashCoverEmpty {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashCover {
|
.splashCover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formWideField {
|
.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 {
|
.splashFormLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashAssetPane,
|
.splashAssetPane,
|
||||||
.splashFieldPane {
|
.splashFieldPane {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashAssetPane {
|
.splashAssetPane {
|
||||||
padding-right: var(--space-1);
|
padding-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashFieldPane {
|
.splashFieldPane {
|
||||||
padding-left: var(--space-1);
|
padding-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashUploadField {
|
.splashUploadField {
|
||||||
width: min(100%, 260px);
|
width: min(100%, 260px);
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashUploadField .splashUploadPreview {
|
.splashUploadField .splashUploadPreview {
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 9 / 16;
|
aspect-ratio: 9 / 16;
|
||||||
max-height: min(52vh, 420px);
|
max-height: min(52vh, 420px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashStatusField {
|
.splashStatusField {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupFormLayout {
|
.popupFormLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupAssetPane,
|
.popupAssetPane,
|
||||||
.popupFieldPane {
|
.popupFieldPane {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupAssetPane {
|
.popupAssetPane {
|
||||||
padding-right: var(--space-1);
|
padding-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupFieldPane {
|
.popupFieldPane {
|
||||||
padding-left: var(--space-1);
|
padding-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupUploadField {
|
.popupUploadField {
|
||||||
width: min(100%, 280px);
|
width: min(100%, 280px);
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupUploadField .popupUploadPreview {
|
.popupUploadField .popupUploadPreview {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: min(42vh, 300px);
|
max-height: min(42vh, 300px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusCell {
|
.statusCell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.splashFormLayout {
|
.splashFormLayout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashAssetPane,
|
.splashAssetPane,
|
||||||
.splashFieldPane {
|
.splashFieldPane {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splashUploadField {
|
.splashUploadField {
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
width: min(100%, 260px);
|
width: min(100%, 260px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupFormLayout {
|
.popupFormLayout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupAssetPane,
|
.popupAssetPane,
|
||||||
.popupFieldPane {
|
.popupFieldPane {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popupUploadField {
|
.popupUploadField {
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
width: min(100%, 280px);
|
width: min(100%, 280px);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.noticeBody,
|
||||||
|
.noticeGrid,
|
||||||
|
.noticeContentGrid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
220
src/features/app-config/pages/SystemMessagePushPage.jsx
Normal file
220
src/features/app-config/pages/SystemMessagePushPage.jsx
Normal file
@ -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 (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
abilities.canSend ? (
|
||||||
|
<Button disabled={saving} startIcon={<SendOutlined />} variant="primary" onClick={submit}>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={null}
|
||||||
|
/>
|
||||||
|
<div className={styles.noticeBody}>
|
||||||
|
<section className={styles.noticePanel}>
|
||||||
|
<div className={styles.noticeHeader}>
|
||||||
|
<CampaignOutlined fontSize="small" />
|
||||||
|
<h2>系统消息推送</h2>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeGrid}>
|
||||||
|
<TextField label="消息类型" size="small" value="系统消息" disabled />
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="发送范围"
|
||||||
|
size="small"
|
||||||
|
value={form.targetScope}
|
||||||
|
onChange={updateField("targetScope")}
|
||||||
|
>
|
||||||
|
{targetScopes.map((item) => (
|
||||||
|
<MenuItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{form.targetScope === "user_ids" ? (
|
||||||
|
<TextField
|
||||||
|
className={styles.noticeWideField}
|
||||||
|
label="用户 ID 列表"
|
||||||
|
minRows={3}
|
||||||
|
multiline
|
||||||
|
placeholder="1001,1002"
|
||||||
|
value={form.userIdsText}
|
||||||
|
onChange={updateField("userIdsText")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeContentGrid}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label="标题"
|
||||||
|
size="small"
|
||||||
|
value={form.title}
|
||||||
|
onChange={updateField("title")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label="摘要"
|
||||||
|
size="small"
|
||||||
|
value={form.summary}
|
||||||
|
onChange={updateField("summary")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.noticeWideField}
|
||||||
|
label="正文"
|
||||||
|
minRows={4}
|
||||||
|
multiline
|
||||||
|
value={form.body}
|
||||||
|
onChange={updateField("body")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.noticeGrid}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="跳转类型"
|
||||||
|
size="small"
|
||||||
|
value={form.actionType}
|
||||||
|
onChange={updateField("actionType")}
|
||||||
|
>
|
||||||
|
{actionTypes.map((item) => (
|
||||||
|
<MenuItem key={item.value || "none"} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="跳转参数"
|
||||||
|
size="small"
|
||||||
|
value={form.actionParam}
|
||||||
|
onChange={updateField("actionParam")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.noticePreview}>
|
||||||
|
<h3>{form.title || "标题"}</h3>
|
||||||
|
<p>{form.summary || "摘要"}</p>
|
||||||
|
<div className={styles.noticePreviewMeta}>
|
||||||
|
<span>系统消息</span>
|
||||||
|
<span>{targetLabel}</span>
|
||||||
|
<span>{form.actionType || "无跳转"}</span>
|
||||||
|
</div>
|
||||||
|
{result ? (
|
||||||
|
<Alert severity={result.created ? "success" : "info"}>
|
||||||
|
{result.status} · {result.job_id} · {result.command_id}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
src/features/app-config/pages/SystemMessagePushPage.test.jsx
Normal file
120
src/features/app-config/pages/SystemMessagePushPage.test.jsx
Normal file
@ -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(
|
||||||
|
<AppProviders>
|
||||||
|
<MemoryRouter>
|
||||||
|
<SystemMessagePushPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</AppProviders>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,15 +2,24 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
|||||||
import { PERMISSIONS } from "@/app/permissions";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
export function useAppConfigAbilities() {
|
export function useAppConfigAbilities() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canCreateVersion: can(PERMISSIONS.appVersionCreate),
|
canCreateVersion: can(PERMISSIONS.appVersionCreate),
|
||||||
canDeleteVersion: can(PERMISSIONS.appVersionDelete),
|
canDeleteVersion: can(PERMISSIONS.appVersionDelete),
|
||||||
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
||||||
canUpdateVersion: can(PERMISSIONS.appVersionUpdate),
|
canUpdateVersion: can(PERMISSIONS.appVersionUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.appConfigView),
|
canView: can(PERMISSIONS.appConfigView),
|
||||||
canViewVersion: can(PERMISSIONS.appVersionView)
|
canViewVersion: can(PERMISSIONS.appVersionView),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSystemMessagePushAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSend: can(PERMISSIONS.fullServerNoticeSend),
|
||||||
|
canView: can(PERMISSIONS.fullServerNoticeView),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,4 +49,12 @@ export const appConfigRoutes = [
|
|||||||
path: "/app-config/versions",
|
path: "/app-config/versions",
|
||||||
permission: PERMISSIONS.appVersionView,
|
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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import {
|
|||||||
appSplashScreenSchema,
|
appSplashScreenSchema,
|
||||||
exploreTabSchema,
|
exploreTabSchema,
|
||||||
h5LinkUpdateSchema,
|
h5LinkUpdateSchema,
|
||||||
|
parseSystemMessagePushUserIds,
|
||||||
|
systemMessagePushSchema,
|
||||||
} from "@/features/app-config/schema";
|
} from "@/features/app-config/schema";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
|
|
||||||
@ -75,6 +77,42 @@ describe("app config form schema", () => {
|
|||||||
).toThrow(FormValidationError);
|
).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", () => {
|
test("keeps banner display scope and delivery time fields", () => {
|
||||||
const payload = parseForm(appBannerSchema, validBannerForm);
|
const payload = parseForm(appBannerSchema, validBannerForm);
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,17 @@ import { z } from "zod";
|
|||||||
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||||
|
|
||||||
const appBannerDisplayScopes = ["home", "room", "recharge", "me"] as const;
|
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
|
const requiredH5LinkURLSchema = z
|
||||||
.string()
|
.string()
|
||||||
@ -34,6 +45,61 @@ export const exploreTabSchema = z.object({
|
|||||||
|
|
||||||
export type ExploreTabForm = z.infer<typeof exploreTabSchema>;
|
export type ExploreTabForm = z.infer<typeof exploreTabSchema>;
|
||||||
|
|
||||||
|
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<typeof systemMessagePushSchema>;
|
||||||
|
|
||||||
export const appBannerSchema = z
|
export const appBannerSchema = z
|
||||||
.object({
|
.object({
|
||||||
bannerType: z.enum(["h5", "app"]),
|
bannerType: z.enum(["h5", "app"]),
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
createCoinAdjustment,
|
createCoinAdjustment,
|
||||||
createFullServerNoticeFanout,
|
|
||||||
exportCoinSellerLedger,
|
exportCoinSellerLedger,
|
||||||
getGiftDiamondRatios,
|
getGiftDiamondRatios,
|
||||||
getWheelConfig,
|
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 () => {
|
test("coin adjustment APIs use generated admin paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -27,40 +27,6 @@ export interface GiftDiamondRatioResponse {
|
|||||||
items: GiftDiamondRatioItem[];
|
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 type WheelRewardType = "coin" | "dress" | "gift" | "prop";
|
||||||
|
|
||||||
export interface WheelTierDto {
|
export interface WheelTierDto {
|
||||||
@ -235,19 +201,6 @@ export function updateGiftDiamondRatios(payload: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFullServerNoticeFanout(
|
|
||||||
payload: FullServerNoticeFanoutPayload,
|
|
||||||
): Promise<FullServerNoticeFanoutResponse> {
|
|
||||||
const endpoint = API_ENDPOINTS.createFullServerNoticeFanout;
|
|
||||||
return apiRequest<FullServerNoticeFanoutResponse, FullServerNoticeFanoutPayload>(
|
|
||||||
apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout),
|
|
||||||
{
|
|
||||||
body: payload,
|
|
||||||
method: endpoint.method,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWheelConfig(wheelId = "default"): Promise<WheelConfigDto> {
|
export function getWheelConfig(wheelId = "default"): Promise<WheelConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.getWheelConfig;
|
const endpoint = API_ENDPOINTS.getWheelConfig;
|
||||||
return apiRequest<RawWheelConfig>(apiEndpointPath(API_OPERATIONS.getWheelConfig), {
|
return apiRequest<RawWheelConfig>(apiEndpointPath(API_OPERATIONS.getWheelConfig), {
|
||||||
@ -318,7 +271,10 @@ function normalizeWheelTier(raw: unknown): WheelTierDto {
|
|||||||
rewardCount: numberValue(item.rewardCount ?? item.reward_count),
|
rewardCount: numberValue(item.rewardCount ?? item.reward_count),
|
||||||
rewardId: stringValue(item.rewardId ?? item.reward_id),
|
rewardId: stringValue(item.rewardId ?? item.reward_id),
|
||||||
rewardType,
|
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,
|
tierId: stringValue(item.tierId ?? item.tier_id) || rewardType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -559,95 +559,6 @@
|
|||||||
font-size: 12px;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.ratioGrid,
|
.ratioGrid,
|
||||||
.ratioTable {
|
.ratioTable {
|
||||||
@ -667,10 +578,7 @@
|
|||||||
|
|
||||||
.wheelBody,
|
.wheelBody,
|
||||||
.wheelConfigGrid,
|
.wheelConfigGrid,
|
||||||
.wheelSummaryGrid,
|
.wheelSummaryGrid {
|
||||||
.noticeBody,
|
|
||||||
.noticeGrid,
|
|
||||||
.noticeContentGrid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
|
||||||
<AdminListPage>
|
|
||||||
<AdminListToolbar
|
|
||||||
actions={
|
|
||||||
abilities.canSend ? (
|
|
||||||
<Button disabled={saving} startIcon={<SendOutlined />} variant="primary" onClick={submit}>
|
|
||||||
发送
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
filters={null}
|
|
||||||
/>
|
|
||||||
<div className={styles.noticeBody}>
|
|
||||||
<section className={styles.noticePanel}>
|
|
||||||
<div className={styles.noticeHeader}>
|
|
||||||
<CampaignOutlined fontSize="small" />
|
|
||||||
<h2>全服通知</h2>
|
|
||||||
</div>
|
|
||||||
<div className={styles.noticeGrid}>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="消息类型"
|
|
||||||
size="small"
|
|
||||||
value={form.messageType}
|
|
||||||
onChange={updateField("messageType")}
|
|
||||||
>
|
|
||||||
{messageTypes.map((item) => (
|
|
||||||
<MenuItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="发送范围"
|
|
||||||
size="small"
|
|
||||||
value={form.targetScope}
|
|
||||||
onChange={updateField("targetScope")}
|
|
||||||
>
|
|
||||||
{targetScopes.map((item) => (
|
|
||||||
<MenuItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
{form.targetScope === "single_user" ? (
|
|
||||||
<TextField
|
|
||||||
label="用户 ID"
|
|
||||||
size="small"
|
|
||||||
value={form.targetUserId}
|
|
||||||
onChange={updateField("targetUserId")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{form.targetScope === "user_ids" ? (
|
|
||||||
<TextField
|
|
||||||
label="用户 ID 列表"
|
|
||||||
size="small"
|
|
||||||
value={form.userIds}
|
|
||||||
onChange={updateField("userIds")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{form.targetScope === "region" ? (
|
|
||||||
<TextField
|
|
||||||
label="区域 ID"
|
|
||||||
size="small"
|
|
||||||
value={form.regionId}
|
|
||||||
onChange={updateField("regionId")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{form.targetScope === "country" ? (
|
|
||||||
<TextField
|
|
||||||
label="国家"
|
|
||||||
size="small"
|
|
||||||
value={form.country}
|
|
||||||
onChange={updateField("country")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<TextField
|
|
||||||
label="幂等命令 ID"
|
|
||||||
size="small"
|
|
||||||
value={form.commandId}
|
|
||||||
onChange={updateField("commandId")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="批量大小"
|
|
||||||
size="small"
|
|
||||||
type="number"
|
|
||||||
value={form.batchSize}
|
|
||||||
onChange={updateField("batchSize")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.noticeContentGrid}>
|
|
||||||
<TextField
|
|
||||||
required
|
|
||||||
label="标题"
|
|
||||||
size="small"
|
|
||||||
value={form.title}
|
|
||||||
onChange={updateField("title")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
required
|
|
||||||
label="摘要"
|
|
||||||
size="small"
|
|
||||||
value={form.summary}
|
|
||||||
onChange={updateField("summary")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
minRows={4}
|
|
||||||
className={styles.noticeWideField}
|
|
||||||
label="正文"
|
|
||||||
value={form.body}
|
|
||||||
onChange={updateField("body")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.noticeGrid}>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="跳转类型"
|
|
||||||
size="small"
|
|
||||||
value={form.actionType}
|
|
||||||
onChange={updateField("actionType")}
|
|
||||||
>
|
|
||||||
{actionTypes.map((item) => (
|
|
||||||
<MenuItem key={item.value || "none"} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
label="跳转参数"
|
|
||||||
size="small"
|
|
||||||
value={form.actionParam}
|
|
||||||
onChange={updateField("actionParam")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="关联类型"
|
|
||||||
size="small"
|
|
||||||
value={form.aggregateType}
|
|
||||||
onChange={updateField("aggregateType")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="关联 ID"
|
|
||||||
size="small"
|
|
||||||
value={form.aggregateId}
|
|
||||||
onChange={updateField("aggregateId")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="事件类型"
|
|
||||||
size="small"
|
|
||||||
value={form.producerEventType}
|
|
||||||
onChange={updateField("producerEventType")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="优先级"
|
|
||||||
size="small"
|
|
||||||
type="number"
|
|
||||||
value={form.priority}
|
|
||||||
onChange={updateField("priority")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="过期时间"
|
|
||||||
size="small"
|
|
||||||
type="datetime-local"
|
|
||||||
value={form.expireAt}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
onChange={updateField("expireAt")}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
label="Metadata JSON"
|
|
||||||
value={form.metadataJson}
|
|
||||||
onChange={updateField("metadataJson")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.noticePreview}>
|
|
||||||
<h3>{form.title || "标题"}</h3>
|
|
||||||
<p>{form.summary || "摘要"}</p>
|
|
||||||
<div className={styles.noticePreviewMeta}>
|
|
||||||
<span>{messageTypes.find((item) => item.value === form.messageType)?.label}</span>
|
|
||||||
<span>{targetLabel}</span>
|
|
||||||
<span>{form.actionType || "无跳转"}</span>
|
|
||||||
</div>
|
|
||||||
{result ? (
|
|
||||||
<Alert severity={result.created ? "success" : "info"}>
|
|
||||||
{result.status} · {result.job_id} · {result.command_id}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</AdminListPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@ -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() {
|
export function useWheelAbilities() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
|||||||
@ -49,12 +49,4 @@ export const operationsRoutes = [
|
|||||||
path: "/operations/gift-diamonds",
|
path: "/operations/gift-diamonds",
|
||||||
permission: PERMISSIONS.giftDiamondView,
|
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,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1944,13 +1944,14 @@ export function useResourceGrantListPage() {
|
|||||||
!abilities.canRevokeGrant ||
|
!abilities.canRevokeGrant ||
|
||||||
!grant?.grantId ||
|
!grant?.grantId ||
|
||||||
grant.status !== "succeeded" ||
|
grant.status !== "succeeded" ||
|
||||||
grant.grantSubjectType !== "resource_group"
|
!isRevocableGrantSubject(grant.grantSubjectType)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accepted = await confirm({
|
const accepted = await confirm({
|
||||||
title: "撤销资源组赠送",
|
title: "撤销资源赠送",
|
||||||
message: "撤销后会扣回金币并移除资源组内装扮。该操作不可自动恢复。",
|
message:
|
||||||
|
"撤销后会按原发放内容扣回资源:素材扣减对应有效天数,金币扣减对应数量,最低扣至 0。该操作不可自动恢复。",
|
||||||
confirmText: "撤销",
|
confirmText: "撤销",
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
});
|
});
|
||||||
@ -1960,7 +1961,7 @@ export function useResourceGrantListPage() {
|
|||||||
setLoadingAction(`grant-revoke-${grant.grantId}`);
|
setLoadingAction(`grant-revoke-${grant.grantId}`);
|
||||||
try {
|
try {
|
||||||
await revokeResourceGrant(grant.grantId);
|
await revokeResourceGrant(grant.grantId);
|
||||||
showToast("资源组赠送已撤销", "success");
|
showToast("资源赠送已撤销", "success");
|
||||||
await result.reload();
|
await result.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "撤销失败", "error");
|
showToast(err.message || "撤销失败", "error");
|
||||||
@ -2365,6 +2366,10 @@ function grantResourceIds(form) {
|
|||||||
return form.resourceId ? [form.resourceId] : [];
|
return form.resourceId ? [form.resourceId] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRevocableGrantSubject(subjectType) {
|
||||||
|
return subjectType === "resource" || subjectType === "resource_group";
|
||||||
|
}
|
||||||
|
|
||||||
function makeCommandId(prefix) {
|
function makeCommandId(prefix) {
|
||||||
return `${prefix}-${Date.now()}`;
|
return `${prefix}-${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -174,7 +174,9 @@ export function ResourceGrantListPage() {
|
|||||||
|
|
||||||
function ResourceGrantActions({ grant, page }) {
|
function ResourceGrantActions({ grant, page }) {
|
||||||
const canRevoke =
|
const canRevoke =
|
||||||
page.abilities.canRevokeGrant && grant?.status === "succeeded" && grant?.grantSubjectType === "resource_group";
|
page.abilities.canRevokeGrant &&
|
||||||
|
grant?.status === "succeeded" &&
|
||||||
|
isRevocableGrantSubject(grant?.grantSubjectType);
|
||||||
if (!canRevoke) {
|
if (!canRevoke) {
|
||||||
return <span className={styles.meta}>-</span>;
|
return <span className={styles.meta}>-</span>;
|
||||||
}
|
}
|
||||||
@ -185,7 +187,7 @@ function ResourceGrantActions({ grant, page }) {
|
|||||||
disabled={page.loadingAction === `grant-revoke-${grant.grantId}`}
|
disabled={page.loadingAction === `grant-revoke-${grant.grantId}`}
|
||||||
label="撤销"
|
label="撤销"
|
||||||
sx={dangerActionSx}
|
sx={dangerActionSx}
|
||||||
tooltip="撤销资源组赠送"
|
tooltip="撤销资源赠送"
|
||||||
onClick={() => page.revokeGrant(grant)}
|
onClick={() => page.revokeGrant(grant)}
|
||||||
>
|
>
|
||||||
<UndoOutlined fontSize="small" />
|
<UndoOutlined fontSize="small" />
|
||||||
@ -461,6 +463,10 @@ function subjectMeta(grant) {
|
|||||||
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
|
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRevocableGrantSubject(subjectType) {
|
||||||
|
return subjectType === "resource" || subjectType === "resource_group";
|
||||||
|
}
|
||||||
|
|
||||||
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
|
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
|
||||||
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
|
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
|
||||||
if (walletAssetType) {
|
if (walletAssetType) {
|
||||||
|
|||||||
@ -15,19 +15,28 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resource grant list shows revoke only for succeeded resource group grants", () => {
|
test("resource grant list shows revoke for succeeded resource and resource group grants", () => {
|
||||||
const eligible = grantFixture({
|
const eligibleGroup = grantFixture({
|
||||||
grantId: "rgr_group_ok",
|
grantId: "rgr_group_ok",
|
||||||
grantSubjectType: "resource_group",
|
grantSubjectType: "resource_group",
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
});
|
});
|
||||||
|
const eligibleResource = grantFixture({
|
||||||
|
grantId: "rgr_resource_ok",
|
||||||
|
grantSubjectType: "resource",
|
||||||
|
status: "succeeded",
|
||||||
|
});
|
||||||
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
pageFixture({
|
pageFixture({
|
||||||
data: {
|
data: {
|
||||||
items: [
|
items: [
|
||||||
eligible,
|
eligibleGroup,
|
||||||
grantFixture({ grantId: "rgr_resource_ok", grantSubjectType: "resource", status: "succeeded" }),
|
eligibleResource,
|
||||||
grantFixture({ grantId: "rgr_group_revoked", grantSubjectType: "resource_group", status: "revoked" }),
|
grantFixture({
|
||||||
|
grantId: "rgr_group_revoked",
|
||||||
|
grantSubjectType: "resource_group",
|
||||||
|
status: "revoked",
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
@ -40,11 +49,13 @@ test("resource grant list shows revoke only for succeeded resource group grants"
|
|||||||
|
|
||||||
expect(screen.getByText("已撤销")).toBeInTheDocument();
|
expect(screen.getByText("已撤销")).toBeInTheDocument();
|
||||||
const revokeButtons = screen.getAllByLabelText("撤销");
|
const revokeButtons = screen.getAllByLabelText("撤销");
|
||||||
expect(revokeButtons).toHaveLength(1);
|
expect(revokeButtons).toHaveLength(2);
|
||||||
|
|
||||||
fireEvent.click(revokeButtons[0]);
|
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", () => {
|
test("resource grant list hides revoke without permission", () => {
|
||||||
|
|||||||
4
src/shared/api/generated/schema.d.ts
vendored
4
src/shared/api/generated/schema.d.ts
vendored
@ -7005,11 +7005,11 @@ export interface operations {
|
|||||||
sent_at_ms?: number;
|
sent_at_ms?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
/** @enum {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 */
|
/** Format: int64 */
|
||||||
target_user_id?: number;
|
target_user_id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
user_ids?: number[];
|
user_ids?: (number | string)[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user