From f7edceee30c086ef3e10fd4db44fdd2cb67684e6 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 24 Sep 2026 19:37:16 +0800 Subject: [PATCH] feat(ng2): restyle diagnostics and move export to header (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要卡(代理状态/活动目标,来自 DiagnosticsWorkflow.summary 安全投影)+ 日志卡(来源 segmented + 复制 + 等宽日志区与空态)+ 脱敏说明行;状态摘要 侧栏删除;导出诊断迁分区头(DiagnosticReportExportAction 入口不变,「诊断 已导出」alert 移至壳层);分区包装视图归位 WorkspaceDestinations.swift。 地图 #52 · 票 #58;五分区 hermetic 冒烟验收通过,task build + test 全绿。 --- ShadowsocksX-NG2/App/DiagnosticsView.swift | 221 +++++++++--------- ShadowsocksX-NG2/App/MainWindowView.swift | 146 ++++-------- .../App/WorkspaceDestinations.swift | 92 ++++++++ 3 files changed, 239 insertions(+), 220 deletions(-) create mode 100644 ShadowsocksX-NG2/App/WorkspaceDestinations.swift diff --git a/ShadowsocksX-NG2/App/DiagnosticsView.swift b/ShadowsocksX-NG2/App/DiagnosticsView.swift index 49824901..7c4d0aab 100644 --- a/ShadowsocksX-NG2/App/DiagnosticsView.swift +++ b/ShadowsocksX-NG2/App/DiagnosticsView.swift @@ -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) - } } diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 16de74e4..c28ad071 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -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) -> 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 { diff --git a/ShadowsocksX-NG2/App/WorkspaceDestinations.swift b/ShadowsocksX-NG2/App/WorkspaceDestinations.swift new file mode 100644 index 00000000..e80c3298 --- /dev/null +++ b/ShadowsocksX-NG2/App/WorkspaceDestinations.swift @@ -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) -> 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 ?? "") + } + } +}