// // NetworkView.swift // punchnet import SwiftUI import Observation // 连接状态 enum ConnectState { case waitAuth case connected case disconnected } // MARK: - 主网络视图 struct NetworkView: View { @Environment(UserContext.self) var userContext @Environment(AppContext.self) var appContext @Environment(\.openWindow) private var openWindow @State private var networkModel = NetworkModel() @State private var showMode: ShowMode = .resource @State private var connectState: ConnectState = .disconnected @State private var isConnecting: Bool = false private var vpnManager = VPNManager.shared enum ShowMode: String, CaseIterable { case resource = "访问资源" case device = "成员设备" } var body: some View { VStack(spacing: 0) { // 头部:使用原生毛玻璃材质 headerSection Divider() // 内容区 ZStack { VisualEffectView(material: .windowBackground, blendingMode: .behindWindow) .ignoresSafeArea() ScrollView { VStack(spacing: 20) { switch connectState { case .waitAuth: statusLoadingView case .connected: connectedContent case .disconnected: disconnectedContent } } .padding(24) } .scrollIndicators(.hidden) } } .frame(minWidth: 550, minHeight: 480) .onAppear { syncState(vpnManager.vpnStatus) } .onChange(of: vpnManager.vpnStatus) { _, newStatus in withAnimation(.snappy) { syncState(newStatus) } } } } // MARK: - 视图组件扩展 extension NetworkView { private var headerSection: some View { HStack(spacing: 16) { // 状态指示器 statusIndicator VStack(alignment: .leading, spacing: 2) { Text(userContext.networkSession?.networkName ?? "未连接网络") .font(.system(size: 14, weight: .semibold)) if connectState == .connected { Text("虚拟局域网 IP: \(networkModel.networkContext.ip)") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) } else { Text("PunchNet 服务未就绪").font(.caption).foregroundColor(.secondary) } } Spacer() if connectState == .connected { Picker("", selection: $showMode) { ForEach(ShowMode.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, 16) .background(VisualEffectView(material: .headerView, blendingMode: .withinWindow)) } private var statusIndicator: some View { ZStack { Circle() .fill(connectState == .connected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) .frame(width: 40, height: 40) Image(systemName: connectState == .connected ? "checkmark.shield.fill" : "shield.slash.fill") .symbolRenderingMode(.hierarchical) .foregroundStyle(connectState == .connected ? Color.green : Color.secondary) .font(.system(size: 18)) .symbolEffect(.bounce, value: connectState == .connected) } } private var connectedContent: some View { VStack(spacing: 16) { if showMode == .resource { LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 16)], spacing: 16) { ForEach(networkModel.networkContext.resourceList, id: \.id) { res in ResourceItemCard(resource: res) } } } else { VStack(spacing: 0) { let nodes = networkModel.networkContext.nodeList ForEach(nodes, id: \.id) { node in DeviceItemRow(node: node, isLast: node.id == nodes.last?.id) } } .background(Color(NSColor.controlBackgroundColor).opacity(0.4)) .cornerRadius(12) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.08), lineWidth: 1)) } } } private var disconnectedContent: some View { VStack(spacing: 24) { Spacer().frame(height: 40) Image(systemName: "antenna.radiowaves.left.and.right") .font(.system(size: 48, weight: .ultraLight)) .foregroundStyle(.tertiary) .symbolEffect(.pulse, options: .repeating) VStack(spacing: 8) { Text("尚未接入网络").font(.headline) Text("连接后即可访问内部资源与成员节点").font(.subheadline).foregroundColor(.secondary) } Button(action: { startConnection() }) { if isConnecting { ProgressView().controlSize(.small).frame(width: 110) } else { Text("建立安全连接").fontWeight(.medium).frame(width: 110) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(isConnecting) Spacer() } } private var statusLoadingView: some View { VStack(spacing: 16) { ProgressView() Text("正在同步网络状态...").font(.subheadline).foregroundColor(.secondary) } .frame(maxWidth: .infinity, minHeight: 300) } private func syncState(_ status: VPNManager.VPNStatus) { switch status { case .connected: connectState = .connected case .disconnected: connectState = .disconnected @unknown default: connectState = .disconnected } } private func startConnection() { isConnecting = true Task { do { guard let session = userContext.networkSession else { await MainActor.run { isConnecting = false } return } try await networkModel.connect(networkSession: session) let context = networkModel.networkContext if let options = SystemConfig.getOptions( networkId: UInt32(session.networkId), networkDomain: session.networkDomain, ip: context.ip, maskLen: context.maskLen, accessToken: session.accessToken, identityId: context.identityId, hostname: context.hostname, noticePort: appContext.noticePort ) { try await vpnManager.enableVpn(options: options) } } catch { print("Connection error: \(error)") } await MainActor.run { isConnecting = false } } } } // MARK: - 子组件:资源卡片 struct ResourceItemCard: View { let resource: Resource @State private var isHovered = false var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: "safari.fill") .foregroundColor(.accentColor) .font(.title3) Spacer() Circle() .fill(Color.green) .frame(width: 6, height: 6) .shadow(color: .green.opacity(0.5), radius: 2) } Text(resource.name) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) Text(resource.url) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) } .padding(14) .background(isHovered ? Color.primary.opacity(0.05) : Color(NSColor.controlBackgroundColor).opacity(0.3)) .cornerRadius(12) .overlay(RoundedRectangle(cornerRadius: 12).stroke(isHovered ? Color.accentColor.opacity(0.3) : Color.primary.opacity(0.08), lineWidth: 1)) .onHover { isHovered = $0 } .contextMenu { Button("复制链接") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(resource.url, forType: .string) } Button("在浏览器打开") { if let url = URL(string: resource.url) { NSWorkspace.shared.open(url) } } } } } // MARK: - 子组件:设备行 struct DeviceItemRow: View { let node: Node let isLast: Bool @State private var isHovered = false var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { Image(systemName: "desktopcomputer") .font(.title3) .foregroundStyle(isHovered ? .primary : .secondary) 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) } Spacer() HStack(spacing: 4) { Text("直连").font(.system(size: 9, weight: .bold)) .padding(.horizontal, 4).padding(.vertical, 1) .background(Color.accentColor.opacity(0.1)).foregroundColor(.accentColor).cornerRadius(3) Circle().fill(Color.green).frame(width: 6, height: 6) } } .padding(.vertical, 12) .padding(.horizontal, 16) .contentShape(Rectangle()) .background(isHovered ? Color.primary.opacity(0.03) : Color.clear) .onHover { isHovered = $0 } if !isLast { Divider().padding(.leading, 44).opacity(0.5) } } .contextMenu { Button("复制 IP 地址") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(node.ip, forType: .string) } Button("终端 SSH 连接") { /* 呼起终端逻辑 */ } } } }