feat(ios): unify list cards and loading states
This commit is contained in:
@@ -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 |
@@ -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] : [])
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 906 KiB |
+14
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user