From 7778693807e8d471bcd2d29372a9ffcb1c65a6fb Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 9 Sep 2026 00:18:56 +0800 Subject: [PATCH] feat(ios): add cross-instance message center --- .../Features/Messages/MessageModels.swift | 31 ++++ .../Features/Messages/MessageService.swift | 146 ++++++++++++++++++ .../Features/Messages/MessagesView.swift | 125 +++++++++++++++ .../Features/Messages/MessagesViewModel.swift | 88 +++++++++++ .../Messages/SendMessageFormView.swift | 78 ++++++++++ .../MultiSimAdmin/MultiSimAdminApp.swift | 2 +- .../Services/DefaultContainer.swift | 12 ++ .../MessageServiceTests.swift | 78 ++++++++++ 8 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift new file mode 100644 index 0000000..1a17ac2 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift @@ -0,0 +1,31 @@ +import Foundation + +public enum SMSDirection: String, Equatable, Sendable { + case incoming + case outgoing + case unknown + + public var label: String { + switch self { + case .incoming: return "收件" + case .outgoing: return "发件" + case .unknown: return "未知" + } + } +} + +public struct SMSMessage: Equatable, Identifiable, Sendable { + public let id: String + public let instanceID: UUID + public let instanceName: String + public let phoneNumber: String + public let content: String + public let direction: SMSDirection + public let status: String + public let timestamp: String +} + +public struct MessagePage: Equatable, Sendable { + public let messages: [SMSMessage] + public let total: Int +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift new file mode 100644 index 0000000..6fbda68 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift @@ -0,0 +1,146 @@ +import Foundation + +public enum MessageServiceError: Error, Equatable { + case invalidInput + case invalidResponse + case unauthorized +} + +public struct MessageService: Sendable { + private let client: any SimAdminClientProtocol + private let sessions: InstanceSessionStore + private let credentials: (any CredentialStore)? + + public init( + client: any SimAdminClientProtocol, + sessions: InstanceSessionStore, + credentials: (any CredentialStore)? = nil + ) { + self.client = client + self.sessions = sessions + self.credentials = credentials + } + + public func messages( + _ instances: [Instance], + limit: Int = 50, + direction: SMSDirection? = nil + ) async throws -> MessagePage { + let directionText: String? + switch direction { + case .incoming: directionText = "incoming" + case .outgoing: directionText = "outgoing" + default: directionText = nil + } + + var collected: [SMSMessage] = [] + var total = 0 + var lastError: Error? + for instance in instances { + do { + let response = try await request(.smsList(limit: limit, offset: 0, direction: directionText), instance) + let page = try Self.parsePage(response, instance) + collected.append(contentsOf: page.messages) + total += page.total + } catch { + lastError = error + } + } + if collected.isEmpty && total == 0, let lastError { + throw lastError + } + collected.sort { $0.timestamp > $1.timestamp } + return MessagePage(messages: collected, total: total) + } + + public func send( + _ instance: Instance, + phoneNumber rawPhoneNumber: String, + content rawContent: String + ) async throws { + let phoneNumber = rawPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines) + let content = rawContent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !phoneNumber.isEmpty, !content.isEmpty else { + throw MessageServiceError.invalidInput + } + _ = try await request(.smsSend(phoneNumber: phoneNumber, content: content), instance) + } + + public func delete(_ ids: [String], from instance: Instance) async throws { + let numericIDs = ids.compactMap(Int.init) + guard !numericIDs.isEmpty else { + throw MessageServiceError.invalidInput + } + _ = try await request(.smsBatchDelete(ids: numericIDs), instance) + } + + private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse { + var cookie = await sessions.cookie(for: instance.id, origin: instance.origin) + do { + return try await client.send(route, baseURL: instance.origin, cookie: cookie) + } catch UpstreamError.unauthorized, UpstreamError.forbidden { + cookie = try await login(instance) + return try await client.send(route, baseURL: instance.origin, cookie: cookie) + } + } + + private func login(_ instance: Instance) async throws -> String { + guard let credentials, + let password = try credentials.get(instanceID: instance.id), + !password.isEmpty + else { + throw MessageServiceError.unauthorized + } + let sessions = InstanceSessionService(store: sessions, client: client) + let result = try await sessions.login(instance, password: password) + guard result.authenticated, + let cookie = await sessions.cookie(for: instance) + else { + throw MessageServiceError.unauthorized + } + return cookie + } + + static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage { + let root = try JSONSerialization.jsonObject(with: response.data) + guard let container = Self.dataContainer(root), + let data = container["data"] as? [String: Any] ?? container["response"] as? [String: Any] + else { + throw MessageServiceError.invalidResponse + } + let rawMessages = (data["messages"] as? [Any]) ?? [] + let messages = rawMessages.compactMap { Self.message($0, instance) } + let total = (data["total"] as? Int) ?? messages.count + return MessagePage(messages: messages, total: total) + } + + private static func dataContainer(_ root: Any) -> [String: Any]? { + root as? [String: Any] + } + + private static func message(_ raw: Any, _ instance: Instance) -> SMSMessage? { + guard let value = raw as? [String: Any] else { return nil } + let id: String + if let number = value["id"] as? Int { + id = String(number) + } else if let text = value["id"] as? String { + id = text + } else { + return nil + } + let directionText = (value["direction"] as? String)?.lowercased() ?? "" + let direction: SMSDirection = directionText == "incoming" ? .incoming + : directionText == "outgoing" ? .outgoing + : .unknown + return SMSMessage( + id: id, + instanceID: instance.id, + instanceName: instance.name, + phoneNumber: value["phone_number"] as? String ?? "", + content: value["content"] as? String ?? "", + direction: direction, + status: value["status"] as? String ?? "", + timestamp: value["timestamp"] as? String ?? "" + ) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift new file mode 100644 index 0000000..f41f931 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct MessagesView: View { + @StateObject private var model: MessagesViewModel + + init(model: MessagesViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel()) + } + + var body: some View { + ZStack { + AITheme.canvas.ignoresSafeArea() + ScrollView { + LazyVStack(spacing: 12) { + filterBar + if let errorMessage = model.errorMessage { + AICard { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(AITheme.warning) + } + } + if model.filteredRows.isEmpty && !model.isLoading { + AICard { + VStack(spacing: 10) { + Image(systemName: "message") + .font(.system(size: 28)) + .foregroundStyle(AITheme.muted) + Text("暂无消息") + .font(.headline) + Text("刷新后从实例同步短信。") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } + } + ForEach(model.filteredRows) { message in + messageCard(message) + } + } + .padding(16) + } + } + .navigationTitle("消息中心") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + model.showingSendForm = true + } label: { + Label("发送", systemImage: "paperplane") + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) + } + } + .refreshable { + await model.refresh() + } + .task { + await model.refresh() + } + .sheet(isPresented: $model.showingSendForm) { + SendMessageFormView(model: model) + } + } + + private var filterBar: some View { + AICard { + VStack(spacing: 10) { + Picker("方向", selection: $model.direction) { + ForEach(MessagesViewModel.DirectionFilter.allCases) { item in + Text(item.rawValue).tag(item) + } + } + .pickerStyle(.segmented) + + Picker("实例", selection: $model.selectedInstanceID) { + Text("全部实例").tag(Optional.none) + ForEach(model.instances) { instance in + Text(instance.name).tag(Optional(instance.id)) + } + } + .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) + } + } + } + } + + private func messageCard(_ message: SMSMessage) -> some View { + AICard { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber) + .font(.headline) + Spacer() + AITag(message.direction.label, tint: message.direction == .outgoing ? AITheme.primary : AITheme.success) + } + Text(message.content) + .font(.subheadline) + .foregroundStyle(AITheme.ink) + HStack { + Text(message.instanceName) + .font(.caption) + .foregroundStyle(AITheme.muted) + Spacer() + Button("删除") { + Task { await model.delete(message) } + } + .buttonStyle(AIButtonStyle(kind: .danger, size: .compact)) + } + } + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift new file mode 100644 index 0000000..5b26f10 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift @@ -0,0 +1,88 @@ +import Foundation + +@MainActor +final class MessagesViewModel: ObservableObject { + enum DirectionFilter: String, CaseIterable, Identifiable { + case all = "全部" + case incoming = "收件" + case outgoing = "发件" + + var id: String { rawValue } + } + + @Published var rows: [SMSMessage] = [] + @Published var instances: [Instance] = [] + @Published var selectedInstanceID: UUID? + @Published var searchText = "" + @Published var direction: DirectionFilter = .all + @Published var isLoading = false + @Published var errorMessage: String? + @Published var showingSendForm = false + + private let repository: any InstanceRepository + private let service: MessageService + + init(repository: any InstanceRepository, service: MessageService) { + self.repository = repository + self.service = service + } + + var filteredRows: [SMSMessage] { + rows.filter { message in + selectedInstanceID == nil || message.instanceID == selectedInstanceID + }.filter { message in + switch direction { + case .all: return true + case .incoming: return message.direction == .incoming + case .outgoing: return message.direction == .outgoing + } + }.filter { message in + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return true } + return message.phoneNumber.localizedCaseInsensitiveContains(query) + || message.content.localizedCaseInsensitiveContains(query) + || message.instanceName.localizedCaseInsensitiveContains(query) + } + } + + var selectedInstance: Instance? { + instances.first { $0.id == selectedInstanceID } + } + + func refresh() async { + isLoading = true + errorMessage = nil + do { + let loaded = try await repository.list(InstanceQuery()) + instances = loaded + if selectedInstanceID == nil || !loaded.contains(where: { $0.id == selectedInstanceID }) { + selectedInstanceID = nil + } + let page = try await service.messages(loaded, limit: 100) + rows = page.messages + } catch MessageServiceError.unauthorized { + errorMessage = "有实例需要登录,未获取受保护消息。" + } catch { + errorMessage = "无法加载消息。" + } + isLoading = false + } + + func send(phoneNumber: String, content: String) async throws { + guard let instance = selectedInstance ?? instances.first else { + throw MessageServiceError.invalidInput + } + try await service.send(instance, phoneNumber: phoneNumber, content: content) + await refresh() + } + + func delete(_ message: SMSMessage) async { + guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return } + do { + try await service.delete([message.id], from: instance) + rows.removeAll { $0.id == message.id && $0.instanceID == message.instanceID } + } catch { + errorMessage = "删除消息失败。" + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift new file mode 100644 index 0000000..3e35a59 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct SendMessageFormView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var model: MessagesViewModel + @State private var phoneNumber = "" + @State private var content = "" + @State private var validationMessage: String? + + var body: some View { + NavigationStack { + ZStack { + AITheme.canvas.ignoresSafeArea() + ScrollView { + VStack(spacing: 12) { + AICard { + VStack(alignment: .leading, spacing: 14) { + Picker("实例", selection: Binding( + get: { model.selectedInstanceID ?? model.instances.first?.id }, + set: { model.selectedInstanceID = $0 } + )) { + ForEach(model.instances) { instance in + Text(instance.name).tag(Optional(instance.id)) + } + } + .pickerStyle(.menu) + + TextField("收件号码", text: $phoneNumber) + .padding(.horizontal, 12) + .frame(minHeight: AITheme.buttonHeight) + .background(AITheme.canvas) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + + TextField("消息内容", text: $content, axis: .vertical) + .lineLimit(4...8) + .padding(12) + .background(AITheme.canvas) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + } + } + if let validationMessage { + Text(validationMessage) + .font(.subheadline) + .foregroundStyle(AITheme.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + } + } + .navigationTitle("发送消息") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) + } + ToolbarItem(placement: .confirmationAction) { + Button("发送") { send() } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) + } + } + } + } + + private func send() { + Task { + do { + try await model.send(phoneNumber: phoneNumber, content: content) + phoneNumber = "" + content = "" + validationMessage = nil + dismiss() + } catch { + validationMessage = "发送失败,请检查号码、内容和实例登录状态。" + } + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 70d50f5..fdea6f7 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -9,7 +9,7 @@ struct MultiSimAdminApp: App { .tabItem { Label("节点", systemImage: "server.rack") } - Text("消息") + MessagesView() .tabItem { Label("消息", systemImage: "message") } diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index e43dade..cc2c1a6 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -14,4 +14,16 @@ enum DefaultContainer { credentials: KeychainCredentialStore() ) } + + @MainActor + static func makeMessagesModel() -> MessagesViewModel { + MessagesViewModel( + repository: FileInstanceRepository(location: documentFileURL("instances.json")), + service: MessageService( + client: SimAdminClient(), + sessions: InstanceSessionStore(), + credentials: KeychainCredentialStore() + ) + ) + } } diff --git a/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift new file mode 100644 index 0000000..4a87499 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift @@ -0,0 +1,78 @@ +import XCTest + +@testable import MultiSimAdmin + +final class MessageServiceTests: XCTestCase { + func testParsesUpstreamEnvelopeAndNormalizesMessages() async throws { + let store = InstanceSessionStore() + let client = MessageTestClient() + let service = MessageService(client: client, sessions: store) + let instance = Self.instance() + await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token") + + let page = try await service.messages([instance], limit: 2) + + XCTAssertEqual(client.routes.first, .smsList(limit: 2, offset: 0, direction: nil)) + XCTAssertEqual(page.messages.count, 1) + let message = try XCTUnwrap(page.messages.first) + XCTAssertEqual(message.id, "42") + XCTAssertEqual(message.instanceID, instance.id) + XCTAssertEqual(message.instanceName, "Message Node") + XCTAssertEqual(message.phoneNumber, "13800138000") + XCTAssertEqual(message.content, "device ready") + XCTAssertEqual(message.direction, .outgoing) + XCTAssertEqual(message.status, "sent") + } + + func testSendUsesStoredSessionAndRejectsInvalidInput() async throws { + let store = InstanceSessionStore() + let client = MessageTestClient() + let service = MessageService(client: client, sessions: store) + let instance = Self.instance() + await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token") + + try await service.send(instance, phoneNumber: "13800138000", content: "hello") + XCTAssertEqual(client.routes.last, .smsSend(phoneNumber: "13800138000", content: "hello")) + + do { + try await service.send(instance, phoneNumber: "", content: "hello") + XCTFail("Empty phone number should fail.") + } catch { + XCTAssertEqual(error as? MessageServiceError, .invalidInput) + } + } + + private static func instance() -> Instance { + Instance( + id: UUID(), + name: "Message Node", + origin: URL(string: "http://192.168.1.20:3000")!, + configRevision: 1, + createdAt: Date(), + updatedAt: Date() + ) + } +} + +private final class MessageTestClient: SimAdminClientProtocol, @unchecked Sendable { + var routes: [UpstreamRoute] = [] + + func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { + routes.append(route) + guard cookie == "simadmin_session=token" else { + throw UpstreamError.unauthorized + } + switch route { + case .smsList: + let json = """ + {"status":"ok","data":{"total":1,"messages":[{"id":42,"phone_number":"13800138000", + "content":"device ready","direction":"outgoing","status":"sent","timestamp":"2026-09-08T10:00:00Z"}]}} + """ + return UpstreamResponse(status: 200, data: Data(json.utf8)) + case .smsSend: + return UpstreamResponse(status: 200, data: Data(#"{"status":"ok"}"#.utf8)) + default: + return UpstreamResponse(status: 200, data: Data("{}".utf8)) + } + } +}