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
@@ -9,6 +9,7 @@ struct LogsView: View {
@StateObject private var model: LogsViewModel
@State private var tab: Tab = .runtime
@State private var showsPruneDialog = false
init(model: LogsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
@@ -54,10 +55,22 @@ struct LogsView: View {
} label: {
Label("刷新", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
.disabled(model.isLoading)
}
}
.confirmationDialog(
"清理 7 天前的连接日志?操作不可恢复。",
isPresented: $showsPruneDialog,
titleVisibility: .visible
) {
Button("清理连接日志", role: .destructive) {
Task {
_ = await model.pruneConnections(olderThanDays: 7)
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
@@ -81,16 +94,7 @@ struct LogsView: View {
private var filterBar: some View {
VStack(spacing: 10) {
TextField("搜索日志、错误码或实例", text: $model.searchText)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
@@ -168,9 +172,7 @@ struct LogsView: View {
HStack {
sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条")
Button {
Task {
_ = await model.pruneConnections(olderThanDays: 7)
}
showsPruneDialog = true
} label: {
Label("清理", systemImage: "trash")
}
@@ -2,6 +2,7 @@ import SwiftUI
struct MessagesView: View {
@StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
@@ -54,6 +55,20 @@ struct MessagesView: View {
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
}
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteMessage != nil },
set: { if !$0 { pendingDeleteMessage = nil } }
),
titleVisibility: .visible
) {
Button("删除消息", role: .destructive) {
guard let message = pendingDeleteMessage else { return }
Task { await model.delete(message) }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
@@ -83,16 +98,7 @@ struct MessagesView: View {
}
.pickerStyle(.menu)
TextField("搜索号码或内容", text: $model.searchText)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
AISearchField("搜索号码或内容", text: $model.searchText)
}
}
}
@@ -115,7 +121,7 @@ struct MessagesView: View {
.foregroundStyle(AITheme.muted)
Spacer()
Button("删除") {
Task { await model.delete(message) }
pendingDeleteMessage = message
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
}
@@ -7,6 +7,18 @@ struct SendMessageFormView: View {
@State private var content = ""
@State private var validationMessage: String?
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool {
!trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
var body: some View {
NavigationStack {
ZStack {
@@ -26,16 +38,19 @@ struct SendMessageFormView: View {
.pickerStyle(.menu)
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
TextField("消息内容", text: $content, axis: .vertical)
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
.lineLimit(4...8)
.padding(12)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
}
}
if let validationMessage {
@@ -57,6 +72,7 @@ struct SendMessageFormView: View {
ToolbarItem(placement: .confirmationAction) {
Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(!canSend)
}
}
}
@@ -65,7 +81,7 @@ struct SendMessageFormView: View {
private func send() {
Task {
do {
try await model.send(phoneNumber: phoneNumber, content: content)
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = ""
content = ""
validationMessage = nil
@@ -2,6 +2,7 @@ import SwiftUI
struct NotificationsView: View {
@StateObject private var model: NotificationsViewModel
@State private var pendingDeleteQueueID: String?
init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
@@ -58,6 +59,24 @@ struct NotificationsView: View {
.disabled(model.isLoading)
}
}
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteQueueID != nil },
set: { if !$0 { pendingDeleteQueueID = nil } }
),
titleVisibility: .visible
) {
Button("删除队列记录", role: .destructive) {
guard let id = pendingDeleteQueueID else { return }
Task {
if let item = model.overview?.queue.items.first(where: { $0.id == id }) {
await model.deleteQueue(item)
}
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
@@ -189,7 +208,7 @@ struct NotificationsView: View {
.disabled(model.busyQueueID == item.id)
Button {
Task { await model.deleteQueue(item) }
pendingDeleteQueueID = item.id
} label: {
Label("删除", systemImage: "trash")
}
@@ -31,7 +31,6 @@ struct SettingsView: View {
runtimeCard
storageCard
securityCard
backgroundCard
}
.padding(16)
}
@@ -71,10 +70,8 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 12) {
sectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
infoRow("名称", runtime.appName)
infoRow("版本", "\(runtime.marketingVersion) (\(runtime.buildVersion))")
infoRow("标识符", AppMetadata.bundleIdentifier)
infoRow("设备", "\(runtime.platform) · \(runtime.deviceModel)")
infoRow("设备", runtime.deviceModel)
} else {
Text("正在读取应用信息…")
.font(.subheadline)
@@ -88,15 +85,15 @@ struct SettingsView: View {
AICard {
VStack(alignment: .leading, spacing: 12) {
sectionTitle("本地数据", systemImage: "externaldrive")
infoRow("实例配置", "App Documents/instances.json")
infoRow("日志中心", "App Documents/log-center.json")
infoRow("日志数量", model.storageLabel)
infoRow("存储位置", "仅保存在本机应用沙盒")
infoRow("当前占用", model.storageLabel)
Button {
showsClearDialog = true
} label: {
Label("清除运行日志", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular))
.frame(maxWidth: .infinity)
.buttonStyle(AIButtonStyle(kind: .danger, size: .large))
}
}
}
@@ -107,25 +104,6 @@ struct SettingsView: View {
sectionTitle("安全与登录", systemImage: "lock.shield")
infoRow("实例密码", "保存在 iOS 钥匙串")
infoRow("登录会话", "仅保存在内存")
infoRow("访问密码", "已停用")
}
}
}
private var backgroundCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
sectionTitle("后台能力", systemImage: "clock.badge.exclamationmark")
Text(model.serverCapabilitiesNotice)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
if model.serverCapabilities.isEmpty {
AITag("无后台任务", tint: AITheme.muted)
} else {
ForEach(model.serverCapabilities) { capability in
infoRow(capability.name, capability.detail)
}
}
}
}
}
@@ -9,19 +9,11 @@ struct SettingsRuntime: Equatable {
let deviceModel: String
}
struct ServerCapability: Equatable, Identifiable {
let id: String
let name: String
let detail: String
}
@MainActor
final class SettingsViewModel: ObservableObject {
@Published private(set) var runtime: SettingsRuntime?
@Published private(set) var serverCapabilities: [ServerCapability] = []
@Published private(set) var isLoading = false
@Published var storageLabel = "--"
@Published var serverCapabilitiesNotice = ""
@Published var notice: String?
@Published var errorMessage: String?
@@ -42,8 +34,6 @@ final class SettingsViewModel: ObservableObject {
platform: "iOS",
deviceModel: Self.deviceModel()
)
serverCapabilities = []
serverCapabilitiesNotice = "定时任务与后台刷新已在 iOS 版停用。"
await loadStorageLabel()
isLoading = false
@@ -5,26 +5,36 @@ struct MultiSimAdminApp: App {
var body: some Scene {
WindowGroup {
TabView {
FleetView()
.tabItem {
Label("节点", systemImage: "server.rack")
}
MessagesView()
.tabItem {
Label("消息", systemImage: "message")
}
NotificationsView()
.tabItem {
Label("通知", systemImage: "bell")
}
LogsView()
.tabItem {
Label("日志", systemImage: "doc.text")
}
SettingsView()
.tabItem {
Label("设置", systemImage: "gearshape")
}
NavigationStack {
FleetView()
}
.tabItem {
Label("节点", systemImage: "server.rack")
}
NavigationStack {
MessagesView()
}
.tabItem {
Label("消息", systemImage: "message")
}
NavigationStack {
NotificationsView()
}
.tabItem {
Label("通知", systemImage: "bell")
}
NavigationStack {
LogsView()
}
.tabItem {
Label("日志", systemImage: "doc.text")
}
NavigationStack {
SettingsView()
}
.tabItem {
Label("设置", systemImage: "gearshape")
}
}
.tint(AITheme.primary)
}
@@ -12,6 +12,7 @@
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key><true/>
@@ -1,6 +1,10 @@
import Foundation
enum DefaultContainer {
private static let logStore: any LogStore = FileLogStore(
location: documentFileURL("log-center.json")
)
static func documentFileURL(_ name: String) -> URL {
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return directory.appendingPathComponent(name)
@@ -41,7 +45,7 @@ enum DefaultContainer {
@MainActor
static func makeLogsModel() -> LogsViewModel {
let store = FileLogStore(location: documentFileURL("log-center.json"))
let store = logStore
return LogsViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
service: LogCenterService(
@@ -56,7 +60,7 @@ enum DefaultContainer {
@MainActor
static func makeSettingsModel() -> SettingsViewModel {
SettingsViewModel(store: FileLogStore(location: documentFileURL("log-center.json")))
SettingsViewModel(store: logStore)
}
@MainActor
@@ -6,18 +6,22 @@ public struct AIButtonStyle: ButtonStyle {
case secondary
case danger
var foregroundColor: Color {
var foregroundColor: Color { foregroundColor(isEnabled: true) }
var backgroundColor: Color { backgroundColor(isEnabled: true) }
func foregroundColor(isEnabled: Bool) -> Color {
switch self {
case .primary, .danger: return .white
case .secondary: return AITheme.ink
case .primary, .danger: return .white.opacity(isEnabled ? 1 : 0.72)
case .secondary: return isEnabled ? AITheme.ink : AITheme.muted
}
}
var backgroundColor: Color {
func backgroundColor(isEnabled: Bool) -> Color {
switch self {
case .primary: return AITheme.primary
case .secondary: return AITheme.surface
case .danger: return AITheme.danger
case .primary: return isEnabled ? AITheme.primary : AITheme.primary.opacity(0.35)
case .secondary: return isEnabled ? AITheme.surface : AITheme.canvas
case .danger: return isEnabled ? AITheme.danger : AITheme.danger.opacity(0.30)
}
}
}
@@ -30,6 +34,7 @@ public struct AIButtonStyle: ButtonStyle {
private let kind: Kind
private let size: Size
@Environment(\.isEnabled) private var isEnabled
public init(kind: Kind = .secondary, size: Size = .regular) {
self.kind = kind
@@ -43,17 +48,25 @@ public struct AIButtonStyle: ButtonStyle {
case .regular: AITheme.buttonHeight
}
configuration.label
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(kind.foregroundColor)
.frame(minWidth: 44, minHeight: height)
.padding(.horizontal, 14)
.background(kind.backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(kind == .secondary ? AITheme.line : .clear)
}
configuration.label
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
.frame(minWidth: minimumWidth, minHeight: height)
.padding(.horizontal, kind == .secondary ? 12 : 14)
.background(kind.backgroundColor(isEnabled: isEnabled))
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear)
}
.opacity(configuration.isPressed ? 0.78 : 1)
}
private var minimumWidth: CGFloat {
switch size {
case .compact: return AITheme.compactButtonMinWidth
case .regular: return AITheme.regularButtonMinWidth
case .large: return 92
}
}
}
@@ -39,6 +39,58 @@ public struct AITag: View {
}
}
public struct AIFieldStyle: TextFieldStyle {
public init() {}
public func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
}
public struct AISearchField: View {
private let placeholder: String
@Binding private var text: String
private var onSubmit: (() -> Void)?
public init(
_ placeholder: String,
text: Binding<String>,
onSubmit: (() -> Void)? = nil
) {
self.placeholder = placeholder
self._text = text
self.onSubmit = onSubmit
}
public var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AITheme.muted)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.onSubmit {
onSubmit?()
}
}
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.surface)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
}
public struct AIBrandBar: View {
public init() {}
@@ -12,10 +12,12 @@ public enum AITheme {
public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067)
public static let success = Color(red: 0.082, green: 0.584, blue: 0.439)
public static let buttonHeight: CGFloat = 38
public static let compactButtonHeight: CGFloat = 34
public static let largeButtonHeight: CGFloat = 42
public static let iconButtonSize: CGFloat = 40
public static let buttonRadius: CGFloat = 10
public static let cardRadius: CGFloat = 14
public static let buttonHeight: CGFloat = 44
public static let compactButtonHeight: CGFloat = 40
public static let largeButtonHeight: CGFloat = 50
public static let iconButtonSize: CGFloat = 44
public static let buttonRadius: CGFloat = 12
public static let cardRadius: CGFloat = 16
public static let compactButtonMinWidth: CGFloat = 58
public static let regularButtonMinWidth: CGFloat = 76
}
@@ -4,12 +4,12 @@ import XCTest
@testable import MultiSimAdmin
final class AIThemeTests: XCTestCase {
func testControlDimensionsMatchWebScale() {
XCTAssertEqual(AITheme.buttonHeight, 38, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 34, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 42, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 10, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 40, accuracy: 0.1)
func testControlDimensionsMatchIOS() {
XCTAssertEqual(AITheme.buttonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 40, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 50, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 12, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1)
}
func testButtonKindsHaveDistinctBaseColors() {
@@ -20,4 +20,23 @@ final class AIThemeTests: XCTestCase {
].map(\.backgroundColor)
XCTAssertEqual(Set(colors).count, 3)
}
func testDisabledKindsUseReducedButVisibleRendering() {
let disabledKinds = [
AIButtonStyle.Kind.primary,
.secondary,
.danger
]
let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) }
XCTAssertEqual(Set(disabledColors).count, disabledKinds.count)
XCTAssertEqual(
disabledKinds.map { $0.foregroundColor(isEnabled: false) },
[
.white.opacity(0.72),
AITheme.muted,
.white.opacity(0.72)
]
)
}
}
@@ -52,6 +52,36 @@ final class FleetViewModelTests: XCTestCase {
XCTAssertTrue(model.rows.isEmpty)
XCTAssertNil(credentials.saved[row.instance.id])
}
func testDeleteSelectedClearsSelectionAndCredential() async throws {
let repository = MemoryInstanceRepository()
let credentials = NoopCredentialStore()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: credentials
)
try await model.add(InstanceInput(
name: "Selected",
origin: URL(string: "http://192.168.1.50:3000")!,
password: "pass"
))
try await model.add(InstanceInput(
name: "Other",
origin: URL(string: "http://192.168.1.60:3000")!,
password: nil
))
let selectedID = model.rows[0].id
model.selectionMode = true
model.selectedIDs = [selectedID]
await model.deleteSelected()
XCTAssertEqual(model.rows.map(\.instance.name), ["Selected"])
XCTAssertEqual(credentials.removed, [selectedID])
XCTAssertFalse(model.selectionMode)
XCTAssertTrue(model.selectedIDs.isEmpty)
}
}
struct AlwaysOnlineClient: SimAdminClientProtocol {
@@ -4,7 +4,7 @@ import XCTest
final class SettingsViewModelTests: XCTestCase {
@MainActor
func testLoadsRuntimeAppAndDisabledServerCapabilities() async throws {
func testLoadsRuntimeWithoutServerCapabilities() async throws {
let store = SettingsMemoryLogStore()
let model = SettingsViewModel(store: store)
@@ -16,8 +16,6 @@ final class SettingsViewModelTests: XCTestCase {
XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false)
XCTAssertTrue(model.serverCapabilities.isEmpty)
XCTAssertEqual(model.serverCapabilitiesNotice, "定时任务与后台刷新已在 iOS 版停用。")
XCTAssertNil(model.errorMessage)
}