324 lines
11 KiB
Swift
324 lines
11 KiB
Swift
//
|
||
// 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 连接") {
|
||
/* 呼起终端逻辑 */
|
||
}
|
||
}
|
||
}
|
||
}
|