资源删除
This commit is contained in:
parent
53a178f11c
commit
f12b2767c0
@ -4245,6 +4245,26 @@
|
||||
],
|
||||
"x-permission": "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": {
|
||||
|
||||
@ -134,6 +134,7 @@ export const PERMISSIONS = {
|
||||
resourceView: "resource:view",
|
||||
resourceCreate: "resource:create",
|
||||
resourceUpdate: "resource:update",
|
||||
resourceDelete: "resource:delete",
|
||||
resourceShopView: "resource-shop:view",
|
||||
resourceShopUpdate: "resource-shop:update",
|
||||
resourceGroupView: "resource-group:view",
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
disableResource,
|
||||
disableResourceGroup,
|
||||
deleteGift,
|
||||
deleteResource,
|
||||
enableResourceShopItem,
|
||||
enableGift,
|
||||
enableResource,
|
||||
@ -83,6 +84,7 @@ test("resource APIs use generated admin paths", async () => {
|
||||
sortOrder: 0,
|
||||
status: "active",
|
||||
});
|
||||
await deleteResource(11);
|
||||
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
|
||||
await createGift({
|
||||
chargeAssetType: "COIN",
|
||||
@ -177,23 +179,24 @@ test("resource APIs use generated admin paths", async () => {
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1];
|
||||
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[2];
|
||||
const [giftListUrl, giftListInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [createGiftUrl, createGiftInit] = vi.mocked(fetch).mock.calls[4];
|
||||
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[5];
|
||||
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[6];
|
||||
const [createGroupUrl, createGroupInit] = vi.mocked(fetch).mock.calls[7];
|
||||
const [updateGroupUrl, updateGroupInit] = vi.mocked(fetch).mock.calls[8];
|
||||
const [enableGroupUrl, enableGroupInit] = vi.mocked(fetch).mock.calls[9];
|
||||
const [disableGroupUrl, disableGroupInit] = vi.mocked(fetch).mock.calls[10];
|
||||
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
|
||||
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
|
||||
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
||||
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14];
|
||||
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15];
|
||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
|
||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17];
|
||||
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18];
|
||||
const [revokeGrantUrl, revokeGrantInit] = vi.mocked(fetch).mock.calls[19];
|
||||
const [deleteResourceUrl, deleteResourceInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [giftListUrl, giftListInit] = vi.mocked(fetch).mock.calls[4];
|
||||
const [createGiftUrl, createGiftInit] = vi.mocked(fetch).mock.calls[5];
|
||||
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[6];
|
||||
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[7];
|
||||
const [createGroupUrl, createGroupInit] = vi.mocked(fetch).mock.calls[8];
|
||||
const [updateGroupUrl, updateGroupInit] = vi.mocked(fetch).mock.calls[9];
|
||||
const [enableGroupUrl, enableGroupInit] = vi.mocked(fetch).mock.calls[10];
|
||||
const [disableGroupUrl, disableGroupInit] = vi.mocked(fetch).mock.calls[11];
|
||||
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[12];
|
||||
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
||||
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[14];
|
||||
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[15];
|
||||
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[16];
|
||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[17];
|
||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[18];
|
||||
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("resource_type=gift");
|
||||
@ -217,6 +220,8 @@ test("resource APIs use generated admin paths", async () => {
|
||||
badgeKind: "normal",
|
||||
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("region_id=1001");
|
||||
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[]> {
|
||||
return apiRequest<ResourceDto[], ResourceMp4LayoutsPayload>("/v1/admin/resources/mp4-layouts/batch", {
|
||||
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,
|
||||
createResourceGroup,
|
||||
deleteGift as requestDeleteGift,
|
||||
deleteResource as requestDeleteResource,
|
||||
disableResourceShopItem,
|
||||
disableGift,
|
||||
disableResource,
|
||||
@ -391,6 +392,7 @@ function groupItemToForm(item) {
|
||||
|
||||
export function useResourceListPage() {
|
||||
const abilities = useResourceAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
@ -460,6 +462,14 @@ export function useResourceListPage() {
|
||||
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 () => {
|
||||
setResourceRowPatches({});
|
||||
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) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
@ -588,6 +650,7 @@ export function useResourceListPage() {
|
||||
changeResourceType: resetSetter(setResourceType, setPage),
|
||||
changeStatus: resetSetter(setStatus, setPage),
|
||||
closeAction,
|
||||
deleteResources,
|
||||
form,
|
||||
loadingAction,
|
||||
openCreateResource,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||
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 someChecked = selectedOnPageCount > 0 && !allChecked;
|
||||
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(() => {
|
||||
const itemIds = new Set(items.map((resource) => resource.resourceId));
|
||||
@ -196,9 +200,10 @@ export function ResourceListPage() {
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
disabled={!page.abilities.canUpdate || items.length === 0 || batchMp4Running}
|
||||
disabled={!canSelectResources || items.length === 0 || batchMp4Running || deletingResources}
|
||||
indeterminate={someChecked}
|
||||
size="small"
|
||||
slotProps={{ input: { "aria-label": "选择当前页资源" } }}
|
||||
onChange={(event) => toggleAllResources(event.target.checked)}
|
||||
/>
|
||||
),
|
||||
@ -208,8 +213,10 @@ export function ResourceListPage() {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selectedIdSet.has(resource.resourceId)}
|
||||
disabled={!page.abilities.canUpdate || batchMp4Running}
|
||||
inputProps={{ "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` }}
|
||||
disabled={!canSelectResources || batchMp4Running || deletingResources}
|
||||
slotProps={{
|
||||
input: { "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` },
|
||||
}}
|
||||
size="small"
|
||||
onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
|
||||
/>
|
||||
@ -257,7 +264,13 @@ export function ResourceListPage() {
|
||||
: column.key === "actions"
|
||||
? {
|
||||
...column,
|
||||
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
||||
render: (resource) => (
|
||||
<ResourceRowActions
|
||||
deleteDisabled={batchMp4Running || Boolean(mp4BatchResult)}
|
||||
page={page}
|
||||
resource={resource}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: column,
|
||||
),
|
||||
@ -394,6 +407,18 @@ export function ResourceListPage() {
|
||||
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 rows = mp4BatchResult?.rows || [];
|
||||
if (!rows.length || batchMp4Running) {
|
||||
@ -504,11 +529,24 @@ export function ResourceListPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
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 ? (
|
||||
<AdminActionIconButton
|
||||
disabled={!selectedResources.length || batchMp4Running}
|
||||
disabled={!selectedResources.length || batchMp4Running || deletingResources}
|
||||
label={
|
||||
batchMp4Running
|
||||
? 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 [recalculating, setRecalculating] = useState(false);
|
||||
const mp4AnimationSource = mp4ResourceAnimationSource(resource);
|
||||
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 () => {
|
||||
if (!mp4AnimationSource || !page.abilities.canUpdate || loading) {
|
||||
@ -927,6 +968,15 @@ function ResourceRowActions({ page, resource }) {
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||
canDelete: can(PERMISSIONS.resourceDelete),
|
||||
canDeleteGift: can(PERMISSIONS.giftDelete),
|
||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
|
||||
|
||||
@ -87,6 +87,7 @@ export const API_OPERATIONS = {
|
||||
deletePermission: "deletePermission",
|
||||
deletePopup: "deletePopup",
|
||||
deleteRechargeProduct: "deleteRechargeProduct",
|
||||
deleteResource: "deleteResource",
|
||||
deleteRole: "deleteRole",
|
||||
deleteRoom: "deleteRoom",
|
||||
deleteSplashScreen: "deleteSplashScreen",
|
||||
@ -860,6 +861,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "DELETE",
|
||||
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"];
|
||||
put: operations["updateResource"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["deleteResource"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@ -7901,6 +7901,20 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
deleteResource: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
resource_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
disableResource: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user