punchnet-macos/punchnet/Views/Network/NetworkView.swift
2026-03-25 16:09:06 +08:00

394 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: AppContext
@Environment(\.openWindow) var openWindow
@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) {
NetworkStatusBar()
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()
case .connected:
NetworkConnectedView(showMode: $showMode)
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)
}
}
}
// VPN
private func syncState(_ status: VPNManager.VPNStatus) {
switch status {
case .connected:
connectState = .connected
case .disconnected:
connectState = .disconnected
@unknown default:
connectState = .disconnected
}
}
}
struct NetworkStatusBar: View {
@Environment(AppContext.self) private var appContext
@State private var vpnManger = VPNManager.shared
var body: some View {
let isOnBinding = Binding(
get: { vpnManger.isConnected },
set: { newValue in
if newValue {
Task {
try? await self.appContext.connectNetwork()
}
} else {
Task {
try? await self.appContext.disconnectNetwork()
}
}
}
)
HStack(spacing: 12) {
//
HStack(spacing: 20) {
ZStack {
Circle()
.fill(vpnManger.isConnected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05))
.frame(width: 36, height: 36)
Image(systemName: vpnManger.isConnected ? "checkmark.shield.fill" : "shield.slash.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(vpnManger.isConnected ? Color.green : Color.secondary)
.font(.system(size: 16))
}
VStack(alignment: .leading, spacing: 1) {
if let networkSession = appContext.networkSession {
Text(networkSession.networkName)
.font(.system(size: 12, weight: .semibold))
Text("局域网IP: \(appContext.networkContext.ip)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
}
}
}
// Switch
// 使 Binding /
Toggle("", isOn: isOnBinding)
.toggleStyle(.switch)
.controlSize(.small) // macOS 使 small
}
.padding(.vertical, 5)
}
}
struct NetworkConnectedView: View {
@Environment(AppContext.self) private var appContext: AppContext
@Binding var showMode: NetworkShowMode
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(appContext.networkContext.resourceList, id: \.uuid) { res in
ResourceItemCard(resource: res)
}
}
.padding(20)
}
.transition(.opacity)
.frame(maxWidth: .infinity)
} else {
//
NetworkDeviceGroupView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
}
}
}
struct NetworkDisconnectedView: View {
@Environment(AppContext.self) private var appContext: AppContext
@State private var isConnecting: Bool = false
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 {
try await self.appContext.connectNetwork()
} catch let err {
print("Connection error: \(err)")
}
}
}
// MARK: - (NavigationSplitView)
struct NetworkDeviceGroupView: View {
@Environment(AppContext.self) private var appContext: AppContext
@State private var selectedId: Int?
var body: some View {
NavigationSplitView {
List(appContext.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 = appContext.networkContext.getNode(id: selectedId) {
NetworkNodeDetailView(node: selectedNode)
} else {
ContentUnavailableView("选择成员设备", systemImage: "macbook.and.iphone", description: Text("查看详细网络信息和服务"))
}
}
.onAppear {
if selectedId == nil {
selectedId = appContext.networkContext.firstNodeId()
}
}
}
}
// 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 {
var body: some View {
VStack(spacing: 16) {
ProgressView()
Text("等待认证确认中...")
.foregroundColor(.secondary)
}
}
}