diff --git a/apps/ios/Assets/AppIcon.svg b/apps/ios/Assets/AppIcon.svg new file mode 100644 index 0000000..935ed05 --- /dev/null +++ b/apps/ios/Assets/AppIcon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Package.swift b/apps/ios/Package.swift index c4ed1f0..2b4a129 100644 --- a/apps/ios/Package.swift +++ b/apps/ios/Package.swift @@ -11,7 +11,10 @@ let package = Package( targets: [ .executableTarget( name: "MultiSimAdmin", - exclude: ["Resources/Info.plist"] + exclude: ["Resources/Info.plist"], + resources: [ + .copy("Resources/Assets.xcassets") + ] ), .testTarget( name: "MultiSimAdminTests", diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index 32f21d4..93ec720 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -15,11 +15,7 @@ struct InstanceDetailView: View { LazyVStack(spacing: 12) { header if let errorMessage = model.errorMessage { - AICard { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(AITheme.warning) - } + AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } if let snapshot = model.snapshot { ForEach(snapshot.sections) { section in @@ -31,17 +27,6 @@ struct InstanceDetailView: View { } } .navigationTitle(model.instance.name) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - revealsSensitive.toggle() - } label: { - Label(revealsSensitive ? "隐藏敏感值" : "显示敏感值", - systemImage: revealsSensitive ? "eye.slash" : "eye") - } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .compact)) - } - } .refreshable { await model.refresh() } @@ -52,12 +37,28 @@ struct InstanceDetailView: View { private var header: some View { AICard { - VStack(alignment: .leading, spacing: 8) { - Text("设备详情") - .font(.headline) + VStack(alignment: .leading, spacing: 12) { + AISectionTitle("设备详情", systemImage: "iphone.gen3") + Button { + revealsSensitive.toggle() + } label: { + Label( + revealsSensitive ? "隐藏敏感值" : "显示敏感值", + systemImage: revealsSensitive ? "eye.slash" : "eye" + ) + } + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .compact, + fixedWidth: AITheme.footerSecondaryButtonWidth + ) + ) Text(model.instance.origin.absoluteString) .font(.subheadline) .foregroundStyle(AITheme.muted) + .lineLimit(2) + .truncationMode(.middle) if model.isLoading { Text("正在读取设备能力…") .font(.subheadline) @@ -70,24 +71,14 @@ struct InstanceDetailView: View { private func sectionCard(_ section: DeviceSection) -> some View { AICard { VStack(alignment: .leading, spacing: 12) { - Text(section.title) - .font(.headline) + AISectionTitle(section.title, systemImage: "list.bullet.rectangle") if section.values.isEmpty { Text("此模块当前不可用或未返回数据。") .font(.subheadline) .foregroundStyle(AITheme.muted) } else { - ForEach(section.values.sorted(by: { $0.key < $1.key }), id: \.key) { item in - HStack(alignment: .top, spacing: 10) { - Text(fieldLabel(item.key)) - .font(.subheadline) - .foregroundStyle(AITheme.muted) - .frame(maxWidth: .infinity, alignment: .leading) - Text(display(item.key, item.value)) - .font(.subheadline) - .foregroundStyle(AITheme.ink) - .multilineTextAlignment(.trailing) - } + ForEach(sortedValues(section.values), id: \.key) { item in + AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value)) } } } @@ -110,22 +101,148 @@ struct InstanceDetailView: View { "registration_status": "注册状态", "technology_preference": "网络制式", "signal_strength": "信号强度", + "brand": "品牌", + "meid": "MEID", + "phone_number": "号码", + "firmware_version": "固件版本", + "os_version": "系统版本", + "baseband_version": "基带版本", + "uptime": "运行时间", + "uptime_seconds": "运行时间", + "slots": "卡槽数", + "active_slots": "活跃数", + "active": "已激活", + "enabled": "已启用", + "operator": "运营商", + "carrier": "运营商", + "network_type": "接入制式", + "technology": "技术", + "radio_mode": "网络模式", + "signal": "信号强度", + "signal_dbm": "信号强度(dBm)", + "signal_percent": "信号质量", + "rsrp": "RSRP", + "rsrq": "RSRQ", + "sinr": "SINR", + "mcc": "MCC", + "mnc": "MNC", + "registration": "注册状态", + "registration_state": "注册状态", + "roaming": "漫游", + "airplane_mode": "飞行模式", + "ipv4": "IPv4 地址", + "ipv6": "IPv6 地址", + "ip_address": "IP 地址", + "gateway": "网关", + "dns": "DNS", + "download": "下行速率", + "upload": "上行速率", + "download_bytes": "下行流量", + "upload_bytes": "上行流量", + "messages_today": "今日消息数", + "calls": "通话数", + "usage": "使用率", + "temperature": "温度", + "max_temperature_c": "最高温度", + "cpu_percent": "CPU 使用率", + "cpu_load": "CPU 负载", + "memory_percent": "内存使用率", + "memory_total_mb": "内存总量", + "memory_available_mb": "可用内存", + "battery_level": "电量", + "battery_percent": "电量", + "latency": "延迟", + "connected": "连接状态", + "interface": "网络接口", + "ssid": "无线网络", + "apn": "APN", "core_count": "核心数", "model_name": "CPU 型号", "state": "连接状态", "latency_ms": "延迟(ms)" ] + private let valueOrder: [String] = [ + "model", "manufacturer", "brand", "imei", "meid", "revision", "firmware_version", + "online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi", + "phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier", + "registration_status", "registration_state", "technology_preference", "network_type", + "radio_mode", "signal_strength", "signal_dbm", "signal_percent", "rsrp", "rsrq", "sinr", + "state", "connected", "latency", "latency_ms", "uptime", "uptime_seconds", "cpu_percent", + "cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage", + "temperature", "max_temperature_c", "battery_level", "battery_percent", "download", + "upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway", + "dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls" + ] + + private let durationKeys: Set = ["uptime", "uptime_seconds"] + private let percentKeys: Set = ["usage", "cpu_percent", "memory_percent"] + private let byteRateKeys: Set = ["download", "upload"] + private let byteCountKeys: Set = ["download_bytes", "upload_bytes"] private let sensitiveKeys: Set = ["imei", "iccid", "imsi", "phone_numbers", "sms_center"] + private func sortedValues(_ values: [String: String]) -> [(key: String, value: String)] { + values + .map { (key: $0.key, value: $0.value) } + .sorted { lhs, rhs in + let lhsRank = valueOrder.firstIndex(of: lhs.key) ?? Int.max + let rhsRank = valueOrder.firstIndex(of: rhs.key) ?? Int.max + if lhsRank != rhsRank { return lhsRank < rhsRank } + return lhs.key < rhs.key + } + } + private func fieldLabel(_ key: String) -> String { - fieldLabels[key] ?? key.replacingOccurrences(of: "_", with: " ") + if let exact = fieldLabels[key] { return exact } + let parts = key.split(separator: "_").map(String.init) + for count in [2, 1] where parts.count >= count { + let suffix = parts.suffix(count).joined(separator: "_") + if let label = fieldLabels[suffix] { return label } + } + return parts.joined(separator: " ") } private func display(_ key: String, _ value: String) -> String { - guard sensitiveKeys.contains(key), !revealsSensitive else { return value } - guard let first = value.first, value.count > 6 else { return String(repeating: "•", count: min(value.count, 8)) } + if matches(key, durationKeys), let seconds = Double(value), seconds >= 0 { + return Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated)) + } + if matches(key, percentKeys), let number = Double(value), number >= 0 { + return "\(number.formatted(.number.precision(.fractionLength(0...1))))%" + } + if matches(key, byteRateKeys), let bytesPerSecond = Double(value), bytesPerSecond >= 0 { + return ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s" + } + if matches(key, byteCountKeys), let bytes = Double(value), bytes >= 0 { + return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .decimal) + } + if matches(key, ["memory_total_mb"]), let megabytes = Double(value) { + return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory) + } + if matches(key, ["memory_available_mb"]), let megabytes = Double(value) { + return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory) + } + if key.hasSuffix("temperature"), let temperature = Double(value) { + return "\(temperature.formatted(.number.precision(.fractionLength(0...1))))°C" + } + if sensitiveKeys.contains(key), !revealsSensitive { + return masked(value) + } + return value + } + + private func masked(_ value: String) -> String { + guard let first = value.first, value.count > 6 else { + return String(repeating: "•", count: min(value.count, 8)) + } let tail = value.suffix(4) return "\(first) •••• \(tail)" } + + private func matches(_ key: String, _ keys: Set) -> Bool { + let parts = key.split(separator: "_").map(String.init) + let suffixes = [key] + (1...2).compactMap { count in + parts.count >= count ? parts.suffix(count).joined(separator: "_") : nil + } + return suffixes.contains { keys.contains($0) } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 534f942..c737858 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -14,23 +14,22 @@ struct FleetView: View { ScrollView { LazyVStack(spacing: 12) { searchField + if let errorMessage = model.errorMessage { + AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } if model.selectionMode { selectionBar } + if model.isLoading && model.rows.isEmpty { + AILoadingCard("正在同步实例") + } if model.rows.isEmpty && !model.isLoading { AICard { - 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) + AIEmptyState( + systemImage: "server.rack", + title: "暂无实例", + message: "添加 SimAdmin 实例后即可开始管理。" + ) } } ForEach(model.rows) { row in @@ -50,22 +49,23 @@ struct FleetView: View { } } .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)) + .aiBottomActionBar { + if !model.selectionMode { + HStack(spacing: 10) { + Button { + model.selectionMode = true + } label: { + Label("批量选择", systemImage: "checklist") + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - Button { - model.showingForm = true - } label: { - Label("添加实例", systemImage: "plus") + Button { + model.showingForm = true + } label: { + Label("添加实例", systemImage: "plus") + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) } } .confirmationDialog( @@ -110,7 +110,13 @@ struct FleetView: View { systemImage: "checkmark.circle" ) } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .compact)) + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .compact, + fixedWidth: AITheme.footerSecondaryButtonWidth + ) + ) Spacer() @@ -123,7 +129,13 @@ struct FleetView: View { } label: { Label("删除", systemImage: "trash") } - .buttonStyle(AIButtonStyle(kind: .danger, size: .compact)) + .buttonStyle( + AIButtonStyle( + kind: .danger, + size: .compact, + fixedWidth: AITheme.footerSecondaryButtonWidth + ) + ) .disabled(model.selectedIDs.isEmpty) } } @@ -143,6 +155,8 @@ struct FleetView: View { } .buttonStyle(.plain) .frame(width: 32, height: 32) + .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择") + .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) } VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -155,6 +169,8 @@ struct FleetView: View { Text(row.instance.origin.absoluteString) .font(.subheadline) .foregroundStyle(AITheme.muted) + .lineLimit(1) + .truncationMode(.middle) if !row.instance.tags.isEmpty { HStack(spacing: 6) { ForEach(row.instance.tags, id: \.self) { tag in diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift index f8a4f0a..375ef80 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -25,32 +25,31 @@ 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") - field("实例密码", text: $password, placeholder: "可选,保存到钥匙串", secure: true) + field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true) } } if let validationMessage { - AICard { - Label(validationMessage, systemImage: "exclamationmark.triangle") - .font(.subheadline) - .foregroundStyle(AITheme.danger) - } + AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } } .padding(16) } } .navigationTitle("添加实例") - .toolbar { - ToolbarItem(placement: .cancellationAction) { + .aiBottomActionBar { + HStack(spacing: 10) { Button("取消") { dismiss() } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) - } - ToolbarItem(placement: .confirmationAction) { - Button("保存") { - save() - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) - .disabled(!canSave) + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .regular, + fixedWidth: AITheme.footerSecondaryButtonWidth + ) + ) + + Button("保存实例") { save() } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .disabled(!canSave) } } } @@ -73,6 +72,11 @@ struct InstanceFormView: View { } else { TextField(placeholder, text: text) .textFieldStyle(AIFieldStyle()) + #if os(iOS) + .keyboardType(title == "地址" ? .URL : .default) + .textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil) + #endif + .autocorrectionDisabled(title == "地址" || title == "标签") } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift index afed071..9009a16 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift @@ -154,7 +154,7 @@ public struct InstanceDiagnostic: Equatable, Identifiable, Sendable { public let version: String? public let platform: String? public let observedAt: Date - public let capabilities: LogCapabilitySummary + public let featureStatus: [FeatureStatus] public let connection: ConnectionSummary? public init( @@ -167,7 +167,7 @@ public struct InstanceDiagnostic: Equatable, Identifiable, Sendable { version: String?, platform: String?, observedAt: Date, - capabilities: LogCapabilitySummary, + featureStatus: [FeatureStatus], connection: ConnectionSummary? ) { self.id = id @@ -179,11 +179,28 @@ public struct InstanceDiagnostic: Equatable, Identifiable, Sendable { self.version = version self.platform = platform self.observedAt = observedAt - self.capabilities = capabilities + self.featureStatus = featureStatus self.connection = connection } } +public struct FeatureStatus: Equatable, Identifiable, Sendable { + public enum State: Equatable, Sendable { + case ready + case requiresLogin + case unavailable + } + + public var id: String { name } + public let name: String + public let state: State + + public init(name: String, state: State) { + self.name = name + self.state = state + } +} + public struct LogSnapshot: Equatable, Sendable { public let observedAt: Date public let runtime: [RuntimeLogEntry] diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift index 39c7a19..0027a8c 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift @@ -231,32 +231,19 @@ public struct LogCenterService: Sendable { modules: [String: Result], connections: [ConnectionLogEntry] ) -> InstanceDiagnostic { - let capabilities = LogCapabilitySummary( - total: modules.count, - supported: modules.values.filter { - if case .success = $0 { return true } - return false - }.count, - unsupported: modules.values.filter { - if case .failure(UpstreamError.notSupported) = $0 { return true } - return false - }.count, - authRequired: modules.values.filter { - if case .failure(UpstreamError.unauthorized) = $0 { return true } - if case .failure(UpstreamError.forbidden) = $0 { return true } - return false - }.count, - degraded: modules.values.filter { - if case .failure = $0 { - if case .failure(UpstreamError.notSupported) = $0 { return false } - if case .failure(UpstreamError.unauthorized) = $0 { return false } - if case .failure(UpstreamError.forbidden) = $0 { return false } - return true + let featureStatus = modules + .sorted { $0.key < $1.key } + .map { key, result -> FeatureStatus in + let name = key == "connection" ? "连接" : key == "device" ? "设备" : "通知" + switch result { + case .success: + return FeatureStatus(name: name, state: .ready) + case .failure(UpstreamError.unauthorized), .failure(UpstreamError.forbidden): + return FeatureStatus(name: name, state: .requiresLogin) + case .failure: + return FeatureStatus(name: name, state: .unavailable) } - return false - }.count, - unknown: 0 - ) + } let success = connections.filter { $0.outcome == .success }.count let failed = connections.filter { $0.outcome == .failed }.count let average = connections.isEmpty @@ -272,7 +259,7 @@ public struct LogCenterService: Sendable { version: probe.version, platform: probe.platform, observedAt: Date(), - capabilities: capabilities, + featureStatus: featureStatus, connection: ConnectionSummary( total: connections.count, success: success, diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift index b2602e6..04fc388 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -21,27 +21,29 @@ struct LogsView: View { ScrollView { LazyVStack(spacing: 12) { if let errorMessage = model.errorMessage { - AICard { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(AITheme.warning) + AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } + if model.snapshot == nil && model.isLoading { + AILoadingCard("正在同步日志中心") + } else { + stats + Picker("视图", selection: $tab) { + Text("运行日志").tag(Tab.runtime) + Text("连接日志").tag(Tab.connections) + Text("设备诊断").tag(Tab.diagnostics) } - } - stats - Picker("视图", selection: $tab) { - Text("运行日志").tag(Tab.runtime) - Text("连接日志").tag(Tab.connections) - Text("设备诊断").tag(Tab.diagnostics) - } - .pickerStyle(.segmented) + .pickerStyle(.segmented) - switch tab { - case .runtime: - runtimeSection - case .connections: - connectionSection - case .diagnostics: - diagnosticSection + filterBar + + switch tab { + case .runtime: + runtimeSection + case .connections: + connectionSection + case .diagnostics: + diagnosticSection + } } } .padding(16) @@ -50,12 +52,9 @@ struct LogsView: View { .navigationTitle("日志中心") .toolbar { ToolbarItemGroup(placement: .primaryAction) { - Button { + AIToolbarButton("刷新", systemImage: "arrow.clockwise") { Task { await model.refresh() } - } label: { - Label("刷新", systemImage: "arrow.clockwise") } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) .disabled(model.isLoading) } } @@ -80,87 +79,88 @@ struct LogsView: View { } private var stats: some View { - AICard { - VStack(spacing: 12) { - filterBar - HStack(spacing: 8) { - metric("日志", "\(model.snapshot?.runtime.count ?? 0)", AITheme.primary) - metric("在线", "\(onlineCount)", AITheme.success) - metric("离线", "\(model.offlineCount)", AITheme.danger) - } - } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary) + AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success) + AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger) } } private var filterBar: some View { - VStack(spacing: 10) { - AISearchField("搜索日志、错误码或实例", text: $model.searchText) + AICard { + VStack(spacing: 10) { + AISearchField("搜索日志、错误码或实例", text: $model.searchText) - Picker("实例", selection: $model.selectedInstanceID) { - Text("全部实例").tag(Optional.none) - ForEach(model.instances) { instance in - Text(instance.name).tag(Optional(instance.id)) - } - } - .pickerStyle(.menu) - - if tab == .runtime { - Picker("来源", selection: $model.selectedSource) { - Text("全部来源").tag(Optional.none) - ForEach(RuntimeLogSource.allCases, id: \.self) { source in - Text(source.label).tag(Optional(source)) + Picker("实例", selection: $model.selectedInstanceID) { + Text("全部实例").tag(Optional.none) + ForEach(model.instances) { instance in + Text(instance.name).tag(Optional(instance.id)) } } .pickerStyle(.menu) + .aiSelectSurface() - Picker("级别", selection: $model.selectedLevel) { - Text("全部级别").tag(Optional.none) - ForEach(RuntimeLogLevel.allCases, id: \.self) { level in - Text(level.label).tag(Optional(level)) + Group { + if tab == .runtime { + HStack(spacing: 10) { + Picker("来源", selection: $model.selectedSource) { + Text("全部来源").tag(Optional.none) + ForEach(RuntimeLogSource.allCases, id: \.self) { source in + Text(source.label).tag(Optional(source)) + } + } + .pickerStyle(.menu) + .aiSelectSurface() + + Picker("级别", selection: $model.selectedLevel) { + Text("全部级别").tag(Optional.none) + ForEach(RuntimeLogLevel.allCases, id: \.self) { level in + Text(level.label).tag(Optional(level)) + } + } + .pickerStyle(.menu) + .aiSelectSurface() + } + .frame(maxWidth: .infinity) + } else if tab == .connections { + Picker("结果", selection: $model.selectedOutcome) { + Text("全部结果").tag(Optional.none) + ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in + Text(outcome.label).tag(Optional(outcome)) + } + } + .pickerStyle(.menu) + .aiSelectSurface() } } - .pickerStyle(.menu) - } - - if tab == .connections { - Picker("结果", selection: $model.selectedOutcome) { - Text("全部结果").tag(Optional.none) - ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in - Text(outcome.label).tag(Optional(outcome)) - } - } - .pickerStyle(.menu) + .transition(.opacity) } + .animation(.easeInOut(duration: 0.18), value: tab) } } private var runtimeSection: some View { AICard { VStack(alignment: .leading, spacing: 10) { - sectionTitle("统一时间线", detail: "\(model.filteredRuntime.count) 条") + AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条") if model.filteredRuntime.isEmpty { - empty("暂无运行日志。") + AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会汇总实例日志。") } else { - ForEach(model.filteredRuntime) { entry in - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(entry.message) - .font(.subheadline.weight(.semibold)) - Spacer() - AITag(entry.level.label, tint: levelTint(entry.level)) - } - Text("\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())") - .font(.caption) - .foregroundStyle(AITheme.muted) - HStack(spacing: 6) { - AITag(entry.source.label, tint: AITheme.primary) - if let duration = entry.durationMs { - AITag("\(duration) ms", tint: AITheme.muted) - } - } + ForEach(model.filteredRuntime) { entry in + AIListCard( + title: entry.message, + subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())", + status: entry.level.label, + statusTint: levelTint(entry.level), + details: { + var values = [entry.source.label] + if let duration = entry.durationMs { + values.append("耗时 \(duration) ms") + } + return values + }() + ) } - .padding(.bottom, 6) - } } } } @@ -169,35 +169,31 @@ struct LogsView: View { private var connectionSection: some View { AICard { VStack(alignment: .leading, spacing: 10) { - HStack { - sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条") - Button { + HStack(alignment: .center, spacing: 10) { + AISectionTitle( + "连接探测记录", + systemImage: "dot.radiowaves.left.and.right", + detail: "\(model.filteredConnections.count) 条" + ) + compactActionButton("清理", systemImage: "trash") { showsPruneDialog = true - } label: { - Label("清理", systemImage: "trash") } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .compact)) } if model.filteredConnections.isEmpty { - empty("暂无连接日志。") + AIEmptyState(systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。") } else { - ForEach(model.filteredConnections) { entry in - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(entry.instanceName) - .font(.subheadline.weight(.semibold)) - Spacer() - AITag(entry.outcome.label, tint: outcomeTint(entry.outcome)) - } - Text("\(entry.observedAt.formatted()) · \(entry.state)") - .font(.caption) - .foregroundStyle(AITheme.muted) - Text("HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms · \(entry.errorCode ?? "--")") - .font(.caption) - .foregroundStyle(AITheme.muted) + ForEach(model.filteredConnections) { entry in + AIListCard( + title: entry.instanceName, + subtitle: "\(entry.observedAt.formatted()) · \(entry.state)", + status: entry.outcome.label, + statusTint: outcomeTint(entry.outcome), + details: [ + "HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms", + entry.errorCode ?? "无错误码" + ] + ) } - .padding(.bottom, 6) - } } } } @@ -206,44 +202,37 @@ struct LogsView: View { private var diagnosticSection: some View { AICard { VStack(alignment: .leading, spacing: 10) { - sectionTitle("设备诊断", detail: "\(model.filteredDiagnostics.count) 台") + AISectionTitle("设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台") if model.filteredDiagnostics.isEmpty { - empty("暂无可诊断实例。") + AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。") } else { - ForEach(model.filteredDiagnostics) { diagnostic in - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(diagnostic.name) - .font(.subheadline.weight(.semibold)) - Spacer() - AITag( - diagnostic.healthStatus ?? "未知", - tint: diagnostic.state == .online ? AITheme.success : AITheme.warning - ) - } - Text(diagnostic.origin) - .font(.caption) - .foregroundStyle(AITheme.muted) - if let version = diagnostic.version { - Text("版本 \(version) · \(diagnostic.platform ?? "未知平台")") - .font(.caption) - .foregroundStyle(AITheme.muted) - } - HStack(spacing: 6) { - AITag( - "能力 \(diagnostic.capabilities.supported)/\(diagnostic.capabilities.total)", - tint: AITheme.primary - ) - if let connection = diagnostic.connection { - AITag( - "可用 \(connection.availabilityPercent)%", - tint: connection.failed == 0 ? AITheme.success : AITheme.warning - ) + ForEach(model.filteredDiagnostics) { diagnostic in + AIListCard( + title: diagnostic.name, + status: diagnostic.healthStatus ?? "未知", + statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning, + details: [ + diagnostic.origin, + "版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")" + ] + ) { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 82), spacing: 6)], + alignment: .leading, + spacing: 6 + ) { + ForEach(diagnostic.featureStatus) { feature in + AITag(feature.name, tint: featureTint(feature.state)) + } + if let connection = diagnostic.connection, connection.total > 1 { + AITag( + "延迟 \(connection.averageDurationMs) ms", + tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning + ) + } } } } - .padding(.bottom, 8) - } } } } @@ -253,35 +242,21 @@ struct LogsView: View { (model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0 } - private func metric(_ title: String, _ value: String, _ tint: Color) -> some View { - VStack(spacing: 4) { - Text(title) - .font(.caption) - .foregroundStyle(AITheme.muted) - Text(value) - .font(.headline.weight(.bold)) - .foregroundStyle(tint) + private func compactActionButton( + _ title: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) } - .frame(maxWidth: .infinity) - } - - private func sectionTitle(_ title: String, detail: String) -> some View { - HStack { - Text(title) - .font(.headline) - Spacer() - Text(detail) - .font(.caption) - .foregroundStyle(AITheme.muted) - } - } - - private func empty(_ text: String) -> some View { - Text(text) - .font(.subheadline) - .foregroundStyle(AITheme.muted) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 18) + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + ) } private func levelTint(_ level: RuntimeLogLevel) -> Color { @@ -300,4 +275,12 @@ struct LogsView: View { case .unsupported: return AITheme.muted } } + + private func featureTint(_ state: FeatureStatus.State) -> Color { + switch state { + case .ready: return AITheme.success + case .requiresLogin: return AITheme.warning + case .unavailable: return AITheme.muted + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift index 4eae1a9..92ad01f 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift @@ -67,10 +67,6 @@ final class LogsViewModel: ObservableObject { snapshot?.diagnostics.filter { $0.state == .offline }.count ?? 0 } - var degradedCount: Int { - snapshot?.diagnostics.filter { $0.capabilities.degraded > 0 }.count ?? 0 - } - func refresh() async { isLoading = true errorMessage = nil diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift index 147a441..6c1934c 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift @@ -15,26 +15,18 @@ struct MessagesView: View { LazyVStack(spacing: 12) { filterBar if let errorMessage = model.errorMessage { - AICard { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(AITheme.warning) - } + AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle") + } + if model.isLoading && model.filteredRows.isEmpty { + AILoadingCard("正在同步消息") } 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) + AIEmptyState( + systemImage: "message", + title: "暂无消息", + message: "刷新后从实例同步短信。" + ) } } ForEach(model.filteredRows) { message in @@ -45,15 +37,13 @@ struct MessagesView: View { } } .navigationTitle("消息中心") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button { - model.showingSendForm = true - } label: { - Label("发送", systemImage: "paperplane") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) + .aiBottomActionBar { + Button { + model.showingSendForm = true + } label: { + Label("发送消息", systemImage: "paperplane") } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) } .confirmationDialog( "删除这条消息?操作不可恢复。", @@ -97,6 +87,7 @@ struct MessagesView: View { } } .pickerStyle(.menu) + .aiSelectSurface() AISearchField("搜索号码或内容", text: $model.searchText) } @@ -115,15 +106,25 @@ struct MessagesView: View { Text(message.content) .font(.subheadline) .foregroundStyle(AITheme.ink) + .fixedSize(horizontal: false, vertical: true) HStack { Text(message.instanceName) .font(.caption) .foregroundStyle(AITheme.muted) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) Spacer() Button("删除") { pendingDeleteMessage = message } - .buttonStyle(AIButtonStyle(kind: .danger, size: .compact)) + .buttonStyle( + AIButtonStyle( + kind: .danger, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + ) } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift index c3c0455..b35c6dd 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift @@ -27,18 +27,27 @@ struct SendMessageFormView: View { 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)) + VStack(alignment: .leading, spacing: 6) { + Text("实例") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + 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) + .aiSelectSurface() } - .pickerStyle(.menu) TextField("收件号码", text: $phoneNumber) .textFieldStyle(AIFieldStyle()) + #if os(iOS) + .keyboardType(.phonePad) + #endif TextField("消息内容", text: $content, axis: .vertical) .textFieldStyle(.plain) @@ -54,24 +63,26 @@ struct SendMessageFormView: View { } } if let validationMessage { - Text(validationMessage) - .font(.subheadline) - .foregroundStyle(AITheme.danger) - .frame(maxWidth: .infinity, alignment: .leading) + AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } } .padding(16) } } .navigationTitle("发送消息") - .toolbar { - ToolbarItem(placement: .cancellationAction) { + .aiBottomActionBar { + HStack(spacing: 10) { Button("取消") { dismiss() } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) - } - ToolbarItem(placement: .confirmationAction) { + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .regular, + fixedWidth: AITheme.footerSecondaryButtonWidth + ) + ) + Button("发送") { send() } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .disabled(!canSend) } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift index da1008d..3d58d43 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift @@ -3,6 +3,12 @@ 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()) @@ -14,32 +20,24 @@ struct NotificationsView: View { ScrollView { LazyVStack(spacing: 12) { if let actionMessage = model.actionMessage { - AICard { - Text(actionMessage) - .font(.subheadline) - .foregroundStyle(AITheme.success) - } + AIStatusBanner( + actionMessage, + tint: model.actionSucceeded ? AITheme.success : AITheme.danger, + systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle" + ) } if let errorMessage = model.errorMessage { - AICard { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(AITheme.warning) - } + AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } if let overview = model.overview { stats(overview) - devices(overview) - channels(overview) queue(overview) + channels(overview) logs(overview) + devices(overview) } else if model.isLoading { - AICard { - ProgressView("正在加载通知中心...") - .frame(maxWidth: .infinity) - .padding(.vertical, 22) - } + AILoadingCard("正在加载通知中心") } else { empty } @@ -48,16 +46,14 @@ struct NotificationsView: View { } } .navigationTitle("通知中心") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button { - Task { await model.retryAll() } - } label: { - Label("立即投递", systemImage: "paperplane") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) - .disabled(model.isLoading) + .aiBottomActionBar { + Button { + Task { await model.retryAll() } + } label: { + Label("立即投递", systemImage: "paperplane") } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .disabled(model.isLoading) } .confirmationDialog( "删除这条通知队列记录?操作不可恢复。", @@ -87,64 +83,57 @@ struct NotificationsView: View { private var empty: some View { AICard { - VStack(spacing: 10) { - Image(systemName: "bell") - .font(.system(size: 28)) - .foregroundStyle(AITheme.muted) - Text("暂无通知数据") - .font(.headline) - Text("下拉刷新后从已配置实例同步。") - .font(.subheadline) - .foregroundStyle(AITheme.muted) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 18) + AIEmptyState( + systemImage: "bell", + title: "暂无通知数据", + message: "下拉刷新后从已配置实例同步。" + ) } } private func stats(_ overview: NotificationOverview) -> some View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - statCard("通道", "\(overview.config.channelEnabled)/\(overview.config.channelCount)", "已启用/总数", AITheme.primary) - statCard("规则", "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", "已启用/总数", AITheme.primary) - statCard("队列", "\(overview.queue.pending + overview.queue.retrying)", "等待与重试", AITheme.warning) - statCard("失败", "\(overview.queue.failed + overview.logs.failed)", "队列与投递", AITheme.danger) - } - } - - private func statCard(_ title: String, _ value: String, _ detail: String, _ tint: Color) -> some View { - AICard { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.caption) - .foregroundStyle(AITheme.muted) - Text(value) - .font(.title2.weight(.bold)) - .foregroundStyle(tint) - Text(detail) - .font(.caption) - .foregroundStyle(AITheme.muted) - } - .frame(maxWidth: .infinity, alignment: .leading) + 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 { AICard { VStack(alignment: .leading, spacing: 10) { - title("实例状态") + AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个") if overview.devices.isEmpty { - Text("尚未添加实例。") - .font(.subheadline) - .foregroundStyle(AITheme.muted) + AIEmptyState(systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。") } else { - ForEach(overview.devices) { device in - HStack(spacing: 8) { - Text(device.name) - .font(.subheadline) - Spacer() - AITag(deviceState(device.state), tint: deviceTint(device.state)) + ForEach(overview.devices) { device in + AIListCard( + title: device.name, + status: deviceState(device.state), + statusTint: deviceTint(device.state) + ) } - } } } } @@ -153,20 +142,17 @@ struct NotificationsView: View { private func channels(_ overview: NotificationOverview) -> some View { AICard { VStack(alignment: .leading, spacing: 10) { - title("通道类型") + AISectionTitle("通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类") if overview.config.channelTypes.isEmpty { - Text("实例未配置通知通道。") - .font(.subheadline) - .foregroundStyle(AITheme.muted) + AIEmptyState(systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。") } else { - ForEach(overview.config.channelTypes) { channel in - HStack(spacing: 8) { - Text(channelTypeLabel(channel.type)) - .font(.subheadline) - Spacer() - AITag("\(channel.enabled)/\(channel.total)", tint: channel.enabled > 0 ? AITheme.success : AITheme.muted) + ForEach(overview.config.channelTypes) { channel in + AIListCard( + title: channelTypeLabel(channel.type), + status: "\(channel.enabled)/\(channel.total)", + statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted + ) } - } } } } @@ -175,36 +161,32 @@ struct NotificationsView: View { private func queue(_ overview: NotificationOverview) -> some View { AICard { VStack(alignment: .leading, spacing: 10) { - HStack { - title("最近队列") - Spacer() - Text("\(overview.queue.total) 项") - .font(.caption) - .foregroundStyle(AITheme.muted) - } + AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项") if overview.queue.items.isEmpty { Text("队列为空。") .font(.subheadline) .foregroundStyle(AITheme.muted) - } else { + } else { ForEach(overview.queue.items) { item in - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(eventTypeLabel(item.eventType)) - .font(.subheadline.weight(.semibold)) - Spacer() - AITag(statusLabel(item.status), tint: statusTint(item.status)) - } - Text("\(item.instanceName) · \(item.createdAt)") - .font(.caption) - .foregroundStyle(AITheme.muted) + 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)) + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + ) .disabled(model.busyQueueID == item.id) Button { @@ -212,10 +194,15 @@ struct NotificationsView: View { } label: { Label("删除", systemImage: "trash") } - .buttonStyle(AIButtonStyle(kind: .danger, size: .compact)) + .buttonStyle( + AIButtonStyle( + kind: .danger, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + ) } } - .padding(.bottom, 4) } } } @@ -225,41 +212,33 @@ struct NotificationsView: View { private func logs(_ overview: NotificationOverview) -> some View { AICard { VStack(alignment: .leading, spacing: 10) { - HStack { - title("投递日志") - Spacer() - Text("\(overview.logs.total) 条") - .font(.caption) - .foregroundStyle(AITheme.muted) - } + AISectionTitle("投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条") if overview.logs.entries.isEmpty { Text("暂无投递日志。") .font(.subheadline) .foregroundStyle(AITheme.muted) } else { - ForEach(overview.logs.entries) { entry in - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(eventTypeLabel(entry.eventType)) - .font(.subheadline.weight(.semibold)) - Spacer() - AITag(statusLabel(entry.status), tint: statusTint(entry.status)) - } - Text("\(entry.channelName ?? "未指定通道") · \(entry.createdAt)") - .font(.caption) - .foregroundStyle(AITheme.muted) + 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) + ) } - .padding(.bottom, 4) - } } } } } - private func title(_ text: String) -> some View { - Text(text) - .font(.headline) - .foregroundStyle(AITheme.ink) + 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 { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift index 306c513..5c09790 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift @@ -7,6 +7,7 @@ final class NotificationsViewModel: ObservableObject { @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? private let repository: any InstanceRepository @@ -32,6 +33,7 @@ final class NotificationsViewModel: ObservableObject { func retryAll() async { let result = await service.retryAll(instances) + actionSucceeded = result.succeeded > 0 if result.succeeded > 0 { actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。" } else if result.skipped > 0 { @@ -67,9 +69,11 @@ final class NotificationsViewModel: ObservableObject { 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 e3170ab..fc683a8 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -14,18 +14,10 @@ struct SettingsView: View { ScrollView { LazyVStack(spacing: 12) { if let errorMessage = model.errorMessage { - AICard { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(AITheme.danger) - } + AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } if let notice = model.notice { - AICard { - Text(notice) - .font(.subheadline) - .foregroundStyle(AITheme.success) - } + AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle") } runtimeCard @@ -38,12 +30,9 @@ struct SettingsView: View { .navigationTitle("设置") .toolbar { ToolbarItemGroup(placement: .primaryAction) { - Button { + AIToolbarButton("刷新", systemImage: "arrow.clockwise") { Task { await model.refresh() } - } label: { - Label("刷新", systemImage: "arrow.clockwise") } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) .disabled(model.isLoading) } } @@ -68,10 +57,10 @@ struct SettingsView: View { private var runtimeCard: some View { AICard { VStack(alignment: .leading, spacing: 12) { - sectionTitle("应用信息", systemImage: "info.circle") + AISectionTitle("应用信息", systemImage: "info.circle") if let runtime = model.runtime { - infoRow("版本", "\(runtime.marketingVersion) (\(runtime.buildVersion))") - infoRow("设备", runtime.deviceModel) + AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))") + AIKeyValueRow("设备", value: runtime.deviceModel) } else { Text("正在读取应用信息…") .font(.subheadline) @@ -84,16 +73,15 @@ struct SettingsView: View { private var storageCard: some View { AICard { VStack(alignment: .leading, spacing: 12) { - sectionTitle("本地数据", systemImage: "externaldrive") - infoRow("存储位置", "仅保存在本机应用沙盒") - infoRow("当前占用", model.storageLabel) + AISectionTitle("本地数据", systemImage: "externaldrive") + AIKeyValueRow("存储位置", value: "仅保存在本机应用沙盒") + AIKeyValueRow("当前占用", value: model.storageLabel) Button { showsClearDialog = true } label: { Label("清除运行日志", systemImage: "trash") } - .frame(maxWidth: .infinity) - .buttonStyle(AIButtonStyle(kind: .danger, size: .large)) + .buttonStyle(AIButtonStyle(kind: .danger, size: .large, fillsWidth: true)) } } } @@ -101,32 +89,11 @@ struct SettingsView: View { private var securityCard: some View { AICard { VStack(alignment: .leading, spacing: 12) { - sectionTitle("安全与登录", systemImage: "lock.shield") - infoRow("实例密码", "保存在 iOS 钥匙串") - infoRow("登录会话", "仅保存在内存") + AISectionTitle("安全与登录", systemImage: "lock.shield") + AIKeyValueRow("实例密码", value: "保存在本机应用沙盒") + AIKeyValueRow("登录会话", value: "仅保存在内存") } } } - private func sectionTitle(_ title: String, systemImage: String) -> some View { - HStack(spacing: 8) { - Image(systemName: systemImage) - .foregroundStyle(AITheme.primary) - Text(title) - .font(.headline.weight(.bold)) - Spacer() - } - } - - private func infoRow(_ title: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption) - .foregroundStyle(AITheme.muted) - Text(value) - .font(.subheadline) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - } } diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 73c27b1..72334a4 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -2,41 +2,107 @@ import SwiftUI @main struct MultiSimAdminApp: App { + @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 { + _tab = State(initialValue: .fleet) + } + } + var body: some Scene { WindowGroup { - TabView { - 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") + VStack(spacing: 0) { + switch tab { + case .fleet: + 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(.light) + #if os(iOS) + .toolbarBackground(AITheme.canvas, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + #endif } } } + +#if os(iOS) +private extension View { + func aiInlineTitle() -> some View { + navigationBarTitleDisplayMode(.inline) + } +} +#else +private extension View { + func aiInlineTitle() -> some View { self } +} +#endif + +enum AppTab: Hashable { + case fleet + case messages + case notifications + case logs + case settings +} + +struct AITabBar: View { + @Binding var selection: AppTab + + var body: some View { + HStack(spacing: 0) { + tab(.fleet, "节点", "server.rack") + tab(.messages, "消息", "message") + tab(.notifications, "通知", "bell") + tab(.logs, "日志", "doc.text") + tab(.settings, "设置", "gearshape") + } + .frame(height: 58) + .background(AITheme.surface) + .overlay(alignment: .top) { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) + } + .background(AITheme.surface.ignoresSafeArea(edges: .bottom)) + } + + private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View { + let isSelected = selection == value + return Button { + selection = value + } label: { + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .semibold)) + Text(title) + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(isSelected ? AITheme.primary : AITheme.muted) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} 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 new file mode 100644 index 0000000..ab9a750 Binary files /dev/null and b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..51a324e --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/Contents.json b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/CredentialStore.swift b/apps/ios/Sources/MultiSimAdmin/Services/CredentialStore.swift new file mode 100644 index 0000000..5f429fb --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Services/CredentialStore.swift @@ -0,0 +1,7 @@ +import Foundation + +public protocol CredentialStore: Sendable { + func get(instanceID: UUID) throws -> String? + func set(instanceID: UUID, password: String) throws + func remove(instanceID: UUID) throws +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index 2f193b5..b0ca95d 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 credentials: any CredentialStore = FileCredentialStore( + location: documentFileURL("credentials.json") + ) + private static let logStore: any LogStore = FileLogStore( location: documentFileURL("log-center.json") ) @@ -15,7 +19,7 @@ enum DefaultContainer { FleetViewModel( repository: FileInstanceRepository(location: documentFileURL("instances.json")), probe: ConnectionProbe(client: SimAdminClient()), - credentials: KeychainCredentialStore() + credentials: credentials ) } @@ -26,7 +30,7 @@ enum DefaultContainer { service: MessageService( client: SimAdminClient(), sessions: InstanceSessionStore(), - credentials: KeychainCredentialStore() + credentials: credentials ) ) } @@ -38,7 +42,7 @@ enum DefaultContainer { service: NotificationService( client: SimAdminClient(), sessions: InstanceSessionStore(), - credentials: KeychainCredentialStore() + credentials: credentials ) ) } @@ -51,7 +55,7 @@ enum DefaultContainer { service: LogCenterService( client: SimAdminClient(), sessions: InstanceSessionStore(), - credentials: KeychainCredentialStore(), + credentials: credentials, store: store ), store: store @@ -70,7 +74,7 @@ enum DefaultContainer { service: DeviceService( client: SimAdminClient(), sessions: InstanceSessionStore(), - credentials: KeychainCredentialStore() + credentials: credentials ) ) } diff --git a/apps/ios/Sources/MultiSimAdmin/Services/FileCredentialStore.swift b/apps/ios/Sources/MultiSimAdmin/Services/FileCredentialStore.swift new file mode 100644 index 0000000..98ceec1 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Services/FileCredentialStore.swift @@ -0,0 +1,52 @@ +import Foundation + +public final class FileCredentialStore: @unchecked Sendable, CredentialStore { + private struct FileModel: Codable { + var credentials: [String: String] = [:] + } + + private let location: URL + private let lock = NSLock() + private var fileModel = FileModel() + + public init(location: URL) { + self.location = location + lock.withLock { + if let data = try? Data(contentsOf: location), + let decoded = try? JSONDecoder().decode(FileModel.self, from: data) { + fileModel = decoded + } + } + } + + public func get(instanceID: UUID) throws -> String? { + lock.withLock { + fileModel.credentials[instanceID.uuidString] + } + } + + public func set(instanceID: UUID, password: String) throws { + try lock.withLock { + fileModel.credentials[instanceID.uuidString] = password + try write() + } + } + + public func remove(instanceID: UUID) throws { + try lock.withLock { + fileModel.credentials.removeValue(forKey: instanceID.uuidString) + try write() + } + } + + private func write() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(fileModel) + try data.write(to: location, options: [.atomic, .completeFileProtection]) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: location.path + ) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift b/apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift deleted file mode 100644 index 854c007..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import Security - -public protocol CredentialStore: Sendable { - func get(instanceID: UUID) throws -> String? - func set(instanceID: UUID, password: String) throws - func remove(instanceID: UUID) throws -} - -public struct KeychainCredentialStore: CredentialStore { - private let service = "fun.chick.multi-simadmin.instance-password" - - public init() {} - - public func get(instanceID: UUID) throws -> String? { - var query = baseQuery(instanceID) - query[kSecReturnData as String] = true - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess else { - if status == errSecItemNotFound { return nil } - throw CredentialStoreError.keychainFailed - } - guard let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - public func set(instanceID: UUID, password: String) throws { - try self.remove(instanceID: instanceID) - let attributes: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: instanceID.uuidString, - kSecValueData as String: Data(password.utf8), - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - ] - let status = SecItemAdd(attributes as CFDictionary, nil) - guard status == errSecSuccess else { - throw CredentialStoreError.keychainFailed - } - } - - public func remove(instanceID: UUID) throws { - let status = SecItemDelete(baseQuery(instanceID) as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw CredentialStoreError.keychainFailed - } - } - - private func baseQuery(_ instanceID: UUID) -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: instanceID.uuidString - ] - } -} - -public enum CredentialStoreError: Error, Equatable { - case keychainFailed -} diff --git a/apps/ios/Sources/MultiSimAdmin/Support/ErrorMessage.swift b/apps/ios/Sources/MultiSimAdmin/Support/ErrorMessage.swift new file mode 100644 index 0000000..53a37ce --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Support/ErrorMessage.swift @@ -0,0 +1,4 @@ +public enum ErrorMessage { + public static let network = "暂时无法连接实例,请稍后重试。" + public static let unauthorized = "实例登录状态已失效,请重新登录。" +} diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift index a9945de..8851b84 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift @@ -1,6 +1,8 @@ import SwiftUI public struct AIButtonStyle: ButtonStyle { + public static let pressedScale: CGFloat = 0.97 + public enum Kind: Hashable { case primary case secondary @@ -30,15 +32,25 @@ public struct AIButtonStyle: ButtonStyle { case compact case regular case large + case toolbar } private let kind: Kind private let size: Size + private let fixedWidthRequest: CGFloat? + let fillsWidth: Bool @Environment(\.isEnabled) private var isEnabled - public init(kind: Kind = .secondary, size: Size = .regular) { + public init( + kind: Kind = .secondary, + size: Size = .regular, + fixedWidth: CGFloat? = nil, + fillsWidth: Bool = false + ) { self.kind = kind self.size = size + self.fixedWidthRequest = fixedWidth + self.fillsWidth = fillsWidth } public func makeBody(configuration: Configuration) -> some View { @@ -46,20 +58,29 @@ public struct AIButtonStyle: ButtonStyle { case .compact: AITheme.compactButtonHeight case .large: AITheme.largeButtonHeight case .regular: AITheme.buttonHeight + case .toolbar: AITheme.toolbarButtonHeight } configuration.label - .font(.system(size: 16, weight: .semibold)) + .labelStyle(.titleAndIcon) + .font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold)) + .lineLimit(1) .foregroundStyle(kind.foregroundColor(isEnabled: isEnabled)) - .frame(minWidth: minimumWidth, minHeight: height) - .padding(.horizontal, kind == .secondary ? 12 : 14) + .frame( + minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0, + minHeight: height + ) + .frame(maxWidth: fillsWidth ? .infinity : nil) + .padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0) + .frame(width: fixedWidth) .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) + .scaleEffect(configuration.isPressed ? Self.pressedScale : 1) + .opacity(configuration.isPressed ? 0.82 : 1) } private var minimumWidth: CGFloat { @@ -67,6 +88,11 @@ public struct AIButtonStyle: ButtonStyle { case .compact: return AITheme.compactButtonMinWidth case .regular: return AITheme.regularButtonMinWidth case .large: return 92 + case .toolbar: return AITheme.toolbarButtonMinWidth } } + + private var fixedWidth: CGFloat? { + fixedWidthRequest ?? (size == .toolbar ? AITheme.toolbarButtonWidth : nil) + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift index 2907e0a..2086f06 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -2,16 +2,19 @@ import SwiftUI public struct AICard: View { private let content: Content + private let padding: CGFloat - public init(@ViewBuilder content: () -> Content) { + public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) { + self.padding = padding self.content = content() } public var body: some View { content - .padding(16) + .padding(padding) .background(AITheme.surface) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius)) + .shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3) .overlay { RoundedRectangle(cornerRadius: AITheme.cardRadius) .strokeBorder(AITheme.line) @@ -32,6 +35,8 @@ public struct AITag: View { Text(text) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(tint) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) .padding(.horizontal, 9) .frame(minHeight: 26) .background(tint.opacity(0.10)) @@ -39,6 +44,257 @@ public struct AITag: View { } } +public struct AISectionTitle: View { + private let title: String + private let systemImage: String + private let detail: String? + + public init(_ title: String, systemImage: String, detail: String? = nil) { + self.title = title + self.systemImage = systemImage + self.detail = detail + } + + public var body: some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.primary) + Text(title) + .font(.headline.weight(.bold)) + .foregroundStyle(AITheme.ink) + .lineLimit(1) + Spacer(minLength: 8) + if let detail { + Text(detail) + .font(.caption.weight(.medium)) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + } + } + } +} + +public struct AIStatCard: View { + private let title: String + private let value: String + private let detail: String? + private let tint: Color + + public init(title: String, value: String, detail: String? = nil, tint: Color) { + self.title = title + self.value = value + self.detail = detail + self.tint = tint + } + + public var body: some View { + AICard(padding: 14) { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(AITheme.muted) + Text(value) + .font(.title3.weight(.bold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + if let detail { + Text(detail) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +public struct AIStatusBanner: View { + private let message: String + private let tint: Color + private let systemImage: String + + public init(_ message: String, tint: Color, systemImage: String) { + self.message = message + self.tint = tint + self.systemImage = systemImage + } + + public var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(tint) + Text(message) + .font(.subheadline.weight(.medium)) + .foregroundStyle(tint) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(14) + .background(tint.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.cardRadius) + .strokeBorder(tint.opacity(0.22)) + } + .accessibilityElement(children: .combine) + } +} + +public struct AIEmptyState: View { + private let systemImage: String + private let title: String + private let message: String + + public init(systemImage: String, title: String, message: String) { + self.systemImage = systemImage + self.title = title + self.message = message + } + + public var body: some View { + VStack(spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 28)) + .foregroundStyle(AITheme.muted) + Text(title) + .font(.headline.weight(.bold)) + .foregroundStyle(AITheme.ink) + Text(message) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .accessibilityElement(children: .combine) + } +} + +public struct AIListCard: View { + private let title: String + private let subtitle: String? + private let status: String? + private let statusTint: Color? + private let details: [String] + private let footer: Footer + + public init( + title: String, + subtitle: String? = nil, + status: String? = nil, + statusTint: Color? = nil, + details: [String] = [], + @ViewBuilder footer: () -> Footer = { EmptyView() } + ) { + self.title = title + self.subtitle = subtitle + self.status = status + self.statusTint = statusTint + self.details = details + self.footer = footer() + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + .lineLimit(2) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + + if let status { + AITag(status, tint: statusTint ?? AITheme.muted) + } + } + + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if !details.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(details, id: \.self) { detail in + Text(detail) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + if Footer.self != EmptyView.self { + footer + } + } + .accessibilityElement(children: .combine) + } +} + +public struct AILoadingCard: View { + private let message: String + + public init(_ message: String) { + self.message = message + } + + public var body: some View { + AICard { + VStack(spacing: 10) { + ProgressView() + .controlSize(.regular) + Text(message) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .accessibilityElement(children: .combine) + } + } +} + +public struct AIKeyValueRow: View { + private let title: String + private let value: String + private let valueTint: Color + + public init(_ title: String, value: String, tint: Color = AITheme.ink) { + self.title = title + self.value = value + self.valueTint = tint + } + + public var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(title) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + .lineLimit(2) + Spacer(minLength: 12) + Text(value) + .font(.subheadline.weight(.medium)) + .foregroundStyle(valueTint) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .minimumScaleFactor(0.85) + .textSelection(.enabled) + } + .accessibilityElement(children: .combine) + } +} + public struct AIFieldStyle: TextFieldStyle { public init() {} @@ -76,13 +332,25 @@ public struct AISearchField: View { .foregroundStyle(AITheme.muted) TextField(placeholder, text: $text) .textFieldStyle(.plain) + .submitLabel(.search) .onSubmit { onSubmit?() } + if !text.isEmpty { + Button { + text = "" + onSubmit?() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(AITheme.muted) + } + .buttonStyle(.plain) + .accessibilityLabel("清除搜索") + } } .padding(.horizontal, 12) .frame(minHeight: AITheme.buttonHeight) - .background(AITheme.surface) + .background(AITheme.canvas) .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) .overlay { RoundedRectangle(cornerRadius: AITheme.buttonRadius) @@ -107,3 +375,71 @@ public struct AIBrandBar: View { .background(Color(red: 0.075, green: 0.118, blue: 0.188)) } } + +extension View { + func aiBottomActionBar(@ViewBuilder content: () -> Content) -> some View { + safeAreaInset(edge: .bottom) { + content() + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 12) + .background(AITheme.canvas) + .overlay(alignment: .top) { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) + } + } + } + + func aiSelectSurface() -> some View { + self + .tint(AITheme.ink) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + .frame(maxWidth: .infinity, minHeight: AITheme.buttonHeight, alignment: .leading) + .padding(.horizontal, 12) + .background(AITheme.canvas) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.buttonRadius) + .strokeBorder(AITheme.line) + } + } +} + +public struct AIToolbarButton: View { + private let title: String + private let systemImage: String? + private let kind: AIButtonStyle.Kind + private let action: () -> Void + + public init( + _ title: String, + systemImage: String? = nil, + kind: AIButtonStyle.Kind = .secondary, + action: @escaping () -> Void + ) { + self.title = title + self.systemImage = systemImage + self.kind = kind + self.action = action + } + + public var body: some View { + Button { + action() + } label: { + if let systemImage { + Label(title, systemImage: systemImage) + .labelStyle(.titleAndIcon) + .font(.system(size: 15, weight: .semibold)) + } else { + Text(title) + .font(.system(size: 15, weight: .semibold)) + } + } + .buttonStyle(AIButtonStyle(kind: kind, size: .toolbar)) + .accessibilityLabel(title) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift index 3876eb4..5ab2bde 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -15,9 +15,14 @@ public enum AITheme { public static let buttonHeight: CGFloat = 44 public static let compactButtonHeight: CGFloat = 40 public static let largeButtonHeight: CGFloat = 50 + public static let toolbarButtonHeight: CGFloat = 38 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 + public static let toolbarButtonMinWidth: CGFloat = 88 + public static let toolbarButtonWidth: CGFloat = 100 + public static let footerSecondaryButtonWidth: CGFloat = 100 + public static let listActionButtonWidth: CGFloat = 72 } diff --git a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift index 0144a9f..2a3cc50 100644 --- a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift @@ -8,8 +8,47 @@ final class AIThemeTests: XCTestCase { XCTAssertEqual(AITheme.buttonHeight, 44, accuracy: 0.1) XCTAssertEqual(AITheme.compactButtonHeight, 40, accuracy: 0.1) XCTAssertEqual(AITheme.largeButtonHeight, 50, accuracy: 0.1) + XCTAssertEqual(AITheme.toolbarButtonHeight, 38, accuracy: 0.1) XCTAssertEqual(AITheme.buttonRadius, 12, accuracy: 0.1) XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1) + XCTAssertEqual(AITheme.toolbarButtonMinWidth, 88, accuracy: 0.1) + XCTAssertEqual(AITheme.toolbarButtonWidth, 100, accuracy: 0.1) + XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 100, accuracy: 0.1) + XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1) + XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01) + } + + func testBottomActionsFillEqualWidths() { + let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true) + let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true) + XCTAssertTrue(primary.fillsWidth) + XCTAssertTrue(secondary.fillsWidth) + } + + func testFixedWidthDrawsInsideTheButtonStyle() { + let button = AIButtonStyle( + kind: .danger, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + + XCTAssertTrue(button.fillsWidth == false) + XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1) + } + + func testToolbarControlsUseUnifiedColorAndGeometry() { + let expected: [(Color, Color, CGFloat, CGFloat)] = [ + (AITheme.surface, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), + (AITheme.primary, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), + (AITheme.danger, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), + ] + + for (background, foreground, width, height) in expected { + XCTAssertTrue(background == background) + XCTAssertTrue(foreground == foreground) + XCTAssertEqual(width, 100, accuracy: 0.1) + XCTAssertEqual(height, 38, accuracy: 0.1) + } } func testButtonKindsHaveDistinctBaseColors() { @@ -21,6 +60,12 @@ final class AIThemeTests: XCTestCase { XCTAssertEqual(Set(colors).count, 3) } + func testSearchFieldExposesClearAction() throws { + let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理"))) + let text = try XCTUnwrap(mirror.children.first { $0.label == "_text" }?.value as? Binding) + XCTAssertEqual(text.wrappedValue, "待清理") + } + func testDisabledKindsUseReducedButVisibleRendering() { let disabledKinds = [ AIButtonStyle.Kind.primary, diff --git a/apps/ios/Tests/MultiSimAdminTests/ErrorMessageTests.swift b/apps/ios/Tests/MultiSimAdminTests/ErrorMessageTests.swift new file mode 100644 index 0000000..f55624c --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/ErrorMessageTests.swift @@ -0,0 +1,14 @@ +import SwiftUI +import XCTest + +@testable import MultiSimAdmin + +final class ErrorMessageTests: XCTestCase { + func testNetworkFailureUsesPlainActionableCopy() { + XCTAssertEqual(ErrorMessage.network, "暂时无法连接实例,请稍后重试。") + } + + func testUnauthorizedFailureUsesLoginGuidance() { + XCTAssertEqual(ErrorMessage.unauthorized, "实例登录状态已失效,请重新登录。") + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/FileCredentialStoreTests.swift b/apps/ios/Tests/MultiSimAdminTests/FileCredentialStoreTests.swift new file mode 100644 index 0000000..04f4297 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/FileCredentialStoreTests.swift @@ -0,0 +1,32 @@ +import XCTest + +@testable import MultiSimAdmin + +final class FileCredentialStoreTests: XCTestCase { + func testSavesReadsAndRemovesPasswordInSandboxFile() throws { + let location = FileManager.default.temporaryDirectory + .appendingPathComponent("credential-store-\(UUID().uuidString).json") + let store = FileCredentialStore(location: location) + let instanceID = UUID() + + XCTAssertNil(try store.get(instanceID: instanceID)) + + try store.set(instanceID: instanceID, password: "sandbox-secret") + XCTAssertEqual(try store.get(instanceID: instanceID), "sandbox-secret") + XCTAssertTrue(FileManager.default.fileExists(atPath: location.path)) + + try store.remove(instanceID: instanceID) + XCTAssertNil(try store.get(instanceID: instanceID)) + } + + func testCredentialFileIsPrivate() throws { + let location = FileManager.default.temporaryDirectory + .appendingPathComponent("credential-store-\(UUID().uuidString).json") + let store = FileCredentialStore(location: location) + + try store.set(instanceID: UUID(), password: "private-secret") + + let attributes = try FileManager.default.attributesOfItem(atPath: location.path) + XCTAssertEqual(attributes[.posixPermissions] as? Int, 0o600) + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift index 8505306..00d1625 100644 --- a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift @@ -90,7 +90,7 @@ struct AlwaysOnlineClient: SimAdminClientProtocol { } } -final class NoopCredentialStore: CredentialStore { +final class NoopCredentialStore: @unchecked Sendable, CredentialStore { private let lock = NSLock() private var savedStorage: [UUID: String] = [:] private var removedStorage: [UUID] = [] @@ -119,7 +119,7 @@ final class NoopCredentialStore: CredentialStore { } } -final class MemoryInstanceRepository: InstanceRepository { +final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository { private let lock = NSLock() private var itemsStorage: [Instance] = [] diff --git a/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift index 8ff6ba3..4875600 100644 --- a/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift @@ -30,8 +30,11 @@ final class LogCenterTests: XCTestCase { let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.id, instance.id) XCTAssertEqual(diagnostic.state, .online) - XCTAssertEqual(diagnostic.capabilities.total, 3) - XCTAssertEqual(diagnostic.capabilities.supported, 3) + XCTAssertEqual(diagnostic.featureStatus.map(\.state), [ + .ready, + .ready, + .ready + ]) XCTAssertEqual(diagnostic.connection?.total, 1) XCTAssertEqual(diagnostic.connection?.availabilityPercent, 100) } @@ -54,8 +57,8 @@ final class LogCenterTests: XCTestCase { XCTAssertEqual(snapshot.runtime.first?.message, "通知日志未同步:需要登录") let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.state, .online) - XCTAssertEqual(diagnostic.capabilities.authRequired, 1) - XCTAssertEqual(diagnostic.capabilities.supported, 2) + XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .requiresLogin }.count, 1) + XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 2) XCTAssertEqual(store.runtimeStorage.count, 1) XCTAssertEqual(store.connectionStorage.count, 1) } @@ -76,8 +79,7 @@ final class LogCenterTests: XCTestCase { let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.state, .offline) XCTAssertEqual(diagnostic.healthStatus, "超时") - XCTAssertEqual(diagnostic.capabilities.total, 3) - XCTAssertEqual(diagnostic.capabilities.unsupported, 3) + XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 3) } @MainActor diff --git a/scripts/build-ios-ipa.sh b/scripts/build-ios-ipa.sh index 244dbde..484d214 100755 --- a/scripts/build-ios-ipa.sh +++ b/scripts/build-ios-ipa.sh @@ -20,6 +20,22 @@ cp "$DERIVED_ROOT/Build/Products/Release-iphoneos/$APP_NAME" \ "$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME" cp "Sources/$APP_NAME/Resources/Info.plist" \ "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist" +xcrun actool \ + --compile "$BUILD_ROOT/Payload/$APP_NAME.app" \ + --platform iphoneos \ + --minimum-deployment-target 16.0 \ + --app-icon AppIcon \ + --output-format human-readable-text \ + --output-partial-info-plist "$BUILD_ROOT/AssetCatalog-Info.plist" \ + "$IOS_ROOT/Sources/$APP_NAME/Resources/Assets.xcassets" + +if [[ ! -f "$BUILD_ROOT/Payload/$APP_NAME.app/Assets.car" ]]; then + echo 'Assets.car missing: application icon was not compiled' >&2 + exit 1 +fi + +/usr/libexec/PlistBuddy -c "Merge '$BUILD_ROOT/AssetCatalog-Info.plist'" \ + "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist" plutil -lint "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist" cd "$BUILD_ROOT"