punchnet-macos/punchnet/Views/Network/NetworkView.swift
2026-03-24 00:05:17 +08:00

383 lines
12 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, connected, disconnected
}
// MARK: -
struct NetworkView: View {
@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) {
// 1. (Header)
headerSection
Divider()
// 2. (Content)
Group {
switch connectState {
case .waitAuth:
NetworkWaitAuthView(networkModel: networkModel)
case .connected:
connectedContent
case .disconnected:
disconnectedContent
}
}
.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)
}
}
}
}
// MARK: -
extension NetworkView {
private var headerSection: some View {
HStack(spacing: 16) {
statusIndicator
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(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, 14)
.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: 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))
}
}
@ViewBuilder
private var connectedContent: 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))
}
}
private var disconnectedContent: 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: { startConnection() }) {
if isConnecting {
ProgressView()
.controlSize(.small)
.frame(width: 80)
} else {
Text("建立安全连接")
.frame(width: 80)
}
}
.buttonStyle(.borderedProminent)
.disabled(isConnecting)
Spacer()
}
}
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 = appContext.networkSession else {
return
}
let context = try await networkModel.connect(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.enableVpn(options: options)
}
} catch {
print("Connection error: \(error)")
}
await MainActor.run {
isConnecting = false
}
}
}
}
// 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: 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: Node
@State private var resources: [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(id: node.id) {
await loadNodeResources(id: node.id)
}
}
private func loadNodeResources(id: Int) async {
guard let session = appContext.networkSession else {
return
}
isLoading = true
defer {
isLoading = false
}
let params: [String: Any] = [
"client_id": SystemConfig.getClientId(),
"access_token": session.accessToken,
"id": id
]
if let detail = try? await SDLAPIClient.doPost(path: "/get_node_resources", params: params, as: NodeDetail.self) {
self.resources = detail.resourceList
}
}
}
struct ResourceItemCard: View {
let resource: 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)
}
}
}