359 lines
12 KiB
Swift
359 lines
12 KiB
Swift
//
|
||
// NetworkView.swift
|
||
// punchnet
|
||
import SwiftUI
|
||
import Observation
|
||
|
||
// MARK: - 基础模型协议
|
||
enum ConnectState {
|
||
case waitAuth
|
||
case connected
|
||
case disconnected
|
||
}
|
||
|
||
// 资源展示模式
|
||
enum NetworkShowMode: String, CaseIterable {
|
||
case resource = "访问资源"
|
||
case device = "成员设备"
|
||
}
|
||
|
||
// MARK: - 主网络视图
|
||
struct NetworkView: View {
|
||
@Environment(AppContext.self) var appContext: AppContext
|
||
@Environment(\.openWindow) private var openWindow
|
||
|
||
@State private var showMode: NetworkShowMode = .resource
|
||
@State private var connectState: ConnectState = .disconnected
|
||
|
||
private var vpnManager = VPNManager.shared
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// 1. 头部区域 (Header)
|
||
HStack(spacing: 16) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(connectState == .connected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05))
|
||
.frame(width: 36, height: 36)
|
||
|
||
Image(systemName: connectState == .connected ? "checkmark.shield.fill" : "shield.slash.fill")
|
||
.symbolRenderingMode(.hierarchical)
|
||
.foregroundStyle(connectState == .connected ? Color.green : Color.secondary)
|
||
.font(.system(size: 16))
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(appContext.networkSession?.networkName ?? "未连接网络")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
|
||
if connectState == .connected {
|
||
Text("虚拟局域网 IP: \(self.appContext.networkContext.ip)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
} else {
|
||
Text("PunchNet 服务未就绪")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if connectState == .connected {
|
||
Picker("", selection: $showMode) {
|
||
ForEach(NetworkShowMode.allCases, id: \.self) {
|
||
Text($0.rawValue).tag($0)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.frame(width: 160)
|
||
}
|
||
|
||
SettingsLink {
|
||
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 connectState {
|
||
case .waitAuth:
|
||
NetworkWaitAuthView()
|
||
case .connected:
|
||
NetworkConnectedView(showMode: $showMode)
|
||
case .disconnected:
|
||
NetworkDisconnectedView()
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow))
|
||
}
|
||
.frame(minWidth: 700, minHeight: 500) // 适当调大宽度以适应 SplitView
|
||
.onAppear {
|
||
syncState(vpnManager.vpnStatus)
|
||
}
|
||
.onChange(of: vpnManager.vpnStatus) { _, newStatus in
|
||
withAnimation(.snappy) {
|
||
syncState(newStatus)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 通过VPN的连接状态同步当前页面的显示状态
|
||
private func syncState(_ status: VPNManager.VPNStatus) {
|
||
switch status {
|
||
case .connected:
|
||
connectState = .connected
|
||
case .disconnected:
|
||
connectState = .disconnected
|
||
@unknown default:
|
||
connectState = .disconnected
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
struct NetworkConnectedView: View {
|
||
@Environment(AppContext.self) private var appContext: AppContext
|
||
@Binding var showMode: NetworkShowMode
|
||
|
||
var body: some View {
|
||
if showMode == .resource {
|
||
// 资源视图:网格布局
|
||
ScrollView {
|
||
LazyVGrid(columns: [
|
||
GridItem(.flexible(), spacing: 8),
|
||
GridItem(.flexible(), spacing: 8),
|
||
GridItem(.flexible(), spacing: 8)
|
||
], spacing: 10) {
|
||
ForEach(appContext.networkContext.resourceList, id: \.uuid) { res in
|
||
ResourceItemCard(resource: res)
|
||
}
|
||
}
|
||
.padding(20)
|
||
}
|
||
.transition(.opacity)
|
||
.frame(maxWidth: .infinity)
|
||
} else {
|
||
// 设备视图:双栏布局
|
||
NetworkDeviceGroupView()
|
||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
struct NetworkDisconnectedView: View {
|
||
@Environment(AppContext.self) private var appContext: AppContext
|
||
@State private var isConnecting: Bool = false
|
||
|
||
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 startConnection()
|
||
}
|
||
}) {
|
||
if isConnecting {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
.frame(width: 80)
|
||
} else {
|
||
Text("建立安全连接")
|
||
.frame(width: 80)
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(isConnecting)
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private func startConnection() async {
|
||
self.isConnecting = true
|
||
defer {
|
||
self.isConnecting = false
|
||
}
|
||
|
||
do {
|
||
try await self.appContext.connectNetwork()
|
||
} catch let err {
|
||
print("Connection error: \(err)")
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - 设备组视图 (NavigationSplitView)
|
||
struct NetworkDeviceGroupView: View {
|
||
@Environment(AppContext.self) private var appContext: AppContext
|
||
@State private var selectedId: Int?
|
||
|
||
var body: some View {
|
||
NavigationSplitView {
|
||
List(appContext.networkContext.nodeList, id: \.id, selection: $selectedId) { node in
|
||
NetworkNodeHeadView(node: node)
|
||
.tag(node.id)
|
||
}
|
||
.listStyle(.sidebar)
|
||
.navigationSplitViewColumnWidth(min: 200, ideal: 220)
|
||
} detail: {
|
||
if let selectedNode = appContext.networkContext.getNode(id: selectedId) {
|
||
NetworkNodeDetailView(node: selectedNode)
|
||
} else {
|
||
ContentUnavailableView("选择成员设备", systemImage: "macbook.and.iphone", description: Text("查看详细网络信息和服务"))
|
||
}
|
||
}
|
||
.onAppear {
|
||
if selectedId == nil {
|
||
selectedId = appContext.networkContext.firstNodeId()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
@Environment(AppContext.self) private var appContext: AppContext
|
||
|
||
var node: SDLAPIClient.NetworkContext.Node
|
||
@State private var resources: [SDLAPIClient.NetworkContext.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 ?? "未知")
|
||
}
|
||
|
||
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 {
|
||
await loadNodeResources(id: node.id)
|
||
}
|
||
}
|
||
|
||
// 请求对应的资源信息
|
||
private func loadNodeResources(id: Int) async {
|
||
guard let session = appContext.networkSession else {
|
||
return
|
||
}
|
||
|
||
self.isLoading = true
|
||
defer {
|
||
self.isLoading = false
|
||
}
|
||
self.resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: 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 body: some View {
|
||
VStack(spacing: 16) {
|
||
ProgressView()
|
||
|
||
Text("等待认证确认中...")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|