From f46480a9873ec156ff89778e2250c1a1fc242ae9 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 07:29:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E5=95=86=E5=8A=A1=E5=8F=99?= =?UTF-8?q?=E4=BA=8B=E9=98=B6=E6=AE=B5=E5=85=81=E8=AE=B8=E7=BC=96=E5=88=B6?= =?UTF-8?q?=E4=BB=BB=E6=84=8F=E5=B7=B2=E5=90=AF=E7=94=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/projectinstance/document_access.go | 3 +- internal/projectinstance/live_plan.go | 1 + .../projectinstance/narrative_gating_test.go | 197 ++++++++++++++++++ internal/projectinstance/stage_access.go | 19 +- internal/projectinstance/types.go | 15 +- internal/projectinstance/validator.go | 13 ++ internal/workflowdef/manifests/main.v2.json | 1 + internal/workflowdef/stage_gating_test.go | 44 ++++ internal/workflowdef/types.go | 49 +++-- internal/workflowdef/validator.go | 14 ++ internal/workflowdef/validator_v2.go | 1 + .../project/ProjectSettingsModal.tsx | 5 +- .../workflow/StageAdvanceBanner.tsx | 16 +- web/src/hooks/useDocumentLifecycleActions.ts | 6 +- web/src/lib/stageGating.test.ts | 32 +++ web/src/lib/stageGating.ts | 22 ++ web/src/types/projectSettings.ts | 2 + 17 files changed, 401 insertions(+), 39 deletions(-) create mode 100644 internal/projectinstance/narrative_gating_test.go create mode 100644 internal/workflowdef/stage_gating_test.go create mode 100644 web/src/lib/stageGating.test.ts create mode 100644 web/src/lib/stageGating.ts diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go index 860f2d0c..f9d234c4 100644 --- a/internal/projectinstance/document_access.go +++ b/internal/projectinstance/document_access.go @@ -93,7 +93,8 @@ func (s *Service) CheckDocTaskAccess( } } - if stageKey != "" && stageKey != detail.ActiveStageKey { + // narrative 快照不按活动阶段拦截编制;职责、禁用和实例关闭检查仍在后面。 + if stageKey != "" && stageKey != detail.ActiveStageKey && !gov.ConfigSnapshot.NarrativeStageGating() { if action != DocAccessActionView { return DocAccessDecision{ Allowed: false, diff --git a/internal/projectinstance/live_plan.go b/internal/projectinstance/live_plan.go index 663a6b1c..c9076ba2 100644 --- a/internal/projectinstance/live_plan.go +++ b/internal/projectinstance/live_plan.go @@ -68,6 +68,7 @@ func (s *Service) UpdateLivePlan(ctx context.Context, projectID int64, userID in if err := s.rejectBusyDocTaskChanges(ctx, projectID, live.ConfigSnapshot, plan); err != nil { return nil, err } + stampPlanStageGating(&plan, version) updated, err := s.store.UpdateLivePlan(ctx, projectID, plan, live.ConfigRevision, userID) if err != nil { diff --git a/internal/projectinstance/narrative_gating_test.go b/internal/projectinstance/narrative_gating_test.go new file mode 100644 index 00000000..1ccfaa62 --- /dev/null +++ b/internal/projectinstance/narrative_gating_test.go @@ -0,0 +1,197 @@ +package projectinstance + +import ( + "context" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestNarrativeGating_PendingStageDocsRunnable(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 880, LifecyclePlanning, "", 1, 0) + svc := NewService(store, twoStageResolver(workflowdef.StageGatingNarrative), nil, nil) + activateTwoStage(t, svc, 880) + forcePartialDuty(store, 880, "partial_doc") + + access, err := svc.ResolveStageAccess(ctx, 880, 99, false) + if err != nil { + t.Fatal(err) + } + if access.ActiveStageKey != "V0.1_triage" { + t.Fatalf("active=%s", access.ActiveStageKey) + } + if !access.TaskKeyAllowed("research") { + t.Fatalf("narrative must allow pending-stage research, %+v", access.AllowedTaskKeys) + } + if !access.StageKeyAllowed("V0.5_feasibility") { + t.Fatal("narrative must open later stage keys") + } + if access.TaskKeyAllowed("disabled_doc") { + t.Fatal("disabled docs stay out of AllowedTaskKeys") + } + if access.TaskKeyAllowed("partial_doc") { + t.Fatal("partial duty stays out of AllowedTaskKeys") + } + + run := svc.CheckDocTaskAccess(ctx, 880, 99, "research", DocAccessActionRun, false) + if !run.Allowed { + t.Fatalf("narrative run: %+v", run) + } + submit := svc.CheckDocTaskAccess(ctx, 880, 99, "research", DocAccessActionSubmit, false) + if submit.Allowed || submit.Code != AccessCodeForbiddenDuty { + t.Fatalf("narrative must keep duty on submit: %+v", submit) + } + authorSubmit := svc.CheckDocTaskAccess(ctx, 880, 1, "research", DocAccessActionSubmit, false) + if !authorSubmit.Allowed { + t.Fatalf("author submit: %+v", authorSubmit) + } + disabled := svc.CheckDocTaskAccess(ctx, 880, 1, "disabled_doc", DocAccessActionRun, true) + if disabled.Allowed || disabled.Code != AccessCodeDocTaskDisabled { + t.Fatalf("disabled: %+v", disabled) + } + partial := svc.CheckDocTaskAccess(ctx, 880, 1, "partial_doc", DocAccessActionEdit, true) + if partial.Allowed || partial.Code != AccessCodeForbiddenDuty { + t.Fatalf("partial: %+v", partial) + } +} + +func TestStrictGating_PendingStageDocsDenied(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 881, LifecyclePlanning, "", 1, 0) + svc := NewService(store, twoStageResolver(""), nil, nil) + activateTwoStage(t, svc, 881) + + access, err := svc.ResolveStageAccess(ctx, 881, 99, false) + if err != nil { + t.Fatal(err) + } + if access.TaskKeyAllowed("research") { + t.Fatal("strict must not run pending-stage research") + } + if !access.TaskKeyVisible("research") { + t.Fatal("strict view exemption stays") + } + decision := svc.CheckDocTaskAccess(ctx, 881, 1, "research", DocAccessActionRun, true) + if decision.Allowed || decision.Code != AccessCodeNotCurrentStage { + t.Fatalf("strict run: %+v", decision) + } + view := svc.CheckDocTaskAccess(ctx, 881, 1, "research", DocAccessActionView, true) + if !view.Allowed { + t.Fatalf("strict view: %+v", view) + } +} + +func TestStampPlanStageGating_OmittedForStrictHash(t *testing.T) { + version := &workflowdef.Version{Definition: workflowdef.VersionDefinition{}} + plan := WorkflowPlan{WorkflowDefinitionKey: "main", WorkflowVersion: 1} + stampPlanStageGating(&plan, version) + if plan.StageGating != "" { + t.Fatalf("strict stamp=%q", plan.StageGating) + } + h1, err := computePlanHash(plan) + if err != nil { + t.Fatal(err) + } + plan.StageGating = workflowdef.StageGatingStrict + stampPlanStageGating(&plan, version) + h2, err := computePlanHash(plan) + if err != nil { + t.Fatal(err) + } + if h1 != h2 { + t.Fatalf("strict hash drifted %s vs %s", h1, h2) + } + version.Definition.StageGating = workflowdef.StageGatingNarrative + stampPlanStageGating(&plan, version) + if plan.StageGating != workflowdef.StageGatingNarrative { + t.Fatalf("narrative stamp=%q", plan.StageGating) + } +} + +func twoStageResolver(gating string) *stubVersionResolver { + r := testResolver() + ver := r.versions[workflowdef.OfficialKeyMain] + ver.Definition.StageGating = gating + ver.Definition.Stages = []workflowdef.StageDef{ + { + Key: "V0.1_triage", Name: "V0.1 需求摸底", Position: 10, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表", + }}, + }, + { + Key: "V0.5_feasibility", Name: "V0.5 方案与可行性", Position: 20, + DocTasks: []workflowdef.DocTaskDef{ + { + Key: "research", Title: "调研报告", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "research", OutputDocTitle: "调研报告", + }, + { + Key: "disabled_doc", Title: "关闭文档", CreatorConfigurable: true, DefaultEnabled: false, + OutputDocType: "disabled_doc", OutputDocTitle: "关闭文档", + }, + { + Key: "partial_doc", Title: "半配置", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "partial_doc", OutputDocTitle: "半配置", + }, + }, + }, + } + return r +} + +func activateTwoStage(t *testing.T, svc *Service, projectID int64) { + t.Helper() + full := DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} + plan := WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, + WorkflowVersion: 1, + Stages: []StagePlan{ + { + StageKey: "V0.1_triage", Enabled: true, + DocTasks: []DocTaskPlan{{TaskKey: "intake", Enabled: true, Duties: full}}, + }, + { + StageKey: "V0.5_feasibility", Enabled: true, + DocTasks: []DocTaskPlan{ + {TaskKey: "research", Enabled: true, Duties: full}, + {TaskKey: "disabled_doc", Enabled: false, Duties: full}, + {TaskKey: "partial_doc", Enabled: true, Duties: full}, + }, + }, + }, + } + if _, err := svc.SaveWorkflowPlan(context.Background(), projectID, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(context.Background(), projectID, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(context.Background(), projectID, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } +} + +func forcePartialDuty(store *memStore, projectID int64, taskKey string) { + store.mu.Lock() + defer store.mu.Unlock() + inst := store.live[projectID] + for i := range inst.ConfigSnapshot.Stages { + for j := range inst.ConfigSnapshot.Stages[i].DocTasks { + dt := &inst.ConfigSnapshot.Stages[i].DocTasks[j] + if dt.TaskKey == taskKey { + dt.Duties = DocDuty{AuthorUserID: 1} + } + } + } +} diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index d981dcb6..68647cd1 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -19,7 +19,7 @@ type StageAccess struct { // // GetGoverningInstanceForAccess 语义(与 GetLiveInstance/HasLiveInstance 分离): // - (nil, nil):从未激活(无实例或仅 planning)→ 不限制(legacy / 新项目预激活) -// - live 系:AllowedTaskKeys 对活跃成员开放(run 语义);ViewableTaskKeys 对全部启用文档开放 +// - live 系:strict 时 AllowedTaskKeys 只含 active/completed 阶段;narrative 时含全部已启用文档。ViewableTaskKeys 始终对全部启用文档开放 // - completed/terminated:Restricted=true 全拒(不得误判为从未激活) // - 存储错误:上抛,由调用方 fail-closed // @@ -54,6 +54,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI out.Restricted = true out.ActiveStageKey = detail.ActiveStageKey + narrative := gov.ConfigSnapshot.NarrativeStageGating() stageStatus := map[string]string{} for _, st := range detail.Stages { @@ -71,14 +72,16 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI out.ViewableTaskKeys[dt.TaskKey] = true } } - // Stage chat/history: any caller who already passed project membership may - // see active/completed stages. Run/edit task keys are member-level below. - switch status { - case StageActive, StageCompleted: - out.AllowedStageKeys[stPlan.StageKey] = true - default: - continue + // narrative:阶段只标记成熟度,已启用阶段的聊天与编制都不看 pending/active。 + // strict:仅 active/completed 阶段进入 AllowedStageKeys / AllowedTaskKeys。 + if !narrative { + switch status { + case StageActive, StageCompleted: + default: + continue + } } + out.AllowedStageKeys[stPlan.StageKey] = true for _, dt := range stPlan.DocTasks { if !dt.Enabled { continue diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index f91129a7..079e3499 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -55,10 +55,17 @@ type StagePlan struct { // WorkflowPlan 项目工作流规划快照。 type WorkflowPlan struct { - WorkflowDefinitionKey string `json:"workflow_definition_key"` - WorkflowVersion int `json:"workflow_version"` - WorkflowVersionID int64 `json:"workflow_version_id,omitempty"` - Stages []StagePlan `json:"stages"` + WorkflowDefinitionKey string `json:"workflow_definition_key"` + WorkflowVersion int `json:"workflow_version"` + WorkflowVersionID int64 `json:"workflow_version_id,omitempty"` + // StageGating 仅 narrative 写入快照;空值保持旧快照 hash,语义为 strict。 + StageGating string `json:"stage_gating,omitempty"` + Stages []StagePlan `json:"stages"` +} + +// NarrativeStageGating 报告阶段是否只作成熟度标记。 +func (p WorkflowPlan) NarrativeStageGating() bool { + return p.StageGating == workflowdef.StageGatingNarrative } // WorkflowInstance 项目工作流实例。 diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go index 3d87c6a2..c7f3619d 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -158,6 +158,7 @@ func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan { WorkflowVersionID: version.ID, Stages: make([]StagePlan, 0, len(version.Definition.Stages)), } + stampPlanStageGating(&plan, version) for _, st := range version.Definition.Stages { sp := StagePlan{ StageKey: st.Key, @@ -183,6 +184,7 @@ func applyDefinitionLabels(plan *WorkflowPlan, version *workflowdef.Version) { if plan == nil || version == nil { return } + stampPlanStageGating(plan, version) stageDefs := make(map[string]workflowdef.StageDef, len(version.Definition.Stages)) for _, stage := range version.Definition.Stages { stageDefs[stage.Key] = stage @@ -208,6 +210,17 @@ func applyDefinitionLabels(plan *WorkflowPlan, version *workflowdef.Version) { } } +// stampPlanStageGating 以已发布定义为准。空值不写入快照,避免改变 strict 工作流的 plan hash。 +func stampPlanStageGating(plan *WorkflowPlan, version *workflowdef.Version) { + if plan == nil { + return + } + plan.StageGating = "" + if version != nil && version.Definition.StageGating == workflowdef.StageGatingNarrative { + plan.StageGating = workflowdef.StageGatingNarrative + } +} + func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSummary { sum := PreviewSummary{ WorkflowVersion: plan.WorkflowVersion, diff --git a/internal/workflowdef/manifests/main.v2.json b/internal/workflowdef/manifests/main.v2.json index 17d9c9c6..e314c8e9 100644 --- a/internal/workflowdef/manifests/main.v2.json +++ b/internal/workflowdef/manifests/main.v2.json @@ -1,6 +1,7 @@ { "schema_version": 2, "workflow_key": "main", + "stage_gating": "narrative", "product_domains": [ "medical_device", "non_medical_software" diff --git a/internal/workflowdef/stage_gating_test.go b/internal/workflowdef/stage_gating_test.go new file mode 100644 index 00000000..6f118104 --- /dev/null +++ b/internal/workflowdef/stage_gating_test.go @@ -0,0 +1,44 @@ +package workflowdef + +import "testing" + +func TestValidateDefinitionV2_StageGatingEnum(t *testing.T) { + def, err := LoadManifestDefinition("main.v2.json") + if err != nil { + t.Fatal(err) + } + if def.StageGating != StageGatingNarrative { + t.Fatalf("main manifest stage_gating=%q", def.StageGating) + } + if res := ValidateDefinitionV2(def, nil, nil); !res.Valid { + t.Fatalf("narrative main manifest invalid: %+v", res.Issues) + } + + def.StageGating = "parallel" + res := ValidateDefinitionV2(def, nil, nil) + if res.Valid { + t.Fatal("expected invalid stage_gating") + } + found := false + for _, issue := range res.Issues { + if issue.Code == "STAGE_GATING" { + found = true + } + } + if !found { + t.Fatalf("missing STAGE_GATING issue: %+v", res.Issues) + } +} + +func TestValidateDefinitionV2_EmptyStageGatingStaysStrict(t *testing.T) { + def, err := LoadManifestDefinition("rdev.v2.json") + if err != nil { + t.Fatal(err) + } + if def.StageGating != "" { + t.Fatalf("rdev must stay strict by omission, got %q", def.StageGating) + } + if res := ValidateDefinitionV2(def, nil, nil); !res.Valid { + t.Fatalf("rdev manifest invalid: %+v", res.Issues) + } +} diff --git a/internal/workflowdef/types.go b/internal/workflowdef/types.go index 8ca20247..e8bcb9c3 100644 --- a/internal/workflowdef/types.go +++ b/internal/workflowdef/types.go @@ -7,6 +7,11 @@ const ( StatusPublished = "published" StatusRetired = "retired" + // StageGatingStrict 按活动阶段限制编制(缺省)。 + StageGatingStrict = "strict" + // StageGatingNarrative 阶段只作成熟度标记,已启用文档可在任意阶段编制。 + StageGatingNarrative = "narrative" + ScanPending = "pending" ScanReady = "ready" ScanRejected = "rejected" @@ -20,20 +25,20 @@ type TemplateRef struct { // DocTaskDef 阶段内文档任务定义。 type DocTaskDef struct { - Key string `json:"key"` - Title string `json:"title"` - Required bool `json:"required"` - Blocking bool `json:"blocking"` - DefaultEnabled bool `json:"default_enabled"` - CreatorConfigurable bool `json:"creator_configurable"` - BlockingWhenEnabled bool `json:"blocking_when_enabled"` - TemplateAssetKey string `json:"template_asset_key,omitempty"` - TemplateVersion int `json:"template_version,omitempty"` - Template *TemplateRef `json:"template,omitempty"` - ExecutorKey string `json:"executor_key,omitempty"` - QualityGateKey string `json:"quality_gate_key,omitempty"` - OutputDocType string `json:"output_doc_type"` - OutputDocTitle string `json:"output_doc_title"` + Key string `json:"key"` + Title string `json:"title"` + Required bool `json:"required"` + Blocking bool `json:"blocking"` + DefaultEnabled bool `json:"default_enabled"` + CreatorConfigurable bool `json:"creator_configurable"` + BlockingWhenEnabled bool `json:"blocking_when_enabled"` + TemplateAssetKey string `json:"template_asset_key,omitempty"` + TemplateVersion int `json:"template_version,omitempty"` + Template *TemplateRef `json:"template,omitempty"` + ExecutorKey string `json:"executor_key,omitempty"` + QualityGateKey string `json:"quality_gate_key,omitempty"` + OutputDocType string `json:"output_doc_type"` + OutputDocTitle string `json:"output_doc_title"` } // StageDef 线性阶段定义。 @@ -48,13 +53,15 @@ type StageDef struct { // VersionDefinition 工作流版本完整快照(JSONB)。 type VersionDefinition struct { - SchemaVersion int `json:"schema_version"` - WorkflowKey string `json:"workflow_key,omitempty"` - ProductDomains []string `json:"product_domains,omitempty"` - RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"` - Stages []StageDef `json:"stages"` - ParticipationPolicy map[string]any `json:"participation_policy,omitempty"` - CompletionPolicy map[string]any `json:"completion_policy,omitempty"` + SchemaVersion int `json:"schema_version"` + WorkflowKey string `json:"workflow_key,omitempty"` + ProductDomains []string `json:"product_domains,omitempty"` + RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"` + // StageGating 为空或 strict 时按活动阶段限制编制;narrative 时阶段只标记成熟度。 + StageGating string `json:"stage_gating,omitempty"` + Stages []StageDef `json:"stages"` + ParticipationPolicy map[string]any `json:"participation_policy,omitempty"` + CompletionPolicy map[string]any `json:"completion_policy,omitempty"` } // Definition 工作流逻辑标识。 diff --git a/internal/workflowdef/validator.go b/internal/workflowdef/validator.go index 90386989..c2303b72 100644 --- a/internal/workflowdef/validator.go +++ b/internal/workflowdef/validator.go @@ -16,6 +16,7 @@ func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, up return ValidateDefinitionV2(def, execValidate, gateValidate) } issues := make([]ValidationIssue, 0) + issues = append(issues, stageGatingIssues(def)...) if def.SchemaVersion <= 0 { def.SchemaVersion = 1 } @@ -90,3 +91,16 @@ func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, up return ValidationResult{Valid: len(issues) == 0, Issues: issues} } + +func stageGatingIssues(def VersionDefinition) []ValidationIssue { + switch strings.TrimSpace(def.StageGating) { + case "", StageGatingStrict, StageGatingNarrative: + return nil + default: + return []ValidationIssue{{ + Code: "STAGE_GATING", + Field: "stage_gating", + Message: "stage_gating must be strict or narrative", + }} + } +} diff --git a/internal/workflowdef/validator_v2.go b/internal/workflowdef/validator_v2.go index 12cdd5b6..112615b0 100644 --- a/internal/workflowdef/validator_v2.go +++ b/internal/workflowdef/validator_v2.go @@ -26,6 +26,7 @@ func ValidateDefinitionV2(def VersionDefinition, execValidate, gateValidate keyV gateValidate = defaultValidateQualityGateKey } issues := make([]ValidationIssue, 0) + issues = append(issues, stageGatingIssues(def)...) if def.SchemaVersion < 2 { issues = append(issues, ValidationIssue{Code: "SCHEMA_VERSION", Message: "schema_version must be >= 2"}) } diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 965f78b9..67fa9380 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -21,6 +21,7 @@ import { ActivationPreview, DocTaskPlan, } from '../../types/projectSettings'; +import { isNarrativeStageGating } from '../../lib/stageGating'; import { frozenDutySnapshot, projectSettingsChrome, @@ -433,7 +434,9 @@ export const ProjectSettingsModal: React.FC = ({
当前工作流已激活
工作流:{settings.active_workflow_key || plan?.workflow_definition_key || '—'}
版本:v{plan?.workflow_version ?? settings.workflow_selection_version}
-
当前阶段:{settings.active_stage_key || '—'}
+
+ {isNarrativeStageGating(plan) ? '成熟度标记' : '当前阶段'}:{settings.active_stage_key || '—'} +
当前工作流已冻结,只读查看。规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
diff --git a/web/src/components/workflow/StageAdvanceBanner.tsx b/web/src/components/workflow/StageAdvanceBanner.tsx index 21d31861..f993e064 100644 --- a/web/src/components/workflow/StageAdvanceBanner.tsx +++ b/web/src/components/workflow/StageAdvanceBanner.tsx @@ -8,6 +8,7 @@ import { } from '../../api/projectSettings'; import { StageReadiness } from '../../types/projectSettings'; import { canSubmitAdvance, recheckAckRequired } from '../../lib/recheckAck'; +import { isNarrativeStageGating } from '../../lib/stageGating'; interface StageAdvanceBannerProps { projectId: number; @@ -27,6 +28,7 @@ export const StageAdvanceBanner: React.FC = ({ const [readiness, setReadiness] = useState(null); const [lastNotificationId, setLastNotificationId] = useState(0); const [acked, setAcked] = useState(false); + const [narrative, setNarrative] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -37,9 +39,11 @@ export const StageAdvanceBanner: React.FC = ({ if (!live || live.status === 'completed') { setInstanceId(null); setReadiness(null); + setNarrative(false); return; } setInstanceId(live.id); + setNarrative(isNarrativeStageGating(settings.plan)); const activeStage = settings.active_stage_key || ''; if (!activeStage) return; const rd = await fetchStageReadiness(live.id, activeStage); @@ -132,10 +136,16 @@ export const StageAdvanceBanner: React.FC = ({ )} - 当前阶段:{readiness.stage_name || readiness.stage_key} + {narrative ? '成熟度标记' : '当前阶段'}:{readiness.stage_name || readiness.stage_key} - {readiness.ready ? '· 必需文档已批准,可推进' : '· 等待必需文档批准'} + {narrative + ? readiness.ready + ? '· 本阶段文档已齐,可标记进入下一成熟度' + : '· 标记下一成熟度前,需本阶段文档批准' + : readiness.ready + ? '· 必需文档已批准,可推进' + : '· 等待必需文档批准'} {isCreator && readiness.can_advance && ( @@ -156,7 +166,7 @@ export const StageAdvanceBanner: React.FC = ({ }} > {advancing ? : } - 确认推进 + {narrative ? '标记推进' : '确认推进'} )} diff --git a/web/src/hooks/useDocumentLifecycleActions.ts b/web/src/hooks/useDocumentLifecycleActions.ts index cd441a04..7a75190f 100644 --- a/web/src/hooks/useDocumentLifecycleActions.ts +++ b/web/src/hooks/useDocumentLifecycleActions.ts @@ -9,6 +9,7 @@ import { } from '../api/docgov'; import { requestReopen } from '../api/projectTrace'; import { fetchProjectSettings } from '../api/projectSettings'; +import { stageKeyForDocTask } from '../lib/stageGating'; import type { DocumentAST } from '../types/block'; import type { DocLifecycleState, UserProfile } from '../types/docgov'; import type { ChatMessage, DeliverableItem } from '../types/workbench'; @@ -252,7 +253,10 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu if (settings?.live_instance?.id) { instanceId = settings.live_instance.id; } - if (settings?.active_stage_key) { + const ownedStage = stageKeyForDocTask(settings?.plan, docAST.taskKey, ''); + if (ownedStage) { + stageKey = ownedStage; + } else if (settings?.active_stage_key) { stageKey = settings.active_stage_key; } } catch { diff --git a/web/src/lib/stageGating.test.ts b/web/src/lib/stageGating.test.ts new file mode 100644 index 00000000..2f077114 --- /dev/null +++ b/web/src/lib/stageGating.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { WorkflowPlan } from '../types/projectSettings'; +import { isNarrativeStageGating, stageKeyForDocTask } from './stageGating'; + +const plan: WorkflowPlan = { + workflow_definition_key: 'main', + workflow_version: 2, + stage_gating: 'narrative', + stages: [ + { + stage_key: 'V0.1_triage', + enabled: true, + doc_tasks: [{ task_key: 'bd_intake', enabled: true, duties: {} }], + }, + { + stage_key: 'V0.5_feasibility', + enabled: true, + doc_tasks: [{ task_key: 'bd_research', enabled: true, duties: {} }], + }, + ], +}; + +test('narrative plan is recognized and strict omission is not', () => { + assert.equal(isNarrativeStageGating(plan), true); + assert.equal(isNarrativeStageGating({ ...plan, stage_gating: undefined }), false); +}); + +test('doc task maps to its own stage even when another stage is active', () => { + assert.equal(stageKeyForDocTask(plan, 'bd_research', 'V0.1_triage'), 'V0.5_feasibility'); + assert.equal(stageKeyForDocTask(plan, 'missing', 'V0.1_triage'), 'V0.1_triage'); +}); diff --git a/web/src/lib/stageGating.ts b/web/src/lib/stageGating.ts new file mode 100644 index 00000000..4207e894 --- /dev/null +++ b/web/src/lib/stageGating.ts @@ -0,0 +1,22 @@ +import type { WorkflowPlan } from '../types/projectSettings'; + +export function isNarrativeStageGating(plan?: WorkflowPlan | null): boolean { + return plan?.stage_gating === 'narrative'; +} + +/** 文档再修订应回到该文档所属阶段,而不是当前活动阶段。 */ +export function stageKeyForDocTask( + plan: WorkflowPlan | undefined | null, + taskKey: string, + fallback = '', +): string { + const key = taskKey.trim(); + if (plan && key) { + for (const stage of plan.stages) { + if (stage.doc_tasks.some((task) => task.task_key === key)) { + return stage.stage_key; + } + } + } + return fallback; +} diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index 85fbf77f..3bfe0eee 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -27,6 +27,8 @@ export interface WorkflowPlan { workflow_definition_key: string; workflow_version: number; workflow_version_id?: number; + /** narrative:阶段只标记成熟度,任意已启用文档都可编制。缺省为 strict。 */ + stage_gating?: 'narrative' | 'strict' | ''; stages: StagePlan[]; }