import SwiftUI /// Pairs this phone with a node over the LAN. /// /// The flow needs the phone to be inside the network, which is why it is a separate sheet rather /// than part of the instance form: the address here is the node's LAN address, not the relay's, and /// the password is the node's own — the one fact the pairing flow leans on. struct RelayPairingView: View { @Environment(\.dismiss) private var dismiss @ObservedObject var model: RelayPairingViewModel @ObservedObject var fleet: FleetViewModel var body: some View { NavigationStack { ScrollView { VStack(spacing: 12) { AICard { VStack(alignment: .leading, spacing: 14) { field("节点内网地址", text: $model.host, placeholder: "192.168.1.10") field("节点访问密码", text: $model.password, placeholder: "SimAdmin 密码", secure: true) field("显示名称", text: $model.name, placeholder: "可留空,默认使用节点 ID") VStack(alignment: .leading, spacing: 6) { AIFieldLabel("标签") TagEditorView(tags: $model.tags, suggestions: fleet.allTags) } } } AIStatusBanner( "配对需要手机与节点在同一内网。节点上先运行一次 msa-agent --pair,配对成功后它的管理端口会自动关闭。", tint: AITheme.muted, systemImage: "wifi" ) if let message = model.errorMessage { AIStatusBanner(message, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } if case .done(let nodeID) = model.stage { AIStatusBanner( "配对完成,节点 \(nodeID) 已加入列表。", tint: AITheme.success, systemImage: "checkmark.circle" ) } } .padding(16) .padding(.bottom, 8) } .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle("配对中继节点") #if os(iOS) .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) #endif .aiBottomActionBar { AIActionRow { Button(model.isDone ? "完成" : "取消") { if model.isDone { Task { await fleet.refresh() } } dismiss() } .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) if !model.isDone { Button { Task { await model.pair() } } label: { HStack(spacing: 8) { if model.stage == .pairing { ProgressView().controlSize(.small) } Text(model.stage == .pairing ? "配对中" : "开始配对") } } .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .disabled(!model.canSubmit) } } } } } private func field( _ label: String, text: Binding, placeholder: String, secure: Bool = false ) -> some View { VStack(alignment: .leading, spacing: 6) { AIFieldLabel(label) if secure { SecureField(placeholder, text: text) .textFieldStyle(.plain) .font(.body) .padding(.horizontal, 12) .padding(.vertical, 10) .background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } else { TextField(placeholder, text: text) .textFieldStyle(.plain) .font(.body) #if os(iOS) .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() #endif .padding(.horizontal, 12) .padding(.vertical, 10) .background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } } } }