资源撤回,系统消息
This commit is contained in:
parent
46654ac23f
commit
24e9bfc439
@ -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]*$" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = []) {
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
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,
|
||||
} 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>> {
|
||||
const endpoint = API_ENDPOINTS.listH5Links;
|
||||
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<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
|
||||
.object({
|
||||
bannerType: z.enum(["h5", "app"]),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<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> {
|
||||
const endpoint = API_ENDPOINTS.getWheelConfig;
|
||||
return apiRequest<RawWheelConfig>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
const { can } = useAuth();
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
|
||||
@ -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 <span className={styles.meta}>-</span>;
|
||||
}
|
||||
@ -185,7 +187,7 @@ function ResourceGrantActions({ grant, page }) {
|
||||
disabled={page.loadingAction === `grant-revoke-${grant.grantId}`}
|
||||
label="撤销"
|
||||
sx={dangerActionSx}
|
||||
tooltip="撤销资源组赠送"
|
||||
tooltip="撤销资源赠送"
|
||||
onClick={() => page.revokeGrant(grant)}
|
||||
>
|
||||
<UndoOutlined fontSize="small" />
|
||||
@ -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) {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
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;
|
||||
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)[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user