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"