feat(assets): 素材回收站与画布历史 - 合并 PR #370 并修复删除一致性
This commit is contained in:
@@ -1072,6 +1072,18 @@ 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).
|
||||
Order("updated_at asc, id asc").
|
||||
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 {
|
||||
|
||||
@@ -257,6 +257,61 @@ func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbox(t *testing.T) {
|
||||
svc, db, _ := newResourceDeletionTestService(t)
|
||||
old := time.Now().Add(-45 * 24 * time.Hour)
|
||||
resource := model.Resource{
|
||||
ID: "resource-expired-archive", UserID: "user-1", Provider: "unsupported-test-provider",
|
||||
ObjectKey: "users/user-1/image/expired.png", Status: model.ResourceStatusReady,
|
||||
}
|
||||
asset := model.Asset{
|
||||
ID: "asset-expired-archive", UserID: "user-1", Title: "过期回收站素材",
|
||||
Status: model.AssetVersionStatusArchived, PayloadJSON: `{"data":{"storageKey":"resource:resource-expired-archive"}}`,
|
||||
CreatedAt: old, UpdatedAt: old,
|
||||
}
|
||||
canvas := model.CanvasProject{
|
||||
ID: "canvas-expired-archive", UserID: "user-1", Title: "仍引用回收站素材的画布",
|
||||
PayloadJSON: `{"nodes":[{"data":{"storageKey":"resource:resource-expired-archive"}}]}`,
|
||||
}
|
||||
for _, item := range []any{&resource, &asset, &canvas} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
svc.cleanupExpiredArchivedAssets()
|
||||
var assetCount, resourceCount, jobCount int64
|
||||
if err := db.Model(&model.Asset{}).Where("id = ?", asset.ID).Count(&assetCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assetCount != 1 || resourceCount != 1 || jobCount != 0 {
|
||||
t.Fatalf("referenced archived asset cleanup changed data: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount)
|
||||
}
|
||||
|
||||
if err := db.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", canvas.ID, canvas.UserID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.cleanupExpiredArchivedAssets()
|
||||
if err := db.Model(&model.Asset{}).Where("id = ?", asset.ID).Count(&assetCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assetCount != 0 || resourceCount != 0 || jobCount != 1 {
|
||||
t.Fatalf("unreferenced archived asset did not use deletion outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount)
|
||||
}
|
||||
}
|
||||
|
||||
func newResourceDeletionTestService(t *testing.T) (*Service, *gorm.DB, string) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
|
||||
@@ -15,24 +15,56 @@ 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
|
||||
}
|
||||
deleted := 0
|
||||
for _, asset := range expired {
|
||||
// 自动清理与用户手动删除走同一条强校验路径:先检查业务引用,
|
||||
// 再以事务 + Outbox 删除资源记录和物理对象,不能从 repository 旁路。
|
||||
if err := s.DeleteUserAsset(asset.UserID, asset.ID); err != nil {
|
||||
log.Printf("expired archived asset delete failed for %s: %v", asset.ID, err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.Printf("recycle bin cleanup: deleted %d expired assets (retention: %d days)", deleted, retentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) drainResourceDeletionJobs(limit int) {
|
||||
owner := s.workerID
|
||||
if owner == "" {
|
||||
|
||||
@@ -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: 0,
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -136,3 +136,39 @@ func TestRuntimePolicySaveAndResetTakeEffectImmediately(t *testing.T) {
|
||||
t.Fatalf("reset active task limit = %d, error = %v", effective.Task.ActiveTaskLimit, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePolicyBackfillsRecycleBinRetentionForLegacyJSON(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := defaultRuntimePolicy()
|
||||
encoded, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(encoded, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := value["resource"].(map[string]any)
|
||||
delete(resource, "recycleBinRetentionDays")
|
||||
encoded, err = json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: runtimePolicySettingKey, ValueJSON: string(encoded), UpdatedBy: "admin"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
policy, err := New(repository.New(db), t.TempDir()).RuntimePolicy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if policy.Resource.RecycleBinRetentionDays != 30 {
|
||||
t.Fatalf("legacy recycle bin retention = %d, want 30", policy.Resource.RecycleBinRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,3 +133,5 @@ description: 影策后端核心持久化表与状态边界
|
||||
| `resource_deletion_jobs` | 在业务记录删除后可靠清理物理对象 | 固化资源的存储位置与对象键;删除失败保留任务并重试,S3 404 按幂等成功处理 |
|
||||
|
||||
`storage_locations.value_json` 中的 Secret Key 和 Session Token 使用后端设置密钥加密。启用 S3 前,关键位置字段和凭据摘要必须与最近一次 Put、Range Get、Delete 连接测试结果一致;历史资源继续按绑定的位置读取和删除,不随当前配置切换。
|
||||
|
||||
素材状态为 `archived` 时进入回收站。后台按“资源与请求策略”中的 `recycleBinRetentionDays` 定期扫描过期项;`0` 表示永久保留。自动清理与用户手动彻底删除复用同一 service 引用校验和 `resource_deletion_jobs` Outbox,仍被画布、项目、任务或其他业务记录引用的素材不会被删除。
|
||||
|
||||
@@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 画布导入同步、素材回收站与历史
|
||||
|
||||
- [x] 画布压缩包导入会先校验媒体完整性,并统一通过远端同步入口上传资源;失败时保持脏状态和自动重试,不再把浏览器本地 storage key 误报为已同步。
|
||||
- [x] 素材回收站使用独立归档状态;彻底删除复用服务端引用校验与资源删除 Outbox,自动过期清理不会旁路业务引用。
|
||||
- [x] 已删除画布历史改为按用户 scope 存入 IndexedDB;删除失败不记录历史,共享素材不会因删除单个画布被错误归档。
|
||||
- [ ] 使用两个账号分别删除画布并切换登录,确认历史记录和回收站素材不串账号;刷新后记录仍在,清理历史只影响当前账号。
|
||||
- [ ] 删除仍被其他画布或项目引用的回收站素材,确认服务端拒绝并显示引用来源;解除引用后彻底删除,确认资源记录进入 Outbox 并最终清理物理对象。
|
||||
- [ ] 导入包含图片、视频、音频的真实画布压缩包,分别模拟上传中断和服务端保存失败,确认进度遮罩最终关闭、页面可离开且恢复网络后自动重试。
|
||||
|
||||
## 邮箱找回密码
|
||||
|
||||
- [ ] 后端专项测试验证验证码用途隔离、错误和过期验证码拒绝、单次消费、bcrypt 密码更新,以及重置事务撤销全部旧登录会话。
|
||||
|
||||
@@ -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,12 +9,14 @@ 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;
|
||||
title: string;
|
||||
category: string;
|
||||
archived?: boolean;
|
||||
kindLabel: string;
|
||||
asset?: Asset;
|
||||
imageUrl?: string;
|
||||
@@ -91,6 +93,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 +115,37 @@ 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 activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]);
|
||||
const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]);
|
||||
const sourceFolders = source === "plugin" ? folders : [];
|
||||
const showCategories = source === "local" || !sourceFolders.length;
|
||||
const categories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other")))], [sourceItems]);
|
||||
const normalCategories = useMemo(() => ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems]);
|
||||
const archivedCount = archivedItems.length;
|
||||
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 === "archived") {
|
||||
if (!item.archived) return false;
|
||||
} else if (item.archived || (category !== "all" && 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],
|
||||
);
|
||||
const archivedSelectedIds = useMemo(() => selectedIds.filter((id) => allItems.find((item) => item.id === id)?.archived), [allItems, selectedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -149,9 +163,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 +205,55 @@ export function AssetLibraryPickerModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreSelected = async () => {
|
||||
if (!archivedSelectedIds.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const id of archivedSelectedIds) {
|
||||
useAssetStore.getState().updateAsset(id, { status: "confirmed" });
|
||||
}
|
||||
await flushAssetStorePersistence();
|
||||
await saveRemoteUserDataNow();
|
||||
setSelected(new Set());
|
||||
message.success(`已还原 ${archivedSelectedIds.length} 个素材至素材库`);
|
||||
setCategory("all");
|
||||
} catch {
|
||||
message.warning("已在本地还原,稍后自动同步至云端");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (!archivedSelectedIds.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const id of archivedSelectedIds) await deleteAssetWithRemoteSync(id);
|
||||
setSelected(new Set());
|
||||
message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`);
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmptyRecycleBin = async () => {
|
||||
const toDelete = archivedItems;
|
||||
if (!toDelete.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const item of toDelete) await deleteAssetWithRemoteSync(item.id);
|
||||
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 +291,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" ? activeSourceItems.length : activeSourceItems.filter((item) => item.category === value).length);
|
||||
const sourceLabel = source === "plugin" ? "插件来源" : "本地素材";
|
||||
const sourceMenuItems: MenuProps["items"] = [
|
||||
{ key: "local", icon: <HardDrive aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>本地素材</span><em>{localItems.length}</em></span> },
|
||||
...(hasPluginSource ? [{ key: "plugin", icon: <Puzzle aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>插件来源</span><em>{pluginItems.length}</em></span> }] : []),
|
||||
{
|
||||
key: "local",
|
||||
icon: <HardDrive aria-hidden="true" />,
|
||||
label: (
|
||||
<span className="asset-picker-source-menu-label">
|
||||
<span>本地素材</span>
|
||||
<em>{localItems.filter((item) => !item.archived).length}</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(hasPluginSource
|
||||
? [
|
||||
{
|
||||
key: "plugin",
|
||||
icon: <Puzzle aria-hidden="true" />,
|
||||
label: (
|
||||
<span className="asset-picker-source-menu-label">
|
||||
<span>插件来源</span>
|
||||
<em>{pluginItems.length}</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const activeUpload = source === "plugin" ? upload?.external : upload;
|
||||
const activeUpload = isRecycleBin ? undefined : source === "plugin" ? upload?.external : upload;
|
||||
const uploading = uploadingCount > 0;
|
||||
|
||||
return (
|
||||
@@ -271,31 +356,76 @@ export function AssetLibraryPickerModal({
|
||||
}}
|
||||
>
|
||||
<button type="button" className="asset-picker-title-trigger" aria-haspopup="menu" aria-expanded={sourceMenuOpen} aria-label={"素材库来源:" + sourceLabel}>
|
||||
<strong>{title}</strong><ChevronDown aria-hidden="true" />
|
||||
<strong>{isRecycleBin ? "回收站" : title}</strong>
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<label className="asset-picker-search"><Search aria-hidden /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></label>
|
||||
<span className="asset-picker-count">已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材</span>
|
||||
<label className="asset-picker-search">
|
||||
<Search aria-hidden />
|
||||
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" />
|
||||
</label>
|
||||
<span className="asset-picker-count">
|
||||
已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材
|
||||
</span>
|
||||
</header>
|
||||
<div className="asset-picker-body">
|
||||
<nav className="asset-picker-categories" aria-label="素材分类">
|
||||
{sourceFolders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span className="assets-filter-item-label">全部文件夹</span><span className="assets-filter-count">{sourceItems.length}</span></button>{renderPickerFolders(sourceFolders, sourceItems, folderId, setFolderId)}</> : null}
|
||||
{showCategories ? <><span className="asset-picker-nav-label">分类</span>{categories.map((value) => (
|
||||
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
|
||||
<span className="assets-filter-item-label">{categoryLabels[value] || "其他"}</span><span className="assets-filter-count">{countFor(value)}</span>
|
||||
</button>
|
||||
))}</> : null}
|
||||
{sourceFolders.length ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">文件夹</span>
|
||||
<button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}>
|
||||
<span className="assets-filter-item-label">全部文件夹</span>
|
||||
<span className="assets-filter-count">{sourceItems.length}</span>
|
||||
</button>
|
||||
{renderPickerFolders(sourceFolders, activeSourceItems, folderId, setFolderId)}
|
||||
</>
|
||||
) : null}
|
||||
{showCategories ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">分类</span>
|
||||
{normalCategories.map((value) => (
|
||||
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
|
||||
<span className="assets-filter-item-label">{categoryLabels[value] || (value === "all" ? "全部素材" : "其他")}</span>
|
||||
<span className="assets-filter-count">{countFor(value)}</span>
|
||||
</button>
|
||||
))}
|
||||
{archivedCount > 0 ? (
|
||||
<div className="mt-3 border-t border-border/40 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("assets-filter-item text-amber-500 hover:text-amber-400 dark:text-amber-400", category === "archived" && "is-active !bg-amber-500/10")}
|
||||
aria-pressed={category === "archived"}
|
||||
onClick={() => setCategory("archived")}
|
||||
>
|
||||
<span className="assets-filter-item-label flex items-center gap-1.5">
|
||||
<Trash2 className="size-3.5" />
|
||||
<span>回收站</span>
|
||||
</span>
|
||||
<span className="assets-filter-count">{archivedCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
<div className="asset-picker-grid-wrap">
|
||||
<div className="asset-picker-grid">
|
||||
{loading ? (
|
||||
<div className="asset-picker-empty"><LoaderCircle className="animate-spin" /><strong>正在读取素材</strong><span>素材会按页加载,不会一次下载整个项目库。</span></div>
|
||||
) : visibleItems.length ? visibleItems.map((item) => (
|
||||
<PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />
|
||||
)) : (
|
||||
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
|
||||
<div className="asset-picker-empty">
|
||||
<LoaderCircle className="animate-spin" />
|
||||
<strong>正在读取素材</strong>
|
||||
<span>素材会按页加载,不会一次下载整个项目库。</span>
|
||||
</div>
|
||||
) : visibleItems.length ? (
|
||||
visibleItems.map((item) => <PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />)
|
||||
) : (
|
||||
<div className="asset-picker-empty">
|
||||
<FolderOpen />
|
||||
<strong>{isRecycleBin ? "回收站是空的" : emptyTitle}</strong>
|
||||
<span>{isRecycleBin ? "删除画布或手动归档的素材会暂存到这里,可在需要时还原。" : activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pagination ? <PaginationBar alwaysShow current={pagination.current} pageSize={pagination.pageSize} total={pagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={pagination.onChange} /> : null}
|
||||
@@ -307,17 +437,57 @@ export function AssetLibraryPickerModal({
|
||||
<input ref={uploadInputRef} type="file" hidden accept={activeUpload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
|
||||
<button type="button" className="asset-picker-upload" onClick={() => uploadInputRef.current?.click()} disabled={working} aria-busy={uploading}>
|
||||
{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}
|
||||
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small></span>
|
||||
<span>
|
||||
<strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong>
|
||||
<small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
|
||||
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
|
||||
) : footerNote ? (
|
||||
<span className="asset-picker-footer-note">{footerNote}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{error ? (
|
||||
<span className="asset-picker-footer-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="asset-picker-actions">
|
||||
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
|
||||
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
|
||||
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
|
||||
{confirmLabel(selectedIds.length)}
|
||||
</Button>
|
||||
{isRecycleBin ? (
|
||||
<>
|
||||
<Popconfirm title="确认清空回收站?" description="清空后所有回收站素材及其物理文件将被彻底删除,不可恢复。" onConfirm={handleEmptyRecycleBin} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" danger disabled={working || !archivedCount}>
|
||||
清空回收站
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm title="确认彻底删除已选素材?" onConfirm={handleDeleteSelected} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" danger disabled={working || !archivedSelectedIds.length}>
|
||||
彻底删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button type="text" onClick={onClose} disabled={working}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button type="primary" icon={<RotateCcw className="size-3.5" />} disabled={working || !archivedSelectedIds.length} loading={working} onClick={handleRestoreSelected}>
|
||||
还原已选素材{archivedSelectedIds.length ? `(${archivedSelectedIds.length})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? (
|
||||
<Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>
|
||||
{folderActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="text" onClick={onClose} disabled={working}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
|
||||
{confirmLabel(selectedIds.length)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -331,14 +501,32 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
|
||||
<AssetLibraryCard selected={selected} className={cn("asset-picker-card", disabled && "is-disabled")}>
|
||||
<button type="button" className="asset-picker-card-action" onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
|
||||
<div className="assets-cover asset-picker-card-media">
|
||||
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />}
|
||||
{item.imageUrl || item.imageStorageKey ? (
|
||||
<CachedResourceImage
|
||||
storageKey={item.imageStorageKey}
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={item.imageFit === "contain" ? "is-contain" : undefined}
|
||||
fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>}
|
||||
/>
|
||||
) : (
|
||||
<AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />
|
||||
)}
|
||||
<span className="assets-cover-vignette" aria-hidden="true" />
|
||||
<span className="assets-cover-badges" aria-hidden="true">
|
||||
<span className="assets-cover-badge is-kind">{item.kindLabel}</span></span>
|
||||
<span className="asset-picker-card-check" aria-hidden="true"><Check /></span>
|
||||
<span className="assets-cover-badge is-kind">{item.kindLabel}</span>
|
||||
</span>
|
||||
<span className="asset-picker-card-check" aria-hidden="true">
|
||||
<Check />
|
||||
</span>
|
||||
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
|
||||
</div>
|
||||
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
|
||||
<div className="asset-picker-card-copy">
|
||||
<strong>{item.title || "未命名素材"}</strong>
|
||||
{item.description ? <span>{item.description}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
</AssetLibraryCard>
|
||||
);
|
||||
@@ -346,17 +534,28 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
|
||||
|
||||
function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLibraryPickerItem[], selectedId: string, onSelect: (folderId: string) => void, parentId = "", depth = 0, visited: ReadonlySet<string> = new Set()): ReactNode {
|
||||
if (depth >= 8) return null;
|
||||
return folders.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id)).map((folder) => {
|
||||
const nextVisited = new Set(visited).add(folder.id);
|
||||
return (
|
||||
<span key={folder.id} className="contents">
|
||||
<button type="button" className={cn("assets-filter-item", selectedId === folder.id && "is-active")} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
|
||||
<span className="assets-filter-item-label" title={folder.name}>{folder.name}</span><span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
|
||||
</button>
|
||||
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
return folders
|
||||
.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id))
|
||||
.map((folder) => {
|
||||
const nextVisited = new Set(visited).add(folder.id);
|
||||
return (
|
||||
<span key={folder.id} className="contents">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("assets-filter-item", selectedId === folder.id && "is-active")}
|
||||
aria-pressed={selectedId === folder.id}
|
||||
onClick={() => onSelect(folder.id)}
|
||||
style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}
|
||||
>
|
||||
<span className="assets-filter-item-label" title={folder.name}>
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
|
||||
</button>
|
||||
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function kindIcon(label: string): ReactNode {
|
||||
|
||||
@@ -13,7 +13,21 @@ export type InsertAssetPayload =
|
||||
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; hasAudio?: boolean; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
|
||||
| {
|
||||
kind: "character";
|
||||
title: string;
|
||||
assetId: string;
|
||||
versionId: string;
|
||||
prompt: string;
|
||||
aliases: string[];
|
||||
definition: Record<string, unknown>;
|
||||
coverUrl?: string;
|
||||
visualStatus: string;
|
||||
voiceStatus: string;
|
||||
voiceName?: string;
|
||||
voiceProfile?: { name: string; provider: string; language: string; timbre: string };
|
||||
voiceInstructions?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -22,23 +36,27 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
|
||||
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS, archived: "回收站" };
|
||||
|
||||
export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
...insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: normalizeAssetCategory(asset.category),
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
], [externalAssetSources.items, insertableAssets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(
|
||||
() => [
|
||||
...insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: normalizeAssetCategory(asset.category),
|
||||
archived: asset.status === "archived",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
],
|
||||
[externalAssetSources.items, insertableAssets],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssetLibraryPickerModal
|
||||
@@ -76,7 +94,20 @@ export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibr
|
||||
function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload {
|
||||
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
|
||||
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, hasAudio: asset.data.hasAudio, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "video")
|
||||
return {
|
||||
kind: "video",
|
||||
url: asset.data.url,
|
||||
storageKey: asset.data.storageKey,
|
||||
title: asset.title,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
durationMs: asset.data.durationMs,
|
||||
hasAudio: asset.data.hasAudio,
|
||||
bytes: asset.data.bytes,
|
||||
mimeType: asset.data.mimeType,
|
||||
assetId: asset.id,
|
||||
};
|
||||
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
}
|
||||
|
||||
|
||||
243
web/src/components/canvas/canvas-history-drawer.tsx
Normal file
243
web/src/components/canvas/canvas-history-drawer.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Drawer, Tag, Button, Input, Popconfirm, Empty, Tooltip } from "antd";
|
||||
import { Clock, Clock3, ExternalLink, History, Search, Trash2, X } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TimelineItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
isDeleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
nodeCount: number;
|
||||
timelineTime: string;
|
||||
};
|
||||
|
||||
export function CanvasHistoryDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const activeProjects = useCanvasStore((state) => state.projects);
|
||||
const deletedProjects = useCanvasHistoryStore((state) => state.deletedProjects);
|
||||
const removeDeletedItem = useCanvasHistoryStore((state) => state.removeDeletedHistoryItem);
|
||||
const clearDeletedHistory = useCanvasHistoryStore((state) => state.clearDeletedHistory);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "active" | "deleted">("all");
|
||||
|
||||
const timelineItems = useMemo<TimelineItem[]>(() => {
|
||||
const activeList: TimelineItem[] = activeProjects.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title || "未命名画布",
|
||||
isDeleted: false,
|
||||
createdAt: p.createdAt || p.updatedAt || new Date().toISOString(),
|
||||
updatedAt: p.updatedAt || new Date().toISOString(),
|
||||
nodeCount: p.nodes?.length || 0,
|
||||
timelineTime: p.createdAt || p.updatedAt || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const deletedList: TimelineItem[] = deletedProjects.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title || "未命名画布",
|
||||
isDeleted: true,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
deletedAt: d.deletedAt,
|
||||
nodeCount: d.nodeCount,
|
||||
timelineTime: d.deletedAt || d.createdAt,
|
||||
}));
|
||||
|
||||
const all = [...activeList, ...deletedList];
|
||||
all.sort((a, b) => new Date(b.timelineTime).getTime() - new Date(a.timelineTime).getTime());
|
||||
return all;
|
||||
}, [activeProjects, deletedProjects]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
return timelineItems.filter((item) => {
|
||||
if (filter === "active" && item.isDeleted) return false;
|
||||
if (filter === "deleted" && !item.isDeleted) return false;
|
||||
if (!q) return true;
|
||||
return item.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [filter, keyword, timelineItems]);
|
||||
|
||||
const openProject = (id: string) => {
|
||||
navigate(`/canvas/${id}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-2 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-4 text-[var(--workspace-accent)]" />
|
||||
<span className="text-sm font-semibold text-stone-900 dark:text-stone-100">画布创建与变更历史时间线</span>
|
||||
</div>
|
||||
{deletedProjects.length > 0 ? (
|
||||
<Popconfirm title="确定清空已删除画布的历史记录?" onConfirm={clearDeletedHistory} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" size="small" danger icon={<Trash2 className="size-3.5" />}>
|
||||
清理删除记录
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="workspace-drawer canvas-history-drawer"
|
||||
styles={{
|
||||
header: { borderBottom: "1px solid var(--border-color, rgba(255,255,255,0.08))" },
|
||||
body: { padding: "16px 20px" },
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 搜索与筛选 */}
|
||||
<div className="space-y-3">
|
||||
<Input allowClear prefix={<Search className="size-3.5 text-stone-400" />} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索画布名称..." className="text-xs" />
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
|
||||
filter === "all"
|
||||
? "!bg-stone-900 !text-white shadow-sm dark:!bg-stone-100 dark:!text-stone-900 font-semibold"
|
||||
: "bg-stone-100 text-stone-700 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700",
|
||||
)}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
全部 ({timelineItems.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
|
||||
filter === "active"
|
||||
? "!bg-emerald-600 !text-white shadow-sm font-semibold"
|
||||
: "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 border border-emerald-200/60 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/50",
|
||||
)}
|
||||
onClick={() => setFilter("active")}
|
||||
>
|
||||
活跃中 ({activeProjects.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
|
||||
filter === "deleted" ? "!bg-rose-600 !text-white shadow-sm font-semibold" : "bg-rose-50 text-rose-800 hover:bg-rose-100 border border-rose-200/60 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-800/50",
|
||||
)}
|
||||
onClick={() => setFilter("deleted")}
|
||||
>
|
||||
已删除 ({deletedProjects.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间线列表 */}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无匹配的历史记录" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative border-l-2 border-stone-200 dark:border-stone-800 pl-4 space-y-4 pt-1">
|
||||
{filteredItems.map((item) => {
|
||||
const isDeleted = item.isDeleted;
|
||||
return (
|
||||
<div key={item.id + (item.deletedAt || "")} className="group relative">
|
||||
{/* 时间轴圆点 */}
|
||||
<span className={cn("absolute -left-[23px] top-3.5 size-3 rounded-full border-2 border-background", isDeleted ? "bg-rose-500 ring-2 ring-rose-500/20" : "bg-emerald-500 ring-2 ring-emerald-500/20")} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-3.5 transition-all shadow-sm",
|
||||
isDeleted
|
||||
? "border-rose-200/90 bg-rose-50/50 dark:border-rose-900/40 dark:bg-rose-950/20 hover:border-rose-300"
|
||||
: "border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900/70 hover:border-stone-300 dark:hover:border-stone-700",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<span className="truncate text-sm font-semibold text-stone-500 line-through decoration-rose-500/80 decoration-2 dark:text-stone-400" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-left text-sm font-semibold text-stone-900 hover:text-blue-600 dark:text-stone-100 dark:hover:text-blue-400 transition-colors"
|
||||
onClick={() => openProject(item.id)}
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
)}
|
||||
{isDeleted ? (
|
||||
<Tag color="error" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
|
||||
已删除
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="success" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
|
||||
活跃中
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-stone-600 dark:text-stone-300">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock3 className="size-3.5 text-stone-400 shrink-0" />
|
||||
<span>创建时间:{formatTimelineDate(item.createdAt)}</span>
|
||||
</div>
|
||||
{isDeleted && item.deletedAt ? (
|
||||
<div className="flex items-center gap-1.5 text-rose-600 dark:text-rose-400 font-medium">
|
||||
<Trash2 className="size-3.5 text-rose-500 shrink-0" />
|
||||
<span>删除时间:{formatTimelineDate(item.deletedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-stone-500 dark:text-stone-400">
|
||||
<Clock className="size-3.5 text-stone-400 shrink-0" />
|
||||
<span>最近更新:{formatTimelineDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[11px] text-stone-500 dark:text-stone-400 pt-0.5">包含 {item.nodeCount} 个节点</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0 pt-0.5">
|
||||
{!isDeleted ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ExternalLink className="size-4 text-stone-500 hover:text-stone-800 dark:text-stone-400 dark:hover:text-stone-100" />}
|
||||
onClick={() => openProject(item.id)}
|
||||
title="打开画布"
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title="从历史列表中移除此条记录">
|
||||
<Button type="text" size="small" danger icon={<X className="size-4" />} onClick={() => removeDeletedItem(item.id)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimelineDate(isoString: string) {
|
||||
if (!isoString) return "--";
|
||||
const date = new Date(isoString);
|
||||
if (!Number.isFinite(date.getTime())) return "--";
|
||||
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -13,7 +13,23 @@ import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
const categoryLabels: Record<string, string> = { all: "全部资产", ...ASSET_CATEGORY_LABELS };
|
||||
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
|
||||
|
||||
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
|
||||
export function CanvasProjectAssetModal({
|
||||
open,
|
||||
detail,
|
||||
initialCategory = "all",
|
||||
initialFolderId = "all",
|
||||
onClose,
|
||||
onInsert,
|
||||
onInsertFolder,
|
||||
}: {
|
||||
open: boolean;
|
||||
detail?: ProjectDetail;
|
||||
initialCategory?: string;
|
||||
initialFolderId?: string;
|
||||
onClose: () => void;
|
||||
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
|
||||
onInsertFolder?: (folderId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const mediaAssets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const items = useMemo<ProjectPickerItem[]>(() => {
|
||||
@@ -21,38 +37,40 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
|
||||
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }];
|
||||
const media = mediaById.get(asset.id);
|
||||
if (media?.status === "archived") return [];
|
||||
return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: normalizeAssetCategory(asset.category || media?.category), folderId: asset.folderId, project: asset, media }];
|
||||
});
|
||||
if (detail) return projectItems;
|
||||
// 自由画布未关联项目时回退到个人素材库。
|
||||
return mediaAssets
|
||||
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
|
||||
.map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
|
||||
return mediaAssets.filter((asset) => asset.kind !== "model" && asset.kind !== "entity" && asset.status !== "archived").map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
|
||||
}, [detail?.assets, mediaAssets]);
|
||||
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const character = item.character;
|
||||
const project = item.project;
|
||||
const media = item.media;
|
||||
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
|
||||
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
|
||||
return {
|
||||
id: item.id,
|
||||
title: character?.title || project?.title || media?.title || "未命名资产",
|
||||
category: item.category,
|
||||
folderId: item.folderId,
|
||||
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation
|
||||
? resourceFileUrl(coverRepresentation.resourceId)
|
||||
: project?.mediaType === "image" && remoteResourceId
|
||||
? resourceFileUrl(remoteResourceId)
|
||||
: undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
|
||||
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}), [items]);
|
||||
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const character = item.character;
|
||||
const project = item.project;
|
||||
const media = item.media;
|
||||
const coverRepresentation =
|
||||
character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") ||
|
||||
character?.character?.representations.find((representation) => representation.role === "primary") ||
|
||||
character?.character?.representations.find((representation) => representation.role === "front");
|
||||
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
|
||||
return {
|
||||
id: item.id,
|
||||
title: character?.title || project?.title || media?.title || "未命名资产",
|
||||
category: item.category,
|
||||
folderId: item.folderId,
|
||||
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
|
||||
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
|
||||
|
||||
return (
|
||||
@@ -69,18 +87,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 +133,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 +170,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 +186,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
|
||||
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
@@ -30,16 +31,22 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
// Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。
|
||||
if (previousUserId !== nextUserId) appQueryClient.clear();
|
||||
await switchUserStorageScope(payload.user?.id);
|
||||
const [persistedCanvas, persistedAssets, persistedPlugins] = await Promise.all([localForageStorage.getItem(CANVAS_STORE_KEY), localForageStorage.getItem(ASSET_STORE_KEY), localForageStorage.getItem(PLUGIN_STORE_KEY)]);
|
||||
const [persistedCanvas, persistedCanvasHistory, persistedAssets, persistedPlugins] = await Promise.all([
|
||||
localForageStorage.getItem(CANVAS_STORE_KEY),
|
||||
localForageStorage.getItem(CANVAS_HISTORY_STORE_KEY),
|
||||
localForageStorage.getItem(ASSET_STORE_KEY),
|
||||
localForageStorage.getItem(PLUGIN_STORE_KEY),
|
||||
]);
|
||||
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
|
||||
usePluginStore.setState({ hydrated: false, runtimeStatuses: {}, pluginStates: {} });
|
||||
useUserStore.getState().setUser(payload.user);
|
||||
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
|
||||
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
|
||||
useUserStore.getState().setFeatures(payload.features);
|
||||
await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate()]);
|
||||
await Promise.all([useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate()]);
|
||||
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
|
||||
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
|
||||
if (!persistedCanvasHistory) useCanvasHistoryStore.setState({ deletedProjects: [] });
|
||||
if (!persistedAssets) useAssetStore.setState({ assets: [] });
|
||||
if (!persistedPlugins) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} });
|
||||
if (!persistedConfig) {
|
||||
@@ -106,9 +113,7 @@ function managedModelChannels(models: PublicLogicalModel[]) {
|
||||
scope: "system",
|
||||
enabled: true,
|
||||
models: availableModels.map((item) => item.id),
|
||||
modelAliases: Object.fromEntries(
|
||||
availableModels.flatMap((item) => (item.legacyModelIds || []).map((legacyID) => [legacyID, item.id])),
|
||||
),
|
||||
modelAliases: Object.fromEntries(availableModels.flatMap((item) => (item.legacyModelIds || []).map((legacyID) => [legacyID, item.id]))),
|
||||
modelCosts: availableModels.map((item) => ({
|
||||
model: item.id,
|
||||
displayName: item.name,
|
||||
@@ -125,7 +130,7 @@ function managedModelChannels(models: PublicLogicalModel[]) {
|
||||
logicalModelId: item.id,
|
||||
logicalCapabilitySpec: item.capabilitySpec,
|
||||
logicalCapabilityProfiles: item.capabilityProfiles,
|
||||
logicalPriceTiers: item.priceTiers,
|
||||
logicalPriceTiers: item.priceTiers,
|
||||
defaultOptions: item.defaultOptions,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AdminStatTile, AdminStatusBadge, SettingsSectionCard } from "../compone
|
||||
|
||||
type PolicyGroup = "resource" | "task" | "request";
|
||||
type RuntimePolicyDraft = Pick<RuntimePolicySetting, "resource" | "task" | "request">;
|
||||
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
|
||||
<div className="admin-runtime-policy-field-grid">
|
||||
{fields.map((field) => {
|
||||
const inputId = `admin-runtime-policy-${field.group}-${field.name}`;
|
||||
const min = field.min ?? 1;
|
||||
return (
|
||||
<Form.Item key={`${field.group}.${field.name}`} label={field.label} htmlFor={inputId} extra={field.extra}>
|
||||
<div className="admin-runtime-policy-number-control">
|
||||
@@ -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} 的整数` },
|
||||
]}
|
||||
>
|
||||
<InputNumber id={inputId} min={1} max={field.max} precision={0} aria-label={field.label} />
|
||||
<InputNumber id={inputId} min={min} max={field.max} precision={0} aria-label={field.label} />
|
||||
</Form.Item>
|
||||
<span className="admin-runtime-policy-number-unit" aria-hidden="true">
|
||||
{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;
|
||||
|
||||
@@ -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,11 +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<Asset, { kind: "entity" }>;
|
||||
|
||||
@@ -47,10 +48,7 @@ const kindOptions = [
|
||||
{ label: "3D 模型", value: "model" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: "全部分类", value: "all" },
|
||||
...ASSET_CATEGORY_OPTIONS,
|
||||
];
|
||||
const categoryOptions = [{ label: "全部分类", value: "all" }, ...ASSET_CATEGORY_OPTIONS];
|
||||
|
||||
const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
|
||||
text: FileText,
|
||||
@@ -74,6 +72,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<AssetKind | "all">("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<AssetCategory | "all">("all");
|
||||
@@ -83,8 +83,10 @@ export default function AssetsPage() {
|
||||
const [isAssetOpen, setIsAssetOpen] = useState(false);
|
||||
const [previewAsset, setPreviewAsset] = useState<LibraryAsset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<LibraryAsset | null>(null);
|
||||
const [archivingAsset, setArchivingAsset] = useState<LibraryAsset | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||
const [batchArchiveOpen, setBatchArchiveOpen] = useState(false);
|
||||
|
||||
const [formKind, setFormKind] = useState<AssetKind>("text");
|
||||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||||
@@ -95,11 +97,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();
|
||||
@@ -184,7 +190,7 @@ export default function AssetsPage() {
|
||||
const base = {
|
||||
title: values.title.trim(),
|
||||
category: values.category,
|
||||
status: editingAsset?.status || "confirmed" as const,
|
||||
status: editingAsset?.status || ("confirmed" as const),
|
||||
primaryVersionId: editingAsset?.primaryVersionId,
|
||||
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageData ? imageData.dataUrl : ""),
|
||||
tags: values.tags || [],
|
||||
@@ -234,7 +240,15 @@ export default function AssetsPage() {
|
||||
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
|
||||
const uploaded = await uploadMediaFile(file, "model");
|
||||
void queryClient.invalidateQueries({ queryKey: assetStorageUsageQueryKey });
|
||||
addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "手动上传", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "manual" } });
|
||||
addAsset({
|
||||
kind: "model",
|
||||
title: file.name.replace(/\.(glb|gltf)$/i, ""),
|
||||
coverUrl: "",
|
||||
tags: ["3D模型"],
|
||||
source: "手动上传",
|
||||
data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name },
|
||||
metadata: { source: "manual" },
|
||||
});
|
||||
message.success("3D 模型已保存");
|
||||
};
|
||||
|
||||
@@ -277,11 +291,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 +379,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) {
|
||||
@@ -305,71 +387,244 @@ export default function AssetsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspacePage grid className="library-page assets-library-page canvas-library-page">
|
||||
<div className="studio-band">
|
||||
<PageHeader
|
||||
title="素材库"
|
||||
description="管理文本、图片、视频、音频和 3D 模型素材。"
|
||||
meta={<span className="app-projects-header-meta assets-header-meta">{validAssets.length} 个素材</span>}
|
||||
actions={(
|
||||
<div className="assets-header-actions">
|
||||
<div className="assets-header-action-buttons">
|
||||
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>新增素材</Button>
|
||||
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库</Button>
|
||||
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
|
||||
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
|
||||
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
|
||||
</Dropdown>
|
||||
<div className="studio-band">
|
||||
<PageHeader
|
||||
title={viewMode === "trash" ? "素材库 / 回收站" : "素材库"}
|
||||
description={viewMode === "trash" ? "已删除画布或手动归档的临时素材,可随时还原或彻底清理。" : "管理文本、图片、视频、音频和 3D 模型素材。"}
|
||||
meta={<span className="app-projects-header-meta assets-header-meta">{validAssets.length} 个素材</span>}
|
||||
actions={
|
||||
<div className="assets-header-actions">
|
||||
<div className="assets-header-action-buttons">
|
||||
{viewMode === "trash" ? (
|
||||
<>
|
||||
{trashAssets.length > 0 ? (
|
||||
<Popconfirm
|
||||
title="确定清空回收站吗?"
|
||||
description="清空后所有回收站素材及其文件将被彻底永久删除,不可恢复。"
|
||||
onConfirm={() => void emptyTrash()}
|
||||
okText="清空"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button danger icon={<Trash2 className="size-3.5" />}>
|
||||
清空回收站
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Button
|
||||
icon={<RotateCcw className="size-3.5" />}
|
||||
onClick={() => {
|
||||
setViewMode("library");
|
||||
setPage(1);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
返回素材库
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>
|
||||
新增素材
|
||||
</Button>
|
||||
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>
|
||||
Eagle 素材库
|
||||
</Button>
|
||||
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() },
|
||||
{ key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AssetStorageUsage />
|
||||
</div>
|
||||
<AssetStorageUsage />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ListToolbar className="library-toolbar" active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")} onReset={() => { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}>
|
||||
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
|
||||
</ListToolbar>
|
||||
</div>
|
||||
|
||||
<div className="canvas-library-frame assets-library-frame">
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:pr-3">
|
||||
<AssetFilterGroup title="素材类型" options={kindOptions} value={kindFilter} counts={kindCounts} onChange={(value) => { setKindFilter(value as AssetKind | "all"); setPage(1); }} />
|
||||
<AssetFilterGroup title="业务分类" options={categoryOptions} value={categoryFilter} counts={categoryCounts} onChange={(value) => { setCategoryFilter(value as AssetCategory | "all"); setPage(1); }} className="lg:mt-5" />
|
||||
</aside>
|
||||
<section className="min-w-0">
|
||||
{selectedAssets.length ? (
|
||||
<AssetsBatchBar count={selectedAssets.length} allSelected={allFilteredSelected} onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))} onClear={() => setSelectedIds([])} onExport={() => void exportSelectedAssets()} onDelete={() => setBatchDeleteOpen(true)} />
|
||||
) : null}
|
||||
{validAssets.length === 0 ? (
|
||||
<AssetsEmptyState onNew={openCreate} onImport={() => assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
|
||||
) : (
|
||||
<>
|
||||
{filteredAssets.length === 0 ? (
|
||||
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
|
||||
) : (
|
||||
<CollectionGrid className="library-grid assets-library-grid">
|
||||
{canCreateAsset ? <button type="button" className="library-create-card" onClick={openCreate}>
|
||||
<span className="library-create-cover"><Plus className="size-8" /></span>
|
||||
<span className="library-create-title">新增素材</span>
|
||||
<span className="library-create-meta">文本、图片、音视频或模型</span>
|
||||
</button> : null}
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard key={asset.id} asset={asset} selected={selectedIds.includes(asset.id)} onSelect={(selected) => 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)} />
|
||||
))}
|
||||
</CollectionGrid>
|
||||
)}
|
||||
<PaginationBar current={page} pageSize={pageSize} total={filteredAssets.length} pageSizeOptions={[35, 70, 105]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
<ListToolbar
|
||||
className="library-toolbar"
|
||||
active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")}
|
||||
onReset={() => {
|
||||
setKeyword("");
|
||||
setKindFilter("all");
|
||||
setCategoryFilter("all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
className="w-full sm:w-80"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="搜索标题、内容、标签或来源"
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</ListToolbar>
|
||||
</div>
|
||||
|
||||
<div className="canvas-library-frame assets-library-frame">
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:pr-3">
|
||||
<AssetFilterGroup
|
||||
title="素材类型"
|
||||
options={kindOptions}
|
||||
value={viewMode === "library" ? kindFilter : ""}
|
||||
counts={kindCounts}
|
||||
onChange={(value) => {
|
||||
setViewMode("library");
|
||||
setKindFilter(value as AssetKind | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<AssetFilterGroup
|
||||
title="业务分类"
|
||||
options={categoryOptions}
|
||||
value={viewMode === "library" ? categoryFilter : ""}
|
||||
counts={categoryCounts}
|
||||
onChange={(value) => {
|
||||
setViewMode("library");
|
||||
setCategoryFilter(value as AssetCategory | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
className="lg:mt-5"
|
||||
/>
|
||||
<div className="mt-6 border-t border-border/40 pt-3">
|
||||
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">垃圾箱与归档</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === "trash"}
|
||||
className={cn(
|
||||
"assets-filter-item w-full transition-colors",
|
||||
viewMode === "trash" ? "is-active !bg-amber-500/15 !text-amber-600 dark:!text-amber-400 font-semibold shadow-sm" : "text-foreground/65 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (viewMode === "trash") {
|
||||
setViewMode("library");
|
||||
} else {
|
||||
setViewMode("trash");
|
||||
setKindFilter("all");
|
||||
setCategoryFilter("all");
|
||||
}
|
||||
setPage(1);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
<span className="assets-filter-item-label flex items-center gap-1.5">
|
||||
<Trash2 className="size-3.5" />
|
||||
<span>回收站</span>
|
||||
</span>
|
||||
<span className="assets-filter-count">{trashAssets.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="min-w-0">
|
||||
{viewMode === "trash" ? (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-600 dark:text-amber-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
|
||||
<span>{retentionDays > 0 ? `回收站内的素材将在 ${retentionDays} 天后自动彻底清除。您可以随时还原素材,或手动彻底删除释放空间。` : "回收站内的素材当前设置为永久保留,您可以随时还原素材或手动彻底清除。"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAssets.length ? (
|
||||
<AssetsBatchBar
|
||||
count={selectedAssets.length}
|
||||
isTrash={viewMode === "trash"}
|
||||
allSelected={allFilteredSelected}
|
||||
onSelectAll={() => 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 ? (
|
||||
viewMode === "trash" ? (
|
||||
<WorkspaceState icon="assets" compact title="回收站是空的" description="删除画布或手动移入回收站的素材会暂存到这里,可在需要时随时还原。" />
|
||||
) : (
|
||||
<AssetsEmptyState onNew={openCreate} onImport={() => assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{filteredAssets.length === 0 ? (
|
||||
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
|
||||
) : (
|
||||
<CollectionGrid className="library-grid assets-library-grid">
|
||||
{canCreateAsset ? (
|
||||
<button type="button" className="library-create-card" onClick={openCreate}>
|
||||
<span className="library-create-cover">
|
||||
<Plus className="size-8" />
|
||||
</span>
|
||||
<span className="library-create-title">新增素材</span>
|
||||
<span className="library-create-meta">文本、图片、音视频或模型</span>
|
||||
</button>
|
||||
) : null}
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={selectedIds.includes(asset.id)}
|
||||
isTrash={viewMode === "trash"}
|
||||
retentionDays={retentionDays}
|
||||
onSelect={(selected) => 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)}
|
||||
/>
|
||||
))}
|
||||
</CollectionGrid>
|
||||
)}
|
||||
<PaginationBar
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredAssets.length}
|
||||
pageSizeOptions={[35, 70, 105]}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkspacePage>
|
||||
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} onCancel={() => { if (!imageUploading) setIsAssetOpen(false); }} onOk={() => void saveAsset()} okText={imageUploading ? "正在上传" : "保存"} cancelText="取消" confirmLoading={imageUploading} cancelButtonProps={{ disabled: imageUploading }} closable={!imageUploading} destroyOnHidden>
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide library-modal"
|
||||
title={editingAsset ? "编辑素材" : "新增素材"}
|
||||
open={isAssetOpen}
|
||||
onCancel={() => {
|
||||
if (!imageUploading) setIsAssetOpen(false);
|
||||
}}
|
||||
onOk={() => void saveAsset()}
|
||||
okText={imageUploading ? "正在上传" : "保存"}
|
||||
cancelText="取消"
|
||||
confirmLoading={imageUploading}
|
||||
cancelButtonProps={{ disabled: imageUploading }}
|
||||
closable={!imageUploading}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", category: "other", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
@@ -385,7 +640,7 @@ export default function AssetsPage() {
|
||||
<Select options={categoryOptions.slice(1)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input placeholder="给素材起一个容易检索的名字" />
|
||||
<Input placeholder="给素材起一个容易检索的名字" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Space.Compact className="w-full">
|
||||
@@ -416,7 +671,11 @@ export default function AssetsPage() {
|
||||
<Button disabled={imageUploading} icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
|
||||
{imageUploading ? "正在上传图片" : "选择图片文件"}
|
||||
</Button>
|
||||
{imageFile ? <Tag color="gold" className="ml-3">待保存上传</Tag> : null}
|
||||
{imageFile ? (
|
||||
<Tag color="gold" className="ml-3">
|
||||
待保存上传
|
||||
</Tag>
|
||||
) : null}
|
||||
{imageDraft ? (
|
||||
<Typography.Text type="secondary" className="ml-3 text-xs" title={resourceStorageTitle(imageDraft.storageKey)}>
|
||||
{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)} · {resourceStorageLabel(imageDraft.storageKey)}
|
||||
@@ -431,7 +690,9 @@ export default function AssetsPage() {
|
||||
)}
|
||||
</Form>
|
||||
<div className="lg:pl-4">
|
||||
<Typography.Text strong className="text-xs">预览</Typography.Text>
|
||||
<Typography.Text strong className="text-xs">
|
||||
预览
|
||||
</Typography.Text>
|
||||
<div className="mt-2 overflow-hidden rounded-md bg-stone-100 dark:bg-stone-900">
|
||||
{coverUrl || imageDraft?.dataUrl ? (
|
||||
<div className={`asset-preview-uploading ${imageUploading ? "is-uploading" : ""}`}>
|
||||
@@ -493,36 +754,153 @@ export default function AssetsPage() {
|
||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
||||
|
||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} />
|
||||
<input
|
||||
ref={modelInputRef}
|
||||
type="file"
|
||||
accept=".glb,.gltf,model/gltf-binary,model/gltf+json"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void readModelFile(event.target.files?.[0]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal className="library-modal library-confirm-modal" title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除;若仍被画布、任务或其他素材占用,本次删除将被阻止。
|
||||
<Modal
|
||||
className="library-modal library-confirm-modal"
|
||||
title="移入回收站"
|
||||
open={Boolean(archivingAsset)}
|
||||
onCancel={() => setArchivingAsset(null)}
|
||||
onOk={() => {
|
||||
if (archivingAsset) {
|
||||
void archiveAsset(archivingAsset);
|
||||
setArchivingAsset(null);
|
||||
}
|
||||
}}
|
||||
okText="移入回收站"
|
||||
cancelText="取消"
|
||||
>
|
||||
确定将「{archivingAsset?.title}」移入回收站吗?移入后不会出现在正常素材库中,可在回收站随时还原。
|
||||
</Modal>
|
||||
<Modal className="library-modal library-confirm-modal" title="批量删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除;仍被画布、任务或其他素材占用的素材会保留并提示具体来源。
|
||||
<Modal
|
||||
className="library-modal library-confirm-modal"
|
||||
title="批量移入回收站"
|
||||
open={batchArchiveOpen}
|
||||
onCancel={() => setBatchArchiveOpen(false)}
|
||||
onOk={() => {
|
||||
void batchArchive();
|
||||
setBatchArchiveOpen(false);
|
||||
}}
|
||||
okText="移入回收站"
|
||||
cancelText="取消"
|
||||
>
|
||||
确定将已选择的 {selectedAssets.length} 个素材移入回收站吗?移入后可随时在回收站批量还原。
|
||||
</Modal>
|
||||
<Modal
|
||||
className="library-modal library-confirm-modal"
|
||||
title="彻底删除素材"
|
||||
open={Boolean(deletingAsset)}
|
||||
onCancel={() => setDeletingAsset(null)}
|
||||
onOk={() => void confirmDelete()}
|
||||
okText="彻底删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。
|
||||
</Modal>
|
||||
<Modal
|
||||
className="library-modal library-confirm-modal"
|
||||
title="批量彻底删除素材"
|
||||
open={batchDeleteOpen}
|
||||
onCancel={() => setBatchDeleteOpen(false)}
|
||||
onOk={() => void confirmBatchDelete()}
|
||||
okText="彻底删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
|
||||
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
|
||||
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
|
||||
{ type: "divider" as const },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: onDelete },
|
||||
];
|
||||
const menuItems: MenuProps["items"] = isTrash
|
||||
? [{ key: "restore", icon: <RotateCcw className="size-3.5" />, label: "还原到素材库", onClick: onRestore }, { type: "divider" as const }, { key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "彻底删除", onClick: onDelete }]
|
||||
: [
|
||||
...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
|
||||
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
|
||||
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
|
||||
{ type: "divider" as const },
|
||||
{ key: "archive", icon: <Trash2 className="size-3.5 text-amber-500" />, label: "移入回收站", onClick: onArchive },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "彻底删除", onClick: onDelete },
|
||||
];
|
||||
return (
|
||||
<AssetLibraryCard selected={selected}>
|
||||
<AssetCover asset={asset} selected={selected} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
|
||||
<AssetCover asset={asset} selected={selected} isTrash={isTrash} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
|
||||
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>{asset.title}</h2>
|
||||
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>
|
||||
{asset.title}
|
||||
</h2>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums text-foreground/38">{formatAssetTime(asset.updatedAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={summary}>{summary}</div>
|
||||
{isTrash ? (
|
||||
<div className="mt-1 flex items-center gap-1 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-400" title={formatExpirationDate(asset.updatedAt, retentionDays)}>
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
<span>{formatExpirationHint(asset.updatedAt, retentionDays)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={summary}>
|
||||
{summary}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38">
|
||||
<span className="truncate">{asset.source || "未标注来源"}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
@@ -533,7 +911,7 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCover({ asset, selected, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
|
||||
function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; isTrash?: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
|
||||
const KindIcon = assetKindIcons[asset.kind];
|
||||
const clock = asset.kind === "video" || asset.kind === "audio" ? formatAssetClock(asset.data.durationMs) : null;
|
||||
const showPlay = asset.kind === "video";
|
||||
@@ -548,21 +926,34 @@ function AssetCover({ asset, selected, onSelect, onOpen, menuItems }: { asset: L
|
||||
) : asset.kind === "model" ? (
|
||||
<ModelCover asset={asset} />
|
||||
) : (
|
||||
<AssetMediaPreview asset={asset} alt={asset.title} className="assets-cover-media" fallback={<div className="assets-cover-fallback"><KindIcon className="size-7" /></div>} />
|
||||
<AssetMediaPreview
|
||||
asset={asset}
|
||||
alt={asset.title}
|
||||
className="assets-cover-media"
|
||||
fallback={
|
||||
<div className="assets-cover-fallback">
|
||||
<KindIcon className="size-7" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<span className="assets-cover-vignette" aria-hidden="true" />
|
||||
{showPlay ? <span className="assets-cover-play"><Play className="size-4" /></span> : null}
|
||||
{showPlay ? (
|
||||
<span className="assets-cover-play">
|
||||
<Play className="size-4" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="assets-cover-badges">
|
||||
<span className="assets-cover-badge is-kind"><KindIcon />{assetKindLabel(asset.kind)}</span>
|
||||
<span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>
|
||||
<span className="assets-cover-badge is-kind">
|
||||
<KindIcon />
|
||||
{assetKindLabel(asset.kind)}
|
||||
</span>
|
||||
{isTrash ? <span className="assets-cover-badge is-category !bg-amber-500/85 !text-white">回收站</span> : <span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>}
|
||||
</span>
|
||||
{clock ? <span className="assets-cover-clock">{clock}</span> : null}
|
||||
<input type="checkbox" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className="assets-select-check" aria-label={`选择 ${asset.title}`} />
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{ items: menuItems }}
|
||||
>
|
||||
<Dropdown trigger={["click"]} menu={{ items: menuItems }}>
|
||||
<button type="button" className="assets-cover-more" aria-label="更多素材操作" title="更多操作">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
@@ -575,7 +966,9 @@ function AudioWaveCover({ asset }: { asset: LibraryAsset & { kind: "audio" } })
|
||||
const bars = audioWaveBars(asset.id);
|
||||
return (
|
||||
<div className="assets-cover-wave" aria-hidden="true">
|
||||
{bars.map((height, index) => <span key={index} style={{ height: `${height}%` }} />)}
|
||||
{bars.map((height, index) => (
|
||||
<span key={index} style={{ height: `${height}%` }} />
|
||||
))}
|
||||
<AudioLines className="assets-cover-wave-glyph" />
|
||||
</div>
|
||||
);
|
||||
@@ -598,15 +991,61 @@ function ModelCover({ asset }: { asset: LibraryAsset & { kind: "model" } }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AssetsBatchBar({ count, allSelected, onSelectAll, onClear, onExport, onDelete }: { count: number; allSelected: boolean; onSelectAll: () => void; onClear: () => void; onExport: () => void; onDelete: () => void }) {
|
||||
function AssetsBatchBar({
|
||||
count,
|
||||
isTrash = false,
|
||||
allSelected,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onExport,
|
||||
onRestore,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: {
|
||||
count: number;
|
||||
isTrash?: boolean;
|
||||
allSelected: boolean;
|
||||
onSelectAll: () => void;
|
||||
onClear: () => void;
|
||||
onExport: () => void;
|
||||
onRestore?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="assets-batch-bar" role="toolbar" aria-label="批量操作">
|
||||
<span className="assets-batch-count">已选择 <strong>{count}</strong> 个素材</span>
|
||||
<span className="assets-batch-count">
|
||||
已选择 <strong>{count}</strong> 个素材
|
||||
</span>
|
||||
<div className="assets-batch-actions">
|
||||
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allSelected} onClick={onSelectAll}>全选</Button>
|
||||
<Button size="small" onClick={onClear}>取消选择</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={onExport}>导出</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
|
||||
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allSelected} onClick={onSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" onClick={onClear}>
|
||||
取消选择
|
||||
</Button>
|
||||
{isTrash ? (
|
||||
<>
|
||||
<Button size="small" type="primary" icon={<RotateCcw className="size-3.5" />} onClick={onRestore}>
|
||||
还原已选
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
|
||||
彻底删除已选
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={onExport}>
|
||||
导出
|
||||
</Button>
|
||||
<Button size="small" icon={<Trash2 className="size-3.5 text-amber-500" />} onClick={onArchive}>
|
||||
移入回收站
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
|
||||
彻底删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -628,21 +1067,29 @@ function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void;
|
||||
<span>{frame.caption}</span>
|
||||
</figure>
|
||||
))}
|
||||
<span className="assets-empty-banner-caption"><span>影策素材库</span>把每次创作的结果,留档成可复用的资产</span>
|
||||
<span className="assets-empty-banner-caption">
|
||||
<span>影策素材库</span>把每次创作的结果,留档成可复用的资产
|
||||
</span>
|
||||
</div>
|
||||
<div className="assets-empty-cards">
|
||||
<button type="button" className="assets-empty-card" onClick={onNew}>
|
||||
<span className="assets-empty-card-icon"><Plus /></span>
|
||||
<span className="assets-empty-card-icon">
|
||||
<Plus />
|
||||
</span>
|
||||
<strong>新建素材</strong>
|
||||
<span>录入提示词、说明文案,或上传图片资产。</span>
|
||||
</button>
|
||||
<button type="button" className="assets-empty-card" onClick={onImport}>
|
||||
<span className="assets-empty-card-icon"><FileUp /></span>
|
||||
<span className="assets-empty-card-icon">
|
||||
<FileUp />
|
||||
</span>
|
||||
<strong>导入素材包</strong>
|
||||
<span>从素材压缩包一键恢复旧资产,继续创作。</span>
|
||||
</button>
|
||||
<button type="button" className="assets-empty-card" onClick={onGoCanvas}>
|
||||
<span className="assets-empty-card-icon"><Clapperboard /></span>
|
||||
<span className="assets-empty-card-icon">
|
||||
<Clapperboard />
|
||||
</span>
|
||||
<strong>去画布保存</strong>
|
||||
<span>把画布上满意的镜头与画面留档进素材库。</span>
|
||||
</button>
|
||||
@@ -651,7 +1098,21 @@ function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void;
|
||||
);
|
||||
}
|
||||
|
||||
function AssetFilterGroup({ title, options, value, counts, onChange, className = "" }: { title: string; options: Array<{ label: string; value: string }>; value: string; counts: Map<string, number>; onChange: (value: string) => void; className?: string }) {
|
||||
function AssetFilterGroup({
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
counts,
|
||||
onChange,
|
||||
className = "",
|
||||
}: {
|
||||
title: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
value: string;
|
||||
counts: Map<string, number>;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">{title}</div>
|
||||
@@ -678,19 +1139,30 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
|
||||
{asset ? (
|
||||
<div className="space-y-4">
|
||||
<div className="asset-archive-header">
|
||||
<span className="asset-archive-header-icon"><KindIcon /></span>
|
||||
<span className="asset-archive-header-icon">
|
||||
<KindIcon />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="asset-archive-title">{asset.title}</h2>
|
||||
<p className="asset-archive-subtitle">{assetCategoryLabel(asset.category)} · {formatAssetDateTime(asset.createdAt)} 创建</p>
|
||||
<p className="asset-archive-subtitle">
|
||||
{assetCategoryLabel(asset.category)} · {formatAssetDateTime(asset.createdAt)} 创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-archive-preview">
|
||||
{asset.kind === "text" ? (
|
||||
<div className="asset-archive-preview-note">{asset.data.content}</div>
|
||||
) : asset.kind === "audio" ? (
|
||||
<div className="asset-archive-audio"><audio src={asset.data.url} controls /></div>
|
||||
<div className="asset-archive-audio">
|
||||
<audio src={asset.data.url} controls />
|
||||
</div>
|
||||
) : asset.kind === "model" ? (
|
||||
<div className="asset-archive-preview-model"><Box /><span>{asset.data.fileName} · {formatBytes(asset.data.bytes)}</span></div>
|
||||
<div className="asset-archive-preview-model">
|
||||
<Box />
|
||||
<span>
|
||||
{asset.data.fileName} · {formatBytes(asset.data.bytes)}
|
||||
</span>
|
||||
</div>
|
||||
) : asset.kind === "video" ? (
|
||||
<video src={asset.data.url} controls className="asset-archive-preview-media" />
|
||||
) : (
|
||||
@@ -699,7 +1171,9 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(asset.tags || []).map((tag) => (
|
||||
<Tag key={tag} className="m-0">{tag}</Tag>
|
||||
<Tag key={tag} className="m-0">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
<StorageTag asset={asset} />
|
||||
</div>
|
||||
@@ -707,11 +1181,17 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label} className="asset-archive-fact">
|
||||
<span className="asset-archive-fact-label">{fact.label}</span>
|
||||
<span className="asset-archive-fact-value" title={fact.value}>{fact.value}</span>
|
||||
<span className="asset-archive-fact-value" title={fact.value}>
|
||||
{fact.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="asset-archive-link"><Link2 /><span>所属项目</span><strong>{assetProjectLabel(asset)}</strong></div>
|
||||
<div className="asset-archive-link">
|
||||
<Link2 />
|
||||
<span>所属项目</span>
|
||||
<strong>{assetProjectLabel(asset)}</strong>
|
||||
</div>
|
||||
{asset.note ? (
|
||||
<div className="asset-archive-section">
|
||||
<span className="asset-archive-section-title">备注</span>
|
||||
@@ -720,10 +1200,14 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
|
||||
) : null}
|
||||
<div className="asset-archive-actions">
|
||||
{asset.kind === "text" ? (
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>复制文本</Button>
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
|
||||
复制文本
|
||||
</Button>
|
||||
) : null}
|
||||
{asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? (
|
||||
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>{assetDownloadLabel(asset)}</Button>
|
||||
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
|
||||
{assetDownloadLabel(asset)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Modal, Select } from "antd";
|
||||
import { ArrowDownAZ, Clock3, Download, FileUp, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
import { ArrowDownAZ, Clock3, Download, FileUp, History, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
|
||||
import { CollectionGrid, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
@@ -12,6 +12,7 @@ import { setMediaBlob } from "@/services/file-storage";
|
||||
import { setImageBlob } from "@/services/image-storage";
|
||||
import { CanvasCreateCard } from "@/components/canvas/canvas-project-card";
|
||||
import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
|
||||
import { CanvasHistoryDrawer } from "@/components/canvas/canvas-history-drawer";
|
||||
import type { CanvasExportFile } from "@/types/canvas-export";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
@@ -44,6 +45,7 @@ export default function CanvasPage() {
|
||||
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
|
||||
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
||||
const updateProject = useCanvasStore((state) => state.updateProject);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [associationOpen, setAssociationOpen] = useState(false);
|
||||
const [associationProjectId, setAssociationProjectId] = useState("");
|
||||
const projectQuery = useQuery({ queryKey: ["projects"], queryFn: () => listProjects() });
|
||||
@@ -382,6 +384,10 @@ export default function CanvasPage() {
|
||||
<span>{sortLabel}</span>
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button type="button" className="canvas-library-filter flex items-center gap-1.5" onClick={() => setHistoryOpen(true)} aria-label="查看画布创建与变更历史时间线" title="查看画布创建与变更历史时间线">
|
||||
<History className="size-3.5 text-[var(--workspace-accent)]" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
{keyword || projectFilter !== "all" || sort !== "updated" ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -483,6 +489,7 @@ export default function CanvasPage() {
|
||||
onChange={setAssociationProjectId}
|
||||
/>
|
||||
</Modal>
|
||||
<CanvasHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,15 +157,12 @@ export function useCanvasRenderModel({
|
||||
const nodeSpatialIndexRef = useRef<{ source: CanvasNodeData[]; index: CanvasSpatialIndex<string> } | null>(null);
|
||||
const nodeSpatialIndex = useMemo(() => {
|
||||
const previous = nodeSpatialIndexRef.current;
|
||||
const geometryUnchanged = previous
|
||||
&& previous.source.length === nodes.length
|
||||
&& nodes.every((node, index) => {
|
||||
const geometryUnchanged =
|
||||
previous &&
|
||||
previous.source.length === nodes.length &&
|
||||
nodes.every((node, index) => {
|
||||
const old = previous.source[index];
|
||||
return old.id === node.id
|
||||
&& old.position.x === node.position.x
|
||||
&& old.position.y === node.position.y
|
||||
&& old.width === node.width
|
||||
&& old.height === node.height;
|
||||
return old.id === node.id && old.position.x === node.position.x && old.position.y === node.position.y && old.width === node.width && old.height === node.height;
|
||||
});
|
||||
if (geometryUnchanged) return previous.index;
|
||||
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node.id })));
|
||||
@@ -179,7 +176,8 @@ export function useCanvasRenderModel({
|
||||
const renderedNodeIds = renderedNodeIdsRef.current;
|
||||
const renderBudget = canvasNodeRenderBudget(viewport.k);
|
||||
const forcedNodeIds = new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])].slice(0, renderBudget));
|
||||
const candidates = nodeSpatialIndex.query(renderBounds.retain, renderBudget + forcedNodeIds.size)
|
||||
const candidates = nodeSpatialIndex
|
||||
.query(renderBounds.retain, renderBudget + forcedNodeIds.size)
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
||||
const candidateIds = new Set(candidates.map((node) => node.id));
|
||||
@@ -193,10 +191,7 @@ export function useCanvasRenderModel({
|
||||
[...prioritized, ...remaining].forEach((node) => {
|
||||
if (renderHiddenNodeIds.has(node.id)) return;
|
||||
const retained = forcedNodeIds.has(node.id) || renderedNodeIds.has(node.id);
|
||||
const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left
|
||||
&& node.position.x < renderBounds.enter.right
|
||||
&& node.position.y + node.height > renderBounds.enter.top
|
||||
&& node.position.y < renderBounds.enter.bottom;
|
||||
const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left && node.position.x < renderBounds.enter.right && node.position.y + node.height > renderBounds.enter.top && node.position.y < renderBounds.enter.bottom;
|
||||
if (!retained && !insideEnterBounds) return;
|
||||
(isFrameNode(node) ? frames : regular).push(node);
|
||||
});
|
||||
@@ -206,7 +201,7 @@ export function useCanvasRenderModel({
|
||||
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
|
||||
}, [visibleNodes]);
|
||||
|
||||
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
|
||||
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image" && asset.status !== "archived"), [assets]);
|
||||
const semanticNodesRef = useRef(nodes);
|
||||
const semanticNodes = useMemo(() => {
|
||||
const previous = semanticNodesRef.current;
|
||||
@@ -239,9 +234,7 @@ export function useCanvasRenderModel({
|
||||
|
||||
const selectedNodeBounds = useMemo(() => {
|
||||
if (selectedNodeIds.size < 2) return null;
|
||||
const selectedNodes = [...selectedNodeIds]
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && !renderHiddenNodeIds.has(node.id)));
|
||||
const selectedNodes = [...selectedNodeIds].map((nodeId) => nodeById.get(nodeId)).filter((node): node is CanvasNodeData => Boolean(node && !renderHiddenNodeIds.has(node.id)));
|
||||
if (selectedNodes.length < 2) return null;
|
||||
const left = Math.min(...selectedNodes.map((node) => node.position.x));
|
||||
const top = Math.min(...selectedNodes.map((node) => node.position.y));
|
||||
@@ -249,14 +242,18 @@ 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 };
|
||||
}, [nodeById, renderHiddenNodeIds, selectedNodeIds]);
|
||||
const selectedVideoNodes = useMemo(() => [...selectedNodeIds]
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Video && 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;
|
||||
}), [nodeById, renderHiddenNodeIds, selectedNodeIds]);
|
||||
const selectedVideoNodes = useMemo(
|
||||
() =>
|
||||
[...selectedNodeIds]
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Video && 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;
|
||||
}),
|
||||
[nodeById, renderHiddenNodeIds, selectedNodeIds],
|
||||
);
|
||||
const relatedHighlight = useMemo(() => {
|
||||
const nodeIds = new Set<string>();
|
||||
const connectionIds = new Set<string>();
|
||||
@@ -344,7 +341,10 @@ export function useCanvasRenderModel({
|
||||
}
|
||||
return targetNodes;
|
||||
}, [activeNodeId, dialogNodeId, nodeById, visibleNodes]);
|
||||
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId, resourceReferenceTargetNodes), [activeNodeId, connections, dialogNodeId, resourceReferenceTargetNodes, semanticNodes]);
|
||||
const canvasResourceReferences = useMemo(
|
||||
() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId, resourceReferenceTargetNodes),
|
||||
[activeNodeId, connections, dialogNodeId, resourceReferenceTargetNodes, semanticNodes],
|
||||
);
|
||||
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
|
||||
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
|
||||
const mentionReferencesByNodeId = useMemo(() => {
|
||||
|
||||
@@ -49,6 +49,7 @@ export type RuntimeLimits = {
|
||||
activeTaskLimit: number;
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
recycleBinRetentionDays?: number;
|
||||
};
|
||||
|
||||
export type ApiCallLog = {
|
||||
@@ -318,6 +319,7 @@ export type RuntimeResourcePolicy = {
|
||||
sessionCount: number;
|
||||
taskCount: number;
|
||||
apiCallLogCount: number;
|
||||
recycleBinRetentionDays?: number;
|
||||
};
|
||||
|
||||
export type RuntimeTaskPolicy = {
|
||||
|
||||
@@ -8,6 +8,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";
|
||||
@@ -134,16 +135,31 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||||
if (!projectIds.length) return;
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
if (activeRemoteUserId) requireRemoteUserDataBaseline();
|
||||
const currentProjects = useCanvasStore.getState().projects;
|
||||
const projectById = new Map(currentProjects.map((project) => [project.id, project]));
|
||||
const deletedProjectIds: string[] = [];
|
||||
const deletedProjectObjects: CanvasProject[] = [];
|
||||
let deletionError: unknown;
|
||||
for (const id of projectIds) {
|
||||
if (activeRemoteUserId) {
|
||||
await deleteRemoteCanvasProject(id);
|
||||
acknowledgedProjects.delete(id);
|
||||
try {
|
||||
if (activeRemoteUserId) {
|
||||
await deleteRemoteCanvasProject(id);
|
||||
acknowledgedProjects.delete(id);
|
||||
}
|
||||
useCanvasStore.getState().deleteProjects([id]);
|
||||
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
|
||||
await flushCanvasStorePersistence();
|
||||
deletedProjectIds.push(id);
|
||||
const project = projectById.get(id);
|
||||
if (project) deletedProjectObjects.push(project);
|
||||
} catch (error) {
|
||||
deletionError = error;
|
||||
break;
|
||||
}
|
||||
useCanvasStore.getState().deleteProjects([id]);
|
||||
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
|
||||
await flushCanvasStorePersistence();
|
||||
}
|
||||
// 清理仅属于被删除画布的自动生成素材
|
||||
if (deletedProjectObjects.length > 0) useCanvasHistoryStore.getState().recordDeletedProjects(deletedProjectObjects);
|
||||
|
||||
// 将属于被删除画布的所有媒体节点安全归档至素材库回收站 (status = "archived")
|
||||
const currentAssets = useAssetStore.getState().assets;
|
||||
const remainingProjects = useCanvasStore.getState().projects;
|
||||
const activeAssetIds = new Set<string>();
|
||||
@@ -152,25 +168,122 @@ 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 && deletedProjectIds.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) {
|
||||
const owningCanvasId = existingAsset.metadata?.canvasId as string | undefined;
|
||||
if (owningCanvasId === project.id && !activeAssetIds.has(existingAsset.id) && 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) {
|
||||
scheduleRemoteUserDataSync();
|
||||
console.warn("回收站素材云端同步警告:", syncErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deletionError) throw deletionError;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
67
web/src/stores/canvas/use-canvas-history-store.ts
Normal file
67
web/src/stores/canvas/use-canvas-history-store.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, type PersistStorage } from "zustand/middleware";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
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 CANVAS_HISTORY_STORE_KEY = "infinite-canvas:deleted_history_store";
|
||||
|
||||
const historyStorage: PersistStorage<CanvasHistoryStore> = {
|
||||
getItem: async (name) => {
|
||||
const value = await localForageStorageForScope().getItem(name);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: (name, value) => localForageStorageForScope().setItem(name, JSON.stringify(value)),
|
||||
removeItem: (name) => localForageStorageForScope().removeItem(name),
|
||||
};
|
||||
|
||||
export const useCanvasHistoryStore = create<CanvasHistoryStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
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,
|
||||
}));
|
||||
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: CANVAS_HISTORY_STORE_KEY,
|
||||
storage: historyStorage,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -22,6 +22,7 @@ export type RuntimeLimits = {
|
||||
activeTaskLimit: number;
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
recycleBinRetentionDays?: number;
|
||||
};
|
||||
|
||||
export type FeatureAvailability = {
|
||||
@@ -66,13 +67,13 @@ type UserStore = {
|
||||
export const useUserStore = create<UserStore>()((set) => ({
|
||||
hydrated: false,
|
||||
user: null,
|
||||
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 },
|
||||
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32, recycleBinRetentionDays: 30 },
|
||||
drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE },
|
||||
features: defaultFeatureAvailability,
|
||||
setUser: (user) => set({ user }),
|
||||
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
|
||||
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32, recycleBinRetentionDays: 30 } }),
|
||||
setDrawingEngine: (drawingEngine) => set({ drawingEngine: drawingEngine || { defaultEngine: DEFAULT_DRAWING_ENGINE } }),
|
||||
setFeatures: (features) => set({ features: features ? { ...defaultFeatureAvailability, ...features } : defaultFeatureAvailability }),
|
||||
setHydrated: (hydrated) => set({ hydrated }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE }, features: defaultFeatureAvailability }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32, recycleBinRetentionDays: 30 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE }, features: defaultFeatureAvailability }),
|
||||
}));
|
||||
|
||||
@@ -17,16 +17,19 @@ import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, typ
|
||||
import { deleteAssetWithRemoteSync, deleteCanvasProjectsWithRemoteSync, installRemoteUserDataAutoSync, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "../src/services/user-data-sync";
|
||||
import { apiClient } from "../src/services/api/request";
|
||||
import { useUserStore } from "../src/stores/use-user-store";
|
||||
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "../src/stores/canvas/use-canvas-history-store";
|
||||
|
||||
test("creation recovery observes streaming text tasks after reload", () => {
|
||||
const conversations = [{
|
||||
id: "conversation-text-recovery",
|
||||
messages: [
|
||||
{ id: "text-streaming", role: "assistant" as const, mode: "text", status: "streaming", taskIds: ["task-text"] },
|
||||
{ id: "text-done", role: "assistant" as const, mode: "text", status: "done", taskIds: ["task-text-done"] },
|
||||
{ id: "image-pending", role: "assistant" as const, mode: "image", status: "pending", taskIds: ["task-image"] },
|
||||
],
|
||||
}];
|
||||
const conversations = [
|
||||
{
|
||||
id: "conversation-text-recovery",
|
||||
messages: [
|
||||
{ id: "text-streaming", role: "assistant" as const, mode: "text", status: "streaming", taskIds: ["task-text"] },
|
||||
{ id: "text-done", role: "assistant" as const, mode: "text", status: "done", taskIds: ["task-text-done"] },
|
||||
{ id: "image-pending", role: "assistant" as const, mode: "image", status: "pending", taskIds: ["task-image"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(pendingCreationTaskIds(conversations)).toEqual(["task-text", "task-image"]);
|
||||
expect(pendingCreationTaskKey(conversations)).toContain("conversation-text-recovery:text-streaming:task-text");
|
||||
@@ -788,9 +791,13 @@ test("generation Asset publication preserves an ordinary edit queued behind its
|
||||
const effectKey = "materialize:stale-publication:0";
|
||||
let gateGenerationRead = false;
|
||||
let markGenerationReadStarted!: () => void;
|
||||
const generationReadStarted = new Promise<void>((resolve) => { markGenerationReadStarted = resolve; });
|
||||
const generationReadStarted = new Promise<void>((resolve) => {
|
||||
markGenerationReadStarted = resolve;
|
||||
});
|
||||
let releaseGenerationRead!: () => void;
|
||||
const generationReadGate = new Promise<void>((resolve) => { releaseGenerationRead = resolve; });
|
||||
const generationReadGate = new Promise<void>((resolve) => {
|
||||
releaseGenerationRead = resolve;
|
||||
});
|
||||
localforage.getItem = (async (key: string) => {
|
||||
const snapshot = values.get(key) ?? null;
|
||||
if (key === assetKey && gateGenerationRead) {
|
||||
@@ -4471,3 +4478,57 @@ test("user session stays unhydrated until the remote baseline is durable", async
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test("deleted canvas history rehydrates from the active account scope only", async () => {
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
const originalGetItem = localforage.getItem.bind(localforage);
|
||||
const originalSetItem = localforage.setItem.bind(localforage);
|
||||
const previousScope = getActiveUserScope();
|
||||
const previousHistory = useCanvasHistoryStore.getState().deletedProjects;
|
||||
const values = new Map<string, string>();
|
||||
const localStorageValues = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||||
removeItem: (key: string) => localStorageValues.delete(key),
|
||||
},
|
||||
},
|
||||
});
|
||||
localforage.getItem = (async (key: string) => values.get(key) ?? null) as typeof localforage.getItem;
|
||||
localforage.setItem = (async (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
return value;
|
||||
}) as typeof localforage.setItem;
|
||||
|
||||
try {
|
||||
setActiveUserScope("history-account-A");
|
||||
useCanvasHistoryStore.setState({ deletedProjects: [] });
|
||||
useCanvasHistoryStore.getState().recordDeletedProjects([storedCanvasProject("deleted-A", "账号 A 的历史")]);
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
|
||||
setActiveUserScope("history-account-B");
|
||||
useCanvasHistoryStore.setState({ deletedProjects: [] });
|
||||
useCanvasHistoryStore.getState().recordDeletedProjects([storedCanvasProject("deleted-B", "账号 B 的历史")]);
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
|
||||
setActiveUserScope("history-account-A");
|
||||
await useCanvasHistoryStore.persist.rehydrate();
|
||||
expect(useCanvasHistoryStore.getState().deletedProjects.map((item) => item.id)).toEqual(["deleted-A"]);
|
||||
|
||||
setActiveUserScope("history-account-B");
|
||||
await useCanvasHistoryStore.persist.rehydrate();
|
||||
expect(useCanvasHistoryStore.getState().deletedProjects.map((item) => item.id)).toEqual(["deleted-B"]);
|
||||
expect(values.has(`${CANVAS_HISTORY_STORE_KEY}:user:history-account-A`)).toBe(true);
|
||||
expect(values.has(`${CANVAS_HISTORY_STORE_KEY}:user:history-account-B`)).toBe(true);
|
||||
} finally {
|
||||
setActiveUserScope(previousScope);
|
||||
useCanvasHistoryStore.setState({ deletedProjects: previousHistory });
|
||||
localforage.getItem = originalGetItem;
|
||||
localforage.setItem = originalSetItem;
|
||||
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user