perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -2,6 +2,8 @@ import { App, Button, Dropdown, Modal, Popconfirm } from "antd";
import type { MenuProps } from "antd";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useUserStore } from "@/stores/use-user-store";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard } from "@/components/assets/asset-library-card";
@@ -10,7 +12,7 @@ import { PaginationBar } from "@/components/layout/workspace-page";
import { cn } from "@/lib/utils";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync";
export type AssetLibraryPickerItem = {
id: string;
@@ -36,6 +38,8 @@ export type AssetLibraryPickerFolder = {
};
type Props = {
remoteLibrary?: boolean;
remoteKind?: string;
open: boolean;
items: AssetLibraryPickerItem[];
categoryLabels: Record<string, string>;
@@ -70,6 +74,8 @@ type Props = {
};
export function AssetLibraryPickerModal({
remoteLibrary = false,
remoteKind,
open,
items,
categoryLabels,
@@ -104,6 +110,28 @@ export function AssetLibraryPickerModal({
const [working, setWorking] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const [error, setError] = useState("");
const userId = useUserStore((state) => state.user?.id);
const sessionHydrated = useUserStore((state) => state.hydrated);
const [remotePage, setRemotePage] = useState(1);
const [remotePageSize, setRemotePageSize] = useState(40);
const [remoteKeyword, setRemoteKeyword] = useState("");
const remoteEnabled = remoteLibrary && Boolean(userId) && source === "local";
useEffect(() => {
const timer = window.setTimeout(() => setRemoteKeyword(keyword.trim()), 250);
return () => window.clearTimeout(timer);
}, [keyword]);
useEffect(() => setRemotePage(1), [category, remoteKeyword, open]);
const remoteQuery = useQuery({
queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteKind],
queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteKind, category: category === "all" || category === "archived" || category === remoteKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }),
enabled: remoteEnabled && open && sessionHydrated,
});
const remoteItems = useMemo<AssetLibraryPickerItem[]>(() => (remoteQuery.data?.assets || []).filter((asset) => asset.kind !== "entity" && asset.kind !== "model").map((asset) => ({
id: asset.id, title: asset.title, category: asset.category || "other", archived: asset.status === "archived", asset,
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本", searchText: asset.tags.join(" "),
...(items.find((item) => item.id === asset.id) || { disabledReason: "此素材不适用于当前操作" }),
})), [remoteQuery.data, items]);
const effectivePagination = remoteEnabled ? { current: remotePage, pageSize: remotePageSize, total: remoteQuery.data?.total || 0, onChange: (page: number, pageSize: number) => { setRemotePage(page); setRemotePageSize(pageSize); } } : pagination;
const uploadInputRef = useRef<HTMLInputElement>(null);
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
@@ -116,12 +144,12 @@ export function AssetLibraryPickerModal({
const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]);
const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]);
const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]);
const sourceItems = source === "plugin" ? pluginItems : localItems;
const sourceItems = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems;
const activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]);
const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]);
const sourceFolders = source === "plugin" ? folders : [];
const showCategories = source === "local" || !sourceFolders.length;
const normalCategories = useMemo(() => ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems]);
const normalCategories = useMemo(() => remoteEnabled ? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:")) : ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems, categoryLabels, remoteEnabled]);
const archivedCount = archivedItems.length;
const isRecycleBin = category === "archived";
@@ -134,9 +162,9 @@ export function AssetLibraryPickerModal({
return false;
}
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
return remoteEnabled || !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, folderId, keyword, sourceItems]);
}, [category, folderId, keyword, sourceItems, remoteEnabled]);
const selectedIds = useMemo(
() =>
Array.from(selected).filter((id) => {
@@ -221,6 +249,7 @@ export function AssetLibraryPickerModal({
message.warning("已在本地还原,稍后自动同步至云端");
} finally {
setWorking(false);
if (remoteEnabled) void remoteQuery.refetch();
}
};
@@ -235,6 +264,7 @@ export function AssetLibraryPickerModal({
message.error(err instanceof Error ? err.message : "删除失败");
} finally {
setWorking(false);
if (remoteEnabled) void remoteQuery.refetch();
}
};
@@ -245,12 +275,13 @@ export function AssetLibraryPickerModal({
try {
for (const item of toDelete) await deleteAssetWithRemoteSync(item.id);
setSelected(new Set());
message.success(`已清空回收站 ${toDelete.length} 个素材`);
message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`);
setCategory("all");
} catch (err) {
message.error(err instanceof Error ? err.message : "清空回收站失败");
} finally {
setWorking(false);
if (remoteEnabled) void remoteQuery.refetch();
}
};
@@ -367,7 +398,7 @@ export function AssetLibraryPickerModal({
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" />
</label>
<span className="asset-picker-count">
已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材
已选 {selectedIds.length} · {effectivePagination ? effectivePagination.total : visibleItems.length} 个素材
</span>
</header>
<div className="asset-picker-body">
@@ -391,7 +422,7 @@ export function AssetLibraryPickerModal({
<span className="assets-filter-count">{countFor(value)}</span>
</button>
))}
{archivedCount > 0 ? (
{archivedCount > 0 || remoteEnabled ? (
<div className="mt-3 border-t border-border/40 pt-2">
<button
type="button"
@@ -412,7 +443,7 @@ export function AssetLibraryPickerModal({
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{loading ? (
{remoteEnabled && remoteQuery.isError ? <div role="alert">素材读取失败<Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (remoteEnabled && remoteQuery.isFetching) ? (
<div className="asset-picker-empty">
<LoaderCircle className="animate-spin" />
<strong>正在读取素材</strong>
@@ -428,7 +459,7 @@ export function AssetLibraryPickerModal({
</div>
)}
</div>
{pagination ? <PaginationBar alwaysShow current={pagination.current} pageSize={pagination.pageSize} total={pagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={pagination.onChange} /> : null}
{effectivePagination ? <PaginationBar alwaysShow current={effectivePagination.current} pageSize={effectivePagination.pageSize} total={effectivePagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={effectivePagination.onChange} /> : null}
</div>
</div>
<footer className={cn("asset-picker-footer", !activeUpload && "is-compact")}>
@@ -456,9 +487,9 @@ export function AssetLibraryPickerModal({
<div className="asset-picker-actions">
{isRecycleBin ? (
<>
<Popconfirm title="确认清空回收站?" description="清空后所有回收站素材及其物理文件将被彻底删除,不可恢复。" onConfirm={handleEmptyRecycleBin} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
<Popconfirm title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"} description="仅删除当前列表中的素材;仍被引用的素材由服务端拒绝删除。删除不可恢复。" onConfirm={handleEmptyRecycleBin} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
<Button type="text" danger disabled={working || !archivedCount}>
清空回收站
{remoteEnabled ? "删除当前页" : "清空回收站"}
</Button>
</Popconfirm>
<Popconfirm title="确认彻底删除已选素材?" onConfirm={handleDeleteSelected} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">