fix network view

This commit is contained in:
anlicheng 2026-03-19 17:39:52 +08:00
parent 2b96a5a8cf
commit 177f8932fa

View File

@ -4,11 +4,9 @@
import SwiftUI import SwiftUI
import Observation import Observation
// // MARK: - ( Model )
enum ConnectState { enum ConnectState {
case waitAuth case waitAuth, connected, disconnected
case connected
case disconnected
} }
// MARK: - // MARK: -
@ -31,40 +29,31 @@ struct NetworkView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// 使 // 1. (Header)
headerSection headerSection
Divider() Divider()
// // 2. (Content)
ZStack { Group {
VisualEffectView(material: .windowBackground, blendingMode: .behindWindow) switch connectState {
.ignoresSafeArea() case .waitAuth:
NetworkWaitAuthView(networkModel: networkModel)
ScrollView { case .connected:
VStack(spacing: 20) { connectedContent
switch connectState { case .disconnected:
case .waitAuth: disconnectedContent
statusLoadingView
case .connected:
connectedContent
case .disconnected:
disconnectedContent
}
}
.padding(24)
} }
.scrollIndicators(.hidden)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow))
} }
.frame(minWidth: 550, minHeight: 480) .frame(minWidth: 700, minHeight: 500) // SplitView
.onAppear { .onAppear {
syncState(vpnManager.vpnStatus) syncState(vpnManager.vpnStatus)
} }
.onChange(of: vpnManager.vpnStatus) { _, newStatus in .onChange(of: vpnManager.vpnStatus) { _, newStatus in
withAnimation(.snappy) { withAnimation(.snappy) { syncState(newStatus) }
syncState(newStatus)
}
} }
} }
} }
@ -74,7 +63,6 @@ extension NetworkView {
private var headerSection: some View { private var headerSection: some View {
HStack(spacing: 16) { HStack(spacing: 16) {
//
statusIndicator statusIndicator
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@ -109,7 +97,7 @@ extension NetworkView {
.help("配置中心") .help("配置中心")
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 16) .padding(.vertical, 14)
.background(VisualEffectView(material: .headerView, blendingMode: .withinWindow)) .background(VisualEffectView(material: .headerView, blendingMode: .withinWindow))
} }
@ -117,75 +105,58 @@ extension NetworkView {
ZStack { ZStack {
Circle() Circle()
.fill(connectState == .connected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) .fill(connectState == .connected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05))
.frame(width: 40, height: 40) .frame(width: 36, height: 36)
Image(systemName: connectState == .connected ? "checkmark.shield.fill" : "shield.slash.fill") Image(systemName: connectState == .connected ? "checkmark.shield.fill" : "shield.slash.fill")
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.foregroundStyle(connectState == .connected ? Color.green : Color.secondary) .foregroundStyle(connectState == .connected ? Color.green : Color.secondary)
.font(.system(size: 18)) .font(.system(size: 16))
.symbolEffect(.bounce, value: connectState == .connected)
} }
} }
@ViewBuilder
private var connectedContent: some View { private var connectedContent: some View {
VStack(spacing: 16) { if showMode == .resource {
if showMode == .resource { //
LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 16)], spacing: 16) { ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200), spacing: 16)], spacing: 16) {
ForEach(networkModel.networkContext.resourceList, id: \.id) { res in ForEach(networkModel.networkContext.resourceList, id: \.id) { res in
ResourceItemCard(resource: res) ResourceItemCard(resource: res)
} }
} }
} else { .padding(20)
VStack(spacing: 0) {
let nodes = networkModel.networkContext.nodeList
ForEach(nodes, id: \.id) { node in
DeviceItemRow(node: node, isLast: node.id == nodes.last?.id)
}
}
.background(Color(NSColor.controlBackgroundColor).opacity(0.4))
.cornerRadius(12)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.08), lineWidth: 1))
} }
.transition(.opacity)
} else {
//
NetworkDeviceGroupView(networkModel: networkModel)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
} }
} }
private var disconnectedContent: some View { private var disconnectedContent: some View {
VStack(spacing: 24) { VStack(spacing: 20) {
Spacer().frame(height: 40) Spacer()
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 48, weight: .ultraLight)) .font(.system(size: 40, weight: .ultraLight))
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.symbolEffect(.pulse, options: .repeating) .symbolEffect(.pulse, options: .repeating)
VStack(spacing: 8) { Text("尚未接入网络").font(.headline)
Text("尚未接入网络").font(.headline)
Text("连接后即可访问内部资源与成员节点").font(.subheadline).foregroundColor(.secondary)
}
Button(action: { startConnection() }) { Button(action: { startConnection() }) {
if isConnecting { if isConnecting {
ProgressView().controlSize(.small).frame(width: 110) ProgressView().controlSize(.small).frame(width: 80)
} else { } else {
Text("建立安全连接").fontWeight(.medium).frame(width: 110) Text("建立安全连接").frame(width: 80)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isConnecting) .disabled(isConnecting)
Spacer() Spacer()
} }
} }
private var statusLoadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("正在同步网络状态...").font(.subheadline).foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
}
private func syncState(_ status: VPNManager.VPNStatus) { private func syncState(_ status: VPNManager.VPNStatus) {
switch status { switch status {
case .connected: connectState = .connected case .connected: connectState = .connected
@ -198,13 +169,7 @@ extension NetworkView {
isConnecting = true isConnecting = true
Task { Task {
do { do {
guard let session = userContext.networkSession else { guard let session = userContext.networkSession else { return }
await MainActor.run {
isConnecting = false
}
return
}
try await networkModel.connect(networkSession: session) try await networkModel.connect(networkSession: session)
let context = networkModel.networkContext let context = networkModel.networkContext
if let options = SystemConfig.getOptions( if let options = SystemConfig.getOptions(
@ -219,105 +184,130 @@ extension NetworkView {
) { ) {
try await vpnManager.enableVpn(options: options) try await vpnManager.enableVpn(options: options)
} }
} catch { } catch { print("Connection error: \(error)") }
print("Connection error: \(error)")
}
await MainActor.run { isConnecting = false } await MainActor.run { isConnecting = false }
} }
} }
} }
// MARK: - // MARK: - (NavigationSplitView)
struct ResourceItemCard: View { struct NetworkDeviceGroupView: View {
let resource: Resource @Bindable var networkModel: NetworkModel
@State private var isHovered = false @State private var selectedId: Int?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { NavigationSplitView {
HStack { List(networkModel.networkContext.nodeList, id: \.id, selection: $selectedId) { node in
Image(systemName: "safari.fill") NetworkNodeHeadView(node: node)
.foregroundColor(.accentColor) .tag(node.id)
.font(.title3) }
Spacer() .listStyle(.sidebar)
Circle() .navigationSplitViewColumnWidth(min: 200, ideal: 220)
.fill(Color.green) } detail: {
.frame(width: 6, height: 6) if let selectedNode = networkModel.networkContext.nodeList.first(where: { $0.id == selectedId }) {
.shadow(color: .green.opacity(0.5), radius: 2) NetworkNodeDetailView(node: selectedNode)
} else {
ContentUnavailableView("选择成员设备", systemImage: "macbook.and.iphone", description: Text("查看详细网络信息和服务"))
} }
Text(resource.name)
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
Text(resource.url)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
} }
.padding(14) .onAppear {
.background(isHovered ? Color.primary.opacity(0.05) : Color(NSColor.controlBackgroundColor).opacity(0.3)) if selectedId == nil {
.cornerRadius(12) selectedId = networkModel.networkContext.nodeList.first?.id
.overlay(RoundedRectangle(cornerRadius: 12).stroke(isHovered ? Color.accentColor.opacity(0.3) : Color.primary.opacity(0.08), lineWidth: 1))
.onHover { isHovered = $0 }
.contextMenu {
Button("复制链接") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(resource.url, forType: .string)
}
Button("在浏览器打开") {
if let url = URL(string: resource.url) {
NSWorkspace.shared.open(url)
}
} }
} }
} }
} }
// MARK: - // MARK: -
struct DeviceItemRow: View { struct NetworkNodeHeadView: View {
let node: Node var node: Node
let isLast: Bool
@State private var isHovered = false
var body: some View { var body: some View {
VStack(spacing: 0) { HStack(spacing: 10) {
HStack(spacing: 12) { Circle()
Image(systemName: "desktopcomputer") .fill(node.connectionStatus == "在线" ? Color.green : Color.secondary.opacity(0.4))
.font(.title3) .frame(width: 8, height: 8)
.foregroundStyle(isHovered ? .primary : .secondary)
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)
}
Spacer()
HStack(spacing: 4) {
Text("直连").font(.system(size: 9, weight: .bold))
.padding(.horizontal, 4).padding(.vertical, 1)
.background(Color.accentColor.opacity(0.1)).foregroundColor(.accentColor).cornerRadius(3)
Circle().fill(Color.green).frame(width: 6, height: 6)
}
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.contentShape(Rectangle())
.background(isHovered ? Color.primary.opacity(0.03) : Color.clear)
.onHover { isHovered = $0 }
if !isLast { VStack(alignment: .leading, spacing: 2) {
Divider().padding(.leading, 44).opacity(0.5) Text(node.name).font(.system(size: 13, weight: .medium))
Text(node.ip).font(.system(size: 11, design: .monospaced)).foregroundColor(.secondary)
} }
} }
.contextMenu { .padding(.vertical, 4)
Button("复制 IP 地址") { }
NSPasteboard.general.clearContents() }
NSPasteboard.general.setString(node.ip, forType: .string)
struct NetworkNodeDetailView: View {
@Environment(UserContext.self) var userContext
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 ?? "未知")
} }
Button("终端 SSH 连接") {
/* */ 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 = userContext.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)
Text(resource.url).font(.caption2).foregroundColor(.secondary).lineLimit(1)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(NSColor.controlBackgroundColor).opacity(isHovered ? 0.8 : 0.4))
.cornerRadius(10)
.onHover { isHovered = $0 }
}
}
struct NetworkWaitAuthView: View {
@Bindable var networkModel: NetworkModel
var body: some View {
VStack(spacing: 16) {
ProgressView()
Text("等待认证确认中...").foregroundColor(.secondary)
}
} }
} }