资源删除
This commit is contained in:
parent
53a178f11c
commit
f12b2767c0
@ -4245,6 +4245,26 @@
|
|||||||
],
|
],
|
||||||
"x-permission": "resource:update",
|
"x-permission": "resource:update",
|
||||||
"x-permissions": ["resource:update"]
|
"x-permissions": ["resource:update"]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "deleteResource",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "resource_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "resource:delete",
|
||||||
|
"x-permissions": ["resource:delete"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/resources/{resource_id}/disable": {
|
"/admin/resources/{resource_id}/disable": {
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export const PERMISSIONS = {
|
|||||||
resourceView: "resource:view",
|
resourceView: "resource:view",
|
||||||
resourceCreate: "resource:create",
|
resourceCreate: "resource:create",
|
||||||
resourceUpdate: "resource:update",
|
resourceUpdate: "resource:update",
|
||||||
|
resourceDelete: "resource:delete",
|
||||||
resourceShopView: "resource-shop:view",
|
resourceShopView: "resource-shop:view",
|
||||||
resourceShopUpdate: "resource-shop:update",
|
resourceShopUpdate: "resource-shop:update",
|
||||||
resourceGroupView: "resource-group:view",
|
resourceGroupView: "resource-group:view",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
disableResource,
|
disableResource,
|
||||||
disableResourceGroup,
|
disableResourceGroup,
|
||||||
deleteGift,
|
deleteGift,
|
||||||
|
deleteResource,
|
||||||
enableResourceShopItem,
|
enableResourceShopItem,
|
||||||
enableGift,
|
enableGift,
|
||||||
enableResource,
|
enableResource,
|
||||||
@ -83,6 +84,7 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
|
await deleteResource(11);
|
||||||
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
|
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
|
||||||
await createGift({
|
await createGift({
|
||||||
chargeAssetType: "COIN",
|
chargeAssetType: "COIN",
|
||||||
@ -177,23 +179,24 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1];
|
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1];
|
||||||
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[2];
|
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[2];
|
||||||
const [giftListUrl, giftListInit] = vi.mocked(fetch).mock.calls[3];
|
const [deleteResourceUrl, deleteResourceInit] = vi.mocked(fetch).mock.calls[3];
|
||||||
const [createGiftUrl, createGiftInit] = vi.mocked(fetch).mock.calls[4];
|
const [giftListUrl, giftListInit] = vi.mocked(fetch).mock.calls[4];
|
||||||
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[5];
|
const [createGiftUrl, createGiftInit] = vi.mocked(fetch).mock.calls[5];
|
||||||
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[6];
|
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[6];
|
||||||
const [createGroupUrl, createGroupInit] = vi.mocked(fetch).mock.calls[7];
|
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[7];
|
||||||
const [updateGroupUrl, updateGroupInit] = vi.mocked(fetch).mock.calls[8];
|
const [createGroupUrl, createGroupInit] = vi.mocked(fetch).mock.calls[8];
|
||||||
const [enableGroupUrl, enableGroupInit] = vi.mocked(fetch).mock.calls[9];
|
const [updateGroupUrl, updateGroupInit] = vi.mocked(fetch).mock.calls[9];
|
||||||
const [disableGroupUrl, disableGroupInit] = vi.mocked(fetch).mock.calls[10];
|
const [enableGroupUrl, enableGroupInit] = vi.mocked(fetch).mock.calls[10];
|
||||||
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
|
const [disableGroupUrl, disableGroupInit] = vi.mocked(fetch).mock.calls[11];
|
||||||
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
|
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[12];
|
||||||
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
||||||
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14];
|
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[14];
|
||||||
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15];
|
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[15];
|
||||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
|
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[16];
|
||||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17];
|
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[17];
|
||||||
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18];
|
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[18];
|
||||||
const [revokeGrantUrl, revokeGrantInit] = vi.mocked(fetch).mock.calls[19];
|
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[19];
|
||||||
|
const [revokeGrantUrl, revokeGrantInit] = vi.mocked(fetch).mock.calls[20];
|
||||||
|
|
||||||
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
|
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
|
||||||
expect(String(listUrl)).toContain("resource_type=gift");
|
expect(String(listUrl)).toContain("resource_type=gift");
|
||||||
@ -217,6 +220,8 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
badgeKind: "normal",
|
badgeKind: "normal",
|
||||||
metadataJson: '{"profile_card_layout":{"content_top":120,"content_height":1500}}',
|
metadataJson: '{"profile_card_layout":{"content_top":120,"content_height":1500}}',
|
||||||
});
|
});
|
||||||
|
expect(String(deleteResourceUrl)).toContain("/api/v1/admin/resources/11");
|
||||||
|
expect(deleteResourceInit?.method).toBe("DELETE");
|
||||||
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
||||||
expect(String(giftListUrl)).toContain("region_id=1001");
|
expect(String(giftListUrl)).toContain("region_id=1001");
|
||||||
expect(giftListInit?.method).toBe("GET");
|
expect(giftListInit?.method).toBe("GET");
|
||||||
|
|||||||
@ -364,6 +364,13 @@ export function updateResource(resourceId: EntityId, payload: ResourcePayload):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteResource(resourceId: EntityId): Promise<ResourceDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteResource;
|
||||||
|
return apiRequest<ResourceDto>(apiEndpointPath(API_OPERATIONS.deleteResource, { resource_id: resourceId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function updateResourceMp4Layouts(items: ResourceMp4LayoutUpdatePayload[]): Promise<ResourceDto[]> {
|
export function updateResourceMp4Layouts(items: ResourceMp4LayoutUpdatePayload[]): Promise<ResourceDto[]> {
|
||||||
return apiRequest<ResourceDto[], ResourceMp4LayoutsPayload>("/v1/admin/resources/mp4-layouts/batch", {
|
return apiRequest<ResourceDto[], ResourceMp4LayoutsPayload>("/v1/admin/resources/mp4-layouts/batch", {
|
||||||
body: { items },
|
body: { items },
|
||||||
|
|||||||
158
src/features/resources/hooks/useResourceListPageDelete.test.jsx
Normal file
158
src/features/resources/hooks/useResourceListPageDelete.test.jsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { useResourceListPage } from "./useResourcePages.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
can: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
deleteResource: vi.fn(),
|
||||||
|
reload: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
usePaginatedQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
||||||
|
useAuth: () => ({ can: mocks.can }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
|
||||||
|
usePaginatedQuery: mocks.usePaginatedQuery,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||||
|
useToast: () => ({ showToast: mocks.showToast }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/resources/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal()),
|
||||||
|
deleteResource: mocks.deleteResource,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.can.mockReturnValue(true);
|
||||||
|
mocks.confirm.mockResolvedValue(true);
|
||||||
|
mocks.deleteResource.mockResolvedValue({});
|
||||||
|
mocks.reload.mockResolvedValue(undefined);
|
||||||
|
mocks.usePaginatedQuery.mockReturnValue({
|
||||||
|
data: resourcePage(),
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
reload: mocks.reload,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleting a resource hides only that row without reloading the list", async () => {
|
||||||
|
const { result } = renderHook(() => useResourceListPage());
|
||||||
|
|
||||||
|
let deletedResourceIds = [];
|
||||||
|
await act(async () => {
|
||||||
|
deletedResourceIds = await result.current.deleteResources([resourceFixture({ resourceId: 11 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deletedResourceIds).toEqual([11]);
|
||||||
|
expect(mocks.deleteResource).toHaveBeenCalledWith(11);
|
||||||
|
expect(mocks.reload).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.data.items.map((resource) => resource.resourceId)).toEqual([12]);
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith("资源已删除", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch resource delete hides successful rows without reloading the list", async () => {
|
||||||
|
const { result } = renderHook(() => useResourceListPage());
|
||||||
|
|
||||||
|
let deletedResourceIds = [];
|
||||||
|
await act(async () => {
|
||||||
|
deletedResourceIds = await result.current.deleteResources([
|
||||||
|
resourceFixture({ resourceId: 11 }),
|
||||||
|
resourceFixture({ resourceId: 12 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deletedResourceIds).toEqual([11, 12]);
|
||||||
|
expect(mocks.deleteResource).toHaveBeenNthCalledWith(1, 11);
|
||||||
|
expect(mocks.deleteResource).toHaveBeenNthCalledWith(2, 12);
|
||||||
|
expect(mocks.reload).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.data.items).toEqual([]);
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith("已删除 2 个资源", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch resource delete continues after an item fails and hides successful rows", async () => {
|
||||||
|
mocks.usePaginatedQuery.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
resourceFixture({ resourceId: 11 }),
|
||||||
|
resourceFixture({ resourceId: 12 }),
|
||||||
|
resourceFixture({ resourceId: 13 }),
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
reload: mocks.reload,
|
||||||
|
});
|
||||||
|
mocks.deleteResource.mockImplementation((resourceId) =>
|
||||||
|
resourceId === 12 ? Promise.reject(new Error("delete failed")) : Promise.resolve({}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useResourceListPage());
|
||||||
|
|
||||||
|
let deletedResourceIds = [];
|
||||||
|
await act(async () => {
|
||||||
|
deletedResourceIds = await result.current.deleteResources([
|
||||||
|
resourceFixture({ resourceId: 11 }),
|
||||||
|
resourceFixture({ resourceId: 12 }),
|
||||||
|
resourceFixture({ resourceId: 13 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deletedResourceIds).toEqual([11, 13]);
|
||||||
|
expect(mocks.deleteResource).toHaveBeenNthCalledWith(1, 11);
|
||||||
|
expect(mocks.deleteResource).toHaveBeenNthCalledWith(2, 12);
|
||||||
|
expect(mocks.deleteResource).toHaveBeenNthCalledWith(3, 13);
|
||||||
|
expect(result.current.data.items.map((resource) => resource.resourceId)).toEqual([12]);
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith("已删除 2 个资源,1 个删除失败:delete failed", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed resource delete keeps the row visible", async () => {
|
||||||
|
mocks.deleteResource.mockRejectedValueOnce(new Error("delete failed"));
|
||||||
|
const { result } = renderHook(() => useResourceListPage());
|
||||||
|
|
||||||
|
let deletedResourceIds = [];
|
||||||
|
await act(async () => {
|
||||||
|
deletedResourceIds = await result.current.deleteResources([resourceFixture({ resourceId: 11 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deletedResourceIds).toEqual([]);
|
||||||
|
expect(mocks.reload).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.data.items.map((resource) => resource.resourceId)).toEqual([11, 12]);
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith("delete failed", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
function resourcePage() {
|
||||||
|
return {
|
||||||
|
items: [resourceFixture({ resourceId: 11, name: "Rose" }), resourceFixture({ resourceId: 12, name: "Ring" })],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
coinPrice: 10,
|
||||||
|
name: "Rose",
|
||||||
|
resourceCode: "gift_rose",
|
||||||
|
resourceId: 11,
|
||||||
|
resourceType: "gift",
|
||||||
|
status: "active",
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
createResourceGroup,
|
createResourceGroup,
|
||||||
deleteGift as requestDeleteGift,
|
deleteGift as requestDeleteGift,
|
||||||
|
deleteResource as requestDeleteResource,
|
||||||
disableResourceShopItem,
|
disableResourceShopItem,
|
||||||
disableGift,
|
disableGift,
|
||||||
disableResource,
|
disableResource,
|
||||||
@ -391,6 +392,7 @@ function groupItemToForm(item) {
|
|||||||
|
|
||||||
export function useResourceListPage() {
|
export function useResourceListPage() {
|
||||||
const abilities = useResourceAbilities();
|
const abilities = useResourceAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
@ -460,6 +462,14 @@ export function useResourceListPage() {
|
|||||||
setHiddenResourceIds((current) => (current.includes(resourceId) ? current : [...current, resourceId]));
|
setHiddenResourceIds((current) => (current.includes(resourceId) ? current : [...current, resourceId]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hideResourceRows = (resourceIds) => {
|
||||||
|
const nextIds = resourceIds.filter(Boolean);
|
||||||
|
if (!nextIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHiddenResourceIds((current) => Array.from(new Set([...current, ...nextIds])));
|
||||||
|
};
|
||||||
|
|
||||||
const reloadResources = async () => {
|
const reloadResources = async () => {
|
||||||
setResourceRowPatches({});
|
setResourceRowPatches({});
|
||||||
setHiddenResourceIds([]);
|
setHiddenResourceIds([]);
|
||||||
@ -562,6 +572,58 @@ export function useResourceListPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteResources = async (resources = []) => {
|
||||||
|
const targetResources = resources.filter((resource) => resource?.resourceId);
|
||||||
|
if (!abilities.canDelete || !targetResources.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const title = targetResources.length > 1 ? "批量删除资源" : "删除资源";
|
||||||
|
const message =
|
||||||
|
targetResources.length > 1
|
||||||
|
? `确认删除选中的 ${targetResources.length} 个资源?删除成功后资源会从资源列表移除。`
|
||||||
|
: `确认删除资源 ${targetResources[0].name || targetResources[0].resourceCode || targetResources[0].resourceId}?删除成功后资源会从资源列表移除。`;
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "删除",
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
tone: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedResourceIds = [];
|
||||||
|
const failedDeletes = [];
|
||||||
|
setLoadingAction(
|
||||||
|
targetResources.length > 1 ? "resource-delete-batch" : `resource-delete-${targetResources[0].resourceId}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
for (const resource of targetResources) {
|
||||||
|
try {
|
||||||
|
await requestDeleteResource(resource.resourceId);
|
||||||
|
deletedResourceIds.push(resource.resourceId);
|
||||||
|
} catch (err) {
|
||||||
|
failedDeletes.push({ err, resource });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideResourceRows(deletedResourceIds);
|
||||||
|
if (failedDeletes.length) {
|
||||||
|
const firstError = failedDeletes[0].err?.message || "删除资源失败";
|
||||||
|
showToast(
|
||||||
|
deletedResourceIds.length
|
||||||
|
? `已删除 ${deletedResourceIds.length} 个资源,${failedDeletes.length} 个删除失败:${firstError}`
|
||||||
|
: firstError,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
return deletedResourceIds;
|
||||||
|
}
|
||||||
|
showToast(targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除", "success");
|
||||||
|
return deletedResourceIds;
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runAction = async (action, successMessage, submitter) => {
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
setLoadingAction(action);
|
setLoadingAction(action);
|
||||||
try {
|
try {
|
||||||
@ -588,6 +650,7 @@ export function useResourceListPage() {
|
|||||||
changeResourceType: resetSetter(setResourceType, setPage),
|
changeResourceType: resetSetter(setResourceType, setPage),
|
||||||
changeStatus: resetSetter(setStatus, setPage),
|
changeStatus: resetSetter(setStatus, setPage),
|
||||||
closeAction,
|
closeAction,
|
||||||
|
deleteResources,
|
||||||
form,
|
form,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
openCreateResource,
|
openCreateResource,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||||
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||||
@ -163,6 +164,9 @@ export function ResourceListPage() {
|
|||||||
const selectedOnPageCount = items.filter((resource) => selectedIdSet.has(resource.resourceId)).length;
|
const selectedOnPageCount = items.filter((resource) => selectedIdSet.has(resource.resourceId)).length;
|
||||||
const someChecked = selectedOnPageCount > 0 && !allChecked;
|
const someChecked = selectedOnPageCount > 0 && !allChecked;
|
||||||
const batchMp4Running = batchMp4Progress.running;
|
const batchMp4Running = batchMp4Progress.running;
|
||||||
|
const deletingResources = page.loadingAction.startsWith("resource-delete-");
|
||||||
|
const deletingBatch = page.loadingAction === "resource-delete-batch";
|
||||||
|
const canSelectResources = page.abilities.canUpdate || page.abilities.canDelete;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const itemIds = new Set(items.map((resource) => resource.resourceId));
|
const itemIds = new Set(items.map((resource) => resource.resourceId));
|
||||||
@ -196,9 +200,10 @@ export function ResourceListPage() {
|
|||||||
header: (
|
header: (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allChecked}
|
checked={allChecked}
|
||||||
disabled={!page.abilities.canUpdate || items.length === 0 || batchMp4Running}
|
disabled={!canSelectResources || items.length === 0 || batchMp4Running || deletingResources}
|
||||||
indeterminate={someChecked}
|
indeterminate={someChecked}
|
||||||
size="small"
|
size="small"
|
||||||
|
slotProps={{ input: { "aria-label": "选择当前页资源" } }}
|
||||||
onChange={(event) => toggleAllResources(event.target.checked)}
|
onChange={(event) => toggleAllResources(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -208,8 +213,10 @@ export function ResourceListPage() {
|
|||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIdSet.has(resource.resourceId)}
|
checked={selectedIdSet.has(resource.resourceId)}
|
||||||
disabled={!page.abilities.canUpdate || batchMp4Running}
|
disabled={!canSelectResources || batchMp4Running || deletingResources}
|
||||||
inputProps={{ "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` }}
|
slotProps={{
|
||||||
|
input: { "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` },
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
|
onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
|
||||||
/>
|
/>
|
||||||
@ -257,7 +264,13 @@ export function ResourceListPage() {
|
|||||||
: column.key === "actions"
|
: column.key === "actions"
|
||||||
? {
|
? {
|
||||||
...column,
|
...column,
|
||||||
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
render: (resource) => (
|
||||||
|
<ResourceRowActions
|
||||||
|
deleteDisabled={batchMp4Running || Boolean(mp4BatchResult)}
|
||||||
|
page={page}
|
||||||
|
resource={resource}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: column,
|
: column,
|
||||||
),
|
),
|
||||||
@ -394,6 +407,18 @@ export function ResourceListPage() {
|
|||||||
setMp4BatchResult({ ignoredCount, layoutCounts, rows: updates });
|
setMp4BatchResult({ ignoredCount, layoutCounts, rows: updates });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteSelectedResources = async () => {
|
||||||
|
if (!page.abilities.canDelete || !selectedResources.length || deletingResources) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deletedResourceIds = await page.deleteResources(selectedResources);
|
||||||
|
if (!deletedResourceIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deletedIdSet = new Set(deletedResourceIds);
|
||||||
|
setSelectedResourceIds((current) => current.filter((resourceId) => !deletedIdSet.has(resourceId)));
|
||||||
|
};
|
||||||
|
|
||||||
const writeMp4BatchResult = async () => {
|
const writeMp4BatchResult = async () => {
|
||||||
const rows = mp4BatchResult?.rows || [];
|
const rows = mp4BatchResult?.rows || [];
|
||||||
if (!rows.length || batchMp4Running) {
|
if (!rows.length || batchMp4Running) {
|
||||||
@ -504,11 +529,24 @@ export function ResourceListPage() {
|
|||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreate || page.abilities.canUpdate ? (
|
page.abilities.canCreate || page.abilities.canUpdate || page.abilities.canDelete ? (
|
||||||
<>
|
<>
|
||||||
|
{page.abilities.canDelete ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!selectedResources.length || deletingResources || batchMp4Running}
|
||||||
|
label={deletingBatch ? "批量删除中" : `批量删除资源(${selectedResources.length})`}
|
||||||
|
onClick={deleteSelectedResources}
|
||||||
|
>
|
||||||
|
{deletingBatch ? (
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
) : (
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
)}
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
{page.abilities.canUpdate ? (
|
{page.abilities.canUpdate ? (
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
disabled={!selectedResources.length || batchMp4Running}
|
disabled={!selectedResources.length || batchMp4Running || deletingResources}
|
||||||
label={
|
label={
|
||||||
batchMp4Running
|
batchMp4Running
|
||||||
? batchMp4Progress.label || "批量重算 MP4"
|
? batchMp4Progress.label || "批量重算 MP4"
|
||||||
@ -885,11 +923,14 @@ function Mp4BatchResultDialog({ loading, onClose, onSubmit, open, result }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceRowActions({ page, resource }) {
|
function ResourceRowActions({ deleteDisabled = false, page, resource }) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [recalculating, setRecalculating] = useState(false);
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
const mp4AnimationSource = mp4ResourceAnimationSource(resource);
|
const mp4AnimationSource = mp4ResourceAnimationSource(resource);
|
||||||
const loading = recalculating || page.loadingAction === `resource-mp4-layout-${resource.resourceId}`;
|
const loading = recalculating || page.loadingAction === `resource-mp4-layout-${resource.resourceId}`;
|
||||||
|
const deleting = page.loadingAction === `resource-delete-${resource.resourceId}`;
|
||||||
|
const deletingBatch = page.loadingAction === "resource-delete-batch";
|
||||||
|
const deletingResources = page.loadingAction.startsWith("resource-delete-");
|
||||||
|
|
||||||
const recalculateMp4Layout = async () => {
|
const recalculateMp4Layout = async () => {
|
||||||
if (!mp4AnimationSource || !page.abilities.canUpdate || loading) {
|
if (!mp4AnimationSource || !page.abilities.canUpdate || loading) {
|
||||||
@ -927,6 +968,15 @@ function ResourceRowActions({ page, resource }) {
|
|||||||
>
|
>
|
||||||
<EditOutlined fontSize="small" />
|
<EditOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
|
{page.abilities.canDelete ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={deleteDisabled || deletingResources || deletingBatch}
|
||||||
|
label={deleting ? "删除中" : "删除资源"}
|
||||||
|
onClick={() => page.deleteResources([resource])}
|
||||||
|
>
|
||||||
|
{deleting ? <CircularProgress size={16} /> : <DeleteOutlineOutlined fontSize="small" />}
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
150
src/features/resources/pages/ResourceListPage.test.jsx
Normal file
150
src/features/resources/pages/ResourceListPage.test.jsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { ResourceListPage } from "./ResourceListPage.jsx";
|
||||||
|
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
deleteResources: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/resources/hooks/useResourcePages.js", () => ({
|
||||||
|
useResourceListPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||||
|
useToast: () => ({ showToast: mocks.showToast }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
mocks.deleteResources.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource list calls row delete action with the target resource", async () => {
|
||||||
|
const page = pageFixture();
|
||||||
|
vi.mocked(useResourceListPage).mockReturnValue(page);
|
||||||
|
|
||||||
|
render(<ResourceListPage />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "删除资源" })[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.deleteResources).toHaveBeenCalledWith([page.data.items[0]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource list batch delete uses selected resources and clears selected rows after success", async () => {
|
||||||
|
const page = pageFixture();
|
||||||
|
mocks.deleteResources.mockResolvedValue([11, 12]);
|
||||||
|
vi.mocked(useResourceListPage).mockReturnValue(page);
|
||||||
|
|
||||||
|
render(<ResourceListPage />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("选择 Rose"));
|
||||||
|
fireEvent.click(screen.getByLabelText("选择 Ring"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "批量删除资源(2)" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.deleteResources).toHaveBeenCalledWith(page.data.items);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("选择 Rose")).not.toBeChecked();
|
||||||
|
expect(screen.getByLabelText("选择 Ring")).not.toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource list hides delete actions without delete permission", () => {
|
||||||
|
vi.mocked(useResourceListPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: {
|
||||||
|
canCreate: false,
|
||||||
|
canDelete: false,
|
||||||
|
canUpdate: true,
|
||||||
|
canUpload: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ResourceListPage />);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(/批量删除资源/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("删除资源")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource list disables other delete actions while a resource delete is running", () => {
|
||||||
|
vi.mocked(useResourceListPage).mockReturnValue(pageFixture({ loadingAction: "resource-delete-11" }));
|
||||||
|
|
||||||
|
render(<ResourceListPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "删除中" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "删除资源" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "批量删除资源(0)" })).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText("选择 Rose")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pageFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
abilities: {
|
||||||
|
canCreate: true,
|
||||||
|
canDelete: true,
|
||||||
|
canUpdate: true,
|
||||||
|
canUpload: true,
|
||||||
|
},
|
||||||
|
activeAction: "",
|
||||||
|
changeQuery: vi.fn(),
|
||||||
|
changeResourceType: vi.fn(),
|
||||||
|
changeStatus: vi.fn(),
|
||||||
|
closeAction: vi.fn(),
|
||||||
|
data: {
|
||||||
|
items: [resourceFixture({ resourceId: 11, name: "Rose" }), resourceFixture({ resourceId: 12, name: "Ring" })],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
deleteResources: mocks.deleteResources,
|
||||||
|
error: null,
|
||||||
|
form: {},
|
||||||
|
loading: false,
|
||||||
|
loadingAction: "",
|
||||||
|
openCreateResource: vi.fn(),
|
||||||
|
openEditResource: vi.fn(),
|
||||||
|
page: 1,
|
||||||
|
query: "",
|
||||||
|
reload: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
|
resourceType: "",
|
||||||
|
selectedResource: null,
|
||||||
|
setForm: vi.fn(),
|
||||||
|
setPage: vi.fn(),
|
||||||
|
status: "",
|
||||||
|
submitResource: vi.fn(),
|
||||||
|
toggleManagerGrant: vi.fn(),
|
||||||
|
toggleResource: vi.fn(),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
assetUrl: "https://media.example.com/resource.png",
|
||||||
|
coinPrice: 10,
|
||||||
|
managerGrantEnabled: false,
|
||||||
|
name: "Rose",
|
||||||
|
previewUrl: "https://media.example.com/cover.png",
|
||||||
|
priceType: "coin",
|
||||||
|
resourceCode: "gift_rose",
|
||||||
|
resourceId: 11,
|
||||||
|
resourceType: "gift",
|
||||||
|
status: "active",
|
||||||
|
updatedAtMs: 1782287714973,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ export function useResourceAbilities() {
|
|||||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||||
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
||||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||||
|
canDelete: can(PERMISSIONS.resourceDelete),
|
||||||
canDeleteGift: can(PERMISSIONS.giftDelete),
|
canDeleteGift: can(PERMISSIONS.giftDelete),
|
||||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||||
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
|
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
|
||||||
|
|||||||
@ -87,6 +87,7 @@ export const API_OPERATIONS = {
|
|||||||
deletePermission: "deletePermission",
|
deletePermission: "deletePermission",
|
||||||
deletePopup: "deletePopup",
|
deletePopup: "deletePopup",
|
||||||
deleteRechargeProduct: "deleteRechargeProduct",
|
deleteRechargeProduct: "deleteRechargeProduct",
|
||||||
|
deleteResource: "deleteResource",
|
||||||
deleteRole: "deleteRole",
|
deleteRole: "deleteRole",
|
||||||
deleteRoom: "deleteRoom",
|
deleteRoom: "deleteRoom",
|
||||||
deleteSplashScreen: "deleteSplashScreen",
|
deleteSplashScreen: "deleteSplashScreen",
|
||||||
@ -860,6 +861,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "payment-product:delete",
|
permission: "payment-product:delete",
|
||||||
permissions: ["payment-product:delete"]
|
permissions: ["payment-product:delete"]
|
||||||
},
|
},
|
||||||
|
deleteResource: {
|
||||||
|
method: "DELETE",
|
||||||
|
operationId: API_OPERATIONS.deleteResource,
|
||||||
|
path: "/v1/admin/resources/{resource_id}",
|
||||||
|
permission: "resource:delete",
|
||||||
|
permissions: ["resource:delete"]
|
||||||
|
},
|
||||||
deleteRole: {
|
deleteRole: {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
operationId: API_OPERATIONS.deleteRole,
|
operationId: API_OPERATIONS.deleteRole,
|
||||||
|
|||||||
16
src/shared/api/generated/schema.d.ts
vendored
16
src/shared/api/generated/schema.d.ts
vendored
@ -2670,7 +2670,7 @@ export interface paths {
|
|||||||
get: operations["getResource"];
|
get: operations["getResource"];
|
||||||
put: operations["updateResource"];
|
put: operations["updateResource"];
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete: operations["deleteResource"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
@ -7901,6 +7901,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteResource: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
resource_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
disableResource: {
|
disableResource: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user