// // NetworkView.swift // punchnet import SwiftUI import Observation // MARK: - 基础模型协议 (确保代码可编译,请根据实际 Model 调整) enum ConnectState { case waitAuth, connected, 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) { // 1. 头部区域 (Header) headerSection Divider() // 2. 内容区域 (Content) Group { switch connectState { case .waitAuth: NetworkWaitAuthView(networkModel: networkModel) case .connected: connectedContent case .disconnected: disconnectedContent } } .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) } } } } // 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, 14) .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: 36, height: 36) Image(systemName: connectState == .connected ? "checkmark.shield.fill" : "shield.slash.fill") .symbolRenderingMode(.hierarchical) .foregroundStyle(connectState == .connected ? Color.green : Color.secondary) .font(.system(size: 16)) } } @ViewBuilder private var connectedContent: some View { if showMode == .resource { // 资源视图:网格布局 ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 200), spacing: 16)], spacing: 16) { ForEach(networkModel.networkContext.resourceList, id: \.id) { res in ResourceItemCard(resource: res) } } .padding(20) } .transition(.opacity) } else { // 设备视图:双栏布局 NetworkDeviceGroupView(networkModel: networkModel) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity)) } } private var disconnectedContent: 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: { startConnection() }) { if isConnecting { ProgressView().controlSize(.small).frame(width: 80) } else { Text("建立安全连接").frame(width: 80) } } .buttonStyle(.borderedProminent) .disabled(isConnecting) Spacer() } } 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 { 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: - 设备组视图 (NavigationSplitView) struct NetworkDeviceGroupView: View { @Bindable var networkModel: NetworkModel @State private var selectedId: Int? var body: some View { NavigationSplitView { List(networkModel.networkContext.nodeList, id: \.id, selection: $selectedId) { node in NetworkNodeHeadView(node: node) .tag(node.id) } .listStyle(.sidebar) .navigationSplitViewColumnWidth(min: 200, ideal: 220) } detail: { if let selectedNode = networkModel.networkContext.nodeList.first(where: { $0.id == selectedId }) { NetworkNodeDetailView(node: selectedNode) } else { ContentUnavailableView("选择成员设备", systemImage: "macbook.and.iphone", description: Text("查看详细网络信息和服务")) } } .onAppear { if selectedId == nil { selectedId = networkModel.networkContext.nodeList.first?.id } } } } // MARK: - 子组件 struct NetworkNodeHeadView: View { var node: 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(UserContext.self) var userContext var node: Node @State private var resources: [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(id: node.id) { await loadNodeResources(id: node.id) } } private func loadNodeResources(id: Int) async { guard let session = userContext.networkSession else { return } isLoading = true defer { isLoading = false } let params: [String: Any] = [ "client_id": SystemConfig.getClientId(), "access_token": session.accessToken, "id": id ] if let detail = try? await SDLAPIClient.doPost(path: "/get_node_resources", params: params, as: NodeDetail.self) { self.resources = detail.resourceList } } } struct ResourceItemCard: View { let resource: 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) Text(resource.url).font(.caption2).foregroundColor(.secondary).lineLimit(1) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color(NSColor.controlBackgroundColor).opacity(isHovered ? 0.8 : 0.4)) .cornerRadius(10) .onHover { isHovered = $0 } } } struct NetworkWaitAuthView: View { @Bindable var networkModel: NetworkModel var body: some View { VStack(spacing: 16) { ProgressView() Text("等待认证确认中...").foregroundColor(.secondary) } } }