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
+3 -3
View File
@@ -4,11 +4,11 @@
## iOS Native 应用
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.1 (build 6)**,运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.2**(build 7),运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。
底部为节点、消息、设置三个入口:
- **节点**:运行概览、分组视图、标签多选筛选、搜索、批量选择、实例增删改,卡片直连实例详情。
- **节点**:运行概览、标签多选筛选、标签批量归类、搜索、批量选择、实例增删改,卡片直连实例详情。
- **实例详情**:设备、SIM、蜂窝网络、系统资源、CPU、连接状态六个模块,敏感值默认脱敏,显示后离开页面即隐藏。
- **消息**:跨实例短信读取、按实例与方向筛选、搜索、发送、删除。
- **设置**:主题外观(跟随系统 / 浅色 / 深色)、应用信息、本地数据与日志中心、隐私与数据、数据清理。
@@ -24,7 +24,7 @@
cd apps/ios && swift test # 48 个用例
```
文档:现行说明 [docs/ios-native.md](docs/ios-native.md),发布记录 [0.2.1](docs/ios-native-0.2.1.md) 与 [0.2.0](docs/ios-native-0.2.0.md)。
文档:现行说明 [docs/ios-native.md](docs/ios-native.md),发布记录 [0.2.2](docs/ios-native-0.2.2.md)、[0.2.1](docs/ios-native-0.2.1.md) 与 [0.2.0](docs/ios-native-0.2.0.md)。
## 功能
+17 -30
View File
@@ -1,44 +1,31 @@
<!-- 设计参考稿,构图与 Design/generate_app_icon.swift 一致;构建实际用 Swift 脚本光栅化 1024 PNG。 -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="field" x1="0.10" y1="0" x2="0.92" y2="1">
<stop offset="0" stop-color="#1C4280"/>
<stop offset="0.46" stop-color="#0D2450"/>
<stop offset="1" stop-color="#040B1B"/>
<linearGradient id="field" x1="0.06" y1="0" x2="0.94" y2="1">
<stop offset="0" stop-color="#1A1F1E"/>
<stop offset="0.55" stop-color="#111514"/>
<stop offset="1" stop-color="#0B0E0D"/>
</linearGradient>
<radialGradient id="key" cx="0.26" cy="-0.04" r="0.92">
<stop offset="0" stop-color="#96C4FF" stop-opacity="0.25"/>
<stop offset="0.55" stop-color="#70A2E6" stop-opacity="0.08"/>
<stop offset="1" stop-color="#70A2E6" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vig" cx="0.5" cy="0.52" r="0.78">
<stop offset="0.55" stop-color="#020712" stop-opacity="0"/>
<stop offset="1" stop-color="#020712" stop-opacity="0.59"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFF4CD"/>
<stop offset="0.30" stop-color="#FACE6A"/>
<stop offset="0.62" stop-color="#D69628"/>
<stop offset="1" stop-color="#925C10"/>
<linearGradient id="brass" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#F8D57C"/>
<stop offset="0.50" stop-color="#ECBD56"/>
<stop offset="1" stop-color="#D49E36"/>
</linearGradient>
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#050D1E"/>
<stop offset="0.58" stop-color="#091732"/>
<stop offset="1" stop-color="#112649"/>
<stop offset="0" stop-color="#0F1312"/>
<stop offset="1" stop-color="#1D2321"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="url(#field)"/>
<rect width="1024" height="1024" fill="url(#key)"/>
<rect width="1024" height="1024" fill="url(#vig)"/>
<!-- SIM 卡体:左上切角,其余三角大圆角 -->
<path d="M494.5,218 L688,218 Q822,218 822,352 L822,704 Q822,838 688,838 L336,838
Q202,838 202,704 L202,510.5 Q202,420 266,356 L340,282 Q404,218 494.5,218 Z"
fill="url(#gold)"/>
<path d="M216,403.7 Q216,372 238.4,349.6 L361.6,226.4 Q384,204 415.7,204 L686,204
Q808,204 808,326 L808,674 Q808,796 686,796 L304,796 Q216,796 216,674 Z"
fill="url(#brass)"/>
<!-- 三格信号:底边对齐,右高,读作多卡队列健康度 -->
<rect x="318" y="546" width="88" height="142" rx="26" fill="url(#slot)"/>
<rect x="468" y="446" width="88" height="242" rx="26" fill="url(#slot)"/>
<rect x="618" y="346" width="88" height="342" rx="26" fill="url(#slot)"/>
<!-- 三格插槽:底边对齐,右高,读作多卡队列健康度 -->
<rect x="324" y="504" width="92" height="136" rx="28" fill="url(#slot)"/>
<rect x="470" y="408" width="92" height="232" rx="28" fill="url(#slot)"/>
<rect x="616" y="312" width="92" height="328" rx="28" fill="url(#slot)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+47 -89
View File
@@ -1,11 +1,10 @@
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
// Usage: swift Design/generate_app_icon.swift <output.png>
//
// The mark is the same geometry as the web favicon: a gold SIM plate carrying
// three ascending signal bars. One flat silhouette, one light source, and no
// photographic detail, so it still reads at the 29pt settings size. The plate
// keeps the classic SIM chamfer, moved to the top-left so the tallest bar can
// sit under the intact top-right corner.
// The mark is the web favicon grown up: a flat brass SIM plate with three
// ascending slots cut out of it, on a graphite field. Two colours, one light
// source from the upper left, no metal texture, so it still reads at the 29pt
// settings size. The plate keeps the classic single SIM cut, top-left.
//
// Every coordinate is in visual space (origin top-left, y grows down).
import AppKit
@@ -87,15 +86,15 @@ func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
}
/// SIM plate outline: rounded square with one cut top corner.
func platePath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
let bevel = corner * 0.28
/// SIM plate outline: rounded square with the top-left corner cut at 45 degrees.
func simPath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
let bevel = corner * 0.26
let vertices: [(point: CGPoint, radius: CGFloat)] = [
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
(CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel),
(CGPoint(x: rect.maxX, y: rect.minY), corner),
(CGPoint(x: rect.maxX, y: rect.maxY), corner),
(CGPoint(x: rect.minX, y: rect.maxY), corner),
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
]
let path = CGMutablePath()
path.move(to: vertices[0].point)
@@ -110,7 +109,7 @@ func platePath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
return path
}
/// Bright along the upper-left edges, dead by the lower-right: an even outline
/// Bright along the upper-left edges, gone by the lower-right: an even outline
/// reads like a sticker cut rather than a plate lit from above.
func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [Stop]) {
ctx.saveGState()
@@ -130,38 +129,34 @@ func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [
fillLinear(
rectPath(CGRect(x: 0, y: 0, width: side, height: side)),
[
(0.00, (28, 66, 128, 255)),
(0.46, (13, 36, 80, 255)),
(1.00, (4, 11, 27, 255)),
(0.00, (26, 31, 30, 255)),
(0.55, (17, 21, 20, 255)),
(1.00, (11, 14, 13, 255)),
] as [Stop],
CGPoint(x: side * 0.10, y: 0),
CGPoint(x: side * 0.92, y: side)
CGPoint(x: side * 0.06, y: 0),
CGPoint(x: side * 0.94, y: side)
)
fillRadial(
CGPoint(x: side * 0.26, y: -side * 0.04), CGFloat(side * 0.92),
[(0.0, (150, 196, 255, 64)), (0.55, (112, 162, 230, 20)), (1.0, (112, 162, 230, 0))] as [Stop]
)
fillRadial(
CGPoint(x: side * 0.5, y: side * 0.52), CGFloat(side * 0.78),
[(0.55, (2, 7, 18, 0)), (1.0, (2, 7, 18, 150))] as [Stop]
CGPoint(x: side * 0.22, y: -side * 0.10), CGFloat(side * 0.86),
[(0.0, (214, 226, 218, 26)), (0.60, (214, 226, 218, 7)), (1.0, (214, 226, 218, 0))] as [Stop]
)
// MARK: - Plate
let plateSide = envDouble("ICON_PLATE_SIDE", 620)
let plateCenterY = envDouble("ICON_PLATE_Y", 528)
let plateCorner = envDouble("ICON_PLATE_CORNER", 134)
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 202)
let plateSide = envDouble("ICON_PLATE_SIDE", 592)
let plateCenterY = envDouble("ICON_PLATE_Y", 500)
let plateCorner = envDouble("ICON_PLATE_CORNER", 122)
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 168)
let plateRect = CGRect(
x: (side - plateSide) / 2,
y: plateCenterY - plateSide / 2,
width: plateSide,
height: plateSide
)
let plate = platePath(plateRect, corner: plateCorner, chamfer: plateChamfer)
let plate = simPath(plateRect, corner: plateCorner, chamfer: plateChamfer)
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -30), blur: 62, color: rgb(2, 8, 20, 168))
ctx.setShadow(offset: CGSize(width: 0, height: -22), blur: 46, color: rgb(0, 3, 2, 150))
ctx.addPath(plate)
ctx.setFillColor(rgb(0, 0, 0))
ctx.fillPath()
@@ -170,98 +165,61 @@ ctx.restoreGState()
fillLinear(
plate,
[
(0.00, (255, 244, 205, 255)),
(0.30, (250, 206, 106, 255)),
(0.62, (214, 150, 40, 255)),
(1.00, (146, 92, 16, 255)),
(0.00, (248, 213, 124, 255)),
(0.50, (236, 189, 86, 255)),
(1.00, (212, 158, 54, 255)),
] as [Stop],
CGPoint(x: plateRect.minX, y: plateRect.minY),
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
)
directionalRim(
plate, plateRect, plateSide * 0.016,
plate, plateRect, plateSide * 0.007,
[
(0.00, (255, 251, 232, 230)),
(0.40, (255, 240, 190, 66)),
(0.74, (255, 232, 170, 0)),
(0.00, (255, 244, 214, 132)),
(0.36, (255, 238, 198, 30)),
(0.68, (255, 234, 186, 0)),
] as [Stop]
)
ctx.saveGState()
ctx.addPath(plate)
ctx.clip()
ctx.move(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.52))
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.10))
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.22))
ctx.addLine(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.64))
ctx.closePath()
ctx.setFillColor(rgb(255, 252, 236, 5))
ctx.fillPath()
ctx.restoreGState()
// MARK: - Slots
// MARK: - Signal bars
let slotWidth = envDouble("ICON_SLOT_W", 92)
let slotGap = envDouble("ICON_SLOT_GAP", 54)
let slotBottom = envDouble("ICON_SLOT_BOTTOM", 640)
let slotHeights: [CGFloat] = [136, 232, 328]
let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap
let slotsStartX = (side - slotsWidth) / 2
let barWidth = envDouble("ICON_BAR_W", 88)
let barGap = envDouble("ICON_BAR_GAP", 62)
let barBottom = envDouble("ICON_BAR_BOTTOM", 688)
let barHeights: [CGFloat] = [142, 242, 342]
let barsWidth = CGFloat(barHeights.count) * barWidth + CGFloat(barHeights.count - 1) * barGap
let barsStartX = (side - barsWidth) / 2
for (index, height) in barHeights.enumerated() {
for (index, height) in slotHeights.enumerated() {
let rect = CGRect(
x: barsStartX + CGFloat(index) * (barWidth + barGap),
y: barBottom - height,
width: barWidth,
x: slotsStartX + CGFloat(index) * (slotWidth + slotGap),
y: slotBottom - height,
width: slotWidth,
height: height
)
let radius = barWidth * 0.30
let radius = slotWidth * 0.30
let path = roundedPath(rect, radius)
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: 5), blur: 16, color: rgb(255, 236, 176, 104))
ctx.addPath(path)
ctx.setFillColor(rgb(0, 0, 0))
ctx.fillPath()
ctx.restoreGState()
fillLinear(
path,
[
(0.00, (5, 13, 30, 255)),
(0.58, (9, 23, 50, 255)),
(1.00, (17, 38, 74, 255)),
(0.00, (15, 19, 18, 255)),
(1.00, (29, 35, 33, 255)),
] as [Stop],
CGPoint(x: rect.minX, y: rect.minY),
CGPoint(x: rect.minX, y: rect.maxY)
)
directionalRim(
path, rect, radius * 0.34,
path, rect, radius * 0.30,
[
(0.00, (0, 0, 0, 120)),
(0.48, (0, 0, 0, 26)),
(0.84, (255, 226, 152, 70)),
(1.00, (255, 236, 176, 118)),
(0.00, (120, 84, 18, 96)),
(0.52, (120, 84, 18, 18)),
(1.00, (255, 232, 176, 58)),
] as [Stop]
)
}
// MARK: - Surface sheen
ctx.saveGState()
ctx.addPath(rectPath(CGRect(x: 0, y: 0, width: side, height: side)))
ctx.clip()
ctx.move(to: CGPoint(x: -120, y: 430))
ctx.addLine(to: CGPoint(x: 1140, y: 40))
ctx.addLine(to: CGPoint(x: 1140, y: 214))
ctx.addLine(to: CGPoint(x: -120, y: 604))
ctx.closePath()
ctx.setFillColor(rgb(255, 255, 255, 4))
ctx.fillPath()
ctx.restoreGState()
guard let image = ctx.makeImage() else {
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
exit(1)
+6 -3
View File
@@ -9,7 +9,7 @@ iPhone 原生应用的 Swift Package。产品与技术说明见 [docs/ios-native
Sources/MultiSimAdmin/
AppMetadata.swift 版本号与 Bundle ID 的唯一来源
MultiSimAdminApp.swift 入口、三个底部 Tab、调试直达参数
Models/ 实例、分组、查询等值类型
Models/ 实例、标签、查询等值类型
Features/{Fleet,Device,Messages,Logs,Settings}/ 页面与 view model
Upstream/ 路由白名单、HTTP 客户端、按实例隔离的会话 Cookie
Services/ 连接探测、凭据存储、依赖装配
@@ -49,5 +49,8 @@ README 的版本引用。
## 界面约定
主操作只用一种蓝色,危险操作只用红色;同屏并排控件等高;选中态不得改变控件尺寸;标签统一
28pt 胶囊;卡片内部不再嵌套卡片;不得出现因配色而在深色背景上消失的按钮或文字。
界面不含任何蓝色:主操作、选中态、进度填充一律用石墨墨色(`AITheme.primary` / `AITheme.ink`),
颜色只留给状态语义——危险红、告警琥珀、成功绿,且只在真的表示状态时出现,健康读数保持墨色。
选中态用实心主色加 `onPrimary` 文字,不用低透明度色雾,浅色深色两套主题下都必须一眼可读;
同屏并排控件等高;选中态不得改变控件尺寸;标签统一 28pt 胶囊;卡片内部不再嵌套卡片,
并列读数改用发丝线分隔的栅格;不得出现因配色而在深色背景上消失的按钮或文字。
@@ -1,6 +1,6 @@
public enum AppMetadata {
public static let name = "MultiSimAdmin"
public static let bundleIdentifier = "fun.chick.multi-simadmin"
public static let marketingVersion = "0.2.1"
public static let buildVersion = "6"
public static let marketingVersion = "0.2.2"
public static let buildVersion = "7"
}
@@ -7,15 +7,11 @@ struct InstanceDetailView: View {
init(
instance: Instance,
groupName: String? = nil,
model: DeviceViewModel? = nil
) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
self.groupName = groupName
}
private let groupName: String?
var body: some View {
ScrollView {
VStack(spacing: 12) {
@@ -98,9 +94,6 @@ struct InstanceDetailView: View {
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 ? "已保存" : "未设置")
}
@@ -135,11 +128,8 @@ struct InstanceDetailView: View {
moduleAvailability
if groupName != nil || !model.instance.tags.isEmpty {
if !model.instance.tags.isEmpty {
FlowLayout(spacing: 7) {
if let groupName {
AITag("分组 · \(groupName)", tint: AITheme.primary)
}
ForEach(model.instance.tags, id: \.self) { tag in
AITag(tag)
}
@@ -220,6 +210,8 @@ struct InstanceDetailView: View {
let progress: Double?
var id: String { title }
// 与节点卡片同一规则:只有越过警告线才着色,正常值走墨色。
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
}
private var allHeaderMetrics: [HeaderMetric] {
@@ -428,16 +420,17 @@ struct InstanceDetailView: View {
)
}
/// 阈值只回答"要不要提醒",健康值不着色,四格量表不会染成一片绿。
private func signalTint(_ value: Double) -> Color {
value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger)
value >= 60 ? AITheme.ink : (value >= 30 ? AITheme.warning : AITheme.danger)
}
private func latencyTint(_ value: Double) -> Color {
value < 300 ? AITheme.success : (value < 800 ? AITheme.warning : AITheme.danger)
value < 300 ? AITheme.ink : (value < 800 ? AITheme.warning : AITheme.danger)
}
private func resourceTint(_ value: Double) -> Color {
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success)
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.ink)
}
private struct DetailHeaderMetric: View {
@@ -456,29 +449,20 @@ struct InstanceDetailView: View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(tint)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(metric.isAlert ? tint : AITheme.faint)
.accessibilityHidden(true)
Text(title)
.font(.caption.weight(.semibold))
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
if isProgressVisible {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(tint)
.frame(height: 4)
} else {
Color.clear
.frame(height: 4)
}
if let value {
Text(value)
.font(.subheadline.weight(.semibold))
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
.foregroundStyle(metric.isAlert ? tint : AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.minimumScaleFactor(0.72)
@@ -489,34 +473,65 @@ struct InstanceDetailView: View {
.frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
// 读数在上、进度在下,缺数据时也留一条轨道,数值到位后格子不跳动。
if isProgressVisible {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule(style: .continuous).fill(AITheme.line)
Capsule(style: .continuous)
.fill(metric.isAlert ? tint : AITheme.primary)
.frame(width: max(3, proxy.size.width * CGFloat(min(1, max(0, progress)))))
}
}
.frame(height: 4)
.accessibilityHidden(true)
} else {
Color.clear
.frame(height: 4)
}
}
.padding(11)
.frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading)
.aiInsetSurface()
.frame(maxWidth: .infinity, minHeight: 62, alignment: .topLeading)
.accessibilityElement(children: .combine)
}
}
// 统一两列栅格。此前首行放 4 个指标、其余两列,导致首行指标卡只有下方一半宽。
/// 两列发丝线栅格:量表是头部卡片内的读数,不再各自套一层底色块。
private struct DetailMetricGrid: View {
let metrics: [HeaderMetric]
private var rows: [[HeaderMetric]] {
stride(from: 0, to: metrics.count, by: 2).map {
Array(metrics[$0..<min($0 + 2, metrics.count)])
}
}
var body: some View {
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(rowMetrics) { DetailHeaderMetric(metric: $0) }
if rowMetrics.count == 1 {
VStack(spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, rowMetrics in
if rowIndex > 0 {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
HStack(alignment: .top, spacing: 0) {
ForEach(Array(rowMetrics.enumerated()), id: \.element.id) { columnIndex, metric in
DetailHeaderMetric(metric: metric)
.padding(.leading, columnIndex == 0 ? 0 : 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
if rowMetrics.count < 2 {
Color.clear
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 10)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
}
@@ -541,9 +556,7 @@ struct InstanceDetailView: View {
}
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface()
.accessibilityLabel("设备信息读取中")
} else if items.isEmpty == false {
VStack(alignment: .leading, spacing: 6) {
@@ -564,9 +577,13 @@ struct InstanceDetailView: View {
.accessibilityElement(children: .combine)
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface()
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.padding(.top, 1)
}
}
}
@@ -797,11 +814,15 @@ struct InstanceDetailView: View {
"core_count": "核心数",
"model_name": "CPU 型号",
"state": "连接状态",
"latency_ms": "延迟(ms)"
"latency_ms": "延迟(ms)",
"name": "设备名称",
"disk_total_bytes": "存储总量",
"disk_available_bytes": "可用存储",
"percent": "使用率"
]
private let valueOrder: [String] = [
"model", "manufacturer", "brand", "imei", "meid", "revision", "firmware_version",
"model", "manufacturer", "brand", "name", "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",
@@ -810,7 +831,8 @@ struct InstanceDetailView: View {
"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"
"dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls",
"disk_total_bytes", "disk_available_bytes", "percent"
]
private let durationKeys: Set<String> = ["uptime", "uptime_seconds"]
@@ -0,0 +1,172 @@
import SwiftUI
/// 分组被移除后,批量归类这件事由标签承担:一次点选给多台节点贴同一个标签。
struct FleetTagBatchSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var draft = ""
@State private var isBusy = false
private var targets: [UUID] { model.selectedIDs.sorted { $0.uuidString < $1.uuidString } }
private var selectedNames: [String] {
model.rows.filter { model.selectedIDs.contains($0.id) }.map(\.instance.name).sorted {
$0.localizedCompare($1) == .orderedAscending
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
targetsCard
tagGridCard
newTagCard
}
.padding(16)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("批量标签")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
AIToolbarButton("完成", kind: .primary) { dismiss() }
}
}
}
}
private var targetsCard: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("已选 \(targets.count) 台", systemImage: "checklist")
Text(selectedNames.joined(separator: "、"))
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(3)
.truncationMode(.tail)
}
}
}
private var tagGridCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("现有标签", systemImage: "tag")
if model.allTags.isEmpty {
Text("还没有标签,先在下方新建一个。")
.font(.caption)
.foregroundStyle(AITheme.faint)
} else {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 112), alignment: .leading)],
alignment: .leading,
spacing: 8
) {
ForEach(model.allTags, id: \.self) { tag in
CoverageChip(
title: tag,
covered: model.tagCoverage(tag, in: targets),
total: targets.count,
isBusy: isBusy
) {
Task { await apply(tag, removing: model.tagCoverage(tag, in: targets) == targets.count) }
}
}
}
}
}
}
}
private var newTagCard: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AIFieldLabel("新建标签并贴上")
HStack(spacing: 10) {
TextField("如 深圳-现场", text: $draft)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("新标签名称")
.submitLabel(.done)
.onSubmit { submitDraft() }
.disabled(isBusy)
Button(isBusy ? "处理中" : "添加") { submitDraft() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fixedWidth: 84))
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isBusy)
}
}
}
}
private func submitDraft() {
let tag = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard tag.isEmpty == false, isBusy == false else { return }
draft = ""
Task { await apply(tag, removing: false) }
}
private func apply(_ tag: String, removing: Bool) async {
guard isBusy == false, targets.isEmpty == false else { return }
isBusy = true
await model.applyTag(tag, to: targets, removing: removing)
isBusy = false
}
/// 覆盖状态直接写在胶囊上:全部已贴 / 部分已贴 / 未贴,点击即切换整批。
private struct CoverageChip: View {
let title: String
let covered: Int
let total: Int
let isBusy: Bool
let action: () -> Void
private var isComplete: Bool { total > 0 && covered == total }
private var isPartial: Bool { covered > 0 && covered < total }
private var stateText: String {
if isComplete { return "全部" }
if isPartial { return "\(covered)/\(total)" }
return "未贴"
}
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Image(systemName: isComplete ? "checkmark.circle.fill" : (isPartial ? "circle.lefthalf.filled" : "circle"))
.font(.system(size: 12, weight: .semibold))
.accessibilityHidden(true)
Text(title)
.font(.footnote.weight(isComplete || isPartial ? .semibold : .medium))
.lineLimit(1)
.truncationMode(.middle)
Text(stateText)
.font(.caption2.weight(.semibold))
.monospacedDigit()
.opacity(0.72)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.frame(height: 38)
.foregroundStyle(isComplete ? AITheme.onPrimary : (isPartial ? AITheme.ink : AITheme.muted))
.background(isComplete ? AITheme.primary : (isPartial ? AITheme.tile : AITheme.surface))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(isComplete ? AITheme.primary : (isPartial ? AITheme.primary.opacity(0.42) : AITheme.line))
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.disabled(isBusy || total == 0)
.accessibilityLabel(isComplete ? "从已选节点移除标签 \(title)" : "给已选节点贴上标签 \(title)")
.accessibilityAddTraits(isComplete ? [.isButton, .isSelected] : .isButton)
}
}
}
@@ -4,9 +4,7 @@ struct FleetView: View {
@StateObject private var model: FleetViewModel
@State private var showsDeleteDialog = false
@State private var editingInstance: Instance?
@State private var collapsedGroups: Set<String> = []
@State private var isSummaryCollapsed = true
@State private var showingGroupEditor = false
init(model: FleetViewModel? = nil) {
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
// 所以调试开关必须先落到这个具体的 model 对象。
@@ -15,23 +13,22 @@ struct FleetView: View {
if DebugLaunch.has("--summary-open") {
_isSummaryCollapsed = State(initialValue: false)
}
if DebugLaunch.has("--sheet-editor") {
_showingGroupEditor = State(initialValue: true)
}
if DebugLaunch.has("--select-mode") {
target.selectionMode = true
}
if DebugLaunch.has("--sheet-form") {
target.showingForm = true
}
if DebugLaunch.has("--sheet-groups") {
target.showingGroupManager = true
if DebugLaunch.has("--sheet-tagbatch") {
target.selectionMode = true
target.showingTagBatch = true
}
if DebugLaunch.has("--group-status") {
target.groupMode = .status
// 图例胶囊的选中态只能靠点选进入,逐页截图时留一个直达开关。
if DebugLaunch.has("--filter-degraded") {
target.statusFilter = .degraded
}
if DebugLaunch.has("--group-flat") {
target.groupMode = .none
if DebugLaunch.has("--tag-field") {
target.selectedTagFilters = ["field"]
}
#endif
_model = StateObject(wrappedValue: target)
@@ -39,7 +36,7 @@ struct FleetView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
@@ -49,10 +46,10 @@ struct FleetView: View {
)
}
if !model.rows.isEmpty {
organizationControls
if showsSummary {
fleetSummary
}
tagFilterStrip
}
if model.isLoading && model.rows.isEmpty {
AICard(padding: 16) {
@@ -86,19 +83,8 @@ struct FleetView: View {
)
}
}
ForEach(model.groupedRows) { group in
instanceGroup(group)
}
if !model.rows.isEmpty && !model.selectionMode {
VStack(spacing: 0) {
Button {
showingGroupEditor = true
} label: {
Label("管理分组与批量归类", systemImage: "folder.badge.gearshape")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
ForEach(model.visibleRows) { row in
instanceEntry(row)
}
if !model.rows.isEmpty {
Text("共 \(model.rows.count) 台实例 · 显示 \(summaryRows.count) 台")
@@ -126,6 +112,11 @@ struct FleetView: View {
}
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("批量标签", systemImage: "tag", kind: .secondary) {
model.showingTagBatch = true
}
.disabled(model.selectedIDs.isEmpty)
AIIconButton("删除已选实例", systemImage: "trash", kind: .danger) {
showsDeleteDialog = true
}
@@ -138,11 +129,6 @@ struct FleetView: View {
}
} else {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("管理分组", systemImage: "folder.badge.gearshape") {
model.showingGroupManager = true
}
.disabled(model.isLoading)
AIIconButton("批量选择", systemImage: "checklist") {
model.selectionMode = true
}
@@ -155,18 +141,8 @@ struct FleetView: View {
}
}
}
.sheet(isPresented: $model.showingGroupManager) {
InstanceGroupManagerView(model: model)
}
.sheet(isPresented: $showingGroupEditor) {
InstanceGroupEditorView(
model: model,
targetIDs: batchGroupTargetIDs,
onSaved: {
model.selectedIDs.removeAll()
model.selectionMode = false
}
)
.sheet(isPresented: $model.showingTagBatch) {
FleetTagBatchSheet(model: model)
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(
@@ -220,13 +196,15 @@ private extension FleetView {
#endif
}
/// 概览副标题:更新时间,检测未完成时追加进度。进度原本占读数栅格一格,挪到这里后栅格正好两行。
private var updatedDetail: String {
let progress = testingCount > 0 ? " · 已检测 \(settledCount)/\(summaryRows.count) 台" : ""
guard let lastRefresh = model.lastRefresh else {
return model.isLoading ? "正在读取" : "待刷新"
return (model.isLoading ? "正在读取" : "待刷新") + progress
}
// RelativeDateTimeFormatter 在中文下会把刚发生的事说成"0秒后",这里直接兜成"刚刚"。
if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" }
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))"
if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" + progress }
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" + progress
}
private var searchField: some View {
@@ -235,23 +213,11 @@ private extension FleetView {
}
}
private var organizationControls: some View {
VStack(alignment: .leading, spacing: 10) {
AISegmentedControl(
"组织方式",
selection: $model.groupMode,
options: FleetGroupMode.allCases.map { ($0, $0.title) }
)
if model.allTags.isEmpty == false {
tagFilterStrip
}
}
}
// 标签直接作为筛选器使用:一条横向可滑动的胶囊,首个「全部」用于复位。
// 标签是唯一的归类手段:一条横向可滑动的胶囊,首个「全部」用于复位。
@ViewBuilder
private var tagFilterStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
if model.allTags.isEmpty == false {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "全部",
@@ -276,9 +242,10 @@ private extension FleetView {
}
.padding(.vertical, 3)
.padding(.horizontal, 1)
}
.accessibilityElement(children: .contain)
.accessibilityLabel("按标签筛选")
}
.accessibilityElement(children: .contain)
.accessibilityLabel("按标签筛选")
}
private struct FilterChip: View {
@@ -360,18 +327,14 @@ private extension FleetView {
}
private var groupedCount: Int {
summaryRows.filter { model.groupName(for: $0.instance.groupId) != nil }.count
}
private var groupCount: Int {
Set(summaryRows.compactMap(\.instance.groupId)).count
}
private var tagCount: Int {
Set(summaryRows.flatMap(\.instance.tags)).count
}
private var taggedCount: Int {
summaryRows.filter { $0.instance.tags.isEmpty == false }.count
}
private var summaryRows: [FleetViewModel.Row] { model.visibleRows }
private var allVisibleSelected: Bool {
@@ -379,10 +342,8 @@ private extension FleetView {
&& model.selectedIDs.count == model.visibleRows.count
}
private var batchGroupTargetIDs: [UUID] {
model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted {
$0.uuidString < $1.uuidString
}
var selectedIDs: [UUID] {
model.selectedIDs.sorted { $0.uuidString < $1.uuidString }
}
private var fleetSummary: some View {
@@ -430,9 +391,8 @@ private extension FleetView {
if !isSummaryCollapsed {
FleetOrganizationFootnote(
groupCount: groupCount,
tagCount: tagCount,
grouped: groupedCount,
covered: taggedCount,
total: summaryRows.count
)
}
@@ -531,7 +491,7 @@ private extension FleetView {
Button(action: action) {
HStack(spacing: 5) {
Circle()
.fill(tint)
.fill(isSelected ? AITheme.onPrimary : tint)
.frame(width: 6, height: 6)
.accessibilityHidden(true)
Text(title)
@@ -544,12 +504,14 @@ private extension FleetView {
}
.padding(.horizontal, 9)
.frame(height: 26)
.foregroundStyle(isSelected ? tint : AITheme.muted)
.background(isSelected ? tint.opacity(0.12) : AITheme.tile)
// 图例即筛选器:选中时整颗胶囊填成状态色,文字走反色。
// onPrimary 在浅色下是白、深色下是近黑,正好是两套状态色的对比搭档。
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.background(isSelected ? tint : AITheme.tile)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(isSelected ? tint.opacity(0.34) : AITheme.tileLine)
.strokeBorder(isSelected ? Color.clear : AITheme.tileLine)
}
// 视觉高度保持 26,但把点击区扩到 44pt 以上。
.contentShape(Capsule(style: .continuous).inset(by: -9))
@@ -624,17 +586,16 @@ private extension FleetView {
}
struct FleetOrganizationFootnote: View {
let groupCount: Int
let tagCount: Int
let grouped: Int
let covered: Int
let total: Int
var body: some View {
HStack(spacing: 6) {
Image(systemName: "folder")
Image(systemName: "tag")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text("\(groupCount) 个分组 · \(tagCount) 个标签 · 已归类 \(grouped)/\(max(grouped, total))")
Text(tagCount == 0 ? "还没有标签,可在编辑实例时添加" : "\(tagCount) 个标签 · 已标记 \(covered)/\(total) 台")
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
@@ -649,6 +610,7 @@ private extension FleetView {
}
.accessibilityElement(children: .combine)
}
}
private var statusSegments: [FleetStatusSegment] {
@@ -675,42 +637,19 @@ private extension FleetView {
}
}
/// 读数区固定八格两行:温度与电量即使整群节点都没有上报也保留占位,
/// 否则栅格会在七格、九格之间跳,最后一行只剩孤立的一列。
private var fleetSummaryItems: [FleetStatItem] {
var items = [
FleetStatItem(
title: "检测进度",
value: "\(settledCount)/\(summaryRows.count)",
systemImage: "checkmark.seal",
tint: testingCount > 0 ? AITheme.warning : AITheme.success
),
[
FleetStatItem(title: "信号", value: signalSummary, systemImage: "cellularbars", tint: signalSummaryTint),
FleetStatItem(title: "延迟", value: latencySummary, systemImage: "speedometer", tint: latencySummaryTint),
FleetStatItem(title: "存储", value: storageSummary, systemImage: "internaldrive", tint: storageSummaryTint),
FleetStatItem(title: "流量", value: trafficSummary, systemImage: "arrow.up.arrow.down", tint: trafficSummaryTint),
FleetStatItem(title: "CPU", value: cpuSummary, systemImage: "cpu", tint: loadSummaryTint),
FleetStatItem(title: "内存", value: memorySummary, systemImage: "memorychip", tint: loadSummaryTint)
FleetStatItem(title: "内存", value: memorySummary, systemImage: "memorychip", tint: loadSummaryTint),
FleetStatItem(title: "温度", value: temperatureSummary, systemImage: "thermometer.medium", tint: temperatureSummaryTint),
FleetStatItem(title: "电量", value: batterySummary, systemImage: "battery.100", tint: batterySummaryTint)
]
if summaryRows.contains(where: { $0.temperatureCelsius != nil }) {
items.append(
FleetStatItem(
title: "温度",
value: temperatureSummary,
systemImage: "thermometer.medium",
tint: temperatureSummaryTint
)
)
}
if summaryRows.contains(where: { $0.batteryPercent != nil }) {
items.append(
FleetStatItem(
title: "电量",
value: batterySummary,
systemImage: "battery.100",
tint: batterySummaryTint
)
)
}
return items
}
private var onlineCount: Int { summaryRows.filter { $0.status == .online }.count }
@@ -904,6 +843,12 @@ private extension FleetView {
.truncationMode(.middle)
}
// 四条量表把"要不要担心这台"变成一次扫视:信号、延迟、CPU、内存。
FleetGaugeStrip(
gauges: rowGauges(row),
isPending: row.testing
)
// 健康节点不再占一行重复"连接正常",只有需要解释的状态才出现说明与链路。
if let statusLine = statusLine(row) {
FleetStatusStrip(status: row.status, statusDetail: statusLine)
@@ -913,11 +858,13 @@ private extension FleetView {
FleetChainRow(items: health)
}
if model.groupName(for: row.instance.groupId) != nil || row.instance.tags.isEmpty == false {
FleetMetricGrid(
metrics: metrics,
isPending: row.testing && metrics.isEmpty
)
if row.instance.tags.isEmpty == false {
FlowLayout(spacing: 6) {
if let groupName = model.groupName(for: row.instance.groupId) {
InlinePill(title: groupName, systemImage: "folder", tint: AITheme.primary)
}
ForEach(row.instance.tags, id: \.self) { tag in
TagToken(
title: tag,
@@ -930,11 +877,6 @@ private extension FleetView {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
FleetMetricGrid(
metrics: metrics,
isPending: row.testing && metrics.isEmpty
)
}
}
}
@@ -980,39 +922,49 @@ private extension FleetView {
}
/// 卡片顶部的四条量表:数值配一条发丝进度,正常时不着色,异常时只给颜色不给感叹号。
private func rowGauges(_ row: FleetViewModel.Row) -> [FleetGauge] {
[
FleetGauge(
label: "信号",
value: row.signalPercent.map { "\($0)%" } ?? "--",
ratio: row.signalPercent.map { Double($0) / 100 },
tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted
),
FleetGauge(
label: "延迟",
value: row.latencyMilliseconds.map { "\($0)ms" } ?? "--",
ratio: row.latencyMilliseconds.map { max(0, 1 - Double($0) / 1000) },
tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted
),
FleetGauge(
label: "CPU",
value: row.cpuPercent.map(Self.percentText) ?? "--",
ratio: row.cpuPercent.map { $0 / 100 },
tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted
),
FleetGauge(
label: "内存",
value: row.memoryPercent.map(Self.percentText) ?? "--",
ratio: row.memoryPercent.map { $0 / 100 },
tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted
),
]
}
private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] {
// 卡片只留最该被扫到的六项:先身份(设备/运营商/号码),再链路(信号/延迟/存储)。
// 信号质量已经交给量表,这一区只放身份与容量:设备、网络、存储、续航、运行时长。
let ordered: [FleetMetric] = [
.init(label: "设备", value: row.deviceName),
.init(label: "运营商", value: row.operatorName),
.init(label: "号码", value: row.phoneNumber),
.init(
label: "信号",
value: row.signalPercent.map { "\($0)%" },
tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted
),
.init(
label: "延迟",
value: row.latencyMilliseconds.map { "\($0) ms" },
tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted
),
.init(label: "制式", value: row.networkTechnology),
.init(label: "IP", value: row.ipAddress),
.init(
label: "存储",
value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" },
tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted
),
.init(
label: "CPU",
value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount),
tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted
),
.init(
label: "内存",
value: row.memoryPercent.map(Self.percentText),
tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted
),
.init(label: "制式", value: row.networkTechnology),
.init(label: "IP", value: row.ipAddress),
.init(
label: "温度",
value: row.temperatureCelsius.map(Self.temperatureText),
@@ -1024,7 +976,7 @@ private extension FleetView {
tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted
),
.init(label: "固件", value: row.firmwareVersion),
.init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText)),
.init(label: "运行时长", value: row.uptimeSeconds.map(Self.durationText)),
.init(
label: "下行",
value: row.downloadRateBytesPerSecond.map(Self.rateText)
@@ -1105,75 +1057,6 @@ private extension FleetView {
return milliseconds < 300 ? AITheme.success : (milliseconds < 800 ? AITheme.warning : AITheme.danger)
}
private func groupDetail(_ group: FleetGroup) -> String {
model.health(for: group).detail
}
private func groupTint(_ group: FleetGroup) -> Color {
switch model.groupMode {
case .status:
return group.rows.first.map { statusColor($0.status) } ?? AITheme.muted
case .groups:
return AITheme.primary
case .none:
return AITheme.muted
}
}
@ViewBuilder
private func instanceGroup(_ group: FleetGroup) -> some View {
if model.groupMode == .none {
ForEach(group.rows) { row in
instanceEntry(row)
}
} else {
Section {
ForEach(group.rows) { row in
if collapsedGroups.contains(group.id) {
EmptyView()
} else {
instanceEntry(row)
}
}
} header: {
let health = model.health(for: group)
FleetGroupHeader(
title: group.title,
detail: groupDetail(group),
onlineValue: health.onlineAverageText,
abnormalValue: health.abnormalAverageText,
onlineTint: health.onlineTint ?? AITheme.muted,
abnormalTint: health.abnormalTint ?? AITheme.muted,
total: group.rows.count,
testing: health.testing,
signalValue: health.signalAverageText,
latencyValue: health.latencyAverageText,
storageValue: health.storageAverageText,
trafficValue: health.trafficTotalText,
signalTint: health.signalTint ?? AITheme.muted,
latencyTint: health.latencyTint ?? AITheme.muted,
storageTint: health.storageTint ?? AITheme.muted,
trafficTint: health.trafficTint ?? AITheme.muted,
tint: groupTint(group),
isCollapsed: collapsedGroups.contains(group.id)
) {
toggleGroup(group.id)
}
.padding(.top, 4)
.background(AITheme.canvas)
}
}
}
private func toggleGroup(_ id: String) {
if collapsedGroups.contains(id) {
collapsedGroups.remove(id)
} else {
collapsedGroups.insert(id)
}
}
private func instanceEntry(_ row: FleetViewModel.Row) -> some View {
Group {
if model.selectionMode {
@@ -1192,10 +1075,7 @@ private extension FleetView {
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
} else {
NavigationLink {
InstanceDetailView(
instance: row.instance,
groupName: model.groupName(for: row.instance.groupId)
)
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
@@ -1263,7 +1143,7 @@ extension FleetView {
let metrics: [FleetMetric]
var isPending = false
private static let maxVisible = 6
private static let maxVisible = 8
private var visible: [FleetMetric] { Array(metrics.prefix(Self.maxVisible)) }
@@ -1315,7 +1195,7 @@ extension FleetView {
}
if overflow > 0 {
Text("另有 \(overflow) 项指标")
Text("另有 \(overflow) 项,进入详情查看")
.font(.caption2)
.foregroundStyle(AITheme.faint)
.padding(.top, 7)
@@ -1333,6 +1213,76 @@ extension FleetView {
}
}
/// 量表单元:一个可读数值加一条发丝进度。
struct FleetGauge: Identifiable {
let label: String
let value: String
let ratio: Double?
let tint: Color
var id: String { label }
var hasValue: Bool { ratio != nil }
// 与读数栅格同一规则:只有警告和危险才配颜色。
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
}
/// 四格量表条:等宽排布,缺数据时保留占位,避免数值到位后卡片抖动。
struct FleetGaugeStrip: View {
let gauges: [FleetGauge]
let isPending: Bool
var body: some View {
HStack(alignment: .top, spacing: 10) {
ForEach(gauges) { gauge in
VStack(alignment: .leading, spacing: 4) {
Text(gauge.label)
.font(.caption2)
.foregroundStyle(AITheme.faint)
.lineLimit(1)
Text(gauge.value)
.font(.footnote.weight(.semibold))
.monospacedDigit()
.foregroundStyle(color(for: gauge))
.lineLimit(1)
.minimumScaleFactor(0.7)
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule(style: .continuous)
.fill(AITheme.line)
Capsule(style: .continuous)
.fill(barColor(for: gauge))
.frame(width: max(3, proxy.size.width * CGFloat(gauge.ratio ?? 0)))
}
}
.frame(height: 4)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(gauge.label) \(gauge.value)")
}
}
.padding(.top, 2)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
.offset(y: -7)
}
}
private func color(for gauge: FleetGauge) -> Color {
if !gauge.hasValue { return isPending ? AITheme.faint : AITheme.muted }
return gauge.isAlert ? gauge.tint : AITheme.ink
}
/// 正常读数的进度条用墨色,颜色只留给异常,四格量表不会把卡片染成一片绿。
private func barColor(for gauge: FleetGauge) -> Color {
guard gauge.hasValue else { return AITheme.line }
return gauge.isAlert ? gauge.tint : AITheme.primary
}
}
struct FleetStatItem {
let title: String
let value: String
@@ -1580,151 +1530,6 @@ extension FleetView {
}
}
struct FleetGroupHeader: View {
let title: String
let detail: String
let onlineValue: String
let abnormalValue: String
let onlineTint: Color
let abnormalTint: Color
let total: Int
let testing: Int
let signalValue: String
let latencyValue: String
let storageValue: String
let trafficValue: String
let signalTint: Color
let latencyTint: Color
let storageTint: Color
let trafficTint: Color
let tint: Color
let isCollapsed: Bool
let action: () -> Void
private var onlineRatio: Double {
guard let text = onlineValue.split(separator: "%").first, let value = Double(text) else {
return 0
}
return min(1, max(0, value / 100))
}
private var progressTint: Color {
guard hasSettledResults else { return AITheme.muted }
return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted)
}
private var progress: Double {
guard hasSettledResults else { return 0 }
return onlineRatio
}
// 整组都还在检测时,在线率/异常率没有任何可信依据,先不给结论。
private var hasSettledResults: Bool { total > 0 && testing < total }
private var displayedOnlineValue: String { hasSettledResults ? onlineValue : "--" }
private var displayedAbnormalValue: String { hasSettledResults ? abnormalValue : "--" }
private var displayedOnlineTint: Color { hasSettledResults ? onlineTint : AITheme.muted }
private var displayedAbnormalTint: Color { hasSettledResults ? abnormalTint : AITheme.muted }
// 分组头部与运行概览共用同一套读数样式,扫读时不需要重新对齐视线。
private var headerReadouts: [FleetStatItem] {
[
FleetStatItem(title: "节点", value: "\(total)", systemImage: "server.rack", tint: AITheme.ink),
FleetStatItem(
title: "在线率",
value: displayedOnlineValue,
systemImage: "checkmark.circle",
tint: displayedOnlineTint
),
FleetStatItem(
title: "异常率",
value: displayedAbnormalValue,
systemImage: "exclamationmark.triangle",
tint: displayedAbnormalTint
),
FleetStatItem(
title: "检测中",
value: testing > 0 ? "\(testing)" : "--",
systemImage: "clock.arrow.circlepath",
tint: testing > 0 ? AITheme.warning : AITheme.muted
),
FleetStatItem(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint),
FleetStatItem(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint),
FleetStatItem(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint),
FleetStatItem(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint)
]
.map { item in
item.value == "不可用"
? FleetStatItem(title: item.title, value: "--", systemImage: item.systemImage, tint: AITheme.muted)
: item
}
// 全组检测完成后,这一列只剩一个空占位,属于纯噪音。
.filter { item in
item.title != "检测中" || testing > 0
}
}
var body: some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(tint)
.frame(width: 3, height: 20)
Text(title)
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 24, height: 24)
.accessibilityHidden(true)
}
// 结论未出时不画空进度条,否则会被读成"0% 在线"。
if hasSettledResults {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(progressTint)
.frame(height: 3)
} else {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 10, weight: .semibold))
Text("正在检测 \(testing)/\(total) 台")
.font(.caption2.weight(.semibold))
.monospacedDigit()
}
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
FleetReadoutGrid(items: headerReadouts)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AITheme.chrome)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(AITheme.line)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title),\(detail)")
.accessibilityHint(isCollapsed ? "展开分组" : "折叠分组")
.accessibilityAddTraits(.isButton)
}
}
struct FleetMetric: View {
let label: String
let value: String?
@@ -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 {
@@ -9,7 +9,6 @@ struct InstanceFormView: View {
@State private var tags: [String] = []
@State private var tagDraft = ""
@State private var password = ""
@State private var selectedGroupId: UUID?
@State private var validationMessage: String?
@State private var isSaving = false
@@ -19,7 +18,6 @@ struct InstanceFormView: View {
_name = State(initialValue: instance?.name ?? "")
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
_tags = State(initialValue: instance?.tags ?? [])
_selectedGroupId = State(initialValue: instance?.groupId)
}
private var canSave: Bool {
@@ -36,7 +34,6 @@ struct InstanceFormView: View {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
tagEditor
groupPicker
field(
"实例密码",
text: $password,
@@ -81,30 +78,6 @@ struct InstanceFormView: View {
}
}
private var groupPicker: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
AIFieldLabel("分组")
Spacer()
Button {
model.showingGroupManager = true
} label: {
Label("管理", systemImage: "folder.badge.gearshape")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 72))
}
Picker("分组", selection: $selectedGroupId) {
Text("未分组").tag(UUID?.none)
ForEach(model.groups) { group in
Text(group.name).tag(UUID?.some(group.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
.accessibilityElement(children: .contain)
}
private var tagEditor: some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("标签")
@@ -156,7 +129,6 @@ struct InstanceFormView: View {
name: normalizedName,
origin: origin,
tags: tags,
groupId: selectedGroupId,
password: password.isEmpty ? nil : password
)
Task {
@@ -1,226 +0,0 @@
import SwiftUI
/// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。
struct InstanceGroupEditorView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
let targetIDs: [UUID]
let onSaved: () -> Void
@State private var isCreatingGroup = false
@State private var newGroupName = ""
@State private var isWorking = false
@State private var errorMessage: String?
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 {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
AISectionTitle(
"移动到",
systemImage: "folder",
detail: "\(targets.count) 台实例"
)
if isCreatingGroup {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 12) {
AIFieldLabel("新分组名称")
TextField("例如:家里、外场、测试机", text: $newGroupName)
.textFieldStyle(AIFieldStyle())
.submitLabel(.done)
.onSubmit { Task { await createAndAssign() } }
AIActionRow(spacing: 8) {
Button("取消") {
isCreatingGroup = false
newGroupName = ""
errorMessage = nil
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.disabled(isWorking)
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)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("实例分组")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
AIIconButton("新建分组", systemImage: "plus") {
errorMessage = nil
isCreatingGroup = true
}
.disabled(isWorking)
}
}
}
}
private func groupRow(
title: String,
subtitle: String,
systemImage: String,
groupID: UUID?
) -> some View {
Button {
guard uniformGroup != groupID else {
dismiss()
return
}
Task { await apply(groupID: groupID) }
} label: {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted)
.frame(width: 32, height: 32)
.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) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
Text(subtitle)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
}
Spacer(minLength: 8)
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)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.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 apply(groupID: UUID?) async {
guard !isWorking else { return }
isWorking = true
errorMessage = nil
do {
try await model.assign(groupID: groupID, to: targets)
onSaved()
dismiss()
} catch {
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
}
}
}
@@ -1,184 +0,0 @@
import SwiftUI
struct InstanceGroupManagerView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var newName = ""
@State private var editingGroup: InstanceGroup?
@State private var editingName = ""
@State private var validationMessage: String?
@State private var isWorking = false
@State private var pendingDelete: InstanceGroup?
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 10) {
AIFieldLabel("新增分组")
TextField("例如:家里、外场、测试机", text: $newName)
.textFieldStyle(AIFieldStyle())
.submitLabel(.done)
}
}
if let validationMessage {
AIStatusBanner(
validationMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if model.groups.isEmpty {
AICard {
AIEmptyState(
systemImage: "folder.badge.plus",
title: "暂无分组",
message: "创建分组后,实例可按业务或位置归类。"
)
}
} else {
ForEach(model.groups) { group in
AICard {
HStack(spacing: 12) {
Image(systemName: "folder")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.primary)
.frame(width: 34, height: 34)
.background(AITheme.primary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(group.name)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
Text("\(instanceCount(group.id)) 个实例")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
Spacer()
AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) {
editingGroup = group
editingName = group.name
validationMessage = nil
}
AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) {
pendingDelete = group
}
.disabled(isWorking)
}
}
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("分组管理")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("完成") { dismiss() }
}
}
.aiBottomActionBar {
AIActionRow {
Button {
Task { await create() }
} label: {
Label("创建分组", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(isWorking)
}
}
.alert("编辑分组", isPresented: Binding(
get: { editingGroup != nil },
set: { if !$0 { editingGroup = nil } }
)) {
TextField("分组名称", text: $editingName)
Button("保存") {
Task { await saveEditing() }
}
Button("取消", role: .cancel) {}
} 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()
}
}
}
private func instanceCount(_ id: UUID) -> Int {
model.rows.filter { $0.instance.groupId == id }.count
}
private func create() async {
guard !isWorking else { return }
isWorking = true
validationMessage = nil
do {
try await model.createGroup(named: newName)
newName = ""
} catch {
validationMessage = "创建失败,名称不能为空且不能重复。"
}
isWorking = false
}
private func saveEditing() async {
guard let editingGroup else { return }
isWorking = true
validationMessage = nil
do {
try await model.updateGroup(editingGroup.id, name: editingName)
self.editingGroup = nil
} catch {
validationMessage = "更新失败,名称不能为空且不能重复。"
}
isWorking = false
}
private func delete(_ group: InstanceGroup) async {
guard !isWorking else { return }
isWorking = true
pendingDelete = nil
do {
try await model.deleteGroup(group.id)
} catch {
validationMessage = "删除失败,请稍后重试。"
}
isWorking = false
}
}
@@ -62,7 +62,7 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))")
AIKeyValueRow("版本", value: runtime.marketingVersion)
AIKeyValueRow("系统", value: runtime.systemVersion)
} else {
Text("正在读取应用信息…")
@@ -98,20 +98,21 @@ struct SettingsView: View {
VStack(spacing: 7) {
Image(systemName: mode.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.accessibilityHidden(true)
Text(mode.label)
.font(.caption.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, minHeight: 68)
.background(isSelected ? AITheme.primary.opacity(0.10) : AITheme.control)
// 选中格直接反色成实心块,浅底黑、深底白;一层 10% 的色雾在三块同色瓷砖里根本分不出来。
.background(isSelected ? AITheme.primary : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(isSelected ? AITheme.primary.opacity(0.42) : AITheme.line)
.strokeBorder(isSelected ? Color.clear : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
@@ -13,7 +13,6 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable {
public var name: String
public var origin: URL
public var tags: [String]
public var groupId: UUID?
public var credentialConfigured: Bool
public var configRevision: Int
public let createdAt: Date
@@ -24,7 +23,6 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable {
name: String,
origin: URL,
tags: [String] = [],
groupId: UUID? = nil,
credentialConfigured: Bool = false,
configRevision: Int = 1,
createdAt: Date,
@@ -34,7 +32,6 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable {
self.name = name
self.origin = origin
self.tags = tags
self.groupId = groupId
self.credentialConfigured = credentialConfigured
self.configRevision = configRevision
self.createdAt = createdAt
@@ -46,20 +43,17 @@ public struct InstanceInput: Sendable {
public var name: String
public var origin: URL
public var tags: [String]
public var groupId: UUID?
public var password: String?
public init(
name: String,
origin: URL,
tags: [String] = [],
groupId: UUID? = nil,
password: String? = nil
) {
self.name = name
self.origin = origin
self.tags = tags
self.groupId = groupId
self.password = password
}
}
@@ -68,20 +62,17 @@ public struct InstancePatch: Sendable {
public var name: String?
public var origin: URL?
public var tags: [String]?
public var groupId: UUID?
public var password: String?
public init(
name: String? = nil,
origin: URL? = nil,
tags: [String]? = nil,
groupId: UUID? = nil,
password: String? = nil
) {
self.name = name
self.origin = origin
self.tags = tags
self.groupId = groupId
self.password = password
}
}
@@ -89,12 +80,10 @@ public struct InstancePatch: Sendable {
public struct InstanceQuery: Sendable {
public var search: String?
public var tags: [String]?
public var groupId: UUID?
public init(search: String? = nil, tags: [String]? = nil, groupId: UUID? = nil) {
public init(search: String? = nil, tags: [String]? = nil) {
self.search = search
self.tags = tags
self.groupId = groupId
}
}
@@ -80,12 +80,12 @@ enum AppTab: Hashable {
#if DEBUG
/// 本地截图验证用的入口,Release 构建不包含。
struct DebugDetailView: View {
@State private var target: (Instance, String?)?
@State private var target: Instance?
var body: some View {
Group {
if let (instance, groupName) = target {
InstanceDetailView(instance: instance, groupName: groupName)
if let instance = target {
InstanceDetailView(instance: instance)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -93,13 +93,9 @@ enum AppTab: Hashable {
}
.task {
let repository = FileInstanceRepository(location: DefaultContainer.documentFileURL("instances.json"))
let groups = FileInstanceGroupStore(location: DefaultContainer.documentFileURL("instance-groups.json"))
guard let listed = try? await repository.list(InstanceQuery()), listed.isEmpty == false else { return }
// 截图验证优先看可达节点(3000),否则详情页全是空态,看不出真实排版。
let instance =
listed.first { $0.origin.port == 3000 } ?? listed[0]
let name = (try? await groups.list())?.first { $0.id == instance.groupId }?.name
target = (instance, name)
target = listed.first { $0.origin.port == 3000 } ?? listed[0]
}
}
}
@@ -1,130 +0,0 @@
import Foundation
public struct InstanceGroup: Codable, Equatable, Identifiable, Sendable {
public var id: UUID
public var name: String
public var createdAt: Date
public init(id: UUID, name: String, createdAt: Date) {
self.id = id
self.name = name
self.createdAt = createdAt
}
}
public struct InstanceGroupInput: Sendable {
public var name: String
public init(name: String) {
self.name = name
}
}
public protocol InstanceGroupStore: Sendable {
func list() async throws -> [InstanceGroup]
func create(_ input: InstanceGroupInput) async throws -> InstanceGroup
func update(_ id: UUID, _ name: String) async throws -> InstanceGroup
func delete(_ id: UUID) async throws
}
public final class FileInstanceGroupStore: @unchecked Sendable, InstanceGroupStore {
private struct FileModel: Codable {
var groups: [InstanceGroup] = []
}
private let location: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let lock = NSLock()
private var fileModel = FileModel()
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-group-store")
public init(location: URL) {
self.location = URL(fileURLWithPath: location.path)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.encoder = encoder
lock.withLock {
if let data = try? Data(contentsOf: location),
let decoded = try? decoder.decode(FileModel.self, from: data) {
fileModel = decoded
}
}
}
public func list() async throws -> [InstanceGroup] {
lock.withLock {
fileModel.groups.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
}
public func create(_ input: InstanceGroupInput) async throws -> InstanceGroup {
try await run { model in
let name = try Self.normalized(input.name)
guard !model.groups.contains(where: { $0.name == name }) else {
throw RepositoryError.duplicateOrigin
}
let group = InstanceGroup(id: UUID(), name: name, createdAt: Date())
model.groups.append(group)
return group
}
}
public func update(_ id: UUID, _ name: String) async throws -> InstanceGroup {
try await run { model in
let normalized = try Self.normalized(name)
guard let index = model.groups.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard !model.groups.contains(where: { $0.id != id && $0.name == normalized }) else {
throw RepositoryError.duplicateOrigin
}
model.groups[index].name = normalized
return model.groups[index]
}
}
public func delete(_ id: UUID) async throws {
try await run { model in
model.groups.removeAll { $0.id == id }
}
}
private func run<T: Sendable>(
_ transform: @escaping @Sendable (inout FileModel) throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
var model = self.lock.withLock { self.fileModel }
let output = try transform(&model)
try self.write(model)
self.lock.withLock { self.fileModel = model }
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func write(_ model: FileModel) throws {
let directory = location.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try encoder.encode(model).write(to: location, options: [.atomic])
}
private static func normalized(_ value: String) throws -> String {
let name = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.count <= 100 else {
throw RepositoryError.invalidOrigin
}
return name
}
}
@@ -35,9 +35,6 @@ public struct FileInstanceRepository: InstanceRepository {
if let tags = query.tags, !tags.isEmpty {
instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) }
}
if let groupId = query.groupId {
instances = instances.filter { $0.groupId == groupId }
}
return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
}
@@ -61,7 +58,6 @@ public struct FileInstanceRepository: InstanceRepository {
name: input.name.trimmingCharacters(in: .whitespacesAndNewlines),
origin: origin,
tags: Self.normalizedTags(input.tags),
groupId: input.groupId,
credentialConfigured: !(input.password ?? "").isEmpty,
createdAt: now,
updatedAt: now
@@ -96,9 +92,6 @@ public struct FileInstanceRepository: InstanceRepository {
if let tags = patch.tags {
instance.tags = Self.normalizedTags(tags)
}
if patch.groupId != nil || instance.groupId != nil {
instance.groupId = patch.groupId
}
if let password = patch.password {
instance.credentialConfigured = !password.isEmpty
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 936 KiB

@@ -9,8 +9,8 @@
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>MultiSimAdmin</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.1</string>
<key>CFBundleVersion</key><string>6</string>
<key>CFBundleShortVersionString</key><string>0.2.2</string>
<key>CFBundleVersion</key><string>7</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
@@ -5,10 +5,6 @@ enum DefaultContainer {
location: documentFileURL("credentials.json")
)
private static let instanceGroups: any InstanceGroupStore = FileInstanceGroupStore(
location: documentFileURL("instance-groups.json")
)
private static let logStore: any LogStore = FileLogStore(
location: documentFileURL("log-center.json")
)
@@ -20,7 +16,8 @@ enum DefaultContainer {
@MainActor
static func makeFleetModel() -> FleetViewModel {
FleetViewModel(
purgeLegacyGrouping()
return FleetViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
probe: ConnectionProbe(client: SimAdminClient()),
credentials: credentials,
@@ -28,11 +25,16 @@ enum DefaultContainer {
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: credentials
),
groupStore: instanceGroups
)
)
}
/// 0.2.2 起分组被标签取代,遗留的分组文件与偏好键一次性清掉,避免 Documents 里留死数据。
private static func purgeLegacyGrouping() {
try? FileManager.default.removeItem(at: documentFileURL("instance-groups.json"))
UserDefaults.standard.removeObject(forKey: "fleet.groupMode")
}
@MainActor
static func makeMessagesModel() -> MessagesViewModel {
MessagesViewModel(
@@ -28,50 +28,50 @@ private enum AIPalette {
dynamicColor(light: light, dark: dark)
}
// 浅色:中性冷灰底 + 纯白卡 + 极浅内嵌面,避免"灰蓝蒙层"把层级糊成一团。
static let canvas = color(light: rgba(242, 244, 248, 1), dark: rgba(7, 10, 16, 1))
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(20, 26, 36, 1))
static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(26, 35, 49, 1))
static let control = color(light: rgba(237, 241, 247, 1), dark: rgba(34, 44, 60, 1))
static let tile = color(light: rgba(247, 249, 252, 1), dark: rgba(255, 255, 255, 0.05))
static let tileLine = color(light: rgba(233, 239, 247, 1), dark: rgba(255, 255, 255, 0.08))
static let chrome = color(light: rgba(255, 255, 255, 0.90), dark: rgba(12, 17, 25, 0.86))
static let ink = color(light: rgba(15, 23, 41, 1), dark: rgba(241, 245, 249, 1))
static let muted = color(light: rgba(92, 106, 128, 1), dark: rgba(154, 167, 184, 1))
static let faint = color(light: rgba(141, 152, 170, 1), dark: rgba(112, 127, 148, 1))
static let line = color(light: rgba(224, 231, 240, 1), dark: rgba(255, 255, 255, 0.10))
static let brand = color(light: rgba(11, 27, 51, 1), dark: rgba(7, 10, 16, 1))
static let primary = color(light: rgba(18, 96, 214, 1), dark: rgba(96, 165, 250, 1))
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(5, 22, 43, 1))
static let danger = color(light: rgba(199, 44, 44, 1), dark: rgba(248, 113, 113, 1))
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(24, 7, 7, 1))
static let warning = color(light: rgba(176, 84, 12, 1), dark: rgba(245, 158, 11, 1))
static let success = color(light: rgba(16, 124, 90, 1), dark: rgba(74, 222, 128, 1))
static let segmentTrack = color(light: rgba(236, 240, 246, 1), dark: rgba(12, 17, 25, 1))
static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(48, 61, 81, 1))
static let cardShadow = color(light: rgba(15, 30, 60, 0.07), dark: rgba(0, 0, 0, 0.55))
// 石墨灰阶:所有中性色 R≈G≈B,不带蓝调;主色让位给黑/白,颜色只用来表达状态。
static let canvas = color(light: rgba(244, 245, 245, 1), dark: rgba(8, 9, 9, 1))
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(19, 21, 21, 1))
static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(27, 29, 30, 1))
static let control = color(light: rgba(238, 239, 239, 1), dark: rgba(36, 39, 40, 1))
static let tile = color(light: rgba(246, 247, 247, 1), dark: rgba(255, 255, 255, 0.055))
static let tileLine = color(light: rgba(231, 233, 233, 1), dark: rgba(255, 255, 255, 0.09))
static let chrome = color(light: rgba(252, 252, 252, 0.92), dark: rgba(11, 12, 12, 0.88))
static let ink = color(light: rgba(17, 19, 20, 1), dark: rgba(240, 242, 242, 1))
static let muted = color(light: rgba(88, 94, 97, 1), dark: rgba(158, 165, 168, 1))
static let faint = color(light: rgba(126, 132, 135, 1), dark: rgba(112, 118, 121, 1))
static let line = color(light: rgba(223, 226, 226, 1), dark: rgba(255, 255, 255, 0.11))
static let brand = color(light: rgba(18, 20, 21, 1), dark: rgba(8, 9, 9, 1))
static let primary = color(light: rgba(21, 24, 25, 1), dark: rgba(238, 241, 241, 1))
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 14, 1))
static let danger = color(light: rgba(193, 46, 40, 1), dark: rgba(247, 113, 105, 1))
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(24, 8, 7, 1))
static let warning = color(light: rgba(170, 92, 15, 1), dark: rgba(233, 166, 60, 1))
static let success = color(light: rgba(16, 122, 86, 1), dark: rgba(66, 199, 132, 1))
static let segmentTrack = color(light: rgba(234, 236, 236, 1), dark: rgba(26, 29, 29, 1))
static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(54, 58, 59, 1))
static let cardShadow = color(light: rgba(17, 22, 25, 0.06), dark: rgba(0, 0, 0, 0.60))
#elseif canImport(AppKit)
static let canvas = Color(red: 0.914, green: 0.933, blue: 0.961)
static let canvas = Color(red: 0.957, green: 0.961, blue: 0.961)
static let surface = Color.white
static let control = Color(red: 0.941, green: 0.957, blue: 0.980)
static let ink = Color(red: 0.035, green: 0.086, blue: 0.169)
static let muted = Color(red: 0.275, green: 0.341, blue: 0.435)
static let line = Color(red: 0.827, green: 0.867, blue: 0.918)
static let brand = Color(red: 0.035, green: 0.086, blue: 0.169)
static let control = Color(red: 0.933, green: 0.937, blue: 0.937)
static let ink = Color(red: 0.067, green: 0.075, blue: 0.078)
static let muted = Color(red: 0.345, green: 0.369, blue: 0.380)
static let line = Color(red: 0.875, green: 0.886, blue: 0.886)
static let brand = Color(red: 0.071, green: 0.078, blue: 0.082)
static let surfaceRaised = Color.white
static let chrome = Color.white.opacity(0.86)
static let onPrimary = Color.white
static let onDanger = Color.white
static let primary = Color(red: 0.145, green: 0.388, blue: 0.922)
static let danger = Color(red: 0.784, green: 0.118, blue: 0.118)
static let warning = Color(red: 0.706, green: 0.325, blue: 0.035)
static let success = Color(red: 0.071, green: 0.502, blue: 0.361)
static let tile = Color(red: 0.969, green: 0.976, blue: 0.988)
static let tileLine = Color(red: 0.914, green: 0.937, blue: 0.969)
static let faint = Color(red: 0.553, green: 0.596, blue: 0.667)
static let segmentTrack = Color(red: 0.925, green: 0.941, blue: 0.965)
static let primary = Color(red: 0.082, green: 0.094, blue: 0.098)
static let danger = Color(red: 0.757, green: 0.180, blue: 0.157)
static let warning = Color(red: 0.667, green: 0.361, blue: 0.059)
static let success = Color(red: 0.063, green: 0.478, blue: 0.337)
static let tile = Color(red: 0.965, green: 0.969, blue: 0.969)
static let tileLine = Color(red: 0.906, green: 0.914, blue: 0.914)
static let faint = Color(red: 0.494, green: 0.518, blue: 0.529)
static let segmentTrack = Color(red: 0.918, green: 0.925, blue: 0.925)
static let segmentSelected = Color.white
static let cardShadow = Color(red: 0.059, green: 0.118, blue: 0.235).opacity(0.07)
static let cardShadow = Color(red: 0.067, green: 0.086, blue: 0.098).opacity(0.06)
#else
private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color {
Color(red: red / 255, green: green / 255, blue: blue / 255)
@@ -6,7 +6,7 @@ final class AppMetadataTests: XCTestCase {
func testMetadataUsesIPAFoundationContract() {
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.1")
XCTAssertEqual(AppMetadata.buildVersion, "6")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.2")
XCTAssertEqual(AppMetadata.buildVersion, "7")
}
}
@@ -24,7 +24,6 @@ final class ConnectionProbeTests: XCTestCase {
name: "Probe",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
groupId: nil,
credentialConfigured: false,
configRevision: 1,
createdAt: Date(),
@@ -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
}
@@ -22,7 +22,6 @@ final class InstanceRepositoryTests: XCTestCase {
name: "Lab Alpha",
origin: URL(string: "http://192.168.3.55:3000/")!,
tags: ["primary", "lab"],
groupId: nil,
password: "secret"
)
@@ -48,7 +47,7 @@ final class InstanceRepositoryTests: XCTestCase {
func testRejectsInvalidOriginDuplicateOriginAndStaleRevision() async throws {
let repository = FileInstanceRepository(location: location)
let origin = URL(string: "http://192.168.1.20:3000")!
let input = InstanceInput(name: "One", origin: origin, tags: [], groupId: nil, password: nil)
let input = InstanceInput(name: "One", origin: origin, tags: [], password: nil)
let first = try await repository.create(input)
do {
@@ -56,7 +55,6 @@ final class InstanceRepositoryTests: XCTestCase {
name: "Nested",
origin: URL(string: "http://192.168.1.20:3000/admin")!,
tags: [],
groupId: nil,
password: nil
)
_ = try await repository.create(nested)
@@ -80,21 +78,18 @@ final class InstanceRepositoryTests: XCTestCase {
}
}
func testListFiltersBySearchTagAndGroup() async throws {
func testListFiltersBySearchAndTag() async throws {
let repository = FileInstanceRepository(location: location)
let groupId = UUID()
_ = try await repository.create(InstanceInput(
name: "Lab Alpha",
origin: URL(string: "http://192.168.1.10:3000")!,
tags: ["production"],
groupId: groupId,
password: nil
))
_ = try await repository.create(InstanceInput(
name: "Home Beta",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: ["lab"],
groupId: nil,
password: nil
))
@@ -102,8 +97,8 @@ final class InstanceRepositoryTests: XCTestCase {
XCTAssertEqual(search.map(\.name), ["Lab Alpha"])
let tagged = try await repository.list(InstanceQuery(tags: ["lab"]))
XCTAssertEqual(tagged.map(\.name), ["Home Beta"])
let grouped = try await repository.list(InstanceQuery(groupId: groupId))
XCTAssertEqual(grouped.map(\.name), ["Lab Alpha"])
let production = try await repository.list(InstanceQuery(tags: ["production"]))
XCTAssertEqual(production.map(\.name), ["Lab Alpha"])
let sorted = try await repository.list(InstanceQuery())
XCTAssertEqual(sorted.map(\.name), ["Home Beta", "Lab Alpha"])
}
@@ -11,8 +11,8 @@ final class SettingsViewModelTests: XCTestCase {
await model.refresh()
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.1")
XCTAssertEqual(model.runtime?.buildVersion, "6")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.2")
XCTAssertEqual(model.runtime?.buildVersion, "7")
XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false)
+70
View File
@@ -0,0 +1,70 @@
# iOS Native 0.2.2
0.2.2 (7) 把「分组」这个实体从应用里彻底删掉,归类只靠标签;同时完成第二轮配色与图标重构,
界面上不再出现蓝色。页面能力、上游白名单与本地数据布局见现行说明 [docs/ios-native.md](ios-native.md),
本文只记录本次发布的变化。
## 本次变更
- 分组概念移除:删除分组视图、分组编辑器与分组仓储,实例模型不再有 `groupId`。升级后首次启动
一次性删除 `Documents/instance-groups.json` 与偏好键 `fleet.groupMode`,本机不留死数据。
- 标签接管归类:新增「批量标签」表,批量选择后一次给多台节点贴同一个标签;每个标签显示
「未贴 / N/M / 全部」三态覆盖度,点击即在整批上加减,也可以在同一张表里新建标签。卡片与
标签条上的标签仍然可点,点一下即加入筛选。
- 配色重构,全应用零蓝色:主操作、选中态、进度填充一律用石墨墨色,黄铜只作图标点缀;颜色
只保留给状态语义(危险红、告警琥珀、成功绿),健康读数回到墨色,深色主题不再整屏泛绿。
浅色主题重做为中性冷灰画布加纯白卡片,深色主题为低明度石墨三层。
- 选中态可见性:主题切换块与状态图例胶囊原先用 10%~12% 色雾表示选中,浅色下几乎看不出来,
改为实心主色加 `onPrimary` 文字,两套主题都一眼可读,且选中不改变控件尺寸。
- 应用图标重做:放弃立体卡与投影,改扁平石墨底加黄铜 SIM 面板,三根底部对齐的递增信号条,
29pt 下轮廓仍可读;面板边长、圆角、切角与信号条间距可用环境变量调参后重新光栅化。
- 运行概览收紧:检测进度从读数格移进卡片副标题(`刚刚更新 · 已检测 6/6 台`);读数栅格固定
八格两行,温度与电量即使整群节点都没上报也保留「不可用」占位,栅格不再在七格与九格之间跳;
概览量表健康时用墨色,只在告警与危险时上色。
- 实例详情头部重排:四项仪表从四枚嵌套小卡改为两列发丝线栅格,读数在上、进度在下,缺数据时
保留空轨道,数据到位后卡片高度不变,修掉 0.2.1「加载完成后头部比下方窄一圈」的问题;身份摘要
去掉内嵌底色,改用顶部发丝线分隔。
- 字段名补全:详情页里 `name`、`disk_total_bytes`、`disk_available_bytes`、`percent` 四个键
此前直接把原始键名渲染到界面上,现补为「设备名称」「存储总量」「可用存储」「使用率」并排进
固定顺序。
- 版本号显示去掉括号:设置页「应用信息」只显示 `0.2.2`。
## 文档同步
- 新增本文,[docs/ios-native.md](ios-native.md) 同步到 0.2.2:导航表去掉分组视图,节点总览与
实例详情两节按实况重写,本地数据表删掉 `instance-groups.json`,设计语言一节改为石墨与墨色
中性、颜色只留给状态、不含蓝色,偏离表新增分组一行。
- 仓库根 README 与 `apps/ios/README.md` 同步版本号、节点入口描述、目录说明与界面约定;界面
约定里「主操作只用一种蓝色」按本次结论改写。
## 数据与安全
与 0.2.1 一致:实例信息保存在应用沙盒本地数据文件,实例密码保存在同一沙盒的私有凭据文件,
不使用 iOS Keychain,登录会话只存在于进程内存。应用仅连接用户添加的节点地址,不上传任何数据。
本次新增的唯一数据动作是删除遗留的分组文件,不改动实例、标签与凭据内容。
## 构建与安装
从仓库根目录构建:
```bash
./scripts/build-ios-ipa.sh
```
产物:
- `build/ipa/MultiSimAdmin.ipa`
- `build/ipa/MultiSimAdmin.ipa.sha256`
0.2.2 (7) 校验值:
```text
a8ac7a33aa8dbbeb3197d8131b8c5f77847d1908cbb65beadd8ea6e44c475c12 MultiSimAdmin.ipa
```
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`,未签名,
校验方式:
```bash
cd build/ipa && shasum -a 256 -c MultiSimAdmin.ipa.sha256
```
+38 -22
View File
@@ -3,7 +3,7 @@
本文是 iPhone 应用的唯一现行说明,内容与仓库代码一致。历史设计稿和执行计划保留在
`docs/superpowers/` 下,其中已被实现推翻的决策在本文第 9 节逐条列出。
- 当前版本:`0.2.1 (build 6)`
- 当前版本:`0.2.2`(build 7)
- 承载分支:`ios-native-ipa`
- 运行要求:iOS 16.0 及以上,`arm64`,Bundle ID `fun.chick.multi-simadmin`
- 产物:未签名 `MultiSimAdmin.ipa`,由 TrollStore 或用户自有签名流程安装
@@ -20,7 +20,7 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。
| 入口 | 页面 | 内容 |
| --- | --- | --- |
| 节点 | `FleetView` | 节点总览、运行概览、标签筛选、分组视图、批量选择、添加与编辑实例 |
| 节点 | `FleetView` | 节点总览、运行概览、标签筛选与批量归类、批量选择、添加与编辑实例 |
| 消息 | `MessagesView` | 跨实例短信读取、按实例与方向筛选、搜索、发送、删除 |
| 设置 | `SettingsView` | 主题外观、应用信息、本地数据(进入日志中心)、隐私与数据、数据清理 |
@@ -32,24 +32,31 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。
### 节点总览
- 运行概览:在线率、异常节点、检测进度、今日消息、分组与标签统计,可折叠。
- 分组视图:按分组折叠成段,段头给出该组健康度与台数,支持展开与折叠。
- 标签筛选:标签条支持多选叠加,标签上直接显示命中台数,可单个取消或全部清除。
- 运行概览:在线率与异常节点两枚大字结论、状态分布条与图例、八格两行的群集读数(信号、延迟、
存储、流量、CPU、内存、温度、电量),可折叠,折叠时保留前四格。检测进度与更新时间写在卡片
副标题里(`刚刚更新 · 已检测 6/6 台`),不占读数格;整群节点没有上报的读数保留「不可用」占位,
栅格不会在七格与九格之间跳动。
- 标签归类:标签是唯一归类手段,分组概念已在 0.2.2 移除。标签条支持多选叠加,胶囊上直接显示
命中台数,可单个取消或全部清除;卡片内的标签同样可点,点一下即加入筛选。
- 批量标签:批量选择后一次给多台节点贴同一个标签,每个标签显示「未贴 / N/M / 全部」三态覆盖度,
点击即在整批上加减;可在同一张表里新建标签。
- 搜索:匹配名称、地址、运营商与标签。
- 节点卡片:号码、运营商、制式、温度、电量、内存、存储、延迟、信号、固件、运行时间等
指标,超出一屏时折叠为「另有 N 项指标」。
- 节点卡片:顶部四条量表(信号、延迟、CPU、内存)配发丝进度,下面是号码、运营商、设备、存储、
温度、电量、运行时间等指标,超出一屏时折叠为「另有 N 项指标」。健康读数不着色,只有越过阈值
才出现告警或危险色。
- 批量选择:工具栏为 34pt 图标按钮(全选、删除、退出等高),勾选列独立于名称区域,
选中态不遮挡文字。
- 实例增删改:地址、名称、标签、分组、访问凭据;删除前确认,并一并清除本地登录信息。
- 实例增删改:地址、名称、标签、访问凭据;删除前确认,并一并清除本地登录信息。
### 实例详情
- 头部卡片:地址、连接状态、四项仪表(信号、延迟、CPU、内存)、身份摘要(号码、运营商、
网络、IP)、模块可用度读数、分组与标签。
- 头部卡片:地址与连接状态、四项仪表(信号、延迟、CPU、内存)、身份摘要(号码、运营商、网络、
IP)、模块可用度读数、标签。四项仪表排成两列发丝线栅格,读数在上、进度在下,缺数据时保留
空轨道,数据到位后卡片高度不变。
- 数据模块:设备、SIM、蜂窝网络、系统资源、CPU、连接状态共 6 个分区,逐模块独立加载,
单模块失败只标记该模块,不影响其余分区。
- 敏感值(IMEI、ICCID 等)默认隐藏,需要显式开启显示,离开页面后自动恢复隐藏。
- 本机保存的配置:地址、凭据状态、标签、分组,以及登录、登出、重新检测。
- 本机保存的配置:地址、凭据状态、标签,以及登录、登出、重新检测。
### 消息中心
@@ -97,22 +104,27 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。
| 文件 | 内容 |
| --- | --- |
| `instances.json` | 实例名称、地址、标签、分组、修订号 |
| `instance-groups.json` | 分组定义 |
| `instances.json` | 实例名称、地址、标签、修订号 |
| `credentials.json` | 实例访问密码 |
| `log-center.json` | 运行日志、连接探测记录、诊断快照缓存 |
主题偏好存在 `UserDefaults`(键 `aiThemeMode`)。登录会话只存在于进程内存,应用退出即消失。
0.2.1 及更早版本写过的 `instance-groups.json` 与偏好键 `fleet.groupMode` 在升级后的首次启动
一次性删除。主题偏好存在 `UserDefaults`(键 `aiThemeMode`)。登录会话只存在于进程内存,应用退出即消失。
密码与 Cookie 不写日志、不进导出内容。删除应用即彻底清除本机数据。允许 HTTP 明文访问是
为了兼容局域网 SimAdmin,公网节点应由用户自行通过 HTTPS 反代暴露。
## 6. 设计语言
深墨蓝与金色构成品牌色,界面在浅色下使用中性冷灰画布与纯白卡片,深色下使用低明度蓝黑分层,
主操作使用蓝色,危险操作使用红色。控件令牌集中在 `AITheme`:主按钮高 48pt(紧凑 44pt、大号
56pt)、工具栏按钮高 44pt(宽 104pt)、导航栏图标按钮 34pt 方形(圆角 11pt)、输入框高 52pt、
卡片圆角 22pt、按钮圆角 16pt、标签为 28pt 胶囊。
同屏并排控件必须等高,选中态不得改变控件尺寸,卡片内部不再嵌套卡片,正文与标题不使用负字距。
界面不含蓝色。品牌色是石墨与黄铜:浅色画布中性冷灰 `#F4F5F5`、纯白卡片、墨黑主操作
`#151819`;深色画布 `#080909`、低明度石墨分层 `#131515` / `#1B1D1E`、近白主操作 `#EEF1F1`。
颜色只保留给状态语义——危险 `#C12E28` / `#F77169`、告警 `#AA5C0F` / `#E9A63C`、成功
`#107A56` / `#42C784`,健康读数一律用墨色,避免整屏泛绿或整屏泛红。选中态用实心主色加
`onPrimary` 文字,不使用低透明度色雾,浅色深色两套主题下都必须一眼可读。
控件令牌集中在 `AITheme`:主按钮高 48pt(紧凑 44pt、大号 56pt)、工具栏按钮高 44pt(宽 104pt)、
导航栏图标按钮 34pt 方形(圆角 11pt)、输入框高 52pt、卡片圆角 22pt、按钮圆角 16pt、标签为
28pt 胶囊。同屏并排控件必须等高,选中态不得改变控件尺寸,卡片内部不再嵌套卡片——并列读数改用
发丝线分隔的栅格,正文与标题不使用负字距,字距为 0。
## 7. 构建与产物
@@ -137,8 +149,10 @@ cd apps/ios && swift Design/generate_app_icon.swift \
Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
```
图标几何与 Web 端 favicon 一致:深墨蓝底、金色 SIM 面板(切角在左上)、三根底部对齐的递增
信号条。`apps/ios/Design/AppIcon.svg` 是同构图参考稿,实际构建使用 PNG。
图标为扁平构图,不用立体卡与投影:石墨底、黄铜 SIM 面板(切角在左上)、三根底部对齐的递增
信号条,29pt 尺寸下轮廓仍然可读。`apps/ios/Design/AppIcon.svg` 是同构图参考稿,实际构建使用
由 `Design/generate_app_icon.swift` 光栅化出的 PNG,面板边长、圆角、切角与信号条间距都可用
环境变量调整。
## 8. 验证方式
@@ -169,7 +183,8 @@ xcodebuild -scheme MultiSimAdmin \
| GRDB + SQLite 控制面库 | 沙盒内 JSON 文件 | 去掉第三方依赖,数据结构与备份语义更直观 |
| 操作目录、任务、审计、维护、备份 | 未实现 | 高风险写操作与常驻调度不在 iPhone 前台场景内 |
| 设备模块 13 项(含通话、eSIM、OTA、WiFi Calling、自动化) | 6 个只读模块 + 短信读写 | 上游只保留有稳定读取价值的接口,其余按需在后续版本评估 |
| 浅灰画布、青绿主按钮 | 深浅双主题、蓝色主操作、金色品牌色 | 0.2.x 视觉重构结论 |
| 浅灰画布、青绿主按钮 | 深浅双主题、石墨墨色主操作、黄铜点缀 | 0.2.x 视觉重构结论,用户明确排除蓝色 |
| 实例分组(分组视图、分组编辑器、分组仓储) | 标签归类 + 批量标签 | 分组与标签职责重复,分组作为实体在 0.2.2 移除 |
## 10. 已确认的移除边界
@@ -182,6 +197,7 @@ xcodebuild -scheme MultiSimAdmin \
## 11. 文档索引
- [docs/ios-native.md](ios-native.md):本文,现行说明
- [docs/ios-native-0.2.2.md](ios-native-0.2.2.md):0.2.2 (7) 发布说明与校验值
- [docs/ios-native-0.2.1.md](ios-native-0.2.1.md):0.2.1 (6) 发布说明与校验值
- [docs/ios-native-0.2.0.md](ios-native-0.2.0.md):0.2.0 发布说明
- [docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md](superpowers/specs/2026-09-08-ios-native-ipa-design.md):历史设计稿