撤销资源组

This commit is contained in:
zhx 2026-06-24 19:49:05 +08:00
parent b6d8af0fa3
commit bc9484c60d
11 changed files with 323 additions and 27 deletions

View File

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

View File

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

View File

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

View File

@ -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<ResourceGrantDto> {
const endpoint = API_ENDPOINTS.revokeResourceGrant;
return apiRequest<ResourceGrantDto>(apiEndpointPath(API_OPERATIONS.revokeResourceGrant, { grant_id: grantId }), {
method: endpoint.method,
});
}
type RawResourceIdentityAutoGrantItem = ResourceIdentityAutoGrantItemDto & {
created_at_ms?: unknown;
identity_type?: unknown;

View File

@ -33,10 +33,12 @@ export const cpRelationTypeLabels = Object.fromEntries(cpRelationTypeOptions);
export const resourceGrantStatusFilters = [
["", "全部状态"],
["succeeded", "成功"],
["revoked", "已撤销"],
];
export const resourceGrantStatusLabels = {
succeeded: "成功",
revoked: "已撤销",
};
export const resourceGrantSubjectLabels = {

View File

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

View File

@ -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) => (
<span className={`status-badge status-badge--${grant.status || "stopped"}`}>
<span className={`status-badge status-badge--${grantStatusTone(grant.status)}`}>
<span className="status-point" />
{resourceGrantStatusLabels[grant.status] || grant.status || "-"}
</span>
@ -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) => <ResourceGrantActions grant={grant} page={page} />,
},
];
return (
<AdminListPage>
@ -128,7 +143,7 @@ export function ResourceGrantListPage() {
<DataTable
columns={columns}
items={items}
minWidth="1370px"
minWidth="1460px"
pagination={
total > 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 <span className={styles.meta}>-</span>;
}
return (
<AdminRowActions>
<AdminActionIconButton
aria-label="撤销"
disabled={page.loadingAction === `grant-revoke-${grant.grantId}`}
label="撤销"
sx={dangerActionSx}
tooltip="撤销资源组赠送"
onClick={() => page.revokeGrant(grant)}
>
<UndoOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
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) {

View File

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

View File

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

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

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