添加相关功能

This commit is contained in:
zhx 2026-06-04 11:04:48 +08:00
parent a0b141c31b
commit d4eaf20c8e
24 changed files with 2423 additions and 406 deletions

View File

@ -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",

View File

@ -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) {

View File

@ -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 || "-";
}

View File

@ -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"] });
}

View File

@ -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 });

View File

@ -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), {

View File

@ -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",

View File

@ -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", () => {

View 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");
}

View File

@ -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;
}
}

View File

@ -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 GiftCP Ring")).toBeInTheDocument();
});

View File

@ -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: "封面图",

View File

@ -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()}`;
}

View File

@ -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="数量"

View File

@ -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) {

View 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;
}

View 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];
}
}
}

View File

@ -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] : [];
}

View File

@ -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", () => {

View File

@ -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,

View File

@ -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;

View File

@ -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 ? "图片" : "文件";
}

View File

@ -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");
});

View File

@ -1 +1 @@
99664
91618