diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index fd71559..e118b1a 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2959,6 +2959,28 @@ "x-permissions": ["resource-shop:update"] } }, + "/admin/resource-groups/identity-auto-grant-config": { + "get": { + "operationId": "getResourceIdentityAutoGrantConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-group:view", + "x-permissions": ["resource-group:view"] + }, + "put": { + "operationId": "updateResourceIdentityAutoGrantConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-group:update", + "x-permissions": ["resource-group:update"] + } + }, "/admin/resource-groups": { "get": { "operationId": "listResourceGroups", diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index d8b2d27..b6a51ad 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -61,8 +61,13 @@ test("host org list APIs use generated admin paths and filters", async () => { region_id: 7, status: "active", }); - await createManager({ canBlockUser: false, contact: "+63", targetUserId: "1003" }); - await updateManager("1003", { canBlockUser: false, canGrantVehicle: true, contact: "+63" }); + await createManager({ canBlockUser: false, canGrantBadge: false, contact: "+63", targetUserId: "1003" }); + await updateManager("1003", { + canBlockUser: false, + canGrantVehicle: true, + canTransferUserCountry: false, + contact: "+63", + }); await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" }); await listCoinSellers({ page: 1, @@ -133,6 +138,7 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(managerCreateInit?.method).toBe("POST"); expect(JSON.parse(String(managerCreateInit?.body))).toEqual({ canBlockUser: false, + canGrantBadge: false, contact: "+63", targetUserId: "1003", }); @@ -141,6 +147,7 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({ canBlockUser: false, canGrantVehicle: true, + canTransferUserCountry: false, contact: "+63", }); expect(String(hostUrl)).toContain("/api/v1/admin/hosts?"); diff --git a/src/features/host-org/hooks/useHostManagersPage.js b/src/features/host-org/hooks/useHostManagersPage.js index 1bf6623..991a0db 100644 --- a/src/features/host-org/hooks/useHostManagersPage.js +++ b/src/features/host-org/hooks/useHostManagersPage.js @@ -12,11 +12,13 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 }; export const managerPermissionFields = [ "canGrantAvatarFrame", "canGrantVehicle", + "canGrantBadge", "canUpdateUserLevel", "canAddBdLeader", "canAddAdmin", "canAddSuperadmin", "canBlockUser", + "canTransferUserCountry", ]; const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true])); diff --git a/src/features/host-org/pages/HostManagersPage.jsx b/src/features/host-org/pages/HostManagersPage.jsx index bc9693c..738fa2c 100644 --- a/src/features/host-org/pages/HostManagersPage.jsx +++ b/src/features/host-org/pages/HostManagersPage.jsx @@ -18,11 +18,13 @@ import styles from "@/features/host-org/host-org.module.css"; const managerPermissionOptions = [ { key: "canGrantAvatarFrame", label: "头像框" }, { key: "canGrantVehicle", label: "Car" }, + { key: "canGrantBadge", label: "徽章" }, { key: "canUpdateUserLevel", label: "升级等级" }, { key: "canAddBdLeader", label: "BD Leader" }, { key: "canAddAdmin", label: "Admin" }, { key: "canAddSuperadmin", label: "Superadmin" }, { key: "canBlockUser", label: "Block User" }, + { key: "canTransferUserCountry", label: "转移国家" }, ]; const managerColumns = [ diff --git a/src/features/host-org/schema.ts b/src/features/host-org/schema.ts index 003c749..ae674c9 100644 --- a/src/features/host-org/schema.ts +++ b/src/features/host-org/schema.ts @@ -114,7 +114,9 @@ export const createManagerSchema = z.object({ canAddSuperadmin: z.boolean().optional().default(true), canBlockUser: z.boolean().optional().default(true), canGrantAvatarFrame: z.boolean().optional().default(true), + canGrantBadge: z.boolean().optional().default(true), canGrantVehicle: 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(""), targetUserId: userIdSchema, diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index d2a05aa..57f7e22 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -14,6 +14,7 @@ import { enableGift, enableResource, enableResourceGroup, + getResourceIdentityAutoGrantConfig, grantResource, grantResourceGroup, listEmojiPackCategories, @@ -28,6 +29,7 @@ import { updateGift, updateGiftTypes, updateResource, + updateResourceIdentityAutoGrantConfig, updateResourceMp4Layouts, updateResourceGroup, upsertResourceShopItems, @@ -262,6 +264,58 @@ test("resource APIs use generated admin paths", async () => { expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: "1001" }); }); +test("resource identity auto grant config APIs use generated admin paths", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + items: [ + { identity_type: "host", resource_group_id: 11 }, + { identityType: "bd", resourceGroupId: 12 }, + ], + }, + }), + ), + ), + ); + + const config = await getResourceIdentityAutoGrantConfig(); + await updateResourceIdentityAutoGrantConfig({ + items: [ + { identity_type: "host", resource_group_id: 11 }, + { identity_type: "bd", resource_group_id: 12 }, + { identity_type: "bd_leader", resource_group_id: 13 }, + { identity_type: "agency", resource_group_id: 14 }, + { identity_type: "manager", resource_group_id: 0 }, + ], + }); + + const [getUrl, getInit] = vi.mocked(fetch).mock.calls[0]; + const [putUrl, putInit] = vi.mocked(fetch).mock.calls[1]; + + expect(config.items).toMatchObject([ + { identityType: "host", resourceGroupId: 11 }, + { identityType: "bd", resourceGroupId: 12 }, + ]); + expect(String(getUrl)).toContain("/api/v1/admin/resource-groups/identity-auto-grant-config"); + expect(getInit?.method).toBe("GET"); + expect(String(putUrl)).toContain("/api/v1/admin/resource-groups/identity-auto-grant-config"); + expect(putInit?.method).toBe("PUT"); + expect(JSON.parse(String(putInit?.body))).toEqual({ + items: [ + { identity_type: "host", resource_group_id: 11 }, + { identity_type: "bd", resource_group_id: 12 }, + { identity_type: "bd_leader", resource_group_id: 13 }, + { identity_type: "agency", resource_group_id: 14 }, + { identity_type: "manager", resource_group_id: 0 }, + ], + }); +}); + test("resource mp4 layout batch API uses narrow update endpoint", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 21c38fc..cb14ded 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -1,6 +1,7 @@ import { apiRequest } from "@/shared/api/request"; import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types"; +import { resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js"; export interface ResourceDto { appCode?: string; @@ -108,6 +109,30 @@ export interface ResourceGroupDto { updatedAtMs?: number; } +export type ResourceIdentityAutoGrantType = "host" | "bd" | "bd_leader" | "agency" | "manager"; + +export interface ResourceIdentityAutoGrantItemDto { + identityType: ResourceIdentityAutoGrantType; + resourceGroupId: number; + resourceGroup?: ResourceGroupDto; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface ResourceIdentityAutoGrantConfigDto { + items: ResourceIdentityAutoGrantItemDto[]; + updatedByAdminId?: number; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface ResourceIdentityAutoGrantConfigPayload { + items: Array<{ + identity_type: ResourceIdentityAutoGrantType; + resource_group_id: number; + }>; +} + export interface ResourceShopItemDto { appCode?: string; shopItemId: number; @@ -446,6 +471,29 @@ export function disableResourceGroup(groupId: EntityId): Promise { + const endpoint = API_ENDPOINTS.getResourceIdentityAutoGrantConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.getResourceIdentityAutoGrantConfig), + { + method: endpoint.method, + }, + ).then(normalizeResourceIdentityAutoGrantConfig); +} + +export function updateResourceIdentityAutoGrantConfig( + payload: ResourceIdentityAutoGrantConfigPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateResourceIdentityAutoGrantConfig; + return apiRequest< + RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[], + ResourceIdentityAutoGrantConfigPayload + >(apiEndpointPath(API_OPERATIONS.updateResourceIdentityAutoGrantConfig), { + body: payload, + method: endpoint.method, + }).then(normalizeResourceIdentityAutoGrantConfig); +} + export function listGifts(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listGifts; return apiRequest>(apiEndpointPath(API_OPERATIONS.listGifts), { @@ -571,3 +619,58 @@ export function grantResourceGroup(payload: GrantResourceGroupPayload): Promise< method: endpoint.method, }); } + +type RawResourceIdentityAutoGrantItem = ResourceIdentityAutoGrantItemDto & { + created_at_ms?: unknown; + identity_type?: unknown; + resource_group?: unknown; + resource_group_id?: unknown; + updated_at_ms?: unknown; +}; + +type RawResourceIdentityAutoGrantConfig = ResourceIdentityAutoGrantConfigDto & { + configs?: RawResourceIdentityAutoGrantItem[]; + created_at_ms?: unknown; + items?: RawResourceIdentityAutoGrantItem[]; + updated_at_ms?: unknown; + updated_by_admin_id?: unknown; +}; + +function normalizeResourceIdentityAutoGrantConfig( + source: RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[], +): ResourceIdentityAutoGrantConfigDto { + const rawItems = Array.isArray(source) ? source : source.items || source.configs || []; + return { + items: rawItems.map(normalizeResourceIdentityAutoGrantItem), + updatedByAdminId: Array.isArray(source) ? 0 : numberValue(source.updatedByAdminId ?? source.updated_by_admin_id), + createdAtMs: Array.isArray(source) ? 0 : numberValue(source.createdAtMs ?? source.created_at_ms), + updatedAtMs: Array.isArray(source) ? 0 : numberValue(source.updatedAtMs ?? source.updated_at_ms), + }; +} + +function normalizeResourceIdentityAutoGrantItem( + item: RawResourceIdentityAutoGrantItem, +): ResourceIdentityAutoGrantItemDto { + const resourceGroup = item.resourceGroup ?? item.resource_group; + return { + identityType: normalizeIdentityType(item.identityType ?? item.identity_type), + resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id), + resourceGroup: resourceGroup && typeof resourceGroup === "object" ? (resourceGroup as ResourceGroupDto) : undefined, + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms), + }; +} + +function normalizeIdentityType(value: unknown): ResourceIdentityAutoGrantType { + const identityType = stringValue(value) as ResourceIdentityAutoGrantType; + return resourceIdentityAutoGrantTypes.includes(identityType) ? identityType : "host"; +} + +function numberValue(value: unknown): number { + const parsed = Number(value || 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +function stringValue(value: unknown): string { + return value === undefined || value === null ? "" : String(value); +} diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 258676d..edd71f3 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -127,6 +127,18 @@ export const resourceGroupAssetOptions = [ export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions); +export const resourceIdentityAutoGrantOptions = [ + ["host", "主播"], + ["bd", "BD"], + ["bd_leader", "BD Leader"], + ["agency", "Agency"], + ["manager", "Manager"], +]; + +export const resourceIdentityAutoGrantTypes = resourceIdentityAutoGrantOptions.map(([value]) => value); + +export const resourceIdentityAutoGrantLabels = Object.fromEntries(resourceIdentityAutoGrantOptions); + export const grantStrategyLabels = { extend_expiry: "延长有效期", increase_quantity: "增加数量", diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 6728027..007ea2f 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -18,6 +18,7 @@ import { enableGift, enableResource, enableResourceGroup, + getResourceIdentityAutoGrantConfig, grantResource, grantResourceGroup, listEmojiPackCategories, @@ -33,6 +34,7 @@ import { updateGift, updateGiftTypes, updateResource, + updateResourceIdentityAutoGrantConfig, updateResourceGroup, upsertResourceShopItems, } from "@/features/resources/api"; @@ -40,6 +42,7 @@ import { cpRelationTypeLabels, cpRelationTypeOptions, defaultGiftTypeOptions, + resourceIdentityAutoGrantOptions, resourceShopSellableTypes, } from "@/features/resources/constants.js"; import { useResourceAbilities } from "@/features/resources/permissions.js"; @@ -51,6 +54,7 @@ import { resourceFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema, + resourceIdentityAutoGrantFormSchema, resourceShopItemsFormSchema, } from "@/features/resources/schema.js"; @@ -109,6 +113,15 @@ const emptyResourceGroupForm = (group = {}) => ({ name: group.name || "", sortOrder: group.sortOrder === 0 || group.sortOrder ? String(group.sortOrder) : "0", }); +const emptyResourceIdentityAutoGrantForm = (items = []) => { + const itemByIdentityType = new Map(items.map((item) => [item.identityType, item])); + return resourceIdentityAutoGrantOptions.map(([identityType]) => ({ + identityType, + resourceGroupId: itemByIdentityType.get(identityType)?.resourceGroupId + ? String(itemByIdentityType.get(identityType).resourceGroupId) + : "", + })); +}; const emptyGroupResourceItem = () => ({ durationDays: "1", itemType: "resource", @@ -778,6 +791,9 @@ export function useResourceGroupListPage() { const [loadingAction, setLoadingAction] = useState(""); const [resourceOptions, setResourceOptions] = useState([]); const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false); + const [identityGrantForm, setIdentityGrantForm] = useState(() => emptyResourceIdentityAutoGrantForm()); + const [identityGrantGroupOptions, setIdentityGrantGroupOptions] = useState([]); + const [identityGrantLoading, setIdentityGrantLoading] = useState(false); const filters = useMemo( () => ({ keyword: query, @@ -821,6 +837,40 @@ export function useResourceGroupListPage() { }; }, [activeAction, selectedGroup, showToast]); + useEffect(() => { + if (activeAction !== "identity-config") { + return undefined; + } + let ignore = false; + setIdentityGrantLoading(true); + // 这里并行拉取配置和可发放资源组,避免弹窗先显示旧配置再异步换值造成误保存。 + Promise.all([ + getResourceIdentityAutoGrantConfig(), + fetchAllOptionPages(listResourceGroups, { status: "active" }), + ]) + .then(([config, groups]) => { + if (ignore) { + return; + } + setIdentityGrantForm(emptyResourceIdentityAutoGrantForm(config.items || [])); + setIdentityGrantGroupOptions(mergeResourceGroupOptions(groups.items || [], config.items || [])); + }) + .catch((err) => { + if (!ignore) { + showToast(err.message || "加载身份资源组配置失败", "error"); + setActiveAction(""); + } + }) + .finally(() => { + if (!ignore) { + setIdentityGrantLoading(false); + } + }); + return () => { + ignore = true; + }; + }, [activeAction, showToast]); + const openCreateGroup = () => { setForm(emptyResourceGroupForm()); setSelectedGroup(null); @@ -836,6 +886,11 @@ export function useResourceGroupListPage() { setActiveAction("edit"); }; + const openIdentityGrantConfig = () => { + setIdentityGrantLoading(true); + setActiveAction("identity-config"); + }; + const closeAction = () => { setActiveAction(""); setSelectedGroup(null); @@ -887,6 +942,27 @@ export function useResourceGroupListPage() { } }; + const submitIdentityGrantConfig = async (event) => { + event.preventDefault(); + if (!abilities.canUpdateGroup) { + return; + } + setLoadingAction("identity-grant-config"); + try { + const payload = buildResourceIdentityAutoGrantPayload( + parseForm(resourceIdentityAutoGrantFormSchema, { items: identityGrantForm }), + ); + const nextConfig = await updateResourceIdentityAutoGrantConfig(payload); + setIdentityGrantForm(emptyResourceIdentityAutoGrantForm(nextConfig.items || [])); + showToast("身份资源组配置已保存", "success"); + closeAction(); + } catch (err) { + showToast(err.message || "保存身份资源组配置失败", "error"); + } finally { + setLoadingAction(""); + } + }; + const toggleGroup = async (group, nextEnabled = group.status !== "active") => { if (!abilities.canUpdateGroup || !group?.groupId) { return; @@ -925,16 +1001,22 @@ export function useResourceGroupListPage() { changeStatus: resetSetter(setStatus, setPage), closeAction, form, + identityGrantForm, + identityGrantGroupOptions, + identityGrantLoading, loadingAction, openCreateGroup, openEditGroup, + openIdentityGrantConfig, removeGroupItem, resourceOptions, resourceOptionsLoading, selectedGroup, resetFilters, setForm, + setIdentityGrantForm, status, + submitIdentityGrantConfig, submitGroup, toggleGroup, updateGroupItem, @@ -1744,6 +1826,35 @@ function buildResourceGroupPayload(form) { }; } +function buildResourceIdentityAutoGrantPayload(form) { + const itemByIdentityType = new Map(form.items.map((item) => [item.identityType, item])); + return { + items: resourceIdentityAutoGrantOptions.map(([identityType]) => { + const resourceGroupId = Number(itemByIdentityType.get(identityType)?.resourceGroupId || 0); + return { + identity_type: identityType, + resource_group_id: Number.isInteger(resourceGroupId) && resourceGroupId >= 0 ? resourceGroupId : 0, + }; + }), + }; +} + +function mergeResourceGroupOptions(activeGroups = [], configItems = []) { + const groupById = new Map(); + activeGroups.forEach((group) => { + if (group?.groupId) { + groupById.set(String(group.groupId), group); + } + }); + configItems.forEach((item) => { + const group = item?.resourceGroup; + if (group?.groupId && !groupById.has(String(group.groupId))) { + groupById.set(String(group.groupId), group); + } + }); + return Array.from(groupById.values()); +} + function buildGiftPayload(form) { const cpRelationType = form.giftTypeCode === cpGiftTypeCode ? normalizedCPRelationType(form.cpRelationType) : ""; return { diff --git a/src/features/resources/pages/ResourceGroupListPage.jsx b/src/features/resources/pages/ResourceGroupListPage.jsx index 2c8f279..20e7fab 100644 --- a/src/features/resources/pages/ResourceGroupListPage.jsx +++ b/src/features/resources/pages/ResourceGroupListPage.jsx @@ -4,6 +4,7 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; +import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { @@ -28,8 +29,14 @@ import { import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx"; -import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js"; +import { + resourceGroupAssetLabels, + resourceIdentityAutoGrantLabels, + resourceStatusFilters, + resourceTypeLabels, +} from "@/features/resources/constants.js"; import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js"; +import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx"; import styles from "@/features/resources/resources.module.css"; const dayMillis = 24 * 60 * 60 * 1000; @@ -114,6 +121,13 @@ export function ResourceGroupListPage() { ) : null } + filters={ + page.abilities.canViewGroup ? ( + + + + ) : null + } /> @@ -151,6 +165,16 @@ export function ResourceGroupListPage() { onClose={page.closeAction} onSubmit={page.submitGroup} /> + ); } @@ -271,6 +295,53 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub ); } +function ResourceIdentityAutoGrantDialog({ disabled, form, groupOptions, loading, onClose, onSubmit, open, page }) { + const submitDisabled = disabled || loading; + return ( + + + + {form.map((item, index) => ( + + + {resourceIdentityAutoGrantLabels[item.identityType] || item.identityType} + + + page.setIdentityGrantForm((current) => + current.map((currentItem, itemIndex) => + itemIndex === index + ? { ...currentItem, resourceGroupId: value } + : currentItem, + ), + ) + } + /> + + ))} + + + + ); +} + function ResourceGroupItemEditor({ disabled, index, item, page }) { if (item.itemType === "wallet_asset") { const label = resourceGroupAssetLabels[item.walletAssetType] || "钱包资产"; diff --git a/src/features/resources/permissions.js b/src/features/resources/permissions.js index 3416eec..40b82c7 100644 --- a/src/features/resources/permissions.js +++ b/src/features/resources/permissions.js @@ -20,6 +20,7 @@ export function useResourceAbilities() { canView: can(PERMISSIONS.resourceView), canViewEmojiPack: can(PERMISSIONS.emojiPackView), canViewGrant: can(PERMISSIONS.resourceGrantView), + canViewGroup: can(PERMISSIONS.resourceGroupView), canViewShop: can(PERMISSIONS.resourceShopView), }; } diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 662ddcb..a24f80a 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -100,6 +100,14 @@ white-space: nowrap; } +.identityGrantLabel { + display: inline-flex; + min-height: var(--control-height); + align-items: center; + color: var(--text-primary); + font-weight: 700; +} + .selectColumn { justify-content: center; } diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 48b1ee8..a5da48b 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -1,5 +1,5 @@ import { z } from "zod"; -import { cpRelationTypeOptions } from "@/features/resources/constants.js"; +import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js"; import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js"; const resourceTypes = [ @@ -177,6 +177,58 @@ export const resourceGroupCreateFormSchema = z }); }); +const identityAutoGrantItemSchema = z.object({ + identityType: z.enum(resourceIdentityAutoGrantTypes, "请选择身份"), + resourceGroupId: z.union([z.string(), z.number()]).optional(), +}); + +export const resourceIdentityAutoGrantFormSchema = z + .object({ + items: z.array(identityAutoGrantItemSchema), + }) + .superRefine((value, context) => { + const requiredIdentityTypes = new Set(resourceIdentityAutoGrantTypes); + const seenIdentityTypes = new Set(); + + if (value.items.length !== resourceIdentityAutoGrantTypes.length) { + context.addIssue({ + code: "custom", + message: "必须配置所有身份", + path: ["items"], + }); + } + + value.items.forEach((item, index) => { + if (seenIdentityTypes.has(item.identityType)) { + context.addIssue({ + code: "custom", + message: "身份不能重复配置", + path: ["items", index, "identityType"], + }); + } + seenIdentityTypes.add(item.identityType); + requiredIdentityTypes.delete(item.identityType); + + const resourceGroupId = + item.resourceGroupId === undefined || item.resourceGroupId === "" ? 0 : Number(item.resourceGroupId); + if (!Number.isInteger(resourceGroupId) || resourceGroupId < 0) { + context.addIssue({ + code: "custom", + message: "资源组必须是有效资源组", + path: ["items", index, "resourceGroupId"], + }); + } + }); + + requiredIdentityTypes.forEach((identityType) => { + context.addIssue({ + code: "custom", + message: "必须配置所有身份", + path: ["items", identityType], + }); + }); + }); + export const giftFormSchema = z .object({ chargeAssetType: z.enum(["COIN"]), diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index e1aa0ac..a6d2193 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -6,6 +6,7 @@ import { resourceFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema, + resourceIdentityAutoGrantFormSchema, resourceShopItemsFormSchema, } from "./schema.js"; @@ -162,6 +163,62 @@ describe("resource form schema", () => { ).toThrow(FormValidationError); }); + test("validates identity auto grant resource group config", () => { + const payload = parseForm(resourceIdentityAutoGrantFormSchema, { + items: [ + { identityType: "host", resourceGroupId: "11" }, + { identityType: "bd", resourceGroupId: "12" }, + { identityType: "bd_leader", resourceGroupId: "13" }, + { identityType: "agency", resourceGroupId: "14" }, + { identityType: "manager", resourceGroupId: "" }, + ], + }); + + expect(payload.items).toHaveLength(5); + expect(payload.items[4].resourceGroupId).toBe(""); + }); + + test("rejects incomplete identity auto grant config", () => { + expect(() => + parseForm(resourceIdentityAutoGrantFormSchema, { + items: [ + { identityType: "host", resourceGroupId: "11" }, + { identityType: "bd", resourceGroupId: "12" }, + { identityType: "bd_leader", resourceGroupId: "13" }, + { identityType: "agency", resourceGroupId: "14" }, + ], + }), + ).toThrow(FormValidationError); + }); + + test("rejects duplicate identity auto grant config", () => { + expect(() => + parseForm(resourceIdentityAutoGrantFormSchema, { + items: [ + { identityType: "host", resourceGroupId: "11" }, + { identityType: "bd", resourceGroupId: "12" }, + { identityType: "bd_leader", resourceGroupId: "13" }, + { identityType: "agency", resourceGroupId: "14" }, + { identityType: "agency", resourceGroupId: "15" }, + ], + }), + ).toThrow(FormValidationError); + }); + + test("rejects invalid identity auto grant resource group id", () => { + expect(() => + parseForm(resourceIdentityAutoGrantFormSchema, { + items: [ + { identityType: "host", resourceGroupId: "-1" }, + { identityType: "bd", resourceGroupId: "12" }, + { identityType: "bd_leader", resourceGroupId: "13" }, + { identityType: "agency", resourceGroupId: "14" }, + { identityType: "manager", resourceGroupId: "0" }, + ], + }), + ).toThrow(FormValidationError); + }); + test("validates editable gift form fields", () => { const payload = parseForm(giftFormSchema, { chargeAssetType: "COIN", diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index e8d5ea1..30c4810 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -117,6 +117,7 @@ export const API_OPERATIONS = { getRegistrationRewardConfig: "getRegistrationRewardConfig", getResource: "getResource", getResourceGroup: "getResourceGroup", + getResourceIdentityAutoGrantConfig: "getResourceIdentityAutoGrantConfig", getRoleDataScopes: "getRoleDataScopes", getRoomConfig: "getRoomConfig", getRoomRocketConfig: "getRoomRocketConfig", @@ -267,6 +268,7 @@ export const API_OPERATIONS = { updateResource: "updateResource", updateResourceGroup: "updateResourceGroup", updateResourceGroupItems: "updateResourceGroupItems", + updateResourceIdentityAutoGrantConfig: "updateResourceIdentityAutoGrantConfig", updateRole: "updateRole", updateRoom: "updateRoom", updateRoomConfig: "updateRoomConfig", @@ -1021,6 +1023,13 @@ export const API_ENDPOINTS: Record = { permission: "resource-group:view", permissions: ["resource-group:view"] }, + getResourceIdentityAutoGrantConfig: { + method: "GET", + operationId: API_OPERATIONS.getResourceIdentityAutoGrantConfig, + path: "/v1/admin/resource-groups/identity-auto-grant-config", + permission: "resource-group:view", + permissions: ["resource-group:view"] + }, getRoleDataScopes: { method: "GET", operationId: API_OPERATIONS.getRoleDataScopes, @@ -2057,6 +2066,13 @@ export const API_ENDPOINTS: Record = { permission: "resource-group:update", permissions: ["resource-group:update"] }, + updateResourceIdentityAutoGrantConfig: { + method: "PUT", + operationId: API_OPERATIONS.updateResourceIdentityAutoGrantConfig, + path: "/v1/admin/resource-groups/identity-auto-grant-config", + permission: "resource-group:update", + permissions: ["resource-group:update"] + }, updateRole: { method: "PATCH", operationId: API_OPERATIONS.updateRole, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index c0d0602..768612d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -2084,6 +2084,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/resource-groups/identity-auto-grant-config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getResourceIdentityAutoGrantConfig"]; + put: operations["updateResourceIdentityAutoGrantConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/resource-groups": { parameters: { query?: never; @@ -6151,6 +6167,30 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + getResourceIdentityAutoGrantConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateResourceIdentityAutoGrantConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listResourceGroups: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index caaa54b..793f9c2 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -566,7 +566,9 @@ export interface ManagerDto { canAddSuperadmin?: boolean; canBlockUser?: boolean; canGrantAvatarFrame?: boolean; + canGrantBadge?: boolean; canGrantVehicle?: boolean; + canTransferUserCountry?: boolean; canUpdateUserLevel?: boolean; contact?: string; createdAtMs?: number; @@ -586,7 +588,9 @@ export interface CreateManagerPayload { canAddSuperadmin?: boolean; canBlockUser?: boolean; canGrantAvatarFrame?: boolean; + canGrantBadge?: boolean; canGrantVehicle?: boolean; + canTransferUserCountry?: boolean; canUpdateUserLevel?: boolean; contact?: string; targetUserId: string; @@ -598,7 +602,9 @@ export interface UpdateManagerPayload { canAddSuperadmin?: boolean; canBlockUser?: boolean; canGrantAvatarFrame?: boolean; + canGrantBadge?: boolean; canGrantVehicle?: boolean; + canTransferUserCountry?: boolean; canUpdateUserLevel?: boolean; contact?: string; } diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index b0ebb19..f03744c 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -99,6 +99,10 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 const clearRange = () => applyRange(emptyRange); const submitDraft = (event) => { event.preventDefault(); + // Popover content is rendered through a portal, but React still bubbles + // submit events through the component tree. Stop here so a picker confirm + // never submits an outer edit drawer before the new range reaches state. + event.stopPropagation(); const nextRange = normalizeRange(draft); if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) { setError("结束时间必须晚于开始时间"); diff --git a/src/shared/ui/TimeRangeFilter.test.jsx b/src/shared/ui/TimeRangeFilter.test.jsx index 9782dc1..9d30370 100644 --- a/src/shared/ui/TimeRangeFilter.test.jsx +++ b/src/shared/ui/TimeRangeFilter.test.jsx @@ -1,5 +1,7 @@ -import { describe, expect, test } from "vitest"; -import { datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; +import { TimeRangeFilter, datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx"; describe("TimeRangeFilter helpers", () => { test("parses strict local date time text", () => { @@ -24,4 +26,23 @@ describe("TimeRangeFilter helpers", () => { expect(timeRangeLabel("时间", {})).toBe("时间"); }); + test("confirms picker changes without submitting an outer form", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onSubmit = vi.fn((event) => event.preventDefault()); + const startMs = datetimeTextToMs("2026-05-13 10:00"); + const endMs = datetimeTextToMs("2026-05-13 12:00"); + + render( +
+ + , + ); + + await user.click(screen.getByRole("button", { name: /活动时间/ })); + await user.click(screen.getByRole("button", { name: "确定" })); + + expect(onChange).toHaveBeenCalledWith({ endMs, startMs }); + expect(onSubmit).not.toHaveBeenCalled(); + }); });