资源撤回,系统消息

This commit is contained in:
zhx 2026-07-06 12:43:25 +08:00
parent 46654ac23f
commit 24e9bfc439
22 changed files with 936 additions and 734 deletions

View File

@ -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]*$" }
]
}
}
}
}

View File

@ -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 = []) {

View File

@ -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 = [
{

View 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"],
});
});

View File

@ -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), {

View File

@ -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);
}
}

View 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,
};
}

View 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, 4242");
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,
},
}),
),
);
}

View File

@ -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),
};
}

View File

@ -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,
},
];

View File

@ -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, 4242 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);

View File

@ -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"]),

View File

@ -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",

View File

@ -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,
};
}

View File

@ -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);
}
}

View File

@ -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 };
}

View File

@ -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();

View File

@ -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,
},
];

View File

@ -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()}`;
}

View File

@ -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) {

View File

@ -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", () => {

View File

@ -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)[];
};
};
};