feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复

业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连
- 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用
- 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView

首页/弹框减法:
- 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」
- 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻)
- 会话卡加 ✕ + 复用终端页同款二次确认
- 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近)

终端页修复:
- 侧边栏会话列表改创建顺序,切换不再重排抖动
- 焦点高亮上移到 pane 容器边框(四边完整,去负 padding)
- 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次
- 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦;
  vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-26 17:49:58 +08:00
parent 8da725114f
commit 4b5292107b
14 changed files with 378 additions and 470 deletions

View File

@@ -1,166 +1,10 @@
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 }
// HostEditorView Termius `HostsListView`/`HostCard`/`SessionStatusRow`
// + 稿
//
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+
/// + = Keychain / + / Tailscale+
struct HostEditorView: View {
let existing: SavedHost?
@ObservedObject var credStore: FileCredentialStore
@@ -184,6 +28,7 @@ struct HostEditorView: View {
@State private var tailscaleID: UUID?
@State private var useMosh = false
@State private var showNewCredential = false
@State private var showNewTailscale = false
/// chips
@State private var group = ""
@State private var os = ""
@@ -264,13 +109,19 @@ struct HostEditorView: View {
authMode = .credential
}
}
.sheet(isPresented: $showNewTailscale) {
// Tailscale
TailscaleEditorView(existing: nil, store: tailscaleStore) { conn in
tailscaleID = conn.id
}
}
.onAppear(perform: populate)
}
.tint(p.accent)
}
@ViewBuilder private var authSection: some View {
Section("认证") {
Section {
Picker("方式", selection: $authMode) {
Text("直接输入").tag(AuthMode.inline)
Text("从 Keychain 选择").tag(AuthMode.credential)
@@ -294,18 +145,28 @@ struct HostEditorView: View {
}
.txRow(p)
}
} header: {
Text("认证")
} footer: {
if authMode == .inline {
Text("保存时自动存为 Keychain 凭据(可在 设置 → Keychain 里管理),主机只保留引用。")
}
}
}
@ViewBuilder private var networkSection: some View {
Section("网络(出口)") {
Picker("经由", selection: $tailscaleID) {
Text("Direct直连").tag(UUID?.none)
Text("直连").tag(UUID?.none)
ForEach(tailscaleStore.connections) { conn in
Text(conn.name).tag(UUID?.some(conn.id))
}
}
.txRow(p)
Button { showNewTailscale = true } label: {
Label("新建 Tailscale 连接", systemImage: "plus.circle")
}
.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)
@@ -330,7 +191,22 @@ struct HostEditorView: View {
private func save() {
let auth: SavedHost.AuthRef
switch authMode {
case .inline: auth = .inlinePassword(username: inlineUser, password: inlinePassword)
case .inline:
// ** Keychain **
// 稿`inlinePassword`
// /fixture
//
if let dup = credStore.credentials.first(where: { c in
c.kind == .password && c.username == inlineUser
&& credStore.secret(for: c.id) == .password(inlinePassword)
}) {
auth = .credential(id: dup.id)
} else {
let cred = Credential(name: "\(inlineUser)@\(name.isEmpty ? host : name)",
username: inlineUser, kind: .password)
credStore.upsert(cred, secret: .password(inlinePassword))
auth = .credential(id: cred.id)
}
case .credential:
guard let id = selectedCredentialID else { return }
auth = .credential(id: id)