diff --git a/ShadowsocksX-NG2/App/HomeComponents.swift b/ShadowsocksX-NG2/App/HomeComponents.swift new file mode 100644 index 00000000..20cedd6b --- /dev/null +++ b/ShadowsocksX-NG2/App/HomeComponents.swift @@ -0,0 +1,86 @@ +import SwiftUI + +/// 首页卡片容器(地图 #52,票 #54):标题 + 副题 + 头部尾随内容 + 正文; +/// 圆角描边卡片底,各分区票的分区卡片可复用。 +struct HomeCard: View { + let title: String + let subtitle: String? + @ViewBuilder var trailing: () -> Trailing + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.title3.weight(.semibold)) + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 16) + trailing() + } + content() + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + .background.secondary, + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.quaternary) + ) + } +} + +/// 当前模式徽标(票 #54)。 +struct ModeBadge: View { + let label: String + + var body: some View { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.accentColor.opacity(0.12), in: Capsule()) + } +} + +/// 快速操作行按钮(票 #54)。 +struct QuickActionButton: View { + let icon: String + let title: String + var help: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.tint) + Text(title) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: "arrow.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + .quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .help(help ?? title) + } +} diff --git a/ShadowsocksX-NG2/App/HomeView.swift b/ShadowsocksX-NG2/App/HomeView.swift new file mode 100644 index 00000000..fda17b05 --- /dev/null +++ b/ShadowsocksX-NG2/App/HomeView.swift @@ -0,0 +1,357 @@ +import SwiftUI + +/// 首页分区(地图 #52,票 #54):代理模式切换、当前服务器目标树与快速操作。 +/// 模式与开关走代理控制工作流的整体 snapshot(issue #47);目标树与激活走 +/// 目录工作流 projection 与 `activate`;复制 HTTP 导出是 UI 副作用。原型中 +/// 的「手动/外部 PAC」模式不实现:Domain 只有 PAC/全局两种模式。 +struct HomeView: View { + @ObservedObject var workflow: CatalogWorkflow + @ObservedObject var control: ProxyControlWorkflow + let clipboard: any TextClipboard + let onManageServers: () -> Void + let errors: ErrorAlertPresenter + + var body: some View { + ScrollView { + HStack(alignment: .top, spacing: 20) { + VStack(spacing: 20) { + ModeCard(control: control) + TargetTreeCard( + workflow: workflow, + control: control, + onManageServers: onManageServers, + errors: errors) + } + .frame(maxWidth: .infinity, alignment: .leading) + QuickActionCard(workflow: workflow, control: control, clipboard: clipboard, errors: errors) + .frame(width: 300) + } + .padding(.leading, 28) + .padding(.trailing, 32) + .padding(.top, 24) + .padding(.bottom, 36) + } + } +} + +/// 「代理模式」卡:可用模式切换(Domain 单点策略投影)+ 模式说明 + 当前徽标。 +private struct ModeCard: View { + @ObservedObject var control: ProxyControlWorkflow + + var body: some View { + HomeCard( + title: "代理模式", + subtitle: "选择系统代理接管方式", + trailing: { ModeBadge(label: control.snapshot.proxyMode.label) }, + content: { + VStack(alignment: .leading, spacing: 14) { + Picker("代理模式", selection: modeBinding) { + ForEach(control.snapshot.availableModes, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 480, alignment: .leading) + Text(modeDescription(control.snapshot.proxyMode)) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 6) + }) + } + + private var modeBinding: Binding { + Binding( + get: { control.snapshot.proxyMode }, + set: { mode in + Task { await control.setProxyMode(mode) } + }) + } + + private func modeDescription(_ mode: ProxyMode) -> String { + switch mode { + case .pac: + "根据规则自动决定直连或通过代理,适合日常使用。" + case .global: + "所有系统代理流量通过本地 SOCKS5 端点转发,不使用规则分流。" + } + } +} + +/// 「当前服务器」卡:只显示有服务器的分组;双击节点激活为当前服务器。 +private struct TargetTreeCard: View { + @ObservedObject var workflow: CatalogWorkflow + @ObservedObject var control: ProxyControlWorkflow + let onManageServers: () -> Void + let errors: ErrorAlertPresenter + + /// 收起状态的目标分组(默认全部展开)。 + @State private var collapsedGroups: Set = [] + + var body: some View { + HomeCard( + title: "当前服务器", + subtitle: "只显示有服务器的分组,双击节点以设为当前服务器", + trailing: { + Button(action: onManageServers) { + HStack(spacing: 4) { + Text("管理服务器") + Image(systemName: "arrow.right") + } + .font(.callout.weight(.medium)) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + }, + content: { + tree.padding(.top, 6) + }) + } + + @ViewBuilder + private var tree: some View { + let groups = targetGroups + if groups.isEmpty { + Text("暂无服务器;在「服务器」分区导入 ss:// 链接或新建分组。") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(groups) { group in + TargetGroupSection( + group: group, + activeTargetID: control.snapshot.activeTarget?.id, + isCollapsed: isCollapsed(group.id), + onToggle: { toggleCollapsed(group.id) }, + onActivate: activate) + } + ForEach(rootServers) { server in + TargetServerRow( + server: server, + isActive: control.snapshot.activeTarget?.id == server.id, + onActivate: activate) + } + } + } + } + + private func activate(_ id: NodeID) { + Task { + do { + _ = try await workflow.activate(id) + } catch { + errors.present(error) + } + } + } + + private func toggleCollapsed(_ id: NodeID) { + if isCollapsed(id) { + collapsedGroups.remove(id) + } else { + collapsedGroups.insert(id) + } + } + + private func isCollapsed(_ id: NodeID) -> Bool { + collapsedGroups.contains(id) + } + + /// 有服务器叶子的分组(递归收集;每组只呈现直属服务器叶子)。 + private var targetGroups: [TargetGroupInfo] { + var groups: [TargetGroupInfo] = [] + func walk(_ nodes: [CatalogTreeNode]) { + for node in nodes where node.isGroup { + let servers = node.childNodes.filter { !$0.isGroup } + if !servers.isEmpty { + groups.append( + TargetGroupInfo( + id: node.id, + name: node.name, + sourceLabel: node.source == .subscription ? "订阅分组" : "手动分组", + servers: servers)) + } + walk(node.childNodes.filter { $0.isGroup }) + } + } + walk(workflow.tree.roots) + return groups + } + + /// 根层直接挂的服务器叶子(目录根层允许放服务器时兜底呈现)。 + private var rootServers: [CatalogTreeNode] { + workflow.tree.roots.filter { !$0.isGroup } + } +} + +/// 单个目标分组:展开/收起头 + 直属服务器行。 +private struct TargetGroupSection: View { + let group: TargetGroupInfo + let activeTargetID: NodeID? + let isCollapsed: Bool + let onToggle: () -> Void + let onActivate: (NodeID) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button(action: onToggle) { + HStack(spacing: 8) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .rotationEffect(.degrees(isCollapsed ? 0 : 90)) + Image(systemName: "folder") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(group.name) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + Text("\(group.sourceLabel) · \(group.servers.count) 台") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if !isCollapsed { + VStack(alignment: .leading, spacing: 2) { + ForEach(group.servers) { server in + TargetServerRow( + server: server, + isActive: activeTargetID == server.id, + onActivate: onActivate) + } + } + .padding(.leading, 16) + } + } + .padding(.vertical, 2) + } +} + +/// 目标树里的服务器行:双击激活,活动目标勾选标记。 +private struct TargetServerRow: View { + let server: CatalogTreeNode + let isActive: Bool + let onActivate: (NodeID) -> Void + + var body: some View { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary) + .frame(width: 28, height: 28) + .overlay { + Image(systemName: "server.rack") + .font(.system(size: 12)) + .foregroundStyle(.tint) + } + VStack(alignment: .leading, spacing: 1) { + Text(server.name) + .font(.callout.weight(isActive ? .semibold : .regular)) + .foregroundStyle(server.isInvalid ? .secondary : .primary) + Text(server.source == .subscription ? "订阅节点" : "手动服务器") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + if server.isInvalid { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .help("该服务器存在已知阻塞问题,激活会被点名拒绝") + } + Image(systemName: "checkmark") + .font(.callout.weight(.semibold)) + .foregroundStyle(.tint) + .opacity(isActive ? 1 : 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isActive ? Color.accentColor.opacity(0.10) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + onActivate(server.id) + } + .help("双击设为当前服务器") + } +} + +/// 「快速操作」卡:更新全部订阅、复制终端 HTTP 代理导出行。 +private struct QuickActionCard: View { + @ObservedObject var workflow: CatalogWorkflow + @ObservedObject var control: ProxyControlWorkflow + let clipboard: any TextClipboard + let errors: ErrorAlertPresenter + + @State private var isRefreshingAll = false + + var body: some View { + HomeCard( + title: "快速操作", + subtitle: nil, + trailing: { EmptyView() }, + content: { + VStack(spacing: 10) { + QuickActionButton( + icon: "arrow.triangle.2.circlepath", + title: isRefreshingAll ? "正在更新…" : "更新全部订阅", + help: nil, + action: refreshAll + ) + .disabled(workflow.subscriptions.isEmpty || isRefreshingAll) + QuickActionButton( + icon: "doc.on.doc", + title: "复制终端 HTTP 代理指令", + help: copyHelp, + action: copyHTTPExport + ) + .disabled(control.snapshot.httpExport == nil) + if isRefreshingAll { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 6) + }) + } + + private var copyHelp: String? { + control.snapshot.httpExport.map { _ in "复制可在 shell 中 source 的 http/https 代理导出行" } + ?? "HTTP 代理未启用,无法导出" + } + + private func refreshAll() { + isRefreshingAll = true + Task { + await workflow.refreshAllSubscriptions() + isRefreshingAll = false + } + } + + private func copyHTTPExport() { + guard let line = control.snapshot.httpExport?.copyableLine else { return } + do { + try clipboard.write(line) + } catch { + errors.present(error) + } + } +} + +/// 目标分组的呈现模型(票 #54):名称、来源说明与直属服务器叶子。 +private struct TargetGroupInfo: Identifiable { + let id: NodeID + let name: String + let sourceLabel: String + let servers: [CatalogTreeNode] +} diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index f82a480f..45a9d55e 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -71,7 +71,11 @@ struct MainWindowView: View { private var destinationView: some View { switch route.destination { case .home: - WorkspaceHomeView(workflow: workflow) + WorkspaceHomeView( + workflow: workflow, + control: control, + clipboard: clipboard, + onManageServers: { route.navigate(to: .servers) }) case .servers: ServersView( workflow: workflow, @@ -104,14 +108,21 @@ struct MainWindowView: View { 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 { - ContentUnavailableView( - "首页", systemImage: "house", - description: Text("从侧栏选择服务器、订阅、设置或诊断。") + HomeView( + workflow: workflow, + control: control, + clipboard: clipboard, + onManageServers: onManageServers, + errors: errors ) .sheet(isPresented: $showLegacyImportSheet) { LegacyImportSheet(workflow: workflow) @@ -121,6 +132,16 @@ private struct WorkspaceHomeView: View { didOfferLegacyImport = true showLegacyImportSheet = true } + .alert( + "操作失败", + isPresented: Binding( + get: { errors.isPresented }, + set: { if !$0 { errors.dismiss() } }) + ) { + Button("好", role: .cancel) {} + } message: { + Text(errors.message ?? "") + } } }