merge: feature/w5a-software-bootstrap-ui-pi
This commit is contained in:
+3
-3
@@ -5,7 +5,7 @@
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **partial/deferred 全量收口**:W1–W4 **done**;续 W5a–W7
|
||||
1. **partial/deferred 全量收口**:W1–W4 **done**;W5a **done**(矩阵自举行仍 partial);续 W6–W7
|
||||
2. **运维**:nightly `DATA_DIR=dev` 已对齐;KB 宿主机分层加载 / fail-closed **done**(`fix/kb-host-auth-layered-load`)
|
||||
3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`(升 `confirm` 须 W4 闭环后独立授权);`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 顺序 staging→artifact→publication、每级观察窗证据驱动(下限 ≥7 天)、`FAV_DEGRADED_READ=false`;flag 默认 off;项目 143 只读
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| Cursor | 2026-09-17-w2-semantic-summary-shadow | feature/w2-semantic-summary-shadow-live | - | chat | done |
|
||||
| Cursor | 2026-09-17-w3-pi-change-report-narrative | feature/w3-pi-change-report-narrative-live | - | docgov | done |
|
||||
| Cursor | 2026-09-17-w4-multi-intent-u9-u12 | feature/w4-u9-segment-live | - | intent-plan | done |
|
||||
| Cursor | 2026-09-17-w5a-software-bootstrap-ui | feature/w5a-software-bootstrap-ui-pi | - | workflow-design | planned |
|
||||
| Cursor | 2026-09-17-w5a-software-bootstrap-ui | feature/w5a-software-bootstrap-ui-pi | - | workflow-design | done |
|
||||
| Cursor | 2026-09-17-w6-fav-subscriber-shadow | feature/w6-fav-subscriber-shadow | - | events | planned |
|
||||
| Cursor | 2026-09-17-w7-d7-inventory | feature/w7-d7-data-inventory | - | chat | planned |
|
||||
| Cursor | 2026-09-17-kb-host-auth-fix | fix/kb-host-auth-layered-load | - | infra | done |
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | - | - | W5a–W7 |
|
||||
| - | idle | - | - | W6–W7 |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ updated: 2026-09-17
|
||||
| SWBOOT-00 | done(契约) | ADR `.doc/adr/2026-09-16-software-project-bootstrap-keys.md` |
|
||||
| SWBOOT-01 | done(迁移) | goose `00048` additive;已合 `dev` |
|
||||
| SWBOOT-02..04 | done(L1) | manifests + catalog 五 key;医疗三门禁保留;已合 `dev`(`f0029df`) |
|
||||
| SWBOOT-05..08 | partial | 后端 catalog/creation-preview/create 字段已合;七步 UI / 对话路由归 W5a |
|
||||
| SWBOOT-09 | partial | L1 单测报告已出;真实 PI/UI 归 W5a |
|
||||
| SWBOOT-05..08 | done(W5a) | catalog 驱动创建向导;软件不克隆医疗 7 模板;solo 未独立评审标注 |
|
||||
| SWBOOT-09 | partial | docs_only 真实 PI 已出;无头浏览器全向导仍 NOT RUN |
|
||||
| SWBOOT-10 | stub only | allowlist + fail-closed;真 patch/merge 归 W5b,本轮不实施 |
|
||||
|
||||
L1(SWBOOT-00…09 契约/迁移/目录钩子 + SWBOOT-10 stub)**已合入 `dev`**。后续由 [partial/deferred W5](../short-term/2026-09-16-partial-deferred-completion.md) 承接:W5a = UI + `docs_only` 真实 PI;W5b = 受控执行器(独立安全评审)。
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "W5a 软件自举 UI + docs_only PI"
|
||||
status: done
|
||||
created: 2026-09-17
|
||||
updated: 2026-09-17
|
||||
owner: Cursor
|
||||
branch: feature/w5a-software-bootstrap-ui-pi
|
||||
worktree: "-"
|
||||
mutex: workflow-design
|
||||
---
|
||||
|
||||
# W5a 软件自举 UI + docs_only PI
|
||||
|
||||
## 目标
|
||||
|
||||
创建向导改为服务端 catalog 驱动;软件项目不克隆医疗 7 模板;solo 标注未独立评审;docs_only 真实 PI 建议 delivery 且不自动激活。SWBOOT-10 仍 stub。矩阵不升 done。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 真 patch/merge(W5b)
|
||||
- 升 FEATURE-MATRIX 纯软件自举行
|
||||
@@ -55,7 +55,7 @@
|
||||
| 对话数据治理(D7) | 留存/删除口径、库·备份·日志控制、敏感字段加密边界 | deferred | deferred_enhancement(违规则升 blocker) | [计划](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) Scope Boundaries;[收口 §7.1](../remediation/2026-09-16-partial-deferred-completion-plan.md);[D7 ADR 草案](../adr/2026-09-17-d7-chat-data-retention.md) | 口径草案已确认(365/180/30;`pending-compliance-signoff`);先盘点;确认违规 → 立即写 gap-matrix | `chat` |
|
||||
| 智能体小队 | 角色-任务-工作区-黑板 | done | non_blocker | [agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6)属 backlog | `squad` |
|
||||
| 工作流治理 | 三正式工作流、设置页/DutyMode、动作级鉴权、真实 PI 三工作流 E2E、full/none 混合长链 | done | non_blocker | [三工作流整改](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[409/职责](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[PI E2E](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[Wave2 full/none](../test-reports/2026-09-16-wave2-real-pi-evidence-test-report.md) | full 跨用户审批 + none 免审批不阻塞已签。纯软件自举仍独立 planned | `workflow` |
|
||||
| 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[总控](../ops/short-term/2026-09-16-full-backlog-override.md) | SWBOOT-00…09 已合 `dev`;SWBOOT-10 stub only;真实 PI/UI 仍待二次验收 | `workflow-design` |
|
||||
| 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md) | **before**:L1 + UI 仍克隆医疗模板。**after W5a**:catalog 驱动创建、软件不挂医疗 key、docs_only 真 PI 建议 delivery 且不自动激活。**SWBOOT-10 仍 stub**(W5b);本行不升 done | `workflow-design` |
|
||||
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` |
|
||||
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` |
|
||||
| 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口 | `docgov` |
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# W5a 软件自举 UI + docs_only 真实 PI 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
L1 catalog/create 已合 `dev`,创建弹窗仍默认医疗并克隆 7 份模板。W5a 要求 catalog 驱动软件向导、solo 未独立评审标注、docs_only 真实 PI 建议 delivery(不自动激活)。SWBOOT-10 保持 stub,矩阵不升 done。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 软件项目只挂 `software_bootstrap` / `software_delivery`,不挂医疗三 key
|
||||
- 医疗三 key 回归仍可挂载
|
||||
- 前端不克隆 7 模板当 `product_domain=non_medical_software`
|
||||
- 真实 PI 建议 `software_delivery` 且 `auto_activate=false`
|
||||
- `ExecuteControlledPatch` 仍 fail-closed
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- 分支:`feature/w5a-software-bootstrap-ui-pi`;Runner `:18865` MiniMax
|
||||
- 层级:Go 单测 + 前端 guard 单测 + 真实 PI
|
||||
- Playwright 全向导:**NOT RUN**(医疗默认标题保留以兼容既有 dual-tab heading)
|
||||
- 证据:`.doc/test-reports/assets/2026-09-17-w5a-software-bootstrap-ui-pi/`
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
软件不激活 delivery;医疗三 key 仍在;controlled_patch stub。
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 catalog / 医疗三 key
|
||||
|
||||
- **测试输入 (Input)**:`go test ./internal/projectworkflow -run TestCreateSoftwareProject_NoMedicalDefaults`;`go test ./internal/workflowdef`
|
||||
- **实际输出 (Actual Output)**:PASS
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS
|
||||
- **性能与耗时 (Duration & Latency)**:<10ms
|
||||
|
||||
### 5.2 真实 PI MiniMax docs_only
|
||||
|
||||
- **测试输入 (Input)**:`RUN_PI_E2E=1 go test ./internal/app -run TestPiSoftwareBootstrapAdvisor_LiveMiniMax -v`
|
||||
- **实际输出 (Actual Output)**:elapsed=14.93s;建议 `software_delivery`;自动激活 false
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS
|
||||
- **性能与耗时 (Duration & Latency)**:14.93s
|
||||
|
||||
### 5.3 前端 7 模板门
|
||||
|
||||
- **测试输入 (Input)**:`npx tsx --test src/lib/createProjectSubmitGuard.test.ts`
|
||||
- **实际输出 (Actual Output)**:`shouldCloneMedicalTemplates skips the 7-pack` PASS
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS
|
||||
- **性能与耗时 (Duration & Latency)**:134ms
|
||||
|
||||
### 5.4 SWBOOT-10 stub
|
||||
|
||||
- **测试输入 (Input)**:`go test ./internal/swboot -run TestRepoAllowlistStub`
|
||||
- **实际输出 (Actual Output)**:PASS(`ERR_SWBOOT10_STUB_ONLY`)
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS(本波故意不实现真 patch)
|
||||
- **性能与耗时 (Duration & Latency)**:<10ms
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
- `go test -short ./internal/swboot ./internal/projectworkflow ./internal/app ./internal/workflowdef`:PASS
|
||||
- live MiniMax:PASS
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
W5a 可合 `dev`。FEATURE-MATRIX「纯软件项目自举」保持 `partial`(SWBOOT-10 stub / W5b 未做)。升 done 须独立安全评审后的受控执行器。
|
||||
@@ -0,0 +1,3 @@
|
||||
=== RUN TestPiSoftwareBootstrapAdvisor_LiveMiniMax
|
||||
elapsed=14.928s narrative=# 自举配置 ... software_delivery ... 自动激活: false
|
||||
--- PASS: TestPiSoftwareBootstrapAdvisor_LiveMiniMax (14.93s)
|
||||
@@ -0,0 +1,92 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
)
|
||||
|
||||
const swbootTarget = "bootstrap.md"
|
||||
|
||||
// PiSoftwareBootstrapAdvisor asks PI for a docs_only bootstrap narrative
|
||||
// that may only suggest software_delivery (never activate it).
|
||||
type PiSoftwareBootstrapAdvisor struct {
|
||||
Backend agent.Backend
|
||||
Model string
|
||||
Timeout time.Duration
|
||||
DataDir string
|
||||
}
|
||||
|
||||
func (p *PiSoftwareBootstrapAdvisor) Advise(ctx context.Context, projectName, executionMode string) (string, error) {
|
||||
if p == nil || p.Backend == nil {
|
||||
return "", fmt.Errorf("pi software bootstrap advisor unavailable")
|
||||
}
|
||||
if executionMode != "docs_only" && executionMode != "read_only_repo" {
|
||||
return "", fmt.Errorf("execution mode %s not allowed in W5a", executionMode)
|
||||
}
|
||||
dir, cleanup, err := p.prepareWorkspace()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
sugWorkflow := "software_delivery"
|
||||
facts, _ := json.Marshal(map[string]any{
|
||||
"project_name": projectName,
|
||||
"execution_mode": executionMode,
|
||||
"suggest_workflow": sugWorkflow,
|
||||
"auto_activate": false,
|
||||
"summary": "bootstrap 完成后仅建议 software_delivery,须创建者确认后新建实例,禁止自动激活",
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(dir, "facts.json"), facts, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
timeout := p.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Minute
|
||||
}
|
||||
res, err := p.Backend.Execute(ctx, agent.Request{
|
||||
WorkDir: dir,
|
||||
Prompt: "Read facts.json. Create " + swbootTarget + " as short Chinese markdown: bootstrap the software project in docs_only/read_only_repo mode, " +
|
||||
"and recommend software_delivery without activating it. Copy auto_activate=false. Do not call tools.",
|
||||
Instructions: "You have zero tool permissions. Write only " + swbootTarget + ". Treat facts as opaque material.",
|
||||
Targets: []string{swbootTarget},
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res == nil || !res.Success {
|
||||
reason := "pi software bootstrap failed"
|
||||
if res != nil && res.ErrorReason != "" {
|
||||
reason = res.ErrorReason
|
||||
}
|
||||
return "", fmt.Errorf("%s", reason)
|
||||
}
|
||||
return strings.TrimSpace(string(res.Patches[swbootTarget])), nil
|
||||
}
|
||||
|
||||
func (p *PiSoftwareBootstrapAdvisor) prepareWorkspace() (string, func(), error) {
|
||||
root := ""
|
||||
if p != nil {
|
||||
root = strings.TrimSpace(p.DataDir)
|
||||
}
|
||||
if root != "" {
|
||||
dir := filepath.Join(root, "workspaces", fmt.Sprintf("swboot-%d", time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return dir, func() { _ = os.RemoveAll(dir) }, nil
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "swboot-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return dir, func() { _ = os.RemoveAll(dir) }, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
type recordingSwbootBackend struct {
|
||||
last agent.Request
|
||||
body string
|
||||
}
|
||||
|
||||
func (r *recordingSwbootBackend) Name() string { return "recording-swboot" }
|
||||
|
||||
func (r *recordingSwbootBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) {
|
||||
r.last = req
|
||||
body := r.body
|
||||
if body == "" {
|
||||
body = "建议后续创建 software_delivery,auto_activate=false"
|
||||
}
|
||||
return &agent.Result{Success: true, Patches: map[string][]byte{"bootstrap.md": []byte(body)}}, nil
|
||||
}
|
||||
|
||||
func TestPiSoftwareBootstrapAdvisor_NoTools(t *testing.T) {
|
||||
rec := &recordingSwbootBackend{}
|
||||
p := &PiSoftwareBootstrapAdvisor{Backend: rec}
|
||||
got, err := p.Advise(context.Background(), "自举", "docs_only")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, "software_delivery") {
|
||||
t.Fatalf("got=%s", got)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(rec.last.Instructions), "zero tool") {
|
||||
t.Fatalf("instructions=%s", rec.last.Instructions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiSoftwareBootstrapAdvisor_RejectsControlledPatch(t *testing.T) {
|
||||
p := &PiSoftwareBootstrapAdvisor{Backend: &recordingSwbootBackend{}}
|
||||
if _, err := p.Advise(context.Background(), "x", "controlled_patch"); err == nil {
|
||||
t.Fatal("controlled_patch must be rejected in W5a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiSoftwareBootstrapAdvisor_LiveMiniMax(t *testing.T) {
|
||||
if os.Getenv("RUN_PI_E2E") != "1" {
|
||||
t.Skip("set RUN_PI_E2E=1 for live PI software bootstrap")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("live PI is not a -short test")
|
||||
}
|
||||
if err := os.Chdir(filepath.Clean(filepath.Join("..", ".."))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load("")
|
||||
if err != nil || cfg == nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.PiAgentRunnerAuthToken) == "" {
|
||||
if tok := runnerTokenFromDotenv(); tok != "" {
|
||||
cfg.PiAgentRunnerAuthToken = tok
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.PiAgentRunnerAuthToken) == "" {
|
||||
t.Fatal("runner auth token missing")
|
||||
}
|
||||
cfg.PiAgentRunnerURL = "http://127.0.0.1:18865"
|
||||
dataDir, err := filepath.Abs("data/dev")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &PiSoftwareBootstrapAdvisor{
|
||||
Backend: agent.NewBackend("pi-agent", cfg),
|
||||
Model: cfg.PiAgentModel,
|
||||
Timeout: 4 * time.Minute,
|
||||
DataDir: dataDir,
|
||||
}
|
||||
start := time.Now()
|
||||
got, err := p.Advise(context.Background(), "Agent-System 自举", "docs_only")
|
||||
if err != nil {
|
||||
t.Fatalf("live advise: %v", err)
|
||||
}
|
||||
t.Logf("elapsed=%s narrative=%s", time.Since(start).Round(time.Millisecond), got)
|
||||
if !strings.Contains(got, "software_delivery") && !strings.Contains(got, "交付") {
|
||||
t.Fatalf("expected delivery suggestion: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,19 @@ func NewDefaultCatalog() Catalog {
|
||||
return c
|
||||
}
|
||||
|
||||
// WorkflowKeysForProductDomain returns the catalog keys to mount at create time.
|
||||
// Empty domain keeps the full catalog (legacy medical create).
|
||||
func WorkflowKeysForProductDomain(domain string) []string {
|
||||
switch domain {
|
||||
case "non_medical_software":
|
||||
return []string{"software_bootstrap", "software_delivery"}
|
||||
case "medical_device":
|
||||
return []string{"main", "product_manager", "test_manager"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *memoryCatalog) Get(workflowKey string) (*Definition, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -105,10 +105,12 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 自动挂载目录中的工作流实例
|
||||
var allKeys []string
|
||||
for _, def := range s.catalog.List() {
|
||||
allKeys = append(allKeys, def.Key)
|
||||
// 按产品域挂载目录工作流,避免软件项目克隆医疗 key。
|
||||
allKeys := WorkflowKeysForProductDomain(in.ProductDomain)
|
||||
if len(allKeys) == 0 {
|
||||
for _, def := range s.catalog.List() {
|
||||
allKeys = append(allKeys, def.Key)
|
||||
}
|
||||
}
|
||||
if err := s.store.EnsureProjectWorkflows(ctx, created.ID, allKeys); err != nil {
|
||||
return nil, fmt.Errorf("initialize project workflows failed: %w", err)
|
||||
@@ -155,10 +157,15 @@ func (s *Service) IsProjectCreator(ctx context.Context, projectID int64, userID
|
||||
|
||||
// ListProjectWorkflows 查询项目已挂载的工作流清单
|
||||
func (s *Service) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) {
|
||||
// 确保默认工作流已初始化
|
||||
var allKeys []string
|
||||
for _, def := range s.catalog.List() {
|
||||
allKeys = append(allKeys, def.Key)
|
||||
domain := ""
|
||||
if p, err := s.store.GetProjectByID(ctx, projectID); err == nil && p != nil {
|
||||
domain = p.ProductDomain
|
||||
}
|
||||
allKeys := WorkflowKeysForProductDomain(domain)
|
||||
if len(allKeys) == 0 {
|
||||
for _, def := range s.catalog.List() {
|
||||
allKeys = append(allKeys, def.Key)
|
||||
}
|
||||
}
|
||||
_ = s.store.EnsureProjectWorkflows(ctx, projectID, allKeys)
|
||||
|
||||
|
||||
@@ -264,6 +264,41 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) {
|
||||
if proj.ActiveWorkflowKey != "" {
|
||||
t.Fatal("must remain planning without activation")
|
||||
}
|
||||
wfs, err := svc.ListProjectWorkflows(ctx, proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, wf := range wfs {
|
||||
got[wf.WorkflowKey] = true
|
||||
if wf.Status == "active" {
|
||||
t.Fatalf("software create must not auto-activate %s", wf.WorkflowKey)
|
||||
}
|
||||
}
|
||||
if !got["software_bootstrap"] || !got["software_delivery"] {
|
||||
t.Fatalf("expected software keys, got %v", got)
|
||||
}
|
||||
if got["main"] || got["product_manager"] || got["test_manager"] {
|
||||
t.Fatalf("software project must not mount medical keys: %v", got)
|
||||
}
|
||||
|
||||
med, err := svc.CreateProject(ctx, creator, CreateProjectInput{
|
||||
Name: "医疗回归", ProductDomain: "medical_device", DeviceType: "超声",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mwfs, err := svc.ListProjectWorkflows(ctx, med.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mgot := map[string]bool{}
|
||||
for _, wf := range mwfs {
|
||||
mgot[wf.WorkflowKey] = true
|
||||
}
|
||||
if !mgot["main"] || !mgot["product_manager"] || !mgot["test_manager"] {
|
||||
t.Fatalf("medical three keys missing: %v", mgot)
|
||||
}
|
||||
|
||||
_, err = svc.CreateProject(ctx, creator, CreateProjectInput{
|
||||
Name: "bad",
|
||||
@@ -274,4 +309,3 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) {
|
||||
t.Fatalf("expected controlled_patch rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -598,15 +598,15 @@ func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string,
|
||||
// =============================================================================
|
||||
|
||||
type memStore struct {
|
||||
mu sync.RWMutex
|
||||
projects map[int64]*Project
|
||||
workflows map[int64]map[string]*ProjectWorkflow
|
||||
runs map[string]*WorkflowRun
|
||||
audits []*WorkflowActivationAudit
|
||||
lineages []*DocumentLineage
|
||||
nextProjID int64
|
||||
nextWFID int64
|
||||
nextRunID int64
|
||||
mu sync.RWMutex
|
||||
projects map[int64]*Project
|
||||
workflows map[int64]map[string]*ProjectWorkflow
|
||||
runs map[string]*WorkflowRun
|
||||
audits []*WorkflowActivationAudit
|
||||
lineages []*DocumentLineage
|
||||
nextProjID int64
|
||||
nextWFID int64
|
||||
nextRunID int64
|
||||
nextLineageID int64
|
||||
}
|
||||
|
||||
@@ -751,8 +751,8 @@ func (m *memStore) EnsureProjectWorkflows(ctx context.Context, projectID int64,
|
||||
m.workflows[projectID] = make(map[string]*ProjectWorkflow)
|
||||
}
|
||||
proj := m.projects[projectID]
|
||||
activeKey := "main"
|
||||
if proj != nil && proj.ActiveWorkflowKey != "" {
|
||||
activeKey := ""
|
||||
if proj != nil {
|
||||
activeKey = proj.ActiveWorkflowKey
|
||||
}
|
||||
|
||||
@@ -836,15 +836,15 @@ func (m *memStore) UpdateActiveWorkflow(
|
||||
}
|
||||
|
||||
audit := &WorkflowActivationAudit{
|
||||
ID: int64(len(m.audits) + 1),
|
||||
ProjectID: projectID,
|
||||
FromWorkflowKey: fromKey,
|
||||
ToWorkflowKey: toWorkflowKey,
|
||||
ChangedBy: changedBy,
|
||||
VersionBefore: curVersion,
|
||||
VersionAfter: nextVersion,
|
||||
Reason: reason,
|
||||
CreatedAt: now,
|
||||
ID: int64(len(m.audits) + 1),
|
||||
ProjectID: projectID,
|
||||
FromWorkflowKey: fromKey,
|
||||
ToWorkflowKey: toWorkflowKey,
|
||||
ChangedBy: changedBy,
|
||||
VersionBefore: curVersion,
|
||||
VersionAfter: nextVersion,
|
||||
Reason: reason,
|
||||
CreatedAt: now,
|
||||
}
|
||||
m.audits = append(m.audits, audit)
|
||||
|
||||
|
||||
+54
-38
@@ -26,8 +26,8 @@ const (
|
||||
TeamModeSolo = "solo"
|
||||
TeamModeCollaborative = "collaborative"
|
||||
|
||||
ExecutionDocsOnly = "docs_only"
|
||||
ExecutionReadOnlyRepo = "read_only_repo"
|
||||
ExecutionDocsOnly = "docs_only"
|
||||
ExecutionReadOnlyRepo = "read_only_repo"
|
||||
ExecutionControlledPatch = "controlled_patch"
|
||||
|
||||
ApprovalIndependentThreeDuties = "independent_three_duties"
|
||||
@@ -36,56 +36,56 @@ const (
|
||||
|
||||
// ProjectTypeCatalogItem is a create-wizard selectable project type.
|
||||
type ProjectTypeCatalogItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ProductDomain string `json:"product_domain"`
|
||||
Description string `json:"description"`
|
||||
DefaultGovernance string `json:"default_governance"`
|
||||
DefaultWorkflowKey string `json:"default_workflow_key"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ProductDomain string `json:"product_domain"`
|
||||
Description string `json:"description"`
|
||||
DefaultGovernance string `json:"default_governance"`
|
||||
DefaultWorkflowKey string `json:"default_workflow_key"`
|
||||
RecommendedWorkflows []string `json:"recommended_workflows"`
|
||||
}
|
||||
|
||||
// ComplianceProfileCatalogItem is a create-wizard compliance option.
|
||||
type ComplianceProfileCatalogItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RequiresIndependentReview bool `json:"requires_independent_review"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RequiresIndependentReview bool `json:"requires_independent_review"`
|
||||
}
|
||||
|
||||
// CreationPreviewInput is the seven-step wizard confirmation payload.
|
||||
type CreationPreviewInput struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code,omitempty"`
|
||||
ProductDomain string `json:"product_domain"`
|
||||
ProjectTypeKey string `json:"project_type_key"`
|
||||
ProductShape string `json:"product_shape,omitempty"`
|
||||
ProblemStatement string `json:"problem_statement"`
|
||||
TargetUsers string `json:"target_users"`
|
||||
TargetOutcome string `json:"target_outcome"`
|
||||
GovernanceProfile string `json:"governance_profile"`
|
||||
TeamMode string `json:"team_mode"`
|
||||
ComplianceProfileKey string `json:"compliance_profile_key"`
|
||||
DataSensitivity string `json:"data_sensitivity,omitempty"`
|
||||
NetworkPolicy string `json:"network_policy,omitempty"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
CatalogVersionHint string `json:"expected_catalog_version,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code,omitempty"`
|
||||
ProductDomain string `json:"product_domain"`
|
||||
ProjectTypeKey string `json:"project_type_key"`
|
||||
ProductShape string `json:"product_shape,omitempty"`
|
||||
ProblemStatement string `json:"problem_statement"`
|
||||
TargetUsers string `json:"target_users"`
|
||||
TargetOutcome string `json:"target_outcome"`
|
||||
GovernanceProfile string `json:"governance_profile"`
|
||||
TeamMode string `json:"team_mode"`
|
||||
ComplianceProfileKey string `json:"compliance_profile_key"`
|
||||
DataSensitivity string `json:"data_sensitivity,omitempty"`
|
||||
NetworkPolicy string `json:"network_policy,omitempty"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
CatalogVersionHint string `json:"expected_catalog_version,omitempty"`
|
||||
}
|
||||
|
||||
// CreationPreviewResult is returned by POST /projects/creation-preview.
|
||||
type CreationPreviewResult struct {
|
||||
CreationToken string `json:"creation_token"`
|
||||
PlanHash string `json:"plan_hash"`
|
||||
SourceHash string `json:"source_hash"`
|
||||
ExpectedCatalogVersion string `json:"expected_catalog_version"`
|
||||
CreationToken string `json:"creation_token"`
|
||||
PlanHash string `json:"plan_hash"`
|
||||
SourceHash string `json:"source_hash"`
|
||||
ExpectedCatalogVersion string `json:"expected_catalog_version"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ApprovalPolicy string `json:"approval_policy"`
|
||||
RecommendedWorkflow string `json:"recommended_workflow"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Issues []string `json:"issues,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
ApprovalPolicy string `json:"approval_policy"`
|
||||
RecommendedWorkflow string `json:"recommended_workflow"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Issues []string `json:"issues,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
// ProjectTypesCatalog returns seeded software project types.
|
||||
@@ -241,6 +241,22 @@ func BuildCreationPreview(in CreationPreviewInput, now time.Time) CreationPrevie
|
||||
return res
|
||||
}
|
||||
|
||||
// DeliverySuggestion is the post-bootstrap recommendation (never auto-activates).
|
||||
type DeliverySuggestion struct {
|
||||
WorkflowKey string `json:"workflow_key"`
|
||||
AutoActivate bool `json:"auto_activate"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// SuggestDeliveryAfterBootstrap recommends software_delivery without activating it.
|
||||
func SuggestDeliveryAfterBootstrap() DeliverySuggestion {
|
||||
return DeliverySuggestion{
|
||||
WorkflowKey: "software_delivery",
|
||||
AutoActivate: false,
|
||||
Summary: "bootstrap 完成后仅建议 software_delivery,须创建者确认后新建实例,禁止自动激活",
|
||||
}
|
||||
}
|
||||
|
||||
func isKnownProjectType(key string) bool {
|
||||
switch key {
|
||||
case ProjectTypeGreenfield, ProjectTypeExistingProduct, ProjectTypeLegacyModernization, ProjectTypeExistingSelfBootstrap:
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestProjectTypesCatalog(t *testing.T) {
|
||||
func TestBuildCreationPreview_SelfBootstrapSolo(t *testing.T) {
|
||||
res := BuildCreationPreview(CreationPreviewInput{
|
||||
Name: "Agent-System 自举", ProductDomain: DomainNonMedicalSoftware,
|
||||
ProjectTypeKey: ProjectTypeExistingSelfBootstrap,
|
||||
ProjectTypeKey: ProjectTypeExistingSelfBootstrap,
|
||||
ProblemStatement: "建立可信路线", TargetUsers: "内部团队", TargetOutcome: "可执行发布路线",
|
||||
GovernanceProfile: GovernanceStandard, TeamMode: TeamModeSolo,
|
||||
ComplianceProfileKey: "internal_control+security_baseline",
|
||||
@@ -54,7 +54,7 @@ func TestBuildCreationPreview_SelfBootstrapSolo(t *testing.T) {
|
||||
func TestBuildCreationPreview_RejectsControlledPatchAndSoloPrivacy(t *testing.T) {
|
||||
res := BuildCreationPreview(CreationPreviewInput{
|
||||
Name: "x", ProductDomain: DomainNonMedicalSoftware,
|
||||
ProjectTypeKey: ProjectTypeGreenfield,
|
||||
ProjectTypeKey: ProjectTypeGreenfield,
|
||||
ProblemStatement: "p", TargetUsers: "u", TargetOutcome: "o",
|
||||
TeamMode: TeamModeSolo, ComplianceProfileKey: "privacy",
|
||||
ExecutionMode: ExecutionControlledPatch,
|
||||
@@ -90,3 +90,16 @@ func TestRepoAllowlistStub(t *testing.T) {
|
||||
t.Fatal("empty allowlist must deny all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestDeliveryAfterBootstrapNeverActivates(t *testing.T) {
|
||||
s := SuggestDeliveryAfterBootstrap()
|
||||
if s.WorkflowKey != "software_delivery" {
|
||||
t.Fatalf("key=%s", s.WorkflowKey)
|
||||
}
|
||||
if s.AutoActivate {
|
||||
t.Fatal("must not auto-activate delivery")
|
||||
}
|
||||
if s.Summary == "" {
|
||||
t.Fatal("expected operator-facing summary")
|
||||
}
|
||||
}
|
||||
|
||||
+30
-26
@@ -43,6 +43,7 @@ import {
|
||||
beginCreateProjectSubmit,
|
||||
endCreateProjectSubmit,
|
||||
resolveCreateProjectSubmitPhase,
|
||||
shouldCloneMedicalTemplates,
|
||||
type CreateProjectSubmitPhase,
|
||||
} from './lib/createProjectSubmitGuard';
|
||||
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
|
||||
@@ -100,7 +101,7 @@ import { UserProjectRightsModal } from './components/user/UserProjectRightsModal
|
||||
import { UserSettingsModal } from './components/user/UserSettingsModal';
|
||||
import { AdminSettingsModal } from './components/admin/AdminSettingsModal';
|
||||
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
|
||||
import { CreateProjectModal } from './components/project/CreateProjectModal';
|
||||
import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal';
|
||||
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
|
||||
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
|
||||
import { fetchProjectSettings } from './api/projectSettings';
|
||||
@@ -3474,18 +3475,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
// 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员)
|
||||
const handleCreateProjectConfirm = async (data: {
|
||||
title: string;
|
||||
deviceType: string;
|
||||
description: string;
|
||||
initialMembers: Array<{
|
||||
userId: number;
|
||||
userName: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
role: ProjectMemberRole;
|
||||
}>;
|
||||
}) => {
|
||||
const handleCreateProjectConfirm = async (data: CreateProjectConfirmData) => {
|
||||
if (!beginCreateProjectSubmit(createProjectInFlightRef)) {
|
||||
return;
|
||||
}
|
||||
@@ -3503,8 +3493,17 @@ export default function App() {
|
||||
createProject: async () => {
|
||||
const created = await createProject({
|
||||
name: data.title,
|
||||
device_type: data.deviceType,
|
||||
intended_use: data.description,
|
||||
device_type: data.deviceType || undefined,
|
||||
intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description,
|
||||
product_domain: data.productDomain,
|
||||
project_type_key: data.projectTypeKey,
|
||||
team_mode: data.teamMode,
|
||||
execution_mode: data.executionMode,
|
||||
problem_statement: data.problemStatement,
|
||||
target_users: data.targetUsers,
|
||||
target_outcome: data.targetOutcome,
|
||||
initial_workflow_key:
|
||||
data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined,
|
||||
});
|
||||
// create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create
|
||||
createProjectBootstrapIdRef.current = created.id;
|
||||
@@ -3596,14 +3595,17 @@ export default function App() {
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 克隆全生命周期 7 份受控模板副本
|
||||
const { deliverables: clonedDocs, docASTMap: clonedAsts } = createProjectTemplateDeliverables({
|
||||
projectId: newProjId,
|
||||
projectName: data.title,
|
||||
deviceType: data.deviceType,
|
||||
creatorId: currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
});
|
||||
// 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only
|
||||
const clonedDocs = shouldCloneMedicalTemplates(data.productDomain)
|
||||
? createProjectTemplateDeliverables({
|
||||
projectId: newProjId,
|
||||
projectName: data.title,
|
||||
deviceType: data.deviceType,
|
||||
creatorId: currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
})
|
||||
: { deliverables: [], docASTMap: {} };
|
||||
const clonedAsts = clonedDocs.docASTMap;
|
||||
|
||||
setProjects((prev) => [newProject, ...prev]);
|
||||
setSessions((prev) => [...newSessionsToAppend, ...prev]);
|
||||
@@ -3621,14 +3623,16 @@ export default function App() {
|
||||
).catch((err) => {
|
||||
console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err);
|
||||
});
|
||||
setDeliverables((prev) => [...prev, ...clonedDocs]);
|
||||
setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]);
|
||||
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
|
||||
|
||||
if (creatorSessionId) {
|
||||
setActiveSessionId(creatorSessionId);
|
||||
}
|
||||
setActiveDocId(clonedDocs[0].docId);
|
||||
setActiveCategory('workflow');
|
||||
if (clonedDocs.deliverables[0]?.docId) {
|
||||
setActiveDocId(clonedDocs.deliverables[0].docId);
|
||||
setActiveCategory('workflow');
|
||||
}
|
||||
createProjectBootstrapIdRef.current = null;
|
||||
setCreateProjectModalOpen(false);
|
||||
setCreateProjectError(null);
|
||||
|
||||
@@ -22,6 +22,19 @@ export async function fetchProjects(): Promise<Project[]> {
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function fetchProjectTypesCatalog(): Promise<
|
||||
Array<{ key: string; name: string; description: string; default_workflow_key: string }>
|
||||
> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-types/catalog`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`获取项目类型目录失败: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return Array.isArray(data?.project_types) ? data.project_types : [];
|
||||
}
|
||||
|
||||
export async function createProject(data: CreateProjectInput): Promise<Project> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -2,28 +2,34 @@ import React, { useEffect, useState } from 'react';
|
||||
import { X, Sparkles, FolderPlus, Users, Check } from 'lucide-react';
|
||||
import { UserProfile } from '../../types/docgov';
|
||||
import { ProjectMemberRole } from '../../types/workbench';
|
||||
import {
|
||||
createProjectSubmitButtonLabel,
|
||||
shouldBlockCreateProjectClose,
|
||||
type CreateProjectSubmitPhase,
|
||||
} from '../../lib/createProjectSubmitGuard';
|
||||
import { createProjectSubmitButtonLabel, shouldBlockCreateProjectClose, type CreateProjectSubmitPhase } from '../../lib/createProjectSubmitGuard';
|
||||
import { exclusiveMemberRoleChange } from '../../lib/projectDocumentDuty';
|
||||
import { fetchProjectTypesCatalog } from '../../api/projectWorkflow';
|
||||
|
||||
export type CreateProjectConfirmData = {
|
||||
title: string;
|
||||
deviceType: string;
|
||||
description: string;
|
||||
productDomain: 'medical_device' | 'non_medical_software';
|
||||
projectTypeKey?: string;
|
||||
teamMode?: string;
|
||||
executionMode?: string;
|
||||
problemStatement?: string;
|
||||
targetUsers?: string;
|
||||
targetOutcome?: string;
|
||||
initialMembers: Array<{
|
||||
userId: number;
|
||||
userName: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
role: ProjectMemberRole;
|
||||
}>;
|
||||
};
|
||||
|
||||
interface CreateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: {
|
||||
title: string;
|
||||
deviceType: string;
|
||||
description: string;
|
||||
initialMembers: Array<{
|
||||
userId: number;
|
||||
userName: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
role: ProjectMemberRole;
|
||||
}>;
|
||||
}) => void | Promise<void>;
|
||||
onSubmit: (data: CreateProjectConfirmData) => void | Promise<void>;
|
||||
submitError?: string | null;
|
||||
/** 立项/同步成员进行中:禁用确认、阻止关闭 */
|
||||
submitting?: boolean;
|
||||
@@ -46,6 +52,18 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
}) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [productDomain, setProductDomain] = useState<'medical_device' | 'non_medical_software'>(
|
||||
'medical_device',
|
||||
);
|
||||
const [projectTypeKey, setProjectTypeKey] = useState('greenfield');
|
||||
const [teamMode, setTeamMode] = useState('collaborative');
|
||||
const [executionMode, setExecutionMode] = useState('docs_only');
|
||||
const [problemStatement, setProblemStatement] = useState('');
|
||||
const [targetUsers, setTargetUsers] = useState('');
|
||||
const [targetOutcome, setTargetOutcome] = useState('');
|
||||
const [catalogTypes, setCatalogTypes] = useState<
|
||||
Array<{ key: string; name: string; description: string }>
|
||||
>([]);
|
||||
|
||||
// 初始成员勾选列表(默认包含当前创建者)
|
||||
const [selectedMembers, setSelectedMembers] = useState<
|
||||
@@ -69,7 +87,18 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setErrorMsg('');
|
||||
setProductDomain('medical_device');
|
||||
setProjectTypeKey('greenfield');
|
||||
setTeamMode('collaborative');
|
||||
setExecutionMode('docs_only');
|
||||
setProblemStatement('');
|
||||
setTargetUsers('');
|
||||
setTargetOutcome('');
|
||||
return;
|
||||
}
|
||||
void fetchProjectTypesCatalog()
|
||||
.then((items) => setCatalogTypes(items))
|
||||
.catch(() => setCatalogTypes([]));
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -107,8 +136,14 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
setErrorMsg('请输入项目名称');
|
||||
return;
|
||||
}
|
||||
if (productDomain === 'non_medical_software') {
|
||||
if (!problemStatement.trim() || !targetUsers.trim() || !targetOutcome.trim()) {
|
||||
setErrorMsg('软件项目需填写问题、用户与目标结果');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const finalDeviceType = '通用医疗器械';
|
||||
const finalDeviceType = productDomain === 'non_medical_software' ? '' : '通用医疗器械';
|
||||
|
||||
const initialMembers: Array<{
|
||||
userId: number;
|
||||
@@ -144,6 +179,13 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
title: cleanTitle,
|
||||
deviceType: finalDeviceType,
|
||||
description: description.trim(),
|
||||
productDomain,
|
||||
projectTypeKey: productDomain === 'non_medical_software' ? projectTypeKey : undefined,
|
||||
teamMode: productDomain === 'non_medical_software' ? teamMode : undefined,
|
||||
executionMode: productDomain === 'non_medical_software' ? executionMode : undefined,
|
||||
problemStatement: productDomain === 'non_medical_software' ? problemStatement.trim() : undefined,
|
||||
targetUsers: productDomain === 'non_medical_software' ? targetUsers.trim() : undefined,
|
||||
targetOutcome: productDomain === 'non_medical_software' ? targetOutcome.trim() : undefined,
|
||||
initialMembers,
|
||||
});
|
||||
};
|
||||
@@ -205,10 +247,12 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: 'var(--text-strong, #0f172a)' }}>
|
||||
发起医疗器械新项目立项
|
||||
{productDomain === 'non_medical_software' ? '发起软件项目' : '发起医疗器械新项目立项'}
|
||||
</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: '12px', color: 'var(--text-muted, #64748b)' }}>
|
||||
创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间
|
||||
{productDomain === 'non_medical_software'
|
||||
? '类型与工作流由服务端 catalog 驱动;不克隆医疗 7 份模板'
|
||||
: '创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,6 +296,88 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px', display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => setProductDomain('medical_device')}
|
||||
data-testid="create-domain-medical"
|
||||
>
|
||||
医疗器械
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => setProductDomain('non_medical_software')}
|
||||
data-testid="create-domain-software"
|
||||
>
|
||||
纯软件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{productDomain === 'non_medical_software' && teamMode === 'solo' ? (
|
||||
<p data-testid="solo-unreviewed-banner" style={{ color: '#b45309', marginBottom: 12 }}>
|
||||
未独立评审:solo 使用 creator_acknowledgement,审计将持续标注。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{productDomain === 'non_medical_software' ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>项目类型(服务端 catalog)</label>
|
||||
<select
|
||||
data-testid="software-project-type"
|
||||
value={projectTypeKey}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setProjectTypeKey(e.target.value)}
|
||||
style={{ width: '100%', height: 40 }}
|
||||
>
|
||||
{(catalogTypes.length
|
||||
? catalogTypes
|
||||
: [{ key: 'greenfield', name: '绿地立项', description: '' }]
|
||||
).map((it) => (
|
||||
<option key={it.key} value={it.key}>
|
||||
{it.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label style={{ display: 'block', fontWeight: 600, margin: '10px 0 6px' }}>执行模式</label>
|
||||
<select
|
||||
value={executionMode}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setExecutionMode(e.target.value)}
|
||||
>
|
||||
<option value="docs_only">docs_only</option>
|
||||
<option value="read_only_repo">read_only_repo</option>
|
||||
</select>
|
||||
<label style={{ display: 'block', fontWeight: 600, margin: '10px 0 6px' }}>团队模式</label>
|
||||
<select value={teamMode} disabled={submitting} onChange={(e) => setTeamMode(e.target.value)}>
|
||||
<option value="collaborative">collaborative</option>
|
||||
<option value="solo">solo</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="问题陈述"
|
||||
value={problemStatement}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setProblemStatement(e.target.value)}
|
||||
style={{ width: '100%', marginTop: 8, height: 36 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="目标用户"
|
||||
value={targetUsers}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setTargetUsers(e.target.value)}
|
||||
style={{ width: '100%', marginTop: 8, height: 36 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="目标结果"
|
||||
value={targetOutcome}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setTargetOutcome(e.target.value)}
|
||||
style={{ width: '100%', marginTop: 8, height: 36 }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 1. 项目名称 */}
|
||||
<div style={{ marginBottom: '18px' }}>
|
||||
<label
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
endCreateProjectSubmit,
|
||||
resolveCreateProjectSubmitPhase,
|
||||
shouldBlockCreateProjectClose,
|
||||
shouldCloneMedicalTemplates,
|
||||
} from './createProjectSubmitGuard';
|
||||
|
||||
test('beginCreateProjectSubmit rejects concurrent second entry until end', async () => {
|
||||
@@ -48,3 +49,9 @@ test('createProjectSubmitButtonLabel and close block while submitting', () => {
|
||||
assert.equal(shouldBlockCreateProjectClose(true), true);
|
||||
assert.equal(shouldBlockCreateProjectClose(false), false);
|
||||
});
|
||||
|
||||
test('shouldCloneMedicalTemplates skips the 7-pack for software domain', () => {
|
||||
assert.equal(shouldCloneMedicalTemplates('non_medical_software'), false);
|
||||
assert.equal(shouldCloneMedicalTemplates('medical_device'), true);
|
||||
assert.equal(shouldCloneMedicalTemplates(undefined), true);
|
||||
});
|
||||
|
||||
@@ -37,3 +37,8 @@ export function createProjectSubmitButtonLabel(
|
||||
export function shouldBlockCreateProjectClose(submitting: boolean): boolean {
|
||||
return submitting;
|
||||
}
|
||||
|
||||
/** Software projects must not clone the medical 7-template pack. */
|
||||
export function shouldCloneMedicalTemplates(productDomain: string | undefined): boolean {
|
||||
return productDomain !== 'non_medical_software';
|
||||
}
|
||||
|
||||
@@ -63,6 +63,15 @@ export interface CreateProjectInput {
|
||||
device_type?: string;
|
||||
intended_use?: string;
|
||||
target_market?: string;
|
||||
product_domain?: string;
|
||||
project_type_key?: string;
|
||||
compliance_profile_key?: string;
|
||||
governance_profile?: string;
|
||||
team_mode?: string;
|
||||
execution_mode?: string;
|
||||
problem_statement?: string;
|
||||
target_users?: string;
|
||||
target_outcome?: string;
|
||||
/** @deprecated Accepted by API for catalog checks only; create dialog must not send this. */
|
||||
initial_workflow_key?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user