撤销资源组
This commit is contained in:
parent
b6d8af0fa3
commit
bc9484c60d
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -33,10 +33,12 @@ export const cpRelationTypeLabels = Object.fromEntries(cpRelationTypeOptions);
|
||||
export const resourceGrantStatusFilters = [
|
||||
["", "全部状态"],
|
||||
["succeeded", "成功"],
|
||||
["revoked", "已撤销"],
|
||||
];
|
||||
|
||||
export const resourceGrantStatusLabels = {
|
||||
succeeded: "成功",
|
||||
revoked: "已撤销",
|
||||
};
|
||||
|
||||
export const resourceGrantSubjectLabels = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
124
src/features/resources/pages/ResourceGrantListPage.test.jsx
Normal file
124
src/features/resources/pages/ResourceGrantListPage.test.jsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
30
src/shared/api/generated/schema.d.ts
vendored
30
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user