diff --git a/apps/ios/Assets/AppIcon.svg b/apps/ios/Assets/AppIcon.svg index 935ed05..8d3d180 100644 --- a/apps/ios/Assets/AppIcon.svg +++ b/apps/ios/Assets/AppIcon.svg @@ -1,37 +1,45 @@ - - - - + + + + + + + + + + - - - + + + + - - - + + + + + + + + + + + + - - - + + - - - - - - - - - - - - - - + + + + + + + diff --git a/apps/ios/Design/AppIcon.svg b/apps/ios/Design/AppIcon.svg index 0200841..8d3d180 100644 --- a/apps/ios/Design/AppIcon.svg +++ b/apps/ios/Design/AppIcon.svg @@ -1,73 +1,45 @@ - + - - - - - - - - - + + + + - - - - - + + + + + - - - + + + - - - - - - - + + + - - - + + + - - - - - + + - - - - - - + + + + + - - - - + + + + - - - - - - - - - - - - - - - diff --git a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift index ff26da9..4339a48 100644 --- a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift +++ b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift @@ -1,6 +1,6 @@ public enum AppMetadata { public static let name = "MultiSimAdmin" public static let bundleIdentifier = "fun.chick.multi-simadmin" - public static let marketingVersion = "0.1.0" - public static let buildVersion = "1" + public static let marketingVersion = "0.2.0" + public static let buildVersion = "2" } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index b680ebe..5eead1d 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -38,40 +38,95 @@ struct InstanceDetailView: View { } private var header: some View { - AICard { - VStack(alignment: .leading, spacing: 12) { - AISectionTitle( - "设备详情", - systemImage: "iphone.gen3", - actionTitle: revealsSensitive ? "隐藏敏感值" : "显示敏感值", - actionSystemImage: revealsSensitive ? "eye.slash" : "eye" - ) { - revealsSensitive.toggle() + AICard(padding: 18) { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 14) { + Image(systemName: "cpu") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(AITheme.primary) + .frame(width: 46, height: 46) + .background(AITheme.primary.opacity(0.11)) + .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 5) { + Text(model.instance.name) + .font(.title3.weight(.bold)) + .kerning(-0.2) + .foregroundStyle(AITheme.ink) + .lineLimit(2) + Text(model.instance.origin.absoluteString) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(2) + .truncationMode(.middle) + } } - Text(model.instance.origin.absoluteString) - .font(.subheadline) - .foregroundStyle(AITheme.muted) - .lineLimit(2) - .truncationMode(.middle) + + HStack(spacing: 10) { + AITag(revealsSensitive ? "敏感值已显示" : "敏感值已隐藏", tint: AITheme.primary) + if let availableCount { + AITag("\(availableCount)/6 模块", tint: AITheme.success) + } + } + + Button { + withAnimation(.easeOut(duration: 0.18)) { + revealsSensitive.toggle() + } + } label: { + Label( + revealsSensitive ? "隐藏敏感值" : "显示敏感值", + systemImage: revealsSensitive ? "eye.slash" : "eye" + ) + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .compact, fixedWidth: 112)) + if model.isLoading { - Text("正在读取设备详情…") - .font(.subheadline) - .foregroundStyle(AITheme.muted) + AILoadingCard("正在读取设备详情") } } } } + private var availableCount: Int? { + guard let snapshot = model.snapshot else { return nil } + return snapshot.sections.filter { !$0.values.isEmpty }.count + } + + @ViewBuilder private func sectionCard(_ section: DeviceSection) -> some View { - AICard { - VStack(alignment: .leading, spacing: 12) { - AISectionTitle(section.title, systemImage: "list.bullet.rectangle") - if section.values.isEmpty { - Text("此模块当前不可用或未返回数据。") - .font(.subheadline) + if section.values.isEmpty { + AICard(padding: 14) { + HStack(spacing: 10) { + Image(systemName: "wifi.slash") + .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.muted) - } else { - ForEach(sortedValues(section.values), id: \.key) { item in + .frame(width: 32, height: 32) + .background(AITheme.control) + .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(section.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + Text("模块未返回数据") + .font(.caption) + .foregroundStyle(AITheme.muted) + } + } + } + } else { + AICard(padding: 18) { + VStack(alignment: .leading, spacing: 16) { + AISectionTitle(section.title, systemImage: sectionIcon(section.id)) + Divider() + .overlay(AITheme.line) + ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in + if index > 0 { + Divider() + .overlay(AITheme.line.opacity(0.6)) + } AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value)) } } @@ -79,6 +134,18 @@ struct InstanceDetailView: View { } } + private func sectionIcon(_ id: String) -> String { + switch id { + case "device": return "iphone.gen3" + case "sim": return "simcard" + case "network": return "antenna.radiowaves.left.and.right" + case "stats": return "gauge.with.needle" + case "cpu": return "cpu" + case "connectivity": return "dot.radiowaves.left.and.right" + default: return "list.bullet.rectangle" + } + } + private let fieldLabels: [String: String] = [ "model": "型号", "manufacturer": "制造商", diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift index 9009a16..bf75e9b 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift @@ -5,7 +5,7 @@ public enum RuntimeLogSource: String, Codable, Equatable, Sendable, CaseIterable public var label: String { switch self { - case .delivery: return "通知投递" + case .delivery: return "节点运行" } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift index 0027a8c..8e1eb33 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift @@ -102,17 +102,7 @@ public struct LogCenterService: Sendable { )) } - let deliveryEntries = probe.outcome == .failed ? [] : await deliveryLogs(instance) - result.runtime.append(contentsOf: deliveryEntries) - if probe.outcome == .failed { - modules["delivery"] = .failure(UpstreamError.notSupported) - } else if deliveryEntries.contains(where: { $0.code == "UNAUTHORIZED" }) { - modules["delivery"] = .failure(UpstreamError.unauthorized) - } else if deliveryEntries.contains(where: { $0.code == "UPSTREAM" }) { - modules["delivery"] = .failure(UpstreamError.invalidResponse) - } else { - modules["delivery"] = .success(true) - } + modules["status"] = .success(true) result.diagnostic = diagnostic( instance, probe: probe, @@ -190,41 +180,6 @@ public struct LogCenterService: Sendable { } } - private func deliveryLogs(_ instance: Instance) async -> [RuntimeLogEntry] { - do { - let response = try await request(.notificationLogs(limit: 50, offset: 0), instance) - let entries = Self.deliveryEntries(response, instance) - if entries.isEmpty { - throw UpstreamError.invalidResponse - } - return entries - } catch UpstreamError.unauthorized, UpstreamError.forbidden { - return [RuntimeLogEntry( - id: "delivery-\(instance.id.uuidString)-auth", - source: .delivery, - level: .warning, - message: "通知日志未同步:需要登录", - instanceID: instance.id, - instanceName: instance.name, - occurredAt: Date(), - code: "UNAUTHORIZED", - durationMs: nil - )] - } catch { - return [RuntimeLogEntry( - id: "delivery-\(instance.id.uuidString)-failed", - source: .delivery, - level: .warning, - message: "通知日志未同步:接口异常", - instanceID: instance.id, - instanceName: instance.name, - occurredAt: Date(), - code: "UPSTREAM", - durationMs: nil - )] - } - } - private func diagnostic( _ instance: Instance, probe: ProbeOutcome, @@ -234,7 +189,7 @@ public struct LogCenterService: Sendable { let featureStatus = modules .sorted { $0.key < $1.key } .map { key, result -> FeatureStatus in - let name = key == "connection" ? "连接" : key == "device" ? "设备" : "通知" + let name = key == "connection" ? "连接" : key == "status" ? "状态" : "设备" switch result { case .success: return FeatureStatus(name: name, state: .ready) @@ -270,39 +225,6 @@ public struct LogCenterService: Sendable { ) } - static func deliveryEntries( - _ response: UpstreamResponse, - _ instance: Instance - ) -> [RuntimeLogEntry] { - guard let root = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any], - let data = root["data"] as? [String: Any], - let raw = data["logs"] as? [[String: Any]] - else { - return [] - } - return raw.compactMap { item in - guard let id = Self.text(item["id"]), - let status = Self.text(item["status"]), - let eventType = Self.text(item["event_type"]) - else { - return nil - } - let level: RuntimeLogLevel = status == "failed" ? .error : .info - return RuntimeLogEntry( - id: "delivery-\(instance.id.uuidString)-\(id)", - source: .delivery, - level: level, - message: Self.text(item["message"]) ?? "\(eventType) · \(status)", - eventType: eventType, - instanceID: instance.id, - instanceName: instance.name, - occurredAt: Self.date(item["created_at"]), - code: status, - durationMs: nil - ) - } - } - private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse { var cookie = await sessions.cookie(for: instance.id, origin: instance.origin) do { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift index f05a734..be820cb 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -8,11 +8,14 @@ struct LogsView: View { } @StateObject private var model: LogsViewModel + private let autoRefreshOnAppear: Bool @State private var tab: Tab = .runtime @State private var showsPruneDialog = false init(model: LogsViewModel? = nil) { - _model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel()) + let resolvedModel = model ?? DefaultContainer.makeLogsModel(autoRefreshOnAppear: false) + _model = StateObject(wrappedValue: resolvedModel) + self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear } var body: some View { @@ -81,7 +84,9 @@ struct LogsView: View { await model.refresh() } .task { - await model.refresh() + if model.snapshot == nil && autoRefreshOnAppear { + await model.refresh() + } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift index 92ad01f..26195dd 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift @@ -16,15 +16,18 @@ final class LogsViewModel: ObservableObject { private let repository: any InstanceRepository private let service: LogCenterService private let store: any LogStore + let autoRefreshOnAppear: Bool init( repository: any InstanceRepository, service: LogCenterService, - store: any LogStore + store: any LogStore, + autoRefreshOnAppear: Bool = true ) { self.repository = repository self.service = service self.store = store + self.autoRefreshOnAppear = autoRefreshOnAppear } var filteredRuntime: [RuntimeLogEntry] { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationModels.swift deleted file mode 100644 index e3615e9..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationModels.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -public enum NotificationDeviceState: String, Equatable, Sendable, CaseIterable { - case ready - case unauthorized - case unavailable - case failed -} - -public struct NotificationDevice: Equatable, Identifiable, Sendable { - public let id: UUID - public let name: String - public let state: NotificationDeviceState -} - -public struct NotificationChannelTypeSummary: Equatable, Identifiable, Sendable { - public var id: String { type } - public let type: String - public let total: Int - public let enabled: Int -} - -public struct NotificationConfigSummary: Equatable, Sendable { - public let channelCount: Int - public let channelEnabled: Int - public let ruleCount: Int - public let ruleEnabled: Int - public let channelTypes: [NotificationChannelTypeSummary] -} - -public struct NotificationLogEntry: Equatable, Identifiable, Sendable { - public let id: String - public let eventType: String - public let status: String - public let ruleName: String? - public let channelName: String? - public let createdAt: String -} - -public struct NotificationLogSummary: Equatable, Sendable { - public let total: Int - public let success: Int - public let failed: Int - public let quietHours: Int - public let unmatched: Int - public let noAvailableChannel: Int - public let other: Int - public let entries: [NotificationLogEntry] -} - -public struct NotificationQueueEntry: Equatable, Identifiable, Sendable { - public let id: String - public let instanceID: UUID - public let instanceName: String - public let eventType: String - public let status: String - public let ruleName: String? - public let channelName: String? - public let createdAt: String -} - -public struct NotificationQueueSummary: Equatable, Sendable { - public let total: Int - public let pending: Int - public let scheduled: Int - public let retrying: Int - public let sending: Int - public let failed: Int - public let items: [NotificationQueueEntry] -} - -public struct NotificationOverview: Equatable, Sendable { - public let observedAt: Date - public let devices: [NotificationDevice] - public let config: NotificationConfigSummary - public let logs: NotificationLogSummary - public let queue: NotificationQueueSummary -} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationService.swift deleted file mode 100644 index 8ee7baf..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationService.swift +++ /dev/null @@ -1,356 +0,0 @@ -import Foundation - -public enum NotificationServiceError: Error, Equatable { - case invalidInput - case invalidResponse - case unauthorized -} - -public struct NotificationService: 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 overview(_ instances: [Instance]) async throws -> NotificationOverview { - var devices: [NotificationDevice] = [] - var configs: [NotificationConfigSummary] = [] - var logs: [NotificationLogSummary] = [] - var queues: [NotificationQueueSummary] = [] - - for instance in instances { - do { - let config = try Self.parseConfig(Self.data(of: try await request(.notificationConfig, instance))) - let logPage = try Self.parseLogs(Self.data(of: try await request( - .notificationLogs(limit: 50, offset: 0), - instance - ))) - let queue = try Self.parseQueue( - Self.data(of: try await request(.notificationQueue(limit: 100), instance)), - instance - ) - configs.append(config) - logs.append(logPage) - queues.append(queue) - devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .ready)) - } catch UpstreamError.notSupported { - devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unavailable)) - } catch UpstreamError.unauthorized, UpstreamError.forbidden { - devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized)) - } catch NotificationServiceError.unauthorized { - devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized)) - } catch { - devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .failed)) - } - } - - return NotificationOverview( - observedAt: Date(), - devices: devices, - config: Self.aggregateConfigs(configs), - logs: Self.aggregateLogs(logs), - queue: Self.aggregateQueues(queues) - ) - } - - public func retryAll(_ instances: [Instance]) async -> NotificationActionResult { - var result = NotificationActionResult() - for instance in instances { - do { - _ = try await request(.notificationRetryAllQueue, instance) - result.succeeded += 1 - } catch UpstreamError.notSupported { - result.skipped += 1 - } catch { - result.failed += 1 - } - } - return result - } - - public func retryQueue(_ instance: Instance, id: String) async throws { - try Self.validateQueueID(id) - _ = try await request(.notificationRetryQueue(id: id), instance) - } - - public func deleteQueueItem(_ instance: Instance, id: String) async throws { - try Self.validateQueueID(id) - _ = try await request(.notificationDeleteQueue(id: id), instance) - } - - static func validateQueueID(_ id: String) throws { - guard !id.isEmpty, id.count <= 128, - id.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" || $0 == "." }) - else { - throw NotificationServiceError.invalidInput - } - } - - 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 NotificationServiceError.unauthorized - } - let sessionService = InstanceSessionService(store: sessions, client: client) - let result = try await sessionService.login(instance, password: password) - guard result.authenticated, - let cookie = await sessions.cookie(for: instance.id, origin: instance.origin) - else { - throw NotificationServiceError.unauthorized - } - return cookie - } - - static func data(of response: UpstreamResponse) throws -> [String: Any] { - guard let root = try JSONSerialization.jsonObject(with: response.data) as? [String: Any], - let status = root["status"] as? String, - ["success", "ok", "OK"].contains(status), - let data = root["data"] as? [String: Any] - else { - throw NotificationServiceError.invalidResponse - } - return data - } - - static func parseConfig(_ data: [String: Any]) throws -> NotificationConfigSummary { - var channelCount = 0 - var channelEnabled = 0 - var typeOrder: [String] = [] - var typeTotals: [String: (total: Int, enabled: Int)] = [:] - - func record(type: String, enabled: Bool) { - if !typeOrder.contains(type) { - typeOrder.append(type) - } - let current = typeTotals[type] ?? (0, 0) - typeTotals[type] = (current.total + 1, current.enabled + (enabled ? 1 : 0)) - } - - if let channels = data["channels"] as? [[String: Any]] { - for raw in channels { - guard let type = raw["type"] as? String, let enabled = raw["enabled"] as? Bool else { continue } - channelCount += 1 - channelEnabled += enabled ? 1 : 0 - record(type: type, enabled: enabled) - } - } else { - for type in ["webhook", "bark", "pushplus", "wecom_app", "wecom_robot", "dingtalk_robot", "dingtalk_app", "feishu_robot", "telegram", "email", "serverchan"] { - guard let value = data[type] as? [String: Any], let enabled = value["enabled"] as? Bool else { continue } - channelCount += 1 - channelEnabled += enabled ? 1 : 0 - record(type: type, enabled: enabled) - } - } - - var ruleCount = 0 - var ruleEnabled = 0 - if let rules = data["rules"] as? [[String: Any]] { - for raw in rules { - guard let enabled = raw["enabled"] as? Bool else { continue } - ruleCount += 1 - ruleEnabled += enabled ? 1 : 0 - } - } - let types = typeOrder.map { - NotificationChannelTypeSummary( - type: $0, - total: typeTotals[$0]?.total ?? 0, - enabled: typeTotals[$0]?.enabled ?? 0 - ) - } - return NotificationConfigSummary( - channelCount: channelCount, - channelEnabled: channelEnabled, - ruleCount: ruleCount, - ruleEnabled: ruleEnabled, - channelTypes: types - ) - } - - static func parseLogs(_ data: [String: Any]) throws -> NotificationLogSummary { - guard let total = positive(data["total"]), let raw = data["logs"] as? [[String: Any]] else { - throw NotificationServiceError.invalidResponse - } - var entries: [NotificationLogEntry] = [] - var counts = ( - success: 0, failed: 0, quietHours: 0, unmatched: 0, noAvailableChannel: 0, other: 0 - ) - for item in raw { - guard let id = text(item["id"], 128), - let eventType = text(item["event_type"], 80), - let status = text(item["status"], 40), - let createdAt = text(item["created_at"], 64) - else { - throw NotificationServiceError.invalidResponse - } - entries.append(NotificationLogEntry( - id: id, - eventType: eventType, - status: status, - ruleName: text(item["rule_name"], 160), - channelName: text(item["channel_name"], 160), - createdAt: createdAt - )) - switch status { - case "success": counts.success += 1 - case "failed": counts.failed += 1 - case "quiet_hours": counts.quietHours += 1 - case "unmatched": counts.unmatched += 1 - case "no_available_channel": counts.noAvailableChannel += 1 - default: counts.other += 1 - } - } - return NotificationLogSummary( - total: total, - success: counts.success, - failed: counts.failed, - quietHours: counts.quietHours, - unmatched: counts.unmatched, - noAvailableChannel: counts.noAvailableChannel, - other: counts.other, - entries: entries - ) - } - - static func parseQueue(_ data: [String: Any], _ instance: Instance) throws -> NotificationQueueSummary { - guard let total = positive(data["total"]), let raw = data["items"] as? [[String: Any]] else { - throw NotificationServiceError.invalidResponse - } - var items: [NotificationQueueEntry] = [] - var counts = (pending: 0, scheduled: 0, retrying: 0, sending: 0, failed: 0) - for value in raw { - guard let id = text(value["id"], 128), - let eventType = text(value["event_type"], 80), - let status = text(value["status"], 32), - let createdAt = text(value["created_at"], 64) - else { - throw NotificationServiceError.invalidResponse - } - items.append(NotificationQueueEntry( - id: id, - instanceID: instance.id, - instanceName: instance.name, - eventType: eventType, - status: status, - ruleName: text(value["rule_name"], 160), - channelName: text(value["channel_name"], 160), - createdAt: createdAt - )) - switch status { - case "pending": counts.pending += 1 - case "scheduled": counts.scheduled += 1 - case "retrying": counts.retrying += 1 - case "sending": counts.sending += 1 - case "failed": counts.failed += 1 - default: break - } - } - return NotificationQueueSummary( - total: total, - pending: counts.pending, - scheduled: counts.scheduled, - retrying: counts.retrying, - sending: counts.sending, - failed: counts.failed, - items: items - ) - } - - static func aggregateConfigs(_ values: [NotificationConfigSummary]) -> NotificationConfigSummary { - NotificationConfigSummary( - channelCount: values.reduce(0) { $0 + $1.channelCount }, - channelEnabled: values.reduce(0) { $0 + $1.channelEnabled }, - ruleCount: values.reduce(0) { $0 + $1.ruleCount }, - ruleEnabled: values.reduce(0) { $0 + $1.ruleEnabled }, - channelTypes: mergeChannelTypes(values.map(\.channelTypes)) - ) - } - - static func aggregateLogs(_ values: [NotificationLogSummary]) -> NotificationLogSummary { - NotificationLogSummary( - total: values.reduce(0) { $0 + $1.total }, - success: values.reduce(0) { $0 + $1.success }, - failed: values.reduce(0) { $0 + $1.failed }, - quietHours: values.reduce(0) { $0 + $1.quietHours }, - unmatched: values.reduce(0) { $0 + $1.unmatched }, - noAvailableChannel: values.reduce(0) { $0 + $1.noAvailableChannel }, - other: values.reduce(0) { $0 + $1.other }, - entries: Array(values.flatMap(\.entries).prefix(20)) - ) - } - - static func aggregateQueues(_ values: [NotificationQueueSummary]) -> NotificationQueueSummary { - NotificationQueueSummary( - total: values.reduce(0) { $0 + $1.total }, - pending: values.reduce(0) { $0 + $1.pending }, - scheduled: values.reduce(0) { $0 + $1.scheduled }, - retrying: values.reduce(0) { $0 + $1.retrying }, - sending: values.reduce(0) { $0 + $1.sending }, - failed: values.reduce(0) { $0 + $1.failed }, - items: Array(values.flatMap(\.items).prefix(20)) - ) - } - - private static func text(_ value: Any?, _ maximum: Int) -> String? { - guard let value, let text = value as? String, - !text.isEmpty, text.count <= maximum, - !text.unicodeScalars.contains(where: { $0.value < 9 || ($0.value > 13 && $0.value < 32) || $0.value == 127 }) - else { return nil } - return text - } - - private static func positive(_ value: Any?) -> Int? { - guard let number = value as? Int, number >= 0, number <= 10_000_000 else { return nil } - return number - } - - private static func mergeChannelTypes( - _ groups: [[NotificationChannelTypeSummary]] - ) -> [NotificationChannelTypeSummary] { - var merged: [NotificationChannelTypeSummary] = [] - for group in groups { - for summary in group { - if let index = merged.firstIndex(where: { $0.type == summary.type }) { - let current = merged[index] - merged[index] = NotificationChannelTypeSummary( - type: current.type, - total: current.total + summary.total, - enabled: current.enabled + summary.enabled - ) - } else { - merged.append(summary) - } - } - } - return merged - } -} - -public struct NotificationActionResult: Equatable, Sendable { - public var succeeded = 0 - public var failed = 0 - public var skipped = 0 - - public init() {} -} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift deleted file mode 100644 index 312df0d..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift +++ /dev/null @@ -1,335 +0,0 @@ -import SwiftUI - -struct NotificationsView: View { - @StateObject private var model: NotificationsViewModel - @State private var pendingDeleteQueueID: String? - private static let timestampFormatter = ISO8601DateFormatter() - private static let fractionalTimestampFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - init(model: NotificationsViewModel? = nil) { - _model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel()) - } - - var body: some View { - ScrollView { - LazyVStack(spacing: 12) { - if let actionMessage = model.actionMessage { - AIStatusBanner( - actionMessage, - tint: model.actionSucceeded ? AITheme.success : AITheme.danger, - systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle" - ) - } - if let errorMessage = model.errorMessage { - AIStatusBanner( - errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") - } - - if let overview = model.overview { - stats(overview) - queue(overview) - channels(overview) - logs(overview) - devices(overview) - } else if model.isLoading { - AILoadingCard("正在加载通知中心") - } else { - empty - } - } - .padding(16) - .padding(.bottom, 8) - } - .aiBottomActionBar { - Button { - Task { await model.retryAll() } - } label: { - HStack(spacing: 8) { - if model.isRetryingAll { - ProgressView() - .controlSize(.small) - } - Text( - model.isRetryingAll - ? "投递中" - : (model.overview?.queue.total == 0 ? "没有待投递" : "立即投递") - ) - } - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - .disabled(model.isLoading || model.isRetryingAll || model.overview?.queue.total == 0) - } - .background(AITheme.canvas.ignoresSafeArea()) - .navigationTitle("通知中心") - .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() - } - .task { - await model.refresh() - } - } - - private var empty: some View { - AICard { - AIEmptyState( - systemImage: "bell", - title: "暂无通知数据", - message: "下拉刷新后从已配置实例同步。" - ) - } - } - - private func stats(_ overview: NotificationOverview) -> some View { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - AIStatCard( - title: "通道", - value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)", - detail: "已启用 / 总数", - tint: AITheme.primary - ) - AIStatCard( - title: "规则", - value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", - detail: "已启用 / 总数", - tint: AITheme.primary - ) - AIStatCard( - title: "队列", - value: "\(overview.queue.pending + overview.queue.retrying)", - detail: "等待与重试", - tint: AITheme.warning - ) - AIStatCard( - title: "失败", - value: "\(overview.queue.failed + overview.logs.failed)", - detail: "队列与投递", - tint: AITheme.danger - ) - } - } - - private func devices(_ overview: NotificationOverview) -> some View { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个") - if overview.devices.isEmpty { - AICard { - AIEmptyState( - systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。") - } - } else { - ForEach(overview.devices) { device in - AIListCard( - title: device.name, - status: deviceState(device.state), - statusTint: deviceTint(device.state) - ) - } - } - } - } - - private func channels(_ overview: NotificationOverview) -> some View { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle( - "通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类" - ) - if overview.config.channelTypes.isEmpty { - AICard { - AIEmptyState( - systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。") - } - } else { - ForEach(overview.config.channelTypes) { channel in - AIListCard( - title: channelTypeLabel(channel.type), - status: "\(channel.enabled)/\(channel.total)", - statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted - ) - } - } - } - } - - private func queue(_ overview: NotificationOverview) -> some View { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项") - if overview.queue.items.isEmpty { - AICard { - AIEmptyState( - systemImage: "tray.full", - title: "投递队列为空", - message: "新通知触发后会在这里等待投递。" - ) - } - } else { - ForEach(overview.queue.items) { item in - AIListCard( - title: eventTypeLabel(item.eventType), - subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))", - status: statusLabel(item.status), - statusTint: statusTint(item.status) - ) { - HStack(spacing: 8) { - Button { - Task { await model.retryQueue(item) } - } label: { - Label("重试", systemImage: "arrow.clockwise") - } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth - ) - ) - .disabled(model.busyQueueID != nil) - - Button { - pendingDeleteQueueID = item.id - } label: { - Label("删除", systemImage: "trash") - } - .buttonStyle( - AIButtonStyle( - kind: .danger, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth - ) - ) - .disabled(model.busyQueueID != nil) - } - } - } - } - } - } - - private func logs(_ overview: NotificationOverview) -> some View { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle( - "投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条") - if overview.logs.entries.isEmpty { - AICard { - AIEmptyState( - systemImage: "clock.arrow.circlepath", - title: "暂无投递日志", - message: "通知送达或失败后会在这里记录。" - ) - } - } else { - ForEach(overview.logs.entries) { entry in - AIListCard( - title: eventTypeLabel(entry.eventType), - subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))", - status: statusLabel(entry.status), - statusTint: statusTint(entry.status) - ) - } - } - } - } - - private func timestampText(_ value: String) -> String { - let date = - Self.fractionalTimestampFormatter.date(from: value) - ?? Self.timestampFormatter.date(from: value) - guard let date else { return value } - return date.formatted( - Date.FormatStyle(date: .numeric, time: .shortened) - .locale(Locale(identifier: "zh_CN")) - ) - } - - private func deviceState(_ state: NotificationDeviceState) -> String { - switch state { - case .ready: return "在线" - case .unauthorized: return "需登录" - case .unavailable: return "离线" - case .failed: return "异常" - } - } - - private func deviceTint(_ state: NotificationDeviceState) -> Color { - switch state { - case .ready: return AITheme.success - case .unauthorized: return AITheme.warning - case .unavailable: return AITheme.muted - case .failed: return AITheme.danger - } - } - - private func channelTypeLabel(_ type: String) -> String { - switch type { - case "webhook": return "Webhook" - case "bark": return "Bark" - case "pushplus": return "PushPlus" - case "wecom_app": return "企业微信应用" - case "wecom_robot": return "企业微信机器人" - case "dingtalk_robot": return "钉钉机器人" - case "dingtalk_app": return "钉钉应用" - case "feishu_robot": return "飞书机器人" - case "telegram": return "Telegram" - case "email": return "邮件" - case "serverchan": return "Server 酱" - default: return type - } - } - - private func eventTypeLabel(_ value: String) -> String { - switch value { - case "sms": return "短信" - case "ddns": return "DDNS" - case "version": return "版本" - case "system": return "系统" - case "device": return "设备" - case "automation": return "自动化" - default: return value - } - } - - private func statusLabel(_ value: String) -> String { - switch value { - case "success", "succeeded": return "成功" - case "failed": return "失败" - case "pending": return "等待" - case "sending": return "发送中" - case "retrying": return "重试中" - case "cancelled": return "已取消" - case "unmatched": return "未匹配" - case "no_available_channel": return "无可用通道" - case "quiet_hours": return "免打扰" - default: return value - } - } - - private func statusTint(_ value: String) -> Color { - switch value { - case "success", "succeeded": return AITheme.success - case "pending", "sending", "retrying": return AITheme.warning - case "failed": return AITheme.danger - default: return AITheme.muted - } - } -} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift deleted file mode 100644 index fd610d3..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation - -@MainActor -final class NotificationsViewModel: ObservableObject { - @Published private(set) var overview: NotificationOverview? - @Published private(set) var instances: [Instance] = [] - @Published private(set) var isLoading = false - @Published var errorMessage: String? - @Published private(set) var actionMessage: String? - @Published private(set) var actionSucceeded = true - @Published private(set) var busyQueueID: String? - @Published private(set) var isRetryingAll = false - - private let repository: any InstanceRepository - private let service: NotificationService - - init(repository: any InstanceRepository, service: NotificationService) { - self.repository = repository - self.service = service - } - - func refresh() async { - isLoading = true - errorMessage = nil - do { - let loaded = try await repository.list(InstanceQuery()) - instances = loaded - overview = try await service.overview(loaded) - } catch { - errorMessage = "无法加载通知中心。" - } - isLoading = false - } - - func retryAll() async { - guard !isRetryingAll && !isLoading else { return } - isRetryingAll = true - let result = await service.retryAll(instances) - actionSucceeded = result.succeeded > 0 - if result.succeeded > 0 { - actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。" - } else if result.skipped > 0 { - actionMessage = "没有可重试的队列接口。" - } else { - actionMessage = "队列重试失败。" - } - await refresh() - isRetryingAll = false - } - - func retryQueue(_ item: NotificationQueueEntry) async { - await queueAction(item) { instance, id in - try await self.service.retryQueue(instance, id: id) - } - } - - func deleteQueue(_ item: NotificationQueueEntry) async { - await queueAction(item) { instance, id in - try await self.service.deleteQueueItem(instance, id: id) - } - await refresh() - } - - private func queueAction( - _ item: NotificationQueueEntry, - action: (Instance, String) async throws -> Void - ) async { - guard let instance = instances.first(where: { $0.id == item.instanceID }) else { - errorMessage = "找不到队列所属实例。" - return - } - busyQueueID = item.id - do { - try await action(instance, item.id) - actionMessage = "队列操作已完成。" - actionSucceeded = true - errorMessage = nil - } catch { - errorMessage = "队列操作失败。" - actionSucceeded = false - } - busyQueueID = nil - } -} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift index 6e4b920..01c6576 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsView: View { @StateObject private var model: SettingsViewModel + @ObservedObject private var themeStore = ThemeStore.shared @State private var showsClearDialog = false init(model: SettingsViewModel? = nil) { @@ -20,6 +21,7 @@ struct SettingsView: View { } runtimeCard + themeCard storageCard securityCard dangerZoneCard @@ -75,24 +77,54 @@ struct SettingsView: View { } } - private var storageCard: some View { + private var themeCard: some View { AICard { VStack(alignment: .leading, spacing: 12) { - AISectionTitle("本地数据", systemImage: "externaldrive") - AIKeyValueRow("存储位置", value: "仅保存在本机应用沙盒") - AIKeyValueRow("当前占用", value: model.storageLabel) + AISectionTitle("外观", systemImage: "circle.lefthalf.filled", detail: themeStore.mode.label) + AISegmentedControl( + "主题模式", + selection: Binding( + get: { themeStore.mode }, + set: { themeStore.set($0) } + ), + options: ThemeMode.allCases.map { (value: $0, title: $0.label) } + ) } } } + private var storageCard: some View { + AICard { + NavigationLink { + LogsView() + } label: { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + AISectionTitle("本地数据", systemImage: "externaldrive") + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(AITheme.muted) + .accessibilityHidden(true) + } + AIKeyValueRow("数据位置", value: "仅保存在本机设备") + AIKeyValueRow("当前占用", value: model.storageLabel) + } + } + .buttonStyle(.plain) + .accessibilityHint("进入日志与诊断") + } + } + private var securityCard: some View { AICard { VStack(alignment: .leading, spacing: 12) { - AISectionTitle("隐私与安全", systemImage: "lock.shield") - AIKeyValueRow("实例密码", value: "保存在本机应用沙盒") - AIKeyValueRow("登录会话", value: "仅保存在内存") + AISectionTitle("隐私与数据", systemImage: "lock.shield") + AIKeyValueRow("节点信息", value: "仅保存在本机设备") + AIKeyValueRow("实例密码", value: "保存在本地数据文件中") + AIKeyValueRow("登录会话", value: "应用关闭后自动清除") AIStatusBanner( - "实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。", + "节点地址、实例密码和运行数据都保存在本机。应用只连接你添加的节点,登录会话关闭应用后即被清除。", tint: AITheme.primary, systemImage: "shield.checkered" ) diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 7980ff0..0c051f9 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -2,16 +2,13 @@ import SwiftUI @main struct MultiSimAdminApp: App { + @StateObject private var themeStore = ThemeStore.shared @State private var tab: AppTab init() { let arguments = ProcessInfo.processInfo.arguments if arguments.contains("--tab-messages") { _tab = State(initialValue: .messages) - } else if arguments.contains("--tab-notifications") { - _tab = State(initialValue: .notifications) - } else if arguments.contains("--tab-logs") { - _tab = State(initialValue: .logs) } else if arguments.contains("--tab-settings") { _tab = State(initialValue: .settings) } else { @@ -27,16 +24,13 @@ struct MultiSimAdminApp: App { NavigationStack { FleetView().aiInlineTitle() } case .messages: NavigationStack { MessagesView().aiInlineTitle() } - case .notifications: - NavigationStack { NotificationsView().aiInlineTitle() } - case .logs: - NavigationStack { LogsView().aiInlineTitle() } case .settings: NavigationStack { SettingsView().aiInlineTitle() } } AITabBar(selection: $tab) } .tint(AITheme.primary) + .preferredColorScheme(themeStore.mode.colorScheme) #if os(iOS) .toolbarBackground(.ultraThinMaterial, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) @@ -60,8 +54,6 @@ struct MultiSimAdminApp: App { enum AppTab: Hashable { case fleet case messages - case notifications - case logs case settings } @@ -72,8 +64,6 @@ struct AITabBar: View { HStack(spacing: 0) { tab(.fleet, "节点", "server.rack") tab(.messages, "消息", "message") - tab(.notifications, "通知", "bell") - tab(.logs, "日志", "doc.text") tab(.settings, "设置", "gearshape") } .frame(height: 62) diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png index c07114f..72827e6 100644 Binary files a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png and b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist index d71dde0..1e598e4 100644 --- a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist +++ b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist @@ -9,8 +9,8 @@ CFBundleInfoDictionaryVersion6.0 CFBundleNameMultiSimAdmin CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1.0 - CFBundleVersion1 + CFBundleShortVersionString0.2.0 + CFBundleVersion2 LSRequiresIPhoneOS MinimumOSVersion16.0 NSAppTransportSecurity diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index b0ca95d..1b78dcb 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -36,19 +36,7 @@ enum DefaultContainer { } @MainActor - static func makeNotificationsModel() -> NotificationsViewModel { - NotificationsViewModel( - repository: FileInstanceRepository(location: documentFileURL("instances.json")), - service: NotificationService( - client: SimAdminClient(), - sessions: InstanceSessionStore(), - credentials: credentials - ) - ) - } - - @MainActor - static func makeLogsModel() -> LogsViewModel { + static func makeLogsModel(autoRefreshOnAppear: Bool = true) -> LogsViewModel { let store = logStore return LogsViewModel( repository: FileInstanceRepository(location: documentFileURL("instances.json")), @@ -58,7 +46,8 @@ enum DefaultContainer { credentials: credentials, store: store ), - store: store + store: store, + autoRefreshOnAppear: autoRefreshOnAppear ) } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/ThemeMode.swift b/apps/ios/Sources/MultiSimAdmin/Theme/ThemeMode.swift new file mode 100644 index 0000000..f4e6f23 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Theme/ThemeMode.swift @@ -0,0 +1,56 @@ +import Foundation +import SwiftUI + +public enum ThemeMode: String, CaseIterable, Identifiable, Codable, Sendable { + case system + case light + case dark + + public var id: String { rawValue } + + public var label: String { + switch self { + case .system: return "跟随系统" + case .light: return "浅色" + case .dark: return "深色" + } + } + + public var systemImage: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max" + case .dark: return "moon.stars" + } + } +} + +@MainActor +final class ThemeStore: ObservableObject { + static let shared = ThemeStore() + + @Published private(set) var mode: ThemeMode + private let defaults: UserDefaults + private static let storageKey = "aiThemeMode" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + mode = ThemeMode(rawValue: defaults.string(forKey: Self.storageKey) ?? "") ?? .system + } + + func set(_ newMode: ThemeMode) { + guard newMode != mode else { return } + mode = newMode + defaults.set(newMode.rawValue, forKey: Self.storageKey) + } +} + +extension ThemeMode { + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift index 349ff68..41919ed 100644 --- a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift +++ b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift @@ -12,24 +12,16 @@ public enum UpstreamRoute: Hashable, Sendable { case smsList(limit: Int, offset: Int, direction: String?) case smsSend(phoneNumber: String, content: String) case smsBatchDelete(ids: [Int]) - case notificationConfig - case notificationLogs(limit: Int, offset: Int) - case notificationQueue(limit: Int) - case notificationRetryAllQueue - case notificationRetryQueue(id: String) - case notificationDeleteQueue(id: String) case authLogin(password: String) case authLogout public var method: String { switch self { case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu, - .connectivity, .smsList, .notificationConfig, .notificationLogs, .notificationQueue: + .connectivity, .smsList: return "GET" - case .smsSend, .smsBatchDelete, .notificationRetryAllQueue, .notificationRetryQueue, .authLogin, .authLogout: + case .smsSend, .smsBatchDelete, .authLogin, .authLogout: return "POST" - case .notificationDeleteQueue: - return "DELETE" } } @@ -51,17 +43,6 @@ public enum UpstreamRoute: Hashable, Sendable { return "/api/sms/list?\(items.joined(separator: "&"))" case .smsSend: return "/api/sms/send" case .smsBatchDelete: return "/api/sms/batch-delete" - case .notificationConfig: return "/api/notifications/config" - case .notificationLogs(let limit, let offset): - return "/api/notifications/logs?limit=\(limit)&offset=\(offset)" - case .notificationQueue(let limit): - return "/api/notifications/queue?limit=\(limit)" - case .notificationRetryAllQueue: - return "/api/notifications/queue/retry-all" - case .notificationRetryQueue(let id): - return "/api/notifications/queue/\(id)/retry" - case .notificationDeleteQueue(let id): - return "/api/notifications/queue/\(id)" case .authLogin: return "/api/auth/login" case .authLogout: return "/api/auth/logout" } diff --git a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift index 66c2ab8..e84c7c7 100644 --- a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift @@ -6,7 +6,7 @@ final class AppMetadataTests: XCTestCase { func testMetadataUsesIPAFoundationContract() { XCTAssertEqual(AppMetadata.name, "MultiSimAdmin") XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin") - XCTAssertEqual(AppMetadata.marketingVersion, "0.1.0") - XCTAssertEqual(AppMetadata.buildVersion, "1") + XCTAssertEqual(AppMetadata.marketingVersion, "0.2.0") + XCTAssertEqual(AppMetadata.buildVersion, "2") } } diff --git a/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift index 4875600..b83db53 100644 --- a/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift @@ -14,13 +14,9 @@ final class LogCenterTests: XCTestCase { let snapshot = try await service.refresh([instance]) - XCTAssertEqual(snapshot.runtime.count, 2) - XCTAssertEqual(snapshot.runtime.first?.id, "delivery-\(instance.id.uuidString)-log-2") - XCTAssertEqual(snapshot.runtime.first?.level, .error) - XCTAssertEqual(snapshot.runtime.first?.instanceName, instance.name) - XCTAssertEqual(snapshot.runtime.first?.source, .delivery) - XCTAssertEqual(snapshot.counts[.delivery], 2) - XCTAssertEqual(store.runtimeStorage.count, 2) + XCTAssertEqual(snapshot.runtime.count, 0) + XCTAssertEqual(snapshot.counts[.delivery], 0) + XCTAssertEqual(store.runtimeStorage.count, 0) XCTAssertEqual(snapshot.connections.count, 1) XCTAssertEqual(snapshot.connections.first?.outcome, .success) @@ -43,7 +39,6 @@ final class LogCenterTests: XCTestCase { let store = MemoryLogStore() let service = LogCenterService( client: LogCenterTestClient( - failingRoutes: [.notificationLogs(limit: 50, offset: 0)] ), sessions: InstanceSessionStore(), store: store @@ -52,14 +47,11 @@ final class LogCenterTests: XCTestCase { let snapshot = try await service.refresh([instance]) - XCTAssertEqual(snapshot.runtime.count, 1) - XCTAssertEqual(snapshot.runtime.first?.level, .warning) - XCTAssertEqual(snapshot.runtime.first?.message, "通知日志未同步:需要登录") + XCTAssertEqual(snapshot.runtime.count, 0) let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.state, .online) - XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .requiresLogin }.count, 1) - XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 2) - XCTAssertEqual(store.runtimeStorage.count, 1) + XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 3) + XCTAssertEqual(store.runtimeStorage.count, 0) XCTAssertEqual(store.connectionStorage.count, 1) } @@ -79,7 +71,7 @@ final class LogCenterTests: XCTestCase { let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.state, .offline) XCTAssertEqual(diagnostic.healthStatus, "超时") - XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 3) + XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 2) } @MainActor @@ -105,7 +97,7 @@ final class LogCenterTests: XCTestCase { await model.refresh() XCTAssertNotNil(model.snapshot) - XCTAssertEqual(model.filteredRuntime.count, 2) + XCTAssertEqual(model.filteredRuntime.count, 0) XCTAssertEqual(model.filteredDiagnostics.count, 1) XCTAssertEqual(model.offlineCount, 0) @@ -113,8 +105,8 @@ final class LogCenterTests: XCTestCase { model.searchText = "offline" model.selectedLevel = .error - XCTAssertEqual(model.filteredRuntime.count, 1) - XCTAssertEqual(model.filteredRuntime.first?.message, "实例进入离线状态") + XCTAssertEqual(model.filteredRuntime.count, 0) + XCTAssertNil(model.filteredRuntime.first) let removed = await model.pruneConnections(olderThanDays: 7) XCTAssertEqual(removed, 0) @@ -203,17 +195,6 @@ private struct LogCenterTestClient: SimAdminClientProtocol { ) case .connectivity: return Self.response(#"{"status":"ok","data":{"connected":true}}"#) - case .notificationLogs: - return Self.response( - """ - {"status":"ok","data":{"total":2,"logs":[ - {"id":"log-1","event_type":"device_offline","status":"success", - "message":"实例恢复在线","created_at":"2026-09-08T10:01:00Z"}, - {"id":"log-2","event_type":"device_offline","status":"failed", - "message":"实例进入离线状态","created_at":"2026-09-08T10:02:00Z"} - ]}} - """ - ) default: return UpstreamResponse(status: 404, data: Data("{}".utf8)) } diff --git a/apps/ios/Tests/MultiSimAdminTests/NotificationServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/NotificationServiceTests.swift deleted file mode 100644 index 1b49dda..0000000 --- a/apps/ios/Tests/MultiSimAdminTests/NotificationServiceTests.swift +++ /dev/null @@ -1,163 +0,0 @@ -import XCTest - -@testable import MultiSimAdmin - -final class NotificationServiceTests: XCTestCase { - func testAggregatesInstanceConfigLogsAndQueue() async throws { - let client = NotificationTestClient() - let service = NotificationService(client: client, sessions: InstanceSessionStore()) - let instance = Self.instance() - - let overview = try await service.overview([instance]) - - XCTAssertEqual(client.routes, [ - .notificationConfig, - .notificationLogs(limit: 50, offset: 0), - .notificationQueue(limit: 100) - ]) - XCTAssertEqual(overview.config.channelCount, 2) - XCTAssertEqual(overview.config.channelEnabled, 1) - XCTAssertEqual(overview.config.ruleCount, 1) - XCTAssertEqual(overview.config.ruleEnabled, 1) - XCTAssertEqual(overview.config.channelTypes.first?.type, "webhook") - XCTAssertEqual(overview.logs.total, 3) - XCTAssertEqual(overview.logs.success, 2) - XCTAssertEqual(overview.logs.failed, 1) - XCTAssertEqual(overview.queue.total, 2) - XCTAssertEqual(overview.queue.pending, 1) - XCTAssertEqual(overview.queue.failed, 1) - XCTAssertEqual(overview.queue.items.first?.id, "q-1") - XCTAssertEqual(overview.queue.items.first?.instanceID, instance.id) - XCTAssertEqual(overview.devices.first?.state, .ready) - } - - func testQueueActionsValidateIDsAndUseUpstreamRoutes() async throws { - let client = NotificationTestClient() - let service = NotificationService(client: client, sessions: InstanceSessionStore()) - let instance = Self.instance() - - try await service.retryQueue(instance, id: "q_1-a.2") - try await service.deleteQueueItem(instance, id: "q_1-a.2") - - XCTAssertEqual(client.routes.suffix(2), [ - .notificationRetryQueue(id: "q_1-a.2"), - .notificationDeleteQueue(id: "q_1-a.2") - ]) - - do { - try await service.retryQueue(instance, id: "bad id") - XCTFail("Invalid queue ID should fail.") - } catch { - XCTAssertEqual(error as? NotificationServiceError, .invalidInput) - } - } - - func testReturnsStatefulOverviewWhenEveryInstanceIsUnauthorized() async throws { - let service = NotificationService( - client: UnauthorizedNotificationClient(), - sessions: InstanceSessionStore() - ) - let instance = Self.instance() - - let overview = try await service.overview([instance]) - - XCTAssertEqual(overview.devices, [NotificationDevice( - id: instance.id, - name: instance.name, - state: .unauthorized - )]) - XCTAssertEqual(overview.config.channelCount, 0) - XCTAssertEqual(overview.logs.total, 0) - XCTAssertEqual(overview.queue.total, 0) - } - - func testAggregatesChannelTypeSummariesInFirstSeenOrder() { - let first = NotificationConfigSummary( - channelCount: 2, - channelEnabled: 1, - ruleCount: 0, - ruleEnabled: 0, - channelTypes: [ - NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1), - NotificationChannelTypeSummary(type: "bark", total: 1, enabled: 0) - ] - ) - let second = NotificationConfigSummary( - channelCount: 2, - channelEnabled: 2, - ruleCount: 0, - ruleEnabled: 0, - channelTypes: [ - NotificationChannelTypeSummary(type: "telegram", total: 1, enabled: 1), - NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1) - ] - ) - - let result = NotificationService.aggregateConfigs([first, second]) - - XCTAssertEqual(result.channelTypes.map(\.type), ["webhook", "bark", "telegram"]) - let webhook = result.channelTypes.first { $0.type == "webhook" } - XCTAssertEqual(webhook, NotificationChannelTypeSummary(type: "webhook", total: 2, enabled: 2)) - } - - private static func instance() -> Instance { - Instance( - id: UUID(), - name: "Notification Node", - origin: URL(string: "http://192.168.1.20:3000")!, - configRevision: 1, - createdAt: Date(), - updatedAt: Date() - ) - } -} - -private struct UnauthorizedNotificationClient: SimAdminClientProtocol { - func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { - throw UpstreamError.unauthorized - } -} - -private final class NotificationTestClient: SimAdminClientProtocol, @unchecked Sendable { - var routes: [UpstreamRoute] = [] - - func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { - routes.append(route) - switch route { - case .notificationConfig: - let json = """ - {"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}, - {"type":"bark","enabled":false}],"rules":[{"enabled":true}]}} - """ - return Self.response(json) - case .notificationLogs(let limit, let offset): - XCTAssertEqual(limit, 50) - XCTAssertEqual(offset, 0) - let json = """ - {"status":"ok","data":{"total":3,"logs":[ - {"id":"l-1","event_type":"device_offline","status":"success","created_at":"2026-09-08T10:00:00Z"}, - {"id":"l-2","event_type":"low_signal","status":"success","created_at":"2026-09-08T10:01:00Z"}, - {"id":"l-3","event_type":"device_offline","status":"failed","created_at":"2026-09-08T10:02:00Z"} - ]}} - """ - return Self.response(json) - case .notificationQueue(let limit): - XCTAssertEqual(limit, 100) - let json = """ - {"status":"ok","data":{"total":2,"items":[ - {"id":"q-1","event_type":"device_offline","status":"pending","created_at":"2026-09-08T10:00:00Z"}, - {"id":"q-2","event_type":"low_signal","status":"failed","created_at":"2026-09-08T10:01:00Z"} - ]}} - """ - return Self.response(json) - case .notificationRetryQueue, .notificationDeleteQueue: - return Self.response(#"{"status":"ok"}"#) - default: - return UpstreamResponse(status: 404, data: Data("{}".utf8)) - } - } - - private static func response(_ json: String) -> UpstreamResponse { - UpstreamResponse(status: 200, data: Data(json.utf8)) - } -} diff --git a/apps/ios/Tests/MultiSimAdminTests/NotificationsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/NotificationsViewModelTests.swift deleted file mode 100644 index 8352eb2..0000000 --- a/apps/ios/Tests/MultiSimAdminTests/NotificationsViewModelTests.swift +++ /dev/null @@ -1,91 +0,0 @@ -import XCTest - -@testable import MultiSimAdmin - -@MainActor -final class NotificationsViewModelTests: XCTestCase { - func testRefreshLoadsInstancesAndAggregatedOverview() async throws { - let repository = MemoryInstanceRepository() - let client = NotificationViewModelClient() - let service = NotificationService(client: client, sessions: InstanceSessionStore()) - let model = NotificationsViewModel(repository: repository, service: service) - _ = try await repository.create(InstanceInput( - name: "Notification Node", - origin: URL(string: "http://192.168.1.20:3000")!, - tags: [], - password: nil - )) - - await model.refresh() - - XCTAssertEqual(model.instances.count, 1) - XCTAssertEqual(model.overview?.devices.first?.state, .ready) - XCTAssertEqual(model.overview?.queue.items.first?.id, "queue-1") - XCTAssertNil(model.errorMessage) - } - - func testRetryQueueTargetsOwningInstance() async throws { - let repository = MemoryInstanceRepository() - let client = NotificationViewModelClient() - let service = NotificationService(client: client, sessions: InstanceSessionStore()) - let model = NotificationsViewModel(repository: repository, service: service) - _ = try await repository.create(InstanceInput( - name: "Notification Node", - origin: URL(string: "http://192.168.1.20:3000")!, - tags: [], - password: nil - )) - await model.refresh() - let item = try XCTUnwrap(model.overview?.queue.items.first) - - await model.retryQueue(item) - - XCTAssertEqual(client.routes.last, .notificationRetryQueue(id: "queue-1")) - } - - func testRetryAllTargetsEveryInstance() async throws { - let repository = MemoryInstanceRepository() - let client = NotificationViewModelClient() - let service = NotificationService(client: client, sessions: InstanceSessionStore()) - let model = NotificationsViewModel(repository: repository, service: service) - _ = try await repository.create(InstanceInput( - name: "First", - origin: URL(string: "http://192.168.1.20:3000")!, - tags: [], - password: nil - )) - _ = try await repository.create(InstanceInput( - name: "Second", - origin: URL(string: "http://192.168.1.21:3000")!, - tags: [], - password: nil - )) - await model.refresh() - - await model.retryAll() - - XCTAssertEqual(client.routes.filter { $0 == .notificationRetryAllQueue }.count, 2) - } -} - -private final class NotificationViewModelClient: SimAdminClientProtocol, @unchecked Sendable { - var routes: [UpstreamRoute] = [] - - func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { - routes.append(route) - switch route { - case .notificationConfig: - return Self.response(#"{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}]}}"#) - case .notificationLogs: - return Self.response(#"{"status":"ok","data":{"total":1,"logs":[{"id":"log-1","event_type":"system","status":"success","created_at":"2026-09-08T10:00:00Z"}]}}"#) - case .notificationQueue: - return Self.response(#"{"status":"ok","data":{"total":1,"items":[{"id":"queue-1","event_type":"system","status":"failed","created_at":"2026-09-08T10:00:00Z"}]}}"#) - default: - return Self.response(#"{"status":"ok"}"#) - } - } - - private static func response(_ json: String) -> UpstreamResponse { - UpstreamResponse(status: 200, data: Data(json.utf8)) - } -} diff --git a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift index e72f9ad..c9bbdc8 100644 --- a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift @@ -11,8 +11,8 @@ final class SettingsViewModelTests: XCTestCase { await model.refresh() XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin") - XCTAssertEqual(model.runtime?.marketingVersion, "0.1.0") - XCTAssertEqual(model.runtime?.buildVersion, "1") + XCTAssertEqual(model.runtime?.marketingVersion, "0.2.0") + XCTAssertEqual(model.runtime?.buildVersion, "2") XCTAssertEqual(model.runtime?.platform, "iOS") XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false) diff --git a/apps/ios/Tests/MultiSimAdminTests/ThemeModeTests.swift b/apps/ios/Tests/MultiSimAdminTests/ThemeModeTests.swift new file mode 100644 index 0000000..5015fda --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/ThemeModeTests.swift @@ -0,0 +1,26 @@ +import XCTest + +@testable import MultiSimAdmin + +final class ThemeModeTests: XCTestCase { + @MainActor + func testLoadsPersistedThemeMode() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "ThemeModeTests-\(UUID().uuidString)")) + defaults.set(ThemeMode.dark.rawValue, forKey: "aiThemeMode") + + XCTAssertEqual(ThemeStore(defaults: defaults).mode, .dark) + } + + @MainActor + func testSelectionPersistsThemeMode() throws { + let suiteName = "ThemeModeTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + let store = ThemeStore(defaults: defaults) + + store.set(.light) + store.set(.system) + + XCTAssertEqual(ThemeMode(rawValue: defaults.string(forKey: "aiThemeMode") ?? ""), .system) + XCTAssertEqual(store.mode, .system) + } +}