2026-04-17 14:58:36 +08:00

479 lines
16 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
//
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()
}
}
)
}
}
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))"
}
}
struct NetworkConnectedView: View {
var model: NetworkModel
var body: some View {
if self.model.showMode == .resource {
//
ScrollView {
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
], spacing: 10) {
ForEach(self.model.resourceList, id: \.uuid) { res in
ResourceItemCard(resource: res)
}
}
.padding(20)
}
.transition(.opacity)
.frame(maxWidth: .infinity)
} else {
//
NetworkDeviceGroupView(model: self.model)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
}
}
}
struct NetworkDisconnectedView: View {
var model: NetworkModel
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 self.model.connect()
}
}) {
Text("建立安全连接")
.frame(width: 80)
}
.buttonStyle(.borderedProminent)
.disabled(self.model.phase == .connecting || self.model.networkSession == nil)
Spacer()
}
}
}
// MARK: - (NavigationSplitView)
struct NetworkDeviceGroupView: View {
var model: NetworkModel
//
private let sidebarWidth: CGFloat = 240
var body: some View {
let selectedIdBinding = Binding(
get: { self.model.selectedNodeId },
set: { newValue in
self.model.selectNode(id: newValue)
}
)
HStack(spacing: 0) {
// --- 1. (Sidebar) ---
VStack(alignment: .leading, spacing: 0) {
// macOS 绿
// WindowStyle .hiddenTitleBar Padding
Color.clear.frame(height: 28)
List(self.model.nodeList, id: \.id, selection: selectedIdBinding) { node in
NetworkNodeHeadView(node: node)
// HStack tag List selection
.tag(node.id)
.listRowSeparator(.hidden)
}
.listStyle(.inset) // 使 inset
.scrollContentBackground(.hidden) //
}
.frame(width: sidebarWidth)
Divider() // 线
// --- 2. (Detail) ---
ZStack {
if let selectedNode = self.model.selectedNode {
NetworkNodeDetailView(model: self.model, node: selectedNode)
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
} else {
ContentUnavailableView(
"选择成员设备",
systemImage: "macbook.and.iphone",
description: Text("查看详细网络信息和服务")
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor)) // 使
}
.ignoresSafeArea() //
}
}
// 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 {
var model: NetworkModel
var node: SDLAPIClient.NetworkContext.Node
var body: some View {
List {
Section("节点信息") {
LabeledContent("连接状态", value: node.connectionStatus)
LabeledContent("虚拟IPv4", value: node.ip)
LabeledContent("系统环境", value: node.system ?? "未知")
}
Section("提供的服务") {
if self.model.isLoadingResources(for: node.id) {
ProgressView()
.controlSize(.small)
} else if self.model.resources(for: node.id).isEmpty {
Text("该节点暂未发布资源")
.foregroundColor(.secondary)
.font(.callout)
} else {
ForEach(self.model.resources(for: node.id), id: \.id) { res in
VStack(alignment: .leading) {
Text(res.name)
.font(.body)
Text(res.url)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
.task(id: self.node.id) {
await self.model.loadResourcesIfNeeded(for: self.node.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 phase: NetworkConnectionPhase
var body: some View {
VStack(spacing: 16) {
ProgressView()
Text(self.phase == .disconnecting ? "正在断开网络..." : "正在建立安全连接...")
.foregroundColor(.secondary)
}
}
}