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