// // NetworkView.swift // punchnet import SwiftUI import Observation // 资源展示模式 enum NetworkShowMode: String, CaseIterable { case resource = "访问资源" case device = "成员设备" } // MARK: - 网络连接状态 enum NetworkConnectionPhase { case disconnected case connecting case connected case disconnecting } // MARK: - 主网络视图 struct NetworkView: View { @Environment(AppContext.self) var appContext: AppContext @Environment(\.openWindow) var openWindow @State private var networkModel = NetworkModel() var body: some View { @Bindable var networkModel = self.networkModel VStack(spacing: 0) { // 1. 头部区域 (Header) HStack(spacing: 16) { NetworkStatusBar(model: self.networkModel) Spacer() if self.networkModel.shouldShowModePicker { Picker("", selection: $networkModel.showMode) { ForEach(NetworkShowMode.allCases, id: \.self) { Text($0.rawValue).tag($0) } } .pickerStyle(.segmented) .frame(width: 160) } Button { openWindow(id: "settings") } label: { Image(systemName: "slider.horizontal.3") .font(.system(size: 14)) .foregroundColor(.secondary) } .buttonStyle(.plain) .help("配置中心") } .padding(.horizontal, 20) .padding(.vertical, 14) .background(VisualEffectView(material: .headerView, blendingMode: .withinWindow)) Divider() // 2. 内容区域 (Content) Group { switch self.networkModel.phase { case .connecting, .disconnecting: NetworkWaitAuthView(phase: self.networkModel.phase) case .connected: NetworkConnectedView(model: self.networkModel) case .disconnected: NetworkDisconnectedView(model: self.networkModel) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow)) } .frame(minWidth: 700, minHeight: 500) // 适当调大宽度以适应 SplitView .task { await self.networkModel.activate(appContext: self.appContext) } .alert("提示", isPresented: self.errorPresented) { Button("确定", role: .cancel) { self.networkModel.clearError() } } message: { Text(self.networkModel.errorMessage ?? "") } } private var errorPresented: Binding { Binding( get: { self.networkModel.errorMessage != nil }, set: { isPresented in if !isPresented { self.networkModel.clearError() } } ) } } 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: SDLAPIClient.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: SDLAPIClient.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: SDLAPIClient.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) } } }