From d4eaf20c8eb6f213f43535df045cedd9e3653e0c Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 4 Jun 2026 11:04:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9B=B8=E5=85=B3=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 12 + .../level-config/hooks/useLevelConfigPage.js | 4 +- .../level-config/pages/LevelConfigPage.jsx | 70 +- src/features/level-config/schema.ts | 6 +- src/features/resources/api.test.ts | 31 +- src/features/resources/api.ts | 19 +- src/features/resources/batchUpload.js | 4 + src/features/resources/batchUpload.test.js | 21 + .../components/GiftResourceSelectDrawer.jsx | 386 +++++++++ .../GiftResourceSelectDrawer.module.css | 184 +++++ .../GiftResourceSelectDrawer.test.jsx | 119 +++ src/features/resources/constants.js | 15 + .../resources/hooks/useResourcePages.js | 110 ++- .../resources/pages/ResourceGrantListPage.jsx | 37 +- .../resources/pages/ResourceListPage.jsx | 167 +++- src/features/resources/profileCardLayout.js | 742 ++++++++++++++++++ .../resources/profileCardLayout.test.js | 113 +++ src/features/resources/schema.js | 44 +- src/features/resources/schema.test.ts | 24 +- src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 28 + src/shared/ui/UploadField.jsx | 645 +++++++-------- src/shared/ui/UploadField.test.jsx | 38 +- var/admin-dev.pid | 2 +- 24 files changed, 2423 insertions(+), 406 deletions(-) create mode 100644 src/features/resources/components/GiftResourceSelectDrawer.jsx create mode 100644 src/features/resources/components/GiftResourceSelectDrawer.module.css create mode 100644 src/features/resources/components/GiftResourceSelectDrawer.test.jsx create mode 100644 src/features/resources/profileCardLayout.js create mode 100644 src/features/resources/profileCardLayout.test.js diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index fe7ca30..df7ede9 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -1897,6 +1897,18 @@ "x-permissions": ["resource-grant:create"] } }, + "/admin/resource-grants/target": { + "get": { + "operationId": "lookupResourceGrantTarget", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:create", + "x-permissions": ["resource-grant:create"] + } + }, "/admin/resource-shop/items": { "get": { "operationId": "listResourceShopItems", diff --git a/src/features/level-config/hooks/useLevelConfigPage.js b/src/features/level-config/hooks/useLevelConfigPage.js index 4c50f05..dd87e9b 100644 --- a/src/features/level-config/hooks/useLevelConfigPage.js +++ b/src/features/level-config/hooks/useLevelConfigPage.js @@ -318,12 +318,12 @@ function buildTrackPayload(form) { function nextRuleLevel(levelTrack) { const levels = (levelTrack?.rules || []).map((rule) => Number(rule.level || 0)); - return Math.max(0, ...levels) + 1; + return levels.length > 0 ? Math.max(...levels) + 1 : 0; } function nextTierStart(levelTrack) { const levels = (levelTrack?.tiers || []).map((tier) => Number(tier.maxLevel || 0)); - return Math.max(0, ...levels) + 1; + return levels.length > 0 ? Math.max(...levels) + 1 : 0; } function optionalId(value) { diff --git a/src/features/level-config/pages/LevelConfigPage.jsx b/src/features/level-config/pages/LevelConfigPage.jsx index c56afaa..d230b3a 100644 --- a/src/features/level-config/pages/LevelConfigPage.jsx +++ b/src/features/level-config/pages/LevelConfigPage.jsx @@ -422,7 +422,7 @@ function RuleFormDialog({ page }) { @@ -519,7 +519,7 @@ function TierFormDialog({ page }) { {resource?.name || resource?.resourceCode || `资源 #${id}`} - ID {id} + {resource ? resourceMetaLabel(resource, id) : `ID ${id}`} ); } @@ -682,6 +682,66 @@ function withSelectedResource(resources, value) { return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources]; } +function levelBadgeResourcesForTrack(resources, track) { + return (resources || []).filter((resource) => { + const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson); + const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson); + const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson); + return badgeForm === "strip" && badgeKind === "level" && levelTrack === track; + }); +} + +function resourceMetaLabel(resource, id) { + const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson); + const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson); + if (resource.resourceType === "badge" && badgeKind === "level" && levelTrack) { + return `ID ${id} · 等级徽章 · ${levelTrackLabels[levelTrack] || levelTrack}`; + } + return `ID ${id}`; +} + +function badgeFormFromMetadata(metadataJson) { + if (!metadataJson) { + return ""; + } + try { + const metadata = JSON.parse(metadataJson); + const badgeForm = String(metadata?.badge_form || "") + .trim() + .toLowerCase(); + return badgeForm === "strip" || badgeForm === "tile" ? badgeForm : ""; + } catch { + return ""; + } +} + +function badgeKindFromMetadata(metadataJson) { + if (!metadataJson) { + return ""; + } + try { + const metadata = JSON.parse(metadataJson); + return String(metadata?.badge_kind || "").trim().toLowerCase() === "level" ? "level" : "normal"; + } catch { + return ""; + } +} + +function levelTrackFromMetadata(metadataJson) { + if (!metadataJson) { + return ""; + } + try { + const metadata = JSON.parse(metadataJson); + const levelTrack = String(metadata?.level_track || "") + .trim() + .toLowerCase(); + return levelTrack === "wealth" || levelTrack === "game" || levelTrack === "charm" ? levelTrack : ""; + } catch { + return ""; + } +} + function levelStatusLabel(status) { return levelStatusLabels[status] || status || "-"; } diff --git a/src/features/level-config/schema.ts b/src/features/level-config/schema.ts index 52c93b2..c567ad8 100644 --- a/src/features/level-config/schema.ts +++ b/src/features/level-config/schema.ts @@ -27,7 +27,7 @@ export const levelRuleFormSchema = z track: trackSchema, }) .superRefine((value, context) => { - validatePositiveInteger(context, "level", value.level, "等级必须是正整数"); + validateNonNegativeInteger(context, "level", value.level, "等级必须是非负整数"); validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数"); validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源"); validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组"); @@ -50,8 +50,8 @@ export const levelTierFormSchema = z const minLevel = Number(value.minLevel); const maxLevel = Number(value.maxLevel); - validatePositiveInteger(context, "minLevel", value.minLevel, "起始等级必须是正整数"); - validatePositiveInteger(context, "maxLevel", value.maxLevel, "结束等级必须是正整数"); + validateNonNegativeInteger(context, "minLevel", value.minLevel, "起始等级必须是非负整数"); + validateNonNegativeInteger(context, "maxLevel", value.maxLevel, "结束等级必须是非负整数"); if (Number.isInteger(minLevel) && Number.isInteger(maxLevel) && maxLevel < minLevel) { context.addIssue({ code: "custom", message: "结束等级不能小于起始等级", path: ["maxLevel"] }); } diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index a17e188..f49a43b 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -22,6 +22,7 @@ import { listGifts, listResourceShopItems, listResources, + lookupResourceGrantTarget, updateGift, updateGiftTypes, updateResource, @@ -47,7 +48,10 @@ test("resource APIs use generated admin paths", async () => { amount: 0, assetUrl: "https://media.haiyihy.com/resource/material.png", badgeForm: "strip", + badgeKind: "level", coinPrice: 10, + levelTrack: "wealth", + metadataJson: '{"profile_card_layout":{"content_top":117,"content_height":1550}}', name: "VIP Badge", previewUrl: "https://media.haiyihy.com/resource/cover.png", priceType: "coin", @@ -60,7 +64,9 @@ test("resource APIs use generated admin paths", async () => { amount: 0, assetUrl: "https://media.haiyihy.com/resource/material-updated.png", badgeForm: "tile", + badgeKind: "normal", coinPrice: 20, + metadataJson: '{"profile_card_layout":{"content_top":120,"content_height":1500}}', name: "VIP Badge Updated", previewUrl: "https://media.haiyihy.com/resource/cover-updated.png", priceType: "coin", @@ -144,13 +150,14 @@ test("resource APIs use generated admin paths", async () => { await enableGift("rose"); await disableGift("rose"); await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" }); + await lookupResourceGrantTarget("163000"); await grantResource({ commandId: "resource-grant-test", durationMs: 0, quantity: 1, reason: "manual", resourceId: 11, - targetUserId: 1001, + targetUserId: "318705991371722752", }); await grantResourceGroup({ commandId: "resource-group-grant-test", @@ -174,8 +181,9 @@ test("resource APIs use generated admin paths", async () => { const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12]; const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13]; const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[14]; - const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[15]; - const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[16]; + const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[15]; + const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[16]; + const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[17]; expect(String(listUrl)).toContain("/api/v1/admin/resources?"); expect(String(listUrl)).toContain("resource_type=gift"); @@ -185,13 +193,20 @@ test("resource APIs use generated admin paths", async () => { expect(createInit?.method).toBe("POST"); expect(JSON.parse(String(createInit?.body))).toMatchObject({ badgeForm: "strip", + badgeKind: "level", + levelTrack: "wealth", + metadataJson: '{"profile_card_layout":{"content_top":117,"content_height":1550}}', resourceCode: "badge_vip_test", resourceType: "badge", status: "active", }); expect(String(updateUrl)).toContain("/api/v1/admin/resources/11"); expect(updateInit?.method).toBe("PUT"); - expect(JSON.parse(String(updateInit?.body))).toMatchObject({ badgeForm: "tile" }); + expect(JSON.parse(String(updateInit?.body))).toMatchObject({ + badgeForm: "tile", + badgeKind: "normal", + metadataJson: '{"profile_card_layout":{"content_top":120,"content_height":1500}}', + }); expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?"); expect(String(giftListUrl)).toContain("region_id=1001"); expect(giftListInit?.method).toBe("GET"); @@ -226,9 +241,15 @@ test("resource APIs use generated admin paths", async () => { expect(String(grantListUrl)).toContain("/api/v1/admin/resource-grants?"); expect(String(grantListUrl)).toContain("target_user_id=1001"); expect(grantListInit?.method).toBe("GET"); + expect(String(grantTargetUrl)).toContain("/api/v1/admin/resource-grants/target?"); + expect(String(grantTargetUrl)).toContain("user_id=163000"); + expect(grantTargetInit?.method).toBe("GET"); expect(String(grantResourceUrl)).toContain("/api/v1/admin/resource-grants/resource"); expect(grantResourceInit?.method).toBe("POST"); - expect(JSON.parse(String(grantResourceInit?.body))).toMatchObject({ resourceId: 11, targetUserId: 1001 }); + expect(JSON.parse(String(grantResourceInit?.body))).toMatchObject({ + resourceId: 11, + targetUserId: "318705991371722752", + }); expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group"); expect(grantGroupInit?.method).toBe("POST"); expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 }); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index e844a99..08f17fe 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -1,6 +1,6 @@ import { apiRequest } from "@/shared/api/request"; import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; -import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types"; +import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types"; export interface ResourceDto { appCode?: string; @@ -17,6 +17,8 @@ export interface ResourceDto { coinPrice?: number; giftPointAmount?: number; badgeForm?: string; + badgeKind?: string; + levelTrack?: string; usageScopes?: string[]; assetUrl?: string; previewUrl?: string; @@ -32,7 +34,10 @@ export interface ResourcePayload { animationUrl?: string; assetUrl: string; badgeForm?: string; + badgeKind?: string; coinPrice: number; + levelTrack?: string; + metadataJson?: string; name: string; previewUrl: string; priceType: string; @@ -229,14 +234,14 @@ export interface GrantResourcePayload { quantity: number; reason: string; resourceId: number; - targetUserId: number; + targetUserId: number | string; } export interface GrantResourceGroupPayload { commandId: string; groupId: number; reason: string; - targetUserId: number; + targetUserId: number | string; } export interface ResourceGroupItemPayload { @@ -487,6 +492,14 @@ export function listResourceGrants(query: PageQuery = {}): Promise { + const endpoint = API_ENDPOINTS.lookupResourceGrantTarget; + return apiRequest(apiEndpointPath(API_OPERATIONS.lookupResourceGrantTarget), { + method: endpoint.method, + query: { user_id: String(userIdOrDisplayId).trim() }, + }); +} + export function grantResource(payload: GrantResourcePayload): Promise { const endpoint = API_ENDPOINTS.grantResource; return apiRequest(apiEndpointPath(API_OPERATIONS.grantResource), { diff --git a/src/features/resources/batchUpload.js b/src/features/resources/batchUpload.js index 02830db..6bddac8 100644 --- a/src/features/resources/batchUpload.js +++ b/src/features/resources/batchUpload.js @@ -1,3 +1,5 @@ +import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js"; + export const resourceBatchUploadSize = 10; const resourceTypeAliases = new Map([ @@ -101,7 +103,9 @@ export function resourcePlanToPayload(item) { animationUrl: item.animationUrl, assetUrl: item.animationUrl, badgeForm: item.resourceType === "badge" ? item.badgeForm || "tile" : undefined, + badgeKind: item.resourceType === "badge" ? "normal" : undefined, coinPrice, + metadataJson: item.resourceType === "profile_card" ? profileCardMetadataJSON(item.metadataJson) : undefined, name: item.name, previewUrl: item.coverUrl, priceType: coinPrice > 0 ? "coin" : "free", diff --git a/src/features/resources/batchUpload.test.js b/src/features/resources/batchUpload.test.js index 89ca990..7328246 100644 --- a/src/features/resources/batchUpload.test.js +++ b/src/features/resources/batchUpload.test.js @@ -48,6 +48,27 @@ test("parses folder resource filenames into resource plans", () => { resourceType: "gift", }, ); + + expect(resourcePlanToPayload({ ...plan.resources[1], coverUrl: "cover", animationUrl: "animation" })).toMatchObject( + { + badgeForm: "strip", + badgeKind: "normal", + resourceType: "badge", + }, + ); + + expect( + resourcePlanToPayload({ + ...plan.resources[2], + animationUrl: "animation", + coverUrl: "cover", + metadataJson: + '{"profile_card_layout":{"source_width":1136,"source_height":1680,"color_content_width":750,"content_top":117,"content_bottom":1666,"content_height":1550,"content_top_ratio":0.069643,"content_height_ratio":0.922619,"detect_version":1}}', + }), + ).toMatchObject({ + metadataJson: expect.stringContaining("profile_card_layout"), + resourceType: "profile_card", + }); }); test("silently ignores invalid and unpaired material", () => { diff --git a/src/features/resources/components/GiftResourceSelectDrawer.jsx b/src/features/resources/components/GiftResourceSelectDrawer.jsx new file mode 100644 index 0000000..c7f4e3c --- /dev/null +++ b/src/features/resources/components/GiftResourceSelectDrawer.jsx @@ -0,0 +1,386 @@ +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; +import SearchOutlined from "@mui/icons-material/SearchOutlined"; +import Checkbox from "@mui/material/Checkbox"; +import InputAdornment from "@mui/material/InputAdornment"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import TextField from "@mui/material/TextField"; +import { useMemo, useState } from "react"; +import { + resourcePriceTypeLabels, + resourceTypeFilters, + resourceTypeLabels, +} from "@/features/resources/constants.js"; +import { Button } from "@/shared/ui/Button.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import styles from "@/features/resources/components/GiftResourceSelectDrawer.module.css"; + +const drawerZIndex = 1600; + +export function GiftResourceSelectField({ + disabled = false, + drawerTitle = "选择资源", + giftTypes = [], + gifts = [], + label = "资源", + loading = false, + onChange = () => {}, + placeholder = "请选择资源", + required = true, + resources = [], + selectedResourceIds = [], +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeType, setActiveType] = useState(""); + const selectedIds = useMemo(() => selectedResourceIds.map((id) => String(id)), [selectedResourceIds]); + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const giftsByResourceId = useMemo(() => { + const items = new Map(); + gifts.forEach((gift) => { + if (gift?.resourceId) { + items.set(String(gift.resourceId), gift); + } + }); + return items; + }, [gifts]); + const resourceItems = useMemo( + () => buildResourceItems(resources, giftsByResourceId), + [giftsByResourceId, resources], + ); + const itemCounts = useMemo(() => countResourceItems(resourceItems), [resourceItems]); + const categoryOptions = useMemo( + () => buildCategoryOptions(giftTypes, resources, resourceItems), + [giftTypes, resourceItems, resources], + ); + const activeCategory = categoryOptions.some((item) => item.value === activeType) + ? activeType + : categoryOptions.find((item) => itemCounts.get(item.value))?.value || categoryOptions[0]?.value || ""; + const resourcesById = useMemo(() => { + const items = new Map(); + resourceItems.forEach((item) => { + items.set(String(item.resource.resourceId), item); + }); + return items; + }, [resourceItems]); + const displayValue = useMemo(() => { + if (!selectedIds.length) { + return ""; + } + const labels = selectedIds.map((resourceId) => resourceName(resourcesById.get(resourceId), resourceId)); + if (labels.length <= 3) { + return labels.join(","); + } + return `${labels.slice(0, 3).join(",")} 等 ${labels.length} 个资源`; + }, [resourcesById, selectedIds]); + const filteredResources = useMemo(() => { + const keyword = query.trim().toLowerCase(); + return resourceItems.filter((item) => { + if (activeCategory && item.categoryValue !== activeCategory) { + return false; + } + if (!keyword) { + return true; + } + const resource = item.resource || {}; + const gift = item.gift || {}; + const haystack = [ + resource.name, + resource.resourceCode, + resource.resourceId, + resource.resourceType, + gift.name, + gift.giftId, + gift.giftTypeCode, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(keyword); + }); + }, [activeCategory, query, resourceItems]); + + const openDrawer = () => { + if (!disabled && !loading) { + setOpen(true); + } + }; + + const toggleResource = (item) => { + if (!item?.resource?.resourceId || disabled) { + return; + } + const resourceId = String(item.resource.resourceId); + if (selectedIdSet.has(resourceId)) { + onChange(selectedIds.filter((selectedId) => selectedId !== resourceId)); + return; + } + onChange([...selectedIds, resourceId]); + }; + + return ( + <> + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDrawer(); + } + }} + /> + setOpen(false)}> + 完成 + + } + className={styles.drawer} + contentClassName={styles.drawerBody} + drawerProps={{ sx: { zIndex: drawerZIndex } }} + open={open} + title={drawerTitle} + width="wide" + onClose={() => setOpen(false)} + > +
+ + + + ), + }, + }} + value={query} + onChange={(event) => setQuery(event.target.value)} + /> + {categoryOptions.length ? ( + setActiveType(value)} + > + {categoryOptions.map((item) => ( + + ))} + + ) : null} +
+
+ {filteredResources.map((item) => { + const resourceId = String(item.resource.resourceId); + const selected = selectedIdSet.has(resourceId); + const name = resourceName(item); + return ( + + ); + })} + {!filteredResources.length ?
当前无可选资源
: null} +
+
+ + ); +} + +function buildResourceItems(resources, giftsByResourceId) { + const seen = new Set(); + return resources + .filter((resource) => resource?.resourceId) + .filter((resource) => { + const resourceId = String(resource.resourceId); + if (seen.has(resourceId)) { + return false; + } + seen.add(resourceId); + return true; + }) + .map((resource) => { + const gift = giftsByResourceId.get(String(resource.resourceId)); + const category = categoryForResource(resource, gift); + return { + categoryLabel: category.label, + categoryValue: category.value, + gift, + resource, + }; + }); +} + +function buildCategoryOptions(giftTypes, resources, resourceItems) { + const options = []; + const resourceTypes = new Set(resources.map((resource) => resource?.resourceType).filter(Boolean)); + resourceTypeFilters.forEach(([type, label], index) => { + if (!type) { + return; + } + if (type === "gift") { + options.push(...buildGiftCategoryOptions(giftTypes, resourceItems, index)); + if (resourceItems.some((item) => item.categoryValue === "resource:gift")) { + options.push({ label: resourceTypeLabels.gift || "礼物", order: index * 100, value: "resource:gift" }); + } + return; + } + options.push({ label, order: index * 100, value: `resource:${type}` }); + resourceTypes.delete(type); + }); + resourceTypes.forEach((type) => { + options.push({ + label: resourceTypeLabels[type] || type, + order: options.length * 100, + value: `resource:${type}`, + }); + }); + return dedupeCategories(options); +} + +function buildGiftCategoryOptions(giftTypes, resourceItems, resourceTypeOrder) { + const options = []; + const knownGiftTypes = new Set(); + giftTypes + .filter((item) => item?.status !== "disabled") + .forEach((item) => { + const value = String(item.tabKey || item.typeCode || "").trim(); + if (!value) { + return; + } + knownGiftTypes.add(value); + options.push({ + label: item.displayName || item.tabName || value, + order: resourceTypeOrder * 100 + Number(item.sortOrder || 0), + value: `gift:${value}`, + }); + }); + resourceItems.forEach((item) => { + const value = String(item.gift?.giftTypeCode || "").trim(); + if (value && !knownGiftTypes.has(value)) { + knownGiftTypes.add(value); + options.push({ + label: value, + order: resourceTypeOrder * 100 + 1000 + options.length, + value: `gift:${value}`, + }); + } + }); + return options; +} + +function dedupeCategories(options) { + const byValue = new Map(); + options.forEach((item) => { + if (!byValue.has(item.value)) { + byValue.set(item.value, item); + } + }); + return Array.from(byValue.values()).sort((left, right) => left.order - right.order); +} + +function categoryForResource(resource, gift) { + if (resource.resourceType === "gift" && gift?.giftTypeCode) { + return { value: `gift:${gift.giftTypeCode}` }; + } + const type = resource.resourceType || "unknown"; + return { + label: resourceTypeLabels[type] || type, + value: `resource:${type}`, + }; +} + +function categoryLabel(item, categoryOptions) { + return categoryOptions.find((option) => option.value === item.categoryValue)?.label || item.categoryLabel || "资源"; +} + +function countResourceItems(resourceItems) { + const counts = new Map(); + resourceItems.forEach((item) => { + counts.set(item.categoryValue, (counts.get(item.categoryValue) || 0) + 1); + }); + return counts; +} + +function ResourceThumb({ item }) { + const imageUrl = imageURL(item.resource.previewUrl || item.resource.assetUrl || item.resource.animationUrl); + return ( + + {imageUrl ? ( + + ) : item.resource.resourceType === "gift" ? ( + + ) : ( + + )} + + ); +} + +function resourceName(item, fallbackResourceId = "") { + const resource = item?.resource || {}; + const gift = item?.gift || {}; + return gift.name || resource.name || resource.resourceCode || `资源 #${fallbackResourceId || resource.resourceId || "-"}`; +} + +function resourcePriceLabel(item = {}) { + const resource = item.resource || {}; + const gift = item.gift || {}; + if (resource.resourceType === "gift" && (gift.chargeAssetType === "COIN" || Number(gift.coinPrice || 0) > 0)) { + return `${formatNumber(gift.coinPrice)}金币`; + } + if (resource.priceType === "coin") { + return `${formatNumber(resource.coinPrice)}金币`; + } + if (resource.priceType === "free") { + return resourcePriceTypeLabels.free; + } + return "未配置价格"; +} + +function imageURL(value) { + const url = String(value || "").trim(); + return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : ""; +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} diff --git a/src/features/resources/components/GiftResourceSelectDrawer.module.css b/src/features/resources/components/GiftResourceSelectDrawer.module.css new file mode 100644 index 0000000..77e25b9 --- /dev/null +++ b/src/features/resources/components/GiftResourceSelectDrawer.module.css @@ -0,0 +1,184 @@ +.field :global(.MuiInputBase-input:not(:disabled)) { + cursor: pointer; +} + +.drawer.drawer { + width: min(860px, 100vw); + height: 100%; +} + +.drawerBody { + display: grid; + min-height: 0; + grid-template-rows: auto 1fr; + gap: var(--space-3); +} + +.filters { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.search { + width: 100%; +} + +.tabs { + min-height: 38px; + border-bottom: 1px solid var(--border-muted); +} + +.tabs :global(.MuiTab-root) { + min-height: 38px; + padding: 0 var(--space-3); + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; +} + +.tabs :global(.Mui-selected) { + color: var(--primary); +} + +.grid { + display: grid; + min-height: 0; + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + gap: var(--space-3); + overflow-y: auto; + padding-right: var(--space-1); +} + +.card { + position: relative; + display: grid; + min-width: 0; + min-height: 184px; + align-content: start; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + color: var(--text-primary); + cursor: pointer; + font: inherit; + text-align: left; + transition: + border-color var(--motion-fast) var(--ease-standard), + background var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.card:hover { + border-color: var(--primary-border); + background: var(--primary-surface); + box-shadow: var(--shadow-soft); + transform: translateY(-1px); +} + +.card:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.cardSelected, +.cardSelected:hover { + border-color: var(--primary); + background: var(--primary-surface-strong); + box-shadow: inset 0 0 0 1px var(--primary-border); +} + +.checkbox { + position: absolute; + top: 6px; + right: 6px; + pointer-events: none; +} + +.thumb { + display: inline-flex; + width: 58px; + height: 58px; + 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); +} + +.thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.name, +.meta, +.tag { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.name { + color: var(--text-primary); + font-weight: 700; +} + +.meta { + color: var(--text-tertiary); + font-size: 13px; +} + +.tags { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: var(--space-1); +} + +.tag { + display: inline-flex; + max-width: 100%; + height: var(--admin-tag-height); + align-items: center; + padding: 0 var(--space-2); + border: 1px solid var(--admin-tag-border); + border-radius: var(--admin-tag-radius); + background: var(--admin-tag-bg); + color: var(--admin-tag-color); + font-size: 12px; + font-weight: 650; +} + +.empty { + display: flex; + min-height: 180px; + grid-column: 1 / -1; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-weight: 650; +} + +@media (max-width: 640px) { + .grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } + + .card:hover { + transform: none; + } +} diff --git a/src/features/resources/components/GiftResourceSelectDrawer.test.jsx b/src/features/resources/components/GiftResourceSelectDrawer.test.jsx new file mode 100644 index 0000000..05abcf3 --- /dev/null +++ b/src/features/resources/components/GiftResourceSelectDrawer.test.jsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { expect, test, vi } from "vitest"; +import { GiftResourceSelectField } from "./GiftResourceSelectDrawer.jsx"; + +vi.mock("@/shared/ui/SideDrawer.jsx", () => ({ + SideDrawer({ actions, children, drawerProps, onClose, open, title }) { + if (!open) { + return null; + } + return ( + + ); + }, +})); + +const giftTypes = [ + { displayName: "普通礼物", sortOrder: 10, status: "active", tabKey: "normal" }, + { displayName: "CP礼物", sortOrder: 20, status: "active", tabKey: "cp" }, +]; + +const resources = [ + { + coinPrice: 10, + name: "Rose Resource", + priceType: "coin", + resourceCode: "rose_resource", + resourceId: 11, + resourceType: "gift", + }, + { + coinPrice: 20, + name: "CP Ring Resource", + priceType: "coin", + resourceCode: "cp_ring_resource", + resourceId: 12, + resourceType: "gift", + }, + { + coinPrice: 100, + name: "VIP Vehicle", + priceType: "coin", + resourceCode: "vip_vehicle", + resourceId: 21, + resourceType: "vehicle", + }, +]; + +const gifts = [ + { + chargeAssetType: "COIN", + coinPrice: 10, + giftId: "rose", + giftTypeCode: "normal", + name: "Rose Gift", + resource: { previewUrl: "https://media.haiyihy.com/rose.png", resourceCode: "rose_resource" }, + resourceId: 11, + }, + { + chargeAssetType: "COIN", + coinPrice: 20, + giftId: "cp_ring", + giftTypeCode: "cp", + name: "CP Ring", + resourceId: 12, + }, +]; + +test("gift resource select drawer filters by gift type and supports multiple selection", () => { + const changes = []; + + function Harness() { + const [value, setValue] = useState([]); + return ( + { + changes.push(nextValue); + setValue(nextValue); + }} + /> + ); + } + + render(); + + fireEvent.click(screen.getByPlaceholderText("请选择资源")); + + expect(screen.getByRole("dialog", { name: "选择资源" })).toHaveAttribute("data-z-index", "1600"); + expect(screen.getByText("VIP Vehicle")).toBeInTheDocument(); + expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument(); + expect(screen.queryByText("CP Ring")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "普通礼物" })); + + expect(screen.getByText("Rose Gift")).toBeInTheDocument(); + expect(screen.queryByText("VIP Vehicle")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("Rose Gift")); + + fireEvent.click(screen.getByRole("tab", { name: "CP礼物" })); + + expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument(); + expect(screen.getByText("CP Ring")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("CP Ring")); + + expect(changes.at(-1)).toEqual(["11", "12"]); + expect(screen.getByDisplayValue("Rose Gift,CP Ring")).toBeInTheDocument(); +}); diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 17528c4..3ea3354 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -55,6 +55,21 @@ export const badgeFormOptions = [ export const badgeFormLabels = Object.fromEntries(badgeFormOptions); +export const badgeKindOptions = [ + ["normal", "普通徽章"], + ["level", "等级徽章"], +]; + +export const badgeKindLabels = Object.fromEntries(badgeKindOptions); + +export const badgeLevelTrackOptions = [ + ["wealth", "财富等级"], + ["game", "游戏等级"], + ["charm", "魅力等级"], +]; + +export const badgeLevelTrackLabels = Object.fromEntries(badgeLevelTrackOptions); + export const resourceBatchUploadRoleLabels = { animation: "动效素材", cover: "封面图", diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 0d72edb..ab09ab4 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -26,6 +26,7 @@ import { listResourceGroups, listResourceShopItems, listResources, + lookupResourceGrantTarget, updateGift, updateGiftTypes, updateResource, @@ -34,6 +35,7 @@ import { } from "@/features/resources/api"; import { defaultGiftTypeOptions, resourceShopSellableTypes } from "@/features/resources/constants.js"; import { useResourceAbilities } from "@/features/resources/permissions.js"; +import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js"; import { emojiPackFormSchema, giftFormSchema, @@ -53,8 +55,14 @@ const emptyResourceForm = (resource = {}) => ({ resource.resourceType === "badge" ? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile" : "tile", + badgeKind: + resource.resourceType === "badge" + ? resource.badgeKind || badgeKindFromMetadata(resource.metadataJson) || "normal" + : "normal", coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "", enabled: resource.status ? resource.status === "active" : true, + levelTrack: resource.resourceType === "badge" ? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || "" : "", + metadataJson: resource.metadataJson || "", name: resource.name || "", previewUrl: resource.previewUrl || "", priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin", @@ -150,11 +158,12 @@ function applyGiftResourcePrice(form, resource) { } const emptyGrantForm = () => ({ - durationDays: "0", + durationDays: "7", groupId: "", quantity: "1", reason: "", resourceId: "", + resourceIds: [], subjectType: "resource", targetUserId: "", }); @@ -1120,8 +1129,10 @@ export function useResourceGrantListPage() { const [page, setPage] = useState(1); const [activeAction, setActiveAction] = useState(""); const [form, setForm] = useState(emptyGrantForm); - const [resourceOptions, setResourceOptions] = useState([]); + const [giftOptions, setGiftOptions] = useState([]); + const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions); const [groupOptions, setGroupOptions] = useState([]); + const [resourceOptions, setResourceOptions] = useState([]); const [optionsLoading, setOptionsLoading] = useState(false); const [loadingAction, setLoadingAction] = useState(""); const filters = useMemo( @@ -1147,12 +1158,20 @@ export function useResourceGrantListPage() { let ignore = false; setOptionsLoading(true); Promise.all([ - listResources({ page: 1, page_size: 100, status: "active" }), + listResources({ page: 1, page_size: 500, status: "active" }), + listGifts({ page: 1, page_size: 500, status: "active" }), + listGiftTypes(), listResourceGroups({ page: 1, page_size: 100, status: "active" }), ]) - .then(([resources, groups]) => { + .then(([resources, gifts, giftTypes, groups]) => { if (!ignore) { - setResourceOptions((resources.items || []).filter((resource) => resource.grantable !== false)); + 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 || []); } }) @@ -1187,9 +1206,14 @@ export function useResourceGrantListPage() { } setLoadingAction("grant-create"); try { - const payload = buildGrantPayload(parseForm(resourceGrantFormSchema, form)); + const parsedForm = parseForm(resourceGrantFormSchema, form); + const targetUser = await lookupResourceGrantTarget(parsedForm.targetUserId); + const targetUserId = normalizedResolvedUserId(targetUser); + const payload = buildGrantPayload(parsedForm, targetUserId); if (payload.subjectType === "resource") { - await grantResource(payload.resource); + for (const resourcePayload of payload.resources) { + await grantResource(resourcePayload); + } } else { await grantResourceGroup(payload.group); } @@ -1217,6 +1241,8 @@ export function useResourceGrantListPage() { changeStatus: resetSetter(setStatus, setPage), closeAction, form, + giftOptions, + giftTypeOptions, groupOptions, loadingAction, openCreateGrant, @@ -1258,7 +1284,10 @@ function buildResourcePayload(form) { animationUrl, assetUrl: animationUrl, badgeForm: resourceType === "badge" ? form.badgeForm : undefined, + badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined, coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0, + levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined, + metadataJson: resourceType === "profile_card" ? profileCardMetadataJSON(form.metadataJson) : undefined, name: form.name.trim(), previewUrl: form.previewUrl.trim(), priceType: form.priceType, @@ -1287,6 +1316,42 @@ function badgeFormFromMetadata(metadataJson) { return ""; } +function badgeKindFromMetadata(metadataJson) { + if (!metadataJson) { + return ""; + } + try { + const metadata = JSON.parse(metadataJson); + const badgeKind = String(metadata?.badge_kind || "") + .trim() + .toLowerCase(); + if (badgeKind === "level") { + return "level"; + } + } catch { + return ""; + } + return ""; +} + +function levelTrackFromMetadata(metadataJson) { + if (!metadataJson) { + return ""; + } + try { + const metadata = JSON.parse(metadataJson); + const levelTrack = String(metadata?.level_track || "") + .trim() + .toLowerCase(); + if (levelTrack === "wealth" || levelTrack === "game" || levelTrack === "charm") { + return levelTrack; + } + } catch { + return ""; + } + return ""; +} + function buildEmojiPackPayload(form) { const regionIds = (form.regionIds || []) .map(Number) @@ -1435,26 +1500,34 @@ function datetimeLocalToMs(value) { return Number.isFinite(parsed) ? parsed : 0; } -function buildGrantPayload(form) { - const targetUserId = Number(form.targetUserId); +function normalizedResolvedUserId(user) { + const userId = String(user?.userId || user?.user_id || "").trim(); + if (!/^[1-9]\d*$/.test(userId)) { + throw new Error("用户不存在或用户 ID 无效"); + } + return userId; +} + +function buildGrantPayload(form, resolvedTargetUserId) { + const targetUserId = String(resolvedTargetUserId || "").trim(); const reason = form.reason.trim(); - const commandId = makeCommandId(form.subjectType === "resource" ? "resource-grant" : "resource-group-grant"); if (form.subjectType === "resource") { + const resourceIds = grantResourceIds(form); return { - resource: { - commandId, + resources: resourceIds.map((resourceId, index) => ({ + commandId: makeCommandId(`resource-grant-${resourceId}-${index}`), durationMs: Math.round(Number(form.durationDays || 0) * dayMillis), quantity: Number(form.quantity || 1), reason, - resourceId: Number(form.resourceId), + resourceId: Number(resourceId), targetUserId, - }, + })), subjectType: "resource", }; } return { group: { - commandId, + commandId: makeCommandId("resource-group-grant"), groupId: Number(form.groupId), reason, targetUserId, @@ -1463,6 +1536,13 @@ function buildGrantPayload(form) { }; } +function grantResourceIds(form) { + if (Array.isArray(form.resourceIds) && form.resourceIds.length) { + return form.resourceIds; + } + return form.resourceId ? [form.resourceId] : []; +} + function makeCommandId(prefix) { return `${prefix}-${Date.now()}`; } diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index dc6ee4a..e8acbdd 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -13,6 +13,7 @@ import { } from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; +import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx"; import { resourceGrantStatusFilters, resourceGrantStatusLabels, @@ -150,6 +151,8 @@ export function ResourceGrantListPage() { setForm({ ...form, targetUserId: event.target.value })} /> @@ -204,7 +209,13 @@ function ResourceGrantDialog({ select value={form.subjectType} onChange={(event) => - setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value }) + setForm({ + ...form, + groupId: "", + resourceId: "", + resourceIds: [], + subjectType: event.target.value, + }) } > 资源 @@ -212,20 +223,18 @@ function ResourceGrantDialog({ {form.subjectType === "resource" ? ( <> - setForm({ ...form, resourceId: event.target.value })} - > - {resourceOptions.map((resource) => ( - - {resource.name || resource.resourceCode || resource.resourceId} - - ))} - + resources={resourceOptions} + selectedResourceIds={form.resourceIds || []} + onChange={(resourceIds) => setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })} + /> { + if (form.resourceType !== "profile_card") { + return; + } + try { + const layout = await detectProfileCardLayout(file); + setForm((previous) => + previous.resourceType === "profile_card" + ? { + ...previous, + metadataJson: mergeProfileCardLayoutMetadata(previous.metadataJson, layout), + } + : previous, + ); + } catch (err) { + showToast(err.message || "资料卡内容高度解析失败", "error"); + } + }; return ( { const resourceType = event.target.value; - setForm({ - ...form, - badgeForm: resourceType === "badge" ? form.badgeForm || "tile" : form.badgeForm, + setForm((previous) => ({ + ...previous, + badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm, + badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal", + levelTrack: resourceType === "badge" ? previous.levelTrack || "" : "", + metadataJson: resourceType === "profile_card" ? previous.metadataJson : "", resourceType, walletAssetAmount: "", - }); + })); }} > {resourceTypeOptions.map(([value, label]) => ( @@ -452,20 +498,58 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, /> ) : null} {form.resourceType === "badge" ? ( - setForm({ ...form, badgeForm: event.target.value })} - > - {badgeFormOptions.map(([value, label]) => ( - - {label} - - ))} - + <> + setForm({ ...form, badgeForm: event.target.value })} + > + {badgeFormOptions.map(([value, label]) => ( + + {label} + + ))} + + + setForm({ + ...form, + badgeKind: event.target.value, + levelTrack: event.target.value === "level" ? form.levelTrack : "", + }) + } + > + {badgeKindOptions.map(([value, label]) => ( + + {label} + + ))} + + {form.badgeKind === "level" ? ( + setForm({ ...form, levelTrack: event.target.value })} + > + {badgeLevelTrackOptions.map(([value, label]) => ( + + {label} + + ))} + + ) : null} + ) : null} @@ -508,16 +592,23 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, kind="file" label="资源封面" value={form.previewUrl} - onChange={(previewUrl) => setForm({ ...form, previewUrl })} + onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))} /> setForm({ ...form, animationUrl })} + onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))} + onFileSelected={handleProfileCardAnimationSelected} /> + {profileCardLayout ? ( + + + + + ) : null} + frame?.data && + Number.isInteger(frame.width) && + Number.isInteger(frame.height) && + frame.width > 0 && + frame.height > 0, + ); + if (!validFrames.length) { + throw new Error("资料卡素材无法读取有效画面"); + } + + const vapcLayout = sanitizeVapcLayout(options.vapcLayout, validFrames[0]); + if (vapcLayout) { + const vapcMeasure = measureVapcProfileCardContent(validFrames, vapcLayout); + if (vapcMeasure) { + return vapcMeasure; + } + } + + const bounds = validFrames.map((frame) => scanFrameBounds(frame)).filter(Boolean); + if (!bounds.length) { + throw new Error("资料卡素材没有解析到有效内容"); + } + const firstFrame = validFrames[0]; + const top = Math.min(...bounds.map((item) => item.contentTop)); + const bottom = Math.max(...bounds.map((item) => item.contentBottom)); + const scanWidth = Math.min(...bounds.map((item) => item.colorContentWidth).filter((value) => value > 0)); + const contentHeight = Math.max(0, bottom - top + 1); + + return { + source_width: firstFrame.width, + source_height: firstFrame.height, + color_content_width: scanWidth || firstFrame.width, + content_top: top, + content_bottom: bottom, + content_height: contentHeight, + content_top_ratio: ratio(top, firstFrame.height), + content_height_ratio: ratio(contentHeight, firstFrame.height), + detect_version: 1, + }; +} + +function measureVapcProfileCardContent(frames, vapcLayout) { + const bounds = frames + .map((frame) => + scanFrameBounds(frame, { + scanLeft: vapcLayout.rgbFrame.x, + scanWidth: vapcLayout.rgbFrame.width, + }), + ) + .filter(Boolean); + const alphaTops = frames + .map((frame) => scanVapcAlphaTop(frame, vapcLayout)) + .filter((value) => Number.isInteger(value) && value >= 0); + if (!bounds.length || !alphaTops.length) { + return null; + } + + const firstFrame = frames[0]; + const top = clampInteger( + snapPixel(upperQuantile(alphaTops, stableTopQuantile), contentTopSnapPixels), + 0, + firstFrame.height - 1, + ); + const bottom = Math.max(...bounds.map((item) => item.contentBottom)); + if (bottom < top) { + return null; + } + const contentHeight = bottom - top + 1; + + return { + source_width: firstFrame.width, + source_height: firstFrame.height, + color_content_width: vapcLayout.rgbFrame.width, + content_top: top, + content_bottom: bottom, + content_height: contentHeight, + content_top_ratio: ratio(top, firstFrame.height), + content_height_ratio: ratio(contentHeight, firstFrame.height), + detect_version: 2, + }; +} + +function scanFrameBounds(frame, options = {}) { + const { data, width, height } = frame; + const alphaMode = hasMeaningfulAlpha(data); + const scanLeft = clampInteger(options.scanLeft ?? 0, 0, width - 1); + const maxScanWidth = width - scanLeft; + const colorContentWidth = alphaMode + ? maxScanWidth + : options.scanWidth || detectColorContentWidth(data, width, height); + const scanWidth = Math.max(1, Math.min(maxScanWidth, colorContentWidth)); + const background = alphaMode ? null : sampleBackgroundColor(data, width, height, scanLeft, scanWidth); + const minPixels = Math.max(3, Math.floor(scanWidth * minContentRowRatio)); + const rowHasContent = (y) => + rowContentPixels(data, width, scanLeft, scanWidth, y, background, alphaMode) >= minPixels; + const contentTop = findFirstContentRow(height, rowHasContent); + const contentBottom = findLastContentRow(height, rowHasContent); + + if (contentTop < 0 || contentBottom < contentTop) { + return null; + } + return { + colorContentWidth: scanWidth, + contentTop, + contentBottom, + }; +} + +function scanVapcAlphaTop(frame, vapcLayout) { + const { data, width, height } = frame; + const alphaFrame = vapcLayout.alphaFrame; + const left = clampInteger(alphaFrame.x, 0, width - 1); + const top = clampInteger(alphaFrame.y, 0, height - 1); + const scanWidth = Math.max(1, Math.min(alphaFrame.width, width - left)); + const scanHeight = Math.max(1, Math.min(alphaFrame.height, height - top)); + const minPixels = Math.max(3, Math.floor(scanWidth * alphaMaskContentRowRatio)); + const rowHasContent = (y) => rowAlphaMaskPixels(data, width, left, scanWidth, y) >= minPixels; + let run = 0; + for (let y = top; y < top + scanHeight; y += 1) { + run = rowHasContent(y) ? run + 1 : 0; + if (run >= consecutiveRows) { + return y - consecutiveRows + 1; + } + } + return -1; +} + +function findFirstContentRow(height, rowHasContent) { + let run = 0; + for (let y = 0; y < height; y += 1) { + run = rowHasContent(y) ? run + 1 : 0; + if (run >= consecutiveRows) { + return y - consecutiveRows + 1; + } + } + return -1; +} + +function findLastContentRow(height, rowHasContent) { + let run = 0; + for (let y = height - 1; y >= 0; y -= 1) { + run = rowHasContent(y) ? run + 1 : 0; + if (run >= consecutiveRows) { + return y + consecutiveRows - 1; + } + } + return -1; +} + +function rowContentPixels(data, width, scanLeft, scanWidth, y, background, alphaMode) { + let count = 0; + const offset = y * width * 4; + for (let x = scanLeft; x < scanLeft + scanWidth; x += 1) { + const index = offset + x * 4; + const alpha = data[index + 3]; + if (alphaMode) { + if (alpha > alphaContentThreshold) { + count += 1; + } + continue; + } + if (alpha <= alphaContentThreshold) { + continue; + } + const diff = + Math.abs(data[index] - background.r) + + Math.abs(data[index + 1] - background.g) + + Math.abs(data[index + 2] - background.b); + if (diff > rgbDiffThreshold) { + count += 1; + } + } + return count; +} + +function rowAlphaMaskPixels(data, width, scanLeft, scanWidth, y) { + let count = 0; + const offset = y * width * 4; + for (let x = scanLeft; x < scanLeft + scanWidth; x += 1) { + const index = offset + x * 4; + const brightness = (data[index] + data[index + 1] + data[index + 2]) / 3; + if (brightness > alphaMaskBrightnessThreshold) { + count += 1; + } + } + return count; +} + +function hasMeaningfulAlpha(data) { + const totalPixels = Math.floor(data.length / 4); + if (!totalPixels) { + return false; + } + let transparentPixels = 0; + for (let index = 3; index < data.length; index += 4) { + if (data[index] < alphaTransparentThreshold) { + transparentPixels += 1; + } + } + return transparentPixels / totalPixels > 0.005; +} + +function sampleBackgroundColor(data, width, height, scanLeft, scanWidth) { + const sampleRows = Math.max(1, Math.floor(height * 0.02)); + const sampleStep = Math.max(1, Math.floor(scanWidth / 160)); + let r = 0; + let g = 0; + let b = 0; + let count = 0; + for (let y = 0; y < sampleRows; y += 1) { + const offset = y * width * 4; + for (let x = scanLeft; x < scanLeft + scanWidth; x += sampleStep) { + const index = offset + x * 4; + r += data[index]; + g += data[index + 1]; + b += data[index + 2]; + count += 1; + } + } + return count + ? { + r: Math.round(r / count), + g: Math.round(g / count), + b: Math.round(b / count), + } + : { r: 0, g: 0, b: 0 }; +} + +function detectColorContentWidth(data, width, height) { + const minMaskWidth = Math.max(1, Math.floor(width * 0.15)); + const minStart = Math.floor(width * 0.45); + const rowStep = Math.max(1, Math.floor(height / 240)); + let run = 0; + let start = width; + + for (let x = width - 1; x >= 0; x -= 1) { + if (isLowChromaColumn(data, width, height, x, rowStep)) { + run += 1; + start = x; + continue; + } + if (run >= minMaskWidth && start >= minStart) { + return start; + } + run = 0; + start = width; + } + if (run >= minMaskWidth && start >= minStart) { + return start; + } + return width; +} + +function isLowChromaColumn(data, width, height, x, rowStep) { + let lowChromaPixels = 0; + let sampled = 0; + for (let y = 0; y < height; y += rowStep) { + const index = (y * width + x) * 4; + const max = Math.max(data[index], data[index + 1], data[index + 2]); + const min = Math.min(data[index], data[index + 1], data[index + 2]); + if (max - min <= 8) { + lowChromaPixels += 1; + } + sampled += 1; + } + return sampled > 0 && lowChromaPixels / sampled > 0.92; +} + +async function detectImageLayout(file) { + const bitmap = await createImageBitmap(file); + try { + const frame = drawBitmapFrame(bitmap, bitmap.width, bitmap.height); + return measureProfileCardContent([frame]); + } finally { + bitmap.close?.(); + } +} + +async function detectVideoLayout(file) { + const video = document.createElement("video"); + const url = URL.createObjectURL(file); + try { + const vapcLayoutPromise = parseVapcLayout(file).catch(() => null); + video.muted = true; + video.preload = "metadata"; + video.playsInline = true; + video.src = url; + await waitForVideoEvent(video, "loadedmetadata"); + await waitForVideoReady(video); + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; + const width = video.videoWidth; + const height = video.videoHeight; + if (!width || !height) { + throw new Error("资料卡视频尺寸无效"); + } + const sampleTimes = videoSampleTimes(duration); + const frames = []; + for (const time of sampleTimes) { + await seekVideo(video, time); + frames.push(drawBitmapFrame(video, width, height)); + } + return measureProfileCardContent(frames, { vapcLayout: await vapcLayoutPromise }); + } finally { + video.removeAttribute("src"); + video.load(); + URL.revokeObjectURL(url); + } +} + +async function parseVapcLayout(file) { + const bytes = new Uint8Array(await file.arrayBuffer()); + const markerIndex = indexOfBytes(bytes, [118, 97, 112, 99]); + if (markerIndex < 0) { + return null; + } + const searchEnd = Math.min(bytes.length, markerIndex + 4096); + const jsonStart = findByte(bytes, 123, markerIndex + 4, searchEnd); + if (jsonStart < 0) { + return null; + } + const jsonEnd = findJsonObjectEnd(bytes, jsonStart, searchEnd); + if (jsonEnd <= jsonStart) { + return null; + } + const payload = JSON.parse(new TextDecoder().decode(bytes.slice(jsonStart, jsonEnd + 1))); + return payload?.info || null; +} + +function sanitizeVapcLayout(value, frame) { + if (!value || typeof value !== "object") { + return null; + } + const sourceWidth = positiveInteger(value.videoW ?? value.video_width); + const sourceHeight = positiveInteger(value.videoH ?? value.video_height); + if (frame && (sourceWidth !== frame.width || sourceHeight !== frame.height)) { + return null; + } + const rgbFrame = parseVapcFrame(value.rgbFrame ?? value.rgb_frame); + const alphaFrame = parseVapcFrame(value.aFrame ?? value.a_frame); + if (!sourceWidth || !sourceHeight || !rgbFrame || !alphaFrame) { + return null; + } + if (!rectFitsFrame(rgbFrame, sourceWidth, sourceHeight) || !rectFitsFrame(alphaFrame, sourceWidth, sourceHeight)) { + return null; + } + return { + sourceWidth, + sourceHeight, + contentWidth: positiveInteger(value.w) || rgbFrame.width, + contentHeight: positiveInteger(value.h) || rgbFrame.height, + rgbFrame, + alphaFrame, + }; +} + +function parseVapcFrame(value) { + const raw = Array.isArray(value) + ? value + : typeof value === "string" + ? value.split(",").map((item) => Number(item.trim())) + : []; + if (raw.length < 4) { + return null; + } + const rect = { + x: nonNegativeInteger(raw[0]), + y: nonNegativeInteger(raw[1]), + width: positiveInteger(raw[2]), + height: positiveInteger(raw[3]), + }; + if (rect.x === null || rect.y === null || !rect.width || !rect.height) { + return null; + } + return rect; +} + +function indexOfBytes(bytes, pattern) { + if (!bytes?.length || !pattern?.length || pattern.length > bytes.length) { + return -1; + } + for (let index = 0; index <= bytes.length - pattern.length; index += 1) { + let matched = true; + for (let patternIndex = 0; patternIndex < pattern.length; patternIndex += 1) { + if (bytes[index + patternIndex] !== pattern[patternIndex]) { + matched = false; + break; + } + } + if (matched) { + return index; + } + } + return -1; +} + +function findByte(bytes, target, start, end) { + for (let index = Math.max(0, start); index < Math.min(bytes.length, end); index += 1) { + if (bytes[index] === target) { + return index; + } + } + return -1; +} + +function findJsonObjectEnd(bytes, start, end) { + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < Math.min(bytes.length, end); index += 1) { + const byte = bytes[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (byte === 92) { + escaping = true; + } else if (byte === 34) { + inString = false; + } + continue; + } + if (byte === 34) { + inString = true; + continue; + } + if (byte === 123) { + depth += 1; + continue; + } + if (byte === 125) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return -1; +} + +function rectFitsFrame(rect, width, height) { + return ( + rect.x >= 0 && + rect.y >= 0 && + rect.width > 0 && + rect.height > 0 && + rect.x + rect.width <= width && + rect.y + rect.height <= height + ); +} + +async function detectPagLayout(file) { + const PAG = await getPAGModule(); + let pagFile; + let pagView; + try { + const buffer = await file.arrayBuffer(); + pagFile = await PAG.PAGFile.load(buffer); + const width = pagFile.width(); + const height = pagFile.height(); + if (!width || !height) { + throw new Error("资料卡 PAG 尺寸无效"); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + pagView = await PAG.PAGView.init(pagFile, canvas, { firstFrame: false, useScale: false }); + if (!pagView) { + throw new Error("资料卡 PAG 读取失败"); + } + const frames = []; + for (const progress of pagSampleProgresses()) { + pagView.setProgress(progress); + await pagView.flush(); + frames.push(drawBitmapFrame(canvas, width, height)); + } + return measureProfileCardContent(frames); + } finally { + if (pagView) { + pagView.destroy(); + } + pagFile?.destroy?.(); + } +} + +function drawBitmapFrame(source, width, height) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + throw new Error("当前浏览器无法读取资料卡画面"); + } + context.clearRect(0, 0, width, height); + context.drawImage(source, 0, 0, width, height); + const imageData = context.getImageData(0, 0, width, height); + return { + data: imageData.data, + width, + height, + }; +} + +function videoSampleTimes(duration) { + if (!duration) { + return [0]; + } + const last = Math.max(0, duration - 0.05); + if (sampleFrameCount <= 1) { + return [Math.min(0.05, last)]; + } + return Array.from({ length: sampleFrameCount }, (_, index) => { + const progress = index / (sampleFrameCount - 1); + return Math.min(last, Math.max(0, progress * last)); + }); +} + +function pagSampleProgresses() { + return Array.from({ length: sampleFrameCount }, (_, index) => + sampleFrameCount <= 1 ? 0 : index / (sampleFrameCount - 1), + ); +} + +function waitForVideoEvent(video, eventName) { + return new Promise((resolve, reject) => { + const cleanup = () => { + video.removeEventListener(eventName, handleSuccess); + video.removeEventListener("error", handleError); + }; + const handleSuccess = () => { + cleanup(); + resolve(); + }; + const handleError = () => { + cleanup(); + reject(new Error("资料卡视频读取失败")); + }; + video.addEventListener(eventName, handleSuccess, { once: true }); + video.addEventListener("error", handleError, { once: true }); + }); +} + +function seekVideo(video, time) { + return new Promise((resolve, reject) => { + const targetTime = Math.min(Math.max(0, time), video.duration || time || 0); + if (video.readyState >= 2 && Math.abs(video.currentTime - targetTime) < 0.01) { + waitForPresentedVideoFrame(video).then(resolve).catch(reject); + return; + } + const cleanup = () => { + video.removeEventListener("seeked", handleSeeked); + video.removeEventListener("error", handleError); + }; + const handleSeeked = () => { + cleanup(); + waitForPresentedVideoFrame(video).then(resolve).catch(reject); + }; + const handleError = () => { + cleanup(); + reject(new Error("资料卡视频抽帧失败")); + }; + video.addEventListener("seeked", handleSeeked, { once: true }); + video.addEventListener("error", handleError, { once: true }); + video.currentTime = targetTime; + }); +} + +function waitForVideoReady(video) { + if (video.readyState >= 2) { + return waitForPresentedVideoFrame(video); + } + return waitForVideoEvent(video, "loadeddata").then(() => waitForPresentedVideoFrame(video)); +} + +function waitForPresentedVideoFrame(video) { + if (typeof video.requestVideoFrameCallback === "function") { + return new Promise((resolve) => { + const timeout = window.setTimeout(resolve, 120); + video.requestVideoFrameCallback(() => { + window.clearTimeout(timeout); + resolve(); + }); + }); + } + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); +} + +function sanitizeProfileCardLayout(value) { + if (!value || typeof value !== "object") { + return null; + } + const sourceWidth = positiveInteger(value.source_width); + const sourceHeight = positiveInteger(value.source_height); + const contentTop = nonNegativeInteger(value.content_top); + const contentBottom = nonNegativeInteger(value.content_bottom); + const contentHeight = positiveInteger(value.content_height); + if (!sourceWidth || !sourceHeight || contentTop === null || contentBottom === null || !contentHeight) { + return null; + } + if ( + contentBottom < contentTop || + contentBottom >= sourceHeight || + contentHeight !== contentBottom - contentTop + 1 + ) { + return null; + } + return { + source_width: sourceWidth, + source_height: sourceHeight, + color_content_width: positiveInteger(value.color_content_width) || sourceWidth, + content_top: contentTop, + content_bottom: contentBottom, + content_height: contentHeight, + content_top_ratio: decimalRatio(value.content_top_ratio, ratio(contentTop, sourceHeight)), + content_height_ratio: decimalRatio(value.content_height_ratio, ratio(contentHeight, sourceHeight)), + detect_version: positiveInteger(value.detect_version) || 1, + }; +} + +function isVideoFile(file) { + return file.type?.startsWith("video/") || /\.(mp4|mov|m4v|webm)$/i.test(file.name || ""); +} + +function isPagFile(file) { + return file.type?.includes("pag") || /\.pag$/i.test(file.name || ""); +} + +function isImageFile(file) { + return file.type?.startsWith("image/") || /\.(avif|gif|jpe?g|png|webp)$/i.test(file.name || ""); +} + +function getPAGModule() { + if (!pagModulePromise) { + pagModulePromise = import("libpag").then(({ PAGInit }) => PAGInit({ locateFile: () => pagWasmURL })); + } + return pagModulePromise; +} + +function positiveInteger(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : 0; +} + +function nonNegativeInteger(value) { + const number = Number(value); + return Number.isInteger(number) && number >= 0 ? number : null; +} + +function ratio(value, total) { + return Number((value / total).toFixed(6)); +} + +function decimalRatio(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number >= 0 && number <= 1 ? Number(number.toFixed(6)) : fallback; +} + +function clampInteger(value, min, max) { + const number = Number(value); + if (!Number.isInteger(number)) { + return min; + } + return Math.min(max, Math.max(min, number)); +} + +function upperQuantile(values, quantile) { + const sorted = values.filter((value) => Number.isFinite(value)).sort((left, right) => left - right); + if (!sorted.length) { + return 0; + } + const index = Math.min(sorted.length - 1, Math.ceil((sorted.length - 1) * quantile)); + return sorted[index]; +} + +function snapPixel(value, size) { + if (!size || size <= 1) { + return Math.round(value); + } + return Math.round(value / size) * size; +} diff --git a/src/features/resources/profileCardLayout.test.js b/src/features/resources/profileCardLayout.test.js new file mode 100644 index 0000000..7e0096a --- /dev/null +++ b/src/features/resources/profileCardLayout.test.js @@ -0,0 +1,113 @@ +import { describe, expect, test } from "vitest"; +import { + measureProfileCardContent, + mergeProfileCardLayoutMetadata, + profileCardLayoutFromMetadata, + profileCardMetadataJSON, +} from "./profileCardLayout.js"; + +describe("profile card layout detection", () => { + test("ignores right-side grayscale mask when scanning video frames", () => { + const width = 10; + const height = 10; + const frame = opaqueFrame(width, height, [0, 0, 0]); + fillRect(frame.data, width, 7, 0, 3, height, [255, 255, 255, 255]); + fillRect(frame.data, width, 1, 3, 5, 5, [255, 0, 0, 255]); + + const layout = measureProfileCardContent([frame]); + + expect(layout.color_content_width).toBeLessThan(width); + expect(layout.content_top).toBe(3); + expect(layout.content_bottom).toBe(7); + expect(layout.content_height).toBe(5); + }); + + test("uses real alpha when the asset contains transparent rows", () => { + const width = 12; + const height = 12; + const data = new Uint8ClampedArray(width * height * 4); + fillRect(data, width, 0, 4, width, 7, [36, 120, 220, 255]); + + const layout = measureProfileCardContent([{ data, width, height }]); + + expect(layout.content_top).toBe(4); + expect(layout.content_bottom).toBe(10); + expect(layout.content_height).toBe(7); + }); + + test("ignores blank transparent frames instead of returning full height", () => { + const width = 12; + const height = 12; + const blank = { data: new Uint8ClampedArray(width * height * 4), width, height }; + const content = { data: new Uint8ClampedArray(width * height * 4), width, height }; + fillRect(content.data, width, 0, 4, width, 7, [36, 120, 220, 255]); + + expect(measureProfileCardContent([blank, content]).content_top).toBe(4); + expect(() => measureProfileCardContent([blank])).toThrow("资料卡素材没有解析到有效内容"); + }); + + test("uses VAP alpha mask top instead of transient RGB decoration", () => { + const width = 20; + const height = 20; + const alphaStarts = [5, 6, 7, 8]; + const frames = alphaStarts.map((alphaTop, index) => { + const frame = opaqueFrame(width, height, [0, 0, 0]); + fillRect(frame.data, width, 1, 2 + index, 3, 2, [255, 0, 0, 255]); + fillRect(frame.data, width, 0, 8, 12, 10, [36, 120, 220, 255]); + fillRect(frame.data, width, 12, alphaTop, 6, 10 - alphaTop, [255, 255, 255, 255]); + return frame; + }); + + const layout = measureProfileCardContent(frames, { + vapcLayout: { + videoW: width, + videoH: height, + rgbFrame: [0, 0, 12, 18], + aFrame: [12, 0, 6, 10], + }, + }); + + expect(layout.content_top).toBe(8); + expect(layout.content_bottom).toBe(17); + expect(layout.content_height).toBe(10); + expect(layout.detect_version).toBe(2); + }); + + test("serializes only valid profile card layout metadata", () => { + const metadataJson = mergeProfileCardLayoutMetadata("", { + source_width: 10, + source_height: 10, + color_content_width: 7, + content_top: 3, + content_bottom: 7, + content_height: 5, + content_top_ratio: 0.3, + content_height_ratio: 0.5, + detect_version: 1, + }); + + expect(profileCardLayoutFromMetadata(metadataJson)).toMatchObject({ + content_top: 3, + content_height: 5, + }); + expect(profileCardMetadataJSON('{"unexpected":true}')).toBe("{}"); + }); +}); + +function opaqueFrame(width, height, rgb) { + const data = new Uint8ClampedArray(width * height * 4); + fillRect(data, width, 0, 0, width, height, [rgb[0], rgb[1], rgb[2], 255]); + return { data, width, height }; +} + +function fillRect(data, width, left, top, rectWidth, rectHeight, rgba) { + for (let y = top; y < top + rectHeight; y += 1) { + for (let x = left; x < left + rectWidth; x += 1) { + const index = (y * width + x) * 4; + data[index] = rgba[0]; + data[index + 1] = rgba[1]; + data[index + 2] = rgba[2]; + data[index + 3] = rgba[3]; + } + } +} diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 08584c2..2e93498 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -16,6 +16,8 @@ const resourceTypes = [ const walletAssetTypes = ["COIN"]; const resourcePriceTypes = ["coin", "free"]; const badgeForms = ["strip", "tile"]; +const badgeKinds = ["normal", "level"]; +const badgeLevelTracks = ["wealth", "game", "charm"]; const resourceShopDurations = ["1", "3", "7"]; const optionalWalletAssetTypeSchema = z .preprocess((value) => { @@ -31,7 +33,10 @@ export const resourceCreateFormSchema = z .object({ animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"), badgeForm: z.enum(badgeForms).optional(), + badgeKind: z.enum(badgeKinds).optional(), enabled: z.boolean(), + levelTrack: z.enum(badgeLevelTracks).optional().or(z.literal("")), + metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(), name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"), coinPrice: z.union([z.string(), z.number()]).optional(), previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"), @@ -49,6 +54,13 @@ export const resourceCreateFormSchema = z path: ["badgeForm"], }); } + if (value.resourceType === "badge" && value.badgeKind === "level" && !value.levelTrack) { + context.addIssue({ + code: "custom", + message: "请选择等级徽章所属等级", + path: ["levelTrack"], + }); + } } else { const amount = Number(value.walletAssetAmount); if (!Number.isInteger(amount) || amount <= 0) { @@ -378,12 +390,13 @@ export const resourceGrantFormSchema = z quantity: z.union([z.string(), z.number()]).optional(), 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"]), targetUserId: z.union([z.string(), z.number()]), }) .superRefine((value, context) => { const targetUserId = Number(value.targetUserId); - const resourceId = Number(value.resourceId || 0); + const resourceIds = grantResourceIds(value); const groupId = Number(value.groupId || 0); const quantity = Number(value.quantity || 1); const durationDays = Number(value.durationDays || 0); @@ -395,11 +408,23 @@ export const resourceGrantFormSchema = z path: ["targetUserId"], }); } - if (value.subjectType === "resource" && (!Number.isInteger(resourceId) || resourceId <= 0)) { - context.addIssue({ - code: "custom", - message: "请选择资源", - path: ["resourceId"], + if (value.subjectType === "resource") { + if (!resourceIds.length) { + context.addIssue({ + code: "custom", + message: "请选择资源", + path: ["resourceIds"], + }); + } + resourceIds.forEach((resourceIdValue, index) => { + const resourceId = Number(resourceIdValue || 0); + if (!Number.isInteger(resourceId) || resourceId <= 0) { + context.addIssue({ + code: "custom", + message: "请选择资源", + path: ["resourceIds", index], + }); + } }); } if (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) { @@ -424,3 +449,10 @@ export const resourceGrantFormSchema = z }); } }); + +function grantResourceIds(value) { + if (Array.isArray(value.resourceIds) && value.resourceIds.length) { + return value.resourceIds; + } + return value.resourceId ? [value.resourceId] : []; +} diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index 738d02c..baff934 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -15,6 +15,8 @@ describe("resource form schema", () => { animationUrl: "https://media.haiyihy.com/resource/profile-card.pag", badgeForm: "tile", enabled: true, + metadataJson: + '{"profile_card_layout":{"source_width":1136,"source_height":1680,"color_content_width":750,"content_top":117,"content_bottom":1666,"content_height":1550,"content_top_ratio":0.069643,"content_height_ratio":0.922619,"detect_version":1}}', name: "Profile Card", previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png", priceType: "free", @@ -24,13 +26,16 @@ describe("resource form schema", () => { }); expect(payload.resourceType).toBe("profile_card"); + expect(payload.metadataJson).toContain("profile_card_layout"); }); test("requires badge form for badge resources", () => { const payload = parseForm(resourceFormSchema, { animationUrl: "https://media.haiyihy.com/resource/badge.pag", badgeForm: "strip", + badgeKind: "level", enabled: true, + levelTrack: "wealth", name: "VIP Badge", previewUrl: "https://media.haiyihy.com/resource/badge.png", priceType: "free", @@ -40,6 +45,8 @@ describe("resource form schema", () => { }); expect(payload.badgeForm).toBe("strip"); + expect(payload.badgeKind).toBe("level"); + expect(payload.levelTrack).toBe("wealth"); expect(() => parseForm(resourceFormSchema, { animationUrl: "https://media.haiyihy.com/resource/badge.pag", @@ -52,6 +59,20 @@ describe("resource form schema", () => { walletAssetAmount: "", }), ).toThrow(FormValidationError); + expect(() => + parseForm(resourceFormSchema, { + animationUrl: "https://media.haiyihy.com/resource/badge.pag", + badgeForm: "strip", + badgeKind: "level", + enabled: true, + name: "VIP Badge", + previewUrl: "https://media.haiyihy.com/resource/badge.png", + priceType: "free", + resourceCode: "badge_vip_level", + resourceType: "badge", + walletAssetAmount: "", + }), + ).toThrow(FormValidationError); }); test("allows resource group resource items without wallet asset type", () => { @@ -141,12 +162,13 @@ describe("resource form schema", () => { durationDays: "7", quantity: "1", reason: "manual", - resourceId: "11", + resourceIds: ["11", "12"], subjectType: "resource", targetUserId: "1001", }); expect(payload.subjectType).toBe("resource"); + expect(payload.resourceIds).toEqual(["11", "12"]); }); test("validates resource shop durations", () => { diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index cbc989d..b098f8a 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -154,6 +154,7 @@ export const API_OPERATIONS = { login: "login", logout: "logout", lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget", + lookupResourceGrantTarget: "lookupResourceGrantTarget", me: "me", navigationMenus: "navigationMenus", publishHostAgencyPolicy: "publishHostAgencyPolicy", @@ -1197,6 +1198,13 @@ export const API_ENDPOINTS: Record = { permission: "coin-adjustment:create", permissions: ["coin-adjustment:create"] }, + lookupResourceGrantTarget: { + method: "GET", + operationId: API_OPERATIONS.lookupResourceGrantTarget, + path: "/v1/admin/resource-grants/target", + permission: "resource-grant:create", + permissions: ["resource-grant:create"] + }, me: { method: "GET", operationId: API_OPERATIONS.me, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 018b908..6c00382 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1300,6 +1300,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/resource-grants/target": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["lookupResourceGrantTarget"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/resource-shop/items": { parameters: { query?: never; @@ -4261,6 +4277,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + lookupResourceGrantTarget: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listResourceShopItems: { parameters: { query?: never; diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 20d4d33..0eb9bcd 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -14,376 +14,399 @@ const pagWasmURL = "/vendor/libpag.wasm"; let pagModulePromise; export function UploadField({ - accept, - disabled = false, - kind = "image", - label, - onChange, - uploadKind, - value = "" + accept, + disabled = false, + kind = "image", + label, + onChange, + onFileSelected, + uploadKind, + value = "", }) { - const inputId = useId(); - const inputRef = useRef(null); - const { showToast } = useToast(); - const [uploading, setUploading] = useState(false); - const [localPreview, setLocalPreview] = useState(null); - const isImage = kind === "image"; - const uploadMode = uploadKind || kind; - const useImageUpload = uploadMode === "image"; - const source = localPreview?.url || value; - const assetKind = localPreview?.assetKind || getAssetKind(value, kind); - const displayName = localPreview?.name || getDisplayValue(value); - const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; + const inputId = useId(); + const inputRef = useRef(null); + const { showToast } = useToast(); + const [uploading, setUploading] = useState(false); + const [localPreview, setLocalPreview] = useState(null); + const isImage = kind === "image"; + const uploadMode = uploadKind || kind; + const useImageUpload = uploadMode === "image"; + const source = localPreview?.url || value; + const assetKind = localPreview?.assetKind || getAssetKind(value, kind); + const displayName = localPreview?.name || getDisplayValue(value); + const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; - useEffect(() => { - return () => { - if (localPreview?.url) { - URL.revokeObjectURL(localPreview.url); - } + useEffect(() => { + return () => { + if (localPreview?.url) { + URL.revokeObjectURL(localPreview.url); + } + }; + }, [localPreview]); + + const openPicker = () => { + if (!disabled && !uploading) { + inputRef.current?.click(); + } }; - }, [localPreview]); - const openPicker = () => { - if (!disabled && !uploading) { - inputRef.current?.click(); - } - }; + const handleFileChange = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } - const handleFileChange = async (event) => { - const file = event.target.files?.[0]; - event.target.value = ""; - if (!file) { - return; - } + setLocalPreview((previous) => { + if (previous?.url) { + URL.revokeObjectURL(previous.url); + } + return { + assetKind: getAssetKind(file.name, kind, file.type), + name: file.name, + url: URL.createObjectURL(file), + }; + }); + setUploading(true); + try { + await onFileSelected?.(file); + const result = useImageUpload ? await uploadImage(file) : await uploadFile(file); + onChange(result.url); + setLocalPreview(null); + showToast("上传成功", "success"); + } catch (err) { + setLocalPreview(null); + showToast(err.message || "上传失败", "error"); + } finally { + setUploading(false); + } + }; - setLocalPreview((previous) => { - if (previous?.url) { - URL.revokeObjectURL(previous.url); - } - return { - assetKind: getAssetKind(file.name, kind, file.type), - name: file.name, - url: URL.createObjectURL(file) - }; - }); - setUploading(true); - try { - const result = useImageUpload ? await uploadImage(file) : await uploadFile(file); - onChange(result.url); - setLocalPreview(null); - showToast("上传成功", "success"); - } catch (err) { - setLocalPreview(null); - showToast(err.message || "上传失败", "error"); - } finally { - setUploading(false); - } - }; + const clearValue = () => { + setLocalPreview(null); + onChange(""); + }; - const clearValue = () => { - setLocalPreview(null); - onChange(""); - }; - - return ( -
-
- {label} - {assetKindLabel(assetKind, isImage)} -
-
- -
- {displayName || "未上传"} - - - - - - - {source ? ( - - - - - - ) : null} - +
+ {displayName || "未上传"} + + + + + + + {source ? ( + + + + + + ) : null} + +
+
+
-
- - - ); + ); } function AssetPreview({ assetKind, isImage, src }) { - if (!src) { + if (!src) { + return ( + + {isImage ? : } + + ); + } + if (assetKind === "svga") { + return ; + } + if (assetKind === "pag") { + return ; + } + if (assetKind === "image" || isImage) { + return ; + } return ( - - {isImage ? : } - + + + ); - } - if (assetKind === "svga") { - return ; - } - if (assetKind === "pag") { - return ; - } - if (assetKind === "image" || isImage) { - return ; - } - return ( - - - - ); } function RasterAssetPreview({ src }) { - const [failed, setFailed] = useState(false); + const [failed, setFailed] = useState(false); - useEffect(() => { - setFailed(false); - }, [src]); + useEffect(() => { + setFailed(false); + }, [src]); - if (failed) { - return ( - - - - ); - } + if (failed) { + return ( + + + + ); + } - return setFailed(true)} />; + return setFailed(true)} />; } function SVGAAssetPreview({ src }) { - const containerRef = useRef(null); - const [failed, setFailed] = useState(false); + const containerRef = useRef(null); + const [failed, setFailed] = useState(false); - useEffect(() => { - let cancelled = false; - const container = containerRef.current; - let player; - setFailed(false); + useEffect(() => { + let cancelled = false; + const container = containerRef.current; + let player; + setFailed(false); - async function load() { - try { - const module = await import("svgaplayerweb"); - const SVGA = module.default || module; - if (cancelled || !container) { - return; - } - container.innerHTML = ""; - player = new SVGA.Player(container); - player.loops = 0; - player.clearsAfterStop = false; - player.setContentMode("AspectFit"); - const parser = new SVGA.Parser(container); - parser.load( - src, - (videoItem) => { - if (cancelled) { - return; + async function load() { + try { + const module = await import("svgaplayerweb"); + const SVGA = module.default || module; + if (cancelled || !container) { + return; + } + container.innerHTML = ""; + player = new SVGA.Player(container); + player.loops = 0; + player.clearsAfterStop = false; + player.setContentMode("AspectFit"); + const parser = new SVGA.Parser(container); + parser.load( + src, + (videoItem) => { + if (cancelled) { + return; + } + player.setVideoItem(videoItem); + player.startAnimation(); + }, + () => { + if (!cancelled) { + setFailed(true); + } + }, + ); + } catch { + if (!cancelled) { + setFailed(true); + } } - player.setVideoItem(videoItem); - player.startAnimation(); - }, - () => { - if (!cancelled) { - setFailed(true); + } + + load(); + + return () => { + cancelled = true; + if (player) { + try { + player.stopAnimation(true); + player.clear(); + } catch { + // Ignore player cleanup errors from partially loaded assets. + } } - } - ); - } catch { - if (!cancelled) { - setFailed(true); - } - } - } + if (container) { + container.innerHTML = ""; + } + }; + }, [src]); - load(); - - return () => { - cancelled = true; - if (player) { - try { - player.stopAnimation(true); - player.clear(); - } catch { - // Ignore player cleanup errors from partially loaded assets. - } - } - if (container) { - container.innerHTML = ""; - } - }; - }, [src]); - - return ( - - - {failed ? SVGA 预览失败 : null} - - ); + return ( + + + {failed ? SVGA 预览失败 : null} + + ); } function PAGAssetPreview({ src }) { - const canvasRef = useRef(null); - const [failed, setFailed] = useState(false); + const canvasRef = useRef(null); + const [failed, setFailed] = useState(false); - useEffect(() => { - let cancelled = false; - let pagFile; - let pagView; - setFailed(false); + useEffect(() => { + let cancelled = false; + let pagFile; + let pagView; + setFailed(false); - async function load() { - try { - const PAG = await getPAGModule(); - const buffer = await fetch(src).then((response) => response.arrayBuffer()); - pagFile = await PAG.PAGFile.load(buffer); - if (cancelled || !canvasRef.current) { - return; + async function load() { + try { + const PAG = await getPAGModule(); + const buffer = await fetch(src).then((response) => response.arrayBuffer()); + pagFile = await PAG.PAGFile.load(buffer); + if (cancelled || !canvasRef.current) { + return; + } + canvasRef.current.width = pagFile.width(); + canvasRef.current.height = pagFile.height(); + pagView = await PAG.PAGView.init(pagFile, canvasRef.current, { useScale: true }); + if (!pagView) { + throw new Error("PAGView init failed"); + } + pagView.setRepeatCount(0); + await pagView.play(); + } catch { + if (!cancelled) { + setFailed(true); + } + } } - canvasRef.current.width = pagFile.width(); - canvasRef.current.height = pagFile.height(); - pagView = await PAG.PAGView.init(pagFile, canvasRef.current, { useScale: true }); - if (!pagView) { - throw new Error("PAGView init failed"); - } - pagView.setRepeatCount(0); - await pagView.play(); - } catch { - if (!cancelled) { - setFailed(true); - } - } - } - load(); + load(); - return () => { - cancelled = true; - if (pagView) { - pagView.destroy(); - } - if (pagFile?.destroy) { - pagFile.destroy(); - } - }; - }, [src]); + return () => { + cancelled = true; + if (pagView) { + pagView.destroy(); + } + if (pagFile?.destroy) { + pagFile.destroy(); + } + }; + }, [src]); - return ( - - - {failed ? PAG 预览失败 : null} - - ); + return ( + + + {failed ? PAG 预览失败 : null} + + ); } async function getPAGModule() { - if (!pagModulePromise) { - pagModulePromise = import("libpag").then(({ PAGInit }) => PAGInit({ locateFile: () => pagWasmURL })); - } - return pagModulePromise; + if (!pagModulePromise) { + pagModulePromise = import("libpag").then(({ PAGInit }) => PAGInit({ locateFile: () => pagWasmURL })); + } + return pagModulePromise; } function getDisplayValue(value) { - if (!value) { - return ""; - } - const raw = String(value); - try { - const url = new URL(raw); - const name = url.pathname.split("/").filter(Boolean).pop(); - return name ? decodeURIComponent(name) : raw; - } catch { - return raw; - } + if (!value) { + return ""; + } + const raw = String(value); + try { + const url = new URL(raw); + const name = url.pathname.split("/").filter(Boolean).pop(); + return name ? decodeURIComponent(name) : raw; + } catch { + return raw; + } } function getAssetKind(value, kind, contentType = "") { - const extension = getExtension(value); - if (extension === "svga") { - return "svga"; - } - if (extension === "pag") { - return "pag"; - } - if (isRasterImageExtension(extension)) { - return "image"; - } - const normalizedType = String(contentType || "").toLowerCase(); - if (normalizedType.includes("svga")) { - return "svga"; - } - if (normalizedType.includes("pag")) { - return "pag"; - } - if (isRasterImageContentType(normalizedType)) { - return "image"; - } - return kind === "image" ? "image" : "file"; + const extension = getExtension(value); + if (extension === "svga") { + return "svga"; + } + if (extension === "pag") { + return "pag"; + } + if (isRasterImageExtension(extension)) { + return "image"; + } + const normalizedType = String(contentType || "").toLowerCase(); + if (normalizedType.includes("svga")) { + return "svga"; + } + if (normalizedType.includes("pag")) { + return "pag"; + } + if (isRasterImageContentType(normalizedType)) { + return "image"; + } + return kind === "image" ? "image" : "file"; } function isRasterImageExtension(extension) { - return ["avif", "gif", "jpg", "jpeg", "png", "webp"].includes(extension); + return ["avif", "gif", "jpg", "jpeg", "png", "webp"].includes(extension); } function isRasterImageContentType(contentType) { - return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes(contentType); + return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes(contentType); } function getExtension(value) { - if (!value) { - return ""; - } - const raw = String(value); - const pathname = safePathname(raw); - const name = pathname.split("/").filter(Boolean).pop() || raw; - const cleanName = name.split("?")[0].split("#")[0]; - const dotIndex = cleanName.lastIndexOf("."); - return dotIndex >= 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : ""; + if (!value) { + return ""; + } + const raw = String(value); + const pathname = safePathname(raw); + const name = pathname.split("/").filter(Boolean).pop() || raw; + const cleanName = name.split("?")[0].split("#")[0]; + const dotIndex = cleanName.lastIndexOf("."); + return dotIndex >= 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : ""; } function safePathname(value) { - try { - return new URL(value).pathname; - } catch { - return value; - } + try { + return new URL(value).pathname; + } catch { + return value; + } } function assetKindLabel(assetKind, isImage) { - if (assetKind === "image") { - return "图片"; - } - if (assetKind === "svga") { - return "SVGA"; - } - if (assetKind === "pag") { - return "PAG"; - } - return isImage ? "图片" : "文件"; + if (assetKind === "image") { + return "图片"; + } + if (assetKind === "svga") { + return "SVGA"; + } + if (assetKind === "pag") { + return "PAG"; + } + return isImage ? "图片" : "文件"; } diff --git a/src/shared/ui/UploadField.test.jsx b/src/shared/ui/UploadField.test.jsx index 0ddfb59..af36bdc 100644 --- a/src/shared/ui/UploadField.test.jsx +++ b/src/shared/ui/UploadField.test.jsx @@ -5,28 +5,30 @@ import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { UploadField } from "./UploadField.jsx"; vi.mock("@/shared/api/upload", () => ({ - uploadFile: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/files/effect.bin" })), - uploadImage: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/images/effect.png" })) + uploadFile: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/files/effect.bin" })), + uploadImage: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/images/effect.png" })), })); test("file upload field does not restrict picker type and uses generic upload endpoint", async () => { - const onChange = vi.fn(); - const { container } = render( - - - - ); - const input = container.querySelector('input[type="file"]'); + const onChange = vi.fn(); + const onFileSelected = vi.fn(); + const { container } = render( + + + , + ); + const input = container.querySelector('input[type="file"]'); - expect(input).not.toHaveAttribute("accept"); + expect(input).not.toHaveAttribute("accept"); - fireEvent.change(input, { - target: { - files: [new File(["effect"], "effect.bin", { type: "application/octet-stream" })] - } - }); + fireEvent.change(input, { + target: { + files: [new File(["effect"], "effect.bin", { type: "application/octet-stream" })], + }, + }); - await waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(1)); - expect(uploadImage).not.toHaveBeenCalled(); - expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin"); + await waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(1)); + expect(onFileSelected).toHaveBeenCalledWith(expect.objectContaining({ name: "effect.bin" })); + expect(uploadImage).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin"); }); diff --git a/var/admin-dev.pid b/var/admin-dev.pid index 0a67ee7..1aa4350 100644 --- a/var/admin-dev.pid +++ b/var/admin-dev.pid @@ -1 +1 @@ -99664 +91618