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 ? : 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) => (
+
+ ))
+ ) : (
+
+ )}
+
+ ) : 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 ? (
- }
- onClick={page.openGrantDrawer}
- >
- 赠送VIP
-
- ) : 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 (
-
-
-
- );
-}
-
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),
};
}