vip赠送
This commit is contained in:
parent
24e9bfc439
commit
ee023df614
@ -44,6 +44,8 @@ export const resourceGrantStatusLabels = {
|
|||||||
export const resourceGrantSubjectLabels = {
|
export const resourceGrantSubjectLabels = {
|
||||||
resource: "资源",
|
resource: "资源",
|
||||||
resource_group: "资源组",
|
resource_group: "资源组",
|
||||||
|
vip: "VIP",
|
||||||
|
vip_level: "VIP等级",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emojiPackPricingLabels = {
|
export const emojiPackPricingLabels = {
|
||||||
|
|||||||
206
src/features/resources/hooks/useResourceGrantListPage.test.jsx
Normal file
206
src/features/resources/hooks/useResourceGrantListPage.test.jsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { useResourceGrantListPage } from "./useResourcePages.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
can: vi.fn(),
|
||||||
|
getVipConfig: vi.fn(),
|
||||||
|
grantResource: vi.fn(),
|
||||||
|
grantResourceGroup: vi.fn(),
|
||||||
|
grantVip: vi.fn(),
|
||||||
|
listGiftTypes: vi.fn(),
|
||||||
|
listGifts: vi.fn(),
|
||||||
|
listResourceGroups: vi.fn(),
|
||||||
|
listResources: vi.fn(),
|
||||||
|
lookupResourceGrantTarget: 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: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||||
|
useToast: () => ({ showToast: mocks.showToast }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/resources/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal()),
|
||||||
|
grantResource: mocks.grantResource,
|
||||||
|
grantResourceGroup: mocks.grantResourceGroup,
|
||||||
|
listGiftTypes: mocks.listGiftTypes,
|
||||||
|
listGifts: mocks.listGifts,
|
||||||
|
listResourceGroups: mocks.listResourceGroups,
|
||||||
|
listResources: mocks.listResources,
|
||||||
|
lookupResourceGrantTarget: mocks.lookupResourceGrantTarget,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/vip-config/api", () => ({
|
||||||
|
getVipConfig: mocks.getVipConfig,
|
||||||
|
grantVip: mocks.grantVip,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.can.mockReturnValue(true);
|
||||||
|
mocks.getVipConfig.mockResolvedValue({ levels: [{ level: 5, name: "VIP5", status: "active" }] });
|
||||||
|
mocks.grantResource.mockResolvedValue({});
|
||||||
|
mocks.grantResourceGroup.mockResolvedValue({});
|
||||||
|
mocks.grantVip.mockResolvedValue({ vip: { level: 5, name: "VIP5" } });
|
||||||
|
mocks.listGiftTypes.mockResolvedValue([]);
|
||||||
|
mocks.listGifts.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
|
||||||
|
mocks.listResourceGroups.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
|
||||||
|
mocks.listResources.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
|
||||||
|
mocks.lookupResourceGrantTarget.mockResolvedValue({ userId: "318705991371722752" });
|
||||||
|
mocks.reload.mockResolvedValue(undefined);
|
||||||
|
mocks.usePaginatedQuery.mockReturnValue({
|
||||||
|
data: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
reload: mocks.reload,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vip grant resolves target user before calling vip grant api", async () => {
|
||||||
|
const { result } = renderHook(() => useResourceGrantListPage());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.setForm(grantFormFixture({ subjectType: "vip", vipLevel: "5" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitGrant({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.lookupResourceGrantTarget).toHaveBeenCalledWith("163000");
|
||||||
|
expect(mocks.grantVip).toHaveBeenCalledWith({
|
||||||
|
level: 5,
|
||||||
|
reason: "manual",
|
||||||
|
targetUserId: "318705991371722752",
|
||||||
|
});
|
||||||
|
expect(mocks.lookupResourceGrantTarget.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
mocks.grantVip.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(mocks.grantResource).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.grantResourceGroup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource grant branch still calls resource grant api", async () => {
|
||||||
|
const { result } = renderHook(() => useResourceGrantListPage());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.setForm(
|
||||||
|
grantFormFixture({
|
||||||
|
durationDays: "7",
|
||||||
|
quantity: "2",
|
||||||
|
resourceIds: ["11"],
|
||||||
|
subjectType: "resource",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitGrant({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.lookupResourceGrantTarget).toHaveBeenCalledWith("163000");
|
||||||
|
expect(mocks.grantResource).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
durationMs: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
quantity: 2,
|
||||||
|
reason: "manual",
|
||||||
|
resourceId: 11,
|
||||||
|
targetUserId: "318705991371722752",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.grantResource.mock.calls[0][0].commandId).toMatch(/^resource-grant-11-0-/);
|
||||||
|
expect(mocks.lookupResourceGrantTarget.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
mocks.grantResource.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(mocks.grantVip).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.grantResourceGroup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource group grant branch still calls resource group grant api", async () => {
|
||||||
|
const { result } = renderHook(() => useResourceGrantListPage());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.setForm(grantFormFixture({ groupId: "22", subjectType: "resource_group" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitGrant({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.lookupResourceGrantTarget).toHaveBeenCalledWith("163000");
|
||||||
|
expect(mocks.grantResourceGroup).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
groupId: 22,
|
||||||
|
reason: "manual",
|
||||||
|
targetUserId: "318705991371722752",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.grantResourceGroup.mock.calls[0][0].commandId).toMatch(/^resource-group-grant-/);
|
||||||
|
expect(mocks.lookupResourceGrantTarget.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
mocks.grantResourceGroup.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(mocks.grantVip).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.grantResource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create dialog loads only vip levels when user only has vip grant permission", async () => {
|
||||||
|
mocks.can.mockImplementation((permission) => permission === "vip-config:grant");
|
||||||
|
const { result } = renderHook(() => useResourceGrantListPage());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.openCreateGrant();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.getVipConfig).toHaveBeenCalledTimes(1));
|
||||||
|
expect(result.current.form.subjectType).toBe("vip");
|
||||||
|
expect(mocks.listResources).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.listGifts).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.listGiftTypes).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.listResourceGroups).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create dialog skips vip config when user lacks vip grant permission", async () => {
|
||||||
|
mocks.can.mockImplementation((permission) => permission !== "vip-config:grant");
|
||||||
|
const { result } = renderHook(() => useResourceGrantListPage());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.openCreateGrant();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.listResources).toHaveBeenCalled());
|
||||||
|
expect(result.current.form.subjectType).toBe("resource");
|
||||||
|
expect(mocks.getVipConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function grantFormFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
durationDays: "7",
|
||||||
|
groupId: "",
|
||||||
|
quantity: "1",
|
||||||
|
reason: "manual",
|
||||||
|
resourceId: "",
|
||||||
|
resourceIds: [],
|
||||||
|
subjectType: "resource",
|
||||||
|
targetUserId: "163000",
|
||||||
|
vipLevel: "",
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -43,6 +43,7 @@ import {
|
|||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
upsertResourceShopItems,
|
upsertResourceShopItems,
|
||||||
} from "@/features/resources/api";
|
} from "@/features/resources/api";
|
||||||
|
import { getVipConfig, grantVip } from "@/features/vip-config/api";
|
||||||
import {
|
import {
|
||||||
cpRelationTypeLabels,
|
cpRelationTypeLabels,
|
||||||
cpRelationTypeOptions,
|
cpRelationTypeOptions,
|
||||||
@ -313,6 +314,15 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option
|
|||||||
throw new Error("资源选项分页过多,请收窄筛选条件");
|
throw new Error("资源选项分页过多,请收窄筛选条件");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadResourceGrantOptions() {
|
||||||
|
return Promise.all([
|
||||||
|
fetchAllOptionPages(listResources, { status: "active" }),
|
||||||
|
fetchAllOptionPages(listGifts, { status: "active" }),
|
||||||
|
listGiftTypes(),
|
||||||
|
fetchAllOptionPages(listResourceGroups, { status: "active" }),
|
||||||
|
]).then(([resources, gifts, giftTypes, groups]) => ({ gifts, giftTypes, groups, resources }));
|
||||||
|
}
|
||||||
|
|
||||||
function parseGiftPresentationObject(presentationJson) {
|
function parseGiftPresentationObject(presentationJson) {
|
||||||
const rawValue = String(presentationJson || "").trim();
|
const rawValue = String(presentationJson || "").trim();
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
@ -366,6 +376,7 @@ const emptyGrantForm = () => ({
|
|||||||
resourceIds: [],
|
resourceIds: [],
|
||||||
subjectType: "resource",
|
subjectType: "resource",
|
||||||
targetUserId: "",
|
targetUserId: "",
|
||||||
|
vipLevel: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
function groupItemToForm(item) {
|
function groupItemToForm(item) {
|
||||||
@ -1840,6 +1851,7 @@ export function useResourceGrantListPage() {
|
|||||||
const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions);
|
const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions);
|
||||||
const [groupOptions, setGroupOptions] = useState([]);
|
const [groupOptions, setGroupOptions] = useState([]);
|
||||||
const [resourceOptions, setResourceOptions] = useState([]);
|
const [resourceOptions, setResourceOptions] = useState([]);
|
||||||
|
const [vipLevelOptions, setVipLevelOptions] = useState([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
@ -1868,28 +1880,53 @@ export function useResourceGrantListPage() {
|
|||||||
}
|
}
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
setOptionsLoading(true);
|
setOptionsLoading(true);
|
||||||
Promise.all([
|
const optionRequests = [];
|
||||||
fetchAllOptionPages(listResources, { status: "active" }),
|
if (abilities.canCreateGrant) {
|
||||||
fetchAllOptionPages(listGifts, { status: "active" }),
|
optionRequests.push(loadResourceGrantOptions().then((data) => ({ data, type: "resource" })));
|
||||||
listGiftTypes(),
|
} else {
|
||||||
fetchAllOptionPages(listResourceGroups, { status: "active" }),
|
setResourceOptions([]);
|
||||||
])
|
setGiftOptions([]);
|
||||||
.then(([resources, gifts, giftTypes, groups]) => {
|
setGiftTypeOptions(defaultGiftTypeOptions);
|
||||||
if (!ignore) {
|
setGroupOptions([]);
|
||||||
setResourceOptions(
|
}
|
||||||
(resources.items || []).filter(
|
if (abilities.canGrantVip) {
|
||||||
(resource) => resource.status !== "disabled" && resource.grantable !== false,
|
optionRequests.push(getVipConfig().then((data) => ({ data, type: "vip" })));
|
||||||
),
|
} else {
|
||||||
);
|
setVipLevelOptions([]);
|
||||||
setGiftOptions((gifts.items || []).filter((gift) => gift.resourceId && gift.status !== "disabled"));
|
}
|
||||||
setGiftTypeOptions(mergeGiftTypeOptions(giftTypes));
|
Promise.allSettled(optionRequests)
|
||||||
setGroupOptions(groups.items || []);
|
.then((results) => {
|
||||||
}
|
if (ignore) {
|
||||||
})
|
return;
|
||||||
.catch((err) => {
|
|
||||||
if (!ignore) {
|
|
||||||
showToast(err.message || "加载资源选项失败", "error");
|
|
||||||
}
|
}
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
showToast(result.reason?.message || "加载资源选项失败", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.value.type === "resource") {
|
||||||
|
const { gifts, giftTypes, groups, resources } = result.value.data;
|
||||||
|
setResourceOptions(
|
||||||
|
(resources.items || []).filter(
|
||||||
|
(resource) => resource.status !== "disabled" && resource.grantable !== false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setGiftOptions(
|
||||||
|
(gifts.items || []).filter((gift) => gift.resourceId && gift.status !== "disabled"),
|
||||||
|
);
|
||||||
|
setGiftTypeOptions(mergeGiftTypeOptions(giftTypes));
|
||||||
|
setGroupOptions(groups.items || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeVipLevels = (result.value.data.levels || []).filter((level) => level.status === "active");
|
||||||
|
setVipLevelOptions(activeVipLevels);
|
||||||
|
setForm((current) => {
|
||||||
|
if (current.subjectType !== "vip" || current.vipLevel || !activeVipLevels.length) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return { ...current, vipLevel: String(activeVipLevels[0].level) };
|
||||||
|
});
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
@ -1899,10 +1936,10 @@ export function useResourceGrantListPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
ignore = true;
|
ignore = true;
|
||||||
};
|
};
|
||||||
}, [activeAction, showToast]);
|
}, [abilities.canCreateGrant, abilities.canGrantVip, activeAction, showToast]);
|
||||||
|
|
||||||
const openCreateGrant = () => {
|
const openCreateGrant = () => {
|
||||||
setForm(emptyGrantForm());
|
setForm({ ...emptyGrantForm(), subjectType: defaultGrantSubjectType(abilities) });
|
||||||
setActiveAction("create");
|
setActiveAction("create");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1912,12 +1949,15 @@ export function useResourceGrantListPage() {
|
|||||||
|
|
||||||
const submitGrant = async (event) => {
|
const submitGrant = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!abilities.canCreateGrant) {
|
if (!abilities.canCreateGrant && !abilities.canGrantVip) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingAction("grant-create");
|
setLoadingAction("grant-create");
|
||||||
try {
|
try {
|
||||||
const parsedForm = parseForm(resourceGrantFormSchema, form);
|
const parsedForm = parseForm(resourceGrantFormSchema, form);
|
||||||
|
if (!canSubmitGrantSubject(parsedForm.subjectType, abilities)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const targetUser = await lookupResourceGrantTarget(parsedForm.targetUserId);
|
const targetUser = await lookupResourceGrantTarget(parsedForm.targetUserId);
|
||||||
const targetUserId = normalizedResolvedUserId(targetUser);
|
const targetUserId = normalizedResolvedUserId(targetUser);
|
||||||
const payload = buildGrantPayload(parsedForm, targetUserId);
|
const payload = buildGrantPayload(parsedForm, targetUserId);
|
||||||
@ -1925,10 +1965,12 @@ export function useResourceGrantListPage() {
|
|||||||
for (const resourcePayload of payload.resources) {
|
for (const resourcePayload of payload.resources) {
|
||||||
await grantResource(resourcePayload);
|
await grantResource(resourcePayload);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (payload.subjectType === "resource_group") {
|
||||||
await grantResourceGroup(payload.group);
|
await grantResourceGroup(payload.group);
|
||||||
|
} else {
|
||||||
|
await grantVip(payload.vip);
|
||||||
}
|
}
|
||||||
showToast("资源已赠送", "success");
|
showToast(payload.subjectType === "vip" ? "VIP已赠送" : "资源已赠送", "success");
|
||||||
closeAction();
|
closeAction();
|
||||||
setForm(emptyGrantForm());
|
setForm(emptyGrantForm());
|
||||||
await result.reload();
|
await result.reload();
|
||||||
@ -1944,7 +1986,7 @@ export function useResourceGrantListPage() {
|
|||||||
!abilities.canRevokeGrant ||
|
!abilities.canRevokeGrant ||
|
||||||
!grant?.grantId ||
|
!grant?.grantId ||
|
||||||
grant.status !== "succeeded" ||
|
grant.status !== "succeeded" ||
|
||||||
!isRevocableGrantSubject(grant.grantSubjectType)
|
!isRevocableGrant(grant)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2002,6 +2044,7 @@ export function useResourceGrantListPage() {
|
|||||||
status,
|
status,
|
||||||
submitGrant,
|
submitGrant,
|
||||||
targetFilter,
|
targetFilter,
|
||||||
|
vipLevelOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2348,6 +2391,16 @@ function buildGrantPayload(form, resolvedTargetUserId) {
|
|||||||
subjectType: "resource",
|
subjectType: "resource",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (form.subjectType === "vip") {
|
||||||
|
return {
|
||||||
|
subjectType: "vip",
|
||||||
|
vip: {
|
||||||
|
level: Number(form.vipLevel),
|
||||||
|
reason,
|
||||||
|
targetUserId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
group: {
|
group: {
|
||||||
commandId: makeCommandId("resource-group-grant"),
|
commandId: makeCommandId("resource-group-grant"),
|
||||||
@ -2366,8 +2419,31 @@ function grantResourceIds(form) {
|
|||||||
return form.resourceId ? [form.resourceId] : [];
|
return form.resourceId ? [form.resourceId] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRevocableGrantSubject(subjectType) {
|
function isRevocableGrant(grant) {
|
||||||
return subjectType === "resource" || subjectType === "resource_group";
|
if (isVipGrantRecord(grant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return grant?.grantSubjectType === "resource" || grant?.grantSubjectType === "resource_group";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVipGrantRecord(grant) {
|
||||||
|
const source = String(grant?.grantSource || "").trim();
|
||||||
|
const commandId = String(grant?.commandId || "").trim();
|
||||||
|
return source === "admin_grant" || source === "activity_grant" || source === "vip_purchase" || commandId.startsWith("vip_reward:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSubmitGrantSubject(subjectType, abilities) {
|
||||||
|
if (subjectType === "vip") {
|
||||||
|
return Boolean(abilities.canGrantVip);
|
||||||
|
}
|
||||||
|
return Boolean(abilities.canCreateGrant);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultGrantSubjectType(abilities) {
|
||||||
|
if (abilities.canCreateGrant) {
|
||||||
|
return "resource";
|
||||||
|
}
|
||||||
|
return abilities.canGrantVip ? "vip" : "resource";
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCommandId(prefix) {
|
function makeCommandId(prefix) {
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const grantColumns = [
|
|||||||
width: "minmax(220px, 1fr)",
|
width: "minmax(220px, 1fr)",
|
||||||
render: (grant) => (
|
render: (grant) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span className={styles.name}>{grantSourceLabel(grant.grantSource)}</span>
|
<span className={styles.name}>{grantSourceLabel(grant.grantSource, grant)}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -127,7 +127,7 @@ export function ResourceGrantListPage() {
|
|||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreateGrant ? (
|
page.abilities.canCreateGrant || page.abilities.canGrantVip ? (
|
||||||
<AdminActionIconButton label="资源赠送" primary onClick={page.openCreateGrant}>
|
<AdminActionIconButton label="资源赠送" primary onClick={page.openCreateGrant}>
|
||||||
<Add fontSize="small" />
|
<Add fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
@ -155,7 +155,8 @@ export function ResourceGrantListPage() {
|
|||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
<ResourceGrantDialog
|
<ResourceGrantDialog
|
||||||
disabled={!page.abilities.canCreateGrant}
|
abilities={page.abilities}
|
||||||
|
disabled={!page.abilities.canCreateGrant && !page.abilities.canGrantVip}
|
||||||
form={page.form}
|
form={page.form}
|
||||||
giftOptions={page.giftOptions}
|
giftOptions={page.giftOptions}
|
||||||
giftTypeOptions={page.giftTypeOptions}
|
giftTypeOptions={page.giftTypeOptions}
|
||||||
@ -165,6 +166,7 @@ export function ResourceGrantListPage() {
|
|||||||
optionsLoading={page.optionsLoading}
|
optionsLoading={page.optionsLoading}
|
||||||
resourceOptions={page.resourceOptions}
|
resourceOptions={page.resourceOptions}
|
||||||
setForm={page.setForm}
|
setForm={page.setForm}
|
||||||
|
vipLevelOptions={page.vipLevelOptions}
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitGrant}
|
onSubmit={page.submitGrant}
|
||||||
/>
|
/>
|
||||||
@ -176,7 +178,7 @@ function ResourceGrantActions({ grant, page }) {
|
|||||||
const canRevoke =
|
const canRevoke =
|
||||||
page.abilities.canRevokeGrant &&
|
page.abilities.canRevokeGrant &&
|
||||||
grant?.status === "succeeded" &&
|
grant?.status === "succeeded" &&
|
||||||
isRevocableGrantSubject(grant?.grantSubjectType);
|
isRevocableGrant(grant);
|
||||||
if (!canRevoke) {
|
if (!canRevoke) {
|
||||||
return <span className={styles.meta}>-</span>;
|
return <span className={styles.meta}>-</span>;
|
||||||
}
|
}
|
||||||
@ -197,6 +199,7 @@ function ResourceGrantActions({ grant, page }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResourceGrantDialog({
|
function ResourceGrantDialog({
|
||||||
|
abilities,
|
||||||
disabled,
|
disabled,
|
||||||
form,
|
form,
|
||||||
giftOptions,
|
giftOptions,
|
||||||
@ -209,8 +212,30 @@ function ResourceGrantDialog({
|
|||||||
optionsLoading,
|
optionsLoading,
|
||||||
resourceOptions,
|
resourceOptions,
|
||||||
setForm,
|
setForm,
|
||||||
|
vipLevelOptions,
|
||||||
}) {
|
}) {
|
||||||
const submitDisabled = disabled || loading || optionsLoading;
|
const resourceGrantAllowed = Boolean(abilities?.canCreateGrant);
|
||||||
|
const vipGrantAllowed = Boolean(abilities?.canGrantVip);
|
||||||
|
const subjectAllowed = canEditGrantSubject(form.subjectType, abilities);
|
||||||
|
const hasVipLevels = (vipLevelOptions || []).length > 0;
|
||||||
|
const currentVipLevel = String(form.vipLevel || "");
|
||||||
|
const vipLevelValue = (vipLevelOptions || []).some((level) => String(level.level) === currentVipLevel)
|
||||||
|
? currentVipLevel
|
||||||
|
: "";
|
||||||
|
const vipUnavailable = form.subjectType === "vip" && (!vipGrantAllowed || !hasVipLevels);
|
||||||
|
const submitDisabled = disabled || loading || optionsLoading || !subjectAllowed || vipUnavailable;
|
||||||
|
const handleSubjectChange = (event) => {
|
||||||
|
const subjectType = event.target.value;
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
groupId: "",
|
||||||
|
resourceId: "",
|
||||||
|
resourceIds: [],
|
||||||
|
subjectType,
|
||||||
|
vipLevel: subjectType === "vip" ? String((vipLevelOptions || [])[0]?.level || "") : "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@ -222,7 +247,7 @@ function ResourceGrantDialog({
|
|||||||
>
|
>
|
||||||
<AdminFormSection title="赠送对象">
|
<AdminFormSection title="赠送对象">
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || loading || !subjectAllowed}
|
||||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||||
label="用户 ID / 短号"
|
label="用户 ID / 短号"
|
||||||
required
|
required
|
||||||
@ -238,24 +263,17 @@ function ResourceGrantDialog({
|
|||||||
required
|
required
|
||||||
select
|
select
|
||||||
value={form.subjectType}
|
value={form.subjectType}
|
||||||
onChange={(event) =>
|
onChange={handleSubjectChange}
|
||||||
setForm({
|
|
||||||
...form,
|
|
||||||
groupId: "",
|
|
||||||
resourceId: "",
|
|
||||||
resourceIds: [],
|
|
||||||
subjectType: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="resource">资源</MenuItem>
|
{resourceGrantAllowed ? <MenuItem value="resource">资源</MenuItem> : null}
|
||||||
<MenuItem value="resource_group">资源组</MenuItem>
|
{resourceGrantAllowed ? <MenuItem value="resource_group">资源组</MenuItem> : null}
|
||||||
|
{vipGrantAllowed ? <MenuItem value="vip">VIP</MenuItem> : null}
|
||||||
</TextField>
|
</TextField>
|
||||||
{form.subjectType === "resource" ? (
|
{form.subjectType === "resource" ? (
|
||||||
<>
|
<>
|
||||||
<GiftResourceSelectField
|
<GiftResourceSelectField
|
||||||
drawerTitle="选择资源"
|
drawerTitle="选择资源"
|
||||||
disabled={disabled || optionsLoading}
|
disabled={disabled || optionsLoading || !resourceGrantAllowed}
|
||||||
giftTypes={giftTypeOptions}
|
giftTypes={giftTypeOptions}
|
||||||
gifts={giftOptions}
|
gifts={giftOptions}
|
||||||
label="资源"
|
label="资源"
|
||||||
@ -268,7 +286,7 @@ function ResourceGrantDialog({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || !resourceGrantAllowed}
|
||||||
label="数量"
|
label="数量"
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
@ -276,16 +294,17 @@ function ResourceGrantDialog({
|
|||||||
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
|
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || !resourceGrantAllowed}
|
||||||
label="有效天数"
|
label="有效天数"
|
||||||
type="number"
|
type="number"
|
||||||
value={form.durationDays}
|
value={form.durationDays}
|
||||||
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
|
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
|
{form.subjectType === "resource_group" ? (
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled || optionsLoading}
|
disabled={disabled || optionsLoading || !resourceGrantAllowed}
|
||||||
label="资源组"
|
label="资源组"
|
||||||
required
|
required
|
||||||
select
|
select
|
||||||
@ -298,12 +317,35 @@ function ResourceGrantDialog({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
) : null}
|
||||||
|
{form.subjectType === "vip" ? (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || optionsLoading || !vipGrantAllowed || !hasVipLevels}
|
||||||
|
helperText={!hasVipLevels && !optionsLoading ? "暂无启用VIP等级" : undefined}
|
||||||
|
label="VIP等级"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={vipLevelValue}
|
||||||
|
onChange={(event) => setForm({ ...form, vipLevel: event.target.value })}
|
||||||
|
>
|
||||||
|
{hasVipLevels ? (
|
||||||
|
(vipLevelOptions || []).map((level) => (
|
||||||
|
<MenuItem key={level.level} value={String(level.level)}>
|
||||||
|
VIP{level.level} · {level.name || `VIP${level.level}`}
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<MenuItem disabled value="">
|
||||||
|
暂无启用VIP等级
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="备注">
|
<AdminFormSection title="备注">
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || loading || !subjectAllowed}
|
||||||
label="原因"
|
label="原因"
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
@ -375,8 +417,10 @@ function GrantItemMedia({ item }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GrantSubject({ grant }) {
|
function GrantSubject({ grant }) {
|
||||||
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
|
const subjectLabel = isVipGrantRecord(grant)
|
||||||
const sourceLabel = grantSourceLabel(grant.grantSource);
|
? "VIP奖励资源组"
|
||||||
|
: resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
|
||||||
|
const sourceLabel = grantSourceLabel(grant.grantSource, grant);
|
||||||
return (
|
return (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span className={styles.name}>{sourceLabel === "-" ? subjectLabel : `${subjectLabel} · ${sourceLabel}`}</span>
|
<span className={styles.name}>{sourceLabel === "-" ? subjectLabel : `${subjectLabel} · ${sourceLabel}`}</span>
|
||||||
@ -388,10 +432,11 @@ function GrantSubject({ grant }) {
|
|||||||
function GrantReason({ grant }) {
|
function GrantReason({ grant }) {
|
||||||
const label = reasonLabel(grant.reason);
|
const label = reasonLabel(grant.reason);
|
||||||
const raw = String(grant.reason || "").trim();
|
const raw = String(grant.reason || "").trim();
|
||||||
|
const showRawReason = raw && raw !== label && !grantReasonLabels[raw];
|
||||||
return (
|
return (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span className={styles.name}>{label}</span>
|
<span className={styles.name}>{label}</span>
|
||||||
{raw && raw !== label ? <span className={styles.meta}>{raw}</span> : null}
|
{showRawReason ? <span className={styles.meta}>{raw}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -427,7 +472,9 @@ function GrantOperator({ grant }) {
|
|||||||
const grantSourceLabels = {
|
const grantSourceLabels = {
|
||||||
achievement: "成就奖励",
|
achievement: "成就奖励",
|
||||||
admin: "后台发放",
|
admin: "后台发放",
|
||||||
|
admin_grant: "后台VIP赠送",
|
||||||
agency_opening: "Agency 开通",
|
agency_opening: "Agency 开通",
|
||||||
|
activity_grant: "活动VIP赠送",
|
||||||
cp_weekly_rank: "CP 周榜奖励",
|
cp_weekly_rank: "CP 周榜奖励",
|
||||||
cumulative_recharge_reward: "累充奖励",
|
cumulative_recharge_reward: "累充奖励",
|
||||||
first_recharge_reward: "首充奖励",
|
first_recharge_reward: "首充奖励",
|
||||||
@ -438,6 +485,7 @@ const grantSourceLabels = {
|
|||||||
resource_shop: "道具商店",
|
resource_shop: "道具商店",
|
||||||
room_rocket: "房间火箭",
|
room_rocket: "房间火箭",
|
||||||
seven_day_checkin: "七日签到",
|
seven_day_checkin: "七日签到",
|
||||||
|
vip_purchase: "VIP购买",
|
||||||
weekly_star: "周星奖励",
|
weekly_star: "周星奖励",
|
||||||
wheel_reward: "转盘奖励",
|
wheel_reward: "转盘奖励",
|
||||||
};
|
};
|
||||||
@ -448,9 +496,12 @@ const grantReasonLabels = {
|
|||||||
"resource shop purchase": "道具商店购买",
|
"resource shop purchase": "道具商店购买",
|
||||||
};
|
};
|
||||||
|
|
||||||
function grantSourceLabel(source) {
|
function grantSourceLabel(source, grant) {
|
||||||
const key = String(source || "").trim();
|
const key = String(source || "").trim();
|
||||||
return grantSourceLabels[key] || key || "-";
|
if (grantSourceLabels[key]) {
|
||||||
|
return grantSourceLabels[key];
|
||||||
|
}
|
||||||
|
return isVipRewardCommand(grant) ? "VIP奖励" : key || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function reasonLabel(reason) {
|
function reasonLabel(reason) {
|
||||||
@ -459,12 +510,44 @@ function reasonLabel(reason) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function subjectMeta(grant) {
|
function subjectMeta(grant) {
|
||||||
|
if (isVipGrantRecord(grant)) {
|
||||||
|
return grant.grantSubjectId ? `奖励资源组 ID ${grant.grantSubjectId}` : "-";
|
||||||
|
}
|
||||||
|
if (grant.grantSubjectType === "vip_level") {
|
||||||
|
return grant.grantSubjectId ? `VIP等级 ${grant.grantSubjectId}` : "-";
|
||||||
|
}
|
||||||
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "对象";
|
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "对象";
|
||||||
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
|
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRevocableGrantSubject(subjectType) {
|
function isRevocableGrant(grant) {
|
||||||
return subjectType === "resource" || subjectType === "resource_group";
|
if (isVipGrantRecord(grant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return grant?.grantSubjectType === "resource" || grant?.grantSubjectType === "resource_group";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVipGrantRecord(grant) {
|
||||||
|
const source = String(grant?.grantSource || "").trim();
|
||||||
|
return (
|
||||||
|
source === "admin_grant" ||
|
||||||
|
source === "activity_grant" ||
|
||||||
|
source === "vip_purchase" ||
|
||||||
|
isVipRewardCommand(grant)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVipRewardCommand(grant) {
|
||||||
|
return String(grant?.commandId || "")
|
||||||
|
.trim()
|
||||||
|
.startsWith("vip_reward:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditGrantSubject(subjectType, abilities) {
|
||||||
|
if (subjectType === "vip") {
|
||||||
|
return Boolean(abilities?.canGrantVip);
|
||||||
|
}
|
||||||
|
return Boolean(abilities?.canCreateGrant);
|
||||||
}
|
}
|
||||||
|
|
||||||
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
|
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
|
||||||
|
|||||||
@ -161,11 +161,78 @@ test("resource grant list keeps operator only for user initiated manager grants"
|
|||||||
expect(screen.getByText("Manager A")).toBeInTheDocument();
|
expect(screen.getByText("Manager A")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resource grant dialog shows vip subject and level selector", () => {
|
||||||
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
activeAction: "create",
|
||||||
|
form: grantFormFixture({ subjectType: "vip", vipLevel: "5" }),
|
||||||
|
vipLevelOptions: [{ level: 5, name: "黄金VIP", status: "active" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ResourceGrantListPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("VIP等级")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("VIP5 · 黄金VIP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource grant dialog hides vip subject without vip grant permission", () => {
|
||||||
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: { canCreateGrant: true, canGrantVip: false },
|
||||||
|
activeAction: "create",
|
||||||
|
form: grantFormFixture({ subjectType: "resource" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ResourceGrantListPage />);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByRole("combobox", { name: "对象类型" }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("option", { name: "VIP" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("VIP等级")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource grant list renders vip source records and hides revoke", () => {
|
||||||
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
grantFixture({
|
||||||
|
commandId: "vip_reward:admin_vip_grant:rgr_vip",
|
||||||
|
grantId: "rgr_vip",
|
||||||
|
grantSource: "admin_grant",
|
||||||
|
grantSubjectId: "22",
|
||||||
|
grantSubjectType: "resource_group",
|
||||||
|
reason: "admin_grant",
|
||||||
|
status: "succeeded",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ResourceGrantListPage />);
|
||||||
|
|
||||||
|
expect(screen.getAllByText("后台VIP赠送").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText("VIP奖励资源组 · 后台VIP赠送")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("奖励资源组 ID 22")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("撤销")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("admin_grant")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("vip_reward:admin_vip_grant:rgr_vip")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
function pageFixture(patch = {}) {
|
function pageFixture(patch = {}) {
|
||||||
|
const { abilities: abilityPatch, form: formPatch, ...restPatch } = patch;
|
||||||
return {
|
return {
|
||||||
abilities: {
|
abilities: {
|
||||||
canCreateGrant: true,
|
canCreateGrant: true,
|
||||||
|
canGrantVip: true,
|
||||||
canRevokeGrant: true,
|
canRevokeGrant: true,
|
||||||
|
...abilityPatch,
|
||||||
},
|
},
|
||||||
activeAction: "",
|
activeAction: "",
|
||||||
changeQuery: vi.fn(),
|
changeQuery: vi.fn(),
|
||||||
@ -173,15 +240,7 @@ function pageFixture(patch = {}) {
|
|||||||
closeAction: vi.fn(),
|
closeAction: vi.fn(),
|
||||||
data: { items: [], page: 1, pageSize: 50, total: 0 },
|
data: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||||
error: null,
|
error: null,
|
||||||
form: {
|
form: grantFormFixture(formPatch),
|
||||||
durationDays: "0",
|
|
||||||
giftIds: [],
|
|
||||||
groupId: "",
|
|
||||||
reason: "",
|
|
||||||
resourceIds: [],
|
|
||||||
subjectType: "resource",
|
|
||||||
targetUserId: "",
|
|
||||||
},
|
|
||||||
giftOptions: [],
|
giftOptions: [],
|
||||||
giftTypeOptions: [],
|
giftTypeOptions: [],
|
||||||
groupOptions: [],
|
groupOptions: [],
|
||||||
@ -199,6 +258,23 @@ function pageFixture(patch = {}) {
|
|||||||
setPage: vi.fn(),
|
setPage: vi.fn(),
|
||||||
status: "",
|
status: "",
|
||||||
submitGrant: vi.fn(),
|
submitGrant: vi.fn(),
|
||||||
|
vipLevelOptions: [],
|
||||||
|
...restPatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantFormFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
durationDays: "7",
|
||||||
|
giftIds: [],
|
||||||
|
groupId: "",
|
||||||
|
quantity: "1",
|
||||||
|
reason: "",
|
||||||
|
resourceId: "",
|
||||||
|
resourceIds: [],
|
||||||
|
subjectType: "resource",
|
||||||
|
targetUserId: "",
|
||||||
|
vipLevel: "",
|
||||||
...patch,
|
...patch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export function useResourceAbilities() {
|
|||||||
canCreateGift: can(PERMISSIONS.giftCreate),
|
canCreateGift: can(PERMISSIONS.giftCreate),
|
||||||
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
||||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||||
|
canGrantVip: can(PERMISSIONS.vipConfigGrant),
|
||||||
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
||||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||||
canDelete: can(PERMISSIONS.resourceDelete),
|
canDelete: can(PERMISSIONS.resourceDelete),
|
||||||
|
|||||||
@ -446,8 +446,9 @@ export const resourceGrantFormSchema = z
|
|||||||
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
|
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
|
||||||
resourceId: z.union([z.string(), z.number()]).optional(),
|
resourceId: z.union([z.string(), z.number()]).optional(),
|
||||||
resourceIds: z.array(z.union([z.string(), z.number()])).optional(),
|
resourceIds: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
subjectType: z.enum(["resource", "resource_group"]),
|
subjectType: z.enum(["resource", "resource_group", "vip"]),
|
||||||
targetUserId: z.union([z.string(), z.number()]),
|
targetUserId: z.union([z.string(), z.number()]),
|
||||||
|
vipLevel: z.union([z.string(), z.number()]).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
const targetUserId = String(value.targetUserId || "").trim();
|
const targetUserId = String(value.targetUserId || "").trim();
|
||||||
@ -455,6 +456,7 @@ export const resourceGrantFormSchema = z
|
|||||||
const groupId = Number(value.groupId || 0);
|
const groupId = Number(value.groupId || 0);
|
||||||
const quantity = Number(value.quantity || 1);
|
const quantity = Number(value.quantity || 1);
|
||||||
const durationDays = Number(value.durationDays || 0);
|
const durationDays = Number(value.durationDays || 0);
|
||||||
|
const vipLevel = Number(value.vipLevel || 0);
|
||||||
|
|
||||||
if (!/^[1-9]\d*$/.test(targetUserId)) {
|
if (!/^[1-9]\d*$/.test(targetUserId)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@ -481,6 +483,20 @@ export const resourceGrantFormSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!Number.isInteger(quantity) || quantity <= 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请输入数量",
|
||||||
|
path: ["quantity"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(durationDays) || durationDays < 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请输入有效天数",
|
||||||
|
path: ["durationDays"],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) {
|
if (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@ -489,18 +505,11 @@ export const resourceGrantFormSchema = z
|
|||||||
path: ["groupId"],
|
path: ["groupId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(quantity) || quantity <= 0) {
|
if (value.subjectType === "vip" && (!Number.isInteger(vipLevel) || vipLevel <= 0)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请输入数量",
|
message: "请选择VIP等级",
|
||||||
path: ["quantity"],
|
path: ["vipLevel"],
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(durationDays) || durationDays < 0) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "请输入有效天数",
|
|
||||||
path: ["durationDays"],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -302,6 +302,31 @@ describe("resource form schema", () => {
|
|||||||
expect(payload.resourceIds).toEqual(["11", "12"]);
|
expect(payload.resourceIds).toEqual(["11", "12"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates vip grant form fields", () => {
|
||||||
|
const payload = parseForm(resourceGrantFormSchema, {
|
||||||
|
reason: "manual",
|
||||||
|
subjectType: "vip",
|
||||||
|
targetUserId: "1001",
|
||||||
|
vipLevel: "5",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.subjectType).toBe("vip");
|
||||||
|
expect(payload.vipLevel).toBe("5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid vip grant form fields", () => {
|
||||||
|
const basePayload = {
|
||||||
|
reason: "manual",
|
||||||
|
subjectType: "vip",
|
||||||
|
targetUserId: "1001",
|
||||||
|
vipLevel: "5",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, vipLevel: "" })).toThrow(FormValidationError);
|
||||||
|
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, vipLevel: "bad" })).toThrow(FormValidationError);
|
||||||
|
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, reason: "" })).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
test("validates resource shop durations", () => {
|
test("validates resource shop durations", () => {
|
||||||
const payload = parseForm(resourceShopItemsFormSchema, {
|
const payload = parseForm(resourceShopItemsFormSchema, {
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { getVipConfig, grantVip, updateVipConfig } from "@/features/vip-config/api";
|
import { getVipConfig, updateVipConfig } from "@/features/vip-config/api";
|
||||||
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
|
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
|
||||||
import { listResourceGroups } from "@/features/resources/api";
|
import { listResourceGroups } from "@/features/resources/api";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
@ -13,11 +13,8 @@ export function useVipConfigPage() {
|
|||||||
const [form, setForm] = useState({ levels: defaultLevels().map(levelToForm) });
|
const [form, setForm] = useState({ levels: defaultLevels().map(levelToForm) });
|
||||||
const [resourceGroups, setResourceGroups] = useState([]);
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [grantDrawerOpen, setGrantDrawerOpen] = useState(false);
|
|
||||||
const [grantForm, setGrantForm] = useState(defaultGrantForm());
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [grantSaving, setGrantSaving] = useState(false);
|
|
||||||
|
|
||||||
const activeCount = useMemo(
|
const activeCount = useMemo(
|
||||||
() => (config.levels || []).filter((level) => level.status === "active").length,
|
() => (config.levels || []).filter((level) => level.status === "active").length,
|
||||||
@ -59,22 +56,6 @@ export function useVipConfigPage() {
|
|||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openGrantDrawer = () => {
|
|
||||||
setGrantForm((current) => ({
|
|
||||||
...defaultGrantForm(),
|
|
||||||
level: firstActiveLevel(config.levels || []) || current.level || "1",
|
|
||||||
}));
|
|
||||||
setGrantDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeGrantDrawer = () => {
|
|
||||||
if (grantSaving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGrantDrawerOpen(false);
|
|
||||||
setGrantForm(defaultGrantForm());
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateLevel = (levelNumber, patch) => {
|
const updateLevel = (levelNumber, patch) => {
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -82,10 +63,6 @@ export function useVipConfigPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGrantForm = (patch) => {
|
|
||||||
setGrantForm((current) => ({ ...current, ...patch }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async (event) => {
|
const submit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!abilities.canUpdate) {
|
if (!abilities.canUpdate) {
|
||||||
@ -113,53 +90,20 @@ export function useVipConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitGrant = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!abilities.canGrant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetUserId = String(grantForm.targetUserId || "").trim();
|
|
||||||
const reason = String(grantForm.reason || "").trim();
|
|
||||||
const level = Number(grantForm.level || 0);
|
|
||||||
if (!targetUserId || !Number.isInteger(level) || level <= 0 || !reason) {
|
|
||||||
showToast("VIP 赠送参数不正确", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGrantSaving(true);
|
|
||||||
try {
|
|
||||||
const result = await grantVip({ targetUserId, level, reason });
|
|
||||||
setGrantDrawerOpen(false);
|
|
||||||
setGrantForm(defaultGrantForm());
|
|
||||||
showToast(`已赠送 ${result.vip?.name || `VIP${level}`}`, "success");
|
|
||||||
await reload();
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "赠送 VIP 失败", "error");
|
|
||||||
} finally {
|
|
||||||
setGrantSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeCount,
|
activeCount,
|
||||||
closeDrawer,
|
closeDrawer,
|
||||||
closeGrantDrawer,
|
|
||||||
config,
|
config,
|
||||||
drawerOpen,
|
drawerOpen,
|
||||||
form,
|
form,
|
||||||
grantDrawerOpen,
|
|
||||||
grantForm,
|
|
||||||
grantSaving,
|
|
||||||
loading,
|
loading,
|
||||||
openDrawer,
|
openDrawer,
|
||||||
openGrantDrawer,
|
|
||||||
reload,
|
reload,
|
||||||
resourceGroups,
|
resourceGroups,
|
||||||
saving,
|
saving,
|
||||||
submit,
|
submit,
|
||||||
submitGrant,
|
|
||||||
updateLevel,
|
updateLevel,
|
||||||
updateGrantForm,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,19 +129,6 @@ function completeLevels(levels) {
|
|||||||
return defaultLevels().map((fallback) => ({ ...fallback, ...(byLevel.get(fallback.level) || {}) }));
|
return defaultLevels().map((fallback) => ({ ...fallback, ...(byLevel.get(fallback.level) || {}) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultGrantForm() {
|
|
||||||
return {
|
|
||||||
targetUserId: "",
|
|
||||||
level: "1",
|
|
||||||
reason: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstActiveLevel(levels) {
|
|
||||||
const active = (levels || []).find((level) => level.status === "active");
|
|
||||||
return active ? String(active.level) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function levelToForm(level) {
|
function levelToForm(level) {
|
||||||
return {
|
return {
|
||||||
level: Number(level.level || 0),
|
level: Number(level.level || 0),
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useVipConfigPage } from "@/features/vip-config/hooks/useVipConfigPage.js";
|
import { useVipConfigPage } from "@/features/vip-config/hooks/useVipConfigPage.js";
|
||||||
@ -48,15 +46,6 @@ export function VipConfigPage() {
|
|||||||
编辑配置
|
编辑配置
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{page.abilities.canGrant ? (
|
|
||||||
<Button
|
|
||||||
disabled={page.loading}
|
|
||||||
startIcon={<CardGiftcardOutlined fontSize="small" />}
|
|
||||||
onClick={page.openGrantDrawer}
|
|
||||||
>
|
|
||||||
赠送VIP
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataState loading={page.loading} onRetry={page.reload}>
|
<DataState loading={page.loading} onRetry={page.reload}>
|
||||||
@ -65,76 +54,10 @@ export function VipConfigPage() {
|
|||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
<VipConfigDrawer page={page} />
|
<VipConfigDrawer page={page} />
|
||||||
<VipGrantDrawer page={page} />
|
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VipGrantDrawer({ page }) {
|
|
||||||
const disabled = !page.abilities.canGrant || page.grantSaving || page.loading;
|
|
||||||
const activeLevels = (page.config.levels || []).filter((level) => level.status === "active");
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
anchor="right"
|
|
||||||
open={page.grantDrawerOpen}
|
|
||||||
onClose={page.grantSaving ? undefined : page.closeGrantDrawer}
|
|
||||||
>
|
|
||||||
<form className="form-drawer" onSubmit={page.submitGrant}>
|
|
||||||
<h2>赠送VIP</h2>
|
|
||||||
<section className="form-drawer__section">
|
|
||||||
<div className="form-drawer__section-title">赠送信息</div>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
inputProps={{ inputMode: "numeric" }}
|
|
||||||
label="用户ID"
|
|
||||||
value={page.grantForm.targetUserId}
|
|
||||||
onChange={(event) => page.updateGrantForm({ targetUserId: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
disabled={disabled || activeLevels.length === 0}
|
|
||||||
label="VIP等级"
|
|
||||||
value={page.grantForm.level}
|
|
||||||
onChange={(event) => page.updateGrantForm({ level: event.target.value })}
|
|
||||||
>
|
|
||||||
{activeLevels.length === 0 ? (
|
|
||||||
<MenuItem value={page.grantForm.level || ""}>暂无启用等级</MenuItem>
|
|
||||||
) : (
|
|
||||||
activeLevels.map((level) => (
|
|
||||||
<MenuItem key={level.level} value={String(level.level)}>
|
|
||||||
VIP{level.level} · {level.name}
|
|
||||||
</MenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
disabled={disabled}
|
|
||||||
inputProps={{ maxLength: 512 }}
|
|
||||||
label="原因"
|
|
||||||
minRows={3}
|
|
||||||
value={page.grantForm.reason}
|
|
||||||
onChange={(event) => page.updateGrantForm({ reason: event.target.value })}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<div className="form-drawer__actions">
|
|
||||||
<Button disabled={page.grantSaving} type="button" onClick={page.closeGrantDrawer}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={disabled || activeLevels.length === 0}
|
|
||||||
startIcon={<CardGiftcardOutlined fontSize="small" />}
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
赠送
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VipConfigDrawer({ page }) {
|
function VipConfigDrawer({ page }) {
|
||||||
const disabled = !page.abilities.canUpdate || page.saving || page.loading;
|
const disabled = !page.abilities.canUpdate || page.saving || page.loading;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,6 +6,5 @@ export function useVipConfigAbilities() {
|
|||||||
return {
|
return {
|
||||||
canView: can(PERMISSIONS.vipConfigView),
|
canView: can(PERMISSIONS.vipConfigView),
|
||||||
canUpdate: can(PERMISSIONS.vipConfigUpdate),
|
canUpdate: can(PERMISSIONS.vipConfigUpdate),
|
||||||
canGrant: can(PERMISSIONS.vipConfigGrant),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user