From bc9484c60d4ebff729c83f6668e21860d51fd6f1 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 24 Jun 2026 19:49:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=92=A4=E9=94=80=E8=B5=84=E6=BA=90=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 22 ++++ src/app/permissions.ts | 1 + src/features/resources/api.test.ts | 5 + src/features/resources/api.ts | 10 ++ src/features/resources/constants.js | 2 + .../resources/hooks/useResourcePages.js | 33 +++++ .../resources/pages/ResourceGrantListPage.jsx | 114 ++++++++++++---- .../pages/ResourceGrantListPage.test.jsx | 124 ++++++++++++++++++ src/features/resources/permissions.js | 1 + src/shared/api/generated/endpoints.ts | 8 ++ src/shared/api/generated/schema.d.ts | 30 +++++ 11 files changed, 323 insertions(+), 27 deletions(-) create mode 100644 src/features/resources/pages/ResourceGrantListPage.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index e118b1a..2fbd957 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2857,6 +2857,28 @@ "x-permissions": ["resource-grant:create"] } }, + "/admin/resource-grants/{grant_id}/revoke": { + "post": { + "operationId": "revokeResourceGrant", + "parameters": [ + { + "in": "path", + "name": "grant_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:revoke", + "x-permissions": ["resource-grant:revoke"] + } + }, "/admin/resource-grants/resource": { "post": { "operationId": "grantResource", diff --git a/src/app/permissions.ts b/src/app/permissions.ts index be16c1f..6b73219 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -120,6 +120,7 @@ export const PERMISSIONS = { resourceGroupUpdate: "resource-group:update", resourceGrantView: "resource-grant:view", resourceGrantCreate: "resource-grant:create", + resourceGrantRevoke: "resource-grant:revoke", giftView: "gift:view", giftCreate: "gift:create", giftUpdate: "gift:update", diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 57f7e22..0430b11 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -26,6 +26,7 @@ import { listResourceShopPurchaseOrders, listResources, lookupResourceGrantTarget, + revokeResourceGrant, updateGift, updateGiftTypes, updateResource, @@ -171,6 +172,7 @@ test("resource APIs use generated admin paths", async () => { reason: "manual", targetUserId: "1001", }); + await revokeResourceGrant("rgr_test"); const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0]; const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1]; @@ -191,6 +193,7 @@ test("resource APIs use generated admin paths", async () => { 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]; expect(String(listUrl)).toContain("/api/v1/admin/resources?"); expect(String(listUrl)).toContain("resource_type=gift"); @@ -262,6 +265,8 @@ test("resource APIs use generated admin paths", async () => { expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group"); expect(grantGroupInit?.method).toBe("POST"); expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: "1001" }); + expect(String(revokeGrantUrl)).toContain("/api/v1/admin/resource-grants/rgr_test/revoke"); + expect(revokeGrantInit?.method).toBe("POST"); }); test("resource identity auto grant config APIs use generated admin paths", async () => { diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index cb14ded..8a8b1b2 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -278,6 +278,9 @@ export interface ResourceGrantDto { items?: ResourceGrantItemDto[]; operatorUserId?: number; reason?: string; + revokedAtMs?: number; + revokedByUserId?: string; + revokeReason?: string; status?: string; targetUser?: ResourceGrantUserDto; targetUserId?: string; @@ -620,6 +623,13 @@ export function grantResourceGroup(payload: GrantResourceGroupPayload): Promise< }); } +export function revokeResourceGrant(grantId: string): Promise { + const endpoint = API_ENDPOINTS.revokeResourceGrant; + return apiRequest(apiEndpointPath(API_OPERATIONS.revokeResourceGrant, { grant_id: grantId }), { + method: endpoint.method, + }); +} + type RawResourceIdentityAutoGrantItem = ResourceIdentityAutoGrantItemDto & { created_at_ms?: unknown; identity_type?: unknown; diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index edd71f3..c448119 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -33,10 +33,12 @@ export const cpRelationTypeLabels = Object.fromEntries(cpRelationTypeOptions); export const resourceGrantStatusFilters = [ ["", "全部状态"], ["succeeded", "成功"], + ["revoked", "已撤销"], ]; export const resourceGrantStatusLabels = { succeeded: "成功", + revoked: "已撤销", }; export const resourceGrantSubjectLabels = { diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 007ea2f..5ca1320 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -31,6 +31,7 @@ import { listResourceShopPurchaseOrders, listResources, lookupResourceGrantTarget, + revokeResourceGrant, updateGift, updateGiftTypes, updateResource, @@ -1539,6 +1540,7 @@ export function useEmojiPackListPage() { export function useResourceGrantListPage() { const abilities = useResourceAbilities(); + const confirm = useConfirm(); const { showToast } = useToast(); const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); @@ -1644,6 +1646,36 @@ export function useResourceGrantListPage() { } }; + const revokeGrant = async (grant) => { + if ( + !abilities.canRevokeGrant || + !grant?.grantId || + grant.status !== "succeeded" || + grant.grantSubjectType !== "resource_group" + ) { + return; + } + const accepted = await confirm({ + title: "撤销资源组赠送", + message: "撤销后会扣回金币并移除资源组内装扮。该操作不可自动恢复。", + confirmText: "撤销", + tone: "danger", + }); + if (!accepted) { + return; + } + setLoadingAction(`grant-revoke-${grant.grantId}`); + try { + await revokeResourceGrant(grant.grantId); + showToast("资源组赠送已撤销", "success"); + await result.reload(); + } catch (err) { + showToast(err.message || "撤销失败", "error"); + } finally { + setLoadingAction(""); + } + }; + const resetFilters = () => { setQuery(""); setStatus(""); @@ -1665,6 +1697,7 @@ export function useResourceGrantListPage() { optionsLoading, resourceOptions, resetFilters, + revokeGrant, setForm, status, submitGrant, diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index fa8040e..47b3a4f 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -1,11 +1,18 @@ import Add from "@mui/icons-material/Add"; +import UndoOutlined from "@mui/icons-material/UndoOutlined"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; -import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx"; +import { + AdminActionIconButton, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, +} from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx"; @@ -63,7 +70,7 @@ const grantColumns = [ label: "状态", width: "minmax(100px, 0.55fr)", render: (grant) => ( - + {resourceGrantStatusLabels[grant.status] || grant.status || "-"} @@ -87,30 +94,38 @@ export function ResourceGrantListPage() { const page = useResourceGrantListPage(); const items = page.data.items || []; const total = page.data.total || 0; - const columns = grantColumns.map((column) => { - if (column.key === "target") { - return { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索用户 ID", - value: page.query, - onChange: page.changeQuery, - }), - }; - } - if (column.key === "status") { - return { - ...column, - filter: createOptionsColumnFilter({ - options: resourceGrantStatusFilters, - placeholder: "搜索状态", - value: page.status, - onChange: page.changeStatus, - }), - }; - } - return column; - }); + const columns = [ + ...grantColumns.map((column) => { + if (column.key === "target") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索用户 ID", + value: page.query, + onChange: page.changeQuery, + }), + }; + } + if (column.key === "status") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: resourceGrantStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + }; + } + return column; + }), + { + key: "actions", + label: "操作", + width: "minmax(88px, 0.45fr)", + render: (grant) => , + }, + ]; return ( @@ -128,7 +143,7 @@ export function ResourceGrantListPage() { 0 ? { @@ -161,6 +176,28 @@ export function ResourceGrantListPage() { ); } +function ResourceGrantActions({ grant, page }) { + const canRevoke = + page.abilities.canRevokeGrant && grant?.status === "succeeded" && grant?.grantSubjectType === "resource_group"; + if (!canRevoke) { + return -; + } + return ( + + page.revokeGrant(grant)} + > + + + + ); +} + function ResourceGrantDialog({ disabled, form, @@ -281,6 +318,29 @@ function ResourceGrantDialog({ ); } +function grantStatusTone(status) { + if (status === "revoked") { + return "stopped"; + } + if (status === "failed") { + return "danger"; + } + if (status === "pending") { + return "warning"; + } + return status || "stopped"; +} + +const dangerActionSx = { + borderColor: "var(--danger-border)", + color: "var(--danger)", + "&:hover": { + borderColor: "var(--danger-border-strong)", + backgroundColor: "var(--danger-surface)", + color: "var(--danger)", + }, +}; + function GrantItems({ grant }) { const items = grant.items || []; if (!items.length) { diff --git a/src/features/resources/pages/ResourceGrantListPage.test.jsx b/src/features/resources/pages/ResourceGrantListPage.test.jsx new file mode 100644 index 0000000..4df88ca --- /dev/null +++ b/src/features/resources/pages/ResourceGrantListPage.test.jsx @@ -0,0 +1,124 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { ResourceGrantListPage } from "./ResourceGrantListPage.jsx"; +import { useResourceGrantListPage } from "@/features/resources/hooks/useResourcePages.js"; + +const mocks = vi.hoisted(() => ({ + revokeGrant: vi.fn(), +})); + +vi.mock("@/features/resources/hooks/useResourcePages.js", () => ({ + useResourceGrantListPage: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("resource grant list shows revoke only for succeeded resource group grants", () => { + const eligible = grantFixture({ + grantId: "rgr_group_ok", + grantSubjectType: "resource_group", + status: "succeeded", + }); + vi.mocked(useResourceGrantListPage).mockReturnValue( + pageFixture({ + data: { + items: [ + eligible, + grantFixture({ grantId: "rgr_resource_ok", grantSubjectType: "resource", status: "succeeded" }), + grantFixture({ grantId: "rgr_group_revoked", grantSubjectType: "resource_group", status: "revoked" }), + ], + page: 1, + pageSize: 50, + total: 3, + }, + }), + ); + + render(); + + expect(screen.getByText("已撤销")).toBeInTheDocument(); + const revokeButtons = screen.getAllByLabelText("撤销"); + expect(revokeButtons).toHaveLength(1); + + fireEvent.click(revokeButtons[0]); + + expect(mocks.revokeGrant).toHaveBeenCalledWith(eligible); +}); + +test("resource grant list hides revoke without permission", () => { + vi.mocked(useResourceGrantListPage).mockReturnValue( + pageFixture({ + abilities: { canCreateGrant: false, canRevokeGrant: false }, + data: { + items: [grantFixture({ grantSubjectType: "resource_group", status: "succeeded" })], + page: 1, + pageSize: 50, + total: 1, + }, + }), + ); + + render(); + + expect(screen.queryByLabelText("撤销")).not.toBeInTheDocument(); +}); + +function pageFixture(patch = {}) { + return { + abilities: { + canCreateGrant: true, + canRevokeGrant: true, + }, + activeAction: "", + changeQuery: vi.fn(), + changeStatus: vi.fn(), + closeAction: vi.fn(), + data: { items: [], page: 1, pageSize: 50, total: 0 }, + error: null, + form: { + durationDays: "0", + giftIds: [], + groupId: "", + reason: "", + resourceIds: [], + subjectType: "resource", + targetUserId: "", + }, + giftOptions: [], + giftTypeOptions: [], + groupOptions: [], + loading: false, + loadingAction: "", + openCreateGrant: vi.fn(), + optionsLoading: false, + page: 1, + query: "", + reload: vi.fn(), + resetFilters: vi.fn(), + resourceOptions: [], + revokeGrant: mocks.revokeGrant, + setForm: vi.fn(), + setPage: vi.fn(), + status: "", + submitGrant: vi.fn(), + ...patch, + }; +} + +function grantFixture(patch = {}) { + return { + commandId: "resource-group-grant-test", + createdAtMs: 1782287714973, + grantId: "rgr_test", + grantSubjectId: "22", + grantSubjectType: "resource_group", + items: [{ grantItemId: 1, quantity: 100, resourceId: 0 }], + operatorUserId: 7, + reason: "manual", + status: "succeeded", + targetUserId: "318705991371722752", + ...patch, + }; +} diff --git a/src/features/resources/permissions.js b/src/features/resources/permissions.js index 40b82c7..53a5e20 100644 --- a/src/features/resources/permissions.js +++ b/src/features/resources/permissions.js @@ -9,6 +9,7 @@ export function useResourceAbilities() { canCreateGift: can(PERMISSIONS.giftCreate), canCreateGrant: can(PERMISSIONS.resourceGrantCreate), canCreateGroup: can(PERMISSIONS.resourceGroupCreate), + canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke), canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate), canDeleteGift: can(PERMISSIONS.giftDelete), canStatusGift: can(PERMISSIONS.giftStatus), diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 30c4810..fa92c05 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -221,6 +221,7 @@ export const API_OPERATIONS = { retryRedPacketRefund: "retryRedPacketRefund", retryRoomRpsSettlement: "retryRoomRpsSettlement", retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement", + revokeResourceGrant: "revokeResourceGrant", search: "search", setAgencyJoinEnabled: "setAgencyJoinEnabled", setBDLeaderStatus: "setBDLeaderStatus", @@ -1739,6 +1740,13 @@ export const API_ENDPOINTS: Record = { permission: "room-turnover-reward:retry", permissions: ["room-turnover-reward:retry"] }, + revokeResourceGrant: { + method: "POST", + operationId: API_OPERATIONS.revokeResourceGrant, + path: "/v1/admin/resource-grants/{grant_id}/revoke", + permission: "resource-grant:revoke", + permissions: ["resource-grant:revoke"] + }, search: { method: "GET", operationId: API_OPERATIONS.search, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 768612d..38a6a99 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1988,6 +1988,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/resource-grants/{grant_id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["revokeResourceGrant"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/resource-grants/resource": { parameters: { query?: never; @@ -6079,6 +6095,20 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + revokeResourceGrant: { + parameters: { + query?: never; + header?: never; + path: { + grant_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; grantResource: { parameters: { query?: never;