资源删除

This commit is contained in:
zhx 2026-07-05 14:08:06 +08:00
parent 53a178f11c
commit f12b2767c0
11 changed files with 502 additions and 25 deletions

View File

@ -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": {

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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