release(ios): 0.2.2 移除分组改用标签归类并完成石墨配色重构
- 删除分组实体:分组视图/编辑器/仓储与 groupId 全部移除,升级首启清理 instance-groups.json 与 fleet.groupMode - 新增批量标签表:三态覆盖度(未贴 / N/M / 全部),整批加减与新建标签 - 配色重构,全应用零蓝色:石墨墨色承担主操作与选中态,颜色只留给状态语义; 浅色改中性冷灰,深色改低明度石墨三层 - 选中态改实心主色 + onPrimary,替换不可读的低透明度色雾(主题块、图例胶囊) - 应用图标重做为扁平石墨底 + 黄铜 SIM 面板,参数化光栅化脚本与参考稿 - 运行概览:检测进度并入副标题,读数栅格固定八格两行并保留不可用占位 - 实例详情:仪表改两列发丝线栅格,缺数据留轨道,修掉头部高度回弹; 补齐设备名称/存储总量/可用存储/使用率四个字段名 - 版本号显示去掉括号,设置页只显示 0.2.2 - 文档:新增 docs/ios-native-0.2.2.md,同步 ios-native.md 与两处 README
This commit is contained in:
@@ -1,135 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case status
|
||||
case groups
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .none: return "平铺"
|
||||
case .status: return "状态"
|
||||
case .groups: return "分组"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FleetGroup: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let rows: [FleetViewModel.Row]
|
||||
}
|
||||
|
||||
struct FleetGroupHealth: Equatable {
|
||||
var total = 0
|
||||
var online = 0
|
||||
var abnormal = 0
|
||||
var testing = 0
|
||||
var signalValues: [Int] = []
|
||||
var latencyValues: [Int] = []
|
||||
var batteryValues: [Double] = []
|
||||
var storageValues: [Double] = []
|
||||
var trafficValues: [Double] = []
|
||||
var onlineValues: [Bool] = []
|
||||
var abnormalValues: [Bool] = []
|
||||
|
||||
private var signalAverage: Double? {
|
||||
average(signalValues.map(Double.init))
|
||||
}
|
||||
|
||||
private var latencyAverage: Double? {
|
||||
average(latencyValues.map(Double.init))
|
||||
}
|
||||
|
||||
private var batteryAverage: Double? {
|
||||
average(batteryValues)
|
||||
}
|
||||
|
||||
private var storageAverage: Double? {
|
||||
average(storageValues)
|
||||
}
|
||||
|
||||
private var trafficTotal: Double {
|
||||
trafficValues.reduce(0, +)
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
var parts = ["\(total) 台"]
|
||||
if let signal = signalAverage {
|
||||
parts.append("信号 \(Int(signal.rounded()))%")
|
||||
}
|
||||
if let latency = latencyAverage {
|
||||
parts.append("延迟 \(Int(latency.rounded())) ms")
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var signalTint: Color? {
|
||||
signalAverage.map { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) }
|
||||
}
|
||||
|
||||
var latencyTint: Color? {
|
||||
latencyAverage.map { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) }
|
||||
}
|
||||
|
||||
var batteryTint: Color? {
|
||||
batteryAverage.map { $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) }
|
||||
}
|
||||
|
||||
var storageTint: Color? {
|
||||
storageAverage.map { $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) }
|
||||
}
|
||||
|
||||
var trafficTint: Color? {
|
||||
trafficValues.isEmpty ? nil : (trafficTotal >= 50_000_000 ? AITheme.warning : AITheme.success)
|
||||
}
|
||||
|
||||
var onlineAverageText: String {
|
||||
guard onlineValues.isEmpty == false else { return "不可用" }
|
||||
let ratio = Double(onlineValues.filter { $0 }.count) / Double(onlineValues.count)
|
||||
return "\(Int((ratio * 100).rounded()))%"
|
||||
}
|
||||
|
||||
var onlineTint: Color? {
|
||||
onlineValues.isEmpty ? nil : (online > 0 ? AITheme.success : AITheme.muted)
|
||||
}
|
||||
|
||||
var abnormalAverageText: String {
|
||||
guard abnormalValues.isEmpty == false else { return "不可用" }
|
||||
let ratio = Double(abnormalValues.filter { $0 }.count) / Double(abnormalValues.count)
|
||||
return "\(Int((ratio * 100).rounded()))%"
|
||||
}
|
||||
|
||||
var abnormalTint: Color? {
|
||||
abnormalValues.isEmpty ? nil : (abnormal > 0 ? AITheme.danger : AITheme.success)
|
||||
}
|
||||
|
||||
private func average(_ values: [Double]) -> Double? {
|
||||
guard values.isEmpty == false else { return nil }
|
||||
return values.reduce(0, +) / Double(values.count)
|
||||
}
|
||||
|
||||
var signalAverageText: String {
|
||||
signalAverage.map { "\(Int($0.rounded()))%" } ?? "不可用"
|
||||
}
|
||||
|
||||
var latencyAverageText: String {
|
||||
latencyAverage.map { "\(Int($0.rounded())) ms" } ?? "不可用"
|
||||
}
|
||||
|
||||
var storageAverageText: String {
|
||||
storageAverage.map { "\(Int(($0 * 100).rounded()))% 已用" } ?? "不可用"
|
||||
}
|
||||
|
||||
var trafficTotalText: String {
|
||||
guard trafficValues.isEmpty == false else { return "不可用" }
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(trafficTotal), countStyle: .decimal) + "/s"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FleetViewModel: ObservableObject {
|
||||
struct Row: Equatable, Identifiable {
|
||||
@@ -182,11 +53,6 @@ final class FleetViewModel: ObservableObject {
|
||||
@Published var rows: [Row] = []
|
||||
@Published var searchText = ""
|
||||
@Published var statusFilter: ConnectionStatus?
|
||||
@Published var groupMode: FleetGroupMode = .groups {
|
||||
didSet {
|
||||
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
}
|
||||
}
|
||||
@Published var selectedTagFilters: Set<String> = [] {
|
||||
didSet {
|
||||
let values = Array(selectedTagFilters).sorted()
|
||||
@@ -199,14 +65,12 @@ final class FleetViewModel: ObservableObject {
|
||||
@Published private(set) var lastRefresh: Date?
|
||||
@Published var errorMessage: String?
|
||||
@Published var showingForm = false
|
||||
@Published var showingGroupManager = false
|
||||
@Published private(set) var groups: [InstanceGroup] = []
|
||||
@Published var showingTagBatch = false
|
||||
|
||||
private let repository: any InstanceRepository
|
||||
private let probe: ConnectionProbe
|
||||
private let credentials: any CredentialStore
|
||||
private let deviceService: (any DeviceSummaryService)?
|
||||
private let groupStore: (any InstanceGroupStore)?
|
||||
private let preferences: UserDefaults
|
||||
|
||||
init(
|
||||
@@ -214,18 +78,13 @@ final class FleetViewModel: ObservableObject {
|
||||
probe: ConnectionProbe,
|
||||
credentials: any CredentialStore,
|
||||
deviceService: (any DeviceSummaryService)? = 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") ?? [])
|
||||
}
|
||||
|
||||
@@ -241,7 +100,6 @@ final class FleetViewModel: ObservableObject {
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
await loadGroups()
|
||||
do {
|
||||
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
|
||||
let instances = try await repository.list(query)
|
||||
@@ -287,7 +145,6 @@ final class FleetViewModel: ObservableObject {
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
tags: input.tags,
|
||||
groupId: input.groupId,
|
||||
password: input.password
|
||||
)
|
||||
)
|
||||
@@ -318,63 +175,37 @@ final class FleetViewModel: ObservableObject {
|
||||
await delete(Array(selectedIDs))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createGroup(named name: String) async throws -> InstanceGroup {
|
||||
guard let groupStore else { throw RepositoryError.notFound }
|
||||
let group = try await groupStore.create(InstanceGroupInput(name: name))
|
||||
await loadGroups()
|
||||
return group
|
||||
}
|
||||
|
||||
func updateGroup(_ id: UUID, name: String) async throws {
|
||||
guard let groupStore else { return }
|
||||
_ = try await groupStore.update(id, name)
|
||||
await loadGroups()
|
||||
}
|
||||
|
||||
func deleteGroup(_ id: UUID) async throws {
|
||||
guard let groupStore else { return }
|
||||
try await groupStore.delete(id)
|
||||
for row in rows where row.instance.groupId == id {
|
||||
_ = try await repository.update(
|
||||
row.instance.id,
|
||||
expectedRevision: row.instance.configRevision,
|
||||
InstancePatch(groupId: nil)
|
||||
)
|
||||
}
|
||||
await loadGroups()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func assign(groupID: UUID?, to ids: [UUID]) async throws {
|
||||
/// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。
|
||||
func applyTag(_ tag: String, to ids: [UUID], removing: Bool = false) async {
|
||||
let normalized = Self.normalizedTag(tag)
|
||||
guard normalized.isEmpty == false else { return }
|
||||
var failures = 0
|
||||
for id in ids {
|
||||
guard let row = rows.first(where: { $0.id == id }) else { continue }
|
||||
var updated = row.instance.tags.filter { Self.normalizedTag($0) != normalized }
|
||||
if removing == false {
|
||||
updated.append(tag.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
do {
|
||||
_ = try await repository.update(
|
||||
id,
|
||||
expectedRevision: row.instance.configRevision,
|
||||
InstancePatch(groupId: groupID)
|
||||
InstancePatch(tags: updated)
|
||||
)
|
||||
} catch {
|
||||
failures += 1
|
||||
errorMessage = "更新 \(row.instance.name) 分组失败"
|
||||
errorMessage = "更新 \(row.instance.name) 标签失败"
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
throw RepositoryError.databaseFailed
|
||||
if failures > 0, errorMessage == nil {
|
||||
errorMessage = "部分节点标签更新失败"
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func loadGroups() async {
|
||||
guard let groupStore else { return }
|
||||
groups = (try? await groupStore.list()) ?? []
|
||||
}
|
||||
|
||||
func groupName(for id: UUID?) -> String? {
|
||||
guard let id else { return nil }
|
||||
return groups.first { $0.id == id }?.name
|
||||
func tagNames(in ids: [UUID]) -> [String] {
|
||||
let set = Set(rows.filter { ids.contains($0.id) }.flatMap { $0.instance.tags.map(Self.normalizedTag) })
|
||||
return set.sorted { $0.localizedCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func applyProbe(
|
||||
@@ -413,6 +244,7 @@ final class FleetViewModel: ObservableObject {
|
||||
rows[index].testing = false
|
||||
}
|
||||
|
||||
/// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。
|
||||
var visibleRows: [Row] {
|
||||
rows.filter { row in
|
||||
if let statusFilter, row.status != statusFilter {
|
||||
@@ -440,6 +272,21 @@ final class FleetViewModel: ObservableObject {
|
||||
$0.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
.sorted { lhs, rhs in
|
||||
let left = severity(lhs.status)
|
||||
let right = severity(rhs.status)
|
||||
if left != right { return left < right }
|
||||
return lhs.instance.name.localizedCompare(rhs.instance.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func severity(_ status: ConnectionStatus) -> Int {
|
||||
switch status {
|
||||
case .offline: return 0
|
||||
case .authRequired, .degraded: return 1
|
||||
case .unknown: return 2
|
||||
case .online: return 3
|
||||
}
|
||||
}
|
||||
|
||||
var allTags: [String] {
|
||||
@@ -464,6 +311,17 @@ final class FleetViewModel: ObservableObject {
|
||||
selectedTagFilters.contains(Self.normalizedTag(tag))
|
||||
}
|
||||
|
||||
/// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。
|
||||
func tagCoverage(_ tag: String, in ids: [UUID]) -> Int {
|
||||
let needle = Self.normalizedTag(tag)
|
||||
guard needle.isEmpty == false else { return 0 }
|
||||
let targets = Set(ids)
|
||||
return rows.filter { row in
|
||||
targets.contains(row.id)
|
||||
&& row.instance.tags.map(Self.normalizedTag).contains(needle)
|
||||
}.count
|
||||
}
|
||||
|
||||
func toggleTagFilter(_ tag: String) {
|
||||
let normalized = Self.normalizedTag(tag)
|
||||
guard normalized.isEmpty == false else { return }
|
||||
@@ -482,62 +340,6 @@ final class FleetViewModel: ObservableObject {
|
||||
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
|
||||
}
|
||||
switch groupMode {
|
||||
case .none:
|
||||
return [FleetGroup(id: "", title: "", rows: rows)]
|
||||
case .status:
|
||||
let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown]
|
||||
return order.compactMap { status in
|
||||
let matched = rows.filter { $0.status == status }
|
||||
guard !matched.isEmpty else { return nil }
|
||||
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
|
||||
}
|
||||
case .groups:
|
||||
var groupedRows: [UUID: [Row]] = [:]
|
||||
var ungroupedRows: [Row] = []
|
||||
for row in rows {
|
||||
if let id = row.instance.groupId, groupName(for: id) != nil {
|
||||
groupedRows[id, default: []].append(row)
|
||||
} else {
|
||||
ungroupedRows.append(row)
|
||||
}
|
||||
}
|
||||
let namedGroups = groupedRows.map { key, values in
|
||||
FleetGroup(
|
||||
id: key.uuidString,
|
||||
title: groupName(for: key) ?? "未命名分组",
|
||||
rows: values
|
||||
)
|
||||
}
|
||||
.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }
|
||||
return namedGroups + (ungroupedRows.isEmpty
|
||||
? []
|
||||
: [FleetGroup(id: "__ungrouped__", title: "未分组", rows: ungroupedRows)])
|
||||
}
|
||||
}
|
||||
|
||||
func health(for group: FleetGroup) -> FleetGroupHealth {
|
||||
FleetGroupHealth(
|
||||
total: group.rows.count,
|
||||
online: group.rows.filter { $0.status == .online }.count,
|
||||
abnormal: group.rows.filter { $0.status != .online && $0.status != .unknown }.count,
|
||||
testing: group.rows.filter(\.testing).count,
|
||||
signalValues: group.rows.compactMap(\.signalPercent),
|
||||
latencyValues: group.rows.compactMap(\.latencyMilliseconds),
|
||||
batteryValues: group.rows.compactMap(\.batteryPercent),
|
||||
storageValues: group.rows.compactMap(\.storageUsageRatio),
|
||||
trafficValues: group.rows.compactMap(\.downloadRateBytesPerSecond)
|
||||
+ group.rows.compactMap(\.uploadRateBytesPerSecond)
|
||||
,
|
||||
onlineValues: group.rows.map { $0.status == .online },
|
||||
abnormalValues: group.rows.map { $0.status != .online && $0.status != .unknown }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protocol DeviceSummaryService {
|
||||
|
||||
Reference in New Issue
Block a user