feat(ios): refine native ui controls

This commit is contained in:
chick
2026-09-09 11:14:39 +08:00
parent c568e496b2
commit 995206ac23
18 changed files with 397 additions and 212 deletions
@@ -2,99 +2,130 @@ import SwiftUI
struct FleetView: View {
@StateObject private var model: FleetViewModel
@Environment(\.refresh) private var refreshEnvironment
@State private var showsDeleteDialog = false
init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
}
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
searchField
if model.rows.isEmpty && !model.isLoading {
AICard {
VStack(spacing: 10) {
Image(systemName: "server.rack")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无实例")
.font(.headline)
Text("添加 SimAdmin 实例后即可开始管理。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
searchField
if model.selectionMode {
selectionBar
}
if model.rows.isEmpty && !model.isLoading {
AICard {
VStack(spacing: 10) {
Image(systemName: "server.rack")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无实例")
.font(.headline)
Text("添加 SimAdmin 实例后即可开始管理。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
ForEach(model.rows) { row in
if model.selectionMode {
}
ForEach(model.rows) { row in
if model.selectionMode {
instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
}
.padding(16)
}
.padding(16)
}
.navigationTitle("节点总览")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
model.selectionMode.toggle()
model.selectedIDs.removeAll()
} label: {
Label("批量选择", systemImage: model.selectionMode ? "xmark" : "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
}
.navigationTitle("节点总览")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
model.selectionMode.toggle()
model.selectedIDs.removeAll()
} label: {
Label(model.selectionMode ? "完成" : "批量选择", systemImage: model.selectionMode ? "xmark" : "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(model: model)
}
.confirmationDialog(
"删除已选实例?登录信息也会一并清除。",
isPresented: $showsDeleteDialog,
titleVisibility: .visible
) {
Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
Task { await model.deleteSelected() }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(model: model)
}
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AITheme.muted)
TextField("搜索实例或地址", text: $model.searchText)
.textFieldStyle(.plain)
.onSubmit {
Task { await model.refresh() }
}
AISearchField("搜索实例或地址", text: $model.searchText) {
Task { await model.refresh() }
}
.padding(.horizontal, 14)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.surface)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
private var selectionBar: some View {
AICard {
HStack(spacing: 10) {
Button {
if model.selectedIDs.count == model.rows.count {
model.selectedIDs.removeAll()
} else {
model.selectedIDs = Set(model.rows.map(\.id))
}
} label: {
Label(
model.selectedIDs.count == model.rows.count ? "取消全选" : "全选",
systemImage: "checkmark.circle"
)
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
Spacer()
Text("已选 \(model.selectedIDs.count)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.muted)
Button {
showsDeleteDialog = true
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
.disabled(model.selectedIDs.isEmpty)
}
}
}
@@ -79,6 +79,10 @@ final class FleetViewModel: ObservableObject {
await refresh()
}
func deleteSelected() async {
await delete(Array(selectedIDs))
}
private func applyProbe(_ id: UUID, _ result: ConnectionResult?) {
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
rows[index].status = result?.status ?? .unknown
@@ -9,6 +9,11 @@ struct InstanceFormView: View {
@State private var password = ""
@State private var validationMessage: String?
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
ZStack {
@@ -20,19 +25,15 @@ struct InstanceFormView: View {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
SecureField("实例密码(可选)", text: $password)
.textContentType(.password)
.padding(.horizontal, 12)
.frame(minHeight: 38)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
field("实例密码", text: $password, placeholder: "可选,保存到钥匙串", secure: true)
}
}
if let validationMessage {
Text(validationMessage)
.font(.subheadline)
.foregroundStyle(AITheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
AICard {
Label(validationMessage, systemImage: "exclamationmark.triangle")
.font(.subheadline)
.foregroundStyle(AITheme.danger)
}
}
}
.padding(16)
@@ -49,26 +50,35 @@ struct InstanceFormView: View {
save()
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(!canSave)
}
}
}
}
private func field(_ title: String, text: Binding<String>, placeholder: String) -> some View {
private func field(
_ title: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
TextField(placeholder, text: text)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: 38)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.textContentType(.password)
} else {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
}
}
}
private func save() {
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
origin.scheme == "http" || origin.scheme == "https",
origin.host != nil
@@ -81,7 +91,7 @@ struct InstanceFormView: View {
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: name,
name: normalizedName,
origin: origin,
tags: tags,
password: password.isEmpty ? nil : password