diff --git a/punchnet/Views/Network/NetworkView.swift b/punchnet/Views/Network/NetworkView.swift index 03baf20..2f4f2fb 100644 --- a/punchnet/Views/Network/NetworkView.swift +++ b/punchnet/Views/Network/NetworkView.swift @@ -1,11 +1,8 @@ // // NetworkView.swift // punchnet -// -// Created by 安礼成 on 2026/1/16. -// - import SwiftUI +import Observation // 连接状态 enum ConnectState { @@ -14,353 +11,313 @@ enum ConnectState { case disconnected } +// MARK: - 主网络视图 struct NetworkView: View { - @Environment(UserContext.self) var userContext: UserContext + @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 isOn: Bool = false - @State private var vpnManager: VPNManager = VPNManager.shared + @State private var isConnecting: Bool = false - // 展示状态 - enum ShowMode { - case resource - case device + private var vpnManager = VPNManager.shared + + enum ShowMode: String, CaseIterable { + case resource = "访问资源" + case device = "成员设备" } var body: some View { - VStack { - HStack { - - VStack { - HStack(alignment: .center) { - Text(userContext.networkSession?.networkName ?? "未知") - Text(">") - Spacer() - } - - HStack { - Toggle("", isOn: $isOn) - .toggleStyle(SwitchToggleStyle(tint: .green)) - .disabled(true) - - Text("已连接") - - Spacer() - } - } - .frame(width: 320) - - if self.connectState == .connected { - // 显示设备和资源选项 - HStack { - Button { - self.showMode = .resource - } label: { - Text("资源") - } - - Button { - self.showMode = .device - } label: { - Text("设备") - } - } - } - Spacer() - } + VStack(spacing: 0) { + // 头部:使用原生毛玻璃材质 + headerSection - Group { - switch self.connectState { - case .waitAuth: - NetworkWaitAuthView(networkModel: self.networkModel) - case .connected: - Group { - switch self.showMode { - case .resource: - NetworkResourceGroupView(networkModel: self.networkModel) - case .device: - NetworkDeviceGroupView(networkModel: self.networkModel) + Divider() + + // 内容区 + ZStack { + VisualEffectView(material: .windowBackground, blendingMode: .behindWindow) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + switch connectState { + case .waitAuth: + statusLoadingView + case .connected: + connectedContent + case .disconnected: + disconnectedContent } } - case .disconnected: - NetworkDisconnctedView(networkModel: self.networkModel) + .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() - } - .padding(.top, 10) - .padding(.leading, 10) - .onChange(of: vpnManager.vpnStatus) { _, newState in - NSLog("print view change: \(newState)") - switch newState { - case .connected: - self.connectState = .connected - self.isOn = true - case .disconnected: - self.connectState = .disconnected - self.isOn = false - } - } - .toolbar { - if self.connectState == .connected { - ToolbarItem(placement: .primaryAction) { - Button { - openWindow(id: "settings") - } label: { - Image(systemName: "gearshape") - } + + 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)) } -} - -// 网络处于未连接状态 -struct NetworkDisconnctedView: View { - @Bindable var networkModel: NetworkModel - @Environment(AppContext.self) var appContext: AppContext - @Environment(UserContext.self) var userContext: UserContext - @State private var showAlert = false - @State private var errorMessage = "" - - var body: some View { + private var statusIndicator: some View { ZStack { - Color.clear + Circle() + .fill(connectState == .connected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) + .frame(width: 40, height: 40) - VStack { - Button { - Task { @MainActor in - do { - try await self.connect() - try await self.startVpn() - } catch let err { - self.showAlert = true - self.errorMessage = err.localizedDescription - } + 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) } - } label: { - Text("连接") - .font(.system(size: 14, weight: .regular)) - .padding([.top, .bottom], 8) - .padding([.leading, .trailing], 30) - .foregroundColor(.white) - - } - .background(Color(red: 74/255, green: 207/255, blue: 154/255)) - .cornerRadius(5) - .frame(width: 120, height: 35) - - Button { - Task { - try await VPNManager.shared.disableVpn() - } - } label: { - Text("关闭") - .font(.system(size: 14, weight: .regular)) - .padding([.top, .bottom], 8) - .padding([.leading, .trailing], 30) - .foregroundColor(.white) - - } - .background(Color(red: 74/255, green: 207/255, blue: 154/255)) - .cornerRadius(5) - .frame(width: 120, height: 35) - } - } - .alert(isPresented: $showAlert) { - Alert(title: Text("提示"), message: Text(self.errorMessage)) - } - - } - - private func connect() async throws { - guard let networkSession = userContext.networkSession else { - return - } - try await networkModel.connect(networkSession: networkSession) - } - - // 执行登陆操作 - private func startVpn() async throws { - guard let networkSession = userContext.networkSession else { - return - } - - let networkContext = networkModel.networkContext - let options = SystemConfig.getOptions(networkId: UInt32(networkSession.networkId), - networkDomain: networkSession.networkDomain, - ip: networkContext.ip, - maskLen: networkContext.maskLen, - accessToken: networkSession.accessToken, - identityId: networkContext.identityId, - hostname: networkContext.hostname, - noticePort: self.appContext.noticePort) - // token存在则优先使用token - try await VPNManager.shared.enableVpn(options: options!) - } - -} - -// 网络处于连接状态 - -// 显示资源信息 -struct NetworkResourceGroupView: View { - @Bindable var networkModel: NetworkModel - - var body: some View { - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 8) { - ForEach(self.networkModel.networkContext.resourceList, id: \.id) { resource in - NetworkResourceView(resource: resource) - } - } - } -} - -struct NetworkResourceView: View { - var resource: Resource - - var body: some View { - VStack { - HStack { - Text(resource.connectionStatus) - - Text(resource.name) - .font(.system(size: 14, weight: .regular)) - } - - Text(resource.url) - .font(.system(size: 14, weight: .regular)) - .padding(.leading, 30) - - } - } -} - -// 显示设备信息 -struct NetworkDeviceGroupView: View { - @Bindable var networkModel: NetworkModel - @State private var selectedId: Int? - - var body: some View { - NavigationSplitView { - List(self.networkModel.networkContext.nodeList, id: \.id, selection: $selectedId) { node in - NetworkNodeHeadView(node: node) - } - .listStyle(.sidebar) - .onChange(of: selectedId) { - self.networkModel.changeSelectedNode(nodeId: selectedId) - } - .onAppear { - if selectedId == nil { - selectedId = self.networkModel.networkContext.nodeList.first?.id - } - } - } detail: { - NetworkNodeDetailView(node: networkModel.selectedNode) - } - } - -} - -struct NetworkNodeHeadView: View { - var node: Node - - var body: some View { - VStack { - HStack { - Text(node.connectionStatus) - - Text(node.name) - .font(.system(size: 14, weight: .regular)) - } - - Text(node.ip) - .font(.system(size: 14, weight: .regular)) - .padding(.leading, 30) - } - } -} - -struct NetworkNodeDetailView: View { - @Environment(UserContext.self) var userContext: UserContext - var node: Node? - @State private var resources: [Resource] = [] - - var body: some View { - Group { - if let node { - List { - Section { - HStack { - Text("连接状态") - Text("\(node.connectionStatus)") - Spacer() - } - - HStack { - Text("虚拟IPv4") - Text("\(node.ip)") - Spacer() - } - - HStack { - Text("操作系统") - Text(node.system ?? "未知") - Spacer() - } - } - - Section("服务列表") { - ForEach(resources, id: \.id) { resource in - HStack { - Text("\(resource.name)") - Text("\(resource.url)") - } - } - } - } - .task(id: node.id) { - await self.loadNodeResources(id: node.id) } } else { - EmptyView() + 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 func loadNodeResources(id: Int) async { - guard let networkSession = userContext.networkSession else { - return + 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() } - - let params: [String: Any] = [ - "client_id": SystemConfig.getClientId(), - "access_token": networkSession.accessToken, - "id": id - ] - - if let nodeDetail = try? await SDLAPIClient.doPost(path: "/get_node_resources", params: params, as: NodeDetail.self) { - self.resources = nodeDetail.resourceList + } + + 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 } } } } -struct NetworkWaitAuthView: View { - @Bindable var networkModel: NetworkModel +// MARK: - 子组件:资源卡片 +struct ResourceItemCard: View { + let resource: Resource + @State private var isHovered = false var body: some View { - Color.clear - .overlay { - Text("等待确认中") + 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) + } + } + } } } -#Preview { - NetworkView() +// 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 连接") { + /* 呼起终端逻辑 */ + } + } + } }