fix(asset-library): 素材库 - 兼容历史素材缺失字段避免页面崩溃
This commit is contained in:
23
web/src/lib/asset-storage-revision.test.ts
Normal file
23
web/src/lib/asset-storage-revision.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
|
||||||
|
import { normalizeAssetRecord } from "./asset-storage-revision.ts";
|
||||||
|
|
||||||
|
test("缺失图片尺寸的历史素材会补齐可渲染数据", () => {
|
||||||
|
const asset = normalizeAssetRecord({
|
||||||
|
id: "legacy-image",
|
||||||
|
kind: "image",
|
||||||
|
title: "历史图片",
|
||||||
|
coverUrl: "https://example.com/image.png",
|
||||||
|
tags: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
data: undefined,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(asset.kind, "image");
|
||||||
|
assert.equal(asset.data.width, 0);
|
||||||
|
assert.equal(asset.data.height, 0);
|
||||||
|
assert.equal(asset.data.dataUrl, "https://example.com/image.png");
|
||||||
|
});
|
||||||
@@ -11,13 +11,38 @@ export type AssetStorageDocument = {
|
|||||||
export function normalizeAssetRecord(asset: Asset): Asset {
|
export function normalizeAssetRecord(asset: Asset): Asset {
|
||||||
const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind));
|
const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind));
|
||||||
const folderId = typeof asset.folderId === "string" ? asset.folderId.trim() : "";
|
const folderId = typeof asset.folderId === "string" ? asset.folderId.trim() : "";
|
||||||
if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category && (asset.folderId || "") === folderId) return asset;
|
const normalizedData = normalizeAssetData(asset);
|
||||||
|
if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category && (asset.folderId || "") === folderId && normalizedData === asset.data) return asset;
|
||||||
return {
|
return {
|
||||||
...asset,
|
...asset,
|
||||||
tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||||
folderId: folderId || undefined,
|
folderId: folderId || undefined,
|
||||||
category,
|
category,
|
||||||
};
|
data: normalizedData,
|
||||||
|
} as Asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssetData(asset: Asset): Asset["data"] {
|
||||||
|
const raw = asset.data && typeof asset.data === "object" ? asset.data as Record<string, unknown> : {};
|
||||||
|
const numberValue = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||||
|
if (asset.kind === "text") return typeof raw.content === "string" ? asset.data : { content: "" };
|
||||||
|
if (asset.kind === "image") {
|
||||||
|
if (typeof raw.dataUrl === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data;
|
||||||
|
return { dataUrl: typeof raw.dataUrl === "string" ? raw.dataUrl : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "image/*" };
|
||||||
|
}
|
||||||
|
if (asset.kind === "video") {
|
||||||
|
if (typeof raw.url === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data;
|
||||||
|
return { url: typeof raw.url === "string" ? raw.url : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), durationMs: numberValue(raw.durationMs), hasAudio: raw.hasAudio === true, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "video/*" };
|
||||||
|
}
|
||||||
|
if (asset.kind === "audio") {
|
||||||
|
if (typeof raw.url === "string") return asset.data;
|
||||||
|
return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, durationMs: numberValue(raw.durationMs), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "audio/*" };
|
||||||
|
}
|
||||||
|
if (asset.kind === "model") {
|
||||||
|
if (typeof raw.url === "string" && typeof raw.fileName === "string") return asset.data;
|
||||||
|
return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "model/*", fileName: typeof raw.fileName === "string" ? raw.fileName : asset.title || "未命名模型" };
|
||||||
|
}
|
||||||
|
return asset.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAssets(assets: Asset[]) {
|
function normalizeAssets(assets: Asset[]) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AssetMediaPreview } from "@/components/asset-media-preview";
|
|||||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { normalizeAssetRecord } from "@/lib/asset-storage-revision";
|
||||||
|
|
||||||
import { useCopyText } from "@/hooks/use-copy-text";
|
import { useCopyText } from "@/hooks/use-copy-text";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
@@ -123,7 +124,7 @@ export default function AssetsPage() {
|
|||||||
});
|
});
|
||||||
const folders = foldersQuery.data?.folders || [];
|
const folders = foldersQuery.data?.folders || [];
|
||||||
|
|
||||||
const allLibraryAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
|
const allLibraryAssets = useMemo(() => assets.map(normalizeAssetRecord).filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
|
||||||
const activeAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status !== "archived"), [allLibraryAssets]);
|
const activeAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status !== "archived"), [allLibraryAssets]);
|
||||||
const trashAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status === "archived"), [allLibraryAssets]);
|
const trashAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status === "archived"), [allLibraryAssets]);
|
||||||
const validAssets = viewMode === "trash" ? trashAssets : activeAssets;
|
const validAssets = viewMode === "trash" ? trashAssets : activeAssets;
|
||||||
@@ -162,7 +163,7 @@ export default function AssetsPage() {
|
|||||||
return filteredAssets.slice(start, start + pageSize);
|
return filteredAssets.slice(start, start + pageSize);
|
||||||
}, [filteredAssets, page, pageSize]);
|
}, [filteredAssets, page, pageSize]);
|
||||||
const visibleAssets = useMemo(
|
const visibleAssets = useMemo(
|
||||||
() => (assetPageQuery.data?.assets || localVisibleAssets).filter((asset): asset is LibraryAsset => asset.kind !== "entity"),
|
() => (assetPageQuery.data?.assets || localVisibleAssets).map(normalizeAssetRecord).filter((asset): asset is LibraryAsset => asset.kind !== "entity"),
|
||||||
[assetPageQuery.data?.assets, localVisibleAssets],
|
[assetPageQuery.data?.assets, localVisibleAssets],
|
||||||
);
|
);
|
||||||
const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]);
|
const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]);
|
||||||
|
|||||||
Reference in New Issue
Block a user