import SwiftUI /// 主机列表页(split 详情)。点卡片连接;右上 + 新建;上下文菜单编辑/删除。 struct HostsListView: View { @ObservedObject var manager: SessionManager @ObservedObject var router: AppRouter @ObservedObject var hostStore: HostStore @ObservedObject var credStore: FileCredentialStore @ObservedObject var tailscaleStore: TailscaleStore @EnvironmentObject var theme: TXThemeManager @State private var editing: SavedHost? @State private var showEditor = false private var p: TXPalette { theme.palette } var body: some View { ZStack { p.bg.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: TX.Space.m) { if hostStore.hosts.isEmpty { TXEmptyState(icon: "server.rack", title: "还没有主机", subtitle: "点击右上角 + 添加一台服务器") } else { ForEach(hostStore.hosts) { host in HostCard(host: host, credName: credName(for: host), tailscaleName: tailscaleName(for: host)) { // 设计定稿:建好即进终端(连接进度显示在终端页);已有该主机会话则复用。 let s = manager.open(host: host) router.enter(s.id) } .contextMenu { Button { editing = host; showEditor = true } label: { Label("编辑", systemImage: "pencil") } Button(role: .destructive) { hostStore.remove(host) } label: { Label("删除", systemImage: "trash") } } } } // 独立订阅焦点会话:manager 是嵌套 ObservableObject,会话内部变化不冒泡到这里。 if let s = manager.focused { SessionStatusRow(session: s) } } .padding(TX.Space.m) } } .navigationTitle("主机") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(p.bg, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showEditor) { HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore) { host in if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) } else { hostStore.add(host) } } } } private func credName(for host: SavedHost) -> String? { if case .credential(let id) = host.auth { return credStore.list().first { $0.id == id }?.name ?? "(凭据已删除)" } return nil } private func tailscaleName(for host: SavedHost) -> String? { guard let id = host.tailscaleID else { return nil } return tailscaleStore.connection(id: id)?.name ?? "(连接已删除)" } } /// 焦点会话的连接进度/失败提示(连接中或失败时才出现)。 /// 单独成视图以 `@ObservedObject` 直接订阅该会话——`SessionManager` 不会转发会话内部变化。 struct SessionStatusRow: View { @ObservedObject var session: TerminalSession @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } private var isBusy: Bool { session.phaseText.hasSuffix("…") || session.phaseText.contains("重连") } var body: some View { if isBusy || session.phaseText.hasPrefix("失败") { HStack(spacing: TX.Space.s) { if isBusy { ProgressView().controlSize(.small).tint(p.accent) } Text(session.phaseText) .font(.system(size: 13)) .foregroundStyle(session.phaseText.hasPrefix("失败") ? p.danger : p.textSecondary) } .padding(.horizontal, 4) } } } /// 主机卡片:图标 + mosh 点 / 名称 / 地址(等宽)/ 凭据·Tailscale 徽章 / chevron。 struct HostCard: View { let host: SavedHost let credName: String? let tailscaleName: String? let onTap: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { Button(action: onTap) { HStack(spacing: TX.Space.m) { ZStack(alignment: .topTrailing) { Image(systemName: "server.rack") .font(.system(size: 20, weight: .regular)) .foregroundStyle(p.textSecondary) .frame(width: 44, height: 44) .background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip)) if host.useMosh { Circle().fill(p.mosh).frame(width: 9, height: 9) .overlay(Circle().stroke(p.surface, lineWidth: 2)) .offset(x: 3, y: -3) } } VStack(alignment: .leading, spacing: 3) { Text(host.name) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(p.textPrimary) Text(host.addressLine) .font(TX.Font.mono(13)) .foregroundStyle(p.textSecondary) if credName != nil || tailscaleName != nil { HStack(spacing: TX.Space.xs) { if let c = credName { badge("key.fill", c, p.accent) } if let t = tailscaleName { badge("network", t, p.mosh) } } } } Spacer() Image(systemName: "chevron.right") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(p.textTertiary) } .padding(TX.Space.m) .background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1)) } .buttonStyle(.txPress) } private func badge(_ icon: String, _ text: String, _ color: Color) -> some View { HStack(spacing: 3) { Image(systemName: icon).font(.system(size: 9)) Text(text).font(.system(size: 11, weight: .medium)).lineLimit(1) } .foregroundStyle(color) .padding(.horizontal, 7).padding(.vertical, 3) .background(color.opacity(0.13), in: Capsule()) } } /// 主机编辑页:基本信息 + 认证(直接输入 / 选 Keychain)+ 网络(Direct / Tailscale)+ 选项。 struct HostEditorView: View { let existing: SavedHost? @ObservedObject var credStore: FileCredentialStore @ObservedObject var tailscaleStore: TailscaleStore /// 已有分组(供一键选,避免录入同义不同名)。 var existingGroups: [String] = [] let onSave: (SavedHost) -> Void @EnvironmentObject var theme: TXThemeManager @Environment(\.dismiss) private var dismiss private enum AuthMode: Hashable { case inline, credential } @State private var name = "" @State private var host = "" @State private var port = "22" @State private var authMode: AuthMode = .inline @State private var inlineUser = "" @State private var inlinePassword = "" @State private var selectedCredentialID: UUID? @State private var tailscaleID: UUID? @State private var useMosh = false @State private var showNewCredential = false /// 分组(首页筛选 chips)与系统描述(主机卡副行)——真实数据只能从这里录入。 @State private var group = "" @State private var os = "" @State private var useTmux = true @State private var tmuxSession = "" private var p: TXPalette { theme.palette } private var canSave: Bool { guard !host.isEmpty else { return false } switch authMode { case .inline: return !inlineUser.isEmpty case .credential: return selectedCredentialID != nil } } var body: some View { NavigationStack { ZStack { p.bg.ignoresSafeArea() Form { Section("基本") { TXTextField(title: "名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host) TXTextField(title: "主机 / IP", text: $host) TXTextField(title: "端口", text: $port, keyboard: .numberPad) } Section { TXTextField(title: "分组", text: $group, placeholder: "生产 / 家里 / 云(可空)") if !existingGroups.isEmpty { // 已有分组一键选,避免同义不同名把 chips 撑爆。 HStack(spacing: TX.Space.xs) { ForEach(existingGroups, id: \.self) { g in TXChip(text: g, isSelected: group == g) { group = (group == g) ? "" : g } } } .txRow(p) } TXTextField(title: "系统", text: $os, placeholder: "macOS · M4 Max(可空)") } header: { Text("归类(首页展示用)") } footer: { Text("分组决定首页「全部主机」的筛选 chips;系统只作卡片副行展示,不参与连接。") } authSection networkSection Section { Toggle("自动进入 tmux", isOn: $useTmux).tint(p.accent).txRow(p) if useTmux { TXTextField(title: "tmux 会话名", text: $tmuxSession, placeholder: "main") } Toggle("使用 mosh", isOn: $useMosh).tint(p.accent).txRow(p) } header: { Text("会话") } footer: { Text(useTmux ? "登录后执行 tmux -CC new -A -s <会话名>:同名会话存在就接回,否则新建。服务器没装 tmux 会自动降级为客户端窗口并给安装引导。" : "不进 tmux:会话由 App 维持,关掉 App 后服务端不会继续跑(mosh 仍能兜断线)。") } } .scrollContentBackground(.hidden) } .navigationTitle(existing == nil ? "添加主机" : "编辑主机") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() }.tint(p.textSecondary) } ToolbarItem(placement: .confirmationAction) { Button("保存") { save() }.disabled(!canSave).tint(p.accent) } } .toolbarBackground(p.bg, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .sheet(isPresented: $showNewCredential) { CredentialEditorView(existing: nil, store: credStore) { newCred in selectedCredentialID = newCred.id authMode = .credential } } .onAppear(perform: populate) } .tint(p.accent) } @ViewBuilder private var authSection: some View { Section("认证") { Picker("方式", selection: $authMode) { Text("直接输入").tag(AuthMode.inline) Text("从 Keychain 选择").tag(AuthMode.credential) } .pickerStyle(.segmented) .txRow(p) if authMode == .inline { TXTextField(title: "用户名", text: $inlineUser) TXTextField(title: "密码", text: $inlinePassword, secure: true) } else { Picker("凭据", selection: $selectedCredentialID) { Text("未选择").tag(UUID?.none) ForEach(credStore.credentials) { c in Text("\(c.name) · \(c.username)").tag(UUID?.some(c.id)) } } .txRow(p) Button { showNewCredential = true } label: { Label("新建凭据", systemImage: "plus.circle") } .txRow(p) } } } @ViewBuilder private var networkSection: some View { Section("网络(出口)") { Picker("经由", selection: $tailscaleID) { Text("Direct(直连)").tag(UUID?.none) ForEach(tailscaleStore.connections) { conn in Text(conn.name).tag(UUID?.some(conn.id)) } } .txRow(p) if let id = tailscaleID, let conn = tailscaleStore.connection(id: id), conn.needsAuthKey { Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。") .font(.system(size: 12)).foregroundStyle(p.warning).txRow(p) } } } private func populate() { guard let h = existing else { return } name = h.name; host = h.host; port = String(h.port) useMosh = h.useMosh; tailscaleID = h.tailscaleID group = h.group ?? ""; os = h.os ?? "" useTmux = h.useTmux; tmuxSession = h.tmuxSession ?? "" switch h.auth { case .inlinePassword(let u, let pw): authMode = .inline; inlineUser = u; inlinePassword = pw case .credential(let id): authMode = .credential; selectedCredentialID = id } } private func save() { let auth: SavedHost.AuthRef switch authMode { case .inline: auth = .inlinePassword(username: inlineUser, password: inlinePassword) case .credential: guard let id = selectedCredentialID else { return } auth = .credential(id: id) } let saved = SavedHost( id: existing?.id ?? UUID(), name: name.isEmpty ? host : name, host: host, port: Int(port) ?? 22, useMosh: useMosh, useTmux: useTmux, tmuxSession: tmuxSession.isEmpty ? nil : tmuxSession.trimmingCharacters(in: .whitespaces), auth: auth, tailscaleID: tailscaleID, group: group.isEmpty ? nil : group.trimmingCharacters(in: .whitespaces), os: os.isEmpty ? nil : os.trimmingCharacters(in: .whitespaces)) onSave(saved) dismiss() } }