feat(assets&canvas): 实现素材回收站机制、自动定时清理策略与过期时间提示,增加画布创建与变更历史时间线
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Button, Dropdown, Modal } from "antd";
|
||||
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, Search, Upload, UserRound, Video } from "lucide-react";
|
||||
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 { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
@@ -9,7 +9,8 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
|
||||
export type AssetLibraryPickerItem = {
|
||||
id: string;
|
||||
@@ -91,6 +92,7 @@ export function AssetLibraryPickerModal({
|
||||
onConfirm,
|
||||
onFolderAction,
|
||||
}: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [category, setCategory] = useState(initialCategory);
|
||||
const [folderId, setFolderId] = useState(initialFolderId);
|
||||
const [source, setSource] = useState<"local" | "plugin">("local");
|
||||
@@ -112,26 +114,34 @@ export function AssetLibraryPickerModal({
|
||||
itemsRef.current = allItems;
|
||||
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 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 sourceFolders = source === "plugin" ? folders : [];
|
||||
const showCategories = source === "local" || !sourceFolders.length;
|
||||
const categories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other")))], [sourceItems]);
|
||||
const normalCategories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other"))).filter((c) => c !== "all" && c !== "archived")], [sourceItems]);
|
||||
const archivedCount = useMemo(() => sourceItems.filter((item) => item.category === "archived").length, [sourceItems]);
|
||||
const isRecycleBin = category === "archived";
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return sourceItems.filter((item) => {
|
||||
if (category !== "all" && item.category !== category) return false;
|
||||
if (category === "all") {
|
||||
if (item.category === "archived") return false;
|
||||
} else if (item.category !== category) {
|
||||
return false;
|
||||
}
|
||||
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
|
||||
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
|
||||
});
|
||||
}, [category, folderId, keyword, sourceItems]);
|
||||
const selectedIds = useMemo(() => Array.from(selected).filter((id) => {
|
||||
const item = allItems.find((entry) => entry.id === id);
|
||||
return !item?.disabledReason;
|
||||
}), [allItems, selected]);
|
||||
const selectedIds = useMemo(
|
||||
() =>
|
||||
Array.from(selected).filter((id) => {
|
||||
const item = allItems.find((entry) => entry.id === id);
|
||||
return !item?.disabledReason;
|
||||
}),
|
||||
[allItems, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -149,9 +159,9 @@ export function AssetLibraryPickerModal({
|
||||
}, [initialCategory, initialFolderId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (category === "all" || categories.includes(category)) return;
|
||||
if (category === "all" || category === "archived" || normalCategories.includes(category)) return;
|
||||
setCategory("all");
|
||||
}, [categories, category]);
|
||||
}, [normalCategories, category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPluginSource || source === "local") return;
|
||||
@@ -191,6 +201,65 @@ export function AssetLibraryPickerModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreSelected = async () => {
|
||||
if (!selectedIds.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
useAssetStore.getState().updateAsset(id, { status: "confirmed" });
|
||||
}
|
||||
await flushAssetStorePersistence();
|
||||
await saveRemoteUserDataNow();
|
||||
setSelected(new Set());
|
||||
message.success(`已还原 ${selectedIds.length} 个素材至素材库`);
|
||||
setCategory("all");
|
||||
} catch {
|
||||
message.warning("已在本地还原,稍后自动同步至云端");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (!selectedIds.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
await useAssetStore.getState().removeAsset(id);
|
||||
await deleteAssetWithRemoteSync(id);
|
||||
}
|
||||
await flushAssetStorePersistence();
|
||||
await saveRemoteUserDataNow();
|
||||
setSelected(new Set());
|
||||
message.success(`已彻底删除 ${selectedIds.length} 个素材`);
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmptyRecycleBin = async () => {
|
||||
const toDelete = sourceItems.filter((item) => item.category === "archived");
|
||||
if (!toDelete.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const item of toDelete) {
|
||||
await useAssetStore.getState().removeAsset(item.id);
|
||||
await deleteAssetWithRemoteSync(item.id);
|
||||
}
|
||||
await flushAssetStorePersistence();
|
||||
await saveRemoteUserDataNow();
|
||||
setSelected(new Set());
|
||||
message.success(`已清空回收站 ${toDelete.length} 个素材`);
|
||||
setCategory("all");
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "清空回收站失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (files: FileList | null) => {
|
||||
if (!files?.length || working || (source === "local" && !upload) || (source === "plugin" && !upload?.external)) return;
|
||||
setWorking(true);
|
||||
@@ -228,13 +297,35 @@ export function AssetLibraryPickerModal({
|
||||
}
|
||||
};
|
||||
|
||||
const countFor = (value: string) => value === "all" ? sourceItems.length : sourceItems.filter((item) => item.category === value).length;
|
||||
const countFor = (value: string) => (value === "all" ? sourceItems.filter((item) => item.category !== "archived").length : sourceItems.filter((item) => item.category === value).length);
|
||||
const sourceLabel = source === "plugin" ? "插件来源" : "本地素材";
|
||||
const sourceMenuItems: MenuProps["items"] = [
|
||||
{ key: "local", icon: <HardDrive aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>本地素材</span><em>{localItems.length}</em></span> },
|
||||
...(hasPluginSource ? [{ key: "plugin", icon: <Puzzle aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>插件来源</span><em>{pluginItems.length}</em></span> }] : []),
|
||||
{
|
||||
key: "local",
|
||||
icon: <HardDrive aria-hidden="true" />,
|
||||
label: (
|
||||
<span className="asset-picker-source-menu-label">
|
||||
<span>本地素材</span>
|
||||
<em>{localItems.filter((i) => i.category !== "archived").length}</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(hasPluginSource
|
||||
? [
|
||||
{
|
||||
key: "plugin",
|
||||
icon: <Puzzle aria-hidden="true" />,
|
||||
label: (
|
||||
<span className="asset-picker-source-menu-label">
|
||||
<span>插件来源</span>
|
||||
<em>{pluginItems.length}</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const activeUpload = source === "plugin" ? upload?.external : upload;
|
||||
const activeUpload = isRecycleBin ? undefined : source === "plugin" ? upload?.external : upload;
|
||||
const uploading = uploadingCount > 0;
|
||||
|
||||
return (
|
||||
@@ -271,31 +362,76 @@ export function AssetLibraryPickerModal({
|
||||
}}
|
||||
>
|
||||
<button type="button" className="asset-picker-title-trigger" aria-haspopup="menu" aria-expanded={sourceMenuOpen} aria-label={"素材库来源:" + sourceLabel}>
|
||||
<strong>{title}</strong><ChevronDown aria-hidden="true" />
|
||||
<strong>{isRecycleBin ? "回收站" : title}</strong>
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<label className="asset-picker-search"><Search aria-hidden /><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} 个素材</span>
|
||||
<label className="asset-picker-search">
|
||||
<Search aria-hidden />
|
||||
<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} 个素材
|
||||
</span>
|
||||
</header>
|
||||
<div className="asset-picker-body">
|
||||
<nav className="asset-picker-categories" aria-label="素材分类">
|
||||
{sourceFolders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span className="assets-filter-item-label">全部文件夹</span><span className="assets-filter-count">{sourceItems.length}</span></button>{renderPickerFolders(sourceFolders, sourceItems, folderId, setFolderId)}</> : null}
|
||||
{showCategories ? <><span className="asset-picker-nav-label">分类</span>{categories.map((value) => (
|
||||
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
|
||||
<span className="assets-filter-item-label">{categoryLabels[value] || "其他"}</span><span className="assets-filter-count">{countFor(value)}</span>
|
||||
</button>
|
||||
))}</> : null}
|
||||
{sourceFolders.length ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">文件夹</span>
|
||||
<button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}>
|
||||
<span className="assets-filter-item-label">全部文件夹</span>
|
||||
<span className="assets-filter-count">{sourceItems.length}</span>
|
||||
</button>
|
||||
{renderPickerFolders(sourceFolders, sourceItems, folderId, setFolderId)}
|
||||
</>
|
||||
) : null}
|
||||
{showCategories ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">分类</span>
|
||||
{normalCategories.map((value) => (
|
||||
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
|
||||
<span className="assets-filter-item-label">{categoryLabels[value] || (value === "all" ? "全部素材" : "其他")}</span>
|
||||
<span className="assets-filter-count">{countFor(value)}</span>
|
||||
</button>
|
||||
))}
|
||||
{archivedCount > 0 ? (
|
||||
<div className="mt-3 border-t border-border/40 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("assets-filter-item text-amber-500 hover:text-amber-400 dark:text-amber-400", category === "archived" && "is-active !bg-amber-500/10")}
|
||||
aria-pressed={category === "archived"}
|
||||
onClick={() => setCategory("archived")}
|
||||
>
|
||||
<span className="assets-filter-item-label flex items-center gap-1.5">
|
||||
<Trash2 className="size-3.5" />
|
||||
<span>回收站</span>
|
||||
</span>
|
||||
<span className="assets-filter-count">{archivedCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
<div className="asset-picker-grid-wrap">
|
||||
<div className="asset-picker-grid">
|
||||
{loading ? (
|
||||
<div className="asset-picker-empty"><LoaderCircle className="animate-spin" /><strong>正在读取素材</strong><span>素材会按页加载,不会一次下载整个项目库。</span></div>
|
||||
) : visibleItems.length ? visibleItems.map((item) => (
|
||||
<PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />
|
||||
)) : (
|
||||
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
|
||||
<div className="asset-picker-empty">
|
||||
<LoaderCircle className="animate-spin" />
|
||||
<strong>正在读取素材</strong>
|
||||
<span>素材会按页加载,不会一次下载整个项目库。</span>
|
||||
</div>
|
||||
) : visibleItems.length ? (
|
||||
visibleItems.map((item) => <PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />)
|
||||
) : (
|
||||
<div className="asset-picker-empty">
|
||||
<FolderOpen />
|
||||
<strong>{isRecycleBin ? "回收站是空的" : emptyTitle}</strong>
|
||||
<span>{isRecycleBin ? "删除画布或手动归档的素材会暂存到这里,可在需要时还原。" : activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pagination ? <PaginationBar alwaysShow current={pagination.current} pageSize={pagination.pageSize} total={pagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={pagination.onChange} /> : null}
|
||||
@@ -307,17 +443,57 @@ export function AssetLibraryPickerModal({
|
||||
<input ref={uploadInputRef} type="file" hidden accept={activeUpload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
|
||||
<button type="button" className="asset-picker-upload" onClick={() => uploadInputRef.current?.click()} disabled={working} aria-busy={uploading}>
|
||||
{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}
|
||||
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small></span>
|
||||
<span>
|
||||
<strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong>
|
||||
<small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
|
||||
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
|
||||
) : footerNote ? (
|
||||
<span className="asset-picker-footer-note">{footerNote}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{error ? (
|
||||
<span className="asset-picker-footer-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="asset-picker-actions">
|
||||
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
|
||||
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
|
||||
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
|
||||
{confirmLabel(selectedIds.length)}
|
||||
</Button>
|
||||
{isRecycleBin ? (
|
||||
<>
|
||||
<Popconfirm title="确认清空回收站?" description="清空后所有回收站素材及其物理文件将被彻底删除,不可恢复。" onConfirm={handleEmptyRecycleBin} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" danger disabled={working || !archivedCount}>
|
||||
清空回收站
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm title="确认彻底删除已选素材?" onConfirm={handleDeleteSelected} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" danger disabled={working || !selectedIds.length}>
|
||||
彻底删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button type="text" onClick={onClose} disabled={working}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button type="primary" icon={<RotateCcw className="size-3.5" />} disabled={working || !selectedIds.length} loading={working} onClick={handleRestoreSelected}>
|
||||
还原已选素材{selectedIds.length ? `(${selectedIds.length})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? (
|
||||
<Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>
|
||||
{folderActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="text" onClick={onClose} disabled={working}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
|
||||
{confirmLabel(selectedIds.length)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -331,14 +507,32 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
|
||||
<AssetLibraryCard selected={selected} className={cn("asset-picker-card", disabled && "is-disabled")}>
|
||||
<button type="button" className="asset-picker-card-action" onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
|
||||
<div className="assets-cover asset-picker-card-media">
|
||||
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />}
|
||||
{item.imageUrl || item.imageStorageKey ? (
|
||||
<CachedResourceImage
|
||||
storageKey={item.imageStorageKey}
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={item.imageFit === "contain" ? "is-contain" : undefined}
|
||||
fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>}
|
||||
/>
|
||||
) : (
|
||||
<AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />
|
||||
)}
|
||||
<span className="assets-cover-vignette" aria-hidden="true" />
|
||||
<span className="assets-cover-badges" aria-hidden="true">
|
||||
<span className="assets-cover-badge is-kind">{item.kindLabel}</span></span>
|
||||
<span className="asset-picker-card-check" aria-hidden="true"><Check /></span>
|
||||
<span className="assets-cover-badge is-kind">{item.kindLabel}</span>
|
||||
</span>
|
||||
<span className="asset-picker-card-check" aria-hidden="true">
|
||||
<Check />
|
||||
</span>
|
||||
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
|
||||
</div>
|
||||
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
|
||||
<div className="asset-picker-card-copy">
|
||||
<strong>{item.title || "未命名素材"}</strong>
|
||||
{item.description ? <span>{item.description}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
</AssetLibraryCard>
|
||||
);
|
||||
@@ -346,17 +540,28 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
|
||||
|
||||
function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLibraryPickerItem[], selectedId: string, onSelect: (folderId: string) => void, parentId = "", depth = 0, visited: ReadonlySet<string> = new Set()): ReactNode {
|
||||
if (depth >= 8) return null;
|
||||
return folders.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id)).map((folder) => {
|
||||
const nextVisited = new Set(visited).add(folder.id);
|
||||
return (
|
||||
<span key={folder.id} className="contents">
|
||||
<button type="button" className={cn("assets-filter-item", selectedId === folder.id && "is-active")} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
|
||||
<span className="assets-filter-item-label" title={folder.name}>{folder.name}</span><span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
|
||||
</button>
|
||||
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
return folders
|
||||
.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id))
|
||||
.map((folder) => {
|
||||
const nextVisited = new Set(visited).add(folder.id);
|
||||
return (
|
||||
<span key={folder.id} className="contents">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("assets-filter-item", selectedId === folder.id && "is-active")}
|
||||
aria-pressed={selectedId === folder.id}
|
||||
onClick={() => onSelect(folder.id)}
|
||||
style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}
|
||||
>
|
||||
<span className="assets-filter-item-label" title={folder.name}>
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
|
||||
</button>
|
||||
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function kindIcon(label: string): ReactNode {
|
||||
|
||||
@@ -33,12 +33,15 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setNearViewport(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}, { rootMargin: "240px" });
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setNearViewport(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "240px" },
|
||||
);
|
||||
observer.observe(image);
|
||||
return () => observer.disconnect();
|
||||
}, [eager, remoteResource]);
|
||||
@@ -50,47 +53,61 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag
|
||||
if (remoteResource && storageKey) {
|
||||
if (!nearViewport) {
|
||||
setCachedSrc("");
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setCachedSrc("");
|
||||
const resolve = cacheResourceObjectUrl(storageKey);
|
||||
void resolve.then((url) => {
|
||||
if (!cancelled) setCachedSrc(url || src);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCacheFailed(true);
|
||||
setCachedSrc(src);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
void resolve
|
||||
.then((url) => {
|
||||
if (!cancelled) setCachedSrc(url || src);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCacheFailed(true);
|
||||
setCachedSrc(src);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (localImageResource && storageKey) {
|
||||
void resolveImageUrl(storageKey, src).then((url) => {
|
||||
if (!cancelled) setCachedSrc(url || src);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setCachedSrc(src);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
void resolveImageUrl(storageKey, src)
|
||||
.then((url) => {
|
||||
if (!cancelled) setCachedSrc(url || src);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCachedSrc(src);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setCachedSrc(src);
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localImageResource, nearViewport, remoteResource, src, storageKey]);
|
||||
|
||||
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
|
||||
void resolveImageUrl(storageKey).then((url) => {
|
||||
if (url && url !== cachedSrc) {
|
||||
setCachedSrc(url);
|
||||
return;
|
||||
}
|
||||
setCacheFailed(true);
|
||||
onError?.(e);
|
||||
}).catch(() => {
|
||||
setCacheFailed(true);
|
||||
onError?.(e);
|
||||
});
|
||||
void resolveImageUrl(storageKey)
|
||||
.then((url) => {
|
||||
if (url && url !== cachedSrc) {
|
||||
setCachedSrc(url);
|
||||
return;
|
||||
}
|
||||
setCacheFailed(true);
|
||||
onError?.(e);
|
||||
})
|
||||
.catch(() => {
|
||||
setCacheFailed(true);
|
||||
onError?.(e);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCacheFailed(true);
|
||||
|
||||
@@ -13,7 +13,21 @@ export type InsertAssetPayload =
|
||||
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; hasAudio?: boolean; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
|
||||
| {
|
||||
kind: "character";
|
||||
title: string;
|
||||
assetId: string;
|
||||
versionId: string;
|
||||
prompt: string;
|
||||
aliases: string[];
|
||||
definition: Record<string, unknown>;
|
||||
coverUrl?: string;
|
||||
visualStatus: string;
|
||||
voiceStatus: string;
|
||||
voiceName?: string;
|
||||
voiceProfile?: { name: string; provider: string; language: string; timbre: string };
|
||||
voiceInstructions?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -22,23 +36,26 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
|
||||
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS, archived: "回收站" };
|
||||
|
||||
export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
...insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: normalizeAssetCategory(asset.category),
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
], [externalAssetSources.items, insertableAssets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(
|
||||
() => [
|
||||
...insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.status === "archived" ? "archived" : normalizeAssetCategory(asset.category),
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
],
|
||||
[externalAssetSources.items, insertableAssets],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssetLibraryPickerModal
|
||||
@@ -76,7 +93,20 @@ export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibr
|
||||
function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload {
|
||||
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
|
||||
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, hasAudio: asset.data.hasAudio, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "video")
|
||||
return {
|
||||
kind: "video",
|
||||
url: asset.data.url,
|
||||
storageKey: asset.data.storageKey,
|
||||
title: asset.title,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
durationMs: asset.data.durationMs,
|
||||
hasAudio: asset.data.hasAudio,
|
||||
bytes: asset.data.bytes,
|
||||
mimeType: asset.data.mimeType,
|
||||
assetId: asset.id,
|
||||
};
|
||||
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
}
|
||||
|
||||
|
||||
243
web/src/components/canvas/canvas-history-drawer.tsx
Normal file
243
web/src/components/canvas/canvas-history-drawer.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Drawer, Tag, Button, Input, Popconfirm, Empty, Tooltip } from "antd";
|
||||
import { Clock, Clock3, ExternalLink, History, Search, Trash2, X } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TimelineItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
isDeleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
nodeCount: number;
|
||||
timelineTime: string;
|
||||
};
|
||||
|
||||
export function CanvasHistoryDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const activeProjects = useCanvasStore((state) => state.projects);
|
||||
const deletedProjects = useCanvasHistoryStore((state) => state.deletedProjects);
|
||||
const removeDeletedItem = useCanvasHistoryStore((state) => state.removeDeletedHistoryItem);
|
||||
const clearDeletedHistory = useCanvasHistoryStore((state) => state.clearDeletedHistory);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "active" | "deleted">("all");
|
||||
|
||||
const timelineItems = useMemo<TimelineItem[]>(() => {
|
||||
const activeList: TimelineItem[] = activeProjects.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title || "未命名画布",
|
||||
isDeleted: false,
|
||||
createdAt: p.createdAt || p.updatedAt || new Date().toISOString(),
|
||||
updatedAt: p.updatedAt || new Date().toISOString(),
|
||||
nodeCount: p.nodes?.length || 0,
|
||||
timelineTime: p.createdAt || p.updatedAt || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const deletedList: TimelineItem[] = deletedProjects.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title || "未命名画布",
|
||||
isDeleted: true,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
deletedAt: d.deletedAt,
|
||||
nodeCount: d.nodeCount,
|
||||
timelineTime: d.deletedAt || d.createdAt,
|
||||
}));
|
||||
|
||||
const all = [...activeList, ...deletedList];
|
||||
all.sort((a, b) => new Date(b.timelineTime).getTime() - new Date(a.timelineTime).getTime());
|
||||
return all;
|
||||
}, [activeProjects, deletedProjects]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
return timelineItems.filter((item) => {
|
||||
if (filter === "active" && item.isDeleted) return false;
|
||||
if (filter === "deleted" && !item.isDeleted) return false;
|
||||
if (!q) return true;
|
||||
return item.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [filter, keyword, timelineItems]);
|
||||
|
||||
const openProject = (id: string) => {
|
||||
navigate(`/canvas/${id}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-2 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-4 text-[var(--workspace-accent)]" />
|
||||
<span className="text-sm font-semibold text-stone-900 dark:text-stone-100">画布创建与变更历史时间线</span>
|
||||
</div>
|
||||
{deletedProjects.length > 0 ? (
|
||||
<Popconfirm title="确定清空已删除画布的历史记录?" onConfirm={clearDeletedHistory} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" size="small" danger icon={<Trash2 className="size-3.5" />}>
|
||||
清理删除记录
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="workspace-drawer canvas-history-drawer"
|
||||
styles={{
|
||||
header: { borderBottom: "1px solid var(--border-color, rgba(255,255,255,0.08))" },
|
||||
body: { padding: "16px 20px" },
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 搜索与筛选 */}
|
||||
<div className="space-y-3">
|
||||
<Input allowClear prefix={<Search className="size-3.5 text-stone-400" />} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索画布名称..." className="text-xs" />
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
|
||||
filter === "all"
|
||||
? "!bg-stone-900 !text-white shadow-sm dark:!bg-stone-100 dark:!text-stone-900 font-semibold"
|
||||
: "bg-stone-100 text-stone-700 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700",
|
||||
)}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
全部 ({timelineItems.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
|
||||
filter === "active"
|
||||
? "!bg-emerald-600 !text-white shadow-sm font-semibold"
|
||||
: "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 border border-emerald-200/60 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/50",
|
||||
)}
|
||||
onClick={() => setFilter("active")}
|
||||
>
|
||||
活跃中 ({activeProjects.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
|
||||
filter === "deleted" ? "!bg-rose-600 !text-white shadow-sm font-semibold" : "bg-rose-50 text-rose-800 hover:bg-rose-100 border border-rose-200/60 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-800/50",
|
||||
)}
|
||||
onClick={() => setFilter("deleted")}
|
||||
>
|
||||
已删除 ({deletedProjects.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间线列表 */}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无匹配的历史记录" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative border-l-2 border-stone-200 dark:border-stone-800 pl-4 space-y-4 pt-1">
|
||||
{filteredItems.map((item) => {
|
||||
const isDeleted = item.isDeleted;
|
||||
return (
|
||||
<div key={item.id + (item.deletedAt || "")} className="group relative">
|
||||
{/* 时间轴圆点 */}
|
||||
<span className={cn("absolute -left-[23px] top-3.5 size-3 rounded-full border-2 border-background", isDeleted ? "bg-rose-500 ring-2 ring-rose-500/20" : "bg-emerald-500 ring-2 ring-emerald-500/20")} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-3.5 transition-all shadow-sm",
|
||||
isDeleted
|
||||
? "border-rose-200/90 bg-rose-50/50 dark:border-rose-900/40 dark:bg-rose-950/20 hover:border-rose-300"
|
||||
: "border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900/70 hover:border-stone-300 dark:hover:border-stone-700",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<span className="truncate text-sm font-semibold text-stone-500 line-through decoration-rose-500/80 decoration-2 dark:text-stone-400" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-left text-sm font-semibold text-stone-900 hover:text-blue-600 dark:text-stone-100 dark:hover:text-blue-400 transition-colors"
|
||||
onClick={() => openProject(item.id)}
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
)}
|
||||
{isDeleted ? (
|
||||
<Tag color="error" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
|
||||
已删除
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="success" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
|
||||
活跃中
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-stone-600 dark:text-stone-300">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock3 className="size-3.5 text-stone-400 shrink-0" />
|
||||
<span>创建时间:{formatTimelineDate(item.createdAt)}</span>
|
||||
</div>
|
||||
{isDeleted && item.deletedAt ? (
|
||||
<div className="flex items-center gap-1.5 text-rose-600 dark:text-rose-400 font-medium">
|
||||
<Trash2 className="size-3.5 text-rose-500 shrink-0" />
|
||||
<span>删除时间:{formatTimelineDate(item.deletedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-stone-500 dark:text-stone-400">
|
||||
<Clock className="size-3.5 text-stone-400 shrink-0" />
|
||||
<span>最近更新:{formatTimelineDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[11px] text-stone-500 dark:text-stone-400 pt-0.5">包含 {item.nodeCount} 个节点</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0 pt-0.5">
|
||||
{!isDeleted ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ExternalLink className="size-4 text-stone-500 hover:text-stone-800 dark:text-stone-400 dark:hover:text-stone-100" />}
|
||||
onClick={() => openProject(item.id)}
|
||||
title="打开画布"
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title="从历史列表中移除此条记录">
|
||||
<Button type="text" size="small" danger icon={<X className="size-4" />} onClick={() => removeDeletedItem(item.id)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimelineDate(isoString: string) {
|
||||
if (!isoString) return "--";
|
||||
const date = new Date(isoString);
|
||||
if (!Number.isFinite(date.getTime())) return "--";
|
||||
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -13,7 +13,23 @@ import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
const categoryLabels: Record<string, string> = { all: "全部资产", ...ASSET_CATEGORY_LABELS };
|
||||
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
|
||||
|
||||
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
|
||||
export function CanvasProjectAssetModal({
|
||||
open,
|
||||
detail,
|
||||
initialCategory = "all",
|
||||
initialFolderId = "all",
|
||||
onClose,
|
||||
onInsert,
|
||||
onInsertFolder,
|
||||
}: {
|
||||
open: boolean;
|
||||
detail?: ProjectDetail;
|
||||
initialCategory?: string;
|
||||
initialFolderId?: string;
|
||||
onClose: () => void;
|
||||
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
|
||||
onInsertFolder?: (folderId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const mediaAssets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const items = useMemo<ProjectPickerItem[]>(() => {
|
||||
@@ -25,34 +41,35 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
});
|
||||
if (detail) return projectItems;
|
||||
// 自由画布未关联项目时回退到个人素材库。
|
||||
return mediaAssets
|
||||
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
|
||||
.map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
|
||||
return mediaAssets.filter((asset) => asset.kind !== "model" && asset.kind !== "entity" && asset.status !== "archived").map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
|
||||
}, [detail?.assets, mediaAssets]);
|
||||
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const character = item.character;
|
||||
const project = item.project;
|
||||
const media = item.media;
|
||||
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
|
||||
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
|
||||
return {
|
||||
id: item.id,
|
||||
title: character?.title || project?.title || media?.title || "未命名资产",
|
||||
category: item.category,
|
||||
folderId: item.folderId,
|
||||
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation
|
||||
? resourceFileUrl(coverRepresentation.resourceId)
|
||||
: project?.mediaType === "image" && remoteResourceId
|
||||
? resourceFileUrl(remoteResourceId)
|
||||
: undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
|
||||
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}), [items]);
|
||||
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const character = item.character;
|
||||
const project = item.project;
|
||||
const media = item.media;
|
||||
const coverRepresentation =
|
||||
character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") ||
|
||||
character?.character?.representations.find((representation) => representation.role === "primary") ||
|
||||
character?.character?.representations.find((representation) => representation.role === "front");
|
||||
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
|
||||
return {
|
||||
id: item.id,
|
||||
title: character?.title || project?.title || media?.title || "未命名资产",
|
||||
category: item.category,
|
||||
folderId: item.folderId,
|
||||
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
|
||||
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
|
||||
|
||||
return (
|
||||
@@ -69,18 +86,27 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
emptyTitle="此分类没有可引用资产"
|
||||
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
|
||||
footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"}
|
||||
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
|
||||
onFolderAction={
|
||||
onInsertFolder
|
||||
? async (folderId) => {
|
||||
await onInsertFolder(folderId);
|
||||
onClose();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={onClose}
|
||||
onConfirm={async (ids) => {
|
||||
const payloads = await Promise.all(ids.map(async (id) => {
|
||||
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
|
||||
if (external) return externalAssetToInsertPayload(external);
|
||||
const item = items.find((candidate) => candidate.id === id);
|
||||
if (!item) throw new Error("所选资产已不存在,请重新选择");
|
||||
if (item.media || item.character || !item.project) return toInsertPayload(item);
|
||||
const { asset } = await getRemoteAsset(item.project.id);
|
||||
return toInsertPayload({ ...item, media: asset });
|
||||
}));
|
||||
const payloads = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
|
||||
if (external) return externalAssetToInsertPayload(external);
|
||||
const item = items.find((candidate) => candidate.id === id);
|
||||
if (!item) throw new Error("所选资产已不存在,请重新选择");
|
||||
if (item.media || item.character || !item.project) return toInsertPayload(item);
|
||||
const { asset } = await getRemoteAsset(item.project.id);
|
||||
return toInsertPayload({ ...item, media: asset });
|
||||
}),
|
||||
);
|
||||
if (!payloads.length) return;
|
||||
await onInsert(payloads);
|
||||
onClose();
|
||||
@@ -106,8 +132,30 @@ function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
|
||||
}
|
||||
if (!asset) throw new Error("项目资产不可用");
|
||||
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
|
||||
if (asset.kind === "video") return { kind: "video", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "audio") return { kind: "audio", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "video")
|
||||
return {
|
||||
kind: "video",
|
||||
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
|
||||
storageKey: asset.data.storageKey,
|
||||
title: asset.title,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
durationMs: asset.data.durationMs,
|
||||
bytes: asset.data.bytes,
|
||||
mimeType: asset.data.mimeType,
|
||||
assetId: asset.id,
|
||||
};
|
||||
if (asset.kind === "audio")
|
||||
return {
|
||||
kind: "audio",
|
||||
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
|
||||
storageKey: asset.data.storageKey,
|
||||
title: asset.title,
|
||||
durationMs: asset.data.durationMs,
|
||||
bytes: asset.data.bytes,
|
||||
mimeType: asset.data.mimeType,
|
||||
assetId: asset.id,
|
||||
};
|
||||
if (asset.kind === "image") return { kind: "image", dataUrl: projectAssetMediaUrl(asset.data.storageKey, asset.data.dataUrl), storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
|
||||
throw new Error("当前项目资产不能直接插入画布");
|
||||
}
|
||||
@@ -121,7 +169,10 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
|
||||
if (!asset.character) throw new Error("项目角色信息不完整");
|
||||
const card = asset.character;
|
||||
const definition = card.definition;
|
||||
const cover = card.representations.find((representation) => representation.role === "turnaround_sheet") || card.representations.find((representation) => representation.role === "primary") || card.representations.find((representation) => representation.role === "front");
|
||||
const cover =
|
||||
card.representations.find((representation) => representation.role === "turnaround_sheet") ||
|
||||
card.representations.find((representation) => representation.role === "primary") ||
|
||||
card.representations.find((representation) => representation.role === "front");
|
||||
return {
|
||||
kind: "character",
|
||||
title: asset.title,
|
||||
@@ -134,12 +185,14 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
|
||||
visualStatus: card.visualStatus,
|
||||
voiceStatus: card.voiceStatus,
|
||||
voiceName: card.voice?.profile.name,
|
||||
voiceProfile: card.voice ? {
|
||||
name: card.voice.profile.name,
|
||||
provider: card.voice.profile.provider,
|
||||
language: card.voice.profile.language,
|
||||
timbre: card.voice.profile.timbre,
|
||||
} : undefined,
|
||||
voiceProfile: card.voice
|
||||
? {
|
||||
name: card.voice.profile.name,
|
||||
provider: card.voice.profile.provider,
|
||||
language: card.voice.profile.language,
|
||||
timbre: card.voice.profile.timbre,
|
||||
}
|
||||
: undefined,
|
||||
voiceInstructions: card.voice?.instructions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,11 +14,15 @@ import { cn } from "@/lib/utils";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
|
||||
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
|
||||
return <button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
|
||||
<span className="app-canvas-create-preview"><Plus className="app-canvas-create-icon" /></span>
|
||||
<span className="app-canvas-create-title">新建画布</span>
|
||||
<span className="app-canvas-create-meta">从空白开始</span>
|
||||
</button>;
|
||||
return (
|
||||
<button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
|
||||
<span className="app-canvas-create-preview">
|
||||
<Plus className="app-canvas-create-icon" />
|
||||
</span>
|
||||
<span className="app-canvas-create-title">新建画布</span>
|
||||
<span className="app-canvas-create-meta">从空白开始</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) {
|
||||
@@ -55,14 +59,27 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
|
||||
>
|
||||
<ProjectPreview project={project} />
|
||||
</button>
|
||||
{!compact && !readOnly ? <span className={`canvas-project-select ${selected ? "is-visible" : ""}`} onClick={(event) => event.stopPropagation()}><input type="checkbox" checked={selected} onChange={(event) => toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /></span> : null}
|
||||
<div className="canvas-project-cover-meta" aria-hidden="true"><span className="canvas-project-node-count">{project.nodes.length} 节点</span></div>
|
||||
{!compact && !readOnly ? (
|
||||
<span className={`canvas-project-select ${selected ? "is-visible" : ""}`} onClick={(event) => event.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected} onChange={(event) => toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="canvas-project-cover-meta" aria-hidden="true">
|
||||
<span className="canvas-project-node-count">{project.nodes.length} 节点</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("app-canvas-project-body", compact ? "is-compact" : "")}>
|
||||
<div className="canvas-project-heading-row">
|
||||
{editing && !readOnly ? (
|
||||
<Input className="canvas-project-title-input" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
|
||||
<Input
|
||||
className="canvas-project-title-input"
|
||||
value={editingTitle}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setEditingTitle(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && saveTitle()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -77,12 +94,18 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
|
||||
)}
|
||||
{editing && !readOnly ? (
|
||||
<div className="canvas-project-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<button type="button" onClick={saveTitle} aria-label="保存名称"><Check className="size-3.5" /></button>
|
||||
<button type="button" onClick={stopEditing} aria-label="取消重命名"><X className="size-3.5" /></button>
|
||||
<button type="button" onClick={saveTitle} aria-label="保存名称">
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={stopEditing} aria-label="取消重命名">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : !readOnly ? (
|
||||
<div className="canvas-project-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<button type="button" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名"><Pencil className="size-3.5" /></button>
|
||||
<button type="button" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名">
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
@@ -94,13 +117,23 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" aria-label={`${project.title} 画布操作`} title="更多操作"><MoreHorizontal className="size-4" /></button>
|
||||
<button type="button" aria-label={`${project.title} 画布操作`} title="更多操作">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="canvas-project-stats"><span>{projectName || "自由画布"}</span><span aria-hidden="true">·</span><time dateTime={project.updatedAt}>{formatProjectTime(project.updatedAt)}</time></div>
|
||||
{footer ? <div className="canvas-project-card-footer" onClick={(event) => event.stopPropagation()}>{footer}</div> : null}
|
||||
<div className="canvas-project-stats">
|
||||
<span>{projectName || "自由画布"}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time dateTime={project.updatedAt}>{formatProjectTime(project.updatedAt)}</time>
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="canvas-project-card-footer" onClick={(event) => event.stopPropagation()}>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -109,26 +142,31 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
|
||||
export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
|
||||
const syncProgress = useSyncProgressStore((state) => state.syncingProjects[project.id]);
|
||||
const isSyncing = Boolean(syncProgress && (syncProgress.phase === "uploading" || syncProgress.phase === "saving"));
|
||||
const mediaNodes = project.nodes
|
||||
.flatMap((node) => {
|
||||
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
|
||||
const url = getNodeMediaUrl(node);
|
||||
const storageKey = node.metadata?.storageKey;
|
||||
return isPreviewUrl(url) || Boolean(storageKey) ? [{ node, url, storageKey }] : [];
|
||||
});
|
||||
const mediaNodes = project.nodes.flatMap((node) => {
|
||||
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
|
||||
const url = getNodeMediaUrl(node);
|
||||
const storageKey = node.metadata?.storageKey;
|
||||
return isPreviewUrl(url) || Boolean(storageKey) ? [{ node, url, storageKey }] : [];
|
||||
});
|
||||
const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image);
|
||||
const media = preferLatestImage
|
||||
? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1]
|
||||
: imageNodes[0] || mediaNodes[0];
|
||||
const media = preferLatestImage ? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1] : imageNodes[0] || mediaNodes[0];
|
||||
|
||||
const content = media ? (
|
||||
<div className="canvas-project-media size-full">
|
||||
{media.node.type === CanvasNodeType.Video
|
||||
? <div className="canvas-project-video size-full"><Video className="size-8" aria-label={media.node.title || "项目视频"} /></div>
|
||||
: <CachedResourceImage storageKey={media.storageKey} src={media.url} alt={media.node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
|
||||
{media.node.type === CanvasNodeType.Video ? (
|
||||
<div className="canvas-project-video size-full">
|
||||
<Video className="size-8" aria-label={media.node.title || "项目视频"} />
|
||||
</div>
|
||||
) : (
|
||||
<CachedResourceImage storageKey={media.storageKey} src={media.url} alt={media.node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />
|
||||
)}
|
||||
</div>
|
||||
) : !project.nodes.length ? (
|
||||
<div className="canvas-project-empty size-full"><Plus className="canvas-project-empty-icon" /><span>空白画布</span><small>等待第一幕</small></div>
|
||||
<div className="canvas-project-empty size-full">
|
||||
<Plus className="canvas-project-empty-icon" />
|
||||
<span>空白画布</span>
|
||||
<small>等待第一幕</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="canvas-project-preview-canvas relative size-full overflow-hidden">
|
||||
{buildNodePreviewLayout(project.nodes.slice(0, 8)).map(({ node, style }) => {
|
||||
@@ -147,10 +185,7 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project
|
||||
<div className="relative size-full overflow-hidden">
|
||||
{content}
|
||||
{isSyncing && syncProgress ? (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-stone-950/75 p-3 text-center backdrop-blur-sm transition-all duration-300 pointer-events-none select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-stone-950/75 p-3 text-center backdrop-blur-sm transition-all duration-300 pointer-events-none select-none" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1.5 text-amber-400">
|
||||
<CloudUpload className="size-4 animate-bounce" />
|
||||
<span className="text-xs font-medium tracking-wide">云端同步中</span>
|
||||
@@ -230,7 +265,7 @@ function getNodePresentation(node: CanvasNodeData) {
|
||||
}
|
||||
|
||||
function isPreviewUrl(value?: string) {
|
||||
return Boolean(value && (/^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value)));
|
||||
return Boolean(value && /^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value));
|
||||
}
|
||||
|
||||
export function formatProjectTime(value: string) {
|
||||
|
||||
Reference in New Issue
Block a user