diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go
index 13453e931..51a20715a 100644
--- a/backend/internal/repository/repository.go
+++ b/backend/internal/repository/repository.go
@@ -1072,6 +1072,17 @@ func (r *Repository) DeleteAsset(userID string, id string) error {
return r.DeleteAssetAndResources(userID, id, nil, nil)
}
+func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) {
+ var assets []model.Asset
+ if limit <= 0 {
+ limit = 100
+ }
+ err := r.db.Where("status = ? AND updated_at <= ?", model.AssetVersionStatusArchived, cutoff).
+ Limit(limit).
+ Find(&assets).Error
+ return assets, err
+}
+
func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&model.Asset{}, "user_id = ?", userID).Error; err != nil {
@@ -1104,6 +1115,15 @@ func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.Canv
return &project, nil
}
+func (r *Repository) CanvasProjectTasks(userID string, projectIDs []string) ([]model.Task, error) {
+ var tasks []model.Task
+ if len(projectIDs) == 0 {
+ return tasks, nil
+ }
+ err := r.db.Where("user_id = ? AND project_id IN ?", userID, projectIDs).Find(&tasks).Error
+ return tasks, err
+}
+
func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error {
result := r.db.Model(&model.CanvasProject{}).
Where("id = ? AND user_id = ?", project.ID, project.UserID).
diff --git a/backend/internal/service/resource_deletion_worker.go b/backend/internal/service/resource_deletion_worker.go
index 1266b12b5..7e0901636 100644
--- a/backend/internal/service/resource_deletion_worker.go
+++ b/backend/internal/service/resource_deletion_worker.go
@@ -15,24 +15,51 @@ func (s *Service) startResourceDeletionWorker(ctx context.Context) {
s.runWorkerLoop(func(ctx context.Context) {
s.drainResourceDeletionJobs(32)
s.cleanupStaleAnnouncementImageDrafts()
+ s.cleanupExpiredArchivedAssets()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
- lastDraftCleanup := time.Now()
+ lastPeriodicCleanup := time.Now()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.drainResourceDeletionJobs(32)
- if time.Since(lastDraftCleanup) >= time.Hour {
+ if time.Since(lastPeriodicCleanup) >= time.Hour {
s.cleanupStaleAnnouncementImageDrafts()
- lastDraftCleanup = time.Now()
+ s.cleanupExpiredArchivedAssets()
+ lastPeriodicCleanup = time.Now()
}
}
}
})
}
+func (s *Service) cleanupExpiredArchivedAssets() {
+ policy, err := s.RuntimePolicy()
+ if err != nil {
+ return
+ }
+ retentionDays := policy.Resource.RecycleBinRetentionDays
+ if retentionDays <= 0 {
+ return
+ }
+ cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
+ expired, err := s.repo.FindExpiredArchivedAssets(cutoff, 100)
+ if err != nil {
+ log.Printf("expired archived assets query failed: %v", err)
+ return
+ }
+ for _, asset := range expired {
+ if err := s.repo.DeleteAsset(asset.UserID, asset.ID); err != nil {
+ log.Printf("expired archived asset delete failed for %s: %v", asset.ID, err)
+ }
+ }
+ if len(expired) > 0 {
+ log.Printf("recycle bin cleanup: deleted %d expired assets (retention: %d days)", len(expired), retentionDays)
+ }
+}
+
func (s *Service) drainResourceDeletionJobs(limit int) {
owner := s.workerID
if owner == "" {
diff --git a/backend/internal/service/runtime_policy.go b/backend/internal/service/runtime_policy.go
index 701978f31..2340d4cce 100644
--- a/backend/internal/service/runtime_policy.go
+++ b/backend/internal/service/runtime_policy.go
@@ -25,18 +25,19 @@ const (
)
type RuntimeResourcePolicy struct {
- ResourceUploadMB int64 `json:"resourceUploadMB"`
- SessionUploadMB int64 `json:"sessionUploadMB"`
- GeneratedFileMB int64 `json:"generatedFileMB"`
- DailyUploadMB int64 `json:"dailyUploadMB"`
- StoredFileGB int64 `json:"storedFileGB"`
- StructuredDataMB int64 `json:"structuredDataMB"`
- TaskDataGB int64 `json:"taskDataGB"`
- AssetCount int64 `json:"assetCount"`
- CanvasCount int64 `json:"canvasCount"`
- SessionCount int64 `json:"sessionCount"`
- TaskCount int64 `json:"taskCount"`
- APICallLogCount int64 `json:"apiCallLogCount"`
+ ResourceUploadMB int64 `json:"resourceUploadMB"`
+ SessionUploadMB int64 `json:"sessionUploadMB"`
+ GeneratedFileMB int64 `json:"generatedFileMB"`
+ DailyUploadMB int64 `json:"dailyUploadMB"`
+ StoredFileGB int64 `json:"storedFileGB"`
+ StructuredDataMB int64 `json:"structuredDataMB"`
+ TaskDataGB int64 `json:"taskDataGB"`
+ AssetCount int64 `json:"assetCount"`
+ CanvasCount int64 `json:"canvasCount"`
+ SessionCount int64 `json:"sessionCount"`
+ TaskCount int64 `json:"taskCount"`
+ APICallLogCount int64 `json:"apiCallLogCount"`
+ RecycleBinRetentionDays int `json:"recycleBinRetentionDays"`
}
type RuntimeTaskPolicy struct {
@@ -90,26 +91,28 @@ type PublicRuntimePolicySetting struct {
}
type PublicRuntimeLimits struct {
- ActiveTaskLimit int `json:"activeTaskLimit"`
- ResourceUploadMB int64 `json:"resourceUploadMB"`
- SessionUploadMB int64 `json:"sessionUploadMB"`
+ ActiveTaskLimit int `json:"activeTaskLimit"`
+ ResourceUploadMB int64 `json:"resourceUploadMB"`
+ SessionUploadMB int64 `json:"sessionUploadMB"`
+ RecycleBinRetentionDays int `json:"recycleBinRetentionDays"`
}
func defaultRuntimePolicy() RuntimePolicySetting {
return RuntimePolicySetting{
Resource: RuntimeResourcePolicy{
- ResourceUploadMB: 50,
- SessionUploadMB: 32,
- GeneratedFileMB: 64,
- DailyUploadMB: 200,
- StoredFileGB: 2,
- StructuredDataMB: 256,
- TaskDataGB: 1,
- AssetCount: 2_000,
- CanvasCount: 1_000,
- SessionCount: 1_000,
- TaskCount: 20_000,
- APICallLogCount: 100_000,
+ ResourceUploadMB: 50,
+ SessionUploadMB: 32,
+ GeneratedFileMB: 64,
+ DailyUploadMB: 200,
+ StoredFileGB: 2,
+ StructuredDataMB: 256,
+ TaskDataGB: 1,
+ AssetCount: 2_000,
+ CanvasCount: 1_000,
+ SessionCount: 1_000,
+ TaskCount: 20_000,
+ APICallLogCount: 100_000,
+ RecycleBinRetentionDays: 30,
},
Task: RuntimeTaskPolicy{
WorkerConcurrency: effectiveChannelConcurrencyLimit(envInt("CANVAS_WORKER_CONCURRENCY", taskWorkerConcurrency)),
@@ -155,6 +158,7 @@ func selfUseRuntimePolicy() RuntimePolicySetting {
DailyUploadMB: maxRuntimeDataMB, StoredFileGB: maxRuntimeStorageGB, StructuredDataMB: maxRuntimeDataMB,
TaskDataGB: maxRuntimeStorageGB, AssetCount: maxRuntimeCount, CanvasCount: maxRuntimeCount,
SessionCount: maxRuntimeCount, TaskCount: maxRuntimeCount, APICallLogCount: maxRuntimeCount,
+ RecycleBinRetentionDays: 365,
}
value.Task = RuntimeTaskPolicy{
WorkerConcurrency: maxRuntimeConcurrency, ChannelConcurrency: maxRuntimeConcurrency, ActiveTaskLimit: maxRuntimeConcurrency,
@@ -194,7 +198,8 @@ func (s *Service) PublicRuntimeLimits() (*PublicRuntimeLimits, error) {
}
return &PublicRuntimeLimits{
ActiveTaskLimit: policy.Task.ActiveTaskLimit, ResourceUploadMB: policy.Resource.ResourceUploadMB,
- SessionUploadMB: policy.Resource.SessionUploadMB,
+ SessionUploadMB: policy.Resource.SessionUploadMB,
+ RecycleBinRetentionDays: policy.Resource.RecycleBinRetentionDays,
}, nil
}
@@ -271,7 +276,7 @@ func (s *Service) readRuntimePolicy() (*model.SystemSetting, RuntimePolicySettin
if err != nil {
return nil, RuntimePolicySetting{}, err
}
- value := RuntimePolicySetting{}
+ value := defaultRuntimePolicy()
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
return nil, RuntimePolicySetting{}, errors.New("资源与请求策略配置格式无效")
}
@@ -312,6 +317,9 @@ func validateRuntimePolicy(value RuntimePolicySetting) error {
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 的整数", label, maxRuntimeCount))
}
}
+ if resource.RecycleBinRetentionDays < 0 || resource.RecycleBinRetentionDays > 365 {
+ return BadAuthRequest("回收站保留天数必须是 0-365 的整数 (0 表示不自动清理)")
+ }
task := value.Task
for label, item := range map[string]int{
"Worker 并发数": task.WorkerConcurrency, "全局渠道并发数": task.ChannelConcurrency, "活动任务上限": task.ActiveTaskLimit,
diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx
index 727f6775a..0f0d402b3 100644
--- a/web/src/components/assets/asset-library-picker-modal.tsx
+++ b/web/src/components/assets/asset-library-picker-modal.tsx
@@ -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: , label: 本地素材{localItems.length} },
- ...(hasPluginSource ? [{ key: "plugin", icon: , label: 插件来源{pluginItems.length} }] : []),
+ {
+ key: "local",
+ icon: ,
+ label: (
+
+ 本地素材
+ {localItems.filter((i) => i.category !== "archived").length}
+
+ ),
+ },
+ ...(hasPluginSource
+ ? [
+ {
+ key: "plugin",
+ icon: ,
+ label: (
+
+ 插件来源
+ {pluginItems.length}
+
+ ),
+ },
+ ]
+ : []),
];
- 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({
}}
>
-
- 已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材
+
+
+ 已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材
+
{loading ? (
-
正在读取素材素材会按页加载,不会一次下载整个项目库。
- ) : visibleItems.length ? visibleItems.map((item) => (
-
toggle(item)} />
- )) : (
- {emptyTitle}{activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}
+
+
+ 正在读取素材
+ 素材会按页加载,不会一次下载整个项目库。
+
+ ) : visibleItems.length ? (
+ visibleItems.map((item) => toggle(item)} />)
+ ) : (
+
+
+ {isRecycleBin ? "回收站是空的" : emptyTitle}
+ {isRecycleBin ? "删除画布或手动归档的素材会暂存到这里,可在需要时还原。" : activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}
+
)}
{pagination ?
: null}
@@ -307,17 +443,57 @@ export function AssetLibraryPickerModal({
void handleUpload(event.target.files)} />
>
- ) : footerNote ?
{footerNote} :
}
- {error ?
{error} : null}
+ ) : footerNote ? (
+
{footerNote}
+ ) : (
+
+ )}
+ {error ? (
+
+ {error}
+
+ ) : null}
- {onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ?
} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel} : null}
-
-
} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
- {confirmLabel(selectedIds.length)}
-
+ {isRecycleBin ? (
+ <>
+
+
+
+
+
+
+
+
} disabled={working || !selectedIds.length} loading={working} onClick={handleRestoreSelected}>
+ 还原已选素材{selectedIds.length ? `(${selectedIds.length})` : ""}
+
+ >
+ ) : (
+ <>
+ {onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? (
+
} disabled={working} onClick={() => void runFolderAction()}>
+ {folderActionLabel}
+
+ ) : null}
+
+
} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
+ {confirmLabel(selectedIds.length)}
+
+ >
+ )}
@@ -331,14 +507,32 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
} />}
+ {item.imageUrl || item.imageStorageKey ? (
+ {kindIcon(item.kindLabel)}}
+ />
+ ) : (
+ {kindIcon(item.kindLabel)}} />
+ )}
- {item.kindLabel}
-
+ {item.kindLabel}
+
+
+
+
{item.disabledReason ? {item.disabledReason} : null}
- {item.title || "未命名素材"}{item.description ? {item.description} : null}
+
+ {item.title || "未命名素材"}
+ {item.description ? {item.description} : null}
+
);
@@ -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 = 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 (
-
-
- {renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
-
- );
- });
+ return folders
+ .filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id))
+ .map((folder) => {
+ const nextVisited = new Set(visited).add(folder.id);
+ return (
+
+
+ {renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
+
+ );
+ });
}
function kindIcon(label: string): ReactNode {
diff --git a/web/src/components/cached-resource-image.tsx b/web/src/components/cached-resource-image.tsx
index fe0213322..f30b29853 100644
--- a/web/src/components/cached-resource-image.tsx
+++ b/web/src/components/cached-resource-image.tsx
@@ -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) => {
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);
diff --git a/web/src/components/canvas/asset-picker-modal.tsx b/web/src/components/canvas/asset-picker-modal.tsx
index 315497a01..173e5e108 100644
--- a/web/src/components/canvas/asset-picker-modal.tsx
+++ b/web/src/components/canvas/asset-picker-modal.tsx
@@ -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; 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;
+ 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 = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
+const categoryLabels: Record = { 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(() => [
- ...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(
+ () => [
+ ...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 (
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(() => {
+ 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 (
+
+
+
+ 画布创建与变更历史时间线
+
+ {deletedProjects.length > 0 ? (
+
+ }>
+ 清理删除记录
+
+
+ ) : null}
+
+ }
+ 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" },
+ }}
+ >
+
+ {/* 搜索与筛选 */}
+
+
} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索画布名称..." className="text-xs" />
+
+
+
+
+
+
+
+ {/* 时间线列表 */}
+ {filteredItems.length === 0 ? (
+
+
+
+ ) : (
+
+ {filteredItems.map((item) => {
+ const isDeleted = item.isDeleted;
+ return (
+
+ {/* 时间轴圆点 */}
+
+
+
+
+
+
+ {isDeleted ? (
+
+ {item.title}
+
+ ) : (
+
+ )}
+ {isDeleted ? (
+
+ 已删除
+
+ ) : (
+
+ 活跃中
+
+ )}
+
+
+
+
+
+ 创建时间:{formatTimelineDate(item.createdAt)}
+
+ {isDeleted && item.deletedAt ? (
+
+
+ 删除时间:{formatTimelineDate(item.deletedAt)}
+
+ ) : (
+
+
+ 最近更新:{formatTimelineDate(item.updatedAt)}
+
+ )}
+
包含 {item.nodeCount} 个节点
+
+
+
+
+ {!isDeleted ? (
+ }
+ onClick={() => openProject(item.id)}
+ title="打开画布"
+ />
+ ) : (
+
+ } onClick={() => removeDeletedItem(item.id)} />
+
+ )}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+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")}`;
+}
diff --git a/web/src/components/canvas/canvas-project-asset-modal.tsx b/web/src/components/canvas/canvas-project-asset-modal.tsx
index d471bcb60..db39f090c 100644
--- a/web/src/components/canvas/canvas-project-asset-modal.tsx
+++ b/web/src/components/canvas/canvas-project-asset-modal.tsx
@@ -13,7 +13,23 @@ import { useAssetStore, type Asset } from "@/stores/use-asset-store";
const categoryLabels: Record = { 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; onInsertFolder?: (folderId: string) => Promise | 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;
+ onInsertFolder?: (folderId: string) => Promise | void;
+}) {
const mediaAssets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const items = useMemo(() => {
@@ -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(() => 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(
+ () =>
+ 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(() => [...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,
};
}
diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx
index df63add13..4a2a6b185 100644
--- a/web/src/components/canvas/canvas-project-card.tsx
+++ b/web/src/components/canvas/canvas-project-card.tsx
@@ -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 ;
+ return (
+
+ );
}
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
>
- {!compact && !readOnly ? event.stopPropagation()}> toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /> : null}
- {project.nodes.length} 节点
+ {!compact && !readOnly ? (
+ event.stopPropagation()}>
+ toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} />
+
+ ) : null}
+
+ {project.nodes.length} 节点
+
- {projectName || "自由画布"}·
- {footer ? event.stopPropagation()}>{footer}
: null}
+
+ {projectName || "自由画布"}
+ ·
+
+
+ {footer ? (
+ event.stopPropagation()}>
+ {footer}
+
+ ) : null}
);
@@ -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 ? (
- {media.node.type === CanvasNodeType.Video
- ?
- :
}
+ {media.node.type === CanvasNodeType.Video ? (
+
+
+
+ ) : (
+
+ )}
) : !project.nodes.length ? (
-
+
) : (
{buildNodePreviewLayout(project.nodes.slice(0, 8)).map(({ node, style }) => {
@@ -147,10 +185,7 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project
{content}
{isSyncing && syncProgress ? (
-
e.stopPropagation()}
- >
+
e.stopPropagation()}>
云端同步中
@@ -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) {
diff --git a/web/src/pages/admin/settings/runtime-policy-settings-page.tsx b/web/src/pages/admin/settings/runtime-policy-settings-page.tsx
index e437c2a78..532006d69 100644
--- a/web/src/pages/admin/settings/runtime-policy-settings-page.tsx
+++ b/web/src/pages/admin/settings/runtime-policy-settings-page.tsx
@@ -11,7 +11,7 @@ import { AdminStatTile, AdminStatusBadge, SettingsSectionCard } from "../compone
type PolicyGroup = "resource" | "task" | "request";
type RuntimePolicyDraft = Pick
;
-type PolicyField = { group: PolicyGroup; name: string; label: string; extra: string; unit: string; max: number };
+type PolicyField = { group: PolicyGroup; name: string; label: string; extra: string; unit: string; min?: number; max: number };
type PolicySectionDefinition = {
id: string;
icon: ReactNode;
@@ -35,6 +35,7 @@ const resourceFields: PolicyField[] = [
{ group: "resource", name: "sessionCount", label: "Agent 会话数量", extra: "单账号可保存的 Agent 会话数量。", unit: "个", max: 999_999_999 },
{ group: "resource", name: "taskCount", label: "任务历史数量", extra: "单账号保留的任务历史记录数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "apiCallLogCount", label: "请求日志数量", extra: "单账号保留的上游请求日志数。", unit: "条", max: 999_999_999 },
+ { group: "resource", name: "recycleBinRetentionDays", label: "回收站自动清理时间", extra: "素材移入回收站后自动彻底删除的天数,0 表示不自动清理。", unit: "天", min: 0, max: 365 },
];
const concurrencyFields: PolicyField[] = [
@@ -472,6 +473,7 @@ function PolicySection({ icon, title, description, fields, status }: PolicySecti
{fields.map((field) => {
const inputId = `admin-runtime-policy-${field.group}-${field.name}`;
+ const min = field.min ?? 1;
return (
@@ -480,10 +482,10 @@ function PolicySection({ icon, title, description, fields, status }: PolicySecti
name={[field.group, field.name]}
rules={[
{ required: true, message: `请填写${field.label}` },
- { type: "number", min: 1, max: field.max, message: `${field.label}必须是 1-${field.max} 的整数` },
+ { type: "number", min, max: field.max, message: `${field.label}必须是 ${min}-${field.max} 的整数` },
]}
>
-
+
{field.unit}
@@ -517,7 +519,8 @@ function parseRuntimePolicySetting(value: RuntimePolicySetting): RuntimePolicySe
if (!value || typeof value !== "object") throw new Error("服务端返回的资源与策略格式无效");
for (const field of allPolicyFields) {
const fieldValue = readPolicyValue(value, field);
- if (!Number.isInteger(fieldValue) || fieldValue < 1 || fieldValue > field.max) throw new Error(`服务端返回的“${field.label}”无效`);
+ const min = field.min ?? 1;
+ if (!Number.isInteger(fieldValue) || fieldValue < min || fieldValue > field.max) throw new Error(`服务端返回的“${field.label}”无效`);
}
if (typeof value.configured !== "boolean") throw new Error("服务端未返回有效的配置来源状态");
return value;
diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx
index b5e69d40b..b0bce2671 100644
--- a/web/src/pages/assets/index.tsx
+++ b/web/src/pages/assets/index.tsx
@@ -1,7 +1,7 @@
-import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
+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 { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
-import { App, Button, Drawer, Dropdown, Form, Input, Modal, Progress, Select, Space, Tag, Typography } from "antd";
+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";
@@ -10,6 +10,7 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { saveAs } from "file-saver";
+import { cn } from "@/lib/utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category";
@@ -17,10 +18,11 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
import { uploadMediaFile } from "@/services/file-storage";
-import { useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
+import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
import { exportAssets, readAssetPackage } from "./asset-transfer";
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
-import { deleteAssetWithRemoteSync } from "@/services/user-data-sync";
+import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
+import { useUserStore } from "@/stores/use-user-store";
type LibraryAsset = Exclude;
@@ -74,6 +76,8 @@ export default function AssetsPage() {
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
+ const retentionDays = useUserStore((state) => state.runtimeLimits.recycleBinRetentionDays ?? 30);
+ const [viewMode, setViewMode] = useState<"library" | "trash">("library");
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
@@ -83,8 +87,10 @@ export default function AssetsPage() {
const [isAssetOpen, setIsAssetOpen] = useState(false);
const [previewAsset, setPreviewAsset] = useState(null);
const [deletingAsset, setDeletingAsset] = useState(null);
+ const [archivingAsset, setArchivingAsset] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
+ const [batchArchiveOpen, setBatchArchiveOpen] = useState(false);
const [formKind, setFormKind] = useState("text");
const [imageDraft, setImageDraft] = useState(null);
@@ -95,11 +101,15 @@ export default function AssetsPage() {
const title = Form.useWatch("title", form) || "";
const tags = Form.useWatch("tags", form) || [];
const content = Form.useWatch("content", form) || "";
- const validAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
+
+ 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" ? validAssets.length : validAssets.filter((asset) => asset.kind === option.value).length])), [validAssets]);
- const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => (asset.category || "other") === option.value).length])), [validAssets]);
- const canCreateAsset = !keyword.trim() && kindFilter === "all" && categoryFilter === "all";
+ 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();
@@ -277,11 +287,79 @@ export default function AssetsPage() {
}
};
+ const restoreAsset = async (asset: LibraryAsset) => {
+ updateAsset(asset.id, { status: "confirmed" });
+ await flushAssetStorePersistence();
+ try {
+ await saveRemoteUserDataNow();
+ message.success(`已还原素材「${asset.title}」`);
+ } catch {
+ message.warning("已在本地还原,稍后自动同步至云端");
+ }
+ };
+
+ const batchRestore = async () => {
+ if (!selectedIds.length) return;
+ for (const id of selectedIds) {
+ updateAsset(id, { status: "confirmed" });
+ }
+ const count = selectedIds.length;
+ setSelectedIds([]);
+ await flushAssetStorePersistence();
+ try {
+ await saveRemoteUserDataNow();
+ message.success(`已还原 ${count} 个素材`);
+ } catch {
+ message.warning("已在本地还原,稍后自动同步至云端");
+ }
+ };
+
+ const archiveAsset = async (asset: LibraryAsset) => {
+ updateAsset(asset.id, { status: "archived" });
+ await flushAssetStorePersistence();
+ try {
+ await saveRemoteUserDataNow();
+ message.success(`已将「${asset.title}」移入回收站`);
+ } catch {
+ message.warning("已移入回收站,稍后自动同步至云端");
+ }
+ };
+
+ const batchArchive = async () => {
+ if (!selectedIds.length) return;
+ for (const id of selectedIds) {
+ updateAsset(id, { status: "archived" });
+ }
+ const count = selectedIds.length;
+ setSelectedIds([]);
+ await flushAssetStorePersistence();
+ try {
+ await saveRemoteUserDataNow();
+ message.success(`已将 ${count} 个素材移入回收站`);
+ } catch {
+ message.warning("已移入回收站,稍后自动同步至云端");
+ }
+ };
+
+ const emptyTrash = async () => {
+ const count = trashAssets.length;
+ if (!count) return;
+ try {
+ for (const asset of trashAssets) {
+ await deleteAssetWithRemoteSync(asset.id);
+ }
+ setSelectedIds([]);
+ message.success(`已彻底清空回收站 ${count} 个素材`);
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "清空回收站失败");
+ }
+ };
+
const confirmDelete = async () => {
if (!deletingAsset) return;
try {
await deleteAssetWithRemoteSync(deletingAsset.id);
- message.success("素材已删除");
+ message.success("素材已彻底删除");
setDeletingAsset(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "素材删除失败");
@@ -297,7 +375,7 @@ export default function AssetsPage() {
if (!selectedAssets.length) return;
try {
for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id);
- message.success(`已删除 ${selectedAssets.length} 个素材`);
+ message.success(`已彻底删除 ${selectedAssets.length} 个素材`);
setSelectedIds([]);
setBatchDeleteOpen(false);
} catch (error) {
@@ -311,18 +389,31 @@ export default function AssetsPage() {
{validAssets.length} 个素材}
actions={(
-
} onClick={openCreate}>新增素材
-
} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库
-
} onClick={() => void exportAllAssets()} />
-
, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon:
, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
-
} />
-
+ {viewMode === "trash" ? (
+ <>
+ {trashAssets.length > 0 ? (
+
void emptyTrash()} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
+ }>清空回收站
+
+ ) : null}
+
} onClick={() => { setViewMode("library"); setPage(1); setSelectedIds([]); }}>返回素材库
+ >
+ ) : (
+ <>
+
} onClick={openCreate}>新增素材
+
} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库
+
} onClick={() => void exportAllAssets()} />
+
, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon:
, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
+
} />
+
+ >
+ )}
@@ -336,15 +427,90 @@ export default function AssetsPage() {
+ {viewMode === "trash" ? (
+
+
+
+
+ {retentionDays > 0
+ ? `回收站内的素材将在 ${retentionDays} 天后自动彻底清除。您可以随时还原素材,或手动彻底删除释放空间。`
+ : "回收站内的素材当前设置为永久保留,您可以随时还原素材或手动彻底清除。"}
+
+
+
+ ) : null}
{selectedAssets.length ? (
- setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))} onClear={() => setSelectedIds([])} onExport={() => void exportSelectedAssets()} onDelete={() => setBatchDeleteOpen(true)} />
+ setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))}
+ onClear={() => setSelectedIds([])}
+ onExport={() => void exportSelectedAssets()}
+ onRestore={() => void batchRestore()}
+ onArchive={() => setBatchArchiveOpen(true)}
+ onDelete={() => setBatchDeleteOpen(true)}
+ />
) : null}
{validAssets.length === 0 ? (
- assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
+ viewMode === "trash" ? (
+
+ ) : (
+ assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
+ )
) : (
<>
{filteredAssets.length === 0 ? (
@@ -357,7 +523,21 @@ export default function AssetsPage() {
文本、图片、音视频或模型
: null}
{visibleAssets.map((asset) => (
- setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
+ setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))}
+ onOpen={() => setPreviewAsset(asset)}
+ onEdit={() => openEdit(asset)}
+ onCopy={copyAssetText}
+ onDownload={downloadImage}
+ onRestore={() => void restoreAsset(asset)}
+ onArchive={() => setArchivingAsset(asset)}
+ onDelete={() => setDeletingAsset(asset)}
+ />
))}
)}
@@ -495,34 +675,100 @@ export default function AssetsPage() {
void importAssetZip(event.target.files?.[0])} />
{ void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} />
- setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
- 确定删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除;若仍被画布、任务或其他素材占用,本次删除将被阻止。
+ setArchivingAsset(null)} onOk={() => { if (archivingAsset) { void archiveAsset(archivingAsset); setArchivingAsset(null); } }} okText="移入回收站" cancelText="取消">
+ 确定将「{archivingAsset?.title}」移入回收站吗?移入后不会出现在正常素材库中,可在回收站随时还原。
- setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
- 确定删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除;仍被画布、任务或其他素材占用的素材会保留并提示具体来源。
+ setBatchArchiveOpen(false)} onOk={() => { void batchArchive(); setBatchArchiveOpen(false); }} okText="移入回收站" cancelText="取消">
+ 确定将已选择的 {selectedAssets.length} 个素材移入回收站吗?移入后可随时在回收站批量还原。
+
+ setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="彻底删除" okButtonProps={{ danger: true }} cancelText="取消">
+ 确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。
+
+ setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="彻底删除" okButtonProps={{ danger: true }} cancelText="取消">
+ 确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。
>
);
}
-function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; onEdit: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void; onDelete: () => void }) {
+function formatExpirationHint(updatedAt: string, retentionDays: number) {
+ if (!retentionDays || retentionDays <= 0) return "永久保留";
+ const updatedTime = new Date(updatedAt).getTime();
+ if (!Number.isFinite(updatedTime)) return `保留 ${retentionDays} 天`;
+ const expireTime = updatedTime + retentionDays * 24 * 60 * 60 * 1000;
+ const remainingMs = expireTime - Date.now();
+ const remainingDays = Math.ceil(remainingMs / (24 * 60 * 60 * 1000));
+ if (remainingDays <= 0) return "即将彻底清除";
+ if (remainingDays === 1) return "剩余 1 天过期";
+ return `剩余 ${remainingDays} 天过期`;
+}
+
+function formatExpirationDate(updatedAt: string, retentionDays: number) {
+ if (!retentionDays || retentionDays <= 0) return "永久保留";
+ const updatedTime = new Date(updatedAt).getTime();
+ if (!Number.isFinite(updatedTime)) return "";
+ const expireDate = new Date(updatedTime + retentionDays * 24 * 60 * 60 * 1000);
+ return `预计于 ${expireDate.getFullYear()}-${String(expireDate.getMonth() + 1).padStart(2, "0")}-${String(expireDate.getDate()).padStart(2, "0")} 彻底清除`;
+}
+
+function AssetCard({
+ asset,
+ selected,
+ isTrash = false,
+ retentionDays = 30,
+ onSelect,
+ onOpen,
+ onEdit,
+ onCopy,
+ onDownload,
+ onRestore,
+ onArchive,
+ onDelete,
+}: {
+ asset: LibraryAsset;
+ selected: boolean;
+ isTrash?: boolean;
+ retentionDays?: number;
+ onSelect: (selected: boolean) => void;
+ onOpen: () => void;
+ onEdit: () => void;
+ onCopy: (asset: LibraryAsset) => void;
+ onDownload: (asset: LibraryAsset) => void;
+ onRestore?: () => void;
+ onArchive?: () => void;
+ onDelete: () => void;
+}) {
const summary = assetSummary(asset);
- const menuItems: MenuProps["items"] = [
- ...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: , label: "编辑", onClick: onEdit }] : []),
- ...(asset.kind === "text" ? [{ key: "copy", icon: , label: "复制文本", onClick: () => void onCopy(asset) }] : []),
- ...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: , label: "下载", onClick: () => onDownload(asset) }] : []),
- { type: "divider" as const },
- { key: "delete", danger: true, icon: , label: "删除", onClick: onDelete },
- ];
+ const menuItems: MenuProps["items"] = isTrash
+ ? [
+ { key: "restore", icon: , label: "还原到素材库", onClick: onRestore },
+ { type: "divider" as const },
+ { key: "delete", danger: true, icon: , label: "彻底删除", onClick: onDelete },
+ ]
+ : [
+ ...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: , label: "编辑", onClick: onEdit }] : []),
+ ...(asset.kind === "text" ? [{ key: "copy", icon: , label: "复制文本", onClick: () => void onCopy(asset) }] : []),
+ ...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: , label: "下载", onClick: () => onDownload(asset) }] : []),
+ { type: "divider" as const },
+ { key: "archive", icon: , label: "移入回收站", onClick: onArchive },
+ { key: "delete", danger: true, icon: , label: "彻底删除", onClick: onDelete },
+ ];
return (
-
+
+ setHistoryOpen(false)} />
);
}
diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts
index 1d8bcadd3..c1a5a1ab4 100644
--- a/web/src/pages/canvas/use-canvas-render-model.ts
+++ b/web/src/pages/canvas/use-canvas-render-model.ts
@@ -122,8 +122,11 @@ export function useCanvasRenderModel({
return [...frames, ...regular];
}, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
- const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
- const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
+ const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image" && asset.status !== "archived"), [assets]);
+ const canvasImageNodes = useMemo(
+ () => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)),
+ [collapsedBatchChildIds, nodeById, nodes],
+ );
const semanticNodesRef = useRef(nodes);
const semanticNodes = useMemo(() => {
const previous = semanticNodesRef.current;
@@ -164,13 +167,17 @@ export function useCanvasRenderModel({
const bottom = Math.max(...selectedNodes.map((node) => node.position.y + node.height));
return { left, top, width: right - left, height: bottom - top, count: selectedNodes.length };
}, [nodes, renderHiddenNodeIds, selectedNodeIds]);
- const selectedVideoNodes = useMemo(() => nodes
- .filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(node.metadata?.content) && !renderHiddenNodeIds.has(node.id))
- .sort((a, b) => {
- const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
- const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
- return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
- }), [nodes, renderHiddenNodeIds, selectedNodeIds]);
+ const selectedVideoNodes = useMemo(
+ () =>
+ nodes
+ .filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(node.metadata?.content) && !renderHiddenNodeIds.has(node.id))
+ .sort((a, b) => {
+ const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
+ const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
+ return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
+ }),
+ [nodes, renderHiddenNodeIds, selectedNodeIds],
+ );
const batchChildCountById = useMemo(() => {
const map = new Map();
nodes.forEach((node) => {
@@ -216,25 +223,29 @@ export function useCanvasRenderModel({
});
return { nodeIds, connectionIds };
}, [activeNodeId, connections]);
- const displayConnections = useMemo(() => connections.flatMap((connection) => {
- if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return [];
- const fromNode = nodeById.get(connection.fromNodeId);
- const toNode = nodeById.get(connection.toNodeId);
- if (!fromNode || !toNode) return [];
- const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
- const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
- const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
- const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
- if (displayFrom.id === displayTo.id) return [];
- const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
- const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
- const connectionLeft = Math.min(from.position.x, to.position.x);
- const connectionTop = Math.min(from.position.y, to.position.y);
- const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
- const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
- if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
- return [{ connection, from, to }];
- }), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
+ const displayConnections = useMemo(
+ () =>
+ connections.flatMap((connection) => {
+ if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return [];
+ const fromNode = nodeById.get(connection.fromNodeId);
+ const toNode = nodeById.get(connection.toNodeId);
+ if (!fromNode || !toNode) return [];
+ const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
+ const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
+ const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
+ const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
+ if (displayFrom.id === displayTo.id) return [];
+ const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
+ const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
+ const connectionLeft = Math.min(from.position.x, to.position.x);
+ const connectionTop = Math.min(from.position.y, to.position.y);
+ const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
+ const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
+ if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
+ return [{ connection, from, to }];
+ }),
+ [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds],
+ );
const configInputsById = useMemo(() => {
const map = new Map();
diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts
index 068e5b23e..cc5408881 100644
--- a/web/src/services/api/auth.ts
+++ b/web/src/services/api/auth.ts
@@ -318,6 +318,7 @@ export type RuntimeResourcePolicy = {
sessionCount: number;
taskCount: number;
apiCallLogCount: number;
+ recycleBinRetentionDays?: number;
};
export type RuntimeTaskPolicy = {
diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts
index 669a038e8..38b391992 100644
--- a/web/src/services/user-data-sync.ts
+++ b/web/src/services/user-data-sync.ts
@@ -7,6 +7,7 @@ import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-st
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
+import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
let activeRemoteUserId = "";
type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed";
@@ -128,6 +129,12 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
if (!projectIds.length) return;
await withRemoteUserDataSyncExclusive(async () => {
if (activeRemoteUserId) requireRemoteUserDataBaseline();
+ const currentProjects = useCanvasStore.getState().projects;
+ const deletedProjectObjects = currentProjects.filter((p) => projectIds.includes(p.id));
+ if (deletedProjectObjects.length > 0) {
+ useCanvasHistoryStore.getState().recordDeletedProjects(deletedProjectObjects);
+ }
+
for (const id of projectIds) {
if (activeRemoteUserId) {
await deleteRemoteCanvasProject(id);
@@ -137,7 +144,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
await flushCanvasStorePersistence();
}
- // 清理仅属于被删除画布的自动生成素材
+ // 将属于被删除画布的所有媒体节点安全归档至素材库回收站 (status = "archived")
const currentAssets = useAssetStore.getState().assets;
const remainingProjects = useCanvasStore.getState().projects;
const activeAssetIds = new Set();
@@ -146,24 +153,118 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
if (node.metadata?.assetId) activeAssetIds.add(node.metadata.assetId);
}
}
- const assetsToRemove = currentAssets.filter((asset) => {
+ let assetChanged = false;
+ // 1. 已存在的关联素材标记为 archived
+ const assetsToArchive = currentAssets.filter((asset) => {
const canvasId = asset.metadata?.canvasId as string | undefined;
- const source = asset.metadata?.source as string | undefined;
- return canvasId && projectIds.includes(canvasId) && source === "canvas-generation" && !activeAssetIds.has(asset.id);
+ return canvasId && projectIds.includes(canvasId) && !activeAssetIds.has(asset.id) && asset.status !== "archived";
});
- for (const asset of assetsToRemove) {
- if (activeRemoteUserId) {
- try {
- await deleteRemoteAsset(asset.id);
- acknowledgedAssets.delete(asset.id);
- } catch {
- // 忽略远端删除失败,继续本地清理
+ for (const asset of assetsToArchive) {
+ useAssetStore.getState().updateAsset(asset.id, { status: "archived" });
+ assetChanged = true;
+ }
+
+ // 2. 对于画布中尚未入库的媒体节点,直接归档为回收站素材
+ for (const project of deletedProjectObjects) {
+ for (const node of project.nodes || []) {
+ const isMedia = node.type === "image" || node.type === "video" || node.type === "audio";
+ const content = typeof node.metadata?.content === "string" ? node.metadata.content : "";
+ const storageKey = typeof node.metadata?.storageKey === "string" ? node.metadata.storageKey : undefined;
+ if (!isMedia || (!content && !storageKey)) continue;
+
+ const existingAsset = node.metadata?.assetId ? currentAssets.find((a) => a.id === node.metadata?.assetId) : undefined;
+ if (existingAsset) {
+ if (existingAsset.status !== "archived") {
+ useAssetStore.getState().updateAsset(existingAsset.id, { status: "archived" });
+ assetChanged = true;
+ }
+ continue;
+ }
+
+ const title = node.title || `${project.title} - ${node.type === "image" ? "图片" : node.type === "video" ? "视频" : "音频"}`;
+ const prompt = typeof node.metadata?.prompt === "string" ? node.metadata.prompt : "";
+ if (node.type === "image") {
+ useAssetStore.getState().addAsset({
+ kind: "image",
+ title,
+ coverUrl: content || "",
+ tags: prompt ? [prompt.slice(0, 16)] : ["画布生成"],
+ category: "other",
+ status: "archived",
+ source: `已删除画布:${project.title}`,
+ data: {
+ dataUrl: content || "",
+ storageKey,
+ width: Number(node.width) || 1024,
+ height: Number(node.height) || 1024,
+ bytes: Number(node.metadata?.bytes) || 0,
+ mimeType: (node.metadata?.mimeType as string) || "image/png",
+ },
+ metadata: {
+ canvasId: project.id,
+ sourceNodeId: node.id,
+ },
+ });
+ assetChanged = true;
+ } else if (node.type === "video") {
+ useAssetStore.getState().addAsset({
+ kind: "video",
+ title,
+ coverUrl: content || "",
+ tags: prompt ? [prompt.slice(0, 16)] : ["画布视频"],
+ category: "other",
+ status: "archived",
+ source: `已删除画布:${project.title}`,
+ data: {
+ url: content || "",
+ storageKey,
+ width: Number(node.width) || 1280,
+ height: Number(node.height) || 720,
+ durationMs: Number(node.metadata?.durationMs) || 0,
+ bytes: Number(node.metadata?.bytes) || 0,
+ mimeType: (node.metadata?.mimeType as string) || "video/mp4",
+ },
+ metadata: {
+ canvasId: project.id,
+ sourceNodeId: node.id,
+ },
+ });
+ assetChanged = true;
+ } else if (node.type === "audio") {
+ useAssetStore.getState().addAsset({
+ kind: "audio",
+ title,
+ coverUrl: "",
+ tags: ["画布音频"],
+ category: "other",
+ status: "archived",
+ source: `已删除画布:${project.title}`,
+ data: {
+ url: content || "",
+ storageKey,
+ durationMs: Number(node.metadata?.durationMs) || 0,
+ bytes: Number(node.metadata?.bytes) || 0,
+ mimeType: (node.metadata?.mimeType as string) || "audio/mpeg",
+ },
+ metadata: {
+ canvasId: project.id,
+ sourceNodeId: node.id,
+ },
+ });
+ assetChanged = true;
}
}
- await useAssetStore.getState().removeAsset(asset.id);
}
- if (assetsToRemove.length > 0) {
+
+ if (assetChanged) {
await flushAssetStorePersistence();
+ if (activeRemoteUserId) {
+ try {
+ await drainRemoteUserDataChanges();
+ } catch (syncErr) {
+ console.warn("回收站素材云端同步警告:", syncErr);
+ }
+ }
}
});
}
@@ -272,11 +373,7 @@ function collectLocalMediaKeys(value: unknown, set = new Set()): string[
return [...set];
}
-async function ensureRemoteResourceReferences(
- value: T,
- uploaded = new Map(),
- onUploaded?: () => void,
-): Promise {
+async function ensureRemoteResourceReferences(value: T, uploaded = new Map(), onUploaded?: () => void): Promise {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) {
const result: unknown[] = [];
diff --git a/web/src/stores/canvas/use-canvas-history-store.ts b/web/src/stores/canvas/use-canvas-history-store.ts
new file mode 100644
index 000000000..90c210568
--- /dev/null
+++ b/web/src/stores/canvas/use-canvas-history-store.ts
@@ -0,0 +1,55 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
+
+export type DeletedCanvasHistoryItem = {
+ id: string;
+ title: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string;
+ nodeCount: number;
+ coverUrl?: string;
+};
+
+type CanvasHistoryStore = {
+ deletedProjects: DeletedCanvasHistoryItem[];
+ recordDeletedProjects: (projects: CanvasProject[]) => void;
+ removeDeletedHistoryItem: (id: string) => void;
+ clearDeletedHistory: () => void;
+};
+
+export const useCanvasHistoryStore = create()(
+ persist(
+ (set, get) => ({
+ deletedProjects: [],
+ recordDeletedProjects: (projects) => {
+ const now = new Date().toISOString();
+ const newItems: DeletedCanvasHistoryItem[] = projects.map((p) => ({
+ id: p.id,
+ title: p.title || "未命名画布",
+ createdAt: p.createdAt || p.updatedAt || now,
+ updatedAt: p.updatedAt || now,
+ deletedAt: now,
+ nodeCount: p.nodes?.length || 0,
+ coverUrl: p.nodes?.find((n) => n.type === "image" && n.metadata?.content)?.metadata?.content as string | undefined,
+ }));
+ set((state) => {
+ const existingIds = new Set(newItems.map((item) => item.id));
+ const filtered = state.deletedProjects.filter((item) => !existingIds.has(item.id));
+ return {
+ deletedProjects: [...newItems, ...filtered].slice(0, 200),
+ };
+ });
+ },
+ removeDeletedHistoryItem: (id) =>
+ set((state) => ({
+ deletedProjects: state.deletedProjects.filter((item) => item.id !== id),
+ })),
+ clearDeletedHistory: () => set({ deletedProjects: [] }),
+ }),
+ {
+ name: "infinite-canvas:deleted_history_store",
+ },
+ ),
+);
diff --git a/web/src/stores/use-user-store.ts b/web/src/stores/use-user-store.ts
index 7c5eda76a..e7e92697d 100644
--- a/web/src/stores/use-user-store.ts
+++ b/web/src/stores/use-user-store.ts
@@ -22,6 +22,7 @@ export type RuntimeLimits = {
activeTaskLimit: number;
resourceUploadMB: number;
sessionUploadMB: number;
+ recycleBinRetentionDays?: number;
};
export type FeatureAvailability = {