// // NetworkView.swift // punchnet import SwiftUI import Observation // MARK: - 基础模型协议 enum ConnectState { case waitAuth case connected case disconnected } // 资源展示模式 enum NetworkShowMode: String, CaseIterable { case resource = "访问资源" case device = "成员设备" } // MARK: - 主网络视图 struct NetworkView: View { @Environment(AppContext.self) var appContext: AppContext @Environment(\.openWindow) var openWindow @State private var showMode: NetworkShowMode = .resource @State private var connectState: ConnectState = .disconnected private var vpnManager = VPNManager.shared var body: some View { VStack(spacing: 0) { // 1. 头部区域 (Header) HStack(spacing: 16) { NetworkStatusBar() Spacer() if connectState == .connected { Picker("", selection: $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 connectState { case .waitAuth: NetworkWaitAuthView() case .connected: NetworkConnectedView(showMode: $showMode) case .disconnected: NetworkDisconnectedView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow)) } .frame(minWidth: 700, minHeight: 500) // 适当调大宽度以适应 SplitView .onAppear { syncState(vpnManager.vpnStatus) } .onChange(of: vpnManager.vpnStatus) { _, newStatus in withAnimation(.snappy) { syncState(newStatus) } } } // 通过VPN的连接状态同步当前页面的显示状态 private func syncState(_ status: VPNManager.VPNStatus) { switch status { case .connected: connectState = .connected case .disconnected: connectState = .disconnected @unknown default: connectState = .disconnected } } } struct NetworkStatusBar: View { @Environment(AppContext.self) private var appContext @State private var vpnManger = VPNManager.shared @State private var exitNodeIp: String = "" var body: some View { let isOnBinding = Binding( get: { vpnManger.isConnected }, set: { newValue in if newValue { Task { if self.appContext.networkContext == nil { try? await self.appContext.connectNetwork() } try? await self.appContext.startTun() } } else { Task { try? await self.appContext.stopTun() } } } ) HStack(spacing: 12) { // 左侧:状态指示器与文字 HStack(spacing: 20) { ZStack { Circle() .fill(vpnManger.isConnected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) .frame(width: 36, height: 36) Image(systemName: vpnManger.isConnected ? "checkmark.shield.fill" : "shield.slash.fill") .symbolRenderingMode(.hierarchical) .foregroundStyle(vpnManger.isConnected ? Color.green : Color.secondary) .font(.system(size: 16)) } VStack(alignment: .leading, spacing: 1) { if let networkSession = appContext.networkSession { Text(networkSession.networkName) .font(.system(size: 12, weight: .semibold)) Text("局域网IP: \(appContext.networkContext?.ip ?? "0.0.0.0")") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) } } } // 右侧:Switch 开关 // 注意:这里使用 Binding 手动接管连接/断开逻辑 Toggle("", isOn: isOnBinding) .toggleStyle(.switch) .controlSize(.small) // macOS 顶部栏或面板推荐使用 small 尺寸 TextField("出口节点:", text: $exitNodeIp) Button { Task { let result = try await self.appContext.changeExitNodeIp(exitNodeIp: self.exitNodeIp) let reply = try NEReply(serializedBytes: result) NSLog("change exit node ip: \(reply)") } } label: { Text("启动出口节点") } } .padding(.vertical, 5) } } struct NetworkConnectedView: View { @Environment(AppContext.self) private var appContext: AppContext @Binding var showMode: NetworkShowMode var body: some View { if showMode == .resource { // 资源视图:网格布局 ScrollView { LazyVGrid(columns: [ GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8) ], spacing: 10) { ForEach(appContext.networkContext?.resourceList ?? [], id: \.uuid) { res in ResourceItemCard(resource: res) } } .padding(20) } .transition(.opacity) .frame(maxWidth: .infinity) } else { // 设备视图:双栏布局 NetworkDeviceGroupView() .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity)) } } } struct NetworkDisconnectedView: View { @Environment(AppContext.self) private var appContext: AppContext @State private var isConnecting: Bool = false @State private var showAlert: Bool = false @State private var errorMessage: String = "" 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 startConnection() } }) { if isConnecting { ProgressView() .controlSize(.small) .frame(width: 80) } else { Text("建立安全连接") .frame(width: 80) } } .buttonStyle(.borderedProminent) .disabled(isConnecting) Spacer() } .alert(isPresented: $showAlert) { Alert(title: Text("提示"), message: Text(errorMessage)) } } private func startConnection() async { self.isConnecting = true defer { self.isConnecting = false } do { try await self.appContext.connectNetwork() try await self.appContext.startTun() } catch let err as SDLAPIError { self.showAlert = true self.errorMessage = err.message } catch let err as AppContextError { self.showAlert = true self.errorMessage = err.message } catch let err { self.showAlert = true self.errorMessage = err.localizedDescription } } } // MARK: - 设备组视图 (NavigationSplitView) struct NetworkDeviceGroupView: View { @Environment(AppContext.self) private var appContext: AppContext @State private var selectedId: Int? // 侧边栏宽度 private let sidebarWidth: CGFloat = 240 var body: some View { HStack(spacing: 0) { // --- 1. 自定义侧边栏 (Sidebar) --- VStack(alignment: .leading, spacing: 0) { // 顶部留白:避开 macOS 窗口左上角的红绿灯按钮 // 如果你的 WindowStyle 是 .hiddenTitleBar,这个 Padding 非常重要 Color.clear.frame(height: 28) List(appContext.networkContext?.nodeList ?? [], id: \.id, selection: $selectedId) { 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 = appContext.networkContext?.getNode(id: selectedId) { NetworkNodeDetailView(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() // 真正顶到最上方 .onAppear { if selectedId == nil { selectedId = appContext.networkContext?.firstNodeId() } } } } // 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 { @Environment(AppContext.self) private var appContext: AppContext var node: SDLAPIClient.NetworkContext.Node @State private var resources: [SDLAPIClient.NetworkContext.Resource] = [] @State private var isLoading = false var body: some View { List { Section("节点信息") { LabeledContent("连接状态", value: node.connectionStatus) LabeledContent("虚拟IPv4", value: node.ip) LabeledContent("系统环境", value: node.system ?? "未知") } Section("提供的服务") { if isLoading { ProgressView() .controlSize(.small) } else if resources.isEmpty { Text("该节点暂未发布资源") .foregroundColor(.secondary) .font(.callout) } else { ForEach(resources, id: \.id) { res in VStack(alignment: .leading) { Text(res.name) .font(.body) Text(res.url) .font(.caption) .foregroundColor(.secondary) } } } } } .task { await loadNodeResources(id: node.id) } } // 请求对应的资源信息 private func loadNodeResources(id: Int) async { guard let session = appContext.networkSession else { return } self.isLoading = true defer { self.isLoading = false } self.resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: 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 body: some View { VStack(spacing: 16) { ProgressView() Text("等待认证确认中...") .foregroundColor(.secondary) } } }