datatestool/macos/Sources/DataTestApp/ContentView.swift

2066 lines
85 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}