diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index a81de6ea3..22faed045 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -33,6 +33,7 @@ func Models() []any { &model.Resource{}, &model.Asset{}, &model.ProjectAssetLink{}, + &model.ProjectAssetFolder{}, &model.ProjectAssetCandidate{}, &model.AssetVersion{}, &model.AssetRepresentation{}, diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index 3ad0502dc..8e42eb4f4 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -308,6 +308,85 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"assets": assets}) }) + r.GET("/projects/:id/asset-folders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + folders, err := svc.ProjectAssetFolders(user.ID, c.Param("id")) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"folders": folders}) + }) + r.POST("/projects/:id/asset-folders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) + var req service.CreateProjectAssetFolderRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + folder, err := svc.CreateProjectAssetFolder(user.ID, c.Param("id"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"folder": folder}) + }) + r.PATCH("/projects/:id/asset-folders/:folderId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) + var req service.UpdateProjectAssetFolderRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + folder, err := svc.UpdateProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"folder": folder}) + }) + r.DELETE("/projects/:id/asset-folders/:folderId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId")); err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("folderId")}) + }) r.POST("/projects/:id/characters", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -448,12 +527,12 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.UpdateProjectAssetCategoryRequest + var req service.UpdateProjectAssetRequest if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } - asset, err := svc.UpdateProjectAssetCategory(user.ID, c.Param("id"), c.Param("assetId"), req) + asset, err := svc.UpdateProjectAsset(user.ID, c.Param("id"), c.Param("assetId"), req) if err != nil { failService(c, err) return diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index d2c2bce90..82875dd30 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -42,9 +42,24 @@ type ProjectAssetLink struct { ID string `json:"id" gorm:"primaryKey;size:36"` ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` + FolderID string `json:"folderId,omitempty" gorm:"index;size:36"` + Position int `json:"position" gorm:"index"` CreatedAt time.Time `json:"createdAt"` } +// ProjectAssetFolder 只保存项目内的目录结构;真实媒体仍由 Asset/Resource 唯一持有。 +type ProjectAssetFolder struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_folders_sibling_name,priority:1"` + ParentID string `json:"parentId,omitempty" gorm:"index;size:36;uniqueIndex:idx_project_asset_folders_sibling_name,priority:2"` + Name string `json:"name" gorm:"size:240"` + NameKey string `json:"-" gorm:"size:240;uniqueIndex:idx_project_asset_folders_sibling_name,priority:3"` + Style string `json:"style" gorm:"size:24"` + Position int `json:"position" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + type ProjectAssetCandidate struct { ID string `json:"id" gorm:"primaryKey;size:36"` ProjectID string `json:"projectId" gorm:"index;size:36"` diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 77760ed47..6319bc127 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -24,6 +24,8 @@ var ErrTextReplayQuotaExceeded = errors.New("text replay quota exceeded") var ErrTextReplayClosed = errors.New("text replay task is closed") +var ErrProjectAssetFolderNotEmpty = errors.New("project asset folder is not empty") + type Repository struct { db *gorm.DB } @@ -968,6 +970,9 @@ func (r *Repository) DeleteProject(userID string, id string) error { if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetLink{}).Error; err != nil { return err } + if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetFolder{}).Error; err != nil { + return err + } if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { return err } @@ -1156,6 +1161,110 @@ func (r *Repository) ProjectAssets(userID string, projectID string) ([]model.Ass return assets, err } +func (r *Repository) ProjectAssetLinks(projectID string) ([]model.ProjectAssetLink, error) { + var links []model.ProjectAssetLink + err := r.db.Where("project_id = ?", projectID).Order("folder_id asc, position asc, created_at asc").Find(&links).Error + return links, err +} + +func (r *Repository) ProjectAssetLink(projectID string, assetID string) (*model.ProjectAssetLink, error) { + var link model.ProjectAssetLink + if err := r.db.First(&link, "project_id = ? AND asset_id = ?", projectID, assetID).Error; err != nil { + return nil, err + } + return &link, nil +} + +func (r *Repository) NextProjectAssetPosition(projectID string, folderID string) (int, error) { + var result struct{ Maximum int } + err := r.db.Model(&model.ProjectAssetLink{}). + Select("COALESCE(MAX(position), -1) AS maximum"). + Where("project_id = ? AND folder_id = ?", projectID, folderID). + Scan(&result).Error + return result.Maximum + 1, err +} + +func (r *Repository) MoveProjectAsset(projectID string, assetID string, folderID string, position int) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ProjectAssetLink{}). + Where("project_id = ? AND asset_id = ?", projectID, assetID). + Updates(map[string]any{"folder_id": folderID, "position": position}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", projectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + +func (r *Repository) ProjectAssetFolders(projectID string) ([]model.ProjectAssetFolder, error) { + var folders []model.ProjectAssetFolder + err := r.db.Where("project_id = ?", projectID).Order("parent_id asc, position asc, created_at asc").Find(&folders).Error + return folders, err +} + +func (r *Repository) ProjectAssetFolder(projectID string, folderID string) (*model.ProjectAssetFolder, error) { + var folder model.ProjectAssetFolder + if err := r.db.First(&folder, "id = ? AND project_id = ?", folderID, projectID).Error; err != nil { + return nil, err + } + return &folder, nil +} + +func (r *Repository) CreateProjectAssetFolder(folder *model.ProjectAssetFolder) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(folder).Error; err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error + }) +} + +func (r *Repository) UpdateProjectAssetFolder(folder *model.ProjectAssetFolder) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ProjectAssetFolder{}). + Where("id = ? AND project_id = ?", folder.ID, folder.ProjectID). + Updates(map[string]any{"parent_id": folder.ParentID, "name": folder.Name, "name_key": folder.NameKey, "style": folder.Style, "position": folder.Position, "updated_at": folder.UpdatedAt}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error + }) +} + +func (r *Repository) DeleteProjectAssetFolder(projectID string, folderID string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var childCount int64 + if err := tx.Model(&model.ProjectAssetFolder{}).Where("project_id = ? AND parent_id = ?", projectID, folderID).Count(&childCount).Error; err != nil { + return err + } + var assetCount int64 + if err := tx.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND folder_id = ?", projectID, folderID).Count(&assetCount).Error; err != nil { + return err + } + if childCount > 0 || assetCount > 0 { + return ErrProjectAssetFolderNotEmpty + } + result := tx.Delete(&model.ProjectAssetFolder{}, "id = ? AND project_id = ?", folderID, projectID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", projectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + // LinkProjectAsset 将首版本、素材领域字段、项目引用和修订号原子提交,避免产生半关联资产。 func (r *Repository) LinkProjectAsset(asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) (bool, error) { createdLink := false diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index d6df1b6da..d969ead0c 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -73,6 +73,7 @@ type ProjectDetail struct { Canvases []model.CanvasProject `json:"canvases"` CanvasUnitLinks []model.CanvasUnitLink `json:"canvasUnitLinks"` Assets []ProjectAssetSummary `json:"assets"` + AssetFolders []model.ProjectAssetFolder `json:"assetFolders"` Workflows []ProjectWorkflowDetail `json:"workflows"` Shots []model.Shot `json:"shots"` ShotReferences []model.ShotAssetReference `json:"shotReferences"` @@ -138,6 +139,10 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) if err != nil { return ProjectDetail{}, err } + assetFolders, err := s.ProjectAssetFolders(userID, project.ID) + if err != nil { + return ProjectDetail{}, err + } workflows, err := s.ProjectWorkflows(project.ID) if err != nil { return ProjectDetail{}, err @@ -154,7 +159,7 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) if err != nil { return ProjectDetail{}, err } - return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil + return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil } func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.Project, error) { diff --git a/backend/internal/service/project_asset.go b/backend/internal/service/project_asset.go index aed93a673..f7f68c322 100644 --- a/backend/internal/service/project_asset.go +++ b/backend/internal/service/project_asset.go @@ -9,12 +9,14 @@ import ( ) type LinkProjectAssetRequest struct { - AssetID string `json:"assetId"` - Category string `json:"category"` + AssetID string `json:"assetId"` + Category string `json:"category"` + FolderID *string `json:"folderId"` } -type UpdateProjectAssetCategoryRequest struct { - Category string `json:"category"` +type UpdateProjectAssetRequest struct { + Category *string `json:"category"` + FolderID *string `json:"folderId"` } type CreateAssetVersionRequest struct { @@ -32,6 +34,10 @@ type ProjectAssetSummary struct { PrimaryVersionID string `json:"primaryVersionId,omitempty"` VersionCount int `json:"versionCount"` Usages []string `json:"usages"` + FolderID string `json:"folderId,omitempty"` + Position int `json:"position"` + StorageKey string `json:"storageKey,omitempty"` + PreviewText string `json:"previewText,omitempty"` UpdatedAt time.Time `json:"updatedAt"` Character *CharacterCardSummary `json:"character,omitempty"` } @@ -103,20 +109,16 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj if !validAssetCategory(category) { return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类") } + folderID, err := s.resolveProjectAssetFolderID(projectID, req.FolderID) + if err != nil { + return ProjectAssetSummary{}, err + } linked, err := s.repo.ProjectAssetLinked(projectID, asset.ID) if err != nil { return ProjectAssetSummary{}, err } if linked { - versions, versionErr := s.repo.AssetVersions(asset.ID) - if versionErr != nil { - return ProjectAssetSummary{}, versionErr - } - usages, usageErr := s.repo.ProjectAssetUsageRoles(projectID, asset.ID) - if usageErr != nil { - return ProjectAssetSummary{}, usageErr - } - return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}, nil + return s.projectAssetSummary(userID, projectID, asset) } now := time.Now() asset.Category = category @@ -135,7 +137,11 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj versions = append(versions, version) } asset.UpdatedAt = now - link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: asset.ID, CreatedAt: now} + position, err := s.repo.NextProjectAssetPosition(projectID, folderID) + if err != nil { + return ProjectAssetSummary{}, err + } + link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: asset.ID, FolderID: folderID, Position: position, CreatedAt: now} created, err := s.repo.LinkProjectAsset(asset, initialVersion, &link) if err != nil { return ProjectAssetSummary{}, err @@ -145,17 +151,9 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj if currentErr != nil { return ProjectAssetSummary{}, currentErr } - currentVersions, versionErr := s.repo.AssetVersions(asset.ID) - if versionErr != nil { - return ProjectAssetSummary{}, versionErr - } - usages, usageErr := s.repo.ProjectAssetUsageRoles(projectID, asset.ID) - if usageErr != nil { - return ProjectAssetSummary{}, usageErr - } - return ProjectAssetSummary{ID: current.ID, Title: current.Title, MediaType: current.Kind, Category: current.Category, Status: current.Status, PrimaryVersionID: current.PrimaryVersionID, VersionCount: len(currentVersions), Usages: usages, UpdatedAt: current.UpdatedAt}, nil + return s.projectAssetSummary(userID, projectID, current) } - return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: []string{}, UpdatedAt: asset.UpdatedAt}, nil + return s.projectAssetSummary(userID, projectID, asset) } func (s *Service) UnlinkProjectAsset(userID string, projectID string, assetID string) error { @@ -214,7 +212,7 @@ func canvasReferencesCharacterAsset(payloadJSON string, assetID string) (bool, e return false, nil } -func (s *Service) UpdateProjectAssetCategory(userID string, projectID string, assetID string, req UpdateProjectAssetCategoryRequest) (ProjectAssetSummary, error) { +func (s *Service) UpdateProjectAsset(userID string, projectID string, assetID string, req UpdateProjectAssetRequest) (ProjectAssetSummary, error) { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return ProjectAssetSummary{}, err } @@ -229,27 +227,38 @@ func (s *Service) UpdateProjectAssetCategory(userID string, projectID string, as if !linked { return ProjectAssetSummary{}, BadAuthRequest("素材尚未加入当前项目") } - category := model.AssetCategory(strings.TrimSpace(req.Category)) - if !validAssetCategory(category) { - return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类") + if req.Category != nil && req.FolderID != nil { + return ProjectAssetSummary{}, BadAuthRequest("一次只能修改素材分类或所在文件夹") } - asset.Category = category - asset.UpdatedAt = time.Now() - if err := s.repo.UpdateAssetDomain(asset); err != nil { - return ProjectAssetSummary{}, err + if req.Category != nil { + category := model.AssetCategory(strings.TrimSpace(*req.Category)) + if !validAssetCategory(category) { + return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类") + } + asset.Category = category + asset.UpdatedAt = time.Now() + if err := s.repo.UpdateAssetDomain(asset); err != nil { + return ProjectAssetSummary{}, err + } + if err := s.repo.BumpProjectRevision(projectID); err != nil { + return ProjectAssetSummary{}, err + } + } else if req.FolderID != nil { + folderID, folderErr := s.resolveProjectAssetFolderID(projectID, req.FolderID) + if folderErr != nil { + return ProjectAssetSummary{}, folderErr + } + position, positionErr := s.repo.NextProjectAssetPosition(projectID, folderID) + if positionErr != nil { + return ProjectAssetSummary{}, positionErr + } + if err := s.repo.MoveProjectAsset(projectID, asset.ID, folderID, position); err != nil { + return ProjectAssetSummary{}, err + } + } else { + return ProjectAssetSummary{}, BadAuthRequest("没有可更新的素材字段") } - if err := s.repo.BumpProjectRevision(projectID); err != nil { - return ProjectAssetSummary{}, err - } - versions, err := s.repo.AssetVersions(asset.ID) - if err != nil { - return ProjectAssetSummary{}, err - } - usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID) - if err != nil { - return ProjectAssetSummary{}, err - } - return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}, nil + return s.projectAssetSummary(userID, projectID, asset) } func (s *Service) CreateProjectAssetVersion(userID string, projectID string, assetID string, req CreateAssetVersionRequest) (model.AssetVersion, error) { @@ -373,7 +382,15 @@ func (s *Service) ConfirmProjectAssetCandidate(userID string, projectID string, candidate.Status = "confirmed" candidate.ResolvedAssetID = assetID candidate.UpdatedAt = now - link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, CreatedAt: now} + folderID, folderErr := s.resolveProjectAssetFolderID(projectID, nil) + if folderErr != nil { + return ProjectAssetSummary{}, folderErr + } + position, positionErr := s.repo.NextProjectAssetPosition(projectID, folderID) + if positionErr != nil { + return ProjectAssetSummary{}, positionErr + } + link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now} if err := s.repo.ConfirmProjectAssetCandidate(candidate, &asset, &version, &link, createAsset); err != nil { return ProjectAssetSummary{}, err } @@ -461,7 +478,12 @@ func (s *Service) projectAssetSummary(userID string, projectID string, asset *mo if err != nil { return ProjectAssetSummary{}, err } - summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt} + link, err := s.repo.ProjectAssetLink(projectID, asset.ID) + if err != nil { + return ProjectAssetSummary{}, err + } + storageKey, previewText := projectAssetPreview(asset.PayloadJSON) + summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, FolderID: link.FolderID, Position: link.Position, StorageKey: storageKey, PreviewText: previewText, UpdatedAt: asset.UpdatedAt} if asset.Category == model.AssetCategoryCharacter && asset.PrimaryVersionID != "" { card, cardErr := s.characterCard(userID, asset) if cardErr != nil { @@ -471,3 +493,20 @@ func (s *Service) projectAssetSummary(userID string, projectID string, asset *mo } return summary, nil } + +func projectAssetPreview(payloadJSON string) (string, string) { + var payload struct { + Data struct { + StorageKey string `json:"storageKey"` + Content string `json:"content"` + } `json:"data"` + } + if json.Unmarshal([]byte(payloadJSON), &payload) != nil { + return "", "" + } + previewRunes := []rune(strings.TrimSpace(payload.Data.Content)) + if len(previewRunes) > 240 { + previewRunes = append(previewRunes[:240], '…') + } + return strings.TrimSpace(payload.Data.StorageKey), string(previewRunes) +} diff --git a/backend/internal/service/project_asset_folder.go b/backend/internal/service/project_asset_folder.go new file mode 100644 index 000000000..1a42b87ab --- /dev/null +++ b/backend/internal/service/project_asset_folder.go @@ -0,0 +1,256 @@ +package service + +import ( + "errors" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +const projectAssetFolderMaxDepth = 8 + +type CreateProjectAssetFolderRequest struct { + Name string `json:"name"` + ParentID string `json:"parentId"` + Style string `json:"style"` +} + +type UpdateProjectAssetFolderRequest struct { + Name *string `json:"name"` + ParentID *string `json:"parentId"` + Style *string `json:"style"` +} + +func (s *Service) ProjectAssetFolders(userID string, projectID string) ([]model.ProjectAssetFolder, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return nil, err + } + return s.repo.ProjectAssetFolders(projectID) +} + +func (s *Service) CreateProjectAssetFolder(userID string, projectID string, req CreateProjectAssetFolderRequest) (model.ProjectAssetFolder, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return model.ProjectAssetFolder{}, err + } + folders, err := s.repo.ProjectAssetFolders(projectID) + if err != nil { + return model.ProjectAssetFolder{}, err + } + name, err := validateProjectAssetFolderName(req.Name) + if err != nil { + return model.ProjectAssetFolder{}, err + } + parentID := strings.TrimSpace(req.ParentID) + if err := validateProjectAssetFolderParent(folders, "", parentID); err != nil { + return model.ProjectAssetFolder{}, err + } + if projectAssetFolderNameExists(folders, parentID, name, "") { + return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹") + } + position := nextProjectAssetFolderPosition(folders, parentID) + now := time.Now() + style, err := validateProjectAssetFolderStyle(req.Style) + if err != nil { + return model.ProjectAssetFolder{}, err + } + folder := model.ProjectAssetFolder{ID: newID(), ProjectID: projectID, ParentID: parentID, Name: name, NameKey: projectAssetFolderNameKey(name), Style: style, Position: position, CreatedAt: now, UpdatedAt: now} + if err := s.repo.CreateProjectAssetFolder(&folder); err != nil { + if isProjectAssetFolderNameConflict(err) { + return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹") + } + return model.ProjectAssetFolder{}, err + } + return folder, nil +} + +func (s *Service) UpdateProjectAssetFolder(userID string, projectID string, folderID string, req UpdateProjectAssetFolderRequest) (model.ProjectAssetFolder, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return model.ProjectAssetFolder{}, err + } + folder, err := s.repo.ProjectAssetFolder(projectID, strings.TrimSpace(folderID)) + if err != nil { + return model.ProjectAssetFolder{}, err + } + folders, err := s.repo.ProjectAssetFolders(projectID) + if err != nil { + return model.ProjectAssetFolder{}, err + } + if req.Name != nil { + name, nameErr := validateProjectAssetFolderName(*req.Name) + if nameErr != nil { + return model.ProjectAssetFolder{}, nameErr + } + folder.Name = name + folder.NameKey = projectAssetFolderNameKey(name) + } + if req.ParentID != nil { + parentID := strings.TrimSpace(*req.ParentID) + if err := validateProjectAssetFolderParent(folders, folder.ID, parentID); err != nil { + return model.ProjectAssetFolder{}, err + } + if parentID != folder.ParentID { + folder.ParentID = parentID + folder.Position = nextProjectAssetFolderPosition(folders, parentID) + } + } + if req.Style != nil { + style, styleErr := validateProjectAssetFolderStyle(*req.Style) + if styleErr != nil { + return model.ProjectAssetFolder{}, styleErr + } + folder.Style = style + } + if projectAssetFolderNameExists(folders, folder.ParentID, folder.Name, folder.ID) { + return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹") + } + folder.UpdatedAt = time.Now() + if err := s.repo.UpdateProjectAssetFolder(folder); err != nil { + if isProjectAssetFolderNameConflict(err) { + return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹") + } + return model.ProjectAssetFolder{}, err + } + return *folder, nil +} + +func (s *Service) DeleteProjectAssetFolder(userID string, projectID string, folderID string) error { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return err + } + err := s.repo.DeleteProjectAssetFolder(projectID, strings.TrimSpace(folderID)) + if errors.Is(err, repository.ErrProjectAssetFolderNotEmpty) { + return BadAuthRequest("文件夹非空,请先移动其中的素材和子文件夹") + } + return err +} + +func (s *Service) resolveProjectAssetFolderID(projectID string, requested *string) (string, error) { + if requested == nil || strings.TrimSpace(*requested) == "" { + return "", nil + } + folderID := strings.TrimSpace(*requested) + if _, err := s.repo.ProjectAssetFolder(projectID, folderID); err != nil { + return "", BadAuthRequest("目标文件夹不存在或不属于当前素材库") + } + return folderID, nil +} + +func validateProjectAssetFolderStyle(value string) (string, error) { + style := strings.TrimSpace(value) + if style == "" { + return "glass", nil + } + switch style { + case "glass", "stacked", "midnight", "paper", "cinema", "compact": + return style, nil + default: + return "", BadAuthRequest("不支持的文件夹样式") + } +} + +func validateProjectAssetFolderName(value string) (string, error) { + name := strings.TrimSpace(value) + if name == "" { + return "", BadAuthRequest("文件夹名称不能为空") + } + if len([]rune(name)) > 60 { + return "", BadAuthRequest("文件夹名称不能超过 60 个字符") + } + return name, nil +} + +func validateProjectAssetFolderParent(folders []model.ProjectAssetFolder, folderID string, parentID string) error { + byID := make(map[string]model.ProjectAssetFolder, len(folders)) + for _, folder := range folders { + byID[folder.ID] = folder + } + ancestorDepth := 0 + currentID := parentID + seen := make(map[string]struct{}, projectAssetFolderMaxDepth) + for currentID != "" { + if currentID == folderID && folderID != "" { + return BadAuthRequest("文件夹不能移动到自身或其子目录") + } + if _, exists := seen[currentID]; exists { + return BadAuthRequest("文件夹目录关系存在循环") + } + seen[currentID] = struct{}{} + current, ok := byID[currentID] + if !ok { + return BadAuthRequest("父文件夹不存在或不属于当前项目") + } + ancestorDepth++ + currentID = current.ParentID + } + subtreeHeight := projectAssetFolderSubtreeHeight(folders, folderID) + if ancestorDepth+subtreeHeight > projectAssetFolderMaxDepth { + return BadAuthRequest("文件夹层级不能超过 8 层") + } + return nil +} + +func projectAssetFolderSubtreeHeight(folders []model.ProjectAssetFolder, folderID string) int { + if folderID == "" { + return 1 + } + maxDepth := 1 + queue := []struct { + id string + depth int + }{{id: folderID, depth: 1}} + seen := map[string]struct{}{folderID: {}} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if current.depth > maxDepth { + maxDepth = current.depth + } + for _, folder := range folders { + if folder.ParentID != current.id { + continue + } + if _, exists := seen[folder.ID]; exists { + continue + } + seen[folder.ID] = struct{}{} + queue = append(queue, struct { + id string + depth int + }{id: folder.ID, depth: current.depth + 1}) + } + } + return maxDepth +} + +func projectAssetFolderNameExists(folders []model.ProjectAssetFolder, parentID string, name string, excludeID string) bool { + for _, folder := range folders { + if folder.ID != excludeID && folder.ParentID == parentID && strings.EqualFold(folder.Name, name) { + return true + } + } + return false +} + +func projectAssetFolderNameKey(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func isProjectAssetFolderNameConflict(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "idx_project_asset_folders_sibling_name") || strings.Contains(message, "unique constraint") || strings.Contains(message, "duplicate key") +} + +func nextProjectAssetFolderPosition(folders []model.ProjectAssetFolder, parentID string) int { + position := 0 + for _, folder := range folders { + if folder.ParentID == parentID && folder.Position >= position { + position = folder.Position + 1 + } + } + return position +} diff --git a/backend/internal/service/project_asset_folder_test.go b/backend/internal/service/project_asset_folder_test.go new file mode 100644 index 000000000..f85e573bb --- /dev/null +++ b/backend/internal/service/project_asset_folder_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestValidateProjectAssetFolderParentRejectsCycle(t *testing.T) { + folders := []model.ProjectAssetFolder{ + {ID: "root", ParentID: ""}, + {ID: "child", ParentID: "root"}, + {ID: "leaf", ParentID: "child"}, + } + if err := validateProjectAssetFolderParent(folders, "root", "leaf"); err == nil { + t.Fatal("expected moving a folder into its descendant to fail") + } +} + +func TestValidateProjectAssetFolderParentLimitsCombinedDepth(t *testing.T) { + folders := []model.ProjectAssetFolder{ + {ID: "a", ParentID: ""}, + {ID: "b", ParentID: "a"}, + {ID: "c", ParentID: "b"}, + {ID: "d", ParentID: "c"}, + {ID: "e", ParentID: "d"}, + {ID: "f", ParentID: "e"}, + {ID: "g", ParentID: "f"}, + {ID: "h", ParentID: "g"}, + } + if err := validateProjectAssetFolderParent(folders, "", "h"); err == nil { + t.Fatal("expected a ninth level folder to fail") + } + if err := validateProjectAssetFolderParent(folders, "", "g"); err != nil { + t.Fatalf("expected an eighth level folder to be allowed: %v", err) + } +} + +func TestProjectAssetFolderNameIsUniqueWithinSiblingOnly(t *testing.T) { + folders := []model.ProjectAssetFolder{ + {ID: "first", ParentID: "one", Name: "Reference"}, + {ID: "second", ParentID: "two", Name: "Reference"}, + } + if !projectAssetFolderNameExists(folders, "one", "reference", "") { + t.Fatal("expected case-insensitive duplicate in the same parent") + } + if projectAssetFolderNameExists(folders, "three", "Reference", "") { + t.Fatal("did not expect a duplicate across different parents") + } +} diff --git a/backend/internal/service/project_character.go b/backend/internal/service/project_character.go index 921ab4bf1..1d1e2fdf4 100644 --- a/backend/internal/service/project_character.go +++ b/backend/internal/service/project_character.go @@ -136,7 +136,15 @@ func (s *Service) CreateProjectCharacter(userID string, projectID string, req Cr } asset := model.Asset{ID: assetID, UserID: userID, Kind: "entity", Category: model.AssetCategoryCharacter, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: name, PayloadJSON: payload, CreatedAt: now, UpdatedAt: now} version := model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: string(definition), CreatedAt: now, UpdatedAt: now} - link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, CreatedAt: now} + folderID, err := s.resolveProjectAssetFolderID(projectID, nil) + if err != nil { + return ProjectCharacterDetail{}, err + } + position, err := s.repo.NextProjectAssetPosition(projectID, folderID) + if err != nil { + return ProjectCharacterDetail{}, err + } + link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now} if err := s.repo.CreateProjectCharacter(projectID, &asset, &version, &link); err != nil { return ProjectCharacterDetail{}, err } @@ -418,20 +426,14 @@ func (s *Service) prepareNextCharacterVersion(asset *model.Asset, name string, d } func (s *Service) projectCharacterDetail(userID string, projectID string, asset *model.Asset) (ProjectCharacterDetail, error) { - versions, err := s.repo.AssetVersions(asset.ID) + summary, err := s.projectAssetSummary(userID, projectID, asset) if err != nil { return ProjectCharacterDetail{}, err } - usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID) - if err != nil { - return ProjectCharacterDetail{}, err + if summary.Character == nil { + return ProjectCharacterDetail{}, BadAuthRequest("角色素材缺少角色设定") } - card, err := s.characterCard(userID, asset) - if err != nil { - return ProjectCharacterDetail{}, err - } - summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt, Character: &card} - return ProjectCharacterDetail{Asset: summary, Character: card}, nil + return ProjectCharacterDetail{Asset: summary, Character: *summary.Character}, nil } func (s *Service) characterCard(userID string, asset *model.Asset) (CharacterCardSummary, error) { diff --git a/web/package.json b/web/package.json index d0cf7ce4c..5002c685a 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/public/images/canvas/folder-default-cover.png b/web/public/images/canvas/folder-default-cover.png new file mode 100644 index 000000000..c56217c22 Binary files /dev/null and b/web/public/images/canvas/folder-default-cover.png differ diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 31f89ff25..002903dbf 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -17,6 +17,13 @@ export type AssetLibraryPickerItem = { description?: string; searchText?: string; disabledReason?: string; + folderId?: string; +}; + +export type AssetLibraryPickerFolder = { + id: string; + parentId?: string; + name: string; }; type Props = { @@ -24,6 +31,8 @@ type Props = { items: AssetLibraryPickerItem[]; categoryLabels: Record; initialCategory?: string; + initialFolderId?: string; + folders?: AssetLibraryPickerFolder[]; initialSelectedIds?: Iterable; multiple?: boolean; title?: string; @@ -32,6 +41,7 @@ type Props = { emptyTitle?: string; emptyDescription?: string; footerNote?: string; + folderActionLabel?: string; upload?: { accept: string; description: string; @@ -39,6 +49,7 @@ type Props = { }; onClose: () => void; onConfirm: (ids: string[]) => Promise | void; + onFolderAction?: (folderId: string) => Promise | void; }; export function AssetLibraryPickerModal({ @@ -46,6 +57,8 @@ export function AssetLibraryPickerModal({ items, categoryLabels, initialCategory = "all", + initialFolderId = "all", + folders = [], initialSelectedIds, multiple = true, title = "素材库", @@ -54,11 +67,14 @@ export function AssetLibraryPickerModal({ emptyTitle = "这个分类还没有素材", emptyDescription = "换个分类后再试。", footerNote, + folderActionLabel = "将文件夹放到画布", upload, onClose, onConfirm, + onFolderAction, }: Props) { const [category, setCategory] = useState(initialCategory); + const [folderId, setFolderId] = useState(initialFolderId); const [keyword, setKeyword] = useState(""); const [selected, setSelected] = useState>(new Set()); const [working, setWorking] = useState(false); @@ -74,9 +90,10 @@ export function AssetLibraryPickerModal({ const query = keyword.trim().toLowerCase(); return items.filter((item) => { if (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, items, keyword]); + }, [category, folderId, items, keyword]); const selectedIds = useMemo( () => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id), [items, selected], @@ -85,13 +102,14 @@ export function AssetLibraryPickerModal({ useEffect(() => { if (!open) return; setCategory(initialCategory); + setFolderId(initialFolderId); setKeyword(""); const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id)); setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id)))); setWorking(false); setUploadingCount(0); setError(""); - }, [initialCategory, open]); + }, [initialCategory, initialFolderId, open]); useEffect(() => { if (category === "all" || categories.includes(category)) return; @@ -140,6 +158,19 @@ export function AssetLibraryPickerModal({ } }; + const runFolderAction = async () => { + if (!onFolderAction || folderId === "all" || working) return; + setWorking(true); + setError(""); + try { + await onFolderAction(folderId); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "文件夹操作失败,请重试"); + } finally { + setWorking(false); + } + }; + const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length; const uploading = uploadingCount > 0; @@ -166,6 +197,7 @@ export function AssetLibraryPickerModal({