feat(ios): simplify navigation and ship 0.2.0

Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
This commit is contained in:
chick
2026-09-12 17:10:02 +08:00
parent 20632be86a
commit e01a105645
25 changed files with 318 additions and 1393 deletions
+35 -27
View File
@@ -1,37 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#13243A"/>
<stop offset="0.58" stop-color="#14504A"/>
<stop offset="1" stop-color="#0F6B58"/>
<radialGradient id="background" cx="0.22" cy="0.10" r="1.08">
<stop offset="0" stop-color="#303945"/>
<stop offset="0.42" stop-color="#141921"/>
<stop offset="1" stop-color="#050709"/>
</radialGradient>
<linearGradient id="gold" x1="0.10" y1="0.02" x2="0.90" y2="0.98">
<stop offset="0" stop-color="#FFE7AD"/>
<stop offset="0.24" stop-color="#F4C767"/>
<stop offset="0.66" stop-color="#D79F47"/>
<stop offset="1" stop-color="#B67D33"/>
</linearGradient>
<linearGradient id="card" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#DEE7EF"/>
<linearGradient id="goldEdge" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFF8DE" stop-opacity="0.82"/>
<stop offset="0.48" stop-color="#E9BA6D" stop-opacity="0.16"/>
<stop offset="1" stop-color="#7C5525" stop-opacity="0.72"/>
</linearGradient>
<linearGradient id="chip" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#E8B14B"/>
<stop offset="1" stop-color="#D18A28"/>
<linearGradient id="sheen" x1="0.15" y1="0" x2="0.85" y2="0.82">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.38"/>
<stop offset="0.46" stop-color="#FFFFFF" stop-opacity="0.05"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#242A33"/>
<stop offset="1" stop-color="#0C0F13"/>
</linearGradient>
<filter id="tileShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="24" stdDeviation="30" flood-color="#000000" flood-opacity="0.48"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="url(#background)"/>
<g opacity="0.14" stroke="#FFFFFF" stroke-width="26" fill="none" stroke-linecap="round">
<path d="M270 630a345 345 0 0 1 484 0"/>
<path d="M180 528a470 470 0 0 1 664 0"/>
<g filter="url(#tileShadow)">
<rect x="162" y="162" width="700" height="700" rx="210" fill="url(#gold)"/>
</g>
<g transform="translate(292 276)">
<rect width="440" height="472" rx="72" fill="url(#card)"/>
<path d="M440 168h-192a48 48 0 0 0-48 48v256H72a72 72 0 0 1-72-72V72a72 72 0 0 1 72-72h296a72 72 0 0 1 72 72z" fill="#DCE5EE" opacity="0.36"/>
<rect x="104" y="92" width="168" height="148" rx="24" fill="url(#chip)"/>
<path d="M136 132h104M136 178h104M136 224h104" stroke="#7A4F10" stroke-width="13" stroke-linecap="round"/>
<rect x="296" y="92" width="100" height="32" rx="16" fill="#0F6B58" opacity="0.82"/>
<rect x="296" y="146" width="100" height="32" rx="16" fill="#0F6B58" opacity="0.52"/>
<rect x="104" y="300" width="292" height="32" rx="16" fill="#8797A9" opacity="0.68"/>
<rect x="104" y="354" width="192" height="32" rx="16" fill="#8797A9" opacity="0.46"/>
</g>
<g fill="#FFFFFF">
<circle cx="392" cy="836" r="24" opacity="0.68"/>
<circle cx="512" cy="836" r="24"/>
<circle cx="632" cy="836" r="24" opacity="0.42"/>
<rect x="164" y="164" width="696" height="696" rx="208" fill="url(#sheen)"/>
<rect x="164" y="164" width="696" height="696" rx="208" fill="none" stroke="url(#goldEdge)" stroke-width="7"/>
<g fill="url(#bar)">
<rect x="317" y="602" width="108" height="188" rx="54"/>
<rect x="458" y="475" width="108" height="315" rx="54"/>
<rect x="599" y="348" width="108" height="442" rx="54"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+30 -58
View File
@@ -1,73 +1,45 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="canvas" x1="0.04" y1="0" x2="0.96" y2="1">
<stop offset="0" stop-color="#22272F"/>
<stop offset="0.45" stop-color="#0A0C10"/>
<stop offset="1" stop-color="#040507"/>
</linearGradient>
<radialGradient id="glow" cx="0.31" cy="0.16" r="0.78">
<stop offset="0" stop-color="#424A57" stop-opacity="0.70"/>
<stop offset="0.62" stop-color="#161A20" stop-opacity="0.10"/>
<stop offset="1" stop-color="#060709" stop-opacity="0"/>
<radialGradient id="background" cx="0.22" cy="0.10" r="1.08">
<stop offset="0" stop-color="#303945"/>
<stop offset="0.42" stop-color="#141921"/>
<stop offset="1" stop-color="#050709"/>
</radialGradient>
<linearGradient id="gold" x1="0.08" y1="0.02" x2="0.90" y2="1">
<stop offset="0" stop-color="#FBE1AC"/>
<stop offset="0.32" stop-color="#E4B875"/>
<stop offset="0.72" stop-color="#C08E52"/>
<stop offset="1" stop-color="#9F7040"/>
<linearGradient id="gold" x1="0.10" y1="0.02" x2="0.90" y2="0.98">
<stop offset="0" stop-color="#FFE7AD"/>
<stop offset="0.24" stop-color="#F4C767"/>
<stop offset="0.66" stop-color="#D79F47"/>
<stop offset="1" stop-color="#B67D33"/>
</linearGradient>
<linearGradient id="goldEdge" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFF2D1" stop-opacity="0.96"/>
<stop offset="0.52" stop-color="#D3A362" stop-opacity="0.30"/>
<stop offset="1" stop-color="#6E4922" stop-opacity="0.82"/>
<stop offset="0" stop-color="#FFF8DE" stop-opacity="0.82"/>
<stop offset="0.48" stop-color="#E9BA6D" stop-opacity="0.16"/>
<stop offset="1" stop-color="#7C5525" stop-opacity="0.72"/>
</linearGradient>
<linearGradient id="chip" x1="0.08" y1="0" x2="0.92" y2="1">
<stop offset="0" stop-color="#262B33"/>
<stop offset="1" stop-color="#0A0D11"/>
</linearGradient>
<linearGradient id="sheen" x1="0.16" y1="0" x2="0.84" y2="0.70">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.30"/>
<stop offset="0.42" stop-color="#FFFFFF" stop-opacity="0.05"/>
<linearGradient id="sheen" x1="0.15" y1="0" x2="0.85" y2="0.82">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.38"/>
<stop offset="0.46" stop-color="#FFFFFF" stop-opacity="0.05"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#F3CE91"/>
<stop offset="1" stop-color="#A87C42"/>
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#242A33"/>
<stop offset="1" stop-color="#0C0F13"/>
</linearGradient>
<filter id="cardShadow" x="-35%" y="-35%" width="170%" height="170%">
<feDropShadow dx="0" dy="28" stdDeviation="36" flood-color="#000000" flood-opacity="0.55"/>
</filter>
<filter id="plateShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="18" stdDeviation="30" flood-color="#000000" flood-opacity="0.38"/>
<filter id="tileShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="24" stdDeviation="30" flood-color="#000000" flood-opacity="0.48"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="url(#canvas)"/>
<rect width="1024" height="1024" fill="url(#glow)"/>
<g>
<rect x="247" y="284" width="470" height="560" rx="98" fill="#12161A" fill-opacity="0.94" filter="url(#plateShadow)"/>
<rect x="247" y="284" width="470" height="560" rx="98" fill="none" stroke="#DCB26F" stroke-opacity="0.13" stroke-width="3"/>
<rect width="1024" height="1024" fill="url(#background)"/>
<g filter="url(#tileShadow)">
<rect x="162" y="162" width="700" height="700" rx="210" fill="url(#gold)"/>
</g>
<rect x="164" y="164" width="696" height="696" rx="208" fill="url(#sheen)"/>
<rect x="164" y="164" width="696" height="696" rx="208" fill="none" stroke="url(#goldEdge)" stroke-width="7"/>
<g opacity="0.86">
<path d="M743 304 A94 94 0 0 1 831 392" fill="none" stroke="url(#signal)" stroke-opacity="0.36" stroke-width="23" stroke-linecap="round"/>
<path d="M739 239 A181 181 0 0 1 887 387" fill="none" stroke="url(#signal)" stroke-opacity="0.20" stroke-width="23" stroke-linecap="round"/>
<path d="M736 174 A268 268 0 0 1 944 382" fill="none" stroke="url(#signal)" stroke-opacity="0.10" stroke-width="23" stroke-linecap="round"/>
<g fill="url(#bar)">
<rect x="317" y="602" width="108" height="188" rx="54"/>
<rect x="458" y="475" width="108" height="315" rx="54"/>
<rect x="599" y="348" width="108" height="442" rx="54"/>
</g>
<g filter="url(#cardShadow)">
<rect x="311" y="208" width="430" height="584" rx="108" fill="url(#gold)"/>
<rect x="311" y="208" width="430" height="584" rx="108" fill="url(#sheen)"/>
<rect x="313.5" y="210.5" width="425" height="579" rx="105.5" fill="none" stroke="url(#goldEdge)" stroke-width="5"/>
</g>
<g>
<rect x="429" y="421" width="194" height="180" rx="48" fill="url(#chip)"/>
<rect x="431" y="423" width="190" height="176" rx="46" fill="none" stroke="#F0C37E" stroke-opacity="0.24" stroke-width="3"/>
<path d="M458 470h136M458 505h136M458 540h136" stroke="#DCB26F" stroke-opacity="0.72" stroke-width="15" stroke-linecap="round"/>
<circle cx="526" cy="668" r="9" fill="#FFF6E3" fill-opacity="0.58"/>
</g>
<rect x="311" y="208" width="430" height="584" rx="108" fill="none" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,6 +1,6 @@
public enum AppMetadata {
public static let name = "MultiSimAdmin"
public static let bundleIdentifier = "fun.chick.multi-simadmin"
public static let marketingVersion = "0.1.0"
public static let buildVersion = "1"
public static let marketingVersion = "0.2.0"
public static let buildVersion = "2"
}
@@ -38,40 +38,95 @@ struct InstanceDetailView: View {
}
private var header: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle(
"设备详情",
systemImage: "iphone.gen3",
actionTitle: revealsSensitive ? "隐藏敏感值" : "显示敏感值",
actionSystemImage: revealsSensitive ? "eye.slash" : "eye"
) {
revealsSensitive.toggle()
AICard(padding: 18) {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 14) {
Image(systemName: "cpu")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(AITheme.primary)
.frame(width: 46, height: 46)
.background(AITheme.primary.opacity(0.11))
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 5) {
Text(model.instance.name)
.font(.title3.weight(.bold))
.kerning(-0.2)
.foregroundStyle(AITheme.ink)
.lineLimit(2)
Text(model.instance.origin.absoluteString)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.truncationMode(.middle)
}
}
Text(model.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.truncationMode(.middle)
HStack(spacing: 10) {
AITag(revealsSensitive ? "敏感值已显示" : "敏感值已隐藏", tint: AITheme.primary)
if let availableCount {
AITag("\(availableCount)/6 模块", tint: AITheme.success)
}
}
Button {
withAnimation(.easeOut(duration: 0.18)) {
revealsSensitive.toggle()
}
} label: {
Label(
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
systemImage: revealsSensitive ? "eye.slash" : "eye"
)
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact, fixedWidth: 112))
if model.isLoading {
Text("正在读取设备详情…")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
AILoadingCard("正在读取设备详情")
}
}
}
}
private var availableCount: Int? {
guard let snapshot = model.snapshot else { return nil }
return snapshot.sections.filter { !$0.values.isEmpty }.count
}
@ViewBuilder
private func sectionCard(_ section: DeviceSection) -> some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle(section.title, systemImage: "list.bullet.rectangle")
if section.values.isEmpty {
Text("此模块当前不可用或未返回数据。")
.font(.subheadline)
if section.values.isEmpty {
AICard(padding: 14) {
HStack(spacing: 10) {
Image(systemName: "wifi.slash")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.muted)
} else {
ForEach(sortedValues(section.values), id: \.key) { item in
.frame(width: 32, height: 32)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Text("模块未返回数据")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
}
}
} else {
AICard(padding: 18) {
VStack(alignment: .leading, spacing: 16) {
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
Divider()
.overlay(AITheme.line)
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
}
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
}
@@ -79,6 +134,18 @@ struct InstanceDetailView: View {
}
}
private func sectionIcon(_ id: String) -> String {
switch id {
case "device": return "iphone.gen3"
case "sim": return "simcard"
case "network": return "antenna.radiowaves.left.and.right"
case "stats": return "gauge.with.needle"
case "cpu": return "cpu"
case "connectivity": return "dot.radiowaves.left.and.right"
default: return "list.bullet.rectangle"
}
}
private let fieldLabels: [String: String] = [
"model": "型号",
"manufacturer": "制造商",
@@ -5,7 +5,7 @@ public enum RuntimeLogSource: String, Codable, Equatable, Sendable, CaseIterable
public var label: String {
switch self {
case .delivery: return "通知投递"
case .delivery: return "节点运行"
}
}
}
@@ -102,17 +102,7 @@ public struct LogCenterService: Sendable {
))
}
let deliveryEntries = probe.outcome == .failed ? [] : await deliveryLogs(instance)
result.runtime.append(contentsOf: deliveryEntries)
if probe.outcome == .failed {
modules["delivery"] = .failure(UpstreamError.notSupported)
} else if deliveryEntries.contains(where: { $0.code == "UNAUTHORIZED" }) {
modules["delivery"] = .failure(UpstreamError.unauthorized)
} else if deliveryEntries.contains(where: { $0.code == "UPSTREAM" }) {
modules["delivery"] = .failure(UpstreamError.invalidResponse)
} else {
modules["delivery"] = .success(true)
}
modules["status"] = .success(true)
result.diagnostic = diagnostic(
instance,
probe: probe,
@@ -190,41 +180,6 @@ public struct LogCenterService: Sendable {
}
}
private func deliveryLogs(_ instance: Instance) async -> [RuntimeLogEntry] {
do {
let response = try await request(.notificationLogs(limit: 50, offset: 0), instance)
let entries = Self.deliveryEntries(response, instance)
if entries.isEmpty {
throw UpstreamError.invalidResponse
}
return entries
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
return [RuntimeLogEntry(
id: "delivery-\(instance.id.uuidString)-auth",
source: .delivery,
level: .warning,
message: "通知日志未同步:需要登录",
instanceID: instance.id,
instanceName: instance.name,
occurredAt: Date(),
code: "UNAUTHORIZED",
durationMs: nil
)]
} catch {
return [RuntimeLogEntry(
id: "delivery-\(instance.id.uuidString)-failed",
source: .delivery,
level: .warning,
message: "通知日志未同步:接口异常",
instanceID: instance.id,
instanceName: instance.name,
occurredAt: Date(),
code: "UPSTREAM",
durationMs: nil
)]
}
}
private func diagnostic(
_ instance: Instance,
probe: ProbeOutcome,
@@ -234,7 +189,7 @@ public struct LogCenterService: Sendable {
let featureStatus = modules
.sorted { $0.key < $1.key }
.map { key, result -> FeatureStatus in
let name = key == "connection" ? "连接" : key == "device" ? "设备" : "通知"
let name = key == "connection" ? "连接" : key == "status" ? "状态" : "设备"
switch result {
case .success:
return FeatureStatus(name: name, state: .ready)
@@ -270,39 +225,6 @@ public struct LogCenterService: Sendable {
)
}
static func deliveryEntries(
_ response: UpstreamResponse,
_ instance: Instance
) -> [RuntimeLogEntry] {
guard let root = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any],
let data = root["data"] as? [String: Any],
let raw = data["logs"] as? [[String: Any]]
else {
return []
}
return raw.compactMap { item in
guard let id = Self.text(item["id"]),
let status = Self.text(item["status"]),
let eventType = Self.text(item["event_type"])
else {
return nil
}
let level: RuntimeLogLevel = status == "failed" ? .error : .info
return RuntimeLogEntry(
id: "delivery-\(instance.id.uuidString)-\(id)",
source: .delivery,
level: level,
message: Self.text(item["message"]) ?? "\(eventType) · \(status)",
eventType: eventType,
instanceID: instance.id,
instanceName: instance.name,
occurredAt: Self.date(item["created_at"]),
code: status,
durationMs: nil
)
}
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
do {
@@ -8,11 +8,14 @@ struct LogsView: View {
}
@StateObject private var model: LogsViewModel
private let autoRefreshOnAppear: Bool
@State private var tab: Tab = .runtime
@State private var showsPruneDialog = false
init(model: LogsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
let resolvedModel = model ?? DefaultContainer.makeLogsModel(autoRefreshOnAppear: false)
_model = StateObject(wrappedValue: resolvedModel)
self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear
}
var body: some View {
@@ -81,7 +84,9 @@ struct LogsView: View {
await model.refresh()
}
.task {
await model.refresh()
if model.snapshot == nil && autoRefreshOnAppear {
await model.refresh()
}
}
}
@@ -16,15 +16,18 @@ final class LogsViewModel: ObservableObject {
private let repository: any InstanceRepository
private let service: LogCenterService
private let store: any LogStore
let autoRefreshOnAppear: Bool
init(
repository: any InstanceRepository,
service: LogCenterService,
store: any LogStore
store: any LogStore,
autoRefreshOnAppear: Bool = true
) {
self.repository = repository
self.service = service
self.store = store
self.autoRefreshOnAppear = autoRefreshOnAppear
}
var filteredRuntime: [RuntimeLogEntry] {
@@ -1,78 +0,0 @@
import Foundation
public enum NotificationDeviceState: String, Equatable, Sendable, CaseIterable {
case ready
case unauthorized
case unavailable
case failed
}
public struct NotificationDevice: Equatable, Identifiable, Sendable {
public let id: UUID
public let name: String
public let state: NotificationDeviceState
}
public struct NotificationChannelTypeSummary: Equatable, Identifiable, Sendable {
public var id: String { type }
public let type: String
public let total: Int
public let enabled: Int
}
public struct NotificationConfigSummary: Equatable, Sendable {
public let channelCount: Int
public let channelEnabled: Int
public let ruleCount: Int
public let ruleEnabled: Int
public let channelTypes: [NotificationChannelTypeSummary]
}
public struct NotificationLogEntry: Equatable, Identifiable, Sendable {
public let id: String
public let eventType: String
public let status: String
public let ruleName: String?
public let channelName: String?
public let createdAt: String
}
public struct NotificationLogSummary: Equatable, Sendable {
public let total: Int
public let success: Int
public let failed: Int
public let quietHours: Int
public let unmatched: Int
public let noAvailableChannel: Int
public let other: Int
public let entries: [NotificationLogEntry]
}
public struct NotificationQueueEntry: Equatable, Identifiable, Sendable {
public let id: String
public let instanceID: UUID
public let instanceName: String
public let eventType: String
public let status: String
public let ruleName: String?
public let channelName: String?
public let createdAt: String
}
public struct NotificationQueueSummary: Equatable, Sendable {
public let total: Int
public let pending: Int
public let scheduled: Int
public let retrying: Int
public let sending: Int
public let failed: Int
public let items: [NotificationQueueEntry]
}
public struct NotificationOverview: Equatable, Sendable {
public let observedAt: Date
public let devices: [NotificationDevice]
public let config: NotificationConfigSummary
public let logs: NotificationLogSummary
public let queue: NotificationQueueSummary
}
@@ -1,356 +0,0 @@
import Foundation
public enum NotificationServiceError: Error, Equatable {
case invalidInput
case invalidResponse
case unauthorized
}
public struct NotificationService: Sendable {
private let client: any SimAdminClientProtocol
private let sessions: InstanceSessionStore
private let credentials: (any CredentialStore)?
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.client = client
self.sessions = sessions
self.credentials = credentials
}
public func overview(_ instances: [Instance]) async throws -> NotificationOverview {
var devices: [NotificationDevice] = []
var configs: [NotificationConfigSummary] = []
var logs: [NotificationLogSummary] = []
var queues: [NotificationQueueSummary] = []
for instance in instances {
do {
let config = try Self.parseConfig(Self.data(of: try await request(.notificationConfig, instance)))
let logPage = try Self.parseLogs(Self.data(of: try await request(
.notificationLogs(limit: 50, offset: 0),
instance
)))
let queue = try Self.parseQueue(
Self.data(of: try await request(.notificationQueue(limit: 100), instance)),
instance
)
configs.append(config)
logs.append(logPage)
queues.append(queue)
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .ready))
} catch UpstreamError.notSupported {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unavailable))
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized))
} catch NotificationServiceError.unauthorized {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized))
} catch {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .failed))
}
}
return NotificationOverview(
observedAt: Date(),
devices: devices,
config: Self.aggregateConfigs(configs),
logs: Self.aggregateLogs(logs),
queue: Self.aggregateQueues(queues)
)
}
public func retryAll(_ instances: [Instance]) async -> NotificationActionResult {
var result = NotificationActionResult()
for instance in instances {
do {
_ = try await request(.notificationRetryAllQueue, instance)
result.succeeded += 1
} catch UpstreamError.notSupported {
result.skipped += 1
} catch {
result.failed += 1
}
}
return result
}
public func retryQueue(_ instance: Instance, id: String) async throws {
try Self.validateQueueID(id)
_ = try await request(.notificationRetryQueue(id: id), instance)
}
public func deleteQueueItem(_ instance: Instance, id: String) async throws {
try Self.validateQueueID(id)
_ = try await request(.notificationDeleteQueue(id: id), instance)
}
static func validateQueueID(_ id: String) throws {
guard !id.isEmpty, id.count <= 128,
id.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" || $0 == "." })
else {
throw NotificationServiceError.invalidInput
}
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
do {
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
cookie = try await login(instance)
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
}
}
private func login(_ instance: Instance) async throws -> String {
guard let credentials,
let password = try credentials.get(instanceID: instance.id),
!password.isEmpty
else {
throw NotificationServiceError.unauthorized
}
let sessionService = InstanceSessionService(store: sessions, client: client)
let result = try await sessionService.login(instance, password: password)
guard result.authenticated,
let cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
else {
throw NotificationServiceError.unauthorized
}
return cookie
}
static func data(of response: UpstreamResponse) throws -> [String: Any] {
guard let root = try JSONSerialization.jsonObject(with: response.data) as? [String: Any],
let status = root["status"] as? String,
["success", "ok", "OK"].contains(status),
let data = root["data"] as? [String: Any]
else {
throw NotificationServiceError.invalidResponse
}
return data
}
static func parseConfig(_ data: [String: Any]) throws -> NotificationConfigSummary {
var channelCount = 0
var channelEnabled = 0
var typeOrder: [String] = []
var typeTotals: [String: (total: Int, enabled: Int)] = [:]
func record(type: String, enabled: Bool) {
if !typeOrder.contains(type) {
typeOrder.append(type)
}
let current = typeTotals[type] ?? (0, 0)
typeTotals[type] = (current.total + 1, current.enabled + (enabled ? 1 : 0))
}
if let channels = data["channels"] as? [[String: Any]] {
for raw in channels {
guard let type = raw["type"] as? String, let enabled = raw["enabled"] as? Bool else { continue }
channelCount += 1
channelEnabled += enabled ? 1 : 0
record(type: type, enabled: enabled)
}
} else {
for type in ["webhook", "bark", "pushplus", "wecom_app", "wecom_robot", "dingtalk_robot", "dingtalk_app", "feishu_robot", "telegram", "email", "serverchan"] {
guard let value = data[type] as? [String: Any], let enabled = value["enabled"] as? Bool else { continue }
channelCount += 1
channelEnabled += enabled ? 1 : 0
record(type: type, enabled: enabled)
}
}
var ruleCount = 0
var ruleEnabled = 0
if let rules = data["rules"] as? [[String: Any]] {
for raw in rules {
guard let enabled = raw["enabled"] as? Bool else { continue }
ruleCount += 1
ruleEnabled += enabled ? 1 : 0
}
}
let types = typeOrder.map {
NotificationChannelTypeSummary(
type: $0,
total: typeTotals[$0]?.total ?? 0,
enabled: typeTotals[$0]?.enabled ?? 0
)
}
return NotificationConfigSummary(
channelCount: channelCount,
channelEnabled: channelEnabled,
ruleCount: ruleCount,
ruleEnabled: ruleEnabled,
channelTypes: types
)
}
static func parseLogs(_ data: [String: Any]) throws -> NotificationLogSummary {
guard let total = positive(data["total"]), let raw = data["logs"] as? [[String: Any]] else {
throw NotificationServiceError.invalidResponse
}
var entries: [NotificationLogEntry] = []
var counts = (
success: 0, failed: 0, quietHours: 0, unmatched: 0, noAvailableChannel: 0, other: 0
)
for item in raw {
guard let id = text(item["id"], 128),
let eventType = text(item["event_type"], 80),
let status = text(item["status"], 40),
let createdAt = text(item["created_at"], 64)
else {
throw NotificationServiceError.invalidResponse
}
entries.append(NotificationLogEntry(
id: id,
eventType: eventType,
status: status,
ruleName: text(item["rule_name"], 160),
channelName: text(item["channel_name"], 160),
createdAt: createdAt
))
switch status {
case "success": counts.success += 1
case "failed": counts.failed += 1
case "quiet_hours": counts.quietHours += 1
case "unmatched": counts.unmatched += 1
case "no_available_channel": counts.noAvailableChannel += 1
default: counts.other += 1
}
}
return NotificationLogSummary(
total: total,
success: counts.success,
failed: counts.failed,
quietHours: counts.quietHours,
unmatched: counts.unmatched,
noAvailableChannel: counts.noAvailableChannel,
other: counts.other,
entries: entries
)
}
static func parseQueue(_ data: [String: Any], _ instance: Instance) throws -> NotificationQueueSummary {
guard let total = positive(data["total"]), let raw = data["items"] as? [[String: Any]] else {
throw NotificationServiceError.invalidResponse
}
var items: [NotificationQueueEntry] = []
var counts = (pending: 0, scheduled: 0, retrying: 0, sending: 0, failed: 0)
for value in raw {
guard let id = text(value["id"], 128),
let eventType = text(value["event_type"], 80),
let status = text(value["status"], 32),
let createdAt = text(value["created_at"], 64)
else {
throw NotificationServiceError.invalidResponse
}
items.append(NotificationQueueEntry(
id: id,
instanceID: instance.id,
instanceName: instance.name,
eventType: eventType,
status: status,
ruleName: text(value["rule_name"], 160),
channelName: text(value["channel_name"], 160),
createdAt: createdAt
))
switch status {
case "pending": counts.pending += 1
case "scheduled": counts.scheduled += 1
case "retrying": counts.retrying += 1
case "sending": counts.sending += 1
case "failed": counts.failed += 1
default: break
}
}
return NotificationQueueSummary(
total: total,
pending: counts.pending,
scheduled: counts.scheduled,
retrying: counts.retrying,
sending: counts.sending,
failed: counts.failed,
items: items
)
}
static func aggregateConfigs(_ values: [NotificationConfigSummary]) -> NotificationConfigSummary {
NotificationConfigSummary(
channelCount: values.reduce(0) { $0 + $1.channelCount },
channelEnabled: values.reduce(0) { $0 + $1.channelEnabled },
ruleCount: values.reduce(0) { $0 + $1.ruleCount },
ruleEnabled: values.reduce(0) { $0 + $1.ruleEnabled },
channelTypes: mergeChannelTypes(values.map(\.channelTypes))
)
}
static func aggregateLogs(_ values: [NotificationLogSummary]) -> NotificationLogSummary {
NotificationLogSummary(
total: values.reduce(0) { $0 + $1.total },
success: values.reduce(0) { $0 + $1.success },
failed: values.reduce(0) { $0 + $1.failed },
quietHours: values.reduce(0) { $0 + $1.quietHours },
unmatched: values.reduce(0) { $0 + $1.unmatched },
noAvailableChannel: values.reduce(0) { $0 + $1.noAvailableChannel },
other: values.reduce(0) { $0 + $1.other },
entries: Array(values.flatMap(\.entries).prefix(20))
)
}
static func aggregateQueues(_ values: [NotificationQueueSummary]) -> NotificationQueueSummary {
NotificationQueueSummary(
total: values.reduce(0) { $0 + $1.total },
pending: values.reduce(0) { $0 + $1.pending },
scheduled: values.reduce(0) { $0 + $1.scheduled },
retrying: values.reduce(0) { $0 + $1.retrying },
sending: values.reduce(0) { $0 + $1.sending },
failed: values.reduce(0) { $0 + $1.failed },
items: Array(values.flatMap(\.items).prefix(20))
)
}
private static func text(_ value: Any?, _ maximum: Int) -> String? {
guard let value, let text = value as? String,
!text.isEmpty, text.count <= maximum,
!text.unicodeScalars.contains(where: { $0.value < 9 || ($0.value > 13 && $0.value < 32) || $0.value == 127 })
else { return nil }
return text
}
private static func positive(_ value: Any?) -> Int? {
guard let number = value as? Int, number >= 0, number <= 10_000_000 else { return nil }
return number
}
private static func mergeChannelTypes(
_ groups: [[NotificationChannelTypeSummary]]
) -> [NotificationChannelTypeSummary] {
var merged: [NotificationChannelTypeSummary] = []
for group in groups {
for summary in group {
if let index = merged.firstIndex(where: { $0.type == summary.type }) {
let current = merged[index]
merged[index] = NotificationChannelTypeSummary(
type: current.type,
total: current.total + summary.total,
enabled: current.enabled + summary.enabled
)
} else {
merged.append(summary)
}
}
}
return merged
}
}
public struct NotificationActionResult: Equatable, Sendable {
public var succeeded = 0
public var failed = 0
public var skipped = 0
public init() {}
}
@@ -1,335 +0,0 @@
import SwiftUI
struct NotificationsView: View {
@StateObject private var model: NotificationsViewModel
@State private var pendingDeleteQueueID: String?
private static let timestampFormatter = ISO8601DateFormatter()
private static let fractionalTimestampFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if let actionMessage = model.actionMessage {
AIStatusBanner(
actionMessage,
tint: model.actionSucceeded ? AITheme.success : AITheme.danger,
systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle"
)
}
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let overview = model.overview {
stats(overview)
queue(overview)
channels(overview)
logs(overview)
devices(overview)
} else if model.isLoading {
AILoadingCard("正在加载通知中心")
} else {
empty
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
Button {
Task { await model.retryAll() }
} label: {
HStack(spacing: 8) {
if model.isRetryingAll {
ProgressView()
.controlSize(.small)
}
Text(
model.isRetryingAll
? "投递中"
: (model.overview?.queue.total == 0 ? "没有待投递" : "立即投递")
)
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading || model.isRetryingAll || model.overview?.queue.total == 0)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("通知中心")
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteQueueID != nil },
set: { if !$0 { pendingDeleteQueueID = nil } }
),
titleVisibility: .visible
) {
Button("删除队列记录", role: .destructive) {
guard let id = pendingDeleteQueueID else { return }
Task {
if let item = model.overview?.queue.items.first(where: { $0.id == id }) {
await model.deleteQueue(item)
}
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
private var empty: some View {
AICard {
AIEmptyState(
systemImage: "bell",
title: "暂无通知数据",
message: "下拉刷新后从已配置实例同步。"
)
}
}
private func stats(_ overview: NotificationOverview) -> some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
AIStatCard(
title: "通道",
value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "规则",
value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "队列",
value: "\(overview.queue.pending + overview.queue.retrying)",
detail: "等待与重试",
tint: AITheme.warning
)
AIStatCard(
title: "失败",
value: "\(overview.queue.failed + overview.logs.failed)",
detail: "队列与投递",
tint: AITheme.danger
)
}
}
private func devices(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
if overview.devices.isEmpty {
AICard {
AIEmptyState(
systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
}
} else {
ForEach(overview.devices) { device in
AIListCard(
title: device.name,
status: deviceState(device.state),
statusTint: deviceTint(device.state)
)
}
}
}
}
private func channels(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类"
)
if overview.config.channelTypes.isEmpty {
AICard {
AIEmptyState(
systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
}
} else {
ForEach(overview.config.channelTypes) { channel in
AIListCard(
title: channelTypeLabel(channel.type),
status: "\(channel.enabled)/\(channel.total)",
statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted
)
}
}
}
}
private func queue(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
if overview.queue.items.isEmpty {
AICard {
AIEmptyState(
systemImage: "tray.full",
title: "投递队列为空",
message: "新通知触发后会在这里等待投递。"
)
}
} else {
ForEach(overview.queue.items) { item in
AIListCard(
title: eventTypeLabel(item.eventType),
subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))",
status: statusLabel(item.status),
statusTint: statusTint(item.status)
) {
HStack(spacing: 8) {
Button {
Task { await model.retryQueue(item) }
} label: {
Label("重试", systemImage: "arrow.clockwise")
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID != nil)
Button {
pendingDeleteQueueID = item.id
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID != nil)
}
}
}
}
}
}
private func logs(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条")
if overview.logs.entries.isEmpty {
AICard {
AIEmptyState(
systemImage: "clock.arrow.circlepath",
title: "暂无投递日志",
message: "通知送达或失败后会在这里记录。"
)
}
} else {
ForEach(overview.logs.entries) { entry in
AIListCard(
title: eventTypeLabel(entry.eventType),
subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))",
status: statusLabel(entry.status),
statusTint: statusTint(entry.status)
)
}
}
}
}
private func timestampText(_ value: String) -> String {
let date =
Self.fractionalTimestampFormatter.date(from: value)
?? Self.timestampFormatter.date(from: value)
guard let date else { return value }
return date.formatted(
Date.FormatStyle(date: .numeric, time: .shortened)
.locale(Locale(identifier: "zh_CN"))
)
}
private func deviceState(_ state: NotificationDeviceState) -> String {
switch state {
case .ready: return "在线"
case .unauthorized: return "需登录"
case .unavailable: return "离线"
case .failed: return "异常"
}
}
private func deviceTint(_ state: NotificationDeviceState) -> Color {
switch state {
case .ready: return AITheme.success
case .unauthorized: return AITheme.warning
case .unavailable: return AITheme.muted
case .failed: return AITheme.danger
}
}
private func channelTypeLabel(_ type: String) -> String {
switch type {
case "webhook": return "Webhook"
case "bark": return "Bark"
case "pushplus": return "PushPlus"
case "wecom_app": return "企业微信应用"
case "wecom_robot": return "企业微信机器人"
case "dingtalk_robot": return "钉钉机器人"
case "dingtalk_app": return "钉钉应用"
case "feishu_robot": return "飞书机器人"
case "telegram": return "Telegram"
case "email": return "邮件"
case "serverchan": return "Server 酱"
default: return type
}
}
private func eventTypeLabel(_ value: String) -> String {
switch value {
case "sms": return "短信"
case "ddns": return "DDNS"
case "version": return "版本"
case "system": return "系统"
case "device": return "设备"
case "automation": return "自动化"
default: return value
}
}
private func statusLabel(_ value: String) -> String {
switch value {
case "success", "succeeded": return "成功"
case "failed": return "失败"
case "pending": return "等待"
case "sending": return "发送中"
case "retrying": return "重试中"
case "cancelled": return "已取消"
case "unmatched": return "未匹配"
case "no_available_channel": return "无可用通道"
case "quiet_hours": return "免打扰"
default: return value
}
}
private func statusTint(_ value: String) -> Color {
switch value {
case "success", "succeeded": return AITheme.success
case "pending", "sending", "retrying": return AITheme.warning
case "failed": return AITheme.danger
default: return AITheme.muted
}
}
}
@@ -1,84 +0,0 @@
import Foundation
@MainActor
final class NotificationsViewModel: ObservableObject {
@Published private(set) var overview: NotificationOverview?
@Published private(set) var instances: [Instance] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
@Published private(set) var actionMessage: String?
@Published private(set) var actionSucceeded = true
@Published private(set) var busyQueueID: String?
@Published private(set) var isRetryingAll = false
private let repository: any InstanceRepository
private let service: NotificationService
init(repository: any InstanceRepository, service: NotificationService) {
self.repository = repository
self.service = service
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
overview = try await service.overview(loaded)
} catch {
errorMessage = "无法加载通知中心。"
}
isLoading = false
}
func retryAll() async {
guard !isRetryingAll && !isLoading else { return }
isRetryingAll = true
let result = await service.retryAll(instances)
actionSucceeded = result.succeeded > 0
if result.succeeded > 0 {
actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。"
} else if result.skipped > 0 {
actionMessage = "没有可重试的队列接口。"
} else {
actionMessage = "队列重试失败。"
}
await refresh()
isRetryingAll = false
}
func retryQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.retryQueue(instance, id: id)
}
}
func deleteQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.deleteQueueItem(instance, id: id)
}
await refresh()
}
private func queueAction(
_ item: NotificationQueueEntry,
action: (Instance, String) async throws -> Void
) async {
guard let instance = instances.first(where: { $0.id == item.instanceID }) else {
errorMessage = "找不到队列所属实例。"
return
}
busyQueueID = item.id
do {
try await action(instance, item.id)
actionMessage = "队列操作已完成。"
actionSucceeded = true
errorMessage = nil
} catch {
errorMessage = "队列操作失败。"
actionSucceeded = false
}
busyQueueID = nil
}
}
@@ -2,6 +2,7 @@ import SwiftUI
struct SettingsView: View {
@StateObject private var model: SettingsViewModel
@ObservedObject private var themeStore = ThemeStore.shared
@State private var showsClearDialog = false
init(model: SettingsViewModel? = nil) {
@@ -20,6 +21,7 @@ struct SettingsView: View {
}
runtimeCard
themeCard
storageCard
securityCard
dangerZoneCard
@@ -75,24 +77,54 @@ struct SettingsView: View {
}
}
private var storageCard: some View {
private var themeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("本地数据", systemImage: "externaldrive")
AIKeyValueRow("存储位置", value: "仅保存在本机应用沙盒")
AIKeyValueRow("当前占用", value: model.storageLabel)
AISectionTitle("外观", systemImage: "circle.lefthalf.filled", detail: themeStore.mode.label)
AISegmentedControl(
"主题模式",
selection: Binding(
get: { themeStore.mode },
set: { themeStore.set($0) }
),
options: ThemeMode.allCases.map { (value: $0, title: $0.label) }
)
}
}
}
private var storageCard: some View {
AICard {
NavigationLink {
LogsView()
} label: {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
AISectionTitle("本地数据", systemImage: "externaldrive")
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
}
AIKeyValueRow("数据位置", value: "仅保存在本机设备")
AIKeyValueRow("当前占用", value: model.storageLabel)
}
}
.buttonStyle(.plain)
.accessibilityHint("进入日志与诊断")
}
}
private var securityCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("隐私与安全", systemImage: "lock.shield")
AIKeyValueRow("实例密码", value: "保存在本机应用沙盒")
AIKeyValueRow("登录会话", value: "仅保存在内存")
AISectionTitle("隐私与数据", systemImage: "lock.shield")
AIKeyValueRow("节点信息", value: "仅保存在本机设备")
AIKeyValueRow("实例密码", value: "保存在本地数据文件中")
AIKeyValueRow("登录会话", value: "应用关闭后自动清除")
AIStatusBanner(
"实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。",
"节点地址、实例密码和运行数据都保存在本机。应用只连接你添加的节点,登录会话关闭应用后即被清除。",
tint: AITheme.primary,
systemImage: "shield.checkered"
)
@@ -2,16 +2,13 @@ import SwiftUI
@main
struct MultiSimAdminApp: App {
@StateObject private var themeStore = ThemeStore.shared
@State private var tab: AppTab
init() {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("--tab-messages") {
_tab = State(initialValue: .messages)
} else if arguments.contains("--tab-notifications") {
_tab = State(initialValue: .notifications)
} else if arguments.contains("--tab-logs") {
_tab = State(initialValue: .logs)
} else if arguments.contains("--tab-settings") {
_tab = State(initialValue: .settings)
} else {
@@ -27,16 +24,13 @@ struct MultiSimAdminApp: App {
NavigationStack { FleetView().aiInlineTitle() }
case .messages:
NavigationStack { MessagesView().aiInlineTitle() }
case .notifications:
NavigationStack { NotificationsView().aiInlineTitle() }
case .logs:
NavigationStack { LogsView().aiInlineTitle() }
case .settings:
NavigationStack { SettingsView().aiInlineTitle() }
}
AITabBar(selection: $tab)
}
.tint(AITheme.primary)
.preferredColorScheme(themeStore.mode.colorScheme)
#if os(iOS)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
@@ -60,8 +54,6 @@ struct MultiSimAdminApp: App {
enum AppTab: Hashable {
case fleet
case messages
case notifications
case logs
case settings
}
@@ -72,8 +64,6 @@ struct AITabBar: View {
HStack(spacing: 0) {
tab(.fleet, "节点", "server.rack")
tab(.messages, "消息", "message")
tab(.notifications, "通知", "bell")
tab(.logs, "日志", "doc.text")
tab(.settings, "设置", "gearshape")
}
.frame(height: 62)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 514 KiB

@@ -9,8 +9,8 @@
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>MultiSimAdmin</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleShortVersionString</key><string>0.2.0</string>
<key>CFBundleVersion</key><string>2</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
@@ -36,19 +36,7 @@ enum DefaultContainer {
}
@MainActor
static func makeNotificationsModel() -> NotificationsViewModel {
NotificationsViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
service: NotificationService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: credentials
)
)
}
@MainActor
static func makeLogsModel() -> LogsViewModel {
static func makeLogsModel(autoRefreshOnAppear: Bool = true) -> LogsViewModel {
let store = logStore
return LogsViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
@@ -58,7 +46,8 @@ enum DefaultContainer {
credentials: credentials,
store: store
),
store: store
store: store,
autoRefreshOnAppear: autoRefreshOnAppear
)
}
@@ -0,0 +1,56 @@
import Foundation
import SwiftUI
public enum ThemeMode: String, CaseIterable, Identifiable, Codable, Sendable {
case system
case light
case dark
public var id: String { rawValue }
public var label: String {
switch self {
case .system: return "跟随系统"
case .light: return "浅色"
case .dark: return "深色"
}
}
public var systemImage: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max"
case .dark: return "moon.stars"
}
}
}
@MainActor
final class ThemeStore: ObservableObject {
static let shared = ThemeStore()
@Published private(set) var mode: ThemeMode
private let defaults: UserDefaults
private static let storageKey = "aiThemeMode"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
mode = ThemeMode(rawValue: defaults.string(forKey: Self.storageKey) ?? "") ?? .system
}
func set(_ newMode: ThemeMode) {
guard newMode != mode else { return }
mode = newMode
defaults.set(newMode.rawValue, forKey: Self.storageKey)
}
}
extension ThemeMode {
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
@@ -12,24 +12,16 @@ public enum UpstreamRoute: Hashable, Sendable {
case smsList(limit: Int, offset: Int, direction: String?)
case smsSend(phoneNumber: String, content: String)
case smsBatchDelete(ids: [Int])
case notificationConfig
case notificationLogs(limit: Int, offset: Int)
case notificationQueue(limit: Int)
case notificationRetryAllQueue
case notificationRetryQueue(id: String)
case notificationDeleteQueue(id: String)
case authLogin(password: String)
case authLogout
public var method: String {
switch self {
case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu,
.connectivity, .smsList, .notificationConfig, .notificationLogs, .notificationQueue:
.connectivity, .smsList:
return "GET"
case .smsSend, .smsBatchDelete, .notificationRetryAllQueue, .notificationRetryQueue, .authLogin, .authLogout:
case .smsSend, .smsBatchDelete, .authLogin, .authLogout:
return "POST"
case .notificationDeleteQueue:
return "DELETE"
}
}
@@ -51,17 +43,6 @@ public enum UpstreamRoute: Hashable, Sendable {
return "/api/sms/list?\(items.joined(separator: "&"))"
case .smsSend: return "/api/sms/send"
case .smsBatchDelete: return "/api/sms/batch-delete"
case .notificationConfig: return "/api/notifications/config"
case .notificationLogs(let limit, let offset):
return "/api/notifications/logs?limit=\(limit)&offset=\(offset)"
case .notificationQueue(let limit):
return "/api/notifications/queue?limit=\(limit)"
case .notificationRetryAllQueue:
return "/api/notifications/queue/retry-all"
case .notificationRetryQueue(let id):
return "/api/notifications/queue/\(id)/retry"
case .notificationDeleteQueue(let id):
return "/api/notifications/queue/\(id)"
case .authLogin: return "/api/auth/login"
case .authLogout: return "/api/auth/logout"
}
@@ -6,7 +6,7 @@ final class AppMetadataTests: XCTestCase {
func testMetadataUsesIPAFoundationContract() {
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
XCTAssertEqual(AppMetadata.marketingVersion, "0.1.0")
XCTAssertEqual(AppMetadata.buildVersion, "1")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.0")
XCTAssertEqual(AppMetadata.buildVersion, "2")
}
}
@@ -14,13 +14,9 @@ final class LogCenterTests: XCTestCase {
let snapshot = try await service.refresh([instance])
XCTAssertEqual(snapshot.runtime.count, 2)
XCTAssertEqual(snapshot.runtime.first?.id, "delivery-\(instance.id.uuidString)-log-2")
XCTAssertEqual(snapshot.runtime.first?.level, .error)
XCTAssertEqual(snapshot.runtime.first?.instanceName, instance.name)
XCTAssertEqual(snapshot.runtime.first?.source, .delivery)
XCTAssertEqual(snapshot.counts[.delivery], 2)
XCTAssertEqual(store.runtimeStorage.count, 2)
XCTAssertEqual(snapshot.runtime.count, 0)
XCTAssertEqual(snapshot.counts[.delivery], 0)
XCTAssertEqual(store.runtimeStorage.count, 0)
XCTAssertEqual(snapshot.connections.count, 1)
XCTAssertEqual(snapshot.connections.first?.outcome, .success)
@@ -43,7 +39,6 @@ final class LogCenterTests: XCTestCase {
let store = MemoryLogStore()
let service = LogCenterService(
client: LogCenterTestClient(
failingRoutes: [.notificationLogs(limit: 50, offset: 0)]
),
sessions: InstanceSessionStore(),
store: store
@@ -52,14 +47,11 @@ final class LogCenterTests: XCTestCase {
let snapshot = try await service.refresh([instance])
XCTAssertEqual(snapshot.runtime.count, 1)
XCTAssertEqual(snapshot.runtime.first?.level, .warning)
XCTAssertEqual(snapshot.runtime.first?.message, "通知日志未同步:需要登录")
XCTAssertEqual(snapshot.runtime.count, 0)
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .online)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .requiresLogin }.count, 1)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 2)
XCTAssertEqual(store.runtimeStorage.count, 1)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 3)
XCTAssertEqual(store.runtimeStorage.count, 0)
XCTAssertEqual(store.connectionStorage.count, 1)
}
@@ -79,7 +71,7 @@ final class LogCenterTests: XCTestCase {
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .offline)
XCTAssertEqual(diagnostic.healthStatus, "超时")
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 3)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 2)
}
@MainActor
@@ -105,7 +97,7 @@ final class LogCenterTests: XCTestCase {
await model.refresh()
XCTAssertNotNil(model.snapshot)
XCTAssertEqual(model.filteredRuntime.count, 2)
XCTAssertEqual(model.filteredRuntime.count, 0)
XCTAssertEqual(model.filteredDiagnostics.count, 1)
XCTAssertEqual(model.offlineCount, 0)
@@ -113,8 +105,8 @@ final class LogCenterTests: XCTestCase {
model.searchText = "offline"
model.selectedLevel = .error
XCTAssertEqual(model.filteredRuntime.count, 1)
XCTAssertEqual(model.filteredRuntime.first?.message, "实例进入离线状态")
XCTAssertEqual(model.filteredRuntime.count, 0)
XCTAssertNil(model.filteredRuntime.first)
let removed = await model.pruneConnections(olderThanDays: 7)
XCTAssertEqual(removed, 0)
@@ -203,17 +195,6 @@ private struct LogCenterTestClient: SimAdminClientProtocol {
)
case .connectivity:
return Self.response(#"{"status":"ok","data":{"connected":true}}"#)
case .notificationLogs:
return Self.response(
"""
{"status":"ok","data":{"total":2,"logs":[
{"id":"log-1","event_type":"device_offline","status":"success",
"message":"实例恢复在线","created_at":"2026-09-08T10:01:00Z"},
{"id":"log-2","event_type":"device_offline","status":"failed",
"message":"实例进入离线状态","created_at":"2026-09-08T10:02:00Z"}
]}}
"""
)
default:
return UpstreamResponse(status: 404, data: Data("{}".utf8))
}
@@ -1,163 +0,0 @@
import XCTest
@testable import MultiSimAdmin
final class NotificationServiceTests: XCTestCase {
func testAggregatesInstanceConfigLogsAndQueue() async throws {
let client = NotificationTestClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let instance = Self.instance()
let overview = try await service.overview([instance])
XCTAssertEqual(client.routes, [
.notificationConfig,
.notificationLogs(limit: 50, offset: 0),
.notificationQueue(limit: 100)
])
XCTAssertEqual(overview.config.channelCount, 2)
XCTAssertEqual(overview.config.channelEnabled, 1)
XCTAssertEqual(overview.config.ruleCount, 1)
XCTAssertEqual(overview.config.ruleEnabled, 1)
XCTAssertEqual(overview.config.channelTypes.first?.type, "webhook")
XCTAssertEqual(overview.logs.total, 3)
XCTAssertEqual(overview.logs.success, 2)
XCTAssertEqual(overview.logs.failed, 1)
XCTAssertEqual(overview.queue.total, 2)
XCTAssertEqual(overview.queue.pending, 1)
XCTAssertEqual(overview.queue.failed, 1)
XCTAssertEqual(overview.queue.items.first?.id, "q-1")
XCTAssertEqual(overview.queue.items.first?.instanceID, instance.id)
XCTAssertEqual(overview.devices.first?.state, .ready)
}
func testQueueActionsValidateIDsAndUseUpstreamRoutes() async throws {
let client = NotificationTestClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let instance = Self.instance()
try await service.retryQueue(instance, id: "q_1-a.2")
try await service.deleteQueueItem(instance, id: "q_1-a.2")
XCTAssertEqual(client.routes.suffix(2), [
.notificationRetryQueue(id: "q_1-a.2"),
.notificationDeleteQueue(id: "q_1-a.2")
])
do {
try await service.retryQueue(instance, id: "bad id")
XCTFail("Invalid queue ID should fail.")
} catch {
XCTAssertEqual(error as? NotificationServiceError, .invalidInput)
}
}
func testReturnsStatefulOverviewWhenEveryInstanceIsUnauthorized() async throws {
let service = NotificationService(
client: UnauthorizedNotificationClient(),
sessions: InstanceSessionStore()
)
let instance = Self.instance()
let overview = try await service.overview([instance])
XCTAssertEqual(overview.devices, [NotificationDevice(
id: instance.id,
name: instance.name,
state: .unauthorized
)])
XCTAssertEqual(overview.config.channelCount, 0)
XCTAssertEqual(overview.logs.total, 0)
XCTAssertEqual(overview.queue.total, 0)
}
func testAggregatesChannelTypeSummariesInFirstSeenOrder() {
let first = NotificationConfigSummary(
channelCount: 2,
channelEnabled: 1,
ruleCount: 0,
ruleEnabled: 0,
channelTypes: [
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1),
NotificationChannelTypeSummary(type: "bark", total: 1, enabled: 0)
]
)
let second = NotificationConfigSummary(
channelCount: 2,
channelEnabled: 2,
ruleCount: 0,
ruleEnabled: 0,
channelTypes: [
NotificationChannelTypeSummary(type: "telegram", total: 1, enabled: 1),
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1)
]
)
let result = NotificationService.aggregateConfigs([first, second])
XCTAssertEqual(result.channelTypes.map(\.type), ["webhook", "bark", "telegram"])
let webhook = result.channelTypes.first { $0.type == "webhook" }
XCTAssertEqual(webhook, NotificationChannelTypeSummary(type: "webhook", total: 2, enabled: 2))
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
}
private struct UnauthorizedNotificationClient: SimAdminClientProtocol {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
throw UpstreamError.unauthorized
}
}
private final class NotificationTestClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
switch route {
case .notificationConfig:
let json = """
{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true},
{"type":"bark","enabled":false}],"rules":[{"enabled":true}]}}
"""
return Self.response(json)
case .notificationLogs(let limit, let offset):
XCTAssertEqual(limit, 50)
XCTAssertEqual(offset, 0)
let json = """
{"status":"ok","data":{"total":3,"logs":[
{"id":"l-1","event_type":"device_offline","status":"success","created_at":"2026-09-08T10:00:00Z"},
{"id":"l-2","event_type":"low_signal","status":"success","created_at":"2026-09-08T10:01:00Z"},
{"id":"l-3","event_type":"device_offline","status":"failed","created_at":"2026-09-08T10:02:00Z"}
]}}
"""
return Self.response(json)
case .notificationQueue(let limit):
XCTAssertEqual(limit, 100)
let json = """
{"status":"ok","data":{"total":2,"items":[
{"id":"q-1","event_type":"device_offline","status":"pending","created_at":"2026-09-08T10:00:00Z"},
{"id":"q-2","event_type":"low_signal","status":"failed","created_at":"2026-09-08T10:01:00Z"}
]}}
"""
return Self.response(json)
case .notificationRetryQueue, .notificationDeleteQueue:
return Self.response(#"{"status":"ok"}"#)
default:
return UpstreamResponse(status: 404, data: Data("{}".utf8))
}
}
private static func response(_ json: String) -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data(json.utf8))
}
}
@@ -1,91 +0,0 @@
import XCTest
@testable import MultiSimAdmin
@MainActor
final class NotificationsViewModelTests: XCTestCase {
func testRefreshLoadsInstancesAndAggregatedOverview() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
XCTAssertEqual(model.instances.count, 1)
XCTAssertEqual(model.overview?.devices.first?.state, .ready)
XCTAssertEqual(model.overview?.queue.items.first?.id, "queue-1")
XCTAssertNil(model.errorMessage)
}
func testRetryQueueTargetsOwningInstance() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
let item = try XCTUnwrap(model.overview?.queue.items.first)
await model.retryQueue(item)
XCTAssertEqual(client.routes.last, .notificationRetryQueue(id: "queue-1"))
}
func testRetryAllTargetsEveryInstance() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "First",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
_ = try await repository.create(InstanceInput(
name: "Second",
origin: URL(string: "http://192.168.1.21:3000")!,
tags: [],
password: nil
))
await model.refresh()
await model.retryAll()
XCTAssertEqual(client.routes.filter { $0 == .notificationRetryAllQueue }.count, 2)
}
}
private final class NotificationViewModelClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
switch route {
case .notificationConfig:
return Self.response(#"{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}]}}"#)
case .notificationLogs:
return Self.response(#"{"status":"ok","data":{"total":1,"logs":[{"id":"log-1","event_type":"system","status":"success","created_at":"2026-09-08T10:00:00Z"}]}}"#)
case .notificationQueue:
return Self.response(#"{"status":"ok","data":{"total":1,"items":[{"id":"queue-1","event_type":"system","status":"failed","created_at":"2026-09-08T10:00:00Z"}]}}"#)
default:
return Self.response(#"{"status":"ok"}"#)
}
}
private static func response(_ json: String) -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data(json.utf8))
}
}
@@ -11,8 +11,8 @@ final class SettingsViewModelTests: XCTestCase {
await model.refresh()
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
XCTAssertEqual(model.runtime?.marketingVersion, "0.1.0")
XCTAssertEqual(model.runtime?.buildVersion, "1")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.0")
XCTAssertEqual(model.runtime?.buildVersion, "2")
XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false)
@@ -0,0 +1,26 @@
import XCTest
@testable import MultiSimAdmin
final class ThemeModeTests: XCTestCase {
@MainActor
func testLoadsPersistedThemeMode() throws {
let defaults = try XCTUnwrap(UserDefaults(suiteName: "ThemeModeTests-\(UUID().uuidString)"))
defaults.set(ThemeMode.dark.rawValue, forKey: "aiThemeMode")
XCTAssertEqual(ThemeStore(defaults: defaults).mode, .dark)
}
@MainActor
func testSelectionPersistsThemeMode() throws {
let suiteName = "ThemeModeTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
let store = ThemeStore(defaults: defaults)
store.set(.light)
store.set(.system)
XCTAssertEqual(ThemeMode(rawValue: defaults.string(forKey: "aiThemeMode") ?? ""), .system)
XCTAssertEqual(store.mode, .system)
}
}