feat(ng2): restyle diagnostics and move export to header (#58)

摘要卡(代理状态/活动目标,来自 DiagnosticsWorkflow.summary 安全投影)+
日志卡(来源 segmented + 复制 + 等宽日志区与空态)+ 脱敏说明行;状态摘要
侧栏删除;导出诊断迁分区头(DiagnosticReportExportAction 入口不变,「诊断
已导出」alert 移至壳层);分区包装视图归位 WorkspaceDestinations.swift。

地图 #52 · 票 #58;五分区 hermetic 冒烟验收通过,task build + test 全绿。
This commit is contained in:
Qiu Yuzhou
2026-09-24 19:37:16 +08:00
parent 1d34c0b3b3
commit f7edceee30
3 changed files with 239 additions and 220 deletions
+106 -115
View File
@@ -1,15 +1,15 @@
import SwiftUI
/// 主窗口诊断区(spec #21 D11,issue #34/#43):日志查看器(GUI 事件流实时
/// 呈现 + wrapper 收敛日志尾部,均可复制)与显式触发的脱敏诊断导出。事实
/// 采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43 的唯一
/// UI-facing seam);本视图只负责生命周期触发、呈现与用户触发动作;剪贴板
/// 和报告文件效果经显式 adapter 注入,文件实际写入成功后才登记导出完成事件。
/// 主窗口诊断区(spec #21 D11,issue #34/#43,地图 #52 票 #58):按原型重排
/// 为状态摘要卡(代理状态、活动目标)+ 日志卡(GUI 事件流实时呈现 + wrapper
/// 收敛日志尾部,来源切换 segmented、可复制)+ 底部脱敏说明行。导出诊断动作
/// 在分区头动作槽位(由主窗口壳提供,保持 DiagnosticReportExportAction 既有
/// 入口)。事实采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43
/// 的唯一 UI-facing seam);本视图只负责生命周期触发、呈现与复制动作。
struct DiagnosticsView: View {
@ObservedObject var diagnostics: DiagnosticsWorkflow
let errors: ErrorAlertPresenter
let clipboard: any TextClipboard
let reportExporter: any DiagnosticReportExporter
enum LogSource: String, CaseIterable, Identifiable {
case guiEvents
@@ -28,59 +28,96 @@ struct DiagnosticsView: View {
}
@State private var source: LogSource = .guiEvents
@State private var exportedPath: String?
var body: some View {
VStack(spacing: 0) {
header
Divider()
sourcePicker
Divider()
logPane
ScrollView {
VStack(alignment: .leading, spacing: 20) {
summaryRow
logCard
Text(
"诊断导出只包含状态、存在性、权限和脱敏元数据,不包含密码、插件参数、"
+ "订阅 URL 或运行时 JSON。"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 960, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 28)
.padding(.trailing, 32)
.padding(.top, 24)
.padding(.bottom, 36)
}
.task { await diagnostics.readWhileActive() }
.alert(
"诊断已导出",
isPresented: Binding(
get: { exportedPath != nil },
set: { if !$0 { exportedPath = nil } })
) {
Button("好", role: .cancel) {}
} message: {
Text(exportedPath ?? "")
}
// MARK: - 状态摘要
private var summaryRow: some View {
HStack(alignment: .top, spacing: 14) {
statCard("代理状态", value: diagnostics.summary.proxyState.label)
statCard("活动目标", value: diagnostics.summary.hasActiveTarget ? "已设置" : "未设置")
}
}
// MARK: - 头部动作
private var header: some View {
HStack {
Text("诊断").font(.headline)
Spacer()
Button {
copyToClipboard(activeText)
} label: {
Label("复制", systemImage: "doc.on.doc")
}
.disabled(activeText.isEmpty)
Button {
exportReport()
} label: {
Label("导出诊断…", systemImage: "square.and.arrow.up")
}
private func statCard(_ label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.callout.weight(.semibold))
.lineLimit(2)
.help(value)
}
.padding(8)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
.background.secondary,
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.quaternary)
)
}
private var sourcePicker: some View {
Picker("日志来源", selection: $source) {
ForEach(LogSource.allCases) { source in
Text(source.label).tag(source)
// MARK: - 日志卡
private var logCard: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 12) {
Picker("日志来源", selection: $source) {
ForEach(LogSource.allCases) { source in
Text(source.label).tag(source)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 320)
Spacer(minLength: 12)
Button {
copyToClipboard(activeText)
} label: {
Label("复制", systemImage: "doc.on.doc")
}
.disabled(activeText.isEmpty)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
logBox
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
.background.secondary,
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.quaternary)
)
}
/// 当前日志来源的文本与空态提示(呈现与复制共用同一来源,story 21)。
@@ -99,33 +136,32 @@ struct DiagnosticsView: View {
}
}
@ViewBuilder
private var logPane: some View {
logText(activeLog.text, emptyMessage: activeLog.emptyMessage)
}
private func logText(_ text: String, emptyMessage: String) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
if text.isEmpty {
Text(emptyMessage)
.foregroundStyle(.secondary)
.padding(12)
} else {
Text(text)
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
}
}
}
private var activeText: String {
activeLog.text
}
private var logBox: some View {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
if activeText.isEmpty {
Text(activeLog.emptyMessage)
.font(.callout)
.foregroundStyle(.secondary)
.padding(16)
} else {
Text(activeText)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minHeight: 300, maxHeight: 390)
}
private func copyToClipboard(_ text: String) {
do {
try clipboard.write(text)
@@ -133,49 +169,4 @@ struct DiagnosticsView: View {
errors.present(error)
}
}
// MARK: - 显式导出(唯一导出入口)
private func exportReport() {
switch DiagnosticReportExportAction(
diagnostics: diagnostics, exporter: reportExporter
).perform()
{
case .preparationFailed(let failure):
errors.present(failure)
case .cancelled:
break
case .saved(let url):
diagnostics.noteExportCompleted()
exportedPath = url.path
case .exportFailed(let failure):
errors.present(failure)
}
}
}
/// 诊断分区侧栏摘要(D11,story 25):与详情共用 DiagnosticsWorkflow 的同一
/// 份安全摘要 projection。
struct DiagnosticsSummarySidebar: View {
@ObservedObject var workflow: DiagnosticsWorkflow
var body: some View {
List {
Section("状态摘要") {
LabeledContent("代理状态", value: workflow.summary.proxyState.label)
LabeledContent(
"活动目标", value: workflow.summary.hasActiveTarget ? "已设置" : "未设置")
}
Section {
Text(
"日志可在右侧查看与复制;「导出诊断」生成仅含脱敏元数据的报告"
+ "(不含密码、插件参数、钥匙串值、订阅 URL、服务器地址与备注),"
+ "供报障时提供。"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.listStyle(.sidebar)
}
}
+41 -105
View File
@@ -1,10 +1,10 @@
import SwiftUI
/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与
/// 底部常驻代理状态卡;详情区按 route destination 承载既有五个分区视图,
/// 顶部是系统设置风格的分区大标题(动作槽位留给各分区票填充)。路由状态
/// 仍由 WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制
/// 工作流的整体 snapshot(issue #47),与状态菜单同一口径。
/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图,顶部是
/// 系统设置风格的分区大标题(动作槽位留给各分区票填充)。路由状态仍由
/// WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制工作流
/// 的整体 snapshot(issue #47),与状态菜单同一口径。
struct MainWindowView: View {
@ObservedObject var route: WorkspaceRoute
@ObservedObject var workflow: CatalogWorkflow
@@ -17,8 +17,9 @@ struct MainWindowView: View {
let diagnosticReportExporter: any DiagnosticReportExporter
@State private var selection: NodeID?
/// 订阅分区头动作(票 #56):添加订阅 sheet 由壳持有。
/// 分区头动作(票 #56/#58):添加订阅 sheet 与诊断导出的呈现状态由壳持有。
@State private var showAddSubscription = false
@State private var exportedDiagnosticsPath: String?
@StateObject private var shellActionErrors = ErrorAlertPresenter()
var body: some View {
@@ -47,6 +48,16 @@ struct MainWindowView: View {
} message: {
Text(shellActionErrors.message ?? "")
}
.alert(
"诊断已导出",
isPresented: Binding(
get: { exportedDiagnosticsPath != nil },
set: { if !$0 { exportedDiagnosticsPath = nil } })
) {
Button("好", role: .cancel) {}
} message: {
Text(exportedDiagnosticsPath ?? "")
}
}
// MARK: - 分区标题与动作槽位
@@ -102,11 +113,34 @@ struct MainWindowView: View {
.keyboardShortcut(.defaultAction)
.disabled(!settingsWorkflow.canSave)
}
case .home, .servers, .diagnostics:
case .diagnostics:
Button("导出诊断…", systemImage: "square.and.arrow.up") {
exportDiagnostics()
}
case .home, .servers:
EmptyView()
}
}
/// 诊断导出(票 #58):沿用 DiagnosticReportExportAction 既有入口;文件写
/// 入成功后才登记导出完成事件。
private func exportDiagnostics() {
switch DiagnosticReportExportAction(
diagnostics: diagnostics, exporter: diagnosticReportExporter
).perform()
{
case .preparationFailed(let failure):
shellActionErrors.present(failure)
case .cancelled:
break
case .saved(let url):
diagnostics.noteExportCompleted()
exportedDiagnosticsPath = url.path
case .exportFailed(let failure):
shellActionErrors.present(failure)
}
}
// MARK: - 分区承载
@ViewBuilder
@@ -134,8 +168,7 @@ struct MainWindowView: View {
case .diagnostics:
WorkspaceDiagnosticsView(
diagnostics: diagnostics,
clipboard: clipboard,
reportExporter: diagnosticReportExporter)
clipboard: clipboard)
}
}
@@ -146,103 +179,6 @@ struct MainWindowView: View {
}
}
// MARK: - 分区包装(票 #53)
private struct WorkspaceHomeView: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
let clipboard: any TextClipboard
let onManageServers: () -> Void
@StateObject private var errors = ErrorAlertPresenter()
@State private var showLegacyImportSheet = false
@State private var didOfferLegacyImport = false
var body: some View {
HomeView(
workflow: workflow,
control: control,
clipboard: clipboard,
onManageServers: onManageServers,
errors: errors
)
.sheet(isPresented: $showLegacyImportSheet) {
LegacyImportSheet(workflow: workflow)
}
.onAppear {
guard !didOfferLegacyImport, workflow.legacyImportState.shouldOffer else { return }
didOfferLegacyImport = true
showLegacyImportSheet = true
}
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
private struct WorkspaceSubscriptionsView: View {
@ObservedObject var workflow: CatalogWorkflow
let onNodesRemoved: (Set<NodeID>) -> Void
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
SubscriptionsView(
workflow: workflow,
errors: errors,
onNodesRemoved: onNodesRemoved
)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
private struct WorkspaceDiagnosticsView: View {
@ObservedObject var diagnostics: DiagnosticsWorkflow
let clipboard: any TextClipboard
let reportExporter: any DiagnosticReportExporter
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
HStack(spacing: 0) {
DiagnosticsSummarySidebar(workflow: diagnostics)
.frame(width: 220)
Divider()
DiagnosticsView(
diagnostics: diagnostics,
errors: errors,
clipboard: clipboard,
reportExporter: reportExporter
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
}
extension WorkspaceDestination {
var label: String {
switch self {
@@ -0,0 +1,92 @@
import SwiftUI
/// 主窗口各 destination 的包装视图(票 #53/#54):负责每个分区的共享
/// alert/sheet 生命周期,行为委托给既有分区视图。
struct WorkspaceHomeView: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
let clipboard: any TextClipboard
let onManageServers: () -> Void
@StateObject private var errors = ErrorAlertPresenter()
@State private var showLegacyImportSheet = false
@State private var didOfferLegacyImport = false
var body: some View {
HomeView(
workflow: workflow,
control: control,
clipboard: clipboard,
onManageServers: onManageServers,
errors: errors
)
.sheet(isPresented: $showLegacyImportSheet) {
LegacyImportSheet(workflow: workflow)
}
.onAppear {
guard !didOfferLegacyImport, workflow.legacyImportState.shouldOffer else { return }
didOfferLegacyImport = true
showLegacyImportSheet = true
}
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
struct WorkspaceSubscriptionsView: View {
@ObservedObject var workflow: CatalogWorkflow
let onNodesRemoved: (Set<NodeID>) -> Void
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
SubscriptionsView(
workflow: workflow,
errors: errors,
onNodesRemoved: onNodesRemoved
)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
struct WorkspaceDiagnosticsView: View {
@ObservedObject var diagnostics: DiagnosticsWorkflow
let clipboard: any TextClipboard
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
// 导出诊断动作在分区头动作槽位(票 #58);本包装只承载复制错误呈现。
DiagnosticsView(
diagnostics: diagnostics,
errors: errors,
clipboard: clipboard
)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}