From 46baa8f9a7f15731aad6753fd17f32167e76512c Mon Sep 17 00:00:00 2001 From: anlicheng <244108715@qq.com> Date: Fri, 17 Apr 2026 15:56:56 +0800 Subject: [PATCH] =?UTF-8?q?View=E4=BB=A3=E7=A0=81=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Views/NetworkConnectedView.swift | 190 +++++++++ .../Views/NetworkDisconnectedView.swift | 36 ++ .../Network/Views/NetworkStatusBar.swift | 157 ++++++++ .../Features/Network/Views/NetworkView.swift | 374 ------------------ .../Network/Views/NetworkWaitAuthView.swift | 20 + 5 files changed, 403 insertions(+), 374 deletions(-) create mode 100644 punchnet/Features/Network/Views/NetworkConnectedView.swift create mode 100644 punchnet/Features/Network/Views/NetworkDisconnectedView.swift create mode 100644 punchnet/Features/Network/Views/NetworkStatusBar.swift create mode 100644 punchnet/Features/Network/Views/NetworkWaitAuthView.swift diff --git a/punchnet/Features/Network/Views/NetworkConnectedView.swift b/punchnet/Features/Network/Views/NetworkConnectedView.swift new file mode 100644 index 0000000..1edb4eb --- /dev/null +++ b/punchnet/Features/Network/Views/NetworkConnectedView.swift @@ -0,0 +1,190 @@ +// +// NetworkConnectedView.swift +// punchnet +// +// Created by 安礼成 on 2026/4/17. +// +import SwiftUI + +struct NetworkConnectedView: View { + var model: NetworkModel + + var body: some View { + if self.model.showMode == .resource { + // 资源视图:网格布局 + ScrollView { + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ], spacing: 10) { + ForEach(self.model.resourceList, id: \.uuid) { res in + ResourceItemCard(resource: res) + } + } + .padding(20) + } + .transition(.opacity) + .frame(maxWidth: .infinity) + } else { + // 设备视图:双栏布局 + NetworkDeviceGroupView(model: self.model) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity)) + } + } +} + +// MARK: - 设备组视图 (NavigationSplitView) +struct NetworkDeviceGroupView: View { + var model: NetworkModel + + // 侧边栏宽度 + private let sidebarWidth: CGFloat = 240 + + var body: some View { + let selectedIdBinding = Binding( + get: { self.model.selectedNodeId }, + set: { newValue in + self.model.selectNode(id: newValue) + } + ) + + HStack(spacing: 0) { + // --- 1. 自定义侧边栏 (Sidebar) --- + VStack(alignment: .leading, spacing: 0) { + // 顶部留白:避开 macOS 窗口左上角的红绿灯按钮 + // 如果你的 WindowStyle 是 .hiddenTitleBar,这个 Padding 非常重要 + Color.clear.frame(height: 28) + + List(self.model.nodeList, id: \.id, selection: selectedIdBinding) { node in + NetworkNodeHeadView(node: node) + // 技巧:在 HStack 方案中,tag 配合 List 的 selection 依然有效 + .tag(node.id) + .listRowSeparator(.hidden) + } + .listStyle(.inset) // 使用 inset 样式在自定义侧边栏中更美观 + .scrollContentBackground(.hidden) // 隐藏默认白色背景,显示下方的磨砂材质 + } + .frame(width: sidebarWidth) + + Divider() // 分割线 + + // --- 2. 详情区域 (Detail) --- + ZStack { + if let selectedNode = self.model.selectedNode { + NetworkNodeDetailView(model: self.model, node: selectedNode) + .transition(.opacity.animation(.easeInOut(duration: 0.2))) + } else { + ContentUnavailableView( + "选择成员设备", + systemImage: "macbook.and.iphone", + description: Text("查看详细网络信息和服务") + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) // 详情页使用标准窗口背景色 + } + .ignoresSafeArea() // 真正顶到最上方 + } +} + +// MARK: - 子组件 +struct NetworkNodeHeadView: View { + var node: NetworkContext.Node + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(node.connectionStatus == "在线" ? Color.green : Color.secondary.opacity(0.4)) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 2) { + Text(node.name) + .font(.system(size: 13, weight: .medium)) + + Text(node.ip) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } +} + +struct NetworkNodeDetailView: View { + var model: NetworkModel + var node: NetworkContext.Node + + var body: some View { + List { + Section("节点信息") { + LabeledContent("连接状态", value: node.connectionStatus) + LabeledContent("虚拟IPv4", value: node.ip) + LabeledContent("系统环境", value: node.system ?? "未知") + } + + Section("提供的服务") { + if self.model.isLoadingResources(for: node.id) { + ProgressView() + .controlSize(.small) + } else if self.model.resources(for: node.id).isEmpty { + Text("该节点暂未发布资源") + .foregroundColor(.secondary) + .font(.callout) + } else { + ForEach(self.model.resources(for: node.id), id: \.id) { res in + VStack(alignment: .leading) { + Text(res.name) + .font(.body) + + Text(res.url) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + .task(id: self.node.id) { + await self.model.loadResourcesIfNeeded(for: self.node.id) + } + } +} + +struct ResourceItemCard: View { + let resource: NetworkContext.Resource + @State private var isHovered = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: "safari.fill") + .foregroundColor(.accentColor) + .font(.title3) + + Text(resource.name) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + + Text(resource.url) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .padding() + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.gray, lineWidth: 1) + ) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(isHovered ? NSColor.selectedControlColor : NSColor.controlBackgroundColor)) + ) + .onHover { + isHovered = $0 + } + } +} diff --git a/punchnet/Features/Network/Views/NetworkDisconnectedView.swift b/punchnet/Features/Network/Views/NetworkDisconnectedView.swift new file mode 100644 index 0000000..2ab7a40 --- /dev/null +++ b/punchnet/Features/Network/Views/NetworkDisconnectedView.swift @@ -0,0 +1,36 @@ +// +// NetworkDisconnectedView.swift +// punchnet +// +// Created by 安礼成 on 2026/4/17. +// +import SwiftUI + +struct NetworkDisconnectedView: View { + var model: NetworkModel + + var body: some View { + VStack(spacing: 20) { + Spacer() + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 40, weight: .ultraLight)) + .foregroundStyle(.tertiary) + .symbolEffect(.pulse, options: .repeating) + + Text("尚未接入网络") + .font(.headline) + + Button(action: { + Task { @MainActor in + await self.model.connect() + } + }) { + Text("建立安全连接") + .frame(width: 80) + } + .buttonStyle(.borderedProminent) + .disabled(self.model.phase == .connecting || self.model.networkSession == nil) + Spacer() + } + } +} diff --git a/punchnet/Features/Network/Views/NetworkStatusBar.swift b/punchnet/Features/Network/Views/NetworkStatusBar.swift new file mode 100644 index 0000000..12b8aa3 --- /dev/null +++ b/punchnet/Features/Network/Views/NetworkStatusBar.swift @@ -0,0 +1,157 @@ +// +// NetworkStatusBar.swift +// punchnet +// +// Created by 安礼成 on 2026/4/17. +// +import SwiftUI + +struct NetworkStatusBar: View { + var model: NetworkModel + + var body: some View { + let isOnBinding = Binding( + get: { self.model.isTunnelEnabled }, + set: { newValue in + Task { @MainActor in + await self.model.setConnectionEnabled(newValue) + } + } + ) + + HStack(spacing: 12) { + // 左侧:状态指示器与文字 + HStack(spacing: 20) { + ZStack { + Circle() + .fill(self.model.isTunnelEnabled ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) + .frame(width: 36, height: 36) + + Image(systemName: self.model.isTunnelEnabled ? "checkmark.shield.fill" : "shield.slash.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(self.model.isTunnelEnabled ? Color.green : Color.secondary) + .font(.system(size: 16)) + } + + VStack(alignment: .leading, spacing: 1) { + if let networkSession = self.model.networkSession { + Text(networkSession.networkName) + .font(.system(size: 12, weight: .semibold)) + + Text("局域网IP: \(self.model.networkContext?.ip ?? "0.0.0.0")") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + } else { + Text("未登录网络") + .font(.system(size: 12, weight: .semibold)) + + Text("登录后可建立连接") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + + if self.model.networkSession != nil { + exitNodeMenu + } + + // 右侧:Switch 开关 + // 注意:这里使用 Binding 手动接管连接/断开逻辑 + Toggle("", isOn: isOnBinding) + .toggleStyle(.switch) + .controlSize(.small) // macOS 顶部栏或面板推荐使用 small 尺寸 + .disabled(self.model.phase == .connecting || self.model.phase == .disconnecting || self.model.networkSession == nil) + } + .padding(.vertical, 5) + } + + private var exitNodeMenu: some View { + Menu { + Button { + Task { @MainActor in + await self.model.updateExitNodeSelection(nil) + } + } label: { + if self.model.selectedExitNode == nil { + Label("不设置出口节点", systemImage: "checkmark") + } else { + Text("不设置出口节点") + } + } + + if !self.model.exitNodeOptions.isEmpty { + Divider() + + ForEach(self.model.exitNodeOptions) { option in + Button { + Task { @MainActor in + await self.model.updateExitNodeSelection(option.ip) + } + } label: { + if self.model.selectedExitNode?.ip == option.ip { + Label(option.nodeNameWithIp, systemImage: "checkmark") + } else { + Text(option.nodeNameWithIp) + } + } + } + } + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text("出口节点") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + + Text(self.model.exitNodeTitle) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + + Text(self.model.exitNodeSubtitle) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if self.model.isUpdatingExitNode { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 220, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.06), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(!self.model.canSelectExitNode) + .opacity(self.model.canSelectExitNode ? 1 : 0.7) + .help(self.model.exitNodeHelpText) + } +} + +struct ExitNodeOption: Identifiable, Equatable { + let id: Int + let nodeName: String + let ip: String + let system: String? + + var nodeNameWithIp: String { + "\(nodeName) (\(ip))" + } +} diff --git a/punchnet/Features/Network/Views/NetworkView.swift b/punchnet/Features/Network/Views/NetworkView.swift index b3f1753..f865926 100644 --- a/punchnet/Features/Network/Views/NetworkView.swift +++ b/punchnet/Features/Network/Views/NetworkView.swift @@ -18,8 +18,6 @@ enum NetworkConnectionPhase { case disconnecting } - - // MARK: - 主网络视图 struct NetworkView: View { @Environment(AppContext.self) var appContext: AppContext @@ -102,377 +100,5 @@ struct NetworkView: View { } } -struct NetworkStatusBar: View { - var model: NetworkModel - var body: some View { - let isOnBinding = Binding( - get: { self.model.isTunnelEnabled }, - set: { newValue in - Task { @MainActor in - await self.model.setConnectionEnabled(newValue) - } - } - ) - - HStack(spacing: 12) { - // 左侧:状态指示器与文字 - HStack(spacing: 20) { - ZStack { - Circle() - .fill(self.model.isTunnelEnabled ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) - .frame(width: 36, height: 36) - - Image(systemName: self.model.isTunnelEnabled ? "checkmark.shield.fill" : "shield.slash.fill") - .symbolRenderingMode(.hierarchical) - .foregroundStyle(self.model.isTunnelEnabled ? Color.green : Color.secondary) - .font(.system(size: 16)) - } - - VStack(alignment: .leading, spacing: 1) { - if let networkSession = self.model.networkSession { - Text(networkSession.networkName) - .font(.system(size: 12, weight: .semibold)) - - Text("局域网IP: \(self.model.networkContext?.ip ?? "0.0.0.0")") - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.secondary) - } else { - Text("未登录网络") - .font(.system(size: 12, weight: .semibold)) - - Text("登录后可建立连接") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - } - } - - if self.model.networkSession != nil { - exitNodeMenu - } - - // 右侧:Switch 开关 - // 注意:这里使用 Binding 手动接管连接/断开逻辑 - Toggle("", isOn: isOnBinding) - .toggleStyle(.switch) - .controlSize(.small) // macOS 顶部栏或面板推荐使用 small 尺寸 - .disabled(self.model.phase == .connecting || self.model.phase == .disconnecting || self.model.networkSession == nil) - } - .padding(.vertical, 5) - } - - private var exitNodeMenu: some View { - Menu { - Button { - Task { @MainActor in - await self.model.updateExitNodeSelection(nil) - } - } label: { - if self.model.selectedExitNode == nil { - Label("不设置出口节点", systemImage: "checkmark") - } else { - Text("不设置出口节点") - } - } - - if !self.model.exitNodeOptions.isEmpty { - Divider() - - ForEach(self.model.exitNodeOptions) { option in - Button { - Task { @MainActor in - await self.model.updateExitNodeSelection(option.ip) - } - } label: { - if self.model.selectedExitNode?.ip == option.ip { - Label(option.nodeNameWithIp, systemImage: "checkmark") - } else { - Text(option.nodeNameWithIp) - } - } - } - } - } label: { - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 3) { - Text("出口节点") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - - Text(self.model.exitNodeTitle) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - .lineLimit(1) - - Text(self.model.exitNodeSubtitle) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 0) - - if self.model.isUpdatingExitNode { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(width: 220, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.primary.opacity(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.06), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .disabled(!self.model.canSelectExitNode) - .opacity(self.model.canSelectExitNode ? 1 : 0.7) - .help(self.model.exitNodeHelpText) - } -} -struct ExitNodeOption: Identifiable, Equatable { - let id: Int - let nodeName: String - let ip: String - let system: String? - - var nodeNameWithIp: String { - "\(nodeName) (\(ip))" - } -} - -struct NetworkConnectedView: View { - var model: NetworkModel - - var body: some View { - if self.model.showMode == .resource { - // 资源视图:网格布局 - ScrollView { - LazyVGrid(columns: [ - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8) - ], spacing: 10) { - ForEach(self.model.resourceList, id: \.uuid) { res in - ResourceItemCard(resource: res) - } - } - .padding(20) - } - .transition(.opacity) - .frame(maxWidth: .infinity) - } else { - // 设备视图:双栏布局 - NetworkDeviceGroupView(model: self.model) - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity)) - } - } -} - -struct NetworkDisconnectedView: View { - var model: NetworkModel - - var body: some View { - VStack(spacing: 20) { - Spacer() - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 40, weight: .ultraLight)) - .foregroundStyle(.tertiary) - .symbolEffect(.pulse, options: .repeating) - - Text("尚未接入网络") - .font(.headline) - - Button(action: { - Task { @MainActor in - await self.model.connect() - } - }) { - Text("建立安全连接") - .frame(width: 80) - } - .buttonStyle(.borderedProminent) - .disabled(self.model.phase == .connecting || self.model.networkSession == nil) - Spacer() - } - } -} - -// MARK: - 设备组视图 (NavigationSplitView) -struct NetworkDeviceGroupView: View { - var model: NetworkModel - - // 侧边栏宽度 - private let sidebarWidth: CGFloat = 240 - - var body: some View { - let selectedIdBinding = Binding( - get: { self.model.selectedNodeId }, - set: { newValue in - self.model.selectNode(id: newValue) - } - ) - - HStack(spacing: 0) { - // --- 1. 自定义侧边栏 (Sidebar) --- - VStack(alignment: .leading, spacing: 0) { - // 顶部留白:避开 macOS 窗口左上角的红绿灯按钮 - // 如果你的 WindowStyle 是 .hiddenTitleBar,这个 Padding 非常重要 - Color.clear.frame(height: 28) - - List(self.model.nodeList, id: \.id, selection: selectedIdBinding) { node in - NetworkNodeHeadView(node: node) - // 技巧:在 HStack 方案中,tag 配合 List 的 selection 依然有效 - .tag(node.id) - .listRowSeparator(.hidden) - } - .listStyle(.inset) // 使用 inset 样式在自定义侧边栏中更美观 - .scrollContentBackground(.hidden) // 隐藏默认白色背景,显示下方的磨砂材质 - } - .frame(width: sidebarWidth) - - Divider() // 分割线 - - // --- 2. 详情区域 (Detail) --- - ZStack { - if let selectedNode = self.model.selectedNode { - NetworkNodeDetailView(model: self.model, node: selectedNode) - .transition(.opacity.animation(.easeInOut(duration: 0.2))) - } else { - ContentUnavailableView( - "选择成员设备", - systemImage: "macbook.and.iphone", - description: Text("查看详细网络信息和服务") - ) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .windowBackgroundColor)) // 详情页使用标准窗口背景色 - } - .ignoresSafeArea() // 真正顶到最上方 - } -} - -// MARK: - 子组件 -struct NetworkNodeHeadView: View { - var node: NetworkContext.Node - - var body: some View { - HStack(spacing: 10) { - Circle() - .fill(node.connectionStatus == "在线" ? Color.green : Color.secondary.opacity(0.4)) - .frame(width: 8, height: 8) - - VStack(alignment: .leading, spacing: 2) { - Text(node.name) - .font(.system(size: 13, weight: .medium)) - - Text(node.ip) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } -} - -struct NetworkNodeDetailView: View { - var model: NetworkModel - var node: NetworkContext.Node - - var body: some View { - List { - Section("节点信息") { - LabeledContent("连接状态", value: node.connectionStatus) - LabeledContent("虚拟IPv4", value: node.ip) - LabeledContent("系统环境", value: node.system ?? "未知") - } - - Section("提供的服务") { - if self.model.isLoadingResources(for: node.id) { - ProgressView() - .controlSize(.small) - } else if self.model.resources(for: node.id).isEmpty { - Text("该节点暂未发布资源") - .foregroundColor(.secondary) - .font(.callout) - } else { - ForEach(self.model.resources(for: node.id), id: \.id) { res in - VStack(alignment: .leading) { - Text(res.name) - .font(.body) - - Text(res.url) - .font(.caption) - .foregroundColor(.secondary) - } - } - } - } - } - .task(id: self.node.id) { - await self.model.loadResourcesIfNeeded(for: self.node.id) - } - } -} - -struct ResourceItemCard: View { - let resource: NetworkContext.Resource - @State private var isHovered = false - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Image(systemName: "safari.fill") - .foregroundColor(.accentColor) - .font(.title3) - - Text(resource.name) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - - Text(resource.url) - .font(.caption2) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - .padding() - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.gray, lineWidth: 1) - ) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(isHovered ? NSColor.selectedControlColor : NSColor.controlBackgroundColor)) - ) - .onHover { - isHovered = $0 - } - } -} - -struct NetworkWaitAuthView: View { - var phase: NetworkConnectionPhase - - var body: some View { - VStack(spacing: 16) { - ProgressView() - - Text(self.phase == .disconnecting ? "正在断开网络..." : "正在建立安全连接...") - .foregroundColor(.secondary) - } - } -} diff --git a/punchnet/Features/Network/Views/NetworkWaitAuthView.swift b/punchnet/Features/Network/Views/NetworkWaitAuthView.swift new file mode 100644 index 0000000..e0b16b4 --- /dev/null +++ b/punchnet/Features/Network/Views/NetworkWaitAuthView.swift @@ -0,0 +1,20 @@ +// +// NetworkWaitAuthView.swift +// punchnet +// +// Created by 安礼成 on 2026/4/17. +// +import SwiftUI + +struct NetworkWaitAuthView: View { + var phase: NetworkConnectionPhase + + var body: some View { + VStack(spacing: 16) { + ProgressView() + + Text(self.phase == .disconnecting ? "正在断开网络..." : "正在建立安全连接...") + .foregroundColor(.secondary) + } + } +}