feat(ios): refine native interface and app icon

Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
This commit is contained in:
chick
2026-09-12 14:09:02 +08:00
parent 31b2f33d15
commit 0f2866a853
16 changed files with 1293 additions and 1035 deletions
@@ -1,212 +1,217 @@
import SwiftUI
struct FleetView: View {
@StateObject private var model: FleetViewModel
@State private var showsDeleteDialog = false
@StateObject private var model: FleetViewModel
@State private var showsDeleteDialog = false
init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
}
init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if model.selectionMode {
selectionBar
}
if model.isLoading && model.rows.isEmpty {
AILoadingCard("正在同步实例数据")
}
if model.rows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "server.rack",
title: "暂无实例",
message: "添加 SimAdmin 实例后即可开始管理。"
)
}
}
ForEach(model.rows) { row in
if model.selectionMode {
instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if model.selectionMode {
selectionBar
}
if model.isLoading && model.rows.isEmpty {
AILoadingCard("正在同步实例数据")
}
if model.rows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "server.rack",
title: "暂无实例",
message: "添加 SimAdmin 实例后即可开始管理。"
)
}
}
.navigationTitle("节点总览")
.aiBottomActionBar {
if !model.selectionMode {
HStack(spacing: 10) {
Button {
model.selectionMode = true
} label: {
Label("批量选择", systemImage: "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
}
}
ForEach(model.rows) { row in
if model.selectionMode {
instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
}
}
}
.confirmationDialog(
"删除已选实例?登录信息也会一并清除。",
isPresented: $showsDeleteDialog,
titleVisibility: .visible
) {
Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
Task { await model.deleteSelected() }
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
if !model.selectionMode {
AIActionRow {
Button {
model.selectionMode = true
} label: {
Label("批量选择", systemImage: "checklist")
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(model: model)
}
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
private var searchField: some View {
AISearchField("搜索实例或地址", text: $model.searchText) {
Task { await model.refresh() }
}
}
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,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
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,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
.disabled(model.selectedIDs.isEmpty)
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
}
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
AICard {
HStack(alignment: .top, spacing: 12) {
if model.selectionMode {
Button {
toggle(row.id)
} label: {
Image(systemName: model.selectedIDs.contains(row.id)
? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
}
.buttonStyle(.plain)
.frame(width: 32, height: 32)
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(row.status.label, tint: statusColor(row.status))
}
Text(row.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
if !row.instance.tags.isEmpty {
HStack(spacing: 6) {
ForEach(row.instance.tags, id: \.self) { tag in
AITag(tag)
}
}
}
}
}
}
}
private func statusColor(_ status: ConnectionStatus) -> Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired: return AITheme.warning
case .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
private func toggle(_ id: UUID) {
if model.selectedIDs.contains(id) {
model.selectedIDs.remove(id)
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
} else {
model.selectedIDs.insert(id)
AIActionRow {
Button {
model.selectionMode = false
model.selectedIDs.removeAll()
} label: {
Label("取消选择", systemImage: "xmark.circle")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
showsDeleteDialog = true
} label: {
Label(
model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count)",
systemImage: "trash"
)
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
.disabled(model.selectedIDs.isEmpty)
}
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("节点总览")
.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 {
AISearchField("搜索实例或地址", text: $model.searchText) {
Task { await model.refresh() }
}
}
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)
}
}
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
AICard {
HStack(alignment: .top, spacing: 12) {
if model.selectionMode {
Button {
toggle(row.id)
} label: {
Image(
systemName: model.selectedIDs.contains(row.id)
? "checkmark.circle.fill" : "circle"
)
.font(.system(size: 20))
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
}
.buttonStyle(.plain)
.frame(width: 44, height: 44)
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(row.status.label, tint: statusColor(row.status))
}
Text(row.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
if !row.instance.tags.isEmpty {
HStack(spacing: 6) {
ForEach(row.instance.tags, id: \.self) { tag in
AITag(tag)
}
}
}
}
}
}
}
private func statusColor(_ status: ConnectionStatus) -> Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired: return AITheme.warning
case .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
private func toggle(_ id: UUID) {
if model.selectedIDs.contains(id) {
model.selectedIDs.remove(id)
} else {
model.selectedIDs.insert(id)
}
}
}
@@ -1,106 +1,125 @@
import SwiftUI
struct InstanceFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var password = ""
@State private var validationMessage: String?
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var password = ""
@State private var validationMessage: String?
@State private var isSaving = false
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
}
}
if let validationMessage {
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
}
.navigationTitle("添加实例")
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
}
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() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave)
.frame(maxWidth: .infinity)
}
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("添加实例")
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
private func field(
_ title: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel(title)
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.textContentType(.password)
} else {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
}
}
private func field(
_ title: String,
text: Binding<String>,
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(title == "地址" ? .next : .done)
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
}
}
}
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
else {
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
return
}
let tags = tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: normalizedName,
origin: origin,
tags: tags,
password: password.isEmpty ? nil : password
)
Task {
do {
try await model.add(input)
dismiss()
} catch {
validationMessage = "保存失败,请检查地址是否重复。"
}
}
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
}
let tags =
tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: normalizedName,
origin: origin,
tags: tags,
password: password.isEmpty ? nil : password
)
Task {
do {
try await model.add(input)
} catch {
validationMessage = "保存失败,请检查地址是否重复。"
}
isSaving = false
}
}
}