feat(ios): unify list cards and loading states

This commit is contained in:
chick
2026-09-10 01:35:12 +08:00
parent 995206ac23
commit c841748971
32 changed files with 1297 additions and 607 deletions
+37
View File
@@ -0,0 +1,37 @@
<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"/>
</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>
<linearGradient id="chip" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#E8B14B"/>
<stop offset="1" stop-color="#D18A28"/>
</linearGradient>
</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>
<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"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+4 -1
View File
@@ -11,7 +11,10 @@ let package = Package(
targets: [
.executableTarget(
name: "MultiSimAdmin",
exclude: ["Resources/Info.plist"]
exclude: ["Resources/Info.plist"],
resources: [
.copy("Resources/Assets.xcassets")
]
),
.testTarget(
name: "MultiSimAdminTests",
@@ -15,11 +15,7 @@ struct InstanceDetailView: View {
LazyVStack(spacing: 12) {
header
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
}
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
@@ -31,17 +27,6 @@ struct InstanceDetailView: View {
}
}
.navigationTitle(model.instance.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
revealsSensitive.toggle()
} label: {
Label(revealsSensitive ? "隐藏敏感值" : "显示敏感值",
systemImage: revealsSensitive ? "eye.slash" : "eye")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
}
}
.refreshable {
await model.refresh()
}
@@ -52,12 +37,28 @@ struct InstanceDetailView: View {
private var header: some View {
AICard {
VStack(alignment: .leading, spacing: 8) {
Text("设备详情")
.font(.headline)
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("设备详情", systemImage: "iphone.gen3")
Button {
revealsSensitive.toggle()
} label: {
Label(
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
systemImage: revealsSensitive ? "eye.slash" : "eye"
)
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Text(model.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.truncationMode(.middle)
if model.isLoading {
Text("正在读取设备能力…")
.font(.subheadline)
@@ -70,24 +71,14 @@ struct InstanceDetailView: View {
private func sectionCard(_ section: DeviceSection) -> some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
Text(section.title)
.font(.headline)
AISectionTitle(section.title, systemImage: "list.bullet.rectangle")
if section.values.isEmpty {
Text("此模块当前不可用或未返回数据。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(section.values.sorted(by: { $0.key < $1.key }), id: \.key) { item in
HStack(alignment: .top, spacing: 10) {
Text(fieldLabel(item.key))
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
Text(display(item.key, item.value))
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.multilineTextAlignment(.trailing)
}
ForEach(sortedValues(section.values), id: \.key) { item in
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
}
}
@@ -110,22 +101,148 @@ struct InstanceDetailView: View {
"registration_status": "注册状态",
"technology_preference": "网络制式",
"signal_strength": "信号强度",
"brand": "品牌",
"meid": "MEID",
"phone_number": "号码",
"firmware_version": "固件版本",
"os_version": "系统版本",
"baseband_version": "基带版本",
"uptime": "运行时间",
"uptime_seconds": "运行时间",
"slots": "卡槽数",
"active_slots": "活跃数",
"active": "已激活",
"enabled": "已启用",
"operator": "运营商",
"carrier": "运营商",
"network_type": "接入制式",
"technology": "技术",
"radio_mode": "网络模式",
"signal": "信号强度",
"signal_dbm": "信号强度(dBm)",
"signal_percent": "信号质量",
"rsrp": "RSRP",
"rsrq": "RSRQ",
"sinr": "SINR",
"mcc": "MCC",
"mnc": "MNC",
"registration": "注册状态",
"registration_state": "注册状态",
"roaming": "漫游",
"airplane_mode": "飞行模式",
"ipv4": "IPv4 地址",
"ipv6": "IPv6 地址",
"ip_address": "IP 地址",
"gateway": "网关",
"dns": "DNS",
"download": "下行速率",
"upload": "上行速率",
"download_bytes": "下行流量",
"upload_bytes": "上行流量",
"messages_today": "今日消息数",
"calls": "通话数",
"usage": "使用率",
"temperature": "温度",
"max_temperature_c": "最高温度",
"cpu_percent": "CPU 使用率",
"cpu_load": "CPU 负载",
"memory_percent": "内存使用率",
"memory_total_mb": "内存总量",
"memory_available_mb": "可用内存",
"battery_level": "电量",
"battery_percent": "电量",
"latency": "延迟",
"connected": "连接状态",
"interface": "网络接口",
"ssid": "无线网络",
"apn": "APN",
"core_count": "核心数",
"model_name": "CPU 型号",
"state": "连接状态",
"latency_ms": "延迟(ms)"
]
private let valueOrder: [String] = [
"model", "manufacturer", "brand", "imei", "meid", "revision", "firmware_version",
"online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi",
"phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier",
"registration_status", "registration_state", "technology_preference", "network_type",
"radio_mode", "signal_strength", "signal_dbm", "signal_percent", "rsrp", "rsrq", "sinr",
"state", "connected", "latency", "latency_ms", "uptime", "uptime_seconds", "cpu_percent",
"cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage",
"temperature", "max_temperature_c", "battery_level", "battery_percent", "download",
"upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway",
"dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls"
]
private let durationKeys: Set<String> = ["uptime", "uptime_seconds"]
private let percentKeys: Set<String> = ["usage", "cpu_percent", "memory_percent"]
private let byteRateKeys: Set<String> = ["download", "upload"]
private let byteCountKeys: Set<String> = ["download_bytes", "upload_bytes"]
private let sensitiveKeys: Set<String> = ["imei", "iccid", "imsi", "phone_numbers", "sms_center"]
private func sortedValues(_ values: [String: String]) -> [(key: String, value: String)] {
values
.map { (key: $0.key, value: $0.value) }
.sorted { lhs, rhs in
let lhsRank = valueOrder.firstIndex(of: lhs.key) ?? Int.max
let rhsRank = valueOrder.firstIndex(of: rhs.key) ?? Int.max
if lhsRank != rhsRank { return lhsRank < rhsRank }
return lhs.key < rhs.key
}
}
private func fieldLabel(_ key: String) -> String {
fieldLabels[key] ?? key.replacingOccurrences(of: "_", with: " ")
if let exact = fieldLabels[key] { return exact }
let parts = key.split(separator: "_").map(String.init)
for count in [2, 1] where parts.count >= count {
let suffix = parts.suffix(count).joined(separator: "_")
if let label = fieldLabels[suffix] { return label }
}
return parts.joined(separator: " ")
}
private func display(_ key: String, _ value: String) -> String {
guard sensitiveKeys.contains(key), !revealsSensitive else { return value }
guard let first = value.first, value.count > 6 else { return String(repeating: "•", count: min(value.count, 8)) }
if matches(key, durationKeys), let seconds = Double(value), seconds >= 0 {
return Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated))
}
if matches(key, percentKeys), let number = Double(value), number >= 0 {
return "\(number.formatted(.number.precision(.fractionLength(0...1))))%"
}
if matches(key, byteRateKeys), let bytesPerSecond = Double(value), bytesPerSecond >= 0 {
return ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s"
}
if matches(key, byteCountKeys), let bytes = Double(value), bytes >= 0 {
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .decimal)
}
if matches(key, ["memory_total_mb"]), let megabytes = Double(value) {
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
}
if matches(key, ["memory_available_mb"]), let megabytes = Double(value) {
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
}
if key.hasSuffix("temperature"), let temperature = Double(value) {
return "\(temperature.formatted(.number.precision(.fractionLength(0...1))))°C"
}
if sensitiveKeys.contains(key), !revealsSensitive {
return masked(value)
}
return value
}
private func masked(_ value: String) -> String {
guard let first = value.first, value.count > 6 else {
return String(repeating: "•", count: min(value.count, 8))
}
let tail = value.suffix(4)
return "\(first) •••• \(tail)"
}
private func matches(_ key: String, _ keys: Set<String>) -> Bool {
let parts = key.split(separator: "_").map(String.init)
let suffixes = [key] + (1...2).compactMap { count in
parts.count >= count ? parts.suffix(count).joined(separator: "_") : nil
}
return suffixes.contains { keys.contains($0) }
}
}
@@ -14,23 +14,22 @@ struct FleetView: View {
ScrollView {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if model.selectionMode {
selectionBar
}
if model.isLoading && model.rows.isEmpty {
AILoadingCard("正在同步实例")
}
if model.rows.isEmpty && !model.isLoading {
AICard {
VStack(spacing: 10) {
Image(systemName: "server.rack")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无实例")
.font(.headline)
Text("添加 SimAdmin 实例后即可开始管理。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
AIEmptyState(
systemImage: "server.rack",
title: "暂无实例",
message: "添加 SimAdmin 实例后即可开始管理。"
)
}
}
ForEach(model.rows) { row in
@@ -50,22 +49,23 @@ struct FleetView: View {
}
}
.navigationTitle("节点总览")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
model.selectionMode.toggle()
model.selectedIDs.removeAll()
} label: {
Label(model.selectionMode ? "完成" : "批量选择", systemImage: model.selectionMode ? "xmark" : "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
.aiBottomActionBar {
if !model.selectionMode {
HStack(spacing: 10) {
Button {
model.selectionMode = true
} label: {
Label("批量选择", systemImage: "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
}
}
.confirmationDialog(
@@ -110,7 +110,13 @@ struct FleetView: View {
systemImage: "checkmark.circle"
)
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Spacer()
@@ -123,7 +129,13 @@ struct FleetView: View {
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
.disabled(model.selectedIDs.isEmpty)
}
}
@@ -143,6 +155,8 @@ struct FleetView: View {
}
.buttonStyle(.plain)
.frame(width: 32, height: 32)
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
@@ -155,6 +169,8 @@ struct FleetView: View {
Text(row.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
if !row.instance.tags.isEmpty {
HStack(spacing: 6) {
ForEach(row.instance.tags, id: \.self) { tag in
@@ -25,32 +25,31 @@ struct InstanceFormView: View {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
field("实例密码", text: $password, placeholder: "可选,保存到钥匙串", secure: true)
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
}
}
if let validationMessage {
AICard {
Label(validationMessage, systemImage: "exclamationmark.triangle")
.font(.subheadline)
.foregroundStyle(AITheme.danger)
}
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
}
}
.navigationTitle("添加实例")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
save()
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(!canSave)
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .regular,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Button("保存实例") { save() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave)
}
}
}
@@ -73,6 +72,11 @@ struct InstanceFormView: View {
} else {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
}
}
}
@@ -154,7 +154,7 @@ public struct InstanceDiagnostic: Equatable, Identifiable, Sendable {
public let version: String?
public let platform: String?
public let observedAt: Date
public let capabilities: LogCapabilitySummary
public let featureStatus: [FeatureStatus]
public let connection: ConnectionSummary?
public init(
@@ -167,7 +167,7 @@ public struct InstanceDiagnostic: Equatable, Identifiable, Sendable {
version: String?,
platform: String?,
observedAt: Date,
capabilities: LogCapabilitySummary,
featureStatus: [FeatureStatus],
connection: ConnectionSummary?
) {
self.id = id
@@ -179,11 +179,28 @@ public struct InstanceDiagnostic: Equatable, Identifiable, Sendable {
self.version = version
self.platform = platform
self.observedAt = observedAt
self.capabilities = capabilities
self.featureStatus = featureStatus
self.connection = connection
}
}
public struct FeatureStatus: Equatable, Identifiable, Sendable {
public enum State: Equatable, Sendable {
case ready
case requiresLogin
case unavailable
}
public var id: String { name }
public let name: String
public let state: State
public init(name: String, state: State) {
self.name = name
self.state = state
}
}
public struct LogSnapshot: Equatable, Sendable {
public let observedAt: Date
public let runtime: [RuntimeLogEntry]
@@ -231,32 +231,19 @@ public struct LogCenterService: Sendable {
modules: [String: Result<Bool, Error>],
connections: [ConnectionLogEntry]
) -> InstanceDiagnostic {
let capabilities = LogCapabilitySummary(
total: modules.count,
supported: modules.values.filter {
if case .success = $0 { return true }
return false
}.count,
unsupported: modules.values.filter {
if case .failure(UpstreamError.notSupported) = $0 { return true }
return false
}.count,
authRequired: modules.values.filter {
if case .failure(UpstreamError.unauthorized) = $0 { return true }
if case .failure(UpstreamError.forbidden) = $0 { return true }
return false
}.count,
degraded: modules.values.filter {
if case .failure = $0 {
if case .failure(UpstreamError.notSupported) = $0 { return false }
if case .failure(UpstreamError.unauthorized) = $0 { return false }
if case .failure(UpstreamError.forbidden) = $0 { return false }
return true
let featureStatus = modules
.sorted { $0.key < $1.key }
.map { key, result -> FeatureStatus in
let name = key == "connection" ? "连接" : key == "device" ? "设备" : "通知"
switch result {
case .success:
return FeatureStatus(name: name, state: .ready)
case .failure(UpstreamError.unauthorized), .failure(UpstreamError.forbidden):
return FeatureStatus(name: name, state: .requiresLogin)
case .failure:
return FeatureStatus(name: name, state: .unavailable)
}
return false
}.count,
unknown: 0
)
}
let success = connections.filter { $0.outcome == .success }.count
let failed = connections.filter { $0.outcome == .failed }.count
let average = connections.isEmpty
@@ -272,7 +259,7 @@ public struct LogCenterService: Sendable {
version: probe.version,
platform: probe.platform,
observedAt: Date(),
capabilities: capabilities,
featureStatus: featureStatus,
connection: ConnectionSummary(
total: connections.count,
success: success,
@@ -21,27 +21,29 @@ struct LogsView: View {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if model.snapshot == nil && model.isLoading {
AILoadingCard("正在同步日志中心")
} else {
stats
Picker("视图", selection: $tab) {
Text("运行日志").tag(Tab.runtime)
Text("连接日志").tag(Tab.connections)
Text("设备诊断").tag(Tab.diagnostics)
}
}
stats
Picker("视图", selection: $tab) {
Text("运行日志").tag(Tab.runtime)
Text("连接日志").tag(Tab.connections)
Text("设备诊断").tag(Tab.diagnostics)
}
.pickerStyle(.segmented)
.pickerStyle(.segmented)
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
filterBar
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
}
}
.padding(16)
@@ -50,12 +52,9 @@ struct LogsView: View {
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
} label: {
Label("刷新", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
.disabled(model.isLoading)
}
}
@@ -80,87 +79,88 @@ struct LogsView: View {
}
private var stats: some View {
AICard {
VStack(spacing: 12) {
filterBar
HStack(spacing: 8) {
metric("日志", "\(model.snapshot?.runtime.count ?? 0)", AITheme.primary)
metric("在线", "\(onlineCount)", AITheme.success)
metric("离线", "\(model.offlineCount)", AITheme.danger)
}
}
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
}
}
private var filterBar: some View {
VStack(spacing: 10) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
AICard {
VStack(spacing: 10) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
if tab == .runtime {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
Group {
if tab == .runtime {
HStack(spacing: 10) {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
.frame(maxWidth: .infinity)
} else if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
}
.pickerStyle(.menu)
}
if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.transition(.opacity)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
}
private var runtimeSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
sectionTitle("统一时间线", detail: "\(model.filteredRuntime.count) 条")
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
empty("暂无运行日志。")
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会汇总实例日志。")
} else {
ForEach(model.filteredRuntime) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(entry.message)
.font(.subheadline.weight(.semibold))
Spacer()
AITag(entry.level.label, tint: levelTint(entry.level))
}
Text("\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())")
.font(.caption)
.foregroundStyle(AITheme.muted)
HStack(spacing: 6) {
AITag(entry.source.label, tint: AITheme.primary)
if let duration = entry.durationMs {
AITag("\(duration) ms", tint: AITheme.muted)
}
}
ForEach(model.filteredRuntime) { entry in
AIListCard(
title: entry.message,
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
status: entry.level.label,
statusTint: levelTint(entry.level),
details: {
var values = [entry.source.label]
if let duration = entry.durationMs {
values.append("耗时 \(duration) ms")
}
return values
}()
)
}
.padding(.bottom, 6)
}
}
}
}
@@ -169,35 +169,31 @@ struct LogsView: View {
private var connectionSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条")
Button {
HStack(alignment: .center, spacing: 10) {
AISectionTitle(
"连接探测记录",
systemImage: "dot.radiowaves.left.and.right",
detail: "\(model.filteredConnections.count) 条"
)
compactActionButton("清理", systemImage: "trash") {
showsPruneDialog = true
} label: {
Label("清理", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
}
if model.filteredConnections.isEmpty {
empty("暂无连接日志。")
AIEmptyState(systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。")
} else {
ForEach(model.filteredConnections) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(entry.instanceName)
.font(.subheadline.weight(.semibold))
Spacer()
AITag(entry.outcome.label, tint: outcomeTint(entry.outcome))
}
Text("\(entry.observedAt.formatted()) · \(entry.state)")
.font(.caption)
.foregroundStyle(AITheme.muted)
Text("HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms · \(entry.errorCode ?? "--")")
.font(.caption)
.foregroundStyle(AITheme.muted)
ForEach(model.filteredConnections) { entry in
AIListCard(
title: entry.instanceName,
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
status: entry.outcome.label,
statusTint: outcomeTint(entry.outcome),
details: [
"HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms",
entry.errorCode ?? "无错误码"
]
)
}
.padding(.bottom, 6)
}
}
}
}
@@ -206,44 +202,37 @@ struct LogsView: View {
private var diagnosticSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
sectionTitle("设备诊断", detail: "\(model.filteredDiagnostics.count) 台")
AISectionTitle("设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
if model.filteredDiagnostics.isEmpty {
empty("暂无可诊断实例。")
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
} else {
ForEach(model.filteredDiagnostics) { diagnostic in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(diagnostic.name)
.font(.subheadline.weight(.semibold))
Spacer()
AITag(
diagnostic.healthStatus ?? "未知",
tint: diagnostic.state == .online ? AITheme.success : AITheme.warning
)
}
Text(diagnostic.origin)
.font(.caption)
.foregroundStyle(AITheme.muted)
if let version = diagnostic.version {
Text("版本 \(version) · \(diagnostic.platform ?? "未知平台")")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
HStack(spacing: 6) {
AITag(
"能力 \(diagnostic.capabilities.supported)/\(diagnostic.capabilities.total)",
tint: AITheme.primary
)
if let connection = diagnostic.connection {
AITag(
"可用 \(connection.availabilityPercent)%",
tint: connection.failed == 0 ? AITheme.success : AITheme.warning
)
ForEach(model.filteredDiagnostics) { diagnostic in
AIListCard(
title: diagnostic.name,
status: diagnostic.healthStatus ?? "未知",
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
details: [
diagnostic.origin,
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")"
]
) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
alignment: .leading,
spacing: 6
) {
ForEach(diagnostic.featureStatus) { feature in
AITag(feature.name, tint: featureTint(feature.state))
}
if let connection = diagnostic.connection, connection.total > 1 {
AITag(
"延迟 \(connection.averageDurationMs) ms",
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
)
}
}
}
}
.padding(.bottom, 8)
}
}
}
}
@@ -253,35 +242,21 @@ struct LogsView: View {
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
}
private func metric(_ title: String, _ value: String, _ tint: Color) -> some View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.headline.weight(.bold))
.foregroundStyle(tint)
private func compactActionButton(
_ title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
}
.frame(maxWidth: .infinity)
}
private func sectionTitle(_ title: String, detail: String) -> some View {
HStack {
Text(title)
.font(.headline)
Spacer()
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
}
}
private func empty(_ text: String) -> some View {
Text(text)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 18)
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
@@ -300,4 +275,12 @@ struct LogsView: View {
case .unsupported: return AITheme.muted
}
}
private func featureTint(_ state: FeatureStatus.State) -> Color {
switch state {
case .ready: return AITheme.success
case .requiresLogin: return AITheme.warning
case .unavailable: return AITheme.muted
}
}
}
@@ -67,10 +67,6 @@ final class LogsViewModel: ObservableObject {
snapshot?.diagnostics.filter { $0.state == .offline }.count ?? 0
}
var degradedCount: Int {
snapshot?.diagnostics.filter { $0.capabilities.degraded > 0 }.count ?? 0
}
func refresh() async {
isLoading = true
errorMessage = nil
@@ -15,26 +15,18 @@ struct MessagesView: View {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
}
AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息")
}
if model.filteredRows.isEmpty && !model.isLoading {
AICard {
VStack(spacing: 10) {
Image(systemName: "message")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无消息")
.font(.headline)
Text("刷新后从实例同步短信。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
}
}
ForEach(model.filteredRows) { message in
@@ -45,15 +37,13 @@ struct MessagesView: View {
}
}
.navigationTitle("消息中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
model.showingSendForm = true
} label: {
Label("发送", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.aiBottomActionBar {
Button {
model.showingSendForm = true
} label: {
Label("发送消息", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
@@ -97,6 +87,7 @@ struct MessagesView: View {
}
}
.pickerStyle(.menu)
.aiSelectSurface()
AISearchField("搜索号码或内容", text: $model.searchText)
}
@@ -115,15 +106,25 @@ struct MessagesView: View {
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.fixedSize(horizontal: false, vertical: true)
HStack {
Text(message.instanceName)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
Button("删除") {
pendingDeleteMessage = message
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
@@ -27,18 +27,27 @@ struct SendMessageFormView: View {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
Picker("实例", selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)) {
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
VStack(alignment: .leading, spacing: 6) {
Text("实例")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Picker("实例", selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)) {
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
.pickerStyle(.menu)
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(.phonePad)
#endif
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(.plain)
@@ -54,24 +63,26 @@ struct SendMessageFormView: View {
}
}
if let validationMessage {
Text(validationMessage)
.font(.subheadline)
.foregroundStyle(AITheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
}
}
.navigationTitle("发送消息")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
}
ToolbarItem(placement: .confirmationAction) {
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .regular,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend)
}
}
@@ -3,6 +3,12 @@ import SwiftUI
struct NotificationsView: View {
@StateObject private var model: NotificationsViewModel
@State private var pendingDeleteQueueID: String?
private static let timestampFormatter = ISO8601DateFormatter()
private static let fractionalTimestampFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
@@ -14,32 +20,24 @@ struct NotificationsView: View {
ScrollView {
LazyVStack(spacing: 12) {
if let actionMessage = model.actionMessage {
AICard {
Text(actionMessage)
.font(.subheadline)
.foregroundStyle(AITheme.success)
}
AIStatusBanner(
actionMessage,
tint: model.actionSucceeded ? AITheme.success : AITheme.danger,
systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle"
)
}
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
}
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let overview = model.overview {
stats(overview)
devices(overview)
channels(overview)
queue(overview)
channels(overview)
logs(overview)
devices(overview)
} else if model.isLoading {
AICard {
ProgressView("正在加载通知中心...")
.frame(maxWidth: .infinity)
.padding(.vertical, 22)
}
AILoadingCard("正在加载通知中心")
} else {
empty
}
@@ -48,16 +46,14 @@ struct NotificationsView: View {
}
}
.navigationTitle("通知中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(model.isLoading)
.aiBottomActionBar {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
@@ -87,64 +83,57 @@ struct NotificationsView: View {
private var empty: some View {
AICard {
VStack(spacing: 10) {
Image(systemName: "bell")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无通知数据")
.font(.headline)
Text("下拉刷新后从已配置实例同步。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
AIEmptyState(
systemImage: "bell",
title: "暂无通知数据",
message: "下拉刷新后从已配置实例同步。"
)
}
}
private func stats(_ overview: NotificationOverview) -> some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
statCard("通道", "\(overview.config.channelEnabled)/\(overview.config.channelCount)", "已启用/总数", AITheme.primary)
statCard("规则", "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", "已启用/总数", AITheme.primary)
statCard("队列", "\(overview.queue.pending + overview.queue.retrying)", "等待与重试", AITheme.warning)
statCard("失败", "\(overview.queue.failed + overview.logs.failed)", "队列与投递", AITheme.danger)
}
}
private func statCard(_ title: String, _ value: String, _ detail: String, _ tint: Color) -> some View {
AICard {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.title2.weight(.bold))
.foregroundStyle(tint)
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
AIStatCard(
title: "通道",
value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "规则",
value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "队列",
value: "\(overview.queue.pending + overview.queue.retrying)",
detail: "等待与重试",
tint: AITheme.warning
)
AIStatCard(
title: "失败",
value: "\(overview.queue.failed + overview.logs.failed)",
detail: "队列与投递",
tint: AITheme.danger
)
}
}
private func devices(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
title("实例状态")
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
if overview.devices.isEmpty {
Text("尚未添加实例。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
AIEmptyState(systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
} else {
ForEach(overview.devices) { device in
HStack(spacing: 8) {
Text(device.name)
.font(.subheadline)
Spacer()
AITag(deviceState(device.state), tint: deviceTint(device.state))
ForEach(overview.devices) { device in
AIListCard(
title: device.name,
status: deviceState(device.state),
statusTint: deviceTint(device.state)
)
}
}
}
}
}
@@ -153,20 +142,17 @@ struct NotificationsView: View {
private func channels(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
title("通道类型")
AISectionTitle("通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类")
if overview.config.channelTypes.isEmpty {
Text("实例未配置通知通道。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
AIEmptyState(systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
} else {
ForEach(overview.config.channelTypes) { channel in
HStack(spacing: 8) {
Text(channelTypeLabel(channel.type))
.font(.subheadline)
Spacer()
AITag("\(channel.enabled)/\(channel.total)", tint: channel.enabled > 0 ? AITheme.success : AITheme.muted)
ForEach(overview.config.channelTypes) { channel in
AIListCard(
title: channelTypeLabel(channel.type),
status: "\(channel.enabled)/\(channel.total)",
statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted
)
}
}
}
}
}
@@ -175,36 +161,32 @@ struct NotificationsView: View {
private func queue(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
title("最近队列")
Spacer()
Text("\(overview.queue.total) 项")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
if overview.queue.items.isEmpty {
Text("队列为空。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
} else {
ForEach(overview.queue.items) { item in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(eventTypeLabel(item.eventType))
.font(.subheadline.weight(.semibold))
Spacer()
AITag(statusLabel(item.status), tint: statusTint(item.status))
}
Text("\(item.instanceName) · \(item.createdAt)")
.font(.caption)
.foregroundStyle(AITheme.muted)
AIListCard(
title: eventTypeLabel(item.eventType),
subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))",
status: statusLabel(item.status),
statusTint: statusTint(item.status)
) {
HStack(spacing: 8) {
Button {
Task { await model.retryQueue(item) }
} label: {
Label("重试", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID == item.id)
Button {
@@ -212,10 +194,15 @@ struct NotificationsView: View {
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
.padding(.bottom, 4)
}
}
}
@@ -225,41 +212,33 @@ struct NotificationsView: View {
private func logs(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
title("投递日志")
Spacer()
Text("\(overview.logs.total) 条")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
AISectionTitle("投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条")
if overview.logs.entries.isEmpty {
Text("暂无投递日志。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(overview.logs.entries) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(eventTypeLabel(entry.eventType))
.font(.subheadline.weight(.semibold))
Spacer()
AITag(statusLabel(entry.status), tint: statusTint(entry.status))
}
Text("\(entry.channelName ?? "未指定通道") · \(entry.createdAt)")
.font(.caption)
.foregroundStyle(AITheme.muted)
ForEach(overview.logs.entries) { entry in
AIListCard(
title: eventTypeLabel(entry.eventType),
subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))",
status: statusLabel(entry.status),
statusTint: statusTint(entry.status)
)
}
.padding(.bottom, 4)
}
}
}
}
}
private func title(_ text: String) -> some View {
Text(text)
.font(.headline)
.foregroundStyle(AITheme.ink)
private func timestampText(_ value: String) -> String {
let date = Self.fractionalTimestampFormatter.date(from: value)
?? Self.timestampFormatter.date(from: value)
guard let date else { return value }
return date.formatted(
Date.FormatStyle(date: .numeric, time: .shortened)
.locale(Locale(identifier: "zh_CN"))
)
}
private func deviceState(_ state: NotificationDeviceState) -> String {
@@ -7,6 +7,7 @@ final class NotificationsViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published var errorMessage: String?
@Published private(set) var actionMessage: String?
@Published private(set) var actionSucceeded = true
@Published private(set) var busyQueueID: String?
private let repository: any InstanceRepository
@@ -32,6 +33,7 @@ final class NotificationsViewModel: ObservableObject {
func retryAll() async {
let result = await service.retryAll(instances)
actionSucceeded = result.succeeded > 0
if result.succeeded > 0 {
actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。"
} else if result.skipped > 0 {
@@ -67,9 +69,11 @@ final class NotificationsViewModel: ObservableObject {
do {
try await action(instance, item.id)
actionMessage = "队列操作已完成。"
actionSucceeded = true
errorMessage = nil
} catch {
errorMessage = "队列操作失败。"
actionSucceeded = false
}
busyQueueID = nil
}
@@ -14,18 +14,10 @@ struct SettingsView: View {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.danger)
}
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let notice = model.notice {
AICard {
Text(notice)
.font(.subheadline)
.foregroundStyle(AITheme.success)
}
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
}
runtimeCard
@@ -38,12 +30,9 @@ struct SettingsView: View {
.navigationTitle("设置")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
} label: {
Label("刷新", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
.disabled(model.isLoading)
}
}
@@ -68,10 +57,10 @@ struct SettingsView: View {
private var runtimeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
sectionTitle("应用信息", systemImage: "info.circle")
AISectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
infoRow("版本", "\(runtime.marketingVersion) (\(runtime.buildVersion))")
infoRow("设备", runtime.deviceModel)
AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))")
AIKeyValueRow("设备", value: runtime.deviceModel)
} else {
Text("正在读取应用信息…")
.font(.subheadline)
@@ -84,16 +73,15 @@ struct SettingsView: View {
private var storageCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
sectionTitle("本地数据", systemImage: "externaldrive")
infoRow("存储位置", "仅保存在本机应用沙盒")
infoRow("当前占用", model.storageLabel)
AISectionTitle("本地数据", systemImage: "externaldrive")
AIKeyValueRow("存储位置", value: "仅保存在本机应用沙盒")
AIKeyValueRow("当前占用", value: model.storageLabel)
Button {
showsClearDialog = true
} label: {
Label("清除运行日志", systemImage: "trash")
}
.frame(maxWidth: .infinity)
.buttonStyle(AIButtonStyle(kind: .danger, size: .large))
.buttonStyle(AIButtonStyle(kind: .danger, size: .large, fillsWidth: true))
}
}
}
@@ -101,32 +89,11 @@ struct SettingsView: View {
private var securityCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
sectionTitle("安全与登录", systemImage: "lock.shield")
infoRow("实例密码", "保存在 iOS 钥匙串")
infoRow("登录会话", "仅保存在内存")
AISectionTitle("安全与登录", systemImage: "lock.shield")
AIKeyValueRow("实例密码", value: "保存在本机应用沙盒")
AIKeyValueRow("登录会话", value: "仅保存在内存")
}
}
}
private func sectionTitle(_ title: String, systemImage: String) -> some View {
HStack(spacing: 8) {
Image(systemName: systemImage)
.foregroundStyle(AITheme.primary)
Text(title)
.font(.headline.weight(.bold))
Spacer()
}
}
private func infoRow(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
}
@@ -2,41 +2,107 @@ import SwiftUI
@main
struct MultiSimAdminApp: App {
@State private var tab: AppTab
init() {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("--tab-messages") {
_tab = State(initialValue: .messages)
} else if arguments.contains("--tab-notifications") {
_tab = State(initialValue: .notifications)
} else if arguments.contains("--tab-logs") {
_tab = State(initialValue: .logs)
} else if arguments.contains("--tab-settings") {
_tab = State(initialValue: .settings)
} else {
_tab = State(initialValue: .fleet)
}
}
var body: some Scene {
WindowGroup {
TabView {
NavigationStack {
FleetView()
}
.tabItem {
Label("节点", systemImage: "server.rack")
}
NavigationStack {
MessagesView()
}
.tabItem {
Label("消息", systemImage: "message")
}
NavigationStack {
NotificationsView()
}
.tabItem {
Label("通知", systemImage: "bell")
}
NavigationStack {
LogsView()
}
.tabItem {
Label("日志", systemImage: "doc.text")
}
NavigationStack {
SettingsView()
}
.tabItem {
Label("设置", systemImage: "gearshape")
VStack(spacing: 0) {
switch tab {
case .fleet:
NavigationStack { FleetView().aiInlineTitle() }
case .messages:
NavigationStack { MessagesView().aiInlineTitle() }
case .notifications:
NavigationStack { NotificationsView().aiInlineTitle() }
case .logs:
NavigationStack { LogsView().aiInlineTitle() }
case .settings:
NavigationStack { SettingsView().aiInlineTitle() }
}
AITabBar(selection: $tab)
}
.tint(AITheme.primary)
.preferredColorScheme(.light)
#if os(iOS)
.toolbarBackground(AITheme.canvas, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
#endif
}
}
}
#if os(iOS)
private extension View {
func aiInlineTitle() -> some View {
navigationBarTitleDisplayMode(.inline)
}
}
#else
private extension View {
func aiInlineTitle() -> some View { self }
}
#endif
enum AppTab: Hashable {
case fleet
case messages
case notifications
case logs
case settings
}
struct AITabBar: View {
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
tab(.fleet, "节点", "server.rack")
tab(.messages, "消息", "message")
tab(.notifications, "通知", "bell")
tab(.logs, "日志", "doc.text")
tab(.settings, "设置", "gearshape")
}
.frame(height: 58)
.background(AITheme.surface)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.background(AITheme.surface.ignoresSafeArea(edges: .bottom))
}
private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View {
let isSelected = selection == value
return Button {
selection = value
} label: {
VStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,7 @@
import Foundation
public protocol CredentialStore: Sendable {
func get(instanceID: UUID) throws -> String?
func set(instanceID: UUID, password: String) throws
func remove(instanceID: UUID) throws
}
@@ -1,6 +1,10 @@
import Foundation
enum DefaultContainer {
private static let credentials: any CredentialStore = FileCredentialStore(
location: documentFileURL("credentials.json")
)
private static let logStore: any LogStore = FileLogStore(
location: documentFileURL("log-center.json")
)
@@ -15,7 +19,7 @@ enum DefaultContainer {
FleetViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
probe: ConnectionProbe(client: SimAdminClient()),
credentials: KeychainCredentialStore()
credentials: credentials
)
}
@@ -26,7 +30,7 @@ enum DefaultContainer {
service: MessageService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
credentials: credentials
)
)
}
@@ -38,7 +42,7 @@ enum DefaultContainer {
service: NotificationService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
credentials: credentials
)
)
}
@@ -51,7 +55,7 @@ enum DefaultContainer {
service: LogCenterService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore(),
credentials: credentials,
store: store
),
store: store
@@ -70,7 +74,7 @@ enum DefaultContainer {
service: DeviceService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
credentials: credentials
)
)
}
@@ -0,0 +1,52 @@
import Foundation
public final class FileCredentialStore: @unchecked Sendable, CredentialStore {
private struct FileModel: Codable {
var credentials: [String: String] = [:]
}
private let location: URL
private let lock = NSLock()
private var fileModel = FileModel()
public init(location: URL) {
self.location = location
lock.withLock {
if let data = try? Data(contentsOf: location),
let decoded = try? JSONDecoder().decode(FileModel.self, from: data) {
fileModel = decoded
}
}
}
public func get(instanceID: UUID) throws -> String? {
lock.withLock {
fileModel.credentials[instanceID.uuidString]
}
}
public func set(instanceID: UUID, password: String) throws {
try lock.withLock {
fileModel.credentials[instanceID.uuidString] = password
try write()
}
}
public func remove(instanceID: UUID) throws {
try lock.withLock {
fileModel.credentials.removeValue(forKey: instanceID.uuidString)
try write()
}
}
private func write() throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(fileModel)
try data.write(to: location, options: [.atomic, .completeFileProtection])
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: location.path
)
}
}
@@ -1,61 +0,0 @@
import Foundation
import Security
public protocol CredentialStore: Sendable {
func get(instanceID: UUID) throws -> String?
func set(instanceID: UUID, password: String) throws
func remove(instanceID: UUID) throws
}
public struct KeychainCredentialStore: CredentialStore {
private let service = "fun.chick.multi-simadmin.instance-password"
public init() {}
public func get(instanceID: UUID) throws -> String? {
var query = baseQuery(instanceID)
query[kSecReturnData as String] = true
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
if status == errSecItemNotFound { return nil }
throw CredentialStoreError.keychainFailed
}
guard let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
public func set(instanceID: UUID, password: String) throws {
try self.remove(instanceID: instanceID)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: instanceID.uuidString,
kSecValueData as String: Data(password.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else {
throw CredentialStoreError.keychainFailed
}
}
public func remove(instanceID: UUID) throws {
let status = SecItemDelete(baseQuery(instanceID) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw CredentialStoreError.keychainFailed
}
}
private func baseQuery(_ instanceID: UUID) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: instanceID.uuidString
]
}
}
public enum CredentialStoreError: Error, Equatable {
case keychainFailed
}
@@ -0,0 +1,4 @@
public enum ErrorMessage {
public static let network = "暂时无法连接实例,请稍后重试。"
public static let unauthorized = "实例登录状态已失效,请重新登录。"
}
@@ -1,6 +1,8 @@
import SwiftUI
public struct AIButtonStyle: ButtonStyle {
public static let pressedScale: CGFloat = 0.97
public enum Kind: Hashable {
case primary
case secondary
@@ -30,15 +32,25 @@ public struct AIButtonStyle: ButtonStyle {
case compact
case regular
case large
case toolbar
}
private let kind: Kind
private let size: Size
private let fixedWidthRequest: CGFloat?
let fillsWidth: Bool
@Environment(\.isEnabled) private var isEnabled
public init(kind: Kind = .secondary, size: Size = .regular) {
public init(
kind: Kind = .secondary,
size: Size = .regular,
fixedWidth: CGFloat? = nil,
fillsWidth: Bool = false
) {
self.kind = kind
self.size = size
self.fixedWidthRequest = fixedWidth
self.fillsWidth = fillsWidth
}
public func makeBody(configuration: Configuration) -> some View {
@@ -46,20 +58,29 @@ public struct AIButtonStyle: ButtonStyle {
case .compact: AITheme.compactButtonHeight
case .large: AITheme.largeButtonHeight
case .regular: AITheme.buttonHeight
case .toolbar: AITheme.toolbarButtonHeight
}
configuration.label
.font(.system(size: 16, weight: .semibold))
.labelStyle(.titleAndIcon)
.font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold))
.lineLimit(1)
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
.frame(minWidth: minimumWidth, minHeight: height)
.padding(.horizontal, kind == .secondary ? 12 : 14)
.frame(
minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0,
minHeight: height
)
.frame(maxWidth: fillsWidth ? .infinity : nil)
.padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0)
.frame(width: fixedWidth)
.background(kind.backgroundColor(isEnabled: isEnabled))
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear)
}
.opacity(configuration.isPressed ? 0.78 : 1)
.scaleEffect(configuration.isPressed ? Self.pressedScale : 1)
.opacity(configuration.isPressed ? 0.82 : 1)
}
private var minimumWidth: CGFloat {
@@ -67,6 +88,11 @@ public struct AIButtonStyle: ButtonStyle {
case .compact: return AITheme.compactButtonMinWidth
case .regular: return AITheme.regularButtonMinWidth
case .large: return 92
case .toolbar: return AITheme.toolbarButtonMinWidth
}
}
private var fixedWidth: CGFloat? {
fixedWidthRequest ?? (size == .toolbar ? AITheme.toolbarButtonWidth : nil)
}
}
@@ -2,16 +2,19 @@ import SwiftUI
public struct AICard<Content: View>: View {
private let content: Content
private let padding: CGFloat
public init(@ViewBuilder content: () -> Content) {
public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) {
self.padding = padding
self.content = content()
}
public var body: some View {
content
.padding(16)
.padding(padding)
.background(AITheme.surface)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
.shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3)
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius)
.strokeBorder(AITheme.line)
@@ -32,6 +35,8 @@ public struct AITag: View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(tint)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 9)
.frame(minHeight: 26)
.background(tint.opacity(0.10))
@@ -39,6 +44,257 @@ public struct AITag: View {
}
}
public struct AISectionTitle: View {
private let title: String
private let systemImage: String
private let detail: String?
public init(_ title: String, systemImage: String, detail: String? = nil) {
self.title = title
self.systemImage = systemImage
self.detail = detail
}
public var body: some View {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.primary)
Text(title)
.font(.headline.weight(.bold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
Spacer(minLength: 8)
if let detail {
Text(detail)
.font(.caption.weight(.medium))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
}
}
}
}
public struct AIStatCard: View {
private let title: String
private let value: String
private let detail: String?
private let tint: Color
public init(title: String, value: String, detail: String? = nil, tint: Color) {
self.title = title
self.value = value
self.detail = detail
self.tint = tint
}
public var body: some View {
AICard(padding: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(AITheme.muted)
Text(value)
.font(.title3.weight(.bold))
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.75)
if let detail {
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
public struct AIStatusBanner: View {
private let message: String
private let tint: Color
private let systemImage: String
public init(_ message: String, tint: Color, systemImage: String) {
self.message = message
self.tint = tint
self.systemImage = systemImage
}
public var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tint)
Text(message)
.font(.subheadline.weight(.medium))
.foregroundStyle(tint)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(14)
.background(tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius)
.strokeBorder(tint.opacity(0.22))
}
.accessibilityElement(children: .combine)
}
}
public struct AIEmptyState: View {
private let systemImage: String
private let title: String
private let message: String
public init(systemImage: String, title: String, message: String) {
self.systemImage = systemImage
self.title = title
self.message = message
}
public var body: some View {
VStack(spacing: 10) {
Image(systemName: systemImage)
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text(title)
.font(.headline.weight(.bold))
.foregroundStyle(AITheme.ink)
Text(message)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.accessibilityElement(children: .combine)
}
}
public struct AIListCard<Footer: View>: View {
private let title: String
private let subtitle: String?
private let status: String?
private let statusTint: Color?
private let details: [String]
private let footer: Footer
public init(
title: String,
subtitle: String? = nil,
status: String? = nil,
statusTint: Color? = nil,
details: [String] = [],
@ViewBuilder footer: () -> Footer = { EmptyView() }
) {
self.title = title
self.subtitle = subtitle
self.status = status
self.statusTint = statusTint
self.details = details
self.footer = footer()
}
public var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(2)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
if let status {
AITag(status, tint: statusTint ?? AITheme.muted)
}
}
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !details.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(details, id: \.self) { detail in
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
}
if Footer.self != EmptyView.self {
footer
}
}
.accessibilityElement(children: .combine)
}
}
public struct AILoadingCard: View {
private let message: String
public init(_ message: String) {
self.message = message
}
public var body: some View {
AICard {
VStack(spacing: 10) {
ProgressView()
.controlSize(.regular)
Text(message)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.accessibilityElement(children: .combine)
}
}
}
public struct AIKeyValueRow: View {
private let title: String
private let value: String
private let valueTint: Color
public init(_ title: String, value: String, tint: Color = AITheme.ink) {
self.title = title
self.value = value
self.valueTint = tint
}
public var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(title)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
Spacer(minLength: 12)
Text(value)
.font(.subheadline.weight(.medium))
.foregroundStyle(valueTint)
.multilineTextAlignment(.trailing)
.lineLimit(2)
.minimumScaleFactor(0.85)
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
}
}
public struct AIFieldStyle: TextFieldStyle {
public init() {}
@@ -76,13 +332,25 @@ public struct AISearchField: View {
.foregroundStyle(AITheme.muted)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.submitLabel(.search)
.onSubmit {
onSubmit?()
}
if !text.isEmpty {
Button {
text = ""
onSubmit?()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AITheme.muted)
}
.buttonStyle(.plain)
.accessibilityLabel("清除搜索")
}
}
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.surface)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
@@ -107,3 +375,71 @@ public struct AIBrandBar: View {
.background(Color(red: 0.075, green: 0.118, blue: 0.188))
}
}
extension View {
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
safeAreaInset(edge: .bottom) {
content()
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background(AITheme.canvas)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
}
func aiSelectSurface() -> some View {
self
.tint(AITheme.ink)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.frame(maxWidth: .infinity, minHeight: AITheme.buttonHeight, alignment: .leading)
.padding(.horizontal, 12)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
}
public struct AIToolbarButton: View {
private let title: String
private let systemImage: String?
private let kind: AIButtonStyle.Kind
private let action: () -> Void
public init(
_ title: String,
systemImage: String? = nil,
kind: AIButtonStyle.Kind = .secondary,
action: @escaping () -> Void
) {
self.title = title
self.systemImage = systemImage
self.kind = kind
self.action = action
}
public var body: some View {
Button {
action()
} label: {
if let systemImage {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.font(.system(size: 15, weight: .semibold))
} else {
Text(title)
.font(.system(size: 15, weight: .semibold))
}
}
.buttonStyle(AIButtonStyle(kind: kind, size: .toolbar))
.accessibilityLabel(title)
}
}
@@ -15,9 +15,14 @@ public enum AITheme {
public static let buttonHeight: CGFloat = 44
public static let compactButtonHeight: CGFloat = 40
public static let largeButtonHeight: CGFloat = 50
public static let toolbarButtonHeight: CGFloat = 38
public static let iconButtonSize: CGFloat = 44
public static let buttonRadius: CGFloat = 12
public static let cardRadius: CGFloat = 16
public static let compactButtonMinWidth: CGFloat = 58
public static let regularButtonMinWidth: CGFloat = 76
public static let toolbarButtonMinWidth: CGFloat = 88
public static let toolbarButtonWidth: CGFloat = 100
public static let footerSecondaryButtonWidth: CGFloat = 100
public static let listActionButtonWidth: CGFloat = 72
}
@@ -8,8 +8,47 @@ final class AIThemeTests: XCTestCase {
XCTAssertEqual(AITheme.buttonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 40, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 50, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonHeight, 38, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 12, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonMinWidth, 88, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonWidth, 100, accuracy: 0.1)
XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 100, accuracy: 0.1)
XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1)
XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01)
}
func testBottomActionsFillEqualWidths() {
let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)
let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)
XCTAssertTrue(primary.fillsWidth)
XCTAssertTrue(secondary.fillsWidth)
}
func testFixedWidthDrawsInsideTheButtonStyle() {
let button = AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
XCTAssertTrue(button.fillsWidth == false)
XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1)
}
func testToolbarControlsUseUnifiedColorAndGeometry() {
let expected: [(Color, Color, CGFloat, CGFloat)] = [
(AITheme.surface, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.primary, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.danger, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
]
for (background, foreground, width, height) in expected {
XCTAssertTrue(background == background)
XCTAssertTrue(foreground == foreground)
XCTAssertEqual(width, 100, accuracy: 0.1)
XCTAssertEqual(height, 38, accuracy: 0.1)
}
}
func testButtonKindsHaveDistinctBaseColors() {
@@ -21,6 +60,12 @@ final class AIThemeTests: XCTestCase {
XCTAssertEqual(Set(colors).count, 3)
}
func testSearchFieldExposesClearAction() throws {
let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理")))
let text = try XCTUnwrap(mirror.children.first { $0.label == "_text" }?.value as? Binding<String>)
XCTAssertEqual(text.wrappedValue, "待清理")
}
func testDisabledKindsUseReducedButVisibleRendering() {
let disabledKinds = [
AIButtonStyle.Kind.primary,
@@ -0,0 +1,14 @@
import SwiftUI
import XCTest
@testable import MultiSimAdmin
final class ErrorMessageTests: XCTestCase {
func testNetworkFailureUsesPlainActionableCopy() {
XCTAssertEqual(ErrorMessage.network, "暂时无法连接实例,请稍后重试。")
}
func testUnauthorizedFailureUsesLoginGuidance() {
XCTAssertEqual(ErrorMessage.unauthorized, "实例登录状态已失效,请重新登录。")
}
}
@@ -0,0 +1,32 @@
import XCTest
@testable import MultiSimAdmin
final class FileCredentialStoreTests: XCTestCase {
func testSavesReadsAndRemovesPasswordInSandboxFile() throws {
let location = FileManager.default.temporaryDirectory
.appendingPathComponent("credential-store-\(UUID().uuidString).json")
let store = FileCredentialStore(location: location)
let instanceID = UUID()
XCTAssertNil(try store.get(instanceID: instanceID))
try store.set(instanceID: instanceID, password: "sandbox-secret")
XCTAssertEqual(try store.get(instanceID: instanceID), "sandbox-secret")
XCTAssertTrue(FileManager.default.fileExists(atPath: location.path))
try store.remove(instanceID: instanceID)
XCTAssertNil(try store.get(instanceID: instanceID))
}
func testCredentialFileIsPrivate() throws {
let location = FileManager.default.temporaryDirectory
.appendingPathComponent("credential-store-\(UUID().uuidString).json")
let store = FileCredentialStore(location: location)
try store.set(instanceID: UUID(), password: "private-secret")
let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
XCTAssertEqual(attributes[.posixPermissions] as? Int, 0o600)
}
}
@@ -90,7 +90,7 @@ struct AlwaysOnlineClient: SimAdminClientProtocol {
}
}
final class NoopCredentialStore: CredentialStore {
final class NoopCredentialStore: @unchecked Sendable, CredentialStore {
private let lock = NSLock()
private var savedStorage: [UUID: String] = [:]
private var removedStorage: [UUID] = []
@@ -119,7 +119,7 @@ final class NoopCredentialStore: CredentialStore {
}
}
final class MemoryInstanceRepository: InstanceRepository {
final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
private let lock = NSLock()
private var itemsStorage: [Instance] = []
@@ -30,8 +30,11 @@ final class LogCenterTests: XCTestCase {
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.id, instance.id)
XCTAssertEqual(diagnostic.state, .online)
XCTAssertEqual(diagnostic.capabilities.total, 3)
XCTAssertEqual(diagnostic.capabilities.supported, 3)
XCTAssertEqual(diagnostic.featureStatus.map(\.state), [
.ready,
.ready,
.ready
])
XCTAssertEqual(diagnostic.connection?.total, 1)
XCTAssertEqual(diagnostic.connection?.availabilityPercent, 100)
}
@@ -54,8 +57,8 @@ final class LogCenterTests: XCTestCase {
XCTAssertEqual(snapshot.runtime.first?.message, "通知日志未同步:需要登录")
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .online)
XCTAssertEqual(diagnostic.capabilities.authRequired, 1)
XCTAssertEqual(diagnostic.capabilities.supported, 2)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .requiresLogin }.count, 1)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 2)
XCTAssertEqual(store.runtimeStorage.count, 1)
XCTAssertEqual(store.connectionStorage.count, 1)
}
@@ -76,8 +79,7 @@ final class LogCenterTests: XCTestCase {
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .offline)
XCTAssertEqual(diagnostic.healthStatus, "超时")
XCTAssertEqual(diagnostic.capabilities.total, 3)
XCTAssertEqual(diagnostic.capabilities.unsupported, 3)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 3)
}
@MainActor
+16
View File
@@ -20,6 +20,22 @@ cp "$DERIVED_ROOT/Build/Products/Release-iphoneos/$APP_NAME" \
"$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"
cp "Sources/$APP_NAME/Resources/Info.plist" \
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
xcrun actool \
--compile "$BUILD_ROOT/Payload/$APP_NAME.app" \
--platform iphoneos \
--minimum-deployment-target 16.0 \
--app-icon AppIcon \
--output-format human-readable-text \
--output-partial-info-plist "$BUILD_ROOT/AssetCatalog-Info.plist" \
"$IOS_ROOT/Sources/$APP_NAME/Resources/Assets.xcassets"
if [[ ! -f "$BUILD_ROOT/Payload/$APP_NAME.app/Assets.car" ]]; then
echo 'Assets.car missing: application icon was not compiled' >&2
exit 1
fi
/usr/libexec/PlistBuddy -c "Merge '$BUILD_ROOT/AssetCatalog-Info.plist'" \
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
plutil -lint "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
cd "$BUILD_ROOT"