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:
chick
2026-09-15 02:23:24 +08:00
parent ec3ffb9db3
commit eb880e0d14
28 changed files with 757 additions and 1524 deletions
@@ -37,7 +37,7 @@ final class FleetViewModelTests: XCTestCase {
XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil })
}
func testVisibleRowsAndGroupModesUseFilteredResults() async throws {
func testVisibleRowsFollowSearchAndTagFilters() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
@@ -52,7 +52,6 @@ final class FleetViewModelTests: XCTestCase {
model.searchText = "home"
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"])
model.searchText = ""
model.clearTagFilters()
@@ -61,9 +60,22 @@ final class FleetViewModelTests: XCTestCase {
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
XCTAssertEqual(model.countForTagFilter("home"), 1)
model.clearTagFilters()
XCTAssertEqual(model.visibleRows.count, 2, "没有筛选时全部节点都要出现")
}
model.groupMode = .status
XCTAssertEqual(model.groupedRows.map(\.title), ["在线"])
func testVisibleRowsPutProblemNodesFirst() async throws {
let model = FleetViewModel(
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: UnreachableForClient(host: "192.168.1.99")),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(name: "Healthy", origin: URL(string: "http://192.168.1.98:3000")!))
try await model.add(InstanceInput(name: "Broken", origin: URL(string: "http://192.168.1.99:3000")!))
await model.refresh()
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Broken", "Healthy"])
}
func testTagFiltersCombineWithAndSemanticsAndPruneStaleSelections() async throws {
@@ -129,89 +141,58 @@ final class FleetViewModelTests: XCTestCase {
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
}
func testGroupModeKeepsUngroupedInstancesVisible() async throws {
let repository = MemoryInstanceRepository()
func testUpdateRewritesNameAndTags() async throws {
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore()
)
try await model.add(InstanceInput(name: "Ungrouped", origin: URL(string: "http://192.168.1.72:3000")!))
await model.refresh()
model.groupMode = .groups
XCTAssertEqual(model.groupedRows.map(\.title), ["未分组"])
XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Ungrouped"])
}
func testStoredGroupsResolveNamesAndCanUpdateInstance() async throws {
let location = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathComponent("groups.json")
let groupStore = FileInstanceGroupStore(location: location)
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
groupStore: groupStore
preferences: transientPreferences()
)
let createdGroup = try await groupStore.create(InstanceGroupInput(name: "外场"))
try await model.add(InstanceInput(
name: "Van",
origin: URL(string: "http://192.168.1.80:3000")!,
groupId: createdGroup.id
tags: ["old"]
))
await model.refresh()
model.groupMode = .groups
XCTAssertEqual(model.groups.map(\.name), ["外场"])
XCTAssertEqual(model.groupedRows.map(\.title), ["外场"])
XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Van"])
let row = try XCTUnwrap(model.rows.first)
try await model.update(row.id, InstanceInput(
name: "Van Pro",
origin: row.instance.origin,
tags: ["field"],
groupId: createdGroup.id
tags: ["field"]
))
XCTAssertEqual(model.rows.first?.instance.name, "Van Pro")
XCTAssertEqual(model.rows.first?.instance.tags, ["field"])
}
func testAssignMovesSelectedInstancesIntoAndOutOfGroup() async throws {
let location = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathComponent("groups.json")
let groupStore = FileInstanceGroupStore(location: location)
let repository = MemoryInstanceRepository()
func testApplyTagAddsAndRemovesAcrossTheWholeSelection() async throws {
let model = FleetViewModel(
repository: repository,
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
groupStore: groupStore
preferences: transientPreferences()
)
let first = try await model.add(InstanceInput(
name: "First",
origin: URL(string: "http://192.168.1.90:3000")!
origin: URL(string: "http://192.168.1.90:3000")!,
tags: ["keep"]
))
let second = try await model.add(InstanceInput(
name: "Second",
origin: URL(string: "http://192.168.1.91:3000")!
))
let group = try await model.createGroup(named: "外场")
await model.refresh()
let ids = [first.id, second.id]
try await model.assign(groupID: group.id, to: [first.id, second.id])
XCTAssertEqual(model.rows.map(\.instance.groupId), [group.id, group.id])
await model.applyTag("外场", to: ids)
XCTAssertEqual(model.rows.map(\.instance.tags).map { $0.contains("外场") }, [true, true])
XCTAssertEqual(model.tagCoverage("外场", in: ids), 2)
try await model.assign(groupID: nil, to: [first.id])
XCTAssertEqual(model.rows.first?.instance.groupId, nil)
XCTAssertEqual(model.rows.last?.instance.groupId, group.id)
// 再点一次只撕掉这一个标签,其他标签必须留下。
await model.applyTag("外场", to: [first.id], removing: true)
XCTAssertEqual(model.rows.first?.instance.tags, ["keep"])
XCTAssertEqual(model.tagCoverage("外场", in: ids), 1)
}
func testAddValidatesPasswordAndDeleteRemovesCredential() async throws {
@@ -274,6 +255,18 @@ struct AlwaysOnlineClient: SimAdminClientProtocol {
}
}
/// 指定主机一律不可达,用来验证"问题节点排在前面"。
struct UnreachableForClient: SimAdminClientProtocol {
let host: String
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
if baseURL.host == host {
throw UpstreamError.transportFailed
}
return UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
final class NoopCredentialStore: @unchecked Sendable, CredentialStore {
private let lock = NSLock()
private var savedStorage: [UUID: String] = [:]
@@ -327,7 +320,6 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
name: input.name,
origin: input.origin,
tags: input.tags,
groupId: input.groupId,
credentialConfigured: !(input.password ?? "").isEmpty,
configRevision: 1,
createdAt: now,
@@ -348,7 +340,6 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
if let name = patch.name { instance.name = name }
if let origin = patch.origin { instance.origin = origin }
if let tags = patch.tags { instance.tags = tags }
if patch.groupId != nil || instance.groupId != nil { instance.groupId = patch.groupId }
if let password = patch.password {
instance.credentialConfigured = !password.isEmpty
}