From d66c7adc2c7579839481a2b61bb3c1f202974a9a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 29 Aug 2026 13:19:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(*):=20=E5=88=9B=E4=BD=9C=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=20-=20=E5=8D=87=E7=BA=A7=E7=9F=AD=E5=89=A7=E5=88=86=E9=95=9C?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=B9=B6=E5=AE=8C=E5=96=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=AE=A1=E8=B4=B9=E4=B8=8E=E7=95=8C=E9=9D=A2=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/migrate-sqlite-postgres/main.go | 3 + backend/internal/database/schema.go | 3 + backend/internal/handler/project.go | 19 + backend/internal/model/models_project.go | 74 +- backend/internal/repository/repository.go | 241 ++++++- .../internal/repository/resource_reference.go | 31 + .../service/feature_availability_test.go | 2 +- backend/internal/service/finance.go | 21 +- .../service/finance_price_tier_test.go | 54 ++ backend/internal/service/model_router.go | 3 +- backend/internal/service/project.go | 29 +- .../internal/service/project_delete_test.go | 10 + backend/internal/service/project_shot.go | 124 +++- backend/internal/service/project_workflow.go | 186 ++++- .../service/project_workflow_v2_test.go | 235 +++++++ backend/internal/service/task_creation.go | 8 +- .../internal/service/task_creation_test.go | 20 + .../content/docs/backend/backend-database.mdx | 13 + docs/content/docs/progress/pending-test.mdx | 39 ++ web/src/components/layout/app-top-nav.tsx | 16 +- .../layout/workspace-sidebar-state.ts | 28 + .../components/channel-model-manager.tsx | 237 +++---- .../channel-model-price-tier-form.ts | 102 +++ web/src/pages/projects/detail.tsx | 49 +- .../detail/workflow-production-workbench.tsx | 478 +++++++++++++ .../pages/projects/detail/workflow-shared.tsx | 83 +++ .../projects/detail/workflow-stage-views.tsx | 25 + web/src/pages/projects/detail/workflow.css | 661 ++++++++++++++++++ web/src/pages/projects/detail/workflow.tsx | 81 +++ web/src/router.tsx | 8 + web/src/services/api/projects.ts | 101 ++- .../channel-model-price-tier-form.test.ts | 55 ++ web/test/workspace-sidebar-state.test.ts | 49 ++ 33 files changed, 2849 insertions(+), 239 deletions(-) create mode 100644 backend/internal/service/finance_price_tier_test.go create mode 100644 backend/internal/service/project_workflow_v2_test.go create mode 100644 web/src/pages/admin/components/channel-model-price-tier-form.ts create mode 100644 web/src/pages/projects/detail/workflow-production-workbench.tsx create mode 100644 web/src/pages/projects/detail/workflow-shared.tsx create mode 100644 web/src/pages/projects/detail/workflow-stage-views.tsx create mode 100644 web/src/pages/projects/detail/workflow.css create mode 100644 web/src/pages/projects/detail/workflow.tsx create mode 100644 web/test/channel-model-price-tier-form.test.ts create mode 100644 web/test/workspace-sidebar-state.test.ts diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 2750be7b7..6273f5442 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -260,11 +260,14 @@ func migrations() []tableMigration { migrateTable[model.ProjectUnit]("project_units"), migrateTable[model.CanvasUnitLink]("canvas_unit_links"), migrateTable[model.Shot]("shots"), + migrateTable[model.ShotRevision]("shot_revisions"), + migrateTable[model.ShotArtifact]("shot_artifacts"), migrateTable[model.ShotAssetReference]("shot_asset_references"), migrateTable[model.WorkflowTemplateVersion]("workflow_template_versions"), migrateTable[model.WorkflowInstance]("workflow_instances"), migrateTable[model.WorkflowStepInstance]("workflow_step_instances"), migrateTable[model.WorkflowStepTask]("workflow_step_tasks"), + migrateTable[model.ProductionTaskLink]("production_task_links"), migrateTable[model.CanvasProject]("canvas_projects"), migrateTable[model.CanvasShare]("canvas_shares"), migrateTable[model.PromptTemplate]("prompt_templates"), diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 42b104e9b..d06994d0e 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -62,11 +62,14 @@ func Models() []any { &model.ProjectUnit{}, &model.CanvasUnitLink{}, &model.Shot{}, + &model.ShotRevision{}, + &model.ShotArtifact{}, &model.ShotAssetReference{}, &model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.WorkflowStepTask{}, + &model.ProductionTaskLink{}, &model.CanvasProject{}, &model.CanvasShare{}, &model.PromptTemplate{}, diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index 7127781f8..6d44e90d7 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -678,6 +678,25 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"shots": shots}) }) + r.POST("/projects/:id/shots/:shotId/revisions", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.ShotRevisionInput + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + shot, revision, err := svc.CreateShotRevision(user.ID, c.Param("id"), c.Param("shotId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"shot": shot, "revision": revision}) + }) r.POST("/projects/:id/shots/:shotId/assets", 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 0c4d876c2..716ef0aa4 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -203,16 +203,56 @@ type CanvasUnitLink struct { } 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"` - Title string `json:"title" gorm:"size:240"` - Description string `json:"description" gorm:"type:text"` - Position int `json:"position"` - DurationMs int64 `json:"durationMs"` - Status string `json:"status" gorm:"index;size:24"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + UnitID string `json:"unitId" gorm:"index;size:36"` + 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"` + DurationMs int64 `json:"durationMs"` + Status string `json:"status" gorm:"index;size:24"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ShotRevision 保存可复现的分镜脚本版本;Shot 只保留稳定身份、排序和当前版本指针。 +type ShotRevision struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_revisions_version,priority:1"` + Version int `json:"version" gorm:"uniqueIndex:idx_shot_revisions_version,priority:2"` + PlotDescription string `json:"plotDescription" gorm:"type:text"` + Action string `json:"action" gorm:"type:text"` + Dialogue string `json:"dialogue" gorm:"type:text"` + ShotSize string `json:"shotSize" gorm:"size:80"` + CameraAngle string `json:"cameraAngle" gorm:"size:80"` + CameraMovement string `json:"cameraMovement" gorm:"size:120"` + DurationMs int64 `json:"durationMs"` + ImagePrompt string `json:"imagePrompt" gorm:"type:text"` + VideoPrompt string `json:"videoPrompt" gorm:"type:text"` + NegativePrompt string `json:"negativePrompt" gorm:"type:text"` + ContinuityNotes string `json:"continuityNotes" gorm:"type:text"` + ActionBeatsJSON string `json:"actionBeatsJson" gorm:"type:text"` + CreatedBy string `json:"createdBy,omitempty" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` +} + +// 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"` + 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"` + Type string `json:"type" gorm:"index;size:40;uniqueIndex:idx_shot_artifacts_version,priority:2"` + Version int `json:"version" gorm:"uniqueIndex:idx_shot_artifacts_version,priority:3"` + ResourceID string `json:"resourceId,omitempty" gorm:"index;size:36"` + Status string `json:"status" gorm:"index;size:24"` + Selected bool `json:"selected" gorm:"index"` + MetadataJSON string `json:"metadataJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type ShotAssetReference struct { @@ -268,6 +308,20 @@ type WorkflowStepTask struct { CreatedAt time.Time `json:"createdAt"` } +// ProductionTaskLink 显式区分领域项目、画布和镜头上下文,避免继续复用 Task.ProjectID 表达多种身份。 +type ProductionTaskLink struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_production_task_context,priority:1"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + CanvasID string `json:"canvasId,omitempty" gorm:"index;size:80"` + UnitID string `json:"unitId,omitempty" gorm:"index;size:36"` + ShotID string `json:"shotId,omitempty" gorm:"index;size:36;uniqueIndex:idx_production_task_context,priority:2"` + WorkflowStepID string `json:"workflowStepId,omitempty" gorm:"index;size:36"` + ArtifactType string `json:"artifactType,omitempty" gorm:"index;size:40;uniqueIndex:idx_production_task_context,priority:3"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + 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"` diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index dffa6eddd..bc0f304dc 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -1154,6 +1154,15 @@ func (r *Repository) DeleteProject(userID string, id string, canvasUpdates []mod return fmt.Errorf("项目删除时仍有 %d 个画布关联未处理", remainingCanvasCount) } shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ?", id) + if err := tx.Where("project_id = ?", id).Delete(&model.ProductionTaskLink{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { return err } @@ -1253,10 +1262,27 @@ func (r *Repository) ProjectUnit(projectID string, id string) (*model.ProjectUni return &unit, nil } -func (r *Repository) UpdateProjectUnit(unit *model.ProjectUnit) error { - return r.db.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unit.ID, unit.ProjectID).Updates(map[string]any{ - "parent_id": unit.ParentID, "title": unit.Title, "source_text": unit.SourceText, "status": unit.Status, "position": unit.Position, "updated_at": unit.UpdatedAt, - }).Error +func (r *Repository) UpdateProjectUnit(unit *model.ProjectUnit, invalidateWorkflow bool) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unit.ID, unit.ProjectID).Updates(map[string]any{ + "parent_id": unit.ParentID, "title": unit.Title, "source_text": unit.SourceText, "status": unit.Status, "position": unit.Position, "updated_at": unit.UpdatedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + if invalidateWorkflow { + if err := tx.Model(&model.ShotArtifact{}).Where("project_id = ? AND unit_id = ? AND status NOT IN ?", unit.ProjectID, unit.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": unit.UpdatedAt}).Error; err != nil { + return err + } + if err := invalidateUnitWorkflowTx(tx, unit.ProjectID, unit.ID, "story", unit.UpdatedAt); err != nil { + return err + } + } + return tx.Model(&model.Project{}).Where("id = ?", unit.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": unit.UpdatedAt}).Error + }) } func (r *Repository) DeleteProjectUnit(projectID string, id string) error { @@ -1265,6 +1291,15 @@ func (r *Repository) DeleteProjectUnit(projectID string, id string) error { return err } shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id) + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProductionTaskLink{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { return err } @@ -1609,9 +1644,61 @@ func (r *Repository) SaveShot(shot *model.Shot, create bool) error { }).Error } -func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot) error { +// SaveShotWithRevision 原子保存镜头当前值、新版本和下游失效状态。 +func (r *Repository) SaveShotWithRevision(shot *model.Shot, revision *model.ShotRevision, create bool) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if create { + if err := tx.Create(shot).Error; err != nil { + return err + } + } else { + result := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ + "unit_id": shot.UnitID, "title": shot.Title, "description": shot.Description, "position": shot.Position, + "duration_ms": shot.DurationMs, "status": shot.Status, "updated_at": shot.UpdatedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + } + var currentVersion int + if err := tx.Model(&model.ShotRevision{}).Where("shot_id = ?", shot.ID).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { + return err + } + revision.Version = currentVersion + 1 + if err := tx.Create(revision).Error; err != nil { + return err + } + shot.CurrentRevisionID = revision.ID + if err := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ + "current_revision_id": revision.ID, "description": shot.Description, "duration_ms": shot.DurationMs, + "status": shot.Status, "updated_at": shot.UpdatedAt, + }).Error; err != nil { + return err + } + if !create { + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shot.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": shot.UpdatedAt}).Error; err != nil { + return err + } + } + if err := invalidateUnitWorkflowTx(tx, shot.ProjectID, shot.UnitID, "storyboard", shot.UpdatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", shot.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": shot.UpdatedAt}).Error + }) +} + +func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot, revisions []model.ShotRevision) error { return r.db.Transaction(func(tx *gorm.DB) error { shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, unitID) + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { return err } @@ -1624,6 +1711,14 @@ func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, sh if err := tx.Create(&shots).Error; err != nil { return err } + if len(revisions) > 0 { + if err := tx.Create(&revisions).Error; err != nil { + return err + } + } + if err := invalidateUnitWorkflowTx(tx, projectID, unitID, "storyboard", time.Now()); err != nil { + return err + } return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error }) } @@ -1636,6 +1731,48 @@ func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Sho return &shot, nil } +func (r *Repository) ProjectShotRevisions(projectID 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 = ?", projectID). + Order("shots.unit_id asc, shots.position asc, shot_revisions.version asc").Scan(&revisions).Error + return revisions, err +} + +func (r *Repository) ProjectShotArtifacts(projectID string) ([]model.ShotArtifact, error) { + var artifacts []model.ShotArtifact + err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, shot_id asc, type asc, version asc").Find(&artifacts).Error + return artifacts, err +} + +func (r *Repository) CreateShotArtifact(artifact *model.ShotArtifact) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var currentVersion int + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { + return err + } + artifact.Version = currentVersion + 1 + if artifact.Selected { + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil { + return err + } + } + return tx.Create(artifact).Error + }) +} + +func (r *Repository) MarkShotArtifactsStale(shotID string, updatedAt time.Time) error { + return r.db.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error +} + +func (r *Repository) UpsertProductionTaskLink(link *model.ProductionTaskLink) error { + return r.db.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", link.TaskID, link.ShotID, link.ArtifactType).Assign(map[string]any{ + "project_id": link.ProjectID, "canvas_id": link.CanvasID, "unit_id": link.UnitID, "shot_id": link.ShotID, + "workflow_step_id": link.WorkflowStepID, "artifact_type": link.ArtifactType, "updated_at": link.UpdatedAt, + }).FirstOrCreate(link).Error +} + func (r *Repository) UpsertShotAssetReference(reference *model.ShotAssetReference) error { result := r.db.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status}) if result.Error != nil || result.RowsAffected > 0 { @@ -1644,6 +1781,75 @@ func (r *Repository) UpsertShotAssetReference(reference *model.ShotAssetReferenc return r.db.Create(reference).Error } +func (r *Repository) UpsertShotAssetReferenceAndInvalidate(projectID string, reference *model.ShotAssetReference, updatedAt time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + if err := tx.Create(reference).Error; err != nil { + return err + } + } + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", reference.ShotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error; err != nil { + return err + } + var shot model.Shot + if err := tx.First(&shot, "id = ? AND project_id = ?", reference.ShotID, projectID).Error; err != nil { + return err + } + if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error + }) +} + +func invalidateUnitWorkflowTx(tx *gorm.DB, projectID string, unitID string, fromStepKey string, updatedAt time.Time) error { + if strings.TrimSpace(unitID) == "" { + return nil + } + var instances []model.WorkflowInstance + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Find(&instances).Error; err != nil { + return err + } + for _, instance := range instances { + var steps []model.WorkflowStepInstance + if err := tx.Where("workflow_instance_id = ?", instance.ID).Order("position asc").Find(&steps).Error; err != nil { + return err + } + fromPosition := -1 + for _, step := range steps { + if step.StepKey == fromStepKey { + fromPosition = step.Position + break + } + } + if fromPosition < 0 { + continue + } + for _, step := range steps { + if step.Position < fromPosition { + continue + } + status := model.WorkflowStepStatusPending + if step.Position == fromPosition { + status = model.WorkflowStepStatusRunning + } + if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, instance.ID).Updates(map[string]any{ + "status": status, "error": "", "completed_at": nil, "updated_at": updatedAt, + }).Error; err != nil { + return err + } + } + if err := tx.Model(&model.WorkflowInstance{}).Where("id = ?", instance.ID).Updates(map[string]any{"status": model.WorkflowStatusActive, "revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error; err != nil { + return err + } + } + return nil +} + func (r *Repository) ProjectShotAssetReferences(projectID string) ([]model.ShotAssetReference, error) { var references []model.ShotAssetReference err := r.db.Table("shot_asset_references").Select("shot_asset_references.*"). @@ -1802,7 +2008,7 @@ func (r *Repository) UpdateWorkflowProgress(step *model.WorkflowStepInstance, ne } // RegisterWorkflowTaskOutput 将成功任务、流程步骤和产物表示写入同一事务,重复回填使用任务与用途唯一键幂等。 -func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation) error { +func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation, productionLink *model.ProductionTaskLink, artifact *model.ShotArtifact) error { return r.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("workflow_step_id = ? AND task_id = ?", link.WorkflowStepID, link.TaskID).FirstOrCreate(link).Error; err != nil { return err @@ -1812,6 +2018,29 @@ func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance return err } } + if productionLink != nil { + if err := tx.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", productionLink.TaskID, productionLink.ShotID, productionLink.ArtifactType).Assign(map[string]any{ + "project_id": productionLink.ProjectID, "canvas_id": productionLink.CanvasID, "unit_id": productionLink.UnitID, "shot_id": productionLink.ShotID, + "workflow_step_id": productionLink.WorkflowStepID, "artifact_type": productionLink.ArtifactType, "updated_at": productionLink.UpdatedAt, + }).FirstOrCreate(productionLink).Error; err != nil { + return err + } + } + if artifact != nil { + var currentVersion int + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { + return err + } + artifact.Version = currentVersion + 1 + if artifact.Selected { + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil { + return err + } + } + if err := tx.Create(artifact).Error; err != nil { + return err + } + } stepResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{ "status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt, diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index ffd97fcf1..a6a4b0311 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -161,6 +161,19 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工作流", ID: step.ID, Title: step.Title, PrimaryJSON: step.PrimaryJSON, SecondaryJSON: step.SecondaryJSON}) } + var shotArtifacts []joinedDocument + artifactDocumentQuery := r.db.Table("shot_artifacts"). + Select("shot_artifacts.id, shots.title, shot_artifacts.metadata_json AS primary_json"). + Joins("JOIN shots ON shots.id = shot_artifacts.shot_id"). + Joins("JOIN projects ON projects.id = shots.project_id"). + Where("projects.user_id = ?", userID) + if err := artifactDocumentQuery.Scan(&shotArtifacts).Error; err != nil { + return snapshot, err + } + for _, artifact := range shotArtifacts { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "镜头产物", ID: artifact.ID, Title: artifact.Title, PrimaryJSON: artifact.PrimaryJSON}) + } + type joinedRepresentation struct { ID string Title string @@ -186,6 +199,24 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s for _, voice := range voices { snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "声音", ID: voice.ID, Title: voice.Name, ResourceID: voice.SampleResourceID}) } + + type joinedShotArtifact struct { + ID string + Title string + ResourceID string + } + var artifacts []joinedShotArtifact + if err := r.db.Table("shot_artifacts"). + Select("shot_artifacts.id, shots.title, shot_artifacts.resource_id"). + Joins("JOIN shots ON shots.id = shot_artifacts.shot_id"). + Joins("JOIN projects ON projects.id = shots.project_id"). + Where("projects.user_id = ? AND shot_artifacts.resource_id IN ?", userID, resourceIDs). + Scan(&artifacts).Error; err != nil { + return snapshot, err + } + for _, artifact := range artifacts { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "镜头产物", ID: artifact.ID, Title: artifact.Title, ResourceID: artifact.ResourceID}) + } return snapshot, nil } diff --git a/backend/internal/service/feature_availability_test.go b/backend/internal/service/feature_availability_test.go index 8cb239275..0dc44206c 100644 --- a/backend/internal/service/feature_availability_test.go +++ b/backend/internal/service/feature_availability_test.go @@ -136,7 +136,7 @@ func TestCustomChannelTaskInputRequiresFeature(t *testing.T) { func TestCreateTaskDoesNotClassifyCustomChannelAsMissingSystemModel(t *testing.T) { svc, _ := newFeatureAvailabilityTestService(t) actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} - if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: false}); err != nil { + if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: false, FrontendModelsEnabled: true}); err != nil { t.Fatal(err) } diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 7aa4cb6ae..b08eb70b9 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -452,7 +452,9 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st capability = capabilityFromTaskType(task.Type) } scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type) - return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability)) + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + priceTierID, _ := config["priceTierId"].(string) + return s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(priceTierID), intent) } func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) { @@ -474,7 +476,9 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i capability = capabilityFromTaskType(task.Type) } if logicalModel.PricePolicy == "channel" { - order, priceErr := s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"]))) + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + priceTierID, _ := config["priceTierId"].(string) + order, priceErr := s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(priceTierID), intent) if priceErr != nil { return nil, priceErr } @@ -560,7 +564,7 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s return s.newBillingOrderWithPriceTier(userID, taskID, idempotencyKey, channelID, modelKey, capability, scene, requestedQuantity, tokenEstimate, "") } -func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate, priceTierID string) (*model.BillingOrder, error) { +func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate, priceTierID string, intents ...ModelRequestIntent) (*model.BillingOrder, error) { item, err := s.repo.ChannelModelByKey(channelID, modelKey) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, BadAuthRequest("当前模型暂时不可用,请重新选择") @@ -568,7 +572,7 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide if err != nil { return nil, err } - tier := channelModelPriceTierForBilling(*item, priceTierID, capability) + tier := channelModelPriceTierForBilling(*item, priceTierID, capability, intents...) if tier == nil { return nil, BadAuthRequest("当前模型尚未配置所选规格的用户积分价格") } @@ -625,7 +629,7 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide }, nil } -func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string) *model.ChannelModelPriceTier { +func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string, intents ...ModelRequestIntent) *model.ChannelModelPriceTier { if priceTierID != "" { for index := range channelModel.PriceTiers { tier := &channelModel.PriceTiers[index] @@ -635,6 +639,13 @@ func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierI } return nil } + if len(intents) > 0 { + intent := intents[0] + if normalizeCapability(intent.Capability) == "" { + intent.Capability = capability + } + return channelModelPriceTierForIntent(channelModel, intent) + } return channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: capability, Options: map[string]any{}}) } diff --git a/backend/internal/service/finance_price_tier_test.go b/backend/internal/service/finance_price_tier_test.go new file mode 100644 index 000000000..4b3930cbb --- /dev/null +++ b/backend/internal/service/finance_price_tier_test.go @@ -0,0 +1,54 @@ +package service + +import ( + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestTaskBillingOrderMatchesSystemImagePriceTierFromRequestedSpec(t *testing.T) { + 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.SystemSetting{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil { + t.Fatal(err) + } + channelModel := model.ChannelModel{ + ID: "channel-model-1", ChannelID: "channel-1", ModelKey: "gpt-image-2", ProviderModelKey: "gpt-image-2", + Capability: "image", Protocol: model.ChannelInterfaceOpenAIImage, BillingMode: "fixed_request", + PriceConfigured: true, Enabled: true, PriceVersion: 1, + } + if err := db.Create(&channelModel).Error; err != nil { + t.Fatal(err) + } + tier := model.ChannelModelPriceTier{ + ID: "tier-2k", ChannelModelID: channelModel.ID, SelectorKey: `{"quality":"2k"}`, SelectorJSON: `{"quality":"2k"}`, + Resolution: "*", ProviderModelKey: channelModel.ProviderModelKey, BillingMode: "fixed_request", + UnitPriceMicrocredits: 4_000_000, PriceConfigured: true, Enabled: true, PriceVersion: 1, + } + if err := db.Create(&tier).Error; err != nil { + t.Fatal(err) + } + + svc := New(repository.New(db), t.TempDir()) + order, err := svc.taskBillingOrder("user-1", &model.Task{ID: "task-1", Type: "canvas_image", Operation: "image"}, map[string]any{ + "mode": "image", + "config": map[string]any{ + "channelId": "channel-1", + "model": "gpt-image-2", + "quality": "2K", + "size": "*", + }, + }) + if err != nil { + t.Fatalf("taskBillingOrder() error = %v", err) + } + if order == nil || order.PriceTierID != tier.ID || order.AmountMicrocredits != tier.UnitPriceMicrocredits { + t.Fatalf("taskBillingOrder() = %#v, want tier %s", order, tier.ID) + } +} diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index 36e841ad0..64d1c9828 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -1086,7 +1086,8 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route if capability == "" { capability = capabilityFromTaskType(task.Type) } - replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"]))) + priceTierID, _ := config["priceTierId"].(string) + replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent) if err != nil { return nil, err } diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index b4201f893..d293630b8 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -86,6 +86,8 @@ type ProjectDetail struct { AssetFolders []model.ProjectAssetFolder `json:"assetFolders"` 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"` } @@ -177,6 +179,14 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) if err != nil { return ProjectDetail{}, err } + shotRevisions, err := s.repo.ProjectShotRevisions(project.ID) + if err != nil { + return ProjectDetail{}, err + } + shotArtifacts, err := s.repo.ProjectShotArtifacts(project.ID) + if err != nil { + return ProjectDetail{}, err + } shotReferences, err := s.repo.ProjectShotAssetReferences(project.ID) if err != nil { return ProjectDetail{}, err @@ -185,7 +195,7 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) if err != nil { return ProjectDetail{}, err } - return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil + return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotRevisions: shotRevisions, ShotArtifacts: shotArtifacts, ShotReferences: shotReferences, AssetCandidates: candidates}, nil } func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.Project, error) { @@ -317,6 +327,17 @@ func (s *Service) DeleteProject(userID string, id string) error { return nil } +func (s *Service) activeProjectForUser(userID string, projectID string) (*model.Project, error) { + project, err := s.repo.ProjectForUser(userID, projectID) + if err != nil { + return nil, err + } + if project.Status == model.ProjectStatusArchived { + return nil, BadAuthRequest("项目已归档,不能修改短剧生产数据") + } + return project, nil +} + func (s *Service) CreateProjectUnit(userID string, projectID string, req CreateProjectUnitRequest) (model.ProjectUnit, error) { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return model.ProjectUnit{}, err @@ -439,6 +460,7 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri if err != nil { return model.ProjectUnit{}, err } + sourceChanged := unit.SourceText != req.SourceText if title := strings.TrimSpace(req.Title); title != "" { unit.Title = title } @@ -450,10 +472,7 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri unit.Status = status } unit.UpdatedAt = time.Now() - if err := s.repo.UpdateProjectUnit(unit); err != nil { - return model.ProjectUnit{}, err - } - if err := s.repo.BumpProjectRevision(projectID); err != nil { + if err := s.repo.UpdateProjectUnit(unit, sourceChanged); err != nil { return model.ProjectUnit{}, err } return *unit, nil diff --git a/backend/internal/service/project_delete_test.go b/backend/internal/service/project_delete_test.go index f2a4eb16a..4d4a44014 100644 --- a/backend/internal/service/project_delete_test.go +++ b/backend/internal/service/project_delete_test.go @@ -31,10 +31,13 @@ func newProjectDeleteTestService(t *testing.T) (*Service, *gorm.DB) { &model.Task{}, &model.Session{}, &model.Shot{}, + &model.ShotRevision{}, + &model.ShotArtifact{}, &model.ShotAssetReference{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.WorkflowStepTask{}, + &model.ProductionTaskLink{}, ); err != nil { t.Fatal(err) } @@ -60,8 +63,12 @@ func TestDeleteProjectUnlinksCanvasAndKeepsIndependentRecords(t *testing.T) { &model.ProjectAssetLink{ID: "asset-link-1", ProjectID: project.ID, AssetID: asset.ID}, &model.ProjectAssetFolder{ID: "folder-1", ProjectID: project.ID, Name: "角色", NameKey: "角色"}, &model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: project.ID, UnitID: "unit-1", Name: "角色", Status: "pending_confirmation"}, + &model.Shot{ID: "shot-1", ProjectID: project.ID, UnitID: "unit-1", CurrentRevisionID: "revision-1", Title: "镜头一"}, + &model.ShotRevision{ID: "revision-1", ShotID: "shot-1", Version: 1, PlotDescription: "角色入场"}, + &model.ShotArtifact{ID: "artifact-1", ProjectID: project.ID, UnitID: "unit-1", ShotID: "shot-1", RevisionID: "revision-1", Type: "storyboard", Version: 1, Status: "ready"}, &task, &canvasTask, + &model.ProductionTaskLink{ID: "production-link-1", TaskID: task.ID, ProjectID: project.ID, UnitID: "unit-1", ShotID: "shot-1"}, &session, &canvasSession, } @@ -105,6 +112,9 @@ func TestDeleteProjectUnlinksCanvasAndKeepsIndependentRecords(t *testing.T) { {name: "asset link", model: &model.ProjectAssetLink{}, where: "project_id = ?"}, {name: "asset folder", model: &model.ProjectAssetFolder{}, where: "project_id = ?"}, {name: "asset candidate", model: &model.ProjectAssetCandidate{}, where: "project_id = ?"}, + {name: "shot", model: &model.Shot{}, where: "project_id = ?"}, + {name: "shot artifact", model: &model.ShotArtifact{}, where: "project_id = ?"}, + {name: "production task link", model: &model.ProductionTaskLink{}, where: "project_id = ?"}, } { var count int64 if err := db.Model(check.model).Where(check.where, project.ID).Count(&count).Error; err != nil { diff --git a/backend/internal/service/project_shot.go b/backend/internal/service/project_shot.go index 490427eef..9396d2fcd 100644 --- a/backend/internal/service/project_shot.go +++ b/backend/internal/service/project_shot.go @@ -9,13 +9,29 @@ import ( ) type CreateProjectShotRequest struct { - ID string `json:"id"` - UnitID string `json:"unitId"` - Title string `json:"title"` - Description string `json:"description"` - Position int `json:"position"` - DurationMs int64 `json:"durationMs"` - Status string `json:"status"` + ID string `json:"id"` + UnitID string `json:"unitId"` + Title string `json:"title"` + Description string `json:"description"` + Position int `json:"position"` + DurationMs int64 `json:"durationMs"` + Status string `json:"status"` + Revision ShotRevisionInput `json:"revision"` +} + +type ShotRevisionInput struct { + PlotDescription string `json:"plotDescription"` + Action string `json:"action"` + Dialogue string `json:"dialogue"` + ShotSize string `json:"shotSize"` + CameraAngle string `json:"cameraAngle"` + CameraMovement string `json:"cameraMovement"` + DurationMs int64 `json:"durationMs"` + ImagePrompt string `json:"imagePrompt"` + VideoPrompt string `json:"videoPrompt"` + NegativePrompt string `json:"negativePrompt"` + ContinuityNotes string `json:"continuityNotes"` + ActionBeats []map[string]any `json:"actionBeats"` } type ReplaceProjectUnitShotsRequest struct { @@ -40,7 +56,7 @@ type CreateAssetCandidatesRequest struct { } func (s *Service) CreateProjectShot(userID string, projectID string, req CreateProjectShotRequest) (model.Shot, error) { - if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { return model.Shot{}, err } unitID := strings.TrimSpace(req.UnitID) @@ -70,6 +86,9 @@ func (s *Service) CreateProjectShot(userID string, projectID string, req CreateP if err != nil { return model.Shot{}, err } + if unitID == "" { + unitID = existing.UnitID + } if status == "" { status = existing.Status } @@ -78,18 +97,21 @@ func (s *Service) CreateProjectShot(userID string, projectID string, req CreateP if !validShotStatus(status) { return model.Shot{}, BadAuthRequest("不支持的镜头状态") } - shot := model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, Title: title, Description: strings.TrimSpace(req.Description), Position: req.Position, DurationMs: req.DurationMs, Status: status, CreatedAt: now, UpdatedAt: time.Now()} - if err := s.repo.SaveShot(&shot, create); err != nil { + updatedAt := time.Now() + description := strings.TrimSpace(req.Description) + revision, err := newShotRevision(userID, shotID, req.Revision, description, req.DurationMs, updatedAt) + if err != nil { return model.Shot{}, err } - if err := s.repo.BumpProjectRevision(projectID); err != nil { + shot := model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, CurrentRevisionID: revision.ID, Title: title, Description: revision.PlotDescription, Position: req.Position, DurationMs: revision.DurationMs, Status: status, CreatedAt: now, UpdatedAt: updatedAt} + if err := s.repo.SaveShotWithRevision(&shot, &revision, create); err != nil { return model.Shot{}, err } return shot, nil } func (s *Service) ReplaceProjectUnitShots(userID string, projectID string, unitID string, req ReplaceProjectUnitShotsRequest) ([]model.Shot, error) { - if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { return nil, err } unitID = strings.TrimSpace(unitID) @@ -101,21 +123,83 @@ func (s *Service) ReplaceProjectUnitShots(userID string, projectID string, unitI } now := time.Now() shots := make([]model.Shot, 0, len(req.Shots)) + revisions := make([]model.ShotRevision, 0, len(req.Shots)) for position, input := range req.Shots { title := strings.TrimSpace(input.Title) description := strings.TrimSpace(input.Description) if title == "" || description == "" || input.DurationMs < 0 { return nil, BadAuthRequest("分镜标题、描述或时长无效") } - shots = append(shots, model.Shot{ID: newID(), ProjectID: projectID, UnitID: unitID, Title: title, Description: description, Position: position, DurationMs: input.DurationMs, Status: "draft", CreatedAt: now, UpdatedAt: now}) + shotID := newID() + revision, err := newShotRevision(userID, shotID, input.Revision, description, input.DurationMs, now) + if err != nil { + return nil, err + } + revision.Version = 1 + shots = append(shots, model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, CurrentRevisionID: revision.ID, Title: title, Description: revision.PlotDescription, Position: position, DurationMs: revision.DurationMs, Status: "draft", CreatedAt: now, UpdatedAt: now}) + revisions = append(revisions, revision) } // 章节级重生成是一个整体写操作,旧镜头与引用必须和新镜头在同一事务中替换。 - if err := s.repo.ReplaceProjectUnitShots(projectID, unitID, shots); err != nil { + if err := s.repo.ReplaceProjectUnitShots(projectID, unitID, shots, revisions); err != nil { return nil, err } return shots, nil } +func (s *Service) CreateShotRevision(userID string, projectID string, shotID string, input ShotRevisionInput) (model.Shot, model.ShotRevision, error) { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { + return model.Shot{}, model.ShotRevision{}, err + } + shot, err := s.repo.ShotForProject(projectID, strings.TrimSpace(shotID)) + if err != nil { + return model.Shot{}, model.ShotRevision{}, err + } + now := time.Now() + revision, err := newShotRevision(userID, shot.ID, input, shot.Description, shot.DurationMs, now) + if err != nil { + return model.Shot{}, model.ShotRevision{}, err + } + shot.Description = revision.PlotDescription + shot.DurationMs = revision.DurationMs + shot.Status = "draft" + shot.UpdatedAt = now + if err := s.repo.SaveShotWithRevision(shot, &revision, false); err != nil { + return model.Shot{}, model.ShotRevision{}, err + } + return *shot, revision, nil +} + +func newShotRevision(userID string, shotID string, input ShotRevisionInput, fallbackDescription string, fallbackDuration int64, now time.Time) (model.ShotRevision, error) { + plotDescription := strings.TrimSpace(input.PlotDescription) + if plotDescription == "" { + plotDescription = strings.TrimSpace(fallbackDescription) + } + if plotDescription == "" { + return model.ShotRevision{}, BadAuthRequest("镜头画面描述不能为空") + } + durationMs := input.DurationMs + if durationMs == 0 { + durationMs = fallbackDuration + } + if durationMs < 0 { + return model.ShotRevision{}, BadAuthRequest("镜头时长不能为负数") + } + actionBeatsJSON := "[]" + if input.ActionBeats != nil { + encoded, err := json.Marshal(input.ActionBeats) + if err != nil { + return model.ShotRevision{}, BadAuthRequest("动作节拍格式无效") + } + actionBeatsJSON = string(encoded) + } + return model.ShotRevision{ + ID: newID(), ShotID: shotID, PlotDescription: plotDescription, Action: strings.TrimSpace(input.Action), Dialogue: strings.TrimSpace(input.Dialogue), + ShotSize: strings.TrimSpace(input.ShotSize), CameraAngle: strings.TrimSpace(input.CameraAngle), CameraMovement: strings.TrimSpace(input.CameraMovement), DurationMs: durationMs, + ImagePrompt: strings.TrimSpace(input.ImagePrompt), VideoPrompt: strings.TrimSpace(input.VideoPrompt), NegativePrompt: strings.TrimSpace(input.NegativePrompt), + ContinuityNotes: strings.TrimSpace(input.ContinuityNotes), ActionBeatsJSON: actionBeatsJSON, CreatedBy: userID, CreatedAt: now, + }, nil +} + func validShotStatus(status string) bool { switch status { case "draft", "ready", "running", "review", "completed", "failed": @@ -126,7 +210,7 @@ func validShotStatus(status string) bool { } func (s *Service) LinkShotAsset(userID string, projectID string, shotID string, req LinkShotAssetRequest) (model.ShotAssetReference, error) { - if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { return model.ShotAssetReference{}, err } if _, err := s.repo.ShotForProject(projectID, shotID); err != nil { @@ -140,18 +224,16 @@ func (s *Service) LinkShotAsset(userID string, projectID string, shotID string, if !validShotAssetRole(role) { return model.ShotAssetReference{}, BadAuthRequest("不支持的镜头素材用途") } - reference := model.ShotAssetReference{ID: newID(), ShotID: shotID, AssetVersionID: versionID, Role: role, Status: "linked", CreatedAt: time.Now()} - if err := s.repo.UpsertShotAssetReference(&reference); err != nil { - return model.ShotAssetReference{}, err - } - if err := s.repo.BumpProjectRevision(projectID); err != nil { + now := time.Now() + reference := model.ShotAssetReference{ID: newID(), ShotID: shotID, AssetVersionID: versionID, Role: role, Status: "linked", CreatedAt: now} + if err := s.repo.UpsertShotAssetReferenceAndInvalidate(projectID, &reference, now); err != nil { return model.ShotAssetReference{}, err } return reference, nil } func (s *Service) CreateProjectAssetCandidates(userID string, projectID string, req CreateAssetCandidatesRequest) ([]model.ProjectAssetCandidate, error) { - if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { return nil, err } if len(req.Candidates) == 0 || len(req.Candidates) > 100 { diff --git a/backend/internal/service/project_workflow.go b/backend/internal/service/project_workflow.go index c036ada1f..d0cea6d4d 100644 --- a/backend/internal/service/project_workflow.go +++ b/backend/internal/service/project_workflow.go @@ -12,7 +12,7 @@ import ( ) const builtinShortDramaWorkflowKey = "short-drama-production" -const builtinShortDramaWorkflowVersion = 1 +const builtinShortDramaWorkflowVersion = 2 type workflowStepDefinition struct { Key string `json:"key"` @@ -20,13 +20,12 @@ type workflowStepDefinition struct { } var builtinShortDramaSteps = []workflowStepDefinition{ - {Key: "style", Name: "选择画风"}, - {Key: "story", Name: "输入故事"}, - {Key: "storyboard", Name: "生成分镜"}, - {Key: "assets", Name: "准备资产"}, - {Key: "video", Name: "生成视频"}, - {Key: "review", Name: "审核与重试"}, - {Key: "delivery", Name: "合并成片"}, + {Key: "story", Name: "剧情与章节"}, + {Key: "assets", Name: "资产拆分"}, + {Key: "storyboard", Name: "分镜脚本"}, + {Key: "previz", Name: "黑白动作预演"}, + {Key: "video", Name: "视频生成"}, + {Key: "delivery", Name: "交付与打包"}, } type ProjectWorkflowDetail struct { @@ -42,6 +41,10 @@ type UpdateWorkflowStepRequest struct { type RegisterTaskOutputRequest struct { TaskID string `json:"taskId"` + CanvasID string `json:"canvasId"` + UnitID string `json:"unitId"` + ShotID string `json:"shotId"` + ArtifactType string `json:"artifactType"` AssetVersionID string `json:"assetVersionId"` ResourceID string `json:"resourceId"` MediaType string `json:"mediaType"` @@ -60,7 +63,7 @@ func (s *Service) EnsureBuiltinProjectWorkflowTemplate() error { if err != nil { return err } - template := model.WorkflowTemplateVersion{ID: newID(), TemplateKey: builtinShortDramaWorkflowKey, Name: "短剧标准制作流程", Version: builtinShortDramaWorkflowVersion, DefinitionJSON: string(definition), CreatedAt: time.Now()} + template := model.WorkflowTemplateVersion{ID: newID(), TemplateKey: builtinShortDramaWorkflowKey, Name: "短剧分镜工作流", Version: builtinShortDramaWorkflowVersion, DefinitionJSON: string(definition), CreatedAt: time.Now()} return s.repo.CreateWorkflowTemplateVersion(&template) } @@ -111,7 +114,7 @@ func (s *Service) ProjectWorkflows(projectID string) ([]ProjectWorkflowDetail, e } func (s *Service) CreateUnitWorkflow(userID string, projectID string, unitID string) (ProjectWorkflowDetail, error) { - if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { return ProjectWorkflowDetail{}, err } if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil { @@ -121,7 +124,7 @@ func (s *Service) CreateUnitWorkflow(userID string, projectID string, unitID str } func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID string, req UpdateWorkflowStepRequest) (model.WorkflowStepInstance, error) { - if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { return model.WorkflowStepInstance{}, err } step, err := s.repo.WorkflowStepForProject(projectID, stepID) @@ -155,6 +158,11 @@ func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID str if err != nil { return model.WorkflowStepInstance{}, err } + if status == model.WorkflowStepStatusCompleted { + if err := s.validateWorkflowStepCompletion(projectID, instance, step); err != nil { + return model.WorkflowStepInstance{}, err + } + } instance.Status = model.WorkflowStatusActive instance.Revision++ instance.UpdatedAt = now @@ -180,15 +188,29 @@ func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID str } func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID string, req RegisterTaskOutputRequest) (model.WorkflowStepInstance, error) { - if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { return model.WorkflowStepInstance{}, err } task, err := s.repo.TaskForUser(userID, strings.TrimSpace(req.TaskID)) if err != nil { return model.WorkflowStepInstance{}, err } + canvasID := strings.TrimSpace(req.CanvasID) if task.ProjectID != projectID { - return model.WorkflowStepInstance{}, BadAuthRequest("任务不属于当前项目") + canvas, canvasErr := s.repo.CanvasProjectForUser(userID, task.ProjectID) + if canvasErr != nil || canvas.ProjectID != projectID { + return model.WorkflowStepInstance{}, BadAuthRequest("任务不属于当前项目") + } + if canvasID != "" && canvasID != canvas.ID { + return model.WorkflowStepInstance{}, BadAuthRequest("任务画布与产物画布不一致") + } + canvasID = canvas.ID + } + if canvasID != "" { + canvas, canvasErr := s.repo.CanvasProjectForUser(userID, canvasID) + if canvasErr != nil || canvas.ProjectID != projectID { + return model.WorkflowStepInstance{}, BadAuthRequest("画布不属于当前项目") + } } if task.Status != model.TaskStatusSucceeded { return model.WorkflowStepInstance{}, BadAuthRequest("只有成功任务才能登记产物") @@ -200,6 +222,24 @@ func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID str if step.Status == model.WorkflowStepStatusFailed { return model.WorkflowStepInstance{}, BadAuthRequest("失败步骤不能登记成功产物") } + unitID := strings.TrimSpace(req.UnitID) + shotID := strings.TrimSpace(req.ShotID) + var shot *model.Shot + if shotID != "" { + shot, err = s.repo.ShotForProject(projectID, shotID) + if err != nil { + return model.WorkflowStepInstance{}, err + } + if unitID == "" { + unitID = shot.UnitID + } else if unitID != shot.UnitID { + return model.WorkflowStepInstance{}, BadAuthRequest("镜头不属于指定章节") + } + } else if unitID != "" { + if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil { + return model.WorkflowStepInstance{}, err + } + } if versionID := strings.TrimSpace(req.AssetVersionID); versionID != "" { if _, err := s.repo.AssetVersionForProject(projectID, versionID); err != nil { return model.WorkflowStepInstance{}, err @@ -258,12 +298,112 @@ func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID str representation = &model.AssetRepresentation{ID: newID(), TaskID: task.ID, AssetVersionID: strings.TrimSpace(req.AssetVersionID), ResourceID: strings.TrimSpace(req.ResourceID), MediaType: strings.TrimSpace(req.MediaType), Role: role, MetadataJSON: metadata, CreatedAt: now} } link := &model.WorkflowStepTask{ID: newID(), WorkflowStepID: step.ID, TaskID: task.ID, CreatedAt: now} - if err := s.repo.RegisterWorkflowTaskOutput(step, next, instance, projectID, link, representation); err != nil { + artifactType := strings.TrimSpace(req.ArtifactType) + if artifactType == "" && shotID != "" && strings.TrimSpace(req.ResourceID) != "" { + artifactType = workflowArtifactType(step.StepKey) + } + productionLink := &model.ProductionTaskLink{ID: newID(), TaskID: task.ID, ProjectID: projectID, CanvasID: canvasID, UnitID: unitID, ShotID: shotID, WorkflowStepID: step.ID, ArtifactType: artifactType, CreatedAt: now, UpdatedAt: now} + var artifact *model.ShotArtifact + if shot != nil && strings.TrimSpace(req.ResourceID) != "" && artifactType != "" { + artifact = &model.ShotArtifact{ID: newID(), ProjectID: projectID, UnitID: shot.UnitID, ShotID: shot.ID, RevisionID: shot.CurrentRevisionID, TaskID: task.ID, Type: artifactType, ResourceID: strings.TrimSpace(req.ResourceID), Status: "ready", Selected: true, MetadataJSON: metadata, CreatedAt: now, UpdatedAt: now} + } + // 单镜产物成功只代表该镜头完成,不能提前放行整个章节阶段。 + if shot != nil { + step.Status = model.WorkflowStepStatusRunning + step.CompletedAt = nil + instance.Status = model.WorkflowStatusActive + next = nil + } + if err := s.repo.RegisterWorkflowTaskOutput(step, next, instance, projectID, link, representation, productionLink, artifact); err != nil { return model.WorkflowStepInstance{}, err } return *step, nil } +func (s *Service) validateWorkflowStepCompletion(projectID string, instance *model.WorkflowInstance, step *model.WorkflowStepInstance) error { + if instance == nil || strings.TrimSpace(instance.UnitID) == "" { + return nil + } + unit, err := s.repo.ProjectUnit(projectID, instance.UnitID) + if err != nil { + return err + } + switch step.StepKey { + case "story": + if strings.TrimSpace(unit.SourceText) == "" { + return BadAuthRequest("章节正文为空,不能完成剧情阶段") + } + case "assets": + candidates, candidateErr := s.repo.ProjectAssetCandidates(projectID) + if candidateErr != nil { + return candidateErr + } + for _, candidate := range candidates { + if candidate.UnitID == instance.UnitID && candidate.Status == "pending_confirmation" { + return BadAuthRequest("仍有待确认资产,不能完成资产拆分阶段") + } + } + case "storyboard", "previz", "video", "delivery": + shots, shotErr := s.repo.ProjectShots(projectID) + if shotErr != nil { + return shotErr + } + unitShots := make([]model.Shot, 0, len(shots)) + for _, shot := range shots { + if shot.UnitID == instance.UnitID { + unitShots = append(unitShots, shot) + } + } + if len(unitShots) == 0 { + return BadAuthRequest("当前章节还没有分镜,不能完成本阶段") + } + if step.StepKey == "storyboard" { + for _, shot := range unitShots { + if strings.TrimSpace(shot.CurrentRevisionID) == "" { + return BadAuthRequest("存在没有分镜版本的镜头,不能完成分镜阶段") + } + } + return nil + } + artifactType := "action_board" + if step.StepKey == "video" || step.StepKey == "delivery" { + artifactType = "video" + } + artifacts, artifactErr := s.repo.ProjectShotArtifacts(projectID) + if artifactErr != nil { + return artifactErr + } + readyShots := make(map[string]struct{}, len(unitShots)) + for _, artifact := range artifacts { + if artifact.UnitID == instance.UnitID && artifact.Type == artifactType && artifact.Selected && artifact.Status == "ready" { + readyShots[artifact.ShotID] = struct{}{} + } + } + if len(readyShots) != len(unitShots) { + if artifactType == "action_board" { + return BadAuthRequest("仍有镜头缺少已通过的动作预演,不能完成本阶段") + } + return BadAuthRequest("仍有镜头缺少可交付视频,不能完成本阶段") + } + } + return nil +} + +func workflowArtifactType(stepKey string) string { + switch strings.TrimSpace(stepKey) { + case "storyboard": + return "storyboard" + case "previz": + return "action_board" + case "video": + return "video" + case "delivery": + return "delivery" + default: + return "" + } +} + func (s *Service) RegisterTaskOutputFromTask(task model.Task) error { if strings.TrimSpace(task.ProjectID) == "" || task.Status != model.TaskStatusSucceeded { return nil @@ -278,6 +418,10 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error { var input struct { WorkflowStepID string `json:"workflowStepId"` DomainProjectID string `json:"domainProjectId"` + CanvasID string `json:"canvasId"` + UnitID string `json:"unitId"` + ShotID string `json:"shotId"` + ArtifactType string `json:"artifactType"` AssetVersionID string `json:"assetVersionId"` ResourceID string `json:"resourceId"` MediaType string `json:"mediaType"` @@ -297,6 +441,18 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error { if input.AssetVersionID == "" { input.AssetVersionID, _ = input.Metadata["assetVersionId"].(string) } + if input.CanvasID == "" { + input.CanvasID, _ = input.Metadata["canvasId"].(string) + } + if input.UnitID == "" { + input.UnitID, _ = input.Metadata["unitId"].(string) + } + if input.ShotID == "" { + input.ShotID, _ = input.Metadata["shotId"].(string) + } + if input.ArtifactType == "" { + input.ArtifactType, _ = input.Metadata["artifactType"].(string) + } if input.ResourceID == "" { input.ResourceID, _ = input.Metadata["resourceId"].(string) } @@ -319,7 +475,7 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error { if projectID == "" { return errors.New("任务未提供短剧项目 ID,无法登记产物") } - _, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, OutputJSON: task.ResultJSON}) + _, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, CanvasID: input.CanvasID, UnitID: input.UnitID, ShotID: input.ShotID, ArtifactType: input.ArtifactType, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, OutputJSON: task.ResultJSON}) return err } diff --git a/backend/internal/service/project_workflow_v2_test.go b/backend/internal/service/project_workflow_v2_test.go new file mode 100644 index 000000000..0d7707bed --- /dev/null +++ b/backend/internal/service/project_workflow_v2_test.go @@ -0,0 +1,235 @@ +package service + +import ( + "reflect" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newProjectWorkflowV2TestService(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.Asset{}, &model.AssetVersion{}, &model.ProjectAssetLink{}, + &model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{}, + &model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.WorkflowStepTask{}, + &model.ProductionTaskLink{}, &model.Task{}, &model.Resource{}, + ); err != nil { + t.Fatal(err) + } + return &Service{repo: repository.New(db)}, db +} + +func seedWorkflowProject(t *testing.T, db *gorm.DB) (model.Project, model.ProjectUnit) { + t.Helper() + now := time.Now() + project := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} + unit := model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Kind: model.ProjectUnitKindChapter, Title: "第一章", Status: model.ProjectUnitStatusDraft, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&unit).Error; err != nil { + t.Fatal(err) + } + return project, unit +} + +func TestShortDramaWorkflowV2UsesProductionOrder(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + keys := make([]string, 0, len(workflow.Steps)) + for _, step := range workflow.Steps { + keys = append(keys, step.StepKey) + } + want := []string{"story", "assets", "storyboard", "previz", "video", "delivery"} + if !reflect.DeepEqual(keys, want) { + t.Fatalf("step keys = %v, want %v", keys, want) + } + if workflow.Steps[0].Status != model.WorkflowStepStatusReady { + t.Fatalf("first step status = %s, want ready", workflow.Steps[0].Status) + } +} + +func TestWorkflowCompletionRequiresStageGate(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + step := workflow.Steps[0] + if _, err := service.UpdateWorkflowStep("user-1", project.ID, step.ID, UpdateWorkflowStepRequest{Status: "running"}); err != nil { + t.Fatal(err) + } + if _, err := service.UpdateWorkflowStep("user-1", project.ID, step.ID, UpdateWorkflowStepRequest{Status: "completed"}); err == nil { + t.Fatal("empty chapter completed story gate") + } + unit.SourceText = "第一章正文" + unit.UpdatedAt = time.Now() + if err := db.Save(&unit).Error; err != nil { + t.Fatal(err) + } + if _, err := service.UpdateWorkflowStep("user-1", project.ID, step.ID, UpdateWorkflowStepRequest{Status: "completed"}); err != nil { + t.Fatal(err) + } +} + +func TestCreateShotRevisionInvalidatesExistingArtifacts(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "人物推门进入", DurationMs: 3000}) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&model.WorkflowStepInstance{}).Where("workflow_instance_id = ? AND step_key IN ?", workflow.Instance.ID, []string{"storyboard", "previz", "video", "delivery"}).Updates(map[string]any{"status": model.WorkflowStepStatusCompleted}).Error; err != nil { + t.Fatal(err) + } + artifact := model.ShotArtifact{ID: "artifact-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: shot.ID, RevisionID: shot.CurrentRevisionID, Type: "action_board", Version: 1, Status: "ready", Selected: true, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(&artifact).Error; err != nil { + t.Fatal(err) + } + updatedShot, revision, err := service.CreateShotRevision("user-1", project.ID, shot.ID, ShotRevisionInput{PlotDescription: "人物推门后停在门口", Action: "推门、停顿", DurationMs: 3500}) + if err != nil { + t.Fatal(err) + } + if revision.Version != 2 || updatedShot.CurrentRevisionID != revision.ID { + t.Fatalf("revision = v%d current=%s, want v2 current revision", revision.Version, updatedShot.CurrentRevisionID) + } + var storedArtifact model.ShotArtifact + if err := db.First(&storedArtifact, "id = ?", artifact.ID).Error; err != nil { + t.Fatal(err) + } + if storedArtifact.Status != "stale" || storedArtifact.Selected { + t.Fatalf("artifact status=%s selected=%v, want stale false", storedArtifact.Status, storedArtifact.Selected) + } + var storyboardStep model.WorkflowStepInstance + if err := db.First(&storyboardStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "storyboard").Error; err != nil { + t.Fatal(err) + } + if storyboardStep.Status != model.WorkflowStepStatusRunning { + t.Fatalf("storyboard step status = %s, want running", storyboardStep.Status) + } + var videoStep model.WorkflowStepInstance + if err := db.First(&videoStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "video").Error; err != nil { + t.Fatal(err) + } + if videoStep.Status != model.WorkflowStepStatusPending { + t.Fatalf("video step status = %s, want pending", videoStep.Status) + } +} + +func TestUpdateChapterSourceInvalidatesAllUnitArtifacts(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "旧剧情镜头", DurationMs: 3000}) + if err != nil { + t.Fatal(err) + } + artifact := model.ShotArtifact{ID: "artifact-source-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: shot.ID, RevisionID: shot.CurrentRevisionID, Type: "video", Version: 1, Status: "ready", Selected: true, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(&artifact).Error; err != nil { + t.Fatal(err) + } + if _, err := service.UpdateProjectUnit("user-1", project.ID, unit.ID, UpdateProjectUnitRequest{Title: unit.Title, SourceText: "修改后的章节正文"}); err != nil { + t.Fatal(err) + } + var storedArtifact model.ShotArtifact + if err := db.First(&storedArtifact, "id = ?", artifact.ID).Error; err != nil { + t.Fatal(err) + } + if storedArtifact.Status != "stale" || storedArtifact.Selected { + t.Fatalf("artifact status=%s selected=%v, want stale false", storedArtifact.Status, storedArtifact.Selected) + } + var storyStep model.WorkflowStepInstance + if err := db.First(&storyStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "story").Error; err != nil { + t.Fatal(err) + } + if storyStep.Status != model.WorkflowStepStatusRunning { + t.Fatalf("story step status = %s, want running", storyStep.Status) + } +} + +func TestRegisterTaskOutputAcceptsLinkedCanvasAndCreatesShotArtifact(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "人物走入画面", DurationMs: 3000}) + if err != nil { + t.Fatal(err) + } + canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", ProjectID: project.ID, Title: "探索画布", CreatedAt: time.Now(), UpdatedAt: time.Now()} + task := model.Task{ID: "task-1", UserID: "user-1", ProjectID: canvas.ID, Status: model.TaskStatusSucceeded, ResultJSON: `{}`, CreatedAt: time.Now(), UpdatedAt: time.Now()} + resource := model.Resource{ID: "resource-1", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, CreatedAt: time.Now(), UpdatedAt: time.Now()} + for _, item := range []any{&canvas, &task, &resource} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + step := workflow.Steps[0] + updatedStep, err := service.RegisterTaskOutput("user-1", project.ID, step.ID, RegisterTaskOutputRequest{TaskID: task.ID, UnitID: unit.ID, ShotID: shot.ID, ArtifactType: "storyboard", ResourceID: resource.ID, MediaType: "image"}) + if err != nil { + t.Fatal(err) + } + if updatedStep.Status != model.WorkflowStepStatusRunning { + t.Fatalf("step status = %s, want running until the unit gate is complete", updatedStep.Status) + } + var nextStep model.WorkflowStepInstance + if err := db.First(&nextStep, "workflow_instance_id = ? AND position = ?", workflow.Instance.ID, 1).Error; err != nil { + t.Fatal(err) + } + if nextStep.Status != model.WorkflowStepStatusPending { + t.Fatalf("next step status = %s, want pending", nextStep.Status) + } + var productionLink model.ProductionTaskLink + if err := db.First(&productionLink, "task_id = ?", task.ID).Error; err != nil { + t.Fatal(err) + } + if productionLink.ProjectID != project.ID || productionLink.CanvasID != canvas.ID || productionLink.ShotID != shot.ID { + t.Fatalf("unexpected production link: %+v", productionLink) + } + var storedArtifact model.ShotArtifact + if err := db.First(&storedArtifact, "task_id = ?", task.ID).Error; err != nil { + t.Fatal(err) + } + if storedArtifact.Type != "storyboard" || storedArtifact.Status != "ready" || !storedArtifact.Selected { + t.Fatalf("unexpected shot artifact: %+v", storedArtifact) + } +} diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index 61273ad74..8a9e93ad1 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -127,9 +127,11 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, } // resolveTaskModelSelection 根据请求实际携带的模型选择决定路由方式。 -// 显式系统渠道请求不能被全局前台模型开关误判,但仍必须通过渠道、模型和价格校验。 +// 显式系统渠道和用户自定义渠道请求不能被全局前台模型开关误判; +// 它们仍分别进入系统目录校验或自定义渠道的功能、能力与安全校验。 func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID string, taskType string, operation string, frontendEnabled bool) (*RoutedModel, map[string]any, error) { - if frontendEnabled && !taskInputUsesSystemChannel(input) { + customChannelTask := taskInputUsesCustomChannel(input) + if frontendEnabled && !taskInputUsesSystemChannel(input) && !customChannelTask { if logicalModelID == "" { return nil, input, InvalidModelSelection("前台模型模式下必须指定 logicalModelId") } @@ -146,7 +148,7 @@ func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID } // 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、 // 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。 - if !taskInputUsesCustomChannel(input) { + if !customChannelTask { if err := s.validateSystemChannelModelSelection(input); err != nil { return nil, input, err } diff --git a/backend/internal/service/task_creation_test.go b/backend/internal/service/task_creation_test.go index 1fdead543..384e54b7e 100644 --- a/backend/internal/service/task_creation_test.go +++ b/backend/internal/service/task_creation_test.go @@ -72,6 +72,26 @@ func TestResolveTaskModelSelectionAllowsExplicitSystemChannelWhenFrontendModelsE } } +func TestResolveTaskModelSelectionAllowsCustomImageChannelWhenFrontendModelsEnabled(t *testing.T) { + input := map[string]any{ + "mode": "image", + "config": map[string]any{ + "baseUrl": "https://images.example.com/v1", + "apiKey": "custom-key", + "interfaceType": "openai-image", + "model": "custom-image-model", + }, + } + + routed, resolvedInput, err := (&Service{}).resolveTaskModelSelection(input, "", "canvas_image", "image", true) + if err != nil { + t.Fatalf("resolveTaskModelSelection() error = %v", err) + } + if routed != nil || resolvedInput["config"] == nil { + t.Fatalf("resolveTaskModelSelection() routed = %#v, input = %#v", routed, resolvedInput) + } +} + func TestResolveTaskModelSelectionStillRequiresLogicalModelWithoutExplicitSystemChannel(t *testing.T) { _, _, err := (&Service{}).resolveTaskModelSelection(map[string]any{ "mode": "video", diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 73179a423..df864eceb 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -7,6 +7,19 @@ description: 影策后端核心持久化表与状态边界 后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。服务启动时由 `backend/internal/database/schema.go` 执行迁移;业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 +## 短剧分镜生产 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `shots` | 保存章节内稳定的镜头身份、排序和当前版本指针 | 不保存完整生成历史;`current_revision_id` 指向当前采用的分镜脚本版本 | +| `shot_revisions` | 保存画面、动作、台词、景别、机位、运镜、提示词和动作节拍 | 每次编辑创建新版本;历史版本不可被静默覆盖 | +| `shot_asset_references` | 将镜头绑定到具体资产版本 | 修改引用时只使该镜头的下游产物过期,不影响其他镜头 | +| `shot_artifacts` | 保存分镜图、动作预演、首尾帧、视频等版本化产物 | `selected` 表示当前采用版本;上游变化后状态转为 `stale`,历史资源继续保留 | +| `production_task_links` | 显式记录生成任务所属的领域项目、画布、章节、镜头和工作流步骤 | `Task.project_id` 仍兼容画布任务身份,不再用它单独推断短剧领域归属 | +| `workflow_template_versions` / `workflow_instances` / `workflow_step_instances` | 保存短剧生产模板和项目、章节实例 | 短剧分镜工作流 v2 顺序为剧情、资产、分镜、动作预演、视频、交付 | + +短剧项目状态以这些领域表为准。自由画布可以承载探索节点,但画布 metadata 不作为生产完成度、镜头版本或交付可用性的真相源。 + ## 模型渠道与目录 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index c1aa402ad..46bd04390 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,17 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 短剧分镜工作流 v2 + +- [x] 后端新增镜头版本、镜头产物和生产任务上下文;分镜修改与资产版本变更会局部将旧产物标记为 `stale`,专项测试通过。 +- [x] 短剧工作流调整为“剧情与章节 → 资产拆分 → 分镜脚本 → 黑白动作预演 → 视频生成 → 交付与打包”,旧 v1 实例保留,新章节使用 v2。 +- [x] 项目工作台新增独立分镜工作流路由,支持初始化章节流程、新增镜头、保存分镜版本、绑定资产版本、查看预演/视频版本和交付缺失状态;前端生产构建通过。 +- [ ] 在真实项目中初始化一个已有章节,确认不会误用旧 v1 工作流,刷新和切换章节后阶段、镜头和当前版本保持一致。 +- [ ] 连续保存同一镜头多个版本并绑定不同资产版本,确认只有该镜头的动作预演和视频转为过期,其他镜头不受影响。 +- [ ] 使用画布任务和项目任务分别登记镜头产物,确认 `production_task_links` 正确记录 Project、Canvas、Unit、Shot 和 WorkflowStep,且账号归属校验有效。 +- [ ] 在浅色、深色、窄屏和常规桌面验证阶段导航、镜头列表、表单、检查器、媒体预览和长章节内容滚动。 +- [ ] 当前“资产拆分 AI、多宫格生成提交、批量视频任务、后端交付包”仍是后续阶段,界面不得显示为已完成或可用。 + ## 画布生成结果命名 - [x] 快捷键复制节点生成图片后保留 `_copyN`,参数变体生成图片后保留 `A/B/C/D` 版本标识;批量输出追加结果序号。 @@ -90,6 +101,14 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 在画布中分别选择按次图片模型、按秒视频模型和前台逻辑模型,确认修改数量、时长、分辨率和参考素材后价格立即更新且与实际账单一致。 - [ ] 在浅色/深色主题及生成中、禁用状态下确认胶囊价格可读、圆形操作区清晰且不挤压模型与参数控件。 +## 系统渠道默认积分价格 + +- [x] 新建渠道模型默认使用“所有规格统一价格”,管理员只需填写一次积分;1K/2K/4K、画幅和生成方式差异定价收进可选的“按规格定价”。 +- [x] 系统渠道直连、前台逻辑模型按渠道计价和失败切换线路均按任务实际规格匹配价格档;缺失价格档 ID 不再被误转成 `""`。 +- [x] 后端全量测试、价格表单专项测试和前端生产构建通过。 +- [ ] 在 `/admin/channels` 新建图片和视频模型,仅填写统一价格后分别生成不同质量、尺寸、分辨率和时长,确认都命中默认价格。 +- [ ] 在默认价格之外新增一个 2K 或指定分辨率价格,确认精确规格优先、其他规格继续使用默认价格;关闭“可供用户使用”后该档不再参与匹配。 + ## 系统渠道模型 Logo - [x] 后端保存系统渠道模型 Logo,并通过脱敏模型目录下发;前端类型检查与生产构建通过。 @@ -405,3 +424,23 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 打开快捷键中心,分别搜索“粘贴”“缩放”和“Alt L”,确认结果与分类计数正确,清空搜索后恢复完整列表。 - [ ] 确认浅色/深色主题下标题、分类选中态、搜索焦点环、键帽和滚动区域清晰可见。 - [ ] 在窄窗口确认分类可横向滚动、快捷键列表改为单列,弹窗不超出视口且长内容仅在列表区滚动。 + +## 短剧分镜工作流工作台 + +短剧工作流已按镜头生产重构为单层项目工作台,并将资产绑定、镜头编辑、生成预览和整集镜头轨道放入同一视口;以下场景仍需用户在真实项目数据与模型渠道下确认: + +- [x] 工作流路由保留系统全局侧栏和顶栏;项目头部压缩为单行,移除额外六阶段横条,项目导航入口改为“分镜制作”。 +- [x] 项目详情 API 对旧后端可能缺失的工作流、镜头版本、产物、引用、候选资产和文件夹数组统一规范为空数组;使用缺失这些字段的响应完成浏览器复现,页面正常加载且控制台无错误。 +- [x] 未初始化章节不再显示阻断式居中空态;镜头编辑器可直接查看和保存,首次真实生成时按当前章节自动建立工作流记录。 +- [x] 分镜脚本、黑白动作预演、视频生成共用资产栏、镜头表单、预览/历史栏和底部镜头轨道;前端生产构建通过。 +- [x] 模型、时长、画幅、分辨率和镜头语言合并为默认折叠的“生成设置”,模型提示词与接戏信息合并为默认折叠的“生成补充”;折叠摘要会显示当前关键参数,浏览器展开/收起验证通过。 +- [x] 删除项目头部重复且发生重叠的“新增分镜”入口,只保留底部镜头轨道中的新增卡片;1280px 与 1680px 浏览器检查无横向溢出,系统全局侧栏保持可见。 +- [x] 右侧产物阶段从标题旁的隐藏下拉框改为常驻三段切换,分镜图、动作预演和镜头视频始终可见;1280px 与 1680px 下切换路由、阶段文案、最新/历史状态和全局侧栏浏览器验证通过。 +- [x] 底部镜头卡片取消悬浮上移和向外扩张的选中阴影,改为不改变坐标与尺寸的底色、边框和内描边反馈;右侧移除“复制参数”“入素材库”,仅保留并排的重新生成与下载,1280px 浏览器悬浮和布局验证通过。 +- [x] 镜头编辑器改为脚本优先顺序:镜头名称与画面、镜头资产、表演与动作、对白/旁白优先展示,生成规格和模型补充保持折叠;字段统一为画幅、镜头时长、机位角度、运镜方式、排除内容和接戏备注等生产术语。 +- [x] 编辑器标题实时反映镜头名称与“已保存/有未保存修改/保存中”状态;无修改时禁止重复保存,切换或新增镜头前会拦截未保存修改。浏览器交互验证通过。 +- [x] 镜头保存使用真实版本写接口;资产点击绑定具体版本;生成统一进入后端任务生命周期,成功后登记对应镜头产物;下载使用带登录凭据的资源接口。 +- [ ] 使用包含角色、场景、道具和画风的真实项目,确认左侧分类、绑定状态、参考素材标签和镜头切换保持一致。 +- [ ] 配置一个图片模型和一个视频模型,分别生成分镜预览、动作预演与视频,确认任务中心、镜头历史、最新预览和阶段状态同步更新。 +- [ ] 在浅色/深色主题及 1280px、1050px、760px 三个断点检查项目导航、表单滚动、右侧预览和底部镜头轨道,确认没有遮挡或不可达控件。 +- [ ] 验证下载视频、复制生成参数、将任务物化产物加入项目素材库、章节切换与新增分镜;确认失败任务展示真实错误且不会伪造产物成功。 diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index 5ca1b16f2..c75b0e584 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "react-router"; import { ModelSetupGuide } from "@/components/layout/model-setup-guide"; import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-palette"; import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav"; +import { readWorkspaceSidebarCollapsed, writeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state"; import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar"; import { cn } from "@/lib/utils"; import { isSpatialWorkbenchPath } from "@/lib/workspace-routes"; @@ -12,7 +13,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { const { pathname } = useLocation(); const navigate = useNavigate(); const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false); - const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false); + const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(readWorkspaceSidebarCollapsed); const [paletteOpen, setPaletteOpen] = useState(false); const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname); @@ -27,7 +28,16 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { setMobileSidebarExpanded((current) => !current); return; } - setDesktopSidebarCollapsed((current) => !current); + setDesktopSidebarCollapsed((current) => { + const next = !current; + writeWorkspaceSidebarCollapsed(next); + return next; + }); + }; + + const expandDesktopSidebar = () => { + setDesktopSidebarCollapsed(false); + writeWorkspaceSidebarCollapsed(false); }; const handleNavClick = () => { @@ -75,7 +85,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { collapsed={desktopSidebarCollapsed} onNavigate={handleNavClick} onOpenSearch={() => setPaletteOpen(true)} - onExpand={() => setDesktopSidebarCollapsed(false)} + onExpand={expandDesktopSidebar} /> ) : null} diff --git a/web/src/components/layout/workspace-sidebar-state.ts b/web/src/components/layout/workspace-sidebar-state.ts index 4d283a9ae..706994e54 100644 --- a/web/src/components/layout/workspace-sidebar-state.ts +++ b/web/src/components/layout/workspace-sidebar-state.ts @@ -1 +1,29 @@ export const WORKSPACE_SIDEBAR_STORAGE_KEY = "infinite-canvas:workspace-sidebar-collapsed"; + +type WorkspaceSidebarStorage = Pick; + +function workspaceSidebarStorage(storage?: WorkspaceSidebarStorage) { + if (storage) return storage; + if (typeof window === "undefined") return undefined; + try { + return window.localStorage; + } catch { + return undefined; + } +} + +export function readWorkspaceSidebarCollapsed(storage?: WorkspaceSidebarStorage) { + try { + return workspaceSidebarStorage(storage)?.getItem(WORKSPACE_SIDEBAR_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +export function writeWorkspaceSidebarCollapsed(collapsed: boolean, storage?: WorkspaceSidebarStorage) { + try { + workspaceSidebarStorage(storage)?.setItem(WORKSPACE_SIDEBAR_STORAGE_KEY, collapsed ? "1" : "0"); + } catch { + // localStorage 不可用时保留当前内存状态,不能阻断侧栏交互。 + } +} diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 08a2adc0b..c622abb28 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -13,6 +13,7 @@ import { modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, m import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog"; import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; +import { defaultPriceTier, legacyPriceTierToForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm, type PriceTierFormValues } from "./channel-model-price-tier-form"; import { AdminPageFrame } from "./admin-shell"; import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui"; @@ -30,23 +31,6 @@ type FormValues = { capabilityConfig?: ModelCapabilityConfig; }; -type PriceTierFormValues = { - operation: string; - quality: string; - size: string; - resolution: string; - videoSeconds: number; - imageCount: number; - providerModelKey?: string; - billingMode: ChannelModel["billingMode"]; - unitPrice: number; - inputTokenPrice: number; - outputTokenPrice: number; - cachedTokenPrice: number; - priceConfigured: boolean; - enabled: boolean; -}; - export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise }) { const { message } = App.useApp(); const [items, setItems] = useState([]); @@ -69,6 +53,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const providerModelKey = Form.useWatch("providerModelKey", form) || ""; const capabilityConfig = Form.useWatch("capabilityConfig", form); const modelEnabled = Form.useWatch("enabled", form) !== false; + const priceTiers = Form.useWatch("priceTiers", form) || []; + const hasDefaultPriceTier = priceTiers.some((tier) => tier.matchMode === "default"); const reload = async () => { if (!channel) return; @@ -84,7 +70,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: useEffect(() => { void reload(); - void fetchPluginProviderCatalog("admin.system-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([])); + void fetchPluginProviderCatalog("admin.system-channel") + .then(setAvailableProtocols) + .catch(() => setAvailableProtocols([])); setEditing(null); setEditorOpen(false); setKeyword(""); @@ -161,8 +149,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: protocol: values.protocol, priceTiers: values.priceTiers.map((tier) => ({ selector: skuSelectorFromForm(values.capability, tier), - resolution: values.capability === "video" ? tier.resolution || "*" : "*", - videoSeconds: values.capability === "video" ? Number(tier.videoSeconds || 0) : 0, + resolution: priceTierResolutionFromForm(values.capability, tier), + videoSeconds: priceTierVideoSecondsFromForm(values.capability, tier), providerModelKey: tier.providerModelKey?.trim() || upstreamModel, billingMode: tier.billingMode, unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000), @@ -550,7 +538,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ) : null}
- +
{ if (!value?.length) throw new Error("请至少配置一个价格档"); + if (value.filter((tier: PriceTierFormValues) => tier.matchMode === "default").length > 1) throw new Error("只能配置一个所有规格统一价格"); }, }, ]} @@ -576,8 +565,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: onRemove={() => remove(field.name)} /> ))} -
@@ -630,6 +619,9 @@ function PriceTierFields({ onRemove: () => void; }) { const billingMode = Form.useWatch(["priceTiers", index, "billingMode"], form) || "fixed_request"; + const matchMode = Form.useWatch(["priceTiers", index, "matchMode"], form) || "default"; + const priceConfigured = Form.useWatch(["priceTiers", index, "priceConfigured"], form) !== false; + const tierEnabled = Form.useWatch(["priceTiers", index, "enabled"], form) !== false; const video = capabilityConfig?.video; const resolutionOptions = video?.resolutions || []; const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : []; @@ -640,8 +632,8 @@ function PriceTierFields({
-
价格档 {ordinal}
-
请求按以下条件命中后,使用本档模型与价格。
+
{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}
+
{matchMode === "default" ? "匹配所有生成请求,保存后即可供用户使用。" : "精确条件优先于默认价格。"}
+

{detail.data.project.name}

{detail.data.project.status === "archived" ? "已归档" : "进行中"}
-
+ + } + title="生成补充" + description="仅在模型需要额外约束时填写" + summary={提示词 · 排除内容 · 接戏} + > +
+ + + + +
+
+ +
+
+
+ +
+ + + + + + + ); +} + +function WorkflowDisclosure({ icon, title, description, summary, className = "", children }: { icon: ReactNode; title: string; description: string; summary: ReactNode; className?: string; children: ReactNode }) { + return ( +
+ + {icon}{title}{description} + {summary} + +
{children}
+
+ ); +} + +function AssetLibrary({ detail, referencedVersionIds, binding, onBind }: { detail: ProjectDetail; referencedVersionIds: Set; binding: boolean; onBind: (asset: ProjectAsset) => void }) { + const groups = useMemo(() => { + const map = new Map(); + detail.assets.forEach((asset) => map.set(asset.category || "other", [...(map.get(asset.category || "other") || []), asset])); + return Array.from(map.entries()); + }, [detail.assets]); + if (!groups.length) return ; + return
{groups.map(([category, assets]) =>

{assetCategoryLabel(category)} ({assets.length})

{assets.map((asset) => { const active = Boolean(asset.primaryVersionId && referencedVersionIds.has(asset.primaryVersionId)); const previewUrl = assetPreviewUrl(asset); return ; })}
)}
; +} + +function EpisodeLibrary({ detail, activeUnitId, projectId, activeStage }: { detail: ProjectDetail; activeUnitId: string; projectId: string; activeStage: ShortDramaWorkflowStage }) { + return
{detail.units.slice().sort((left, right) => left.position - right.position).map((unit, index) => {String(index + 1).padStart(2, "0")}{unit.title})}
; +} + +function ShotLibrary({ detail, shots, selectedShotId, onSelectShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void }) { + return
{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return ; })}
; +} + +function BoundAssets({ detail, shotId }: { detail: ProjectDetail; shotId: string }) { + const references = (detail.shotReferences || []).filter((item) => item.shotId === shotId); + const assetByVersionId = useMemo(() => new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset])), [detail.assets]); + return ( +
+
镜头资产{references.length ? `已绑定 ${references.length} 项` : "从左侧资产栏点击绑定"}
+
+ {references.length ? references.map((reference) => { + const asset = assetByVersionId.get(reference.assetVersionId); + return {asset ? assetCategoryLabel(asset.category) : "历史"}{asset?.title || "历史资产版本"}; + }) : 尚未绑定角色、场景或道具} +
+
+ ); +} + +function LatestPreview({ artifact, emptyText }: { artifact?: ShotArtifact; emptyText: string }) { + if (!artifact?.resourceId) return

{emptyText}

; + const src = resourceFileUrl(artifact.resourceId); + if (artifact.type === "video") return