2066 lines
85 KiB
Swift
2066 lines
85 KiB
Swift
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<Void, Never>?
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|