887 lines
28 KiB
Swift
887 lines
28 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
|
||
}
|
||
|
||
@MainActor
|
||
@Observable
|
||
final class NetworkModel {
|
||
@ObservationIgnored
|
||
private weak var appContext: AppContext?
|
||
|
||
@ObservationIgnored
|
||
private let vpnManager = VPNManager.shared
|
||
|
||
@ObservationIgnored
|
||
private var vpnStatusTask: Task<Void, Never>?
|
||
|
||
var showMode: NetworkShowMode = .resource
|
||
var phase: NetworkConnectionPhase = .disconnected
|
||
|
||
var networkSession: SDLAPIClient.NetworkSession?
|
||
var networkContext: SDLAPIClient.NetworkContext?
|
||
var selectedExitNodeIp: String?
|
||
var selectedNodeId: Int?
|
||
|
||
private(set) var nodeResourcesById: [Int: [SDLAPIClient.NetworkContext.Resource]] = [:]
|
||
private(set) var loadingNodeIDs: Set<Int> = []
|
||
private(set) var isUpdatingExitNode: Bool = false
|
||
private(set) var errorMessage: String?
|
||
|
||
deinit {
|
||
self.vpnStatusTask?.cancel()
|
||
}
|
||
|
||
var isBusy: Bool {
|
||
switch self.phase {
|
||
case .connecting, .disconnecting:
|
||
return true
|
||
case .connected, .disconnected:
|
||
return self.isUpdatingExitNode
|
||
}
|
||
}
|
||
|
||
var isTunnelEnabled: Bool {
|
||
switch self.phase {
|
||
case .connecting, .connected:
|
||
return true
|
||
case .disconnecting, .disconnected:
|
||
return false
|
||
}
|
||
}
|
||
|
||
var shouldShowModePicker: Bool {
|
||
self.phase == .connected
|
||
}
|
||
|
||
var resourceList: [SDLAPIClient.NetworkContext.Resource] {
|
||
self.networkContext?.resourceList ?? []
|
||
}
|
||
|
||
var nodeList: [SDLAPIClient.NetworkContext.Node] {
|
||
self.networkContext?.nodeList ?? []
|
||
}
|
||
|
||
var selectedNode: SDLAPIClient.NetworkContext.Node? {
|
||
self.networkContext?.getNode(id: self.selectedNodeId)
|
||
}
|
||
|
||
var canSelectExitNode: Bool {
|
||
guard self.networkSession != nil else {
|
||
return false
|
||
}
|
||
|
||
guard !self.isUpdatingExitNode else {
|
||
return false
|
||
}
|
||
|
||
switch self.phase {
|
||
case .connected:
|
||
return self.networkContext != nil || self.selectedExitNodeIp != nil
|
||
case .disconnected:
|
||
return self.selectedExitNodeIp != nil
|
||
case .connecting, .disconnecting:
|
||
return false
|
||
}
|
||
}
|
||
|
||
var exitNodeOptions: [ExitNodeOption] {
|
||
guard let networkContext = self.networkContext else {
|
||
return []
|
||
}
|
||
|
||
return networkContext.exitNodeList.compactMap { exitNode in
|
||
guard let node = networkContext.getNode(id: exitNode.nnid) else {
|
||
return nil
|
||
}
|
||
|
||
return ExitNodeOption(
|
||
id: exitNode.nnid,
|
||
nodeName: exitNode.nodeName,
|
||
ip: node.ip,
|
||
system: node.system
|
||
)
|
||
}
|
||
}
|
||
|
||
var selectedExitNode: ExitNodeOption? {
|
||
guard let selectedExitNodeIp = self.selectedExitNodeIp else {
|
||
return nil
|
||
}
|
||
|
||
return self.exitNodeOptions.first(where: { $0.ip == selectedExitNodeIp })
|
||
?? ExitNodeOption(
|
||
id: -1,
|
||
nodeName: "已保存出口节点",
|
||
ip: selectedExitNodeIp,
|
||
system: nil
|
||
)
|
||
}
|
||
|
||
var exitNodeTitle: String {
|
||
if self.isUpdatingExitNode {
|
||
return "正在切换..."
|
||
}
|
||
|
||
if let selectedExitNode = self.selectedExitNode {
|
||
return selectedExitNode.nodeName
|
||
}
|
||
|
||
return "未设置"
|
||
}
|
||
|
||
var exitNodeSubtitle: String {
|
||
if let selectedExitNode = self.selectedExitNode {
|
||
if let system = selectedExitNode.system, !system.isEmpty {
|
||
return "\(selectedExitNode.ip) · \(system)"
|
||
}
|
||
|
||
return selectedExitNode.ip
|
||
}
|
||
|
||
if self.networkContext == nil {
|
||
return "连接后可选择"
|
||
}
|
||
|
||
if self.exitNodeOptions.isEmpty {
|
||
return "当前网络没有可用节点"
|
||
}
|
||
|
||
return "当前流量保持默认出口"
|
||
}
|
||
|
||
var exitNodeHelpText: String {
|
||
if self.isUpdatingExitNode {
|
||
return "正在更新出口节点"
|
||
}
|
||
|
||
if self.networkContext == nil {
|
||
return "建立连接后可选择当前网络的出口节点"
|
||
}
|
||
|
||
return "切换当前网络流量的出口节点,也可以保持未设置"
|
||
}
|
||
|
||
func activate(appContext: AppContext) async {
|
||
if self.appContext !== appContext {
|
||
self.appContext = appContext
|
||
}
|
||
|
||
self.startObservingVPNStatusIfNeeded()
|
||
await self.handleVPNStatusChange(self.vpnManager.vpnStatus)
|
||
}
|
||
|
||
func clearError() {
|
||
self.errorMessage = nil
|
||
}
|
||
|
||
func setConnectionEnabled(_ enabled: Bool) async {
|
||
if enabled {
|
||
await self.connect()
|
||
} else {
|
||
await self.disconnect()
|
||
}
|
||
}
|
||
|
||
func connect() async {
|
||
guard let appContext = self.appContext else {
|
||
return
|
||
}
|
||
|
||
guard !self.isBusy else {
|
||
return
|
||
}
|
||
|
||
self.errorMessage = nil
|
||
self.phase = .connecting
|
||
|
||
do {
|
||
if appContext.networkContext == nil {
|
||
try await appContext.connectNetwork()
|
||
}
|
||
|
||
self.syncSharedStateFromAppContext()
|
||
await self.applyNetworkContext(appContext.networkContext)
|
||
try await appContext.startTun()
|
||
|
||
if self.vpnManager.vpnStatus == .connected {
|
||
self.phase = .connected
|
||
}
|
||
} catch let err as SDLAPIError {
|
||
self.errorMessage = err.message
|
||
self.handleDisconnectedState(syncAppContext: true)
|
||
} catch let err as AppContextError {
|
||
self.errorMessage = err.message
|
||
self.handleDisconnectedState(syncAppContext: true)
|
||
} catch {
|
||
self.errorMessage = error.localizedDescription
|
||
self.handleDisconnectedState(syncAppContext: true)
|
||
}
|
||
}
|
||
|
||
func disconnect() async {
|
||
guard let appContext = self.appContext else {
|
||
return
|
||
}
|
||
|
||
guard !self.isBusy else {
|
||
return
|
||
}
|
||
|
||
self.errorMessage = nil
|
||
self.phase = .disconnecting
|
||
|
||
do {
|
||
try await appContext.stopTun()
|
||
self.handleDisconnectedState(syncAppContext: false)
|
||
} catch let err as AppContextError {
|
||
self.errorMessage = err.message
|
||
await self.handleVPNStatusChange(self.vpnManager.vpnStatus)
|
||
} catch {
|
||
self.errorMessage = error.localizedDescription
|
||
await self.handleVPNStatusChange(self.vpnManager.vpnStatus)
|
||
}
|
||
}
|
||
|
||
func selectNode(id: Int?) {
|
||
self.selectedNodeId = id
|
||
|
||
guard let id else {
|
||
return
|
||
}
|
||
|
||
Task { @MainActor in
|
||
await self.loadResourcesIfNeeded(for: id)
|
||
}
|
||
}
|
||
|
||
func resources(for nodeId: Int) -> [SDLAPIClient.NetworkContext.Resource] {
|
||
self.nodeResourcesById[nodeId] ?? []
|
||
}
|
||
|
||
func isLoadingResources(for nodeId: Int) -> Bool {
|
||
self.loadingNodeIDs.contains(nodeId)
|
||
}
|
||
|
||
func loadResourcesIfNeeded(for nodeId: Int) async {
|
||
guard let session = self.networkSession else {
|
||
return
|
||
}
|
||
|
||
guard self.nodeResourcesById[nodeId] == nil else {
|
||
return
|
||
}
|
||
|
||
guard !self.loadingNodeIDs.contains(nodeId) else {
|
||
return
|
||
}
|
||
|
||
let currentContextIdentity = self.contextIdentity(self.networkContext)
|
||
|
||
self.loadingNodeIDs.insert(nodeId)
|
||
defer {
|
||
self.loadingNodeIDs.remove(nodeId)
|
||
}
|
||
|
||
let resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: nodeId)
|
||
|
||
guard currentContextIdentity == self.contextIdentity(self.networkContext) else {
|
||
return
|
||
}
|
||
|
||
self.nodeResourcesById[nodeId] = resources
|
||
}
|
||
|
||
func updateExitNodeSelection(_ ip: String?) async {
|
||
guard let appContext = self.appContext else {
|
||
return
|
||
}
|
||
|
||
guard !self.isUpdatingExitNode else {
|
||
return
|
||
}
|
||
|
||
self.errorMessage = nil
|
||
self.isUpdatingExitNode = true
|
||
defer {
|
||
self.isUpdatingExitNode = false
|
||
}
|
||
|
||
do {
|
||
try await appContext.updateExitNodeIp(exitNodeIp: ip)
|
||
self.syncSharedStateFromAppContext()
|
||
} catch let err as AppContextError {
|
||
self.errorMessage = err.message
|
||
} catch {
|
||
self.errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func startObservingVPNStatusIfNeeded() {
|
||
guard self.vpnStatusTask == nil else {
|
||
return
|
||
}
|
||
|
||
let vpnStatusStream = self.vpnManager.vpnStatusStream
|
||
|
||
self.vpnStatusTask = Task { [weak self, vpnStatusStream] in
|
||
guard let self else {
|
||
return
|
||
}
|
||
|
||
for await status in vpnStatusStream {
|
||
if Task.isCancelled {
|
||
return
|
||
}
|
||
|
||
await self.handleVPNStatusChange(status)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func syncSharedStateFromAppContext() {
|
||
self.networkSession = self.appContext?.networkSession
|
||
self.selectedExitNodeIp = self.appContext?.selectedExitNodeIp
|
||
}
|
||
|
||
private func handleVPNStatusChange(_ status: VPNManager.VPNStatus) async {
|
||
self.syncSharedStateFromAppContext()
|
||
|
||
switch status {
|
||
case .connecting:
|
||
self.phase = .connecting
|
||
await self.applyNetworkContext(self.appContext?.networkContext)
|
||
case .connected:
|
||
self.phase = .connected
|
||
await self.applyNetworkContext(self.appContext?.networkContext)
|
||
case .disconnecting:
|
||
self.phase = .disconnecting
|
||
case .disconnected:
|
||
self.handleDisconnectedState(syncAppContext: true)
|
||
}
|
||
}
|
||
|
||
private func handleDisconnectedState(syncAppContext: Bool) {
|
||
if syncAppContext {
|
||
self.appContext?.networkContext = nil
|
||
}
|
||
|
||
self.phase = .disconnected
|
||
self.networkContext = nil
|
||
self.selectedNodeId = nil
|
||
self.nodeResourcesById.removeAll()
|
||
self.loadingNodeIDs.removeAll()
|
||
self.showMode = .resource
|
||
self.syncSharedStateFromAppContext()
|
||
}
|
||
|
||
private func applyNetworkContext(_ newContext: SDLAPIClient.NetworkContext?) async {
|
||
let contextChanged = self.contextIdentity(self.networkContext) != self.contextIdentity(newContext)
|
||
|
||
self.networkContext = newContext
|
||
|
||
if contextChanged {
|
||
self.nodeResourcesById.removeAll()
|
||
self.loadingNodeIDs.removeAll()
|
||
self.selectedNodeId = nil
|
||
}
|
||
|
||
guard let newContext else {
|
||
self.selectedNodeId = nil
|
||
return
|
||
}
|
||
|
||
if let selectedNodeId = self.selectedNodeId,
|
||
newContext.getNode(id: selectedNodeId) != nil {
|
||
await self.loadResourcesIfNeeded(for: selectedNodeId)
|
||
return
|
||
}
|
||
|
||
self.selectedNodeId = newContext.firstNodeId()
|
||
|
||
if let selectedNodeId = self.selectedNodeId {
|
||
await self.loadResourcesIfNeeded(for: selectedNodeId)
|
||
}
|
||
}
|
||
|
||
private func contextIdentity(_ context: SDLAPIClient.NetworkContext?) -> String? {
|
||
guard let context else {
|
||
return nil
|
||
}
|
||
|
||
return "\(context.identityId)-\(context.ip)"
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|