diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 49c60e13..1fd9344c 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -1,5 +1,23 @@ import SwiftUI +private enum WorkspaceSheet: String, Identifiable { + case importURL + case qrImport + case legacyImport + case addSubscription + + var id: String { rawValue } + + var destination: WorkspaceDestination { + switch self { + case .importURL, .qrImport, .legacyImport: + .servers + case .addSubscription: + .subscriptions + } + } +} + /// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与 /// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区大 /// 标题由窗口标题承担(组合根把 destination label 推给开窗器,见 @@ -20,8 +38,8 @@ struct MainWindowView: View { let diagnosticReportExporter: any DiagnosticReportExporter @State private var selection: NodeID? - /// 分区动作的呈现状态(票 #56/#58):添加订阅 sheet 与诊断导出由壳持有。 - @State private var showAddSubscription = false + /// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。 + @State private var presentedWorkspaceSheet: WorkspaceSheet? @State private var exportedDiagnosticsPath: String? @StateObject private var shellActionErrors = ErrorAlertPresenter() @@ -34,12 +52,27 @@ struct MainWindowView: View { } .frame(minWidth: 920, minHeight: 580) .toolbar { + ToolbarItem(placement: .navigation) { + addMenu + } ToolbarItemGroup(placement: .primaryAction) { destinationActions } } - .sheet(isPresented: $showAddSubscription) { - AddSubscriptionSheet(workflow: workflow, errors: shellActionErrors) + .toolbar(removing: .sidebarToggle) + .sheet(item: $presentedWorkspaceSheet) { sheet in + switch sheet { + case .importURL: + ImportURLSheet( + workflow: workflow, errors: shellActionErrors, clipboard: clipboard, + selection: $selection) + case .qrImport: + QRImportSheet(workflow: workflow, errors: shellActionErrors, selection: $selection) + case .legacyImport: + LegacyImportSheet(workflow: workflow) + case .addSubscription: + AddSubscriptionSheet(workflow: workflow, errors: shellActionErrors) + } } .alert( "操作失败", @@ -65,6 +98,31 @@ struct MainWindowView: View { // MARK: - 分区动作槽位 + private var addMenu: some View { + Menu { + Button("通过 URL 导入…") { presentWorkspaceSheet(.importURL) } + Button("从二维码图片导入…") { presentWorkspaceSheet(.qrImport) } + if workflow.legacyImportState.snapshotFound { + Button(legacyImportActionTitle) { presentWorkspaceSheet(.legacyImport) } + } + Divider() + Button("添加订阅…") { presentWorkspaceSheet(.addSubscription) } + } label: { + Label("添加", systemImage: "plus") + } + .labelStyle(.iconOnly) + .help("添加") + } + + private var legacyImportActionTitle: String { + workflow.legacyImportState.completed ? "再次导入旧版本服务器…" : "导入旧版本服务器…" + } + + private func presentWorkspaceSheet(_ sheet: WorkspaceSheet) { + route.navigate(to: sheet.destination) + presentedWorkspaceSheet = sheet + } + /// 分区动作(票 #53/#56/#57):页级动作随 destination 切换,经工具栏桥接 /// 出现在窗口工具栏右端;首页/服务器无页级动作。例外:设置的保存/恢复 /// 默认是表单级提交动作,由 SettingsView 内容顶部行承载(动作与表单同置, @@ -79,9 +137,6 @@ struct MainWindowView: View { ProgressView() .controlSize(.small) } - Button("添加订阅", systemImage: "plus") { - showAddSubscription = true - } Button("更新全部", systemImage: "arrow.triangle.2.circlepath") { Task { await workflow.refreshAllSubscriptions() } } diff --git a/ShadowsocksX-NG2/App/SubscriptionsView.swift b/ShadowsocksX-NG2/App/SubscriptionsView.swift index c02852c9..b4496306 100644 --- a/ShadowsocksX-NG2/App/SubscriptionsView.swift +++ b/ShadowsocksX-NG2/App/SubscriptionsView.swift @@ -2,9 +2,9 @@ import SwiftUI /// 订阅分区(spec #21 D11,issue #35/#41,地图 #52 票 #56):每订阅一张卡片 /// (图标 + 名称 + 「HTTPS 订阅地址已隐藏」+ 状态徽标 + 更新/重试 + 「…」菜单 + -/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅与更新全部在窗口 -/// 工具栏(由主窗口壳提供)。卡片数据来自订阅 projection(非敏感);结构化 -/// 刷新状态在呈现层本地化(story 42)。 +/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅通过窗口工具栏全局 +/// 「添加」菜单进入,更新全部仍在本页工具栏。卡片数据来自订阅 projection +/// (非敏感);结构化刷新状态在呈现层本地化(story 42)。 struct SubscriptionsView: View { @ObservedObject var workflow: CatalogWorkflow let errors: ErrorAlertPresenter @@ -38,7 +38,7 @@ struct SubscriptionsView: View { if workflow.subscriptions.isEmpty { ContentUnavailableView( "暂无订阅", systemImage: "arrow.triangle.2.circlepath", - description: Text("用工具栏「添加订阅」粘贴 HTTPS 订阅地址(SIP-008 JSON)") + description: Text("用窗口左上方的「添加」菜单添加 HTTPS 订阅地址(SIP-008 JSON)") ) .allowsHitTesting(false) } diff --git a/ShadowsocksX-NG2/App/WorkspaceView.swift b/ShadowsocksX-NG2/App/WorkspaceView.swift index 49526992..e90f5ff7 100644 --- a/ShadowsocksX-NG2/App/WorkspaceView.swift +++ b/ShadowsocksX-NG2/App/WorkspaceView.swift @@ -1,10 +1,10 @@ import SwiftUI -/// 服务器 destination(spec #21 D11,issue #32/#34/#35/#41):保留服务器目录树、 -/// 详情编辑与导入行为。workspace destination 由外层主窗口壳持有(新壳在 -/// MainWindowView.swift,票 #53);本视图只持有服务器功能的 sheet、alert、 -/// 确认弹窗与编辑草稿边界;selection 由外层绑定,以便订阅删除仍能清除失效 -/// 的服务器选择。 +/// 服务器 destination(spec #21 D11,issue #32/#34/#35/#41):承载服务器目录树、 +/// 详情编辑与分组命令。workspace destination 由外层主窗口壳持有(新壳在 +/// MainWindowView.swift,票 #53);全局导入表单由窗口壳呈现,本视图持有服务器 +/// 功能的 sheet、alert、确认弹窗与编辑草稿边界;selection 由外层绑定,以便订阅 +/// 删除仍能清除失效的服务器选择。 struct ServersView: View { @ObservedObject var workflow: CatalogWorkflow @@ -21,11 +21,9 @@ struct ServersView: View { @State private var renameText = "" @State private var newGroupParent: NodeID? @State private var newGroupName = "" + @State private var isPresentingNewGroup = false @State private var deleteTarget: NodeID? @State private var moveTarget: NodeID? - @State private var showImportURLSheet = false - @State private var showQRImportSheet = false - @State private var showLegacyImportSheet = false @State private var rootDropHovering = false var body: some View { @@ -51,13 +49,11 @@ struct ServersView: View { } .alert( "新建分组", - isPresented: Binding( - get: { newGroupParent != nil }, - set: { if !$0 { newGroupParent = nil } }) + isPresented: $isPresentingNewGroup ) { TextField("名称", text: $newGroupName) Button("创建") { commitNewGroup() } - Button("取消", role: .cancel) { newGroupParent = nil } + Button("取消", role: .cancel) { cancelNewGroup() } } .alert( "操作失败", @@ -81,16 +77,6 @@ struct ServersView: View { } message: { Text(deleteMessage) } - .sheet(isPresented: $showImportURLSheet) { - ImportURLSheet( - workflow: workflow, errors: errors, clipboard: clipboard, selection: $selection) - } - .sheet(isPresented: $showQRImportSheet) { - QRImportSheet(workflow: workflow, errors: errors, selection: $selection) - } - .sheet(isPresented: $showLegacyImportSheet) { - LegacyImportSheet(workflow: workflow) - } .sheet( item: Binding( get: { moveTarget.map(MoveContext.init) }, @@ -113,8 +99,7 @@ struct ServersView: View { renameText = workflow.displayName(for: id) }, onNewGroup: { parent in - newGroupParent = parent - newGroupName = "" + presentNewGroup(in: parent) }, onMove: { moveTarget = $0 }, onDelete: { deleteTarget = $0 } @@ -136,7 +121,7 @@ struct ServersView: View { if workflow.tree.isEmpty { ContentUnavailableView( "暂无服务器", systemImage: "server.rack", - description: Text("用工具栏「添加」导入 ss:// 链接或新建分组") + description: Text("用工具栏的「添加」菜单导入 ss:// 链接,或用「新建分组」按钮创建分组") ) .allowsHitTesting(false) } @@ -196,35 +181,34 @@ extension ServersView { } } - // MARK: - 工具栏(添加与新建分组) + // MARK: - 工具栏(新建分组) @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .primaryAction) { - Menu { - Button("通过 URL 导入…") { showImportURLSheet = true } - Button("从二维码图片导入…") { showQRImportSheet = true } - if workflow.legacyImportState.snapshotFound { - Divider() - Button( - workflow.legacyImportState.completed ? "再次导入 Legacy 配置…" : "导入 Legacy 配置…" - ) { - showLegacyImportSheet = true - } - } - Divider() - Button("新建分组…") { - newGroupParent = workflow.importTargetParent(for: selection) - newGroupName = "" - } + Button { + presentNewGroup(in: workflow.importTargetParent(for: selection)) } label: { - Label("添加", systemImage: "plus") + Label("新建分组", systemImage: "folder.badge.plus") } + .labelStyle(.iconOnly) + .help("新建分组") } } // MARK: - 告警动作 + private func presentNewGroup(in parent: NodeID?) { + newGroupParent = parent + newGroupName = "" + isPresentingNewGroup = true + } + + private func cancelNewGroup() { + isPresentingNewGroup = false + newGroupParent = nil + } + private func commitRename() { guard let id = renameTarget else { return } renameTarget = nil @@ -238,11 +222,12 @@ extension ServersView { } private func commitNewGroup() { - guard let parent = newGroupParent else { return } - newGroupParent = nil + let parent = newGroupParent + let name = newGroupName + cancelNewGroup() Task { do { - _ = try await workflow.createGroup(named: newGroupName, into: parent) + _ = try await workflow.createGroup(named: name, into: parent) } catch { errors.present(error) }