fix(asset-library): 素材库 - 支持自定义分类、分页、完整预览和批量上传
This commit is contained in:
@@ -87,8 +87,8 @@ export function TableSurface({ children, className }: { children: ReactNode; cla
|
||||
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg bg-surface", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CollectionGrid({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn("mt-4 grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(248px,1fr))]", className)}>{children}</div>;
|
||||
export function CollectionGrid({ children, className, style }: { children: ReactNode; className?: string; style?: React.CSSProperties }) {
|
||||
return <div className={cn("mt-4 grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(248px,1fr))]", className)} style={style}>{children}</div>;
|
||||
}
|
||||
|
||||
/* 自研轻量分页:页码胶囊 + 省略号 + 每页条数 + 总数,替代 AntD Pagination(无 AntD 残留样式,与工具栏容器同语言)。 */
|
||||
|
||||
@@ -10,10 +10,12 @@ export type AssetStorageDocument = {
|
||||
|
||||
export function normalizeAssetRecord(asset: Asset): Asset {
|
||||
const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind));
|
||||
if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category) return asset;
|
||||
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;
|
||||
return {
|
||||
...asset,
|
||||
tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||
folderId: folderId || undefined,
|
||||
category,
|
||||
};
|
||||
}
|
||||
|
||||
93
web/src/pages/assets/asset-batch-upload-modal.tsx
Normal file
93
web/src/pages/assets/asset-batch-upload-modal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { App, Button, Modal, Progress, Select, Tag } from "antd";
|
||||
import { FileImage, UploadCloud, X } from "lucide-react";
|
||||
|
||||
import { ASSET_CATEGORY_OPTIONS, type AssetCategory } from "@/lib/asset-category";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import type { AssetFolder } from "@/services/api/user-data";
|
||||
|
||||
type BatchItem = { id: string; file: File; status: "queued" | "uploading" | "done" | "error"; error?: string; percent?: number };
|
||||
|
||||
export function AssetBatchUploadModal({ open, defaultFolderId, folders, onClose, onComplete }: { open: boolean; defaultFolderId: string; folders: AssetFolder[]; onClose: () => void; onComplete: () => Promise<void> }) {
|
||||
const { message } = App.useApp();
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const [items, setItems] = useState<BatchItem[]>([]);
|
||||
const [category, setCategory] = useState<AssetCategory>("material");
|
||||
const [folderId, setFolderId] = useState(defaultFolderId);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const doneCount = items.filter((item) => item.status === "done").length;
|
||||
const failedItems = items.filter((item) => item.status === "error");
|
||||
const hasFiles = items.length > 0;
|
||||
const folderOptions = useMemo(() => [{ label: "未分类", value: "" }, ...folders.map((folder) => ({ label: folder.name, value: folder.id }))], [folders]);
|
||||
|
||||
const chooseFiles = (files: File[]) => {
|
||||
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||
if (!images.length) {
|
||||
message.warning("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
setItems((current) => [...current, ...images.map((file) => ({ id: `${file.name}-${file.lastModified}-${Math.random()}`, file, status: "queued" as const }))]);
|
||||
};
|
||||
|
||||
const uploadBatch = async () => {
|
||||
const pending = items.filter((item) => item.status === "queued" || item.status === "error");
|
||||
if (!pending.length) return;
|
||||
setUploading(true);
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < pending.length) {
|
||||
const item = pending[cursor++];
|
||||
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "uploading", percent: 10, error: undefined } : entry));
|
||||
try {
|
||||
const uploaded = await uploadImage(item.file);
|
||||
const meta = await readImageMeta(uploaded.url).catch(() => ({ width: uploaded.width, height: uploaded.height, mimeType: uploaded.mimeType }));
|
||||
addAsset({ kind: "image", title: item.file.name.replace(/\.[^.]+$/, ""), category, folderId: folderId || undefined, coverUrl: uploaded.url, tags, source: "批量上传", metadata: { source: "manual-batch" }, data: { dataUrl: uploaded.url, storageKey: uploaded.storageKey, width: meta.width || uploaded.width, height: meta.height || uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType } });
|
||||
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "done", percent: 100 } : entry));
|
||||
} catch (error) {
|
||||
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "error", error: error instanceof Error ? error.message : "上传失败" } : entry));
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, () => worker()));
|
||||
await flushAssetStorePersistence();
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
} catch {
|
||||
message.warning("部分素材已保存在本地,稍后自动同步至云端");
|
||||
}
|
||||
setUploading(false);
|
||||
await onComplete();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (uploading) return;
|
||||
setItems([]);
|
||||
setTags([]);
|
||||
setFolderId(defaultFolderId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return <Modal className="library-modal library-batch-upload-modal" title="批量上传图片" open={open} onCancel={close} footer={null} destroyOnHidden>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-xs font-medium text-foreground/65">业务分类<Select className="mt-1 w-full" value={category} options={ASSET_CATEGORY_OPTIONS} onChange={(value) => setCategory(value)} /></label>
|
||||
<label className="text-xs font-medium text-foreground/65">自定义分类<Select className="mt-1 w-full" value={folderId} options={folderOptions} onChange={setFolderId} /></label>
|
||||
<label className="text-xs font-medium text-foreground/65">公共标签<Select mode="tags" className="mt-1 w-full" value={tags} tokenSeparators={[",", ","]} onChange={setTags} placeholder="输入后回车" /></label>
|
||||
</div>
|
||||
<button type="button" className="batch-upload-dropzone" onClick={() => inputRef.current?.click()}>
|
||||
<UploadCloud className="size-7" /><strong>选择多张图片</strong><span>支持拖拽思路的多选上传,标题默认取文件名</span>
|
||||
</button>
|
||||
<input ref={inputRef} type="file" hidden accept="image/*" multiple onChange={(event) => { chooseFiles(Array.from(event.target.files || [])); event.currentTarget.value = ""; }} />
|
||||
{hasFiles ? <div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-foreground/55"><span>已选择 {items.length} 张 · 成功 {doneCount} 张</span><button type="button" className="text-foreground/45 hover:text-foreground" onClick={() => setItems([])} disabled={uploading}>清空</button></div>
|
||||
<div className="batch-upload-list">{items.map((item) => <div key={item.id} className="batch-upload-item"><FileImage className="size-4 shrink-0 text-foreground/45" /><span className="min-w-0 flex-1 truncate" title={item.file.name}>{item.file.name}</span>{item.status === "uploading" ? <Progress percent={item.percent || 10} size="small" showInfo={false} className="w-20" /> : item.status === "done" ? <Tag color="green">完成</Tag> : item.status === "error" ? <Tag color="red" title={item.error}>失败</Tag> : <Tag>待上传</Tag>}<button type="button" aria-label={`移除 ${item.file.name}`} title="移除" className="batch-upload-remove" onClick={() => setItems((current) => current.filter((entry) => entry.id !== item.id))} disabled={uploading}><X className="size-3.5" /></button></div>)}</div>
|
||||
</div> : null}
|
||||
<div className="flex justify-end gap-2"><Button onClick={close} disabled={uploading}>取消</Button><Button type="primary" icon={<UploadCloud className="size-4" />} onClick={() => void uploadBatch()} disabled={!items.some((item) => item.status === "queued" || item.status === "error")} loading={uploading}>{failedItems.length ? `重试失败项 (${failedItems.length})` : "开始上传"}</Button></div>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
|
||||
import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, FolderPlus, Image as ImageIcon, Images, LayoutGrid, Link2, Maximize2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress, Select, Space, Tag, Typography } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
@@ -13,6 +13,7 @@ import { saveAs } from "file-saver";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category";
|
||||
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
|
||||
import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils";
|
||||
@@ -23,6 +24,8 @@ import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
|
||||
import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data";
|
||||
import { AssetBatchUploadModal } from "./asset-batch-upload-modal";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
|
||||
@@ -30,6 +33,7 @@ type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
|
||||
type AssetFormValues = {
|
||||
kind: AssetKind;
|
||||
category: AssetCategory;
|
||||
folderId?: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
tags: string[];
|
||||
@@ -50,6 +54,11 @@ const kindOptions = [
|
||||
];
|
||||
|
||||
const categoryOptions = [{ label: "全部分类", value: "all" }, ...ASSET_CATEGORY_OPTIONS];
|
||||
const ASSET_LIBRARY_QUERY_KEY = ["asset-library"] as const;
|
||||
const ASSET_FOLDER_QUERY_KEY = ["asset-folders"] as const;
|
||||
const ASSET_GRID_DENSITY_KEY = "infinite-canvas:asset-grid-density";
|
||||
type AssetGridDensity = 6 | 8 | 10;
|
||||
type AssetFolderFilter = "all" | "uncategorized" | string;
|
||||
|
||||
const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
|
||||
text: FileText,
|
||||
@@ -73,13 +82,16 @@ export default function AssetsPage() {
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
|
||||
const updateAsset = useAssetStore((state) => state.updateAsset);
|
||||
const userId = useUserStore((state) => state.user?.id || "");
|
||||
const retentionDays = useUserStore((state) => state.runtimeLimits.recycleBinRetentionDays ?? 30);
|
||||
const [viewMode, setViewMode] = useState<"library" | "trash">("library");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<AssetCategory | "all">("all");
|
||||
const [folderFilter, setFolderFilter] = useState<AssetFolderFilter>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(35);
|
||||
const [pageSize, setPageSize] = useState(40);
|
||||
const [gridDensity, setGridDensity] = useState<AssetGridDensity>(readAssetGridDensity);
|
||||
const [editingAsset, setEditingAsset] = useState<LibraryAsset | null>(null);
|
||||
const [isAssetOpen, setIsAssetOpen] = useState(false);
|
||||
const [previewAsset, setPreviewAsset] = useState<LibraryAsset | null>(null);
|
||||
@@ -88,6 +100,10 @@ export default function AssetsPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||
const [batchArchiveOpen, setBatchArchiveOpen] = useState(false);
|
||||
const [batchUploadOpen, setBatchUploadOpen] = useState(false);
|
||||
const [folderEditor, setFolderEditor] = useState<AssetFolder | "new" | null>(null);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [folderSaving, setFolderSaving] = useState(false);
|
||||
|
||||
const [formKind, setFormKind] = useState<AssetKind>("text");
|
||||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||||
@@ -98,43 +114,139 @@ export default function AssetsPage() {
|
||||
const title = Form.useWatch("title", form) || "";
|
||||
const tags = Form.useWatch("tags", form) || [];
|
||||
const content = Form.useWatch("content", form) || "";
|
||||
const debouncedKeyword = useDebouncedValue(keyword.trim(), 250);
|
||||
|
||||
const foldersQuery = useQuery({
|
||||
queryKey: ASSET_FOLDER_QUERY_KEY,
|
||||
queryFn: () => listAssetFolders(),
|
||||
enabled: Boolean(userId),
|
||||
});
|
||||
const folders = foldersQuery.data?.folders || [];
|
||||
|
||||
const allLibraryAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
|
||||
const activeAssets = 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 selectedAssets = useMemo(() => validAssets.filter((asset) => selectedIds.includes(asset.id)), [selectedIds, validAssets]);
|
||||
const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => asset.kind === option.value).length])), [activeAssets]);
|
||||
const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => (asset.category || "other") === option.value).length])), [activeAssets]);
|
||||
const canCreateAsset = viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all";
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return validAssets.filter((asset) => {
|
||||
if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
|
||||
if (categoryFilter !== "all" && (asset.category || "other") !== categoryFilter) return false;
|
||||
if (folderFilter === "uncategorized" && asset.folderId) return false;
|
||||
if (folderFilter !== "all" && folderFilter !== "uncategorized" && asset.folderId !== folderFilter) return false;
|
||||
if (!query) return true;
|
||||
return assetSearchText(asset).includes(query);
|
||||
});
|
||||
}, [validAssets, keyword, kindFilter, categoryFilter]);
|
||||
const filteredAssetIds = useMemo(() => filteredAssets.map((asset) => asset.id), [filteredAssets]);
|
||||
const allFilteredSelected = filteredAssetIds.length > 0 && filteredAssetIds.every((id) => selectedIds.includes(id));
|
||||
}, [validAssets, keyword, kindFilter, categoryFilter, folderFilter]);
|
||||
|
||||
const visibleAssets = useMemo(() => {
|
||||
const assetPageQuery = useQuery({
|
||||
queryKey: [...ASSET_LIBRARY_QUERY_KEY, page, pageSize, viewMode, kindFilter, categoryFilter, folderFilter, debouncedKeyword],
|
||||
queryFn: ({ signal }) => listRemoteAssetsPage({
|
||||
page,
|
||||
pageSize,
|
||||
status: viewMode === "trash" ? "archived" : "active",
|
||||
kind: kindFilter === "all" ? undefined : kindFilter,
|
||||
category: categoryFilter === "all" ? undefined : categoryFilter,
|
||||
folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : undefined,
|
||||
uncategorized: folderFilter === "uncategorized",
|
||||
query: debouncedKeyword || undefined,
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(userId),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const localVisibleAssets = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filteredAssets.slice(start, start + pageSize);
|
||||
}, [filteredAssets, page, pageSize]);
|
||||
const visibleAssets = useMemo(
|
||||
() => (assetPageQuery.data?.assets || localVisibleAssets).filter((asset): asset is LibraryAsset => asset.kind !== "entity"),
|
||||
[assetPageQuery.data?.assets, localVisibleAssets],
|
||||
);
|
||||
const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]);
|
||||
const allFilteredSelected = visibleAssetIds.length > 0 && visibleAssetIds.every((id) => selectedIds.includes(id));
|
||||
const canCreateAsset = page === 1 && viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all" && folderFilter === "all";
|
||||
const totalAssets = assetPageQuery.data?.total ?? filteredAssets.length;
|
||||
const kindCounts = useMemo(() => assetCountMap(kindOptions, assetPageQuery.data?.kindCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.kind), [activeAssets, assetPageQuery.data?.kindCounts, trashAssets, viewMode]);
|
||||
const categoryCounts = useMemo(() => assetCountMap(categoryOptions, assetPageQuery.data?.categoryCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.category || "other"), [activeAssets, assetPageQuery.data?.categoryCounts, trashAssets, viewMode]);
|
||||
const folderCounts = assetPageQuery.data?.folderCounts || {};
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize));
|
||||
const maxPage = Math.max(1, Math.ceil(totalAssets / pageSize));
|
||||
setPage((value) => Math.min(value, maxPage));
|
||||
}, [filteredAssets.length, pageSize]);
|
||||
}, [pageSize, totalAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(ASSET_GRID_DENSITY_KEY, String(gridDensity));
|
||||
}, [gridDensity]);
|
||||
|
||||
useEffect(() => {
|
||||
const existingIds = new Set(validAssets.map((asset) => asset.id));
|
||||
setSelectedIds((current) => current.filter((id) => existingIds.has(id)));
|
||||
}, [validAssets]);
|
||||
|
||||
const folderSelectOptions = useMemo(() => [
|
||||
{ label: "未分类", value: "" },
|
||||
...folders.map((folder) => ({ label: folder.name, value: folder.id })),
|
||||
], [folders]);
|
||||
|
||||
const invalidateAssetLibrary = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ASSET_LIBRARY_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ASSET_FOLDER_QUERY_KEY }),
|
||||
]);
|
||||
};
|
||||
|
||||
const saveFolder = async () => {
|
||||
const name = folderName.trim();
|
||||
if (!name || !folderEditor) return;
|
||||
setFolderSaving(true);
|
||||
try {
|
||||
if (folderEditor === "new") await createAssetFolder(name);
|
||||
else await updateAssetFolder(folderEditor.id, name);
|
||||
setFolderEditor(null);
|
||||
setFolderName("");
|
||||
await invalidateAssetLibrary();
|
||||
message.success(folderEditor === "new" ? "素材分类已创建" : "素材分类已重命名");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "素材分类保存失败");
|
||||
} finally {
|
||||
setFolderSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFolder = async (folder: AssetFolder) => {
|
||||
try {
|
||||
await deleteAssetFolder(folder.id);
|
||||
for (const asset of useAssetStore.getState().assets) {
|
||||
if (asset.folderId === folder.id) updateAsset(asset.id, { folderId: undefined });
|
||||
}
|
||||
await flushAssetStorePersistence();
|
||||
if (folderFilter === folder.id) setFolderFilter("all");
|
||||
setPage(1);
|
||||
await invalidateAssetLibrary();
|
||||
message.success(`已删除分类「${folder.name}」,其中素材已移至未分类`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "素材分类删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const moveAssetsToFolder = async (assetIds: string[], folderId: string) => {
|
||||
if (!assetIds.length) return;
|
||||
try {
|
||||
await moveRemoteAssetsToFolder(assetIds, folderId);
|
||||
assetIds.forEach((id) => updateAsset(id, { folderId: folderId || undefined }));
|
||||
await flushAssetStorePersistence();
|
||||
setSelectedIds([]);
|
||||
await invalidateAssetLibrary();
|
||||
message.success(`已移动 ${assetIds.length} 个素材`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "移动素材失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingAsset(null);
|
||||
setImageDraft(null);
|
||||
@@ -142,7 +254,7 @@ export default function AssetsPage() {
|
||||
setImageUploading(false);
|
||||
setImageUploadProgress(null);
|
||||
setFormKind("text");
|
||||
form.setFieldsValue({ kind: "text", category: "other", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
|
||||
form.setFieldsValue({ kind: "text", category: "other", folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : "", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
|
||||
setIsAssetOpen(true);
|
||||
};
|
||||
|
||||
@@ -156,6 +268,7 @@ export default function AssetsPage() {
|
||||
form.setFieldsValue({
|
||||
kind: asset.kind,
|
||||
category: asset.category || "other",
|
||||
folderId: asset.folderId || "",
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags || [],
|
||||
@@ -191,6 +304,7 @@ export default function AssetsPage() {
|
||||
const base = {
|
||||
title: values.title.trim(),
|
||||
category: values.category,
|
||||
folderId: values.folderId || undefined,
|
||||
status: editingAsset?.status || ("confirmed" as const),
|
||||
primaryVersionId: editingAsset?.primaryVersionId,
|
||||
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageData ? imageData.dataUrl : ""),
|
||||
@@ -212,7 +326,14 @@ export default function AssetsPage() {
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
}
|
||||
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
await flushAssetStorePersistence();
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
await invalidateAssetLibrary();
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
} catch {
|
||||
message.warning(editingAsset ? "素材已在本地更新,稍后自动同步至云端" : "素材已在本地保存,稍后自动同步至云端");
|
||||
}
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
|
||||
@@ -431,6 +552,9 @@ export default function AssetsPage() {
|
||||
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>
|
||||
新增素材
|
||||
</Button>
|
||||
<Button icon={<Images className="size-3.5" />} onClick={() => setBatchUploadOpen(true)}>
|
||||
批量上传图片
|
||||
</Button>
|
||||
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>
|
||||
Eagle 素材库
|
||||
</Button>
|
||||
@@ -460,6 +584,7 @@ export default function AssetsPage() {
|
||||
setKeyword("");
|
||||
setKindFilter("all");
|
||||
setCategoryFilter("all");
|
||||
setFolderFilter("all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -473,8 +598,24 @@ export default function AssetsPage() {
|
||||
setPage(1);
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</ListToolbar>
|
||||
/>
|
||||
<Select
|
||||
value={folderFilter}
|
||||
className="w-full sm:w-44"
|
||||
options={[{ label: "全部自定义分类", value: "all" }, { label: "未分类", value: "uncategorized" }, ...folders.map((folder) => ({ label: folder.name, value: folder.id }))]}
|
||||
onChange={(value) => {
|
||||
setFolderFilter(value as AssetFolderFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={gridDensity}
|
||||
className="w-full sm:w-32"
|
||||
suffixIcon={<LayoutGrid className="size-3.5" />}
|
||||
options={[{ label: "舒适 · 6 列", value: 6 }, { label: "标准 · 8 列", value: 8 }, { label: "紧凑 · 10 列", value: 10 }]}
|
||||
onChange={(value) => setGridDensity(value as AssetGridDensity)}
|
||||
/>
|
||||
</ListToolbar>
|
||||
</div>
|
||||
|
||||
<div className="canvas-library-frame assets-library-frame">
|
||||
@@ -503,6 +644,27 @@ export default function AssetsPage() {
|
||||
}}
|
||||
className="lg:mt-5"
|
||||
/>
|
||||
<div className="mt-5">
|
||||
<div className="mb-1.5 flex items-center justify-between px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">
|
||||
<span>我的分类</span>
|
||||
<button type="button" className="assets-folder-add" title="新建分类" aria-label="新建分类" onClick={() => { setFolderName(""); setFolderEditor("new"); }}><FolderPlus className="size-3.5" /></button>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<button type="button" aria-pressed={folderFilter === "uncategorized"} className={`assets-filter-item w-full ${folderFilter === "uncategorized" ? "is-active" : ""}`} onClick={() => { setFolderFilter("uncategorized"); setPage(1); }}>
|
||||
<span className="assets-filter-item-label">未分类</span><span className="assets-filter-count">{folderCounts[""] ?? activeAssets.filter((asset) => !asset.folderId).length}</span>
|
||||
</button>
|
||||
{folders.map((folder) => (
|
||||
<div key={folder.id} className="assets-folder-row">
|
||||
<button type="button" aria-pressed={folderFilter === folder.id} className={`assets-filter-item min-w-0 flex-1 ${folderFilter === folder.id ? "is-active" : ""}`} onClick={() => { setFolderFilter(folder.id); setPage(1); }}>
|
||||
<span className="assets-filter-item-label min-w-0 truncate">{folder.name}</span><span className="assets-filter-count">{folderCounts[folder.id] ?? activeAssets.filter((asset) => asset.folderId === folder.id).length}</span>
|
||||
</button>
|
||||
<Dropdown trigger={["click"]} menu={{ items: [{ key: "rename", icon: <PencilLine className="size-3.5" />, label: "重命名", onClick: () => { setFolderName(folder.name); setFolderEditor(folder); } }, { key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除分类", onClick: () => void removeFolder(folder) }] }}>
|
||||
<button type="button" className="assets-folder-more" aria-label={`管理分类 ${folder.name}`} title="管理分类"><MoreHorizontal className="size-3.5" /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 border-t border-border/40 pt-3">
|
||||
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">垃圾箱与归档</div>
|
||||
<button
|
||||
@@ -546,7 +708,7 @@ export default function AssetsPage() {
|
||||
count={selectedAssets.length}
|
||||
isTrash={viewMode === "trash"}
|
||||
allSelected={allFilteredSelected}
|
||||
onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))}
|
||||
onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...visibleAssetIds])))}
|
||||
onClear={() => setSelectedIds([])}
|
||||
onExport={() => void exportSelectedAssets()}
|
||||
onRestore={() => void batchRestore()}
|
||||
@@ -565,7 +727,7 @@ export default function AssetsPage() {
|
||||
{filteredAssets.length === 0 ? (
|
||||
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
|
||||
) : (
|
||||
<CollectionGrid className="library-grid assets-library-grid">
|
||||
<CollectionGrid className="library-grid assets-library-grid" style={{ "--assets-grid-columns": gridDensity } as React.CSSProperties}>
|
||||
{canCreateAsset ? (
|
||||
<button type="button" className="library-create-card" onClick={openCreate}>
|
||||
<span className="library-create-cover">
|
||||
@@ -590,6 +752,8 @@ export default function AssetsPage() {
|
||||
onRestore={() => void restoreAsset(asset)}
|
||||
onArchive={() => setArchivingAsset(asset)}
|
||||
onDelete={() => setDeletingAsset(asset)}
|
||||
folderOptions={folderSelectOptions}
|
||||
onMoveToFolder={(folderId) => void moveAssetsToFolder([asset.id], folderId)}
|
||||
/>
|
||||
))}
|
||||
</CollectionGrid>
|
||||
@@ -597,8 +761,8 @@ export default function AssetsPage() {
|
||||
<PaginationBar
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredAssets.length}
|
||||
pageSizeOptions={[35, 70, 105]}
|
||||
total={totalAssets}
|
||||
pageSizeOptions={[40, 80, 120]}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
@@ -754,6 +918,21 @@ export default function AssetsPage() {
|
||||
|
||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
||||
|
||||
<AssetBatchUploadModal open={batchUploadOpen} defaultFolderId={folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : ""} folders={folders} onClose={() => setBatchUploadOpen(false)} onComplete={async () => { setBatchUploadOpen(false); await invalidateAssetLibrary(); }} />
|
||||
|
||||
<Modal
|
||||
className="library-modal library-confirm-modal"
|
||||
title={folderEditor === "new" ? "新建分类" : "重命名分类"}
|
||||
open={Boolean(folderEditor)}
|
||||
confirmLoading={folderSaving}
|
||||
onCancel={() => { if (!folderSaving) setFolderEditor(null); }}
|
||||
onOk={() => void saveFolder()}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Input autoFocus value={folderName} maxLength={40} placeholder="例如:角色参考、场景灵感" onChange={(event) => setFolderName(event.target.value)} onPressEnter={() => void saveFolder()} />
|
||||
</Modal>
|
||||
|
||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||
<input
|
||||
ref={modelInputRef}
|
||||
@@ -857,6 +1036,8 @@ function AssetCard({
|
||||
onRestore,
|
||||
onArchive,
|
||||
onDelete,
|
||||
folderOptions,
|
||||
onMoveToFolder,
|
||||
}: {
|
||||
asset: LibraryAsset;
|
||||
selected: boolean;
|
||||
@@ -870,6 +1051,8 @@ function AssetCard({
|
||||
onRestore?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete: () => void;
|
||||
folderOptions: Array<{ label: string; value: string }>;
|
||||
onMoveToFolder: (folderId: string) => void;
|
||||
}) {
|
||||
const summary = assetSummary(asset);
|
||||
const menuItems: MenuProps["items"] = isTrash
|
||||
@@ -878,6 +1061,7 @@ function AssetCard({
|
||||
...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
|
||||
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
|
||||
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
|
||||
{ key: "move", icon: <FolderOpen className="size-3.5" />, label: "移动到分类", children: folderOptions.map((folder) => ({ key: folder.value || "uncategorized", label: folder.label, onClick: () => onMoveToFolder(folder.value) })) },
|
||||
{ type: "divider" as const },
|
||||
{ key: "archive", icon: <Trash2 className="size-3.5 text-amber-500" />, label: "移入回收站", onClick: onArchive },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "彻底删除", onClick: onDelete },
|
||||
@@ -1168,7 +1352,7 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
|
||||
) : asset.kind === "video" ? (
|
||||
<video src={asset.data.url} controls className="asset-archive-preview-media" />
|
||||
) : (
|
||||
<img src={asset.coverUrl || asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="asset-archive-preview-media" />
|
||||
<AssetImageZoom asset={asset} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -1218,6 +1402,24 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
|
||||
);
|
||||
}
|
||||
|
||||
function AssetImageZoom({ asset }: { asset: LibraryAsset & { kind: "image" } }) {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
|
||||
const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
return (
|
||||
<div className="asset-zoom-viewer" onWheel={(event) => { event.preventDefault(); setScale((value) => Math.min(4, Math.max(.25, value * (event.deltaY < 0 ? 1.12 : .89)))); }} onPointerDown={(event) => { if (scale <= 1) return; event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y }; }} onPointerMove={(event) => { const drag = dragRef.current; if (!drag) return; setOffset({ x: drag.ox + event.clientX - drag.x, y: drag.oy + event.clientY - drag.y }); }} onPointerUp={() => { dragRef.current = null; }} onPointerCancel={() => { dragRef.current = null; }}>
|
||||
<img src={asset.coverUrl || asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="asset-archive-preview-media asset-zoom-image" style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }} />
|
||||
<div className="asset-zoom-controls" data-canvas-no-zoom>
|
||||
<button type="button" title="缩小" aria-label="缩小" onClick={() => setScale((value) => Math.max(.25, value / 1.25))}><ZoomOut className="size-4" /></button>
|
||||
<button type="button" title="恢复适应" aria-label="恢复适应" onClick={reset}>{Math.round(scale * 100)}%</button>
|
||||
<button type="button" title="放大" aria-label="放大" onClick={() => setScale((value) => Math.min(4, value * 1.25))}><ZoomIn className="size-4" /></button>
|
||||
<button type="button" title="查看原图尺寸" aria-label="查看原图尺寸" onClick={() => setScale(1)}><Maximize2 className="size-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function assetArchiveFacts(asset: LibraryAsset) {
|
||||
const facts: Array<{ label: string; value: string }> = [
|
||||
{ label: "类型", value: assetKindLabel(asset.kind) },
|
||||
@@ -1279,6 +1481,21 @@ function assetDownloadLabel(asset: LibraryAsset) {
|
||||
return "下载图片";
|
||||
}
|
||||
|
||||
function readAssetGridDensity(): AssetGridDensity {
|
||||
if (typeof window === "undefined") return 8;
|
||||
const value = Number(window.localStorage.getItem(ASSET_GRID_DENSITY_KEY));
|
||||
return value === 6 || value === 10 ? value : 8;
|
||||
}
|
||||
|
||||
function assetCountMap<T extends { label: string; value: string }>(options: T[], remote: Record<string, number> | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) {
|
||||
const result = new Map<string, number>();
|
||||
options.forEach((option) => {
|
||||
if (remote) result.set(option.value, option.value === "all" ? Object.values(remote).reduce((sum, count) => sum + count, 0) : remote[option.value] || 0);
|
||||
else result.set(option.value, option.value === "all" ? fallback.length : fallback.filter((asset) => valueOf(asset) === option.value).length);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatAssetDuration(durationMs?: number) {
|
||||
if (!durationMs) return "时长未知";
|
||||
return `${Math.round(durationMs / 100) / 10} 秒`;
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
import { apiClient, compactApiParams, request } from "@/services/api/request";
|
||||
|
||||
const api = apiClient;
|
||||
|
||||
export type RemoteUserDataSummary = {
|
||||
id: string;
|
||||
folderId?: string;
|
||||
kind?: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AssetFolder = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type RemoteAssetPage = {
|
||||
assets: Asset[];
|
||||
kindCounts: Record<string, number>;
|
||||
categoryCounts: Record<string, number>;
|
||||
folderCounts: Record<string, number>;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export type RemoteUserDataSnapshot = {
|
||||
assets: Asset[];
|
||||
projects: CanvasProject[];
|
||||
@@ -25,6 +45,42 @@ export function listRemoteAssets() {
|
||||
return request<{ assets: RemoteUserDataSummary[] }>(api.get("/assets"));
|
||||
}
|
||||
|
||||
export function listRemoteAssetsPage(options: { page: number; pageSize: number; kind?: string; category?: string; folderId?: string; uncategorized?: boolean; status?: string; query?: string; signal?: AbortSignal }) {
|
||||
return request<RemoteAssetPage>(api.get("/assets", {
|
||||
signal: options.signal,
|
||||
params: compactApiParams({
|
||||
page: options.page,
|
||||
page_size: options.pageSize,
|
||||
kind: options.kind,
|
||||
category: options.category,
|
||||
folder_id: options.folderId,
|
||||
uncategorized: options.uncategorized ? 1 : undefined,
|
||||
status: options.status,
|
||||
q: options.query,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function listAssetFolders() {
|
||||
return request<{ folders: AssetFolder[] }>(api.get("/asset-folders"));
|
||||
}
|
||||
|
||||
export function createAssetFolder(name: string) {
|
||||
return request<{ folder: AssetFolder }>(api.post("/asset-folders", { name }));
|
||||
}
|
||||
|
||||
export function updateAssetFolder(id: string, name: string) {
|
||||
return request<{ folder: AssetFolder }>(api.patch(`/asset-folders/${encodeURIComponent(id)}`, { name }));
|
||||
}
|
||||
|
||||
export function deleteAssetFolder(id: string) {
|
||||
return request<{ id: string }>(api.delete(`/asset-folders/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function moveRemoteAssetsToFolder(assetIds: string[], folderId = "") {
|
||||
return request<{ assetIds: string[]; folderId: string }>(api.patch("/assets/folder", { assetIds, folderId }));
|
||||
}
|
||||
|
||||
export function getRemoteAsset(id: string) {
|
||||
return request<{ asset: Asset }>(api.get(`/assets/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type AssetBase<T extends AssetKind> = {
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
tags: string[];
|
||||
folderId?: string;
|
||||
category?: AssetCategory;
|
||||
status?: AssetStatus;
|
||||
primaryVersionId?: string;
|
||||
|
||||
@@ -15766,6 +15766,9 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
|
||||
.assets-filter-item.is-active { background: var(--control-selected-bg); color: var(--control-selected-fg); font-weight: 560; }
|
||||
.assets-filter-item.is-active:hover { background: var(--control-selected-hover-bg); color: var(--control-selected-fg); }
|
||||
.assets-filter-item.is-active .assets-filter-count { background: color-mix(in srgb, var(--control-selected-fg) 18%, transparent); color: var(--control-selected-fg); }
|
||||
.assets-folder-row { display: flex; min-width: 0; align-items: center; gap: 2px; }
|
||||
.assets-folder-add, .assets-folder-more { display: grid; width: 26px; height: 26px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--r-md); background: transparent; color: color-mix(in srgb, var(--foreground) 42%, transparent); cursor: pointer; }
|
||||
.assets-folder-add:hover, .assets-folder-more:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
|
||||
@media (min-width: 1024px) {
|
||||
.assets-filter-item { width: 100%; flex: none; }
|
||||
}
|
||||
@@ -15774,7 +15777,7 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
|
||||
.assets-cover { position: relative; overflow: hidden; }
|
||||
.assets-cover-link { position: relative; display: block; width: 100%; height: 100%; padding: 0; cursor: pointer; text-align: left; }
|
||||
.assets-cover-link:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
|
||||
.assets-cover-media { width: 100%; height: 100%; object-fit: cover; }
|
||||
.assets-cover-media { width: 100%; height: 100%; object-fit: contain; background: color-mix(in srgb, var(--foreground) 5%, transparent); }
|
||||
.assets-cover-fallback { display: grid; width: 100%; height: 100%; place-items: center; background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); }
|
||||
.assets-cover-vignette { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0, 0, 0, .16) 0%, transparent 32%, transparent 58%, rgba(0, 0, 0, .32) 100%); }
|
||||
.assets-cover-badges { position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; align-items: center; gap: 5px; pointer-events: none; }
|
||||
@@ -15966,6 +15969,20 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
|
||||
.asset-archive-subtitle { margin-top: 4px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-label); }
|
||||
.asset-archive-preview { overflow: hidden; border-radius: var(--r-xl); background: #050505; box-shadow: var(--elevation-hairline); }
|
||||
.asset-archive-preview-media { display: block; width: 100%; max-height: 400px; object-fit: contain; background: #050505; }
|
||||
.asset-zoom-viewer { position: relative; display: flex; min-height: 420px; align-items: center; justify-content: center; overflow: hidden; background: #050505; touch-action: none; }
|
||||
.asset-zoom-image { max-height: 70vh; transform-origin: center; transition: transform 120ms ease-out; user-select: none; }
|
||||
.asset-zoom-controls { position: absolute; right: 12px; bottom: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 2px; padding: 4px; border: 1px solid rgba(255,255,255,.16); border-radius: var(--r-md); background: rgba(15,15,15,.76); backdrop-filter: blur(8px); }
|
||||
.asset-zoom-controls button { display: grid; min-width: 30px; height: 30px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: rgba(255,255,255,.86); cursor: pointer; font-size: 11px; }
|
||||
.asset-zoom-controls button:hover { background: rgba(255,255,255,.12); color: #fff; }
|
||||
.batch-upload-dropzone { display: flex; min-height: 132px; flex-direction: column; align-items: center; justify-content: center; gap: 7px; width: 100%; border: 1px dashed color-mix(in srgb, var(--foreground) 18%, transparent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 3%, transparent); color: color-mix(in srgb, var(--foreground) 52%, transparent); cursor: pointer; }
|
||||
.batch-upload-dropzone:hover { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 6%, transparent); color: var(--foreground); }
|
||||
.batch-upload-dropzone strong { color: var(--foreground); font-size: var(--fs-body); }
|
||||
.batch-upload-dropzone span { font-size: var(--fs-label); }
|
||||
.batch-upload-list { max-height: 260px; overflow: auto; border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); border-radius: var(--r-lg); }
|
||||
.batch-upload-item { display: flex; min-width: 0; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); font-size: var(--fs-label); }
|
||||
.batch-upload-item:last-child { border-bottom: 0; }
|
||||
.batch-upload-remove { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 5px; background: transparent; color: color-mix(in srgb, var(--foreground) 38%, transparent); cursor: pointer; }
|
||||
.batch-upload-remove:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
|
||||
.asset-archive-preview-note { max-height: 400px; overflow: auto; padding: 26px 24px; color: color-mix(in srgb, var(--foreground) 72%, transparent); font-size: var(--fs-body); line-height: 1.75; white-space: pre-wrap; }
|
||||
.asset-archive-preview-model { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 40px 24px; background: color-mix(in srgb, var(--foreground) 3%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); }
|
||||
.asset-archive-preview-model > svg { width: 44px; height: 44px; color: color-mix(in srgb, var(--workspace-accent) 55%, var(--foreground)); stroke-width: 1; }
|
||||
@@ -16011,11 +16028,11 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
|
||||
}
|
||||
|
||||
/* 3. 素材网格以 6 列为桌面基准;首格是新增素材,因此每页 35 项时正好组成 6×6 完整网格。 */
|
||||
.assets-library-page .assets-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 18px 14px; }
|
||||
.assets-library-page .assets-library-grid { grid-template-columns: repeat(var(--assets-grid-columns, 8), minmax(0, 1fr)); gap: 18px 14px; }
|
||||
.assets-library-page .library-create-title { margin-top: 12px; font-size: var(--fs-body); }
|
||||
.assets-library-page .library-create-meta { margin-top: 4px; font-size: var(--fs-label); }
|
||||
@media (max-width: 1200px) and (min-width: 900px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
@media (max-width: 899px) and (min-width: 640px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 1200px) and (min-width: 900px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
|
||||
@media (max-width: 899px) and (min-width: 640px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
|
||||
/* 4. 封面浮层统一:窄屏隐藏分类徽章;浅底封面(文本/音频/模型)改用自适应玻璃浮层 */
|
||||
@media (max-width: 1100px) { .assets-cover-badge.is-category { display: none; } }
|
||||
|
||||
Reference in New Issue
Block a user