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}