diff --git a/ShadowsocksX-NG2/App/DiagnosticsView.swift b/ShadowsocksX-NG2/App/DiagnosticsView.swift index a5e451c6..722e945d 100644 --- a/ShadowsocksX-NG2/App/DiagnosticsView.swift +++ b/ShadowsocksX-NG2/App/DiagnosticsView.swift @@ -2,15 +2,17 @@ import SwiftUI /// 主窗口诊断区(spec #21 D11,issue #34/#43,地图 #52 票 #58):按原型重排 /// 为状态摘要卡(代理状态、活动目标)+ 日志卡(GUI 事件流实时呈现 + wrapper -/// 收敛日志尾部,来源切换 segmented、可复制)+ 底部脱敏说明行。导出诊断动作 -/// 在窗口工具栏(由主窗口壳提供,保持 DiagnosticReportExportAction 既有 -/// 入口)。事实采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43 -/// 的唯一 UI-facing seam);本视图只负责生命周期触发、呈现与复制动作。 +/// 收敛日志尾部,来源切换 segmented、可复制)+ 底部脱敏说明行。导出诊断由 +/// 本页自挂工具栏(呈现于窗口工具栏右端),沿用 DiagnosticReportExportAction +/// 既有入口,成功 alert 与错误弹窗均在本视图挂载。事实采样、轮询代际与报告 +/// 准备全部经 DiagnosticsWorkflow(issue #43 的唯一 UI-facing seam);本视图 +/// 只负责生命周期触发、呈现与复制动作。 struct DiagnosticsView: View { @ObservedObject var diagnostics: DiagnosticsWorkflow /// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。 @StateObject private var errors = ErrorAlertPresenter() let clipboard: any TextClipboard + let exporter: any DiagnosticReportExporter enum LogSource: String, CaseIterable, Identifiable { case guiEvents @@ -29,6 +31,7 @@ struct DiagnosticsView: View { } @State private var source: LogSource = .guiEvents + @State private var exportedDiagnosticsPath: String? var body: some View { ScrollView { @@ -51,6 +54,42 @@ struct DiagnosticsView: View { } .task { await diagnostics.readWhileActive() } .presentingErrors(errors) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("导出诊断…", systemImage: "square.and.arrow.up") { + exportDiagnostics() + } + } + } + .alert( + "诊断已导出", + isPresented: Binding( + get: { exportedDiagnosticsPath != nil }, + set: { if !$0 { exportedDiagnosticsPath = nil } }) + ) { + Button("好", role: .cancel) {} + } message: { + Text(exportedDiagnosticsPath ?? "") + } + } + + /// 诊断导出(票 #58):沿用 DiagnosticReportExportAction 既有入口;文件写 + /// 入成功后才登记导出完成事件。 + private func exportDiagnostics() { + switch DiagnosticReportExportAction( + diagnostics: diagnostics, exporter: exporter + ).perform() + { + case .preparationFailed(let failure): + errors.present(failure) + case .cancelled: + break + case .saved(let url): + diagnostics.noteExportCompleted() + exportedDiagnosticsPath = url.path + case .exportFailed(let failure): + errors.present(failure) + } } // MARK: - 状态摘要 diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 2de1e545..683db3fe 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -21,24 +21,27 @@ private enum WorkspaceSheet: String, Identifiable { /// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与 /// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区名即 /// 窗口标题(navigationTitle 绑定 route destination,ADR 0016 的 scene 版 -/// 窗口原生呈现);订阅/诊断的页级动作经 .toolbar 桥接进窗口工具栏右端 -/// (票 #56/#58 的槽位仅呈现位置变化),设置的表单级提交动作在其视图内容 -/// 顶部(见 SettingsView)。路由状态仍由 WorkspaceRoute 持有;代理状态卡的 +/// 窗口原生呈现);页级动作由各分区视图自挂 toolbar(呈现于窗口工具栏右 +/// 端,与服务器分区同法,票 #56/#58 的槽位仅呈现位置),设置的表单级提交 +/// 动作在其视图内容顶部(见 SettingsView)。路由状态仍由 WorkspaceRoute +/// 持有;代理状态卡的 /// 状态、摘要与配色政策全部经 StatusCardModel 从代理控制工作流的整体 /// snapshot 派生(issue #47,与状态菜单同口径),本壳只挂 StatusCardView; /// 服务器分区的活动目标标记同源(snapshot.activeTarget)。 struct MainWindowView: View { - @ObservedObject var rulesWorkflow: RulesWorkflow + /// 纯转发的分区工作流与控制器:壳 body 不读它们(分区视图自行观察), + /// 用 let 转发,壳不重复订阅它们的发布。 + let rulesWorkflow: RulesWorkflow @ObservedObject var route: WorkspaceRoute @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow - @ObservedObject var diagnostics: DiagnosticsWorkflow - @ObservedObject var settingsWorkflow: SettingsWorkflow - @ObservedObject var loginController: LaunchAtLoginController - @ObservedObject var silentLaunch: SilentLaunchController + let diagnostics: DiagnosticsWorkflow + let settingsWorkflow: SettingsWorkflow + let loginController: LaunchAtLoginController + let silentLaunch: SilentLaunchController /// 目录树折叠状态:组合根持有的长寿命对象,跨 destination 切换存续;服务器 /// 侧栏使用(见 CatalogExpansionState);首页独立保存浏览状态。 - @ObservedObject var expansion: CatalogExpansionState + let expansion: CatalogExpansionState let clipboard: any TextClipboard let diagnosticReportExporter: any DiagnosticReportExporter let configurationGroupFileExporter: any ConfigurationGroupFileExporter @@ -54,10 +57,8 @@ struct MainWindowView: View { /// 初值须与 WorkspaceRoute 的初始 destination 对齐,否则首帧侧栏无高亮行。 @State private var sidebarSelection: WorkspaceDestination? = WorkspaceRoute.initialDestination @State private var selection: NodeID? - @State private var ruleDraft: CustomRuleDraft? - /// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。 + /// 全局添加菜单打开的表单由窗口壳持有。 @State private var presentedWorkspaceSheet: WorkspaceSheet? - @State private var exportedDiagnosticsPath: String? @StateObject private var shellActionErrors = ErrorAlertPresenter() var body: some View { @@ -77,11 +78,6 @@ struct MainWindowView: View { } .navigationTitle(route.destination.label) .frame(minWidth: 920, minHeight: 580) - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - destinationActions - } - } .sheet(item: $presentedWorkspaceSheet) { sheet in switch sheet { case .importURL: @@ -96,26 +92,10 @@ struct MainWindowView: View { AddSubscriptionSheet(workflow: workflow, errors: shellActionErrors) } } - .sheet(item: $ruleDraft) { draft in - CustomRuleEditorSheet(workflow: rulesWorkflow, draft: draft) - } - .onChange(of: route.destination) { _, destination in - if destination != .rules { ruleDraft = nil } - } .presentingErrors(shellActionErrors) - .alert( - "诊断已导出", - isPresented: Binding( - get: { exportedDiagnosticsPath != nil }, - set: { if !$0 { exportedDiagnosticsPath = nil } }) - ) { - Button("好", role: .cancel) {} - } message: { - Text(exportedDiagnosticsPath ?? "") - } } - // MARK: - 分区动作槽位 + // MARK: - 全局添加菜单 private var addMenu: some View { Menu { @@ -142,57 +122,6 @@ struct MainWindowView: View { presentedWorkspaceSheet = sheet } - /// 分区动作(票 #53/#56/#57):页级动作随 destination 切换,经工具栏桥接 - /// 出现在窗口工具栏右端;首页/服务器无页级动作。例外:设置的保存/恢复 - /// 默认是表单级提交动作,由 SettingsView 内容顶部行承载(动作与表单同置, - /// 工具栏呈现效果差)。sheet/alert 呈现状态仍由壳持有,本处只负责动作的 - /// 呈现。 - @ViewBuilder - private var destinationActions: some View { - switch route.destination { - case .subscriptions: - HStack(spacing: 8) { - if !workflow.refreshingSubscriptionIDs.isEmpty { - ProgressView() - .controlSize(.small) - } - Button("更新全部", systemImage: "arrow.triangle.2.circlepath") { - Task { await workflow.refreshAllSubscriptions() } - } - .disabled(workflow.subscriptions.isEmpty) - } - case .rules: - Button(RulesCopy.text("新增规则"), systemImage: "plus") { - ruleDraft = rulesWorkflow.makeCustomRuleDraft() - }.disabled(!rulesWorkflow.snapshot.isComplete) - case .diagnostics: - Button("导出诊断…", systemImage: "square.and.arrow.up") { - exportDiagnostics() - } - case .home, .servers, .settings: - 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 @@ -220,9 +149,7 @@ struct MainWindowView: View { workflow: workflow, onNodesRemoved: clearSelectionIfInvalidated) case .rules: - RulesView( - workflow: rulesWorkflow, - onEditRule: { ruleDraft = rulesWorkflow.makeCustomRuleDraft(editing: $0) }) + RulesView(workflow: rulesWorkflow) case .settings: SettingsView( workflow: settingsWorkflow, loginController: loginController, @@ -232,7 +159,8 @@ struct MainWindowView: View { case .diagnostics: DiagnosticsView( diagnostics: diagnostics, - clipboard: clipboard) + clipboard: clipboard, + exporter: diagnosticReportExporter) } } diff --git a/ShadowsocksX-NG2/App/RulesView.swift b/ShadowsocksX-NG2/App/RulesView.swift index d2a111e7..a1791b27 100644 --- a/ShadowsocksX-NG2/App/RulesView.swift +++ b/ShadowsocksX-NG2/App/RulesView.swift @@ -4,7 +4,9 @@ import SwiftUI struct RulesView: View { @ObservedObject var workflow: RulesWorkflow @Environment(\.openWindow) private var openWindow - var onEditRule: (UUID) -> Void + /// 规则编辑草稿与编辑 sheet 由本页自持:@State 生命周期绑定在 rules + /// destination 分支上,离开分区即丢弃(与壳级清稿等价)。 + @State private var ruleDraft: CustomRuleDraft? var body: some View { VStack(spacing: 0) { @@ -45,6 +47,17 @@ struct RulesView: View { .task { if workflow.snapshot.version.isEmpty { await workflow.refresh() } } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(RulesCopy.text("新增规则"), systemImage: "plus") { + ruleDraft = workflow.makeCustomRuleDraft() + } + .disabled(!workflow.snapshot.isComplete) + } + } + .sheet(item: $ruleDraft) { draft in + CustomRuleEditorSheet(workflow: workflow, draft: draft) + } } @ViewBuilder @@ -149,10 +162,12 @@ struct RulesView: View { .width(min: 80, ideal: 110) TableColumn("") { row in if let id = row.customIDs.first { - Button(RulesCopy.text("编辑规则"), systemImage: "pencil") { onEditRule(id) } - .labelStyle(.iconOnly) - .help(RulesCopy.text("编辑规则")) - .disabled(!workflow.snapshot.isComplete) + Button(RulesCopy.text("编辑规则"), systemImage: "pencil") { + ruleDraft = workflow.makeCustomRuleDraft(editing: id) + } + .labelStyle(.iconOnly) + .help(RulesCopy.text("编辑规则")) + .disabled(!workflow.snapshot.isComplete) } }.width(30) }.frame(minWidth: 0, maxWidth: .infinity) diff --git a/ShadowsocksX-NG2/App/SubscriptionsView.swift b/ShadowsocksX-NG2/App/SubscriptionsView.swift index 70feea09..ee3e1268 100644 --- a/ShadowsocksX-NG2/App/SubscriptionsView.swift +++ b/ShadowsocksX-NG2/App/SubscriptionsView.swift @@ -2,9 +2,10 @@ import SwiftUI /// 订阅分区(spec #21 D11,issue #35/#41,地图 #52 票 #56):每订阅一张卡片 /// (图标 + 名称 + 「HTTPS 订阅地址已隐藏」+ 状态徽标 + 更新/重试 + 「…」菜单 + -/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅通过窗口工具栏全局 -/// 「添加」菜单进入,更新全部仍在本页工具栏。卡片数据来自订阅 projection -/// (非敏感);结构化刷新状态在呈现层本地化(story 42)。 +/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅通过窗口壳侧栏的 +/// 「添加」菜单进入;「更新全部」由本页自挂工具栏(呈现于窗口工具栏右端, +/// 与服务器分区同法)。卡片数据来自订阅 projection(非敏感);结构化刷新 +/// 状态在呈现层本地化(story 42)。 struct SubscriptionsView: View { @ObservedObject var workflow: CatalogWorkflow /// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。 @@ -62,6 +63,20 @@ struct SubscriptionsView: View { ) } .presentingErrors(errors) + .toolbar { + ToolbarItem(placement: .primaryAction) { + HStack(spacing: 8) { + if !workflow.refreshingSubscriptionIDs.isEmpty { + ProgressView() + .controlSize(.small) + } + Button("更新全部", systemImage: "arrow.triangle.2.circlepath") { + Task { await workflow.refreshAllSubscriptions() } + } + .disabled(workflow.subscriptions.isEmpty) + } + } + } } private func commitDelete() {