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:
+35
-27
@@ -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
@@ -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)
|
||||
|
||||
BIN
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user