From 867a18a948c2522722aa330f094f3de22988eddf Mon Sep 17 00:00:00 2001 From: Danborad Date: Tue, 1 Sep 2026 14:37:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets&canvas):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E5=9B=9E=E6=94=B6=E7=AB=99=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E3=80=81=E8=87=AA=E5=8A=A8=E5=AE=9A=E6=97=B6=E6=B8=85=E7=90=86?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E4=B8=8E=E8=BF=87=E6=9C=9F=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=EF=BC=8C=E5=A2=9E=E5=8A=A0=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=B8=8E=E5=8F=98=E6=9B=B4=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/repository/repository.go | 20 + .../service/resource_deletion_worker.go | 33 +- backend/internal/service/runtime_policy.go | 66 ++-- .../assets/asset-library-picker-modal.tsx | 317 ++++++++++++--- web/src/components/cached-resource-image.tsx | 85 ++-- .../components/canvas/asset-picker-modal.tsx | 58 ++- .../canvas/canvas-history-drawer.tsx | 243 ++++++++++++ .../canvas/canvas-project-asset-modal.tsx | 147 ++++--- .../components/canvas/canvas-project-card.tsx | 101 +++-- .../settings/runtime-policy-settings-page.tsx | 11 +- web/src/pages/assets/index.tsx | 364 ++++++++++++++++-- web/src/pages/canvas/index.tsx | 30 +- .../pages/canvas/use-canvas-render-model.ts | 67 ++-- web/src/services/api/auth.ts | 1 + web/src/services/user-data-sync.ts | 133 ++++++- .../stores/canvas/use-canvas-history-store.ts | 55 +++ web/src/stores/use-user-store.ts | 1 + 17 files changed, 1411 insertions(+), 321 deletions(-) create mode 100644 web/src/components/canvas/canvas-history-drawer.tsx create mode 100644 web/src/stores/canvas/use-canvas-history-store.ts 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: