添加相关功能
This commit is contained in:
parent
a0b141c31b
commit
d4eaf20c8e
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -422,7 +422,7 @@ function RuleFormDialog({ page }) {
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={editing || !page.abilities.canUpdate}
|
||||
inputProps={{ min: 1 }}
|
||||
inputProps={{ min: 0 }}
|
||||
label="等级"
|
||||
type="number"
|
||||
value={form.level}
|
||||
@ -449,7 +449,7 @@ function RuleFormDialog({ page }) {
|
||||
<ResourceSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="长徽章素材"
|
||||
resources={page.badgeResources}
|
||||
resources={levelBadgeResourcesForTrack(page.badgeResources, form.track)}
|
||||
value={form.longBadgeResourceId}
|
||||
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
|
||||
/>
|
||||
@ -519,7 +519,7 @@ function TierFormDialog({ page }) {
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ min: 1 }}
|
||||
inputProps={{ min: 0 }}
|
||||
label="起始等级"
|
||||
type="number"
|
||||
value={form.minLevel}
|
||||
@ -527,7 +527,7 @@ function TierFormDialog({ page }) {
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ min: 1 }}
|
||||
inputProps={{ min: 0 }}
|
||||
label="结束等级"
|
||||
type="number"
|
||||
value={form.maxLevel}
|
||||
@ -649,7 +649,7 @@ function ResourceLabel({ id, resources }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
|
||||
<span className={styles.meta}>ID {id}</span>
|
||||
<span className={styles.meta}>{resource ? resourceMetaLabel(resource, id) : `ID ${id}`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 || "-";
|
||||
}
|
||||
|
||||
@ -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"] });
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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<ApiPage<Resou
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupResourceGrantTarget(userIdOrDisplayId: string | number): Promise<CoinLedgerUserDto> {
|
||||
const endpoint = API_ENDPOINTS.lookupResourceGrantTarget;
|
||||
return apiRequest<CoinLedgerUserDto>(apiEndpointPath(API_OPERATIONS.lookupResourceGrantTarget), {
|
||||
method: endpoint.method,
|
||||
query: { user_id: String(userIdOrDisplayId).trim() },
|
||||
});
|
||||
}
|
||||
|
||||
export function grantResource(payload: GrantResourcePayload): Promise<ResourceGrantDto> {
|
||||
const endpoint = API_ENDPOINTS.grantResource;
|
||||
return apiRequest<ResourceGrantDto, GrantResourcePayload>(apiEndpointPath(API_OPERATIONS.grantResource), {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
386
src/features/resources/components/GiftResourceSelectDrawer.jsx
Normal file
386
src/features/resources/components/GiftResourceSelectDrawer.jsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
className={styles.field}
|
||||
disabled={disabled || loading}
|
||||
label={label}
|
||||
placeholder={loading ? "资源加载中" : placeholder}
|
||||
required={required}
|
||||
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
||||
value={displayValue}
|
||||
onClick={openDrawer}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openDrawer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SideDrawer
|
||||
actions={
|
||||
<Button type="button" variant="primary" onClick={() => setOpen(false)}>
|
||||
完成
|
||||
</Button>
|
||||
}
|
||||
className={styles.drawer}
|
||||
contentClassName={styles.drawerBody}
|
||||
drawerProps={{ sx: { zIndex: drawerZIndex } }}
|
||||
open={open}
|
||||
title={drawerTitle}
|
||||
width="wide"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<div className={styles.filters}>
|
||||
<TextField
|
||||
className={styles.search}
|
||||
placeholder="搜索资源名称、ID"
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchOutlined fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
{categoryOptions.length ? (
|
||||
<Tabs
|
||||
aria-label="资源分类"
|
||||
className={styles.tabs}
|
||||
value={activeCategory}
|
||||
variant="scrollable"
|
||||
onChange={(_, value) => setActiveType(value)}
|
||||
>
|
||||
{categoryOptions.map((item) => (
|
||||
<Tab key={item.value} label={item.label} value={item.value} />
|
||||
))}
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{filteredResources.map((item) => {
|
||||
const resourceId = String(item.resource.resourceId);
|
||||
const selected = selectedIdSet.has(resourceId);
|
||||
const name = resourceName(item);
|
||||
return (
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
className={[styles.card, selected ? styles.cardSelected : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={resourceId}
|
||||
type="button"
|
||||
onClick={() => toggleResource(item)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
className={styles.checkbox}
|
||||
inputProps={{
|
||||
"aria-label": `${selected ? "取消选择" : "选择"} ${name}`,
|
||||
readOnly: true,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<ResourceThumb item={item} />
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.meta}>
|
||||
{item.resource.resourceCode || `资源 ${item.resource.resourceId || "-"}`}
|
||||
</span>
|
||||
<span className={styles.tags}>
|
||||
<span className={styles.tag}>{categoryLabel(item, categoryOptions)}</span>
|
||||
<span className={styles.tag}>{resourcePriceLabel(item)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!filteredResources.length ? <div className={styles.empty}>当前无可选资源</div> : null}
|
||||
</div>
|
||||
</SideDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={styles.thumb}>
|
||||
{imageUrl ? (
|
||||
<img alt="" src={imageUrl} />
|
||||
) : item.resource.resourceType === "gift" ? (
|
||||
<CardGiftcardOutlined fontSize="small" />
|
||||
) : (
|
||||
<Inventory2Outlined fontSize="small" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 (
|
||||
<aside aria-label={title} data-z-index={drawerProps?.sx?.zIndex} role="dialog">
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
{children}
|
||||
{actions}
|
||||
</aside>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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 (
|
||||
<GiftResourceSelectField
|
||||
giftTypes={giftTypes}
|
||||
gifts={gifts}
|
||||
resources={resources}
|
||||
selectedResourceIds={value}
|
||||
onChange={(nextValue) => {
|
||||
changes.push(nextValue);
|
||||
setValue(nextValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
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();
|
||||
});
|
||||
@ -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: "封面图",
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
<ResourceGrantDialog
|
||||
disabled={!page.abilities.canCreateGrant}
|
||||
form={page.form}
|
||||
giftOptions={page.giftOptions}
|
||||
giftTypeOptions={page.giftTypeOptions}
|
||||
groupOptions={page.groupOptions}
|
||||
loading={page.loadingAction === "grant-create"}
|
||||
open={page.activeAction === "create"}
|
||||
@ -166,6 +169,8 @@ export function ResourceGrantListPage() {
|
||||
function ResourceGrantDialog({
|
||||
disabled,
|
||||
form,
|
||||
giftOptions,
|
||||
giftTypeOptions,
|
||||
groupOptions,
|
||||
loading,
|
||||
onClose,
|
||||
@ -188,9 +193,9 @@ function ResourceGrantDialog({
|
||||
<AdminFormSection title="赠送对象">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="用户 ID"
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
label="用户 ID / 短号"
|
||||
required
|
||||
type="number"
|
||||
value={form.targetUserId}
|
||||
onChange={(event) => 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,
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="resource">资源</MenuItem>
|
||||
@ -212,20 +223,18 @@ function ResourceGrantDialog({
|
||||
</TextField>
|
||||
{form.subjectType === "resource" ? (
|
||||
<>
|
||||
<TextField
|
||||
<GiftResourceSelectField
|
||||
drawerTitle="选择资源"
|
||||
disabled={disabled || optionsLoading}
|
||||
giftTypes={giftTypeOptions}
|
||||
gifts={giftOptions}
|
||||
label="资源"
|
||||
loading={optionsLoading}
|
||||
required
|
||||
select
|
||||
value={form.resourceId}
|
||||
onChange={(event) => setForm({ ...form, resourceId: event.target.value })}
|
||||
>
|
||||
{resourceOptions.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
resources={resourceOptions}
|
||||
selectedResourceIds={form.resourceIds || []}
|
||||
onChange={(resourceIds) => setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="数量"
|
||||
|
||||
@ -28,6 +28,10 @@ import {
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
badgeKindLabels,
|
||||
badgeKindOptions,
|
||||
badgeLevelTrackLabels,
|
||||
badgeLevelTrackOptions,
|
||||
badgeFormLabels,
|
||||
badgeFormOptions,
|
||||
resourceBatchUploadRoleLabels,
|
||||
@ -45,6 +49,11 @@ import {
|
||||
translateResourceCodes,
|
||||
} from "@/features/resources/batchUpload.js";
|
||||
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
import {
|
||||
detectProfileCardLayout,
|
||||
mergeProfileCardLayoutMetadata,
|
||||
profileCardLayoutFromMetadata,
|
||||
} from "@/features/resources/profileCardLayout.js";
|
||||
import { uploadFilesBatch } from "@/shared/api/upload";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import styles from "@/features/resources/resources.module.css";
|
||||
@ -174,8 +183,21 @@ export function ResourceListPage() {
|
||||
{ file: resource.animationFile, resourceIndex, role: "animation" },
|
||||
]);
|
||||
|
||||
setBatchProgress({ label: "上传素材 0/" + uploadEntries.length, running: true });
|
||||
setBatchProgress({ label: "解析资料卡内容高度", running: true });
|
||||
try {
|
||||
for (const resource of resources) {
|
||||
if (resource.resourceType !== "profile_card") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const layout = await detectProfileCardLayout(resource.animationFile);
|
||||
resource.metadataJson = mergeProfileCardLayoutMetadata(resource.metadataJson, layout);
|
||||
} catch {
|
||||
showToast(`${resource.name} 内容高度解析失败`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
setBatchProgress({ label: "上传素材 0/" + uploadEntries.length, running: true });
|
||||
for (let index = 0; index < uploadEntries.length; index += resourceBatchUploadSize) {
|
||||
const chunk = uploadEntries.slice(index, index + resourceBatchUploadSize);
|
||||
setBatchProgress({
|
||||
@ -394,6 +416,27 @@ function ResourceRowActions({ page, resource }) {
|
||||
|
||||
function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) {
|
||||
const submitDisabled = disabled || loading;
|
||||
const { showToast } = useToast();
|
||||
const profileCardLayout =
|
||||
form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null;
|
||||
const handleProfileCardAnimationSelected = async (file) => {
|
||||
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 (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
@ -427,12 +470,15 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
||||
value={form.resourceType}
|
||||
onChange={(event) => {
|
||||
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" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="徽章属性"
|
||||
required
|
||||
select
|
||||
value={form.badgeForm}
|
||||
onChange={(event) => setForm({ ...form, badgeForm: event.target.value })}
|
||||
>
|
||||
{badgeFormOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="徽章属性"
|
||||
required
|
||||
select
|
||||
value={form.badgeForm}
|
||||
onChange={(event) => setForm({ ...form, badgeForm: event.target.value })}
|
||||
>
|
||||
{badgeFormOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="徽章类型"
|
||||
required
|
||||
select
|
||||
value={form.badgeKind}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
badgeKind: event.target.value,
|
||||
levelTrack: event.target.value === "level" ? form.levelTrack : "",
|
||||
})
|
||||
}
|
||||
>
|
||||
{badgeKindOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{form.badgeKind === "level" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="所属等级"
|
||||
required
|
||||
select
|
||||
value={form.levelTrack}
|
||||
onChange={(event) => setForm({ ...form, levelTrack: event.target.value })}
|
||||
>
|
||||
{badgeLevelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
@ -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 }))}
|
||||
/>
|
||||
<UploadField
|
||||
disabled={disabled || uploadDisabled}
|
||||
kind="file"
|
||||
label="动效素材"
|
||||
value={form.animationUrl}
|
||||
onChange={(animationUrl) => setForm({ ...form, animationUrl })}
|
||||
onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))}
|
||||
onFileSelected={handleProfileCardAnimationSelected}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
{profileCardLayout ? (
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField disabled label="content_top" value={String(profileCardLayout.content_top)} />
|
||||
<TextField disabled label="内容高度" value={String(profileCardLayout.content_height)} />
|
||||
</AdminFormFieldGrid>
|
||||
) : null}
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="状态">
|
||||
<AdminFormSwitchField
|
||||
@ -574,7 +665,12 @@ function badgeFormLabel(resource) {
|
||||
if (resource.resourceType !== "badge") {
|
||||
return "-";
|
||||
}
|
||||
return badgeFormLabels[resource.badgeForm || badgeFormFromMetadata(resource.metadataJson)] || "未配置";
|
||||
const formLabel = badgeFormLabels[resource.badgeForm || badgeFormFromMetadata(resource.metadataJson)] || "未配置";
|
||||
const kind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson) || "normal";
|
||||
const kindLabel = badgeKindLabels[kind] || badgeKindLabels.normal;
|
||||
const track = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
||||
const trackLabel = kind === "level" && track ? badgeLevelTrackLabels[track] : "";
|
||||
return [formLabel, kindLabel, trackLabel].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function badgeFormFromMetadata(metadataJson) {
|
||||
@ -592,6 +688,33 @@ function badgeFormFromMetadata(metadataJson) {
|
||||
}
|
||||
}
|
||||
|
||||
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 imageURL(value) {
|
||||
const url = String(value || "").trim();
|
||||
if (!url) {
|
||||
|
||||
742
src/features/resources/profileCardLayout.js
Normal file
742
src/features/resources/profileCardLayout.js
Normal file
@ -0,0 +1,742 @@
|
||||
const defaultMetadata = "{}";
|
||||
const profileCardLayoutKey = "profile_card_layout";
|
||||
const sampleFrameCount = 6;
|
||||
const alphaTransparentThreshold = 245;
|
||||
const alphaContentThreshold = 16;
|
||||
const alphaMaskBrightnessThreshold = 80;
|
||||
const alphaMaskContentRowRatio = 0.5;
|
||||
const rgbDiffThreshold = 24;
|
||||
const minContentRowRatio = 0.01;
|
||||
const consecutiveRows = 3;
|
||||
const stableTopQuantile = 0.75;
|
||||
const contentTopSnapPixels = 4;
|
||||
const pagWasmURL = "/vendor/libpag.wasm";
|
||||
let pagModulePromise;
|
||||
|
||||
export async function detectProfileCardLayout(file) {
|
||||
if (!file) {
|
||||
throw new Error("资料卡素材不存在");
|
||||
}
|
||||
if (isVideoFile(file)) {
|
||||
return detectVideoLayout(file);
|
||||
}
|
||||
if (isPagFile(file)) {
|
||||
return detectPagLayout(file);
|
||||
}
|
||||
if (isImageFile(file)) {
|
||||
return detectImageLayout(file);
|
||||
}
|
||||
throw new Error("当前素材类型暂不支持自动解析内容高度");
|
||||
}
|
||||
|
||||
export function profileCardMetadataJSON(metadataJson) {
|
||||
const layout = profileCardLayoutFromMetadata(metadataJson);
|
||||
if (!layout) {
|
||||
return defaultMetadata;
|
||||
}
|
||||
return JSON.stringify({ [profileCardLayoutKey]: layout });
|
||||
}
|
||||
|
||||
export function mergeProfileCardLayoutMetadata(metadataJson, layout) {
|
||||
const sanitized = sanitizeProfileCardLayout(layout);
|
||||
if (!sanitized) {
|
||||
return profileCardMetadataJSON(metadataJson);
|
||||
}
|
||||
return JSON.stringify({ [profileCardLayoutKey]: sanitized });
|
||||
}
|
||||
|
||||
export function profileCardLayoutFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const metadata = JSON.parse(String(metadataJson).trim());
|
||||
return sanitizeProfileCardLayout(metadata?.[profileCardLayoutKey]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function measureProfileCardContent(frames, options = {}) {
|
||||
const validFrames = (frames || []).filter(
|
||||
(frame) =>
|
||||
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;
|
||||
}
|
||||
113
src/features/resources/profileCardLayout.test.js
Normal file
113
src/features/resources/profileCardLayout.test.js
Normal file
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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] : [];
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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<ApiOperationId, ApiEndpoint> = {
|
||||
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,
|
||||
|
||||
28
src/shared/api/generated/schema.d.ts
vendored
28
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 (
|
||||
<div className={[styles.root, disabled ? styles.disabled : ""].filter(Boolean).join(" ")}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<button
|
||||
aria-label={label}
|
||||
className={styles.preview}
|
||||
disabled={disabled || uploading}
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
>
|
||||
<AssetPreview assetKind={assetKind} isImage={isImage} src={source} />
|
||||
{uploading ? (
|
||||
<span className={styles.overlay}>
|
||||
<CircularProgress color="inherit" size={18} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.name}>{displayName || "未上传"}</span>
|
||||
<span className={styles.actions}>
|
||||
<Tooltip arrow title={source ? "替换" : "上传"}>
|
||||
<span>
|
||||
<button className={styles.action} disabled={disabled || uploading} type="button" onClick={openPicker}>
|
||||
{uploading ? <CircularProgress color="inherit" size={16} /> : <FileUploadOutlined fontSize="small" />}
|
||||
{source ? "替换" : "上传"}
|
||||
return (
|
||||
<div className={[styles.root, disabled ? styles.disabled : ""].filter(Boolean).join(" ")}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<button
|
||||
aria-label={label}
|
||||
className={styles.preview}
|
||||
disabled={disabled || uploading}
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
>
|
||||
<AssetPreview assetKind={assetKind} isImage={isImage} src={source} />
|
||||
{uploading ? (
|
||||
<span className={styles.overlay}>
|
||||
<CircularProgress color="inherit" size={18} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{source ? (
|
||||
<Tooltip arrow title="清除">
|
||||
<span>
|
||||
<button className={styles.action} disabled={disabled || uploading} type="button" onClick={clearValue}>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
清除
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.name}>{displayName || "未上传"}</span>
|
||||
<span className={styles.actions}>
|
||||
<Tooltip arrow title={source ? "替换" : "上传"}>
|
||||
<span>
|
||||
<button
|
||||
className={styles.action}
|
||||
disabled={disabled || uploading}
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
>
|
||||
{uploading ? (
|
||||
<CircularProgress color="inherit" size={16} />
|
||||
) : (
|
||||
<FileUploadOutlined fontSize="small" />
|
||||
)}
|
||||
{source ? "替换" : "上传"}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{source ? (
|
||||
<Tooltip arrow title="清除">
|
||||
<span>
|
||||
<button
|
||||
className={styles.action}
|
||||
disabled={disabled || uploading}
|
||||
type="button"
|
||||
onClick={clearValue}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
清除
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
accept={inputAccept}
|
||||
className={styles.input}
|
||||
id={inputId}
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input ref={inputRef} accept={inputAccept} className={styles.input} id={inputId} type="file" onChange={handleFileChange} />
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
function AssetPreview({ assetKind, isImage, src }) {
|
||||
if (!src) {
|
||||
if (!src) {
|
||||
return (
|
||||
<span className={styles.empty}>
|
||||
{isImage ? <ImageOutlined fontSize="small" /> : <InsertDriveFileOutlined fontSize="small" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (assetKind === "svga") {
|
||||
return <SVGAAssetPreview src={src} />;
|
||||
}
|
||||
if (assetKind === "pag") {
|
||||
return <PAGAssetPreview src={src} />;
|
||||
}
|
||||
if (assetKind === "image" || isImage) {
|
||||
return <RasterAssetPreview src={src} />;
|
||||
}
|
||||
return (
|
||||
<span className={styles.empty}>
|
||||
{isImage ? <ImageOutlined fontSize="small" /> : <InsertDriveFileOutlined fontSize="small" />}
|
||||
</span>
|
||||
<span className={styles.empty}>
|
||||
<InsertDriveFileOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (assetKind === "svga") {
|
||||
return <SVGAAssetPreview src={src} />;
|
||||
}
|
||||
if (assetKind === "pag") {
|
||||
return <PAGAssetPreview src={src} />;
|
||||
}
|
||||
if (assetKind === "image" || isImage) {
|
||||
return <RasterAssetPreview src={src} />;
|
||||
}
|
||||
return (
|
||||
<span className={styles.empty}>
|
||||
<InsertDriveFileOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RasterAssetPreview({ src }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className={styles.empty}>
|
||||
<ImageOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (failed) {
|
||||
return (
|
||||
<span className={styles.empty}>
|
||||
<ImageOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <img alt="" className={styles.image} src={src} onError={() => setFailed(true)} />;
|
||||
return <img alt="" className={styles.image} src={src} onError={() => 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 (
|
||||
<span className={styles.player}>
|
||||
<span ref={containerRef} className={styles.playerSurface} />
|
||||
{failed ? <span className={styles.empty}>SVGA 预览失败</span> : null}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className={styles.player}>
|
||||
<span ref={containerRef} className={styles.playerSurface} />
|
||||
{failed ? <span className={styles.empty}>SVGA 预览失败</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={styles.player}>
|
||||
<canvas ref={canvasRef} className={styles.pagCanvas} />
|
||||
{failed ? <span className={styles.empty}>PAG 预览失败</span> : null}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className={styles.player}>
|
||||
<canvas ref={canvasRef} className={styles.pagCanvas} />
|
||||
{failed ? <span className={styles.empty}>PAG 预览失败</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? "图片" : "文件";
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<ToastProvider>
|
||||
<UploadField kind="file" label="动效素材" value="" onChange={onChange} />
|
||||
</ToastProvider>
|
||||
);
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
const onChange = vi.fn();
|
||||
const onFileSelected = vi.fn();
|
||||
const { container } = render(
|
||||
<ToastProvider>
|
||||
<UploadField kind="file" label="动效素材" value="" onChange={onChange} onFileSelected={onFileSelected} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
|
||||
@ -1 +1 @@
|
||||
99664
|
||||
91618
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user