255 lines
9.2 KiB
Swift
255 lines
9.2 KiB
Swift
//
|
||
// NetworkView.swift
|
||
// punchnet
|
||
import SwiftUI
|
||
import Observation
|
||
|
||
// 资源展示模式
|
||
enum NetworkShowMode: String, CaseIterable {
|
||
case resource = "访问资源"
|
||
case device = "成员设备"
|
||
}
|
||
|
||
// MARK: - 网络连接状态
|
||
enum NetworkConnectionPhase {
|
||
case disconnected
|
||
case connecting
|
||
case connected
|
||
case disconnecting
|
||
}
|
||
|
||
// MARK: - 主网络视图
|
||
struct NetworkView: View {
|
||
@Environment(AppContext.self) var appContext: AppContext
|
||
@Environment(\.openWindow) var openWindow
|
||
|
||
@State private var networkModel = NetworkModel()
|
||
|
||
var body: some View {
|
||
@Bindable var networkModel = self.networkModel
|
||
|
||
VStack(spacing: 0) {
|
||
// 1. 头部区域 (Header)
|
||
HStack(spacing: 16) {
|
||
NetworkStatusBar(model: self.networkModel)
|
||
|
||
Spacer()
|
||
|
||
if self.networkModel.shouldShowModePicker {
|
||
Picker("", selection: $networkModel.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 self.networkModel.phase {
|
||
case .connecting, .disconnecting:
|
||
NetworkWaitAuthView(phase: self.networkModel.phase)
|
||
case .connected:
|
||
NetworkConnectedView(model: self.networkModel)
|
||
case .disconnected:
|
||
NetworkDisconnectedView(model: self.networkModel)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow))
|
||
}
|
||
.frame(minWidth: 700, minHeight: 500) // 适当调大宽度以适应 SplitView
|
||
.task {
|
||
await self.networkModel.activate(appContext: self.appContext)
|
||
}
|
||
.alert("提示", isPresented: self.errorPresented) {
|
||
Button("确定", role: .cancel) {
|
||
self.networkModel.clearError()
|
||
}
|
||
} message: {
|
||
Text(self.networkModel.errorMessage ?? "")
|
||
}
|
||
}
|
||
|
||
private var errorPresented: Binding<Bool> {
|
||
Binding(
|
||
get: { self.networkModel.errorMessage != nil },
|
||
set: { isPresented in
|
||
if !isPresented {
|
||
self.networkModel.clearError()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct NetworkStatusBar: View {
|
||
var model: NetworkModel
|
||
|
||
var body: some View {
|
||
let isOnBinding = Binding(
|
||
get: { self.model.isTunnelEnabled },
|
||
set: { newValue in
|
||
Task { @MainActor in
|
||
await self.model.setConnectionEnabled(newValue)
|
||
}
|
||
}
|
||
)
|
||
|
||
HStack(spacing: 12) {
|
||
// 左侧:状态指示器与文字
|
||
HStack(spacing: 20) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(self.model.isTunnelEnabled ? Color.green.opacity(0.15) : Color.primary.opacity(0.05))
|
||
.frame(width: 36, height: 36)
|
||
|
||
Image(systemName: self.model.isTunnelEnabled ? "checkmark.shield.fill" : "shield.slash.fill")
|
||
.symbolRenderingMode(.hierarchical)
|
||
.foregroundStyle(self.model.isTunnelEnabled ? Color.green : Color.secondary)
|
||
.font(.system(size: 16))
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
if let networkSession = self.model.networkSession {
|
||
Text(networkSession.networkName)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
|
||
Text("局域网IP: \(self.model.networkContext?.ip ?? "0.0.0.0")")
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
} else {
|
||
Text("未登录网络")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
|
||
Text("登录后可建立连接")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
if self.model.networkSession != nil {
|
||
exitNodeMenu
|
||
}
|
||
|
||
// 右侧:Switch 开关
|
||
// 注意:这里使用 Binding 手动接管连接/断开逻辑
|
||
Toggle("", isOn: isOnBinding)
|
||
.toggleStyle(.switch)
|
||
.controlSize(.small) // macOS 顶部栏或面板推荐使用 small 尺寸
|
||
.disabled(self.model.phase == .connecting || self.model.phase == .disconnecting || self.model.networkSession == nil)
|
||
}
|
||
.padding(.vertical, 5)
|
||
}
|
||
|
||
private var exitNodeMenu: some View {
|
||
Menu {
|
||
Button {
|
||
Task { @MainActor in
|
||
await self.model.updateExitNodeSelection(nil)
|
||
}
|
||
} label: {
|
||
if self.model.selectedExitNode == nil {
|
||
Label("不设置", systemImage: "checkmark")
|
||
} else {
|
||
Text("不设置")
|
||
}
|
||
}
|
||
|
||
if !self.model.exitNodeOptions.isEmpty {
|
||
Divider()
|
||
|
||
ForEach(self.model.exitNodeOptions) { option in
|
||
Button {
|
||
Task { @MainActor in
|
||
await self.model.updateExitNodeSelection(option.ip)
|
||
}
|
||
} label: {
|
||
if self.model.selectedExitNode?.ip == option.ip {
|
||
Label(option.nodeName, systemImage: "checkmark")
|
||
} else {
|
||
Text(option.nodeName)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color.blue.opacity(0.1))
|
||
.frame(width: 24, height: 24)
|
||
|
||
Image(systemName: "arrow.triangle.branch")
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(.blue)
|
||
}
|
||
|
||
Text(self.model.selectedExitNode?.nodeName ?? "出口节点")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
|
||
if let selectedExitNode = self.model.selectedExitNode {
|
||
Text(selectedExitNode.ip)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 3)
|
||
.background(
|
||
Capsule(style: .continuous)
|
||
.fill(Color.primary.opacity(0.05))
|
||
)
|
||
.lineLimit(1)
|
||
} else {
|
||
Text("未设置")
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
if self.model.isUpdatingExitNode {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
} else {
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.frame(minWidth: 156, maxWidth: 210, alignment: .leading)
|
||
.background(
|
||
Capsule(style: .continuous)
|
||
.fill(Color.primary.opacity(0.04))
|
||
)
|
||
.overlay(
|
||
Capsule(style: .continuous)
|
||
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!self.model.canSelectExitNode)
|
||
.opacity(self.model.canSelectExitNode ? 1 : 0.7)
|
||
.help(self.model.exitNodeHelpText)
|
||
}
|
||
}
|