diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index db2151b..1e2100c 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -44,6 +44,8 @@ export const resourceGrantStatusLabels = { export const resourceGrantSubjectLabels = { resource: "资源", resource_group: "资源组", + vip: "VIP", + vip_level: "VIP等级", }; export const emojiPackPricingLabels = { diff --git a/src/features/resources/hooks/useResourceGrantListPage.test.jsx b/src/features/resources/hooks/useResourceGrantListPage.test.jsx new file mode 100644 index 0000000..d273688 --- /dev/null +++ b/src/features/resources/hooks/useResourceGrantListPage.test.jsx @@ -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, + }; +} diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index f9bba97..032e149 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -43,6 +43,7 @@ import { updateResourceGroup, upsertResourceShopItems, } from "@/features/resources/api"; +import { getVipConfig, grantVip } from "@/features/vip-config/api"; import { cpRelationTypeLabels, cpRelationTypeOptions, @@ -313,6 +314,15 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option 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) { const rawValue = String(presentationJson || "").trim(); if (!rawValue) { @@ -366,6 +376,7 @@ const emptyGrantForm = () => ({ resourceIds: [], subjectType: "resource", targetUserId: "", + vipLevel: "", }); function groupItemToForm(item) { @@ -1840,6 +1851,7 @@ export function useResourceGrantListPage() { const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions); const [groupOptions, setGroupOptions] = useState([]); const [resourceOptions, setResourceOptions] = useState([]); + const [vipLevelOptions, setVipLevelOptions] = useState([]); const [optionsLoading, setOptionsLoading] = useState(false); const [loadingAction, setLoadingAction] = useState(""); const filters = useMemo( @@ -1868,28 +1880,53 @@ export function useResourceGrantListPage() { } let ignore = false; setOptionsLoading(true); - Promise.all([ - fetchAllOptionPages(listResources, { status: "active" }), - fetchAllOptionPages(listGifts, { status: "active" }), - listGiftTypes(), - fetchAllOptionPages(listResourceGroups, { status: "active" }), - ]) - .then(([resources, gifts, giftTypes, groups]) => { - if (!ignore) { - 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 || []); - } - }) - .catch((err) => { - if (!ignore) { - showToast(err.message || "加载资源选项失败", "error"); + const optionRequests = []; + if (abilities.canCreateGrant) { + optionRequests.push(loadResourceGrantOptions().then((data) => ({ data, type: "resource" }))); + } else { + setResourceOptions([]); + setGiftOptions([]); + setGiftTypeOptions(defaultGiftTypeOptions); + setGroupOptions([]); + } + if (abilities.canGrantVip) { + optionRequests.push(getVipConfig().then((data) => ({ data, type: "vip" }))); + } else { + setVipLevelOptions([]); + } + Promise.allSettled(optionRequests) + .then((results) => { + if (ignore) { + return; } + 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(() => { if (!ignore) { @@ -1899,10 +1936,10 @@ export function useResourceGrantListPage() { return () => { ignore = true; }; - }, [activeAction, showToast]); + }, [abilities.canCreateGrant, abilities.canGrantVip, activeAction, showToast]); const openCreateGrant = () => { - setForm(emptyGrantForm()); + setForm({ ...emptyGrantForm(), subjectType: defaultGrantSubjectType(abilities) }); setActiveAction("create"); }; @@ -1912,12 +1949,15 @@ export function useResourceGrantListPage() { const submitGrant = async (event) => { event.preventDefault(); - if (!abilities.canCreateGrant) { + if (!abilities.canCreateGrant && !abilities.canGrantVip) { return; } setLoadingAction("grant-create"); try { const parsedForm = parseForm(resourceGrantFormSchema, form); + if (!canSubmitGrantSubject(parsedForm.subjectType, abilities)) { + return; + } const targetUser = await lookupResourceGrantTarget(parsedForm.targetUserId); const targetUserId = normalizedResolvedUserId(targetUser); const payload = buildGrantPayload(parsedForm, targetUserId); @@ -1925,10 +1965,12 @@ export function useResourceGrantListPage() { for (const resourcePayload of payload.resources) { await grantResource(resourcePayload); } - } else { + } else if (payload.subjectType === "resource_group") { await grantResourceGroup(payload.group); + } else { + await grantVip(payload.vip); } - showToast("资源已赠送", "success"); + showToast(payload.subjectType === "vip" ? "VIP已赠送" : "资源已赠送", "success"); closeAction(); setForm(emptyGrantForm()); await result.reload(); @@ -1944,7 +1986,7 @@ export function useResourceGrantListPage() { !abilities.canRevokeGrant || !grant?.grantId || grant.status !== "succeeded" || - !isRevocableGrantSubject(grant.grantSubjectType) + !isRevocableGrant(grant) ) { return; } @@ -2002,6 +2044,7 @@ export function useResourceGrantListPage() { status, submitGrant, targetFilter, + vipLevelOptions, }; } @@ -2348,6 +2391,16 @@ function buildGrantPayload(form, resolvedTargetUserId) { subjectType: "resource", }; } + if (form.subjectType === "vip") { + return { + subjectType: "vip", + vip: { + level: Number(form.vipLevel), + reason, + targetUserId, + }, + }; + } return { group: { commandId: makeCommandId("resource-group-grant"), @@ -2366,8 +2419,31 @@ function grantResourceIds(form) { return form.resourceId ? [form.resourceId] : []; } -function isRevocableGrantSubject(subjectType) { - return subjectType === "resource" || subjectType === "resource_group"; +function isRevocableGrant(grant) { + 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) { diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index 536fe07..31f22fa 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -34,7 +34,7 @@ const grantColumns = [ width: "minmax(220px, 1fr)", render: (grant) => (
- {grantSourceLabel(grant.grantSource)} + {grantSourceLabel(grant.grantSource, grant)}
), }, @@ -127,7 +127,7 @@ export function ResourceGrantListPage() { @@ -155,7 +155,8 @@ export function ResourceGrantListPage() { @@ -176,7 +178,7 @@ function ResourceGrantActions({ grant, page }) { const canRevoke = page.abilities.canRevokeGrant && grant?.status === "succeeded" && - isRevocableGrantSubject(grant?.grantSubjectType); + isRevocableGrant(grant); if (!canRevoke) { return -; } @@ -197,6 +199,7 @@ function ResourceGrantActions({ grant, page }) { } function ResourceGrantDialog({ + abilities, disabled, form, giftOptions, @@ -209,8 +212,30 @@ function ResourceGrantDialog({ optionsLoading, resourceOptions, 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 ( - setForm({ - ...form, - groupId: "", - resourceId: "", - resourceIds: [], - subjectType: event.target.value, - }) - } + onChange={handleSubjectChange} > - 资源 - 资源组 + {resourceGrantAllowed ? 资源 : null} + {resourceGrantAllowed ? 资源组 : null} + {vipGrantAllowed ? VIP : null} {form.subjectType === "resource" ? ( <> setForm({ ...form, quantity: event.target.value })} /> setForm({ ...form, durationDays: event.target.value })} /> - ) : ( + ) : null} + {form.subjectType === "resource_group" ? ( ))} - )} + ) : null} + {form.subjectType === "vip" ? ( + setForm({ ...form, vipLevel: event.target.value })} + > + {hasVipLevels ? ( + (vipLevelOptions || []).map((level) => ( + + VIP{level.level} · {level.name || `VIP${level.level}`} + + )) + ) : ( + + 暂无启用VIP等级 + + )} + + ) : null} {sourceLabel === "-" ? subjectLabel : `${subjectLabel} · ${sourceLabel}`} @@ -388,10 +432,11 @@ function GrantSubject({ grant }) { function GrantReason({ grant }) { const label = reasonLabel(grant.reason); const raw = String(grant.reason || "").trim(); + const showRawReason = raw && raw !== label && !grantReasonLabels[raw]; return (
{label} - {raw && raw !== label ? {raw} : null} + {showRawReason ? {raw} : null}
); } @@ -427,7 +472,9 @@ function GrantOperator({ grant }) { const grantSourceLabels = { achievement: "成就奖励", admin: "后台发放", + admin_grant: "后台VIP赠送", agency_opening: "Agency 开通", + activity_grant: "活动VIP赠送", cp_weekly_rank: "CP 周榜奖励", cumulative_recharge_reward: "累充奖励", first_recharge_reward: "首充奖励", @@ -438,6 +485,7 @@ const grantSourceLabels = { resource_shop: "道具商店", room_rocket: "房间火箭", seven_day_checkin: "七日签到", + vip_purchase: "VIP购买", weekly_star: "周星奖励", wheel_reward: "转盘奖励", }; @@ -448,9 +496,12 @@ const grantReasonLabels = { "resource shop purchase": "道具商店购买", }; -function grantSourceLabel(source) { +function grantSourceLabel(source, grant) { const key = String(source || "").trim(); - return grantSourceLabels[key] || key || "-"; + if (grantSourceLabels[key]) { + return grantSourceLabels[key]; + } + return isVipRewardCommand(grant) ? "VIP奖励" : key || "-"; } function reasonLabel(reason) { @@ -459,12 +510,44 @@ function reasonLabel(reason) { } 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 || "对象"; return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-"; } -function isRevocableGrantSubject(subjectType) { - return subjectType === "resource" || subjectType === "resource_group"; +function isRevocableGrant(grant) { + 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)) { diff --git a/src/features/resources/pages/ResourceGrantListPage.test.jsx b/src/features/resources/pages/ResourceGrantListPage.test.jsx index 2bfb54e..6252672 100644 --- a/src/features/resources/pages/ResourceGrantListPage.test.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.test.jsx @@ -161,11 +161,78 @@ test("resource grant list keeps operator only for user initiated manager grants" 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(); + + 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(); + + 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(); + + 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 = {}) { + const { abilities: abilityPatch, form: formPatch, ...restPatch } = patch; return { abilities: { canCreateGrant: true, + canGrantVip: true, canRevokeGrant: true, + ...abilityPatch, }, activeAction: "", changeQuery: vi.fn(), @@ -173,15 +240,7 @@ function pageFixture(patch = {}) { closeAction: vi.fn(), data: { items: [], page: 1, pageSize: 50, total: 0 }, error: null, - form: { - durationDays: "0", - giftIds: [], - groupId: "", - reason: "", - resourceIds: [], - subjectType: "resource", - targetUserId: "", - }, + form: grantFormFixture(formPatch), giftOptions: [], giftTypeOptions: [], groupOptions: [], @@ -199,6 +258,23 @@ function pageFixture(patch = {}) { setPage: vi.fn(), status: "", submitGrant: vi.fn(), + vipLevelOptions: [], + ...restPatch, + }; +} + +function grantFormFixture(patch = {}) { + return { + durationDays: "7", + giftIds: [], + groupId: "", + quantity: "1", + reason: "", + resourceId: "", + resourceIds: [], + subjectType: "resource", + targetUserId: "", + vipLevel: "", ...patch, }; } diff --git a/src/features/resources/permissions.js b/src/features/resources/permissions.js index 12bc640..0ce5182 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), + canGrantVip: can(PERMISSIONS.vipConfigGrant), canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke), canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate), canDelete: can(PERMISSIONS.resourceDelete), diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index b1ec2b1..0b85e3a 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -446,8 +446,9 @@ export const resourceGrantFormSchema = z reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"), resourceId: 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()]), + vipLevel: z.union([z.string(), z.number()]).optional(), }) .superRefine((value, context) => { const targetUserId = String(value.targetUserId || "").trim(); @@ -455,6 +456,7 @@ export const resourceGrantFormSchema = z const groupId = Number(value.groupId || 0); const quantity = Number(value.quantity || 1); const durationDays = Number(value.durationDays || 0); + const vipLevel = Number(value.vipLevel || 0); if (!/^[1-9]\d*$/.test(targetUserId)) { 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)) { context.addIssue({ @@ -489,18 +505,11 @@ export const resourceGrantFormSchema = z path: ["groupId"], }); } - if (!Number.isInteger(quantity) || quantity <= 0) { + if (value.subjectType === "vip" && (!Number.isInteger(vipLevel) || vipLevel <= 0)) { context.addIssue({ code: "custom", - message: "请输入数量", - path: ["quantity"], - }); - } - if (!Number.isFinite(durationDays) || durationDays < 0) { - context.addIssue({ - code: "custom", - message: "请输入有效天数", - path: ["durationDays"], + message: "请选择VIP等级", + path: ["vipLevel"], }); } }); diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index 94e8a9d..46d85d5 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -302,6 +302,31 @@ describe("resource form schema", () => { 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", () => { const payload = parseForm(resourceShopItemsFormSchema, { items: [ diff --git a/src/features/vip-config/hooks/useVipConfigPage.js b/src/features/vip-config/hooks/useVipConfigPage.js index 8237274..38003b2 100644 --- a/src/features/vip-config/hooks/useVipConfigPage.js +++ b/src/features/vip-config/hooks/useVipConfigPage.js @@ -1,5 +1,5 @@ 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 { listResourceGroups } from "@/features/resources/api"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; @@ -13,11 +13,8 @@ export function useVipConfigPage() { const [form, setForm] = useState({ levels: defaultLevels().map(levelToForm) }); const [resourceGroups, setResourceGroups] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); - const [grantDrawerOpen, setGrantDrawerOpen] = useState(false); - const [grantForm, setGrantForm] = useState(defaultGrantForm()); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); - const [grantSaving, setGrantSaving] = useState(false); const activeCount = useMemo( () => (config.levels || []).filter((level) => level.status === "active").length, @@ -59,22 +56,6 @@ export function useVipConfigPage() { 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) => { setForm((current) => ({ ...current, @@ -82,10 +63,6 @@ export function useVipConfigPage() { })); }; - const updateGrantForm = (patch) => { - setGrantForm((current) => ({ ...current, ...patch })); - }; - const submit = async (event) => { event.preventDefault(); 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 { abilities, activeCount, closeDrawer, - closeGrantDrawer, config, drawerOpen, form, - grantDrawerOpen, - grantForm, - grantSaving, loading, openDrawer, - openGrantDrawer, reload, resourceGroups, saving, submit, - submitGrant, updateLevel, - updateGrantForm, }; } @@ -185,19 +129,6 @@ function completeLevels(levels) { 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) { return { level: Number(level.level || 0), diff --git a/src/features/vip-config/pages/VipConfigPage.jsx b/src/features/vip-config/pages/VipConfigPage.jsx index bbc2a8a..3c1f385 100644 --- a/src/features/vip-config/pages/VipConfigPage.jsx +++ b/src/features/vip-config/pages/VipConfigPage.jsx @@ -1,9 +1,7 @@ -import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import SaveOutlined from "@mui/icons-material/SaveOutlined"; import Drawer from "@mui/material/Drawer"; -import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import { useMemo } from "react"; import { useVipConfigPage } from "@/features/vip-config/hooks/useVipConfigPage.js"; @@ -48,15 +46,6 @@ export function VipConfigPage() { 编辑配置 ) : null} - {page.abilities.canGrant ? ( - - ) : null} @@ -65,76 +54,10 @@ export function VipConfigPage() { -
); } -function VipGrantDrawer({ page }) { - const disabled = !page.abilities.canGrant || page.grantSaving || page.loading; - const activeLevels = (page.config.levels || []).filter((level) => level.status === "active"); - return ( - -
-

赠送VIP

-
-
赠送信息
- page.updateGrantForm({ targetUserId: event.target.value })} - /> - page.updateGrantForm({ level: event.target.value })} - > - {activeLevels.length === 0 ? ( - 暂无启用等级 - ) : ( - activeLevels.map((level) => ( - - VIP{level.level} · {level.name} - - )) - )} - - page.updateGrantForm({ reason: event.target.value })} - /> -
-
- - -
-
-
- ); -} - function VipConfigDrawer({ page }) { const disabled = !page.abilities.canUpdate || page.saving || page.loading; return ( diff --git a/src/features/vip-config/permissions.js b/src/features/vip-config/permissions.js index 544dc37..d7baeb5 100644 --- a/src/features/vip-config/permissions.js +++ b/src/features/vip-config/permissions.js @@ -6,6 +6,5 @@ export function useVipConfigAbilities() { return { canView: can(PERMISSIONS.vipConfigView), canUpdate: can(PERMISSIONS.vipConfigUpdate), - canGrant: can(PERMISSIONS.vipConfigGrant), }; }