punchnet-macos/punchnet/Views/Network/NetworkView.swift
2026-03-19 17:28:21 +08:00

324 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 连接") {
/* */
}
}
}
}