import SwiftUI struct InstanceFormView: View { @Environment(\.dismiss) private var dismiss @ObservedObject var model: FleetViewModel let instance: Instance? @State private var name = "" @State private var originText = "" @State private var tags: [String] = [] @State private var tagDraft = "" @State private var password = "" @State private var validationMessage: String? @State private var isSaving = false init(model: FleetViewModel, instance: Instance? = nil) { self.model = model self.instance = instance _name = State(initialValue: instance?.name ?? "") _originText = State(initialValue: instance?.endpoint.displayAddress ?? "") _tags = State(initialValue: instance?.tags ?? []) } /// A relay instance is edited by name and tags only. Its address is not a URL the user typed — /// it is a pairing, and letting the form rewrite it would either drop the node's keys or produce /// an endpoint that fails validation. Re-pairing is the way to change it. private var isRelay: Bool { instance?.endpoint.isRelay == true } private var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (isRelay || !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } var body: some View { NavigationStack { ScrollView { VStack(spacing: 12) { AICard { VStack(alignment: .leading, spacing: 14) { field("名称", text: $name, placeholder: "Home Node") if isRelay { relayChannelRow } else { field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") } tagEditor if !isRelay { field( "实例密码", text: $password, placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码", secure: true ) } } } if isRelay { AIStatusBanner( "中继节点的密码保存在节点 agent 上,手机不持有;更换中继需要重新配对。", tint: AITheme.muted, systemImage: "lock.shield" ) } if let validationMessage { AIStatusBanner( validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } } .padding(16) .padding(.bottom, 8) } .aiBottomActionBar { AIActionRow { Button("取消") { dismiss() } .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) Button { save() } label: { HStack(spacing: 8) { if isSaving { ProgressView() .controlSize(.small) } Text(isSaving ? "保存中" : "保存实例") } } .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .disabled(!canSave || isSaving) } } .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle(instance == nil ? "添加实例" : "编辑实例") #if os(iOS) // 表单表统一用行内标题:大标题会在卡片上方留出一整条空白, // 而「批量标签」等其它表已经是行内样式。 .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) #endif } } /// The relay channel is read-only here, but it must still be visible: without it the form shows /// a node with no address at all, which reads as missing data rather than as a different mode. private var relayChannelRow: some View { VStack(alignment: .leading, spacing: 6) { AIFieldLabel("通道") HStack(spacing: 8) { Text("中继") .font(.caption.weight(.semibold)) .foregroundStyle(AITheme.muted) .padding(.horizontal, 8) .padding(.vertical, 3) .background(AITheme.canvas, in: Capsule()) Text(originText.isEmpty ? "已配对节点" : originText) .font(.footnote) .foregroundStyle(AITheme.ink) .lineLimit(1) .truncationMode(.middle) } } .accessibilityElement(children: .combine) } private var tagEditor: some View { VStack(alignment: .leading, spacing: 6) { AIFieldLabel("标签") TagEditorView(tags: $tags, suggestions: model.allTags) } .accessibilityElement(children: .contain) } private func field( _ title: String, text: Binding, placeholder: String, secure: Bool = false ) -> some View { VStack(alignment: .leading, spacing: 6) { AIFieldLabel(title) if secure { SecureField(placeholder, text: text) .textFieldStyle(AIFieldStyle()) .textContentType(.password) .accessibilityLabel(title) } else { TextField(placeholder, text: text) .textFieldStyle(AIFieldStyle()) .accessibilityLabel(title) .submitLabel(.done) #if os(iOS) .keyboardType(title == "地址" ? .URL : .default) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled() } } } private func save() { guard !isSaving else { return } isSaving = true let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)), origin.scheme == "http" || origin.scheme == "https", origin.host != nil else { validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。" isSaving = false return } // An existing relay instance keeps its endpoint: the address field only edits a direct // origin, and rewriting a pairing into a direct one here would silently drop the node's keys. let endpoint = instance?.endpoint.isRelay == true ? instance!.endpoint : InstanceEndpoint.direct(origin) let input = InstanceInput( name: normalizedName, endpoint: endpoint, tags: tags, password: password.isEmpty ? nil : password ) Task { do { if let instance { try await model.update(instance.id, input) } else { try await model.add(input) } } catch { validationMessage = "保存失败,请检查地址是否重复。" } isSaving = false } } }