From 08872009e1b07342f9a67c7b8a8d2627499a2a63 Mon Sep 17 00:00:00 2001 From: zhx Date: Sun, 5 Jul 2026 01:31:45 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A4=BC=E7=89=A9=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 135 +++++++ src/features/host-org/api.test.ts | 5 +- src/features/host-org/api.ts | 5 +- .../host-org/hooks/useHostManagersPage.js | 1 + .../host-org/pages/HostManagersPage.jsx | 1 + .../host-org/pages/HostManagersPage.test.jsx | 4 + src/features/host-org/schema.test.ts | 16 +- src/features/host-org/schema.ts | 1 + .../components/GiftResourceSelectDrawer.jsx | 22 +- .../GiftResourceSelectDrawer.test.jsx | 68 ++++ .../resources/hooks/useResourcePages.js | 183 +++++++++- .../resources/hooks/useResourcePages.test.js | 71 ++++ src/features/resources/pages/GiftListPage.jsx | 333 ++++++++++++++---- src/features/resources/resources.module.css | 76 ++++ src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 69 ++++ src/shared/api/types.ts | 3 + 17 files changed, 908 insertions(+), 93 deletions(-) diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 4590762..7e5872c 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -817,6 +817,38 @@ "x-permissions": ["bd:create"] } }, + "/admin/managers/{user_id}": { + "put": { + "operationId": "updateManager", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateManagerPayload" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ManagerResponse" + } + }, + "x-permission": "bd:update", + "x-permissions": ["bd:update"] + } + }, "/admin/agencies": { "get": { "operationId": "listAgencies", @@ -7189,6 +7221,36 @@ "bdLeaderCount": { "type": "integer" }, + "canAddAdmin": { + "type": "boolean" + }, + "canAddBdLeader": { + "type": "boolean" + }, + "canAddSuperadmin": { + "type": "boolean" + }, + "canBlockUser": { + "type": "boolean" + }, + "canGrantAvatarFrame": { + "type": "boolean" + }, + "canGrantBadge": { + "type": "boolean" + }, + "canGrantVehicle": { + "type": "boolean" + }, + "canGrantVip": { + "type": "boolean" + }, + "canTransferUserCountry": { + "type": "boolean" + }, + "canUpdateUserLevel": { + "type": "boolean" + }, "createdAtMs": { "type": "integer" }, @@ -7226,6 +7288,36 @@ "type": "object", "required": ["targetUserId"], "properties": { + "canAddAdmin": { + "type": "boolean" + }, + "canAddBdLeader": { + "type": "boolean" + }, + "canAddSuperadmin": { + "type": "boolean" + }, + "canBlockUser": { + "type": "boolean" + }, + "canGrantAvatarFrame": { + "type": "boolean" + }, + "canGrantBadge": { + "type": "boolean" + }, + "canGrantVehicle": { + "type": "boolean" + }, + "canGrantVip": { + "type": "boolean" + }, + "canTransferUserCountry": { + "type": "boolean" + }, + "canUpdateUserLevel": { + "type": "boolean" + }, "contact": { "type": "string", "maxLength": 128 @@ -7235,6 +7327,49 @@ } } }, + "UpdateManagerPayload": { + "type": "object", + "properties": { + "canAddAdmin": { + "type": "boolean" + }, + "canAddBdLeader": { + "type": "boolean" + }, + "canAddSuperadmin": { + "type": "boolean" + }, + "canBlockUser": { + "type": "boolean" + }, + "canGrantAvatarFrame": { + "type": "boolean" + }, + "canGrantBadge": { + "type": "boolean" + }, + "canGrantVehicle": { + "type": "boolean" + }, + "canGrantVip": { + "type": "boolean" + }, + "canTransferUserCountry": { + "type": "boolean" + }, + "canUpdateUserLevel": { + "type": "boolean" + }, + "contact": { + "type": "string", + "maxLength": 128 + }, + "status": { + "type": "string", + "enum": ["active", "disabled"] + } + } + }, "BDProfile": { "type": "object", "required": ["userId"], diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index 0353e50..f320681 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -62,10 +62,11 @@ test("host org list APIs use generated admin paths and filters", async () => { region_id: 7, status: "active", }); - await createManager({ canBlockUser: false, canGrantBadge: false, contact: "+63", targetUserId: "1003" }); + await createManager({ canBlockUser: false, canGrantBadge: false, canGrantVip: true, contact: "+63", targetUserId: "1003" }); await updateManager("1003", { canBlockUser: false, canGrantVehicle: true, + canGrantVip: false, canTransferUserCountry: false, contact: "+63", status: "disabled", @@ -147,6 +148,7 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(JSON.parse(String(managerCreateInit?.body))).toEqual({ canBlockUser: false, canGrantBadge: false, + canGrantVip: true, contact: "+63", targetUserId: "1003", }); @@ -155,6 +157,7 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({ canBlockUser: false, canGrantVehicle: true, + canGrantVip: false, canTransferUserCountry: false, contact: "+63", status: "disabled", diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index 9bc5305..d769174 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -191,9 +191,10 @@ export function createManager(payload: CreateManagerPayload): Promise { - return apiRequest(`/v1/admin/managers/${encodeURIComponent(String(userId))}`, { + const endpoint = API_ENDPOINTS.updateManager; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }), { body: payload, - method: "PUT", + method: endpoint.method, }); } diff --git a/src/features/host-org/hooks/useHostManagersPage.js b/src/features/host-org/hooks/useHostManagersPage.js index 69bd94c..f536c94 100644 --- a/src/features/host-org/hooks/useHostManagersPage.js +++ b/src/features/host-org/hooks/useHostManagersPage.js @@ -14,6 +14,7 @@ export const managerPermissionFields = [ "canGrantAvatarFrame", "canGrantVehicle", "canGrantBadge", + "canGrantVip", "canUpdateUserLevel", "canAddBdLeader", "canAddAdmin", diff --git a/src/features/host-org/pages/HostManagersPage.jsx b/src/features/host-org/pages/HostManagersPage.jsx index ac86ada..c2005d0 100644 --- a/src/features/host-org/pages/HostManagersPage.jsx +++ b/src/features/host-org/pages/HostManagersPage.jsx @@ -19,6 +19,7 @@ const managerPermissionOptions = [ { key: "canGrantAvatarFrame", label: "头像框" }, { key: "canGrantVehicle", label: "Car" }, { key: "canGrantBadge", label: "徽章" }, + { key: "canGrantVip", label: "VIP" }, { key: "canUpdateUserLevel", label: "升级等级" }, { key: "canAddBdLeader", label: "BD Leader" }, { key: "canAddAdmin", label: "Admin" }, diff --git a/src/features/host-org/pages/HostManagersPage.test.jsx b/src/features/host-org/pages/HostManagersPage.test.jsx index 52d0a56..52aad46 100644 --- a/src/features/host-org/pages/HostManagersPage.test.jsx +++ b/src/features/host-org/pages/HostManagersPage.test.jsx @@ -21,6 +21,8 @@ test("manager status column uses a switch and toggles through the page hook", () render(); + expect(screen.getByText("VIP")).toBeInTheDocument(); + const statusSwitch = screen.getByRole("switch", { name: "停用经理" }); expect(statusSwitch).toBeChecked(); @@ -55,6 +57,7 @@ function pageFixture(patch = {}) { canGrantAvatarFrame: true, canGrantBadge: true, canGrantVehicle: true, + canGrantVip: true, canTransferUserCountry: true, canUpdateUserLevel: true, contact: "", @@ -88,6 +91,7 @@ function managerFixture() { canGrantAvatarFrame: true, canGrantBadge: true, canGrantVehicle: true, + canGrantVip: true, canTransferUserCountry: true, canUpdateUserLevel: true, contact: "+63 910 323 3670", diff --git a/src/features/host-org/schema.test.ts b/src/features/host-org/schema.test.ts index 4ad2987..2104d1c 100644 --- a/src/features/host-org/schema.test.ts +++ b/src/features/host-org/schema.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema } from "./schema"; +import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema, createManagerSchema } from "./schema"; test("coin seller contact accepts plus sign with large internal user id", () => { const result = coinSellerStatusSchema.safeParse({ @@ -40,3 +40,17 @@ test("agency action schemas preserve large agency ids as strings", () => { expect(joinResult.data.agencyId).toBe(agencyId); } }); + +test("manager schema defaults vip grant permission on and preserves explicit false", () => { + const defaultResult = createManagerSchema.safeParse({ targetUserId: "1003" }); + const disabledResult = createManagerSchema.safeParse({ canGrantVip: false, targetUserId: "1003" }); + + expect(defaultResult.success).toBe(true); + expect(disabledResult.success).toBe(true); + if (defaultResult.success) { + expect(defaultResult.data.canGrantVip).toBe(true); + } + if (disabledResult.success) { + expect(disabledResult.data.canGrantVip).toBe(false); + } +}); diff --git a/src/features/host-org/schema.ts b/src/features/host-org/schema.ts index a7b4b59..2aa2f78 100644 --- a/src/features/host-org/schema.ts +++ b/src/features/host-org/schema.ts @@ -116,6 +116,7 @@ export const createManagerSchema = z.object({ canGrantAvatarFrame: z.boolean().optional().default(true), canGrantBadge: z.boolean().optional().default(true), canGrantVehicle: z.boolean().optional().default(true), + canGrantVip: z.boolean().optional().default(true), canTransferUserCountry: z.boolean().optional().default(true), canUpdateUserLevel: z.boolean().optional().default(true), contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""), diff --git a/src/features/resources/components/GiftResourceSelectDrawer.jsx b/src/features/resources/components/GiftResourceSelectDrawer.jsx index c7f4e3c..42abd2b 100644 --- a/src/features/resources/components/GiftResourceSelectDrawer.jsx +++ b/src/features/resources/components/GiftResourceSelectDrawer.jsx @@ -21,6 +21,7 @@ const drawerZIndex = 1600; export function GiftResourceSelectField({ disabled = false, drawerTitle = "选择资源", + excludedResourceIds = [], giftTypes = [], gifts = [], label = "资源", @@ -36,6 +37,8 @@ export function GiftResourceSelectField({ const [activeType, setActiveType] = useState(""); const selectedIds = useMemo(() => selectedResourceIds.map((id) => String(id)), [selectedResourceIds]); const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const excludedIds = useMemo(() => excludedResourceIds.map((id) => String(id)), [excludedResourceIds]); + const excludedIdSet = useMemo(() => new Set(excludedIds), [excludedIds]); const giftsByResourceId = useMemo(() => { const items = new Map(); gifts.forEach((gift) => { @@ -49,10 +52,19 @@ export function GiftResourceSelectField({ () => buildResourceItems(resources, giftsByResourceId), [giftsByResourceId, resources], ); - const itemCounts = useMemo(() => countResourceItems(resourceItems), [resourceItems]); + const visibleResourceItems = useMemo( + () => + resourceItems.filter((item) => { + const resourceId = String(item.resource.resourceId); + return !excludedIdSet.has(resourceId) || selectedIdSet.has(resourceId); + }), + [excludedIdSet, resourceItems, selectedIdSet], + ); + const visibleResources = useMemo(() => visibleResourceItems.map((item) => item.resource), [visibleResourceItems]); + const itemCounts = useMemo(() => countResourceItems(visibleResourceItems), [visibleResourceItems]); const categoryOptions = useMemo( - () => buildCategoryOptions(giftTypes, resources, resourceItems), - [giftTypes, resourceItems, resources], + () => buildCategoryOptions(giftTypes, visibleResources, visibleResourceItems), + [giftTypes, visibleResourceItems, visibleResources], ); const activeCategory = categoryOptions.some((item) => item.value === activeType) ? activeType @@ -76,7 +88,7 @@ export function GiftResourceSelectField({ }, [resourcesById, selectedIds]); const filteredResources = useMemo(() => { const keyword = query.trim().toLowerCase(); - return resourceItems.filter((item) => { + return visibleResourceItems.filter((item) => { if (activeCategory && item.categoryValue !== activeCategory) { return false; } @@ -99,7 +111,7 @@ export function GiftResourceSelectField({ .toLowerCase(); return haystack.includes(keyword); }); - }, [activeCategory, query, resourceItems]); + }, [activeCategory, query, visibleResourceItems]); const openDrawer = () => { if (!disabled && !loading) { diff --git a/src/features/resources/components/GiftResourceSelectDrawer.test.jsx b/src/features/resources/components/GiftResourceSelectDrawer.test.jsx index 05abcf3..b7e92f9 100644 --- a/src/features/resources/components/GiftResourceSelectDrawer.test.jsx +++ b/src/features/resources/components/GiftResourceSelectDrawer.test.jsx @@ -117,3 +117,71 @@ test("gift resource select drawer filters by gift type and supports multiple sel expect(changes.at(-1)).toEqual(["11", "12"]); expect(screen.getByDisplayValue("Rose Gift,CP Ring")).toBeInTheDocument(); }); + +test("gift resource select drawer hides excluded gift resources from candidates", () => { + function Harness() { + const [value, setValue] = useState([]); + return ( + + ); + } + + render(); + + fireEvent.click(screen.getByPlaceholderText("请选择资源")); + fireEvent.click(screen.getByRole("tab", { name: "普通礼物" })); + + expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument(); + expect(screen.getByText("当前无可选资源")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "CP礼物" })); + expect(screen.getByText("CP Ring")).toBeInTheDocument(); +}); + +test("gift resource select drawer hides excluded resources but keeps selected resources removable", () => { + const changes = []; + + function Harness() { + const [value, setValue] = useState(["11"]); + return ( + { + changes.push(nextValue); + setValue(nextValue); + }} + /> + ); + } + + render(); + + fireEvent.click(screen.getByPlaceholderText("请选择资源")); + + expect(screen.getByText("Rose Gift")).toBeInTheDocument(); + expect(screen.queryByText("VIP Vehicle")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "CP礼物" })); + fireEvent.click(screen.getByText("CP Ring")); + + expect(changes.at(-1)).toEqual(["11", "12"]); + expect(screen.getByDisplayValue("Rose Gift,CP Ring")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "普通礼物" })); + fireEvent.click(screen.getByText("Rose Gift")); + + expect(changes.at(-1)).toEqual(["12"]); + expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument(); + expect(screen.getByDisplayValue("CP Ring")).toBeInTheDocument(); +}); diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 94f3ea1..5087af9 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -163,6 +163,7 @@ const emptyGiftForm = (gift = {}) => { priceVersion: gift.priceVersion || "default", regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String), resourceId: gift.resourceId ? String(gift.resourceId) : "", + items: [], sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0", }; }; @@ -180,6 +181,52 @@ export function applyGiftResourceSelection(form, resource, fillIdentity = true) return applyGiftResourcePrice(nextForm, resource); } +export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) { + const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource])); + const existingDraftsByResourceId = new Map( + (form.items || []) + .filter((item) => item?.resourceId) + .map((item) => [String(item.resourceId), item]), + ); + + return resourceIds + .map((resourceId) => String(resourceId || "").trim()) + .filter(Boolean) + .map((resourceId) => { + const existingDraft = existingDraftsByResourceId.get(resourceId); + if (existingDraft) { + return existingDraft; + } + const resource = resourcesById.get(resourceId) || { resourceId }; + return { + ...applyGiftResourceSelection(giftDraftDefaultsFromForm(form), resource, true), + resource, + }; + }); +} + +function giftDraftDefaultsFromForm(form) { + const giftTypeCode = form.giftTypeCode || "normal"; + const cpRelationType = giftTypeCode === cpGiftTypeCode ? form.cpRelationType || defaultCPRelationType : ""; + return { + chargeAssetType: form.chargeAssetType || "COIN", + coinPrice: "", + cpRelationType, + effectTypes: form.effectTypes || [], + effectiveFrom: form.effectiveFrom || "", + effectiveTo: form.effectiveTo || "", + enabled: form.enabled !== false, + giftId: "", + giftTypeCode, + name: "", + presentationJson: normalizeGiftPresentationJson(form.presentationJson || "{}", giftTypeCode, cpRelationType), + priceVersion: form.priceVersion || "default", + regionIds: form.regionIds?.length ? form.regionIds : ["0"], + resourceId: "", + sortOrder: form.sortOrder || "0", + }; +} + export function applyGiftPriceDefaults(form, coinPrice) { const priceValue = String(coinPrice ?? ""); return { @@ -234,12 +281,12 @@ export function normalizeGiftPresentationJson(presentationJson, giftTypeCode, cp return isCPGift ? cpPresentationJson(nextPresentation, cpRelationType) : JSON.stringify(nextPresentation); } -export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize) { +export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize, maxPages = optionMaxPages) { const items = []; let total = Number.POSITIVE_INFINITY; // 后台会把选项接口的 page_size 截断到 100;抽屉候选项必须按真实分页拉全,避免只展示第一页资源。 - for (let page = 1; page <= optionMaxPages; page += 1) { + for (let page = 1; page <= maxPages; page += 1) { const data = await fetcher({ ...query, page, page_size: pageSize }); const pageItems = Array.isArray(data?.items) ? data.items : []; const responsePageSize = Number(data?.pageSize || data?.page_size || pageSize) || pageSize; @@ -1049,6 +1096,8 @@ export function useGiftListPage() { const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false); const [giftTypesLoading, setGiftTypesLoading] = useState(false); const [resourceOptions, setResourceOptions] = useState([]); + const [giftResourceGiftOptions, setGiftResourceGiftOptions] = useState([]); + const [existingGiftResourceIds, setExistingGiftResourceIds] = useState([]); const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false); const { loadingRegions, regionOptions } = useRegionOptions(); const filters = useMemo( @@ -1113,10 +1162,25 @@ export function useGiftListPage() { } let ignore = false; setResourceOptionsLoading(true); - fetchAllOptionPages(listResources, { resource_type: "gift" }) - .then((data) => { + const optionRequests = + activeAction === "create" + ? [ + fetchAllOptionPages(listResources, { resource_type: "gift" }), + fetchAllOptionPages(listGifts, {}, optionPageSize, 1000), + ] + : [fetchAllOptionPages(listResources, { resource_type: "gift" }), Promise.resolve({ items: [] })]; + Promise.all(optionRequests) + .then(([resourceData, giftOptionsData]) => { if (!ignore) { - setResourceOptions(toGiftResourceOptions(data.items || [], selectedGift)); + const giftOptions = giftOptionsData.items || []; + setGiftResourceGiftOptions(giftOptions); + setExistingGiftResourceIds( + giftOptions + .map((gift) => gift?.resourceId) + .filter((resourceId) => resourceId || resourceId === 0) + .map(String), + ); + setResourceOptions(toGiftResourceOptions(resourceData.items || [], selectedGift)); } }) .catch((err) => { @@ -1165,6 +1229,66 @@ export function useGiftListPage() { }); }; + const selectGiftResources = (resourceIds) => { + setForm((currentForm) => { + const items = buildGiftResourceDrafts(currentForm, resourceIds, resourceOptions); + return { + ...currentForm, + items, + resourceId: items[0]?.resourceId || "", + }; + }); + }; + + const updateGiftDraft = (resourceId, patch) => { + setForm((currentForm) => ({ + ...currentForm, + items: (currentForm.items || []).map((item) => { + if (String(item.resourceId) !== String(resourceId)) { + return item; + } + const nextItem = { ...item, ...patch }; + if (Object.prototype.hasOwnProperty.call(patch, "giftTypeCode")) { + const cpRelationType = + nextItem.giftTypeCode === cpGiftTypeCode + ? nextItem.cpRelationType || defaultCPRelationType + : ""; + return { + ...nextItem, + cpRelationType, + presentationJson: normalizeGiftPresentationJson( + nextItem.presentationJson, + nextItem.giftTypeCode, + cpRelationType, + ), + }; + } + if (Object.prototype.hasOwnProperty.call(patch, "cpRelationType")) { + return { + ...nextItem, + presentationJson: normalizeGiftPresentationJson( + nextItem.presentationJson, + nextItem.giftTypeCode, + nextItem.cpRelationType, + ), + }; + } + return nextItem; + }), + })); + }; + + const removeGiftDraft = (resourceId) => { + setForm((currentForm) => { + const items = (currentForm.items || []).filter((item) => String(item.resourceId) !== String(resourceId)); + return { + ...currentForm, + items, + resourceId: items[0]?.resourceId || "", + }; + }); + }; + const changeGiftPrice = (coinPrice) => { setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice)); }; @@ -1249,21 +1373,55 @@ export function useGiftListPage() { return; } setLoadingAction(editing ? "gift-edit" : "gift-create"); + const createdDrafts = []; + let shouldReload = false; try { - const payload = buildGiftPayload(parseForm(giftFormSchema, form)); + const createDrafts = !editing && Array.isArray(form.items) && form.items.length ? form.items : [form]; if (editing) { + const payload = buildGiftPayload(parseForm(giftFormSchema, form)); await updateGift(selectedGift.giftId, payload); } else { - await createGift(payload); + const payloads = createDrafts.map((draft) => ({ + draft, + payload: buildGiftPayload(parseForm(giftFormSchema, draft)), + })); + for (const { draft, payload } of payloads) { + await createGift(payload); + createdDrafts.push(draft); + } } - showToast(editing ? "礼物已更新" : "礼物已创建", "success"); + showToast(editing ? "礼物已更新" : `已创建 ${createDrafts.length} 个礼物`, "success"); closeAction(); - await result.reload(); + shouldReload = true; } catch (err) { - showToast(err.message || "操作失败", "error"); + if (!editing && createdDrafts.length) { + const createdResourceIds = new Set(createdDrafts.map((draft) => String(draft.resourceId))); + setExistingGiftResourceIds((current) => Array.from(new Set([...current, ...createdResourceIds]))); + setForm((currentForm) => { + const items = (currentForm.items || []).filter( + (item) => !createdResourceIds.has(String(item.resourceId)), + ); + return { + ...currentForm, + items, + resourceId: items[0]?.resourceId || "", + }; + }); + shouldReload = true; + showToast(`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`, "error"); + } else { + showToast(err.message || "操作失败", "error"); + } } finally { setLoadingAction(""); } + if (shouldReload) { + try { + await result.reload(); + } catch (err) { + showToast(err.message || "刷新礼物列表失败", "error"); + } + } }; const toggleGift = async (gift, nextEnabled = gift.status !== "active") => { @@ -1389,6 +1547,8 @@ export function useGiftListPage() { giftTypeForm, giftTypeOptions: giftTypes, giftTypesLoading, + giftResourceGiftOptions, + existingGiftResourceIds, loadingAction, loadingRegions, openCreateGift, @@ -1401,12 +1561,15 @@ export function useGiftListPage() { selectedGift, resetFilters, selectGiftResource, + selectGiftResources, + removeGiftDraft, setForm, saveGiftSortOrder, submitGiftTypes, status, submitGift, toggleGift, + updateGiftDraft, updateGiftTypeFormItem, }; } diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js index 4f85e70..9d5f8d7 100644 --- a/src/features/resources/hooks/useResourcePages.test.js +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -2,6 +2,7 @@ import { expect, test, vi } from "vitest"; import { applyGiftPriceDefaults, applyGiftResourceSelection, + buildGiftResourceDrafts, cpRelationTypeFromGift, cpRelationTypeFromPresentationJson, cpRelationTypeShortLabel, @@ -47,6 +48,76 @@ test("fills gift price without gift points", () => { }); }); +test("builds gift drafts from multiple selected resources and preserves edits", () => { + const form = { + chargeAssetType: "COIN", + cpRelationType: "", + effectTypes: ["animation"], + effectiveFrom: "", + effectiveTo: "", + enabled: true, + giftTypeCode: "normal", + items: [ + { + chargeAssetType: "COIN", + coinPrice: "88", + effectTypes: [], + effectiveFrom: "", + effectiveTo: "", + enabled: false, + giftId: "custom_rose", + giftTypeCode: "normal", + name: "Custom Rose", + presentationJson: "{}", + priceVersion: "default", + regionIds: ["0"], + resourceId: "11", + sortOrder: "7", + }, + ], + presentationJson: "{}", + priceVersion: "default", + regionIds: ["0", "1001"], + sortOrder: "3", + }; + + const drafts = buildGiftResourceDrafts(form, ["11", "12"], [ + { + coinPrice: 10, + name: "Rose", + priceType: "coin", + resourceCode: "gift_rose", + resourceId: 11, + }, + { + coinPrice: 20, + name: "Ring", + priceType: "coin", + resourceCode: "gift_ring", + resourceId: 12, + }, + ]); + + expect(drafts).toHaveLength(2); + expect(drafts[0]).toMatchObject({ + coinPrice: "88", + giftId: "custom_rose", + name: "Custom Rose", + resourceId: "11", + sortOrder: "7", + }); + expect(drafts[1]).toMatchObject({ + coinPrice: "20", + effectTypes: ["animation"], + giftId: "12", + giftTypeCode: "normal", + name: "Ring", + regionIds: ["0", "1001"], + resourceId: "12", + sortOrder: "3", + }); +}); + test("fetches all option pages instead of only the first resource page", async () => { const fetcher = vi .fn() diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index f1f526c..8f9cf33 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -43,6 +43,7 @@ import { resourceStatusFilters, } from "@/features/resources/constants.js"; import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx"; +import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx"; import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx"; import { cpRelationTypeFromGift, @@ -137,7 +138,7 @@ const baseColumns = (giftTypeOptions) => [ export function GiftListPage() { const page = useGiftListPage(); - const items = page.data.items || []; + const items = useMemo(() => page.data.items || [], [page.data.items]); const total = page.data.total || 0; const [selectedGiftIds, setSelectedGiftIds] = useState([]); const isEditing = page.activeAction === "edit"; @@ -419,7 +420,14 @@ function GiftSortOrderCell({ gift, page }) { function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) { const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading; - const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId); + const isCreateMode = mode === "create"; + const giftDrafts = Array.isArray(form.items) ? form.items : []; + const selectedResourceIds = isCreateMode + ? giftDrafts.map((item) => item.resourceId).filter(Boolean) + : form.resourceId + ? [form.resourceId] + : []; + const showGiftIdentityFields = mode === "edit" && Boolean(form.resourceId); const isCPGiftType = form.giftTypeCode === "cp"; return ( - page.selectGiftResource(resourceId)} - /> + {isCreateMode ? ( + + ) : ( + page.selectGiftResource(resourceId)} + /> + )} {showGiftIdentityFields ? ( <> ) : null} - page.changeGiftFormTypeCode(event.target.value)} - > - {page.giftTypeOptions.map((item) => ( - - {item.displayName || item.tabKey} - - ))} - - {isCPGiftType ? ( + {!isCreateMode ? ( + page.changeGiftFormTypeCode(event.target.value)} + > + {page.giftTypeOptions.map((item) => ( + + {item.displayName || item.tabKey} + + ))} + + ) : null} + {!isCreateMode && isCPGiftType ? ( - - - + {isCreateMode ? ( + + ) : ( + <> + + + + page.setForm({ ...form, sortOrder: event.target.value })} + /> + page.setForm({ ...form, enabled: checked })} + /> + + + + + page.setForm({ ...form, regionIds })} + /> + page.setForm({ ...form, effectiveFrom })} + /> + page.setForm({ ...form, effectiveTo })} + /> + page.setForm({ ...form, effectTypes })} + /> + + + + )} + + ); +} + +function GiftDraftSection({ disabled, drafts, page, regionOptions }) { + const regionLabels = buildRegionLabelMap(regionOptions); + if (!drafts.length) { + return ( + +
当前无数据
+
+ ); + } + + return ( + +
+ {drafts.map((draft, index) => ( + + ))} +
+
+ ); +} + +function GiftDraftEditor({ disabled, draft, index, page, regionLabels, regionOptions }) { + const isCPGiftType = draft.giftTypeCode === "cp"; + const updateDraft = (patch) => page.updateGiftDraft(draft.resourceId, patch); + return ( +
+
+ +
+ {draft.name || draft.giftId || `礼物 ${index + 1}`} + 资源 {draft.resourceId || "-"} +
+ page.removeGiftDraft(draft.resourceId)} + > + + +
+ + updateDraft({ name: event.target.value })} + /> + updateDraft({ giftId: event.target.value })} + /> + + updateDraft({ sortOrder: event.target.value })} + /> + updateDraft({ giftTypeCode: event.target.value })} + > + {page.giftTypeOptions.map((item) => ( + + {item.displayName || item.tabKey} + + ))} + + {isCPGiftType ? ( page.setForm({ ...form, sortOrder: event.target.value })} - /> - page.setForm({ ...form, enabled: checked })} - /> - - - - - page.setForm({ ...form, regionIds })} - /> - page.setForm({ ...form, effectiveFrom })} - /> - page.setForm({ ...form, effectiveTo })} - /> - page.setForm({ ...form, effectTypes })} - /> - - - + label="关系类型" + required + select + value={draft.cpRelationType || "cp"} + onChange={(event) => updateDraft({ cpRelationType: event.target.value })} + > + {cpRelationTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + ) : null} + updateDraft({ regionIds })} + /> + updateDraft({ enabled: checked })} + /> + updateDraft({ effectiveFrom })} + /> + updateDraft({ effectiveTo })} + /> + updateDraft({ effectTypes })} + /> + +
+ ); +} + +function GiftDraftThumb({ draft }) { + const resource = draft.resource || {}; + const imageUrl = imageURL(resource.previewUrl || resource.assetUrl || resource.animationUrl); + return ( + + {imageUrl ? : } + ); } diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 0f27230..3612cfa 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -308,6 +308,82 @@ color: var(--text-tertiary); } +.giftDraftList { + display: grid; + gap: var(--space-3); +} + +.giftDraftItem { + display: grid; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.giftDraftHeader { + display: grid; + min-width: 0; + align-items: center; + gap: var(--space-2); + grid-template-columns: 44px minmax(0, 1fr) var(--control-height); +} + +.giftDraftThumb { + display: inline-flex; + width: 44px; + height: 44px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card-strong); + color: var(--text-secondary); +} + +.giftDraftThumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.giftDraftTitle { + display: grid; + min-width: 0; + gap: 3px; +} + +.giftDraftTitle span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.giftDraftTitle span:first-child { + color: var(--text-primary); + font-weight: 750; +} + +.giftDraftTitle span:last-child { + color: var(--text-tertiary); + font-size: 12px; +} + +.giftDraftEmpty { + display: flex; + min-height: 120px; + align-items: center; + justify-content: center; + border: 1px solid var(--border-muted); + border-radius: var(--radius-sm); + background: var(--bg-card-strong); + color: var(--text-tertiary); + font-weight: 650; +} + .batchInput { display: none; } diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index e62027b..260ef5d 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -299,6 +299,7 @@ export const API_OPERATIONS = { updateHostAgencyPolicy: "updateHostAgencyPolicy", updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig", updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig", + updateManager: "updateManager", updateMenu: "updateMenu", updateMenuVisible: "updateMenuVisible", updatePermission: "updatePermission", @@ -2329,6 +2330,13 @@ export const API_ENDPOINTS: Record = { permission: "invite-activity-reward:update", permissions: ["invite-activity-reward:update"] }, + updateManager: { + method: "PUT", + operationId: API_OPERATIONS.updateManager, + path: "/v1/admin/managers/{user_id}", + permission: "bd:update", + permissions: ["bd:update"] + }, updateMenu: { method: "PATCH", operationId: API_OPERATIONS.updateMenu, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index df08fa4..e98d72d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -676,6 +676,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/managers/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateManager"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/agencies": { parameters: { query?: never; @@ -3997,6 +4013,16 @@ export interface components { Manager: { avatar?: string; bdLeaderCount?: number; + canAddAdmin?: boolean; + canAddBdLeader?: boolean; + canAddSuperadmin?: boolean; + canBlockUser?: boolean; + canGrantAvatarFrame?: boolean; + canGrantBadge?: boolean; + canGrantVehicle?: boolean; + canGrantVip?: boolean; + canTransferUserCountry?: boolean; + canUpdateUserLevel?: boolean; createdAtMs?: number; contact?: string; displayUserId?: string; @@ -4010,9 +4036,34 @@ export interface components { username?: string; }; CreateManagerPayload: { + canAddAdmin?: boolean; + canAddBdLeader?: boolean; + canAddSuperadmin?: boolean; + canBlockUser?: boolean; + canGrantAvatarFrame?: boolean; + canGrantBadge?: boolean; + canGrantVehicle?: boolean; + canGrantVip?: boolean; + canTransferUserCountry?: boolean; + canUpdateUserLevel?: boolean; contact?: string; targetUserId: string; }; + UpdateManagerPayload: { + canAddAdmin?: boolean; + canAddBdLeader?: boolean; + canAddSuperadmin?: boolean; + canBlockUser?: boolean; + canGrantAvatarFrame?: boolean; + canGrantBadge?: boolean; + canGrantVehicle?: boolean; + canGrantVip?: boolean; + canTransferUserCountry?: boolean; + canUpdateUserLevel?: boolean; + contact?: string; + /** @enum {string} */ + status?: "active" | "disabled"; + }; BDProfile: { agencyCount?: number; createdAtMs?: number; @@ -5416,6 +5467,24 @@ export interface operations { 201: components["responses"]["ManagerResponse"]; }; }; + updateManager: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateManagerPayload"]; + }; + }; + responses: { + 200: components["responses"]["ManagerResponse"]; + }; + }; listAgencies: { parameters: { query?: { diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 03e92a2..58a45bb 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -681,6 +681,7 @@ export interface ManagerDto { canGrantAvatarFrame?: boolean; canGrantBadge?: boolean; canGrantVehicle?: boolean; + canGrantVip?: boolean; canTransferUserCountry?: boolean; canUpdateUserLevel?: boolean; contact?: string; @@ -703,6 +704,7 @@ export interface CreateManagerPayload { canGrantAvatarFrame?: boolean; canGrantBadge?: boolean; canGrantVehicle?: boolean; + canGrantVip?: boolean; canTransferUserCountry?: boolean; canUpdateUserLevel?: boolean; contact?: string; @@ -717,6 +719,7 @@ export interface UpdateManagerPayload { canGrantAvatarFrame?: boolean; canGrantBadge?: boolean; canGrantVehicle?: boolean; + canGrantVip?: boolean; canTransferUserCountry?: boolean; canUpdateUserLevel?: boolean; contact?: string;