365 lines
13 KiB
Swift
365 lines
13 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
|
||
@Environment(\.openWindow) private var openWindow
|
||
|
||
@State private var networkModel = NetworkModel()
|
||
@State private var showMode: NetworkShowMode = .resource
|
||
@State private var connectState: ConnectState = .disconnected
|
||
@State private var isConnecting: Bool = false
|
||
|
||
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: \(networkModel.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)
|
||
}
|
||
|
||
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 connectState {
|
||
case .waitAuth:
|
||
NetworkWaitAuthView(networkModel: networkModel)
|
||
case .connected:
|
||
if showMode == .resource {
|
||
// 资源视图:网格布局
|
||
ScrollView {
|
||
LazyVGrid(columns: [
|
||
GridItem(.flexible(), spacing: 8),
|
||
GridItem(.flexible(), spacing: 8),
|
||
GridItem(.flexible(), spacing: 8)
|
||
], spacing: 10) {
|
||
ForEach(networkModel.networkContext.resourceList, id: \.uuid) { res in
|
||
ResourceItemCard(resource: res)
|
||
}
|
||
}
|
||
.padding(20)
|
||
}
|
||
.transition(.opacity)
|
||
.frame(maxWidth: .infinity)
|
||
} else {
|
||
// 设备视图:双栏布局
|
||
NetworkDeviceGroupView(networkModel: networkModel)
|
||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
|
||
}
|
||
case .disconnected:
|
||
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: { startConnection() }) {
|
||
if isConnecting {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
.frame(width: 80)
|
||
} else {
|
||
Text("建立安全连接")
|
||
.frame(width: 80)
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(isConnecting)
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func syncState(_ status: VPNManager.VPNStatus) {
|
||
switch status {
|
||
case .connected: connectState = .connected
|
||
case .disconnected: connectState = .disconnected
|
||
@unknown default: connectState = .disconnected
|
||
}
|
||
}
|
||
|
||
private func startConnection() {
|
||
isConnecting = true
|
||
Task {
|
||
do {
|
||
guard let session = appContext.networkSession else {
|
||
return
|
||
}
|
||
|
||
let context = try await networkModel.connect(networkSession: session)
|
||
|
||
// 登陆后需要保存到app的上线文
|
||
self.appContext.networkContext = context
|
||
|
||
if let options = SystemConfig.getOptions(
|
||
networkId: UInt32(session.networkId),
|
||
networkDomain: session.networkDomain,
|
||
ip: context.ip,
|
||
maskLen: context.maskLen,
|
||
accessToken: session.accessToken,
|
||
identityId: context.identityId,
|
||
hostname: context.hostname,
|
||
noticePort: appContext.noticePort
|
||
) {
|
||
try await vpnManager.enableVpn(options: options)
|
||
}
|
||
} catch {
|
||
print("Connection error: \(error)")
|
||
}
|
||
|
||
await MainActor.run {
|
||
isConnecting = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 设备组视图 (NavigationSplitView)
|
||
struct NetworkDeviceGroupView: View {
|
||
@Bindable var networkModel: NetworkModel
|
||
@State private var selectedId: Int?
|
||
|
||
var body: some View {
|
||
NavigationSplitView {
|
||
List(networkModel.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 = networkModel.networkContext.nodeList.first(where: { $0.id == selectedId }) {
|
||
NetworkNodeDetailView(node: selectedNode)
|
||
} else {
|
||
ContentUnavailableView("选择成员设备", systemImage: "macbook.and.iphone", description: Text("查看详细网络信息和服务"))
|
||
}
|
||
}
|
||
.onAppear {
|
||
if selectedId == nil {
|
||
selectedId = networkModel.networkContext.nodeList.first?.id
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 子组件
|
||
struct NetworkNodeHeadView: View {
|
||
var node: 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: 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 ?? "未知")
|
||
}
|
||
|
||
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 = appContext.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)
|
||
.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 {
|
||
@Bindable var networkModel: NetworkModel
|
||
|
||
var body: some View {
|
||
VStack(spacing: 16) {
|
||
ProgressView()
|
||
|
||
Text("等待认证确认中...")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|