import SwiftUI import UniformTypeIdentifiers import AppKit struct ContentView: View { @EnvironmentObject private var model: AppModel @State private var selectedRequirementID: String? @State private var isImportingRequirement = false @State private var statusDismissTask: Task? private var selectedRequirement: RequirementItem? { guard let selectedRequirementID else { return nil } return model.dashboard.requirements.first { $0.id == selectedRequirementID } } private var processingMessage: String { if let workflowAction = model.workflowAction { return workflowAction } if model.isInitializingComplexDemo { return "正在生成 100 万笔交易并计算复杂指标…" } if let caseID = model.analyzingCaseID { return "Codex 正在调查 \(caseID)…" } if let caseID = model.runningCaseID { return "正在重跑 \(caseID)…" } if let runID = model.generatingReportRunID { return "正在生成 \(runID) 的报告…" } return "正在处理…" } var body: some View { NavigationSplitView { List(selection: $selectedRequirementID) { Section("需求") { ForEach(model.dashboard.requirements) { requirement in RequirementSidebarRow(requirement: requirement) .tag(requirement.id) } } } .navigationTitle("需求中心") .overlay { if model.dashboard.requirements.isEmpty { ContentUnavailableView( "尚无需求", systemImage: "doc.badge.plus", description: Text("导入需求文档,或初始化演示需求。") ) } } .safeAreaInset(edge: .bottom) { Button { isImportingRequirement = true } label: { Label("导入需求", systemImage: "doc.badge.plus") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .padding(12) .background(.bar) } .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260) } detail: { if let requirement = selectedRequirement { RequirementWorkspaceView(requirement: requirement) .id(requirement.id) } else { RequirementLandingView() } } .toolbar { ToolbarItemGroup { Button { isImportingRequirement = true } label: { Label("导入需求", systemImage: "doc.badge.plus") } Menu { Button { model.initializeComplexDemo() } label: { Label("复杂大数据体验(含故意错误)", systemImage: "cylinder.split.1x2.fill") } Button { model.initializeDemo() } label: { Label("基础 SQLite 演示", systemImage: "shippingbox") } } label: { Label("初始化演示", systemImage: "shippingbox") } .disabled(model.isLoading) Button { model.refresh() } label: { Label("刷新", systemImage: "arrow.clockwise") } .disabled(model.isLoading) } } .overlay { if model.isLoading && !model.isGeneratingCases && !model.isRunningTests { ProgressView(processingMessage) .padding(24) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) } } .overlay(alignment: .bottom) { if let message = model.message { StatusToast(message: message) { model.clearMessage() } .padding(.horizontal, 24) .padding(.bottom, 16) .transition(.move(edge: .bottom).combined(with: .opacity)) } } .animation(.easeInOut(duration: 0.2), value: model.message) .fileImporter( isPresented: $isImportingRequirement, allowedContentTypes: [.plainText, .text, UTType(filenameExtension: "md") ?? .plainText], allowsMultipleSelection: false ) { result in switch result { case .success(let urls): if let url = urls.first { model.importRequirement(from: url) } case .failure(let error): model.message = "导入需求失败:\(error.localizedDescription)" } } .onChange(of: model.dashboard.requirements.map(\.id)) { _, ids in if let selectedRequirementID, ids.contains(selectedRequirementID) { return } else { selectedRequirementID = ids.first } } .onChange(of: model.lastImportedRequirementID) { _, requirementID in if let requirementID { selectedRequirementID = requirementID } } .onChange(of: model.message) { _, message in statusDismissTask?.cancel() guard let message else { statusDismissTask = nil return } statusDismissTask = Task { @MainActor in do { try await Task.sleep(for: .seconds(5)) } catch { return } guard model.message == message else { return } model.clearMessage() } } .onDisappear { statusDismissTask?.cancel() statusDismissTask = nil } .task { model.refresh() } } } struct RequirementSidebarRow: View { let requirement: RequirementItem var body: some View { VStack(alignment: .leading, spacing: 5) { Text(requirement.name).font(.headline).lineLimit(2) HStack(spacing: 6) { Text(requirement.id).font(.caption.monospaced()) Text("v\(requirement.current_version)").font(.caption) Spacer() StatusBadge(status: requirement.status) } .foregroundStyle(.secondary) } .padding(.vertical, 6) } } struct RequirementLandingView: View { @EnvironmentObject private var model: AppModel var body: some View { if model.dashboard.requirements.isEmpty { VStack(spacing: 18) { ContentUnavailableView( "从一个复杂需求开始", systemImage: "point.3.connected.trianglepath.dotted", description: Text("创建包含多表关联、全量/增量加工、V2 新增字段、复杂指标和故意错误的大数据体验。") ) Button { model.initializeComplexDemo() } label: { Label("创建复杂大数据体验", systemImage: "sparkles.rectangle.stack.fill") } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(model.isLoading) Text("规模:10 万客户 · 20 万账户 · 100 万笔交易 · 30 个增量分区") .font(.caption) .foregroundStyle(.secondary) } .padding(32) } else { ContentUnavailableView( "选择一个需求", systemImage: "arrow.left.circle", description: Text("从左侧选择需求,查看其 Metadata、案例和运行结果。") ) } } } enum RequirementSection: String, CaseIterable, Identifiable { case overview = "概览" case metadata = "Metadata" case cases = "测试案例" case collaboration = "Codex 协作" case runs = "运行记录" case reports = "测试报告" case metrics = "指标趋势" var id: String { rawValue } } struct RequirementWorkspaceView: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem @State private var section: RequirementSection = .overview private var metadata: [MetadataTableItem] { model.dashboard.metadata.filter { $0.requirement_id == requirement.id } } private var cases: [TestCaseItem] { model.dashboard.case_items.filter { $0.requirement_id == requirement.id } } private var runs: [RunItem] { model.dashboard.runs.filter { $0.requirement_id == requirement.id } } private var metrics: [MetricItem] { model.dashboard.metrics.filter { $0.requirement_id == requirement.id } } private var reports: [ReportItem] { model.dashboard.reports.filter { $0.requirement_id == requirement.id } } private var results: [CaseResultItem] { model.dashboard.result_items.filter { $0.requirement_id == requirement.id } } private var analyses: [FailureAnalysisItem] { model.dashboard.failure_analyses.filter { $0.requirement_id == requirement.id } } private var agentMessages: [CaseAgentMessageItem] { model.dashboard.case_agent_messages.filter { $0.requirement_id == requirement.id } } private var reviewEvents: [CaseReviewEventItem] { model.dashboard.case_review_events.filter { $0.requirement_id == requirement.id } } var body: some View { VStack(spacing: 0) { RequirementHeader(requirement: requirement, section: $section) Divider() Group { switch section { case .overview: RequirementOverviewView( requirement: requirement, metadata: metadata, cases: cases, runs: runs ) case .metadata: RequirementMetadataView(requirement: requirement, tables: metadata) case .cases: RequirementCasesView( requirement: requirement, cases: cases, results: results, analyses: analyses, reviewEvents: reviewEvents ) case .collaboration: CaseAgentConversationView( requirement: requirement, cases: cases, messages: agentMessages ) case .runs: RequirementRunsView(requirement: requirement, runs: runs) case .reports: RequirementReportsView(requirement: requirement, runs: runs, reports: reports) case .metrics: RequirementMetricsView(requirement: requirement, metrics: metrics) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } .navigationTitle(requirement.name) } } struct RequirementHeader: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem @Binding var section: RequirementSection private var hasCases: Bool { model.dashboard.case_items.contains { $0.requirement_id == requirement.id } } private var scopeConfirmed: Bool { requirement.status != "imported" && requirement.status != "pending_confirmation" } private var generateCaseHelp: String { if !scopeConfirmed { return "请先审核 Agent 探索出的数据库范围并锁定 Metadata" } if !requirement.metadata_complete { return "请先确认需求范围并获取完整 Metadata" } if hasCases { return "重新调用 Codex 生成候选案例;现有未审核草稿会被历史化,新案例仍需人工审核" } return "根据当前需求版本和真实 Metadata 生成待审核案例草稿" } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { Text(requirement.name).font(.title2.bold()) Text("\(requirement.id) · 需求版本 v\(requirement.current_version)") .font(.callout.monospaced()).foregroundStyle(.secondary) } Spacer() Button { model.generateCases(requirementID: requirement.id) } label: { Label("生成案例", systemImage: "wand.and.stars") } .buttonStyle(.borderedProminent) .disabled( !scopeConfirmed || !requirement.metadata_complete || model.isLoading || model.isChattingAboutCases ) .help(generateCaseHelp) StatusBadge(status: requirement.status) } Picker("需求视图", selection: $section) { ForEach(RequirementSection.allCases) { item in Text(item.rawValue).tag(item) } } .pickerStyle(.segmented) } .padding(.horizontal, 24) .padding(.top, 18) .padding(.bottom, 16) .background(.bar) } } struct StatusBadge: View { let status: String private var color: Color { switch status { case "confirmed", "metadata_ready", "ready", "approved", "PASS": .green case "FAIL", "invalid", "metadata_missing": .red case "ERROR", "reviewing": .orange case "rejected", "reviewed": .secondary default: .blue } } var body: some View { Text(status) .font(.caption.bold()) .padding(.horizontal, 9) .padding(.vertical, 4) .foregroundStyle(color) .background(color.opacity(0.12), in: Capsule()) } } struct StatusToast: View { let message: String let dismiss: () -> Void var body: some View { HStack(spacing: 10) { Image(systemName: "info.circle.fill") .foregroundStyle(.tint) Text(message) .font(.callout) .lineLimit(3) .textSelection(.enabled) Spacer(minLength: 12) Button(action: dismiss) { Image(systemName: "xmark") .font(.caption.bold()) } .buttonStyle(.plain) .help("关闭通知") } .padding(.horizontal, 14) .padding(.vertical, 11) .frame(maxWidth: 720) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) .overlay { RoundedRectangle(cornerRadius: 12) .stroke(Color(nsColor: .separatorColor).opacity(0.55), lineWidth: 1) } .shadow(color: .black.opacity(0.16), radius: 12, y: 5) } } struct RequirementOverviewView: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem let metadata: [MetadataTableItem] let cases: [TestCaseItem] let runs: [RunItem] @State private var supplementalContext = "" private var sourceURL: URL { URL(fileURLWithPath: requirement.source_path) } private var sourceFileExists: Bool { FileManager.default.fileExists(atPath: requirement.source_path) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { HStack(spacing: 14) { Image(systemName: "doc.text.fill") .font(.title2) .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 3) { Text("需求文件").font(.headline) Text(sourceURL.lastPathComponent) .font(.callout) Text("当前版本 v\(requirement.current_version)") .font(.caption) .foregroundStyle(.secondary) } Spacer() Button { guard sourceFileExists else { model.message = "需求文件不存在:\(requirement.source_path)" return } if !NSWorkspace.shared.open(sourceURL) { model.message = "无法打开需求文件:\(requirement.source_path)" } } label: { Label("打开需求文件", systemImage: "arrow.up.forward.app") } .buttonStyle(.borderedProminent) .disabled(!sourceFileExists) .help(sourceFileExists ? requirement.source_path : "原始需求文件已不存在") } .padding(16) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12)) HStack(spacing: 14) { SummaryCard(title: "Metadata 表", value: metadata.count, icon: "cylinder.split.1x2") SummaryCard(title: "测试案例", value: cases.count, icon: "checklist") SummaryCard(title: "已审核", value: cases.filter { $0.status == "approved" }.count, icon: "checkmark.seal") SummaryCard(title: "运行批次", value: runs.count, icon: "play.circle") } GroupBox("需求测试流程") { HStack(spacing: 8) { FlowStep(title: "需求", ready: true) FlowArrow() FlowStep( title: "范围确认", ready: requirement.status != "imported" && requirement.status != "pending_confirmation" ) FlowArrow() FlowStep( title: "Metadata", ready: requirement.metadata_complete && requirement.status != "pending_confirmation" ) FlowArrow() FlowStep(title: "测试案例", ready: !cases.isEmpty) FlowArrow() FlowStep(title: "人工审核", ready: !cases.isEmpty && cases.allSatisfy { $0.status != "draft" && $0.status != "invalid" }) FlowArrow() FlowStep(title: "执行", ready: !runs.isEmpty) } .padding(16) } RequirementWorkflowActionView( requirement: requirement, metadata: metadata, cases: cases, supplementalContext: $supplementalContext ) if requirement.id == "REQ-RISK-002" { HStack(alignment: .top, spacing: 12) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) .font(.title3) VStack(alignment: .leading, spacing: 5) { Text("失败诊断体验已准备").font(.headline) Text("数据中故意保留 1 条风险评分错误。运行全部案例后,进入“测试案例”,选择红色 FAIL 案例并点击“Codex 调查根因”。") .foregroundStyle(.secondary) } Spacer() } .padding(16) .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) } HStack { VStack(alignment: .leading, spacing: 4) { Text("运行该需求").font(.headline) Text("只执行该需求下状态为 approved 的案例。") .foregroundStyle(.secondary) } Spacer() Button { model.runTests(requirementID: requirement.id) } label: { Label("运行全部已审核案例", systemImage: "play.fill") } .buttonStyle(.borderedProminent) .disabled(cases.allSatisfy { $0.status != "approved" } || model.isLoading) } .padding(18) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 14)) } .padding(24) } } } struct RequirementWorkflowActionView: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem let metadata: [MetadataTableItem] let cases: [TestCaseItem] @Binding var supplementalContext: String @State private var isConfirmedScopeExpanded = false private var pendingCases: Int { cases.filter { $0.status == "draft" || $0.status == "invalid" }.count } private var scopeConfirmed: Bool { requirement.status != "imported" && requirement.status != "pending_confirmation" } var body: some View { VStack(alignment: .leading, spacing: 16) { if let extraction = requirement.extraction, scopeConfirmed { confirmedScopeDisclosure(extraction: extraction) } GroupBox("当前步骤") { VStack(alignment: .leading, spacing: 14) { if requirement.extraction == nil && cases.isEmpty { workflowRow( title: "1. Agent 探索数据库并解析需求", detail: "只读探索本地 SQLite 的表、DDL、字段、索引、行数和样例,再提取精确测试范围并形成候选 Metadata。", button: "探索数据库并解析", icon: "sparkles" ) { model.parseRequirement(requirementID: requirement.id) } } else if let extraction = requirement.extraction, requirement.status == "pending_confirmation" || !requirement.metadata_ready { ExtractionScopeView(extraction: extraction) if extraction.open_questions.isEmpty && requirement.metadata_missing_tables.isEmpty { workflowRow( title: "2. 人工审核 Agent 探索结果", detail: "候选 Metadata 已形成。确认库、表、字段映射和规则后,建立 ETL 任务并重新采集、锁定正式 Metadata。", button: "确认范围并锁定 Metadata", icon: "checkmark.seal.fill" ) { model.confirmRequirement(requirementID: requirement.id) } } else { Text("探索结果仍有待确认项,请补充说明后让 Agent 重新探索。") .font(.callout.bold()) .foregroundStyle(.orange) if !requirement.metadata_missing_tables.isEmpty { Text("数据库中未找到:\(requirement.metadata_missing_tables.joined(separator: ", "))") .font(.callout) .foregroundStyle(.red) } TextEditor(text: $supplementalContext) .font(.body) .frame(minHeight: 72) .padding(6) .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) Button { model.parseRequirement( requirementID: requirement.id, context: supplementalContext ) } label: { Label("提交补充并重新探索", systemImage: "arrow.triangle.2.circlepath") } .buttonStyle(.borderedProminent) .disabled(supplementalContext.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isLoading) } } else if requirement.metadata_ready && !requirement.metadata_complete { workflowRow( title: "Metadata 未覆盖需求表", detail: "缺少:\(requirement.metadata_missing_tables.joined(separator: ", "))。请先在本地 SQLite 准备这些表,再刷新 Metadata。", button: "刷新 Metadata", icon: "arrow.clockwise" ) { model.refreshMetadata(requirementID: requirement.id) } } else if cases.isEmpty { workflowRow( title: "3. 根据 Metadata 生成案例草稿", detail: "Codex 只能引用当前需求 Metadata 中存在的库、表和字段;生成结果不会自动批准。", button: "生成测试案例", icon: "wand.and.stars" ) { model.generateCases(requirementID: requirement.id) } } else if pendingCases > 0 { HStack(alignment: .top, spacing: 12) { Image(systemName: "person.crop.circle.badge.checkmark") .font(.title2).foregroundStyle(.orange) VStack(alignment: .leading, spacing: 5) { Text("4. 等待人工审核").font(.headline) Text("还有 \(pendingCases) 条草稿或无效案例。可在“测试案例”逐条审核,或在“Codex 协作”中继续调整和补充。") .foregroundStyle(.secondary) } Spacer() } } else { HStack(alignment: .top, spacing: 12) { Image(systemName: "checkmark.seal.fill") .font(.title2).foregroundStyle(.green) VStack(alignment: .leading, spacing: 5) { Text("审核完成,可以执行").font(.headline) Text("确定性执行器只会运行状态为 approved 的案例。") .foregroundStyle(.secondary) } Spacer() } } } .padding(14) } if model.caseGenerationRequirementID == requirement.id && (model.isGeneratingCases || !model.caseGenerationProgress.isEmpty) { CaseChatProgressCard( events: model.caseGenerationProgress, startedAt: model.caseGenerationStartedAt, finishedAt: model.caseGenerationFinishedAt, isRunning: model.isGeneratingCases, error: model.caseGenerationError, contextPhaseTitle: "读取需求与正式 Metadata", codexPhaseTitle: "Codex 设计并生成测试案例", runningHeadline: "Codex 正在生成测试案例", completedHeadline: "测试案例生成完成", completedDetail: "案例已保存为草稿,请进入测试案例页面进行人工审核" ) } } } private func confirmedScopeDisclosure(extraction: RequirementExtraction) -> some View { DisclosureGroup(isExpanded: $isConfirmedScopeExpanded) { ExtractionScopeView(extraction: extraction) .padding(.top, 14) } label: { HStack(spacing: 11) { Image(systemName: "checkmark.seal.fill") .font(.title3) .foregroundStyle(.green) VStack(alignment: .leading, spacing: 3) { Text("已确认需求范围") .font(.headline) Text("\(extraction.tasks.count) 个 ETL 任务 · \(metadata.count) 张 Metadata 表 · 点击展开查看库、表、字段和规则") .font(.caption) .foregroundStyle(.secondary) } } } .padding(16) .background(Color.green.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) .overlay { RoundedRectangle(cornerRadius: 12) .stroke(Color.green.opacity(0.20), lineWidth: 1) } .animation(.easeInOut(duration: 0.2), value: isConfirmedScopeExpanded) } @ViewBuilder private func workflowRow( title: String, detail: String, button: String, icon: String, action: @escaping () -> Void ) -> some View { HStack(alignment: .center, spacing: 14) { VStack(alignment: .leading, spacing: 5) { Text(title).font(.headline) Text(detail).foregroundStyle(.secondary) } Spacer() Button(action: action) { Label(button, systemImage: icon) } .buttonStyle(.borderedProminent) .disabled(model.isLoading) } } } struct ExtractionScopeView: View { let extraction: RequirementExtraction var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 3) { Text("Agent 提取的需求范围").font(.headline) Text(extraction.requirement_name) .font(.callout) .foregroundStyle(.secondary) } Spacer() Text("\(extraction.tasks.count) 个 ETL 任务") .font(.caption.bold()) .padding(.horizontal, 9) .padding(.vertical, 4) .background(Color.accentColor.opacity(0.10), in: Capsule()) } ForEach(Array(extraction.tasks.enumerated()), id: \.offset) { index, task in ExtractedTaskScopeCard(index: index + 1, task: task) } if !extraction.open_questions.isEmpty { VStack(alignment: .leading, spacing: 9) { Label("待确认问题", systemImage: "exclamationmark.bubble.fill") .font(.subheadline.bold()) .foregroundStyle(.orange) ForEach(Array(extraction.open_questions.enumerated()), id: \.offset) { index, question in HStack(alignment: .top, spacing: 8) { Text("\(index + 1).") .font(.callout.bold().monospacedDigit()) .foregroundStyle(.orange) Text(question).font(.callout) } } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) .overlay { RoundedRectangle(cornerRadius: 10) .stroke(Color.orange.opacity(0.20), lineWidth: 1) } } } .textSelection(.enabled) } } private struct ScopeMappingRow: Identifiable { let id: Int let source: String let transformation: String let target: String } private extension ExtractedTask { var scopeMappingRows: [ScopeMappingRow] { field_mappings.enumerated().map { index, value in guard case .object(let mapping) = value else { return ScopeMappingRow( id: index, source: value.description, transformation: "—", target: "—" ) } let source = mapping["source_field"]?.description ?? "—" let target = mapping["target_field"]?.description ?? "—" let rawTransformation = mapping["transformation"]?.description ?? "" let transformation = rawTransformation == "null" || rawTransformation.isEmpty ? "直接映射" : rawTransformation return ScopeMappingRow( id: index, source: source, transformation: transformation, target: target ) } } } struct ExtractedTaskScopeCard: View { let index: Int let task: ExtractedTask var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 10) { Text(String(format: "%02d", index)) .font(.caption.bold().monospacedDigit()) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(Color.accentColor, in: Circle()) VStack(alignment: .leading, spacing: 2) { Text("ETL 任务").font(.caption).foregroundStyle(.secondary) Text(task.name).font(.headline) } Spacer() } Divider() VStack(alignment: .leading, spacing: 8) { ScopeSectionTitle(title: "数据血缘", icon: "point.3.connected.trianglepath.dotted") Grid(alignment: .topLeading, horizontalSpacing: 18, verticalSpacing: 6) { GridRow { Text("源表 SOURCE").font(.caption.bold()).foregroundStyle(.secondary) Color.clear.frame(width: 24, height: 1) Text("目标表 TARGET").font(.caption.bold()).foregroundStyle(.secondary) } GridRow { ScopeReferenceList(values: task.sources, color: .blue) Image(systemName: "arrow.right") .foregroundStyle(.secondary) .frame(width: 24) ScopeReferenceList(values: task.targets, color: .purple) } } } if !task.scopeMappingRows.isEmpty { VStack(alignment: .leading, spacing: 8) { ScopeSectionTitle(title: "字段映射", icon: "arrow.left.arrow.right") Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text("源字段").font(.caption.bold()).foregroundStyle(.secondary) Text("转换逻辑").font(.caption.bold()).foregroundStyle(.secondary) Text("目标字段").font(.caption.bold()).foregroundStyle(.secondary) } Divider().gridCellColumns(3) ForEach(task.scopeMappingRows) { mapping in GridRow { Text(mapping.source).font(.system(.callout, design: .monospaced)) Text(mapping.transformation) .font(.callout) .foregroundStyle(.secondary) Text(mapping.target).font(.system(.callout, design: .monospaced)) } } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.28), in: RoundedRectangle(cornerRadius: 8)) } } if !task.rules.isEmpty { VStack(alignment: .leading, spacing: 8) { ScopeSectionTitle(title: "加工与校验规则", icon: "list.number") ForEach(Array(task.rules.enumerated()), id: \.offset) { ruleIndex, rule in HStack(alignment: .top, spacing: 9) { Text("R\(ruleIndex + 1)") .font(.caption.bold().monospaced()) .foregroundStyle(.tint) .frame(width: 30, alignment: .leading) Text(rule).font(.callout) } } } } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.28), in: RoundedRectangle(cornerRadius: 12)) .overlay { RoundedRectangle(cornerRadius: 12) .stroke(Color(nsColor: .separatorColor).opacity(0.45), lineWidth: 1) } } } struct ScopeReferenceList: View { let values: [String] let color: Color var body: some View { VStack(alignment: .leading, spacing: 6) { ForEach(values, id: \.self) { value in Text(value) .font(.system(.callout, design: .monospaced).weight(.medium)) .foregroundStyle(color) .padding(.horizontal, 8) .padding(.vertical, 5) .background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 6)) } if values.isEmpty { Text("未识别").font(.callout).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) } } struct ScopeSectionTitle: View { let title: String let icon: String var body: some View { Label(title, systemImage: icon) .font(.subheadline.bold()) .foregroundStyle(.primary) } } struct SummaryCard: View { let title: String let value: Int let icon: String var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.title2).foregroundStyle(.tint) VStack(alignment: .leading, spacing: 2) { Text("\(value)").font(.title2.bold().monospacedDigit()) Text(title).font(.caption).foregroundStyle(.secondary) } Spacer() } .padding(16) .frame(maxWidth: .infinity) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) } } struct FlowStep: View { let title: String let ready: Bool var body: some View { Label(title, systemImage: ready ? "checkmark.circle.fill" : "circle.dashed") .foregroundStyle(ready ? .green : .secondary) .frame(maxWidth: .infinity) } } struct FlowArrow: View { var body: some View { Image(systemName: "chevron.right").foregroundStyle(.tertiary) } } struct RequirementMetadataView: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem let tables: [MetadataTableItem] @State private var selectedTableID: String? private var selectedTable: MetadataTableItem? { tables.first { $0.id == selectedTableID } ?? tables.first } var body: some View { VStack(alignment: .leading, spacing: 16) { if requirement.status == "pending_confirmation" && !tables.isEmpty { HStack(alignment: .top, spacing: 10) { Image(systemName: "sparkles") .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 3) { Text("Agent 探索形成的候选 Metadata").font(.headline) Text("来自真实 SQLite 的只读探查结果。人工确认范围后会再次采集并锁定为正式 Metadata。") .font(.callout) .foregroundStyle(.secondary) } Spacer() StatusBadge(status: "待范围确认") } .padding(12) .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) } if let table = selectedTable { HStack(alignment: .bottom, spacing: 18) { VStack(alignment: .leading, spacing: 6) { Text("当前需求的表").font(.caption).foregroundStyle(.secondary) Picker("数据表", selection: $selectedTableID) { ForEach(tables) { item in Text("\(item.database_name).\(item.name)").tag(Optional(item.id)) } } .labelsHidden() .frame(minWidth: 300) } Spacer() Text("\(table.columns.count) 个字段").foregroundStyle(.secondary) Text("\(table.row_count) 行").foregroundStyle(.secondary) Button { model.refreshMetadata(requirementID: requirement.id) } label: { Label("刷新", systemImage: "arrow.clockwise") } .disabled(model.isLoading) } Text("\(requirement.name) / \(table.database_name).\(table.name)") .font(.title3.bold()) Table(table.columns) { TableColumn("#", value: \.ordinalText).width(42) TableColumn("字段名", value: \.name).width(min: 180, ideal: 260) TableColumn("数据类型", value: \.type).width(min: 110, ideal: 150) TableColumn("可为空", value: \.nullableText).width(75) TableColumn("默认值", value: \.defaultText).width(min: 90, ideal: 120) TableColumn("主键", value: \.primaryKeyText).width(55) } } else { ContentUnavailableView( "该需求尚无 Metadata", systemImage: "cylinder", description: Text("必须先获取当前需求关联表的 Metadata,才能生成案例。") ) } } .padding(22) .onAppear { selectFirstTable() } .onChange(of: tables.map(\.id)) { _, _ in selectFirstTable() } } private func selectFirstTable() { guard !tables.contains(where: { $0.id == selectedTableID }) else { return } selectedTableID = tables.first?.id } } struct RequirementCasesView: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem let cases: [TestCaseItem] let results: [CaseResultItem] let analyses: [FailureAnalysisItem] let reviewEvents: [CaseReviewEventItem] @State private var selectedCaseID: String? @State private var showAgentConfirmation = false private var selectedCase: TestCaseItem? { if let selectedCaseID { return cases.first { $0.id == selectedCaseID } } return cases.first } private var latestResult: CaseResultItem? { guard let caseID = selectedCase?.id else { return nil } return results.first { $0.case_id == caseID } } private var latestAnalysis: FailureAnalysisItem? { guard let resultID = latestResult?.id else { return nil } return analyses.first { $0.result_id == resultID } } private func latestResult(for caseID: String) -> CaseResultItem? { results.first { $0.case_id == caseID } } private func canAnalyze(_ result: CaseResultItem) -> Bool { result.status == "FAIL" || result.status == "ERROR" } private var pendingCount: Int { cases.filter { $0.status == "draft" || $0.status == "invalid" }.count } private var approvedCount: Int { cases.filter { $0.status == "approved" }.count } private var runProgressByCase: [String: TestRunProgressEvent] { guard model.testRunRequirementID == requirement.id else { return [:] } var latest: [String: TestRunProgressEvent] = [:] for event in model.testRunProgress { if let caseID = event.case_id { latest[caseID] = event } } return latest } private var hasRunProgress: Bool { model.testRunRequirementID == requirement.id && (model.isRunningTests || !model.testRunProgress.isEmpty) } private var runCompletedCount: Int { runProgressByCase.values.filter { ["PASS", "FAIL", "ERROR"].contains($0.status) }.count } private var runTotal: Int { model.testRunProgress.compactMap(\.total).max() ?? (model.runningCaseID == nil ? approvedCount : 1) } private var runFinalStatus: String? { model.testRunProgress.last(where: { $0.event == "run_completed" || $0.type == "result" })?.status } private func isActiveRunTarget(_ caseID: String) -> Bool { model.isRunningTests && model.testRunRequirementID == requirement.id && (model.runningCaseID == nil || model.runningCaseID == caseID) } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Label("\(cases.count) 个案例", systemImage: "checklist") Label("已通过 \(approvedCount)", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) if pendingCount > 0 { Label("待审核 \(pendingCount)", systemImage: "person.crop.circle.badge.questionmark") .foregroundStyle(.orange) } if hasRunProgress { Divider().frame(height: 18) if model.isRunningTests { ProgressView().controlSize(.small) Text("执行中 \(runCompletedCount)/\(runTotal)") .font(.callout.bold().monospacedDigit()) .foregroundStyle(.tint) } else { Text("最近执行 \(runCompletedCount)/\(runTotal)") .font(.callout.monospacedDigit()) .foregroundStyle(.secondary) if let runFinalStatus { StatusBadge(status: runFinalStatus) } } } Spacer() Button { model.approveAllCases(requirementID: requirement.id) } label: { Label("批量审核通过", systemImage: "checkmark.seal") } .disabled(pendingCount == 0 || cases.contains { $0.status == "invalid" } || model.isLoading) Button { model.runTests(requirementID: requirement.id) } label: { Label("运行该需求", systemImage: "play.fill") } .disabled(cases.allSatisfy { $0.status != "approved" } || model.isLoading) } HSplitView { List(cases, selection: $selectedCaseID) { item in TestCaseNavigatorRow( item: item, result: latestResult(for: item.id), progress: runProgressByCase[item.id], isActiveRunTarget: isActiveRunTarget(item.id) ) .tag(item.id) } .listStyle(.inset) .frame(minWidth: 260, idealWidth: 300, maxWidth: 340) Group { if let item = selectedCase { ScrollView { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Text(item.id) .font(.caption.bold().monospaced()) .foregroundStyle(.secondary) StatusBadge(status: item.status) if let result = latestResult { StatusBadge(status: result.status) } Spacer() } Text(item.name) .font(.title3.bold()) .fixedSize(horizontal: false, vertical: true) Text("\(item.table_name) · \(item.category) · 案例 v\(item.version)") .font(.caption.monospaced()) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { if item.status != "approved" && item.status != "invalid" { Button { model.approveCase(caseID: item.id) } label: { Label("审核通过", systemImage: "checkmark.seal.fill") } .buttonStyle(.borderedProminent) .disabled(model.isLoading) } if item.status != "rejected" { Button(role: .destructive) { model.rejectCase(caseID: item.id) } label: { Label(item.status == "approved" ? "撤回批准" : "驳回", systemImage: "xmark.circle") } .disabled(model.isLoading) } Button { model.runSingleCase(requirementID: requirement.id, caseID: item.id) } label: { Label("单独重跑", systemImage: "arrow.clockwise.circle.fill") } .buttonStyle(.borderedProminent) .disabled(item.status != "approved" || model.isLoading) Spacer(minLength: 0) } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12)) if let result = latestResult { LatestCaseResultView( result: result, canAnalyze: canAnalyze(result), isAnalyzing: model.analyzingCaseID == item.id ) { showAgentConfirmation = true } } if !item.validation_errors.isEmpty { VStack(alignment: .leading, spacing: 5) { Label("案例未通过确定性校验", systemImage: "exclamationmark.triangle.fill") .font(.subheadline.bold()) .foregroundStyle(.red) ForEach(item.validation_errors, id: \.self) { error in Text("• \(error)").font(.callout) } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) } VStack(alignment: .leading, spacing: 7) { Label("字段范围", systemImage: "text.line.first.and.arrowtriangle.forward") .font(.subheadline.bold()) Text(item.fields.isEmpty ? "表级" : item.fields.joined(separator: ", ")) .font(.callout.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } VStack(alignment: .leading, spacing: 8) { Label("执行 SQL", systemImage: "terminal") .font(.subheadline.bold()) SQLCodeView(sql: item.sql) } VStack(alignment: .leading, spacing: 8) { Label("确定性断言", systemImage: "checkmark.shield") .font(.subheadline.bold()) ForEach(Array(item.assertions.enumerated()), id: \.offset) { _, assertion in Text(assertionText(assertion)) .font(.system(.callout, design: .monospaced)) .textSelection(.enabled) } } let itemReviews = reviewEvents.filter { $0.case_id == item.id } if !itemReviews.isEmpty { VStack(alignment: .leading, spacing: 8) { Label("人工审核记录", systemImage: "person.crop.circle.badge.checkmark") .font(.subheadline.bold()) ForEach(itemReviews.prefix(5)) { review in VStack(alignment: .leading, spacing: 4) { HStack { StatusBadge(status: review.decision) Text("案例 v\(review.case_version)") .font(.caption.bold().monospaced()) } Text(review.created_at) .font(.caption2.monospaced()) .foregroundStyle(.secondary) if let comment = review.comment { Text(comment).font(.caption) } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) } } } if let latestAnalysis { FailureAnalysisView(item: latestAnalysis) } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) } } else { ContentUnavailableView("选择一个测试案例", systemImage: "checklist") } } .frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity) } .background(.quaternary.opacity(0.16), in: RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12)) } .padding(16) .overlay { if cases.isEmpty { ContentUnavailableView( "该需求尚无测试案例", systemImage: "checklist", description: Text("确认 Metadata 和测试范围后再生成案例。") ) } } .onAppear { selectedCaseID = cases.first?.id } .onChange(of: cases.map(\.id)) { _, ids in if let selectedCaseID, ids.contains(selectedCaseID) { return } selectedCaseID = ids.first } .confirmationDialog( "允许 Codex 调查失败根因?", isPresented: $showAgentConfirmation ) { Button("继续调查") { guard let item = selectedCase, let result = latestResult, canAnalyze(result) else { return } model.analyzeFailure(runID: result.run_id, caseID: item.id) } Button("取消", role: .cancel) {} } message: { Text("将该需求版本、Metadata、测试 SQL、断言和失败样例交给当前 Codex CLI 配置的模型服务分析。只调查并保存结论,不会自动修改数据。") } } private func assertionText(_ assertion: CaseAssertion) -> String { let actual = assertion.actual ?? "result" if let expected = assertion.expected { return "\(assertion.type): \(actual) → \(expected.description)" } if let minimum = assertion.minimum, let maximum = assertion.maximum { return "\(assertion.type): \(minimum) ≤ \(actual) ≤ \(maximum)" } return "\(assertion.type): \(actual)" } } struct TestCaseNavigatorRow: View { let item: TestCaseItem let result: CaseResultItem? let progress: TestRunProgressEvent? let isActiveRunTarget: Bool var body: some View { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 7) { Text(item.id) .font(.caption.bold().monospaced()) Spacer() if let progress { CaseExecutionStatus( status: progress.status, durationMS: progress.duration_ms ) } else if isActiveRunTarget { CaseExecutionStatus(status: "PENDING", durationMS: nil) } else if let result { CaseExecutionStatus(status: result.status, durationMS: result.duration_ms) } else { StatusBadge(status: item.status) } } Text(item.name) .font(.callout.weight(.semibold)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) Text(item.table_name) .font(.caption.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) Text(item.category) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) } } struct CaseExecutionStatus: View { let status: String let durationMS: Int? private var color: Color { switch status { case "PASS": return .green case "FAIL": return .red case "ERROR": return .orange case "RUNNING": return .accentColor default: return .secondary } } private var title: String { switch status { case "PENDING": return "等待" case "RUNNING": return "执行中" default: return status } } var body: some View { HStack(spacing: 5) { if status == "RUNNING" { ProgressView().controlSize(.mini) } else { Image(systemName: iconName) .font(.caption) } Text(title) .font(.caption.bold().monospacedDigit()) if let durationMS { Text(durationMS >= 1_000 ? String(format: "%.2fs", Double(durationMS) / 1_000) : "\(durationMS)ms") .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } } .foregroundStyle(color) } private var iconName: String { switch status { case "PASS": return "checkmark.circle.fill" case "FAIL": return "xmark.circle.fill" case "ERROR": return "exclamationmark.triangle.fill" default: return "circle.dotted" } } } struct CaseAgentConversationView: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem let cases: [TestCaseItem] let messages: [CaseAgentMessageItem] @State private var draftMessage = "" private var canSend: Bool { !cases.isEmpty && requirement.metadata_complete && !draftMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !model.isLoading && !model.isChattingAboutCases } var body: some View { VStack(spacing: 0) { HStack(alignment: .top, spacing: 12) { Image(systemName: "sparkles") .font(.title2) .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 4) { Text("与 Codex 协作调整测试案例").font(.headline) Text("可要求修改 SQL、断言、字段范围,或补充边界、分布、波动、全量与增量案例。新增或修改内容一律保存为草稿,必须重新人工审核。") .foregroundStyle(.secondary) } Spacer() } .padding(18) .background(.bar) ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { if messages.isEmpty && model.pendingCaseChatMessage == nil && !model.isChattingAboutCases && model.caseChatProgress.isEmpty { ContentUnavailableView( "尚无沟通记录", systemImage: "bubble.left.and.bubble.right", description: Text(cases.isEmpty ? "请先生成测试案例。" : "描述你希望调整或补充的案例。") ) .frame(minHeight: 260) } ForEach(messages) { message in CaseAgentMessageBubble(message: message) .id(message.id) } if let pendingMessage = model.pendingCaseChatMessage { PendingCaseChatMessageBubble(content: pendingMessage) .id("pending-case-chat-message") } if model.isChattingAboutCases || !model.caseChatProgress.isEmpty { CaseChatProgressCard( events: model.caseChatProgress, startedAt: model.caseChatStartedAt, finishedAt: model.caseChatFinishedAt, isRunning: model.isChattingAboutCases, error: model.caseChatError ) .id("case-chat-progress") } } .padding(20) } .onChange(of: messages.map(\.id)) { _, ids in if let last = ids.last { proxy.scrollTo(last, anchor: .bottom) } } .onChange(of: model.caseChatProgress.count) { _, _ in withAnimation { proxy.scrollTo("case-chat-progress", anchor: .bottom) } } } Divider() VStack(alignment: .leading, spacing: 10) { TextEditor(text: $draftMessage) .font(.body) .frame(minHeight: 74, maxHeight: 130) .padding(6) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 10)) HStack { Label("发送时会向本机 Codex CLI 提供需求版本、Metadata、当前案例和本需求沟通历史。", systemImage: "lock.shield") .font(.caption) .foregroundStyle(.secondary) Spacer() Button { let message = draftMessage.trimmingCharacters(in: .whitespacesAndNewlines) guard !message.isEmpty else { return } draftMessage = "" model.chatAboutCases(requirementID: requirement.id, message: message) } label: { Label( model.isChattingAboutCases ? "处理中" : "发送给 Codex", systemImage: model.isChattingAboutCases ? "sparkles" : "paperplane.fill" ) } .buttonStyle(.borderedProminent) .disabled(!canSend) } } .padding(16) .background(.bar) } } } struct PendingCaseChatMessageBubble: View { let content: String var body: some View { HStack { Spacer(minLength: 80) VStack(alignment: .leading, spacing: 6) { Label("你", systemImage: "person.fill") .font(.caption.bold()) Text(content).textSelection(.enabled) Text("已发送") .font(.caption2) .foregroundStyle(.secondary) } .padding(12) .background(Color.accentColor.opacity(0.13), in: RoundedRectangle(cornerRadius: 12)) } } } enum CaseChatPhaseState { case pending, running, completed, failed } struct CaseChatProgressCard: View { let events: [CaseChatProgressEvent] let startedAt: Date? let finishedAt: Date? let isRunning: Bool let error: String? let contextPhaseTitle: String let codexPhaseTitle: String let runningHeadline: String let completedHeadline: String let completedDetail: String @State private var showsActivity = true init( events: [CaseChatProgressEvent], startedAt: Date?, finishedAt: Date?, isRunning: Bool, error: String?, contextPhaseTitle: String = "读取需求、Metadata 与案例历史", codexPhaseTitle: String = "Codex 分析并生成案例变更", runningHeadline: String = "Codex 正在处理", completedHeadline: String = "案例调整已完成", completedDetail: String = "所有变更均以草稿保存,等待人工审核" ) { self.events = events self.startedAt = startedAt self.finishedAt = finishedAt self.isRunning = isRunning self.error = error self.contextPhaseTitle = contextPhaseTitle self.codexPhaseTitle = codexPhaseTitle self.runningHeadline = runningHeadline self.completedHeadline = completedHeadline self.completedDetail = completedDetail } private var phases: [(id: String, title: String, icon: String)] { [ ("context", contextPhaseTitle, "doc.text.magnifyingglass"), ("codex", codexPhaseTitle, "sparkles"), ("validation", "确定性校验 SQL、字段与断言", "checkmark.shield"), ("persistence", "版本化保存待审核草稿", "tray.and.arrow.down"), ] } private var completedPhaseCount: Int { phases.filter { phaseState(for: $0.id) == .completed }.count } private var headline: String { if error != nil { return "处理失败" } if isRunning { return runningHeadline } return completedHeadline } private var headlineColor: Color { if error != nil { return .red } return isRunning ? .accentColor : .green } private func phaseState(for phase: String) -> CaseChatPhaseState { let phaseEvents = events.filter { $0.phase == phase } if phaseEvents.contains(where: { $0.status == "failed" }) { return .failed } if phaseEvents.contains(where: { $0.status == "completed" }) { return .completed } if !phaseEvents.isEmpty { return .running } return .pending } private func elapsedText(at date: Date) -> String { guard let startedAt else { return "0:00" } let end = finishedAt ?? date let seconds = max(0, Int(end.timeIntervalSince(startedAt))) return String(format: "%d:%02d", seconds / 60, seconds % 60) } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 10) .fill(headlineColor.opacity(0.12)) Image(systemName: error != nil ? "exclamationmark.triangle.fill" : "sparkles") .font(.title3) .foregroundStyle(headlineColor) } .frame(width: 38, height: 38) VStack(alignment: .leading, spacing: 3) { Text(headline).font(.headline) Text(isRunning ? "页面可继续浏览,任务会在此处实时更新" : (error ?? completedDetail)) .font(.caption) .foregroundStyle(error == nil ? Color.secondary : Color.red) } Spacer() TimelineView(.periodic(from: .now, by: 1)) { context in Label(elapsedText(at: context.date), systemImage: "clock") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } ProgressView(value: Double(completedPhaseCount), total: Double(phases.count)) .tint(headlineColor) VStack(alignment: .leading, spacing: 11) { ForEach(phases, id: \.id) { phase in CaseChatPhaseRow( title: phase.title, icon: phase.icon, state: phaseState(for: phase.id) ) } } if !events.isEmpty { Divider() DisclosureGroup(isExpanded: $showsActivity) { VStack(alignment: .leading, spacing: 9) { ForEach(Array(events.suffix(8))) { event in HStack(alignment: .top, spacing: 8) { Circle() .fill(event.status == "failed" ? Color.red : Color.secondary.opacity(0.55)) .frame(width: 5, height: 5) .padding(.top, 6) VStack(alignment: .leading, spacing: 2) { Text(event.title).font(.caption.bold()) Text(event.detail) .font(.caption) .foregroundStyle(.secondary) } } } } .padding(.top, 10) } label: { Label("实时活动 · (events.count)", systemImage: "waveform.path.ecg") .font(.caption.bold()) } } } .padding(16) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .overlay { RoundedRectangle(cornerRadius: 14) .stroke(headlineColor.opacity(0.22), lineWidth: 1) } } } struct CaseChatPhaseRow: View { let title: String let icon: String let state: CaseChatPhaseState private var color: Color { switch state { case .pending: return .secondary case .running: return .accentColor case .completed: return .green case .failed: return .red } } var body: some View { HStack(spacing: 10) { Group { switch state { case .running: ProgressView().controlSize(.small) case .completed: Image(systemName: "checkmark.circle.fill") case .failed: Image(systemName: "xmark.circle.fill") case .pending: Image(systemName: "circle") } } .frame(width: 18, height: 18) .foregroundStyle(color) Image(systemName: icon) .frame(width: 18) .foregroundStyle(color) Text(title) .font(.subheadline) .foregroundStyle(state == .pending ? .secondary : .primary) Spacer() if state == .running { Text("进行中").font(.caption).foregroundStyle(color) } } } } struct CaseAgentMessageBubble: View { let message: CaseAgentMessageItem var body: some View { HStack { if message.role == "user" { Spacer(minLength: 80) } VStack(alignment: .leading, spacing: 6) { Label( message.role == "user" ? "你" : "Codex", systemImage: message.role == "user" ? "person.fill" : "sparkles" ) .font(.caption.bold()) Text(message.content) .textSelection(.enabled) Text(message.created_at) .font(.caption2.monospaced()) .foregroundStyle(.secondary) } .padding(12) .background( message.role == "user" ? Color.accentColor.opacity(0.13) : Color.secondary.opacity(0.10), in: RoundedRectangle(cornerRadius: 12) ) if message.role != "user" { Spacer(minLength: 80) } } } } struct LatestCaseResultView: View { let result: CaseResultItem let canAnalyze: Bool let isAnalyzing: Bool let analyze: () -> Void var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Text("最近结果").font(.subheadline.bold()) StatusBadge(status: result.status) Spacer() } Text("运行 \(result.run_id) · \(result.duration_ms) ms") .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) Text(result.created_at) .font(.caption2.monospaced()) .foregroundStyle(.tertiary) if let error = result.error_message, !error.isEmpty { Text(error) .font(.caption) .foregroundStyle(.orange) .lineLimit(4) } if canAnalyze { Button(action: analyze) { Label(isAnalyzing ? "正在调查" : "Codex 调查根因", systemImage: "sparkles") } .buttonStyle(.borderedProminent) .disabled(isAnalyzing) } } .padding(14) .background(result.status == "FAIL" ? Color.red.opacity(0.07) : Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 10)) } } struct FailureAnalysisView: View { let item: FailureAnalysisItem var body: some View { VStack(alignment: .leading, spacing: 14) { Divider() HStack { Label("Codex 根因分析", systemImage: "sparkles") .font(.headline) Spacer() Text("\(item.analysis.suspected_layer) · 置信度 \(item.analysis.confidence)") .font(.caption.monospaced()) .foregroundStyle(.secondary) } Text(item.analysis.summary).font(.subheadline.bold()) AnalysisSection(title: "根因判断", items: [item.analysis.root_cause]) AnalysisSection(title: "证据", items: item.analysis.evidence) AnalysisSection(title: "建议", items: item.analysis.recommendations) if !item.analysis.validation_sql.isEmpty { Text("只读验证 SQL").font(.subheadline.bold()) ForEach(Array(item.analysis.validation_sql.enumerated()), id: \.offset) { _, sql in Text(sql) .font(.system(.callout, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) } } Text("分析时间 \(item.created_at)") .font(.caption) .foregroundStyle(.tertiary) } .padding(14) .background(Color.accentColor.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)) } } struct AnalysisSection: View { let title: String let items: [String] var body: some View { if !items.isEmpty { VStack(alignment: .leading, spacing: 6) { Text(title).font(.subheadline.bold()) ForEach(Array(items.enumerated()), id: \.offset) { _, item in HStack(alignment: .top, spacing: 7) { Text("•").foregroundStyle(.secondary) Text(item).textSelection(.enabled) } } } } } } struct RequirementRunsView: View { let requirement: RequirementItem let runs: [RunItem] var body: some View { VStack(alignment: .leading, spacing: 12) { Text("\(requirement.name) 的运行记录").font(.headline) Table(runs) { TableColumn("运行 ID", value: \.id).width(min: 150) TableColumn("批次", value: \.batch_id).width(min: 120) TableColumn("状态", value: \.status).width(90) TableColumn("开始时间", value: \.started_at).width(min: 190) } } .padding(20) .overlay { if runs.isEmpty { ContentUnavailableView("该需求尚无运行记录", systemImage: "play.circle") } } } } struct RequirementMetricsView: View { let requirement: RequirementItem let metrics: [MetricItem] var body: some View { List(metrics) { metric in HStack { VStack(alignment: .leading, spacing: 4) { Text("\(metric.table_name) · \(metric.metric_type)") Text("需求 \(requirement.id) · 批次 \(metric.batch_id)") .font(.caption).foregroundStyle(.secondary) } Spacer() Text(metric.metric_value.map { String(format: "%.0f", $0) } ?? "—") .font(.title3.monospacedDigit()) } .padding(.vertical, 5) } .overlay { if metrics.isEmpty { ContentUnavailableView("该需求尚无历史指标", systemImage: "chart.xyaxis.line") } } } } struct RequirementReportsView: View { @EnvironmentObject private var model: AppModel let requirement: RequirementItem let runs: [RunItem] let reports: [ReportItem] @State private var selectedRunID: String? private var selectedRun: RunItem? { if let selectedRunID { return runs.first { $0.id == selectedRunID } } return runs.first } private var selectedReport: ReportItem? { guard let runID = selectedRun?.id else { return nil } return reports.first { $0.run_id == runID } } var body: some View { VStack(alignment: .leading, spacing: 14) { if let run = selectedRun { HStack(alignment: .bottom, spacing: 16) { VStack(alignment: .leading, spacing: 5) { Text("测试报告").font(.title2.bold()) Text(requirement.name).foregroundStyle(.secondary) } Spacer(minLength: 20) VStack(alignment: .leading, spacing: 5) { Text("运行批次").font(.caption).foregroundStyle(.secondary) Picker("运行批次", selection: $selectedRunID) { ForEach(runs) { item in Text("\(item.batch_id) · \(item.id) · \(item.status)").tag(Optional(item.id)) } } .labelsHidden() .frame(minWidth: 280, idealWidth: 380, maxWidth: 460) } } HStack(spacing: 10) { StatusBadge(status: run.status) Text(run.id).font(.caption.monospaced()).foregroundStyle(.secondary) if let report = selectedReport { Label("已生成报告", systemImage: "doc.text.fill") .font(.caption).foregroundStyle(.green) Text(report.created_at).font(.caption).foregroundStyle(.secondary) } else { Text("尚未生成报告").font(.caption).foregroundStyle(.secondary) } Spacer() if let report = selectedReport { Button { NSWorkspace.shared.activateFileViewerSelecting([ URL(fileURLWithPath: report.file_path) ]) } label: { Label("Finder", systemImage: "folder") } } Button { model.generateReport(runID: run.id) } label: { Label(selectedReport == nil ? "生成报告" : "重新生成", systemImage: "doc.badge.gearshape") } .buttonStyle(.borderedProminent) .disabled(model.isLoading) } Divider() if let report = selectedReport { ScrollView { Text(report.content) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10)) } } else { ContentUnavailableView( "该运行尚未生成报告", systemImage: "doc.text", description: Text("报告将汇总案例状态、断言、失败证据和可追溯信息。") ) } } else { ContentUnavailableView( "该需求尚无运行结果", systemImage: "play.slash", description: Text("先运行测试案例,再生成报告。") ) } } .padding(22) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { selectedRunID = runs.first?.id } .onChange(of: runs.map(\.id)) { _, ids in if let selectedRunID, ids.contains(selectedRunID) { return } self.selectedRunID = ids.first } } }