diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 3ed4894..534f942 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -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) + } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift index 5d4a2f4..f5cb838 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift @@ -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 diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift index f657046..f8a4f0a 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -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, placeholder: String) -> some View { + private func field( + _ title: String, + text: Binding, + 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 diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift index 0486cb1..b2602e6 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -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.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") } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift index f41f931..147a441 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift @@ -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)) } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift index 3e35a59..c3c0455 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift @@ -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 diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift index 3e560d3..da1008d 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift @@ -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") } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift index 5ad826d..e3170ab 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -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) - } - } } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift index 3e95903..6952be1 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift @@ -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 diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 1dee336..73c27b1 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -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) } diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist index 292d904..d71dde0 100644 --- a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist +++ b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist @@ -12,6 +12,7 @@ CFBundleShortVersionString0.1.0 CFBundleVersion1 LSRequiresIPhoneOS + MinimumOSVersion16.0 NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index 3a3ee5d..2f193b5 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -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 diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift index 5930c5e..a9945de 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift @@ -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 + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift index 2e73e38..2907e0a 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -39,6 +39,58 @@ public struct AITag: View { } } +public struct AIFieldStyle: TextFieldStyle { + public init() {} + + public func _body(configuration: TextField) -> 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, + 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() {} diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift index a2b8711..3876eb4 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -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 } diff --git a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift index 6985c85..0144a9f 100644 --- a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift @@ -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) + ] + ) + } } diff --git a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift index 55cf60b..8505306 100644 --- a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift @@ -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 { diff --git a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift index 74e55a5..e72f9ad 100644 --- a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift @@ -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) }