From d89f170a08a8c27a6e9fa41451bea8dd33efcd27 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 30 Aug 2026 01:32:33 +0800 Subject: [PATCH] =?UTF-8?q?perf(platform):=20=E7=9F=AD=E5=89=A7=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E5=B7=A5=E4=BD=9C=E5=8F=B0=20-=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E9=95=BF=E7=AF=87=E9=A1=B9=E7=9B=AE=E6=8E=A5=E5=8F=A3=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AB=A0=E8=8A=82=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/project.go | 127 +++++++ backend/internal/model/models_project.go | 38 +- backend/internal/model/models_task.go | 6 +- .../repository/project_workbench_read.go | 218 +++++++++++ .../service/project_workbench_read.go | 356 ++++++++++++++++++ .../service/project_workbench_read_test.go | 187 +++++++++ .../content/docs/backend/backend-database.mdx | 28 ++ docs/content/docs/progress/pending-test.mdx | 17 +- .../assets/asset-library-picker-modal.tsx | 20 +- web/src/components/layout/workspace-page.tsx | 6 +- web/src/pages/assets/index.tsx | 4 +- web/src/pages/projects/detail.tsx | 75 ++-- web/src/pages/projects/detail/assets.tsx | 130 ++++--- web/src/pages/projects/detail/canvases.tsx | 30 +- web/src/pages/projects/detail/chapters.tsx | 72 ++-- web/src/pages/projects/detail/overview.tsx | 103 +++-- web/src/pages/projects/detail/settings.tsx | 32 +- .../detail/workflow-chapter-navigator.tsx | 56 +-- .../detail/workflow-production-workbench.tsx | 20 +- .../projects/detail/workflow-stage-views.tsx | 7 +- web/src/services/api/projects.ts | 114 ++++++ web/src/styles/globals.css | 73 +++- 22 files changed, 1497 insertions(+), 222 deletions(-) create mode 100644 backend/internal/repository/project_workbench_read.go create mode 100644 backend/internal/service/project_workbench_read.go create mode 100644 backend/internal/service/project_workbench_read_test.go diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index fa5c105c2..9739da38c 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -95,6 +95,36 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, detail) }) + r.GET("/projects/:id/core", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + core, err := svc.ProjectCore(user.ID, c.Param("id")) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, core) + }) + r.GET("/projects/:id/overview", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + overview, err := svc.ProjectOverview(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, overview) + }) r.PATCH("/projects/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -157,6 +187,19 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"unit": unit}) }) + r.GET("/projects/:id/units", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + units, err := svc.ProjectUnitSummaries(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, units) + }) r.GET("/projects/:id/units/:unitId", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -174,6 +217,19 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"unit": unit}) }) + r.GET("/projects/:id/units/:unitId/workspace", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + workspace, err := svc.ProjectUnitWorkspace(user.ID, c.Param("id"), c.Param("unitId")) + if err != nil { + failService(c, err) + return + } + ok(c, workspace) + }) r.POST("/projects/:id/units/import", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -318,12 +374,60 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"canvasId": c.Param("canvasId")}) }) + r.GET("/projects/:id/canvases", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, err := parsePositiveQueryInt(c.Query("page"), 1) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + pageSize, err := parsePositiveQueryInt(c.Query("page_size"), 40) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.ProjectCanvasesPage(user.ID, c.Param("id"), page, pageSize) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) r.GET("/projects/:id/assets", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } + pageParam, hasPage := c.GetQuery("page") + pageSizeParam, hasPageSize := c.GetQuery("page_size") + if hasPage || hasPageSize { + page, pageErr := parsePositiveQueryInt(pageParam, 1) + if pageErr != nil { + fail(c, http.StatusBadRequest, pageErr) + return + } + pageSize, pageSizeErr := parsePositiveQueryInt(pageSizeParam, 40) + if pageSizeErr != nil { + fail(c, http.StatusBadRequest, pageSizeErr) + return + } + var folderID *string + if value, present := c.GetQuery("folder_id"); present { + folderID = &value + } + assets, pageErr := svc.ProjectAssetsPage(user.ID, c.Param("id"), page, pageSize, c.Query("category"), c.Query("media_type"), c.Query("status"), folderID, c.Query("q")) + if pageErr != nil { + failService(c, pageErr) + return + } + ok(c, assets) + return + } assets, err := svc.FilterProjectAssets(user.ID, c.Param("id"), service.ProjectAssetFilter{Category: c.Query("category"), MediaType: c.Query("mediaType"), Status: c.Query("status"), Usage: c.Query("usage")}) if err != nil { failService(c, err) @@ -759,6 +863,29 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"candidates": candidates}) }) + r.GET("/projects/:id/asset-candidates", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, err := parsePositiveQueryInt(c.Query("page"), 1) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + pageSize, err := parsePositiveQueryInt(c.Query("page_size"), 100) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unit_id"), c.Query("status"), c.Query("category")) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) r.POST("/projects/:id/asset-candidates/:candidateId/confirm", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 54263bb2a..9428b4f32 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -85,10 +85,10 @@ type Asset struct { 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"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1;index:idx_project_asset_links_project_folder_position,priority:1"` AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_project_asset_links_unique,priority:2"` - FolderID string `json:"folderId,omitempty" gorm:"index;size:36"` - Position int `json:"position" gorm:"index"` + FolderID string `json:"folderId,omitempty" gorm:"index;size:36;index:idx_project_asset_links_project_folder_position,priority:2"` + Position int `json:"position" gorm:"index;index:idx_project_asset_links_project_folder_position,priority:3"` CreatedAt time.Time `json:"createdAt"` } @@ -108,12 +108,12 @@ type ProjectAssetFolder struct { type ProjectAssetCandidate struct { ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - UnitID string `json:"unitId,omitempty" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_project_asset_candidates_project_unit_status,priority:1;index:idx_project_asset_candidates_project_status_category,priority:1"` + UnitID string `json:"unitId,omitempty" gorm:"index;size:36;index:idx_project_asset_candidates_project_unit_status,priority:2"` ShotID string `json:"shotId,omitempty" gorm:"index;size:36"` Name string `json:"name" gorm:"size:240"` - Category AssetCategory `json:"category" gorm:"index;size:32"` - Status string `json:"status" gorm:"index;size:32"` + Category AssetCategory `json:"category" gorm:"index;size:32;index:idx_project_asset_candidates_project_status_category,priority:3"` + Status string `json:"status" gorm:"index;size:32;index:idx_project_asset_candidates_project_unit_status,priority:3;index:idx_project_asset_candidates_project_status_category,priority:2"` DetailsJSON string `json:"detailsJson" gorm:"type:text"` ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:80"` CreatedAt time.Time `json:"createdAt"` @@ -204,35 +204,35 @@ type StyleProfile struct { type ProjectUnit struct { ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_project_units_project_position,priority:1"` ParentID string `json:"parentId,omitempty" gorm:"index;size:36"` Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"` Title string `json:"title" gorm:"size:240"` SourceText string `json:"sourceText" gorm:"type:text"` WordCount int `json:"wordCount"` Status ProjectUnitStatus `json:"status" gorm:"index;size:24"` - Position int `json:"position"` + Position int `json:"position" gorm:"index:idx_project_units_project_position,priority:2"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type CanvasUnitLink struct { ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1;index:idx_canvas_unit_links_project_unit,priority:1"` CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_unit_links_unique,priority:2"` - UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3"` + UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3;index:idx_canvas_unit_links_project_unit,priority:2"` Role string `json:"role" gorm:"size:32"` CreatedAt time.Time `json:"createdAt"` } type Shot struct { ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - UnitID string `json:"unitId" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_shots_project_unit_position,priority:1"` + UnitID string `json:"unitId" gorm:"index;size:36;index:idx_shots_project_unit_position,priority:2"` CurrentRevisionID string `json:"currentRevisionId,omitempty" gorm:"index;size:36"` Title string `json:"title" gorm:"size:240"` Description string `json:"description" gorm:"type:text"` - Position int `json:"position"` + Position int `json:"position" gorm:"index:idx_shots_project_unit_position,priority:3"` DurationMs int64 `json:"durationMs"` Status string `json:"status" gorm:"index;size:24"` CreatedAt time.Time `json:"createdAt"` @@ -263,8 +263,8 @@ type ShotRevision struct { // ShotArtifact 是镜头的版本化生产产物。修改分镜或资产引用时只标记 stale,不删除历史。 type ShotArtifact struct { ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - UnitID string `json:"unitId" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_shot_artifacts_project_unit,priority:1"` + UnitID string `json:"unitId" gorm:"index;size:36;index:idx_shot_artifacts_project_unit,priority:2"` ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_artifacts_version,priority:1"` RevisionID string `json:"revisionId,omitempty" gorm:"index;size:36"` TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` @@ -347,12 +347,12 @@ type ProductionTaskLink struct { type CanvasProject struct { ID string `json:"id" gorm:"primaryKey;size:80"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"` - ProjectID string `json:"projectId,omitempty" gorm:"index;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1;index:idx_canvas_projects_user_project_updated,priority:1"` + ProjectID string `json:"projectId,omitempty" gorm:"index;size:36;index:idx_canvas_projects_user_project_updated,priority:2"` Title string `json:"title" gorm:"size:240"` PayloadJSON string `json:"payloadJson" gorm:"type:text"` CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2;index:idx_canvas_projects_user_project_updated,priority:3"` } type CanvasShare struct { diff --git a/backend/internal/model/models_task.go b/backend/internal/model/models_task.go index fc224b466..77d9273cd 100644 --- a/backend/internal/model/models_task.go +++ b/backend/internal/model/models_task.go @@ -4,11 +4,11 @@ import "time" type Task struct { ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1;index:idx_tasks_user_project_created,priority:1"` TraceID string `json:"-" gorm:"index;size:96"` RequestID string `json:"-" gorm:"index;size:96"` SessionID string `json:"sessionId" gorm:"index;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:80"` + ProjectID string `json:"projectId" gorm:"index;size:80;index:idx_tasks_user_project_created,priority:2"` Type string `json:"type" gorm:"index;size:64"` Status TaskStatus `json:"status" gorm:"index;size:24;index:idx_tasks_status_created,priority:1;index:idx_tasks_claim,priority:1;index:idx_tasks_provider_cancel,priority:1"` Stage string `json:"stage" gorm:"size:80"` @@ -42,7 +42,7 @@ type Task struct { Attempts int `json:"attempts"` StartedAt *time.Time `json:"startedAt"` CompletedAt *time.Time `json:"completedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3;index:idx_tasks_user_project_created,priority:3"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/backend/internal/repository/project_workbench_read.go b/backend/internal/repository/project_workbench_read.go new file mode 100644 index 000000000..f8b1475b1 --- /dev/null +++ b/backend/internal/repository/project_workbench_read.go @@ -0,0 +1,218 @@ +package repository + +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) + +type ProjectOverviewMetrics struct { + UnitCount int64 `gorm:"column:unit_count"` + CompletedUnitCount int64 `gorm:"column:completed_unit_count"` + TotalWordCount int64 `gorm:"column:total_word_count"` + UnitsWithoutText int64 `gorm:"column:units_without_text"` + UnitsWithoutShots int64 `gorm:"column:units_without_shots"` + CanvasCount int64 `gorm:"column:canvas_count"` + AssetCount int64 `gorm:"column:asset_count"` + ShotCount int64 `gorm:"column:shot_count"` + PendingCandidateCount int64 `gorm:"column:pending_candidate_count"` + ReadyStoryboardCount int64 `gorm:"column:ready_storyboard_count"` + ReadyPrevizCount int64 `gorm:"column:ready_previz_count"` + ReadyVideoCount int64 `gorm:"column:ready_video_count"` + StaleArtifactCount int64 `gorm:"column:stale_artifact_count"` +} + +type ProjectOverviewUnitRow struct { + model.ProjectUnit + ShotCount int64 `gorm:"column:shot_count"` + CandidateCount int64 `gorm:"column:candidate_count"` + CanvasCount int64 `gorm:"column:canvas_count"` +} + +type ProjectAssetCountRow struct { + Key string `gorm:"column:key"` + Count int64 `gorm:"column:count"` +} + +func (r *Repository) ProjectUnitCanvasCounts(projectID string) (map[string]int64, error) { + var rows []ProjectAssetCountRow + if err := r.db.Table("canvas_unit_links").Select("unit_id AS key, COUNT(DISTINCT canvas_id) AS count").Where("project_id = ?", projectID).Group("unit_id").Scan(&rows).Error; err != nil { + return nil, err + } + counts := make(map[string]int64, len(rows)) + for _, row := range rows { + counts[row.Key] = row.Count + } + return counts, nil +} + +func (r *Repository) ProjectOverviewMetrics(projectID string) (ProjectOverviewMetrics, error) { + var metrics ProjectOverviewMetrics + err := r.db.Raw(` + SELECT + (SELECT COUNT(*) FROM project_units WHERE project_id = ?) AS unit_count, + (SELECT COUNT(*) FROM project_units WHERE project_id = ? AND status = ?) AS completed_unit_count, + (SELECT COALESCE(SUM(word_count), 0) FROM project_units WHERE project_id = ?) AS total_word_count, + (SELECT COUNT(*) FROM project_units WHERE project_id = ? AND word_count = 0) AS units_without_text, + (SELECT COUNT(*) FROM project_units pu WHERE pu.project_id = ? AND pu.status <> ? AND NOT EXISTS (SELECT 1 FROM shots s WHERE s.project_id = pu.project_id AND s.unit_id = pu.id)) AS units_without_shots, + (SELECT COUNT(*) FROM canvas_projects WHERE project_id = ?) AS canvas_count, + (SELECT COUNT(*) FROM project_asset_links WHERE project_id = ?) AS asset_count, + (SELECT COUNT(*) FROM shots WHERE project_id = ?) AS shot_count, + (SELECT COUNT(*) FROM project_asset_candidates WHERE project_id = ? AND status = 'pending_confirmation') AS pending_candidate_count, + (SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'storyboard' AND selected = ? AND status = 'ready') AS ready_storyboard_count, + (SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'action_board' AND selected = ? AND status = 'ready') AS ready_previz_count, + (SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'video' AND selected = ? AND status = 'ready') AS ready_video_count, + (SELECT COUNT(*) FROM shot_artifacts WHERE project_id = ? AND status = 'stale') AS stale_artifact_count + `, projectID, projectID, model.ProjectUnitStatusCompleted, projectID, projectID, projectID, model.ProjectUnitStatusDraft, projectID, projectID, projectID, projectID, projectID, true, projectID, true, projectID, true, projectID).Scan(&metrics).Error + return metrics, err +} + +func (r *Repository) ProjectOverviewUnits(projectID string, limit int) ([]ProjectOverviewUnitRow, error) { + if limit <= 0 || limit > 20 { + limit = 8 + } + var rows []ProjectOverviewUnitRow + err := r.db.Raw(` + SELECT pu.id, pu.project_id, pu.parent_id, pu.kind, pu.title, pu.word_count, pu.status, pu.position, pu.created_at, pu.updated_at, + (SELECT COUNT(*) FROM shots s WHERE s.project_id = pu.project_id AND s.unit_id = pu.id) AS shot_count, + (SELECT COUNT(*) FROM project_asset_candidates pac WHERE pac.project_id = pu.project_id AND pac.unit_id = pu.id) AS candidate_count, + (SELECT COUNT(DISTINCT cul.canvas_id) FROM canvas_unit_links cul WHERE cul.project_id = pu.project_id AND cul.unit_id = pu.id) AS canvas_count + FROM project_units pu + WHERE pu.project_id = ? + ORDER BY pu.position ASC, pu.created_at ASC + LIMIT ? + `, projectID, limit).Scan(&rows).Error + return rows, err +} + +func (r *Repository) ProjectUnitShots(projectID string, unitID string) ([]model.Shot, error) { + var shots []model.Shot + err := r.db.Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("position asc, created_at asc").Find(&shots).Error + return shots, err +} + +func (r *Repository) ProjectUnitShotRevisions(projectID string, unitID string) ([]model.ShotRevision, error) { + var revisions []model.ShotRevision + err := r.db.Table("shot_revisions").Select("shot_revisions.*").Joins("JOIN shots ON shots.id = shot_revisions.shot_id").Where("shots.project_id = ? AND shots.unit_id = ?", projectID, unitID).Order("shots.position asc, shot_revisions.version asc").Scan(&revisions).Error + return revisions, err +} + +func (r *Repository) ProjectUnitShotArtifacts(projectID string, unitID string) ([]model.ShotArtifact, error) { + var artifacts []model.ShotArtifact + err := r.db.Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("shot_id asc, type asc, version asc").Find(&artifacts).Error + return artifacts, err +} + +func (r *Repository) ProjectUnitShotAssetReferences(projectID string, unitID string) ([]model.ShotAssetReference, error) { + var references []model.ShotAssetReference + err := r.db.Table("shot_asset_references").Select("shot_asset_references.*").Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").Where("shots.project_id = ? AND shots.unit_id = ?", projectID, unitID).Order("shot_asset_references.created_at asc").Scan(&references).Error + return references, err +} + +func (r *Repository) ProjectUnitAssetCandidates(projectID string, unitID string) ([]model.ProjectAssetCandidate, error) { + var candidates []model.ProjectAssetCandidate + err := r.db.Where("project_id = ? AND (unit_id = ? OR unit_id = '')", projectID, unitID).Order("created_at asc").Find(&candidates).Error + return candidates, err +} + +func (r *Repository) ProjectUnitAssets(userID string, projectID string, unitID string) ([]model.Asset, error) { + var assets []model.Asset + err := r.db.Table("assets").Distinct("assets.*"). + Joins("JOIN project_asset_links pal ON pal.asset_id = assets.id AND pal.project_id = ?", projectID). + Joins("JOIN asset_versions av ON av.asset_id = assets.id"). + Joins("JOIN shot_asset_references sar ON sar.asset_version_id = av.id"). + Joins("JOIN shots s ON s.id = sar.shot_id AND s.project_id = ? AND s.unit_id = ?", projectID, unitID). + Where("assets.user_id = ?", userID). + Order("assets.updated_at desc"). + Scan(&assets).Error + return assets, err +} + +func (r *Repository) ProjectWorkflowInstancesForUnit(projectID string, unitID string) ([]model.WorkflowInstance, error) { + var instances []model.WorkflowInstance + err := r.db.Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("created_at asc").Find(&instances).Error + return instances, err +} + +func (r *Repository) ProjectCanvasSummariesPage(userID string, projectID string, page int, pageSize int) ([]model.CanvasProject, int64, error) { + var canvases []model.CanvasProject + var total int64 + query := r.db.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, projectID) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Select("id", "user_id", "project_id", "title", "created_at", "updated_at").Order("updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&canvases).Error + return canvases, total, err +} + +func (r *Repository) ProjectCanvasUnitLinksForCanvases(projectID string, canvasIDs []string) ([]model.CanvasUnitLink, error) { + if len(canvasIDs) == 0 { + return []model.CanvasUnitLink{}, nil + } + var links []model.CanvasUnitLink + err := r.db.Where("project_id = ? AND canvas_id IN ?", projectID, canvasIDs).Order("created_at asc").Find(&links).Error + return links, err +} + +func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, pageSize int, unitID string, status string, category string) ([]model.ProjectAssetCandidate, int64, error) { + var candidates []model.ProjectAssetCandidate + var total int64 + query := r.db.Model(&model.ProjectAssetCandidate{}).Where("project_id = ?", projectID) + if value := strings.TrimSpace(unitID); value != "" { + query = query.Where("unit_id = ?", value) + } + if value := strings.TrimSpace(status); value != "" { + query = query.Where("status = ?", value) + } + if value := strings.TrimSpace(category); value != "" { + query = query.Where("category = ?", value) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&candidates).Error + return candidates, total, err +} + +func (r *Repository) ProjectAssetsPage(userID string, projectID string, page int, pageSize int, category string, mediaType string, status string, folderID *string, queryText string) ([]model.Asset, int64, error) { + var assets []model.Asset + var total int64 + query := r.db.Table("assets").Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").Where("assets.user_id = ? AND project_asset_links.project_id = ?", userID, projectID) + if value := strings.TrimSpace(category); value != "" { + query = query.Where("assets.category = ?", value) + } + if value := strings.TrimSpace(mediaType); value != "" { + query = query.Where("assets.kind = ?", value) + } + if value := strings.TrimSpace(status); value != "" { + query = query.Where("assets.status = ?", value) + } + if folderID != nil { + query = query.Where("project_asset_links.folder_id = ?", strings.TrimSpace(*folderID)) + } + if value := strings.TrimSpace(queryText); value != "" { + query = query.Where("LOWER(assets.title) LIKE ?", "%"+strings.ToLower(value)+"%") + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Select("assets.*").Order("assets.updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&assets).Error + return assets, total, err +} + +func (r *Repository) ProjectAssetFacets(projectID string) ([]ProjectAssetCountRow, []ProjectAssetCountRow, error) { + var categoryRows []ProjectAssetCountRow + if err := r.db.Table("assets").Select("assets.category AS key, COUNT(*) AS count"). + Joins("JOIN project_asset_links pal ON pal.asset_id = assets.id"). + Where("pal.project_id = ?", projectID). + Group("assets.category").Scan(&categoryRows).Error; err != nil { + return nil, nil, err + } + var folderRows []ProjectAssetCountRow + if err := r.db.Table("project_asset_links").Select("folder_id AS key, COUNT(*) AS count"). + Where("project_id = ?", projectID). + Group("folder_id").Scan(&folderRows).Error; err != nil { + return nil, nil, err + } + return categoryRows, folderRows, nil +} diff --git a/backend/internal/service/project_workbench_read.go b/backend/internal/service/project_workbench_read.go new file mode 100644 index 000000000..360f0b803 --- /dev/null +++ b/backend/internal/service/project_workbench_read.go @@ -0,0 +1,356 @@ +package service + +import ( + "infinite-canvas/backend/internal/model" + + "golang.org/x/sync/errgroup" +) + +type ProjectCore struct { + Project model.Project `json:"project"` +} + +type ProjectUnitSummaries struct { + Units []model.ProjectUnit `json:"units"` + CanvasCounts map[string]int64 `json:"canvasCounts"` +} + +type ProjectOverview struct { + Metrics ProjectOverviewMetrics `json:"metrics"` + Units []ProjectOverviewUnit `json:"units"` +} + +type ProjectOverviewMetrics struct { + UnitCount int64 `json:"unitCount"` + CompletedUnitCount int64 `json:"completedUnitCount"` + TotalWordCount int64 `json:"totalWordCount"` + UnitsWithoutText int64 `json:"unitsWithoutText"` + UnitsWithoutShots int64 `json:"unitsWithoutShots"` + CanvasCount int64 `json:"canvasCount"` + AssetCount int64 `json:"assetCount"` + ShotCount int64 `json:"shotCount"` + PendingCandidateCount int64 `json:"pendingCandidateCount"` + ReadyStoryboardCount int64 `json:"readyStoryboardCount"` + ReadyPrevizCount int64 `json:"readyPrevizCount"` + ReadyVideoCount int64 `json:"readyVideoCount"` + StaleArtifactCount int64 `json:"staleArtifactCount"` +} + +type ProjectOverviewUnit struct { + Unit model.ProjectUnit `json:"unit"` + ShotCount int64 `json:"shotCount"` + CandidateCount int64 `json:"candidateCount"` + CanvasCount int64 `json:"canvasCount"` +} + +type ProjectUnitWorkspace struct { + Unit model.ProjectUnit `json:"unit"` + Workflows []ProjectWorkflowDetail `json:"workflows"` + Shots []model.Shot `json:"shots"` + ShotRevisions []model.ShotRevision `json:"shotRevisions"` + ShotArtifacts []model.ShotArtifact `json:"shotArtifacts"` + ShotReferences []model.ShotAssetReference `json:"shotReferences"` + AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"` + Assets []ProjectAssetSummary `json:"assets"` + Tasks []TaskSummary `json:"tasks"` +} + +type ProjectCanvasPage struct { + Canvases []model.CanvasProject `json:"canvases"` + CanvasUnitLinks []model.CanvasUnitLink `json:"canvasUnitLinks"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + HasMore bool `json:"hasMore"` +} + +type ProjectAssetCandidatePage struct { + Candidates []model.ProjectAssetCandidate `json:"candidates"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + HasMore bool `json:"hasMore"` +} + +type ProjectAssetPage struct { + Assets []ProjectAssetSummary `json:"assets"` + CategoryCounts map[string]int64 `json:"categoryCounts"` + FolderCounts map[string]int64 `json:"folderCounts"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + HasMore bool `json:"hasMore"` +} + +func (s *Service) ProjectCore(userID string, projectID string) (ProjectCore, error) { + project, err := s.repo.ProjectForUser(userID, projectID) + if err != nil { + return ProjectCore{}, err + } + if s.reconcileCharacterTurnaroundTasks(userID, project.ID) { + project, err = s.repo.ProjectForUser(userID, projectID) + if err != nil { + return ProjectCore{}, err + } + } + return ProjectCore{Project: *project}, nil +} + +func (s *Service) ProjectUnitSummaries(userID string, projectID string) (ProjectUnitSummaries, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return ProjectUnitSummaries{}, err + } + var units []model.ProjectUnit + var canvasCounts map[string]int64 + var group errgroup.Group + group.Go(func() error { + var err error + units, err = s.repo.ProjectUnitSummaries(projectID) + return err + }) + group.Go(func() error { + var err error + canvasCounts, err = s.repo.ProjectUnitCanvasCounts(projectID) + return err + }) + if err := group.Wait(); err != nil { + return ProjectUnitSummaries{}, err + } + return ProjectUnitSummaries{Units: units, CanvasCounts: canvasCounts}, nil +} + +func (s *Service) ProjectOverview(userID string, projectID string) (ProjectOverview, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return ProjectOverview{}, err + } + var metrics ProjectOverviewMetrics + var units []ProjectOverviewUnit + var group errgroup.Group + group.Go(func() error { + row, err := s.repo.ProjectOverviewMetrics(projectID) + if err != nil { + return err + } + metrics = ProjectOverviewMetrics{ + UnitCount: row.UnitCount, CompletedUnitCount: row.CompletedUnitCount, TotalWordCount: row.TotalWordCount, + UnitsWithoutText: row.UnitsWithoutText, UnitsWithoutShots: row.UnitsWithoutShots, CanvasCount: row.CanvasCount, + AssetCount: row.AssetCount, ShotCount: row.ShotCount, PendingCandidateCount: row.PendingCandidateCount, + ReadyStoryboardCount: row.ReadyStoryboardCount, ReadyPrevizCount: row.ReadyPrevizCount, ReadyVideoCount: row.ReadyVideoCount, + StaleArtifactCount: row.StaleArtifactCount, + } + return nil + }) + group.Go(func() error { + rows, err := s.repo.ProjectOverviewUnits(projectID, 8) + if err != nil { + return err + } + units = make([]ProjectOverviewUnit, 0, len(rows)) + for _, row := range rows { + units = append(units, ProjectOverviewUnit{Unit: row.ProjectUnit, ShotCount: row.ShotCount, CandidateCount: row.CandidateCount, CanvasCount: row.CanvasCount}) + } + return nil + }) + if err := group.Wait(); err != nil { + return ProjectOverview{}, err + } + return ProjectOverview{Metrics: metrics, Units: units}, nil +} + +func (s *Service) ProjectUnitWorkspace(userID string, projectID string, unitID string) (ProjectUnitWorkspace, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return ProjectUnitWorkspace{}, err + } + unit, err := s.repo.ProjectUnit(projectID, unitID) + if err != nil { + return ProjectUnitWorkspace{}, err + } + result := ProjectUnitWorkspace{Unit: *unit} + var projectTasks []TaskSummary + var group errgroup.Group + group.Go(func() error { + var err error + result.Shots, err = s.repo.ProjectUnitShots(projectID, unitID) + return err + }) + group.Go(func() error { + var err error + result.ShotRevisions, err = s.repo.ProjectUnitShotRevisions(projectID, unitID) + return err + }) + group.Go(func() error { + var err error + result.ShotArtifacts, err = s.repo.ProjectUnitShotArtifacts(projectID, unitID) + return err + }) + group.Go(func() error { + var err error + result.ShotReferences, err = s.repo.ProjectUnitShotAssetReferences(projectID, unitID) + return err + }) + group.Go(func() error { + var err error + result.AssetCandidates, err = s.repo.ProjectUnitAssetCandidates(projectID, unitID) + return err + }) + group.Go(func() error { + assets, err := s.repo.ProjectUnitAssets(userID, projectID, unitID) + if err != nil { + return err + } + result.Assets = make([]ProjectAssetSummary, len(assets)) + var summaries errgroup.Group + summaries.SetLimit(8) + for index := range assets { + index := index + summaries.Go(func() error { + summary, summaryErr := s.projectAssetSummary(userID, projectID, &assets[index]) + if summaryErr == nil { + result.Assets[index] = summary + } + return summaryErr + }) + } + return summaries.Wait() + }) + group.Go(func() error { + instances, err := s.repo.ProjectWorkflowInstancesForUnit(projectID, unitID) + if err != nil { + return err + } + result.Workflows = make([]ProjectWorkflowDetail, 0, len(instances)) + for _, instance := range instances { + steps, stepsErr := s.repo.WorkflowSteps(instance.ID) + if stepsErr != nil { + return stepsErr + } + result.Workflows = append(result.Workflows, ProjectWorkflowDetail{Instance: instance, Steps: steps}) + } + return nil + }) + group.Go(func() error { + recent, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: projectID}) + if err != nil { + return err + } + active, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: projectID, ActiveOnly: true}) + if err != nil { + return err + } + seen := make(map[string]struct{}, len(recent)+len(active)) + projectTasks = make([]TaskSummary, 0, len(recent)+len(active)) + for _, task := range append(active, recent...) { + if _, exists := seen[task.ID]; exists { + continue + } + seen[task.ID] = struct{}{} + projectTasks = append(projectTasks, task) + } + return nil + }) + if err := group.Wait(); err != nil { + return ProjectUnitWorkspace{}, err + } + shotIDs := make(map[string]struct{}, len(result.Shots)) + for _, shot := range result.Shots { + shotIDs[shot.ID] = struct{}{} + } + result.Tasks = make([]TaskSummary, 0, len(projectTasks)) + for _, task := range projectTasks { + context := task.ClientContext + if context == nil { + continue + } + _, belongsToShot := shotIDs[context.ShotID] + if context.ChapterID == unitID || belongsToShot { + result.Tasks = append(result.Tasks, task) + } + } + return result, nil +} + +func (s *Service) ProjectCanvasesPage(userID string, projectID string, page int, pageSize int) (ProjectCanvasPage, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return ProjectCanvasPage{}, err + } + page, pageSize = normalizeProjectPage(page, pageSize, 100) + canvases, total, err := s.repo.ProjectCanvasSummariesPage(userID, projectID, page, pageSize) + if err != nil { + return ProjectCanvasPage{}, err + } + ids := make([]string, 0, len(canvases)) + for _, canvas := range canvases { + ids = append(ids, canvas.ID) + } + links, err := s.repo.ProjectCanvasUnitLinksForCanvases(projectID, ids) + if err != nil { + return ProjectCanvasPage{}, err + } + return ProjectCanvasPage{Canvases: canvases, CanvasUnitLinks: links, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil +} + +func (s *Service) ProjectAssetCandidatesPage(userID string, projectID string, page int, pageSize int, unitID string, status string, category string) (ProjectAssetCandidatePage, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return ProjectAssetCandidatePage{}, err + } + page, pageSize = normalizeProjectPage(page, pageSize, 200) + candidates, total, err := s.repo.ProjectAssetCandidatesPage(projectID, page, pageSize, unitID, status, category) + if err != nil { + return ProjectAssetCandidatePage{}, err + } + return ProjectAssetCandidatePage{Candidates: candidates, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil +} + +func (s *Service) ProjectAssetsPage(userID string, projectID string, page int, pageSize int, category string, mediaType string, status string, folderID *string, query string) (ProjectAssetPage, error) { + if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + return ProjectAssetPage{}, err + } + page, pageSize = normalizeProjectPage(page, pageSize, 80) + assets, total, err := s.repo.ProjectAssetsPage(userID, projectID, page, pageSize, category, mediaType, status, folderID, query) + if err != nil { + return ProjectAssetPage{}, err + } + summaries := make([]ProjectAssetSummary, len(assets)) + var group errgroup.Group + group.SetLimit(8) + for index := range assets { + index := index + group.Go(func() error { + summary, summaryErr := s.projectAssetSummary(userID, projectID, &assets[index]) + if summaryErr != nil { + return summaryErr + } + summaries[index] = summary + return nil + }) + } + if err := group.Wait(); err != nil { + return ProjectAssetPage{}, err + } + categoryRows, folderRows, err := s.repo.ProjectAssetFacets(projectID) + if err != nil { + return ProjectAssetPage{}, err + } + categoryCounts := make(map[string]int64, len(categoryRows)) + for _, row := range categoryRows { + categoryCounts[row.Key] = row.Count + } + folderCounts := make(map[string]int64, len(folderRows)) + for _, row := range folderRows { + folderCounts[row.Key] = row.Count + } + return ProjectAssetPage{Assets: summaries, CategoryCounts: categoryCounts, FolderCounts: folderCounts, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil +} + +func normalizeProjectPage(page int, pageSize int, maximum int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 40 + } + if pageSize > maximum { + pageSize = maximum + } + return page, pageSize +} diff --git a/backend/internal/service/project_workbench_read_test.go b/backend/internal/service/project_workbench_read_test.go new file mode 100644 index 000000000..d49e24a4d --- /dev/null +++ b/backend/internal/service/project_workbench_read_test.go @@ -0,0 +1,187 @@ +package service + +import ( + "fmt" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newProjectWorkbenchReadTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.CanvasUnitLink{}, + &model.Asset{}, &model.AssetVersion{}, &model.AssetRepresentation{}, &model.ProjectAssetLink{}, &model.ProjectAssetCandidate{}, + &model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{}, + &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.Task{}, + ); err != nil { + t.Fatal(err) + } + return &Service{repo: repository.New(db)}, db +} + +func seedWorkbenchProject(t *testing.T, db *gorm.DB) model.Project { + t.Helper() + project := model.Project{ID: "project-1", UserID: "user-1", Name: "长篇短剧", Status: model.ProjectStatusActive} + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + return project +} + +func TestProjectOverviewUsesAggregatesWithoutLoadingProjectCollections(t *testing.T) { + service, db := newProjectWorkbenchReadTestService(t) + project := seedWorkbenchProject(t, db) + for index := 0; index < 12; index++ { + status := model.ProjectUnitStatusReady + if index < 3 { + status = model.ProjectUnitStatusCompleted + } + unit := model.ProjectUnit{ID: fmt.Sprintf("unit-%02d", index), ProjectID: project.ID, Title: fmt.Sprintf("第%d章", index+1), Position: index, WordCount: 1200, Status: status} + if err := db.Create(&unit).Error; err != nil { + t.Fatal(err) + } + if index < 4 { + if err := db.Create(&model.Shot{ID: fmt.Sprintf("shot-%02d", index), ProjectID: project.ID, UnitID: unit.ID, Position: 0}).Error; err != nil { + t.Fatal(err) + } + } + } + overview, err := service.ProjectOverview("user-1", project.ID) + if err != nil { + t.Fatal(err) + } + if overview.Metrics.UnitCount != 12 || overview.Metrics.CompletedUnitCount != 3 || overview.Metrics.ShotCount != 4 { + t.Fatalf("unexpected overview metrics: %+v", overview.Metrics) + } + if overview.Metrics.TotalWordCount != 14_400 || overview.Metrics.UnitsWithoutShots != 8 { + t.Fatalf("unexpected aggregate totals: %+v", overview.Metrics) + } + if len(overview.Units) != 8 { + t.Fatalf("overview units = %d, want capped 8", len(overview.Units)) + } + if overview.Units[0].Unit.SourceText != "" { + t.Fatal("overview unit unexpectedly contains source text") + } +} + +func TestProjectUnitWorkspaceIsolatesShotsAndBoundAssetsByUnit(t *testing.T) { + service, db := newProjectWorkbenchReadTestService(t) + project := seedWorkbenchProject(t, db) + unit1 := model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Title: "第一章", Status: model.ProjectUnitStatusReady} + unit2 := model.ProjectUnit{ID: "unit-2", ProjectID: project.ID, Title: "第二章", Position: 1, Status: model.ProjectUnitStatusReady} + asset1 := model.Asset{ID: "asset-1", UserID: "user-1", Title: "角色甲", Kind: "image", Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed} + asset2 := model.Asset{ID: "asset-2", UserID: "user-1", Title: "角色乙", Kind: "image", Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed} + seed := []any{ + &unit1, &unit2, &asset1, &asset2, + &model.AssetVersion{ID: "version-1", AssetID: asset1.ID, Version: 1, Status: model.AssetVersionStatusConfirmed}, + &model.AssetVersion{ID: "version-2", AssetID: asset2.ID, Version: 1, Status: model.AssetVersionStatusConfirmed}, + &model.ProjectAssetLink{ID: "link-1", ProjectID: project.ID, AssetID: asset1.ID}, + &model.ProjectAssetLink{ID: "link-2", ProjectID: project.ID, AssetID: asset2.ID}, + &model.Shot{ID: "shot-1", ProjectID: project.ID, UnitID: unit1.ID, Position: 0}, + &model.Shot{ID: "shot-2", ProjectID: project.ID, UnitID: unit2.ID, Position: 0}, + &model.ShotRevision{ID: "revision-1", ShotID: "shot-1", Version: 1}, + &model.ShotRevision{ID: "revision-2", ShotID: "shot-2", Version: 1}, + &model.ShotArtifact{ID: "artifact-1", ProjectID: project.ID, UnitID: unit1.ID, ShotID: "shot-1", Version: 1}, + &model.ShotArtifact{ID: "artifact-2", ProjectID: project.ID, UnitID: unit2.ID, ShotID: "shot-2", Version: 1}, + &model.ShotAssetReference{ID: "reference-1", ShotID: "shot-1", AssetVersionID: "version-1", Status: "linked"}, + &model.ShotAssetReference{ID: "reference-2", ShotID: "shot-2", AssetVersionID: "version-2", Status: "linked"}, + &model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: project.ID, UnitID: unit1.ID, Name: "甲"}, + &model.ProjectAssetCandidate{ID: "candidate-2", ProjectID: project.ID, UnitID: unit2.ID, Name: "乙"}, + } + for _, item := range seed { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + workspace, err := service.ProjectUnitWorkspace("user-1", project.ID, unit1.ID) + if err != nil { + t.Fatal(err) + } + if len(workspace.Shots) != 1 || workspace.Shots[0].ID != "shot-1" || len(workspace.ShotRevisions) != 1 || workspace.ShotRevisions[0].ID != "revision-1" { + t.Fatalf("workspace leaked another unit: shots=%+v revisions=%+v", workspace.Shots, workspace.ShotRevisions) + } + if len(workspace.ShotArtifacts) != 1 || workspace.ShotArtifacts[0].ID != "artifact-1" || len(workspace.ShotReferences) != 1 || workspace.ShotReferences[0].ID != "reference-1" { + t.Fatalf("workspace artifact isolation failed: artifacts=%+v references=%+v", workspace.ShotArtifacts, workspace.ShotReferences) + } + if len(workspace.AssetCandidates) != 1 || workspace.AssetCandidates[0].ID != "candidate-1" || len(workspace.Assets) != 1 || workspace.Assets[0].ID != "asset-1" { + t.Fatalf("workspace asset isolation failed: candidates=%+v assets=%+v", workspace.AssetCandidates, workspace.Assets) + } +} + +func TestProjectCanvasesPageReturnsLinksOnlyForCurrentPage(t *testing.T) { + service, db := newProjectWorkbenchReadTestService(t) + project := seedWorkbenchProject(t, db) + if err := db.Create(&model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Title: "第一章"}).Error; err != nil { + t.Fatal(err) + } + now := time.Now() + for index := 0; index < 5; index++ { + canvasID := fmt.Sprintf("canvas-%d", index) + if err := db.Create(&model.CanvasProject{ID: canvasID, UserID: "user-1", ProjectID: project.ID, Title: canvasID, UpdatedAt: now.Add(time.Duration(index) * time.Minute)}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.CanvasUnitLink{ID: "link-" + canvasID, ProjectID: project.ID, CanvasID: canvasID, UnitID: "unit-1"}).Error; err != nil { + t.Fatal(err) + } + } + page, err := service.ProjectCanvasesPage("user-1", project.ID, 2, 2) + if err != nil { + t.Fatal(err) + } + if page.Total != 5 || len(page.Canvases) != 2 || len(page.CanvasUnitLinks) != 2 || !page.HasMore { + t.Fatalf("unexpected canvas page: %+v", page) + } + pageIDs := map[string]bool{page.Canvases[0].ID: true, page.Canvases[1].ID: true} + for _, link := range page.CanvasUnitLinks { + if !pageIDs[link.CanvasID] { + t.Fatalf("link %s does not belong to the current canvas page", link.CanvasID) + } + } + units, err := service.ProjectUnitSummaries("user-1", project.ID) + if err != nil { + t.Fatal(err) + } + if units.CanvasCounts["unit-1"] != 5 { + t.Fatalf("unit canvas count = %d, want 5", units.CanvasCounts["unit-1"]) + } +} + +func TestProjectAssetsPagePaginatesAndReturnsFacets(t *testing.T) { + service, db := newProjectWorkbenchReadTestService(t) + project := seedWorkbenchProject(t, db) + for index := 0; index < 6; index++ { + category := model.AssetCategoryProp + folderID := "folder-b" + if index < 2 { + category = model.AssetCategoryCharacter + folderID = "folder-a" + } + asset := model.Asset{ID: fmt.Sprintf("asset-%d", index), UserID: "user-1", Title: fmt.Sprintf("资产%d", index), Kind: "image", Category: category, Status: model.AssetVersionStatusConfirmed} + if err := db.Create(&asset).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ProjectAssetLink{ID: fmt.Sprintf("link-%d", index), ProjectID: project.ID, AssetID: asset.ID, FolderID: folderID}).Error; err != nil { + t.Fatal(err) + } + } + page, err := service.ProjectAssetsPage("user-1", project.ID, 2, 2, "prop", "image", "", nil, "") + if err != nil { + t.Fatal(err) + } + if page.Total != 4 || len(page.Assets) != 2 || page.CategoryCounts["character"] != 2 || page.CategoryCounts["prop"] != 4 { + t.Fatalf("unexpected asset page/facets: %+v", page) + } + if page.FolderCounts["folder-a"] != 2 || page.FolderCounts["folder-b"] != 4 { + t.Fatalf("unexpected folder facets: %+v", page.FolderCounts) + } +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 36a78a67a..a684077aa 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -33,6 +33,34 @@ description: 影策后端核心持久化表与状态边界 短剧项目状态以这些领域表为准。自由画布可以承载探索节点,但画布 metadata 不作为生产完成度、镜头版本或交付可用性的真相源。 +### 长篇项目读取边界 + +短剧工作台不再通过单个项目详情响应下载全部章节正文、镜头历史、产物、画布和资产。读取按页面职责拆分: + +| 接口 | 返回范围 | +| --- | --- | +| `GET /api/projects/:id/core` | 项目基础设置,不包含业务集合 | +| `GET /api/projects/:id/overview` | 项目聚合计数与前 8 个章节进度 | +| `GET /api/projects/:id/units` | 全部章节摘要与字数,不包含正文 | +| `GET /api/projects/:id/units/:unitId` | 单章正文 | +| `GET /api/projects/:id/units/:unitId/workspace` | 当前章节的镜头、版本、产物、引用、候选、相关任务和已绑定资产 | +| `GET /api/projects/:id/canvases` | 项目画布与当前页章节关联,使用 `page`、`page_size` | +| `GET /api/projects/:id/assets` | 项目资产分页、分类/目录计数,支持分类、媒体类型、目录和关键词筛选 | +| `GET /api/projects/:id/asset-candidates` | 资产候选分页,支持章节、状态和分类筛选 | + +旧 `GET /api/projects/:id` 暂时保留给尚未迁移的调用方;短剧工作台页面不得重新依赖该整包接口。 + +面向上千章、上万镜头的数据量,迁移会建立以下组合索引: + +- `idx_project_units_project_position`:章节按项目和位置读取。 +- `idx_shots_project_unit_position`:当前章节镜头按位置读取。 +- `idx_shot_artifacts_project_unit`:当前章节产物读取。 +- `idx_canvas_unit_links_project_unit`:章节关联画布聚合。 +- `idx_canvas_projects_user_project_updated`:项目画布倒序分页。 +- `idx_project_asset_links_project_folder_position`:项目目录资产分页与排序。 +- `idx_project_asset_candidates_project_unit_status`、`idx_project_asset_candidates_project_status_category`:章节候选和待确认分类队列。 +- `idx_tasks_user_project_created`:项目任务恢复读取。 + ## 模型渠道与目录 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 4d24be562..eee6d2869 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,21 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 素材库网格密度 + +- [x] 素材库桌面端缩小卡片并改为每行 6 张;新增素材占首格,默认每页 35 项,可切换 70/105 项,首屏正好组成 6×6 完整网格;中窄屏按 4/3/1 列收敛,前端生产构建通过。 +- [ ] 在 1280px、1440px、1920px 与窄屏下检查图片、视频、音频、文本、模型和新增素材卡片,确认标题、徽标、批量选择和操作菜单不拥挤或溢出。 + +## 短剧长篇项目分页读取 + +- [x] 短剧工作台已拆分项目核心、制作概览、章节摘要、当前章节生产包、画布分页、资产分页和候选分页接口;章节列表不返回正文,当前章节生产包不返回其他章节镜头和产物。 +- [x] 资产库按目录/分类分页,候选角色单独分页;分镜工作台资产栏服务端分页,当前章节响应只携带镜头已绑定资产;项目设置仅在打开主图选择器时读取图片资产。 +- [x] 新增章节、镜头、章节产物、画布、项目目录资产、候选资产和项目任务组合索引;后端聚合/隔离/分页专项测试与前端生产构建通过。 +- [ ] 导入至少 1000 章、每章 10 个镜头的测试项目,分别打开概览、章节、分镜、画布、资产和设置页,确认首屏请求不再出现整项目详情响应,切章只读取目标章节生产包。 +- [ ] 在浏览器网络面板记录上述页面的响应体积、请求耗时和内存占用;连续切换 30 个章节、翻阅 10 页画布和资产,确认 React Query 缓存及 DOM 数量没有持续异常增长。 +- [ ] 在资产超过 80 项、角色候选超过 24 项、画布超过 40 张时验证分页、分类、目录、候选确认、镜头资产绑定和主图选择;确认新增、移动、删除、确认后总数与当前页自动刷新。 +- [ ] 使用 SQLite 与 PostgreSQL 各执行一次大项目冒烟,确认新增组合索引成功创建,概览聚合、章节隔离和分页顺序一致。 + ## 多文件技能包库 - [x] 后端新增技能版本与文件索引,支持单个 Markdown、ZIP 和公开 GitHub 仓库安装;ZIP 路径越界、软链接、重复路径、文件数、单文件大小和总解压大小均在写入前校验,后端全量测试通过。 @@ -541,7 +556,7 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [x] 剧情章节列表去除装饰性侧边表达并显示状态、字数、画布数;制作概览新增六阶段标准流程、当前指标、制作缺口和可点击快速入口;底部镜头卡增加画面、加粗时长、镜头参数、产物版本与绿色成功状态。 - [x] 画布空白区域双击改为直接打开已有新增节点面板,并保留双击位置作为节点落点;原“适配内容”能力继续由缩放控制区提供。 - [ ] 在项目设置分别从个人素材、项目素材和上传图片设置主图,刷新项目列表后确认图片一致;替换、移除主图后确认回退逻辑正确,尝试删除当前主图资源时确认显示项目引用来源。 -- [ ] 使用 2500 章项目搜索章节序号和标题,连续切换上一章、下一章并刷新页面,确认当前位置、总章数、字数和路由一致,弹窗滚动流畅且不会一次渲染全部章节。 +- [ ] 使用 2500 章项目搜索章节序号和标题,确认定位弹窗桌面端为 5 列、默认每页 100 章,搜索区与分页区保持同一表面;连续切换上一章、下一章并刷新页面,确认当前位置、总章数、字数和路由一致。 - [ ] 在制作概览点击剧情、资产、分镜、预演、视频、交付和三个快速入口,确认跳转到正确章节及阶段,真实镜头产物数量与完成状态一致。 - [ ] 在浅色、深色主题和 1280px、1680px 宽度检查镜头卡,确认时长明显、标题与镜头参数不溢出、“已生成”为绿色且选中态仍使用工作台强调色。 - [ ] 在画布不同缩放和平移位置双击空白处,确认新增节点面板直接打开,选择文本、图片、视频和扩展节点后落点接近双击位置;双击节点、弹窗和其他浮层不应触发新增。 diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 98be1fa1a..727f6775a 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AssetMediaPreview } from "@/components/asset-media-preview"; import { AssetLibraryCard } from "@/components/assets/asset-library-card"; 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"; @@ -47,6 +48,8 @@ type Props = { emptyTitle?: string; emptyDescription?: string; footerNote?: string; + loading?: boolean; + pagination?: { current: number; pageSize: number; total: number; onChange: (page: number, pageSize: number) => void }; folderActionLabel?: string; folderActionSource?: "local" | "all"; upload?: { @@ -79,6 +82,8 @@ export function AssetLibraryPickerModal({ emptyTitle = "这个分类还没有素材", emptyDescription = "换个分类后再试。", footerNote, + loading = false, + pagination, folderActionLabel = "将文件夹放到画布", folderActionSource = "all", upload, @@ -123,10 +128,10 @@ export function AssetLibraryPickerModal({ return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); }); }, [category, folderId, keyword, sourceItems]); - const selectedIds = useMemo( - () => allItems.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id), - [allItems, selected], - ); + const selectedIds = useMemo(() => Array.from(selected).filter((id) => { + const item = allItems.find((entry) => entry.id === id); + return !item?.disabledReason; + }), [allItems, selected]); useEffect(() => { if (!open) return; @@ -272,7 +277,7 @@ export function AssetLibraryPickerModal({ - 已选 {selectedIds.length} · {visibleItems.length} 个素材 + 已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材
- {visibleItems.length ? visibleItems.map((item) => ( + {loading ? ( +
正在读取素材素材会按页加载,不会一次下载整个项目库。
+ ) : visibleItems.length ? visibleItems.map((item) => ( toggle(item)} /> )) : (
{emptyTitle}{activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}
)}
+ {pagination ? : null}