punchnet-macos/punchnet/Views/Network/NetworkView.swift
2026-03-24 01:10:06 +08:00

379 lines
13 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
// MARK: -
enum ConnectState {
case waitAuth
case connected
case disconnected
}
//
enum NetworkShowMode: String, CaseIterable {
case resource = "访问资源"
case device = "成员设备"
}
// MARK: -
struct NetworkView: View {
@Environment(AppContext.self) var appContext
@Environment(\.openWindow) private var openWindow
@State private var networkModel = NetworkModel()
@State private var showMode: NetworkShowMode = .resource
@State private var connectState: ConnectState = .disconnected
private var vpnManager = VPNManager.shared
var body: some View {
VStack(spacing: 0) {
// 1. (Header)
HStack(spacing: 16) {
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))
}
VStack(alignment: .leading, spacing: 2) {
Text(appContext.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(NetworkShowMode.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))
Divider()
// 2. (Content)
Group {
switch connectState {
case .waitAuth:
NetworkWaitAuthView(networkModel: networkModel)
case .connected:
NetworkConnectedView(showMode: $showMode, networkModel: networkModel)
case .disconnected:
NetworkDisconnectedView()
}
}
.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)
}
}
}
private func syncState(_ status: VPNManager.VPNStatus) {
switch status {
case .connected: connectState = .connected
case .disconnected: connectState = .disconnected
@unknown default: connectState = .disconnected
}
}
}
struct NetworkConnectedView: View {
@Binding var showMode: NetworkShowMode
@Bindable var networkModel: NetworkModel
var body: some View {
if showMode == .resource {
//
ScrollView {
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
], spacing: 10) {
ForEach(networkModel.networkContext.resourceList, id: \.uuid) { res in
ResourceItemCard(resource: res)
}
}
.padding(20)
}
.transition(.opacity)
.frame(maxWidth: .infinity)
} else {
//
NetworkDeviceGroupView(networkModel: networkModel)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
}
}
}
struct NetworkDisconnectedView: View {
@State private var isConnecting: Bool = false
@Environment(AppContext.self) private var appContext: AppContext
var body: 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: {
Task { @MainActor in
await startConnection()
}
}) {
if isConnecting {
ProgressView()
.controlSize(.small)
.frame(width: 80)
} else {
Text("建立安全连接")
.frame(width: 80)
}
}
.buttonStyle(.borderedProminent)
.disabled(isConnecting)
Spacer()
}
}
private func startConnection() async {
self.isConnecting = true
defer {
self.isConnecting = false
}
do {
guard let session = appContext.networkSession else {
return
}
let context = try await SDLAPIClient.connectNetwork(networkSession: session)
// app线
self.appContext.networkContext = context
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.shared.enableVpn(options: options)
}
} catch {
print("Connection error: \(error)")
}
}
}
// 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: SDLAPIClient.NetworkContext.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(AppContext.self) private var appContext: AppContext
var node: SDLAPIClient.NetworkContext.Node
@State private var resources: [SDLAPIClient.NetworkContext.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 {
await loadNodeResources(id: node.id)
}
}
//
private func loadNodeResources(id: Int) async {
guard let session = appContext.networkSession else {
return
}
self.isLoading = true
defer {
self.isLoading = false
}
self.resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: id)
}
}
struct ResourceItemCard: View {
let resource: SDLAPIClient.NetworkContext.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)
.truncationMode(.tail)
Text(resource.url)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.padding()
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray, lineWidth: 1)
)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(isHovered ? NSColor.selectedControlColor : NSColor.controlBackgroundColor))
)
.onHover {
isHovered = $0
}
}
}
struct NetworkWaitAuthView: View {
@Bindable var networkModel: NetworkModel
var body: some View {
VStack(spacing: 16) {
ProgressView()
Text("等待认证确认中...")
.foregroundColor(.secondary)
}
}
}