158 lines
5.8 KiB
Swift
158 lines
5.8 KiB
Swift
//
|
||
// NetworkStatusBar.swift
|
||
// punchnet
|
||
//
|
||
// Created by 安礼成 on 2026/4/17.
|
||
//
|
||
import SwiftUI
|
||
|
||
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.nodeNameWithIp, systemImage: "checkmark")
|
||
} else {
|
||
Text(option.nodeNameWithIp)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("出口节点")
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundColor(.secondary)
|
||
|
||
Text(self.model.exitNodeTitle)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
|
||
Text(self.model.exitNodeSubtitle)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
|
||
if self.model.isUpdatingExitNode {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
} else {
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.frame(width: 220, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(Color.primary.opacity(0.04))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12, 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)
|
||
}
|
||
}
|
||
|
||
struct ExitNodeOption: Identifiable, Equatable {
|
||
let id: Int
|
||
let nodeName: String
|
||
let ip: String
|
||
let system: String?
|
||
|
||
var nodeNameWithIp: String {
|
||
"\(nodeName) (\(ip))"
|
||
}
|
||
}
|