release(ios): 发布 0.2.1 并重构图标与界面细节

- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
chick
2026-09-14 16:30:39 +08:00
parent 27687868ef
commit 3607eddc9f
34 changed files with 2656 additions and 1213 deletions
@@ -25,7 +25,7 @@ final class DeviceViewModel: ObservableObject {
} catch DeviceServiceError.unauthorized {
errorMessage = "需要实例密码登录后才能读取设备数据。"
} catch {
errorMessage = "无法加载设备数据。"
errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
}
guard let probe else {
connectionStatus = .offline
@@ -19,13 +19,17 @@ struct InstanceDetailView: View {
var body: some View {
ScrollView {
VStack(spacing: 12) {
headerCard
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
if isUnavailable {
unavailableCard
} else {
headerCard
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
}
}
}
}
@@ -51,56 +55,85 @@ struct InstanceDetailView: View {
}
}
// 读取失败且从未拿到数据时,整屏骨架没有意义,改成一屏可读的失败卡片。
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
private var unavailableCard: some View {
VStack(spacing: 12) {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "wifi.slash")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(AITheme.danger)
.frame(width: 38, height: 38)
.background(AITheme.danger.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
Text("节点无法连接")
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Spacer(minLength: 8)
statusChip(title: "不可达", tint: AITheme.danger)
}
Text(model.errorMessage ?? "无法加载设备数据。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
Button {
Task { await model.refresh() }
} label: {
Label("重新检测", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
}
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3")
AIKeyValueRow("地址", value: model.instance.origin.absoluteString, monospaced: true)
if let groupName {
AIKeyValueRow("分组", value: groupName)
}
AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、"))
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? "已保存" : "未设置")
}
}
// 配置是否丢失不是用户需要被告知的事,卡片本身已经说明了一切。
}
}
private var headerCard: some View {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(model.instance.name)
.font(.title3.weight(.bold))
.kerning(-0.2)
.foregroundStyle(AITheme.ink)
.lineLimit(2)
.minimumScaleFactor(0.85)
.frame(maxWidth: .infinity, alignment: .leading)
Text(model.instance.origin.absoluteString)
.font(.caption)
// 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。
HStack(spacing: 8) {
Image(systemName: "globe")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
Text(model.instance.origin.absoluteString)
.font(.footnote.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 8)
statusChip()
}
DetailMetricGrid(metrics: headerMetrics)
DetailMetricGrid(metrics: gaugeMetrics)
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("数据模块")
.font(.caption.weight(.semibold))
.foregroundStyle(AITheme.muted)
Spacer()
Text(availableCount.map { "\($0)/\(moduleSections.count)" } ?? "读取中")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(availableCount == moduleSections.count ? AITheme.success : AITheme.muted)
}
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 9) {
ForEach(stride(from: 0, to: moduleSections.count, by: 2).map {
Array(moduleSections[$0..<min($0 + 2, moduleSections.count)])
}, id: \.first?.id) { rowSections in
GridRow {
ForEach(rowSections, id: \.id) { section in
moduleIndicator(
section.title,
isAvailable: section.values.isEmpty == false
)
}
if rowSections.count == 1 {
Color.clear
}
}
}
}
}
DetailIdentityStrip(items: identitySummary, isLoading: model.isLoading)
moduleAvailability
if groupName != nil || !model.instance.tags.isEmpty {
FlowLayout(spacing: 7) {
@@ -111,11 +144,74 @@ struct InstanceDetailView: View {
AITag(tag)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private func statusChip(title: String? = nil, tint overrideTint: Color? = nil) -> some View {
let loading = model.isLoading && model.snapshot == nil
let tint = overrideTint ?? (loading ? AITheme.muted : statusColor(model.connectionStatus))
let label = title ?? (loading ? "检测中" : model.connectionStatus.label)
return HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: 7, height: 7)
.accessibilityHidden(true)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 10)
.frame(minHeight: 30)
.background(tint.opacity(0.12))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.22))
}
.accessibilityLabel("连接状态 \(label)")
}
/// 模块可用性压成一行读数:六个胶囊和下方分栏标题一一对应,属于纯重复。
/// 只有真的读不到时才展开,直接点名是哪几个模块。
private var moduleAvailability: some View {
let unavailable = moduleSections.filter { $0.values.isEmpty }.map(\.title)
return VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Text("数据模块")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
Spacer()
if let available = availableCount {
Image(systemName: available == moduleSections.count
? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
.accessibilityHidden(true)
Text("\(available)/\(moduleSections.count) 可用")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
} else {
Text("读取中")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
}
}
if unavailable.isEmpty == false {
Text("未返回:" + unavailable.joined(separator: " · "))
.font(.caption2)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private struct HeaderMetric: Identifiable {
let title: String
let value: String?
@@ -126,16 +222,12 @@ struct InstanceDetailView: View {
var id: String { title }
}
private var headerMetrics: [HeaderMetric] {
let loading = model.snapshot == nil
private var allHeaderMetrics: [HeaderMetric] {
let loading = model.isLoading
let phoneValue = stringValue(
section: "sim",
keys: ["phone_numbers", "phone_number"]
)
let firmwareValue = stringValue(
section: "device",
keys: ["revision", "firmware_version"]
)
let storageValue = storageSummary
let uptimeValue = stringValue(
section: "stats",
@@ -169,55 +261,66 @@ struct InstanceDetailView: View {
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { resourceTint($0) }
)
let deviceValue = stringValue(section: "device", keys: ["model", "name", "device"])
let carrierValue = stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])
let networkValue = stringValue(
section: "network",
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
)
let ipValue = stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"])
return [
HeaderMetric(
title: "连接状态",
value: loading ? "--" : model.connectionStatus.label,
systemImage: "dot.radiowaves.left.and.right",
tint: loading ? AITheme.muted : statusColor(model.connectionStatus),
progress: loading ? 0 : (model.connectionStatus == .online ? 1 : 0)
),
HeaderMetric(
makeMetric(
title: "号码",
value: loading ? "--" : phoneValue ?? "无数据",
value: loading ? nil : phoneValue ?? "无数据",
systemImage: "simcard",
tint: loading ? AITheme.muted : AITheme.ink,
progress: loading ? 0 : (phoneValue == nil ? 0 : 1)
tint: loading ? nil : AITheme.ink
),
makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress),
makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress),
HeaderMetric(
makeMetric(
title: "CPU",
value: loading ? "--" : cpu?.value ?? "无数据",
value: loading ? nil : cpu?.value ?? "无数据",
systemImage: "cpu",
tint: loading ? AITheme.muted : cpu?.tint ?? AITheme.muted,
progress: loading ? 0 : cpu?.progress
tint: loading ? nil : cpu?.tint ?? AITheme.muted,
progress: loading ? nil : cpu?.progress
),
HeaderMetric(
makeMetric(
title: "内存",
value: loading ? "--" : memory?.value ?? "无数据",
value: loading ? nil : memory?.value ?? "无数据",
systemImage: "memorychip",
tint: loading ? AITheme.muted : memory?.tint ?? AITheme.muted,
progress: loading ? 0 : memory?.progress
tint: loading ? nil : memory?.tint ?? AITheme.muted,
progress: loading ? nil : memory?.progress
),
makeMetric(title: "设备", value: loading ? nil : deviceValue, systemImage: "iphone.gen3", tint: loading ? nil : AITheme.ink),
makeMetric(title: "运营商", value: loading ? nil : carrierValue, systemImage: "antenna.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
makeMetric(title: "网络", value: loading ? nil : networkValue, systemImage: "dot.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
makeMetric(title: "IP", value: loading ? nil : ipValue, systemImage: "network", tint: loading ? nil : AITheme.ink),
makeMetric(title: "固件", value: loading ? nil : firmwareValue, systemImage: "waveform.path.ecg", tint: loading ? nil : AITheme.ink),
makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress),
makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink)
]
}
// 头部只保留四块带进度语义的量表,其余信息压缩成文本行。
private static let gaugeOrder: [String] = ["信号", "延迟", "CPU", "内存"]
private var gaugeMetrics: [HeaderMetric] {
let metrics = allHeaderMetrics
return Self.gaugeOrder.compactMap { title in
metrics.first { $0.title == title }
}
}
private var identitySummary: [(label: String, value: String)] {
let metrics = allHeaderMetrics
func derived(_ title: String) -> String? {
metrics.first { $0.title == title }?.value
}
// 顶部只留"这是哪张卡、连在哪个网上"四件事;
// 设备型号、固件、存储、运行时间属于硬件清单,下方分栏里已经逐条列出。
let pairs: [(String, String?)] = [
("号码", derived("号码")),
("运营商", stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])),
("网络", stringValue(
section: "network",
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
)),
("IP", stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"]))
]
return pairs.compactMap { label, value in
guard let value, value.isEmpty == false, value != "无数据" else { return nil }
return (label, value)
}
}
private func makeMetric(
title: String,
value: String?,
@@ -389,43 +492,27 @@ struct InstanceDetailView: View {
}
.padding(11)
.frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.aiInsetSurface()
.accessibilityElement(children: .combine)
}
}
// 统一两列栅格。此前首行放 4 个指标、其余两列,导致首行指标卡只有下方一半宽。
private struct DetailMetricGrid: View {
let metrics: [HeaderMetric]
private let primaryKeys: Set<String> = ["连接状态", "号码", "信号", "延迟"]
private let valueKeys: Set<String> = [
"设备", "运营商", "网络", "IP", "固件", "存储", "运行时间"
]
var body: some View {
let primary = metrics.filter { primaryKeys.contains($0.title) }
let values = metrics.filter { valueKeys.contains($0.title) }
VStack(alignment: .leading, spacing: 9) {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(
stride(from: 0, to: metrics.count, by: 2).map {
Array(metrics[$0..<min($0 + 2, metrics.count)])
},
id: \.first?.id
) { rowMetrics in
GridRow {
ForEach(primary) { DetailHeaderMetric(metric: $0) }
}
}
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(
stride(from: 0, to: values.count, by: 2).map {
Array(values[$0..<min($0 + 2, values.count)])
},
id: \.first?.id
) { rowMetrics in
GridRow {
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
if rowMetrics.count == 1 {
Color.clear
}
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
if rowMetrics.count == 1 {
Color.clear
}
}
}
@@ -433,6 +520,57 @@ struct InstanceDetailView: View {
}
}
// 设备身份信息改为一行摘要,细节交给下方的分区卡片,避免头部堆成十几块同宽小卡。
private struct DetailIdentityStrip: View {
let items: [(label: String, value: String)]
let isLoading: Bool
var body: some View {
if isLoading {
// 首屏还没有数据时用骨架条,避免出现一整列"读取中"。
VStack(alignment: .leading, spacing: 9) {
ForEach(0..<3, id: \.self) { _ in
HStack(spacing: 8) {
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.7))
.frame(width: 42, height: 10)
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 10)
}
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface()
.accessibilityLabel("设备信息读取中")
} else if items.isEmpty == false {
VStack(alignment: .leading, spacing: 6) {
ForEach(items, id: \.label) { item in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(item.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 52, alignment: .leading)
Text(item.value)
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface()
}
}
}
private func moduleIndicator(_ title: String, isAvailable: Bool) -> some View {
HStack(spacing: 4) {
Circle()
@@ -452,8 +590,12 @@ struct InstanceDetailView: View {
.accessibilityElement(children: .combine)
}
// FlowLayout 按固有尺寸排布,这里不能再用 maxWidth: .infinity。
private struct FlowLayout: Layout {
let spacing: CGFloat
var lineSpacing: CGFloat?
private var rowGap: CGFloat { lineSpacing ?? spacing }
func sizeThatFits(
proposal: ProposedViewSize,
@@ -462,19 +604,25 @@ struct InstanceDetailView: View {
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var height: CGFloat = 0
var rowHeight: CGFloat = 0
var totalHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight + rowGap
x = 0
height += size.height + spacing
rowHeight = 0
}
x += size.width + spacing
height = max(height, size.height)
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight
return CGSize(width: max(0, x - spacing), height: height)
return CGSize(width: max(0, widestRow), height: max(0, totalHeight))
}
func placeSubviews(
@@ -491,7 +639,7 @@ struct InstanceDetailView: View {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
y += rowHeight + rowGap
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
@@ -544,16 +692,19 @@ struct InstanceDetailView: View {
}
} else {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
Divider()
.overlay(AITheme.line)
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
// 行与行只靠发丝线分隔,纵向节奏交给 row 自己的内边距,
// 否则卡片会越拉越长,一屏读不到两组数据。
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
.padding(.vertical, 9)
}
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
}
}
@@ -565,7 +716,7 @@ struct InstanceDetailView: View {
case "device": return "iphone.gen3"
case "sim": return "simcard"
case "network": return "antenna.radiowaves.left.and.right"
case "stats": return "gauge.with.needle"
case "stats": return "chart.bar.fill"
case "cpu": return "cpu"
case "connectivity": return "dot.radiowaves.left.and.right"
default: return "list.bullet.rectangle"
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,6 @@ import SwiftUI
enum FleetGroupMode: String, CaseIterable, Identifiable {
case none
case status
case tags
case groups
var id: String { rawValue }
@@ -13,7 +12,6 @@ enum FleetGroupMode: String, CaseIterable, Identifiable {
switch self {
case .none: return "平铺"
case .status: return "状态"
case .tags: return "标签"
case .groups: return "分组"
}
}
@@ -184,12 +182,15 @@ final class FleetViewModel: ObservableObject {
@Published var rows: [Row] = []
@Published var searchText = ""
@Published var statusFilter: ConnectionStatus?
@Published var groupMode: FleetGroupMode = {
let stored = UserDefaults.standard.string(forKey: "fleet.groupMode")
return FleetGroupMode(rawValue: stored ?? "") ?? .groups
}() {
@Published var groupMode: FleetGroupMode = .groups {
didSet {
UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode")
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
}
}
@Published var selectedTagFilters: Set<String> = [] {
didSet {
let values = Array(selectedTagFilters).sorted()
preferences.set(values, forKey: "fleet.selectedTagFilters")
}
}
@Published var selectedIDs: Set<UUID> = []
@@ -206,19 +207,35 @@ final class FleetViewModel: ObservableObject {
private let credentials: any CredentialStore
private let deviceService: (any DeviceSummaryService)?
private let groupStore: (any InstanceGroupStore)?
private let preferences: UserDefaults
init(
repository: any InstanceRepository,
probe: ConnectionProbe,
credentials: any CredentialStore,
deviceService: (any DeviceSummaryService)? = nil,
groupStore: (any InstanceGroupStore)? = nil
groupStore: (any InstanceGroupStore)? = nil,
preferences: UserDefaults = .standard
) {
self.repository = repository
self.probe = probe
self.credentials = credentials
self.deviceService = deviceService
self.groupStore = groupStore
self.preferences = preferences
// 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。
let storedMode = preferences.string(forKey: "fleet.groupMode")
groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
}
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
func pruneTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
if live != selectedTagFilters {
selectedTagFilters = live
}
}
func refresh() async {
@@ -243,6 +260,7 @@ final class FleetViewModel: ObservableObject {
} catch {
errorMessage = "无法刷新节点列表"
}
pruneTagFilters()
lastRefresh = Date()
isLoading = false
}
@@ -400,6 +418,13 @@ final class FleetViewModel: ObservableObject {
if let statusFilter, row.status != statusFilter {
return false
}
if selectedTagFilters.isEmpty == false {
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
guard needles.isSubset(of: tags) else {
return false
}
}
guard !searchText.isEmpty else { return true }
let haystacks: [String] = [
row.instance.name,
@@ -417,6 +442,47 @@ final class FleetViewModel: ObservableObject {
}
}
var allTags: [String] {
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
}
var tagCounts: [String: Int] {
var counts: [String: Int] = [:]
for row in rows {
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
counts[tag, default: 0] += 1
}
}
return counts
}
func countForTagFilter(_ tag: String) -> Int {
tagCounts[Self.normalizedTag(tag)] ?? 0
}
func isTagFilterActive(_ tag: String) -> Bool {
selectedTagFilters.contains(Self.normalizedTag(tag))
}
func toggleTagFilter(_ tag: String) {
let normalized = Self.normalizedTag(tag)
guard normalized.isEmpty == false else { return }
if selectedTagFilters.contains(normalized) {
selectedTagFilters.remove(normalized)
} else {
selectedTagFilters.insert(normalized)
}
}
func clearTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
selectedTagFilters.removeAll()
}
private static func normalizedTag(_ tag: String) -> String {
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
var groupedRows: [FleetGroup] {
let rows = visibleRows.sorted {
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
@@ -431,19 +497,6 @@ final class FleetViewModel: ObservableObject {
guard !matched.isEmpty else { return nil }
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
}
case .tags:
let tags = Array(Set(rows.flatMap(\.instance.tags))).sorted()
let grouped = tags.map { tag in
FleetGroup(
id: tag,
title: tag,
rows: rows.filter { $0.instance.tags.contains(tag) }
)
}
let untagged = rows.filter { $0.instance.tags.isEmpty }
return grouped + (untagged.isEmpty
? []
: [FleetGroup(id: "__untagged__", title: "未打标签", rows: untagged)])
case .groups:
var groupedRows: [UUID: [Row]] = [:]
var ungroupedRows: [Row] = []
@@ -6,7 +6,8 @@ struct InstanceFormView: View {
let instance: Instance?
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var tags: [String] = []
@State private var tagDraft = ""
@State private var password = ""
@State private var selectedGroupId: UUID?
@State private var validationMessage: String?
@@ -17,7 +18,7 @@ struct InstanceFormView: View {
self.instance = instance
_name = State(initialValue: instance?.name ?? "")
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
_tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "")
_tags = State(initialValue: instance?.tags ?? [])
_selectedGroupId = State(initialValue: instance?.groupId)
}
@@ -34,12 +35,12 @@ struct InstanceFormView: View {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
tagEditor
groupPicker
field(
"实例密码",
text: $password,
placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码",
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
secure: true
)
}
@@ -104,6 +105,14 @@ struct InstanceFormView: View {
.accessibilityElement(children: .contain)
}
private var tagEditor: some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("标签")
TagEditorView(tags: $tags, suggestions: model.allTags)
}
.accessibilityElement(children: .contain)
}
private func field(
_ title: String,
text: Binding<String>,
@@ -121,12 +130,12 @@ struct InstanceFormView: View {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel(title)
.submitLabel(title == "地址" ? .next : .done)
.submitLabel(.done)
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
.autocorrectionDisabled()
}
}
}
@@ -143,11 +152,6 @@ struct InstanceFormView: View {
isSaving = false
return
}
let tags =
tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: normalizedName,
origin: origin,
@@ -1,18 +1,35 @@
import SwiftUI
/// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。
struct InstanceGroupEditorView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
let targetIDs: [UUID]
let onSaved: () -> Void
@State private var selectedGroupID: UUID?
@State private var isCreatingGroup = false
@State private var newGroupName = ""
@State private var isWorking = false
@State private var errorMessage: String?
private var selectedCount: Int { targetIDs.count }
private var targets: [UUID] {
targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
}
private var targetGroupIDs: [UUID?] {
let owner = Dictionary(model.rows.map { ($0.id, $0.instance.groupId) }, uniquingKeysWith: { a, _ in a })
return targets.compactMap { owner[$0] }
}
/// 目标实例当前是否同属一个分组;nil 表示 mixed,不显示对勾。
private var uniformGroup: UUID?? {
guard let first = targetGroupIDs.first else { return nil }
return targetGroupIDs.allSatisfy { $0 == first } ? .some(first) : nil
}
private var trimmedName: String {
newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationStack {
@@ -26,56 +43,63 @@ struct InstanceGroupEditorView: View {
)
}
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle(
"批量归类",
systemImage: "folder.badge.gearshape",
detail: selectedCount == 0 ? "全部实例" : "\(selectedCount) 个实例"
)
Text("选择一个分组后保存,所选实例会立即移入该分组。")
.font(.footnote)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
AISectionTitle(
"移动到",
systemImage: "folder",
detail: "\(targets.count) 台实例"
)
if isCreatingGroup {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 12) {
AIFieldLabel("新分组名称")
TextField("例如:家里、外场、测试机", text: $newGroupName)
.textFieldStyle(AIFieldStyle())
.submitLabel(.done)
}
}
} else {
AICard(padding: 8) {
VStack(spacing: 2) {
groupRow(
title: "未分组",
subtitle: "移出当前分组",
systemImage: "square.dashed",
value: nil,
isSelected: selectedGroupID == nil,
selectionTrait: selectedGroupID == nil
)
.onSubmit { Task { await createAndAssign() } }
AIActionRow(spacing: 8) {
Button("取消") {
isCreatingGroup = false
newGroupName = ""
errorMessage = nil
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.disabled(isWorking)
ForEach(model.groups) { group in
Divider()
.overlay(AITheme.line.opacity(0.55))
groupRow(
title: group.name,
subtitle: "\(instanceCount(group.id)) 个实例",
systemImage: "folder",
value: group.id,
isSelected: selectedGroupID == group.id,
selectionTrait: selectedGroupID == group.id
)
Button {
Task { await createAndAssign() }
} label: {
Label("创建并移入", systemImage: "folder.badge.plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(trimmedName.isEmpty || isWorking)
}
}
}
}
AICard(padding: 8) {
VStack(spacing: 2) {
groupRow(
title: "未分组",
subtitle: "不归属任何分组",
systemImage: "square.dashed",
groupID: nil
)
ForEach(model.groups) { group in
Divider()
.overlay(AITheme.line.opacity(0.55))
groupRow(
title: group.name,
subtitle: "\(instanceCount(group.id)) 台在此",
systemImage: "folder",
groupID: group.id
)
}
}
}
.opacity(isWorking ? 0.55 : 1)
}
.padding(16)
}
@@ -89,28 +113,13 @@ struct InstanceGroupEditorView: View {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button("新建") {
selectedGroupID = nil
isCreatingGroup = true
AIIconButton("新建分组", systemImage: "plus") {
errorMessage = nil
isCreatingGroup = true
}
.disabled(isWorking)
}
}
.aiBottomActionBar {
AIActionRow {
Button {
Task { await save() }
} label: {
Label(
selectedGroupID == nil ? "移出分组" : "移入分组",
systemImage: "tray.and.arrow.down"
)
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(isWorking || (selectedGroupID == nil && !isCreatingGroup))
}
}
}
}
@@ -118,23 +127,27 @@ struct InstanceGroupEditorView: View {
title: String,
subtitle: String,
systemImage: String,
value: UUID?,
isSelected: Bool,
selectionTrait: Bool
groupID: UUID?
) -> some View {
Button {
selectedGroupID = value
isCreatingGroup = false
newGroupName = ""
errorMessage = nil
guard uniformGroup != groupID else {
dismiss()
return
}
Task { await apply(groupID: groupID) }
} label: {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted)
.frame(width: 32, height: 32)
.background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10))
.background((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
// 深色下 10% 色洗几乎看不出边界,补一圈同色淡描边保住方块轮廓。
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.20))
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
@@ -150,56 +163,64 @@ struct InstanceGroupEditorView: View {
Spacer(minLength: 8)
Image(
systemName: isSelected
? "checkmark.circle.fill"
: "circle.dashed"
)
.font(.system(size: 18))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
Group {
if isWorking {
EmptyView()
} else if isCurrent(groupID) {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AITheme.primary)
}
}
.frame(width: 20, alignment: .trailing)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 54)
.background(isSelected ? AITheme.primary.opacity(0.08) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title),\(subtitle)")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
.accessibilityLabel(isCurrent(groupID) ? "\(title),当前分组" : "移动到\(title)")
.accessibilityAddTraits(.isButton)
}
.buttonStyle(.plain)
.disabled(isWorking)
}
private func isCurrent(_ groupID: UUID?) -> Bool {
uniformGroup == groupID && targetGroupIDs.isEmpty == false
}
private func instanceCount(_ id: UUID) -> Int {
model.rows.filter { $0.instance.groupId == id }.count
}
private func save() async {
private func apply(groupID: UUID?) async {
guard !isWorking else { return }
isWorking = true
errorMessage = nil
do {
if isCreatingGroup {
let name = newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
let group = try await model.createGroup(named: name)
selectedGroupID = group.id
isCreatingGroup = false
newGroupName = ""
}
try await model.assign(
groupID: selectedGroupID,
to: targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
)
try await model.assign(groupID: groupID, to: targets)
onSaved()
dismiss()
} catch {
errorMessage = isCreatingGroup
? "创建分组失败,名称不能为空且不能重复。"
: "保存分组失败,请检查节点后重试。"
errorMessage = "分组保存失败,请重试。"
isWorking = false
}
}
private func createAndAssign() async {
guard !isWorking, trimmedName.isEmpty == false else { return }
isWorking = true
errorMessage = nil
do {
let group = try await model.createGroup(named: trimmedName)
try await model.assign(groupID: group.id, to: targets)
onSaved()
dismiss()
} catch {
errorMessage = "创建分组失败,名称不能为空且不能重复。"
isWorking = false
}
isWorking = false
}
}
@@ -8,6 +8,7 @@ struct InstanceGroupManagerView: View {
@State private var editingName = ""
@State private var validationMessage: String?
@State private var isWorking = false
@State private var pendingDelete: InstanceGroup?
var body: some View {
NavigationStack {
@@ -62,24 +63,16 @@ struct InstanceGroupManagerView: View {
Spacer()
Button {
AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) {
editingGroup = group
editingName = group.name
validationMessage = nil
} label: {
Image(systemName: "pencil")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 44))
Button {
Task {
await delete(group)
}
} label: {
Image(systemName: "trash")
AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) {
pendingDelete = group
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44))
.disabled(instanceCount(group.id) > 0 || isWorking)
.disabled(isWorking)
}
}
}
@@ -124,6 +117,23 @@ struct InstanceGroupManagerView: View {
} message: {
Text("分组名称最多 100 个字符。")
}
.confirmationDialog(
pendingDelete == nil
? "删除分组?"
: "删除「\(pendingDelete!.name)」?该分组下的 \(instanceCount(pendingDelete!.id)) 个实例会回到未分组。",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
titleVisibility: .visible
) {
if let group = pendingDelete {
Button("删除分组", role: .destructive) {
Task { await delete(group) }
}
}
Button("取消", role: .cancel) {}
}
.task {
await model.loadGroups()
}
@@ -163,6 +173,7 @@ struct InstanceGroupManagerView: View {
private func delete(_ group: InstanceGroup) async {
guard !isWorking else { return }
isWorking = true
pendingDelete = nil
do {
try await model.deleteGroup(group.id)
} catch {
@@ -0,0 +1,160 @@
import SwiftUI
/// 标签编辑器:已选标签用可删除胶囊展示,历史标签可点选复用。
struct TagEditorView: View {
@Binding var tags: [String]
let suggestions: [String]
@State private var draft = ""
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
TextField("新标签,回车添加", text: $draft)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
#endif
.onSubmit { commit(draft) }
Button {
commit(draft)
} label: {
Image(systemName: "plus")
.font(.system(size: 15, weight: .semibold))
.frame(width: 44, height: 44)
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.disabled(canAdd == false)
.accessibilityLabel("添加标签")
}
if tags.isEmpty == false || suggestions.isEmpty == false {
// 已选与可选分成两条横向胶囊带:自定义流式布局在弹层里拿到的宽度提案不稳定,
// 会出现高度算少、胶囊压到下一个字段的问题,横向滚动条带既稳又和节点页一致。
if tags.isEmpty == false {
chipStrip(ids: tags.map { "sel-\($0)" }) { index in
removableTag(tags[index])
}
}
if availableSuggestions.isEmpty == false {
chipStrip(ids: availableSuggestions.map { "sug-\($0)" }) { index in
suggestionTag(availableSuggestions[index])
}
}
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("标签")
}
private func chipStrip<Item: View>(
ids: [String],
@ViewBuilder content: @escaping (Int) -> Item
) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(ids.enumerated()), id: \.element) { index, _ in
content(index)
}
}
.padding(.vertical, 2)
}
.accessibilityHidden(false)
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canAdd: Bool {
trimmedDraft.isEmpty == false
}
private var availableSuggestions: [String] {
let taken = Set(tags.map(Self.key))
return suggestions
.filter { taken.contains(Self.key($0)) == false }
.prefix(10)
.map { $0 }
}
private func removableTag(_ tag: String) -> some View {
Button {
tags.removeAll { Self.key($0) == Self.key(tag) }
} label: {
HStack(spacing: 5) {
Text(tag)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.opacity(0.65)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.ink)
.background(AITheme.control)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.line)
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("移除标签 \(tag)")
}
private func suggestionTag(_ tag: String) -> some View {
Button {
add(tag)
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
.font(.system(size: 10, weight: .bold))
Text(tag)
.font(.footnote.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.primary)
.background(AITheme.primary.opacity(0.09))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.primary.opacity(0.24))
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("使用已有标签 \(tag)")
}
private func commit(_ raw: String) {
let parts = raw
.split(whereSeparator: { $0 == "," || $0 == "," || $0 == "\n" })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
parts.forEach(add)
draft = ""
}
private func add(_ tag: String) {
let value = tag.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.isEmpty == false else { return }
guard tags.contains(where: { Self.key($0) == Self.key(value) }) == false else { return }
tags.append(value)
}
private static func key(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
@@ -18,17 +18,38 @@ struct LogsView: View {
self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear
}
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
/// 空列表配一排筛选器只是噪音,有内容才给筛选入口。
private var hasRowsForTab: Bool {
switch tab {
case .runtime: return model.filteredRuntime.isEmpty == false
case .connections: return model.filteredConnections.isEmpty == false
case .diagnostics: return model.filteredDiagnostics.isEmpty == false
}
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if model.snapshot == nil && model.isLoading {
if isUnavailable {
AICard {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取日志",
message: model.errorMessage ?? "所有节点都没有返回日志数据。",
actionTitle: "重试"
) {
Task { await model.refresh() }
}
}
} else if model.snapshot == nil && model.isLoading {
AILoadingCard("正在同步日志数据")
} else {
stats
AISegmentedControl(
"日志视图",
selection: $tab,
@@ -39,7 +60,14 @@ struct LogsView: View {
]
)
filterBar
if hasRowsForTab {
filterBar
}
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
switch tab {
case .runtime:
@@ -58,11 +86,7 @@ struct LogsView: View {
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIToolbarButton(
"刷新",
systemImage: "arrow.clockwise",
kind: .primary
) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
@@ -90,67 +114,74 @@ struct LogsView: View {
}
}
/// 三块全 0 的读数和下方的空态说的是同一件事,没有记录时整段不出现。
private var stats: some View {
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)
let runtime = model.snapshot?.runtime.count ?? 0
let connections = model.snapshot?.connections.count ?? 0
let diagnostics = model.snapshot?.diagnostics.count ?? 0
return Group {
if runtime + connections + diagnostics > 0 {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
) {
AIStatCard(
title: "运行日志", value: "\(runtime)", systemImage: "list.bullet", tint: AITheme.primary)
AIStatCard(
title: "连接探测", value: "\(connections)", systemImage: "dot.radiowaves.left.and.right",
tint: model.offlineCount == 0 ? AITheme.success : AITheme.warning)
AIStatCard(
title: "设备诊断", value: "\(diagnostics)", systemImage: "iphone.gen3", tint: AITheme.ink)
}
}
}
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
VStack(spacing: 8) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText, minHeight: AITheme.compactControlHeight)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
if tab == .runtime {
HStack(spacing: 8) {
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(compact: true)
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(compact: true)
}
} 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()
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()
}
}
.transition(.opacity)
.aiSelectSurface(compact: true)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
private var runtimeSection: some View {
@@ -158,7 +189,7 @@ struct LogsView: View {
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
AICard {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。")
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "所有节点都没有返回运行记录。")
}
} else {
ForEach(model.filteredRuntime) { entry in
@@ -255,10 +286,6 @@ struct LogsView: View {
}
}
private var onlineCount: Int {
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
switch level {
case .info: return AITheme.success
@@ -15,7 +15,8 @@ public enum SMSDirection: String, Equatable, Sendable {
}
public struct SMSMessage: Equatable, Identifiable, Sendable {
public let id: String
/// 上游消息 id 只在单个节点内唯一,聚合展示时不能直接当 Identifiable 用。
public let upstreamID: String
public let instanceID: UUID
public let instanceName: String
public let phoneNumber: String
@@ -23,6 +24,48 @@ public struct SMSMessage: Equatable, Identifiable, Sendable {
public let direction: SMSDirection
public let status: String
public let timestamp: String
public var id: String { "\(instanceID.uuidString)#\(upstreamID)" }
/// 上游 status 是英文原词,列表里直接展示会混进一行英文;未知值仍然原样显示,不做假结论。
public var statusLabel: String? {
let key = status.trimmingCharacters(in: .whitespaces).lowercased()
guard key.isEmpty == false else { return nil }
switch key {
case "received", "in", "incoming": return "已接收"
case "sent": return "已发送"
case "delivered": return "已送达"
case "read": return "已读"
case "failed", "error": return "发送失败"
case "pending", "queued", "sending": return "发送中"
default: return status
}
}
/// 收件的"已接收"和方向标签说的是同一件事,只有发件状态才值得再占一个位置。
public var deliveryLabel: String? {
guard direction == .outgoing else { return nil }
return statusLabel
}
public init(
upstreamID: String,
instanceID: UUID,
instanceName: String,
phoneNumber: String,
content: String,
direction: SMSDirection,
status: String,
timestamp: String
) {
self.upstreamID = upstreamID
self.instanceID = instanceID
self.instanceName = instanceName
self.phoneNumber = phoneNumber
self.content = content
self.direction = direction
self.status = status
self.timestamp = timestamp
}
}
public struct MessagePage: Equatable, Sendable {
@@ -133,7 +133,7 @@ public struct MessageService: Sendable {
: directionText == "outgoing" ? .outgoing
: .unknown
return SMSMessage(
id: id,
upstreamID: id,
instanceID: instance.id,
instanceName: instance.name,
phoneNumber: value["phone_number"] as? String ?? "",
@@ -12,24 +12,35 @@ struct MessagesView: View {
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
} else if model.filteredRows.isEmpty {
AICard {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
if let errorMessage = model.errorMessage {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取消息",
message: errorMessage,
actionTitle: "重试"
) {
Task { await model.refresh() }
}
} else {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "所有节点都没有返回短信。"
)
}
}
} else {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
@@ -45,6 +56,14 @@ struct MessagesView: View {
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("消息中心")
.toolbar {
ToolbarItem(placement: .primaryAction) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
@@ -71,68 +90,83 @@ struct MessagesView: View {
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
VStack(spacing: 8) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
.pickerStyle(.menu)
.aiSelectSurface()
)
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)
.aiSelectSurface(compact: true)
// 选择器右侧原本是一整片空白,这里补上当前条数,让这一行读完就知道结果规模。
.overlay(alignment: .trailing) {
Text("\(model.filteredRows.count) 条")
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
.padding(.trailing, 12)
.accessibilityHidden(true)
}
AISearchField("搜索号码或内容", text: $model.searchText, minHeight: AITheme.compactControlHeight)
}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
AICard(padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.headline)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(
message.direction.label,
tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
Spacer(minLength: 8)
Text(TimestampText.short(message.timestamp))
.font(.caption2.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.faint)
.lineLimit(1)
.accessibilityLabel("时间 \(message.timestamp)")
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
HStack(alignment: .center, spacing: 8) {
Label(message.instanceName, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption)
.labelStyle(.titleAndIcon)
.imageScale(.small)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
if let delivery = message.deliveryLabel {
Text(delivery)
.font(.caption2.weight(.medium))
.foregroundStyle(delivery == "发送失败" ? AITheme.danger : AITheme.faint)
.lineLimit(1)
}
Spacer(minLength: 8)
Button {
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger) {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
@@ -61,9 +61,9 @@ final class MessagesViewModel: ObservableObject {
let page = try await service.messages(loaded, limit: 100)
rows = page.messages
} catch MessageServiceError.unauthorized {
errorMessage = "有实例需要登录,未获取受保护消息。"
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
} catch {
errorMessage = "无法加载消息。"
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
}
isLoading = false
}
@@ -80,7 +80,7 @@ final class MessagesViewModel: ObservableObject {
guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return }
do {
try await service.delete([message.id], from: instance)
rows.removeAll { $0.id == message.id && $0.instanceID == message.instanceID }
rows.removeAll { $0.id == message.id }
} catch {
errorMessage = "删除消息失败。"
}
@@ -20,8 +20,8 @@ struct SettingsView: View {
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
}
runtimeCard
themeCard
runtimeCard
storageCard
securityCard
dangerZoneCard
@@ -33,11 +33,7 @@ struct SettingsView: View {
.navigationTitle("设置")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIToolbarButton(
"刷新",
systemImage: "arrow.clockwise",
kind: .primary
) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
@@ -67,7 +63,7 @@ struct SettingsView: View {
AISectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))")
AIKeyValueRow("设备", value: runtime.deviceModel)
AIKeyValueRow("系统", value: runtime.systemVersion)
} else {
Text("正在读取应用信息…")
.font(.subheadline)
@@ -80,19 +76,51 @@ struct SettingsView: View {
private var themeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("外观", systemImage: "circle.lefthalf.filled", detail: themeStore.mode.label)
AISegmentedControl(
"主题模式",
selection: Binding(
get: { themeStore.mode },
set: { themeStore.set($0) }
),
options: ThemeMode.allCases.map { (value: $0, title: $0.label) }
)
// 三个选项块本身已经标出当前模式,标题右侧再重复一次是冗余信息。
AISectionTitle("主题外观", systemImage: "circle.lefthalf.filled")
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
GridRow {
ForEach(ThemeMode.allCases) { mode in
themeOption(mode)
}
}
}
}
}
}
private func themeOption(_ mode: ThemeMode) -> some View {
let isSelected = themeStore.mode == mode
return Button {
themeStore.set(mode)
} label: {
VStack(spacing: 7) {
Image(systemName: mode.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.accessibilityHidden(true)
Text(mode.label)
.font(.caption.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, minHeight: 68)
.background(isSelected ? AITheme.primary.opacity(0.10) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(isSelected ? AITheme.primary.opacity(0.42) : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("主题:\(mode.label)")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
}
private var storageCard: some View {
AICard {
NavigationLink {
@@ -107,8 +135,7 @@ struct SettingsView: View {
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
}
AIKeyValueRow("数据位置", value: "仅保存在本机设备")
AIKeyValueRow("当前占用", value: model.storageLabel)
AIKeyValueRow("日志记录", value: model.storageLabel)
}
}
.buttonStyle(.plain)
@@ -120,14 +147,21 @@ struct SettingsView: View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("隐私与数据", systemImage: "lock.shield")
AIKeyValueRow("节点信息", value: "仅保存在本机设备")
AIKeyValueRow("实例密码", value: "保存在本地数据文件中")
AIKeyValueRow("登录会话", value: "应用关闭后自动清除")
AIStatusBanner(
"节点地址、实例密码和运行数据都保存在本机。应用只连接你添加的节点,登录会话关闭应用后即被清除。",
tint: AITheme.primary,
systemImage: "shield.checkered"
)
AIKeyValueRow("节点与凭据", value: "仅写入本机存储")
AIKeyValueRow("网络请求", value: "只访问你添加的节点")
// 卡内再套一个描边框会变成"卡中卡",这里退成一段脚注。
HStack(alignment: .top, spacing: 7) {
Image(systemName: "checkmark.seal")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AITheme.success)
.padding(.top, 2)
.accessibilityHidden(true)
Text("不经过中转服务器,也不上传遥测数据;节点地址、密码与日志只保存在这台 iPhone 上,删除应用即彻底清除。")
.font(.caption)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 2)
}
}
}
@@ -136,12 +170,35 @@ struct SettingsView: View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复")
// 整块实心红是这页最大的色块,会把"读设置"的视线抢走;退成一行危险色入口。
Button {
showsClearDialog = true
} label: {
Label("清除运行日志", systemImage: "trash")
HStack(spacing: 8) {
Image(systemName: "trash")
.font(.system(size: 14, weight: .semibold))
.accessibilityHidden(true)
Text("清除运行日志")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.opacity(0.7)
.accessibilityHidden(true)
}
.foregroundStyle(AITheme.danger)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.compactControlHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AITheme.danger.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(AITheme.danger.opacity(0.20))
}
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
.buttonStyle(.plain)
}
}
}
@@ -1,12 +1,14 @@
import Foundation
import Darwin
#if canImport(UIKit)
import UIKit
#endif
struct SettingsRuntime: Equatable {
let appName: String
let marketingVersion: String
let buildVersion: String
let platform: String
let deviceModel: String
let systemVersion: String
}
@MainActor
@@ -32,7 +34,7 @@ final class SettingsViewModel: ObservableObject {
marketingVersion: AppMetadata.marketingVersion,
buildVersion: AppMetadata.buildVersion,
platform: "iOS",
deviceModel: Self.deviceModel()
systemVersion: Self.systemVersion()
)
await loadStorageLabel()
@@ -64,14 +66,14 @@ final class SettingsViewModel: ObservableObject {
}
}
private static func deviceModel() -> String {
var systemInfo = utsname()
guard uname(&systemInfo) == 0 else { return "未知设备" }
let bytes = withUnsafeBytes(of: systemInfo.machine) { Data($0) }
let model = String(
decoding: bytes.prefix { $0 != 0 },
as: UTF8.self
)
return model.isEmpty ? "未知设备" : model
/// 只报对用户有意义的系统版本,不暴露机器标识之类的原始信息。
private static func systemVersion() -> String {
#if canImport(UIKit)
let device = UIDevice.current
return "\(device.systemName) \(device.systemVersion)"
#else
let version = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(version.majorVersion).\(version.minorVersion)"
#endif
}
}